colour-match-sdl 0.1.0__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 (53) hide show
  1. colour_match_sdl-0.1.0/MANIFEST.in +6 -0
  2. colour_match_sdl-0.1.0/PKG-INFO +67 -0
  3. colour_match_sdl-0.1.0/README.md +53 -0
  4. colour_match_sdl-0.1.0/colour_match/__init__.py +0 -0
  5. colour_match_sdl-0.1.0/colour_match/bayesian_optimization_colour_match.py +113 -0
  6. colour_match_sdl-0.1.0/colour_match/colour_utils.py +448 -0
  7. colour_match_sdl-0.1.0/colour_match_sdl.egg-info/PKG-INFO +67 -0
  8. colour_match_sdl-0.1.0/colour_match_sdl.egg-info/SOURCES.txt +51 -0
  9. colour_match_sdl-0.1.0/colour_match_sdl.egg-info/dependency_links.txt +1 -0
  10. colour_match_sdl-0.1.0/colour_match_sdl.egg-info/requires.txt +8 -0
  11. colour_match_sdl-0.1.0/colour_match_sdl.egg-info/top_level.txt +6 -0
  12. colour_match_sdl-0.1.0/colour_match_web/__init__.py +1 -0
  13. colour_match_sdl-0.1.0/colour_match_web/plugin.py +90 -0
  14. colour_match_sdl-0.1.0/colour_match_web/static/assets/capping_closed.svg +1 -0
  15. colour_match_sdl-0.1.0/colour_match_web/static/assets/capping_open.svg +1 -0
  16. colour_match_sdl-0.1.0/colour_match_web/static/assets/robot.svg +1 -0
  17. colour_match_sdl-0.1.0/colour_match_web/static/assets/stir_plate.svg +1 -0
  18. colour_match_sdl-0.1.0/colour_match_web/static/script.js +631 -0
  19. colour_match_sdl-0.1.0/colour_match_web/static/style.css +537 -0
  20. colour_match_sdl-0.1.0/colour_match_web/templates/colour_match_sdl.html +130 -0
  21. colour_match_sdl-0.1.0/ivoryos_basic_sdl/__init__.py +0 -0
  22. colour_match_sdl-0.1.0/ivoryos_basic_sdl/sdl.py +128 -0
  23. colour_match_sdl-0.1.0/ivorysos_colour_match_sdl/__init__.py +0 -0
  24. colour_match_sdl-0.1.0/ivorysos_colour_match_sdl/sdl.py +364 -0
  25. colour_match_sdl-0.1.0/pyproject.toml +50 -0
  26. colour_match_sdl-0.1.0/requirements.txt +9 -0
  27. colour_match_sdl-0.1.0/scripts/__init__.py +0 -0
  28. colour_match_sdl-0.1.0/scripts/demo_bayesian_optimization.py +55 -0
  29. colour_match_sdl-0.1.0/scripts/demo_bayesian_optimization_matplotlib.py +133 -0
  30. colour_match_sdl-0.1.0/scripts/demo_bayesian_optimization_plotly.py +128 -0
  31. colour_match_sdl-0.1.0/scripts/demo_data_visualization_matplotlib.py +151 -0
  32. colour_match_sdl-0.1.0/scripts/demo_data_visualization_plotly.py +170 -0
  33. colour_match_sdl-0.1.0/scripts/demo_prepare_stock_solution.py +42 -0
  34. colour_match_sdl-0.1.0/scripts/demo_prepare_stock_solution_complete.py +179 -0
  35. colour_match_sdl-0.1.0/scripts/demo_prepare_stock_solution_with_analyze.py +207 -0
  36. colour_match_sdl-0.1.0/scripts/instruments.py +429 -0
  37. colour_match_sdl-0.1.0/scripts/multiposition_vici_valve.py +106 -0
  38. colour_match_sdl-0.1.0/scripts/sim_instruments.py +257 -0
  39. colour_match_sdl-0.1.0/sdl_sim_web/__init__.py +1 -0
  40. colour_match_sdl-0.1.0/sdl_sim_web/plugin.py +60 -0
  41. colour_match_sdl-0.1.0/sdl_sim_web/server.py +40 -0
  42. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/balance.svg +1 -0
  43. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/capping_closed.svg +1 -0
  44. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/capping_open.svg +1 -0
  45. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/liquid_addition.svg +1 -0
  46. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/robot.svg +1 -0
  47. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/solid_addition.svg +1 -0
  48. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/solution_analysis_hplc.svg +1 -0
  49. colour_match_sdl-0.1.0/sdl_sim_web/static/assets/stir_plate.svg +1 -0
  50. colour_match_sdl-0.1.0/sdl_sim_web/static/script.js +297 -0
  51. colour_match_sdl-0.1.0/sdl_sim_web/static/style.css +531 -0
  52. colour_match_sdl-0.1.0/sdl_sim_web/templates/web_viz.html +97 -0
  53. colour_match_sdl-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,6 @@
