From bafd48455124fee92cc74ef2ff8cc71f65b48d65 Mon Sep 17 00:00:00 2001 From: "Daniel.Frisinghelli" <daniel.frisinghelli@eurac.edu> Date: Wed, 19 Aug 2020 11:21:20 +0200 Subject: [PATCH] Added docstrings: part 2 --- pysegcnn/core/layers.py | 3 +- pysegcnn/core/logging.py | 7 +- pysegcnn/core/models.py | 2 +- pysegcnn/core/predict.py | 2 +- pysegcnn/core/transforms.py | 255 +++++++++++++++++++++++++++++++++-- pysegcnn/core/utils.py | 260 +++++++++++++++++++++++++++++++----- 6 files changed, 475 insertions(+), 54 deletions(-) diff --git a/pysegcnn/core/layers.py b/pysegcnn/core/layers.py index ed72b36..367bd72 100644 --- a/pysegcnn/core/layers.py +++ b/pysegcnn/core/layers.py @@ -47,7 +47,8 @@ class Conv2dSame(nn.Conv2d): self.padding = (y_pad, x_pad) - def same_padding(self, d, k): + @staticmethod + def same_padding(d, k): """Calculate the amount of padding. Parameters diff --git a/pysegcnn/core/logging.py b/pysegcnn/core/logging.py index ab9e598..283b6ca 100644 --- a/pysegcnn/core/logging.py +++ b/pysegcnn/core/logging.py @@ -1,9 +1,8 @@ +"""Logging configuration.""" + +# !/usr/bin/env python # -*- coding: utf-8 -*- -""" -Created on Fri Aug 14 10:07:12 2020 -@author: Daniel -""" # builtins import pathlib diff --git a/pysegcnn/core/models.py b/pysegcnn/core/models.py index d0aff8d..52400d7 100644 --- a/pysegcnn/core/models.py +++ b/pysegcnn/core/models.py @@ -1,4 +1,4 @@ -"""A collection of neural networks for semantic image segmentation.""" +"""Neural networks for semantic image segmentation.""" # !/usr/bin/env python # -*- coding: utf-8 -*- diff --git a/pysegcnn/core/predict.py b/pysegcnn/core/predict.py index ae282ef..17dffb6 100644 --- a/pysegcnn/core/predict.py +++ b/pysegcnn/core/predict.py @@ -1,4 +1,4 @@ -"""A collection of functions for model inference.""" +"""Functions for model inference.""" # !/usr/bin/env python # -*- coding: utf-8 -*- diff --git a/pysegcnn/core/transforms.py b/pysegcnn/core/transforms.py index 2ff7aa7..77fcad1 100644 --- a/pysegcnn/core/transforms.py +++ b/pysegcnn/core/transforms.py @@ -1,33 +1,60 @@ -# -*- coding: utf-8 -*- -""" -Created on Fri Jul 17 15:28:18 2020 +"""Data augmentation. -@author: Daniel +Image transformations to artificially increase a dataset. """ +# !/usr/bin/env python +# -*- coding: utf-8 -*- + # externals import numpy as np from scipy import ndimage class Transform(object): + """Base class for an image transformation.""" def __init__(self): pass def __call__(self, image): + """Apply transformation. + + Parameters + ---------- + image : `numpy.ndarray` + The image to transform. + + Raises + ------ + NotImplementedError + Raised if `~pysegcnn.core.transforms.Transform` is not inherited. + + Returns + ------- + None. + + """ raise NotImplementedError class VariantTransform(Transform): + """Base class for a spatially variant transformation. + + Transformation on the ground truth required. + """ def __init__(self): - # requires transformation on the ground truth + # transformation on the ground truth required self.invariant = False class InvariantTransform(Transform): + """Base class for a spatially invariant transformation. + + Transformation on the ground truth not required. + """ def __init__(self): @@ -36,6 +63,18 @@ class InvariantTransform(Transform): class FlipLr(VariantTransform): + """Flip an image horizontally. + + Parameters + ---------- + p : `float`, optional + The probability to apply the transformation. The default is 0.5. + + Returns + ------- + None. + + """ def __init__(self, p=0.5): super().__init__() @@ -43,6 +82,19 @@ class FlipLr(VariantTransform): self.p = p def __call__(self, image): + """Apply transformation. + + Parameters + ---------- + image : `numpy.ndarray` + The image to transform. + + Returns + ------- + transform : `numpy.ndarray` + The transformed image. + + """ if np.random.random(1) < self.p: # transformation applied self.applied = True @@ -53,10 +105,30 @@ class FlipLr(VariantTransform): return np.asarray(image) def __repr__(self): + """Representation of the transformation. + + Returns + ------- + repr : `str` + Representation string. + + """ return self.__class__.__name__ + '(p = {})'.format(self.p) class FlipUd(VariantTransform): + """Flip an image vertically. + + Parameters + ---------- + p : `float`, optional + The probability to apply the transformation. The default is 0.5. + + Returns + ------- + None. + + """ def __init__(self, p=0.5): super().__init__() @@ -64,6 +136,19 @@ class FlipUd(VariantTransform): self.p = p def __call__(self, image): + """Apply transformation. + + Parameters + ---------- + image : `numpy.ndarray` + The image to transform. + + Returns + ------- + transform : `numpy.ndarray` + The transformed image. + + """ if np.random.random(1) < self.p: # transformation applied self.applied = True @@ -74,10 +159,36 @@ class FlipUd(VariantTransform): return np.asarray(image) def __repr__(self): + """Representation of the transformation. + + Returns + ------- + repr : `str` + Representation string. + + """ return self.__class__.__name__ + '(p = {})'.format(self.p) class Rotate(VariantTransform): + """Rotate an image by ``angle``. + + The image is rotated in the spatial plane. + + If the input array has more then two dimensions, the spatial dimensions are + assumed to be the last two dimensions of the array. + + Parameters + ---------- + angle : `float` + The rotation angle in degrees. + p : `float`, optional + The probability to apply the transformation. The default is 0.5. + + Returns + ------- + None. + """ def __init__(self, angle, p=0.5): super().__init__() @@ -89,7 +200,18 @@ class Rotate(VariantTransform): self.p = p def __call__(self, image): - + """Apply transformation. + + Parameters + ---------- + image : `numpy.ndarray` + The image to transform. + + Returns + ------- + transform : `numpy.ndarray` + The transformed image. + """ if np.random.random(1) < self.p: # transformation applied @@ -111,19 +233,63 @@ class Rotate(VariantTransform): return np.asarray(image) def __repr__(self): + """Representation of the transformation. + + Returns + ------- + repr : `str` + Representation string. + + """ return self.__class__.__name__ + '(angle = {}, p = {})'.format( self.angle, self.p) class Noise(InvariantTransform): - - def __init__(self, mode, mean=0, var=0.05, p=0.5, exclude=0): + """Add gaussian noise to an image. + + Valid modes are: + + 'add': image = image + noise + 'speckle' : image = image + image * noise + + Parameters + ---------- + mode : `str` + The mode to add the noise. + mean : `float`, optional + The mean of the gaussian distribution from which the noise is sampled. + The default is 0. + var : `float`, optional + The variance of the gaussian distribution from which the noise is + sampled. The default is 0.05. + p : `float`, optional + The probability to apply the transformation. The default is 0.5. + exclude : `list` [`float`] or `list` [`int`], optional + Values for which the noise is not added. Useful for pixels resulting + from image padding. The default is []. + + Raises + ------ + ValueError + Raised if ``mode`` is not supported. + + Returns + ------- + None. + + """ + + # supported modes + modes = ['add', 'speckle'] + + def __init__(self, mode, mean=0, var=0.05, p=0.5, exclude=[]): super().__init__() # check which kind of noise to apply - modes = ['gaussian', 'speckle'] - if mode not in modes: - raise ValueError('Supported noise types are: {}.'.format(modes)) + if mode not in self.modes: + raise ValueError('Supported noise types are: {}.' + .format(self.modes)) self.mode = mode # mean and variance of the gaussian distribution the noise signal is @@ -138,7 +304,19 @@ class Noise(InvariantTransform): self.exclude = exclude def __call__(self, image): + """Apply transformation. + Parameters + ---------- + image : `numpy.ndarray` + The image to transform. + + Returns + ------- + transform : `numpy.ndarray` + The transformed image + + """ if np.random.random(1) < self.p: # transformation applied @@ -148,7 +326,8 @@ class Noise(InvariantTransform): noise = np.random.normal(self.mean, self.var, image.shape) # check which values should not be modified by adding noise - noise[image == self.exclude] = 0 + for val in self.exclude: + noise[image == val] = 0 if self.mode == 'gaussian': return (np.asarray(image) + noise).clip(0, 1) @@ -162,6 +341,14 @@ class Noise(InvariantTransform): return np.asarray(image) def __repr__(self): + """Representation of the transformation. + + Returns + ------- + repr : `str` + Representation string. + + """ return self.__class__.__name__ + ('(mode = {}, mean = {}, var = {}, ' 'p = {})' .format(self.mode, self.mean, @@ -169,13 +356,47 @@ class Noise(InvariantTransform): class Augment(object): + """Apply a sequence of transformations. + + Container class applying each transformation in ``transforms`` in order. + + Parameters + ---------- + transforms : `list` or `tuple` + A sequence of instances of `pysegcnn.core.transforms.VariantTransform` + or `pysegcnn.core.transforms.InvariantTransform`. + + Returns + ------- + None. + + """ def __init__(self, transforms): assert isinstance(transforms, (list, tuple)) self.transforms = transforms def __call__(self, image, gt): - + """Apply a sequence of transformations to ``image``. + + For each spatially variant transformation, the ground truth ``gt`` is + transformed respectively. + + Parameters + ---------- + image : `numpy.ndarray` + The input image. + gt : `numpy.ndarray` + The corresponding ground truth of ``image``. + + Returns + ------- + image : `numpy.ndarray` + The transformed image. + gt : `numpy.ndarray` + The transformed ground truth. + + """ # apply transformations to the input image in specified order for t in self.transforms: image = t(image) @@ -199,6 +420,14 @@ class Augment(object): return image, gt def __repr__(self): + """Representation of the container. + + Returns + ------- + repr : `str` + Representation string. + + """ fstring = self.__class__.__name__ + '(' for t in self.transforms: fstring += '\n' diff --git a/pysegcnn/core/utils.py b/pysegcnn/core/utils.py index c2c1d5d..4d09c80 100644 --- a/pysegcnn/core/utils.py +++ b/pysegcnn/core/utils.py @@ -1,9 +1,8 @@ +"""Utility functions mainly for image IO and reshaping.""" + +# !/usr/bin/env python # -*- coding: utf-8 -*- -""" -Created on Tue Jul 14 15:02:23 2020 -@author: Daniel -""" # builtins import os import re @@ -15,16 +14,83 @@ import gdal import torch import numpy as np -# the following functions are utility functions for common image -# manipulation operations - # module level logger LOGGER = logging.getLogger(__name__) -# this function reads an image to a numpy array -def img2np(path, tile_size=None, tile=None, pad=False, cval=0, verbose=False): +def img2np(path, tile_size=None, tile=None, pad=False, cval=0): + """Read an image to a `numpy.ndarray`. + + If ``tile_size`` is not `None`, the input image is divided into square + tiles of size (``tile_size``, ``tile_size``). If the image is not evenly + divisible and ``pad`` = False, a `ValueError` is raised. However, if + ``pad`` = True, center padding with constant value ``cval`` is applied. + + The tiling works as follows: + + (Padded) Input image: + + ------------------------------------------------ + | | | | | + | tile_00 | tile_01 | ... | tile_0n | + | | | | | + |----------------------------------------------| + | | | | | + | tile_10 | tile_11 | ... | tile_1n | + | | | | | + |----------------------------------------------| + | | | | | + | ... | ... | ... | ... | + | | | | | + |----------------------------------------------| + | | | | | + | tile_m0 | tile_m1 | ... | tile_mn | + | | | | | + ------------------------------------------------ + + where m = n. Each tile has its id, which starts at 0 in the topleft corner + of the input image, i.e. tile_00 has id=0, and increases along the width + axis, i.e. tile_0n has id=n, tile_10 has id=n+1, ..., tile_mn has + id=(m * n) - 1. + + If ``tile`` is an integer, only the tile with id = ``tile`` is returned. + + Parameters + ---------- + path : `str` or `None` or `numpy.ndarray` + The image to read. + tile_size : `None` or `int`, optional + The size of a tile. The default is None. + tile : `int`, optional + The tile id. The default is None. + pad : `bool`, optional + Whether to center pad the input image. The default is False. + cval : `float`, optional + The constant padding value. The default is 0. + + Raises + ------ + FileNotFoundError + Raised if ``path`` is a path that does not exist. + TypeError + Raised if ``path`` is not `str` or `None` or `numpy.ndarray`. + + Returns + ------- + image : `numpy.ndarray` + The image array. The output shape is: + + if ``tile_size`` is not `None`: + shape=(tiles, bands, tile_size, tile_size) + if the image does only have one band: + shape=(tiles, tile_size, tile_size) + else: + shape=(bands, height, width) + if the image does only have one band: + shape=(height, width) + + """ # check the type of path if isinstance(path, str): if not os.path.exists(path): @@ -51,7 +117,7 @@ def img2np(path, tile_size=None, tile=None, pad=False, cval=0, verbose=False): width = img.shape[2] else: - raise ValueError('Input of type {} not supported'.format(type(img))) + raise TypeError('Input of type {} not supported'.format(type(img))) # check whether to read the image in tiles if tile_size is None: @@ -157,11 +223,31 @@ def img2np(path, tile_size=None, tile=None, pad=False, cval=0, verbose=False): return image -# this function checks whether an image is evenly divisible -# in square tiles of defined size tile_size -# if pad=True, a padding is returned to increase the image to the nearest size -# evenly fitting ntiles of size (tile_size, tile_size) def is_divisible(img_size, tile_size, pad=False): + """Check whether an image is evenly divisible into square tiles. + + Parameters + ---------- + img_size : `tuple` + The image size (height, width). + tile_size : `int` + The size of the tile. + pad : `bool`, optional + Whether to center pad the input image. The default is False. + + Raises + ------ + ValueError + Raised if the image is not evenly divisible and ``pad`` = False. + + Returns + ------- + ntiles : `int` + The number of tiles fitting ``img_size``. + padding : `tuple` + The amount of padding (bottom, left, top, right). + + """ # calculate number of pixels per tile pixels_per_tile = tile_size ** 2 @@ -171,7 +257,7 @@ def is_divisible(img_size, tile_size, pad=False): # if it is evenly divisible, no padding is required if ntiles.is_integer(): - pad = 4 * (0,) + padding = 4 * (0,) if not ntiles.is_integer() and not pad: raise ValueError('Image of size {} not evenly divisible in ({}, {}) ' @@ -193,30 +279,46 @@ def is_divisible(img_size, tile_size, pad=False): # in case both offsets are even, the padding is symmetric on both the # bottom/top and left/right if not dh % 2 and not dw % 2: - pad = (dh // 2, dw // 2, dh // 2, dw // 2) + padding = (dh // 2, dw // 2, dh // 2, dw // 2) # in case only one offset is even, the padding is symmetric along the # even offset and asymmetric along the odd offset if not dh % 2 and dw % 2: - pad = (dh // 2, dw // 2, dh // 2, dw // 2 + 1) + padding = (dh // 2, dw // 2, dh // 2, dw // 2 + 1) if dh % 2 and not dw % 2: - pad = (dh // 2, dw // 2, dh // 2 + 1, dw // 2) + padding = (dh // 2, dw // 2, dh // 2 + 1, dw // 2) # in case of offsets are odd, the padding is asymmetric on both the # bottom/top and left/right if dh % 2 and dw % 2: - pad = (dh // 2, dw // 2, dh // 2 + 1, dw // 2 + 1) + padding = (dh // 2, dw // 2, dh // 2 + 1, dw // 2 + 1) # calculate number of tiles on padded image ntiles = (h_new * w_new) / (tile_size ** 2) - return int(ntiles), pad + return int(ntiles), padding -# check whether a tile of size (tile_size, tile_size) with topleft corner at -# topleft exists in an image of size img_size def check_tile_extend(img_size, topleft, tile_size): + """Check if a tile exceeds the image size. + + Parameters + ---------- + img_size : `tuple` + The image size (height, width). + topleft : `tuple` + The topleft corner of the tile (y, x). + tile_size : `int` + The size of the tile. + + Returns + ------- + nrows : `int` + Number of rows of the tile within the image. + ncols : TYPE + Number of columns of the tile within the image. + """ # check if the tile is within both the rows and the columns of the image if (topleft[0] + tile_size < img_size[0] and topleft[1] + tile_size < img_size[1]): @@ -246,11 +348,24 @@ def check_tile_extend(img_size, topleft, tile_size): return nrows, ncols -# this function returns the top-left corners for each tile -# if the image is evenly divisible in square tiles of -# defined size tile_size + def tile_topleft_corner(img_size, tile_size): + """Return the topleft corners of the tiles in the image. + + Parameters + ---------- + img_size : `tuple` + The image size (height, width). + tile_size : `int` + The size of the tile. + + Returns + ------- + indices : `dict` + The keys of ``indices`` are the tile ids (`int`) and the values are the + topleft corners (`tuple` = (y, x)) of the tiles. + """ # check if the image is divisible into square tiles of size # (tile_size, tile_size) _, _ = is_divisible(img_size, tile_size, pad=False) @@ -273,7 +388,25 @@ def tile_topleft_corner(img_size, tile_size): def reconstruct_scene(tiles, img_size, tile_size=None, nbands=1): + """Reconstruct a tiled image. + + Parameters + ---------- + tiles : array_like + The tiled image, shape=(tiles, bands, tile_size, tile_size). + img_size : `tuple` + The size of the reconstructed image (height, width). + tile_size : `int` or `None`, optional + The size of the tile. The default is None. + nbands : `int`, optional + The number of bands of the reconstructed image. The default is 1. + + Returns + ------- + image : `numpy.ndarray` + The reconstructed image. + """ # convert to numpy array tiles = np.asarray(tiles) @@ -297,8 +430,22 @@ def reconstruct_scene(tiles, img_size, tile_size=None, nbands=1): return scene.squeeze() -# function calculating prediction accuracy def accuracy_function(outputs, labels): + """Calculate prediction accuracy. + + Parameters + ---------- + outputs : `torch.Tensor` or array_like + The model prediction. + labels : `torch.Tensor` or array_like + The ground truth. + + Returns + ------- + accuracy : `float` + Mean prediction accuracy. + + """ if isinstance(outputs, torch.Tensor): return (outputs == labels).float().mean().item() else: @@ -306,7 +453,20 @@ def accuracy_function(outputs, labels): def parse_landsat_scene(scene_id): + """Parse a Landsat scene identifier. + + Parameters + ---------- + scene_id : `str` + A Landsat scene identifier. + + Returns + ------- + scene : `dict` or `None` + A dictionary containing scene metadata. If `None`, ``scene_id`` is not + a valid Landsat scene identifier. + """ # Landsat Collection 1 naming convention in regular expression sensor = 'L[COTEM]0[1-8]_' level = 'L[0-9][A-Z][A-Z]_' @@ -383,7 +543,20 @@ def parse_landsat_scene(scene_id): def parse_sentinel2_scene(scene_id): + """Parse a Sentinel-2 scene identifier. + + Parameters + ---------- + scene_id : `str` + A Sentinel-2 scene identifier. + Returns + ------- + scene : `dict` or `None` + A dictionary containing scene metadata. If `None`, ``scene_id`` is not + a valid Sentinel-2 scene identifier. + + """ # Sentinel 2 Level-1C products naming convention after 6th December 2016 mission = 'S2[A-B]_' level = 'MSIL1C_' @@ -470,29 +643,48 @@ def parse_sentinel2_scene(scene_id): def doy2date(year, doy): - """Converts the (year, day of the year) date format to a datetime object. + """Convert the (year, day of the year) date format to a datetime object. Parameters ---------- - year : int - the year - doy : int - the day of the year + year : `int` + The year + doy : `int` + The day of the year Returns ------- - date : datetime.datetime - the converted date as datetime object + date : `datetime.datetime` + The converted date. """ - # convert year/day of year to a datetime object date = (datetime.datetime(int(year), 1, 1) + datetime.timedelta(days=(int(doy) - 1))) return date + def item_in_enum(name, enum): + """Check if an item exists in an enumeration. + + Parameters + ---------- + name : `str` + Name of the item. + enum : `enum.Enum` + An instance of `enum.Enum`. + Raises + ------ + ValueError + Raised if ``name`` is not in ``enum``. + + Returns + ------- + value + The value of ``name`` in ``enum``. + + """ # check whether the input name exists in the enumeration if name not in enum.__members__: raise ValueError('{} is not in {} enumeration. Valid names are: \n {}' -- GitLab