diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt index 531ab842..4abe9256 100644 --- a/CONTRIBUTORS.txt +++ b/CONTRIBUTORS.txt @@ -105,11 +105,8 @@ Shape views: ``util.shape.view_as_windows`` and ``util.shape.view_as_blocks`` - Johannes Schönberger - Polygon, circle and ellipse drawing functions - Adaptive thresholding - Implementation of Matlab's `regionprops` - Estimation of geometric transformation parameters - Local binary pattern texture classification + Drawing functions, adaptive thresholding, regionprops, geometric + transformations, LBPs, polygon approximations, and more. - Pavel Campr Fixes and tests for Histograms of Oriented Gradients. diff --git a/doc/examples/plot_polygon.py b/doc/examples/plot_polygon.py new file mode 100644 index 00000000..6eaef0bf --- /dev/null +++ b/doc/examples/plot_polygon.py @@ -0,0 +1,77 @@ +""" +==================== +Approximate Polygons +==================== + +This example shows how to approximate polygonal chains with the Douglas-Peucker +algorithm. +""" + +import numpy as np +import matplotlib.pyplot as plt +from skimage.draw import ellipse +from skimage.measure import find_contours, approximate_polygon, \ + subdivide_polygon + + +hand = np.array([[1.64516129, 1.16145833], + [1.64516129, 1.59375 ], + [1.35080645, 1.921875 ], + [1.375 , 2.18229167], + [1.68548387, 1.9375 ], + [1.60887097, 2.55208333], + [1.68548387, 2.69791667], + [1.76209677, 2.56770833], + [1.83064516, 1.97395833], + [1.89516129, 2.75 ], + [1.9516129 , 2.84895833], + [2.01209677, 2.76041667], + [1.99193548, 1.99479167], + [2.11290323, 2.63020833], + [2.2016129 , 2.734375 ], + [2.25403226, 2.60416667], + [2.14919355, 1.953125 ], + [2.30645161, 2.36979167], + [2.39112903, 2.36979167], + [2.41532258, 2.1875 ], + [2.1733871 , 1.703125 ], + [2.07782258, 1.16666667]]) + +# subdivide polygon using 2nd degree B-Splines +new_hand = hand.copy() +for _ in range(5): + new_hand = subdivide_polygon(new_hand, degree=2, preserve_ends=True) + +# approximate subdivided polygon with Douglas-Peucker algorithm +appr_hand = approximate_polygon(new_hand, tolerance=0.02) + +print "Number of coordinates:", len(hand), len(new_hand), len(appr_hand) + +fig, (ax1, ax2) = plt.subplots(ncols=2, figsize=(9, 4)) + +ax1.plot(hand[:, 0], hand[:, 1]) +ax1.plot(new_hand[:, 0], new_hand[:, 1]) +ax1.plot(appr_hand[:, 0], appr_hand[:, 1]) + + +# create two ellipses in image +img = np.zeros((800, 800), 'int32') +rr, cc = ellipse(250, 250, 180, 230, img.shape) +img[rr, cc] = 1 +rr, cc = ellipse(600, 600, 150, 90, img.shape) +img[rr, cc] = 1 + +plt.gray() +ax2.imshow(img) + +# approximate / simplify coordinates of the two ellipses +for contour in find_contours(img, 0): + coords = approximate_polygon(contour, tolerance=2.5) + ax2.plot(coords[:, 1], coords[:, 0], '-r', linewidth=2) + coords2 = approximate_polygon(contour, tolerance=39.5) + ax2.plot(coords2[:, 1], coords2[:, 0], '-g', linewidth=2) + print "Number of coordinates:", len(contour), len(coords), len(coords2) + +ax2.axis((0, 800, 0, 800)) + +plt.show() diff --git a/doc/examples/plot_shapes.py b/doc/examples/plot_shapes.py index 8107fc80..9e586209 100644 --- a/doc/examples/plot_shapes.py +++ b/doc/examples/plot_shapes.py @@ -19,11 +19,11 @@ import numpy as np img = np.zeros((500, 500, 3), 'uint8') -#: draw line +# draw line rr, cc = line(120, 123, 20, 400) img[rr,cc,0] = 255 -#: fill polygon +# fill polygon poly = np.array(( (300, 300), (480, 320), @@ -34,11 +34,11 @@ poly = np.array(( rr, cc = polygon(poly[:,0], poly[:,1], img.shape) img[rr,cc,1] = 255 -#: fill circle +# fill circle rr, cc = circle(200, 200, 100, img.shape) img[rr,cc,:] = (255, 255, 0) -#: fill ellipse +# fill ellipse rr, cc = ellipse(300, 300, 100, 200, img.shape) img[rr,cc,2] = 255 diff --git a/skimage/measure/__init__.py b/skimage/measure/__init__.py index f5109698..1c92d9ee 100755 --- a/skimage/measure/__init__.py +++ b/skimage/measure/__init__.py @@ -1,3 +1,4 @@ from .find_contours import find_contours from ._regionprops import regionprops, perimeter from ._structural_similarity import structural_similarity +from ._polygon import approximate_polygon, subdivide_polygon \ No newline at end of file diff --git a/skimage/measure/_polygon.py b/skimage/measure/_polygon.py new file mode 100644 index 00000000..633a9418 --- /dev/null +++ b/skimage/measure/_polygon.py @@ -0,0 +1,169 @@ +import numpy as np +from scipy import signal + + +def approximate_polygon(coords, tolerance): + """Approximate a polygonal chain with the specified tolerance. + + It is based on the Douglas-Peucker algorithm. + + Note that the approximated polygon is always within the convex hull of the + original polygon. + + Parameters + ---------- + coords : (N, 2) array + Coordinate array. + tolerance : float + Maximum distance from original points of polygon to approximated + polygonal chain. If tolerance is 0, the original coordinate array + is returned. + + Returns + ------- + coords : (M, 2) array + Approximated polygonal chain where M <= N. + + References + ---------- + .. [1] http://en.wikipedia.org/wiki/Ramer-Douglas-Peucker_algorithm + """ + if tolerance == 0: + return coords + + chain = np.zeros(coords.shape[0], 'bool') + # pre-allocate distance array for all points + dists = np.zeros(coords.shape[0]) + chain[0] = True + chain[-1] = True + pos_stack = [(0, chain.shape[0] - 1)] + end_of_chain = False + + while not end_of_chain: + start, end = pos_stack.pop() + # determine properties of current line segment + r0, c0 = coords[start, :] + r1, c1 = coords[end, :] + dr = r1 - r0 + dc = c1 - c0 + segment_angle = - np.arctan2(dr, dc) + segment_dist = c0 * np.sin(segment_angle) + r0 * np.cos(segment_angle) + + # select points in-between line segment + segment_coords = coords[start + 1:end, :] + segment_dists = dists[start + 1:end] + + + # check whether to take perpendicular or euclidean distance with + # inner product of vectors + + # vectors from points -> start and end + dr0 = segment_coords[:, 0] - r0 + dc0 = segment_coords[:, 1] - c0 + dr1 = segment_coords[:, 0] - r1 + dc1 = segment_coords[:, 1] - c1 + # vectors points -> start and end projected on start -> end vector + projected_lengths0 = dr0 * dr + dc0 * dc + projected_lengths1 = - dr1 * dr - dc1 * dc + perp = np.logical_and(projected_lengths0 > 0, + projected_lengths1 > 0) + eucl = np.logical_not(perp) + segment_dists[perp] = np.abs( + segment_coords[perp, 0] * np.cos(segment_angle) + + segment_coords[perp, 1] * np.sin(segment_angle) + - segment_dist + ) + segment_dists[eucl] = np.minimum( + # distance to start point + np.sqrt(dc0[eucl] ** 2 + dr0[eucl] ** 2), + # distance to end point + np.sqrt(dc1[eucl] ** 2 + dr1[eucl] ** 2) + ) + + if np.any(segment_dists > tolerance): + # select point with maximum distance to line + new_end = start + np.argmax(segment_dists) + 1 + pos_stack.append((new_end, end)) + pos_stack.append((start, new_end)) + chain[new_end] = True + + if len(pos_stack) == 0: + end_of_chain = True + + return coords[chain, :] + + +# B-Spline subdivision +_SUBDIVISION_MASKS = { + # degree: (mask_even, mask_odd) + # extracted from (degree + 2)th row of Pascal's triangle + 1: ([1, 1], [1, 1]), + 2: ([3, 1], [1, 3]), + 3: ([1, 6, 1], [0, 4, 4]), + 4: ([5, 10, 1], [1, 10, 5]), + 5: ([1, 15, 15, 1], [0, 6, 20, 6]), + 6: ([7, 35, 21, 1], [1, 21, 35, 7]), + 7: ([1, 28, 70, 28, 1], [0, 8, 56, 56, 8]), +} + + +def subdivide_polygon(coords, degree=2, preserve_ends=False): + """Subdivision of polygonal curves using B-Splines. + + Note that the resulting curve is always within the convex hull of the + original polygon. Circular polygons stay closed after subdivision. + + Parameters + ---------- + coords : (N, 2) array + Coordinate array. + degree : {1, 2, 3, 4, 5, 6, 7}, optional + Degree of B-Spline. Default is 2. + preserve_ends : bool, optional + Preserve first and last coordinate of non-circular polygon. Default is + False. + + Returns + ------- + coords : (M, 2) array + Subdivided coordinate array. + + References + ---------- + .. [1] http://mrl.nyu.edu/publications/subdiv-course2000/coursenotes00.pdf + """ + if degree not in _SUBDIVISION_MASKS: + raise ValueError("Invalid B-Spline degree. Only degree 1 - 7 is " + "supported.") + + circular = np.all(coords[0, :] == coords[-1, :]) + + method = 'valid' + if circular: + # remove last coordinate because of wrapping + coords = coords[:-1, :] + # circular convolution by wrapping boundaries + method = 'same' + + mask_even, mask_odd = _SUBDIVISION_MASKS[degree] + # divide by total weight + mask_even = np.array(mask_even, np.float) / (2 ** degree) + mask_odd = np.array(mask_odd, np.float) / (2 ** degree) + + even = signal.convolve2d(coords.T, np.atleast_2d(mask_even), mode=method, + boundary='wrap') + odd = signal.convolve2d(coords.T, np.atleast_2d(mask_odd), mode=method, + boundary='wrap') + + out = np.zeros((even.shape[1] + odd.shape[1], 2)) + out[1::2] = even.T + out[::2] = odd.T + + if circular: + # close polygon + out = np.vstack([out, out[0, :]]) + + if preserve_ends and not circular: + out = np.vstack([coords[0, :], out, coords[-1, :]]) + + return out diff --git a/skimage/measure/tests/test_polygon.py b/skimage/measure/tests/test_polygon.py new file mode 100644 index 00000000..f239c735 --- /dev/null +++ b/skimage/measure/tests/test_polygon.py @@ -0,0 +1,56 @@ +import numpy as np +from skimage.measure import approximate_polygon, subdivide_polygon +from skimage.measure._polygon import _SUBDIVISION_MASKS + +square = np.array([ + [0, 0], [0, 1], [0, 2], [0, 3], + [1, 3], [2, 3], [3, 3], + [3, 2], [3, 1], [3, 0], + [2, 0], [1, 0], [0, 0] +]) + + +def test_approximate_polygon(): + out = approximate_polygon(square, 0.1) + np.testing.assert_array_equal(out, square[(0, 3, 6, 9, 12), :]) + + out = approximate_polygon(square, 2.2) + np.testing.assert_array_equal(out, square[(0, 6, 12), :]) + + out = approximate_polygon(square[(0, 1, 3, 4, 5, 6, 7, 9, 11, 12), :], 0.1) + np.testing.assert_array_equal(out, square[(0, 3, 6, 9, 12), :]) + + out = approximate_polygon(square, -1) + np.testing.assert_array_equal(out, square) + + +def test_subdivide_polygon(): + new_square1 = square + new_square2 = square[:-1] + new_square3 = square[:-1] + # test iterative subdvision + for _ in range(10): + square1, square2, square3 = new_square1, new_square2, new_square3 + # test different B-Spline degrees + for degree in range(1, 7): + mask_len = len(_SUBDIVISION_MASKS[degree][0]) + # test circular + new_square1 = subdivide_polygon(square1, degree) + np.testing.assert_array_equal(new_square1[-1], new_square1[0]) + np.testing.assert_equal(new_square1.shape[0], + 2 * square1.shape[0] - 1) + # test non-circular + new_square2 = subdivide_polygon(square2, degree) + np.testing.assert_equal(new_square2.shape[0], + 2 * (square2.shape[0] - mask_len + 1)) + # test non-circular, preserve_ends + new_square3 = subdivide_polygon(square3, degree, True) + np.testing.assert_equal(new_square3[0], square3[0]) + np.testing.assert_equal(new_square3[-1], square3[-1]) + print mask_len + np.testing.assert_equal(new_square3.shape[0], + 2 * (square3.shape[0] - mask_len + 2)) + + +if __name__ == "__main__": + np.testing.run_module_suite()