Module pvinspect.data.image

Expand source code
from __future__ import annotations

"""Provides classes to store and visualize images with metadata"""

import copy
import inspect
import logging
import math
import re
import sys
from enum import Enum
from functools import lru_cache, partial, wraps
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union

import numpy as np
import pandas as pd
from matplotlib import pyplot as plt
from pvinspect.common.transform import Transform
from skimage import img_as_float64, img_as_int, img_as_uint, io
from tqdm.autonotebook import tqdm

# this is a pointer to the module object instance itself
this = sys.modules[__name__]


# modality
class Modality(Enum):
    EL_IMAGE = (0,)
    PL_IMAGE = 1


EL_IMAGE = Modality.EL_IMAGE
"""Indicate an electroluminescense (EL) image"""
PL_IMAGE = Modality.PL_IMAGE
"""Indicate a photoluminescense (PL) image"""


# datatypes
DTYPE_INT = np.int32
DTYPE_UNSIGNED_INT = np.uint16
DTYPE_UNSIGNED_BYTE = np.uint8
DTYPE_FLOAT = np.float64
img_as_float = img_as_float64

# caching
SEQUENCE_MAX_CACHE_SIZE = 5000


class DType(Enum):
    INT = 0
    UNSIGNED_INT = 1
    FLOAT = 2
    UNSIGNED_BYTE = 3


# global list of plugins that are called on every .show()
this.show_plugins = list()


def register_show_plugin(callable, priority: int = 0):
    """Register a new plugin that is called on every .show()

    Args:
        callable: Callable that receives the image as a first argument and variable arguments to .show() next
        priority (int): Plugins are invoked in the order of increasing priority (highest priority is invoked
            last and hence appears on top)
    """
    this.show_plugins.append((priority, callable))
    this.show_plugins = sorted(this.show_plugins, key=lambda x: x[0])


def _invoke_show_plugins(image, **kwargs):
    for p in this.show_plugins:
        p[1](image, **kwargs)


def _register_default_plugins():
    def show_cell_crossings(
        image: ModuleImage, show_cell_crossings: bool = True, **kwargs
    ):
        if (
            show_cell_crossings
            and isinstance(image, ModuleImage)
            and image.has_meta("transform")
        ):
            grid = image.grid()
            coords = image.get_meta("transform").__call__(grid)
            plt.scatter(coords[:, 0], coords[:, 1], c="yellow", marker="+")

    register_show_plugin(show_cell_crossings)

    def multimodule_show_boxes(
        image: Image,
        multimodule_show_boxes: bool = True,
        multimodule_highlight_selection: bool = True,
        multimodule_boxes_linewidth: int = 2,
        **kwargs
    ):
        if (
            multimodule_show_boxes
            and isinstance(image, Image)
            and image.has_meta("multimodule_boxes")
        ):
            for i, box in enumerate(image.get_meta("multimodule_boxes")):
                color = (
                    "red"
                    if i == image.get_meta("multimodule_index")
                    and multimodule_highlight_selection
                    else "yellow"
                )
                plt.plot(
                    *box[1].exterior.xy,
                    linewidth=multimodule_boxes_linewidth,
                    color=color,
                )

    register_show_plugin(multimodule_show_boxes)

    def multimodule_show_numbers(
        image: Image,
        multimodule_show_numbers: bool = True,
        multimodule_highlight_selection: bool = True,
        multimodule_numbers_fontsize: int = 20,
        **kwargs
    ):
        if (
            multimodule_show_numbers
            and isinstance(image, Image)
            and image.has_meta("multimodule_boxes")
        ):
            for i, box in enumerate(image.get_meta("multimodule_boxes")):
                bgcolor = (
                    "red"
                    if i == image.get_meta("multimodule_index")
                    and multimodule_highlight_selection
                    else "white"
                )
                textcolor = (
                    "white"
                    if i == image.get_meta("multimodule_index")
                    and multimodule_highlight_selection
                    else "black"
                )
                plt.text(
                    box[1].centroid.x,
                    box[1].centroid.y,
                    s=str(i),
                    color=textcolor,
                    fontsize=multimodule_numbers_fontsize,
                    bbox=dict(facecolor=bgcolor, alpha=0.8),
                    ha="center",
                    va="center",
                )

    register_show_plugin(multimodule_show_numbers)

    def calibration_show_reference_box(
        image: Image,
        calibration_show_reference_box: bool = True,
        calibration_reference_box_color="red",
        **kwargs
    ):
        if (
            calibration_show_reference_box
            and isinstance(image, Image)
            and image.has_meta("calibration_reference_box")
        ):
            plt.plot(
                *image.get_meta("calibration_reference_box").exterior.xy,
                # linewidth=multimodule_boxes_linewidth,
                color=calibration_reference_box_color,
            )

    register_show_plugin(calibration_show_reference_box)

    def segment_module_show_box(
        image: Image,
        segment_module_show_box: bool = True,
        segment_module_show_box_color="red",
        **kwargs
    ):
        if (
            segment_module_show_box
            and isinstance(image, Image)
            and image.has_meta("segment_module_original_box")
        ):
            plt.plot(
                *image.get_meta("segment_module_original_box").exterior.xy,
                color=segment_module_show_box_color,
            )

    register_show_plugin(segment_module_show_box)

    def show_image(
        image: Image,
        clip_low: float = 0.001,
        clip_high: float = 99.999,
        colorbar: bool = True,
        **kwargs
    ):
        clip_low = clip_low if clip_low is not None else 0.0
        clip_high = clip_high if clip_high is not None else 100.0
        p = np.percentile(image._data, [clip_low, clip_high])
        d = np.clip(image._data, p[0], p[1])
        plt.imshow(d, cmap="gray")
        if colorbar:
            plt.colorbar()

    register_show_plugin(show_image, -100)

    def axis_options(
        image: Image,
        show_axis: bool = True,
        show_title: bool = True,
        max_title_length: bool = 30,
        **kwargs
    ):
        if not show_axis:
            plt.axis("off")
        if show_title:
            if isinstance(image, CellImage):
                t = "{} (r: {:d}, c: {:d})".format(
                    str(image.path.name), image.row, image.col
                )
            elif image.path is not None:
                t = str(image.path.name)
            else:
                t = ""

            if len(t) > max_title_length:
                l1 = max_title_length // 2 - 2
                l2 = max_title_length - l1 - 2
                t = t[:l1] + ".." + t[len(t) - l2 :]

            plt.title(t)

    register_show_plugin(axis_options, -200)


class _Base:

    T = TypeVar("T")

    @classmethod
    def from_other(
        cls: Type[T], other: T, drop_meta_types: List[Type] = None, **kwargs
    ) -> T:
        """Create a new image by partially overwriting the properties of another

        Args:
            other (Image): The other image
            drop_meta_types (List[Type]): Drop any meta attributes that are insteanceof these types
            **kwargs: Arguments that should be overwritten
        """
        required = inspect.getfullargspec(cls.__init__)[0]

        if "meta" in kwargs.keys() and isinstance(kwargs["meta"], dict):
            kwargs["meta"] = pd.Series(kwargs["meta"])

        other_args = dict()
        for name in required:
            if name == "meta" and "meta" in kwargs.keys():
                # joint meta dictionaries
                tmp = copy.copy(other._meta)
                if drop_meta_types is not None:
                    tmp = pd.Series(
                        {
                            k: v
                            for k, v in tmp.items()
                            if not np.any([isinstance(v, x) for x in drop_meta_types])
                        }
                    )
                kwargs["meta"] = kwargs["meta"].combine_first(tmp)

            if name not in kwargs.keys() and name != "self":

                # first, try public property, then private property, then meta attribute
                if name == "data":
                    other_args[name] = other._data
                elif hasattr(other, name):
                    other_args[name] = getattr(other, name)
                elif hasattr(other, "_" + name):
                    other_args[name] = getattr(other, "_" + name)
                elif isinstance(other, Image) and other.has_meta(name):
                    other_args[name] = other.get_meta(name)

                if name == "meta" and drop_meta_types is not None:
                    other_args[name] = {
                        k: v
                        for k, v in other_args[name].items()
                        if not np.any([isinstance(v, x) for x in drop_meta_types])
                    }

        return cls(**kwargs, **other_args)

    def from_self(self: T, drop_meta_types: List[Type] = None, **kwargs) -> T:
        return type(self).from_other(self, drop_meta_types=drop_meta_types, **kwargs)


