На региональном новостном сайте увидел новость: «Янтарному подарили портрет Канта из 5 километров ниток» и заинтересовался — как же подобные картины делаются.
Оказалось, что подобная техника называется String Art и сразу же нашлись самые разные варианты изготовления подобных картин.
Как вручную:
, так и автоматически — при помощи самых разных станков:
Осталось разобраться — как же это делается программно.
По счастливому стечению обстоятельств на глаза попалась статья: Круглые картины из натянутых ниток. Разоблачение, которая и подсказала, что в основе лежит простой алгоритм, состоящий из следующих основных этапов:
1. предварительная обработка изображения: изображение переводится в градации серого, обрезается в квадрат, вписывается в круг и инвертируется в негатив.
2. процедура определения, где нужно провести линию: алгоритм в цикле перебирает все варианты проведения линии от одной точки ко всем остальны точкам, чтобы найти такой вариант, который даёт наиболее подходящее затенение.
3. генерация кодов управления станком ЧПУ: зная какие линии нужно провести для данного изображения — генерируются соответствующие команды для станка, который будет выполнять автоматическое натягивания нити по заданным координатам.
К сожалению, код который приводится в статье сразу не заработал, зато на гитхабе быстро нашёлся готовый вариант на Python и OpenCV: string-art/alog2.py
Всё реализовано довольно просто:
import cv2 import numpy as np imgPath = './cat4.jpg' # My Cheshire Cat # Parameters imgRadius = 200 # Number of pixels that the image radius is resized to initPin = 0 # Initial pin to start threading from numPins = 200 # Number of pins on the circular loom numLines = 500 # Maximal number of lines minLoop = 3 # Disallow loops of less than minLoop lines lineWidth = 3 # The number of pixels that represents the width of a thread lineWeight = 15 # The weight a single thread has in terms of "darkness"
— подключаются нужные библиотеки и задаются соответствующие параметры, включая имя изображения, подлежащего преобразованию:
Задаются вспомогательные функции, которые реализуют предварительную обработку изображения — обрезки до круга и инвертирования:
# 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 # Compute coordinates of loom pins def pinCoords(radius, numPins=200, offset=0, x0=None, y0=None): alpha = np.linspace(0 + offset, 2*np.pi + offset, numPins + 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[0] - pin0[0], pin1[1] - pin0[1])) x = np.linspace(pin0[0], pin1[0], length) y = np.linspace(pin0[1], pin1[1], length) return (x.astype(np.int)-1, y.astype(np.int)-1)
А так же функции для расчёта кругового расположения штифтов, от которых будут натягиваться нитки.
Вся магия происходит здесь:
def main(): # Load image image = cv2.imread(imgPath) print("[+] loaded " + imgPath + " for threading..") # 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] cv2.imwrite('./cropped.png', imgCropped) # Convert to grayscale imgGray = cv2.cvtColor(imgCropped, cv2.COLOR_BGR2GRAY) cv2.imwrite('./gray.png', imgGray) # Resize image imgSized = cv2.resize(imgGray, (2*imgRadius + 1, 2*imgRadius + 1)) # Invert image imgInverted = invertImage(imgSized) cv2.imwrite('./inverted.png', imgInverted) # Mask image imgMasked = maskImage(imgInverted, imgRadius) cv2.imwrite('./masked.png', imgMasked) print("[+] image preprocessed for threading..") # Define pin coordinates coords = pinCoords(imgRadius, numPins) height, width = imgMasked.shape[0:2] # image result is rendered to imgResult = 255 * np.ones((height, width)) # Initialize variables i = 0 lines = [] previousPins = [] oldPin = initPin lineMask = np.zeros((height, width)) imgResult = 255 * np.ones((height, width)) # Loop over lines until stopping criteria is reached for line in range(numLines): i += 1 bestLine = 0 oldCoord = coords[oldPin] # Loop over possible lines for index in range(1, numPins): pin = (oldPin + index) % numPins 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)) # plot results xLine, yLine = linePixels(coords[bestPin], coord) imgResult[yLine, xLine] = 0 #cv2.imshow('image', imgResult) #cv2.waitKey(1) # Break if no lines possible if bestPin == oldPin: break # Prepare for next loop oldPin = bestPin # Print progress #sys.stdout.write("\b\b") #sys.stdout.write("\r") #sys.stdout.write("[+] Computing line " + str(line + 1) + " of " + str(numLines) + " total") #sys.stdout.flush() print("\n[+] Image threaded") # Wait for user and save before exit #cv2.waitKey(0) #cv2.destroyAllWindows() cv2.imwrite('./threaded.png', imgResult) #rgb_img = cv2.cvtColor(imgResult.astype('uint8'), cv2.COLOR_BGR2RGB) #plt.imshow(rgb_img) #plt.show() main()
— картинка загружается, выполняется предварительная обработка, а далее в цикле
# Loop over lines until stopping criteria is reached for line in range(numLines):
перебираются возможные варианты расположения линий.
В качестве критерия оценки пригодности — используется оценка лучшего «затенения», то есть выбирается такая линия, которая покрывает наибольшее количество исходных тёмных областей исходного изображения:
# Fitness function lineSum = np.sum(imgMasked[yLine, xLine])
Результаты предварительной обработки изображения:
— инвертированное изображение:
— на инвертированное изображение наложена круглая маска:
Итоговый результат работы программы:
Вот и всё! Оказалось, что немного Python и OpenCV способны творить и такие интересные штуки для автоматизированной генерации произведений искусства.
Ссылки
string-art/alog2.py
Круглые картины из натянутых ниток
Янтарному подарили портрет Канта из 5 километров ниток
По теме
Робот-художник