# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.
"""
Image object and related classes
--------------------------------
"""
# pylint: disable=invalid-name # Allows short reference names like x, y, ...
# pylint: disable=duplicate-code
from __future__ import annotations
import enum
import re
from collections.abc import ByteString, Iterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any
from uuid import uuid4
import guidata.dataset as gds
import numpy as np
from guidata.configtools import get_icon
from guidata.dataset import update_dataset
from numpy import ma
from plotpy.builder import make
from plotpy.items import AnnotatedCircle, AnnotatedRectangle, MaskedImageItem
from skimage import draw
from cdl.algorithms.datatypes import clip_astype
from cdl.algorithms.image import scale_data_to_min_max
from cdl.config import Conf, _
from cdl.core.model import base
if TYPE_CHECKING:
from qtpy import QtWidgets as QW
def make_roi_rectangle(
x0: int, y0: int, x1: int, y1: int, title: str
) -> AnnotatedRectangle:
"""Make and return the annnotated rectangle associated to ROI
Args:
x0: top left corner X coordinate
y0: top left corner Y coordinate
x1: bottom right corner X coordinate
y1: bottom right corner Y coordinate
title: title
"""
roi_item: AnnotatedRectangle = make.annotated_rectangle(x0, y0, x1, y1, title)
param = roi_item.label.labelparam
param.anchor = "BL"
param.xc, param.yc = 5, -5
param.update_item(roi_item.label)
return roi_item
def make_roi_circle(x0: int, y0: int, x1: int, y1: int, title: str) -> AnnotatedCircle:
"""Make and return the annnotated circle associated to ROI
Args:
x0: top left corner X coordinate
y0: top left corner Y coordinate
x1: bottom right corner X coordinate
y1: bottom right corner Y coordinate
title: title
"""
item = AnnotatedCircle(x0, y0, x1, y1)
item.annotationparam.title = title
item.annotationparam.update_item(item)
item.set_style("plot", "shape/drag")
return item
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, ByteString):
return str(obj)
if isinstance(obj, Sequence):
return str(obj) if len(obj) == len(str(obj)) else list(obj)
if isinstance(obj, Mapping):
return dict(obj)
if isinstance(obj, np.ndarray):
return obj
return None
[docs]
class RoiDataGeometries(base.Choices):
"""ROI data geometry types"""
RECTANGLE = _("Rectangle")
CIRCLE = _("Circle")
[docs]
class ImageRoiDataItem:
"""Object representing an image ROI.
Args:
data: ROI data
"""
def __init__(self, data: np.ndarray | list | tuple):
self._data = data
[docs]
@classmethod
def from_image(cls, obj: ImageObj, geometry: RoiDataGeometries) -> ImageRoiDataItem:
"""Construct roi data item from image object: called for making new ROI items
Args:
obj: image object
geometry: ROI geometry
"""
width, height = obj.data.shape[1] * obj.dx, obj.data.shape[0] * obj.dy
frac = 0.2
x0, x1 = obj.x0 + frac * width, obj.x0 + (1 - frac) * width
if geometry is RoiDataGeometries.RECTANGLE:
y0, y1 = obj.y0 + frac * height, obj.y0 + (1 - frac) * height
else:
y0 = y1 = obj.yc
coords = x0, y0, x1, y1
return cls(coords)
@property
def geometry(self) -> RoiDataGeometries:
"""ROI geometry"""
_x0, y0, _x1, y1 = self._data
if y0 == y1:
return RoiDataGeometries.CIRCLE
return RoiDataGeometries.RECTANGLE
[docs]
def get_rect(self) -> tuple[int, int, int, int]:
"""Get rectangle coordinates"""
x0, y0, x1, y1 = self._data
if self.geometry is RoiDataGeometries.CIRCLE:
radius = int(round(0.5 * (x1 - x0)))
y0 -= radius
y1 += radius
return x0, y0, x1, y1
[docs]
def get_image_masked_view(self, obj: ImageObj) -> np.ndarray:
"""Return masked view for data
Args:
obj: image object
"""
x0, y0, x1, y1 = self.get_rect()
return obj.get_masked_view()[y0:y1, x0:x1]
[docs]
def apply_mask(self, data: np.ndarray, yxratio: float) -> np.ndarray:
"""Apply ROI to data as a mask and return masked array
Args:
data: data
yxratio: Y/X ratio
"""
roi_mask = np.ones_like(data, dtype=bool)
x0, y0, x1, y1 = self.get_rect()
if self.geometry is RoiDataGeometries.RECTANGLE:
roi_mask[max(y0, 0) : y1, max(x0, 0) : x1] = False
else:
xc, yc = 0.5 * (x0 + x1), 0.5 * (y0 + y1)
radius = 0.5 * (x1 - x0)
rr, cc = draw.ellipse(yc, xc, radius / yxratio, radius, shape=data.shape)
roi_mask[rr, cc] = False
return roi_mask
[docs]
def make_roi_item(
self, index: int | None, fmt: str, lbl: bool, editable: bool = True
):
"""Make ROI plot item
Args:
index: ROI index
fmt: format string
lbl: if True, show label
editable: if True, ROI is editable
"""
coords = self._data
if self.geometry is RoiDataGeometries.RECTANGLE:
func = make_roi_rectangle
else:
func = make_roi_circle
title = "ROI" if index is None else f"ROI{index:02d}"
return base.make_roi_item(func, coords, title, fmt, lbl, editable, option="i")
[docs]
class ROI2DParam(gds.DataSet):
"""Image ROI parameters"""
_geometry_prop = gds.GetAttrProp("geometry")
_rfp = gds.FuncProp(_geometry_prop, lambda x: x is RoiDataGeometries.RECTANGLE)
_cfp = gds.FuncProp(_geometry_prop, lambda x: x is RoiDataGeometries.CIRCLE)
# Do not declare it as a static method: not supported on Python 3.8
def _lbl(name: str, index: int): # pylint: disable=no-self-argument
"""Returns name<sub>index</sub>"""
return f"{name}<sub>{index}</sub>"
geometry = gds.ChoiceItem(_("Geometry"), RoiDataGeometries.get_choices()).set_prop(
"display", store=_geometry_prop, hide=True
)
# Parameters for rectangular ROI geometry:
_tlcorner = gds.BeginGroup(_("Top left corner")).set_prop("display", hide=_cfp)
xr0 = gds.IntItem(_lbl("X", 0), unit="pixel").set_prop("display", hide=_cfp)
yr0 = (
gds.IntItem(_lbl("Y", 0), unit="pixel")
.set_pos(1)
.set_prop("display", hide=_cfp)
)
_e_tlcorner = gds.EndGroup(_("Top left corner"))
_brcorner = gds.BeginGroup(_("Bottom right corner")).set_prop("display", hide=_cfp)
xr1 = gds.IntItem(_lbl("X", 1), unit="pixel").set_prop("display", hide=_cfp)
yr1 = (
gds.IntItem(_lbl("Y", 1), unit="pixel")
.set_pos(1)
.set_prop("display", hide=_cfp)
)
_e_brcorner = gds.EndGroup(_("Bottom right corner"))
# Parameters for circular ROI geometry:
_cgroup = gds.BeginGroup(_("Center coordinates")).set_prop("display", hide=_rfp)
xc = gds.IntItem(_lbl("X", "C"), unit="pixel").set_prop("display", hide=_rfp)
yc = (
gds.IntItem(_lbl("Y", "C"), unit="pixel")
.set_pos(1)
.set_prop("display", hide=_rfp)
)
_e_cgroup = gds.EndGroup(_("Center coordinates"))
r = gds.IntItem(_("Radius"), unit="pixel").set_prop("display", hide=_rfp)
[docs]
def get_suffix(self) -> str:
"""Get suffix text representation for ROI extraction"""
if self.geometry is RoiDataGeometries.CIRCLE:
return f"xc={self.xc},yc={self.yc},r={self.r}"
return f"x={self.xr0}:{self.xr1},y={self.yr0}:{self.yr1}"
[docs]
def get_coords(self) -> tuple[int, int, int, int]:
"""Get ROI coordinates"""
if self.geometry is RoiDataGeometries.CIRCLE:
return self.xc - self.r, self.yc, self.xc + self.r, self.yc
x0, y0, x1, y1 = self.xr0, self.yr0, self.xr1, self.yr1
x0, x1 = min(x0, x1), max(x0, x1)
y0, y1 = min(y0, y1), max(y0, y1)
x1 = x1 + 1 if x1 == x0 else x1
y1 = y1 + 1 if y1 == y0 else y1
return x0, y0, x1, y1
[docs]
def get_single_roi(self) -> np.ndarray | None:
"""Get single ROI, i.e. after extracting ROI from image"""
if self.geometry is RoiDataGeometries.CIRCLE:
return np.array([(0, self.r, 2 * self.r, self.r)], int)
return None
[docs]
def get_rect_indexes(self) -> tuple[int, int, int, int]:
"""Get rectangle indexes"""
if self.geometry is RoiDataGeometries.CIRCLE:
x0, y0 = self.xc - self.r, self.yc - self.r
x1, y1 = self.xc + self.r, self.yc + self.r
else:
x0, y0, x1, y1 = self.xr0, self.yr0, self.xr1, self.yr1
return max(0, x0), max(0, y0), x1, y1
[docs]
def get_data(self, obj: ImageObj) -> np.ndarray:
"""Get data in ROI
Args:
obj: image object
Returns:
Data in ROI
"""
x0, y0, x1, y1 = self.get_rect_indexes()
x1, y1 = min(obj.data.shape[1], x1), min(obj.data.shape[0], y1)
return obj.data[y0:y1, x0:x1]
[docs]
class ImageObj(gds.DataSet, base.BaseObj):
"""Image object"""
PREFIX = "i"
CONF_FMT = Conf.view.ima_format
DEFAULT_FMT = ".1f"
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.regenerate_uuid()
self._dicom_template = None
self._maskdata_cache = None
[docs]
def regenerate_uuid(self):
"""Regenerate UUID
This method is used to regenerate UUID after loading the object from a file.
This is required to avoid UUID conflicts when loading objects from file
without clearing the workspace first.
"""
self.uuid = str(uuid4())
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
@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)
if ipp is not None:
self.x0, self.y0 = float(ipp[0]), float(ipp[1])
pxs = getattr(template, "PixelSpacing", None)
if pxs is not None:
self.dy, self.dx = float(pxs[0]), float(pxs[1])
self.set_metadata_from(template)
self._dicom_template = template
uuid = gds.StringItem("UUID").set_prop("display", hide=True)
_tabs = gds.BeginTabGroup("all")
_datag = gds.BeginGroup(_("Data"))
data = gds.FloatArrayItem(_("Data"))
metadata = gds.DictItem(_("Metadata"), default={})
_e_datag = gds.EndGroup(_("Data"))
_dxdyg = gds.BeginGroup(f'{_("Origin")} / {_("Pixel spacing")}')
_origin = gds.BeginGroup(_("Origin"))
x0 = gds.FloatItem("X<sub>0</sub>", default=0.0)
y0 = gds.FloatItem("Y<sub>0</sub>", default=0.0).set_pos(col=1)
_e_origin = gds.EndGroup(_("Origin"))
_pixel_spacing = gds.BeginGroup(_("Pixel spacing"))
dx = gds.FloatItem("Δx", default=1.0, nonzero=True)
dy = gds.FloatItem("Δy", default=1.0, nonzero=True).set_pos(col=1)
_e_pixel_spacing = gds.EndGroup(_("Pixel spacing"))
_e_dxdyg = gds.EndGroup(f'{_("Origin")} / {_("Pixel spacing")}')
_unitsg = gds.BeginGroup(f'{_("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(f'{_("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 xc(self) -> float:
"""Return image center X-axis coordinate"""
return self.x0 + 0.5 * self.data.shape[1] * self.dx
@property
def yc(self) -> float:
"""Return image center Y-axis coordinate"""
return self.y0 + 0.5 * self.data.shape[0] * self.dy
[docs]
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:
return self.data
roidataitem = ImageRoiDataItem(self.roi[roi_index])
return roidataitem.get_image_masked_view(self)
[docs]
def copy(self, title: str | None = None, dtype: np.dtype | None = None) -> ImageObj:
"""Copy object.
Args:
title: title
dtype: data type
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.xunit = self.xunit
obj.yunit = self.yunit
obj.zunit = self.zunit
obj.x0 = self.x0
obj.y0 = self.y0
obj.dx = self.dx
obj.dy = self.dy
obj.metadata = base.deepcopy_metadata(self.metadata)
obj.data = np.array(self.data, copy=True, dtype=dtype)
obj.dicom_template = self.dicom_template
return obj
[docs]
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 __viewable_data(self) -> np.ndarray:
"""Return viewable data"""
data = self.data.real
if np.any(np.isnan(data)):
data = np.nan_to_num(data, posinf=0, neginf=0)
return data
[docs]
def update_plot_item_parameters(self, item: MaskedImageItem) -> None:
"""Update plot item parameters from object data/metadata
Takes into account a subset of plot item parameters. Those parameters may
have been overriden by object metadata entries or other object data. The goal
is to update the plot item accordingly.
This is *almost* the inverse operation of `update_metadata_from_plot_item`.
Args:
item: plot item
"""
for axis in ("x", "y", "z"):
unit = getattr(self, axis + "unit")
fmt = r"%.1f"
if unit:
fmt = r"%.1f (" + unit + ")"
setattr(item.param, axis + "format", fmt)
# Updating origin and pixel spacing
has_origin = self.x0 is not None and self.y0 is not None
has_pixelspacing = self.dx is not None and self.dy is not None
if has_origin or has_pixelspacing:
x0, y0, dx, dy = 0.0, 0.0, 1.0, 1.0
if has_origin:
x0, y0 = self.x0, self.y0
if has_pixelspacing:
dx, dy = self.dx, self.dy
shape = self.data.shape
item.param.xmin, item.param.xmax = x0, x0 + dx * shape[1]
item.param.ymin, item.param.ymax = y0, y0 + dy * shape[0]
zmin, zmax = item.get_lut_range()
if self.zscalemin is not None or self.zscalemax is not None:
zmin = zmin if self.zscalemin is None else self.zscalemin
zmax = zmax if self.zscalemax is None else self.zscalemax
item.set_lut_range([zmin, zmax])
super().update_plot_item_parameters(item)
[docs]
def make_item(self, update_from: MaskedImageItem | None = None) -> MaskedImageItem:
"""Make plot item from data.
Args:
update_from: update from plot item
Returns:
Plot item
"""
data = self.__viewable_data()
item = make.maskedimage(
data,
self.maskdata,
title=self.title,
colormap="viridis",
eliminate_outliers=Conf.view.ima_eliminate_outliers.get(),
interpolation="nearest",
show_mask=True,
)
if update_from is None:
self.update_plot_item_parameters(item)
else:
update_dataset(item.param, update_from.param)
item.param.update_item(item)
return item
[docs]
def update_item(self, item: MaskedImageItem, data_changed: bool = True) -> None:
"""Update plot item from data.
Args:
item: plot item
data_changed: if True, data has changed
"""
if data_changed:
item.set_data(self.__viewable_data(), lut_range=[item.min, item.max])
item.set_mask(self.maskdata)
item.param.label = self.title
self.update_plot_item_parameters(item)
item.plot().update_colormap_axis(item)
[docs]
def get_roi_param(self, title, *defaults: int) -> ROI2DParam:
"""Return ROI parameters dataset.
Args:
title: title
*defaults: default values
"""
roidataitem = ImageRoiDataItem(defaults)
xd0, yd0, xd1, yd1 = defaults
param = ROI2DParam(title)
param.geometry = roidataitem.geometry
if roidataitem.geometry is RoiDataGeometries.RECTANGLE:
param.xr0, param.yr0, param.xr1, param.yr1 = xd0, yd0, xd1, yd1
else:
param.xc = int(0.5 * (xd0 + xd1))
param.yc = yd0
param.r = int(0.5 * (xd1 - xd0))
return param
[docs]
def params_to_roidata(self, params: gds.DataSetGroup) -> np.ndarray | None:
"""Convert ROI dataset group to ROI array data.
Args:
params: ROI dataset group
Returns:
ROI array data
"""
roilist = []
for roiparam in params.datasets:
roiparam: ROI2DParam
roilist.append(roiparam.get_coords())
if len(roilist) == 0:
return None
return np.array(roilist, int)
[docs]
def new_roi_item(
self, fmt: str, lbl: bool, editable: bool, geometry: RoiDataGeometries
) -> MaskedImageItem:
"""Return a new ROI item from scratch
Args:
fmt: format string
lbl: if True, add label
editable: if True, ROI is editable
geometry: ROI geometry
"""
roidataitem = ImageRoiDataItem.from_image(self, geometry)
return roidataitem.make_roi_item(None, fmt, lbl, editable)
[docs]
def roi_coords_to_indexes(self, coords: list) -> np.ndarray:
"""Convert ROI coordinates to indexes.
Args:
coords: coordinates
Returns:
Indexes
"""
indexes = np.array(coords)
if indexes.size > 0:
indexes[:, ::2] -= self.x0 + 0.5 * self.dx
indexes[:, ::2] /= self.dx
indexes[:, 1::2] -= self.y0 + 0.5 * self.dy
indexes[:, 1::2] /= self.dy
return np.array(indexes, int)
[docs]
def iterate_roi_items(self, fmt: str, lbl: bool, editable: bool = True) -> Iterator:
"""Make plot item representing a Region of Interest.
Args:
fmt: format string
lbl: if True, add label
editable: if True, ROI is editable
Yields:
Plot item
"""
if self.roi is not None:
roicoords = np.array(self.roi, float)
roicoords[:, ::2] *= self.dx
roicoords[:, ::2] += self.x0 - 0.5 * self.dx
roicoords[:, 1::2] *= self.dy
roicoords[:, 1::2] += self.y0 - 0.5 * self.dy
for index, coords in enumerate(roicoords):
roidataitem = ImageRoiDataItem(coords)
yield roidataitem.make_roi_item(index, fmt, lbl, editable)
@property
def maskdata(self) -> np.ndarray:
"""Return masked data (areas outside defined regions of interest)
Returns:
Masked data
"""
roi_changed = self.roi_has_changed()
if self.roi is None:
if roi_changed:
self._maskdata_cache = None
elif roi_changed or self._maskdata_cache is None:
mask = np.ones_like(self.data, dtype=bool)
for roirow in self.roi:
roidataitem = ImageRoiDataItem(roirow)
roi_mask = roidataitem.apply_mask(self.data, yxratio=self.dy / self.dx)
mask &= roi_mask
self._maskdata_cache = mask
return self._maskdata_cache
[docs]
def get_masked_view(self) -> ma.MaskedArray:
"""Return masked view for data
Returns:
Masked view
"""
self.data: np.ndarray
view = self.data.view(ma.MaskedArray)
view.mask = self.maskdata
return view
[docs]
def invalidate_maskdata_cache(self) -> None:
"""Invalidate mask data cache: force to rebuild it"""
self._maskdata_cache = None
[docs]
def add_label_with_title(self, title: str | None = None) -> None:
"""Add label with title annotation
Args:
title: title (if None, use image title)
"""
title = self.title if title is None else title
if title:
label = make.label(title, (self.x0, self.y0), (10, 10), "TL")
self.add_annotations_from_items([label])
[docs]
def create_image(
title: str,
data: np.ndarray | None = None,
metadata: dict | None = None,
units: tuple | None = None,
labels: tuple | None = None,
) -> ImageObj:
"""Create a new Image object
Args:
title: image title
data: image data
metadata: image metadata
units: X, Y, Z units (tuple of strings)
labels: X, Y, Z labels (tuple of strings)
Returns:
Image object
"""
assert isinstance(title, str)
assert data is None or isinstance(data, np.ndarray)
image = ImageObj(title=title)
image.title = title
image.data = data
if units is not None:
image.xunit, image.yunit, image.zunit = units
if labels is not None:
image.xlabel, image.ylabel, image.zlabel = labels
if metadata is not None:
image.metadata.update(metadata)
return image
[docs]
class ImageDatatypes(base.Choices):
"""Image data types"""
[docs]
@classmethod
def from_dtype(cls, dtype):
"""Return member from NumPy dtype"""
return getattr(cls, str(dtype).upper(), cls.UINT8)
[docs]
@classmethod
def check(cls):
"""Check if data types are valid"""
for member in cls:
assert hasattr(np, member.value)
#: Unsigned integer number stored with 8 bits
UINT8 = enum.auto()
#: Unsigned integer number stored with 16 bits
UINT16 = enum.auto()
#: Signed integer number stored with 16 bits
INT16 = enum.auto()
#: Float number stored with 32 bits
FLOAT32 = enum.auto()
#: Float number stored with 64 bits
FLOAT64 = enum.auto()
ImageDatatypes.check()
[docs]
class ImageTypes(base.Choices):
"""Image types"""
#: Image filled with zeros
ZEROS = _("zeros")
#: Empty image (filled with data from memory state)
EMPTY = _("empty")
#: 2D Gaussian image
GAUSS = _("gaussian")
#: Image filled with random data (uniform law)
UNIFORMRANDOM = _("random (uniform law)")
#: Image filled with random data (normal law)
NORMALRANDOM = _("random (normal law)")
[docs]
class NewImageParam(gds.DataSet):
"""New image dataset"""
hide_image_dtype = False
hide_image_type = False
title = gds.StringItem(_("Title"))
height = gds.IntItem(
_("Height"), help=_("Image height (total number of rows)"), min=1
)
width = gds.IntItem(
_("Width"), help=_("Image width (total number of columns)"), min=1
)
dtype = gds.ChoiceItem(_("Data type"), ImageDatatypes.get_choices()).set_prop(
"display", hide=gds.GetAttrProp("hide_image_dtype")
)
itype = gds.ChoiceItem(_("Type"), ImageTypes.get_choices()).set_prop(
"display", hide=gds.GetAttrProp("hide_image_type")
)
DEFAULT_TITLE = _("Untitled image")
[docs]
def new_image_param(
title: str | None = None,
itype: ImageTypes | None = None,
height: int | None = None,
width: int | None = None,
dtype: ImageDatatypes | None = None,
) -> NewImageParam:
"""Create a new Image dataset instance.
Args:
title: dataset title (default: None, uses default title)
itype: image type (default: None, uses default type)
height: image height (default: None, uses default height)
width: image width (default: None, uses default width)
dtype: image data type (default: None, uses default data type)
Returns:
New image dataset instance
"""
title = DEFAULT_TITLE if title is None else title
param = NewImageParam(title=title, icon=get_icon("new_image.svg"))
param.title = title
if height is not None:
param.height = height
if width is not None:
param.width = width
if dtype is not None:
param.dtype = dtype
if itype is not None:
param.itype = itype
return param
IMG_NB = 0
[docs]
class Gauss2DParam(gds.DataSet):
"""2D Gaussian parameters"""
a = gds.FloatItem("Norm")
xmin = gds.FloatItem("Xmin", default=-10).set_pos(col=1)
sigma = gds.FloatItem("σ", default=1.0)
xmax = gds.FloatItem("Xmax", default=10).set_pos(col=1)
mu = gds.FloatItem("μ", default=0.0)
ymin = gds.FloatItem("Ymin", default=-10).set_pos(col=1)
x0 = gds.FloatItem("X0", default=0)
ymax = gds.FloatItem("Ymax", default=10).set_pos(col=1)
y0 = gds.FloatItem("Y0", default=0).set_pos(col=0, colspan=1)
[docs]
def create_image_from_param(
newparam: NewImageParam,
addparam: gds.DataSet | None = None,
edit: bool = False,
parent: QW.QWidget | None = None,
) -> ImageObj | None:
"""Create a new Image object from dialog box.
Args:
newparam: new image parameters
addparam: additional parameters
edit: Open a dialog box to edit parameters (default: False)
parent: parent widget
Returns:
New image object or None if user cancelled
"""
global IMG_NB # pylint: disable=global-statement
if newparam is None:
newparam = new_image_param()
if newparam.height is None:
newparam.height = 500
if newparam.width is None:
newparam.width = 500
if newparam.dtype is None:
newparam.dtype = ImageDatatypes.UINT16
incr_sig_nb = not newparam.title
if incr_sig_nb:
newparam.title = f"{newparam.title} {IMG_NB + 1:d}"
if not edit or addparam is not None or newparam.edit(parent=parent):
prefix = newparam.itype.name.lower()
if incr_sig_nb:
IMG_NB += 1
image = create_image(newparam.title)
shape = (newparam.height, newparam.width)
dtype = newparam.dtype.value
p = addparam
if newparam.itype == ImageTypes.ZEROS:
image.data = np.zeros(shape, dtype=dtype)
elif newparam.itype == ImageTypes.EMPTY:
image.data = np.empty(shape, dtype=dtype)
elif newparam.itype == ImageTypes.GAUSS:
if p is None:
p = Gauss2DParam(_("2D-gaussian image"))
if p.a is None:
try:
p.a = np.iinfo(dtype).max / 2.0
except ValueError:
p.a = 10.0
if edit and not p.edit(parent=parent):
return None
x, y = np.meshgrid(
np.linspace(p.xmin, p.xmax, shape[1]),
np.linspace(p.ymin, p.ymax, shape[0]),
)
zgauss = p.a * np.exp(
-((np.sqrt((x - p.x0) ** 2 + (y - p.y0) ** 2) - p.mu) ** 2)
/ (2.0 * p.sigma**2)
)
image.data = np.array(zgauss, dtype=dtype)
if image.title == DEFAULT_TITLE:
image.title = (
f"{prefix}(a={p.a:g},μ={p.mu:g},σ={p.sigma:g}),"
f"x0={p.x0:g},y0={p.y0:g})"
)
elif newparam.itype in (ImageTypes.UNIFORMRANDOM, ImageTypes.NORMALRANDOM):
pclass = {
ImageTypes.UNIFORMRANDOM: base.UniformRandomParam,
ImageTypes.NORMALRANDOM: base.NormalRandomParam,
}[newparam.itype]
if p is None:
p = pclass(_("Image") + " - " + newparam.itype.value)
p.set_from_datatype(dtype)
if edit and not p.edit(parent=parent):
return None
rng = np.random.default_rng(p.seed)
if newparam.itype == ImageTypes.UNIFORMRANDOM:
data = rng.random(shape)
image.data = scale_data_to_min_max(data, p.vmin, p.vmax)
if image.title == DEFAULT_TITLE:
image.title = (
f"{prefix}(vmin={p.vmin:g},vmax={p.vmax:g},seed={p.seed})"
)
elif newparam.itype == ImageTypes.NORMALRANDOM:
image.data = rng.normal(p.mu, p.sigma, size=shape)
if image.title == DEFAULT_TITLE:
image.title = f"{prefix}(μ={p.mu:g},σ={p.sigma:g},seed={p.seed})"
else:
raise NotImplementedError(f"New param type: {newparam.itype.value}")
return image
return None