class Image(_Base):
    """A general image"""

    @staticmethod
    def _map_numpy_dtype(dtype):
        if dtype == np.float32 or dtype == np.float64:
            return DType.FLOAT
        elif dtype == np.uint16 or dtype == np.uint32 or dtype == np.uint64:
            return DType.UNSIGNED_INT
        elif dtype == np.uint8:
            return DType.UNSIGNED_BYTE
        elif (
            dtype == np.int8
            or dtype == np.int16
            or dtype == np.int32
            or dtype == np.int64
        ):
            return DType.INT

    @staticmethod
    def _unify_dtypes(array):
        if (
            Image._map_numpy_dtype(array.dtype) == DType.UNSIGNED_INT
            and array.dtype != DTYPE_UNSIGNED_INT
        ):
            if (
                array.max() > np.iinfo(DTYPE_UNSIGNED_INT).max
                or array.min() < np.iinfo(DTYPE_UNSIGNED_INT).min
            ):
                raise RuntimeError(
                    "Datatype conversion to {} failed, since original data exceeds dtype limits.".format(
                        DTYPE_UNSIGNED_INT
                    )
                )
            return array.astype(DTYPE_UNSIGNED_INT)
        if (
            Image._map_numpy_dtype(array.dtype) == DType.INT
            and array.dtype != DTYPE_INT
        ):
            if (
                array.max() > np.iinfo(DTYPE_INT).max
                or array.min() < np.iinfo(DTYPE_INT).min
            ):
                raise RuntimeError(
                    "Datatype conversion to {} failed, since original data exceeds dtype limits.".format(
                        DTYPE_INT
                    )
                )
            return array.astype(DTYPE_INT)
        if (
            Image._map_numpy_dtype(array.dtype) == DType.FLOAT
            and array.dtype != DTYPE_FLOAT
        ):
            return array.astype(DTYPE_FLOAT)

        # default
        return array

    class LazyData:
        @classmethod
        @lru_cache(maxsize=SEQUENCE_MAX_CACHE_SIZE, typed=True)
        def _load(
            cls,
            load_fn: Callable[[], np.ndarray],
            checks: Tuple[Callable[[np.ndarray], np.ndarray]],
        ) -> np.ndarray:
            data = load_fn()

            # perform data checks/conversions
            for check in checks:
                data = check(data)

            # make it immutable
            data.setflags(write=False)

            return data

        def __init__(self, load_fn: Callable[[], np.ndarray]):
            self._load_fn = load_fn
            self._checks: List[Callable[[np.ndarray], np.ndarray]] = list()

        def __getattr__(self, name: str):
            # forward to numpy
            data = self._load(self._load_fn, tuple(self._checks))
            return getattr(data, name)

        def __getitem__(self, s):
            data = self._load(self._load_fn, tuple(self._checks))
            return data[s]

        def push_check(self, fn: Callable[[np.ndarray], np.ndarray]):
            self._checks.append(fn)

        def load(self) -> np.ndarray:
            return self._load(self._load_fn, tuple(self._checks))

    def __init__(
        self,
        data: np.ndarray,
        path: Path = None,
        modality: Modality = None,
        meta: Union[Dict[str, Any], pd.Series] = None,
    ):
        """Create a new image. All non-float images as automatically converted to uint.

        Args:
            data (np.ndarray): The image data
            path (Path): Path to the image
            modality (Modality): The imaging modality
            meta (Dict[str, Any]): Meta attributes of this image
        """
        self._data = data
        self._meta = (
            meta
            if isinstance(meta, pd.Series)
            else pd.Series(meta)
            if meta is not None
            else pd.Series()
        )
        self._meta["modality"] = modality
        self._meta["path"] = path.absolute() if path is not None else None

        if isinstance(data, np.ndarray):
            self._data = Image._unify_dtypes(self._data)
            self._data.setflags(write=False)
        else:
            self._data.push_check(Image._unify_dtypes)

    def show(self, **kwargs):
        """Show this image"""
        _invoke_show_plugins(self, **kwargs)

    _T = TypeVar("T")

    def as_type(self: _T, dtype: DType) -> _T:
        if dtype == DType.FLOAT:
            return self.from_self(data=img_as_float(self._data))
        elif dtype == DType.UNSIGNED_INT:
            return self.from_self(data=img_as_uint(self._data))
        elif dtype == DType.INT:
            return self.from_self(data=img_as_int(self._data))

    def __add__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data + other._data)

    def __sub__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        if self.dtype == DType.UNSIGNED_INT:
            res = self._data.astype(DTYPE_INT) - other._data.astype(DTYPE_INT)
            iinfo = np.iinfo(DTYPE_UNSIGNED_INT)
            res = np.clip(res, 0, iinfo.max)
            return self.from_self(data=res)
        else:
            return self.from_self(data=self._data - other._data)

    def __mul__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data * other._data)

    def __truediv__(self: _T, other: _T) -> _T:
        if self.dtype != DType.FLOAT or other.dtype != DType.FLOAT:
            raise RuntimeError("Images must be of type float")
        return self.from_self(data=self._data / other._data)

    def __floordiv__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data // other._data)

    def __mod__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data % other._data)

    def __pow__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data ** other._data)

    def __deepcopy__(self: _T, memo) -> _T:
        # let behavior be determined by overridden attributes
        return type(self).from_other(self)

    @property
    def data(self) -> np.ndarray:
        """The underlying image data"""
        if isinstance(self._data, Image.LazyData):
            v = self._data.load()
        else:
            v = self._data.view()
        return v

    @property
    def path(self) -> Path:
        """Path to the original image"""
        return self.get_meta("path")

    @property
    def dtype(self) -> DType:
        """Datatype of the image"""
        return Image._map_numpy_dtype(self._data.dtype)

    @property
    def shape(self) -> Tuple[int, int]:
        """Shape of the image"""
        return copy.deepcopy(self.data.shape)

    @property
    def modality(self) -> Modality:
        """The imaging modality"""
        return self.get_meta("modality")

    @property
    def lazy(self) -> bool:
        """Check, if this is lazy loaded"""
        return isinstance(self._data, Image.LazyData)

    def get_meta(self, key: str) -> Any:
        """Access a meta attribute"""
        if isinstance(self._meta[key], np.ndarray):
            v = self._meta[key].view()
            v.setflags(write=False)
            return v
        else:
            return copy.copy(self._meta[key])

    def has_meta(self, key: str) -> bool:
        """Check if a meta attribute is set"""
        return key in self._meta.index

    def list_meta(self) -> List[str]:
        """List avaliable meta keys"""
        return list(self._meta.index)

    def meta_from_path(
        self,
        pattern: str,
        key: str,
        target_type: Type,
        group_n: int = 0,
        transform: Callable[[Any], Any] = None,
    ) -> Image:
        """Extract meta information from path. The group_n'th matching group
        from pattern is used as meta value

        Args:
            pattern (str): Regular expression used to parse meta
            key (str): Key of the meta attribute
            target_type (Type): Result is converted to this datatype
            group_n (int): Index of matching group
            transform (Callable[[Any], Any]): Optional function that is applied on the value
                before datatype conversion

        Returns:
            image (Image): Resulting Image
        """
        s = str(self.path.absolute())
        res = re.search(pattern, s)
        v = res.group(group_n)
        if transform is not None:
            v = transform(v)
        v = target_type(v)
        return self.from_self(meta={key: v})

    def meta_from_fn(self, fn: Callable[[Image], Dict[str, Any]]) -> Image:
        """Extract meta data using given callable

        Args:
            fn (Callable[[Image], Dict[str, Any]]): Function used to extract meta data
        
        Returns
            image (Image): Resulting Image
        """
        return self.from_other(self, meta=fn(self))

    def _meta_to_pandas(self) -> pd.Series:
        """Convert (compatible) meta data to pandas series"""
        return self._meta

    def meta_to_pandas(self) -> pd.Series:
        """Convert (compatible) meta data to pandas series"""
        return copy.deepcopy(self._meta_to_pandas())


