Source code for cdl.core.model.image

# 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
[docs] 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) 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 update_metadata_from_plot_item(self, item: MaskedImageItem) -> None: """Update metadata from plot item. Takes into account a subset of plot item parameters. Those parameters may have been modified by the user through the plot item GUI. The goal is to update the metadata accordingly. This is *almost* the inverse operation of `update_plot_item_parameters`. Args: item: plot item """ super().update_metadata_from_plot_item(item) # Updating the LUT range: self.zscalemin, self.zscalemax = item.get_lut_range() # Updating origin and pixel spacing: shape = self.data.shape param = item.param xmin, xmax, ymin, ymax = param.xmin, param.xmax, param.ymin, param.ymax if xmin == 0 and ymin == 0 and xmax == shape[1] and ymax == shape[0]: self.x0, self.y0, self.dx, self.dy = 0.0, 0.0, 1.0, 1.0 else: self.x0, self.y0 = xmin, ymin self.dx, self.dy = (xmax - xmin) / shape[1], (ymax - ymin) / shape[0]
[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