├── .gitignore ├── LICENSE.md ├── README.md ├── convolution.py ├── files ├── conv.gif ├── convolution_process.gif ├── expl_dilation.png ├── expl_padding.gif ├── expl_stride.png ├── meme.jpg ├── pic.jpg ├── pic_blurred.jpg ├── plot_random_feature_map.jpg └── plot_random_matrix_and_kernel.jpg ├── plots.py └── tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .vscode/ 6 | TODO 7 | *.ipynb 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | .python-version 88 | 89 | # pipenv 90 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 91 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 92 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 93 | # install all needed dependencies. 94 | #Pipfile.lock 95 | 96 | # celery beat schedule file 97 | celerybeat-schedule 98 | 99 | # SageMath parsed files 100 | *.sage.py 101 | 102 | # Environments 103 | .env 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | 111 | # Spyder project settings 112 | .spyderproject 113 | .spyproject 114 | 115 | # Rope project settings 116 | .ropeproject 117 | 118 | # mkdocs documentation 119 | /site 120 | 121 | # mypy 122 | .mypy_cache/ 123 | .dmypy.json 124 | dmypy.json 125 | 126 | # Pyre type checker 127 | .pyre/ 128 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 Nikita Detkov 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Convolution from scratch 2 | ![Finally some good convolution with dilation in pure Python and NumPy](files/meme.jpg) 3 | 4 | ### Motivation on repository 5 | I tried to find the algorithm of convolution with dilation, implemented from scratch on a pure python, but could not find anything. There are a lot of self-written CNNs on the Internet and on the GitHub and so on, a lot of tutorials and explanations on convolutions, but there is a lack of a very important thing: proper implementation of a generalized 2D convolution for a kernel of any form with adjustable on both axes parameters, such as stride, padding, and most importantly, dilation. The last one cannot be found literally anywhere! This is why this repository and this picture above appeared. 6 | 7 | ### Who needs this? 8 | If you've ever wanted to understand how this seemingly simple algorithm can be really implemented in code, this repository is for you. As it turns out, it's not so easy to tie all the parameters together in code to make it general, clear and obvious (and optimal in terms of computations). 9 | Feel free to use it as you wish. 10 | 11 | # Contents 12 | * [Explanation](#explanation) 13 | * [Idea in the nutshell](#idea-in-the-nutshell) 14 | * Details on implementation (soon) 15 | * [Usage](#usage) 16 | * [Example with your matrix and kernel](#example-with-your-matrix-and-kernel) 17 | * [Example with your picture and filter](#example-with-your-image-and-filter) 18 | * [Running tests](#running-tests) 19 | * [Citation](#citation) 20 | 21 | ## Explanation 22 | ### Idea in the nutshell 23 | In 2D convolution we move some small matrix called Kernel over 2D Image (some matrix) and multiply it element-wise over each sub-matrix, then sum elements of the obtained sub-matrix into a single pixel of so-called Feature map. We move it from the left to the right and from the top to the bottom. At the end of convolution we usually cover the whole Image surface, but that is not guaranteed with more complex parameters. 24 | This GIF [(source)](https://stackoverflow.com/questions/42450389/how-a-filter-in-convolutional-neural-network-can-generate-multiple-channels) below perfectly presents the essence of the 2D convolution: green matrix is the Image, yellow is the Kernel and red coral is the Feature map: 25 | 26 | ![*Some clarifying GIF*](files/conv.gif) 27 | 28 | Let's clarify it and give a definition to every term used: 29 | - Image or input data is some matrix; 30 | - Kernel is a small matrix that we multiply with sub-matrices of an Image; 31 | - Stride is the size of the step of the slide. For example, when the stride equals 1, we move on 1 pixel on every step, when 2, then we move on 2 pixels and so on. [This picture](files/expl_stride.png) can help you figure it out; 32 | - Padding is just the border of the Image that allows us to keep the size of initial Image and Feature map the same. In the GIF above we see that the shape of Image is 5x5 but the Feature map is 3x3. The reason is that when we use Kernel, we can't put its center in the corner, because if we do, there is a lack of pixels to multiply on. So if we want to keep shape, we use padding and add some zero border of the image. [This GIF](files/expl_padding.gif) can help you figure it out; 33 | - Dilation is just the gap between kernel cells. So, the regular dilation is 1 and each cell is not distanced from its neighbor, but when we set the value as 2, there are no cells in the 1-cell neighborhood — now they are distanced from each other. [This picture](files/expl_dilation.png) can help you figure it out. 34 | - Feature map or output data is the matrix obtained by all the calculations discussed earlier. 35 | 36 | This is it — that easy. 37 | 38 | 39 | ## Usage 40 | ### Example with your matrix and kernel 41 | Say, you have a matrix like this one: 42 | ```python 43 | matrix = np.array([[1, 4, 4, 2, 1, 0, 0, 1, 0, 0, 3, 3, 3, 4], 44 | [0, 2, 0, 2, 0, 3, 4, 4, 2, 1, 1, 3, 0, 4], 45 | [1, 1, 0, 0, 3, 4, 2, 4, 4, 2, 3, 0, 0, 4], 46 | [4, 0, 1, 2, 0, 2, 0, 3, 3, 3, 0, 4, 1, 0], 47 | [3, 0, 0, 3, 3, 3, 2, 0, 2, 1, 1, 0, 4, 2], 48 | [2, 4, 3, 1, 1, 0, 2, 1, 3, 4, 4, 0, 2, 3], 49 | [2, 4, 3, 3, 2, 1, 4, 0, 3, 4, 1, 2, 0, 0], 50 | [2, 1, 0, 1, 1, 2, 2, 3, 0, 0, 1, 2, 4, 2], 51 | [3, 3, 1, 1, 1, 1, 4, 4, 2, 3, 2, 2, 2, 3]]) 52 | ``` 53 | 54 | 55 | And a kernel like this one: 56 | ```python 57 | kernel = np.array([[0, 1, 3, 3, 2], 58 | [0, 1, 3, 1, 3], 59 | [1, 1, 2, 0, 2], 60 | [2, 2, 3, 2, 0], 61 | [1, 3, 1, 2, 0]]) 62 | ``` 63 | 64 | 65 | ![Images](files/plot_random_matrix_and_kernel.jpg) 66 | 67 | Then, say, you want to apply convolution with `stride = (2, 1)` and `dilation = (1, 2)`. All you need to do is just simply pass it as parameters in `conv2d` function: 68 | ```python 69 | from convolution import conv2d 70 | 71 | feature_map = conv2d(matrix, kernel, stride=(2, 1), dilation=(1, 2), padding=(0, 0)) 72 | ``` 73 | And get the following result: 74 | ![Convolution process](files/convolution_process.gif) 75 | 76 | ### Example with your image and filter 77 | For example, if you want to blur your image, you can use "[Gaussian blur](https://en.wikipedia.org/wiki/Gaussian_blur)" and take the corresponding kernel, while some others can be found [here](https://en.wikipedia.org/wiki/Kernel_(image_processing)). 78 | ```python 79 | import imageio 80 | import matplotlib.pyplot as plt 81 | import numpy as np 82 | 83 | 84 | gaussian_blur = np.array([ 85 | [1, 2, 1], 86 | [2, 4, 2], 87 | [1, 2, 1] 88 | ]) / 16 89 | 90 | 91 | image = imageio.imread('files/pic.jpg') 92 | plt.imshow(image) 93 | ``` 94 | ![*Some extremely beautiful picture*](files/pic.jpg) 95 | 96 | Then you just need to use `apply_filter_to_image` function from `convolution.py` module. 97 | I'm going to make this picture blurry: 98 | ```python 99 | filtered_image = apply_filter_to_image(image, gaussian_blur) 100 | plt.imshow(filtered_image) 101 | ``` 102 | ![*Some extremely beautiful blurred picture*](files/pic_blurred.jpg) 103 | 104 | Tadaa, it's blurred! 105 | 106 | > P.S. This photo is taken near the alpine lake Bachalpsee in Switzerland ([credits](https://unsplash.com/photos/z_f2JrBRbOg)). 107 | 108 | ### Running tests 109 | ```bash 110 | python -m unittest tests.py 111 | ``` 112 | 113 | #### Citation 114 | If you used this repository in your work, consider citing: 115 | ``` 116 | @misc{Convolution from scratch, 117 | author = {Detkov, Nikita}, 118 | title = {Implementation of the generalized 2D convolution with dilation from scratch in Python and NumPy}, 119 | year = {2020}, 120 | publisher = {GitHub}, 121 | journal = {GitHub repository}, 122 | howpublished = {\url{https://github.com/detkov/Convolution-From-Scratch}}, 123 | } 124 | ``` 125 | 126 | Thanks [Matthew Romanishin](https://github.com/matthewromanishin) for the project idea. 127 | -------------------------------------------------------------------------------- /convolution.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from typing import List, Tuple, Union 3 | 4 | 5 | def add_padding(matrix: np.ndarray, 6 | padding: Tuple[int, int]) -> np.ndarray: 7 | """Adds padding to the matrix. 8 | 9 | Args: 10 | matrix (np.ndarray): Matrix that needs to be padded. Type is List[List[float]] casted to np.ndarray. 11 | padding (Tuple[int, int]): Tuple with number of rows and columns to be padded. With the `(r, c)` padding we addding `r` rows to the top and bottom and `c` columns to the left and to the right of the matrix 12 | 13 | Returns: 14 | np.ndarray: Padded matrix with shape `n + 2 * r, m + 2 * c`. 15 | """ 16 | n, m = matrix.shape 17 | r, c = padding 18 | 19 | padded_matrix = np.zeros((n + r * 2, m + c * 2)) 20 | padded_matrix[r : n + r, c : m + c] = matrix 21 | 22 | return padded_matrix 23 | 24 | 25 | def _check_params(matrix, kernel, stride, dilation, padding): 26 | params_are_correct = (isinstance(stride[0], int) and isinstance(stride[1], int) and 27 | isinstance(dilation[0], int) and isinstance(dilation[1], int) and 28 | isinstance(padding[0], int) and isinstance(padding[1], int) and 29 | stride[0] >= 1 and stride[1] >= 1 and 30 | dilation[0] >= 1 and dilation[1] >= 1 and 31 | padding[0] >= 0 and padding[1] >= 0) 32 | assert params_are_correct, 'Parameters should be integers equal or greater than default values.' 33 | if not isinstance(matrix, np.ndarray): 34 | matrix = np.array(matrix) 35 | n, m = matrix.shape 36 | matrix = matrix if list(padding) == [0, 0] else add_padding(matrix, padding) 37 | n_p, m_p = matrix.shape 38 | 39 | if not isinstance(kernel, np.ndarray): 40 | kernel = np.array(kernel) 41 | k = kernel.shape 42 | 43 | kernel_is_correct = k[0] % 2 == 1 and k[1] % 2 == 1 44 | assert kernel_is_correct, 'Kernel shape should be odd.' 45 | matrix_to_kernel_is_correct = n_p >= k[0] and m_p >= k[1] 46 | assert matrix_to_kernel_is_correct, 'Kernel can\'t be bigger than matrix in terms of shape.' 47 | 48 | h_out = np.floor((n + 2 * padding[0] - k[0] - (k[0] - 1) * (dilation[0] - 1)) / stride[0]).astype(int) + 1 49 | w_out = np.floor((m + 2 * padding[1] - k[1] - (k[1] - 1) * (dilation[1] - 1)) / stride[1]).astype(int) + 1 50 | out_dimensions_are_correct = h_out > 0 and w_out > 0 51 | assert out_dimensions_are_correct, 'Can\'t apply input parameters, one of resulting output dimension is non-positive.' 52 | 53 | return matrix, kernel, k, h_out, w_out 54 | 55 | 56 | def conv2d(matrix: Union[List[List[float]], np.ndarray], 57 | kernel: Union[List[List[float]], np.ndarray], 58 | stride: Tuple[int, int] = (1, 1), 59 | dilation: Tuple[int, int] = (1, 1), 60 | padding: Tuple[int, int] = (0, 0)) -> np.ndarray: 61 | """Makes a 2D convolution with the kernel over matrix using defined stride, dilation and padding along axes. 62 | 63 | Args: 64 | matrix (Union[List[List[float]], np.ndarray]): 2D matrix to be convolved. 65 | kernel (Union[List[List[float]], np.ndarray]): 2D odd-shaped matrix (e.g. 3x3, 5x5, 13x9, etc.). 66 | stride (Tuple[int, int], optional): Tuple of the stride along axes. With the `(r, c)` stride we move on `r` pixels along rows and on `c` pixels along columns on each iteration. Defaults to (1, 1). 67 | dilation (Tuple[int, int], optional): Tuple of the dilation along axes. With the `(r, c)` dilation we distancing adjacent pixels in kernel by `r` along rows and `c` along columns. Defaults to (1, 1). 68 | padding (Tuple[int, int], optional): Tuple with number of rows and columns to be padded. Defaults to (0, 0). 69 | 70 | Returns: 71 | np.ndarray: 2D Feature map, i.e. matrix after convolution. 72 | """ 73 | matrix, kernel, k, h_out, w_out = _check_params(matrix, kernel, stride, dilation, padding) 74 | matrix_out = np.zeros((h_out, w_out)) 75 | 76 | b = k[0] // 2, k[1] // 2 77 | center_x_0 = b[0] * dilation[0] 78 | center_y_0 = b[1] * dilation[1] 79 | for i in range(h_out): 80 | center_x = center_x_0 + i * stride[0] 81 | indices_x = [center_x + l * dilation[0] for l in range(-b[0], b[0] + 1)] 82 | for j in range(w_out): 83 | center_y = center_y_0 + j * stride[1] 84 | indices_y = [center_y + l * dilation[1] for l in range(-b[1], b[1] + 1)] 85 | 86 | submatrix = matrix[indices_x, :][:, indices_y] 87 | 88 | matrix_out[i][j] = np.sum(np.multiply(submatrix, kernel)) 89 | return matrix_out 90 | 91 | 92 | def apply_filter_to_image(image: np.ndarray, 93 | kernel: List[List[float]]) -> np.ndarray: 94 | """Applies filter to the given image. 95 | 96 | Args: 97 | image (np.ndarray): 3D matrix to be convolved. Shape must be in HWC format. 98 | kernel (List[List[float]]): 2D odd-shaped matrix (e.g. 3x3, 5x5, 13x9, etc.). 99 | 100 | Returns: 101 | np.ndarray: image after applying kernel. 102 | """ 103 | kernel = np.asarray(kernel) 104 | b = kernel.shape 105 | return np.dstack([conv2d(image[:, :, z], kernel, padding=(b[0]//2, b[1]//2)) 106 | for z in range(3)]).astype('uint8') 107 | -------------------------------------------------------------------------------- /files/conv.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/conv.gif -------------------------------------------------------------------------------- /files/convolution_process.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/convolution_process.gif -------------------------------------------------------------------------------- /files/expl_dilation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/expl_dilation.png -------------------------------------------------------------------------------- /files/expl_padding.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/expl_padding.gif -------------------------------------------------------------------------------- /files/expl_stride.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/expl_stride.png -------------------------------------------------------------------------------- /files/meme.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/meme.jpg -------------------------------------------------------------------------------- /files/pic.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/pic.jpg -------------------------------------------------------------------------------- /files/pic_blurred.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/pic_blurred.jpg -------------------------------------------------------------------------------- /files/plot_random_feature_map.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/plot_random_feature_map.jpg -------------------------------------------------------------------------------- /files/plot_random_matrix_and_kernel.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/detkov/Convolution-From-Scratch/9fb976f72d80a8512b3fd1e35d61bf0ea8869113/files/plot_random_matrix_and_kernel.jpg -------------------------------------------------------------------------------- /plots.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 3 | 4 | # In[1]: 5 | 6 | 7 | import numpy as np 8 | import os 9 | 10 | 11 | from convolution import conv2d 12 | 13 | import seaborn as sns 14 | import matplotlib.pyplot as plt 15 | import matplotlib.gridspec as gridspec 16 | from matplotlib.patches import Rectangle 17 | 18 | 19 | # In[2]: 20 | 21 | 22 | matrix = np.array([[1, 4, 4, 2, 1, 0, 0, 1, 0, 0, 3, 3, 3, 4], 23 | [0, 2, 0, 2, 0, 3, 4, 4, 2, 1, 1, 3, 0, 4], 24 | [1, 1, 0, 0, 3, 4, 2, 4, 4, 2, 3, 0, 0, 4], 25 | [4, 0, 1, 2, 0, 2, 0, 3, 3, 3, 0, 4, 1, 0], 26 | [3, 0, 0, 3, 3, 3, 2, 0, 2, 1, 1, 0, 4, 2], 27 | [2, 4, 3, 1, 1, 0, 2, 1, 3, 4, 4, 0, 2, 3], 28 | [2, 4, 3, 3, 2, 1, 4, 0, 3, 4, 1, 2, 0, 0], 29 | [2, 1, 0, 1, 1, 2, 2, 3, 0, 0, 1, 2, 4, 2], 30 | [3, 3, 1, 1, 1, 1, 4, 4, 2, 3, 2, 2, 2, 3]]) 31 | matrix.shape 32 | 33 | 34 | # In[3]: 35 | 36 | 37 | kernel = np.array([[0, 1, 3, 3, 2], 38 | [0, 1, 3, 1, 3], 39 | [1, 1, 2, 0, 2], 40 | [2, 2, 3, 2, 0], 41 | [1, 3, 1, 2, 0]]) 42 | kernel.shape 43 | 44 | 45 | # In[4]: 46 | 47 | 48 | fig = plt.figure(figsize=(14, 21)) 49 | gs = gridspec.GridSpec(9, 21, figure=fig) 50 | 51 | ax1 = fig.add_subplot(gs[:, :14]) 52 | ax1.set_title('Matrix') 53 | ax1.tick_params(left=False, bottom=False) 54 | ax2 = fig.add_subplot(gs[:, 16:]) 55 | ax2.set_title('Kernel') 56 | ax2.tick_params(left=False, bottom=False) 57 | 58 | sns.heatmap(matrix, cbar=False, annot=True, square=True, cmap='Blues', vmin=-4, ax=ax1) 59 | sns.heatmap(kernel, cbar=False, annot=True, square=True, cmap='Blues', vmin=-4, ax=ax2) 60 | 61 | plt.savefig('files/plot_random_matrix_and_kernel.jpg', bbox_inches='tight') 62 | 63 | 64 | # In[7]: 65 | 66 | 67 | feature_map = conv2d(matrix, kernel, stride=(2, 1), dilation=(1, 2)) 68 | 69 | fig, ax = plt.subplots(figsize=(4,4))#[max(feature_map.shape)]*2)) 70 | 71 | ax = sns.heatmap(feature_map, cbar=False, annot=True, square=True, cmap='Blues', vmin=-4, fmt='.3g') 72 | ax.tick_params(left=False, bottom=False) 73 | ax.set_title('Obtained Feature map') 74 | plt.ylim(plt.ylim()[0]+0.5, plt.ylim()[1]-0.5) 75 | plt.savefig('files/plot_random_feature_map.jpg', bbox_inches='tight') 76 | 77 | 78 | # In[11]: 79 | 80 | 81 | receptive_field_0 = [[0, 0], [2, 0], [4, 0], [6, 0], [8, 0], 82 | [0, 1], [2, 1], [4, 1], [6, 1], [8, 1], 83 | [0, 2], [2, 2], [4, 2], [6, 2], [8, 2], 84 | [0, 3], [2, 3], [4, 3], [6, 3], [8, 3], 85 | [0, 4], [2, 4], [4, 4], [6, 4], [8, 4]] 86 | 87 | 88 | # In[13]: 89 | 90 | 91 | 92 | fig = plt.figure(figsize=(15, 10)) 93 | gs = gridspec.GridSpec(14, 28, figure=fig) 94 | for j_i, j in enumerate([0, 2, 4]): 95 | for i_i, i in enumerate([0, 1, 2, 3, 4, 5]): 96 | fig.clear() 97 | # Highlighted matrix 98 | ax1 = fig.add_subplot(gs[:9, :14]) 99 | ax1.set_title('Matrix') 100 | ax1.tick_params(left=False, bottom=False) 101 | 102 | ax1 = sns.heatmap(matrix, cbar=False, annot=True, square=True, cmap='Blues', vmin=-4) 103 | for x, y in receptive_field_0: 104 | ax1.add_patch(Rectangle([x+i, y+j], 1, 1, fill=False, edgecolor='yellow', lw=2)) 105 | 106 | # Submatrix 107 | submatrix = np.array([matrix[y+j, x+i] for x, y in receptive_field_0]).reshape(5, 5) 108 | 109 | ax2 = fig.add_subplot(gs[:9, 16:21]) 110 | ax2.set_title('Highlighted submatrix') 111 | ax2.tick_params(left=False, bottom=False) 112 | 113 | ax2 = sns.heatmap(submatrix, cbar=False, annot=True, square=True, cmap='Blues', vmin=-4) 114 | ax2.add_patch(Rectangle([0, 0], 5, 5, fill=False, edgecolor='yellow', lw=2)) 115 | 116 | # Kernel 117 | ax3 = fig.add_subplot(gs[:9, 23:]) 118 | ax3.set_title('Kernel') 119 | ax3.tick_params(left=False, bottom=False) 120 | ax3 = sns.heatmap(kernel, cbar=False, annot=True, square=True, cmap='Blues', vmin=-4) 121 | 122 | # Obtained Feature Map 123 | feature_map = conv2d(matrix, kernel, stride=(2, 1), dilation=(1, 2)) 124 | 125 | ax4 = fig.add_subplot(gs[6:, 11:17]) 126 | ax4.set_title('Obtained Feature Map') 127 | ax4.tick_params(left=False, bottom=False) 128 | 129 | ax4 = sns.heatmap(feature_map, cbar=False, annot=True, square=True, cmap='Blues', vmin=-4) 130 | ax4.add_patch(Rectangle([i_i, j_i], 1, 1, fill=False, edgecolor='yellow', lw=2)) 131 | 132 | plt.savefig(f'files/plot_convolution_process_{j}_{i}.jpg', bbox_inches='tight') 133 | 134 | os.system(f'ffmpeg -hide_banner -loglevel warning -pattern_type glob -r 1 -i "files/plot_convolution_process_*.jpg" files/convolution_process.gif') 135 | os.system('rm -rf files/plot_convolution_process_*') -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import unittest 3 | 4 | from convolution import conv2d, add_padding 5 | 6 | 7 | class TestConvolution(unittest.TestCase): 8 | def test_paddings_shape(self, N: int = 1000): 9 | for _ in range(N): 10 | m_h = np.random.randint(3, 100) 11 | m_w = np.random.randint(3, 100) 12 | random_matrix = np.random.rand(m_h, m_w) 13 | 14 | rows, cols = np.random.randint(0, 100, 2) 15 | random_matrix_with_padding = add_padding(random_matrix, (rows, cols)) 16 | 17 | self.assertEqual(random_matrix_with_padding.shape, (m_h + rows*2, m_w + cols*2)) 18 | 19 | 20 | def test_random_case(self, N: int = 1000): 21 | for _ in range(N): 22 | d = np.random.randint(1, 100, 2) 23 | k = np.random.choice([1, 3, 5, 7, 9, 10], 2) # `10` is to check oddness assertion 24 | random_matrix = np.random.rand(*d) 25 | random_kernel = np.random.rand(*k) 26 | for __ in range(N): 27 | stride = np.random.randint(0, 5, 2) # `0` is to check parameters assertion 28 | dilation = np.random.randint(0, 5, 2) # `0` is to check parameters assertion 29 | padding = np.random.randint(-1, 5, 2) # `-1` is to check parameters assertion 30 | try: # `try` in case of division by zero when stride[0] or stride[1] equal to zero 31 | h_out = np.floor((d[0] + 2 * padding[0] - k[0] - (k[0] - 1) * (dilation[0] - 1)) / stride[0]).astype(int) + 1 32 | w_out = np.floor((d[1] + 2 * padding[1] - k[1] - (k[1] - 1) * (dilation[1] - 1)) / stride[1]).astype(int) + 1 33 | except: 34 | h_out, w_out = None, None 35 | # print(f'Matr: {d} | Kern: {k} | Stri: {stride} | Dila: {dilation} | Padd: {padding} | OutD: {h_out, w_out}') # for debugging 36 | 37 | if (stride[0] < 1 or stride[1] < 1 or dilation[0] < 1 or dilation[1] < 1 or padding[0] < 0 or padding[1] < 0 or 38 | not isinstance(stride[0], int) or not isinstance(stride[1], int) or not isinstance(dilation[0], int) or 39 | not isinstance(dilation[1], int) or not isinstance(padding[0], int) or not isinstance(padding[1], int)): 40 | with self.assertRaises(AssertionError): 41 | matrix_conved = conv2d(random_matrix, random_kernel, stride=stride, dilation=dilation, padding=padding) 42 | elif k[0] % 2 != 1 or k[1] % 2 != 1: 43 | with self.assertRaises(AssertionError): 44 | matrix_conved = conv2d(random_matrix, random_kernel, stride=stride, dilation=dilation, padding=padding) 45 | elif d[0] < k[0] or d[1] < k[1]: 46 | with self.assertRaises(AssertionError): 47 | matrix_conved = conv2d(random_matrix, random_kernel, stride=stride, dilation=dilation, padding=padding) 48 | elif h_out <= 0 or w_out <= 0: 49 | with self.assertRaises(AssertionError): 50 | matrix_conved = conv2d(random_matrix, random_kernel, stride=stride, dilation=dilation, padding=padding) 51 | else: 52 | matrix_conved = conv2d(random_matrix, random_kernel, stride=stride, dilation=dilation, padding=padding) 53 | self.assertEqual(matrix_conved.shape, (h_out, w_out)) 54 | 55 | 56 | def test_kernel_3x3_easy(self): 57 | matrix = np.array([[0, 4, 3, 2, 0, 1, 0], 58 | [4, 3, 0, 1, 0, 1, 0], 59 | [1, 3, 4, 2, 0, 1, 0], 60 | [3, 4, 2, 2, 0, 1, 0], 61 | [0, 0, 0, 0, 0, 1, 0]]) 62 | 63 | kernel = np.array([[1, 1, 3], 64 | [0, 2, 3], 65 | [3, 3, 3]]) 66 | 67 | # stride = 1, dilation = 1, padding = 0 68 | result_110 = conv2d(matrix, kernel) 69 | answer_110 = np.array([[43, 43, 25, 17, 6], 70 | [52, 44, 17, 16, 6], 71 | [30, 23, 10, 11, 6]]) 72 | 73 | # stride = 1, dilation = 1, padding = 1 74 | result_111 = conv2d(matrix, kernel, padding=(1, 1)) 75 | answer_111 = np.array([[33, 38, 24, 7, 9, 5, 3], 76 | [41, 43, 43, 25, 17, 6, 4], 77 | [45, 52, 44, 17, 16, 6, 4], 78 | [28, 30, 23, 10, 11, 6, 4], 79 | [15, 13, 12, 4, 8, 3, 1]]) 80 | 81 | # stride = 1, dilation = 2, padding = 0 82 | result_120 = conv2d(matrix, kernel, dilation=(2, 2)) 83 | answer_120 = np.array([[11, 19, 3]]) 84 | 85 | # stride = 1, dilation = 2, padding = 1 86 | result_121 = conv2d(matrix, kernel, dilation=(2, 2), padding=(1, 1)) 87 | answer_121 = np.array([[27, 15, 26, 6, 11], 88 | [22, 11, 19, 3, 8], 89 | [20, 8, 14, 0, 4]]) 90 | 91 | # stride = 2, dilation = 1, padding = 0 92 | result_210 = conv2d(matrix, kernel, stride=(2, 2)) 93 | answer_210 = np.array([[43, 25, 6], 94 | [30, 10, 6]]) 95 | 96 | # stride = 2, dilation = 1, padding = 1 97 | result_211 = conv2d(matrix, kernel, stride=(2, 2), padding=(1, 1)) 98 | answer_211 = np.array([[33, 24, 9, 3], 99 | [45, 44, 16, 4], 100 | [15, 12, 8, 1]]) 101 | 102 | # stride = 2, dilation = 2, padding = 0 103 | result_220 = conv2d(matrix, kernel, stride=(2, 2), dilation=(2, 2)) 104 | answer_220 = np.array([[11, 3]]) 105 | 106 | # stride = 2, dilation = 2, padding = 1 107 | result_221 = conv2d(matrix, kernel, stride=(2, 2), dilation=(2, 2), padding=(1, 1)) 108 | answer_221 = np.array([[27, 26, 11], 109 | [20, 14, 4]]) 110 | 111 | self.assertEqual(result_110.tolist(), answer_110.tolist()) 112 | self.assertEqual(result_111.tolist(), answer_111.tolist()) 113 | self.assertEqual(result_120.tolist(), answer_120.tolist()) 114 | self.assertEqual(result_121.tolist(), answer_121.tolist()) 115 | self.assertEqual(result_210.tolist(), answer_210.tolist()) 116 | self.assertEqual(result_211.tolist(), answer_211.tolist()) 117 | self.assertEqual(result_220.tolist(), answer_220.tolist()) 118 | self.assertEqual(result_221.tolist(), answer_221.tolist()) 119 | 120 | 121 | def test_kernel_5x5_difficult(self): 122 | matrix = np.array([[1, 4, 4, 2, 1, 0, 0, 1, 0, 0, 3, 3, 3, 4], 123 | [0, 2, 0, 2, 0, 3, 4, 4, 2, 1, 1, 3, 0, 4], 124 | [1, 1, 0, 0, 3, 4, 2, 4, 4, 2, 3, 0, 0, 4], 125 | [4, 0, 1, 2, 0, 2, 0, 3, 3, 3, 0, 4, 1, 0], 126 | [3, 0, 0, 3, 3, 3, 2, 0, 2, 1, 1, 0, 4, 2], 127 | [2, 4, 3, 1, 1, 0, 2, 1, 3, 4, 4, 0, 2, 3], 128 | [2, 4, 3, 3, 2, 1, 4, 0, 3, 4, 1, 2, 0, 0], 129 | [2, 1, 0, 1, 1, 2, 2, 3, 0, 0, 1, 2, 4, 2], 130 | [3, 3, 1, 1, 1, 1, 4, 4, 2, 3, 2, 2, 2, 3]]) 131 | 132 | kernel = np.array([[2, 0, 2, 2, 2], 133 | [2, 3, 1, 1, 3], 134 | [3, 1, 1, 3, 1], 135 | [2, 2, 3, 1, 1], 136 | [0, 0, 1, 0, 0]]) 137 | 138 | # default params 139 | result_11_11_00 = conv2d(matrix, kernel) 140 | answer_11_11_00 = np.array([[44., 58., 59., 62., 70., 80., 75., 92., 64., 72.], 141 | [52., 52., 59., 87., 92., 83., 77., 74., 71., 67.], 142 | [66., 63., 60., 64., 76., 79., 75., 82., 77., 64.], 143 | [75., 69., 64., 64., 69., 75., 70., 71., 75., 74.], 144 | [74., 71., 63., 66., 61., 75., 79., 47., 73., 76.]]) 145 | 146 | # only stride: (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (4, 6) 147 | result_12_11_00 = conv2d(matrix, kernel, stride=(1, 2)) 148 | answer_12_11_00 = np.array([[44., 59., 70., 75., 64.], 149 | [52., 59., 92., 77., 71.], 150 | [66., 60., 76., 75., 77.], 151 | [75., 64., 69., 70., 75.], 152 | [74., 63., 61., 79., 73.]]) 153 | 154 | result_13_11_00 = conv2d(matrix, kernel, stride=(1, 3)) 155 | answer_13_11_00 = np.array([[44., 62., 75., 72.], 156 | [52., 87., 77., 67.], 157 | [66., 64., 75., 64.], 158 | [75., 64., 70., 74.], 159 | [74., 66., 79., 76.]]) 160 | 161 | result_21_11_00 = conv2d(matrix, kernel, stride=(2, 1)) 162 | answer_21_11_00 = np.array([[44., 58., 59., 62., 70., 80., 75., 92., 64., 72.], 163 | [66., 63., 60., 64., 76., 79., 75., 82., 77., 64.], 164 | [74., 71., 63., 66., 61., 75., 79., 47., 73., 76.]]) 165 | 166 | result_22_11_00 = conv2d(matrix, kernel, stride=(2, 2)) 167 | answer_22_11_00 = np.array([[44., 59., 70., 75., 64], 168 | [66., 60., 76., 75., 77], 169 | [74., 63., 61., 79., 73]]) 170 | 171 | result_23_11_00 = conv2d(matrix, kernel, stride=(2, 3)) 172 | answer_23_11_00 = np.array([[44., 62., 75., 72.], 173 | [66., 64., 75., 64.], 174 | [74., 66., 79., 76.]]) 175 | 176 | result_31_11_00 = conv2d(matrix, kernel, stride=(3, 1)) 177 | answer_31_11_00 = np.array([[44., 58., 59., 62., 70., 80., 75., 92., 64., 72.], 178 | [75., 69., 64., 64., 69., 75., 70., 71., 75., 74.]]) 179 | 180 | result_32_11_00 = conv2d(matrix, kernel, stride=(3, 2)) 181 | answer_32_11_00 = np.array([[44., 59., 70., 75., 64.], 182 | [75., 64., 69., 70., 75.]]) 183 | 184 | result_46_11_00 = conv2d(matrix, kernel, stride=(4, 6)) 185 | answer_46_11_00 = np.array([[44., 75.], 186 | [74., 79.]]) 187 | 188 | # only dilation: (1, 2), (1, 3), (2, 1), (2, 2), (2, 3) 189 | result_11_12_00 = conv2d(matrix, kernel, dilation=(1, 2)) 190 | answer_11_12_00 = np.array([[46., 70., 50., 77., 65., 94.], 191 | [67., 68., 67., 76., 53., 95.], 192 | [80., 65., 60., 64., 70., 73.], 193 | [74., 74., 77., 73., 79., 55.], 194 | [81., 66., 74., 60., 70., 58.]]) 195 | 196 | result_11_13_00 = conv2d(matrix, kernel, dilation=(1, 3)) 197 | answer_11_13_00 = np.array([[48., 77.], 198 | [65., 65.], 199 | [73., 55.], 200 | [97., 67.], 201 | [84., 68.]]) 202 | result_11_21_00 = conv2d(matrix, kernel, dilation=(2, 1)) 203 | answer_11_21_00 = np.array([[78., 73., 64., 72., 81., 69., 73., 69., 68., 81.]]) 204 | 205 | result_11_22_00 = conv2d(matrix, kernel, dilation=(2, 2)) 206 | answer_11_22_00 = np.array([[67., 55., 80., 63., 77., 79.]]) 207 | 208 | result_11_23_00 = conv2d(matrix, kernel, dilation=(2, 3)) 209 | answer_11_23_00 = np.array([[65., 79.]]) 210 | 211 | # only paddings: (0, 1), (1, 0), (1, 1) 212 | result_11_11_01 = conv2d(matrix, kernel, padding=(0, 1)) 213 | answer_11_11_01 = np.array([[41., 44., 58., 59., 62., 70., 80., 75., 92., 64., 72., 71.], 214 | [34., 52., 52., 59., 87., 92., 83., 77., 74., 71., 67., 43.], 215 | [51., 66., 63., 60., 64., 76., 79., 75., 82., 77., 64., 57.], 216 | [63., 75., 69., 64., 64., 69., 75., 70., 71., 75., 74., 43.], 217 | [51., 74., 71., 63., 66., 61., 75., 79., 47., 73., 76., 54.]]) 218 | 219 | result_11_11_10 = conv2d(matrix, kernel, padding=(1, 0)) 220 | answer_11_11_10 = np.array([[39., 45., 45., 61., 52., 58., 66., 63., 53., 56.], 221 | [44., 58., 59., 62., 70., 80., 75., 92., 64., 72.], 222 | [52., 52., 59., 87., 92., 83., 77., 74., 71., 67.], 223 | [66., 63., 60., 64., 76., 79., 75., 82., 77., 64.], 224 | [75., 69., 64., 64., 69., 75., 70., 71., 75., 74.], 225 | [74., 71., 63., 66., 61., 75., 79., 47., 73., 76.], 226 | [70., 59., 64., 55., 72., 83., 81., 77., 70., 69.]]) 227 | 228 | result_11_11_11 = conv2d(matrix, kernel, padding=(1, 1)) 229 | answer_11_11_11 = np.array([[26., 39., 45., 45., 61., 52., 58., 66., 63., 53., 56., 51.], 230 | [41., 44., 58., 59., 62., 70., 80., 75., 92., 64., 72., 71.], 231 | [34., 52., 52., 59., 87., 92., 83., 77., 74., 71., 67., 43.], 232 | [51., 66., 63., 60., 64., 76., 79., 75., 82., 77., 64., 57.], 233 | [63., 75., 69., 64., 64., 69., 75., 70., 71., 75., 74., 43.], 234 | [51., 74., 71., 63., 66., 61., 75., 79., 47., 73., 76., 54.], 235 | [59., 70., 59., 64., 55., 72., 83., 81., 77., 70., 69., 58.]]) 236 | 237 | # different sets of parameters 238 | result_21_13_00 = conv2d(matrix, kernel, stride=(2, 1), dilation=(1, 3), padding=(0, 0)) 239 | answer_21_13_00 = np.array([[48., 77.], 240 | [73., 55.], 241 | [84., 68.]]) 242 | 243 | result_23_13_13 = conv2d(matrix, kernel, stride=(2, 3), dilation=(1, 3), padding=(1, 3)) 244 | answer_23_13_13 = np.array([[28., 36., 31.], 245 | [53., 65., 47.], 246 | [62., 97., 70.], 247 | [64., 79., 74.]]) 248 | 249 | result_32_23_22 = conv2d(matrix, kernel, stride=(3, 2), dilation=(2, 3), padding=(2, 2)) 250 | answer_32_23_22 = np.array([[54., 55., 34.], 251 | [34., 69., 43.]]) 252 | 253 | # default params 254 | self.assertEqual(result_11_11_00.tolist(), answer_11_11_00.tolist()) 255 | # only stride: (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (4, 6) 256 | self.assertEqual(result_12_11_00.tolist(), answer_12_11_00.tolist()) 257 | self.assertEqual(result_13_11_00.tolist(), answer_13_11_00.tolist()) 258 | self.assertEqual(result_21_11_00.tolist(), answer_21_11_00.tolist()) 259 | self.assertEqual(result_22_11_00.tolist(), answer_22_11_00.tolist()) 260 | self.assertEqual(result_23_11_00.tolist(), answer_23_11_00.tolist()) 261 | self.assertEqual(result_31_11_00.tolist(), answer_31_11_00.tolist()) 262 | self.assertEqual(result_32_11_00.tolist(), answer_32_11_00.tolist()) 263 | self.assertEqual(result_46_11_00.tolist(), answer_46_11_00.tolist()) 264 | # only dilation: (1, 2), (1, 3), (2, 1), (2, 2), (2, 3) 265 | self.assertEqual(result_11_12_00.tolist(), answer_11_12_00.tolist()) 266 | self.assertEqual(result_11_13_00.tolist(), answer_11_13_00.tolist()) 267 | self.assertEqual(result_11_21_00.tolist(), answer_11_21_00.tolist()) 268 | self.assertEqual(result_11_22_00.tolist(), answer_11_22_00.tolist()) 269 | self.assertEqual(result_11_23_00.tolist(), answer_11_23_00.tolist()) 270 | # only paddings: (0, 1), (1, 0), (1, 1) 271 | self.assertEqual(result_11_11_01.tolist(), answer_11_11_01.tolist()) 272 | self.assertEqual(result_11_11_10.tolist(), answer_11_11_10.tolist()) 273 | self.assertEqual(result_11_11_11.tolist(), answer_11_11_11.tolist()) 274 | # different sets of parameters 275 | self.assertEqual(result_21_13_00.tolist(), answer_21_13_00.tolist()) 276 | self.assertEqual(result_23_13_13.tolist(), answer_23_13_13.tolist()) 277 | self.assertEqual(result_32_23_22.tolist(), answer_32_23_22.tolist()) 278 | 279 | 280 | def test_kernel_5x3_difficult(self): 281 | matrix = np.array([[0, 4, 3, 2, 0, 1, 0], 282 | [4, 3, 0, 1, 0, 1, 0], 283 | [1, 3, 4, 2, 0, 1, 0], 284 | [3, 4, 2, 2, 0, 1, 0], 285 | [0, 0, 0, 0, 0, 1, 0]]) 286 | 287 | kernel = np.array([[1, 1, 3], 288 | [0, 2, 3], 289 | [3, 3, 3], 290 | [0, 2, 1], 291 | [3, 3, 0]]) 292 | 293 | # default params 294 | result_11_11_00 = conv2d(matrix, kernel, stride=(1, 1), dilation=(1, 1), padding=(0, 0)) 295 | answer_11_11_00 = np.array([[53., 49., 29., 18., 11.]]) 296 | 297 | # different sets of parameters 298 | result_21_13_00 = conv2d(matrix, kernel, stride=(2, 1), dilation=(1, 3), padding=(0, 0)) 299 | answer_21_13_00 = np.array([[17.]]) 300 | 301 | result_23_13_13 = conv2d(matrix, kernel, stride=(2, 3), dilation=(1, 3), padding=(1, 3)) 302 | answer_23_13_13 = np.array([[34., 38., 9.], 303 | [30., 24., 7.]]) 304 | 305 | result_32_23_42 = conv2d(matrix, kernel, stride=(3, 2), dilation=(2, 3), padding=(4, 2)) 306 | answer_32_23_42 = np.array([[18., 10., 17.], 307 | [18., 17., 11.]]) 308 | 309 | result_21_12_04 = conv2d(matrix, kernel, stride=(2, 1), dilation=(1, 2), padding=(0, 4)) 310 | answer_21_12_04 = np.array([[18., 34., 40., 44., 22., 37., 15., 19., 0., 7., 0.]]) 311 | 312 | result_22_12_04 = conv2d(matrix, kernel, stride=(2, 2), dilation=(1, 2), padding=(0, 4)) 313 | answer_22_12_04 = np.array([[18., 40., 22., 15., 0., 0.]]) 314 | 315 | result_23_13_25 = conv2d(matrix, kernel, stride=(2, 3), dilation=(1, 3), padding=(2, 5)) 316 | answer_23_13_25 = np.array([[15., 27., 21., 0.], 317 | [34., 27., 13., 0.], 318 | [21., 11., 3., 0.]]) 319 | 320 | result_11_11_33 = conv2d(matrix, kernel, stride=(1, 1), dilation=(1, 1), padding=(3, 3)) 321 | answer_11_11_33 = np.array([[ 0., 0., 16., 32., 17., 7., 4., 5., 3., 0., 0.], 322 | [ 0., 4., 26., 39., 49., 35., 16., 8., 6., 0., 0.], 323 | [ 0., 13., 47., 69., 52., 23., 16., 10., 6., 0., 0.], 324 | [ 0., 18., 51., 53., 49., 29., 18., 11., 7., 0., 0.], 325 | [ 0., 24., 45., 52., 44., 17., 17., 8., 4., 0., 0.], 326 | [ 0., 12., 28., 30., 23., 10., 11., 6., 4., 0., 0.], 327 | [ 0., 9., 15., 13., 12., 4., 8., 3., 1., 0., 0.]]) 328 | 329 | # default params 330 | self.assertEqual(result_11_11_00.tolist(), answer_11_11_00.tolist()) 331 | # different sets of parameters 332 | self.assertEqual(result_21_13_00.tolist(), answer_21_13_00.tolist()) 333 | self.assertEqual(result_23_13_13.tolist(), answer_23_13_13.tolist()) 334 | self.assertEqual(result_32_23_42.tolist(), answer_32_23_42.tolist()) 335 | self.assertEqual(result_21_12_04.tolist(), answer_21_12_04.tolist()) 336 | self.assertEqual(result_22_12_04.tolist(), answer_22_12_04.tolist()) 337 | self.assertEqual(result_23_13_25.tolist(), answer_23_13_25.tolist()) 338 | self.assertEqual(result_11_11_33.tolist(), answer_11_11_33.tolist()) 339 | 340 | if __name__ == '__main__': 341 | unittest.main() 342 | --------------------------------------------------------------------------------