├── .circleci └── config.yml ├── .gitignore ├── .travis.yml ├── .vscode └── settings.json ├── LICENSE ├── MANIFEST.in ├── README.md ├── build.py ├── coverage.rc ├── cv_algorithms ├── __init__.py ├── _checks.py ├── _ffi.py ├── classification.py ├── colorspace.py ├── contours.py ├── distance.py ├── grassfire.py ├── morphology.py ├── neighbours.py ├── popcount.py ├── resize.py ├── text.py ├── thinning.py └── utils.py ├── doc ├── DoG.md ├── Grassfire.md └── Thinning.md ├── examples ├── .gitignore ├── difference-of-gaussian-result.png ├── difference-of-gaussian.py ├── grassfire-example.png ├── grassfire-result.png ├── grassfire.py ├── guo-hall-result.png ├── thinning-example.png ├── thinning.py └── zhang-suen-result.png ├── pyproject.toml ├── requirements.txt ├── src ├── common.hpp ├── distance.cpp ├── grassfire.cpp ├── neighbours.cpp ├── popcount.cpp ├── thinning.cpp └── windows.cpp └── tests ├── TestColorspace.py ├── TestContours.py ├── TestDistance.py ├── TestGrassfire.py ├── TestNeighbours.py ├── TestPopcount.py ├── TestThinning.py ├── TestUtils.py └── __init__.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Python CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-python/ for more details 4 | # 5 | version: 2 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers` 11 | - image: ulikoehler/ubuntu-python3-opencv:latest 12 | 13 | # Specify service dependencies here if necessary 14 | # CircleCI maintains a library of pre-built images 15 | # documented at https://circleci.com/docs/2.0/circleci-images/ 16 | # - image: circleci/postgres:9.4 17 | 18 | working_directory: ~/repo 19 | 20 | steps: 21 | - checkout 22 | - run: 23 | name: install dependencies 24 | command: | 25 | python3 setup.py install 26 | 27 | # run tests! 28 | # this example uses Django's built-in test-runner 29 | # other common Python testing frameworks include pytest and nose 30 | # https://pytest.org 31 | # https://nose.readthedocs.io 32 | - run: 33 | name: run tests 34 | command: | 35 | python3 setup.py test 36 | 37 | - store_artifacts: 38 | path: test-reports 39 | destination: test-reports 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | - "2.7" 3 | cache: pip 4 | install: 5 | - sudo pip install numpy codecov rednose nose coverage nose-parameterized opencv-python cffi 6 | - sudo pip install . 7 | - sudo python setup.py install 8 | script: nosetests 9 | dist: xenail 10 | sudo: false 11 | after_success: 12 | codecov 13 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.associations": { 3 | "algorithm": "cpp" 4 | } 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft src/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cv_algorithms 2 | 3 | ![image](https://circleci.com/gh/ulikoehler/cv_algorithms/tree/master.svg?style=svg) 4 | A Python package (Python3 ready!) that contains implementations of various OpenCV algorithms are are not 5 | available in OpenCV or OpenCV-contrib. This package is intended to be used with OpenCV 3. 6 | 7 | Some performance-critical algorithms are written in optimized C code. The C code is accessed using [cffi](https://cffi.readthedocs.io/en/latest/) 8 | Currently implemented: 9 | - Morphological algorithms 10 | - [Guo-Hall thinning (C-optimized)](https://github.com/ulikoehler/cv_algorithms/blob/master/doc/Thinning.md) 11 | - [Zhang-Suen thinning (C-optimized)](https://github.com/ulikoehler/cv_algorithms/blob/master/doc/Thinning.md) 12 | - [Difference-of-Gaussian transform](https://github.com/ulikoehler/cv_algorithms/blob/master/doc/DoG.md) 13 | - Algorithms on contours 14 | - Masking extraction of convex polygon area from image without rotation 15 | - Scale around reference point or center 16 | - Fast computation of center by coordinate averaging 17 | - Center-invariant rescaling of upright bounding rectangle by x/ factors 18 | - Filter by min/max area 19 | - Sort by area 20 | - Create binary contour mask 21 | - [Grassfire transform](https://github.com/ulikoehler/cv_algorithms/blob/master/doc/Grassfire.md) 22 | - Colorspace metrics & utilities: 23 | - Convert image to any colorspace supported by OpenCV 24 | - Extract any channel from any colorspace directly 25 | - Euclidean RGB distance 26 | - Other structural algorithms 27 | - Which neighboring pixels are set in a binary image? 28 | - Algorithms on text rendering 29 | - Center text at coordinates 30 | - Auto-scale text to fix into box 31 | - Other algorithms 32 | - Remove n percent of image borders 33 | - Popcount (number of one bits) for 8, 16, 32 and 64 bit numpy arrays 34 | - Resize an image, maintaining the aspect ratio 35 | 36 | As OpenCV's Python bindings (`cv2`) represents images as [numpy](http://www.numpy.org/) arrays, most algorithms generically work with *numpy*1 arrays. 37 | 38 | ## Installation 39 | 40 | Install the *stable* version: 41 | 42 | ``` {.sourceCode .bash} 43 | pip install cv_algorithms 44 | ``` 45 | 46 | How to install the *bleeding-edge* version from GitHub 47 | 48 | ``` {.sourceCode .bash} 49 | pip install git+https://github.com/ulikoehler/cv_algorithms.git 50 | ``` 51 | 52 | How to *build yourself* - we use [Poetry](https://python-poetry.org/): 53 | ```sh 54 | poetry build 55 | ``` 56 | 57 | Potentially, you need to [install OpenCV](https://techoverflow.net/2022/01/23/how-to-fix-python-modulenotfounderror-no-module-named-cv2-on-windows/) if not already present. I recommend first trying to install without that, since modern Python versions will take care of that automatically. 58 | 59 | ## Usage 60 | 61 | [Difference of Gaussian transform documentation & example](https://github.com/ulikoehler/cv_algorithms/blob/master/doc/DoG.md) 62 | [Grassfire transform documentation & example](https://github.com/ulikoehler/cv_algorithms/blob/master/doc/Grassfire.md) 63 | [Thinning documentation & example](https://github.com/ulikoehler/cv_algorithms/blob/master/doc/Thinning.md) 64 | 65 | Here\'s a simple usage showcase: 66 | 67 | ``` {.sourceCode .python} 68 | import cv_algorithms 69 | # img must be a binary, single-channel (grayscale) image. 70 | thinned = cv_algorithms.guo_hall(img) 71 | ``` 72 | 73 | ## Contributions 74 | 75 | Contributions of any shape or form are welcome. Please submit a pull 76 | request or file an issue on GitHub. 77 | 78 | Copyright (c) 2016-2022 Uli Köhler \<\> 79 | -------------------------------------------------------------------------------- /build.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import shutil 4 | from setuptools import Distribution 5 | from setuptools import Extension 6 | from distutils.command.build_ext import build_ext 7 | 8 | extra_compile_args = [] if os.name == 'nt' else ["-g", "-O2"] 9 | extra_link_args = [] if os.name == 'nt' else ["-g"] 10 | platform_src = ["src/windows.cpp"] if os.name == 'nt' else [] 11 | 12 | ext_modules = [ 13 | Extension('cv_algorithms._cv_algorithms', 14 | include_dirs = [os.path.join(os.path.dirname(__file__), "src")], 15 | sources=['src/thinning.cpp', 16 | 'src/distance.cpp', 17 | 'src/grassfire.cpp', 18 | 'src/popcount.cpp', 19 | 'src/neighbours.cpp'] + platform_src, 20 | extra_compile_args=extra_compile_args, 21 | extra_link_args=extra_link_args) 22 | ] 23 | 24 | class BuildFailed(Exception): 25 | pass 26 | 27 | class ExtBuilder(build_ext): 28 | 29 | def run(self): 30 | try: 31 | build_ext.run(self) 32 | except (DistutilsPlatformError, FileNotFoundError): 33 | raise BuildFailed('File not found. Could not compile C extension.') 34 | 35 | def build_extension(self, ext): 36 | try: 37 | build_ext.build_extension(self, ext) 38 | except (CCompilerError, DistutilsExecError, DistutilsPlatformError, ValueError): 39 | raise BuildFailed('Could not compile C extension.') 40 | 41 | 42 | def build(): 43 | """ 44 | This function is mandatory in order to build the extensions. 45 | """ 46 | distribution = Distribution({"name": "cv_algorithm", "ext_modules": ext_modules}) 47 | distribution.package_dir = {"cv_algorithm": "cv_algorithm"} 48 | 49 | cmd = build_ext(distribution) 50 | cmd.ensure_finalized() 51 | cmd.run() 52 | 53 | # Copy built extensions back to the project 54 | for output in cmd.get_outputs(): 55 | relative_extension = os.path.relpath(output, cmd.build_lib) 56 | shutil.copyfile(output, relative_extension) 57 | mode = os.stat(relative_extension).st_mode 58 | mode |= (mode & 0o444) >> 2 59 | os.chmod(relative_extension, mode) 60 | 61 | if __name__ == "__main__": 62 | build() 63 | -------------------------------------------------------------------------------- /coverage.rc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = cv_algorithms 3 | branch = True 4 | 5 | [report] 6 | # Regexes for lines to exclude from consideration 7 | exclude_lines = 8 | # Have to re-enable the standard pragma 9 | pragma: no cover 10 | 11 | # Don't complain if tests don't hit defensive assertion code: 12 | raise AssertionError 13 | raise NotImplementedError 14 | 15 | # Don't complain if non-runnable code isn't run: 16 | if 0: 17 | if __name__ == .__main__.: 18 | 19 | ignore_errors = True -------------------------------------------------------------------------------- /cv_algorithms/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Import submodules to toplevel 5 | from .text import * 6 | from .contours import * 7 | from .classification import * 8 | from .neighbours import * 9 | from .morphology import * 10 | from .popcount import * 11 | from .thinning import * 12 | from .grassfire import * 13 | from .distance import * 14 | from .utils import * 15 | from .colorspace import * 16 | -------------------------------------------------------------------------------- /cv_algorithms/_checks.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import numpy as np 3 | 4 | __all__ = ["__check_image_c_order", "__check_image_grayscale_2d", 5 | "__check_image_min_wh", "__check_array_uint8", 6 | "force_c_order_contiguous"] 7 | 8 | 9 | def __check_image_min_wh(img, min_width, min_height): 10 | """Raise if the image does not have a given minimum width and height""" 11 | height, width = img.shape 12 | if height < min_height or width < min_width: 13 | raise ValueError("Thinning algorithm needs an image at least 3px wide and 3px high but size is {}".format(img.shape)) 14 | 15 | 16 | def __check_image_c_order(img): 17 | """Raise if the image is not in FORTRAN memory order""" 18 | # Check array memory order 19 | if np.isfortran(img): # i.e. not C-ordered 20 | raise ValueError("cv_algorithms works only on C-ordered arrays") 21 | if not img.flags['C_CONTIGUOUS']: 22 | raise ValueError("cv_algorithms works only on contiguous arrays") 23 | 24 | 25 | def force_c_order_contiguous(img): 26 | """Raise if the image is not in FORTRAN memory order""" 27 | # Check array memory order 28 | if not img.flags['C_CONTIGUOUS']: # i.e. not C-ordered 29 | return np.ascontiguousarray(img) 30 | return img 31 | 32 | 33 | def __check_image_grayscale_2d(img): 34 | """Raise if the image is not a 2D image""" 35 | nd = len(img.shape) 36 | if nd == 3: 37 | raise ValueError("Can only use binary (i.e. grayscale) images") 38 | if nd != 2: 39 | raise ValueError("Image has wrong number of dimensions ({0} instead of 2)".format(nd)) 40 | 41 | def __check_array_uint8(img): 42 | """Raise if the image is not a 2D image""" 43 | if img.dtype != np.uint8: 44 | raise ValueError("Can only use images that have np.uint8 dtype") 45 | -------------------------------------------------------------------------------- /cv_algorithms/_ffi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | from cffi import FFI 3 | import sys 4 | 5 | __all__ = ["_ffi", "_libcv_algorithms"] 6 | 7 | _ffi = FFI() 8 | 9 | # Open native library 10 | if sys.version_info >= (3, 4): 11 | import importlib.util 12 | soname = importlib.util.find_spec("cv_algorithms._cv_algorithms").origin 13 | else: 14 | import imp 15 | curmodpath = sys.modules["cv_algorithms"].__path__ 16 | soname = imp.find_module('_cv_algorithms', curmodpath)[1] 17 | 18 | _libcv_algorithms = _ffi.dlopen(soname) 19 | -------------------------------------------------------------------------------- /cv_algorithms/classification.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Utilities for image classification 5 | """ 6 | import numpy as np 7 | 8 | __all__ = ["fractionWhite", "fractionBlack"] 9 | 10 | def fractionWhite(img, minval=255, weights=None): 11 | """ 12 | Given a binary image, counts the fraction of the pixels which are white, 13 | i.e. have a value above a specific threshold. 14 | 15 | Parameters 16 | ---------- 17 | img : numpy (x,y) array 18 | The image to check 19 | minval : number 20 | Which value is the minimum to be considered white. 21 | The type must be the same as the image pixel type (usually int 0-255) 22 | weights : numpy (x,y) array 23 | An optional weights array (default 1) that modifies the 24 | weight of each pixel, if it passes the whiteness check 25 | """ 26 | if len(img.shape) > 2: 27 | raise ValueError("Can only work with binary grayscale images") 28 | vals = img >= minval 29 | if weights is not None: 30 | vals = weights[vals] 31 | return np.sum(vals) / float(img.size) 32 | 33 | def fractionBlack(img, maxval=0, weights=None): 34 | """ 35 | Given a binary image, counts the fraction of the pixels which are black, 36 | i.e. have a value below a specific threshold 37 | 38 | Parameters 39 | ---------- 40 | img : numpy (x,y) array 41 | The image to check 42 | maxval : number 43 | Which value is the maximum to be considered black. 44 | The type must be the same as the image pixel type (usually int 0-255) 45 | weights : numpy (x,y) array 46 | An optional weights array (default 1) that modifies the 47 | weight of each pixel, if it passes the blackness check 48 | """ 49 | if len(img.shape) > 2: 50 | raise ValueError("Can only work with binary grayscale images") 51 | vals = img <= maxval 52 | if weights is not None: 53 | vals = weights[vals] 54 | return np.sum(vals) / float(img.size) 55 | -------------------------------------------------------------------------------- /cv_algorithms/colorspace.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Utilities for extracting color channels out of arbitrary images 5 | """ 6 | import cv2 7 | import numpy as np 8 | import enum 9 | from .text import putTextAutoscale 10 | 11 | __all__ = ["ColorspaceChannel", "Colorspace", "convert_to_colorspace", 12 | "extract_channel", "colorspace_components_overview"] 13 | 14 | 15 | class Colorspace(enum.IntEnum): 16 | BGR = 0 # "Standard" OpenCV colorspace 17 | RGB = 1 18 | HSV = 2 19 | LAB = 3 20 | YUV = 4 21 | YCrCb = 5 22 | HLS = 6 23 | LUV = 7 24 | XYZ = 8 25 | 26 | @property 27 | def channels(colspace): 28 | """ 29 | Get a tuple of all three ColorspaceChannels 30 | for the given colorspace 31 | """ 32 | return (ColorspaceChannel(colspace.value * 3), 33 | ColorspaceChannel(colspace.value * 3 + 1), 34 | ColorspaceChannel(colspace.value * 3 + 2)) 35 | 36 | class ColorspaceChannel(enum.IntEnum): 37 | """ 38 | Different types of color channels 39 | """ 40 | # BGR 41 | BGR_Blue = 0 42 | BGR_Green = 1 43 | BGR_Red = 2 44 | # RGB 45 | RGB_Red = 3 46 | RGB_Green = 4 47 | RGB_Blue = 5 48 | # HSV 49 | HSV_Hue = 6 50 | HSV_Saturation = 7 51 | HSV_Value = 8 52 | # LAB 53 | LAB_L = 9 54 | LAB_a = 10 55 | LAB_b = 11 56 | # YUV 57 | YUV_Luma = 12 # Y 58 | YUV_U = 13 59 | YUV_V = 14 60 | # YCrCb 61 | YCrCb_Luma = 15 # Y 62 | YCrCb_Cr = 16 63 | YCrCb_Cb = 17 64 | # HLS 65 | HLS_Hue = 18 66 | HLS_Lightness = 19 67 | HLS_Saturation = 20 68 | # LUV 69 | LUV_L = 21 70 | LUV_U = 22 71 | LUV_V = 23 72 | # XYZ 73 | XYZ_X = 24 74 | XYZ_Y = 25 75 | XYZ_Z = 26 76 | 77 | 78 | @property 79 | def colorspace(self): 80 | """ 81 | Get the colorspace for the current instance 82 | """ 83 | return Colorspace(self.value // 3) 84 | 85 | @property 86 | def channel_idx(self): 87 | """ 88 | Get the channel number for the colorspace (0 to 2) 89 | """ 90 | return self.value % 3 91 | 92 | @property 93 | def channel_name(self): 94 | """ 95 | The name of the channel, 96 | not including the colorspace name. 97 | 98 | Example: RGB_Red => Red 99 | """ 100 | return self.name.partition("_")[2] 101 | 102 | 103 | # Arguments to convert BGR to another colorspace 104 | _colorspace_cvt_from_bgr = { 105 | Colorspace.BGR: None, 106 | Colorspace.RGB: cv2.COLOR_BGR2RGB, 107 | Colorspace.HSV: cv2.COLOR_BGR2HSV, 108 | Colorspace.LAB: cv2.COLOR_BGR2LAB, 109 | Colorspace.YUV: cv2.COLOR_BGR2YUV, 110 | Colorspace.YCrCb: cv2.COLOR_BGR2YCrCb, 111 | Colorspace.HLS: cv2.COLOR_BGR2HLS, 112 | Colorspace.LUV: cv2.COLOR_BGR2LUV, 113 | Colorspace.XYZ: cv2.COLOR_BGR2XYZ 114 | } 115 | 116 | # Arguments to convert a colorspace to BGR 117 | _colorspace_cvt_to_bgr = { 118 | Colorspace.BGR: None, 119 | Colorspace.RGB: cv2.COLOR_RGB2BGR, 120 | Colorspace.HSV: cv2.COLOR_HSV2BGR, 121 | Colorspace.LAB: cv2.COLOR_LAB2BGR, 122 | Colorspace.YUV: cv2.COLOR_YUV2BGR, 123 | Colorspace.YCrCb: cv2.COLOR_YCrCb2BGR, 124 | Colorspace.HLS: cv2.COLOR_HLS2BGR, 125 | Colorspace.LUV: cv2.COLOR_LUV2BGR, 126 | Colorspace.XYZ: cv2.COLOR_XYZ2BGR 127 | } 128 | 129 | def convert_to_colorspace(img, new_colorspace, source=Colorspace.BGR): 130 | """ 131 | Convert an image in an arbitrary colorspace 132 | to another colorspace using OpenCV 133 | 134 | Parameters 135 | ========== 136 | img : NumPy image 137 | Any supported OpenCV image 138 | new_colorspace : Colorspace enum 139 | The target colorspace 140 | source : Colorspace enum 141 | The source colorspace. 142 | If in doubt, BGR is probably right 143 | 144 | Returns 145 | ======= 146 | The converted image, or img if 147 | source == target. 148 | """ 149 | # Convert from source to BGR 150 | if source != Colorspace.BGR: 151 | img = cv2.cvtColor(img, _colorspace_cvt_to_bgr[source]) 152 | # Convert to target 153 | cvt = _colorspace_cvt_from_bgr[new_colorspace] 154 | if cvt is None: # Already in target 155 | return img 156 | return cv2.cvtColor(img, cvt) 157 | 158 | def extract_channel(img, channel, source=Colorspace.BGR, as_rgb=False): 159 | """ 160 | Extract a single channel from an arbitrary colorspace 161 | from an image 162 | 163 | Parameters 164 | ========== 165 | img : NumPy / OpenCV image 166 | channel : ColorspaceChannel enum 167 | The target channel 168 | source : Colorspace enum 169 | The current colorspace of the imge 170 | as_rgb : bool 171 | Set to True to obtain the graysca 172 | Returns 173 | ======= 174 | The resulting channel as a NumPy image. 175 | The returned array is similar to a grayscale image. 176 | """ 177 | target_space = channel.colorspace 178 | # Convert to the correct colorspace 179 | img = convert_to_colorspace(img, target_space, source) 180 | # Extract appropriate channel 181 | gray = img[:,:,channel.channel_idx] 182 | return cv2.cvtColor(gray, cv2.COLOR_GRAY2RGB) if as_rgb else gray 183 | 184 | 185 | def colorspace_components_overview(img): 186 | """ 187 | Render an image that shows all channels of the given image 188 | in all colorspaces in an ordered and labeled manner. 189 | """ 190 | height, width, _ = img.shape 191 | ncolspaces = len(Colorspace) 192 | hspace = int(0.1 * width) 193 | vspace = int(0.1 * height) 194 | textheight = int(0.3 * height) 195 | 196 | h = ncolspaces * height + vspace * (ncolspaces - 1) + textheight * ncolspaces 197 | w = width * 3 + hspace * 2 198 | out = np.full((h, w), 255, dtype=np.uint8) 199 | 200 | for i, colorspace in enumerate(Colorspace): 201 | # Compute offsets 202 | vofs = textheight * (i + 1) + (vspace + height) * i 203 | hofs = lambda col: hspace * col + width * col 204 | textvofs = vofs - textheight / 2 205 | texthofs = lambda col: hofs(col) + width / 2 206 | # Get channels of current colorspace 207 | channels = colorspace.channels 208 | # Channel text 209 | chn0txt = "{} {}".format(colorspace.name, colorspace.channels[0].channel_name) 210 | chn1txt = "{} {}".format(colorspace.name, colorspace.channels[1].channel_name) 211 | chn2txt = "{} {}".format(colorspace.name, colorspace.channels[2].channel_name) 212 | # Extract all channels and convert to gray RGB mge 213 | chn0 = extract_channel(img, channels[0]) 214 | chn1 = extract_channel(img, channels[1]) 215 | chn2 = extract_channel(img, channels[2]) 216 | # Copy image channels to output 217 | out[vofs:vofs + height, hofs(0):hofs(0) + width] = chn0 218 | out[vofs:vofs + height, hofs(1):hofs(1) + width] = chn1 219 | out[vofs:vofs + height, hofs(2):hofs(2) + width] = chn2 220 | # Render text 221 | putTextAutoscale(out, chn0txt, (texthofs(0), textvofs), 222 | cv2.FONT_HERSHEY_COMPLEX, width, textheight, color=0) 223 | putTextAutoscale(out, chn1txt, (texthofs(1), textvofs), 224 | cv2.FONT_HERSHEY_COMPLEX, width, textheight, color=0) 225 | putTextAutoscale(out, chn2txt, (texthofs(2), textvofs), 226 | cv2.FONT_HERSHEY_COMPLEX, width, textheight, color=0) 227 | 228 | return out 229 | -------------------------------------------------------------------------------- /cv_algorithms/contours.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Contour utilities 5 | """ 6 | import cv2 7 | import numpy as np 8 | 9 | __all__ = ["meanCenter", "scaleByRefpoint", "extractPolygonMask", "expandRectangle", 10 | "cropBorderFraction", "filter_min_area", "filter_max_area", 11 | "sort_by_area", "contour_mask"] 12 | 13 | 14 | def meanCenter(contour): 15 | """ 16 | Compute the center of a contour by taking the mean of all coordinates 17 | 18 | Parameters 19 | ---------- 20 | contour 21 | (n,2) numpy array of coordinates 22 | """ 23 | return np.mean(contour, axis=0) 24 | 25 | 26 | def scaleByRefpoint(contour, xscale=1., yscale=1., refpoint=None): 27 | """ 28 | Scale a contour around a reference point. 29 | Takes a (n,2) coordinate array and optionally a reference point. 30 | If the reference point is None, it is computed from the contour 31 | 32 | Parameters 33 | ---------- 34 | contour 35 | A (n,2) array of 2D coordinates 36 | xscale 37 | The x axis scale factor 38 | yscale 39 | The y axis scale factor 40 | """ 41 | if refpoint is None: 42 | refpoint = meanCenter(contour) 43 | # Create transformation matrix 44 | refx, refy = refpoint 45 | contour = contour.copy() # Don't work on original 46 | # Shift so that refpoint is origin 47 | contour[:, 0] -= refx 48 | contour[:, 1] -= refy 49 | # Apply scale 50 | contour[:, 0] *= xscale 51 | contour[:, 1] *= yscale 52 | # Shift reference point 53 | contour[:, 0] += refx 54 | contour[:, 1] += refy 55 | return contour 56 | 57 | 58 | def extractPolygonMask(img, rotrect, invmask=False, is_convex=True): 59 | """ 60 | Extract a potentially rotated polygon from an image without rotating anything. 61 | (Rotation will cause inaccuracies and interpolation and therefore is often 62 | not desirable) 63 | 64 | This works by first extracting the polygon bounding box from the image, 65 | then creating an equivalently sized mask. Then, the original (yet now origin-referenced) 66 | rotated rectangle is drawn into said mask in black. 67 | After than, the mask is ORed onto the img section (for invmask=False) 68 | or ANDed onto the img section (for invmask=True), resulting in masked areas 69 | to be white (invmask=False) or black (invmask=True). 70 | 71 | This function will work only with grayscale images. 72 | 73 | Parameters 74 | ---------- 75 | img : numpy (x,y) array 76 | The image to extract from 77 | rotrect : A 4-contour e.g. as returned by cv2.boundingBox(cv2.minAreaRect()) 78 | The rectangle to extract 79 | invmask : bool 80 | Set to True for black fill color. 81 | Set to false for white fill color. 82 | is_convex : bool 83 | Set to false if the given polgon may not be convex. 84 | """ 85 | x, y, w, h = cv2.boundingRect(rotrect) 86 | # Extract image section 87 | imgsec = img[y:y+h, x:x+w] 88 | # Create new, equivalently sized image 89 | mask = np.full_like(imgsec, 0 if invmask else 255) 90 | # Reference rr to origin 91 | rect_mask = rotrect - np.asarray([x, y]) 92 | # Draw rr in the mask in black so we can OR the mask later 93 | if is_convex: 94 | cv2.fillConvexPoly(mask, rect_mask, 0) 95 | else: 96 | cv2.fillPoly(mask, [rect_mask], 0) 97 | # Apply mask to image section 98 | if invmask: 99 | imgsec &= mask 100 | else: 101 | imgsec |= mask 102 | return imgsec 103 | 104 | 105 | def expandRectangle(rect, xfactor=3, yfactor=3): 106 | """ 107 | Takes a (x,y,w,h) rectangle tuple and returns a new bounding 108 | rectangle that is centered on the center of the origin rectangle, 109 | but has a width/height that is larger by a given factor. 110 | 111 | The returned coordinates are rounded to integers 112 | """ 113 | x, y, w, h = rect 114 | # Horizontal expansion 115 | x -= ((xfactor - 1) / 2) * w 116 | w *= xfactor 117 | # Horizontal expansion 118 | y -= ((yfactor - 1) / 2) * h 119 | h *= yfactor 120 | return (int(round(x)), int(round(y)), 121 | int(round(w)), int(round(h))) 122 | 123 | 124 | def cropBorderFraction(img, crop_left=.1, crop_right=.1, crop_top=.1, crop_bot=.1): 125 | """ 126 | Crop a fraction of the image at its borders. 127 | For example, cropping 10% (.1) of a 100x100 image left border 128 | would result in the leftmost 10px to be cropped. 129 | 130 | The number of pixels to be cropped are computed based on the original 131 | image size. 132 | """ 133 | w, h = img.shape[0], img.shape[1] 134 | nleft = int(round(crop_left * w)) 135 | nright = int(round(crop_right * w)) 136 | ntop = int(round(crop_top * h)) 137 | nbot = int(round(crop_bot * h)) 138 | return img[ntop:-nbot, nleft:-nright] 139 | 140 | def filter_min_area(contours, min_area): 141 | """ 142 | Filter a list of contours, requiring 143 | a polygon to have an area of >= min_area 144 | to pass the filter. 145 | 146 | Uses OpenCV's contourArea for fast area computation 147 | 148 | Returns a list of contours. 149 | """ 150 | return list(filter(lambda cnt: cv2.contourArea(cnt) >= min_area, 151 | contours)) 152 | 153 | def filter_max_area(contours, max_area): 154 | """ 155 | Filter a list of contours, requiring 156 | a polygon to have an area of <= max_area 157 | to pass the filter. 158 | 159 | Uses OpenCV's contourArea for fast area computation 160 | 161 | Returns a list of contours. 162 | """ 163 | return list(filter(lambda cnt: cv2.contourArea(cnt) <= max_area, 164 | contours)) 165 | 166 | def sort_by_area(contours, reverse=False): 167 | """ 168 | Sort a list of contours by area 169 | """ 170 | return sorted(contours, key=cv2.contourArea, reverse=True) 171 | 172 | def contour_mask(shape, cnt): 173 | """ 174 | Generate a black-white mask of one or multiple contours 175 | 176 | Parameters 177 | ========== 178 | cnt : Numpy array of points (contour) or list of contours 179 | The contour(s) to mark. May overlap. 180 | img: Numpy array or (width,height) tuple 181 | The image or its shape (only the .shape property will be used) 182 | Contour parts are ignored if they are outside the image 183 | 184 | Returns 185 | ======= 186 | A black-white np.uint8 image with the contour marked in 100% white 187 | """ 188 | if isinstance(cnt, np.ndarray): 189 | cnt = [cnt] 190 | if isinstance(shape, np.ndarray): 191 | shape = shape.shape 192 | if len(shape) > 2: 193 | shape = (shape[0], shape[1]) 194 | # Create mask image 195 | mask = np.zeros(shape, dtype=np.uint8) 196 | cv2.drawContours(mask, cnt, -1, (255,255,255), -1) 197 | return mask 198 | -------------------------------------------------------------------------------- /cv_algorithms/distance.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Various distance metrics beteen images and related 5 | data structures 6 | """ 7 | # -*- coding: utf-8 -*- 8 | from ._ffi import * 9 | import numpy as np 10 | 11 | __all__ = ["pairwise_diff", "rgb_distance", "grayscale_distance"] 12 | 13 | _ffi.cdef(''' 14 | int pairwise_diff(const double* a, const double* b, double* result, size_t awidth, size_t bwidth); 15 | ''') 16 | 17 | def pairwise_diff(a, b): 18 | """ 19 | Compute the pairwise |a-b| absolute difference between two 1D float arrays, a and b 20 | Returns a (a.size, b.size) float distance matrix. 21 | 22 | Uses a C-optimized backend. 23 | """ 24 | if len(a.shape) != 1 or len(b.shape) != 1: 25 | raise ValueError("The arrays must have a 1D shape. Actual shape: {0} and {1}".format(a.shape, b.shape)) 26 | if (a.nbytes // a.size) != 8 or (b.nbytes // b.size) != 8: 27 | raise ValueError("The arrays need to have 64-bit float elements") 28 | # Allocate output array 29 | out = np.zeros((a.shape[0], b.shape[0]), np.float64) 30 | 31 | aptr = _ffi.cast("const double*", a.ctypes.data) 32 | bptr = _ffi.cast("const double*", b.ctypes.data) 33 | outptr = _ffi.cast("double*", out.ctypes.data) 34 | 35 | _libcv_algorithms.pairwise_diff(aptr, bptr, outptr, a.shape[0], b.shape[0]) 36 | 37 | return out 38 | 39 | def rgb_distance(img, color): 40 | """ 41 | Compute the euclidean distance between 42 | the given color and each pixel in the image 43 | in the RGB space. 44 | 45 | Computes 46 | sqrt(rdelta² + gdelta² + bdelta²) 47 | 48 | Parameters 49 | ========== 50 | img : 2D RGB image as numpy array 51 | The RGB or BGR OpenCV image 52 | color : RGB 3-tuple 53 | 54 | Returns 55 | ======= 56 | A numpy float array the same size as img, 57 | representing the pixel-to-color distances 58 | """ 59 | imgfloat = img.astype(float) 60 | return np.sqrt(np.sum(np.square(imgfloat[:,:] - color), axis=2)) 61 | 62 | def grayscale_distance(img, value): 63 | """ 64 | Compare a value. 65 | This is similar to 66 | 67 | np.abs(img - value) 68 | but it handles negative values in uint8 images correctly. 69 | 70 | Parameters 71 | ========== 72 | img 73 | A 2D numpy array 74 | value: 75 | Any grayscale value (single number) to compare to. 76 | 77 | Returns 78 | ======= 79 | A floating point absolute difference image 80 | """ 81 | return np.abs(img.astype(float) - float(value)) 82 | -------------------------------------------------------------------------------- /cv_algorithms/grassfire.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Thinning algorithms 5 | """ 6 | import numpy as np 7 | from ._ffi import * 8 | from ._checks import * 9 | 10 | __all__ = ["grassfire"] 11 | 12 | _ffi.cdef(''' 13 | int grassfire(uint32_t* dst, const uint8_t* mask, int width, int height); 14 | ''') 15 | 16 | def grassfire(img): 17 | """ 18 | Perform a grassfire transform on the given binary image. 19 | 20 | Parameters 21 | ========== 22 | img : numpy array-like 23 | A grayscale image that is assumed to be binary 24 | (every non-zero value is interpreted as 0). 25 | For example a countour image. 26 | 27 | Returns 28 | ======= 29 | A uint32-type numpy array of the same dimensions 30 | as img, representing the grassfire count. 31 | """ 32 | # Check if image has the correct type 33 | __check_image_grayscale_2d(img) 34 | img = force_c_order_contiguous(img) 35 | __check_array_uint8(img) 36 | 37 | height, width = img.shape 38 | 39 | # Allocate output array 40 | # uint32 is used so there is no overflow for large inputs 41 | out = np.zeros(img.shape, dtype=np.uint32, order="C") 42 | assert not np.isfortran(out) 43 | 44 | # Extract pointer to binary data 45 | maskptr = _ffi.cast("uint8_t*", img.ctypes.data) 46 | outptr = _ffi.cast("uint32_t*", out.ctypes.data) 47 | 48 | rc = _libcv_algorithms.grassfire(outptr, maskptr, width, height) 49 | if rc != 0: 50 | raise ValueError("Internal error (return code {0}) in algorithm C code".format(rc)) 51 | return out 52 | 53 | 54 | -------------------------------------------------------------------------------- /cv_algorithms/morphology.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import cv2 3 | 4 | __all__ = ["difference_of_gaussian"] 5 | 6 | def difference_of_gaussian(img, ksize1, ksize2, invert=False, normalize=True): 7 | img1 = cv2.GaussianBlur(img, (ksize1, ksize1), 0) 8 | img2 = cv2.GaussianBlur(img, (ksize2, ksize2), 0) 9 | dog = cv2.subtract(img1, img2) 10 | # Normalize 11 | if normalize: 12 | dog = cv2.normalize(dog, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U) 13 | if invert: 14 | dog = 255 - dog 15 | return dog 16 | -------------------------------------------------------------------------------- /cv_algorithms/neighbours.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Thinning algorithms 5 | """ 6 | import numpy as np 7 | from ._ffi import * 8 | from ._checks import * 9 | import enum 10 | 11 | __all__ = ["binary_neighbours", "Neighbours", "Direction"] 12 | 13 | _ffi.cdef(''' 14 | int binary_neighbours(uint8_t* dst, const uint8_t* src, int width, int height); 15 | ''') 16 | 17 | def binary_neighbours(img): 18 | """ 19 | Takes a binary image and, for each pixel, computes 20 | which surrounding pixels are non-zero. 21 | Depdending on those pixels, bits in the uint8 output 22 | array are set or unset 23 | 24 | Parameters 25 | ========== 26 | img : numpy array-like 27 | A grayscale image that is assumed to be binary 28 | (every non-zero value is interpreted as 0). 29 | Usually this is a pre-thinned image. 30 | 31 | Returns 32 | ======= 33 | A uint8-type output array the same shape of img, 34 | where the following bits are set or unset, 35 | if the respective neighbouring pixel is set or unset. 36 | 37 | Bit index by position: 38 | 39 | 0 1 2 40 | 3 4 41 | 5 6 7 42 | 43 | Note that for Numpy due to the coordinate system, 44 | the respective pixels can be accessed like this: 45 | 46 | [y-1,x-1] [y-1,x] [y-1,x+1] 47 | [y,x-1] [y,x] [y,x+1] 48 | [y+1,x-1] [y+1,x] [y+1,x+1] 49 | 50 | This is equivalent to the coordinate system when displaying 51 | the image using matplotlib imshow 52 | 53 | The positions in this matrix correspond to the bit number 54 | shown above, e.g. bit #4 is (1 << 4) ORed to the result. 55 | """ 56 | # Check if image has the correct type 57 | __check_image_grayscale_2d(img) 58 | img = force_c_order_contiguous(img) 59 | __check_array_uint8(img) 60 | 61 | height, width = img.shape 62 | 63 | # Allocate output array 64 | # uint32 is used so there is no overflow for large inputs 65 | out = np.zeros(img.shape, dtype=np.uint8, order="C") 66 | assert not np.isfortran(out) 67 | 68 | # Extract pointer to binary data 69 | srcptr = _ffi.cast("uint8_t*", img.ctypes.data) 70 | dstptr = _ffi.cast("uint8_t*", out.ctypes.data) 71 | 72 | rc = _libcv_algorithms.binary_neighbours(dstptr, srcptr, width, height) 73 | if rc != 0: 74 | raise ValueError("Internal error (return code {0}) in algorithm C code".format(rc)) 75 | return out 76 | 77 | class Direction(enum.IntEnum): 78 | """ 79 | Direction enum, mostly used as an argument for various functions 80 | """ 81 | NorthWest = 1 82 | North = 2 83 | NorthEast = 3 84 | West = 4 85 | East = 5 86 | SouthWest = 6 87 | South = 7 88 | SouthEast = 8 89 | 90 | def __str__(self): 91 | return { 92 | Direction.West: "←", 93 | Direction.North: "↑", 94 | Direction.East: "→", 95 | Direction.South: "↓", 96 | Direction.NorthWest: "↖", 97 | Direction.NorthEast: "↗", 98 | Direction.SouthEast: "↘", 99 | Direction.SouthWest: "↙" 100 | }[self] 101 | 102 | @staticmethod 103 | def opposite(self): 104 | """ 105 | Return the opposite direction 106 | """ 107 | return { 108 | Direction.West: Direction.East, 109 | Direction.North: Direction.South, 110 | Direction.East: Direction.West, 111 | Direction.South: Direction.North, 112 | Direction.NorthWest: Direction.SouthEast, 113 | Direction.NorthEast: Direction.SouthWest, 114 | Direction.SouthEast: Direction.NorthWest, 115 | Direction.SouthWest: Direction.NorthEast 116 | }[self] 117 | 118 | @staticmethod 119 | def from_unicode(s): 120 | """ 121 | Convert a arrow string (such as returned by __str__()) 122 | to one or multiple Direction instances 123 | """ 124 | if len(s) == 1: 125 | return { 126 | "←": Direction.West, 127 | "↑": Direction.North, 128 | "→": Direction.East, 129 | "↓": Direction.South, 130 | "↖": Direction.NorthWest, 131 | "↗": Direction.NorthEast, 132 | "↘": Direction.SouthEast, 133 | "↙": Direction.SouthWest 134 | }[s] 135 | else: 136 | return [Direction.from_unicode(c) for c in s] 137 | 138 | 139 | class Neighbours(): 140 | """ 141 | *is_xxx():* 142 | Methods for checking one pixel of the result of binary_neighbours() 143 | if it has a marked neighbour for a given result. 144 | 145 | *xxx_coords():* 146 | Get the numpy coordinate for the pixel in the given 147 | direction of the given coordinate 148 | """ 149 | @staticmethod 150 | def is_northwest(pixel): return bool(pixel & (1 << 0)) 151 | @staticmethod 152 | def is_north(pixel): return bool(pixel & (1 << 1)) 153 | @staticmethod 154 | def is_northeast(pixel): return bool(pixel & (1 << 2)) 155 | @staticmethod 156 | def is_west(pixel): return bool(pixel & (1 << 3)) 157 | @staticmethod 158 | def is_east(pixel): return bool(pixel & (1 << 4)) 159 | @staticmethod 160 | def is_southwest(pixel): return bool(pixel & (1 << 5)) 161 | @staticmethod 162 | def is_south(pixel): return bool(pixel & (1 << 6)) 163 | @staticmethod 164 | def is_southeast(pixel): return bool(pixel & (1 << 7)) 165 | 166 | @staticmethod 167 | def is_direction(direction, pixel): 168 | return { 169 | Direction.NorthEast: Neighbours.is_northeast, 170 | Direction.North: Neighbours.is_north, 171 | Direction.East: Neighbours.is_east, 172 | Direction.NorthWest: Neighbours.is_northwest, 173 | Direction.SouthEast: Neighbours.is_southeast, 174 | Direction.South: Neighbours.is_south, 175 | Direction.West: Neighbours.is_west, 176 | Direction.SouthWest: Neighbours.is_southwest 177 | }[direction](pixel) 178 | 179 | @staticmethod 180 | def northwest_coords(y, x): return (y-1, x-1) 181 | @staticmethod 182 | def north_coords(y, x): return (y-1, x) 183 | @staticmethod 184 | def northeast_coords(y, x): return (y-1, x+1) 185 | @staticmethod 186 | def west_coords(y, x): return (y, x-1) 187 | @staticmethod 188 | def east_coords(y, x): return (y, x+1) 189 | @staticmethod 190 | def southwest_coords(y, x): return (y+1, x-1) 191 | @staticmethod 192 | def south_coords(y, x): return (y+1, x) 193 | @staticmethod 194 | def southeast_coords(y, x): return (y+1, x+1) 195 | 196 | @staticmethod 197 | def coords(direction, y, x): 198 | return { 199 | Direction.NorthEast: Neighbours.northeast_coords, 200 | Direction.North: Neighbours.north_coords, 201 | Direction.East: Neighbours.east_coords, 202 | Direction.NorthWest: Neighbours.northwest_coords, 203 | Direction.SouthEast: Neighbours.southeast_coords, 204 | Direction.South: Neighbours.south_coords, 205 | Direction.West: Neighbours.west_coords, 206 | Direction.SouthWest: Neighbours.southwest_coords 207 | }[direction](y, x) 208 | 209 | @staticmethod 210 | def iterate_directions(dirs): 211 | """ 212 | Iterate elements of the Direction IntEnum 213 | if the corresponding bit is set 214 | 215 | See binary_directions for a definition of the 216 | bits. 217 | 218 | The order to the directions is the same as the bit order 219 | """ 220 | return (direction for direction in Direction 221 | if Neighbours.is_direction(direction, dirs)) 222 | 223 | -------------------------------------------------------------------------------- /cv_algorithms/popcount.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Thinning algorithms 5 | """ 6 | import numpy as np 7 | from ._ffi import * 8 | from ._checks import * 9 | 10 | __all__ = ["popcount"] 11 | 12 | _ffi.cdef(''' 13 | int popcount8(uint8_t* dst, const uint8_t* src, int size); 14 | int popcount16(uint8_t* dst, const uint16_t* src, int size); 15 | int popcount32(uint8_t* dst, const uint32_t* src, int size); 16 | int popcount64(uint8_t* dst, const uint64_t* src, int size); 17 | ''') 18 | 19 | def popcount(arr): 20 | """ 21 | Provides a population count implementation. 22 | The population count is the number of one bits. 23 | Based on GCC's __builtin_popcount(). 24 | 25 | The implementation is written in C. 26 | 27 | Parameters 28 | ========== 29 | arr : numpy array 30 | Must have dtype of uint8, uint16, uint32 31 | or uint64. 32 | 33 | Returns 34 | ======= 35 | A uint8 numpy array the same shape as array 36 | containing the pop 37 | """ 38 | # Check if image has the correct type 39 | arr = force_c_order_contiguous(arr) 40 | 41 | # Allocate output array 42 | # uint32 is used so there is no overflow for large inputs 43 | out = np.zeros(arr.shape, dtype=np.uint8, order="C") 44 | assert not np.isfortran(out) 45 | 46 | # Extract pointer to binary data 47 | dstptr = _ffi.cast("uint8_t*", out.ctypes.data) 48 | 49 | if arr.dtype == np.uint8: 50 | fn = _libcv_algorithms.popcount8 51 | srcptr = _ffi.cast("uint8_t*", arr.ctypes.data) 52 | elif arr.dtype == np.uint16: 53 | fn = _libcv_algorithms.popcount16 54 | srcptr = _ffi.cast("uint16_t*", arr.ctypes.data) 55 | elif arr.dtype == np.uint32: 56 | fn = _libcv_algorithms.popcount32 57 | srcptr = _ffi.cast("uint32_t*", arr.ctypes.data) 58 | elif arr.dtype == np.uint64: 59 | fn = _libcv_algorithms.popcount64 60 | srcptr = _ffi.cast("uint64_t*", arr.ctypes.data) 61 | else: 62 | raise ValueError("popcount can only work on uint8, uint16, uint32 or uint64 array") 63 | 64 | rc = fn(dstptr, srcptr, arr.size) 65 | if rc != 0: 66 | raise ValueError("Internal error (return code {0}) in algorithm C code".format(rc)) 67 | return out 68 | 69 | 70 | -------------------------------------------------------------------------------- /cv_algorithms/resize.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Algorithms concerning resizing of images 5 | """ 6 | import numpy as np 7 | import cv2 8 | 9 | __all__ = ["resize_maintain_aspect_ratio"] 10 | 11 | def resize_maintain_aspect_ratio(image, width=None, height=None): 12 | """ 13 | Resize the given image while maintaining the aspect ratio. 14 | 15 | Parameters: 16 | - image: numpy.ndarray 17 | The input image to be resized. 18 | - width: int, optional 19 | The desired width of the resized image. If not specified, the height will be used to calculate the width. 20 | - height: int, optional 21 | The desired height of the resized image. If not specified, the width will be used to calculate the height. 22 | 23 | Returns: 24 | - numpy.ndarray 25 | The resized image. 26 | """ 27 | # Get the original image dimensions 28 | original_height, original_width = image.shape[:2] 29 | 30 | # Calculate the aspect ratio 31 | aspect_ratio = original_width / original_height 32 | 33 | if width is not None: 34 | # Calculate the new height based on the desired width and aspect ratio 35 | new_height = int(width / aspect_ratio) 36 | # Resize the image using the new dimensions 37 | resized_image = cv2.resize(image, (width, new_height)) 38 | elif height is not None: 39 | # Calculate the new width based on the desired height and aspect ratio 40 | new_width = int(height * aspect_ratio) 41 | # Resize the image using the new dimensions 42 | resized_image = cv2.resize(image, (new_width, height)) 43 | else: 44 | # If neither width nor height is specified, return the original image 45 | resized_image = image 46 | 47 | return resized_image -------------------------------------------------------------------------------- /cv_algorithms/text.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Utilities regarding text placement in images. 5 | Supplements OpenCV's putText() 6 | """ 7 | import cv2 8 | import numpy as np 9 | 10 | __all__ = ["putTextCenter", "putTextAutoscale"] 11 | 12 | def putTextCenter(img, txt, coords, fontFace, fontScale=1., 13 | color=(0,0,0), thickness=2, hshift=0., vshift=0., baseline_shift=0.): 14 | """ 15 | Like cv2.putText(), but auto-centers text on the given coordinates 16 | by computing size via cv2.getTextSize() and shifting the coordinates by that amount. 17 | 18 | Additional horizontal and vertical shifts can be performed by using nonzero 19 | hshift/vshift parameters. 20 | 21 | The actual text center is used as a reference, not the text baseline. 22 | However, baseline_shift, multiplied by the baseline, can be added to 23 | the value. Setting baseline_shift=1. will result in one (baseline - bottom) 24 | difference being added to the y coordinate, resulting in the image being shifted up. 25 | 26 | Parameters 27 | ---------- 28 | img : numpy ndarray 29 | The image that is passed to OpenCV 30 | txt : str 31 | The text to render 32 | coords : (int, int) 33 | The coordinates where the text center should be placed 34 | fontFace 35 | The fontFace parameter that is passed to OpenCV 36 | fontScale 37 | The fontScale parameter that is passed to OpenCV 38 | color 39 | The font color that will be rendered 40 | thickness 41 | The rendering thickness 42 | hshift : float 43 | Horizontal shift, will be added to the x coordinate of the render position 44 | vshift : float 45 | Horizontal shift, will be added to the x coordinate of the render position 46 | baseline_shift : float 47 | Factor of the (baseline - bottom) y difference that will be added to the 48 | y coordinate of the rendering position 49 | """ 50 | (w,h), baseline = cv2.getTextSize(txt, fontFace, fontScale, thickness) 51 | coords = (int(round(coords[0] - w/2) + hshift), 52 | int(round(coords[1] + h/2) + vshift + baseline_shift * baseline)) 53 | cv2.putText(img, txt, coords, fontFace, fontScale, color, thickness) 54 | 55 | 56 | def putTextAutoscale(img, txt, coords, fontFace, w, h, heightFraction=0.5, widthFraction=0.95, 57 | maxHeight=60, color=(0,0,0), thickness=2, hshift=0., vshift=0., baseline_shift=0.): 58 | """ 59 | Like cv2.putText(), but auto-centers text on the given coordinates and automatically 60 | chooses the text size for a (w*h) box around the center so that it consumes heightFraction*h height. 61 | Also ensures that the text consumes no more than widthFraction*w space horizontally, 62 | but in any case the text will not consume more than maxHeight pixels vertically. 63 | 64 | This is done by computing size via cv2.getTextSize() and shifting and scaling appropriately. 65 | 66 | Parameters 67 | ---------- 68 | img : numpy ndarray 69 | The image that is passed to OpenCV 70 | txt : str 71 | The text to render 72 | coords : (int, int) 73 | The coordinates where the text center should be placed 74 | fontFace 75 | The fontFace parameter that is passed to OpenCV 76 | w : float 77 | The width of the box to place the text into 78 | h : float 79 | The height of the box to place the text into 80 | heightFraction : float 81 | Represents the maximum fraction of the height of the box 82 | that will be occupied by the text box 83 | widthFraction : float 84 | Represents the maximum fraction of the width of the box 85 | that will be occupied by the text box 86 | maxHeight : int 87 | The maximum height of the text box in pixels 88 | color 89 | The font color that will be rendered 90 | thickness 91 | The rendering thickness 92 | hshift : float 93 | Horizontal shift, will be added to the x coordinate of the render position 94 | vshift : float 95 | Horizontal shift, will be added to the x coordinate of the render position 96 | baseline_shift : float 97 | Factor of the (baseline - bottom) y difference that will be added to the 98 | y coordinate of the rendering position 99 | """ 100 | # Get first size estimate. We will scale fractionally. 101 | (scale1Width, scale1Height), baseline = cv2.getTextSize(txt, fontFace, 1., thickness) 102 | # What are the different maximum scales that are mandated by the width and height 103 | heightFractionScale = (heightFraction * h) / scale1Height 104 | widthFractionScale = (widthFraction * w) / scale1Width 105 | absMaxScale = maxHeight / scale1Height 106 | # Combine height scale, width scale and absolute max height 107 | newScale = min(heightFractionScale, widthFractionScale, absMaxScale) 108 | # Now place text using putTextCenter() 109 | putTextCenter(img, txt, coords, fontFace, newScale, 110 | color, thickness, hshift, vshift, baseline_shift) 111 | -------------------------------------------------------------------------------- /cv_algorithms/thinning.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Thinning algorithms 5 | """ 6 | import numpy as np 7 | from ._ffi import * 8 | from ._checks import * 9 | 10 | __all__ = ["guo_hall", "zhang_suen"] 11 | 12 | _ffi.cdef(''' 13 | int guo_hall_thinning(uint8_t* binary_image, size_t width, size_t height); 14 | int zhang_suen_thinning(uint8_t* binary_image, size_t width, size_t height); 15 | ''') 16 | 17 | 18 | def __run_thinning(img, inplace, cfun): 19 | """Internal common thinning function""" 20 | # Copy image (it'll be changed by the C code) if not allowed to modify 21 | if not inplace: 22 | img = img.copy() 23 | # Check if image seems correct 24 | __check_image_grayscale_2d(img) 25 | img = force_c_order_contiguous(img) 26 | __check_image_min_wh(img, 3, 3) 27 | __check_array_uint8(img) 28 | 29 | height, width = img.shape 30 | 31 | # Extract pointer to binary data 32 | dptr = _ffi.cast("uint8_t*", img.ctypes.data) 33 | 34 | rc = cfun(dptr, width, height) 35 | if rc != 0: 36 | raise ValueError("Internal error (return code {0}) in algorithm C code".format(rc)) 37 | return img 38 | 39 | 40 | def guo_hall(img, inplace=False): 41 | """ 42 | Perform in-place optimized Guo-Hall thinning. 43 | Returns img. 44 | 45 | Requires a binary grayscale numpy array as input. 46 | 47 | This calls the optimized C backend from cv_algorithms. 48 | """ 49 | return __run_thinning(img, inplace, _libcv_algorithms.guo_hall_thinning) 50 | 51 | 52 | def zhang_suen(img, inplace=False): 53 | """ 54 | Perform in-place optimized Zhang-Suen thinning. 55 | Returns img. 56 | 57 | Requires a binary grayscale numpy array as input. 58 | 59 | This calls the optimized C backend from cv_algorithms. 60 | """ 61 | 62 | return __run_thinning(img, inplace, _libcv_algorithms.zhang_suen_thinning) 63 | -------------------------------------------------------------------------------- /cv_algorithms/utils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Generic utilities 5 | """ 6 | import numpy as np 7 | import math 8 | 9 | __all__ = ["spread_to_grayscale"] 10 | 11 | def spread_to_grayscale(img, spread_min=True): 12 | """ 13 | Spreads the minimum/maximum range in the given 14 | grayscale image to (0-255) in the returned uint8 imge. 15 | 16 | This is intended to be used as an utility to export 17 | images for functions such as grassfire that return 18 | a datatype or value range that is not suitable for 19 | exporting or displaying a grayscale image. 20 | 21 | Parameters 22 | ========== 23 | img : numpy array-like 24 | The input image. Must be grayscale. 25 | spread_min : bool 26 | If True, the minimum value of the img is mapped 27 | to 0 in the result. 28 | 29 | Returns 30 | ======= 31 | A uint8-dtyped image that returns 32 | """ 33 | fimg = img.astype(float) 34 | fimg -= np.min(fimg) if spread_min else 0 35 | fmax = np.max(fimg) 36 | 37 | if math.fabs(fmax) < 1e-9: # if "fmax == 0" 38 | # Avoid divide by zero 39 | return fimg.astype(np.uint8) 40 | 41 | return (fimg * 255. / fmax).astype(np.uint8) 42 | -------------------------------------------------------------------------------- /doc/DoG.md: -------------------------------------------------------------------------------- 1 | # Difference of Gaussian transform 2 | 3 | The [Difference of Gaussian transform](https://en.wikipedia.org/wiki/Difference_of_Gaussians) is a standard algorithm for filtering an image to enhance features in an image, operating similarly to edge detectors. 4 | 5 | `cv_algorithms` provides an easy-to-use implementation for Python & OpenCV. Although the algorithm is implemented in pure Python, all the computationally expensive parts of the algorithm are implemented by OpenCV or NumPy. 6 | 7 | For a simple example source code see [this file](https://github.com/ulikoehler/cv_algorithms/blob/master/examples/difference-of-gaussian.py). 8 | 9 | ## Result example 10 | 11 | This example has been generated using the example script linked to above. 12 | 13 | Input (generated with GIMP fractal generator): 14 | 15 | ![Thinning input](https://github.com/ulikoehler/cv_algorithms/blob/master/examples/thinning-example.png) 16 | 17 | Output: 18 | 19 | ![DoG output](https://raw.githubusercontent.com/ulikoehler/cv_algorithms/master/examples/difference-of-gaussian-result.png) 20 | -------------------------------------------------------------------------------- /doc/Grassfire.md: -------------------------------------------------------------------------------- 1 | # Grassfire transform 2 | 3 | The [Grassfire transform](https://en.wikipedia.org/wiki/Grassfire_transform) is a simple algorithm is a pixel-discrete algorithm to extract the skeleton or medial axis of a region. 4 | 5 | `cv_algorithms` provides an easy-to-use implementation for Python & OpenCV. Due to it being implemented in C, it is suitable for high-performance applications. 6 | 7 | For a simple example source code see [this file](https://github.com/ulikoehler/cv_algorithms/blob/master/examples/grassfire.py). 8 | 9 | ## Result example 10 | 11 | This example has been generated using the example script linked to above. 12 | 13 | Input: 14 | 15 | ![Grassfire input](https://raw.githubusercontent.com/ulikoehler/cv_algorithms/master/examples/grassfire-example.png) 16 | 17 | Output: 18 | 19 | ![Grassfire result](https://raw.githubusercontent.com/ulikoehler/cv_algorithms/master/examples/grassfire-result.png) 20 | 21 | 22 | ## A pure python implementation 23 | 24 | In order to better understand the algorithm, you can have a look at this pure Python version: 25 | 26 | ```python 27 | 28 | def grassfire_transform(mask): 29 | """ 30 | Apply the grassfire transform to a binary mask array. 31 | """ 32 | h, w = mask.shape 33 | # Use uint32 to avoid overflow 34 | grassfire = np.zeros_like(mask, dtype=np.uint32) 35 | 36 | # 1st pass 37 | # Left to right, top to bottom 38 | for x in range(w): 39 | for y in range(h): 40 | if imgGray[y, x] != 0: # Pixel in contour 41 | north = 0 if y == 0 else grassfire[y - 1, x] 42 | west = 0 if x == 0 else grassfire[y, x - 1] 43 | if x == 3 and y == 3: 44 | print(north, west) 45 | grassfire[y, x] = 1 + min(west, north) 46 | 47 | # 2nd pass 48 | # Right to left, bottom to top 49 | for x in range(w - 1, -1, -1): 50 | for y in range(h - 1, -1, -1): 51 | if gf[y, x] != 0: # Pixel in contour 52 | south = 0 if y == (h - 1) else grassfire[y + 1, x] 53 | east = 0 if x == (w - 1) else grassfire[y, x + 1] 54 | grassfire[y, x] = min(grassfire[y, x], 55 | 1 + min(south, east)) 56 | return grassfire 57 | 58 | ``` 59 | -------------------------------------------------------------------------------- /doc/Thinning.md: -------------------------------------------------------------------------------- 1 | # Thinning 2 | 3 | Thinning algorithms reduce a pixel-discrete binary structure to its [skeleton](https://en.wikipedia.org/wiki/Topological_skeleton). 4 | 5 | `cv_algorithms` provides an easy-to-use implementation for Python & OpenCV. Due to it being implemented in C, it is suitable for high-performance applications. 6 | 7 | Both the Zhang-Suen and the Guo-Hall variants are implemented. They only differ in some details of the calculation. 8 | 9 | For a simple example source code see [this file](https://github.com/ulikoehler/cv_algorithms/blob/master/examples/thinning.py). 10 | 11 | ## Result example 12 | 13 | This example has been generated using the example script linked to above. 14 | 15 | Input (generated with GIMP fractal generator): 16 | 17 | ![Thinning input](https://github.com/ulikoehler/cv_algorithms/blob/master/examples/thinning-example.png) 18 | 19 | Zhang-Suen output: 20 | 21 | ![Zhang-Suen output](https://raw.githubusercontent.com/ulikoehler/cv_algorithms/master/examples/zhang-suen-result.png) 22 | 23 | Guo-Hall output: 24 | 25 | ![Guo-Hall output](https://raw.githubusercontent.com/ulikoehler/cv_algorithms/master/examples/guo-hall-result.png) 26 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/examples/.gitignore -------------------------------------------------------------------------------- /examples/difference-of-gaussian-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/examples/difference-of-gaussian-result.png -------------------------------------------------------------------------------- /examples/difference-of-gaussian.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | cv_algorithms difference-of-gaussian example 4 | """ 5 | import cv2 6 | import cv_algorithms 7 | 8 | # Read example file which contains some fractals (generated by GIMP fractal renderer) 9 | img = cv2.imread("thinning-example.png") 10 | # Convert to grayscale 11 | imgGray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 12 | 13 | # Difference of Gaussian 14 | result = cv_algorithms.difference_of_gaussian(imgGray, 5, 1, invert=True) 15 | 16 | # Write to file so you can see what's been done 17 | cv2.imwrite("difference-of-gaussian-result.png", result) 18 | -------------------------------------------------------------------------------- /examples/grassfire-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/examples/grassfire-example.png -------------------------------------------------------------------------------- /examples/grassfire-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/examples/grassfire-result.png -------------------------------------------------------------------------------- /examples/grassfire.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | cv_algorithms grassfire example 4 | """ 5 | import cv2 6 | import cv_algorithms 7 | 8 | # Read example file which contains an example binary image. 9 | img = cv2.imread("grassfire-example.png") 10 | # Convert to grayscale 11 | # This is only required because OpenCV always reads images as color 12 | imgGray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 13 | 14 | # Alternate algorithm (but very similar) 15 | # Only slight differences in the output! 16 | result = cv_algorithms.grassfire(imgGray) 17 | 18 | # The result contains 32-bit counters 19 | # In order to write it to an output image, 20 | # we need to convert it to (0-255) grayscale. 21 | result = cv_algorithms.spread_to_grayscale(result) 22 | 23 | # Write to file so you can see what's been done 24 | cv2.imwrite("grassfire-result.png", result) 25 | -------------------------------------------------------------------------------- /examples/guo-hall-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/examples/guo-hall-result.png -------------------------------------------------------------------------------- /examples/thinning-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/examples/thinning-example.png -------------------------------------------------------------------------------- /examples/thinning.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | cv_algorithms thinning example 4 | """ 5 | import cv2 6 | import cv_algorithms 7 | 8 | # Read example file which contains some fractals (generated by GIMP fractal renderer) 9 | img = cv2.imread("thinning-example.png") 10 | # Convert to grayscale 11 | imgGray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 12 | 13 | # Thinning needs binary images (i.e. black/white, no gray!) 14 | # 180 is a hand-tuned threshold for the example image. 15 | # The WHITE parts will be trimmed, so depending on the image, you have to use 16 | # either cv2.THRESH_BINARY or cv2.THRESH_BINARY_INV 17 | imgThresh = cv2.threshold(imgGray, 180, 255, cv2.THRESH_BINARY)[1] 18 | 19 | # Perform thinning out-of-place 20 | guo_hall = cv_algorithms.guo_hall(imgThresh) 21 | 22 | # ... or allow the library to modify the original image (= faster): 23 | # cv_algorithms.guo_hall(imgThresh, inplace=True) 24 | 25 | # Alternate algorithm (but very similar) 26 | # Only slight differences in the output! 27 | zhang_suen = cv_algorithms.zhang_suen(imgThresh) 28 | 29 | # Write to file so you can see what's been done 30 | cv2.imwrite("guo-hall-result.png", guo_hall) 31 | cv2.imwrite("zhang-suen-result.png", zhang_suen) -------------------------------------------------------------------------------- /examples/zhang-suen-result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/examples/zhang-suen-result.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "cv-algorithms" 3 | version = "1.1.1" 4 | description = "Optimized OpenCV extra algorithms for Python" 5 | authors = ["Uli Köhler "] 6 | license = "Apache License 2.0" 7 | readme = "README.md" 8 | 9 | include = [ 10 | # Include C extension in the package 11 | {path = "cv_algorithms/*.so", format = "wheel"}, 12 | {path = "src", format = "sdist"}, 13 | ] 14 | 15 | [tool.poetry.build] 16 | script = "build.py" 17 | generate-setup-file = false 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.5" 21 | numpy = "*" 22 | opencv-python = "*" 23 | setuptools = "*" 24 | cffi = ">=0.7" 25 | 26 | [tool.poetry.group.dev.dependencies] 27 | pytest = "^6.0.0" 28 | 29 | [build-system] 30 | requires = ["poetry>=0.12", "wheel", "setuptools"] 31 | build-backend = "poetry.core.masonry.api" 32 | 33 | # Testing 34 | [tool.pytest.ini_options] 35 | python_files = "Test*.py" 36 | testpaths = "tests" -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | cffi >= 0.7 2 | cv2 3 | numpy -------------------------------------------------------------------------------- /src/common.hpp: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | /** 4 | * Dirty macro to directly access an image at X/Y coordinates. 5 | * Assumes that the width variable is defined to the width of the image 6 | */ 7 | #define IMG_XY(img, x, y) img[(x) + (width)*(y)] 8 | 9 | // Windows compatibility 10 | #ifndef CFFI_DLLEXPORT 11 | #if defined(_MSC_VER) 12 | #define CFFI_DLLEXPORT __declspec(dllexport) 13 | #else // !defined(_MSC_VER) 14 | #define CFFI_DLLEXPORT 15 | #endif // defined(_MSC_VER) 16 | #endif // ifndef CFFI_DLLEXPORT 17 | -------------------------------------------------------------------------------- /src/distance.cpp: -------------------------------------------------------------------------------- 1 | #include "common.hpp" 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | //Forward declaration required due to CFFI's requirement to have unmangled symbols 11 | extern "C" { 12 | CFFI_DLLEXPORT int pairwise_diff(const double* a, const double* b, double* result, size_t awidth, size_t bwidth); 13 | } 14 | 15 | CFFI_DLLEXPORT int pairwise_diff(const double* a, const double* b, double* result, size_t awidth, size_t bwidth) { 16 | 17 | //Iterate over all (a,b) element pairs 18 | for (size_t ax = 0; ax < awidth; ++ax) { 19 | for (size_t bx = 0; bx < bwidth; ++bx) { 20 | result[ax*awidth + bx] = std::abs((int64_t)(a[ax] - b[bx])); 21 | } 22 | } 23 | return 0; 24 | } 25 | -------------------------------------------------------------------------------- /src/grassfire.cpp: -------------------------------------------------------------------------------- 1 | #include "common.hpp" 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | 11 | using std::min; 12 | using std::max; 13 | 14 | //Forward declaration required due to CFFI's requirement to have unmangled symbols 15 | extern "C" { 16 | CFFI_DLLEXPORT int grassfire(uint32_t* dst, const uint8_t* mask, int width, int height); 17 | } 18 | 19 | /** 20 | * Fast C implementation of the grassfire algorithm. 21 | * Takes a destination counter array (must be zero-initialized) 22 | * and a binary mask array (checked for != 0). 23 | */ 24 | CFFI_DLLEXPORT int grassfire(uint32_t* dst, const uint8_t* mask, int width, int height) { 25 | // 1st pass 26 | for (int x = 0; x < width; ++x) { 27 | for (int y = 0; y < height; ++y) { 28 | if (IMG_XY(mask, x, y) != 0) { // Pixel in contour 29 | // Get neighbors 30 | int north = (y == 0) ? 0 : IMG_XY(dst, x, y - 1); 31 | int west = (x == 0) ? 0 : IMG_XY(dst, x - 1, y); 32 | // Set value 33 | IMG_XY(dst, x, y) = 1 + min(north, west); 34 | } 35 | } 36 | } 37 | // 2nd pass 38 | for (int x = width - 1; x >= 0; x--) { 39 | for (int y = height - 1; y >= 0; y--) { 40 | if (IMG_XY(mask, x, y) != 0) { // Pixel in contour 41 | // Get neighbors 42 | uint32_t south = (y == (height - 1)) ? 43 | 0 : IMG_XY(dst, x, y + 1); 44 | uint32_t east = (x == (width - 1)) ? 45 | 0 : IMG_XY(dst, x + 1, y); 46 | // Set value 47 | IMG_XY(dst, x, y) = min(IMG_XY(dst, x, y), 48 | 1 + min(south, east)); 49 | } 50 | } 51 | } 52 | return 0; 53 | } 54 | -------------------------------------------------------------------------------- /src/neighbours.cpp: -------------------------------------------------------------------------------- 1 | #include "common.hpp" 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | 10 | //Forward declaration required due to CFFI's requirement to have unmangled symbols 11 | extern "C" { 12 | CFFI_DLLEXPORT int binary_neighbours(uint8_t* dst, const uint8_t* src, int width, int height); 13 | } 14 | 15 | /** 16 | * Vincinity direction algorithm 17 | * Sets bits in the output array based on if the surrounding pixels 18 | * are zero or non-zero. See the python docs for more info. 19 | */ 20 | CFFI_DLLEXPORT int binary_neighbours(uint8_t* dst, const uint8_t* src, int width, int height) { 21 | // 1st pass 22 | for (int x = 0; x < width; ++x) { 23 | for (int y = 0; y < height; ++y) { 24 | // Are we at the borders? 25 | bool x0 = (x == 0); 26 | bool y0 = (y == 0); 27 | bool xe = (x == (width - 1)); 28 | bool ye = (y == (height - 1)); 29 | /** 30 | * Get neighbors. See chart in python docs. 31 | * This has been corrected for the coordinate system 32 | * empirically (also see the unit test) 33 | */ 34 | uint8_t north = y0 ? 0 : IMG_XY(src, x, y - 1); 35 | uint8_t west = x0 ? 0 : IMG_XY(src, x - 1, y); 36 | uint8_t northwest = (x0 || y0) ? 0 : IMG_XY(src, x - 1, y - 1); 37 | uint8_t northeast = (xe || y0) ? 0 : IMG_XY(src, x + 1, y - 1); 38 | uint8_t east = xe ? 0 : IMG_XY(src, x + 1, y); 39 | uint8_t south = ye ? 0 : IMG_XY(src, x, y + 1); 40 | uint8_t southwest = (x0 || ye) ? 0 : IMG_XY(src, x - 1, y + 1); 41 | uint8_t southeast = (xe || ye) ? 0 : IMG_XY(src, x + 1, y + 1); 42 | /** 43 | * Compute value 44 | * See the chart in the python docs. 45 | */ 46 | IMG_XY(dst, x, y) = 47 | ((northwest == 0) ? 0 : (1 << 0)) 48 | | ((north == 0) ? 0 : (1 << 1)) 49 | | ((northeast == 0) ? 0 : (1 << 2)) 50 | | ((west == 0) ? 0 : (1 << 3)) 51 | | ((east == 0) ? 0 : (1 << 4)) 52 | | ((southwest == 0) ? 0 : (1 << 5)) 53 | | ((south == 0) ? 0 : (1 << 6)) 54 | | ((southeast == 0) ? 0 : (1 << 7)); 55 | } 56 | } 57 | return 0; 58 | } 59 | -------------------------------------------------------------------------------- /src/popcount.cpp: -------------------------------------------------------------------------------- 1 | #include "common.hpp" 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | #ifdef _MSC_VER 10 | #include 11 | #define __builtin_popcount __popcnt 12 | #define __builtin_popcountll __popcnt64 13 | #endif 14 | 15 | 16 | //Forward declaration required due to CFFI's requirement to have unmangled symbols 17 | extern "C" { 18 | CFFI_DLLEXPORT int popcount8(uint8_t* dst, const uint8_t* src, int size); 19 | CFFI_DLLEXPORT int popcount16(uint8_t* dst, const uint16_t* src, int size); 20 | CFFI_DLLEXPORT int popcount32(uint8_t* dst, const uint32_t* src, int size); 21 | CFFI_DLLEXPORT int popcount64(uint8_t* dst, const uint64_t* src, int size); 22 | } 23 | 24 | CFFI_DLLEXPORT int popcount8(uint8_t* dst, const uint8_t* src, int size) { 25 | for (int i = 0; i < size; ++i) { 26 | dst[i] = __builtin_popcount(src[i]); 27 | } 28 | return 0; 29 | } 30 | 31 | CFFI_DLLEXPORT int popcount16(uint8_t* dst, const uint16_t* src, int size) { 32 | for (int i = 0; i < size; ++i) { 33 | dst[i] = __builtin_popcount(src[i]); 34 | } 35 | return 0; 36 | } 37 | 38 | CFFI_DLLEXPORT int popcount32(uint8_t* dst, const uint32_t* src, int size) { 39 | for (int i = 0; i < size; ++i) { 40 | dst[i] = __builtin_popcount(src[i]); 41 | } 42 | return 0; 43 | } 44 | 45 | CFFI_DLLEXPORT int popcount64(uint8_t* dst, const uint64_t* src, int size) { 46 | for (int i = 0; i < size; ++i) { 47 | dst[i] = __builtin_popcountll(src[i]); 48 | } 49 | return 0; 50 | } 51 | -------------------------------------------------------------------------------- /src/thinning.cpp: -------------------------------------------------------------------------------- 1 | #include "common.hpp" 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | 9 | 10 | //Forward declaration required due to CFFI's requirement to have unmangled symbols 11 | extern "C" { 12 | CFFI_DLLEXPORT int guo_hall_thinning(uint8_t* binary_image, size_t width, size_t height); 13 | CFFI_DLLEXPORT int zhang_suen_thinning(uint8_t* binary_image, size_t width, size_t height); 14 | } 15 | 16 | 17 | /** 18 | * Perform a logical AND on a memory array (a), ANDing it with another array (b) 19 | * We expect this function to be optimized by the compiler 20 | * specifically for the platform in use. 21 | */ 22 | void bitwiseANDInPlace(uint8_t* a, const uint8_t* b, size_t size) { 23 | for (size_t i = 0; i < size; ++i) { 24 | a[i] &= b[i]; 25 | } 26 | } 27 | 28 | /** 29 | * Performs a single iteration of the Guo-Hall algorithm. 30 | * See http://opencv-code.com/quick-tips/implementation-of-guo-hall-thinning-algorithm/ 31 | * and the original paper http://dx.doi.org/10.1145/62065.62074 for details. 32 | * 33 | * Compared to the opencv-code.com implementation, we also count the number of 34 | * changes during the iteration in order to avoid the cv::absdiff() call and the 35 | * super-expensive whole-image (possibly multi-Mibibyte) copy to prev. 36 | */ 37 | int guo_hall_iteration(uint8_t* img, uint8_t* mask, size_t width, size_t height, bool oddIteration) { 38 | /** 39 | * Compared to 40 | * http://opencv-code.com/quick-tips/implementation-of-guo-hall-thinning-algorithm/ 41 | * we compute the mask in an inverted way so we don't have to invert while performing 42 | * the AND. 43 | */ 44 | int changed = 0; 45 | for (unsigned int y = 1; y < height - 1; y++) { 46 | for (unsigned int x = 1; x < width - 1; x++) { 47 | if(IMG_XY(img, x, y) == 0) continue; 48 | // In the paper, figure 1 lists which Px corresponds to which coordinate 49 | bool p2 = IMG_XY(img, x, y - 1); 50 | bool p3 = IMG_XY(img, x + 1, y - 1); 51 | bool p4 = IMG_XY(img, x + 1, y); 52 | bool p5 = IMG_XY(img, x + 1, y + 1); 53 | bool p6 = IMG_XY(img, x, y + 1); 54 | bool p7 = IMG_XY(img, x - 1, y + 1); 55 | bool p8 = IMG_XY(img, x - 1, y); 56 | bool p9 = IMG_XY(img, x - 1, y - 1); 57 | 58 | unsigned int N1 = (p9 || p2) + (p3 || p4) + (p5 || p6) + (p7 || p8); 59 | unsigned int N2 = (p2 || p3) + (p4 || p5) + (p6 || p7) + (p8 || p9); 60 | unsigned int N = N1 < N2 ? N1 : N2; 61 | unsigned int m = 62 | oddIteration ? (p8 && (p6 || p7 || !p9)) 63 | : (p4 && (p2 || p3 || !p5)); 64 | unsigned int C = 65 | ((!p2 && (p3 || p4)) + 66 | (!p4 && (p5 || p6)) + 67 | (!p6 && (p7 || p8)) + 68 | (!p8 && (p9 || p2))); 69 | if (C == 1 && N >= 2 && N <= 3 && m == 0) { 70 | //See above - mask is computed in an inverted waay 71 | IMG_XY(mask, x, y) = 0; 72 | changed++; 73 | } 74 | } 75 | } 76 | bitwiseANDInPlace(img, mask, width * height); 77 | return changed; 78 | } 79 | 80 | 81 | /** 82 | * Performs a single iteration of the Zhang-Suen algorithm. 83 | * See http://opencv-code.com/quick-tips/implementation-of-thinning-algorithm-in-opencv/ 84 | * and the original paper https://dx.doi.org/10.1145/357994.358023 for details. 85 | * 86 | * This function is very similar to the Guo-Hall algorithm. See guo_hall_iteration() for more implementation details 87 | */ 88 | int zhang_suen_iteration(uint8_t* img, uint8_t* mask, size_t width, size_t height, bool oddIteration) { 89 | 90 | int changed = 0; 91 | for (unsigned int y = 1; y < height - 1; y++) { 92 | for (unsigned int x = 1; x < width - 1; x++) { 93 | if(IMG_XY(img, x, y) == 0) continue; 94 | // In the Guo-Hall paper, figure 1 lists which Px corresponds to which coordinate 95 | bool p2 = IMG_XY(img, x, y - 1); 96 | bool p3 = IMG_XY(img, x + 1, y - 1); 97 | bool p4 = IMG_XY(img, x + 1, y); 98 | bool p5 = IMG_XY(img, x + 1, y + 1); 99 | bool p6 = IMG_XY(img, x, y + 1); 100 | bool p7 = IMG_XY(img, x - 1, y + 1); 101 | bool p8 = IMG_XY(img, x - 1, y); 102 | bool p9 = IMG_XY(img, x - 1, y - 1); 103 | 104 | int A = (p2 == 0 && p3 == 1) + (p3 == 0 && p4 == 1) + 105 | (p4 == 0 && p5 == 1) + (p5 == 0 && p6 == 1) + 106 | (p6 == 0 && p7 == 1) + (p7 == 0 && p8 == 1) + 107 | (p8 == 0 && p9 == 1) + (p9 == 0 && p2 == 1); 108 | int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9; 109 | int m1 = oddIteration ? (p2 * p4 * p8) : (p2 * p4 * p6); 110 | int m2 = oddIteration ? (p2 * p6 * p8) : (p4 * p6 * p8); 111 | 112 | if (A == 1 && (B >= 2 && B <= 6) && m1 == 0 && m2 == 0) { 113 | IMG_XY(mask, x, y) = 0; //Inverted mask! 114 | changed++; 115 | } 116 | } 117 | } 118 | bitwiseANDInPlace(img, mask, width * height); 119 | return changed; 120 | } 121 | 122 | /** 123 | * Main Guo-Hall thinning function (optimized). 124 | * See guo_hall_iteration() for more documentation. 125 | */ 126 | CFFI_DLLEXPORT int guo_hall_thinning(uint8_t* binary_image, size_t width, size_t height) { 127 | /* return -1 if we can't allocate the memory for the mask, else 0 */ 128 | uint8_t* mask = (uint8_t*) malloc(width * height); 129 | if (mask == NULL) { 130 | return -1; 131 | } 132 | 133 | /** 134 | * It is important to understand that with Guo-Hall black pixels will never get white. 135 | * Therefore we don't need to reset the mask in each iteration. 136 | * Especially for large images, this saves us many Mibibytes of memory transfer. 137 | */ 138 | memset(mask, UCHAR_MAX, width*height); 139 | 140 | int changed; 141 | do { 142 | changed = 143 | guo_hall_iteration(binary_image, mask, width, height, false) + 144 | guo_hall_iteration(binary_image, mask, width, height, true); 145 | } while (changed != 0); 146 | 147 | //Cleanup 148 | free(mask); 149 | return 0; 150 | } 151 | 152 | 153 | /** 154 | * Main Zhang-Suen thinning function (optimized). 155 | * See guo_hall_thinning() for more documentation. 156 | */ 157 | CFFI_DLLEXPORT int zhang_suen_thinning(uint8_t* binary_image, size_t width, size_t height) { 158 | /* return -1 if we can't allocate the memory for the mask, else 0 */ 159 | uint8_t* mask = (uint8_t*) malloc(width * height); 160 | if (mask == NULL) { 161 | return -1; 162 | } 163 | 164 | /** 165 | * It is important to understand that with Guo-Hall black pixels will never get white. 166 | * Therefore we don't need to reset the mask in each iteration. 167 | * Especially for large images, this saves us many Mibibytes of memory transfer. 168 | */ 169 | memset(mask, UCHAR_MAX, width*height); 170 | 171 | int changed; 172 | do { 173 | changed = 174 | zhang_suen_iteration(binary_image, mask, width, height, false) + 175 | zhang_suen_iteration(binary_image, mask, width, height, true); 176 | } while (changed != 0); 177 | 178 | //Cleanup 179 | free(mask); 180 | return 0; 181 | } 182 | -------------------------------------------------------------------------------- /src/windows.cpp: -------------------------------------------------------------------------------- 1 | /** 2 | * This contains hacks to get the installation on Windows working. 3 | * This fixes error LNK2001: unresolved external symbol PyInit__cv_algorithms 4 | */ 5 | void PyInit__cv_algorithms(void) { } 6 | -------------------------------------------------------------------------------- /tests/TestColorspace.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import cv_algorithms 4 | from cv_algorithms.colorspace import Colorspace, ColorspaceChannel 5 | import numpy as np 6 | import unittest 7 | 8 | class TestColorspace(unittest.TestCase): 9 | def test_colorspace_channels(self): 10 | self.assertEqual((ColorspaceChannel.RGB_Red, 11 | ColorspaceChannel.RGB_Green, 12 | ColorspaceChannel.RGB_Blue), 13 | Colorspace.RGB.channels) 14 | self.assertEqual((ColorspaceChannel.XYZ_X, 15 | ColorspaceChannel.XYZ_Y, 16 | ColorspaceChannel.XYZ_Z), 17 | Colorspace.XYZ.channels) 18 | 19 | def test_channel_colorspace(self): 20 | self.assertEqual(Colorspace.RGB, ColorspaceChannel.RGB_Red.colorspace) 21 | self.assertEqual(Colorspace.RGB, ColorspaceChannel.RGB_Green.colorspace) 22 | self.assertEqual(Colorspace.RGB, ColorspaceChannel.RGB_Blue.colorspace) 23 | self.assertEqual(Colorspace.LAB, ColorspaceChannel.LAB_L.colorspace) 24 | self.assertEqual(Colorspace.LAB, ColorspaceChannel.LAB_b.colorspace) 25 | self.assertEqual(Colorspace.XYZ, ColorspaceChannel.XYZ_X.colorspace) 26 | self.assertEqual(Colorspace.XYZ, ColorspaceChannel.XYZ_Z.colorspace) 27 | 28 | def test_channel_name(self): 29 | self.assertEqual("Red", ColorspaceChannel.RGB_Red.channel_name) 30 | self.assertEqual("b", ColorspaceChannel.LAB_b.channel_name) 31 | 32 | class TestColorspaceConversion(unittest.TestCase): 33 | def test_convert_to_colorspace(self): 34 | img = np.zeros((10,10,3), np.uint8) 35 | # Just test if it raises 36 | cv_algorithms.convert_to_colorspace(img, Colorspace.BGR) 37 | cv_algorithms.convert_to_colorspace(img, Colorspace.LAB) 38 | cv_algorithms.convert_to_colorspace(img, Colorspace.LAB, Colorspace.XYZ) 39 | 40 | def test_extract_channel(self): 41 | img = np.zeros((10,10,3), np.uint8) 42 | # Just test if it raises 43 | cv_algorithms.extract_channel(img, ColorspaceChannel.LAB_L) 44 | -------------------------------------------------------------------------------- /tests/TestContours.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import io 4 | from numpy.testing import assert_array_equal 5 | import cv2 6 | from cv_algorithms.contours import * 7 | import numpy as np 8 | import unittest 9 | 10 | class TestContours(unittest.TestCase): 11 | def testAreaFilter(self): 12 | "Test contour area filter" 13 | cnts = [np.asarray([[0,0],[1,0],[1,1],[0,1],[0,0]]), # area 1 14 | np.asarray([[0,0],[2,0],[2,2],[0,2],[0,0]]), # area 4 15 | ] 16 | # Min area 17 | assert_array_equal(cnts, filter_min_area(cnts, 0.5)) 18 | assert_array_equal(cnts, filter_min_area(cnts, 1.0)) 19 | assert_array_equal([cnts[1]], filter_min_area(cnts, 1.1)) 20 | assert_array_equal([cnts[1]], filter_min_area(cnts, 2.0)) 21 | assert_array_equal([cnts[1]], filter_min_area(cnts, 3.0)) 22 | assert_array_equal([cnts[1]], filter_min_area(cnts, 4.0)) 23 | assert_array_equal([], filter_min_area(cnts, 5.0)) 24 | # Max area 25 | assert_array_equal([], filter_max_area(cnts, 0.5)) 26 | assert_array_equal([cnts[0]], filter_max_area(cnts, 1.0)) 27 | assert_array_equal([cnts[0]], filter_max_area(cnts, 1.1)) 28 | assert_array_equal([cnts[0]], filter_max_area(cnts, 2.0)) 29 | assert_array_equal([cnts[0]], filter_max_area(cnts, 3.0)) 30 | assert_array_equal(cnts, filter_max_area(cnts, 4.0)) 31 | assert_array_equal(cnts, filter_max_area(cnts, 5.0)) 32 | 33 | def test_contour_mask(self): 34 | cnts = [np.asarray([[0,0],[1,0],[1,1],[0,1],[0,0]]), # area 1 35 | np.asarray([[0,0],[2,0],[2,2],[0,2],[0,0]]), # area 4 36 | ] 37 | # Test return shape 38 | img = np.zeros((10,10,3), dtype=np.uint8) 39 | self.assertEqual((10, 10), contour_mask(img, []).shape) 40 | self.assertEqual((10, 10), contour_mask(img, cnts[0]).shape) 41 | self.assertEqual((10, 10), contour_mask(img, cnts).shape) 42 | self.assertEqual((10, 10), contour_mask((10,10), []).shape) 43 | self.assertEqual((10, 10), contour_mask((10,10,3), []).shape) 44 | # Test return values 45 | res = contour_mask(img, cnts) 46 | self.assertFalse((res == 255).all()) 47 | self.assertFalse((res == 0).all()) 48 | # Test no contours 49 | res = contour_mask(img, []) 50 | self.assertFalse((res == 255).all()) 51 | self.assertTrue((res == 0).all()) 52 | -------------------------------------------------------------------------------- /tests/TestDistance.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | from numpy.testing import assert_allclose, assert_array_equal 4 | import cv2 5 | import cv_algorithms 6 | import numpy as np 7 | import unittest 8 | 9 | class TestXYDistance(unittest.TestCase): 10 | def test_simple(self): 11 | "Simple array-with-itself test" 12 | # Currently just run and see if it crashes 13 | a = np.asarray([1., 2., 3.]) 14 | result = cv_algorithms.pairwise_diff(a, a) 15 | assert_allclose(result, [[0, 1, 2], [1, 0, 1], [2, 1, 0]]) 16 | 17 | def test_different_arrays(self): 18 | # Currently just run and see if it crashes 19 | a = np.asarray([1., 2., 3.]) 20 | b = np.asarray([2., 3., 4.]) 21 | result = cv_algorithms.pairwise_diff(a, b) 22 | assert_allclose(result, [[1, 2, 3], [0, 1, 2], [1, 0, 1]]) 23 | 24 | class TestColorspaceDistance(unittest.TestCase): 25 | def test_rgb_distance(self): 26 | img = np.zeros((10,10,3)) 27 | # black has zero distance to itself 28 | exp = np.zeros((10,10)) 29 | assert_array_equal(exp, cv_algorithms.rgb_distance(img, (0,0,0))) 30 | # Single channel 31 | exp = np.full((10,10), 5) 32 | assert_array_equal(exp, cv_algorithms.rgb_distance(img, (5,0,0))) 33 | # Multiple channel 34 | exp = np.full((10,10), 5) 35 | assert_array_equal(exp, cv_algorithms.rgb_distance(img, (3,0,4))) 36 | # Single pixel 37 | img[5,4] = (3,0,4) 38 | exp = np.full((10,10), 5) 39 | exp[5,4] = 0. 40 | assert_array_equal(exp, cv_algorithms.rgb_distance(img, (3,0,4))) -------------------------------------------------------------------------------- /tests/TestGrassfire.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import cv_algorithms 4 | import numpy as np 5 | 6 | class TestGrassfire(object): 7 | def test_grassfire(self): 8 | "Test grassfire transform" 9 | mask = np.zeros((10,10), dtype=np.uint8) 10 | # Currently just test whether it crashes 11 | cv_algorithms.grassfire(mask) -------------------------------------------------------------------------------- /tests/TestNeighbours.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import cv_algorithms 4 | from cv_algorithms import Neighbours, Direction 5 | import numpy as np 6 | import unittest 7 | 8 | class TestNeighbours(unittest.TestCase): 9 | def test_binary_neighbours_simple(self): 10 | """Test binary direction detection""" 11 | img = np.zeros((10,8), dtype=np.uint8) 12 | y, x = 5, 4 13 | img[5,4] = 255 14 | # Currently just test whether it crashes 15 | directions = cv_algorithms.binary_neighbours(img) 16 | print(directions) 17 | self.assertEqual(0, directions[0,0]) 18 | # NOTE: Directions are inversed, this is not a mistake 19 | # To the pixel on the NW of the white pixel 20 | # the white pixel is SE! 21 | # Center 22 | self.assertEqual(0, directions[5, 4]) 23 | # NW of the white pixel 24 | self.assertEqual((y-1, x-1), Neighbours.northwest_coords(y, x)) 25 | self.assertEqual((y-1, x-1), Neighbours.coords(Direction.NorthWest, y, x)) 26 | self.assertEqual((1 << 7), directions[y-1, x-1]) 27 | self.assertFalse(Neighbours.is_northwest(directions[y-1, x-1])) 28 | self.assertFalse(Neighbours.is_north(directions[y-1, x-1])) 29 | self.assertFalse(Neighbours.is_northeast(directions[y-1, x-1])) 30 | self.assertFalse(Neighbours.is_west(directions[y-1, x-1])) 31 | self.assertFalse(Neighbours.is_east(directions[y-1, x-1])) 32 | self.assertFalse(Neighbours.is_southwest(directions[y-1, x-1])) 33 | self.assertFalse(Neighbours.is_south(directions[y-1, x-1])) 34 | self.assertTrue(Neighbours.is_southeast(directions[y-1, x-1])) 35 | # N of the white pixel 36 | self.assertEqual((y-1, x), Neighbours.north_coords(y, x)) 37 | self.assertEqual((y-1, x), Neighbours.coords(Direction.North, y, x)) 38 | self.assertEqual((1 << 6), directions[y-1, x]) 39 | self.assertFalse(Neighbours.is_northwest(directions[y-1, x])) 40 | self.assertFalse(Neighbours.is_north(directions[y-1, x])) 41 | self.assertFalse(Neighbours.is_northeast(directions[y-1, x])) 42 | self.assertFalse(Neighbours.is_west(directions[y-1, x])) 43 | self.assertFalse(Neighbours.is_east(directions[y-1, x])) 44 | self.assertFalse(Neighbours.is_southwest(directions[y-1, x])) 45 | self.assertTrue(Neighbours.is_south(directions[y-1, x])) 46 | self.assertFalse(Neighbours.is_southeast(directions[y-1, x])) 47 | # NE of the white pixel 48 | self.assertEqual((y-1, x+1), Neighbours.northeast_coords(y, x)) 49 | self.assertEqual((y-1, x+1), Neighbours.coords(Direction.NorthEast, y, x)) 50 | self.assertEqual((1 << 5), directions[y-1, x+1]) 51 | self.assertFalse(Neighbours.is_northwest(directions[y-1, x+1])) 52 | self.assertFalse(Neighbours.is_north(directions[y-1, x+1])) 53 | self.assertFalse(Neighbours.is_northeast(directions[y-1, x+1])) 54 | self.assertFalse(Neighbours.is_west(directions[y-1, x+1])) 55 | self.assertFalse(Neighbours.is_east(directions[y-1, x+1])) 56 | self.assertTrue(Neighbours.is_southwest(directions[y-1, x+1])) 57 | self.assertFalse(Neighbours.is_south(directions[y-1, x+1])) 58 | self.assertFalse(Neighbours.is_southeast(directions[y-1, x+1])) 59 | # W of the white pixel 60 | self.assertEqual((y, x-1), Neighbours.west_coords(y, x)) 61 | self.assertEqual((y, x-1), Neighbours.coords(Direction.West, y, x)) 62 | self.assertEqual((1 << 4), directions[y, x-1]) 63 | self.assertFalse(Neighbours.is_northwest(directions[y, x-1])) 64 | self.assertFalse(Neighbours.is_north(directions[y, x-1])) 65 | self.assertFalse(Neighbours.is_northeast(directions[y, x-1])) 66 | self.assertFalse(Neighbours.is_west(directions[y, x-1])) 67 | self.assertTrue(Neighbours.is_east(directions[y, x-1])) 68 | self.assertFalse(Neighbours.is_southwest(directions[y, x-1])) 69 | self.assertFalse(Neighbours.is_south(directions[y, x-1])) 70 | self.assertFalse(Neighbours.is_southeast(directions[y, x-1])) 71 | # E of the white pixel 72 | self.assertEqual((y, x+1), Neighbours.east_coords(y, x)) 73 | self.assertEqual((y, x+1), Neighbours.coords(Direction.East, y, x)) 74 | self.assertEqual((1 << 3), directions[y, x+1]) 75 | self.assertFalse(Neighbours.is_northwest(directions[y, x+1])) 76 | self.assertFalse(Neighbours.is_north(directions[y, x+1])) 77 | self.assertFalse(Neighbours.is_northeast(directions[y, x+1])) 78 | self.assertTrue(Neighbours.is_west(directions[y, x+1])) 79 | self.assertFalse(Neighbours.is_east(directions[y, x+1])) 80 | self.assertFalse(Neighbours.is_southwest(directions[y, x+1])) 81 | self.assertFalse(Neighbours.is_south(directions[y, x+1])) 82 | self.assertFalse(Neighbours.is_southeast(directions[y, x+1])) 83 | # SW of the white pixel 84 | self.assertEqual((y+1, x-1), Neighbours.southwest_coords(y, x)) 85 | self.assertEqual((y+1, x-1), Neighbours.coords(Direction.SouthWest, y, x)) 86 | self.assertEqual((1 << 2), directions[y+1, x-1]) 87 | self.assertFalse(Neighbours.is_northwest(directions[y+1, x-1])) 88 | self.assertFalse(Neighbours.is_north(directions[y+1, x-1])) 89 | self.assertTrue(Neighbours.is_northeast(directions[y+1, x-1])) 90 | self.assertFalse(Neighbours.is_west(directions[y+1, x-1])) 91 | self.assertFalse(Neighbours.is_east(directions[y+1, x-1])) 92 | self.assertFalse(Neighbours.is_southwest(directions[y+1, x-1])) 93 | self.assertFalse(Neighbours.is_south(directions[y+1, x-1])) 94 | self.assertFalse(Neighbours.is_southeast(directions[y+1, x-1])) 95 | # S of the white pixel 96 | self.assertEqual((y+1, x), Neighbours.south_coords(y, x)) 97 | self.assertEqual((y+1, x), Neighbours.coords(Direction.South, y, x)) 98 | self.assertEqual((1 << 1), directions[y+1, x]) 99 | self.assertFalse(Neighbours.is_northwest(directions[y+1, x])) 100 | self.assertTrue(Neighbours.is_north(directions[y+1, x])) 101 | self.assertFalse(Neighbours.is_northeast(directions[y+1, x])) 102 | self.assertFalse(Neighbours.is_west(directions[y+1, x])) 103 | self.assertFalse(Neighbours.is_east(directions[y+1, x])) 104 | self.assertFalse(Neighbours.is_southwest(directions[y+1, x])) 105 | self.assertFalse(Neighbours.is_south(directions[y+1, x])) 106 | self.assertFalse(Neighbours.is_southeast(directions[y+1, x])) 107 | # SE of the white pixel 108 | self.assertEqual((y+1, x+1), Neighbours.southeast_coords(y, x)) 109 | self.assertEqual((y+1, x+1), Neighbours.coords(Direction.SouthEast, y, x)) 110 | self.assertEqual((1 << 0), directions[y+1, x+1]) 111 | self.assertTrue(Neighbours.is_northwest(directions[y+1, x+1])) 112 | self.assertFalse(Neighbours.is_north(directions[y+1, x+1])) 113 | self.assertFalse(Neighbours.is_northeast(directions[y+1, x+1])) 114 | self.assertFalse(Neighbours.is_west(directions[y+1, x+1])) 115 | self.assertFalse(Neighbours.is_east(directions[y+1, x+1])) 116 | self.assertFalse(Neighbours.is_southwest(directions[y+1, x+1])) 117 | self.assertFalse(Neighbours.is_south(directions[y+1, x+1])) 118 | self.assertFalse(Neighbours.is_southeast(directions[y+1, x+1])) 119 | 120 | def test_binary_neighbours_corner(self): 121 | # Just test if it crashes for something in the corners 122 | img = np.zeros((10,8), dtype=np.uint8) 123 | img[9,7] = 255 124 | img[0,0] = 255 125 | cv_algorithms.binary_neighbours(img) 126 | 127 | def test_direction_str(self): 128 | self.assertEqual("↑", str(Direction.North)) 129 | self.assertEqual(Direction.North, Direction.from_unicode("↑")) 130 | self.assertEqual([Direction.SouthEast, Direction.North], Direction.from_unicode("↘↑")) -------------------------------------------------------------------------------- /tests/TestPopcount.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import io 4 | from numpy.testing import assert_array_equal 5 | import cv2 6 | import cv_algorithms 7 | import numpy as np 8 | import unittest 9 | 10 | class TestPopcount(unittest.TestCase): 11 | def test_popcount8(self): 12 | i = np.zeros((10,10), dtype=np.uint8) 13 | o = np.zeros((10,10), dtype=np.uint8) 14 | # No bits 15 | assert_array_equal(o, cv_algorithms.popcount(i)) 16 | # One bit 17 | i[1,1] = 8 18 | o[1,1] = 1 19 | assert_array_equal(o, cv_algorithms.popcount(i)) 20 | # Two bits 21 | i[1,1] = 3 22 | o[1,1] = 2 23 | assert_array_equal(o, cv_algorithms.popcount(i)) 24 | # Two bits 25 | i[1,1] = 7 26 | o[1,1] = 3 27 | assert_array_equal(o, cv_algorithms.popcount(i)) 28 | # All bits 29 | i[1,1] = 0xFF 30 | o[1,1] = 8 31 | assert_array_equal(o, cv_algorithms.popcount(i)) 32 | 33 | def test_popcount16(self): 34 | i = np.zeros((10,10), dtype=np.uint16) 35 | o = np.zeros((10,10), dtype=np.uint8) 36 | # No bits 37 | assert_array_equal(o, cv_algorithms.popcount(i)) 38 | # One bit 39 | i[1,1] = 8 40 | o[1,1] = 1 41 | assert_array_equal(o, cv_algorithms.popcount(i)) 42 | # Two bits 43 | i[1,1] = 3 44 | o[1,1] = 2 45 | assert_array_equal(o, cv_algorithms.popcount(i)) 46 | # Two bits 47 | i[1,1] = 7 48 | o[1,1] = 3 49 | assert_array_equal(o, cv_algorithms.popcount(i)) 50 | # All bits 51 | i[1,1] = 0xFFFF 52 | o[1,1] = 16 53 | assert_array_equal(o, cv_algorithms.popcount(i)) 54 | 55 | def test_popcount32(self): 56 | i = np.zeros((10,10), dtype=np.uint32) 57 | o = np.zeros((10,10), dtype=np.uint8) 58 | # No bits 59 | assert_array_equal(o, cv_algorithms.popcount(i)) 60 | # One bit 61 | i[1,1] = 8 62 | o[1,1] = 1 63 | assert_array_equal(o, cv_algorithms.popcount(i)) 64 | # Two bits 65 | i[1,1] = 3 66 | o[1,1] = 2 67 | assert_array_equal(o, cv_algorithms.popcount(i)) 68 | # Two bits 69 | i[1,1] = 7 70 | o[1,1] = 3 71 | assert_array_equal(o, cv_algorithms.popcount(i)) 72 | # All bits 73 | i[1,1] = 0xFFFFFFFF 74 | o[1,1] = 32 75 | assert_array_equal(o, cv_algorithms.popcount(i)) 76 | 77 | def test_popcount64(self): 78 | i = np.zeros((10,10), dtype=np.uint64) 79 | o = np.zeros((10,10), dtype=np.uint8) 80 | # No bits 81 | assert_array_equal(o, cv_algorithms.popcount(i)) 82 | # One bit 83 | i[1,1] = 8 84 | o[1,1] = 1 85 | assert_array_equal(o, cv_algorithms.popcount(i)) 86 | # Two bits 87 | i[1,1] = 3 88 | o[1,1] = 2 89 | assert_array_equal(o, cv_algorithms.popcount(i)) 90 | # Two bits 91 | i[1,1] = 7 92 | o[1,1] = 3 93 | assert_array_equal(o, cv_algorithms.popcount(i)) 94 | # All bits 95 | i[1,1] = 0xFFFFFFFFFFFFFFFF 96 | o[1,1] = 64 97 | assert_array_equal(o, cv_algorithms.popcount(i)) 98 | 99 | -------------------------------------------------------------------------------- /tests/TestThinning.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import io 4 | import cv2 5 | import cv_algorithms 6 | import numpy as np 7 | import unittest 8 | 9 | class TestThinning(unittest.TestCase): 10 | def setUp(self) -> None: 11 | img = cv2.imread("examples/thinning-example.png") 12 | # Convert to grayscale 13 | self.img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 14 | self.img_thresh = cv2.threshold(self.img, 180, 255, cv2.THRESH_BINARY)[1] 15 | 16 | return super().setUp() 17 | 18 | def _checkThinningImage(self, result): 19 | # Check corner conditions for thinning algorithms 20 | # a) No pixels that were not white before should be white now... 21 | black_orig = 255 - self.img_thresh 22 | self.assertFalse(np.any(np.logical_and(black_orig, result))) 23 | # b) There are some white pixels, at least for this example image 24 | self.assertTrue(np.any(result == 255)) 25 | # c) There are more black pixels than before 26 | orig_numblack = np.sum(self.img_thresh == 0) 27 | result_numblack = np.sum(result == 0) 28 | self.assertGreater(result_numblack, orig_numblack) 29 | 30 | def testGuoHall(self): 31 | "Test Guo-Hall thinning" 32 | # Currently just run and see if it crashes 33 | result = cv_algorithms.guo_hall(self.img_thresh) 34 | self._checkThinningImage(result) 35 | 36 | def testZhangSuen(self): 37 | "Test Zhang-Suen thinning" 38 | # Currently just run and see if it crashes 39 | result = cv_algorithms.zhang_suen(self.img_thresh) 40 | self._checkThinningImage(result) 41 | -------------------------------------------------------------------------------- /tests/TestUtils.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | import numpy as np 4 | from numpy.testing import assert_array_equal 5 | import cv_algorithms 6 | 7 | class TestSpreadToGrayscale(object): 8 | def test_zero_input(self): 9 | i = np.zeros((10,10), dtype=float) 10 | o = np.zeros((10,10), dtype=np.uint8) 11 | assert_array_equal(o, cv_algorithms.spread_to_grayscale(i)) 12 | 13 | def test_nonzero_input(self): 14 | i = np.zeros((10,10), dtype=float) 15 | i[3,5] = 17.25 16 | o = np.zeros((10,10), dtype=np.uint8) 17 | o[3,5] = 255 18 | assert_array_equal(o, cv_algorithms.spread_to_grayscale(i)) 19 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ulikoehler/cv_algorithms/67b6a04c4d0d41e5c46d259a44dce53eb8d36dc4/tests/__init__.py --------------------------------------------------------------------------------