class ImageSequence(_Base):
    """An immutable sequence of images, allowing for access to single images as well as analysis of the sequence"""

    def _show(self, imgs: List[Image], cols: int, *args, **kwargs):
        N = len(imgs)
        rows = math.ceil(N / cols)

        # adjust the figure size
        if self.shape is not None:
            aspect = self.shape[0] / self.shape[1]
        else:
            aspect = 1.0
        plt.figure(figsize=(6 * cols, 6 * rows * aspect))

        for i, img in enumerate(imgs):
            plt.subplot(rows, cols, i + 1)
            img.show(*args, **kwargs)

    class _PandasHandler:
        def __init__(self, parent: ImageSequence):
            self._parent = parent
            pass

        class _Sub:
            def _result(self, pandas_result):
                if isinstance(pandas_result, pd.DataFrame):
                    idx = pandas_result.index.to_list()
                    result = [self._parent._images[i] for i in idx]
                    seq = type(self._parent).from_other(self._parent, images=result)
                    seq._meta_df = pandas_result.reset_index(drop=True)
                    return seq
                elif isinstance(pandas_result, pd.Series):
                    idx = pandas_result.name
                    return self._parent[idx]

            def __init__(self, parent: ImageSequence, attr):
                self._parent = parent
                self._attr = attr

            def __call__(self, *argv, **kwargs):
                pandas_result = self._attr(*argv, **kwargs)
                return self._result(pandas_result)

            def __getitem__(self, arg):
                pandas_result = self._attr[arg]
                return self._result(pandas_result)

        def __getattr__(self, name):
            attr = getattr(self._parent.meta_to_pandas(), name)
            return self._Sub(self._parent, attr)

    def __init__(
        self,
        images: List[Image],
        same_camera: bool = False,
        allow_different_dtypes=False,
    ):
        """Initialize a module image sequence
        
        Args:
            images (List[Image]): The list of images
            came_camera (bool): Indicates, if all images are from the same camera and hence share the same intrinsic parameters
            allow_different_dtypes (bool): Allow images to have different datatypes?
        """

        self._images = images
        self._same_camera = same_camera
        self._allow_different_dtypes = allow_different_dtypes
        self._meta_df = None
        if len(self.images) == 0:
            logging.error("Creation of an empty sequence is not supported")
            exit()

        # check that all have the same modality, dimension, dtype and module configuration
        shape = self.images[0].shape
        dtype = self.images[0].dtype
        modality = self.images[0].modality
        # for img in self.images:
        #    if img.dtype != dtype and not allow_different_dtypes:
        #        logging.error(
        #            'Cannot create sequence from mixed dtypes. Consider using the "allow_different_dtypes" argument, when reading images.'
        #        )
        #        exit()
        #    if img.shape != shape and same_camera:
        #        logging.error(
        #            'Cannot create sequence from mixed shapes. Consider using the "same_camera" argument, when reading images.'
        #        )
        #        exit()
        #    if img.modality != modality:
        #        logging.error("Cannot create a sequence from mixed modalities.")
        #        exit()

        # namespace for accessing pandas methods
        self.pandas = self._PandasHandler(self)

    def head(self, N: int = 4, cols: int = 2, *args, **kwargs):
        """Show the first N images

        Args:
            N (int): Number of images to show
            cols (int): How many images to show in a column
        """
        self._show(self.images[:N], cols, *args, **kwargs)

    def tail(self, N: int = 4, cols: int = 2, *args, **kwargs):
        """Show the last N images

        Args:
            N (int): Number of images to show
            cols (int): How many images to show in a column
        """
        self._show(self.images[-N:], cols, *args, **kwargs)

    _T = TypeVar("T")

    def apply(
        self, fn: Callable[[Image], Image], *argv, progress_bar: bool = True, **kwargs
    ) -> ImageSequence:
        """Apply the given callable on every image. Returns a copy of the
        original sequence

        Args:
            fn (Callable[[Image], Image]): Callable that receives and returns an Image
            progress_bar (bool): Show progress bar?
        
        Returns:
            sequence (ImageSequence): The copy with modified images
        """
        result = []
        p = tqdm if progress_bar else lambda x: x
        for img in p(self._images):
            result.append(fn(img, *argv, **kwargs))
        return self.from_self(images=result)

    def apply_image_data(
        self: _T,
        fn: Callable[[np.ndarray], np.ndarray],
        *argv,
        progress_bar: bool = True,
        **kwargs
    ) -> _T:
        """Apply the given callable on every image data. Returns a copy of the
        original sequence with modified data

        Args:
            fn (Callable[[np.ndarray], np.ndarray]): Callable that receives a np.ndarray
                and returns a np.ndarray. Note that the argument is immutable.
            progress_bar (bool): Show progress bar?
        
        Returns:
            sequence (ImageSequence): The copy with modified data
        """
        result = []
        p = tqdm if progress_bar else lambda x: x
        for img in p(self._images):
            data = img.data
            res = fn(data, *argv, **kwargs)
            result.append(type(img).from_other(img, data=res))
        return self.from_self(images=result)

    def meta_from_path(
        self,
        pattern: str,
        key: str,
        target_type: Type,
        group_n: int = 1,
        transform: Callable[[Any], Any] = None,
    ) -> ImageSequence:
        """Extract meta information from path of individual aimges. The group_n'th matching group
        from pattern is used as meta value

        Args:
            pattern (str): Regular expression used to parse meta
            key (str): Key of the meta attribute
            target_type (Type): Result is converted to this datatype
            group_n (int): Index of matching group
            transform (Callable[[Any], Any]): Optional function that is applied on the value
                before datatype conversion

        Returns:
            images (ImageSequence): Resulting ImageSequence
        """
        result = []
        for img in self._images:
            result.append(
                img.meta_from_path(
                    pattern=pattern,
                    key=key,
                    target_type=target_type,
                    group_n=group_n,
                    transform=transform,
                )
            )
        return self.from_self(images=result)

    def meta_from_fn(
        self, fn: Callable[[Image], Dict[str, Any]], **kwargs
    ) -> ImageSequence:
        """Extract meta information using given function

        Args:
            fn (Callable[[Image], Dict[str, Any]]): Function that is applied on every element of the sequence
        """
        return self.apply(fn=lambda x: x.meta_from_fn(fn), **kwargs)

    def meta_to_pandas(self) -> pd.DataFrame:
        """Convert meta from images to pandas DataFrame"""
        if self._meta_df is None:
            series = [img._meta_to_pandas() for img in self._images]
            self._meta_df = pd.DataFrame(data=series)
            self._meta_df = self._meta_df.astype(
                {"modality": str}
            )  # allow for easy comparison
        return self._meta_df.copy()  # pd.DataFrame has no writable flag :(

    def as_type(self: _T, dtype: DType) -> _T:
        """Convert sequence to specified dtype"""
        result = [img.as_type(dtype) for img in self._images]
        return self.from_self(images=result)

    def __add__(self: _T, other: _T) -> _T:
        res = [x + y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __sub__(self: _T, other: _T) -> _T:
        res = [x - y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __mul__(self: _T, other: _T) -> _T:
        res = [x * y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __truediv__(self: _T, other: _T) -> _T:
        res = [x / y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __floordiv__(self: _T, other: _T) -> _T:
        res = [x // y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __mod__(self: _T, other: _T) -> _T:
        res = [x % y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __pow__(self: _T, other: _T) -> _T:
        res = [x ** y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    @property
    def images(self) -> List[Image]:
        """Access the list of images"""
        return self._images

    @property
    def dtype(self) -> DType:
        """Access the image datatype"""
        return self.images[0].dtype if not self._allow_different_dtypes else None

    @property
    def shape(self) -> Tuple[int, int]:
        """Access the image shape"""
        return self.images[0].shape if self._same_camera else None

    @property
    def modality(self) -> Modality:
        """Access the imaging modaility"""
        return self.images[0].modality

    @property
    def same_camera(self) -> bool:
        """Indicate, if the images originate from the same camera"""
        return self._same_camera

    def __len__(self) -> int:
        return len(self.images)

    def __getitem__(self, i: int) -> Image:
        return self.images[i]


ImageOrSequence = Union[Image, ImageSequence]


class CellImage(Image):
    """An image of a solar cell with additional meta data"""

    def __init__(
        self,
        data: np.ndarray,
        modality: Modality,
        row: int,
        col: int,
        path: Path = None,
        meta: Dict[str, Any] = None,
    ):
        """Initialize a cell image

        Args:
            data (np.ndarray): The image data
            modality (Modality): The imaging modality
            path (Path): Path to the image
            row (int): Row index (zero-based)
            col (int): Cell index (zero-based)
            meta (Dict[str, Any]): Meta data
        """

        super().__init__(data, path=path, modality=modality, meta=meta)
        self._meta["row"] = row
        self._meta["col"] = col

    @property
    def row(self) -> int:
        """0-based row index of the cell in the original module"""
        return self.get_meta("row")

    @property
    def col(self) -> int:
        """0-based column index of the cell in the original module"""
        return self.get_meta("col")

    @property
    def path(self) -> Path:
        """Get this images path"""
        p = super().path
        return Path(
            "{}_row{:02d}_col{:02d}{}".format(p.stem, self.row, self.col, p.suffix)
        )

    def show(self, *argv, **kwargs):
        """Show this image"""
        super().show(*argv, **kwargs)


class CellImageSequence(ImageSequence):
    """An immutable sequence of cell images, allowing for access to single images as well as analysis of the sequence"""

    def __init__(self, images: List[CellImage]):
        """Initialize a module image sequence
        
        Args:
            images (List[CellImage]): The list of images
        """

        super().__init__(images, False)


class ModuleImage(Image):
    """An image of a solar module with additional meta data"""

    def __init__(
        self,
        data: np.ndarray,
        modality: Modality,
        path: Path = None,
        cols: int = None,
        rows: int = None,
        meta: dict = None,
    ):
        """Initialize a module image

        Args:
            data (np.ndarray): The image data
            modality (Modality): The imaging modality
            path (Path): Path to the image
            cols (int): Number of cells in a column
            rows (int): Number of cells in a row
        """

        super().__init__(data, path, modality, meta)
        self._meta["cols"] = cols
        self._meta["rows"] = rows

    def grid(self) -> np.ndarray:
        """Create a grid of corners according to the module geometry
        
        Returns:
            grid: (cols*rows, 2)-array of coordinates on a regular grid
        """

        if self.cols is not None and self.rows is not None:
            x, y = np.mgrid[0 : self.cols + 1 : 1, 0 : self.rows + 1 : 1]
            grid = np.stack([x.flatten(), y.flatten()], axis=1)
            return grid
        else:
            logging.error("Module geometry is not initialized")
            exit()

    @property
    def cols(self):
        """Number of cell-columns"""
        return self.get_meta("cols")

    @property
    def rows(self):
        """Number of row-columns"""
        return self.get_meta("rows")


class PartialModuleImage(ModuleImage):
    """An image of a solar module with additional meta data"""

    def __init__(
        self,
        data: np.ndarray,
        modality: Modality,
        path: Path = None,
        cols: int = None,
        rows: int = None,
        first_col: int = None,
        first_row: int = None,
        meta: dict = None,
    ):
        """Initialize a module image

        Args:
            data (np.ndarray): The image data
            modality (Modality): The imaging modality
            path (Path): Path to the image
            cols (int): Number of completely visible cells in a column
            rows (int): Number of completely visible cells in a row
            first_col (int): Index of the first complete column shown
            first_row (int): Index of the first complete row shown
        """

        super().__init__(data, modality, path, cols, rows, meta)

        self._meta["first_col"] = first_col
        self._meta["first_row"] = first_row

    def grid(self) -> np.ndarray:
        """Create a grid of corners according to the module geometry
        
        Returns:
            grid: (cols*rows, 2)-array of coordinates on a regular grid
        """

        if self.cols is not None and self.rows is not None:
            x, y = np.mgrid[0 : self.cols + 1 : 1, 0 : self.rows + 1 : 1]
            if self.first_col and self.first_row:
                x += self.first_col
                y += self.first_row
            grid = np.stack([x.flatten(), y.flatten()], axis=1)
            return grid
        else:
            logging.error("Module geometry is not initialized")
            exit()

    @property
    def first_col(self) -> Optional[int]:
        if self.has_meta("first_col"):
            return self.get_meta("first_col")
        else:
            return None

    @property
    def first_row(self) -> Optional[int]:
        if self.has_meta("first_row"):
            return self.get_meta("first_row")
        else:
            return None


ModuleOrPartialModuleImage = Union[ModuleImage, PartialModuleImage]


class ModuleImageSequence(ImageSequence):
    """An immutable sequence of module images, allowing for access to single images as well as analysis of the sequence"""

    def __init__(
        self,
        images: List[ModuleOrPartialModuleImage],
        same_camera: bool = False,
        allow_different_dtypes=False,
    ):
        """Initialize a module image sequence
        
        Args:
            images (List[ModuleImage]): The list of images
            same_camera (bool): Indicates if all images are from the same camera
            allow_different_dtypes (bool): Allow images to have different datatypes?
        """

        cols = images[0].cols
        rows = images[0].rows
        for img in images:
            if img.cols != cols:
                logging.error(
                    "Cannot create sequence from different module configurations"
                )
                exit()
            if img.rows != rows:
                logging.error(
                    "Cannot create sequence from different module configurations"
                )
                exit()

        super().__init__(images, same_camera, allow_different_dtypes)


ModuleImageOrSequence = Union[
    ModuleImageSequence, ModuleImage, PartialModuleImage, Image
]


def _sequence(*args):
    """Assure that the first argument is a sequence and handle the first return value accordingly"""

    def decorator_sequence(func):
        @wraps(func)
        def wrapper_sequence(*args, **kwargs):
            if not isinstance(args[0], ImageSequence):
                args = list(args)
                args[0] = (
                    ModuleImageSequence([args[0]], same_camera=False)
                    if type(args[0]) == ModuleImage
                    else ImageSequence([args[0]], same_camera=False)
                )
                unwrap = True
            else:
                unwrap = False
            res = func(*tuple(args), **kwargs)
            if unwrap and not disable_unwrap:
                if isinstance(res, tuple) and isinstance(res[0], ImageSequence):
                    res[0] = res[0].images[0]
                elif isinstance(res, ImageSequence):
                    res = res.images[0]
            return res

        return wrapper_sequence

    if len(args) == 1 and callable(args[0]):
        disable_unwrap = False
        return decorator_sequence(args[0])
    else:
        disable_unwrap = args[0] if len(args) == 1 else False
        return decorator_sequence

Global variables

var EL_IMAGE

Indicate an electroluminescense (EL) image

var PL_IMAGE

Indicate a photoluminescense (PL) image

Functions

def register_show_plugin(callable, priority: int = 0)

Register a new plugin that is called on every .show()

Args

callable
Callable that receives the image as a first argument and variable arguments to .show() next
priority : int
Plugins are invoked in the order of increasing priority (highest priority is invoked last and hence appears on top)
Expand source code
def register_show_plugin(callable, priority: int = 0):
    """Register a new plugin that is called on every .show()

    Args:
        callable: Callable that receives the image as a first argument and variable arguments to .show() next
        priority (int): Plugins are invoked in the order of increasing priority (highest priority is invoked
            last and hence appears on top)
    """
    this.show_plugins.append((priority, callable))
    this.show_plugins = sorted(this.show_plugins, key=lambda x: x[0])

Classes

class CellImage (data: np.ndarray, modality: Modality, row: int, col: int, path: Path = None, meta: Dict[str, Any] = None)

An image of a solar cell with additional meta data

Initialize a cell image

Args

data : np.ndarray
The image data
modality : Modality
The imaging modality
path : Path
Path to the image
row : int
Row index (zero-based)
col : int
Cell index (zero-based)
meta : Dict[str, Any]
Meta data
Expand source code
class CellImage(Image):
    """An image of a solar cell with additional meta data"""

    def __init__(
        self,
        data: np.ndarray,
        modality: Modality,
        row: int,
        col: int,
        path: Path = None,
        meta: Dict[str, Any] = None,
    ):
        """Initialize a cell image

        Args:
            data (np.ndarray): The image data
            modality (Modality): The imaging modality
            path (Path): Path to the image
            row (int): Row index (zero-based)
            col (int): Cell index (zero-based)
            meta (Dict[str, Any]): Meta data
        """

        super().__init__(data, path=path, modality=modality, meta=meta)
        self._meta["row"] = row
        self._meta["col"] = col

    @property
    def row(self) -> int:
        """0-based row index of the cell in the original module"""
        return self.get_meta("row")

    @property
    def col(self) -> int:
        """0-based column index of the cell in the original module"""
        return self.get_meta("col")

    @property
    def path(self) -> Path:
        """Get this images path"""
        p = super().path
        return Path(
            "{}_row{:02d}_col{:02d}{}".format(p.stem, self.row, self.col, p.suffix)
        )

    def show(self, *argv, **kwargs):
        """Show this image"""
        super().show(*argv, **kwargs)

Ancestors

  • Image
  • pvinspect.data.image._Base

Instance variables

var col : int

0-based column index of the cell in the original module

Expand source code
@property
def col(self) -> int:
    """0-based column index of the cell in the original module"""
    return self.get_meta("col")
var path : pathlib.Path

Get this images path

Expand source code
@property
def path(self) -> Path:
    """Get this images path"""
    p = super().path
    return Path(
        "{}_row{:02d}_col{:02d}{}".format(p.stem, self.row, self.col, p.suffix)
    )
var row : int

0-based row index of the cell in the original module

Expand source code
@property
def row(self) -> int:
    """0-based row index of the cell in the original module"""
    return self.get_meta("row")

Inherited members

class CellImageSequence (images: List[CellImage])

An immutable sequence of cell images, allowing for access to single images as well as analysis of the sequence

Initialize a module image sequence

Args

images : List[CellImage]
The list of images
Expand source code
class CellImageSequence(ImageSequence):
    """An immutable sequence of cell images, allowing for access to single images as well as analysis of the sequence"""

    def __init__(self, images: List[CellImage]):
        """Initialize a module image sequence
        
        Args:
            images (List[CellImage]): The list of images
        """

        super().__init__(images, False)

Ancestors

Inherited members

class DType (value, names=None, *, module=None, qualname=None, type=None, start=1)

An enumeration.

Expand source code
class DType(Enum):
    INT = 0
    UNSIGNED_INT = 1
    FLOAT = 2
    UNSIGNED_BYTE = 3

Ancestors

  • enum.Enum

Class variables

var FLOAT
var INT
var UNSIGNED_BYTE
var UNSIGNED_INT
class Image (data: np.ndarray, path: Path = None, modality: Modality = None, meta: Union[Dict[str, Any], pd.Series] = None)

A general image

Create a new image. All non-float images as automatically converted to uint.

Args

data : np.ndarray
The image data
path : Path
Path to the image
modality : Modality
The imaging modality
meta : Dict[str, Any]
Meta attributes of this image
Expand source code
class Image(_Base):
    """A general image"""

    @staticmethod
    def _map_numpy_dtype(dtype):
        if dtype == np.float32 or dtype == np.float64:
            return DType.FLOAT
        elif dtype == np.uint16 or dtype == np.uint32 or dtype == np.uint64:
            return DType.UNSIGNED_INT
        elif dtype == np.uint8:
            return DType.UNSIGNED_BYTE
        elif (
            dtype == np.int8
            or dtype == np.int16
            or dtype == np.int32
            or dtype == np.int64
        ):
            return DType.INT

    @staticmethod
    def _unify_dtypes(array):
        if (
            Image._map_numpy_dtype(array.dtype) == DType.UNSIGNED_INT
            and array.dtype != DTYPE_UNSIGNED_INT
        ):
            if (
                array.max() > np.iinfo(DTYPE_UNSIGNED_INT).max
                or array.min() < np.iinfo(DTYPE_UNSIGNED_INT).min
            ):
                raise RuntimeError(
                    "Datatype conversion to {} failed, since original data exceeds dtype limits.".format(
                        DTYPE_UNSIGNED_INT
                    )
                )
            return array.astype(DTYPE_UNSIGNED_INT)
        if (
            Image._map_numpy_dtype(array.dtype) == DType.INT
            and array.dtype != DTYPE_INT
        ):
            if (
                array.max() > np.iinfo(DTYPE_INT).max
                or array.min() < np.iinfo(DTYPE_INT).min
            ):
                raise RuntimeError(
                    "Datatype conversion to {} failed, since original data exceeds dtype limits.".format(
                        DTYPE_INT
                    )
                )
            return array.astype(DTYPE_INT)
        if (
            Image._map_numpy_dtype(array.dtype) == DType.FLOAT
            and array.dtype != DTYPE_FLOAT
        ):
            return array.astype(DTYPE_FLOAT)

        # default
        return array

    class LazyData:
        @classmethod
        @lru_cache(maxsize=SEQUENCE_MAX_CACHE_SIZE, typed=True)
        def _load(
            cls,
            load_fn: Callable[[], np.ndarray],
            checks: Tuple[Callable[[np.ndarray], np.ndarray]],
        ) -> np.ndarray:
            data = load_fn()

            # perform data checks/conversions
            for check in checks:
                data = check(data)

            # make it immutable
            data.setflags(write=False)

            return data

        def __init__(self, load_fn: Callable[[], np.ndarray]):
            self._load_fn = load_fn
            self._checks: List[Callable[[np.ndarray], np.ndarray]] = list()

        def __getattr__(self, name: str):
            # forward to numpy
            data = self._load(self._load_fn, tuple(self._checks))
            return getattr(data, name)

        def __getitem__(self, s):
            data = self._load(self._load_fn, tuple(self._checks))
            return data[s]

        def push_check(self, fn: Callable[[np.ndarray], np.ndarray]):
            self._checks.append(fn)

        def load(self) -> np.ndarray:
            return self._load(self._load_fn, tuple(self._checks))

    def __init__(
        self,
        data: np.ndarray,
        path: Path = None,
        modality: Modality = None,
        meta: Union[Dict[str, Any], pd.Series] = None,
    ):
        """Create a new image. All non-float images as automatically converted to uint.

        Args:
            data (np.ndarray): The image data
            path (Path): Path to the image
            modality (Modality): The imaging modality
            meta (Dict[str, Any]): Meta attributes of this image
        """
        self._data = data
        self._meta = (
            meta
            if isinstance(meta, pd.Series)
            else pd.Series(meta)
            if meta is not None
            else pd.Series()
        )
        self._meta["modality"] = modality
        self._meta["path"] = path.absolute() if path is not None else None

        if isinstance(data, np.ndarray):
            self._data = Image._unify_dtypes(self._data)
            self._data.setflags(write=False)
        else:
            self._data.push_check(Image._unify_dtypes)

    def show(self, **kwargs):
        """Show this image"""
        _invoke_show_plugins(self, **kwargs)

    _T = TypeVar("T")

    def as_type(self: _T, dtype: DType) -> _T:
        if dtype == DType.FLOAT:
            return self.from_self(data=img_as_float(self._data))
        elif dtype == DType.UNSIGNED_INT:
            return self.from_self(data=img_as_uint(self._data))
        elif dtype == DType.INT:
            return self.from_self(data=img_as_int(self._data))

    def __add__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data + other._data)

    def __sub__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        if self.dtype == DType.UNSIGNED_INT:
            res = self._data.astype(DTYPE_INT) - other._data.astype(DTYPE_INT)
            iinfo = np.iinfo(DTYPE_UNSIGNED_INT)
            res = np.clip(res, 0, iinfo.max)
            return self.from_self(data=res)
        else:
            return self.from_self(data=self._data - other._data)

    def __mul__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data * other._data)

    def __truediv__(self: _T, other: _T) -> _T:
        if self.dtype != DType.FLOAT or other.dtype != DType.FLOAT:
            raise RuntimeError("Images must be of type float")
        return self.from_self(data=self._data / other._data)

    def __floordiv__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data // other._data)

    def __mod__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data % other._data)

    def __pow__(self: _T, other: _T) -> _T:
        if self.dtype != other.dtype:
            raise RuntimeError("Images must have the same datatype")
        return self.from_self(data=self._data ** other._data)

    def __deepcopy__(self: _T, memo) -> _T:
        # let behavior be determined by overridden attributes
        return type(self).from_other(self)

    @property
    def data(self) -> np.ndarray:
        """The underlying image data"""
        if isinstance(self._data, Image.LazyData):
            v = self._data.load()
        else:
            v = self._data.view()
        return v

    @property
    def path(self) -> Path:
        """Path to the original image"""
        return self.get_meta("path")

    @property
    def dtype(self) -> DType:
        """Datatype of the image"""
        return Image._map_numpy_dtype(self._data.dtype)

    @property
    def shape(self) -> Tuple[int, int]:
        """Shape of the image"""
        return copy.deepcopy(self.data.shape)

    @property
    def modality(self) -> Modality:
        """The imaging modality"""
        return self.get_meta("modality")

    @property
    def lazy(self) -> bool:
        """Check, if this is lazy loaded"""
        return isinstance(self._data, Image.LazyData)

    def get_meta(self, key: str) -> Any:
        """Access a meta attribute"""
        if isinstance(self._meta[key], np.ndarray):
            v = self._meta[key].view()
            v.setflags(write=False)
            return v
        else:
            return copy.copy(self._meta[key])

    def has_meta(self, key: str) -> bool:
        """Check if a meta attribute is set"""
        return key in self._meta.index

    def list_meta(self) -> List[str]:
        """List avaliable meta keys"""
        return list(self._meta.index)

    def meta_from_path(
        self,
        pattern: str,
        key: str,
        target_type: Type,
        group_n: int = 0,
        transform: Callable[[Any], Any] = None,
    ) -> Image:
        """Extract meta information from path. The group_n'th matching group
        from pattern is used as meta value

        Args:
            pattern (str): Regular expression used to parse meta
            key (str): Key of the meta attribute
            target_type (Type): Result is converted to this datatype
            group_n (int): Index of matching group
            transform (Callable[[Any], Any]): Optional function that is applied on the value
                before datatype conversion

        Returns:
            image (Image): Resulting Image
        """
        s = str(self.path.absolute())
        res = re.search(pattern, s)
        v = res.group(group_n)
        if transform is not None:
            v = transform(v)
        v = target_type(v)
        return self.from_self(meta={key: v})

    def meta_from_fn(self, fn: Callable[[Image], Dict[str, Any]]) -> Image:
        """Extract meta data using given callable

        Args:
            fn (Callable[[Image], Dict[str, Any]]): Function used to extract meta data
        
        Returns
            image (Image): Resulting Image
        """
        return self.from_other(self, meta=fn(self))

    def _meta_to_pandas(self) -> pd.Series:
        """Convert (compatible) meta data to pandas series"""
        return self._meta

    def meta_to_pandas(self) -> pd.Series:
        """Convert (compatible) meta data to pandas series"""
        return copy.deepcopy(self._meta_to_pandas())

Ancestors

  • pvinspect.data.image._Base

Subclasses

Class variables

var LazyData

Instance variables

var data : numpy.ndarray

The underlying image data

Expand source code
@property
def data(self) -> np.ndarray:
    """The underlying image data"""
    if isinstance(self._data, Image.LazyData):
        v = self._data.load()
    else:
        v = self._data.view()
    return v
var dtypeDType

Datatype of the image

Expand source code
@property
def dtype(self) -> DType:
    """Datatype of the image"""
    return Image._map_numpy_dtype(self._data.dtype)
var lazy : bool

Check, if this is lazy loaded

Expand source code
@property
def lazy(self) -> bool:
    """Check, if this is lazy loaded"""
    return isinstance(self._data, Image.LazyData)
var modalityModality

The imaging modality

Expand source code
@property
def modality(self) -> Modality:
    """The imaging modality"""
    return self.get_meta("modality")
var path : pathlib.Path

Path to the original image

Expand source code
@property
def path(self) -> Path:
    """Path to the original image"""
    return self.get_meta("path")
var shape : Tuple[int, int]

Shape of the image

Expand source code
@property
def shape(self) -> Tuple[int, int]:
    """Shape of the image"""
    return copy.deepcopy(self.data.shape)

Methods

def as_type(self: _T, dtype: DType) ‑> _T
Expand source code
def as_type(self: _T, dtype: DType) -> _T:
    if dtype == DType.FLOAT:
        return self.from_self(data=img_as_float(self._data))
    elif dtype == DType.UNSIGNED_INT:
        return self.from_self(data=img_as_uint(self._data))
    elif dtype == DType.INT:
        return self.from_self(data=img_as_int(self._data))
def get_meta(self, key: str) ‑> Any

Access a meta attribute

Expand source code
def get_meta(self, key: str) -> Any:
    """Access a meta attribute"""
    if isinstance(self._meta[key], np.ndarray):
        v = self._meta[key].view()
        v.setflags(write=False)
        return v
    else:
        return copy.copy(self._meta[key])
def has_meta(self, key: str) ‑> bool

Check if a meta attribute is set

Expand source code
def has_meta(self, key: str) -> bool:
    """Check if a meta attribute is set"""
    return key in self._meta.index
def list_meta(self) ‑> List[str]

List avaliable meta keys

Expand source code
def list_meta(self) -> List[str]:
    """List avaliable meta keys"""
    return list(self._meta.index)
def meta_from_fn(self, fn: Callable[[Image], Dict[str, Any]]) ‑> Image

Extract meta data using given callable

Args

fn : Callable[[Image], Dict[str, Any]]
Function used to extract meta data

Returns image (Image): Resulting Image

Expand source code
def meta_from_fn(self, fn: Callable[[Image], Dict[str, Any]]) -> Image:
    """Extract meta data using given callable

    Args:
        fn (Callable[[Image], Dict[str, Any]]): Function used to extract meta data
    
    Returns
        image (Image): Resulting Image
    """
    return self.from_other(self, meta=fn(self))
def meta_from_path(self, pattern: str, key: str, target_type: Type, group_n: int = 0, transform: Callable[[Any], Any] = None) ‑> Image

Extract meta information from path. The group_n'th matching group from pattern is used as meta value

Args

pattern : str
Regular expression used to parse meta
key : str
Key of the meta attribute
target_type : Type
Result is converted to this datatype
group_n : int
Index of matching group
transform : Callable[[Any], Any]
Optional function that is applied on the value before datatype conversion

Returns

image (Image): Resulting Image

Expand source code
def meta_from_path(
    self,
    pattern: str,
    key: str,
    target_type: Type,
    group_n: int = 0,
    transform: Callable[[Any], Any] = None,
) -> Image:
    """Extract meta information from path. The group_n'th matching group
    from pattern is used as meta value

    Args:
        pattern (str): Regular expression used to parse meta
        key (str): Key of the meta attribute
        target_type (Type): Result is converted to this datatype
        group_n (int): Index of matching group
        transform (Callable[[Any], Any]): Optional function that is applied on the value
            before datatype conversion

    Returns:
        image (Image): Resulting Image
    """
    s = str(self.path.absolute())
    res = re.search(pattern, s)
    v = res.group(group_n)
    if transform is not None:
        v = transform(v)
    v = target_type(v)
    return self.from_self(meta={key: v})
def meta_to_pandas(self) ‑> pandas.core.series.Series

Convert (compatible) meta data to pandas series

Expand source code
def meta_to_pandas(self) -> pd.Series:
    """Convert (compatible) meta data to pandas series"""
    return copy.deepcopy(self._meta_to_pandas())
def show(self, **kwargs)

Show this image

Expand source code
def show(self, **kwargs):
    """Show this image"""
    _invoke_show_plugins(self, **kwargs)
class ImageSequence (images: List[Image], same_camera: bool = False, allow_different_dtypes=False)

An immutable sequence of images, allowing for access to single images as well as analysis of the sequence

Initialize a module image sequence

Args

images : List[Image]
The list of images
came_camera : bool
Indicates, if all images are from the same camera and hence share the same intrinsic parameters
allow_different_dtypes : bool
Allow images to have different datatypes?
Expand source code
class ImageSequence(_Base):
    """An immutable sequence of images, allowing for access to single images as well as analysis of the sequence"""

    def _show(self, imgs: List[Image], cols: int, *args, **kwargs):
        N = len(imgs)
        rows = math.ceil(N / cols)

        # adjust the figure size
        if self.shape is not None:
            aspect = self.shape[0] / self.shape[1]
        else:
            aspect = 1.0
        plt.figure(figsize=(6 * cols, 6 * rows * aspect))

        for i, img in enumerate(imgs):
            plt.subplot(rows, cols, i + 1)
            img.show(*args, **kwargs)

    class _PandasHandler:
        def __init__(self, parent: ImageSequence):
            self._parent = parent
            pass

        class _Sub:
            def _result(self, pandas_result):
                if isinstance(pandas_result, pd.DataFrame):
                    idx = pandas_result.index.to_list()
                    result = [self._parent._images[i] for i in idx]
                    seq = type(self._parent).from_other(self._parent, images=result)
                    seq._meta_df = pandas_result.reset_index(drop=True)
                    return seq
                elif isinstance(pandas_result, pd.Series):
                    idx = pandas_result.name
                    return self._parent[idx]

            def __init__(self, parent: ImageSequence, attr):
                self._parent = parent
                self._attr = attr

            def __call__(self, *argv, **kwargs):
                pandas_result = self._attr(*argv, **kwargs)
                return self._result(pandas_result)

            def __getitem__(self, arg):
                pandas_result = self._attr[arg]
                return self._result(pandas_result)

        def __getattr__(self, name):
            attr = getattr(self._parent.meta_to_pandas(), name)
            return self._Sub(self._parent, attr)

    def __init__(
        self,
        images: List[Image],
        same_camera: bool = False,
        allow_different_dtypes=False,
    ):
        """Initialize a module image sequence
        
        Args:
            images (List[Image]): The list of images
            came_camera (bool): Indicates, if all images are from the same camera and hence share the same intrinsic parameters
            allow_different_dtypes (bool): Allow images to have different datatypes?
        """

        self._images = images
        self._same_camera = same_camera
        self._allow_different_dtypes = allow_different_dtypes
        self._meta_df = None
        if len(self.images) == 0:
            logging.error("Creation of an empty sequence is not supported")
            exit()

        # check that all have the same modality, dimension, dtype and module configuration
        shape = self.images[0].shape
        dtype = self.images[0].dtype
        modality = self.images[0].modality
        # for img in self.images:
        #    if img.dtype != dtype and not allow_different_dtypes:
        #        logging.error(
        #            'Cannot create sequence from mixed dtypes. Consider using the "allow_different_dtypes" argument, when reading images.'
        #        )
        #        exit()
        #    if img.shape != shape and same_camera:
        #        logging.error(
        #            'Cannot create sequence from mixed shapes. Consider using the "same_camera" argument, when reading images.'
        #        )
        #        exit()
        #    if img.modality != modality:
        #        logging.error("Cannot create a sequence from mixed modalities.")
        #        exit()

        # namespace for accessing pandas methods
        self.pandas = self._PandasHandler(self)

    def head(self, N: int = 4, cols: int = 2, *args, **kwargs):
        """Show the first N images

        Args:
            N (int): Number of images to show
            cols (int): How many images to show in a column
        """
        self._show(self.images[:N], cols, *args, **kwargs)

    def tail(self, N: int = 4, cols: int = 2, *args, **kwargs):
        """Show the last N images

        Args:
            N (int): Number of images to show
            cols (int): How many images to show in a column
        """
        self._show(self.images[-N:], cols, *args, **kwargs)

    _T = TypeVar("T")

    def apply(
        self, fn: Callable[[Image], Image], *argv, progress_bar: bool = True, **kwargs
    ) -> ImageSequence:
        """Apply the given callable on every image. Returns a copy of the
        original sequence

        Args:
            fn (Callable[[Image], Image]): Callable that receives and returns an Image
            progress_bar (bool): Show progress bar?
        
        Returns:
            sequence (ImageSequence): The copy with modified images
        """
        result = []
        p = tqdm if progress_bar else lambda x: x
        for img in p(self._images):
            result.append(fn(img, *argv, **kwargs))
        return self.from_self(images=result)

    def apply_image_data(
        self: _T,
        fn: Callable[[np.ndarray], np.ndarray],
        *argv,
        progress_bar: bool = True,
        **kwargs
    ) -> _T:
        """Apply the given callable on every image data. Returns a copy of the
        original sequence with modified data

        Args:
            fn (Callable[[np.ndarray], np.ndarray]): Callable that receives a np.ndarray
                and returns a np.ndarray. Note that the argument is immutable.
            progress_bar (bool): Show progress bar?
        
        Returns:
            sequence (ImageSequence): The copy with modified data
        """
        result = []
        p = tqdm if progress_bar else lambda x: x
        for img in p(self._images):
            data = img.data
            res = fn(data, *argv, **kwargs)
            result.append(type(img).from_other(img, data=res))
        return self.from_self(images=result)

    def meta_from_path(
        self,
        pattern: str,
        key: str,
        target_type: Type,
        group_n: int = 1,
        transform: Callable[[Any], Any] = None,
    ) -> ImageSequence:
        """Extract meta information from path of individual aimges. The group_n'th matching group
        from pattern is used as meta value

        Args:
            pattern (str): Regular expression used to parse meta
            key (str): Key of the meta attribute
            target_type (Type): Result is converted to this datatype
            group_n (int): Index of matching group
            transform (Callable[[Any], Any]): Optional function that is applied on the value
                before datatype conversion

        Returns:
            images (ImageSequence): Resulting ImageSequence
        """
        result = []
        for img in self._images:
            result.append(
                img.meta_from_path(
                    pattern=pattern,
                    key=key,
                    target_type=target_type,
                    group_n=group_n,
                    transform=transform,
                )
            )
        return self.from_self(images=result)

    def meta_from_fn(
        self, fn: Callable[[Image], Dict[str, Any]], **kwargs
    ) -> ImageSequence:
        """Extract meta information using given function

        Args:
            fn (Callable[[Image], Dict[str, Any]]): Function that is applied on every element of the sequence
        """
        return self.apply(fn=lambda x: x.meta_from_fn(fn), **kwargs)

    def meta_to_pandas(self) -> pd.DataFrame:
        """Convert meta from images to pandas DataFrame"""
        if self._meta_df is None:
            series = [img._meta_to_pandas() for img in self._images]
            self._meta_df = pd.DataFrame(data=series)
            self._meta_df = self._meta_df.astype(
                {"modality": str}
            )  # allow for easy comparison
        return self._meta_df.copy()  # pd.DataFrame has no writable flag :(

    def as_type(self: _T, dtype: DType) -> _T:
        """Convert sequence to specified dtype"""
        result = [img.as_type(dtype) for img in self._images]
        return self.from_self(images=result)

    def __add__(self: _T, other: _T) -> _T:
        res = [x + y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __sub__(self: _T, other: _T) -> _T:
        res = [x - y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __mul__(self: _T, other: _T) -> _T:
        res = [x * y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __truediv__(self: _T, other: _T) -> _T:
        res = [x / y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __floordiv__(self: _T, other: _T) -> _T:
        res = [x // y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __mod__(self: _T, other: _T) -> _T:
        res = [x % y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    def __pow__(self: _T, other: _T) -> _T:
        res = [x ** y for x, y in zip(self.images, other.images)]
        return self.from_self(images=res)

    @property
    def images(self) -> List[Image]:
        """Access the list of images"""
        return self._images

    @property
    def dtype(self) -> DType:
        """Access the image datatype"""
        return self.images[0].dtype if not self._allow_different_dtypes else None

    @property
    def shape(self) -> Tuple[int, int]:
        """Access the image shape"""
        return self.images[0].shape if self._same_camera else None

    @property
    def modality(self) -> Modality:
        """Access the imaging modaility"""
        return self.images[0].modality

    @property
    def same_camera(self) -> bool:
        """Indicate, if the images originate from the same camera"""
        return self._same_camera

    def __len__(self) -> int:
        return len(self.images)

    def __getitem__(self, i: int) -> Image:
        return self.images[i]

Ancestors

  • pvinspect.data.image._Base

Subclasses

Instance variables

var dtypeDType

Access the image datatype

Expand source code
@property
def dtype(self) -> DType:
    """Access the image datatype"""
    return self.images[0].dtype if not self._allow_different_dtypes else None
var images : List[Image]

Access the list of images

Expand source code
@property
def images(self) -> List[Image]:
    """Access the list of images"""
    return self._images
var modalityModality

Access the imaging modaility

Expand source code
@property
def modality(self) -> Modality:
    """Access the imaging modaility"""
    return self.images[0].modality
var same_camera : bool

Indicate, if the images originate from the same camera

Expand source code
@property
def same_camera(self) -> bool:
    """Indicate, if the images originate from the same camera"""
    return self._same_camera
var shape : Tuple[int, int]

Access the image shape

Expand source code
@property
def shape(self) -> Tuple[int, int]:
    """Access the image shape"""
    return self.images[0].shape if self._same_camera else None

Methods

def apply(self, fn: Callable[[Image], Image], *argv, progress_bar: bool = True, **kwargs) ‑> ImageSequence

Apply the given callable on every image. Returns a copy of the original sequence

Args

fn : Callable[[Image], Image]
Callable that receives and returns an Image
progress_bar : bool
Show progress bar?

Returns

sequence (ImageSequence): The copy with modified images

Expand source code
def apply(
    self, fn: Callable[[Image], Image], *argv, progress_bar: bool = True, **kwargs
) -> ImageSequence:
    """Apply the given callable on every image. Returns a copy of the
    original sequence

    Args:
        fn (Callable[[Image], Image]): Callable that receives and returns an Image
        progress_bar (bool): Show progress bar?
    
    Returns:
        sequence (ImageSequence): The copy with modified images
    """
    result = []
    p = tqdm if progress_bar else lambda x: x
    for img in p(self._images):
        result.append(fn(img, *argv, **kwargs))
    return self.from_self(images=result)
def apply_image_data(self: _T, fn: Callable[[np.ndarray], np.ndarray], *argv, progress_bar: bool = True, **kwargs) ‑> _T

Apply the given callable on every image data. Returns a copy of the original sequence with modified data

Args

fn : Callable[[np.ndarray], np.ndarray]
Callable that receives a np.ndarray and returns a np.ndarray. Note that the argument is immutable.
progress_bar : bool
Show progress bar?

Returns

sequence (ImageSequence): The copy with modified data

Expand source code
def apply_image_data(
    self: _T,
    fn: Callable[[np.ndarray], np.ndarray],
    *argv,
    progress_bar: bool = True,
    **kwargs
) -> _T:
    """Apply the given callable on every image data. Returns a copy of the
    original sequence with modified data

    Args:
        fn (Callable[[np.ndarray], np.ndarray]): Callable that receives a np.ndarray
            and returns a np.ndarray. Note that the argument is immutable.
        progress_bar (bool): Show progress bar?
    
    Returns:
        sequence (ImageSequence): The copy with modified data
    """
    result = []
    p = tqdm if progress_bar else lambda x: x
    for img in p(self._images):
        data = img.data
        res = fn(data, *argv, **kwargs)
        result.append(type(img).from_other(img, data=res))
    return self.from_self(images=result)
def as_type(self: _T, dtype: DType) ‑> _T

Convert sequence to specified dtype

Expand source code
def as_type(self: _T, dtype: DType) -> _T:
    """Convert sequence to specified dtype"""
    result = [img.as_type(dtype) for img in self._images]
    return self.from_self(images=result)
def head(self, N: int = 4, cols: int = 2, *args, **kwargs)

Show the first N images

Args

N : int
Number of images to show
cols : int
How many images to show in a column
Expand source code
def head(self, N: int = 4, cols: int = 2, *args, **kwargs):
    """Show the first N images

    Args:
        N (int): Number of images to show
        cols (int): How many images to show in a column
    """
    self._show(self.images[:N], cols, *args, **kwargs)
def meta_from_fn(self, fn: Callable[[Image], Dict[str, Any]], **kwargs) ‑> ImageSequence

Extract meta information using given function

Args

fn : Callable[[Image], Dict[str, Any]]
Function that is applied on every element of the sequence
Expand source code
def meta_from_fn(
    self, fn: Callable[[Image], Dict[str, Any]], **kwargs
) -> ImageSequence:
    """Extract meta information using given function

    Args:
        fn (Callable[[Image], Dict[str, Any]]): Function that is applied on every element of the sequence
    """
    return self.apply(fn=lambda x: x.meta_from_fn(fn), **kwargs)
def meta_from_path(self, pattern: str, key: str, target_type: Type, group_n: int = 1, transform: Callable[[Any], Any] = None) ‑> ImageSequence

Extract meta information from path of individual aimges. The group_n'th matching group from pattern is used as meta value

Args

pattern : str
Regular expression used to parse meta
key : str
Key of the meta attribute
target_type : Type
Result is converted to this datatype
group_n : int
Index of matching group
transform : Callable[[Any], Any]
Optional function that is applied on the value before datatype conversion

Returns

images (ImageSequence): Resulting ImageSequence

Expand source code
def meta_from_path(
    self,
    pattern: str,
    key: str,
    target_type: Type,
    group_n: int = 1,
    transform: Callable[[Any], Any] = None,
) -> ImageSequence:
    """Extract meta information from path of individual aimges. The group_n'th matching group
    from pattern is used as meta value

    Args:
        pattern (str): Regular expression used to parse meta
        key (str): Key of the meta attribute
        target_type (Type): Result is converted to this datatype
        group_n (int): Index of matching group
        transform (Callable[[Any], Any]): Optional function that is applied on the value
            before datatype conversion

    Returns:
        images (ImageSequence): Resulting ImageSequence
    """
    result = []
    for img in self._images:
        result.append(
            img.meta_from_path(
                pattern=pattern,
                key=key,
                target_type=target_type,
                group_n=group_n,
                transform=transform,
            )
        )
    return self.from_self(images=result)
def meta_to_pandas(self) ‑> pandas.core.frame.DataFrame

Convert meta from images to pandas DataFrame

Expand source code
def meta_to_pandas(self) -> pd.DataFrame:
    """Convert meta from images to pandas DataFrame"""
    if self._meta_df is None:
        series = [img._meta_to_pandas() for img in self._images]
        self._meta_df = pd.DataFrame(data=series)
        self._meta_df = self._meta_df.astype(
            {"modality": str}
        )  # allow for easy comparison
    return self._meta_df.copy()  # pd.DataFrame has no writable flag :(
def tail(self, N: int = 4, cols: int = 2, *args, **kwargs)

Show the last N images

Args

N : int
Number of images to show
cols : int
How many images to show in a column
Expand source code
def tail(self, N: int = 4, cols: int = 2, *args, **kwargs):
    """Show the last N images

    Args:
        N (int): Number of images to show
        cols (int): How many images to show in a column
    """
    self._show(self.images[-N:], cols, *args, **kwargs)
class Modality (value, names=None, *, module=None, qualname=None, type=None, start=1)

An enumeration.

Expand source code
class Modality(Enum):
    EL_IMAGE = (0,)
    PL_IMAGE = 1

Ancestors

  • enum.Enum

Class variables

var EL_IMAGE
var PL_IMAGE
class ModuleImage (data: np.ndarray, modality: Modality, path: Path = None, cols: int = None, rows: int = None, meta: dict = None)

An image of a solar module with additional meta data

Initialize a module image

Args

data : np.ndarray
The image data
modality : Modality
The imaging modality
path : Path
Path to the image
cols : int
Number of cells in a column
rows : int
Number of cells in a row
Expand source code
class ModuleImage(Image):
    """An image of a solar module with additional meta data"""

    def __init__(
        self,
        data: np.ndarray,
        modality: Modality,
        path: Path = None,
        cols: int = None,
        rows: int = None,
        meta: dict = None,
    ):
        """Initialize a module image

        Args:
            data (np.ndarray): The image data
            modality (Modality): The imaging modality
            path (Path): Path to the image
            cols (int): Number of cells in a column
            rows (int): Number of cells in a row
        """

        super().__init__(data, path, modality, meta)
        self._meta["cols"] = cols
        self._meta["rows"] = rows

    def grid(self) -> np.ndarray:
        """Create a grid of corners according to the module geometry
        
        Returns:
            grid: (cols*rows, 2)-array of coordinates on a regular grid
        """

        if self.cols is not None and self.rows is not None:
            x, y = np.mgrid[0 : self.cols + 1 : 1, 0 : self.rows + 1 : 1]
            grid = np.stack([x.flatten(), y.flatten()], axis=1)
            return grid
        else:
            logging.error("Module geometry is not initialized")
            exit()

    @property
    def cols(self):
        """Number of cell-columns"""
        return self.get_meta("cols")

    @property
    def rows(self):
        """Number of row-columns"""
        return self.get_meta("rows")

Ancestors

  • Image
  • pvinspect.data.image._Base

Subclasses

Instance variables

var cols

Number of cell-columns

Expand source code
@property
def cols(self):
    """Number of cell-columns"""
    return self.get_meta("cols")
var rows

Number of row-columns

Expand source code
@property
def rows(self):
    """Number of row-columns"""
    return self.get_meta("rows")

Methods

def grid(self) ‑> numpy.ndarray

Create a grid of corners according to the module geometry

Returns

grid
(cols*rows, 2)-array of coordinates on a regular grid
Expand source code
def grid(self) -> np.ndarray:
    """Create a grid of corners according to the module geometry
    
    Returns:
        grid: (cols*rows, 2)-array of coordinates on a regular grid
    """

    if self.cols is not None and self.rows is not None:
        x, y = np.mgrid[0 : self.cols + 1 : 1, 0 : self.rows + 1 : 1]
        grid = np.stack([x.flatten(), y.flatten()], axis=1)
        return grid
    else:
        logging.error("Module geometry is not initialized")
        exit()

Inherited members

class ModuleImageSequence (images: List[ModuleOrPartialModuleImage], same_camera: bool = False, allow_different_dtypes=False)

An immutable sequence of module images, allowing for access to single images as well as analysis of the sequence

Initialize a module image sequence

Args

images : List[ModuleImage]
The list of images
same_camera : bool
Indicates if all images are from the same camera
allow_different_dtypes : bool
Allow images to have different datatypes?
Expand source code
class ModuleImageSequence(ImageSequence):
    """An immutable sequence of module images, allowing for access to single images as well as analysis of the sequence"""

    def __init__(
        self,
        images: List[ModuleOrPartialModuleImage],
        same_camera: bool = False,
        allow_different_dtypes=False,
    ):
        """Initialize a module image sequence
        
        Args:
            images (List[ModuleImage]): The list of images
            same_camera (bool): Indicates if all images are from the same camera
            allow_different_dtypes (bool): Allow images to have different datatypes?
        """

        cols = images[0].cols
        rows = images[0].rows
        for img in images:
            if img.cols != cols:
                logging.error(
                    "Cannot create sequence from different module configurations"
                )
                exit()
            if img.rows != rows:
                logging.error(
                    "Cannot create sequence from different module configurations"
                )
                exit()

        super().__init__(images, same_camera, allow_different_dtypes)

Ancestors

Inherited members

class PartialModuleImage (data: np.ndarray, modality: Modality, path: Path = None, cols: int = None, rows: int = None, first_col: int = None, first_row: int = None, meta: dict = None)

An image of a solar module with additional meta data

Initialize a module image

Args

data : np.ndarray
The image data
modality : Modality
The imaging modality
path : Path
Path to the image
cols : int
Number of completely visible cells in a column
rows : int
Number of completely visible cells in a row
first_col : int
Index of the first complete column shown
first_row : int
Index of the first complete row shown
Expand source code
class PartialModuleImage(ModuleImage):
    """An image of a solar module with additional meta data"""

    def __init__(
        self,
        data: np.ndarray,
        modality: Modality,
        path: Path = None,
        cols: int = None,
        rows: int = None,
        first_col: int = None,
        first_row: int = None,
        meta: dict = None,
    ):
        """Initialize a module image

        Args:
            data (np.ndarray): The image data
            modality (Modality): The imaging modality
            path (Path): Path to the image
            cols (int): Number of completely visible cells in a column
            rows (int): Number of completely visible cells in a row
            first_col (int): Index of the first complete column shown
            first_row (int): Index of the first complete row shown
        """

        super().__init__(data, modality, path, cols, rows, meta)

        self._meta["first_col"] = first_col
        self._meta["first_row"] = first_row

    def grid(self) -> np.ndarray:
        """Create a grid of corners according to the module geometry
        
        Returns:
            grid: (cols*rows, 2)-array of coordinates on a regular grid
        """

        if self.cols is not None and self.rows is not None:
            x, y = np.mgrid[0 : self.cols + 1 : 1, 0 : self.rows + 1 : 1]
            if self.first_col and self.first_row:
                x += self.first_col
                y += self.first_row
            grid = np.stack([x.flatten(), y.flatten()], axis=1)
            return grid
        else:
            logging.error("Module geometry is not initialized")
            exit()

    @property
    def first_col(self) -> Optional[int]:
        if self.has_meta("first_col"):
            return self.get_meta("first_col")
        else:
            return None

    @property
    def first_row(self) -> Optional[int]:
        if self.has_meta("first_row"):
            return self.get_meta("first_row")
        else:
            return None

Ancestors

Instance variables

var first_col : Optional[int]
Expand source code
@property
def first_col(self) -> Optional[int]:
    if self.has_meta("first_col"):
        return self.get_meta("first_col")
    else:
        return None
var first_row : Optional[int]
Expand source code
@property
def first_row(self) -> Optional[int]:
    if self.has_meta("first_row"):
        return self.get_meta("first_row")
    else:
        return None

Inherited members