├── .github └── workflows │ └── python-publish.yml ├── .gitignore ├── ChangeLog ├── LICENSE ├── README.md ├── doc ├── accumulator.png ├── edges.png ├── image.png ├── lines.png ├── pclines.svg └── test.png ├── example.ipynb ├── pclines ├── __init__.py ├── accumulator.py ├── rasterizer.py └── utils.py ├── requirements.txt └── setup.py /.github/workflows/python-publish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [created] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Set up Python 18 | uses: actions/setup-python@v2 19 | with: 20 | python-version: '3.6' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.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 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | # v1.0.1 2 | * Some basic doc 3 | * Fixed setup 4 | 5 | # v1.0.0 6 | * Rasterization with `numba` 7 | * First public version which works! -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Roman Juranek 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `pclines` package for Python 2 | 3 | ![pclines](doc/pclines.svg) 4 | 5 | This package implements a PCLines transform for line detection in images. 6 | 7 | ```bibtex 8 | @INPROCEEDINGS{dubska2011pclines, 9 | author={M. {Dubská} and A. {Herout} and J. {Havel}}, 10 | booktitle={CVPR 2011}, 11 | title={PClines — Line detection using parallel coordinates}, 12 | year={2011}, 13 | } 14 | ``` 15 | 16 | # Requrements 17 | 18 | * Python 3.6+ 19 | * numpy 20 | * numba 21 | * scikit-image 22 | 23 | # Installation 24 | 25 | The package is on [PyPI](https://pypi.org/project/pclines/), so just run following command and install the package. 26 | 27 | ```shell 28 | > pip install pclines 29 | ``` 30 | 31 | Alternatively, you can download this repository and install manually. 32 | 33 | 34 | # Example 35 | 36 | 1. Import package 37 | 38 | ```python 39 | import pclines as pcl 40 | ``` 41 | 42 | 2. Data and observations 43 | The observations are 2D weighted coordinates enclosed by a known bounding box. As an example we extract edge points from an image. 44 | 45 | ```python 46 | image = imread("doc/test.png", as_gray=True) 47 | edges = sobel(image) 48 | r,c = np.nonzero(edges > 0.5) 49 | x = np.array([c,r],"i").T 50 | weights = edges[r,c] 51 | ``` 52 | ![](doc/image.png) ![](doc/edges.png) 53 | 54 | 3. Accumulation in PCLines space 55 | 56 | ```python 57 | h,w = image.shape[:2] 58 | bbox=(0,0,w,h) # Bounding box of observations 59 | d = 1024 # Accumulator resolution 60 | P = PCLines(bbox, d) # Create new accumulator 61 | P.insert(x, weights) # Insert observations 62 | p, w = P.find_peaks(min_dist=10, prominence=1.3, t=0.1) # Find local maxima 63 | ``` 64 | 65 | ![](doc/accumulator.png) 66 | 67 | 4. Detected lines 68 | 69 | ```python 70 | h = P.inverse(p) # (a,b,c) parameters of lines 71 | X,Y = utils.line_segments_from_homogeneous(h, bbox) # Convert to line segments for plotting 72 | ``` 73 | 74 | ![](doc/lines.png) 75 | 76 | 77 | # Contribute 78 | 79 | If you have a suggestion for improvement, let us know by filling an issue. Or you can fork the project and submit a pull request. 80 | 81 | -------------------------------------------------------------------------------- /doc/accumulator.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RomanJuranek/pclines-python/c442cd0dae8fe23044b05ba2cef517456c8e513e/doc/accumulator.png -------------------------------------------------------------------------------- /doc/edges.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RomanJuranek/pclines-python/c442cd0dae8fe23044b05ba2cef517456c8e513e/doc/edges.png -------------------------------------------------------------------------------- /doc/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RomanJuranek/pclines-python/c442cd0dae8fe23044b05ba2cef517456c8e513e/doc/image.png -------------------------------------------------------------------------------- /doc/lines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RomanJuranek/pclines-python/c442cd0dae8fe23044b05ba2cef517456c8e513e/doc/lines.png -------------------------------------------------------------------------------- /doc/pclines.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 32 | 36 | 37 | 38 | 62 | 67 | 68 | 70 | 71 | 73 | image/svg+xml 74 | 76 | 77 | 78 | 79 | 80 | 85 | 93 | 101 | 107 | 113 | 119 | 125 | 131 | 137 | 143 | 144 | 145 | -------------------------------------------------------------------------------- /doc/test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RomanJuranek/pclines-python/c442cd0dae8fe23044b05ba2cef517456c8e513e/doc/test.png -------------------------------------------------------------------------------- /pclines/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | PCLines transform for line detection 3 | """ 4 | 5 | from .accumulator import PCLines -------------------------------------------------------------------------------- /pclines/accumulator.py: -------------------------------------------------------------------------------- 1 | """ 2 | The module provides the class PCLines with "low level" functions for 3 | * accumulation of observations to PCLines space, 4 | * point mapping from PCLines space to homogeneous lines 5 | that can be used to construct a custom PCLines transform of user-defined 6 | edge points (observations). 7 | 8 | 9 | References 10 | ---------- 11 | [1] Dubska et al, PCLines - Line detection with parallel coordinates, CVPR 2011 12 | """ 13 | 14 | 15 | import numpy as np 16 | from skimage.feature import peak_local_max 17 | from skimage.morphology.grey import erosion, dilation 18 | from skimage.morphology import disk 19 | 20 | from .rasterizer import polys 21 | 22 | 23 | def _linear_transform(src, dst): 24 | """ Parameters of a linear transform from range specifications """ 25 | (s0, s1), (d0,d1) = src, dst 26 | w = (d1 - d0) / (s1 - s0) 27 | b = d0 - w*s0 28 | return w, b 29 | 30 | 31 | def _validate_points(x:np.ndarray): 32 | if not isinstance(x, np.ndarray): 33 | raise TypeError("Points must be numpy array") 34 | if x.ndim != 2 or x.shape[1] != 2: 35 | raise ValueError("Points must be 2D array with 2 columns") 36 | 37 | 38 | def _validate_bbox(bbox): 39 | if not isinstance(bbox, (tuple, list, np.ndarray)): 40 | raise TypeError("bbox must be tuple, list or numpy array") 41 | if len(bbox) != 4: 42 | raise ValueError("bbox must contain 4 elements") 43 | x,y,w,h = np.array(bbox,"f") 44 | if w <= 0 or h <= 0: 45 | raise ValueError("bbox width and heigh must be positive") 46 | 47 | 48 | class Normalizer: 49 | """ 50 | Linear mapping of ranges 51 | 52 | The class maps values from source range (a,b) to a destination 53 | range (c,d) by scaling and offset. 54 | 55 | The `transform` then maps a to c and b to d. The `inverse` 56 | then does inverse transform. 57 | """ 58 | def __init__(self, src_range, dst_range=(0,1)): 59 | """ 60 | Inputs 61 | ------ 62 | src_range, dst_range: tuple 63 | A tuple with two values [a, b] of source and destination ranges 64 | 65 | Example 66 | ------- 67 | >> T = Normalizer((0,10), (1,-1)) 68 | >> T.transform(10) 69 | -1 70 | >> T.transform(6) 71 | -0.2 72 | >> T.inverse(-1) 73 | 10 74 | """ 75 | # TODO: check ranges 76 | self.w, self.b = _linear_transform(src_range, dst_range) 77 | self.wi, self.bi = _linear_transform(dst_range, src_range) 78 | 79 | def transform(self, x): 80 | """ src -> dst mapping of x 81 | 82 | Transforms values of x from source range to destination range. 83 | 84 | Inputs 85 | ------ 86 | x : ndarray 87 | values to transform 88 | 89 | Outputs 90 | ------- 91 | y : ndarray 92 | Transformed x 93 | 94 | Example 95 | ------- 96 | >> T = Normalizer((0, 100), (0, 1)) 97 | >> T(50) 98 | 0.5 99 | """ 100 | return self.w * x + self.b 101 | 102 | def inverse(self, x): 103 | """ dst -> src mapping of x 104 | 105 | Transforms values of x from destination range to the source range. 106 | 107 | Inputs 108 | ------ 109 | x : ndarray 110 | values to transform 111 | 112 | Outputs 113 | ------- 114 | y : ndarray 115 | Transformed x 116 | 117 | Example 118 | ------- 119 | >> T = Normalizer((0, 100), (0, 1)) 120 | >> T(0.5) 121 | 50 122 | """ 123 | return self.wi * x + self.bi 124 | 125 | __call__ = transform 126 | 127 | 128 | class PCLines: 129 | """ 130 | Wrapper for PCLines accumulator of certain size 131 | """ 132 | def __init__(self, bbox, d=256): 133 | _validate_bbox(bbox) 134 | self.bbox = bbox 135 | 136 | # Init accumulator 137 | shape = d, 2*d-1 138 | self.A = np.zeros(shape, "f") 139 | self.d = d 140 | 141 | ranges = d * np.array(self.input_shape)/self.scale 142 | ofs = (d-ranges) / 2 143 | (x0,y0),(x1,y1) = ofs, (ranges-1)+ofs 144 | 145 | x,y = self.origin 146 | w,h = self.input_shape 147 | self.norm_u = Normalizer((y,y+h+1), (y1, y0)) 148 | self.norm_v = Normalizer((x,x+w+1), (x0, x1)) 149 | self.norm_w = Normalizer((y,y+h+1), (y0, y1)) 150 | 151 | @property 152 | def origin(self): 153 | """ The origin of the bounding box of the observations """ 154 | return self.bbox[:2] 155 | 156 | @property 157 | def input_shape(self): 158 | """ Size of the bounding box """ 159 | return self.bbox[2:] 160 | 161 | @property 162 | def scale(self): 163 | """ Larger side of the bounding box """ 164 | return max(self.input_shape) 165 | 166 | def clear(self): 167 | """ Set the accumulator to 0 """ 168 | self.A[:] = 0 169 | 170 | def transform(self, x): 171 | """ 172 | Transform points x to the PCLines space and return polylines. 173 | 174 | Input 175 | ----- 176 | x : ndarray 177 | Nx2 array with points 178 | 179 | Output 180 | ------ 181 | p : ndarray 182 | Nx3 array with polyline coordinates for u, v, w parallel axes 183 | """ 184 | _validate_points(x) 185 | x0,x1 = np.split(x,2,axis=1) 186 | return np.concatenate([self.norm_u(x1), self.norm_v(x0), self.norm_w(x1)], axis=1).astype("f") 187 | 188 | def inverse(self, l): 189 | """ 190 | Transform a point from PCLines to homogeneous parameters of line 191 | """ 192 | d = self.d 193 | m = self.scale - 1 194 | norm_v = Normalizer((0,d-1),(-m/2, m/2)) 195 | 196 | u,v = l[:,1], l[:,0] 197 | u = u - (d - 1) 198 | v = norm_v(v) 199 | 200 | f = u < 0 201 | lines = np.array([f*(d+u)+(1-f)*(d-u), u, -v*d], "f").T # TODO: add reference to eq in paper 202 | x,y = self.origin 203 | w,h = self.input_shape 204 | tx,ty = x+0.5*w, y+0.5*h 205 | lines[:,2] -= lines[:,0]*tx + lines[:,1]*ty 206 | return lines 207 | 208 | def valid_points(self, p): 209 | """ Check if the points fits the accumulator """ 210 | return np.all(np.logical_and(p>=0, p prominence 252 | return peaks[valid], value[valid] 253 | -------------------------------------------------------------------------------- /pclines/rasterizer.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | import numba as nb 3 | 4 | @nb.njit 5 | def line(xr, yr, w, arr): 6 | x0,x1 = xr 7 | y0,y1 = yr 8 | dy = (y1-y0) / (x1-x0) 9 | for step,x in enumerate(range(x0, x1)): 10 | y = int(y0 + (dy * step)) 11 | arr[y,x] += w 12 | 13 | @nb.njit 14 | def polys(p, weight, arr): 15 | n = weight.size 16 | d = arr.shape[0] 17 | for i in range(n): 18 | u,v,w = p[i] 19 | wt = weight[i] 20 | # rasterize (u,v), (0,d-1) 21 | line((0, d), (u, v), wt, arr) 22 | # rasterize (v,w), (d, 2d-1) 23 | line((d, 2*d-1), (v, w), wt, arr) 24 | 25 | 26 | -------------------------------------------------------------------------------- /pclines/utils.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | 3 | 4 | def line_segments_from_homogeneous(lines, bbox): 5 | x,y,w,h = bbox 6 | 7 | # Corner points 8 | A = np.array([x,y,1]) 9 | B = np.array([x+w,y,1]) 10 | C = np.array([x+w,y+h,1]) 11 | D = np.array([x,y+h,1]) 12 | 13 | # Cross product of pairs of corner points 14 | edges = [ 15 | np.cross(a,b) for a,b in [[A,B],[B,C],[C,D],[D,A]] 16 | ] 17 | 18 | # Cross product of line params with edges 19 | intersections = [ 20 | np.cross(lines, e) for e in edges 21 | ] 22 | 23 | # Normalize 24 | normalized = [ 25 | p[:,:2] / p[:,-1].reshape(-1,1) for p in intersections 26 | ] 27 | 28 | X = [] 29 | Y = [] 30 | 31 | for p in zip(*normalized): 32 | P = [] 33 | for (u,v) in p: 34 | if (x <= u <= x+w) and (y <= v <= y+h): 35 | P.append( (u,v) ) 36 | if len(P) == 2: 37 | (x0,y0), (x1,y1) = P 38 | X.append( (x0,x1) ) 39 | Y.append( (y0,y1) ) 40 | else: 41 | X.append(None) 42 | Y.append(None) 43 | 44 | return X, Y -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | numba 3 | scikit-image -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | import os 3 | 4 | current_folder = os.path.abspath(os.path.dirname(__file__)) 5 | with open(os.path.join(current_folder, 'README.md'), encoding='utf-8') as f: 6 | long_description = f.read() 7 | 8 | setup( 9 | name='pclines', 10 | version='1.0.2', 11 | description='PCLines transform for python', 12 | long_description=long_description, 13 | long_description_content_type='text/markdown', 14 | url='https://github.com/RomanJuranek/pclines-python', 15 | author='Roman Juranek', 16 | author_email='ijuranek@fit.vutbr.cz', 17 | license='BSD3', 18 | keywords='pclines, hough transform, line detection', 19 | packages=["pclines"], 20 | install_requires=['numpy','numba','scikit-image'], 21 | python_requires='>=3.6', 22 | project_urls={ 23 | "Bug reports": 'https://github.com/RomanJuranek/pclines-python/issues', 24 | }, 25 | ) 26 | --------------------------------------------------------------------------------