autoforge 1.9.2.2__tar.gz → 1.9.2.3__tar.gz

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.
Files changed (59) hide show
  1. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/PKG-INFO +3 -3
  2. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/pyproject.toml +3 -3
  3. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/AutoForge.egg-info/PKG-INFO +3 -3
  4. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/AutoForge.egg-info/requires.txt +1 -1
  5. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/OutputHelper.py +49 -16
  6. autoforge-1.9.2.3/src/autoforge/Misc/color_fit_all.py +448 -0
  7. autoforge-1.9.2.2/src/autoforge/Misc/color_fit_all.py +0 -1892
  8. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/README.md +0 -0
  9. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/setup.cfg +0 -0
  10. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/AutoForge.egg-info/SOURCES.txt +0 -0
  11. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/AutoForge.egg-info/dependency_links.txt +0 -0
  12. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/AutoForge.egg-info/entry_points.txt +0 -0
  13. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/AutoForge.egg-info/top_level.txt +0 -0
  14. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/AmpUtils.py +0 -0
  15. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/CAdamW.py +0 -0
  16. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/FilamentHelper.py +0 -0
  17. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/Heightmaps/ChristofidesHeightMap.py +0 -0
  18. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/Heightmaps/DepthEstimateHeightMap.py +0 -0
  19. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/Heightmaps/__init__.py +0 -0
  20. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/ImageHelper.py +0 -0
  21. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/OptimizerHelper.py +0 -0
  22. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/OtherHelper.py +0 -0
  23. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/PruningHelper.py +0 -0
  24. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/__init__.py +0 -0
  25. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Helper/filamentcolors_library.py +0 -0
  26. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Init_comparer/__init__.py +0 -0
  27. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Init_comparer/auto_forge_init_check.py +0 -0
  28. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Init_comparer/plot_results.py +0 -0
  29. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Loss/LossFunctions.py +0 -0
  30. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Loss/PerceptionLoss.py +0 -0
  31. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Loss/__init__.py +0 -0
  32. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Misc/__init__.py +0 -0
  33. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Modules/Optimizer.py +0 -0
  34. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/Modules/__init__.py +0 -0
  35. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/__init__.py +0 -0
  36. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/__main__.py +0 -0
  37. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/auto_forge.py +0 -0
  38. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/src/autoforge/priority_mask_editor.py +0 -0
  39. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_amp_autocast_probe.py +0 -0
  40. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_cadamw.py +0 -0
  41. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_christofides_height_map.py +0 -0
  42. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_christofides_heightmap.py +0 -0
  43. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_depth_adjusted_height_map.py +0 -0
  44. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_depth_estimate_height_map.py +0 -0
  45. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_end_to_end_optimizer.py +0 -0
  46. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_filament_and_output.py +0 -0
  47. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_filament_helper.py +0 -0
  48. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_flatforge.py +0 -0
  49. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_heightmap_init.py +0 -0
  50. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_image_helper.py +0 -0
  51. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_loss_functions.py +0 -0
  52. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_optimizer_helper.py +0 -0
  53. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_optimizer_module.py +0 -0
  54. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_optimizer_smoke.py +0 -0
  55. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_output_helper.py +0 -0
  56. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_output_helper_extended.py +0 -0
  57. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_perception_and_pruning.py +0 -0
  58. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_property_based.py +0 -0
  59. {autoforge-1.9.2.2 → autoforge-1.9.2.3}/tests/test_pruning_helper.py +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoForge
3
- Version: 1.9.2.2
3
+ Version: 1.9.2.3
4
4
  Summary: AutoForge is a Python tool for generating 3D printed layered models from an input image.
5
5
  Author-email: Hendric Voss <hvoss@techfak.uni-bielefeld.de>
6
6
  License: CC BY-NC-SA 4.0
7
- Requires-Python: >=3.9
7
+ Requires-Python: >=3.12
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: numpy
10
- Requires-Dist: torch>=2.6.0
10
+ Requires-Dist: torch>=2.9.1
11
11
  Requires-Dist: torchvision>=0.21.0
12
12
  Requires-Dist: transformers>=4.48.1
13
13
  Requires-Dist: opencv-python>=4.2.0.32
