├── .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 | 
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 |  
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 | 
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 | 
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 |
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 |
--------------------------------------------------------------------------------