├── .gitignore ├── BTD.jpg ├── BTD_triangle_100_points.jpg ├── BTD_triangle_500_points.jpg ├── BTD_triangle_50_points.jpg ├── test-images ├── pexels-photo-351263.jpeg ├── pexels-photo-359989.jpeg ├── pexels-photo-97108.jpeg ├── sky-earth-galaxy-universe.jpg ├── kitty-cat-kitten-pet-45201.jpeg └── mount-everest-himalayas-nuptse-lhotse-51387.jpeg ├── triangulared ├── __init__.py ├── drawers.py ├── utils.py └── point_generators.py ├── README.md ├── requirements.txt └── triangleit.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | .ipynb_checkpoints/* 3 | */__pycache__/* 4 | -------------------------------------------------------------------------------- /BTD.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/BTD.jpg -------------------------------------------------------------------------------- /BTD_triangle_100_points.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/BTD_triangle_100_points.jpg -------------------------------------------------------------------------------- /BTD_triangle_500_points.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/BTD_triangle_500_points.jpg -------------------------------------------------------------------------------- /BTD_triangle_50_points.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/BTD_triangle_50_points.jpg -------------------------------------------------------------------------------- /test-images/pexels-photo-351263.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/test-images/pexels-photo-351263.jpeg -------------------------------------------------------------------------------- /test-images/pexels-photo-359989.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/test-images/pexels-photo-359989.jpeg -------------------------------------------------------------------------------- /test-images/pexels-photo-97108.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/test-images/pexels-photo-97108.jpeg -------------------------------------------------------------------------------- /test-images/sky-earth-galaxy-universe.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/test-images/sky-earth-galaxy-universe.jpg -------------------------------------------------------------------------------- /test-images/kitty-cat-kitten-pet-45201.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/test-images/kitty-cat-kitten-pet-45201.jpeg -------------------------------------------------------------------------------- /test-images/mount-everest-himalayas-nuptse-lhotse-51387.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonocarroll/images-to-triangles/master/test-images/mount-everest-himalayas-nuptse-lhotse-51387.jpeg -------------------------------------------------------------------------------- /triangulared/__init__.py: -------------------------------------------------------------------------------- 1 | from triangulared.drawers import draw_image, draw_triangles, draw_points, \ 2 | set_axis_defaults 3 | from triangulared.utils import get_triangle_colour, gaussian_mask 4 | from triangulared.point_generators import edge_points, \ 5 | generate_uniform_random_points, generate_max_entropy_points 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Images to Triangles 2 | 3 | The name says it all. The code for [this blog post](http://www.degeneratestate.org/posts/2017/May/24/images-to-triangles/). 4 | 5 | Examples: 6 | 7 | ![original](BTD.jpg) 8 | 9 | ![50 points](BTD_triangle_50_points.jpg) 10 | 11 | ![100 points](BTD_triangle_100_points.jpg) 12 | 13 | ![500 points](BTD_triangle_500_points.jpg) 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | appdirs==1.4.3 2 | bleach==2.0.0 3 | cycler==0.10.0 4 | decorator==4.0.11 5 | entrypoints==0.2.2 6 | html5lib==0.999999999 7 | ipykernel==4.6.1 8 | ipython==6.0.0 9 | ipython-genutils==0.2.0 10 | ipywidgets==6.0.0 11 | jedi==0.10.2 12 | Jinja2==2.9.6 13 | jsonschema==2.6.0 14 | jupyter==1.0.0 15 | jupyter-client==5.0.1 16 | jupyter-console==5.1.0 17 | jupyter-core==4.3.0 18 | MarkupSafe==1.0 19 | matplotlib==2.0.2 20 | mistune==0.7.4 21 | nbconvert==5.1.1 22 | nbformat==4.3.0 23 | networkx==1.11 24 | notebook==5.0.0 25 | numpy==1.12.1 26 | olefile==0.44 27 | packaging==16.8 28 | pandas==0.20.1 29 | pandocfilters==1.4.1 30 | pexpect==4.2.1 31 | pickleshare==0.7.4 32 | Pillow==4.1.1 33 | pkg-resources==0.0.0 34 | prompt-toolkit==1.0.14 35 | ptyprocess==0.5.1 36 | Pygments==2.2.0 37 | pyparsing==2.2.0 38 | python-dateutil==2.6.0 39 | pytz==2017.2 40 | PyWavelets==0.5.2 41 | pyzmq==16.0.2 42 | qtconsole==4.3.0 43 | scikit-image==0.13.0 44 | scipy==0.19.0 45 | simplegeneric==0.8.1 46 | six==1.10.0 47 | terminado==0.6 48 | testpath==0.3 49 | tornado==4.5.1 50 | traitlets==4.3.2 51 | wcwidth==0.1.7 52 | webencodings==0.5.1 53 | widgetsnbextension==2.0.0 54 | -------------------------------------------------------------------------------- /triangleit.py: -------------------------------------------------------------------------------- 1 | from triangulared import generate_max_entropy_points, get_triangle_colour, draw_triangles, set_axis_defaults, edge_points 2 | import numpy as np 3 | import matplotlib.pyplot as plt 4 | from scipy.spatial import Delaunay 5 | import argparse 6 | 7 | 8 | def process(input_path, output_path, n_points): 9 | image = plt.imread(input_path) 10 | points = generate_max_entropy_points(image, n_points=n_points) 11 | points = np.concatenate([points, edge_points(image)]) 12 | 13 | tri = Delaunay(points) 14 | 15 | fig, ax = plt.subplots() 16 | ax.invert_yaxis() 17 | triangle_colours = get_triangle_colour(tri, image) 18 | draw_triangles(ax, tri.points, tri.simplices, triangle_colours) 19 | 20 | # remove boundary 21 | ax.axis("tight") 22 | ax.set_axis_off() 23 | ax.get_xaxis().set_visible(False) 24 | ax.get_yaxis().set_visible(False) 25 | 26 | ratio = image.shape[0] / image.shape[1] 27 | fig.set_size_inches(5, 5*ratio) 28 | 29 | fig.savefig(output_path, bbox_inches='tight', pad_inches=0) 30 | 31 | 32 | if __name__ == "__main__": 33 | parser = argparse.ArgumentParser( 34 | description="Turns and image into triangles") 35 | parser.add_argument("input_file") 36 | parser.add_argument("output_file") 37 | parser.add_argument("-n", "--n_points", nargs='?', 38 | help="number of points to use", default=100) 39 | 40 | ns = parser.parse_args() 41 | 42 | input_file = ns.input_file 43 | output_file = ns.output_file 44 | n_points = int(ns.n_points) 45 | 46 | process(input_path=input_file, output_path=output_file, n_points=n_points) 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /triangulared/drawers.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities to draw different steps to a matplotlib ax 3 | """ 4 | 5 | import matplotlib.pyplot as plt 6 | from matplotlib.patches import Polygon 7 | 8 | 9 | def set_axis_defaults(ax): 10 | """ 11 | Set's some defaults for a matplotlib ax 12 | 13 | :param ax: ax to change 14 | :return: None 15 | """ 16 | ax.axis("off") 17 | ax.axis("tight") 18 | ax.set_aspect("equal") 19 | ax.autoscale(False) 20 | 21 | 22 | def draw_image(ax, image): 23 | """ 24 | Plots image to an ax 25 | :param ax: matplotlib axis 26 | :param image: image in array form 27 | :return: None 28 | """ 29 | ax.imshow(image) 30 | 31 | 32 | def draw_points(ax, points): 33 | """ 34 | Plots a set of points on an ax 35 | :param ax: ax 36 | :param points: array of (x,y) coordinates 37 | :return: None 38 | """ 39 | ax.scatter(x=points[:, 0], y=points[:, 1], color="k") 40 | 41 | 42 | def draw_triangles(ax, points, vertices, colours=None, **kwargs): 43 | """ 44 | Draws a set of triangles on axis 45 | :param ax: ax 46 | :param points: array of (x,y) coordinates 47 | :param vertices: an array of the vertices of the triangles, indexing the array points 48 | :param colours: colour of the faces, set as none just to plot the outline 49 | :param kwargs: kwargs passed to Polygon 50 | :return: None 51 | """ 52 | 53 | if colours is None: 54 | face_colours = len(vertices) * ["none"] 55 | line_colours = len(vertices) * ["black"] 56 | else: 57 | face_colours = colours 58 | line_colours = colours 59 | 60 | for triangle, fc, ec in zip(vertices, face_colours, line_colours): 61 | p = Polygon([points[i] 62 | for i in triangle], 63 | closed=True, facecolor=fc, 64 | edgecolor=ec, **kwargs) 65 | ax.add_patch(p) 66 | -------------------------------------------------------------------------------- /triangulared/utils.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | 4 | 5 | def get_triangle_colour(triangles, image, agg_func=np.median): 6 | """ 7 | Get's the colour of a triangle, based on applying agg_func to the pixels 8 | under it 9 | :param triangles: scipy.spatial.Delaunay 10 | :param image: image as array 11 | :param agg_func: function 12 | :return: colour list 13 | """ 14 | # create a list of all pixel coordinates 15 | ymax, xmax = image.shape[:2] 16 | xx, yy = np.meshgrid(np.arange(xmax), np.arange(ymax)) 17 | pixel_coords = np.c_[xx.ravel(), yy.ravel()] 18 | 19 | # for each pixel, identify which triangle it belongs to 20 | triangles_for_coord = triangles.find_simplex(pixel_coords) 21 | 22 | df = pd.DataFrame({ 23 | "triangle": triangles_for_coord, 24 | "r": image.reshape(-1, 3)[:, 0], 25 | "g": image.reshape(-1, 3)[:, 1], 26 | "b": image.reshape(-1, 3)[:, 2] 27 | }) 28 | 29 | n_triangles = triangles.simplices.shape[0] 30 | 31 | by_triangle = ( 32 | df 33 | .groupby("triangle") 34 | [["r", "g", "b"]] 35 | .aggregate(agg_func) 36 | .reindex(range(n_triangles), fill_value=0) 37 | # some triangles might not have pixels in them 38 | ) 39 | 40 | return by_triangle.values / 256 41 | 42 | 43 | def gaussian_mask(x, y, shape, amp=1, sigma=15): 44 | """ 45 | Returns an array of shape, with values based on 46 | 47 | amp * exp(-((i-x)**2 +(j-y)**2) / (2 * sigma ** 2)) 48 | 49 | :param x: float 50 | :param y: float 51 | :param shape: tuple 52 | :param amp: float 53 | :param sigma: float 54 | :return: array 55 | """ 56 | xv, yv = np.meshgrid(np.arange(shape[1]), np.arange(shape[0])) 57 | g = amp * np.exp(-((xv - x) ** 2 + (yv - y) ** 2) / (2 * sigma ** 2)) 58 | return g 59 | 60 | 61 | def default(value, default_value): 62 | """ 63 | Returns default_value if value is None, value otherwise 64 | """ 65 | if value is None: 66 | return default_value 67 | return value 68 | -------------------------------------------------------------------------------- /triangulared/point_generators.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from skimage import filters, morphology, color 3 | from triangulared.utils import gaussian_mask, default 4 | 5 | 6 | def edge_points(image, length_scale=200, 7 | n_horizontal_points=None, 8 | n_vertical_points=None): 9 | """ 10 | Returns points around the edge of an image. 11 | :param image: image array 12 | :param length_scale: how far to space out the points if no 13 | fixed number of points is given 14 | :param n_horizontal_points: number of points on the horizonal edge. 15 | Leave as None to use lengthscale to determine 16 | the value 17 | :param n_vertical_points: number of points on the horizonal edge. 18 | Leave as None to use lengthscale to determine 19 | the value 20 | :return: array of coordinates 21 | """ 22 | ymax, xmax = image.shape[:2] 23 | 24 | if n_horizontal_points is None: 25 | n_horizontal_points = int(xmax / length_scale) 26 | 27 | if n_vertical_points is None: 28 | n_vertical_points = int(ymax / length_scale) 29 | 30 | delta_x = xmax / n_horizontal_points 31 | delta_y = ymax / n_vertical_points 32 | 33 | return np.array( 34 | [[0, 0], [xmax, 0], [0, ymax], [xmax, ymax]] 35 | + [[delta_x * i, 0] for i in range(1, n_horizontal_points)] 36 | + [[delta_x * i, ymax] for i in range(1, n_horizontal_points)] 37 | + [[0, delta_y * i] for i in range(1, n_vertical_points)] 38 | + [[xmax, delta_y * i] for i in range(1, n_vertical_points)] 39 | ) 40 | 41 | 42 | def generate_uniform_random_points(image, n_points=100): 43 | """ 44 | Generates a set of uniformly distributed points over the area of image 45 | :param image: image as an array 46 | :param n_points: int number of points to generate 47 | :return: array of points 48 | """ 49 | ymax, xmax = image.shape[:2] 50 | points = np.random.uniform(size=(n_points, 2)) 51 | points *= np.array([xmax, ymax]) 52 | points = np.concatenate([points, edge_points(image)]) 53 | return points 54 | 55 | 56 | def generate_max_entropy_points(image, n_points=100, 57 | entropy_width=None, 58 | filter_width=None, 59 | suppression_width=None, 60 | suppression_amplitude=None): 61 | """ 62 | Generates a set of points over the area of image, using maximum entropy 63 | to guess which points are importance. All length scales are relative to the 64 | density of the points. 65 | :param image: image as an array 66 | :param n_points: int number of points to generate: 67 | :param entropy_width: width over which to measure entropy 68 | :param filter_width: width over which to pre filter entropy 69 | :param suppression_width: length for suppressing entropy before choosing the 70 | next point. 71 | :param suppression_amplitude: amplitude to suppress entropy before choosing the 72 | next point. 73 | :return: 74 | """ 75 | # calculate length scale 76 | ymax, xmax = image.shape[:2] 77 | length_scale = np.sqrt(xmax*ymax / n_points) 78 | entropy_width = length_scale * default(entropy_width, 0.2) 79 | filter_width = length_scale * default(filter_width, 0.1) 80 | suppression_width = length_scale * default(suppression_width, 0.3) 81 | suppression_amplitude = default(suppression_amplitude, 3) 82 | 83 | # convert to grayscale 84 | im2 = color.rgb2gray(image) 85 | 86 | # filter 87 | im2 = ( 88 | # 255 * filters.gaussian(im2, sigma=filter_width, multichannel=True) 89 | 255 * filters.gaussian(im2, sigma=filter_width) 90 | ).astype("uint8") 91 | 92 | # calculate entropy 93 | im2 = filters.rank.entropy(im2, morphology.disk(entropy_width)) 94 | 95 | points = [] 96 | for _ in range(n_points): 97 | y, x = np.unravel_index(np.argmax(im2), im2.shape) 98 | im2 -= gaussian_mask(x, y, 99 | shape=im2.shape[:2], 100 | amp=suppression_amplitude, 101 | sigma=suppression_width) 102 | points.append((x, y)) 103 | 104 | points = np.array(points) 105 | return points 106 | --------------------------------------------------------------------------------