# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
Image object definition
=======================
This module defines the main `ImageObj` class for representing 2D image data.
The `ImageObj` class provides:
- Data storage for 2D arrays with associated metadata
- Physical coordinate system with origin and pixel spacing
- Axis labeling and units
- Scale management (linear/logarithmic)
- DICOM template support
- ROI (Region of Interest) integration
- Coordinate conversion utilities (physical ↔ pixel)
This is the core class for image processing operations in Sigima.
"""
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
# pylint: disable=duplicate-code
from __future__ import annotations
import re
from collections.abc import Mapping
from typing import Any, Literal, Type
import guidata.dataset as gds
import numpy as np
from numpy import ma
from sigima.config import _
from sigima.objects import base
from sigima.objects.image.roi import ImageROI
from sigima.tools.datatypes import clip_astype
def to_builtin(obj) -> str | int | float | list | dict | np.ndarray | None:
"""Convert an object implementing a numeric value or collection
into the corresponding builtin/NumPy type.
Return None if conversion fails."""
try:
return int(obj) if int(obj) == float(obj) else float(obj)
except (TypeError, ValueError):
pass
if isinstance(obj, str):
return obj
if hasattr(obj, "__iter__"):
try:
return list(obj)
except (TypeError, ValueError):
pass
if hasattr(obj, "__dict__"):
try:
return dict(obj.__dict__)
except (TypeError, ValueError):
pass
if isinstance(obj, np.ndarray):
return obj
return None
[docs]
class ImageObj(gds.DataSet, base.BaseObj[ImageROI]):
"""Image object"""
PREFIX = "i"
VALID_DTYPES = (
np.uint8,
np.uint16,
np.int16,
np.int32,
np.float32,
np.float64,
np.complex128,
)
def __init__(self, title=None, comment=None, icon=""):
"""Constructor
Args:
title: title
comment: comment
icon: icon
"""
gds.DataSet.__init__(self, title, comment, icon)
base.BaseObj.__init__(self)
self._dicom_template = None
@staticmethod
def get_roi_class() -> Type[ImageROI]:
"""Return ROI class"""
# Import here to avoid circular imports
return ImageROI
def __add_metadata(self, key: str, value: Any) -> None:
"""Add value to metadata if value can be converted into builtin/NumPy type
Args:
key: key
value: value
"""
stored_val = to_builtin(value)
if stored_val is not None:
self.metadata[key] = stored_val
def __set_metadata_from(self, obj: Mapping | dict) -> None:
"""Set metadata from object: dict-like (only string keys are considered)
or any other object (iterating over supported attributes)
Args:
obj: object
"""
self.reset_metadata_to_defaults()
ptn = r"__[\S_]*__$"
if isinstance(obj, Mapping):
for key, value in obj.items():
if isinstance(key, str) and not re.match(ptn, key):
self.__add_metadata(key, value)
else:
for attrname in dir(obj):
if attrname != "GroupLength" and not re.match(ptn, attrname):
try:
attr = getattr(obj, attrname)
if not callable(attr) and attr:
self.__add_metadata(attrname, attr)
except AttributeError:
pass
@property
def dicom_template(self):
"""Get DICOM template"""
return self._dicom_template
@dicom_template.setter
def dicom_template(self, template):
"""Set DICOM template"""
if template is not None:
ipp = getattr(template, "ImagePositionPatient", None)
x0, y0 = (0.0, 0.0) if ipp is None else (float(ipp[0]), float(ipp[1]))
pxs = getattr(template, "PixelSpacing", None)
dx, dy = (1.0, 1.0) if pxs is None else (float(pxs[0]), float(pxs[1]))
self.set_uniform_coords(dx, dy, x0, y0)
self.__set_metadata_from(template)
self._dicom_template = template
_tabs = gds.BeginTabGroup("all")
_datag = gds.BeginGroup(_("Data"))
data = gds.FloatArrayItem(_("Data")) # type: ignore[assignment]
metadata = gds.DictItem(_("Metadata"), default={}) # type: ignore[assignment]
annotations = gds.StringItem(_("Annotations"), default="").set_prop(
"display",
hide=True,
) # Annotations (JSON). Use get/set_annotations() API # type: ignore[assignment]
_e_datag = gds.EndGroup(_("Data"))
def _compute_xmin(self) -> float:
"""Compute Xmin"""
if self.data is None or self.data.size == 0:
return 0.0
if self.is_uniform_coords:
return self.x0
if self.xcoords is None or self.xcoords.size == 0:
return np.nan
return self.xcoords[0]
def _compute_xmax(self) -> float:
"""Compute Xmax"""
if self.data is None or self.data.size == 0:
return 0.0
if self.is_uniform_coords:
return self.x0 + self.width - self.dx
if self.xcoords is None or self.xcoords.size == 0:
return np.nan
return self.xcoords[-1]
def _compute_ymin(self) -> float:
"""Compute Ymin"""
if self.data is None or self.data.size == 0:
return 0.0
if self.is_uniform_coords:
return self.y0
if self.ycoords is None or self.ycoords.size == 0:
return np.nan
return self.ycoords[0]
def _compute_ymax(self) -> float:
"""Compute Ymax"""
if self.data is None or self.data.size == 0:
return 0.0
if self.is_uniform_coords:
return self.y0 + self.height - self.dy
if self.ycoords is None or self.ycoords.size == 0:
return np.nan
return self.ycoords[-1]
_dxdyg = gds.BeginGroup(f"{_('Origin')} / {_('Pixel spacing')}")
_prop_uniform = gds.GetAttrProp("is_uniform_coords")
is_uniform_coords = gds.BoolItem(_("Uniform coordinates"), default=True).set_prop(
"display", store=_prop_uniform, active=False
)
_origin = gds.BeginGroup(_("Origin"))
x0 = gds.FloatItem("X<sub>0</sub>", default=0.0).set_prop(
"display", active=_prop_uniform
)
y0 = (
gds.FloatItem("Y<sub>0</sub>", default=0.0)
.set_prop("display", active=_prop_uniform)
.set_pos(col=1)
)
_e_origin = gds.EndGroup(_("Origin"))
_pixel_spacing = gds.BeginGroup(_("Pixel spacing"))
dx = gds.FloatItem("Δx", default=1.0).set_prop("display", active=_prop_uniform)
dy = (
gds.FloatItem("Δy", default=1.0)
.set_prop("display", active=_prop_uniform)
.set_pos(col=1)
)
_e_pixel_spacing = gds.EndGroup(_("Pixel spacing"))
_boundaries = gds.BeginGroup(_("Extent"))
xmin = gds.FloatItem("X<sub>MIN</sub>").set_computed(_compute_xmin)
xmax = gds.FloatItem("X<sub>MAX</sub>").set_pos(col=1).set_computed(_compute_xmax)
ymin = gds.FloatItem("Y<sub>MIN</sub>").set_computed(_compute_ymin)
ymax = gds.FloatItem("Y<sub>MAX</sub>").set_pos(col=1).set_computed(_compute_ymax)
_e_boundaries = gds.EndGroup(_("Extent"))
_e_dxdyg = gds.EndGroup(f"{_('Origin')} / {_('Pixel spacing')}")
_coordsg = gds.BeginGroup(_("Coordinates"))
xcoords = gds.FloatArrayItem(
_("X coordinates"),
default=np.array([], dtype=float),
).set_prop("display", active=gds.NotProp(_prop_uniform)) # type: ignore[assignment]
ycoords = (
gds.FloatArrayItem(_("Y coordinates"), default=np.array([], dtype=float))
.set_prop("display", active=gds.NotProp(_prop_uniform))
.set_pos(col=1)
) # type: ignore[assignment]
_e_coordsg = gds.EndGroup(_("Coordinates"))
def set_uniform_coords(
self, dx: float, dy: float, x0: float = 0.0, y0: float = 0.0
) -> None:
"""Set uniform coordinates and clear non-uniform arrays.
Args:
dx: pixel size along X-axis
dy: pixel size along Y-axis
x0: origin X-axis coordinate
y0: origin Y-axis coordinate
"""
self.is_uniform_coords = True
self.xcoords = np.array([], dtype=float)
self.ycoords = np.array([], dtype=float)
self.dx, self.dy, self.x0, self.y0 = dx, dy, x0, y0
def set_coords(self, xcoords: np.ndarray, ycoords: np.ndarray) -> None:
"""Set non-uniform coordinates.
Args:
xcoords: X coordinates
ycoords: Y coordinates
"""
self.is_uniform_coords = False
self.xcoords = xcoords
self.ycoords = ycoords
def switch_coords_to(self, coords_type: Literal["uniform", "non-uniform"]) -> None:
"""Switch coordinates to uniform or non-uniform representation.
If switching to uniform, the image pixel size and origin are computed from
the current non-uniform coordinates. If switching to non-uniform, the
corresponding coordinate arrays are generated from the current pixel size
and origin. If the current coordinates are already of the requested type,
no action is performed.
Args:
coords_type: 'uniform' or 'non-uniform'
Raises:
ValueError: If switching to uniform coordinates fails due to insufficient
non-uniform coordinates defined
"""
if coords_type == "uniform" and not self.is_uniform_coords:
if self.xcoords.size >= 2 and self.ycoords.size >= 2:
x0, y0 = float(self.xcoords[0]), float(self.ycoords[0])
dx = float(self.xcoords[-1] - self.xcoords[0]) / (self.xcoords.size - 1)
dy = float(self.ycoords[-1] - self.ycoords[0]) / (self.ycoords.size - 1)
self.set_uniform_coords(dx, dy, x0, y0)
else:
raise ValueError(
"Cannot switch to uniform coordinates: "
"not enough non-uniform coordinates defined"
)
elif coords_type == "non-uniform" and self.is_uniform_coords:
shape = self.data.shape
xcoords = np.linspace(self.x0, self.x0 + self.dx * (shape[1] - 1), shape[1])
ycoords = np.linspace(self.y0, self.y0 + self.dy * (shape[0] - 1), shape[0])
self.set_coords(xcoords, ycoords)
_unitsg = gds.BeginGroup(_("Titles / Units"))
title = gds.StringItem(_("Image title"), default=_("Untitled"))
_tabs_u = gds.BeginTabGroup("units")
_unitsx = gds.BeginGroup(_("X-axis"))
xlabel = gds.StringItem(_("Title"), default="")
xunit = gds.StringItem(_("Unit"), default="")
_e_unitsx = gds.EndGroup(_("X-axis"))
_unitsy = gds.BeginGroup(_("Y-axis"))
ylabel = gds.StringItem(_("Title"), default="")
yunit = gds.StringItem(_("Unit"), default="")
_e_unitsy = gds.EndGroup(_("Y-axis"))
_unitsz = gds.BeginGroup(_("Z-axis"))
zlabel = gds.StringItem(_("Title"), default="")
zunit = gds.StringItem(_("Unit"), default="")
_e_unitsz = gds.EndGroup(_("Z-axis"))
_e_tabs_u = gds.EndTabGroup("units")
_e_unitsg = gds.EndGroup(_("Titles / Units"))
_scalesg = gds.BeginGroup(_("Scales"))
_prop_autoscale = gds.GetAttrProp("autoscale")
autoscale = gds.BoolItem(_("Auto scale"), default=True).set_prop(
"display", store=_prop_autoscale
)
_tabs_b = gds.BeginTabGroup("bounds")
_boundsx = gds.BeginGroup(_("X-axis"))
xscalelog = gds.BoolItem(_("Logarithmic scale"), default=False)
xscalemin = gds.FloatItem(_("Lower bound"), check=False).set_prop(
"display", active=gds.NotProp(_prop_autoscale)
)
xscalemax = gds.FloatItem(_("Upper bound"), check=False).set_prop(
"display", active=gds.NotProp(_prop_autoscale)
)
_e_boundsx = gds.EndGroup(_("X-axis"))
_boundsy = gds.BeginGroup(_("Y-axis"))
yscalelog = gds.BoolItem(_("Logarithmic scale"), default=False)
yscalemin = gds.FloatItem(_("Lower bound"), check=False).set_prop(
"display", active=gds.NotProp(_prop_autoscale)
)
yscalemax = gds.FloatItem(_("Upper bound"), check=False).set_prop(
"display", active=gds.NotProp(_prop_autoscale)
)
_e_boundsy = gds.EndGroup(_("Y-axis"))
_boundsz = gds.BeginGroup(_("LUT range"))
zscalemin = gds.FloatItem(_("Lower bound"), check=False)
zscalemax = gds.FloatItem(_("Upper bound"), check=False)
_e_boundsz = gds.EndGroup(_("LUT range"))
_e_tabs_b = gds.EndTabGroup("bounds")
_e_scalesg = gds.EndGroup(_("Scales"))
_e_tabs = gds.EndTabGroup("all")
@property
def width(self) -> float:
"""Return image width, i.e. number of columns multiplied by pixel size"""
return self.data.shape[1] * self.dx
@property
def height(self) -> float:
"""Return image height, i.e. number of rows multiplied by pixel size"""
return self.data.shape[0] * self.dy
@property
def xc(self) -> float:
"""Return image center X-axis coordinate"""
return self.x0 + 0.5 * self.width
@property
def yc(self) -> float:
"""Return image center Y-axis coordinate"""
return self.y0 + 0.5 * self.height
def _repr_html_(self) -> str:
"""Return HTML representation for Jupyter notebook display.
This method is automatically called by Jupyter when displaying the object
as a cell output, providing a rich HTML rendering of the image object.
Returns:
HTML representation of the image with summary statistics.
"""
shape = self.data.shape if self.data is not None else (0, 0)
dtype_str = str(self.data.dtype) if self.data is not None else "N/A"
z_min = f"{self.data.min():.4g}" if self.data is not None else "N/A"
z_max = f"{self.data.max():.4g}" if self.data is not None else "N/A"
# Build axis labels with optional title
x_label = "X"
if self.xlabel:
x_label = f"X ({self.xlabel})"
y_label = "Y"
if self.ylabel:
y_label = f"Y ({self.ylabel})"
z_label = "Z"
if self.zlabel:
z_label = f"Z ({self.zlabel})"
html = f'<u><b style="color: #5294e2">ImageObj: {self.title}</b></u>:'
html += '<table border="0">'
html += (
f"<tr><td style='text-align:right'>Shape:</td>"
f"<td>{shape[1]} × {shape[0]} (W×H)</td></tr>"
)
html += (
f"<tr><td style='text-align:right'>Data type:</td><td>{dtype_str}</td></tr>"
)
html += (
f"<tr><td style='text-align:right'>{z_label} range:</td>"
f"<td>[{z_min}, {z_max}]"
)
if self.zunit:
html += f" {self.zunit}"
html += "</td></tr>"
# Origin and pixel spacing (for uniform coordinates)
if self.is_uniform_coords:
html += (
f"<tr><td style='text-align:right'>Origin ({x_label}, {y_label}):</td>"
f"<td>({self.x0:.4g}, {self.y0:.4g})"
)
if self.xunit:
html += f" {self.xunit}"
html += "</td></tr>"
html += (
f"<tr><td style='text-align:right'>Pixel spacing (Δx, Δy):</td>"
f"<td>({self.dx:.4g}, {self.dy:.4g})"
)
if self.xunit:
html += f" {self.xunit}"
html += "</td></tr>"
else:
# Non-uniform coordinates: show coordinate array info
x_coords_info = "N/A"
y_coords_info = "N/A"
if self.xcoords is not None and self.xcoords.size > 0:
x_coords_info = f"[{self.xcoords[0]:.4g}, {self.xcoords[-1]:.4g}]"
if self.ycoords is not None and self.ycoords.size > 0:
y_coords_info = f"[{self.ycoords[0]:.4g}, {self.ycoords[-1]:.4g}]"
html += (
f"<tr><td style='text-align:right'>{x_label} coords:</td>"
f"<td>{x_coords_info}"
)
if self.xunit:
html += f" {self.xunit}"
html += "</td></tr>"
html += (
f"<tr><td style='text-align:right'>{y_label} coords:</td>"
f"<td>{y_coords_info}"
)
if self.yunit:
html += f" {self.yunit}"
html += "</td></tr>"
# Extent (physical bounds)
html += (
f"<tr><td style='text-align:right'>Extent ({x_label}):</td>"
f"<td>[{self._compute_xmin():.4g}, {self._compute_xmax():.4g}]"
)
if self.xunit:
html += f" {self.xunit}"
html += "</td></tr>"
html += (
f"<tr><td style='text-align:right'>Extent ({y_label}):</td>"
f"<td>[{self._compute_ymin():.4g}, {self._compute_ymax():.4g}]"
)
if self.yunit:
html += f" {self.yunit}"
html += "</td></tr>"
# Physical size
html += (
f"<tr><td style='text-align:right'>Physical size:</td>"
f"<td>{self.width:.4g} × {self.height:.4g}"
)
if self.xunit:
html += f" {self.xunit}"
html += "</td></tr>"
if self.roi is not None:
html += (
f"<tr><td style='text-align:right'>ROIs:</td>"
f"<td>{len(self.roi)}</td></tr>"
)
html += "</table>"
return html
def get_data(self, roi_index: int | None = None) -> np.ndarray:
"""
Return original data (if ROI is not defined or `roi_index` is None),
or ROI data (if both ROI and `roi_index` are defined).
Args:
roi_index: ROI index
Returns:
Masked data
"""
if self.roi is None or roi_index is None:
view = self.data.view(ma.MaskedArray)
view.mask = np.isnan(self.data)
return view
single_roi = self.roi.get_single_roi(roi_index)
# pylint: disable=unbalanced-tuple-unpacking
x0, y0, x1, y1 = self.physical_to_indices(single_roi.get_bounding_box(self))
# Clip coordinates to image boundaries to handle ROIs extending beyond canvas
x0 = max(0, x0)
y0 = max(0, y0)
x1 = min(self.data.shape[1], x1)
y1 = min(self.data.shape[0], y1)
# If ROI is completely outside the image, return a fully masked array
# with a single element to avoid zero-size array errors in statistics
if x0 >= x1 or y0 >= y1:
empty_array = ma.masked_array([[np.nan]], dtype=self.data.dtype, mask=True)
return empty_array
return self.get_masked_view()[y0:y1, x0:x1]
def copy(
self,
title: str | None = None,
dtype: np.dtype | None = None,
all_metadata: bool = False,
) -> ImageObj:
"""Copy object.
Args:
title: title
dtype: data type
all_metadata: if True, copy all metadata, otherwise only basic metadata
Returns:
Copied object
"""
title = self.title if title is None else title
obj = ImageObj(title=title)
obj.title = title
obj.xlabel = self.xlabel
obj.ylabel = self.ylabel
obj.zlabel = self.zlabel
obj.xunit = self.xunit
obj.yunit = self.yunit
obj.zunit = self.zunit
obj.metadata = base.deepcopy_metadata(self.metadata, all_metadata=all_metadata)
obj.annotations = self.annotations
if self.data is not None:
obj.data = np.array(self.data, copy=True, dtype=dtype)
obj.is_uniform_coords = self.is_uniform_coords
if self.is_uniform_coords:
obj.dx = self.dx
obj.dy = self.dy
obj.x0 = self.x0
obj.y0 = self.y0
else:
obj.xcoords = np.array(self.xcoords, copy=True)
obj.ycoords = np.array(self.ycoords, copy=True)
obj.autoscale = self.autoscale
obj.xscalelog = self.xscalelog
obj.xscalemin = self.xscalemin
obj.xscalemax = self.xscalemax
obj.yscalelog = self.yscalelog
obj.yscalemin = self.yscalemin
obj.yscalemax = self.yscalemax
obj.zscalemin = self.zscalemin
obj.zscalemax = self.zscalemax
obj.dicom_template = self.dicom_template
return obj
def set_data_type(self, dtype: np.dtype) -> None:
"""Change data type.
If data type is integer, clip values to the new data type's range, thus avoiding
overflow or underflow.
Args:
Data type
"""
self.data = clip_astype(self.data, dtype)
def physical_to_indices(
self, coords: list[float], clip: bool = False, as_float: bool = False
) -> list[int] | list[float]:
"""Convert coordinates from physical (real world) to indices (pixel)
Args:
coords: flat list of physical coordinates [x0, y0, x1, y1, ...]
clip: if True, clip values to image boundaries
as_float: if True, return float indices (i.e. without rounding)
Returns:
Indices
Raises:
ValueError: if coords does not contain an even number of elements
"""
if len(coords) % 2 != 0:
raise ValueError(
"coords must contain an even number of elements (x, y pairs)."
)
indices = np.array(coords, float)
if indices.size > 0:
if self.is_uniform_coords:
# Use existing uniform conversion
indices[::2] = (indices[::2] - self.x0) / self.dx
indices[1::2] = (indices[1::2] - self.y0) / self.dy
else:
# Use interpolation for non-uniform coordinates
x_indices = np.arange(len(self.xcoords))
y_indices = np.arange(len(self.ycoords))
indices[::2] = np.interp(indices[::2], self.xcoords, x_indices)
indices[1::2] = np.interp(indices[1::2], self.ycoords, y_indices)
if clip:
indices[::2] = np.clip(indices[::2], 0, self.data.shape[1] - 1)
indices[1::2] = np.clip(indices[1::2], 0, self.data.shape[0] - 1)
if as_float:
return indices.tolist()
return np.floor(indices + 0.5).astype(int).tolist()
def indices_to_physical(self, indices: list[float]) -> list[float]:
"""Convert coordinates from indices to physical (real world)
Args:
indices: flat list of indices [x0, y0, x1, y1, ...]
Returns:
Coordinates
Raises:
ValueError: if indices does not contain an even number of elements
"""
if len(indices) % 2 != 0:
raise ValueError(
"indices must contain an even number of elements (x, y pairs)."
)
coords = np.array(indices, float)
if coords.size > 0:
if self.is_uniform_coords:
# Use existing uniform conversion
coords[::2] = coords[::2] * self.dx + self.x0
coords[1::2] = coords[1::2] * self.dy + self.y0
else:
# Use interpolation for non-uniform coordinates
x_indices = np.arange(len(self.xcoords))
y_indices = np.arange(len(self.ycoords))
coords[::2] = np.interp(coords[::2], x_indices, self.xcoords)
coords[1::2] = np.interp(coords[1::2], y_indices, self.ycoords)
return coords.tolist()