-
-
Save kaspermeerts/781f0137b361b51224dcab722ae387b4 to your computer and use it in GitHub Desktop.
import collections | |
import math | |
import os | |
import cv2 | |
import numpy as np | |
import time | |
MAX_LINES = 4000 | |
N_PINS = 36*8 | |
MIN_LOOP = 20 # To avoid getting stuck in a loop | |
MIN_DISTANCE = 20 # To avoid very short lines | |
LINE_WEIGHT = 15 # Tweakable parameter | |
FILENAME = "leki-lig.jpg" | |
SCALE = 25 # For making a very high resolution render, to attempt to accurately gauge how thick the thread must be | |
HOOP_DIAMETER = 0.625 # To calculate total thread length | |
tic = time.perf_counter() | |
img = cv2.imread(FILENAME, cv2.IMREAD_GRAYSCALE) | |
# Didn't bother to make it work for non-square images | |
assert img.shape[0] == img.shape[1] | |
length = img.shape[0] | |
def disp(image): | |
cv2.imshow('image', image) | |
cv2.waitKey(0) | |
cv2.destroyAllWindows() | |
# Cut away everything around a central circle | |
X,Y = np.ogrid[0:length, 0:length] | |
circlemask = (X - length/2) ** 2 + (Y - length/2) ** 2 > length/2 * length/2 | |
img[circlemask] = 0xFF | |
pin_coords = [] | |
center = length / 2 | |
radius = length / 2 - 1/2 | |
# Precalculate the coordinates of every pin | |
for i in range(N_PINS): | |
angle = 2 * math.pi * i / N_PINS | |
pin_coords.append((math.floor(center + radius * math.cos(angle)), | |
math.floor(center + radius * math.sin(angle)))) | |
line_cache_y = [None] * N_PINS * N_PINS | |
line_cache_x = [None] * N_PINS * N_PINS | |
line_cache_weight = [1] * N_PINS * N_PINS # Turned out to be unnecessary, unused | |
line_cache_length = [0] * N_PINS * N_PINS | |
print("Precalculating all lines... ", end='', flush=True) | |
for a in range(N_PINS): | |
for b in range(a + MIN_DISTANCE, N_PINS): | |
x0 = pin_coords[a][0] | |
y0 = pin_coords[a][1] | |
x1 = pin_coords[b][0] | |
y1 = pin_coords[b][1] | |
d = int(math.sqrt((x1 - x0) * (x1 - x0) + (y1 - y0)*(y1 - y0))) | |
#d = max(abs(y1-y0), abs(x1-x0)) inf-norm | |
# A proper (slower) Bresenham does not give any better result *shrug* | |
xs = np.linspace(x0, x1, d, dtype=int) | |
ys = np.linspace(y0, y1, d, dtype=int) | |
line_cache_y[b*N_PINS + a] = ys | |
line_cache_y[a*N_PINS + b] = ys | |
line_cache_x[b*N_PINS + a] = xs | |
line_cache_x[a*N_PINS + b] = xs | |
line_cache_length[b*N_PINS + a] = d | |
line_cache_length[a*N_PINS + b] = d | |
print("done") | |
error = np.ones(img.shape) * 0xFF - img.copy() | |
img_result = np.ones(img.shape) * 0xFF | |
lse_buffer = np.ones(img.shape) * 0xFF # Used in the unused LSE algorithm | |
result = np.ones((img.shape[0] * SCALE, img.shape[1] * SCALE), np.uint8) * 0xFF | |
line_mask = np.zeros(img.shape, np.float64) # XXX | |
line_sequence = [] | |
pin = 0 | |
line_sequence.append(pin) | |
thread_length = 0 | |
last_pins = collections.deque(maxlen = MIN_LOOP) | |
for l in range(MAX_LINES): | |
if l % 100 == 0: | |
print("%d " % l, end='', flush=True) | |
img_result = cv2.resize(result, img.shape, interpolation=cv2.INTER_AREA) | |
# Some trickery to fast calculate the absolute difference, to estimate the error per pixel | |
diff = img_result - img | |
mul = np.uint8(img_result < img) * 254 + 1 | |
absdiff = diff * mul | |
print(absdiff.sum() / (length * length)) | |
max_err = -math.inf | |
best_pin = -1 | |
# Find the line which will lower the error the most | |
for offset in range(MIN_DISTANCE, N_PINS - MIN_DISTANCE): | |
test_pin = (pin + offset) % N_PINS | |
if test_pin in last_pins: | |
continue | |
xs = line_cache_x[test_pin * N_PINS + pin] | |
ys = line_cache_y[test_pin * N_PINS + pin] | |
# Simple | |
# Error defined as the sum of the brightness of each pixel in the original | |
# The idea being that a wire can only darken pixels in the result | |
line_err = np.sum(error[ys,xs]) * line_cache_weight[test_pin*N_PINS + pin] | |
''' | |
# LSE Unused | |
goal_pixels = img[ys, xs] | |
old_pixels = lse_buffer[ys, xs] | |
new_pixels = np.clip(old_pixels - LINE_WEIGHT * line_cache_weight[test_pin*N_PINS + pin], 0, 255) | |
line_err = np.sum((old_pixels - goal_pixels) ** 2) - np.sum((new_pixels - goal_pixels) ** 2) | |
#LSE | |
''' | |
if line_err > max_err: | |
max_err = line_err | |
best_pin = test_pin | |
line_sequence.append(best_pin) | |
xs = line_cache_x[best_pin * N_PINS + pin] | |
ys = line_cache_y[best_pin * N_PINS + pin] | |
weight = LINE_WEIGHT * line_cache_weight[best_pin*N_PINS + pin] | |
''' | |
#LSE | |
old_pixels = lse_buffer[ys, xs] | |
new_pixels = np.clip(old_pixels - weight, 0, 255) | |
lse_buffer[ys, xs] = new_pixels | |
#LSE | |
''' | |
# Subtract the line from the error | |
line_mask.fill(0) | |
line_mask[ys, xs] = weight | |
error = error - line_mask | |
error.clip(0, 255) | |
# Draw the line in the result | |
cv2.line(result, | |
(pin_coords[pin][0] * SCALE, pin_coords[pin][1] * SCALE), | |
(pin_coords[best_pin][0] * SCALE, pin_coords[best_pin][1] * SCALE), | |
color=0, thickness=4, lineType=8) | |
x0 = pin_coords[pin][0] | |
y0 = pin_coords[pin][1] | |
x1 = pin_coords[best_pin][0] | |
y1 = pin_coords[best_pin][1] | |
# Calculate physical distance | |
dist = math.sqrt((x1 - x0) * (x1 - x0) + (y1 - y0)*(y1 - y0)) | |
thread_length += HOOP_DIAMETER / length * dist | |
last_pins.append(best_pin) | |
pin = best_pin | |
img_result = cv2.resize(result, img.shape, interpolation=cv2.INTER_AREA) | |
diff = img_result - img | |
mul = np.uint8(img_result < img) * 254 + 1 | |
absdiff = diff * mul | |
print(absdiff.sum() / (length * length)) | |
print('\x07') | |
toc = time.perf_counter() | |
print("%.1f seconds" % (toc - tic)) | |
cv2.imwrite(os.path.splitext(FILENAME)[0] + "-out.png", result) | |
with open(os.path.splitext(FILENAME)[0] + ".json", "w") as f: | |
f.write(str(line_sequence)) |
@kaspermeerts It occurs to me that you could blend the two concepts by doing the average, but favoring the shortest/longest distance, whichever makes more sense. I finally got my version to work with just the average, but the animation you had posted in imgur shows that your algorithm is more efficient. I'm going to try refactoring a couple of different ways and see what I can pull off. Thank you for responding!
Forgive me please, I know this thread is old. I was wondering, as I am new to Github, If you shared and how I would access your rendition of the original python code you extended…?
@phillipsartwork What original python code do you mean? I didn't extend anything, I wrote this code.
My mistake…new to GitHub…My comment question was directed to the person who had commented and asked about averaging the pixels…named anyoneseenmyhead…
However I believe that I answered my own question, when I clicked on his name it said that this user doesn’t have any public gists….
Sorry
Hi. How can i change the number of pins? I prefer work with 250 than 288. Thank you
There's a variable N_PINS that can be changed near the top.
Would it be possible to convert this from a circle to a rectangle?
@poke-max
Sure, the coordinates for the pins are calculated at lines 42-43. Something like this should work (I did not test this!)
# Precalculate the coordinates of every pin for a square
for i in range(N_PINS):
# Determine the total number of pins per side (assume pins are distributed evenly)
pins_per_side = N_PINS // 4
# Find the side and position on the side for each pin
side = i // pins_per_side
position = i % pins_per_side
# Calculate the coordinates based on the side
if side == 0: # Top edge (left to right)
x = math.floor(center - radius + 2 * radius * position / (pins_per_side - 1))
y = math.floor(center - radius)
elif side == 1: # Right edge (top to bottom)
x = math.floor(center + radius)
y = math.floor(center - radius + 2 * radius * position / (pins_per_side - 1))
elif side == 2: # Bottom edge (right to left)
x = math.floor(center + radius - 2 * radius * position / (pins_per_side - 1))
y = math.floor(center + radius)
elif side == 3: # Left edge (bottom to top)
x = math.floor(center - radius)
y = math.floor(center + radius - 2 * radius * position / (pins_per_side - 1))
pin_coords.append((x, y))
@anyoneseenmyhead
Good question. If I understand it right, the difference would be that if you take the average, you're not taking into account the length of the string anymore. A very short string traversing a bright patch might have the same average error as a string crossing the entire diameter. But the total error would be much higher for the long string. I don't know which way is "right".
It's been three years or so, so I don't remember if I ever looked at that. Why don't you try it out, and see which one is better?