diff --git a/Makefile b/Makefile index 2ebd666..cf119bc 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,9 @@ test: lint: pylint **/*.py +lint-src: + pylint src/ + lintfix: autopep8 **/*.py --recursive --in-place --aggressive @@ -20,4 +23,4 @@ lock: pipenv lock clean: - pipenv clean \ No newline at end of file + pipenv clean diff --git a/README.md b/README.md index d15f447..f1b0189 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Machine Learning -Machine Learning tools, techniques, gists and projects. Some of this code is referenced in our Blog. +Machine Learning tools, techniques, gists and projects. +Some of this code is referenced in our Blog. This repository uses `pipenv` as an environment manager. The base python version is `3.9`. @@ -55,7 +56,28 @@ make lintfixhard for in-place fixing of lint errors under the `/src` dir. +## VSCode +If you are using VSCode, the virtual environment created by `pipenv` will not +be immediately available and you will see warnings in your import statements. +To fix this first make sure the appropriate virtual environment is activated +by running `make activate`, then get the location of the current python +interpreter using `make python`. The printed line should look something like +this: +```bash +/Users/yourname/path/to/virtualenvs/machine-learning-abcde1234/bin/python +``` + +Copy that line. Then open your +[settings.json](https://code.visualstudio.com/docs/getstarted/settings) +file and add a new key `"python.defaultInterpreterPath"`, then paste the +previously copied python interpreter path as its value and restart VSCode. + +```json +{ + "python.defaultInterpreterPath": "/Users/yourname/path/to/virtualenvs/machine-learning-abcde1234/bin/python" +} +``` ## Contribution 1. Create a new feature branch that compares to the main branch and open a PR. 1. Ensure you have written appropriate tests and they are passing. @@ -65,4 +87,4 @@ Update the `requirements.txt` file using the following command from the main directory: ```bash make lock -``` \ No newline at end of file +``` diff --git a/cli/image_processing.py b/cli/image_processing.py new file mode 100644 index 0000000..b5bd8d3 --- /dev/null +++ b/cli/image_processing.py @@ -0,0 +1,166 @@ +"""CLI to use the convolution examples""" +import argparse +import numpy as np +from PIL import Image + +from computer_vision.image_processing import kernel, pipelines, reshaping +from plotter import MultiPlot + + +def buil_arg_parser(): + """Parses the user's arguments""" + parser = argparse.ArgumentParser( + description="Explore Image Processing techniques that use convolutions", + epilog="Built with <3 by Emmanuel Byrd at 8th Light Ltd.") + parser.add_argument( + "--source-path", metavar="./image.jpg", type=str, + required=True, + help="The read path of the input image (required)" + ) + parser.add_argument( + "--destination-path", metavar="./image.jpg", type=str, + help="The write path of the output image" + ) + parser.add_argument( + "--show", action=argparse.BooleanOptionalAction, type=bool, + help="Whether to show the resulting plot" + ) + parser.add_argument( + "--color", action=argparse.BooleanOptionalAction, type=bool, + help="Whether to use all 3 channels from an image" + ) + parser.add_argument( + "--example", + choices=['kernel', 'gauss', 'blur', 'opening', 'closing', + 'inner_border', 'outer_border'], + help="Examples to choice from", + required=True, + ) + parser.add_argument( + "--kernel", + choices=['top', 'bottom', 'left', 'right', + 'top_sobel', 'bottom_sobel', 'left_sobel', 'right_sobel', + 'sharpen', 'outline'], + help="The write path of the output image" + ) + parser.add_argument( + "--gauss-sigma", metavar="1.", type=float, default=1., + help="Sigma parameter of the Gaussian filter (default: 1.0)" + ) + parser.add_argument( + "--gauss-size", metavar="5", type=int, default=5, + help="Size of the Gaussian filter (default: 5)" + ) + return parser + + +color_agnostic_examples = [ + "kernel", "gauss", "blur" +] + +triple_plot_examples = [ + "opening", "closing", "inner_border", "outer_border" +] + + +def draw_figures(args: argparse.Namespace, plotter: MultiPlot): + """Show or save the generated figures""" + + suptitle = "Example - " + args.example + if args.example in ["gauss", "blur"]: + suptitle += f" size:{args.gauss_size} sigma:{args.gauss_sigma}" + figure = plotter.draw(suptitle) + + if args.show: + figure.show() + input("Press any key to continue...") + + if args.destination_path: + print("Saving plot in " + args.destination_path) + figure.savefig(args.destination_path) + + +def output_color_agnostic(args: argparse.Namespace, input_img: np.ndarray): + """Create the examples that are suitable for color and grayscale inputs""" + if args.example == "kernel": + kernel_choice = kernel.from_name(args.kernel) + output = pipelines.padded_convolution_same_kernel( + input_img, kernel_choice) + elif args.example == "gauss": + kernel_gauss = kernel.simple_gauss(args.gauss_size, args.gauss_sigma) + output = pipelines.padded_convolution_same_kernel( + input_img, kernel_gauss) + elif args.example == "blur": + output = pipelines.padded_blur( + input_img, args.gauss_size, args.gauss_sigma) + return output + + +def outputs_triple_plot_examples( + args: argparse.Namespace, input_img: np.ndarray): + """Create the examples that produce three figures in the plot""" + + if args.example == "opening": + return pipelines.opening(input_img) + + if args.example == "closing": + return pipelines.closing(input_img) + + if args.example == "inner_border": + return pipelines.inner_border(input_img) + + # args.example == "outer_border" + return pipelines.outer_border(input_img) + + +def execute_color(args: argparse.Namespace): + """Do the example in color""" + plotter = MultiPlot() + img = np.asarray(Image.open(args.source_path)) + plotter.add_figure(img, "input") + + img_reshaped = reshaping.channel_as_first_dimension(img) + output_reshaped = output_color_agnostic(args, img_reshaped) + + output = reshaping.channel_as_last_dimension(output_reshaped) + plotter.add_figure(output.astype(int), "output") + + draw_figures(args, plotter) + + +def execute_grayscale(args: argparse.Namespace): + """Do the example in grayscale""" + plotter = MultiPlot() + img = np.asarray(Image.open(args.source_path).convert("L")) + plotter.add_figure(img, "input", "gray") + + if args.example in color_agnostic_examples: + output = output_color_agnostic(args, img) + elif args.example in triple_plot_examples: + middlestep, output = outputs_triple_plot_examples(args, img) + plotter.add_figure(middlestep, "middle step", "gray") + + plotter.add_figure(output, "output", "gray") + + draw_figures(args, plotter) + + +def main(): + """Main function""" + arg_parser = buil_arg_parser() + args = arg_parser.parse_args() + + if args.color and args.example not in color_agnostic_examples: + print("Color examples do not support " + args.example) + return + + if args.color: + execute_color(args) + else: + execute_grayscale(args) + + print("Finished.") + + +if __name__ == "__main__": + main() diff --git a/src/computer_vision/cnn/fashion_mnist_classifier.py b/src/computer_vision/cnn/fashion_mnist_classifier.py index ddf1e11..27b1f21 100644 --- a/src/computer_vision/cnn/fashion_mnist_classifier.py +++ b/src/computer_vision/cnn/fashion_mnist_classifier.py @@ -10,6 +10,7 @@ import matplotlib.pyplot as plt import numpy as np + class FashionMNISTClassifier: """ Can load a trained model from memory or diff --git a/src/computer_vision/cnn/fashion_mnist_classifier_test.py b/src/computer_vision/cnn/fashion_mnist_classifier_test.py index a10f4af..8dbb1c5 100644 --- a/src/computer_vision/cnn/fashion_mnist_classifier_test.py +++ b/src/computer_vision/cnn/fashion_mnist_classifier_test.py @@ -6,6 +6,7 @@ # Make tests deterministic tensorflow.random.set_seed(123) + def test_full_cycle(): """ Tests that the entire flow can be executed without interruptions or failures diff --git a/src/computer_vision/image_processing/__init__.py b/src/computer_vision/image_processing/__init__.py new file mode 100644 index 0000000..f9147dc --- /dev/null +++ b/src/computer_vision/image_processing/__init__.py @@ -0,0 +1,7 @@ +"""Tools and techniques to process images""" +from .convolution import * +from .kernel import * +from .padding import * +from .pipelines import * +from .pooling import * +from .reshaping import * diff --git a/src/computer_vision/image_processing/convolution.py b/src/computer_vision/image_processing/convolution.py new file mode 100644 index 0000000..313b18e --- /dev/null +++ b/src/computer_vision/image_processing/convolution.py @@ -0,0 +1,66 @@ +"""Convolution functions and helpers""" +import numpy as np + + +def _multiply_sum(background: np.ndarray, kernel: np.ndarray) -> float: + """ + Returns the sumation of multiplying each individual element + of the background with the given kernel + """ + assert background.shape == kernel.shape + + return (background * kernel).sum() + + +def conv_repeat_all_chan(img: np.ndarray, kernel: np.ndarray) -> np.ndarray: + """ + Convolves each channel of the input with the same kernel and + returns their outputs as a separate channel each. + """ + output = [] + for index in range(img.shape[0]): + output.append(convolution_1d(img[index], kernel)) + return np.asarray(output) + + +def convolution_1d(image: np.ndarray, kernel: np.ndarray) -> np.ndarray: + """ + Executes a 1-stride convolution with the given kernel, over a 2D input. + The output shape will be (image_y - kernel_y + 1, image_x - kernel_x + 1) + See other external packages for complete convolution functionality. + """ + kernel_y, kernel_x = kernel.shape + image_y, image_x = image.shape + + output_y = image_y - kernel_y + 1 + output_x = image_x - kernel_x + 1 + + output = np.empty((output_y, output_x)) + assert output.shape == (output_y, output_x) + + for i in range(output_y): + for j in range(output_x): + output[i][j] = _multiply_sum(kernel, + image[i:i + kernel_y, j:j + kernel_x]) + + return output + + +def convolution(image: np.ndarray, kernel: np.ndarray) -> np.ndarray: + """ + Executes a 3D or 2D convolution over input as needed. + 3D convolution requires a 3D filter, and creates a 2D output. + """ + if len(image.shape) == 3: + assert len(kernel.shape) == 3 + assert image.shape[0] == kernel.shape[0] + + channel_convs = [] + for index in range(image.shape[0]): + channel_convs.append(convolution_1d(image[index], kernel[index])) + + output = np.asarray(channel_convs).sum(axis=0) + else: + output = convolution_1d(image, kernel) + + return output diff --git a/src/computer_vision/image_processing/kernel.py b/src/computer_vision/image_processing/kernel.py new file mode 100644 index 0000000..b9e0b24 --- /dev/null +++ b/src/computer_vision/image_processing/kernel.py @@ -0,0 +1,89 @@ +"""Common kernels used in convolutions""" +import numpy as np + +top = np.array([ + [1, 1, 1], + [0, 0, 0], + [-1, -1, -1] +]) + +bottom = np.array([ + [-1, -1, -1], + [0, 0, 0], + [1, 1, 1] +]) + +left = np.array([ + [1, 0, -1], + [1, 0, -1], + [1, 0, -1] +]) + +right = np.array([ + [-1, 0, 1], + [-1, 0, 1], + [-1, 0, 1] +]) + +top_sobel = np.array([ + [1, 2, 1], + [0, 0, 0], + [-1, -2, -1] +]) + +bottom_sobel = np.array([ + [-1, -2, -1], + [0, 0, 0], + [1, 2, 1] +]) + +left_sobel = np.array([ + [1, 0, -1], + [2, 0, -2], + [1, 0, -1] +]) + +right_sobel = np.array([ + [-1, 0, 1], + [-2, 0, 2], + [-1, 0, 1] +]) + +sharpen = np.array([ + [0, -1, 0], + [-1, 5, -1], + [0, -1, 0] +]) + +outline = np.array([ + [-1, -1, -1], + [-1, 8, -1], + [-1, -1, -1] +]) + + +def simple_gauss(length=5, sigma=1.) -> np.ndarray: + """ + Creates a gaussian filter of the desired NxN size and the standard deviation + It is created by calculating the outer product of two gaussian vectors. + """ + integer_space = np.linspace(-(length - 1) / 2., (length - 1) / 2., length) + gauss = np.exp(-0.5 * np.square(integer_space) / np.square(sigma)) + kernel = np.outer(gauss, gauss) + return kernel / np.sum(kernel) + + +def from_name(name: str): + """Returns the desired kernel given its name""" + return { + "top": top, + "bottom": bottom, + "left": left, + "right": right, + "top_sobel": top_sobel, + "bottom_sobel": bottom_sobel, + "left_sobel": left_sobel, + "right_sobel": right_sobel, + "sharpen": sharpen, + "outline": outline + }[name] diff --git a/src/computer_vision/image_processing/padding.py b/src/computer_vision/image_processing/padding.py new file mode 100644 index 0000000..061d589 --- /dev/null +++ b/src/computer_vision/image_processing/padding.py @@ -0,0 +1,28 @@ +"""Padding functionality""" +import numpy as np + + +def padding_1d(image: np.ndarray, length: int) -> np.ndarray: + """Adds 0 padding to a 2D input""" + img_y, img_x = image.shape + output = np.zeros((img_y + 2 * length, img_x + 2 * length), int) + + for i in range(img_y): + for j in range(img_x): + output[i + length][j + length] = image[i][j] + + return output + + +def padding(image: np.ndarray, size: int) -> np.ndarray: + """Adds 0 padding to a 3D or 2D input as needed""" + if len(image.shape) == 3: + output = [] + for index in range(image.shape[0]): + output.append(padding_1d(image[index], size)) + + output = np.asarray(output) + else: + output = padding_1d(image, size) + + return output diff --git a/src/computer_vision/image_processing/pipelines.py b/src/computer_vision/image_processing/pipelines.py new file mode 100644 index 0000000..83e0def --- /dev/null +++ b/src/computer_vision/image_processing/pipelines.py @@ -0,0 +1,79 @@ +""" +Pre-built pipelines that demonstrate image processing techniques. +Inputs must be an array of 2D images +- e.g. RGB channels must be the in the first dimension +""" +import numpy as np + +from .kernel import simple_gauss +from .padding import padding +from .pooling import maxpool, minpool +from .convolution import convolution, conv_repeat_all_chan + + +def padded_blur(image: np.ndarray, length: int = 5, sigma: float = 1.): + """Blurs an input using SAME padding""" + kernel = simple_gauss(length, sigma) + return padded_convolution_same_kernel(image, kernel) + + +def padded_convolution_same_kernel(image: np.ndarray, kernel: np.ndarray): + """Uses the same kernel to convolute each channel of the input""" + padding_size = (kernel.shape[0] - 1) // 2 + padded = padding(image, padding_size) + + if len(image.shape) == 3: + output = conv_repeat_all_chan(padded, kernel) + else: + output = convolution(padded, kernel) + return output + + +def opening(image: np.ndarray): + """ + Opening: dilation of erosion. + Returns both the erotion and its further dilation using SAME padding + """ + assert len(image.shape) == 2 + + eroded = minpool(padding(image, 1), 3) + dilated = maxpool(padding(eroded, 1), 3) + + return eroded, dilated + + +def closing(image: np.ndarray): + """ + Closing: erotion of dilation. + Returns both the dilation and its further erotion using SAME padding + """ + assert len(image.shape) == 2 + + dilated = maxpool(padding(image, 1), 3) + eroded: np.ndarray = minpool(padding(dilated, 1), 3) + + return dilated, eroded + + +def inner_border(image: np.ndarray): + """ + Inner border: original - erotion. + Returns both the erotion and the output using SAME padding + """ + assert len(image.shape) == 2 + + eroded = minpool(padding(image, 1), 3) + border: np.ndarray = image - eroded + return eroded, border + + +def outer_border(image: np.ndarray): + """ + Outer border: dilation - image. + Returns both the dilation and the output using SAME padding + """ + assert len(image.shape) == 2 + + dilated = maxpool(padding(image, 1), 3) + border: np.ndarray = dilated - image + return dilated, border diff --git a/src/computer_vision/image_processing/pooling.py b/src/computer_vision/image_processing/pooling.py new file mode 100644 index 0000000..b0b9e51 --- /dev/null +++ b/src/computer_vision/image_processing/pooling.py @@ -0,0 +1,26 @@ +"""Maxpool and Minpool functionality""" +import numpy as np + + +def pool(image: np.ndarray, size: int, method) -> np.ndarray: + """Pools the given area""" + image_y, image_x = image.shape + output_y = image_y - size + 1 + output_x = image_x - size + 1 + output = np.empty((output_y, output_x), int) + + for i in range(output_y): + for j in range(output_x): + output[i][j] = method(image[i:i + size, j:j + size]) + + return output + + +def maxpool(image: np.ndarray, size: int) -> np.ndarray: + """Returns maximum values in the areas of size NxN""" + return pool(image, size, np.amax) + + +def minpool(image: np.ndarray, size: int) -> np.ndarray: + """Returns minimum values in the areas of size NxN""" + return pool(image, size, np.amin) diff --git a/src/computer_vision/image_processing/reshaping.py b/src/computer_vision/image_processing/reshaping.py new file mode 100644 index 0000000..3eec74f --- /dev/null +++ b/src/computer_vision/image_processing/reshaping.py @@ -0,0 +1,36 @@ +""" +RGB and BGR images have the colors in the last dimension of the array. +Convolutions need each channel to be in the first dimension, so that +accessing an element with `img[0]` returns a complete 2D version of +the channel 0. + +This file provides functionality to reshape images to the required shape +and back. +""" +import numpy as np + + +def channel_as_first_dimension(img: np.ndarray) -> np.ndarray: + """ + Moves channel from the last dimension to the first. + - e.g. RGB to convolution shape + """ + assert len(img.shape) == 3 + + temp = img.flatten() + new_shape = (img.shape[2], img.shape[0], img.shape[1]) + + return temp.reshape(new_shape, order="F") + + +def channel_as_last_dimension(img: np.ndarray) -> np.ndarray: + """ + Moves channel from the first dimension to the last. + - e.g. convolution shape to RGB + """ + assert len(img.shape) == 3 + + temp = img.flatten() + new_shape = (img.shape[1], img.shape[2], img.shape[0]) + + return temp.reshape(new_shape, order="F") diff --git a/src/computer_vision/image_processing/test/__init__.py b/src/computer_vision/image_processing/test/__init__.py new file mode 100644 index 0000000..07b4ace --- /dev/null +++ b/src/computer_vision/image_processing/test/__init__.py @@ -0,0 +1 @@ +"""Tests for the image_processing module""" diff --git a/src/computer_vision/image_processing/test/convolution_test.py b/src/computer_vision/image_processing/test/convolution_test.py new file mode 100644 index 0000000..9a41fe3 --- /dev/null +++ b/src/computer_vision/image_processing/test/convolution_test.py @@ -0,0 +1,104 @@ +"""Tests for the .convolution file""" +import numpy as np + +from ..convolution import convolution, _multiply_sum + + +def test_multiply_sum(): + """Multiplies two frames element-wise and adds their values into one""" + img = np.array([ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ]) + kernel = np.array([ + [-1, 1, 2], + [1, 2, 3], + [2, 3, -1] + ]) + output = _multiply_sum(img, kernel) + + assert output == 68 + + +def test_convolution(): + """ + A convolution reduces the img size and is the result of + element-wise multiplications and additions + """ + img = np.array([ + [1, 2, 3, 4], + [5, 6, 7, 8], + [9, 0, 1, 2], + [3, 4, 5, 6] + ]) + kernel = np.array([ + [0, 1, 0], + [1, -1, 1], + [0, 1, 0] + ]) + + output = convolution(img, kernel) + expected = np.array([ + [8, 11], + [20, 13] + ]) + + assert (output == expected).all() + + +def test_convolution_2(): + """Second example of convolution""" + img = np.array([ + [1, 2, 3, 4], + [4, 3, 2, 1], + [5, 6, 7, 8], + [8, 7, 6, 5] + ]) + kernel = np.array([ + [-1, 1], + [1, -1] + ]) + + output = convolution(img, kernel) + expected = np.array([ + [2, 2, 2], + [-2, -2, -2], + [2, 2, 2] + ]) + + assert (output == expected).all() + + +def test_convolution_2_chan(): + """A 3D convolution creates a 2D output""" + img = np.array([ + [ + [1, 2, 3], + [4, 5, 6], + [7, 8, 9] + ], + [ + [3, 4, 5], + [6, 7, 8], + [3, 2, 1] + ] + ]) + kernel = np.array([ + [ + [-1, 1], + [-1, 1] + ], + [ + [1, -1], + [1, -1] + ] + ]) + output = convolution(img, kernel) + + expected = np.array([ + convolution(img[0], kernel[0]), + convolution(img[1], kernel[1]) + ]).sum(axis=0) + + assert (output == expected).all() diff --git a/src/computer_vision/image_processing/test/kernel_test.py b/src/computer_vision/image_processing/test/kernel_test.py new file mode 100644 index 0000000..a7f179a --- /dev/null +++ b/src/computer_vision/image_processing/test/kernel_test.py @@ -0,0 +1,48 @@ +"""Tests the .kernel file""" +import numpy as np + +from ..kernel import simple_gauss + + +def test_gauss_size(): + """simple_gauss creates a kernel of the right size""" + kernel = simple_gauss(length=7) + + assert kernel.shape == (7, 7) + + +def test_gauss_size_default(): + """Default size of a gauss kernel is 5""" + kernel = simple_gauss() + + assert kernel.shape == (5, 5) + + +def test_gauss_sigma(): + """Tests the right values of the default gauss kernel""" + kernel = simple_gauss() + + expected = np.array([ + [0.00296902, 0.01330621, 0.02193823, 0.01330621, 0.00296902], + [0.01330621, 0.0596343, 0.09832033, 0.0596343, 0.01330621], + [0.02193823, 0.09832033, 0.16210282, 0.09832033, 0.02193823], + [0.01330621, 0.0596343, 0.09832033, 0.0596343, 0.01330621], + [0.00296902, 0.01330621, 0.02193823, 0.01330621, 0.00296902] + ]) + + assert (np.round(kernel, 8) == expected).all() + + +def test_gauss_big_sigma(): + """Changing the gaussian sigma created the correct kernel""" + kernel = simple_gauss(sigma=4.) + + expected = np.array([ + [0.03520395, 0.03866398, 0.0398913, 0.03866398, 0.03520395], + [0.03866398, 0.04246407, 0.04381203, 0.04246407, 0.03866398], + [0.0398913, 0.04381203, 0.04520277, 0.04381203, 0.0398913], + [0.03866398, 0.04246407, 0.04381203, 0.04246407, 0.03866398], + [0.03520395, 0.03866398, 0.0398913, 0.03866398, 0.03520395] + ]) + + assert (np.round(kernel, 8) == expected).all() diff --git a/src/computer_vision/image_processing/test/padding_test.py b/src/computer_vision/image_processing/test/padding_test.py new file mode 100644 index 0000000..2378ec4 --- /dev/null +++ b/src/computer_vision/image_processing/test/padding_test.py @@ -0,0 +1,63 @@ +"""Tests for the .padding file""" +import numpy as np + +from ..padding import padding + + +def test_padding(): + """Adds 0 padding of size 1 to the img""" + img = np.array([ + [1, 1], + [1, 1] + ]) + output = padding(img, 1) + + expected = np.array([ + [0, 0, 0, 0], + [0, 1, 1, 0], + [0, 1, 1, 0], + [0, 0, 0, 0] + ]) + + assert (output == expected).all() + + +def test_padding_2(): + """Adds 0 padding of size 2 to the img""" + img = np.array([ + [1, 1], + [1, 1] + ]) + output = padding(img, 2) + + expected = np.array([ + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 1, 1, 0, 0], + [0, 0, 1, 1, 0, 0], + [0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0] + ]) + + assert (output == expected).all() + + +def test_padding_2_chan(): + """Padding a 3D img results in a 3D output""" + img = np.array([ + [ + [1, 1], + [1, 1] + ], + [ + [2, 2], + [2, 2] + ] + ]) + output = padding(img, 1) + expected = np.array([ + padding(img[0], 1), + padding(img[1], 1) + ]) + + assert (output == expected).all() diff --git a/src/computer_vision/image_processing/test/pipelines_test.py b/src/computer_vision/image_processing/test/pipelines_test.py new file mode 100644 index 0000000..cb3df48 --- /dev/null +++ b/src/computer_vision/image_processing/test/pipelines_test.py @@ -0,0 +1,210 @@ +"""Tests for the .pipelines file""" +import numpy as np + +from ..kernel import left +from ..pipelines import ( + padded_blur, padded_convolution_same_kernel, + opening, closing, inner_border, outer_border +) + + +def test_blur_output_shape(): + """Padded blur creates an output of the same size as the input""" + img = np.array([ + [1, 2, 3, 4], + [0, 0, 0, 0], + [5, 6, 7, 8], + [-1, -1, -1, -1] + ]) + output = padded_blur(img) + + assert output.shape == img.shape + + +def test_blur_output_shape_3_chan(): + """Padded blur creates a 3D output of the same size as the input""" + img = np.array([ + [ + [1, 2, 3, 4], + [4, 3, 2, 1], + [5, 6, 7, 8], + [8, 7, 6, 5] + ], + [ + [1, 2, 3, 4], + [4, 3, 2, 1], + [5, 6, 7, 8], + [8, 7, 6, 5] + ], + [ + [1, 2, 3, 4], + [4, 3, 2, 1], + [5, 6, 7, 8], + [8, 7, 6, 5] + ] + ]) + + output = padded_blur(img) + + assert output.shape == img.shape + + +def test_padded_convolution_same_kernel_shape(): + """Padded convolution creates an output of the same size as the input""" + img = np.array([ + [1, 2, 3, 4], + [9, 9, 9, 9], + [5, 6, 7, 8], + [0, 0, 0, 0] + ]) + output = padded_convolution_same_kernel(img, left) + + assert output.shape == img.shape + + +def test_padded_convolution_same_kernel_shape_3_chan(): + """Padded convolution of a 3D input creates an output of the same size""" + img = np.array([ + [ + [1, 2, 3, 4], + [4, 3, 2, 1], + [5, 6, 7, 8], + [8, 7, 6, 5] + ], + [ + [1, 2, 3, 4], + [4, 3, 2, 1], + [5, 6, 7, 8], + [8, 7, 6, 5] + ], + [ + [1, 2, 3, 4], + [4, 3, 2, 1], + [5, 6, 7, 8], + [8, 7, 6, 5] + ] + ]) + output = padded_convolution_same_kernel(img, left) + + assert output.shape == img.shape + + +def test_opening(): + """Opening will disappear isolated pixels of higher value""" + img = np.array([ + [1, 1, 1, 0], + [1, 1, 1, 0], + [1, 1, 1, 0], + [0, 0, 0, 1] # <- see bottom right pixel + ]) + middlestep, output = opening(img) + + assert middlestep.shape == img.shape + assert output.shape == img.shape + + assert (middlestep == np.array([ + [0, 0, 0, 0], + [0, 1, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0] + ])).all() + + assert (output == np.array([ + [1, 1, 1, 0], + [1, 1, 1, 0], + [1, 1, 1, 0], + [0, 0, 0, 0] # <- bottom right pixel changed + ])).all() + + +def test_closing(): + """Closing will eliminate lower value holes""" + img = np.array([ + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 0], + [0, 1, 0, 1, 0], # <- see inner pixel + [0, 1, 1, 1, 0], + [0, 0, 0, 0, 0] + ]) + middlestep, output = closing(img) + + assert middlestep.shape == img.shape + assert output.shape == img.shape + + assert (middlestep == np.array([ + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1], + [1, 1, 1, 1, 1] + ])).all() + + assert (output == np.array([ + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 0], + [0, 1, 1, 1, 0], # <- inner pixel changed + [0, 1, 1, 1, 0], + [0, 0, 0, 0, 0] + ])).all() + + +def test_inner_border(): + """Inner border keeps the out-most pixels of the patterns in the input""" + img = np.array([ + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 1], + [0, 1, 1, 1, 1], + [0, 1, 1, 1, 1], + [0, 1, 1, 1, 1] + ]) + eroded, output = inner_border(img) + + assert eroded.shape == img.shape + assert output.shape == img.shape + + assert (eroded == np.array([ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 0], + [0, 0, 1, 1, 0], + [0, 0, 0, 0, 0] + ])).all() + + assert (output == np.array([ + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 1], + [0, 1, 0, 0, 1], + [0, 1, 0, 0, 1], + [0, 1, 1, 1, 1] + ])).all() + + +def test_outer_border_shapes(): + """Outer border adds surrounding pixels to the patterns in the input""" + img = np.array([ + [0, 0, 0, 0, 0], + [0, 0, 0, 0, 0], + [0, 0, 1, 1, 0], + [0, 0, 1, 1, 0], + [0, 0, 0, 0, 0] + ]) + dilated, output = outer_border(img) + + assert dilated.shape == img.shape + assert output.shape == img.shape + + assert (dilated == np.array([ + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 1], + [0, 1, 1, 1, 1], + [0, 1, 1, 1, 1], + [0, 1, 1, 1, 1] + ])).all() + + assert (output == np.array([ + [0, 0, 0, 0, 0], + [0, 1, 1, 1, 1], + [0, 1, 0, 0, 1], + [0, 1, 0, 0, 1], + [0, 1, 1, 1, 1] + ])).all() diff --git a/src/computer_vision/image_processing/test/pooling_test.py b/src/computer_vision/image_processing/test/pooling_test.py new file mode 100644 index 0000000..e96dc03 --- /dev/null +++ b/src/computer_vision/image_processing/test/pooling_test.py @@ -0,0 +1,37 @@ +"""Tests for the .pooling file""" +import numpy as np + +from ..pooling import maxpool, minpool + + +def test_minpool(): + """Minpool keeps the minimum values of the img according to the area""" + img = np.array([ + [0, 3, 4, -1], + [6, 7, 4, 5], + [9, 7, 6, 4], + [-2, 8, 4, -1] + ]) + output = minpool(img, 3) + expected = np.array([ + [0, -1], + [-2, -1] + ]) + + assert (output == expected).all() + + +def test_maxpool(): + """Maxpool keeps the maximum values of the img according to the area""" + img = np.array([ + [9, 0, 9], + [1, 1, 1], + [8, 2, 6] + ]) + output = maxpool(img, 2) + expected = np.array([ + [9, 9], + [8, 6] + ]) + + assert (output == expected).all() diff --git a/src/computer_vision/image_processing/test/reshaping_test.py b/src/computer_vision/image_processing/test/reshaping_test.py new file mode 100644 index 0000000..823f030 --- /dev/null +++ b/src/computer_vision/image_processing/test/reshaping_test.py @@ -0,0 +1,190 @@ +"""Tests for the .reshaping file""" +import numpy as np + +from ..reshaping import channel_as_first_dimension, channel_as_last_dimension + + +def test_channel_as_first_dimension(): + """Shows the result of extracting RGB values into separate channels""" + img = np.array([ + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]] + ]) + output = channel_as_first_dimension(img) + + expected = np.array([ + [ + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1] + ], + [ + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2] + ], + [ + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3] + ], + ]) + + assert (output == expected).all() + + +def test_channel_as_last_dimension(): + """Shows the result of grouping separate channels into RGB values""" + img = np.array([ + [ + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1] + ], + [ + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2] + ], + [ + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3] + ], + ]) + output = channel_as_last_dimension(img) + + expected = np.array([ + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]] + ]) + + assert (output == expected).all() + + +def test_channel_as_first_dimension_same_shape(): + """Order is preserved when the dimension lengths are the same: (3x3x3)""" + img = np.array([ + [[1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3]] + ]) + output = channel_as_first_dimension(img) + + expected = np.array([ + [ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1] + ], + [ + [2, 2, 2], + [2, 2, 2], + [2, 2, 2] + ], + [ + [3, 3, 3], + [3, 3, 3], + [3, 3, 3] + ] + ]) + + assert (output == expected).all() + + +def test_channel_as_last_dimension_same_shape(): + """Order is preserved when the dimension lengths are the same: (3x3x3)""" + img = np.array([ + [ + [1, 1, 1], + [1, 1, 1], + [1, 1, 1] + ], + [ + [2, 2, 2], + [2, 2, 2], + [2, 2, 2] + ], + [ + [3, 3, 3], + [3, 3, 3], + [3, 3, 3] + ] + ]) + output = channel_as_last_dimension(img) + + expected = np.array([ + [[1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3]] + ]) + + assert (output == expected).all() + + +def test_symmetry_start_channels_last_dimension(): + """ + Reshaping from RGB into convolution shape and back results in the same image + """ + img = np.array([ + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]], + [[1, 2, 3], [1, 2, 3], [1, 2, 3], [1, 2, 3]] + ]) + output1 = channel_as_first_dimension(img) + output = channel_as_last_dimension(output1) + + assert (img == output).all() + + +def test_symmetry_start_channels_first_dimension(): + """ + Reshaping from convolution shape into RGB and back results in the same image + """ + img = np.array([ + [ + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1], + [1, 1, 1, 1] + ], + [ + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2], + [2, 2, 2, 2] + ], + [ + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3], + [3, 3, 3, 3] + ], + ]) + output1 = channel_as_last_dimension(img) + output = channel_as_first_dimension(output1) + + assert (img == output).all() diff --git a/src/plotter/__init__.py b/src/plotter/__init__.py new file mode 100644 index 0000000..3c61937 --- /dev/null +++ b/src/plotter/__init__.py @@ -0,0 +1,2 @@ +"""Custom plotting functionality""" +from .multiplot import * diff --git a/src/plotter/multiplot.py b/src/plotter/multiplot.py new file mode 100644 index 0000000..52b76cf --- /dev/null +++ b/src/plotter/multiplot.py @@ -0,0 +1,35 @@ +"""Abstracted functionality to plot figures with a suptitle""" +import matplotlib.pyplot as plt + + +class MultiPlot: + """ + MultiPlot receives images and creates a Matplotlib figure for them + """ + def __init__(self): + """Initialize variables images, names, cmaps and figure""" + self.images = [] + self.names = [] + self.cmaps = [] + self.figure = None + + def add_figure(self, image, name, cmap=None): + """Adds a figure with a name and cmap""" + self.images.append(image) + self.names.append(name) + self.cmaps.append(cmap) + + def draw(self, suptitle: str) -> plt.Figure: + """Draws the figures in the order they were added and adds a suptitle""" + total = len(self.images) + fig = plt.figure() + for index in range(total): + fig.add_subplot(1, total, index + 1) + cmap = self.cmaps[index] + plt.imshow(self.images[index], cmap) + plt.title(self.names[index]) + + plt.suptitle(suptitle) + self.figure = fig + + return fig