pythonopencvmatplotlibvoronoigenerative-art

Create Voronoi art with rounded region edges


I'm trying to create some artistic "plots" like the ones below:Red Voronoi art enter image description here

The color of the regions do not really matter, what I'm trying to achieve is the variable "thickness" of the edges along the Voronoi regions (espescially, how they look like a bigger rounded blob where they meet in corners, and thinner at their middle point).

I've tried by "painting manually" each pixel based on the minimum distance to each centroid (each associated with a color):

n_centroids = 10
centroids = [(random.randint(0, h), random.randint(0, w)) for _ in range(n_centroids)]
colors = np.array([np.random.choice(range(256), size=3) for _ in range(n_centroids)]) / 255

for x, y in it.product(range(h), range(w)):
    distances = np.sqrt([(x - c[0])**2 + (y - c[1])**2 for c in centroids])
    centroid_i = np.argmin(distances)
    img[x, y] = colors[centroid_i]
    
plt.imshow(img, cmap='gray')

voronoi diagram

Or by scipy.spatial.Voronoi, that also gives me the vertices points, although I still can't see how I can draw a line through them with the desired variable thickness.

from scipy.spatial import Voronoi, voronoi_plot_2d

# make up data points
points = [(random.randint(0, 10), random.randint(0, 10)) for _ in range(10)]

# add 4 distant dummy points
points = np.append(points, [[999,999], [-999,999], [999,-999], [-999,-999]], axis = 0)

# compute Voronoi tesselation
vor = Voronoi(points)

# plot
voronoi_plot_2d(vor)

# colorize
for region in vor.regions:
    if not -1 in region:
        polygon = [vor.vertices[i] for i in region]
        plt.fill(*zip(*polygon))

# fix the range of axes
plt.xlim([-2,12]), plt.ylim([-2,12])
plt.show()

voronoi region plot

Edit:

I've managed to get a somewhat satisfying result via erosion + corner smoothing (via median filter as suggested in the comments) on each individual region, then drawing it into a black background.

res = np.zeros((h,w,3))
for color in colors:
    region = (img == color)[:,:,0]
    region = region.astype(np.uint8) * 255
    region = sg.medfilt2d(region, 15) # smooth corners
    # make edges from eroding regions
    region = cv2.erode(region, np.ones((3, 3), np.uint8))
    region = region.astype(bool)
    res[region] = color
    
plt.imshow(res)

voronoi art But as you can see the "stretched" line along the boundaries/edges of the regions is not quite there. Any other suggestions?


Solution

  • This is what @JohanC suggestion looks like. IMO, it looks much better than my attempt with Bezier curves. However, there appears to be a small problem with the RoundedPolygon class, as there are sometimes small defects at the corners (e.g. between blue and purple in the image below).

    Edit: I fixed the RoundedPolygon class.

    enter image description here

    #!/usr/bin/env python
    # coding: utf-8
    """
    https://stackoverflow.com/questions/72061965/create-voronoi-art-with-rounded-region-edges
    """
    
    import numpy as np
    import matplotlib.pyplot as plt
    
    from matplotlib import patches, path
    from scipy.spatial import Voronoi, voronoi_plot_2d
    
    
    def shrink(polygon, pad):
        center = np.mean(polygon, axis=0)
        resized = np.zeros_like(polygon)
        for ii, point in enumerate(polygon):
            vector = point - center
            unit_vector = vector / np.linalg.norm(vector)
            resized[ii] = point - pad * unit_vector
        return resized
    
    
    class RoundedPolygon(patches.PathPatch):
        # https://stackoverflow.com/a/66279687/2912349
        def __init__(self, xy, pad, **kwargs):
            p = path.Path(*self.__round(xy=xy, pad=pad))
            super().__init__(path=p, **kwargs)
    
        def __round(self, xy, pad):
            n = len(xy)
    
            for i in range(0, n):
    
                x0, x1, x2 = np.atleast_1d(xy[i - 1], xy[i], xy[(i + 1) % n])
    
                d01, d12 = x1 - x0, x2 - x1
                l01, l12 = np.linalg.norm(d01), np.linalg.norm(d12)
                u01, u12 = d01 / l01, d12 / l12
    
                x00 = x0 + min(pad, 0.5 * l01) * u01
                x01 = x1 - min(pad, 0.5 * l01) * u01
                x10 = x1 + min(pad, 0.5 * l12) * u12
                x11 = x2 - min(pad, 0.5 * l12) * u12
    
                if i == 0:
                    verts = [x00, x01, x1, x10]
                else:
                    verts += [x01, x1, x10]
    
            codes = [path.Path.MOVETO] + n*[path.Path.LINETO, path.Path.CURVE3, path.Path.CURVE3]
    
            verts[0] = verts[-1]
    
            return np.atleast_1d(verts, codes)
    
    
    if __name__ == '__main__':
    
        # make up data points
        n = 100
        max_x = 20
        max_y = 10
        points = np.c_[np.random.uniform(0, max_x, size=n),
                       np.random.uniform(0, max_y, size=n)]
    
        # add 4 distant dummy points
        points = np.append(points, [[2 * max_x, 2 * max_y],
                                    [   -max_x, 2 * max_y],
                                    [2 * max_x,    -max_y],
                                    [   -max_x,    -max_y]], axis = 0)
    
        # compute Voronoi tesselation
        vor = Voronoi(points)
    
        fig, ax = plt.subplots(figsize=(max_x, max_y))
        for region in vor.regions:
            if region and (not -1 in region):
                polygon = np.array([vor.vertices[i] for i in region])
                resized = shrink(polygon, 0.15)
                ax.add_patch(RoundedPolygon(resized, 0.2, color=plt.cm.Reds(0.5 + 0.5*np.random.rand())))
    
        ax.axis([0, max_x, 0, max_y])
        ax.axis('off')
        ax.set_facecolor('black')
        ax.add_artist(ax.patch)
        ax.patch.set_zorder(-1)
        plt.show()