# author: Justus Schock (justus.schock@rwth-aachen.de)
import os
import numpy as np
from abc import abstractmethod
from copy import deepcopy
from skimage.color import rgb2gray
from skimage.transform import AffineTransform, warp
from skimage.io import imsave
# TODO: Add support for connectivity-Information
[docs]class AbstractSingleImage(object):
"""
Abstract Class to define a SingleImage-API
"""
def __init__(self):
self._img = None
self._transformation_history = []
self.is_cartesian = True
@property
def img(self):
"""
Property to get the actual image pixels
Returns
-------
np.array
image pixels
"""
return self._img
@img.setter
@abstractmethod
def img(self, new_img):
"""
Setter for the ``img`` property
Parameters
----------
new_img : np.array
the new image
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def save(self, *args, **kwargs):
"""
Abstract Function to save image and landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def save_image(self, *args, **kwargs):
"""
Abstract Function to save image
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def save_landmarks(self, *args, **kwargs):
"""
Abstract Function to save landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def _save_landmarks(self, *args, **kwargs):
"""
Abstract internal Function to save landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
@property
@abstractmethod
def is_gray(self):
"""
Property returning whether the image is a grayscale image
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
@property
@abstractmethod
def is_homogeneous(self):
"""
Property returning whether the landmarks are in homogeneous coordinates
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
@is_homogeneous.setter
@abstractmethod
def is_homogeneous(self, new_state):
"""
Setter to update whether the landmarks are in homogeneous coordinates
Parameters
----------
new_state : bool
the new value
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def apply_trafo(self, *args, **kwargs):
"""
Applies a given transformation to image and landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @classmethod
@abstractmethod
def from_files(cls, *args, **kwargs):
"""
Creates a class instance from files
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
"""
raise NotImplementedError
[docs] @abstractmethod
def cartesian_coordinates(self):
"""
Transforms the landmarks into cartesian coordinates
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def homogeneous_coordinates(self):
"""
Transforms the landmarks into homogeneous coordinates
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def resize(self, *args, **kwargs):
"""
Resizes image and landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def rescale(self, *args, **kwargs):
"""
Rescales image and landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def rotate(self, *args, **kwargs):
"""
Rotates image and landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def translate(self, *args, **kwargs):
"""
Translates image and landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def view(self, *args, **kwargs):
"""
Plots image and landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def normalize_rotation(self, *args, **kwargs):
"""
Rotates image and landmarks in a way, that the vector between two given
points is parallel to horizontal axis
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def _normalize_rotation(self, *args, **kwargs):
"""
Internal implementation of
:meth:`AbstractSingleImage.normalize_rotation`
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def crop(self, *args, **kwargs):
"""
Crops image and landmarks to given range
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def _crop(self, *args, **kwargs):
"""
Internal implementation of
:meth:`AbstractSingleImage.crop`
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def _crop_lmks(self, *args, **kwargs):
"""
Crops the landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def crop_to_landmarks(self, *args, **kwargs):
"""
Crops image and landmarks to bounding box specified by landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def _crop_to_landmarks(self, *args, **kwargs):
"""
Internal implementation of
:meth:`AbstractSingleImage.crop_to_landmarks`
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def get_landmark_bounds(self, *args, **kwargs):
"""
Calculates bounds of landmarks
Parameters
----------
*args :
positional arguments
**kwargs :
keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @abstractmethod
def to_grayscale(self):
"""
Converts image to grayscale
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs]class BaseSingleImage(AbstractSingleImage):
"""
Holds Single Image
"""
def __init__(self, img: np.ndarray, *args, **kwargs):
super().__init__()
self.img = img
@property
def img(self):
return self._img
@img.setter
def img(self, image):
# create image channel if necessary
if len(image.shape) < 3:
image = image.reshape(*image.shape, 1)
# ensure channels at back
if image.shape[0] == 1 or image.shape[0] == 3:
image = image.transpose((*range(1, len(image.shape)), 0))
self._img = image
[docs] def save(self, directory, filename, lmk_type="LJSON", **kwargs):
"""
Saves Image and optionally landmarks to files
Parameters
----------
directory : str
string containing the directory to save
filename : str
string containing the filename (without the extension)
lmk_type : str or None
if None: no landmarks will be saved
if str: specifies type of landmark file
**kwargs :
additional keyword arguments passed to save function for landmarks
"""
self.save_image(os.path.join(directory, filename + ".png"))
if lmk_type is not None:
self.save_landmarks(os.path.join(directory, filename), lmk_type,
**kwargs)
[docs] def save_image(self, filepath):
"""
Saves Image to file
Parameters
----------
filepath : str
file to save the image to
"""
imsave(filepath, self.img.squeeze())
[docs] def save_landmarks(self, filepath, lmk_type="LJSON", **kwargs):
"""
Saves landmarks to file
Parameters
----------
filepath : str
path to file the landmarks should be saved to
lmk_type : str
specifies the type of landmark file
**kwargs :
additional keyword arguments passed to save function
"""
self._save_landmarks(filepath, lmk_type, **kwargs)
[docs] @abstractmethod
def _save_landmarks(self, filepath, lmk_type, **kwargs):
"""
Saves landmarks to file
Parameters
----------
filepath : str
path to file the landmarks should be saved to
lmk_type : str
specifies the type of landmark file
**kwargs
additional keyword arguments passed to save function
Raises
------
NotImplementedError
If not overwritten by subclass
"""
raise NotImplementedError
@property
def is_gray(self):
return self.img.shape[-1] == 1
@property
def is_homogeneous(self):
return not self.is_cartesian
@is_homogeneous.setter
def is_homogeneous(self, homogeneous: bool):
self.is_cartesian = not homogeneous
[docs] def apply_trafo(self, transformation: AffineTransform, **kwargs):
"""
Apply transformation inplace to image and landmarks
Parameters
----------
transformation : :class:`skimage.transform.AffineTransform`
transformation to apply
**kwargs :
additional keyword arguments
Returns
-------
:class:`BaseSingleImage`
Transformed Image and Landmarks
"""
# ensure transformation to be affine
transformation = AffineTransform(transformation.params)
self._transformation_history.append(transformation)
self._transform_img(transformation, **kwargs)
self._transform_lmk(transformation)
return self
[docs] @classmethod
def from_files(cls, file, extension=None, **kwargs):
file = os.path.abspath(file)
if not extension:
# potential file are all files in same directory whose name starts
# with the files name without the extension
potential_files = [os.path.join(os.path.split(file)[0], x)
for x in os.listdir(os.path.split(file)[0])
if x.startswith(os.path.split(file)[1].rsplit(
".", 1)[0])]
if any([_file.endswith(".ljson") for _file in
potential_files]):
extension = ".ljson"
elif any([_file.endswith(".pts") for _file in
potential_files]):
extension = ".pts"
else:
extension = ".txt"
if extension == ".ljson":
return cls.from_ljson_files(file, **kwargs)
elif extension == ".pts":
return cls.from_pts_files(file, **kwargs)
else:
return cls.from_npy_files(file, **kwargs)
[docs] @classmethod
@abstractmethod
def from_npy_files(cls, file, **kwargs):
"""
Create class from image or landmark file
Parameters
----------
file : str
path to image or landmarkfile
Returns
-------
:class:`BaseSingleImage`
"""
raise NotImplementedError
[docs] @classmethod
@abstractmethod
def from_pts_files(cls, file, **kwargs):
"""
Create class from image or landmark file
Parameters
----------
file: string
path to image or landmarkfile
Returns
-------
:class:`BaseSingleImage`
"""
raise NotImplementedError
[docs] @classmethod
@abstractmethod
def from_ljson_files(cls, img_file, **kwargs):
"""
Create class from image or landmark file
Parameters
----------
file: str
path to image or landmarkfile
Returns
-------
:class:`BaseSingleImage`
"""
raise NotImplementedError
[docs] @abstractmethod
def cartesian_coordinates(self):
"""
Transforms landmark coordinates inplace to cartesian coordinates
Returns
-------
:class:`BaseSingleImage`
Image with Landmarks in cartesian Coordinates
"""
raise NotImplementedError
[docs] @abstractmethod
def homogeneous_coordinates(self):
"""
Transforms landmark coordinates inplace to homogeneous coordinates
Returns
-------
:class:`BaseSingleImage`
Image with Landmarks in Homogeneous Coordinates
"""
raise NotImplementedError
[docs] def resize(self, target_shape, **kwargs):
"""
resize image and scale landmarks
Parameters
----------
target_shape : tuple or list
target shape for resizing
**kwargs :
additional keyword arguments (passed to
:meth:`skimage.transform.warp`)
Returns
-------
:class:`BaseSingleImage`
transformed Image
"""
scale = np.asarray(target_shape) / np.asarray(self.img.shape[:-1])
scale = np.array([scale[1], scale[0]])
return self.transform(scale=scale, output_shape=target_shape, **kwargs)
[docs] def rescale(self, scale, **kwargs):
"""
Scale Image and landmarks
Parameters
----------
scale :
scale parameter
**kwargs :
additional keyword arguments (passed to
:meth:`skimage.transform.warp`)
Returns
-------
:class:`BaseSingleImage`
transformed Image
"""
target_shape = np.asarray(self.img.shape[:-1]) * np.asarray(scale)
return self.transform(scale=scale, output_shape=target_shape, **kwargs)
[docs] def rotate(self, angle, degree=True, **kwargs):
"""
Rotates the image and landmarks by given angle
Parameters
----------
angle : float or int
rotation angle
degree : bool
whether the angle is given in degree or radiant
**kwargs :
additional keyword arguments (passed to
:meth:`skimage.transform.warp`)
Returns
-------
:class:`BaseSingleImage`
transformed Image
"""
if degree:
angle = np.deg2rad(angle)
return self.transform_about_centre(rotation=angle, **kwargs)
[docs] def translate(self, translation, relative=False, **kwargs):
"""
translates image and landmarks
Parameters
----------
translation :
translation parameters
relative : bool
whether translation parameters are relative to image size
**kwargs :
additional keyword arguments (passed to
:meth:`skimage.transform.warp`)
Returns
-------
:class:`BaseSingleImage`
transformed Image
"""
if relative:
translation = translation * self.img.shape[:-1]
return self.transform(translation=translation, **kwargs)
[docs] @abstractmethod
def view(self, *args, **kwargs):
raise NotImplementedError
[docs] @abstractmethod
def normalize_rotation(self, *args, **kwargs):
raise NotImplementedError
[docs] def _normalize_rotation(self, lmks, index_left, index_right, **kwargs):
"""
normalizes rotation based on two keypoints
Parameters
----------
lmks : np.ndarray
landmarks for rotation normalization
index_left : int
index for left point
index_right : int
index for right point
**kwargs :
additional keyword arguments (passed to
:meth:`skimage.transform.warp`)
Returns
-------
:class:`BaseSingleImage`
transformed Image
"""
left = lmks[index_left]
right = lmks[index_right]
def get_angle(v0, v1, v2, degree=False):
"""
Calculate the angle between v1 and v2 with v0 as anchor point
Parameters
----------
v0 : np.array
Anchor point
v1 : np.array
First vector
v2 : np.array
Second Vector
degree : bool
if True: returns angle in degree, else in rad
Returns
-------
angle
"""
a1 = v0 - v1
a2 = v0 - v2
cosine_angle = np.dot(a1, a2) / (np.linalg.norm(a1) *
np.linalg.norm(a2))
angle = np.arccos(cosine_angle)
if degree:
angle = np.rad2deg(angle)
return angle
diff = left - right
middle = right + diff / 2
length_middle_left = np.sqrt(((left - middle) ** 2).sum())
left_optim = deepcopy(middle)
left_optim[-1] += length_middle_left
rot_angle = get_angle(middle, left_optim, left, degree=False)
return self.transform_about_centre(rotation=rot_angle, **kwargs)
[docs] def crop(self, min_y, min_x, max_y, max_x):
"""
Crops Image by specified values
Parameters
----------
min_y : int
minimum y value
min_x : int
minimum x value
max_y : int
maximum y value
max_x : int
maximum x value
Returns
-------
:class:`BaseSingleImage`
cropped image
"""
# ensure cropping values are withing the image bounds
# else set cropping val to image bound
min_y, min_x = max(0, min_y), max(0, min_x)
max_y, max_x = min(self.img.shape[0], max_y), \
min(self.img.shape[1], max_x)
return deepcopy(self)._crop(int(np.floor(min_y)), int(np.floor(min_x)),
int(np.ceil(max_y)),
int(np.ceil(max_x)))
[docs] @abstractmethod
def _crop(self, min_y, min_x, max_y, max_x):
"""
Implements actual cropping inplace
Parameters
----------
min_y : int
minimum y value
min_x : int
minimum x value
max_y : int
maximum y value
max_x : int
maximum x value
Raises
-------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @staticmethod
def _crop_lmks(lmks, min_y, min_x, max_y, max_x):
"""
Crops landmarks to given values
Parameters
----------
lmks : np.ndarray
landmarks to crop
min_y : int
minimum y value
min_x : int
minimum x value
max_y : int
maximum y value
max_x : int
maximum x value
Returns
-------
np.ndarray
cropped landmarks
"""
# lmk_mask_y = (lmks[:, 0] >= min_y) & (lmks[:, 0] <= max_y)
# lmk_mask_x = (lmks[:, 1] >= min_x) & (lmks[:, 1] <= max_x)
#
# lmk_mask = lmk_mask_x & lmk_mask_y
#
# return lmks[lmk_mask] - np.array((min_y, min_x))
return lmks - np.array((min_y, min_x))
[docs] def crop_to_landmarks(self, proportion=0., **kwargs):
"""
Crop image to landmarks
Parameters
----------
proportion : float
image proportion to add to size of bounding box
**kwargs :
additional keyword arguments
Returns
-------
:class:`BaseSingleImage`
cropped image
"""
return self._crop_to_landmarks(proportion, **kwargs)
[docs] @abstractmethod
def _crop_to_landmarks(self, proportion=0., **kwargs):
"""
Crop to landmarks inplace
Parameters
----------
proportion : float
boundary proportion of cropping
**kwargs :
additional keyword arguments
Raises
------
NotImplementedError
if not overwritten by subclass
"""
raise NotImplementedError
[docs] @staticmethod
def get_landmark_bounds(lmks):
"""
Function to calculate the landmark bounds
Parameters
----------
lmks : np.ndarray
landmarks
Returns
-------
int: min_y
int: min_x
int: max_y
int: max_x
"""
min_y = lmks[:, 0].min()
max_y = lmks[:, 0].max()
min_x = lmks[:, 1].min()
max_x = lmks[:, 1].max()
return min_y, min_x, max_y, max_x
[docs] def to_grayscale(self):
"""
Convert Image to grayscale
Returns
-------
:class:`BaseSingleImage`
Grayscale Image
"""
new_instance = deepcopy(self)
if not new_instance.is_gray:
new_instance.img = rgb2gray(self.img).reshape(
*self.img.shape[:-1], 1)
return new_instance