@@ -4,15 +4,15 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "AutoForge"
7
- version="1.9.2.2"
7
+ version="1.9.2.3"
8
8
  description = "AutoForge is a Python tool for generating 3D printed layered models from an input image."
9
9
  authors = [{ name = "Hendric Voss", email = "hvoss@techfak.uni-bielefeld.de" }]
10
10
  readme = "README.md"
11
- requires-python = ">=3.9"
11
+ requires-python = ">=3.12"
12
12
  license = { text = "CC BY-NC-SA 4.0" }
13
13
  dependencies = [
14
14
  "numpy",
15
- "torch>=2.6.0",
15
+ "torch>=2.9.1",
16
16
  "torchvision>=0.21.0",
17
17
  "transformers>=4.48.1",
18
18
  "opencv-python>=4.2.0.32",
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: AutoForge
3
- Version: 1.9.2.2
3
+ Version: 1.9.2.3
4
4
  Summary: AutoForge is a Python tool for generating 3D printed layered models from an input image.
5
5
  Author-email: Hendric Voss <hvoss@techfak.uni-bielefeld.de>
6
6
  License: CC BY-NC-SA 4.0
7
- Requires-Python: >=3.9
7
+ Requires-Python: >=3.12
8
8
  Description-Content-Type: text/markdown
9
9
  Requires-Dist: numpy
10
- Requires-Dist: torch>=2.6.0
10
+ Requires-Dist: torch>=2.9.1
11
11
  Requires-Dist: torchvision>=0.21.0
12
12
  Requires-Dist: transformers>=4.48.1
13
13
  Requires-Dist: opencv-python>=4.2.0.32
@@ -1,5 +1,5 @@
1
1
  numpy
2
- torch>=2.6.0
2
+ torch>=2.9.1
3
3
  torchvision>=0.21.0
4
4
  transformers>=4.48.1
5
5
  opencv-python>=4.2.0.32
@@ -906,19 +906,52 @@ def _save_stl_with_manifold_fix(triangles, filename):
906
906
  buffer.write(struct.pack("<I", num_triangles))
907
907
  buffer.write(stl_data.tobytes())
908
908
  buffer.seek(0)
909
-
910
- # Load with trimesh and fix manifold issues
911
- try:
912
- mesh = trimesh.load(buffer, file_type="stl")
913
- mesh.merge_vertices()
914
- mesh.remove_degenerate_faces()
915
- mesh.remove_duplicate_faces()
916
- mesh.fill_holes()
917
- mesh.fix_normals()
918
- mesh.export(filename)
919
- except Exception as e:
920
- print(f"Warning: Error fixing manifold for {filename}: {e}")
921
- # Fall back to saving without manifold fix
922
- with open(filename, "wb") as f:
923
- buffer.seek(0)
924
- f.write(buffer.read())
909
+
910
+ with open(filename, "wb") as f:
911
+ buffer.seek(0)
912
+ f.write(buffer.read())
913
+
914
+ # # Load with trimesh and fix manifold issues
915
+ # try:
916
+ # mesh = trimesh.load(buffer, file_type="stl")
917
+ # mesh.merge_vertices()
918
+ #
919
+ # # Try available degenerate-face removal utilities across trimesh versions
920
+ # try:
921
+ # if hasattr(mesh, "remove_degenerate_faces"):
922
+ # mesh.remove_degenerate_faces()
923
+ # elif hasattr(trimesh.repair, "remove_degenerate_faces"):
924
+ # trimesh.repair.remove_degenerate_faces(mesh)
925
+ # elif hasattr(trimesh.repair, "remove_degenerate_triangles"):
926
+ # trimesh.repair.remove_degenerate_triangles(mesh)
927
+ # except Exception as repair_err:
928
+ # print(f"Warning: Skipped degenerate-face removal for {filename}: {repair_err}")
929
+ #
930
+ # # Remove duplicate faces if the method exists
931
+ # try:
932
+ # if hasattr(mesh, "remove_duplicate_faces"):
933
+ # mesh.remove_duplicate_faces()
934
+ # elif hasattr(trimesh.repair, "remove_duplicate_faces"):
935
+ # trimesh.repair.remove_duplicate_faces(mesh)
936
+ # except Exception as repair_err:
937
+ # print(f"Warning: Skipped duplicate-face removal for {filename}: {repair_err}")
938
+ #
939
+ # # Fill holes and fix normals when available
940
+ # try:
941
+ # if hasattr(mesh, "fill_holes"):
942
+ # mesh.fill_holes()
943
+ # except Exception as repair_err:
944
+ # print(f"Warning: Skipped hole filling for {filename}: {repair_err}")
945
+ #
946
+ # try:
947
+ # mesh.fix_normals()
948
+ # except Exception as repair_err:
949
+ # print(f"Warning: Skipped normal fixing for {filename}: {repair_err}")
950
+ #
951
+ # mesh.export(filename)
952
+ # except Exception as e:
953
+ # print(f"Warning: Error fixing manifold for {filename}: {e}")
954
+ # # Fall back to saving without manifold fix
955
+ # with open(filename, "wb") as f:
956
+ # buffer.seek(0)
957
+ # f.write(buffer.read())
@@ -0,0 +1,448 @@
1
+ import numpy as np
2
+ import pandas as pd
3
+ from typing import List, Tuple, Dict
4
+
5
+ import pysindy as ps
6
+ from pysindy.feature_library import PolynomialLibrary
7
+ from pysindy.optimizers import TorchOptimizer
8
+ import matplotlib.pyplot as plt
9
+ from sklearn.preprocessing import StandardScaler
10
+
11
+ """
12
+ Opacity SINDy Estimator (scalar opacity per layer)
13
+ -------------------------------------------------
14
+ - Extracts a single scalar opacity o_l per layer via RGB least squares using
15
+ the Porter–Duff over compositing model.
16
+ - Fits a PySINDy continuous-time model with control inputs u = [TD_I, layer_height].
17
+ - Simulates opacity sequences and composites predicted colors; plots measured vs predicted.
18
+
19
+ Expected CSV columns:
20
+ - "Transmission Distance" (TD_I)
21
+ - "Background Material" (hex color)
22
+ - "Layer Material" (hex color)
23
+ - "Layer 1" ... "Layer N" (hex color per layer composite measurement)
24
+ """
25
+
26
+ # ---------------------
27
+ # Utilities
28
+ # ---------------------
29
+
30
+
31
+ def hex_to_rgb(hex_str: str) -> np.ndarray:
32
+ s = hex_str.lstrip("#")
33
+ r = int(s[0:2], 16)
34
+ g = int(s[2:4], 16)
35
+ b = int(s[4:6], 16)
36
+ return np.array([r, g, b], dtype=np.float32)
37
+
38
+
39
+ # sRGB (0-1) <-> linear RGB (0-1)
40
+
41
+
42
+ def _srgb01_to_linear01(c: np.ndarray) -> np.ndarray:
43
+ c = np.asarray(c, dtype=np.float32)
44
+ return np.where(
45
+ c <= 0.04045,
46
+ c / 12.92,
47
+ ((c + 0.055) / 1.055) ** 2.4,
48
+ )
49
+
50
+
51
+ def _linear01_to_srgb01(c_lin: np.ndarray) -> np.ndarray:
52
+ c_lin = np.asarray(c_lin, dtype=np.float32)
53
+ return np.where(
54
+ c_lin <= 0.0031308,
55
+ 12.92 * c_lin,
56
+ 1.055 * (np.clip(c_lin, 0.0, None) ** (1.0 / 2.4)) - 0.055,
57
+ )
58
+
59
+
60
+ def hex_to_linear_rgb01(hex_str: str) -> np.ndarray:
61
+ rgb255 = hex_to_rgb(hex_str)
62
+ srgb01 = np.clip(rgb255 / 255.0, 0.0, 1.0)
63
+ return np.clip(_srgb01_to_linear01(srgb01), 0.0, 1.0)
64
+
65
+
66
+ def linear_rgb01_to_srgb255(rgb_lin01: np.ndarray) -> np.ndarray:
67
+ srgb01 = _linear01_to_srgb01(np.clip(rgb_lin01, 0.0, 1.0))
68
+ return np.clip(srgb01 * 255.0, 0.0, 255.0)
69
+
70
+
71
+ # Forward compositing from scalar opacity series (in linear RGB domain)
72
+ # Returns list of per-layer composite sRGB (float32 in [0,255]) and final color.
73
+
74
+
75
+ def composite_from_opacity_series(
76
+ bg_lin01: np.ndarray,
77
+ fg_lin01: np.ndarray,
78
+ o_series: np.ndarray,
79
+ ) -> Tuple[List[np.ndarray], np.ndarray]:
80
+ comp_lin = np.zeros(3, dtype=np.float32)
81
+ remaining = 1.0
82
+ per_layer_colors_srgb255: List[np.ndarray] = []
83
+ for o in o_series:
84
+ o = float(np.clip(o, 0.0, 1.0))
85
+ comp_lin = comp_lin + (remaining * o) * fg_lin01
86
+ remaining = remaining * (1.0 - o)
87
+ color_lin = comp_lin + remaining * bg_lin01
88
+ color_srgb255 = linear_rgb01_to_srgb255(color_lin)
89
+ per_layer_colors_srgb255.append(color_srgb255)
90
+ final_color = (
91
+ per_layer_colors_srgb255[-1]
92
+ if per_layer_colors_srgb255
93
+ else linear_rgb01_to_srgb255(bg_lin01)
94
+ )
95
+ return per_layer_colors_srgb255, final_color
96
+
97
+
98
+ def extract_scalar_opacity_series(
99
+ df: pd.DataFrame,
100
+ num_layers: int,
101
+ layer_height: float,
102
+ ) -> Tuple[List[np.ndarray], List[np.ndarray]]:
103
+ """
104
+ Invert compositing to recover a scalar opacity per layer using RGB least squares.
105
+ Operates in linear RGB domain for physical correctness.
106
+
107
+ Returns:
108
+ - o_list: list of (num_layers,) arrays per sample
109
+ - t_list: list of (num_layers,) time arrays per sample (t = idx * layer_height)
110
+ """
111
+ o_list: List[np.ndarray] = []
112
+ t_list: List[np.ndarray] = []
113
+
114
+ eps = 1e-8
115
+ for _, row in df.iterrows():
116
+ bg_lin = hex_to_linear_rgb01(str(row["Background Material"]))
117
+ fg_lin = hex_to_linear_rgb01(str(row["Layer Material"]))
118
+
119
+ comp_lin = np.zeros(3, dtype=np.float32)
120
+ remaining = 1.0
121
+
122
+ o_series = np.zeros(num_layers, dtype=np.float32)
123
+ for layer in range(1, num_layers + 1):
124
+ meas_lin = hex_to_linear_rgb01(str(row[f"Layer {layer}"]))
125
+ # d = meas - (comp + remaining*bg) = remaining * o * (fg - bg)
126
+ d = meas_lin - (comp_lin + remaining * bg_lin)
127
+ v = remaining * (fg_lin - bg_lin)
128
+ denom = float(np.dot(v, v))
129
+ if denom < eps:
130
+ o = 0.0
131
+ else:
132
+ o = float(np.dot(d, v) / denom)
133
+ o = float(np.clip(o, 0.0, 1.0))
134
+ o_series[layer - 1] = o
135
+
136
+ # Update state in linear
137
+ comp_lin = comp_lin + (remaining * o) * fg_lin
138
+ remaining = remaining * (1.0 - o)
139
+
140
+ t = np.arange(1, num_layers + 1, dtype=np.float32) * layer_height
141
+ o_list.append(o_series)
142
+ t_list.append(t)
143
+
144
+ return o_list, t_list
145
+
146
+
147
+ def build_control_list(
148
+ df: pd.DataFrame, num_layers: int, layer_height: float
149
+ ) -> List[np.ndarray]:
150
+ """
151
+ Build control input list U per trajectory with two controls per layer:
152
+ u = [TD_I, layer_height]
153
+ Returns a list of arrays shape (num_layers, 2).
154
+ """
155
+ U_list: List[np.ndarray] = []
156
+ for _, row in df.iterrows():
157
+ TD_I = float(row.at["Transmission Distance"]) # scalar
158
+ U = np.stack(
159
+ [
160
+ np.full(num_layers, TD_I, dtype=np.float32),
161
+ np.full(num_layers, float(layer_height), dtype=np.float32),
162
+ ],
163
+ axis=1,
164
+ )
165
+ U_list.append(U)
166
+ return U_list
167
+
168
+
169
+ # ---------------------
170
+ # SINDy training (scalar state with controls)
171
+ # ---------------------
172
+
173
+
174
+ def fit_sindy_with_control(
175
+ trajectories: List[np.ndarray],
176
+ t_list: List[np.ndarray],
177
+ U_list_scaled: List[np.ndarray],
178
+ ) -> ps.SINDy:
179
+ """
180
+ Fit continuous-time SINDy-with-control for a scalar state using multiple trajectories.
181
+ Controls are already scaled.
182
+ """
183
+ feature_library = PolynomialLibrary(
184
+ degree=3, include_interaction=True, include_bias=True
185
+ )
186
+
187
+ model = ps.SINDy(
188
+ feature_library=feature_library,
189
+ differentiation_method=ps.FiniteDifference(),
190
+ optimizer=TorchOptimizer(),
191
+ )
192
+
193
+ x_list = [traj.reshape(-1, 1) for traj in trajectories]
194
+ model.fit(x=x_list, t=t_list, u=U_list_scaled)
195
+ return model
196
+
197
+
198
+ # ---------------------
199
+ # Public API
200
+ # ---------------------
201
+
202
+
203
+ def run_sindy_opacity(
204
+ csv_path: str = "printed_colors.csv",
205
+ num_layers: int = 16,
206
+ layer_height: float = 0.04,
207
+ plot_examples: bool = True,
208
+ n_plot_samples: int = 6,
209
+ random_seed: int | None = 123,
210
+ ) -> Dict[str, object]:
211
+ """
212
+ Load CSV, invert compositing to get per-layer scalar opacities, fit SINDy-with-control
213
+ model, print equation, and return the model, scaler, and other artifacts.
214
+ """
215
+ df = pd.read_csv(csv_path)
216
+
217
+ # Extract scalar opacity trajectories (in linear space)
218
+ o_list, t_list = extract_scalar_opacity_series(df, num_layers, layer_height)
219
+
220
+ # Build controls and scale them
221
+ U_list = build_control_list(df, num_layers, layer_height)
222
+ scaler = StandardScaler()
223
+ U_stacked = np.vstack(U_list)
224
+ scaler.fit(U_stacked)
225
+ U_list_scaled = [scaler.transform(U) for U in U_list]
226
+
227
+ # Fit SINDy model (scalar state)
228
+ sindy_model = fit_sindy_with_control(o_list, t_list, U_list_scaled)
229
+
230
+ print("\nDiscovered opacity dynamics (with controls TD_I & layer_height):")
231
+ sindy_model.print()
232
+
233
+ # Smoke simulate first trajectory
234
+ try:
235
+ sim_t = t_list[0]
236
+ sim_u = U_list_scaled[0]
237
+ x0 = np.array([o_list[0][0]], dtype=np.float32)
238
+ x_sim = np.array(sindy_model.simulate(x0, sim_t, u=sim_u)).reshape(-1)
239
+ x_sim = np.clip(x_sim, 0.0, 1.0)
240
+ print("\nSample simulated opacity (first trajectory):", x_sim[:8])
241
+ except Exception as e:
242
+ print("Simulation failed:", e)
243
+
244
+ # Evaluate simulation error across all trajectories (opacity + final RGB)
245
+ metrics = evaluate_simulation_and_color_error(
246
+ sindy_model, df, o_list, t_list, U_list, U_list_scaled
247
+ )
248
+ print("\nOpacity and color simulation error (averaged across trajectories):")
249
+ print(
250
+ f"Opacity: MAE={metrics['opacity']['MAE']:.4f}, RMSE={metrics['opacity']['RMSE']:.4f}"
251
+ )
252
+ print(
253
+ f"Final RGB: MAE={metrics['rgb']['MAE']:.4f}, RMSE={metrics['rgb']['RMSE']:.4f}"
254
+ )
255
+
256
+ artifacts: Dict[str, object] = {
257
+ "model": sindy_model,
258
+ "scaler": scaler,
259
+ "controls": ["Transmission Distance", "Layer Height"],
260
+ "num_layers": num_layers,
261
+ "layer_height": layer_height,
262
+ }
263
+
264
+ if plot_examples:
265
+ try:
266
+ plot_random_color_comparison(
267
+ sindy_model,
268
+ scaler,
269
+ df,
270
+ o_list,
271
+ t_list,
272
+ U_list,
273
+ num_layers=num_layers,
274
+ n_samples=n_plot_samples,
275
+ seed=random_seed,
276
+ )
277
+ except Exception as e:
278
+ print("Plotting failed:", e)
279
+
280
+ return artifacts
281
+
282
+
283
+ def evaluate_simulation_and_color_error(
284
+ model: ps.SINDy,
285
+ df: pd.DataFrame,
286
+ o_list: List[np.ndarray],
287
+ t_list: List[np.ndarray],
288
+ U_list: List[np.ndarray],
289
+ U_list_scaled: List[np.ndarray],
290
+ ) -> Dict[str, Dict[str, float]]:
291
+ """
292
+ Simulate each trajectory and compute errors vs extracted opacity and final RGB.
293
+ Returns a dict with MAE and RMSE for opacity (scalar) and RGB (final color).
294
+ """
295
+ mae_o_all, rmse_o_all = [], []
296
+ mae_rgb_all, rmse_rgb_all = [], []
297
+
298
+ for i, o_series in enumerate(o_list):
299
+ t = t_list[i]
300
+ u_scaled = U_list_scaled[i]
301
+ x0 = np.array([o_series[0]], dtype=np.float32)
302
+ try:
303
+ sim = np.array(model.simulate(x0, t, u=u_scaled)).reshape(-1)
304
+ except Exception:
305
+ continue
306
+ sim = np.clip(sim, 0.0, 1.0)
307
+ n = min(len(sim), len(o_series))
308
+ err = sim[:n] - o_series[:n]
309
+ mae_o_all.append(float(np.mean(np.abs(err))))
310
+ rmse_o_all.append(float(np.sqrt(np.mean(err**2))))
311
+
312
+ # Compare final RGB (work in linear domain for compositing)
313
+ row = df.iloc[i]
314
+ bg_lin = hex_to_linear_rgb01(str(row["Background Material"]))
315
+ fg_lin = hex_to_linear_rgb01(str(row["Layer Material"]))
316
+
317
+ # Measured final color (sRGB for comparison)
318
+ try:
319
+ meas_hex = str(row[f"Layer {len(o_series)}"])
320
+ meas_rgb = hex_to_rgb(meas_hex)
321
+ except Exception:
322
+ last_layer_col = [c for c in df.columns if c.startswith("Layer ")][-1]
323
+ meas_rgb = hex_to_rgb(str(row[last_layer_col]))
324
+
325
+ _, pred_rgb_srgb = composite_from_opacity_series(bg_lin, fg_lin, sim[:n])
326
+ # Color error in 0-1 space averaged across channels
327
+ e_rgb = (pred_rgb_srgb - meas_rgb) / 255.0
328
+ mae_rgb_all.append(float(np.mean(np.abs(e_rgb))))
329
+ rmse_rgb_all.append(float(np.sqrt(np.mean(e_rgb**2))))
330
+ print(
331
+ f"Sample {i}: Average RGB error (MAE) = {np.mean(mae_rgb_all):.4f}, Average Opacity error (MAE) = {np.mean(mae_o_all):.4f}"
332
+ )
333
+
334
+ return {
335
+ "opacity": {
336
+ "MAE": float(np.mean(mae_o_all)) if mae_o_all else float("nan"),
337
+ "RMSE": float(np.mean(rmse_o_all)) if rmse_o_all else float("nan"),
338
+ },
339
+ "rgb": {
340
+ "MAE": float(np.mean(mae_rgb_all)) if mae_rgb_all else float("nan"),
341
+ "RMSE": float(np.mean(rmse_rgb_all)) if rmse_rgb_all else float("nan"),
342
+ },
343
+ }
344
+
345
+
346
+ # ---------------------
347
+ # Visualization: measured vs predicted composite colors
348
+ # ---------------------
349
+
350
+
351
+ def _rgb_to_patch(rgb: np.ndarray, size: int = 40) -> np.ndarray:
352
+ rgb01 = np.clip(rgb / 255.0, 0.0, 1.0)
353
+ arr = np.ones((size, size, 3), dtype=np.float32)
354
+ arr[..., 0] = rgb01[0]
355
+ arr[..., 1] = rgb01[1]
356
+ arr[..., 2] = rgb01[2]
357
+ return arr
358
+
359
+
360
+ def plot_random_color_comparison(
361
+ model: ps.SINDy,
362
+ scaler: StandardScaler,
363
+ df: pd.DataFrame,
364
+ o_list: List[np.ndarray],
365
+ t_list: List[np.ndarray],
366
+ U_list: List[np.ndarray],
367
+ num_layers: int,
368
+ n_samples: int = 6,
369
+ seed: int | None = None,
370
+ save_path: str | None = None,
371
+ ):
372
+ """
373
+ Pick 5-10 random rows from df, simulate opacity with the discovered
374
+ ODE and composite predicted colors. Plot measured final color vs predicted
375
+ final color side by side per sample with MAE in the title.
376
+ """
377
+ rng = np.random.default_rng(seed)
378
+ N = len(df)
379
+ n = max(1, min(n_samples, N))
380
+ idxs = rng.choice(N, size=n, replace=False)
381
+
382
+ rows = [df.iloc[int(i)] for i in idxs]
383
+
384
+ fig, axes = plt.subplots(nrows=n, ncols=2, figsize=(6, 2.2 * n))
385
+ if n == 1:
386
+ axes = np.array([axes])
387
+
388
+ for row_idx, (ax_left, ax_right) in enumerate(axes):
389
+ i = int(idxs[row_idx])
390
+ row = rows[row_idx]
391
+ bg_lin = hex_to_linear_rgb01(str(row["Background Material"]))
392
+ fg_lin = hex_to_linear_rgb01(str(row["Layer Material"]))
393
+
394
+ # Measured final composite color from CSV (Layer num_layers, sRGB space)
395
+ try:
396
+ meas_hex = str(row[f"Layer {num_layers}"])
397
+ meas_rgb = hex_to_rgb(meas_hex)
398
+ except Exception:
399
+ last_layer_col = [c for c in df.columns if c.startswith("Layer ")][-1]
400
+ meas_rgb = hex_to_rgb(str(row[last_layer_col]))
401
+
402
+ # Controls
403
+ U = U_list[i]
404
+ U_scaled = scaler.transform(U)
405
+
406
+ # Simulate predicted opacity sequence
407
+ t = t_list[i]
408
+ x0 = np.array([o_list[i][0]], dtype=np.float32)
409
+ try:
410
+ sim = np.array(model.simulate(x0, t, u=U_scaled)).reshape(-1)
411
+ except Exception:
412
+ sim = np.zeros_like(t)
413
+ sim = np.clip(sim, 0.0, 1.0)
414
+
415
+ # Forward composite predicted final color (linear -> sRGB conversion inside)
416
+ _, pred_rgb = composite_from_opacity_series(
417
+ bg_lin01=bg_lin, fg_lin01=fg_lin, o_series=sim
418
+ )
419
+
420
+ # Build small color patches
421
+ left_img = _rgb_to_patch(meas_rgb)
422
+ right_img = _rgb_to_patch(pred_rgb)
423
+
424
+ ax_left.imshow(left_img)
425
+ ax_left.set_title("Measured")
426
+ ax_left.axis("off")
427
+
428
+ ax_right.imshow(right_img)
429
+ err = np.mean(np.abs((pred_rgb - meas_rgb) / 255.0))
430
+ ax_right.set_title(f"Predicted (MAE={err:.3f})")
431
+ ax_right.axis("off")
432
+
433
+ plt.tight_layout()
434
+ if save_path:
435
+ plt.savefig(save_path, dpi=160)
436
+ print(f"Saved comparison plot to {save_path}")
437
+ else:
438
+ plt.show()
439
+
440
+
441
+ if __name__ == "__main__":
442
+ # Run with default CSV name if available; show comparison.
443
+ try:
444
+ run_sindy_opacity(plot_examples=True, n_plot_samples=12)
445
+ except FileNotFoundError:
446
+ print(
447
+ "CSV not found for SINDy opacity run. Please provide a valid csv_path or run within the project that contains printed_colors.csv."
448
+ )