From c407ff33a04c6a9bac2c1345470a6e0daa06c05c Mon Sep 17 00:00:00 2001 From: Tbs_Fchnr Date: Wed, 25 Nov 2020 16:27:23 +0100 Subject: [PATCH] FINAL COMMIT - all commenting completed for submission. --- filters.py | 436 ++++++++++++++++------------------------------------- handler.py | 2 +- 2 files changed, 135 insertions(+), 303 deletions(-) diff --git a/filters.py b/filters.py index c69ced5..cc3f2a9 100644 --- a/filters.py +++ b/filters.py @@ -1,19 +1,27 @@ """ -Module to define filters and their computation algorithms. Used by handler.py. +Main module to define filters and their computation algorithms. + +These design requirements have been achieved by implementing abstract base classes (ABC) for each ‘family’ of filter +(spatial, Fourier, histogram), and several associated child classes for the filters themselves. +The functionality common to each filter within a given family has been defined in the base class and then +inherited by each child class. Any method implemented as an abstract method, in which it is not defined within +the base class but only declared, must be defined in the child class as a requirement, as is the case +with the method ‘compute’. This framework-like approach both enforces and supports the level of granularity +required to uniquely define the computation method for a given filtering technique. Digital Image Processing 2020, Assignment 1/1, 20% """ +# Import relevant packages from abc import ABC, abstractmethod import numpy as np import matplotlib.pyplot as plt -from math import ceil, floor +from math import ceil import statistics -from scipy import fftpack -from matplotlib.colors import LogNorm from tqdm import tqdm import logging +# Initialise logging used to track info and warning messages logging.basicConfig() def padImage(img, maskSize): @@ -21,28 +29,29 @@ def padImage(img, maskSize): Function pads image in two dimensions. Pad size is dependant on mask shape and therefore both pads are currently always equal since we only use square mask sizes. Added pixels have intensity zero, 0. :param maskSize: used to calculate number of pixels to be added on image - :param img: img to be padded - :return: + :param img: image to be padded + :return: padded array of pixel intensities """ - # Create padding for edges + # Calculate number of pixels required for padding pad = ceil((maskSize - 1) / 2) assert isinstance(pad, int) - # Add padding of zeros to the input image + # Add pad number of rows and columns of zeros around the sides of the input image array imgPadded = np.zeros((img.shape[0] + 2 * pad, img.shape[1] + 2 * pad)).astype('uint8') # Insert image pixel values into padded array imgPadded[pad:-pad, pad:-pad] = img + # Log success result to console logging.info("Padding of {} pixels created.".format(pad)) return imgPadded def scale(x, ceiling=255): """ - Function scales array between 0 and maximo - :param x: array of values to be scaled + Function scales n-dimensional array of values between zero to max value, cieling + :param x: n-dimensional array of values to be scaled :param ceiling: max values/ top of scale :return: scaled array """ @@ -59,11 +68,22 @@ def scale(x, ceiling=255): raise Exception("Can't scale as min and max are the same and will cause div(0) error but not " "all values are the same in array. Printing array... ", x) + # Return array with values scaled between zero and max return ceiling * (x - x.min()) / (x.max() - x.min()) class SpatialFilter(ABC): - # TODO: implement standard mask shapes of square or cross and implement kernel creation based on this for each filter + """ + Base class for all spatial filters, inherited by any spatial filter and containing all methods and attributes + common to operation of all spatial filters. + """ def __init__(self, maskSize, kernel, name, linearity): + """ + Object initialisation override used to assign parameters passed on creation of new class instance to object attributes. + :param maskSize: mask size used to scan over pixels during convolution to detect surrounding pixel intensities (aka window size) + :param kernel: kernel of weights used to multiply with pixel intensities to calculate pixel update value + :param name: meta information - name of filter + :param linearity: meta information - linearity of filter + """ self.assertTypes(maskSize, kernel) self.name = name self.linearity = linearity @@ -74,131 +94,88 @@ def __str__(self): """ Override built-in str function so a description of the filter is shown when you run print(filter), where filter is an instance of class Filter. - :return: string describing filter instance. + :return: generated string describing filter instance/ object state """ + # Combine various object attributes into descriptive string to be displayed descriptor = "Filter name: {},\nLinearity: {},\nMask size: {}\nKernel shown below where possible:".format( self.name, self.linearity, self.maskSize ) + # Generate plot of kernel weights, used to visualise kernel weight distribution plt.imshow(self.kernel, interpolation='none') return descriptor @staticmethod def assertTypes(maskSize, kernel): + """ + Static method used for basic type checking during filtering computation. + :param maskSize: filter window/ mask size + :param kernel: filter kernel of weights + :return: None + """ assert isinstance(maskSize, int) # Mask size must be integer assert maskSize % 2 == 1 # Mask size must be odd assert isinstance(kernel, np.ndarray) # kernel should be n-dimensional numpy array @abstractmethod def compute(self, sub): + """ + Abstract method declared here in base class and later defined in child classes that must as a rule inherit this method. + This is the krux of the ABC design approach - each filter will and must uniquely implement its own computation method to + calculate the pixel update value based on its intended filtering function. + :param sub: the sub matrix/ window of pixel values generated from convolution of the window with image + :return: pixel update value + """ pass def convolve(self, img, padding=True): """ - This function which takes an image and a kernel and returns the convolution of them. - :param padding: bool defines if padding is used - :param img: numpy array of image to be filtered - :return: numpy array of filtered image (image convoluted with kernel) + Convolution of filter object's kernel over the image recieved as a parameter to this function. + :param padding: boolean used to configure the addition of zero-padding to image. + :param img: n-dimensional numpy array of original image pixel values that will each be updates during filtering i.e the original image data + :return: numpy array of dimension equal to original image array with updated pixel values i.e. the filtered image data """ + # If padding required, create padding, else original image stored as padded image if padding: imgPadded = padImage(img, self.maskSize) else: imgPadded = img - print("No padding added.") + logging.warning("No padding added. This may mean the first/ last pixels of each row may not be filtered.") # Flip the kernel up/down and left/right self.kernel = np.flipud(np.fliplr(self.kernel)) - # Create output array of zeros with same shape and type as img array + # Create output array of zeros with same shape and type as original image data output = np.zeros_like(img) - # Loop over every pixel of padded image + # Iterate over every column in that row for col in tqdm(range(img.shape[1])): + + # Iterate over every row in the image for row in range(img.shape[0]): + # Create sub matrix of mask size surrounding pixel under consideration sub = imgPadded[row: row+self.maskSize, col: col+self.maskSize] + + # Store the updated pixel intensity (returned from the filter's own computation method) in the filtered image array output[row, col] = self.compute(sub) return output -class FourierFilter: - def fft2D_scipy(self, img, plot=False): - """ - Function transforms image into Fourier domain - :param plot: bool to configure plotting of fourier spectum. default=False - :param img: image to be transformed - :return: image in fourier domain/ fourier spectrum of image - """ - imgFFT = fftpack.fft2(img) - if plot: self.plotFourierSpectrum(imgFFT) - return imgFFT - - @staticmethod - def dft(x): # not in use - """ - Function computes the discrete fourier transform of a 1D array - :param x: input array, 1 dimensional - :return: np.array of fourier transformed input array - """ - x = np.asarray(x, dtype=float) - N = x.shape[0] - n = np.arange(N) - k = n.reshape((N, 1)) - M = np.exp((-2j * np.pi * k * n) / N) - return np.dot(M, x) - - def fft(self, x): - """ - Function recursively implements 1D Cooley-Turkey fast fourier transform - :param x: input array, 1 dimensional - :return: np.array of fourier transformed input array - """ - x = np.array(x, dtype=float) - N = x.shape[0] - - if N % 2 > 0: - raise ValueError("size of x must be a power of 2") - elif N <= 32: - return self.dft(x) - else: - X_even = self.fft(x[::2]) - X_odd = self.fft(x[1::2]) - factor = np.exp((-2j * np.pi * np.arange(N)) /N) - return np.concatenate([X_even + factor[:N / 2] * X_odd, - X_even + factor[N / 2:] * X_odd ]) - - def fft2D(self, x): - """ - Function recursively implements 1D Cooley-Turkey fast fourier transform - :param x: input array, 1 dimensional - :return: np.array of fourier transformed input array - """ - x = np.array(x, dtype=float) - xRot = x.T - - self.fft(x) - - @staticmethod - def inverseFFT_scipy(img): - return fftpack.ifft2(img).real - - @staticmethod - def plotFourierSpectrum(imgFFT): - """ - Function displays fourier spectrum of image that has been fourier transformed - :param imgFFT: fourier spectrum of img - :return: None - """ - plt.figure() - plt.imshow(np.abs(imgFFT), norm=LogNorm(vmin=5)) - plt.colorbar() - plt.title('Fourier Spectrum') - class HistogramFilter(ABC): + """ + Base class for all histogram filters, inherited by any histogram filter and containing all methods and attributes + common to operation of all histogram filters. + """ def __init__(self, maskSize, name): + """ + Object initialisation override used to assign parameters passed on creation of new class instance to object attributes. + :param maskSize: mask size used to scan over pixels during convolution to detect surrounding pixel intensities (aka window size) + :param name: meta information - name of filter + """ assert isinstance(maskSize, int) # Mask size must be integer try: assert maskSize % 2 == 1 # Mask size must be odd @@ -212,10 +189,11 @@ def __init__(self, maskSize, name): def getHistogramWithCS(self, img): """ - Function takes in image as array of pixel intensities and generates a histogram and scaled cumulative sum + Function takes in image as an n-dimensional array of pixel intensities and generates a histogram and scaled cumulative sum :param img: numpy array of pixel intensities :return: histogram array and scaled cumulative sum of histogram """ + # Catch errors for wrong data type, allowing for one exception by casting to integer on first exception try: assert img.dtype == 'uint8' except AssertionError: @@ -236,14 +214,27 @@ def getHistogramWithCS(self, img): return histogram.astype('uint8'), csScaled def filter(self, img, plotHistograms=True): + """ + Primary access point from external code for any histogram filter. Equivalent to convolve for Spatial filters. + Function computes and returns filtered image. + :param img: original image data + :param plotHistograms: boolean used to configure if a plot of original and updated histograms should be displayed to + Jupyter notebook or not. + :return: filtered image + """ + # Call computation method unique to each filter implementation. imgFiltered, histogram, cs = self.compute(img) + # Plot histograms if required if plotHistograms: + # Generate histogram and cumulative sum for filtered image histogramNew, csNew = self.getHistogramWithCS(imgFiltered) + # Plot histograms for display in notebook self.plotHistograms(histogram, histogramNew, cs, csNew) else: pass + # Return filtered image return imgFiltered @staticmethod @@ -267,13 +258,13 @@ def plotHistograms(histogram, histogramNew, cs, csNew): """ Function plots overlaying histograms with cumulative sum to show change between original and filtered histogram. If no filtered histogram present, second series will be skipped. - :param csNew: - :param cs: cumulative sum of histogram - :param histogramNew: histogram after filtering technique + :param csNew: cumulative sum of filtered image histogram values + :param cs: cumulative sum of original image histogram values + :param histogramNew: histogram after filter has been applied :param histogram: histogram of original image :return: None """ - # Set up figure + # Set dimensions of figure fig = plt.figure() fig.set_figheight(5) fig.set_figwidth(15) @@ -285,23 +276,33 @@ def plotHistograms(histogram, histogramNew, cs, csNew): plt.fill_between(np.arange(np.size(histogramNew)), scale(histogramNew), label='filtered_hist', alpha=0.4) plt.plot(csNew, label='filtered_cs') except ValueError: - print("Only one histogram to plot.") + logging.info("Only one histogram to plot.") pass + # Add legend and show plot of histograms plt.legend() plt.show() @abstractmethod def compute(self, img): + """ + Abstract method declared here in base class and later defined in child classes that must as a rule inherit this method. + This is the krux of the ABC design approach - each filter will and must uniquely implement its own computation method to + calculate the pixel update value based on its intended filtering function. + :param img: the n-dimensional array of pixel values that represent the original image data + :return: pixel update value + """ pass class Median(SpatialFilter): def __init__(self, maskSize): - # arbitrary kernel weights assigned since kernel is not used + # Arbitrary kernel weights assigned since kernel is not used super().__init__(maskSize, np.zeros((maskSize,maskSize)), name='median', linearity='non-linear') def compute(self, sub): + # Python's statistics library is used to compute the statistical median of + # the flattened pixel array return statistics.median(sub.flatten()) class AdaptiveWeightedMedian(SpatialFilter): @@ -332,7 +333,7 @@ def compute(self, sub): else: pass - # create matrix of weights based on sub matrix, using formula for adaptive weighted median filter + # Create matrix of weights based on sub matrix, using formula for adaptive weighted median filter weights = self.centralWeight - self.constant*std*np.divide(self.kernel, mean) # Identify any negative weights in boolean array @@ -340,16 +341,16 @@ def compute(self, sub): # Use as inverse mask truncate negative weights to zero to ensure low pass characteristics weights = np.multiply(np.invert(mask), weights) - # use list comprehension to pair each element from sub matrix with respective weighting in tuple + # Use list comprehension to pair each element from sub matrix with respective weighting in tuple # and sort based on sub matrix values/ pixel intensities pairings = sorted((pixelIntensity, weight) for pixelIntensity, weight in zip(sub.flatten(), weights.flatten())) - # calculate where median position will be + # Calculate where median position will be medIndex = ceil((np.sum(weights) + 1)/ 2) cs = np.cumsum([pair[1] for pair in pairings]) medPairIndex = np.searchsorted(cs, medIndex) - # return median of list of weighted sub matrix values + # Return median of list of weighted sub matrix values return pairings[medPairIndex][0] class Mean(SpatialFilter): @@ -357,7 +358,11 @@ class Mean(SpatialFilter): Effectively a blurring filter. Alternative kernel implemented in class LowPass(Filter). """ def __init__(self, maskSize): + + # Kernel weights defined as one over the number of weights, thus summing to one kernel = np.ones((maskSize,maskSize))/(maskSize**2) + + # Ensure sum of mean kernel weights is essentially 1 try: assert kernel.sum() == 1 except AssertionError: @@ -365,18 +370,23 @@ def __init__(self, maskSize): pass else: raise Exception("Sum of kernel weights for mean filter should equal 1. They equal {}!".format(kernel.sum())) + super().__init__(maskSize, kernel, name='mean', linearity='linear') def compute(self, sub): # element-wise multiplication of the kernel and image pixel under consideration - return (self.kernel * sub).sum() + return np.sum(np.multiply(self.kernel, sub)) class TrimmedMean(SpatialFilter): """ Can be used to discard a number of outliers from the higher and lower ends of the retrieved sub matrix of pixel values. """ def __init__(self, maskSize, trimStart=1, trimEnd=1): + + # Same as the mean filter, kernel weights defined as one over the number of weights, thus summing to one kernel = np.ones((maskSize,maskSize))/(maskSize**2) + + # Ensure sum of weights equals one try: assert kernel.sum() == 1 except AssertionError: @@ -391,7 +401,12 @@ def __init__(self, maskSize, trimStart=1, trimEnd=1): super().__init__(maskSize, kernel, name='trimmed-mean', linearity='linear') def compute(self, sub): + + # Flatten sub matrix trimmedSub = list(sub.flatten()) + + # Index a specified number of elements from either end of the flattened array + # Return mean of this selection of elements return np.mean(trimmedSub[self.trimStart:-self.trimStart]) class Gaussian(SpatialFilter): @@ -399,7 +414,7 @@ def __init__(self, sig): # Calculate mask size from sigma value. Ensures filter approaches zero at edges (always round up) maskSize = ceil((6 * sig) + 1) - # TODO: implement mask size override? or scaling down of kernel values + # Ensure mask size is odd if maskSize % 2 == 0: maskSize += 1 else: @@ -424,7 +439,7 @@ def compute(self, sub): :param sub: sub matrix of image pixel under consideration and surrounding pixels within mask size. :return: product of sub matrix with kernel normalised by sum of kernel weights """ - return (self.kernel * sub).sum()/ self.kernel.sum() + return np.sum(np.multiply(self.kernel, sub))/ self.kernel.sum() class Sharpening(SpatialFilter): """ @@ -445,11 +460,14 @@ def __init__(self, maskSize): super().__init__(maskSize, kernel, name='high-pass', linearity='linear') def compute(self, sub): + + # Ensure sum of kernel weights is effectively zero try: assert -0.01 < np.sum(self.kernel) < 0.01 except AssertionError: raise Exception("Sum of high pass filter weights should be effectively 0.") + # Perform element-wise multiplication of kernel and window contents, then sum return np.sum(np.multiply(self.kernel, sub)) class LowPass(SpatialFilter): @@ -466,28 +484,6 @@ def __init__(self, maskSize, middleWeight=1/2, otherWeights=1/8): def compute(self, sub): return (self.kernel * sub).sum()/ self.kernel.sum() -class TruncateCoefficients(FourierFilter): - def __init__(self, keep=0.1): - self.keep = keep - - def compute(self, img, plot=False): - # Get fourier transform of image - imgFFT = self.fft2D_scipy(img, plot=plot) - - # Call ff a copy of original transform - imgFFT2 = imgFFT.copy() - - # Get shape of image: rows and columns - row, col = imgFFT2.shape - - # Set all rows and cols to zero not within the keep fraction - imgFFT2[ceil(row*self.keep):floor(row*(1-self.keep)), :] = 0 - imgFFT2[:, ceil(col*self.keep):floor(col*(1-self.keep))] = 0 - - if plot: self.plotFourierSpectrum(imgFFT2) - - return self.inverseFFT_scipy(imgFFT2) - class Equalise(HistogramFilter): """ This filter normalises the brightness whilst increasing the contrast of the image at the same time. @@ -496,10 +492,13 @@ def __init__(self): super().__init__(3, name='histogram-equalise') def compute(self, img): + # Generate histogram and cumulative sum of original image histogram, cs = self.getHistogramWithCS(img) + # Index pixel values from flattened original image at each value of the cumulative sum imgNew = cs[img.flatten()] + # Return the image with evenly distributed pixel intensities with the same dimensions as original image return np.reshape(imgNew, img.shape), histogram, cs class AHE(HistogramFilter): @@ -522,7 +521,7 @@ def compute(self, img, padding=True): imgPadded = padImage(img, self.maskSize) else: imgPadded = img - print("No padding added.") + logging.info("No padding added.") # Create output array of zeros with same shape and type as img array imgFiltered = np.zeros_like(img) @@ -530,6 +529,7 @@ def compute(self, img, padding=True): # Loop over every pixel of padded image for row in tqdm(range(img.shape[0])): for col in range(img.shape[1]): + # Create sub matrix of mask size surrounding pixel under consideration sub = imgPadded[row: row+self.maskSize, col: col+self.maskSize] @@ -582,7 +582,7 @@ def compute(self, img, padding=True): imgPadded = padImage(img, self.maskSize) else: imgPadded = img - print("No padding added.") + logging.info("No padding added.") # Create output array of zeros with same shape and type as img array imgFiltered = np.zeros_like(img) @@ -606,7 +606,11 @@ def compute(self, img, padding=True): try: # Get next column of sub array in image nextCol = imgPadded[row: row+self.maskSize, col+self.maskSize] + except IndexError: + + # Allow index error due to it being the last row in the row. + # Favoured computationally over running an if statement during each iteration if col + self.maskSize <= imgPadded.shape[1] + 1: continue else: @@ -632,175 +636,3 @@ def compute(self, img, padding=True): # return pattern of filter function in parent class return imgFiltered, histogramOriginal, csOriginal -class CLAHE(HistogramFilter): - def __init__(self, maskSize): - super().__init__(maskSize, name='contrast-limited-adaptive-histogram-equalise') - - def compute(self, img): - raise NotImplementedError - - @staticmethod - def clahe(img, clipLimit, bins=128, maskSize=32): - """ - Function performs clipped adaptive histogram equalisation on input image - :param maskSize: size of kernel to scan over image - :param img: input image as array - :param clipLimit: normalised clip limit - :param bins: number of gray level bins for histogram - :return: return calhe image - """ - if clipLimit == 1: return - - # Get number of rows and columns of img array - row, col = img.shape - # Allow min 128 bins - bins = max(bins, 128) - - # Pad image to allow for integer number of kernels to fit in rows and columns - subRows = ceil(row / maskSize) - subCols = ceil(col / maskSize) - - # Get size of padding - padX = int(maskSize * (subRows - row / maskSize)) - padY = int(maskSize * (subCols - col / maskSize)) - - if padX != 0 or padY != 0: - imgPadded = padImage(img, padX, padY) - else: - imgPadded = img - print("No padding needed as the mask size of {} creates {} mini rows from the original image with {} rows." - "Likewise, {} mini columns from the original image with {} columns.".format(maskSize,subRows,row,subCols,col)) - - noPixels = maskSize**2 - # xsz2 = round(kernelX / 2) - # ysz2 = round(kernelY / 2) - claheImage = np.zeros(imgPadded.shape) - - if clipLimit > 0: - # Allow minimum clip limit of 1 - clipLimit = max(1, clipLimit * maskSize**2 / bins) - else: - # Convert any negative clip limit to 50 - clipLimit = 50 - - # makeLUT - print("...Make the LUT...") - minVal = 0 # np.min(img) - maxVal = 255 # np.max(img) - - # maxVal1 = maxVal + np.maximum(np.array([0]),minVal) - minVal - # minVal1 = np.maximum(np.array([0]),minVal) - - binSz = np.floor(1 + (maxVal - minVal) / float(bins)) - LUT = np.floor((np.arange(minVal, maxVal + 1) - minVal) / float(binSz)) - - # BACK TO CLAHE - bins = LUT[img] - print(bins.shape) - # makeHistogram - print("...Making the Histogram...") - hist = np.zeros((subRows, subCols, bins)) - print(subRows, subCols, hist.shape) - for i in range(subRows): - for j in range(subCols): - bin_ = bins[i * maskSize:(i + 1) * maskSize, j * maskSize:(j + 1) * maskSize].astype(int) - for i1 in range(maskSize): - for j1 in range(maskSize): - hist[i, j, bin_[i1, j1]] += 1 - - # clipHistogram - print("...Clipping the Histogram...") - if clipLimit > 0: - for i in range(subRows): - for j in range(subCols): - nrExcess = 0 - for nr in range(bins): - excess = hist[i, j, nr] - clipLimit - if excess > 0: - nrExcess += excess - - binIncr = nrExcess / bins - upper = clipLimit - binIncr - for nr in range(bins): - if hist[i, j, nr] > clipLimit: - hist[i, j, nr] = clipLimit - else: - if hist[i, j, nr] > upper: - nrExcess += upper - hist[i, j, nr] - hist[i, j, nr] = clipLimit - else: - nrExcess -= binIncr - hist[i, j, nr] += binIncr - - if nrExcess > 0: - stepSz = max(1, np.floor(1 + nrExcess / bins)) - for nr in range(bins): - nrExcess -= stepSz - hist[i, j, nr] += stepSz - if nrExcess < 1: - break - - # mapHistogram - print("...Mapping the Histogram...") - map_ = np.zeros((subRows, subCols, bins)) - # print(map_.shape) - scale = (maxVal - minVal) / float(noPixels) - for i in range(subRows): - for j in range(subCols): - sum_ = 0 - for nr in range(bins): - sum_ += hist[i, j, nr] - map_[i, j, nr] = np.floor(min(minVal + sum_ * scale, maxVal)) - - # BACK TO CLAHE - # INTERPOLATION - print("...interpolation...") - xI = 0 - for i in range(subRows + 1): - if i == 0: - subX = int(maskSize / 2) - xU = 0 - xB = 0 - elif i == subRows: - subX = int(maskSize / 2) - xU = subRows - 1 - xB = subRows - 1 - else: - subX = maskSize - xU = i - 1 - xB = i - - yI = 0 - for j in range(subCols + 1): - if j == 0: - subY = int(maskSize / 2) - yL = 0 - yR = 0 - elif j == subCols: - subY = int(maskSize / 2) - yL = subCols - 1 - yR = subCols - 1 - else: - subY = maskSize - yL = j - 1 - yR = j - UL = map_[xU, yL, :] - UR = map_[xU, yR, :] - BL = map_[xB, yL, :] - BR = map_[xB, yR, :] - # print("CLAHE vals...") - subBin = bins[xI:xI + subX, yI:yI + subY] - # print("clahe subBin shape: ",subBin.shape) - subImage = HistogramFilter.interpolate(subBin, UL, UR, BL, BR, subX, subY) - claheImage[xI:xI + subX, yI:yI + subY] = subImage - yI += subY - xI += subX - - if padX == 0 and padY != 0: - return claheImage[:, :-padY] - elif padX != 0 and padY == 0: - return claheImage[:-padX, :] - elif padX != 0 and padY != 0: - return claheImage[:-padX, :-padY] - else: - return claheImage \ No newline at end of file diff --git a/handler.py b/handler.py index 6b77906..ef486a7 100644 --- a/handler.py +++ b/handler.py @@ -5,7 +5,7 @@ Digital Image Processing 2020, Assignment 1/1, 20% """ -# Import packages and modules used in code +# Import packages used in code import numpy as np from PIL import Image import logging