├── pics ├── bellucci-h180-r250-q10-c1.png ├── trump-h180-r250-q50-c0.3.png ├── bellucci-h180-r250-q30-c0.5.png ├── trump-h180-r250-q50-c0.3-unquantized.png └── trump-h180-r250-q50-c0.3-allow-negative.png ├── README.md ├── bresenham.py └── strings.py /pics/bellucci-h180-r250-q10-c1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielvarga/string-art/HEAD/pics/bellucci-h180-r250-q10-c1.png -------------------------------------------------------------------------------- /pics/trump-h180-r250-q50-c0.3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielvarga/string-art/HEAD/pics/trump-h180-r250-q50-c0.3.png -------------------------------------------------------------------------------- /pics/bellucci-h180-r250-q30-c0.5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielvarga/string-art/HEAD/pics/bellucci-h180-r250-q30-c0.5.png -------------------------------------------------------------------------------- /pics/trump-h180-r250-q50-c0.3-unquantized.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielvarga/string-art/HEAD/pics/trump-h180-r250-q50-c0.3-unquantized.png -------------------------------------------------------------------------------- /pics/trump-h180-r250-q50-c0.3-allow-negative.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danielvarga/string-art/HEAD/pics/trump-h180-r250-q50-c0.3-allow-negative.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # String art 2 | 3 | Tool calculating how to recreate an input image as [string art](https://en.wikipedia.org/wiki/String_art), 4 | that is, by arranging a single line of thread wound around a circle of nails. 5 | 6 | The video that originally inspired this is not on the web anymore, but this more recent one is similar: 7 | 8 | [![Making Hyperrealistic Portraits With A Single Thread](http://img.youtube.com/vi/XJRVqzoQUG0/0.jpg)](http://www.youtube.com/watch?v=XJRVqzoQUG0 "String art") 9 | 10 | 11 | ## Usage: 12 | 13 | ```python strings.py input-image.png output-prefix``` 14 | 15 | The input image should be square-shaped. 16 | 17 | The output is in ```output-prefix.png```. There are two extra files showing intermediary steps of the computation: 18 | ```output-prefix-allow-negative.png``` shows the string art when both black and white strings are allowed. 19 | ```output-prefix-unquantized.png``` shows the string art when infinitely thin and long threads are allowed (but only white). 20 | 21 | ## Example output: 22 | 23 | Image with allow-negative: 24 | 25 | ![Trump allow-negative](./pics/trump-h180-r250-q50-c0.3-allow-negative.png) 26 | 27 | Image unquantized: 28 | 29 | ![Trump unquantized](./pics/trump-h180-r250-q50-c0.3-unquantized.png) 30 | 31 | Final image, created from ~12000 arcs. Assuming a circle of diameter 1 meter, this is ~10 kilometers of thread: 32 | 33 | ![Trump final](./pics/trump-h180-r250-q50-c0.3.png) 34 | 35 | Don't try to be too cheap with the strings: 36 | 37 | ![Bellucci, 15 quantization levels](./pics/bellucci-h180-r250-q30-c0.5.png) 38 | ![Bellucci, 5 quantization levels](./pics/bellucci-h180-r250-q10-c1.png) 39 | 40 | -------------------------------------------------------------------------------- /bresenham.py: -------------------------------------------------------------------------------- 1 | 2 | # Bresenham line algorithm 3 | # https://gist.github.com/flags/1132363 4 | class bresenham: 5 | def __init__(self, start, end): 6 | self.start = list(start) 7 | self.end = list(end) 8 | self.path = [] 9 | 10 | self.steep = abs(self.end[1]-self.start[1]) > abs(self.end[0]-self.start[0]) 11 | 12 | if self.steep: 13 | self.start = self.swap(self.start[0],self.start[1]) 14 | self.end = self.swap(self.end[0],self.end[1]) 15 | 16 | if self.start[0] > self.end[0]: 17 | _x0 = int(self.start[0]) 18 | _x1 = int(self.end[0]) 19 | self.start[0] = _x1 20 | self.end[0] = _x0 21 | 22 | _y0 = int(self.start[1]) 23 | _y1 = int(self.end[1]) 24 | self.start[1] = _y1 25 | self.end[1] = _y0 26 | 27 | dx = self.end[0] - self.start[0] 28 | dy = abs(self.end[1] - self.start[1]) 29 | error = 0 30 | derr = dy/float(dx) 31 | 32 | ystep = 0 33 | y = self.start[1] 34 | 35 | if self.start[1] < self.end[1]: ystep = 1 36 | else: ystep = -1 37 | 38 | for x in range(self.start[0],self.end[0]+1): 39 | if self.steep: 40 | self.path.append((y,x)) 41 | else: 42 | self.path.append((x,y)) 43 | 44 | error += derr 45 | 46 | if error >= 0.5: 47 | y += ystep 48 | error -= 1.0 49 | 50 | def swap(self,n1,n2): 51 | return [n2,n1] 52 | 53 | def test(): 54 | l = bresenham([8,1],[6,4]) 55 | print(l.path) 56 | 57 | map = [] 58 | for x in range(0,15): 59 | yc = [] 60 | for y in range(0,15): 61 | yc.append('#') 62 | map.append(yc) 63 | 64 | for pos in l.path: 65 | map[pos[0]][pos[1]] = '.' 66 | 67 | for y in range(0,15): 68 | for x in range(0,15): 69 | print(map[x][y], end=' ') 70 | print() 71 | 72 | 73 | # Bresenham circle algorithm 74 | # https://www.daniweb.com/programming/software-development/threads/321181/python-bresenham-circle-arc-algorithm 75 | def circle(radius): 76 | # init vars 77 | switch = 3 - (2 * radius) 78 | points = set() 79 | x = 0 80 | y = radius 81 | # first quarter/octant starts clockwise at 12 o'clock 82 | while x <= y: 83 | # first quarter first octant 84 | points.add((x,-y)) 85 | # first quarter 2nd octant 86 | points.add((y,-x)) 87 | # second quarter 3rd octant 88 | points.add((y,x)) 89 | # second quarter 4.octant 90 | points.add((x,y)) 91 | # third quarter 5.octant 92 | points.add((-x,y)) 93 | # third quarter 6.octant 94 | points.add((-y,x)) 95 | # fourth quarter 7.octant 96 | points.add((-y,-x)) 97 | # fourth quarter 8.octant 98 | points.add((-x,-y)) 99 | if switch < 0: 100 | switch = switch + (4 * x) + 6 101 | else: 102 | switch = switch + (4 * (x - y)) + 10 103 | y = y - 1 104 | x = x + 1 105 | return points 106 | 107 | 108 | if __name__ == "__main__": 109 | test() 110 | -------------------------------------------------------------------------------- /strings.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import numpy as np 3 | import scipy 4 | import scipy.sparse 5 | import scipy.sparse.linalg 6 | from imageio import imread, imsave 7 | from skimage.transform import resize as imresize 8 | from skimage.color import rgb2gray 9 | 10 | import math 11 | from collections import defaultdict 12 | 13 | from bresenham import * 14 | 15 | 16 | def image(filename, size): 17 | img = imresize(rgb2gray(imread(filename)), (size, size)) 18 | return img 19 | 20 | 21 | def build_arc_adjecency_matrix(n, radius): 22 | print("building sparse adjecency matrix") 23 | hooks = np.array([[math.cos(np.pi*2*i/n), math.sin(np.pi*2*i/n)] for i in range(n)]) 24 | hooks = (radius * hooks).astype(int) 25 | edge_codes = [] 26 | row_ind = [] 27 | col_ind = [] 28 | for i, ni in enumerate(hooks): 29 | for j, nj in enumerate(hooks[i+1:], start=i+1): 30 | edge_codes.append((i, j)) 31 | pixels = bresenham(ni, nj).path 32 | edge = [] 33 | for pixel in pixels: 34 | pixel_code = (pixel[1]+radius)*(radius*2+1) + (pixel[0]+radius) 35 | edge.append(pixel_code) 36 | row_ind += edge 37 | col_ind += [len(edge_codes)-1] * len(edge) 38 | # creating the edge-pixel adjecency matrix: 39 | # rows are indexed with pixel codes, columns are indexed with edge codes. 40 | sparse = scipy.sparse.csr_matrix(([1.0]*len(row_ind), (row_ind, col_ind)), shape=((2*radius+1)*(2*radius+1), len(edge_codes))) 41 | return sparse, hooks, edge_codes 42 | 43 | 44 | def build_circle_adjecency_matrix(radius, small_radius): 45 | print("building sparse adjecency matrix") 46 | edge_codes = [] 47 | row_ind = [] 48 | col_ind = [] 49 | pixels = circle(small_radius) 50 | for i, cx in enumerate(range(-radius+small_radius+1, radius-small_radius-1, 1)): 51 | for j, cy in enumerate(range(-radius+small_radius+1, radius-small_radius-1, 1)): 52 | edge_codes.append((i, j)) 53 | edge = [] 54 | for pixel in pixels: 55 | px, py = cx+pixel[0], cy+pixel[1] 56 | pixel_code = (py+radius)*(radius*2+1) + (px+radius) 57 | edge.append(pixel_code) 58 | row_ind += edge 59 | col_ind += [len(edge_codes)-1] * len(edge) 60 | # creating the edge-pixel adjecency matrix: 61 | # rows are indexed with pixel codes, columns are indexed with edge codes. 62 | sparse = scipy.sparse.csr_matrix(([1.0]*len(row_ind), (row_ind, col_ind)), shape=((2*radius+1)*(2*radius+1), len(edge_codes))) 63 | hooks = [] 64 | return sparse, hooks, edge_codes 65 | 66 | 67 | def build_image_vector(img, radius): 68 | # representing the input image as a sparse column vector of pixels: 69 | assert img.shape[0] == img.shape[1] 70 | img_size = img.shape[0] 71 | row_ind = [] 72 | col_ind = [] 73 | data = [] 74 | for y, line in enumerate(img): 75 | for x, pixel_value in enumerate(line): 76 | global_x = x - img_size//2 77 | global_y = y - img_size//2 78 | pixel_code = (global_y+radius)*(radius*2+1) + (global_x+radius) 79 | data.append(float(pixel_value)) 80 | row_ind.append(pixel_code) 81 | col_ind.append(0) 82 | sparse_b = scipy.sparse.csr_matrix((data, (row_ind, col_ind)), shape=((2*radius+1)*(2*radius+1), 1)) 83 | return sparse_b 84 | 85 | 86 | def reconstruct(x, sparse, radius): 87 | b_approx = sparse.dot(x) 88 | b_image = b_approx.reshape((2*radius+1, 2*radius+1)) 89 | b_image = np.clip(b_image, 0, 255) 90 | return b_image 91 | 92 | 93 | def reconstruct_and_save(x, sparse, radius, filename): 94 | brightness_correction = 1.2 95 | b_image = reconstruct(x * brightness_correction, sparse, radius) 96 | imsave(filename, b_image) 97 | 98 | 99 | def dump_arcs(solution, hooks, edge_codes, filename): 100 | f = open(filename, "w") 101 | n = len(hooks) 102 | print(n, file=f) 103 | for i, (x, y) in enumerate(hooks): 104 | print("%d\t%f\t%f" % (i, x, y), file=f) 105 | print(file=f) 106 | assert len(edge_codes) == len(solution) 107 | for (i, j), value in zip(edge_codes, solution): 108 | if value==0: 109 | continue 110 | # int values are shown as ints. 111 | if value==int(value): 112 | value = int(value) 113 | print("%d\t%d\t%s" % (i, j, str(value)), file=f) 114 | f.close() 115 | 116 | 117 | def main(): 118 | filename, output_prefix = sys.argv[1:] 119 | 120 | n = 180 121 | radius = 250 122 | 123 | sparse, hooks, edge_codes = build_arc_adjecency_matrix(n, radius) 124 | # sparse, hooks, edge_codes = build_circle_adjecency_matrix(radius, 10) 125 | 126 | # square image with same center as the circle, sides are 75% of circle diameter. 127 | shrinkage = 0.75 128 | img = image(filename, int(radius * 2 * shrinkage)) 129 | sparse_b = build_image_vector(img, radius) 130 | # imsave(output_prefix+"-original.png", sparse_b.todense().reshape((2*radius+1, 2*radius+1))) 131 | 132 | # finding the solution, a weighting of edges: 133 | print("solving linear system") 134 | # note the .todense(). for some reason the sparse version did not work. 135 | result = scipy.sparse.linalg.lsqr(sparse, np.array(sparse_b.todense()).flatten()) 136 | print("done") 137 | # x, istop, itn, r1norm, r2norm, anorm, acond, arnorm = result 138 | x = result[0] 139 | 140 | reconstruct_and_save(x, sparse, radius, output_prefix+"-allow-negative.png") 141 | 142 | # negative values are clipped, they are physically unrealistic. 143 | x = np.clip(x, 0, 1e6) 144 | 145 | reconstruct_and_save(x, sparse, radius, output_prefix+"-unquantized.png") 146 | dump_arcs(x, hooks, edge_codes, output_prefix+"-unquantized.txt") 147 | 148 | # quantizing: 149 | quantization_level = 30 # 50 is already quite good. None means no quantization. 150 | # clip values larger than clip_factor times maximum. 151 | # (The long tail does not add too much to percieved quality.) 152 | clip_factor = 0.3 153 | if quantization_level is not None: 154 | max_edge_weight_orig = np.max(x) 155 | x_quantized = (x / np.max(x) * quantization_level).round() 156 | x_quantized = np.clip(x_quantized, 0, int(np.max(x_quantized) * clip_factor)) 157 | # scale it back: 158 | x = x_quantized / quantization_level * max_edge_weight_orig 159 | dump_arcs(x_quantized, hooks, edge_codes, output_prefix+".txt") 160 | 161 | reconstruct_and_save(x, sparse, radius, output_prefix+".png") 162 | 163 | 164 | if quantization_level is not None: 165 | arc_count = 0 166 | total_distance = 0.0 167 | hist = defaultdict(int) 168 | for edge_code, multiplicity in enumerate(x_quantized): 169 | multiplicity = int(multiplicity) 170 | hist[multiplicity] += 1 171 | arc_count += multiplicity 172 | hook_index1, hook_index2 = edge_codes[edge_code] 173 | hook1, hook2 = hooks[hook_index1], hooks[hook_index2] 174 | distance = np.linalg.norm(hook1.astype(float) - hook2.astype(float)) / radius 175 | total_distance += distance * multiplicity 176 | for multiplicity in range(max(hist.keys())+1): 177 | print(multiplicity, hist[multiplicity]) 178 | print("total arc count", arc_count) 179 | print("number of different arcs used", len(x_quantized[x_quantized>0])) 180 | print("total distance (assuming a unit diameter circle)", total_distance / 2) # unit diameter, not unit radius. 181 | 182 | main() 183 | --------------------------------------------------------------------------------