Not too long ago I came across a piece of art made by Petros Vrellis. A piece of art that makes the engineer in me scream: automation. As can be seen below Petros uses a circular loom and a lot of patience to develop portraits from thread. Below I’ll quickly go through the steps of automating the process but of course all the credits go to the artist behind this amazing idea! The final result is a halftone representation of an image using nothing but thread.
The main idea is comprised of three steps. First a little image pre-processing is done to various input images for threading. Second is some processing done in python to determine where one should place the successive threads as to obtain the best image representation. Third, I can put my laserless lasercutter to work to do the heavy lifting.
To allow image processing on a pixel level we use openCV. Let’s get started and do some initial processing of the image. The snippet below shows how one can load, crop and resize an image.
import cv2 import numpy as np # Invert grayscale image def invertImage(image): return (255-image) # Apply circular mask to image def maskImage(image, radius): y, x = np.ogrid[-radius:radius + 1, -radius:radius + 1] mask = x**2 + y**2 > radius**2 image[mask] = 0 return image # Load image image = cv2.imread(imgPath) # Crop image height, width = image.shape[0:2] minEdge= min(height, width) topEdge = int((height - minEdge)/2) leftEdge = int((width - minEdge)/2) imgCropped = image[topEdge:topEdge+minEdge, leftEdge:leftEdge+minEdge] # Convert to grayscale imgGray = cv2.cvtColor(imgCropped, cv2.COLOR_BGR2GRAY) # Resize image imgSized = cv2.resize(imgGray, (2*imgRadius + 1, 2*imgRadius + 1)) # Invert image imgInverted = invertImage(imgSized) # Mask image imgMasked = maskImage(imgInverted, imgRadius)
Once the image is of the correct size it is converted to a gray-scale image and inverted. In this form the image can be represented as an array where the originally darkest places have the highest value. Finally the image is masked with a circular mask (all values outside the circle are set to zero).
Deciding where one needs to pass the thread as to obtain a representation of the original image is the main focus of the algorithm. For this the assumption is made that a circular loom is used with an evenly spaced amount of pins.
As seen in the clip above the algorithm starts at a random pin. It checks all possible options for the one that would best fit the image. It now draws the optimal line, accounts for the “darkness” added by this line and starts again. The functions below generate the pin coordinates of the loom and a mask representing a certain line.
# Compute coordinates of loom pins def pinCoords(radius, nPins=200, offset=0, x0=None, y0=None): alpha = np.linspace(0 + offset, 2*np.pi + offset, nPins + 1) if (x0 == None) or (y0 == None): x0 = radius + 1 y0 = radius + 1 coords =  for angle in alpha[0:-1]: x = int(x0 + radius*np.cos(angle)) y = int(y0 + radius*np.sin(angle)) coords.append((x, y)) return coords # Compute a line mask def linePixels(pin0, pin1): length = int(np.hypot(pin1 - pin0, pin1 - pin0)) x = np.linspace(pin0, pin1, length) y = np.linspace(pin0, pin1, length) return (x.astype(np.int)-1, y.astype(np.int)-1)
Using the functions above the algorithm iteratively adds lines to the image until either the maximum number of lines is reached or the stopping criteria is reached. The fitness function is taken to be the specific line integral over the image. Lines that cover a lot of originally dark areas will therefore achieve a high fitness.
# Define pin coordinates coords = pinCoords(imgRadius, nPins) height, width = imgMasked.shape[0:2] # Initialize variables i = 0 lines =  previousPins =  oldPin = initPin lineMask = np.zeros((height, width)) # Loop over lines until stopping criteria is reached for line in range(nLines): i += 1 bestLine = 0 oldCoord = coords[oldPin] # Loop over possible lines for index in range(1, nPins): pin = (oldPin + index) % nPins coord = coords[pin] xLine, yLine = linePixels(oldCoord, coord) # Fitness function lineSum = np.sum(imgMasked[yLine, xLine]) if (lineSum > bestLine) and not(pin in previousPins): bestLine = lineSum bestPin = pin # Update previous pins if len(previousPins) >= minLoop: previousPins.pop(0) previousPins.append(bestPin) # Subtract new line from image lineMask = lineMask * 0 cv2.line(lineMask, oldCoord, coords[bestPin], lineWeight, lineWidth) imgMasked = np.subtract(imgMasked, lineMask) # Save line to results lines.append((oldPin, bestPin)) # Break if no lines possible if bestPin == oldPin: break # Prepare for next loop oldPin = bestPin
Once the algorithm no longer finds any possible lines the loop is broken and the result can be further processed. Below one can find two examples of the algorithms result. Move over the image in question to see the original image.
Always fascinated by the ease at which a lasercutter can produce quality parts I set out to build my own. It turned out I could never get myself to make the last investment and actually buy the lasertube/optics. This means I have a huge part of my small student room filled with this big Cartesian plotter. Coming across Petros’s project it finally got a purpose.
Parsing the resulting line coordinates to G-code allows for some NC threading. A pen was used to develop a small guide for the thread. Below one can find the initial result of about 900 lines completed. At this point the thread broke and I called it a day. I will update the post with some images of the full result once I have some time to retry.
The full code is posted to GitHub and with some explanation on the tuning parameters. Any remarks or advise is most welcome and feel free to contact me with any questions.