autoforge 1.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,655 @@
1
+
2
+ import json
3
+ import os
4
+ import uuid
5
+ from itertools import permutations
6
+
7
+ import configargparse
8
+ import cv2
9
+ import jax
10
+ import jax.numpy as jnp
11
+ import jax.random as random
12
+ import optax
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+ import math
16
+
17
+ from skimage.color import rgb2lab
18
+ from sklearn.cluster import KMeans
19
+ from tqdm import tqdm
20
+ import pandas as pd
21
+
22
+ import os
23
+ import json
24
+ import pandas as pd
25
+ import numpy as np
26
+
27
+ import struct
28
+
29
+
30
+ def resize_image(img, max_size):
31
+ h_img, w_img, _ = img.shape
32
+ if w_img >= h_img:
33
+ new_w = max_size
34
+ new_h = int(max_size * h_img / w_img)
35
+ else:
36
+ new_h = max_size
37
+ new_w = int(max_size * w_img / h_img)
38
+ img_out = cv2.resize(img, (new_w, new_h))
39
+ return img_out
40
+
41
+
42
+ def generate_stl(height_map, filename, background_height, scale=1.0):
43
+ """
44
+ Generate a binary STL file from a height map.
45
+
46
+ Args:
47
+ height_map (np.ndarray): 2D array representing the height map.
48
+ filename (str): The name of the output STL file.
49
+ background_height (float): The height of the background in the STL model.
50
+ scale (float, optional): Scale factor for the x and y dimensions. Defaults to 1.0.
51
+ """
52
+ H, W = height_map.shape
53
+ vertices = np.zeros((H, W, 3), dtype=np.float32)
54
+ for i in range(H):
55
+ for j in range(W):
56
+ # Original coordinates: x = j*scale, y = (H - 1 - i), z = height + background
57
+ vertices[i, j, 0] = j * scale
58
+ vertices[i, j, 1] = (H - 1 - i) # (Consider applying scale if needed)
59
+ vertices[i, j, 2] = height_map[i, j] + background_height
60
+
61
+ triangles = []
62
+
63
+ def add_triangle(v1, v2, v3):
64
+ """
65
+ Add a triangle to the list of triangles.
66
+
67
+ Args:
68
+ v1 (np.ndarray): First vertex of the triangle.
69
+ v2 (np.ndarray): Second vertex of the triangle.
70
+ v3 (np.ndarray): Third vertex of the triangle.
71
+ """
72
+ triangles.append((v1, v2, v3))
73
+
74
+ for i in range(H - 1):
75
+ for j in range(W - 1):
76
+ v0 = vertices[i, j]
77
+ v1 = vertices[i, j + 1]
78
+ v2 = vertices[i + 1, j + 1]
79
+ v3 = vertices[i + 1, j]
80
+ # Reversed order so normals face upward
81
+ add_triangle(v2, v1, v0)
82
+ add_triangle(v3, v2, v0)
83
+
84
+ for j in range(W - 1):
85
+ v0 = vertices[0, j]
86
+ v1 = vertices[0, j + 1]
87
+ v0b = np.array([v0[0], v0[1], 0], dtype=np.float32)
88
+ v1b = np.array([v1[0], v1[1], 0], dtype=np.float32)
89
+ add_triangle(v0, v1, v1b)
90
+ add_triangle(v0, v1b, v0b)
91
+ for j in range(W - 1):
92
+ v0 = vertices[H - 1, j]
93
+ v1 = vertices[H - 1, j + 1]
94
+ v0b = np.array([v0[0], v0[1], 0], dtype=np.float32)
95
+ v1b = np.array([v1[0], v1[1], 0], dtype=np.float32)
96
+ add_triangle(v1, v0, v1b)
97
+ add_triangle(v0, v0b, v1b)
98
+ for i in range(H - 1):
99
+ v0 = vertices[i, 0]
100
+ v1 = vertices[i + 1, 0]
101
+ v0b = np.array([v0[0], v0[1], 0], dtype=np.float32)
102
+ v1b = np.array([v1[0], v1[1], 0], dtype=np.float32)
103
+ add_triangle(v1, v0, v1b)
104
+ add_triangle(v0, v0b, v1b)
105
+ for i in range(H - 1):
106
+ v0 = vertices[i, W - 1]
107
+ v1 = vertices[i + 1, W - 1]
108
+ v0b = np.array([v0[0], v0[1], 0], dtype=np.float32)
109
+ v1b = np.array([v1[0], v1[1], 0], dtype=np.float32)
110
+ add_triangle(v0, v1, v1b)
111
+ add_triangle(v0, v1b, v0b)
112
+
113
+ v0 = np.array([0, 0, 0], dtype=np.float32)
114
+ v1 = np.array([(W - 1) * scale, 0, 0], dtype=np.float32)
115
+ v2 = np.array([(W - 1) * scale, (H - 1) * scale, 0], dtype=np.float32)
116
+ v3 = np.array([0, (H - 1) * scale, 0], dtype=np.float32)
117
+ add_triangle(v2, v1, v0)
118
+ add_triangle(v3, v2, v0)
119
+
120
+ num_triangles = len(triangles)
121
+
122
+ # Write the binary STL file.
123
+ with open(filename, 'wb') as f:
124
+ header_str = "Binary STL generated from heightmap"
125
+ header = header_str.encode('utf-8')
126
+ header = header.ljust(80, b' ')
127
+ f.write(header)
128
+ f.write(struct.pack('<I', num_triangles))
129
+ for tri in triangles:
130
+ v1, v2, v3 = tri
131
+ normal = np.cross(v2 - v1, v3 - v1)
132
+ norm = np.linalg.norm(normal)
133
+ if norm == 0:
134
+ normal = np.array([0, 0, 0], dtype=np.float32)
135
+ else:
136
+ normal = normal / norm
137
+ f.write(struct.pack('<12fH',
138
+ normal[0], normal[1], normal[2],
139
+ v1[0], v1[1], v1[2],
140
+ v2[0], v2[1], v2[2],
141
+ v3[0], v3[1], v3[2],
142
+ 0))
143
+
144
+
145
+
146
+ def generate_swap_instructions(discrete_global, discrete_height_image, h, background_layers, background_height, material_names):
147
+ """
148
+ Generate swap instructions based on discrete material assignments.
149
+
150
+ Args:
151
+ discrete_global (jnp.ndarray): Array of discrete global material assignments.
152
+ discrete_height_image (jnp.ndarray): Array representing the discrete height image.
153
+ h (float): Layer thickness.
154
+ background_layers (int): Number of background layers.
155
+ background_height (float): Height of the background in mm.
156
+ material_names (list): List of material names.
157
+
158
+ Returns:
159
+ list: A list of strings containing the swap instructions.
160
+ """
161
+ L = int(np.max(np.array(discrete_height_image)))
162
+ instructions = []
163
+ if L == 0:
164
+ instructions.append("No layers printed.")
165
+ return instructions
166
+ instructions.append("Start with your background color")
167
+ for i in range(0, L):
168
+ if i == 0 or int(discrete_global[i]) != int(discrete_global[i - 1]):
169
+ ie = i
170
+ instructions.append(f"At layer #{ie + background_layers} ({(ie * h) + background_height:.2f}mm) swap to {material_names[int(discrete_global[i])]}")
171
+ instructions.append("For the rest, use " + material_names[int(discrete_global[L - 1])])
172
+ return instructions
173
+
174
+
175
+ def initialize_pixel_height_logits(target):
176
+ """
177
+ Initialize pixel height logits based on the luminance of the target image.
178
+
179
+ Assumes target is a jnp.array of shape (H, W, 3) in the range [0, 255].
180
+ Uses the formula: L = 0.299*R + 0.587*G + 0.114*B.
181
+
182
+ Args:
183
+ target (jnp.ndarray): The target image array with shape (H, W, 3).
184
+
185
+ Returns:
186
+ jnp.ndarray: The initialized pixel height logits.
187
+ """
188
+ # Compute normalized luminance in [0,1]
189
+ normalized_lum = (0.299 * target[..., 0] +
190
+ 0.587 * target[..., 1] +
191
+ 0.114 * target[..., 2]) / 255.0
192
+ # To avoid log(0) issues, add a small epsilon.
193
+ eps = 1e-6
194
+ # Convert normalized luminance to logits using the inverse sigmoid (logit) function.
195
+ # This ensures that jax.nn.sigmoid(pixel_height_logits) approximates normalized_lum.
196
+ pixel_height_logits = jnp.log((normalized_lum + eps) / (1 - normalized_lum + eps))
197
+ return pixel_height_logits
198
+
199
+
200
+ def load_materials_data(csv_filename):
201
+ """
202
+ Load the full material data from the CSV file.
203
+
204
+ Args:
205
+ csv_filename (str): Path to the CSV file containing material data.
206
+
207
+ Returns:
208
+ list: A list of dictionaries (one per material) with keys such as
209
+ "Brand", "Type", "Color", "Name", "TD", "Owned", and "Uuid".
210
+ """
211
+ df = pd.read_csv(csv_filename)
212
+ # Use a consistent key naming. For example, convert 'TD' to 'Transmissivity' and 'Uuid' to 'uuid'
213
+ records = df.to_dict(orient="records")
214
+ return records
215
+
216
+
217
+ def extract_filament_swaps(disc_global, disc_height_image, background_layers):
218
+ """
219
+ Given the discrete global material assignment (disc_global) and the discrete height image,
220
+ extract the list of material indices (one per swap point) and the corresponding slider
221
+ values (which indicate at which layer the material change occurs).
222
+
223
+ Args:
224
+ disc_global (jnp.ndarray): Discrete global material assignments.
225
+ disc_height_image (jnp.ndarray): Discrete height image.
226
+ background_layers (int): Number of background layers.
227
+
228
+ Returns:
229
+ tuple: A tuple containing:
230
+ - filament_indices (list): List of material indices for each swap point.
231
+ - slider_values (list): List of layer numbers where a material change occurs.
232
+ """
233
+ # L is the total number of layers printed (maximum value in the height image)
234
+ L = int(np.max(np.array(disc_height_image)))
235
+ filament_indices = []
236
+ slider_values = []
237
+ prev = int(disc_global[0])
238
+ for i in range(L):
239
+ current = int(disc_global[i])
240
+ # If this is the first layer or the material changes from the previous layer…
241
+ if current != prev:
242
+ slider = (i + background_layers)-1
243
+ slider_values.append(slider)
244
+ filament_indices.append(prev)
245
+ prev = current
246
+ # Add the last material index
247
+ filament_indices.append(prev)
248
+ slider = i + background_layers
249
+ slider_values.append(slider)
250
+
251
+ return filament_indices, slider_values
252
+
253
+
254
+ def generate_project_file(project_filename, args, disc_global, disc_height_image,
255
+ width_mm, height_mm, stl_filename, csv_filename):
256
+ """
257
+ Export a project file containing the printing parameters, including:
258
+ - Key dimensions and layer information (from your command-line args and computed outputs)
259
+ - The filament_set: a list of filament definitions (each corresponding to a color swap)
260
+ where the same material may be repeated if used at different swap points.
261
+ - slider_values: a list of layer numbers (indices) where a filament swap occurs.
262
+
263
+ The filament_set entries are built using the full material data from the CSV file.
264
+
265
+ Args:
266
+ project_filename (str): Path to the output project file.
267
+ args (Namespace): Command-line arguments containing printing parameters.
268
+ disc_global (jnp.ndarray): Discrete global material assignments.
269
+ disc_height_image (jnp.ndarray): Discrete height image.
270
+ width_mm (float): Width of the model in millimeters.
271
+ height_mm (float): Height of the model in millimeters.
272
+ stl_filename (str): Path to the STL file.
273
+ csv_filename (str): Path to the CSV file containing material data.
274
+ """
275
+ # Compute the number of background layers (as in your main())
276
+ background_layers = int(args.background_height / args.layer_height)
277
+
278
+ # Load full material data from CSV
279
+ material_data = load_materials_data(csv_filename)
280
+
281
+ # Extract the swap points from the discrete solution
282
+ filament_indices, slider_values = extract_filament_swaps(disc_global, disc_height_image, background_layers)
283
+
284
+ # Build the filament_set list. For each swap point, we look up the corresponding material from CSV.
285
+ # Here we map CSV columns to the project file’s expected keys.
286
+ filament_set = []
287
+ for idx in filament_indices:
288
+ mat = material_data[idx]
289
+ filament_entry = {
290
+ "Brand": mat["Brand"],
291
+ "Color": mat[" Color"],
292
+ "Name": mat[" Name"],
293
+ # Convert Owned to a boolean (in case it is read as a string)
294
+ "Owned": str(mat[" Owned"]).strip().lower() == "true",
295
+ "Transmissivity": float(mat[" TD"]) if not float(mat[" TD"]).is_integer() else int(mat[" TD"]),
296
+ "Type": mat[" Type"],
297
+ "uuid": mat[" Uuid"]
298
+ }
299
+ filament_set.append(filament_entry)
300
+
301
+ # add black as the first filament with background height as the first slider value
302
+ filament_set.insert(0, {
303
+ "Brand": "Autoforge",
304
+ "Color": args.background_color,
305
+ "Name": "Background",
306
+ "Owned": False,
307
+ "Transmissivity": 0.1,
308
+ "Type": "PLA",
309
+ "uuid": str(uuid.uuid4())
310
+ })
311
+ # add black to slider value
312
+ slider_values.insert(0, (args.background_height//args.layer_height)-1)
313
+
314
+ # reverse order of filament set
315
+ filament_set = filament_set[::-1]
316
+
317
+ # Build the project file dictionary.
318
+ # Many keys are filled in with default or derived values.
319
+ project_data = {
320
+ "base_layer_height": args.layer_height, # you may adjust this if needed
321
+ "blue_shift": 0,
322
+ "border_height": args.background_height, # here we use the background height
323
+ "border_width": 3,
324
+ "borderless": True,
325
+ "bright_adjust_zero": False,
326
+ "brightness_compensation_name": "Standard",
327
+ "bw_tolerance": 8,
328
+ "color_match_method": 0,
329
+ "depth_mode": 2,
330
+ "edit_image": False,
331
+ "extra_gap": 2,
332
+ "filament_set": filament_set,
333
+ "flatten": False,
334
+ "full_range": False,
335
+ "green_shift": 0,
336
+ "gs_threshold": 0,
337
+ "height_in_mm": height_mm,
338
+ "hsl_invert": False,
339
+ "ignore_blue": False,
340
+ "ignore_green": False,
341
+ "ignore_red": False,
342
+ "invert_blue": False,
343
+ "invert_green": False,
344
+ "invert_red": False,
345
+ "inverted_color_pop": False,
346
+ "layer_height": args.layer_height,
347
+ "legacy_luminance": False,
348
+ "light_intensity": -1,
349
+ "light_temperature": 1,
350
+ "lighting_visualizer": 0,
351
+ "luminance_factor": 0,
352
+ "luminance_method": 2,
353
+ "luminance_offset": 0,
354
+ "luminance_offset_max": 100,
355
+ "luminance_power": 2,
356
+ "luminance_weight": 100,
357
+ "max_depth": args.background_height + args.layer_height * args.max_layers,
358
+ "median": 0,
359
+ "mesh_style_edit": True,
360
+ "min_depth": 0.48,
361
+ "min_detail": 0.2,
362
+ "negative": True,
363
+ "red_shift": 0,
364
+ "reverse_litho": True,
365
+ "slider_values": slider_values,
366
+ "smoothing": 0,
367
+ "srgb_linearize": False,
368
+ "stl": os.path.basename(stl_filename),
369
+ "strict_tolerance": False,
370
+ "transparency": True,
371
+ "version": "0.7.0",
372
+ "width_in_mm": width_mm
373
+ }
374
+
375
+ # Write out the project file as JSON
376
+ with open(project_filename, "w") as f:
377
+ json.dump(project_data, f, indent=4)
378
+
379
+ def init_height_map(target,max_layers,h,eps = 1e-6):
380
+ """
381
+ Initialize pixel height logits based on the luminance of the target image.
382
+
383
+ Assumes target is a jnp.array of shape (H, W, 3) in the range [0, 255].
384
+ Uses the formula: L = 0.299*R + 0.587*G + 0.114*B.
385
+
386
+ Args:
387
+ target (jnp.ndarray): The target image array with shape (H, W, 3).
388
+
389
+ Returns:
390
+ jnp.ndarray: The initialized pixel height logits.
391
+ """
392
+
393
+ target_np = np.asarray(target).reshape(-1, 3)
394
+
395
+ kmeans = KMeans(n_clusters=max_layers).fit(target_np)
396
+ labels = kmeans.labels_
397
+ labels = labels.reshape(target.shape[0], target.shape[1])
398
+ centroids = kmeans.cluster_centers_
399
+
400
+ def luminance(col):
401
+ return 0.299 * col[0] + 0.587 * col[1] + 0.114 * col[2]
402
+
403
+ # --- Step 2: Second clustering of centroids into bands ---
404
+ num_bands = 10
405
+ band_kmeans = KMeans(n_clusters=num_bands).fit(centroids)
406
+ band_labels = band_kmeans.labels_
407
+
408
+ # Group centroids by band and sort within each band by luminance
409
+ bands = [] # each entry will be (band_avg_luminance, sorted_indices_in_this_band)
410
+ for b in range(num_bands):
411
+ indices = np.where(band_labels == b)[0]
412
+ if len(indices) == 0:
413
+ continue
414
+ lum_vals = np.array([luminance(centroids[i]) for i in indices])
415
+ sorted_indices = indices[np.argsort(lum_vals)]
416
+ band_avg = np.mean(lum_vals)
417
+ bands.append((band_avg, sorted_indices))
418
+
419
+ # --- Step 3: Compute a representative color for each band in Lab space ---
420
+ # (Using the average of the centroids in that band)
421
+ band_reps = [] # will hold Lab colors
422
+ for _, indices in bands:
423
+ band_avg_rgb = np.mean(centroids[indices], axis=0)
424
+ # Normalize if needed (assumes image pixel values are 0-255)
425
+ band_avg_rgb_norm = band_avg_rgb / 255.0 if band_avg_rgb.max() > 1 else band_avg_rgb
426
+ # Convert to Lab (expects image in [0,1])
427
+ lab = rgb2lab(np.array([[band_avg_rgb_norm]]))[0, 0, :]
428
+ band_reps.append(lab)
429
+
430
+ # --- Step 4: Identify darkest and brightest bands based on L channel ---
431
+ L_values = [lab[0] for lab in band_reps]
432
+ start_band = np.argmin(L_values) # darkest band index
433
+ end_band = np.argmax(L_values) # brightest band index
434
+
435
+ # --- Step 5: Find the best ordering for the middle bands ---
436
+ # We want to order the bands so that the total perceptual difference (Euclidean distance in Lab)
437
+ # between consecutive bands is minimized, while forcing the darkest band first and brightest band last.
438
+ all_indices = list(range(len(bands)))
439
+ middle_indices = [i for i in all_indices if i not in (start_band, end_band)]
440
+
441
+ min_total_distance = np.inf
442
+ best_order = None
443
+ total = len(middle_indices)*len(middle_indices)
444
+ # Try all permutations of the middle bands
445
+ ie = 0
446
+ tbar = tqdm(permutations(middle_indices),total=total,desc="Finding best ordering for color bands:")
447
+ for perm in tbar:
448
+ candidate = [start_band] + list(perm) + [end_band]
449
+ total_distance = 0
450
+ for i in range(len(candidate) - 1):
451
+ total_distance += np.linalg.norm(band_reps[candidate[i]] - band_reps[candidate[i + 1]])
452
+ if total_distance < min_total_distance:
453
+ min_total_distance = total_distance
454
+ best_order = candidate
455
+ tbar.set_description(f"Finding best ordering for color bands: Total distance = {min_total_distance:.2f}")
456
+ ie += 1
457
+ if ie > 500000:
458
+ break
459
+
460
+ new_order = []
461
+ for band_idx in best_order:
462
+ # Each band tuple is (band_avg, sorted_indices)
463
+ new_order.extend(bands[band_idx][1].tolist())
464
+
465
+ # Remap each pixel's label so that it refers to its new palette index
466
+ mapping = {old_idx: new_idx for new_idx, old_idx in enumerate(new_order)}
467
+ new_labels = np.vectorize(lambda x: mapping[x])(labels)
468
+
469
+ new_labels = new_labels.astype(np.float32) / new_labels.max()
470
+
471
+ normalized_lum = jnp.array(new_labels, dtype=jnp.float64)
472
+ #convert out to inverse sigmoid logit function
473
+ pixel_height_logits = jnp.log((normalized_lum + eps) / (1 - normalized_lum + eps))
474
+
475
+ H, W, _ = target.shape
476
+ return pixel_height_logits
477
+
478
+ def hex_to_rgb(hex_str):
479
+ """
480
+ Convert a hex color string to a normalized RGB list.
481
+
482
+ Args:
483
+ hex_str (str): The hex color string (e.g., '#RRGGBB').
484
+
485
+ Returns:
486
+ list: A list of three floats representing the RGB values normalized to [0, 1].
487
+ """
488
+ hex_str = hex_str.lstrip('#')
489
+ return [int(hex_str[i:i+2], 16) / 255.0 for i in (0, 2, 4)]
490
+
491
+
492
+ def load_materials(csv_filename):
493
+ """
494
+ Load material data from a CSV file.
495
+
496
+ Args:
497
+ csv_filename (str): Path to the hueforge CSV file containing material data.
498
+
499
+ Returns:
500
+ tuple: A tuple containing:
501
+ - material_colors (jnp.ndarray): Array of material colors in float64.
502
+ - material_TDs (jnp.ndarray): Array of material transmission/opacity parameters in float64.
503
+ - material_names (list): List of material names.
504
+ - colors_list (list): List of color hex strings.
505
+ """
506
+ df = pd.read_csv(csv_filename)
507
+ material_names = [brand + " - " + name for brand, name in zip(df["Brand"].tolist(), df[" Name"].tolist())]
508
+ material_TDs = (df[' TD'].astype(float)).to_numpy()
509
+ colors_list = df[' Color'].tolist()
510
+ # Use float64 for material colors.
511
+ material_colors = jnp.array([hex_to_rgb(color) for color in colors_list], dtype=jnp.float64)
512
+ material_TDs = jnp.array(material_TDs, dtype=jnp.float64)
513
+ return material_colors, material_TDs, material_names, colors_list
514
+
515
+
516
+ def sample_gumbel(shape, key, eps=1e-20):
517
+ """
518
+ Sample from a Gumbel distribution.
519
+
520
+ Args:
521
+ shape (tuple): The shape of the output array.
522
+ key (jax.random.PRNGKey): The random key for JAX operations.
523
+ eps (float, optional): A small value to avoid numerical issues. Defaults to 1e-20.
524
+
525
+ Returns:
526
+ jnp.ndarray: Samples from the Gumbel distribution.
527
+ """
528
+ U = random.uniform(key, shape=shape, minval=0.0, maxval=1.0)
529
+ return -jnp.log(-jnp.log(U + eps) + eps)
530
+
531
+
532
+ def gumbel_softmax_sample(logits, temperature, key):
533
+ """
534
+ Sample from the Gumbel-Softmax distribution.
535
+
536
+ Args:
537
+ logits (jnp.ndarray): The input logits for the Gumbel-Softmax.
538
+ temperature (float): The temperature parameter for the Gumbel-Softmax.
539
+ key (jax.random.PRNGKey): The random key for JAX operations.
540
+
541
+ Returns:
542
+ jnp.ndarray: The Gumbel-Softmax samples.
543
+ """
544
+ g = sample_gumbel(logits.shape, key)
545
+ return jax.nn.softmax((logits + g) / temperature)
546
+
547
+
548
+ def gumbel_softmax(logits, temperature, key, hard=False):
549
+ """
550
+ Compute the Gumbel-Softmax.
551
+
552
+ Args:
553
+ logits (jnp.ndarray): The input logits for the Gumbel-Softmax.
554
+ temperature (float): The temperature parameter for the Gumbel-Softmax.
555
+ key (jax.random.PRNGKey): The random key for JAX operations.
556
+ hard (bool, optional): If True, return hard one-hot encoded samples. Defaults to False.
557
+
558
+ Returns:
559
+ jnp.ndarray: The Gumbel-Softmax samples.
560
+ """
561
+ y = gumbel_softmax_sample(logits, temperature, key)
562
+ if hard:
563
+ y_hard = jax.nn.one_hot(jnp.argmax(y, axis=-1), logits.shape[-1])
564
+ y = y_hard + jax.lax.stop_gradient(y - y_hard)
565
+ return y
566
+
567
+
568
+ def srgb_to_linear_lab(rgb):
569
+ """
570
+ Convert sRGB (range [0,1]) to linear RGB.
571
+
572
+ Args:
573
+ rgb (jnp.ndarray): A JAX array of shape (..., 3) representing the sRGB values.
574
+
575
+ Returns:
576
+ jnp.ndarray: A JAX array of shape (..., 3) representing the linear RGB values.
577
+ """
578
+ return jnp.where(rgb <= 0.04045, rgb / 12.92, ((rgb + 0.055) / 1.055) ** 2.4)
579
+
580
+ def linear_to_xyz(rgb_linear):
581
+ """
582
+ Convert linear RGB to XYZ using sRGB D65.
583
+
584
+ Args:
585
+ rgb_linear (jnp.ndarray): A JAX array of shape (..., 3) representing the linear RGB values.
586
+
587
+ Returns:
588
+ jnp.ndarray: A JAX array of shape (..., 3) representing the XYZ values.
589
+ """
590
+ R = rgb_linear[..., 0]
591
+ G = rgb_linear[..., 1]
592
+ B = rgb_linear[..., 2]
593
+ X = 0.4124564 * R + 0.3575761 * G + 0.1804375 * B
594
+ Y = 0.2126729 * R + 0.7151522 * G + 0.0721750 * B
595
+ Z = 0.0193339 * R + 0.1191920 * G + 0.9503041 * B
596
+ return jnp.stack([X, Y, Z], axis=-1)
597
+
598
+
599
+ def xyz_to_lab(xyz):
600
+ """
601
+ Convert XYZ to CIELAB. Assumes D65 reference white.
602
+
603
+ Args:
604
+ xyz (jnp.ndarray): A JAX array of shape (..., 3) representing the XYZ values.
605
+
606
+ Returns:
607
+ jnp.ndarray: A JAX array of shape (..., 3) representing the CIELAB values.
608
+ """
609
+ # Reference white for D65:
610
+ xyz_ref = jnp.array([0.95047, 1.0, 1.08883])
611
+ xyz = xyz / xyz_ref
612
+ delta = 6/29
613
+ f = jnp.where(xyz > delta**3, xyz ** (1/3), (xyz / (3 * delta**2)) + (4/29))
614
+ L = 116 * f[..., 1] - 16
615
+ a = 500 * (f[..., 0] - f[..., 1])
616
+ b = 200 * (f[..., 1] - f[..., 2])
617
+ return jnp.stack([L, a, b], axis=-1)
618
+
619
+
620
+ def rgb_to_lab(rgb):
621
+ """
622
+ Convert an sRGB image (values in [0,1]) to CIELAB.
623
+
624
+ Args:
625
+ rgb (jnp.ndarray): A JAX array of shape (..., 3) representing the sRGB values.
626
+
627
+ Returns:
628
+ jnp.ndarray: A JAX array of shape (..., 3) representing the CIELAB values.
629
+ """
630
+ rgb_linear = srgb_to_linear_lab(rgb)
631
+ xyz = linear_to_xyz(rgb_linear)
632
+ lab = xyz_to_lab(xyz)
633
+ return lab
634
+
635
+
636
+ def adaptive_round(x, tau, high_tau=1.0, low_tau=0.01, temp=0.1):
637
+ """
638
+ Compute a soft (adaptive) rounding of x.
639
+
640
+ When tau is high (>= high_tau) returns x (i.e. no rounding).
641
+ When tau is low (<= low_tau) returns round(x).
642
+ In between, linearly interpolates between x and round(x).
643
+
644
+ Args:
645
+ x (jnp.ndarray): The input array to be rounded.
646
+ tau (float): The temperature parameter controlling the degree of rounding.
647
+ high_tau (float, optional): The high threshold for tau. Defaults to 1.0.
648
+ low_tau (float, optional): The low threshold for tau. Defaults to 0.01.
649
+ temp (float, optional): A temperature parameter for interpolation. Defaults to 0.1.
650
+
651
+ Returns:
652
+ jnp.ndarray: The adaptively rounded array.
653
+ """
654
+ beta = jnp.clip((high_tau - tau) / (high_tau - low_tau), 0.0, 1.0)
655
+ return (1 - beta) * x + beta * jnp.round(x)