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()