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.
- colour_match_sdl-0.1.0/MANIFEST.in +6 -0
- colour_match_sdl-0.1.0/PKG-INFO +67 -0
- colour_match_sdl-0.1.0/README.md +53 -0
- colour_match_sdl-0.1.0/colour_match/__init__.py +0 -0
- colour_match_sdl-0.1.0/colour_match/bayesian_optimization_colour_match.py +113 -0
- colour_match_sdl-0.1.0/colour_match/colour_utils.py +448 -0
- colour_match_sdl-0.1.0/colour_match_sdl.egg-info/PKG-INFO +67 -0
- colour_match_sdl-0.1.0/colour_match_sdl.egg-info/SOURCES.txt +51 -0
- colour_match_sdl-0.1.0/colour_match_sdl.egg-info/dependency_links.txt +1 -0
- colour_match_sdl-0.1.0/colour_match_sdl.egg-info/requires.txt +8 -0
- colour_match_sdl-0.1.0/colour_match_sdl.egg-info/top_level.txt +6 -0
- colour_match_sdl-0.1.0/colour_match_web/__init__.py +1 -0
- colour_match_sdl-0.1.0/colour_match_web/plugin.py +90 -0
- colour_match_sdl-0.1.0/colour_match_web/static/assets/capping_closed.svg +1 -0
- colour_match_sdl-0.1.0/colour_match_web/static/assets/capping_open.svg +1 -0
- colour_match_sdl-0.1.0/colour_match_web/static/assets/robot.svg +1 -0
- colour_match_sdl-0.1.0/colour_match_web/static/assets/stir_plate.svg +1 -0
- colour_match_sdl-0.1.0/colour_match_web/static/script.js +631 -0
- colour_match_sdl-0.1.0/colour_match_web/static/style.css +537 -0
- colour_match_sdl-0.1.0/colour_match_web/templates/colour_match_sdl.html +130 -0
- colour_match_sdl-0.1.0/ivoryos_basic_sdl/__init__.py +0 -0
- colour_match_sdl-0.1.0/ivoryos_basic_sdl/sdl.py +128 -0
- colour_match_sdl-0.1.0/ivorysos_colour_match_sdl/__init__.py +0 -0
- colour_match_sdl-0.1.0/ivorysos_colour_match_sdl/sdl.py +364 -0
- colour_match_sdl-0.1.0/pyproject.toml +50 -0
- colour_match_sdl-0.1.0/requirements.txt +9 -0
- colour_match_sdl-0.1.0/scripts/__init__.py +0 -0
- colour_match_sdl-0.1.0/scripts/demo_bayesian_optimization.py +55 -0
- colour_match_sdl-0.1.0/scripts/demo_bayesian_optimization_matplotlib.py +133 -0
- colour_match_sdl-0.1.0/scripts/demo_bayesian_optimization_plotly.py +128 -0
- colour_match_sdl-0.1.0/scripts/demo_data_visualization_matplotlib.py +151 -0
- colour_match_sdl-0.1.0/scripts/demo_data_visualization_plotly.py +170 -0
- colour_match_sdl-0.1.0/scripts/demo_prepare_stock_solution.py +42 -0
- colour_match_sdl-0.1.0/scripts/demo_prepare_stock_solution_complete.py +179 -0
- colour_match_sdl-0.1.0/scripts/demo_prepare_stock_solution_with_analyze.py +207 -0
- colour_match_sdl-0.1.0/scripts/instruments.py +429 -0
- colour_match_sdl-0.1.0/scripts/multiposition_vici_valve.py +106 -0
- colour_match_sdl-0.1.0/scripts/sim_instruments.py +257 -0
- colour_match_sdl-0.1.0/sdl_sim_web/__init__.py +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/plugin.py +60 -0
- colour_match_sdl-0.1.0/sdl_sim_web/server.py +40 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/balance.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/capping_closed.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/capping_open.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/liquid_addition.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/robot.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/solid_addition.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/solution_analysis_hplc.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/assets/stir_plate.svg +1 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/script.js +297 -0
- colour_match_sdl-0.1.0/sdl_sim_web/static/style.css +531 -0
- colour_match_sdl-0.1.0/sdl_sim_web/templates/web_viz.html +97 -0
- colour_match_sdl-0.1.0/setup.cfg +4 -0
|
@@ -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
|
+
|