File size: 11,680 Bytes
712d0ef
 
 
 
 
 
 
 
b89c9cf
712d0ef
 
 
 
 
 
 
 
b89c9cf
712d0ef
 
 
 
b89c9cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712d0ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b89c9cf
712d0ef
b89c9cf
712d0ef
 
 
 
 
 
b89c9cf
 
 
 
 
 
 
 
 
 
 
712d0ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b89c9cf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
712d0ef
b89c9cf
712d0ef
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b89c9cf
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
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()