string-art-make / strings.py
aboalaa147's picture
Update strings.py
b89c9cf verified
import sys
import numpy as np
import scipy
import scipy.sparse
import scipy.sparse.linalg
from imageio import imread, imsave
from skimage.transform import resize as imresize
from skimage.color import rgb2gray
from PIL import Image
import math
from collections import defaultdict
from bresenham import *
def image(filename, size):
"""Original image loading function - kept for backward compatibility"""
img = imresize(rgb2gray(imread(filename)), (size, size))
return img
def image_from_pil(pil_image, size):
"""New function to handle PIL images for Gradio compatibility"""
# Convert PIL image to grayscale if it's not already
if pil_image.mode != 'L':
img = pil_image.convert('L')
else:
img = pil_image
# Resize image
img = img.resize((size, size), Image.Resampling.LANCZOS)
# Convert to numpy array and normalize to 0-1 range
img_array = np.array(img, dtype=np.float64) / 255.0
return img_array
def image_from_array(img_array, size):
"""Handle numpy arrays directly"""
if len(img_array.shape) == 3:
# Convert RGB to grayscale if needed
img_array = rgb2gray(img_array)
# Resize if needed
if img_array.shape != (size, size):
img_array = imresize(img_array, (size, size))
return img_array
def build_arc_adjecency_matrix(n, radius):
print("building sparse adjecency matrix")
hooks = np.array([[math.cos(np.pi*2*i/n), math.sin(np.pi*2*i/n)] for i in range(n)])
hooks = (radius * hooks).astype(int)
edge_codes = []
row_ind = []
col_ind = []
for i, ni in enumerate(hooks):
for j, nj in enumerate(hooks[i+1:], start=i+1):
edge_codes.append((i, j))
pixels = bresenham(ni, nj).path
edge = []
for pixel in pixels:
pixel_code = (pixel[1]+radius)*(radius*2+1) + (pixel[0]+radius)
edge.append(pixel_code)
row_ind += edge
col_ind += [len(edge_codes)-1] * len(edge)
# creating the edge-pixel adjecency matrix:
# rows are indexed with pixel codes, columns are indexed with edge codes.
sparse = scipy.sparse.csr_matrix(([1.0]*len(row_ind), (row_ind, col_ind)), shape=((2*radius+1)*(2*radius+1), len(edge_codes)))
return sparse, hooks, edge_codes
def build_circle_adjecency_matrix(radius, small_radius):
print("building sparse adjecency matrix")
edge_codes = []
row_ind = []
col_ind = []
pixels = circle(small_radius)
for i, cx in enumerate(range(-radius+small_radius+1, radius-small_radius-1, 1)):
for j, cy in enumerate(range(-radius+small_radius+1, radius-small_radius-1, 1)):
edge_codes.append((i, j))
edge = []
for pixel in pixels:
px, py = cx+pixel[0], cy+pixel[1]
pixel_code = (py+radius)*(radius*2+1) + (px+radius)
edge.append(pixel_code)
row_ind += edge
col_ind += [len(edge_codes)-1] * len(edge)
# creating the edge-pixel adjecency matrix:
# rows are indexed with pixel codes, columns are indexed with edge codes.
sparse = scipy.sparse.csr_matrix(([1.0]*len(row_ind), (row_ind, col_ind)), shape=((2*radius+1)*(2*radius+1), len(edge_codes)))
hooks = []
return sparse, hooks, edge_codes
def build_image_vector(img, radius):
# representing the input image as a sparse column vector of pixels:
assert img.shape[0] == img.shape[1]
img_size = img.shape[0]
row_ind = []
col_ind = []
data = []
for y, line in enumerate(img):
for x, pixel_value in enumerate(line):
global_x = x - img_size//2
global_y = y - img_size//2
pixel_code = (global_y+radius)*(radius*2+1) + (global_x+radius)
data.append(float(pixel_value))
row_ind.append(pixel_code)
col_ind.append(0)
sparse_b = scipy.sparse.csr_matrix((data, (row_ind, col_ind)), shape=((2*radius+1)*(2*radius+1), 1))
return sparse_b
def reconstruct(x, sparse, radius):
b_approx = sparse.dot(x)
b_approx = b_approx.toarray().flatten() if scipy.sparse.issparse(b_approx) else b_approx.flatten()
b_image = b_approx.reshape((2*radius+1, 2*radius+1))
b_image = np.clip(b_image, 0, 1) # Changed from 0-255 to 0-1 range
return b_image
def reconstruct_and_save(x, sparse, radius, filename):
brightness_correction = 1.2
b_image = reconstruct(x * brightness_correction, sparse, radius)
# Convert back to 0-255 range for saving
b_image_255 = (b_image * 255).astype(np.uint8)
imsave(filename, b_image_255)
def reconstruct_as_pil(x, sparse, radius, brightness_correction=1.2):
"""New function to return PIL Image directly for Gradio"""
b_image = reconstruct(x * brightness_correction, sparse, radius)
# Convert to 0-255 range and create PIL Image
b_image_255 = (b_image * 255).astype(np.uint8)
return Image.fromarray(b_image_255, mode='L')
def dump_arcs(solution, hooks, edge_codes, filename):
f = open(filename, "w")
n = len(hooks)
print(n, file=f)
for i, (x, y) in enumerate(hooks):
print("%d\t%f\t%f" % (i, x, y), file=f)
print(file=f)
assert len(edge_codes) == len(solution)
for (i, j), value in zip(edge_codes, solution):
if value==0:
continue
# int values are shown as ints.
if value==int(value):
value = int(value)
print("%d\t%d\t%s" % (i, j, str(value)), file=f)
f.close()
def process_string_art_from_pil(pil_image, n=180, radius=250, quantization_level=30, clip_factor=0.3):
"""
Main processing function for Gradio - takes PIL image and returns results
Args:
pil_image: PIL Image object
n: number of hooks around the circle
radius: radius of the circle in pixels
quantization_level: discretization level for string weights
clip_factor: factor to clip maximum values
Returns:
tuple: (final_pil_image, stats_dict)
"""
try:
# Build adjacency matrix
sparse, hooks, edge_codes = build_arc_adjecency_matrix(n, radius)
# Process image
shrinkage = 0.75
img = image_from_pil(pil_image, int(radius * 2 * shrinkage))
sparse_b = build_image_vector(img, radius)
# Solve linear system
print("solving linear system")
result = scipy.sparse.linalg.lsqr(sparse, np.array(sparse_b.todense()).flatten())
print("done")
x = result[0]
# Clip negative values (physically unrealistic)
x = np.clip(x, 0, 1e6)
# Apply quantization
max_edge_weight_orig = np.max(x)
if max_edge_weight_orig > 0: # Avoid division by zero
x_quantized = (x / max_edge_weight_orig * quantization_level).round()
x_quantized = np.clip(x_quantized, 0, int(np.max(x_quantized) * clip_factor))
# Scale back
x = x_quantized / quantization_level * max_edge_weight_orig
else:
x_quantized = np.zeros_like(x)
# Create final image
final_pil = reconstruct_as_pil(x, sparse, radius)
# Calculate statistics
arc_count = int(np.sum(x_quantized)) if len(x_quantized) > 0 else 0
unique_arcs = len(x_quantized[x_quantized > 0]) if len(x_quantized) > 0 else 0
# Calculate total distance
total_distance = 0.0
for edge_code, multiplicity in enumerate(x_quantized):
if multiplicity > 0:
hook_index1, hook_index2 = edge_codes[edge_code]
hook1, hook2 = hooks[hook_index1], hooks[hook_index2]
distance = np.linalg.norm(hook1.astype(float) - hook2.astype(float)) / radius
total_distance += distance * multiplicity
stats = {
'arc_count': arc_count,
'unique_arcs': unique_arcs,
'total_distance': total_distance / 2, # unit diameter, not radius
'hooks': n,
'radius': radius,
'quantization': quantization_level
}
return final_pil, stats
except Exception as e:
print(f"Error in processing: {str(e)}")
# Return a blank image and error stats
blank_image = Image.new('L', (2*radius+1, 2*radius+1), 128)
error_stats = {
'arc_count': 0,
'unique_arcs': 0,
'total_distance': 0,
'hooks': n,
'radius': radius,
'quantization': quantization_level,
'error': str(e)
}
return blank_image, error_stats
def main():
"""Original main function - kept for backward compatibility"""
filename, output_prefix = sys.argv[1:]
n = 180
radius = 250
sparse, hooks, edge_codes = build_arc_adjecency_matrix(n, radius)
# sparse, hooks, edge_codes = build_circle_adjecency_matrix(radius, 10)
# square image with same center as the circle, sides are 75% of circle diameter.
shrinkage = 0.75
img = image(filename, int(radius * 2 * shrinkage))
sparse_b = build_image_vector(img, radius)
# imsave(output_prefix+"-original.png", sparse_b.todense().reshape((2*radius+1, 2*radius+1)))
# finding the solution, a weighting of edges:
print("solving linear system")
# note the .todense(). for some reason the sparse version did not work.
result = scipy.sparse.linalg.lsqr(sparse, np.array(sparse_b.todense()).flatten())
print("done")
# x, istop, itn, r1norm, r2norm, anorm, acond, arnorm = result
x = result[0]
reconstruct_and_save(x, sparse, radius, output_prefix+"-allow-negative.png")
# negative values are clipped, they are physically unrealistic.
x = np.clip(x, 0, 1e6)
reconstruct_and_save(x, sparse, radius, output_prefix+"-unquantized.png")
dump_arcs(x, hooks, edge_codes, output_prefix+"-unquantized.txt")
# quantizing:
quantization_level = 30 # 50 is already quite good. None means no quantization.
# clip values larger than clip_factor times maximum.
# (The long tail does not add too much to percieved quality.)
clip_factor = 0.3
if quantization_level is not None:
max_edge_weight_orig = np.max(x)
x_quantized = (x / np.max(x) * quantization_level).round()
x_quantized = np.clip(x_quantized, 0, int(np.max(x_quantized) * clip_factor))
# scale it back:
x = x_quantized / quantization_level * max_edge_weight_orig
dump_arcs(x_quantized, hooks, edge_codes, output_prefix+".txt")
reconstruct_and_save(x, sparse, radius, output_prefix+".png")
if quantization_level is not None:
arc_count = 0
total_distance = 0.0
hist = defaultdict(int)
for edge_code, multiplicity in enumerate(x_quantized):
multiplicity = int(multiplicity)
hist[multiplicity] += 1
arc_count += multiplicity
hook_index1, hook_index2 = edge_codes[edge_code]
hook1, hook2 = hooks[hook_index1], hooks[hook_index2]
distance = np.linalg.norm(hook1.astype(float) - hook2.astype(float)) / radius
total_distance += distance * multiplicity
for multiplicity in range(max(hist.keys())+1):
print(multiplicity, hist[multiplicity])
print("total arc count", arc_count)
print("number of different arcs used", len(x_quantized[x_quantized>0]))
print("total distance (assuming a unit diameter circle)", total_distance / 2) # unit diameter, not unit radius.
if __name__ == "__main__":
main()