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.
- autoforge/__init__.py +0 -0
- autoforge/__main__.py +8 -0
- autoforge/auto_forge.py +713 -0
- autoforge/filamentcolors_library.py +123 -0
- autoforge/helper_functions.py +655 -0
- autoforge-1.1.2.dist-info/METADATA +178 -0
- autoforge-1.1.2.dist-info/RECORD +10 -0
- autoforge-1.1.2.dist-info/WHEEL +5 -0
- autoforge-1.1.2.dist-info/entry_points.txt +2 -0
- autoforge-1.1.2.dist-info/top_level.txt +1 -0
|
@@ -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)
|