1
+ include README.md
2
+ include requirements.txt
3
+ recursive-include colour_match_web/static *.css *.js
4
+ recursive-include colour_match_web/templates *.html
5
+ recursive-include sdl_sim_web/static *.css *.js
6
+ recursive-include sdl_sim_web/templates *.html
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: colour-match-sdl
3
+ Version: 0.1.0
4
+ Summary: Colour matching and SDL simulation utilities for IvoryOS demos.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: ivoryos
8
+ Provides-Extra: full
9
+ Requires-Dist: numpy; extra == "full"
10
+ Requires-Dist: matplotlib; extra == "full"
11
+ Requires-Dist: plotly; extra == "full"
12
+ Requires-Dist: opencv-python==4.10.0.84; extra == "full"
13
+ Requires-Dist: colormath==3.0.0; extra == "full"
14
+
15
+ # Colour match SDL
16
+
17
+
18
+ ## Getting started
19
+ ...
20
+
21
+ ## Description
22
+ ...
23
+
24
+ ## Visuals
25
+ ...
26
+
27
+ ## Installation
28
+ First create a new virtual environment then use:
29
+
30
+ ```commandline
31
+ pip install -r requirements.txt
32
+ ```
33
+
34
+ ## Usage
35
+ Order to go through the scripts
36
+
37
+ In the scripts folder
38
+ 1. [instruments.py](scripts/instruments.py)
39
+ 2. [multiposition_vici_valve.py](scripts/multiposition_vici_valve.py)
40
+ 3. [demo_prepare_stock_solution_complete.py](scripts/demo_prepare_stock_solution_complete.py)
41
+ 4. [demo_prepare_stock_solution_with_analyze.py](scripts/demo_prepare_stock_solution_with_analyze.py)
42
+ 5. [demo_data_visualization_matplotlib.py](scripts/demo_data_visualization_matplotlib.py)
43
+ 6. [demo_data_visualization_plotly.py](scripts/demo_data_visualization_plotly.py)
44
+ 7. [demo_bayesian_optimization.py](scripts/demo_bayesian_optimization.py)
45
+ 8. [demo_bayesian_optimization_matplotlib.py](scripts/demo_bayesian_optimization_matplotlib.py)
46
+ 9. [demo_bayesian_optimization_plotly.py](scripts/demo_bayesian_optimization_plotly.py)
47
+
48
+ In the colour match folder
49
+ 1. [bayesian_optimization_colour_match.py](colour_match/bayesian_optimization_colour_match.py)
50
+
51
+ In the ivoryOS demo basic sdl folder
52
+ 1. [ivoryos_basic_sdl/sdl.py](ivoryos_basic_sdl/sdl.py)
53
+ * Explore the workflow designer then load and run
54
+ * [create_stock_solution_and_analyze.json](ivoryos_basic_sdl/create_stock_solution_and_analyze.json)
55
+
56
+ In the ivoryOS demo basic sdl folder
57
+ 1. [ivorysos_colour_match_sdl/sdl.py](ivorysos_colour_match_sdl/sdl.py)
58
+ * Explore the workflow designer then load and run
59
+ * [colour_match_ryb_sunset_orange.json](ivorysos_colour_match_sdl/colour_match_ryb_sunset_orange.json)
60
+ * [colour_match_ryb_grey_blue.json](ivorysos_colour_match_sdl/colour_match_ryb_grey_blue.json)
61
+ * [colour_match_rybgp_grey_blue.json](ivorysos_colour_match_sdl/colour_match_rybgp_grey_blue.json)
62
+
63
+ ## Authors and acknowledgment
64
+ ...
65
+
66
+ ## License
67
+ ...
@@ -0,0 +1,53 @@
1
+ # Colour match SDL
2
+
3
+
4
+ ## Getting started
5
+ ...
6
+
7
+ ## Description
8
+ ...
9
+
10
+ ## Visuals
11
+ ...
12
+
13
+ ## Installation
14
+ First create a new virtual environment then use:
15
+
16
+ ```commandline
17
+ pip install -r requirements.txt
18
+ ```
19
+
20
+ ## Usage
21
+ Order to go through the scripts
22
+
23
+ In the scripts folder
24
+ 1. [instruments.py](scripts/instruments.py)
25
+ 2. [multiposition_vici_valve.py](scripts/multiposition_vici_valve.py)
26
+ 3. [demo_prepare_stock_solution_complete.py](scripts/demo_prepare_stock_solution_complete.py)
27
+ 4. [demo_prepare_stock_solution_with_analyze.py](scripts/demo_prepare_stock_solution_with_analyze.py)
28
+ 5. [demo_data_visualization_matplotlib.py](scripts/demo_data_visualization_matplotlib.py)
29
+ 6. [demo_data_visualization_plotly.py](scripts/demo_data_visualization_plotly.py)
30
+ 7. [demo_bayesian_optimization.py](scripts/demo_bayesian_optimization.py)
31
+ 8. [demo_bayesian_optimization_matplotlib.py](scripts/demo_bayesian_optimization_matplotlib.py)
32
+ 9. [demo_bayesian_optimization_plotly.py](scripts/demo_bayesian_optimization_plotly.py)
33
+
34
+ In the colour match folder
35
+ 1. [bayesian_optimization_colour_match.py](colour_match/bayesian_optimization_colour_match.py)
36
+
37
+ In the ivoryOS demo basic sdl folder
38
+ 1. [ivoryos_basic_sdl/sdl.py](ivoryos_basic_sdl/sdl.py)
39
+ * Explore the workflow designer then load and run
40
+ * [create_stock_solution_and_analyze.json](ivoryos_basic_sdl/create_stock_solution_and_analyze.json)
41
+
42
+ In the ivoryOS demo basic sdl folder
43
+ 1. [ivorysos_colour_match_sdl/sdl.py](ivorysos_colour_match_sdl/sdl.py)
44
+ * Explore the workflow designer then load and run
45
+ * [colour_match_ryb_sunset_orange.json](ivorysos_colour_match_sdl/colour_match_ryb_sunset_orange.json)
46
+ * [colour_match_ryb_grey_blue.json](ivorysos_colour_match_sdl/colour_match_ryb_grey_blue.json)
47
+ * [colour_match_rybgp_grey_blue.json](ivorysos_colour_match_sdl/colour_match_rybgp_grey_blue.json)
48
+
49
+ ## Authors and acknowledgment
50
+ ...
51
+
52
+ ## License
53
+ ...
File without changes
@@ -0,0 +1,113 @@
1
+ """
2
+ pip install ax-platform==1.1.2
3
+ """
4
+
5
+ import time
6
+
7
+ import cv2
8
+ from ax.service.managed_loop import optimize
9
+
10
+ from colour_match.colour_utils import (average_rgb_in_roi, cie2000_distance,
11
+ dummy_mix_colours_red_yellow_blue_green_purple_water_to_bgr, colour_swatch_from_bgr,
12
+ plot_lab_distance, plot_lab_distance_live,
13
+ )
14
+
15
+ # Suppress warnings from ax
16
+ import warnings
17
+ warnings.filterwarnings("ignore", category=UserWarning)
18
+
19
+
20
+ def dummy_colour_match_objective(params):
21
+ """
22
+ Objective Function for BO. params is a dict of
23
+ {
24
+ "red", "type": "range", "bounds": [0.0, 1.0],
25
+ "yellow", "type": "range", "bounds": [0.0, 1.0],
26
+ "blue", "type": "range", "bounds": [0.0, 1.0],
27
+ "green", "type": "range", "bounds": [0.0, 1.0],
28
+ "purple", "type": "range", "bounds": [0.0, 1.0],
29
+ }
30
+ parameter_constraints=["red + blue + yellow + green + purple <= 1.0"]
31
+
32
+ This is good for simulating colour mixing only, not to run on a deck.
33
+ """
34
+ trial_time = time.time()
35
+
36
+ water = 1 - (params['red'] + params['blue'] + params['yellow'] + params['green'] + params['purple'])
37
+
38
+ params['water'] = water
39
+
40
+ _bgr_mix = dummy_mix_colours_red_yellow_blue_green_purple_water_to_bgr(**params)
41
+ img = colour_swatch_from_bgr(bgr=_bgr_mix, width=15, height=15)
42
+
43
+ # convert the image from BGR to RBG
44
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
45
+
46
+ # crop ROI and compute average RGB
47
+ avg_rgb = img.mean(axis=(0, 1))
48
+ predicted_rgb_colours.append(avg_rgb) # add to list of rgb colours that have been predicted by the optimizer, for plotting
49
+
50
+ # # optional: live plot
51
+ # plot_lab_distance_live(predicted_rgb_colours, target_rgb=TARGET_RGB)
52
+
53
+ # # optional: save rgb image
54
+ # filename = f"r{params['red']}_b{params['blue']}_y{params['yellow']}_g{params['green']}_p{params['purple']}_w{params['water']}_trial_{trial_time:.0f}.png"
55
+ # cv2.imwrite(filename, cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
56
+
57
+ # compute ΔE00 distance
58
+ delta_e = cie2000_distance(avg_rgb, TARGET_RGB)
59
+
60
+ print(f"Tested {params} -> Avg RGB {avg_rgb} -> ΔE00 = {delta_e:.2f}")
61
+
62
+ return delta_e # minimize this
63
+
64
+
65
+ if __name__ == "__main__":
66
+ # Run Bayesian Optimization with Ax to match a target colour - simulation only
67
+
68
+ # todo set target colour to match
69
+ SUNSET_ORANGE_RGB = (253, 94, 83) # https://www.colorhexa.com/fd5e53
70
+ PINK_RGB = (255, 183, 206)
71
+ TEAL_RGB = (0, 128, 128)
72
+ DARK_PURPLE = (76, 30, 79)
73
+ FOREST_GREEN = (0, 110, 51)
74
+ GREY_BLUE = (22, 96, 136)
75
+ TARGET_RGB = SUNSET_ORANGE_RGB
76
+ predicted_rgb_colours = [] # to store the rgb colours that have been predicted by the optimizer
77
+
78
+ # BO optimization
79
+ best_params, best_val, experiment, model = optimize(
80
+ parameters=[
81
+ {"name": "red", "type": "range", "bounds": [0.0, 1.0]},
82
+ {"name": "blue", "type": "range", "bounds": [0.0, 1.0]},
83
+ {"name": "yellow", "type": "range", "bounds": [0.0, 1.0]},
84
+ {"name": "green", "type": "range", "bounds": [0.0, 1.0]},
85
+ {"name": "purple", "type": "range", "bounds": [0.0, 1.0]},
86
+ ],
87
+ evaluation_function=dummy_colour_match_objective,
88
+ objective_name="deltaE00",
89
+ minimize=True,
90
+ total_trials=20,
91
+ parameter_constraints=["red + blue + yellow + green + purple <= 1.0"],
92
+ )
93
+
94
+ # Print all evaluated parameter sets and their values
95
+ print("All evaluated parameter sets and their deltaE00 values (objective: minimize):")
96
+ for trial in experiment.trials.values():
97
+ if trial.status.name == "COMPLETED":
98
+ red = trial.arm.parameters["red"]
99
+ blue = trial.arm.parameters["blue"]
100
+ yellow = trial.arm.parameters["yellow"]
101
+ green = trial.arm.parameters["green"]
102
+ purple = trial.arm.parameters["purple"]
103
+ deltaE00 = trial.objective_mean
104
+ print(f"Iteration: {trial.index + 1}: red: {red}, blue: {blue}, yellow: {yellow}, green: {green}, purple: {purple}, ΔE00: {deltaE00}")
105
+
106
+ # Print the best result
107
+ print()
108
+ print("Best params:", best_params)
109
+ print("Best ΔE00 value (want to minimize):", best_val)
110
+
111
+ # final plot of after all optimization is done
112
+ plotly_fig = plot_lab_distance(predicted_rgb_colours, TARGET_RGB, save=True, save_path=f"colour_match_rbygp_bo_lab_plot_{time.time():.0f}.html")
113
+ plotly_fig.show(renderer="browser")
@@ -0,0 +1,448 @@
1
+ import json
2
+ import time
3
+ from typing import Dict, Union, List, Tuple
4
+ import cv2
5
+ import platform
6
+
7
+
8
+ import numpy as np
9
+ from colormath.color_objects import sRGBColor, LabColor
10
+ from colormath.color_conversions import convert_color
11
+ from colormath.color_diff import delta_e_cie2000
12
+ import matplotlib.pyplot as plt
13
+ import plotly.graph_objects as go
14
+
15
+
16
+ # need to patch if using numpy <= 1.24.0
17
+ def patch_asscalar(a):
18
+ return a.item()
19
+ setattr(np, "asscalar", patch_asscalar)
20
+
21
+
22
+ # ---------------------------
23
+ # ROI Handling
24
+ # ---------------------------
25
+ DEFAULT_ROI_FILE_PATH = "roi.json"
26
+
27
+
28
+
29
+ def select_roi_and_save(image: Union[str, np.ndarray],
30
+ roi_file: str = DEFAULT_ROI_FILE_PATH) -> Dict:
31
+ """
32
+ Allows ROI selection on an input image.
33
+
34
+ image can be:
35
+ - str: path to an image file
36
+ - numpy.ndarray: an image already loaded (e.g. via cv2.imread)
37
+
38
+ Returns:
39
+ {
40
+ "x": int,
41
+ "y": int,
42
+ "w": int,
43
+ "h": int
44
+ }
45
+ """
46
+ # LOAD IMAGE BASED ON INPUT TYPE
47
+ if isinstance(image, str):
48
+ img = cv2.imread(image)
49
+ if img is None:
50
+ raise ValueError(f"Could not read image from path: {image}")
51
+ elif isinstance(image, np.ndarray):
52
+ img = image
53
+ else:
54
+ raise TypeError("image_source must be either a file path (str) or an image array (numpy.ndarray).")
55
+
56
+ r = cv2.selectROI("Select ROI", img, showCrosshair=True, fromCenter=False)
57
+ cv2.destroyAllWindows()
58
+
59
+ roi_data = {
60
+ "x": int(r[0]),
61
+ "y": int(r[1]),
62
+ "w": int(r[2]),
63
+ "h": int(r[3])
64
+ }
65
+ print(f'save roi.json file to {roi_file}')
66
+ with open(roi_file, "w") as f:
67
+ json.dump(roi_data, f)
68
+
69
+ return roi_data
70
+
71
+
72
+ def load_roi(roi_file: str = DEFAULT_ROI_FILE_PATH) -> Dict:
73
+ """
74
+ return
75
+ {
76
+ "x": 123,
77
+ "y": 45,
78
+ "w": 80,
79
+ "h": 60
80
+ }
81
+ """
82
+ print(f'load roi from: {roi_file}')
83
+ with open(roi_file, "r") as f:
84
+ roi_data = json.load(f)
85
+ return roi_data
86
+
87
+
88
+ def crop_image_using_roi(image, roi_data):
89
+ x, y, w, h = roi_data["x"], roi_data["y"], roi_data["w"], roi_data["h"]
90
+ return image[y:y + h, x:x + w]
91
+
92
+
93
+ def average_rgb_in_roi(image, roi_data):
94
+ roi = crop_image_using_roi(image, roi_data)
95
+ avg_rgb = tuple(np.mean(roi.reshape(-1, 3), axis=0))
96
+ return avg_rgb
97
+
98
+
99
+ # ---------------------------
100
+ # Color Utilities
101
+ # ---------------------------
102
+ def rgb_to_lab(rgb):
103
+ """Convert 0–255 RGB to Lab array"""
104
+ rgb_scaled = sRGBColor(*rgb, is_upscaled=True)
105
+ lab = convert_color(rgb_scaled, LabColor)
106
+ return np.array([lab.lab_l, lab.lab_a, lab.lab_b])
107
+
108
+
109
+ def cie2000_distance(rgb_1, rgb_2):
110
+ """
111
+ Compute CIEDE2000 color difference using colormath.
112
+ Input RGB: 0–255 tuples
113
+
114
+ | ΔE₀₀ | Perception |
115
+ | ----- | ----------------------------------------- |
116
+ | 0–1 | Not perceptible by human eyes |
117
+ | 1–2 | Perceptible through close observation |
118
+ | 2–10 | Perceptible at a glance, small difference |
119
+ | 11–49 | Colors are clearly different |
120
+ | 100+ | Colors are drastically different |
121
+
122
+ < 2–3: Usually considered a “match” for quality control (e.g., paints, textiles, prints).
123
+ > 5: Most people will notice the difference immediately.
124
+ > 10: Colors are obviously different.
125
+ """
126
+ c1 = sRGBColor(*rgb_1, is_upscaled=True)
127
+ c2 = sRGBColor(*rgb_2, is_upscaled=True)
128
+ lab1 = convert_color(c1, LabColor)
129
+ lab2 = convert_color(c2, LabColor)
130
+ return delta_e_cie2000(lab1, lab2)
131
+
132
+
133
+ def colour_swatch_from_rbg(rgb, width=30, height=30):
134
+ """
135
+ Create solid RGB image
136
+
137
+ Args:
138
+ rgb (tuple/list): (R, G, B) values in 0–255.
139
+ width (int): width of the image.
140
+ height (int): height of the image.
141
+ """
142
+ swatch_rgb = np.zeros((height, width, 3), dtype=np.uint8)
143
+ swatch_rgb[:, :] = rgb
144
+ return swatch_rgb
145
+
146
+
147
+ def save_color_swatch_rgb(rgb, width=30, height=30, filename=None):
148
+ """
149
+ Save an image filled entirely with an RGB color.
150
+
151
+ Args:
152
+ rgb (tuple/list): (R, G, B) values in 0–255.
153
+ width (int): width of the image.
154
+ height (int): height of the image.
155
+ filename (str): output file path, e.g. "swatch.png".
156
+ """
157
+ if filename is None:
158
+ filename = f"swatch_r{rgb[0]}_g{rgb[1]}b{rgb[2]}_{time.time():.0f}.png"
159
+
160
+ swatch_rgb = colour_swatch_from_rbg(rgb=rgb, width=width, height=height)
161
+ swatch_bgr = cv2.cvtColor(swatch_rgb, cv2.COLOR_RGB2BGR)
162
+ cv2.imwrite(filename, swatch_bgr) # need to convert to bgr before saving
163
+ return swatch_rgb
164
+
165
+
166
+ def colour_swatch_from_bgr(bgr, width=30, height=30):
167
+ """
168
+ Create solid BGR image
169
+
170
+ Args:
171
+ bgr (tuple/list): (B, G, R) values in 0–255.
172
+ width (int): width of the image.
173
+ height (int): height of the image.
174
+ """
175
+ swatch_bgr = np.zeros((height, width, 3), dtype=np.uint8)
176
+ swatch_bgr[:, :] = bgr # Fill with BGR color
177
+ return swatch_bgr
178
+
179
+
180
+ def save_color_swatch_bgr(bgr, width=30, height=30, filename=None):
181
+ """
182
+ Save an image filled with a BGR color and return the BGR array.
183
+
184
+ Args:
185
+ bgr (tuple/list): (B, G, R) values in 0–255.
186
+ width (int): width of the image.
187
+ height (int): height of the image.
188
+ filename (str): optional output file name.
189
+
190
+ Returns:
191
+ np.ndarray: The generated (height, width, 3) BGR image array.
192
+ """
193
+ if filename is None:
194
+ filename = f"swatch_b{bgr[0]}_g{bgr[1]}_r{bgr[2]}_{int(time.time())}.png"
195
+
196
+ swatch_bgr = colour_swatch_from_bgr(bgr=bgr, width=width, height=height)
197
+ cv2.imwrite(filename, swatch_bgr)
198
+ return swatch_bgr
199
+
200
+
201
+ def dummy_mix_colours_red_yellow_blue_water_to_bgr(red, yellow, blue, water):
202
+ """
203
+ Mix red, yellow, blue, water (0–1, sum=1)
204
+ Return BGR tuple (0–255 each).
205
+ Uses subtractive absorbance mixing which is good for simulating opaque paints.
206
+ """
207
+ # RGB for pigments (non-zero to avoid collapse)
208
+ red_rgb = np.array([1.0, 0.1, 0.1])
209
+ yellow_rgb = np.array([1.0, 1.0, 0.1])
210
+ blue_rgb = np.array([0.1, 0.1, 1.0])
211
+ white_rgb = np.array([1.0, 1.0, 1.0])
212
+
213
+ # Convert to absorbance: A = 1 - RGB
214
+ A_red = 1 - red_rgb
215
+ A_yellow = 1 - yellow_rgb
216
+ A_blue = 1 - blue_rgb
217
+ A_white = 1 - white_rgb # = 0
218
+
219
+ # Weighted linear mix of absorbance
220
+ A_mix = (
221
+ red * A_red +
222
+ yellow * A_yellow +
223
+ blue * A_blue +
224
+ water * A_white
225
+ )
226
+
227
+ # Convert back to RGB: RGB = 1 - absorbance
228
+ rgb = 1 - A_mix
229
+
230
+ # Scale to 0–255 and convert to int
231
+ rgb_255 = np.clip(rgb * 255, 0, 255).astype(np.uint8)
232
+
233
+ # Return in BGR order
234
+ return tuple(rgb_255[::-1])
235
+
236
+
237
+ def dummy_mix_colours_red_yellow_blue_green_purple_water_to_bgr(red, yellow, blue, green, purple, water):
238
+ """
239
+ Mix red, yellow, blue, green, purple, and water (0–1 each, sum = 1).
240
+ Return BGR tuple (0–255 each).
241
+
242
+ Uses subtractive absorbance mixing (like opaque paints).
243
+ """
244
+ # RGB values of pigments (non-zero to avoid collapse)
245
+ red_rgb = np.array([1.0, 0.1, 0.1])
246
+ yellow_rgb = np.array([1.0, 1.0, 0.1])
247
+ blue_rgb = np.array([0.1, 0.1, 1.0])
248
+ green_rgb = np.array([0.1, 1.0, 0.1])
249
+ purple_rgb = np.array([1.0, 0.1, 1.0])
250
+ white_rgb = np.array([1.0, 1.0, 1.0]) # water/white
251
+
252
+ # Convert to absorbance: A = 1 - RGB
253
+ A_red = 1 - red_rgb
254
+ A_yellow = 1 - yellow_rgb
255
+ A_blue = 1 - blue_rgb
256
+ A_green = 1 - green_rgb
257
+ A_purple = 1 - purple_rgb
258
+ A_white = 1 - white_rgb # = 0
259
+
260
+ # Weighted linear mix of absorbance
261
+ A_mix = (
262
+ red * A_red +
263
+ yellow * A_yellow +
264
+ blue * A_blue +
265
+ green * A_green +
266
+ purple * A_purple +
267
+ water * A_white
268
+ )
269
+
270
+ # Convert back to RGB: RGB = 1 - absorbance
271
+ rgb = 1 - A_mix
272
+
273
+ # Scale to 0–255 and convert to int
274
+ rgb_255 = np.clip(rgb * 255, 0, 255).astype(np.uint8)
275
+
276
+ # Return BGR tuple for OpenCV
277
+ return tuple(rgb_255[::-1])
278
+
279
+
280
+ # ---------------------------
281
+ # Camera Capture
282
+ # ---------------------------
283
+ def capture_camera_image(camera_index: int = 0):
284
+ """Capture and return a camera image in BGR"""
285
+ os_name = platform.system()
286
+ if os_name == "Windows":
287
+ # DirectShow backend (best for Windows)
288
+ cap = cv2.VideoCapture(camera_index, cv2.CAP_DSHOW)
289
+ elif os_name == "Linux":
290
+ # V4L2 backend (Linux)
291
+ cap = cv2.VideoCapture(camera_index, cv2.CAP_V4L2)
292
+ else: # macOS
293
+ # AVFoundation (default, best on mac)
294
+ cap = cv2.VideoCapture(camera_index, cv2.CAP_AVFOUNDATION)
295
+ time.sleep(0.5)
296
+ if not cap.isOpened():
297
+ raise RuntimeError(f"Cannot open camera index {camera_index}")
298
+ cap.read()
299
+ ret, frame = cap.read() # take second photo, first one might have weird autofocus issue
300
+ cap.release()
301
+ if not ret:
302
+ raise RuntimeError("Camera capture failed")
303
+ return frame
304
+
305
+
306
+ # ---------------------------
307
+ # LAB Distance Plot
308
+ # ---------------------------
309
+ def plot_lab_distance_live(rgbs: List[Tuple], target_rgb: Tuple):
310
+ """
311
+ Live-update a LAB a*b* plane plot during optimization.
312
+
313
+ Parameters
314
+ ----------
315
+ rgbs : List[Tuple[int, int, int]]
316
+ List of measured RGB colors (0–255) accumulated so far.
317
+ target_rgb : Tuple[int, int, int]
318
+ RGB color of the target (0–255).
319
+
320
+ Notes
321
+ -----
322
+ - This function clears the previous frame and redraws all points.
323
+ - Iteration numbers are displayed next to each measured color.
324
+ - Use plt.ion() before calling repeatedly in a loop for interactive updates.
325
+ - Does not save the figure; intended for live visualization only.
326
+ """
327
+ plt.clf() # clear previous frame
328
+ lab_target = rgb_to_lab(target_rgb)
329
+
330
+ # Plot target
331
+ plt.scatter(lab_target[1], lab_target[2], color=np.array(target_rgb) / 255.0, s=200, label="Target")
332
+
333
+ # Plot measured points
334
+ for i, rgb_meas in enumerate(rgbs):
335
+ lab_meas = rgb_to_lab(rgb_meas)
336
+ plt.scatter(lab_meas[1], lab_meas[2], color=np.array(rgb_meas) / 255.0, s=100)
337
+ plt.plot([lab_meas[1], lab_target[1]], [lab_meas[2], lab_target[2]], 'k--', lw=1)
338
+ plt.text(lab_meas[1], lab_meas[2], str(i + 1), fontsize=8, ha='center', va='bottom') # trial number
339
+
340
+ plt.xlabel("a*")
341
+ plt.ylabel("b*")
342
+ plt.title("Live LAB a*b* Plane")
343
+ plt.grid(True)
344
+ plt.axis('equal')
345
+ plt.pause(0.1) # short pause to update
346
+
347
+
348
+ def plot_lab_distance(rgbs: List[Tuple[int, int, int]], target_rgb: Tuple[int, int, int], save: bool = False, save_path: str = "lab_final.html") -> go.Figure:
349
+ """
350
+ Plot LAB a*b* distances of multiple RGB colors against a target color interactively using Plotly.
351
+
352
+ Parameters
353
+ ----------
354
+ rgbs : List[Tuple[int, int, int]]
355
+ List of RGB tuples (measured colors) with values in 0–255.
356
+ target_rgb : Tuple[int, int, int]
357
+ Target RGB tuple with values in 0–255.
358
+ save : bool, optional
359
+ If True, save the plot as an interactive and animated HTML file at save_path (default is False).
360
+ save_path : str, optional
361
+ File path for saving the plot HTML (default "lab_distance_plot.html").
362
+
363
+ Opens the animation in the browser after creating.
364
+ """
365
+ # Convert measured and target to Lab
366
+ labs_meas = [rgb_to_lab(rgb) for rgb in rgbs]
367
+ lab_target = rgb_to_lab(target_rgb)
368
+
369
+ # Compute axis ranges
370
+ all_a = [lab[1] for lab in labs_meas] + [lab_target[1]]
371
+ all_b = [lab[2] for lab in labs_meas] + [lab_target[2]]
372
+ padding_a = (max(all_a) - min(all_a)) * 0.1 or 1
373
+ padding_b = (max(all_b) - min(all_b)) * 0.1 or 1
374
+ x_range = [min(all_a) - padding_a, max(all_a) + padding_a]
375
+ y_range = [min(all_b) - padding_b, max(all_b) + padding_b]
376
+
377
+ frames = []
378
+ for i in range(len(rgbs)):
379
+ frame_data = [
380
+ go.Scatter(
381
+ x=[lab[1] for lab in labs_meas[:i + 1]],
382
+ y=[lab[2] for lab in labs_meas[:i + 1]],
383
+ mode='markers+text',
384
+ text=[str(j + 1) for j in range(i + 1)],
385
+ textposition='top center',
386
+ marker=dict(
387
+ size=12,
388
+ color=[f"rgb({r},{g},{b})" for r, g, b in rgbs[:i + 1]],
389
+ line=dict(width=1, color='black')
390
+ ),
391
+ hovertemplate=[
392
+ f"Point {j + 1}<br>RGB: {tuple(int(v) for v in rgbs[j])}<br>a*: {labs_meas[j][1]:.2f}<br>b*: {labs_meas[j][2]:.2f}"
393
+ for j in range(i + 1)
394
+ ],
395
+ name='Measured'
396
+ ),
397
+ go.Scatter(
398
+ x=[lab_target[1]],
399
+ y=[lab_target[2]],
400
+ mode='markers+text',
401
+ text=['Target'],
402
+ textposition='bottom center',
403
+ marker=dict(size=18, color=f"rgb({target_rgb[0]},{target_rgb[1]},{target_rgb[2]})"),
404
+ hovertemplate=f"Target<br>RGB: {target_rgb}<br>a*: {lab_target[1]:.2f}<br>b*: {lab_target[2]:.2f}",
405
+ name='Target'
406
+ ),
407
+ ]
408
+ frames.append(go.Frame(data=frame_data, name=f'frame{i}'))
409
+
410
+ # Start figure at final frame
411
+ final_frame_data = frames[-1].data
412
+ fig = go.Figure(data=final_frame_data, frames=frames)
413
+
414
+ fig.update_layout(
415
+ title='LAB a*b* Plane Animation',
416
+ xaxis_title='a*',
417
+ yaxis_title='b*',
418
+ xaxis=dict(range=x_range, scaleanchor='y', scaleratio=1),
419
+ yaxis=dict(range=y_range, scaleanchor='x', scaleratio=1),
420
+ legend=dict(x=0.8, y=1.1),
421
+ updatemenus=[{
422
+ 'type': 'buttons',
423
+ 'buttons': [
424
+ {'label': 'Play',
425
+ 'method': 'animate',
426
+ 'args': [None, {'frame': {'duration': 500, 'redraw': True}, 'fromcurrent': True}]},
427
+ {'label': 'Pause',
428
+ 'method': 'animate',
429
+ 'args': [[None], {'frame': {'duration': 0}, 'mode': 'immediate'}]}
430
+ ],
431
+ 'showactive': True,
432
+ 'x': 0.1,
433
+ 'y': -0.1,
434
+ 'xanchor': 'right',
435
+ 'yanchor': 'top'
436
+ }]
437
+ )
438
+
439
+ if save:
440
+ fig.write_html(save_path)
441
+ print(f"Saved plot to: {save_path}")
442
+
443
+ # fig.show(renderer="browser")
444
+
445
+ return fig
446
+
447
+
448
+