├── LICENSE ├── Makefile ├── PaintMixing.py ├── PaintMixingGUI.py ├── Pipfile ├── Pipfile.lock ├── README.md ├── data ├── masstone.json └── mix1.json ├── images ├── 1 - figureJPG.JPG ├── 10 - diffuse spec.jpg ├── 10.5 - rough spec vs smooth spec.png ├── 11 - diffuse subsurface.jpg ├── 12 - NdotL.jpg ├── 13 - reflection eq.png ├── 14 - D65 times ( yo + magenta)..JPG ├── 16 - 1920px-Cones_SMJ2_E.svg.png ├── 17 - excitation.jpg ├── 18 - light times refl times x..JPG ├── 18 - light times refl times y..JPG ├── 18 - light times refl times z.JPG ├── 19 - color matching.jpg ├── 2 - spectrum.JPG ├── 20 - CIE1931_RGBCMF2.png ├── 21 - 2560px-CIE_1931_XYZ_Color_Matching_Functions.svg.png ├── 22 - 1931 vs 1964 xyz.jpg ├── 23 - YxyJPG.JPG ├── 24 - sRGB gamut.JPG ├── 25 - absorbtion and scattering.jpg ├── 26 - two diffuse fluxes.jpg ├── 27 - k sum.png ├── 27 - two flux reflectance.png ├── 28 - s sum.png ├── 29 - k from s.png ├── 3 - spectrum red.JPG ├── 30 - k and s from mix.png ├── 31 - spectophotometer.jpg ├── 32 - PXL_20240128_204751800.jpg ├── 33 - green.JPG ├── 33 - red.JPG ├── 34 - blue and yelllow.JPG ├── 35 - single recipe.JPG ├── 36 - whole too.JPG ├── 37 - base paints.JPG ├── 38 - used paints.JPG ├── 39 - reflectance.JPG ├── 4 - spectrum blue.JPG ├── 40 - xy.JPG ├── 41 - recipe list.JPG ├── 5 - 2856K.JPG ├── 6 - fluorescent.JPG ├── 7 - 5000K.JPG ├── 8 - fresnel.jpg ├── 9 - complex interactions.jpg ├── D65.JPG ├── Y sensitivity.JPG ├── mix_1.JPG ├── mix_2.JPG ├── mix_3.JPG └── mix_4.JPG ├── pyproject.toml └── setup.cfg /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 miciwan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash -eo pipefail 2 | 3 | .PHONY: help 4 | help: ## Display this help 5 | @sed \ 6 | -e '/^[a-zA-Z0-9_\-]*:.*##/!d' \ 7 | -e 's/:.*##\s*/:/' \ 8 | -e 's/^\(.\+\):\(.*\)/$(shell tput setaf 6)\1$(shell tput sgr0):\2/' \ 9 | $(MAKEFILE_LIST) | column -c2 -t -s : 10 | 11 | .PHONY: targets 12 | targets: help ## Display this help 13 | 14 | .PHONY: list 15 | list: help ## Display this help 16 | 17 | .PHONY: pipenv lint deadcode typing format-check test coverage clean 18 | pipenv: ## installs all python packages 19 | pipenv install --dev 20 | lint: ## performs linting for python tooling 21 | pipenv run flake8 . 22 | deadcode: ## checks for unused code in python tooling 23 | pipenv run vulture . 24 | typing: ## runs static type checking on python tooling 25 | pipenv run mypy --install-types --non-interactive . 26 | pipenv run mypy . 27 | format-check: ## checks formatting on python tooling 28 | pipenv run black --check --diff . 29 | test: ## runs pytest 30 | pipenv run coverage run -m pytest -v . 31 | coverage: test ## generates markdown report of pytest coverage 32 | pipenv run coverage report -m --format markdown 33 | clean: 34 | rm *.tgz 35 | all: lint deadcode typing format-check coverage ## applies all python checks 36 | -------------------------------------------------------------------------------- /PaintMixing.py: -------------------------------------------------------------------------------- 1 | import json 2 | import numpy as np 3 | import scipy 4 | from itertools import combinations 5 | from scipy.interpolate import interp1d 6 | 7 | 8 | class Spectrum: 9 | def __init__( self, wavelengths = [], values = [ ], value_scale = 1.0 ): 10 | self.wavelengths = np.array( wavelengths ) 11 | self.values = np.array( values ) * value_scale 12 | 13 | def __add__( self, rhs ): 14 | if type( rhs ) is Spectrum: 15 | combined_wavelengths = np.union1d(self.wavelengths, rhs.wavelengths) 16 | 17 | # Interpolate values of both spectra onto the combined wavelength array 18 | interp_lhs = interp1d(self.wavelengths if len( self.wavelengths ) > 0 else [ 0.0 ], self.values if len( self.wavelengths ) > 0 else [ 0.0 ], bounds_error=False, fill_value=0) 19 | interp_rhs = interp1d(rhs.wavelengths if len(rhs.wavelengths ) > 0 else [ 0.0 ], rhs.values if len(rhs.wavelengths ) > 0 else [ 0.0 ], bounds_error=False, fill_value=0) 20 | 21 | # Interpolated values 22 | lhs_interpolated_values = interp_lhs(combined_wavelengths) 23 | rhs_interpolated_values = interp_rhs(combined_wavelengths) 24 | 25 | # add the interpolated values 26 | result_values = lhs_interpolated_values + rhs_interpolated_values 27 | 28 | return Spectrum( combined_wavelengths, result_values ) 29 | else: 30 | return self 31 | 32 | def __mul__( self, rhs ): 33 | if type( rhs ) is Spectrum: 34 | combined_wavelengths = np.union1d(self.wavelengths, rhs.wavelengths) 35 | 36 | # Interpolate values of both spectra onto the combined wavelength array 37 | interp_lhs = interp1d(self.wavelengths if len( self.wavelengths ) > 0 else [ 0.0 ], self.values if len( self.wavelengths ) > 0 else [ 0.0 ], bounds_error=False, fill_value=0) 38 | interp_rhs = interp1d(rhs.wavelengths if len(rhs.wavelengths ) > 0 else [ 0.0 ], rhs.values if len(rhs.wavelengths ) > 0 else [ 0.0 ], bounds_error=False, fill_value=0) 39 | 40 | # Interpolated values 41 | lhs_interpolated_values = interp_lhs(combined_wavelengths) 42 | rhs_interpolated_values = interp_rhs(combined_wavelengths) 43 | 44 | # Multiply the interpolated values 45 | result_values = lhs_interpolated_values * rhs_interpolated_values 46 | 47 | return Spectrum( combined_wavelengths, result_values ) 48 | elif type( rhs ) is float: 49 | return Spectrum( self.wavelengths, self.values * rhs ) 50 | else: 51 | return self 52 | 53 | def sample( self, wavelength ): 54 | return np.interp( wavelength, self.wavelengths, self.values ) 55 | 56 | def resample( self, wavelengths ): 57 | interp = interp1d( self.wavelengths, self.values, bounds_error=False, fill_value=0 ) 58 | resampled_values = interp( wavelengths ) 59 | return Spectrum( wavelengths, resampled_values ) 60 | 61 | def integrate( self, range_min = 380, range_max = 730 ): 62 | valid_indices = np.where((self.wavelengths >= range_min) & (self.wavelengths <= range_max )) 63 | filtered_wavelengths = self.wavelengths[valid_indices] 64 | filtered_values = self.values[valid_indices] 65 | 66 | # Use numpy's trapezoidal rule integration function 67 | integrated_value = np.trapz(filtered_values, filtered_wavelengths) 68 | 69 | return integrated_value 70 | 71 | 72 | class Colorimetry: 73 | predefined_spectra = { 74 | "X" : Spectrum( [ 380.0,385.0,390.0,395.0,400.0,405.0,410.0,415.0,420.0,425.0,430.0,435.0,440.0,445.0, 75 | 450.0,455.0,460.0,465.0,470.0,475.0,480.0,485.0,490.0,495.0,500.0,505.0,510.0,515.0, 76 | 520.0,525.0,530.0,535.0,540.0,545.0,550.0,555.0,560.0,565.0,570.0,575.0,580.0,585.0, 77 | 590.0,595.0,600.0,605.0,610.0,615.0,620.0,625.0,630.0,635.0,640.0,645.0,650.0,655.0, 78 | 660.0,665.0,670.0,675.0,680.0,685.0,690.0,695.0,700.0,705.0,710.0,715.0,720.0,725.0,730.0 ], 79 | [ 0.001368,0.002236,0.004243,0.007650,0.014310,0.023190,0.043510,0.077630,0.134380,0.214770,0.283900,0.328500,0.348280,0.348060, 80 | 0.336200,0.318700,0.290800,0.251100,0.195360,0.142100,0.095640,0.057950,0.032010,0.014700,0.004900,0.002400,0.009300,0.029100, 81 | 0.063270,0.109600,0.165500,0.225750,0.290400,0.359700,0.433450,0.512050,0.594500,0.678400,0.762100,0.842500,0.916300,0.978600, 82 | 1.026300,1.056700,1.062200,1.045600,1.002600,0.938400,0.854450,0.751400, 0.642400,0.541900,0.447900,0.360800,0.283500,0.218700, 83 | 0.164900,0.121200,0.087400,0.063600,0.046770,0.032900,0.022700,0.015840,0.011359,0.008111,0.005790,0.004109,0.002899,0.002049,0.001440 ] ), 84 | 85 | "Y" : Spectrum( [ 380.0,385.0,390.0,395.0,400.0,405.0,410.0,415.0,420.0,425.0,430.0,435.0,440.0,445.0, 86 | 450.0,455.0,460.0,465.0,470.0,475.0,480.0,485.0,490.0,495.0,500.0,505.0,510.0,515.0, 87 | 520.0,525.0,530.0,535.0,540.0,545.0,550.0,555.0,560.0,565.0,570.0,575.0,580.0,585.0, 88 | 590.0,595.0,600.0,605.0,610.0,615.0,620.0,625.0,630.0,635.0,640.0,645.0,650.0,655.0, 89 | 660.0,665.0,670.0,675.0,680.0,685.0,690.0,695.0,700.0,705.0,710.0,715.0,720.0,725.0,730.0 ], 90 | [ 0.000039,0.000064,0.000120,0.000217,0.000396,0.000640,0.001210,0.002180,0.004000,0.007300,0.011600,0.016840,0.023000,0.029800, 91 | 0.038000,0.048000,0.060000,0.073900,0.090980,0.112600,0.139020,0.169300,0.208020,0.258600,0.323000,0.407300,0.503000,0.608200, 92 | 0.710000,0.793200,0.862000,0.914850,0.954000,0.980300,0.994950,1.000000,0.995000,0.978600,0.952000,0.915400,0.870000,0.816300, 93 | 0.757000,0.694900,0.631000,0.566800,0.503000,0.441200,0.381000,0.321000,0.265000,0.217000,0.175000,0.138200,0.107000,0.081600, 94 | 0.061000,0.044580,0.032000,0.023200,0.017000,0.011920,0.008210,0.005723,0.004102,0.002929,0.002091,0.001484,0.001047,0.000740,0.000520 ] ), 95 | 96 | "Z" : Spectrum( [ 380.0,385.0,390.0,395.0,400.0,405.0,410.0,415.0,420.0,425.0,430.0,435.0,440.0,445.0, 97 | 450.0,455.0,460.0,465.0,470.0,475.0,480.0,485.0,490.0,495.0,500.0,505.0,510.0,515.0, 98 | 520.0,525.0,530.0,535.0,540.0,545.0,550.0,555.0,560.0,565.0,570.0,575.0,580.0,585.0, 99 | 590.0,595.0,600.0,605.0,610.0,615.0,620.0,625.0,630.0,635.0,640.0,645.0,650.0,655.0, 100 | 660.0,665.0,670.0,675.0,680.0,685.0,690.0,695.0,700.0,705.0,710.0,715.0,720.0,725.0,730.0 ], 101 | [ 0.006450,0.010550,0.020050,0.036210,0.067850,0.110200,0.207400,0.371300,0.645600,1.039050,1.385600,1.622960, 102 | 1.747060,1.782600,1.772110,1.744100,1.669200,1.528100,1.287640,1.041900,0.812950,0.616200,0.465180,0.353300, 103 | 0.272000,0.212300,0.158200,0.111700,0.078250,0.057250,0.042160,0.029840,0.020300,0.013400,0.008750,0.005750, 104 | 0.003900,0.002750,0.002100,0.001800,0.001650,0.001400,0.001100,0.001000,0.000800,0.000600,0.000340,0.000240, 105 | 0.000190,0.000100,0.000050,0.000030,0.000020,0.000010,0.000000,0.000000,0.000000,0.000000,0.000000,0.000000, 106 | 0.000000,0.000000,0.000000,0.000000,0.000000,0.000000,0.000000,0.000000,0.000000,0.000000,0.000000 ] ), 107 | 108 | "D65" : Spectrum( [ 380.0,385.0,390.0,395.0,400.0,405.0,410.0,415.0,420.0,425.0,430.0,435.0,440.0,445.0, 109 | 450.0,455.0,460.0,465.0,470.0,475.0,480.0,485.0,490.0,495.0,500.0,505.0,510.0,515.0, 110 | 520.0,525.0,530.0,535.0,540.0,545.0,550.0,555.0,560.0,565.0,570.0,575.0,580.0,585.0, 111 | 590.0,595.0,600.0,605.0,610.0,615.0,620.0,625.0,630.0,635.0,640.0,645.0,650.0,655.0, 112 | 660.0,665.0,670.0,675.0,680.0,685.0,690.0,695.0,700.0,705.0,710.0,715.0,720.0,725.0,730.0 ], 113 | [ 49.9755,52.3118,54.6482,68.7015,82.7549,87.1204,91.486,92.4589,93.4318,90.057,86.6823,95.7736,104.865,110.936,117.008, 114 | 117.41,117.812,116.336,114.861,115.392,115.923,112.367,108.811,109.082,109.354,108.578,107.802,106.296,104.79,106.239, 115 | 107.689,106.047,104.405,104.225,104.046,102.023,100.0,98.1671,96.3342,96.0611,95.788,92.2368,88.6856,89.3459,90.0062, 116 | 89.8026,89.5991,88.6489,87.6987,85.4936,83.2886,83.4939,83.6992,81.863,80.0268,80.1207,80.2146,81.2462,82.2778,80.281, 117 | 78.2842,74.0027,69.7213,70.6652,71.6091,72.979,74.349,67.9765,61.604,65.7448,69.8856 ] ) 118 | } 119 | 120 | def gamma( x ): 121 | return np.where( x <= 0.0031308, 323.0 / 25.0 * x, ( 211.0 * ( x ** ( 5.0 / 12.0 ) ) - 11.0 ) / 200.0) 122 | 123 | def degamma( x ): 124 | return np.where( x <= 0.04045, 25.0 / 323.0 * x, ( ( 200.0 * x + 11.0 ) / 211.0 ) ** ( 12.0 / 5.0 ) ) 125 | 126 | # d65 reference by default 127 | def xyz_to_Lab( x, y, z, ref_x = 95.047, ref_y = 100.0, ref_z = 108.883 ): 128 | var_x = x / ref_x 129 | var_y = y / ref_y 130 | var_z = z / ref_z 131 | 132 | var_x = np.where( var_x > 0.008856, var_x ** ( 1.0 / 3.0 ), ( 7.787 * var_x ) + ( 16 / 116 ) ) 133 | var_y = np.where( var_y > 0.008856, var_y ** ( 1.0 / 3.0 ), ( 7.787 * var_y ) + ( 16 / 116 ) ) 134 | var_z = np.where( var_z > 0.008856, var_z ** ( 1.0 / 3.0 ), ( 7.787 * var_z ) + ( 16 / 116 ) ) 135 | 136 | return ( 116 * var_y ) - 16, 500 * ( var_x - var_y ), 200 * ( var_y - var_z ) 137 | 138 | def xyz_to_rgb( x, y, z ): 139 | r = 3.2406 * x + -1.5372 * y + -0.4986 * z 140 | g = -0.9689 * x + 1.8758 * y + 0.0415 * z 141 | b = 0.0557 * x + -0.2040 * y + 1.0570 * z 142 | 143 | return r, g, b 144 | 145 | def rgb_to_xyz( r, g, b ): 146 | x = 0.4124 * r + 0.3576 * g + 0.1805 * b 147 | y = 0.2126 * r + 0.7152 * g + 0.0722 * b 148 | z = 0.0193 * r + 0.1192 * g + 0.9505 * b 149 | 150 | return x, y, z 151 | 152 | def rgb_int_to_float( r, g, b ): 153 | return Colorimetry.degamma( r / 255.0 ), Colorimetry.degamma( g / 255.0 ), Colorimetry.degamma( b / 255.0 ) 154 | 155 | def reflectance_to_xyz( reflectance ): 156 | scale = ( Colorimetry.predefined_spectra["Y"] * Colorimetry.predefined_spectra["D65"] ).integrate() 157 | 158 | lit = reflectance * Colorimetry.predefined_spectra["D65"] 159 | x = ( lit * Colorimetry.predefined_spectra["X"] ).integrate() / scale 160 | y = ( lit * Colorimetry.predefined_spectra["Y"] ).integrate() / scale 161 | z = ( lit * Colorimetry.predefined_spectra["Z"] ).integrate() / scale 162 | 163 | return x, y, z 164 | 165 | def reflectance_to_rgb( reflectance ): 166 | def saturate( x ): 167 | return 1 if x > 1 else 0 if x < 0 else x 168 | 169 | x, y, z = Colorimetry.reflectance_to_xyz( reflectance ) 170 | r, g, b = Colorimetry.xyz_to_rgb( x, y, z ) 171 | return saturate( Colorimetry.gamma(r) ), saturate( Colorimetry.gamma(g) ), saturate( Colorimetry.gamma(b) ) 172 | 173 | 174 | class TwoDiffuseFluxesModel: 175 | def K_S_ratio_from_reflectance( reflectance ): 176 | return Spectrum( reflectance.wavelengths, ( 1 - reflectance.values ) * ( 1 - reflectance.values ) / ( 4.0 * reflectance.values ) ) 177 | 178 | def K_from_S( reflectance, S ): 179 | return TwoDiffuseFluxesModel.K_S_ratio_from_reflectance( reflectance ) * S 180 | 181 | def __init__( self ): 182 | self.paint_parameters = {} 183 | 184 | def init_paints( self, measurements, white_name ): 185 | # todo: 186 | # add cache instead of computing that all the time on startup 187 | self.compute_K_S( measurements, white_name ) 188 | 189 | def compute_K_S( self, measurments, white_name ): 190 | self.paint_parameters = {} 191 | 192 | # pre-set S term for white to 1.0, derive K from that 193 | white_sample = measurments[white_name] 194 | self.paint_parameters[white_name] = {} 195 | self.paint_parameters[white_name]["S"] = Spectrum( white_sample["reflectance"].wavelengths, np.ones_like( white_sample["reflectance"].values ) ) 196 | self.paint_parameters[white_name]["K"] = TwoDiffuseFluxesModel.K_from_S( white_sample["reflectance"], self.paint_parameters[white_name]["S"] ) 197 | 198 | # todo: 199 | # generate graph of dependencies, so they can be computed in proper order 200 | # atm, we only have mixes with white, so whatever 201 | masstone_mixes = {} 202 | 203 | for sample_name in measurments: 204 | if measurments[sample_name]["type"] == "masstone": 205 | masstone_mixes.setdefault( sample_name, [] ).append( sample_name ) 206 | elif measurments[sample_name]["type"] == "mix": 207 | for component in measurments[sample_name]["components"]: 208 | masstone_mixes.setdefault( component, [] ).append( sample_name ) 209 | 210 | for masstone in masstone_mixes: 211 | sample = measurments[masstone] 212 | 213 | sample_name = sample["name"] 214 | 215 | if sample_name in self.paint_parameters.keys(): 216 | continue 217 | 218 | combined_wavelengths = np.array( [] ) 219 | 220 | mixes = masstone_mixes[masstone] 221 | for mix in mixes: 222 | if measurments[mix]["type"] == "masstone": 223 | combined_wavelengths = np.union1d( combined_wavelengths, measurments[mix]["reflectance"].wavelengths ) 224 | elif measurments[mix]["type"] == "mix": 225 | for component in measurments[mix]["components"]: 226 | combined_wavelengths = np.union1d( combined_wavelengths, measurments[component]["reflectance"].wavelengths ) 227 | else: 228 | pass 229 | 230 | A = np.zeros( ( len( combined_wavelengths ), len( masstone_mixes[masstone] ), 2) ) 231 | b = np.zeros( ( len( combined_wavelengths ), len( masstone_mixes[masstone] ) ) ) 232 | 233 | mixes = masstone_mixes[masstone] 234 | for i, mix in enumerate( mixes ): 235 | if measurments[mix]["type"] == "masstone": 236 | resampled_reflectance = measurments[mix]["reflectance"].resample( combined_wavelengths ).values 237 | 238 | A[:,i,0] = 4.0 * resampled_reflectance 239 | A[:,i,1] = - ( 1.0 - resampled_reflectance ) * ( 1.0 - resampled_reflectance ) 240 | b[:, i] = 0 241 | elif measurments[mix]["type"] == "mix": 242 | 243 | total_weight = 0 244 | for component in measurments[mix]["components"]: 245 | total_weight = total_weight + measurments[mix]["components"][component] 246 | 247 | for component in measurments[mix]["components"]: 248 | component_weight = measurments[mix]["components"][component] 249 | component_percentage = component_weight / total_weight 250 | 251 | resampled_reflectance = measurments[mix]["reflectance"].resample( combined_wavelengths ).values 252 | 253 | if component == sample_name: 254 | A[:,i,0] = 4.0 * resampled_reflectance * component_percentage 255 | A[:,i,1] = - ( 1.0 - resampled_reflectance ) * ( 1.0 - resampled_reflectance ) * component_percentage 256 | else: 257 | resampled_K = self.paint_parameters[component]["K"].resample( combined_wavelengths ).values 258 | resampled_S = self.paint_parameters[component]["S"].resample( combined_wavelengths ).values 259 | 260 | b[:,i] = b[:,i] - 4.0 * resampled_reflectance * resampled_K * component_percentage 261 | b[:,i] = b[:,i] + ( 1.0 - resampled_reflectance ) * ( 1.0 - resampled_reflectance ) * resampled_S * component_percentage 262 | else: 263 | pass 264 | 265 | Ainv = np.linalg.pinv( A ) 266 | x = np.einsum( "bij,bj->bi", Ainv, b ) 267 | 268 | self.paint_parameters[sample_name] = {} 269 | self.paint_parameters[sample_name]["K"] = Spectrum( combined_wavelengths, x[:,0] ) 270 | self.paint_parameters[sample_name]["S"] = Spectrum( combined_wavelengths, x[:,1] ) 271 | 272 | for masstone in masstone_mixes: 273 | mixes = masstone_mixes[masstone] 274 | 275 | for i, mix in enumerate( mixes ): 276 | if measurments[mix]["type"] == "masstone": 277 | mixed_R = self.mix( [ ( measurments[mix], 1.0 ) ] ) 278 | 279 | elif measurments[mix]["type"] == "mix": 280 | 281 | mix_elements = [] 282 | 283 | for component in measurments[mix]["components"]: 284 | mix_elements.append( ( measurments[component], measurments[mix]["components"][component] ) ) 285 | 286 | mixed_R = self.mix( mix_elements ) 287 | 288 | combined_wavelengths = np.union1d( mixed_R.wavelengths, measurments[mix]["reflectance"].wavelengths ) 289 | 290 | mixed_R_resampled = mixed_R.resample( combined_wavelengths ).values 291 | reflectance_resampled = measurments[mix]["reflectance"].resample( combined_wavelengths ).values 292 | 293 | diff = mixed_R_resampled - reflectance_resampled 294 | 295 | assert diff.sum() < 0.001 # there should only be some numerical differences, given we only have a masstone and a single mix 296 | 297 | def mix( self, components ): 298 | combined_wavelengths = np.array( [] ) 299 | 300 | total_weight = 0 301 | for component, weight in components: 302 | total_weight = total_weight + weight 303 | combined_wavelengths = np.union1d( combined_wavelengths, self.paint_parameters[component["name"]]["K"].wavelengths ) 304 | combined_wavelengths = np.union1d( combined_wavelengths, self.paint_parameters[component["name"]]["S"].wavelengths ) 305 | 306 | mixed_K = np.zeros_like( combined_wavelengths ) 307 | mixed_S = np.zeros_like( combined_wavelengths ) 308 | 309 | if total_weight == 0: 310 | total_weight = 1.0 311 | 312 | for component, weight in components: 313 | component_weight = weight / total_weight 314 | resampled_K = self.paint_parameters[component["name"]]["K"].resample( combined_wavelengths ).values 315 | resampled_S = self.paint_parameters[component["name"]]["S"].resample( combined_wavelengths ).values 316 | 317 | mixed_K = mixed_K + resampled_K * component_weight 318 | mixed_S = mixed_S + resampled_S * component_weight 319 | 320 | omega = mixed_S / ( mixed_K + mixed_S ) 321 | mixed_R = omega / ( 2.0 - omega + 2.0 * np.sqrt( 1.0 - omega ) ) 322 | 323 | return Spectrum( combined_wavelengths, mixed_R ) 324 | 325 | class RecipeOptimizer: 326 | def __init__( self, base_paints, target_rgb, pigment_model ): 327 | self.base_paints = base_paints 328 | self.target_rgb = target_rgb 329 | self.target_lab = np.array( Colorimetry.xyz_to_Lab( *Colorimetry.rgb_to_xyz( *Colorimetry.rgb_int_to_float( *( target_rgb * 255 ) ) ) ) ) 330 | self.pigment_model = pigment_model 331 | 332 | def mix_current_set( self, paint_set, weights ): 333 | components = [ ( pigment, amount ) for pigment, amount in zip( [self.base_paints[paint] for paint in paint_set], weights ) ] 334 | mixed_paint = self.pigment_model.mix( components ) 335 | return mixed_paint 336 | 337 | def __call__( self, paint_set ): 338 | def func( weights ): 339 | mixed_paint = self.mix_current_set( paint_set, weights ) 340 | 341 | # in sRGB 342 | mixed_rgb = np.array( Colorimetry.reflectance_to_rgb( mixed_paint ) ) 343 | diff = mixed_rgb - self.target_rgb 344 | 345 | # in Lab 346 | #mixed_lab = np.array( Colorimetry.xyz_to_Lab( *Colorimetry.reflectance_to_xyz( mixed_paint ) ) ) 347 | #diff = mixed_lab - self.target_lab 348 | 349 | return np.dot( diff, diff ) 350 | 351 | #optimized_weights = scipy.optimize.minimize( func, np.array( [ 0.5 ] * len( paint_set ) ), method = "Nelder-Mead", bounds = [(0.001, 1)] ) 352 | optimized_weights = scipy.optimize.minimize( func, np.array( [ 0.5 ] * len( paint_set ) ), bounds = [(0.001, 1)] ) 353 | mixed_rgb = np.array( Colorimetry.reflectance_to_rgb( self.mix_current_set( paint_set, optimized_weights["x"] ) ) ) 354 | diff = np.dot( mixed_rgb - self.target_rgb, mixed_rgb - self.target_rgb ) 355 | 356 | return mixed_rgb, diff, paint_set, optimized_weights["x"] 357 | 358 | 359 | 360 | def load_measurments( file_path ): 361 | data = {} 362 | 363 | with open(file_path, 'r') as json_file: 364 | datasets = json.load(json_file) 365 | 366 | wavelengths = [] 367 | 368 | for dataset in datasets: 369 | if dataset["type"] == "wavelengths": 370 | wavelengths = np.array( dataset["values"] ) 371 | break 372 | 373 | if len( wavelengths ) == 0: 374 | return {} 375 | 376 | for dataset in datasets: 377 | if dataset["type"] != "wavelengths": 378 | dataset["reflectance"] = Spectrum( wavelengths, dataset["reflectance"], 1.0 / 100.0 ) 379 | data.setdefault( dataset["name"], [] ).append( dataset ) 380 | 381 | data = average_measurements( data ) 382 | 383 | return data 384 | 385 | 386 | def average_measurements( samples ): 387 | averaged_samples = {} 388 | 389 | for sample in samples: 390 | averaged_spectrum = Spectrum() 391 | 392 | if type( samples[sample] ) is list: 393 | for measurement in samples[sample]: 394 | averaged_spectrum = averaged_spectrum + measurement["reflectance"] 395 | 396 | averaged_spectrum = averaged_spectrum * ( 1.0 / len( samples[sample] ) ) 397 | 398 | new_sample = dict( samples[sample][0] ) 399 | new_sample["reflectance"] = averaged_spectrum 400 | else: 401 | new_sample = dict( samples[sample] ) 402 | 403 | averaged_samples[new_sample["name"]] = new_sample 404 | 405 | return averaged_samples 406 | 407 | 408 | 409 | class PaintDatabase: 410 | def __init__( self, measurement_files ): 411 | self.colorimetry = Colorimetry() 412 | 413 | self.measurments = {} 414 | for file_path in measurement_files: 415 | self.measurments = { **self.measurments, **load_measurments( file_path ) } 416 | 417 | self.mixing_model = TwoDiffuseFluxesModel() 418 | self.mixing_model.init_paints( self.measurments, "white" ) 419 | 420 | self.masstones = [ k for k in self.measurments.keys() if self.measurments[k]["type"] == "masstone" ] 421 | 422 | 423 | def get_base_paints( self ): 424 | return self.masstones 425 | 426 | def get_paint( self, name ): 427 | return self.measurments[name] 428 | 429 | def get_all_paints( self ): 430 | return self.measurments 431 | 432 | def get_mixing_model( self ): 433 | return self.mixing_model 434 | 435 | -------------------------------------------------------------------------------- /PaintMixingGUI.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | import math 4 | import os 5 | import numpy as np 6 | import heapq 7 | from urllib.parse import urlparse 8 | from itertools import combinations 9 | import PaintMixing 10 | import matplotlib.path 11 | import multiprocessing 12 | from multiprocessing import Pool 13 | from PyQt5.QtWidgets import (QApplication, QMainWindow, QListWidget, QPushButton, QGroupBox,QSizePolicy, QVBoxLayout, QHBoxLayout, QFrame, QWidget, QSlider, QSplitter, QColorDialog, QLabel, QListWidgetItem, QCheckBox) 14 | from PyQt5.QtCore import Qt, QSize, QMimeData, QPoint, QObject, QThread, pyqtSignal, QVariant 15 | from PyQt5.QtGui import QColor, QPalette, QDrag, QPainter, QPen, QImage, QPixmap 16 | 17 | PAINT_AMOUNT_SLIDER_SCALE = 10000 18 | MAX_NUM_PAINTS_IN_RECIPE = 4 19 | 20 | 21 | def get_text_color( color ): 22 | luminance = ( (0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue()) / 255 ) * 2.0 - 1.0 23 | luminance = ( math.pow( luminance, 1/8 ) if luminance > 0 else -math.pow( -luminance, 1/8 ) ) * 0.5 + 0.5 24 | return QColor.fromRgbF(1.0 - luminance, 1.0 - luminance, 1.0 - luminance) 25 | 26 | 27 | def get_color_desc( color ): 28 | r_int, g_int, b_int = color.red(), color.green(), color.blue() 29 | r, g, b = PaintMixing.Colorimetry.rgb_int_to_float( r_int, g_int, b_int ) 30 | x, y, z = PaintMixing.Colorimetry.rgb_to_xyz( r, g, b ) 31 | L, a, b = PaintMixing.Colorimetry.xyz_to_Lab( x, y, z ) 32 | 33 | return "RGB ({}, {}, {})\nXYZ ({:.3f}, {:.3f}, {:.3f})".format( r_int, g_int, b_int, x, y, z ); 34 | 35 | 36 | class ShowColorWidget(QWidget): 37 | def __init__( self, color, parent=None ): 38 | super(ShowColorWidget, self).__init__( parent ) 39 | 40 | self.init_ui() 41 | self.update_color( color) 42 | 43 | def init_ui( self ): 44 | self.layout = QHBoxLayout(self) 45 | self.layout.setContentsMargins(2, 2, 2, 2) 46 | self.layout.setSpacing(0) 47 | 48 | self.label = QLabel("") 49 | self.label.setAlignment(Qt.AlignHCenter | Qt.AlignVCenter) 50 | self.layout.addWidget(self.label) 51 | 52 | self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 53 | 54 | self.setMinimumHeight( 80 ) 55 | self.setMaximumHeight( 80 ) 56 | 57 | def update_color(self, new_color): 58 | self.setStyleSheet(""" 59 | QWidget { 60 | background-color: """ + new_color.name() + """; 61 | border-radius: 10px; 62 | }""" ) 63 | 64 | text_color = get_text_color( new_color ) 65 | 66 | self.color = new_color 67 | 68 | self.label.setText( get_color_desc( new_color ) ) 69 | 70 | # Update styles 71 | self.label.setStyleSheet(f"color: {text_color.name()}; padding: 5px;") 72 | 73 | 74 | class SpectraPlotWidget(QWidget): 75 | def __init__(self, parent=None): 76 | super(SpectraPlotWidget, self).__init__(parent) 77 | 78 | self.margins = (50, 20, 20, 80) 79 | 80 | self.setMinimumHeight( 400 ) 81 | self.setMinimumWidth( 400 ) 82 | 83 | self.data = {} 84 | self.range = ( ( 380, 730 ), ( 0, 1) ) 85 | 86 | self.setAcceptDrops(True) # Accept drops if specified 87 | 88 | self.prep_spectral_gradient() 89 | 90 | def prep_spectral_gradient( self ): 91 | wavelengths = np.linspace( self.range[0][0], self.range[0][1], 32 ) 92 | 93 | x = PaintMixing.Colorimetry.predefined_spectra["X"].sample( wavelengths ) 94 | y = PaintMixing.Colorimetry.predefined_spectra["Y"].sample( wavelengths ) 95 | z = PaintMixing.Colorimetry.predefined_spectra["Z"].sample( wavelengths ) 96 | 97 | r, g, b = PaintMixing.Colorimetry.xyz_to_rgb( x, y, z ) 98 | r, g, b = np.clip( PaintMixing.Colorimetry.gamma( r ), 0, 1 ), np.clip( PaintMixing.Colorimetry.gamma( g ), 0, 1 ), np.clip( PaintMixing.Colorimetry.gamma( b ), 0, 1 ) 99 | 100 | gradient = ( np.stack( [ r, g, b ], -1 ) * 255 ).astype(np.uint8) 101 | 102 | self.spectral_gradient = QPixmap( QImage(gradient, 32, 1, 32 * 3, QImage.Format_RGB888) ) 103 | 104 | def dragEnterEvent(self, event): 105 | # accept json files 106 | if event.mimeData().hasFormat("FileName"): 107 | fileUrl = event.mimeData().text() 108 | p = urlparse( fileUrl ) 109 | if os.path.splitext(p.path)[1] == ".json": 110 | event.acceptProposedAction() 111 | 112 | def dropEvent(self, event): 113 | try: 114 | fileUrl = event.mimeData().text() 115 | p = urlparse( fileUrl ) 116 | if os.path.splitext(p.path)[1] == ".json": 117 | with open(p.path[1:], 'r') as json_file: 118 | data = json.load(json_file) 119 | for i, spectrum in enumerate( data ): 120 | self.add_data( spectrum["name"], PaintMixing.Spectrum( spectrum["wavelengths"], np.array(spectrum["values"]) / 100.0 ), QColor.fromRgbF(1.0, 1.0, 1.0 ) ) 121 | except: 122 | pass 123 | 124 | 125 | def add_data( self, name, spectrum, color): 126 | self.data[name] = { "data" : spectrum, 127 | "color" : color} 128 | 129 | self.update() 130 | 131 | 132 | def remove_data( self, name): 133 | if name in self.data: 134 | del self.data[name] 135 | self.update() 136 | 137 | def to_plot_coords( self, x, y ): 138 | def saturate( x ): 139 | return 0 if x < 0 else 1 if x > 1 else x 140 | 141 | plot_width = self.width() - self.margins[0] - self.margins[2] 142 | plot_height = self.height() - self.margins[1] - self.margins[3] 143 | 144 | x_local = saturate( ( x - self.range[0][0] ) / ( self.range[0][1] - self.range[0][0]) ) 145 | y_local = saturate( ( y - self.range[1][0] ) / ( self.range[1][1] - self.range[1][0]) ) 146 | 147 | return int( x_local * plot_width + self.margins[0] ), int( ( 1.0 - y_local ) * plot_height + self.margins[1] ) 148 | 149 | 150 | def paintEvent(self, event): 151 | painter = QPainter(self) 152 | painter.setRenderHint(QPainter.Antialiasing) 153 | painter.setRenderHint(QPainter.SmoothPixmapTransform) 154 | 155 | painter.setPen( QPen( QColor.fromRgbF(1.0, 1.0, 1.0, 0.3), 0.3, Qt.SolidLine ) ) 156 | for wavelength in range( self.range[0][0], self.range[0][1] + 1, 50): 157 | painter.drawLine( *self.to_plot_coords( wavelength, 0 ), *self.to_plot_coords( wavelength, 1 )) 158 | 159 | position_x, position_y = self.to_plot_coords( wavelength, 0 ) 160 | painter.drawText( QPoint( position_x - 10, position_y + 12 ), "{}".format( wavelength ) ) 161 | 162 | for reflectanceInt in range( 6 ): 163 | reflectance = reflectanceInt / 5 164 | painter.drawLine( *self.to_plot_coords( self.range[0][0], reflectance ), *self.to_plot_coords( self.range[0][1], reflectance )) 165 | 166 | position_x, position_y = self.to_plot_coords( self.range[0][0], reflectance ) 167 | painter.drawText( QPoint( position_x - 20, position_y + ( 0 if reflectanceInt == 0 else 10 if reflectanceInt == 5 else 5 ) ), "{:.1f}".format( reflectance ) ) 168 | 169 | for name in self.data: 170 | spectrum = self.data[name]["data"] 171 | 172 | x, y = self.to_plot_coords( spectrum.wavelengths[-1], spectrum.values[-1] ) 173 | painter.setPen( QPen( QColor.fromRgbF(1.0, 1.0, 1.0, 1.0), 0.3, Qt.SolidLine ) ) 174 | painter.drawText( QPoint( x - 30, y - 15 ), name ) 175 | 176 | pen = QPen( self.data[name]["color"], 2, Qt.SolidLine ) 177 | painter.setPen(pen) 178 | 179 | for i in range( 1, len( spectrum.wavelengths ) ): 180 | x1, y1 = self.to_plot_coords( spectrum.wavelengths[i-1], spectrum.values[i-1] ) 181 | x2, y2 = self.to_plot_coords( spectrum.wavelengths[i], spectrum.values[i] ) 182 | painter.drawLine(x1, y1, x2, y2) 183 | 184 | gradient_start_x, gradient_start_y = self.to_plot_coords( self.range[0][0], self.range[1][0] ) 185 | gradient_end_x, gradient_end_y = self.to_plot_coords( self.range[0][1], self.range[1][0] ) 186 | gradient_start_y = gradient_start_y + 20 187 | gradient_end_y = gradient_end_y + 35 188 | painter.drawPixmap( gradient_start_x, gradient_start_y, gradient_end_x - gradient_start_x, gradient_end_y - gradient_start_y, self.spectral_gradient ) 189 | 190 | 191 | 192 | class xyPlotWidget(QWidget): 193 | def __init__(self, parent=None): 194 | super(xyPlotWidget, self).__init__(parent) 195 | 196 | self.setMinimumHeight( 400 ) 197 | self.setMinimumWidth( 400 ) 198 | 199 | self.margins = (50, 20, 20, 80) 200 | 201 | self.data = {} 202 | self.range = ( ( 0, 0.8 ), ( 0, 0.9 ) ) 203 | 204 | self.prep_locus() 205 | 206 | def prep_locus( self, size = 512 ): 207 | def to_plot( x , y ): 208 | return int( ( ( x - self.range[0][0] ) / ( self.range[0][1] - self.range[0][0]) ) * size ), int( ( 1.0 - ( ( y - self.range[1][0] ) / ( self.range[1][1] - self.range[1][0]) ) ) * size ) 209 | 210 | self.locus_size = size 211 | 212 | x = np.linspace( self.range[0][0], self.range[0][1], size ) 213 | y = np.linspace( self.range[1][1], self.range[1][0], size ) 214 | xx, yy = np.meshgrid( x, y ) 215 | zz = 1 - ( xx + yy ) 216 | 217 | r, g, b = PaintMixing.Colorimetry.xyz_to_rgb( xx, yy, zz ) 218 | r, g, b = np.clip( PaintMixing.Colorimetry.gamma( r ), 0, 1 ), np.clip( PaintMixing.Colorimetry.gamma( g ), 0, 1 ), np.clip( PaintMixing.Colorimetry.gamma( b ), 0, 1 ) 219 | a = np.ones_like( r ) 220 | 221 | xy_plot = ( np.stack( [ r, g, b, a ], -1 ) * 255 ).astype(np.uint8) 222 | 223 | wavelengths = np.linspace(380, 730, 128) 224 | locusX = PaintMixing.Colorimetry.predefined_spectra["X"].sample( wavelengths ) 225 | locusY = PaintMixing.Colorimetry.predefined_spectra["Y"].sample( wavelengths ) 226 | locusZ = PaintMixing.Colorimetry.predefined_spectra["Z"].sample( wavelengths ) 227 | locus_x = locusX / ( locusX + locusY + locusZ ) 228 | locus_y = locusY / ( locusX + locusY + locusZ ) 229 | 230 | self.locus_points = np.stack( ( locus_x, locus_y ), -1 ) 231 | 232 | path = matplotlib.path.Path( self.locus_points ) 233 | xy_plot = np.where( path.contains_points(np.stack( ( xx, yy ), -1 ).reshape(-1,2)).reshape(size, size, 1), xy_plot, np.zeros_like( xy_plot ) ) 234 | 235 | self.xyColorDiagram = QPixmap( QImage(xy_plot, size, size, size * 4, QImage.Format_RGBA8888) ) 236 | 237 | self.gamuts = {} 238 | self.gamuts["sRGB"] = { "whitepoint" : ( 0.3127, 0.3290 ), 239 | "corners" : [ ( 0.6400, 0.3300 ), ( 0.3000 , 0.6000 ), ( 0.1500, 0.0600 ) ] } 240 | 241 | 242 | def add_data( self, name, spectrum, color): 243 | x, y, z = PaintMixing.Colorimetry.reflectance_to_xyz( spectrum ) 244 | 245 | self.data[name] = { "data" : ( x / ( x + y + z ), y / ( x + y + z ) ), 246 | "color" : color} 247 | 248 | self.update() 249 | 250 | def add_data_rgb( self, name, color ): 251 | x, y, z = PaintMixing.Colorimetry.rgb_to_xyz( *PaintMixing.Colorimetry.rgb_int_to_float( color.red(), color.green(), color.blue() ) ) 252 | 253 | self.data[name] = { "data" : ( x / ( x + y + z ), y / ( x + y + z ) ), 254 | "color" : color} 255 | 256 | self.update() 257 | 258 | def remove_data( self, name): 259 | if name in self.data: 260 | del self.data[name] 261 | self.update() 262 | 263 | def to_plot_coords( self, x, y ): 264 | def saturate( x ): 265 | return 0 if x < 0 else 1 if x > 1 else x 266 | 267 | width_no_margins = self.width() - self.margins[0] - self.margins[2] 268 | height_no_margins = self.height() - self.margins[1] - self.margins[3] 269 | 270 | min_size = width_no_margins if width_no_margins < height_no_margins else height_no_margins 271 | max_size = width_no_margins if width_no_margins > height_no_margins else height_no_margins 272 | extra_margin = ( max_size - min_size ) // 2 273 | extra_margin_x = extra_margin if width_no_margins > height_no_margins else 0 274 | extra_margin_y = extra_margin if width_no_margins < height_no_margins else 0 275 | 276 | plot_width = min_size 277 | plot_height = min_size 278 | 279 | x_local = saturate( ( x - self.range[0][0] ) / ( self.range[0][1] - self.range[0][0]) ) 280 | y_local = saturate( ( y - self.range[1][0] ) / ( self.range[1][1] - self.range[1][0]) ) 281 | 282 | return int( x_local * plot_width + self.margins[0] + extra_margin_x ), int( ( 1.0 - y_local ) * plot_height + self.margins[1] + extra_margin_y ) 283 | 284 | 285 | def paintEvent(self, event): 286 | painter = QPainter(self) 287 | painter.setRenderHint(QPainter.Antialiasing) 288 | painter.setRenderHint(QPainter.SmoothPixmapTransform) 289 | 290 | painter.setPen( QPen( QColor.fromRgbF(1.0, 1.0, 1.0, 0.3), 0.3, Qt.SolidLine ) ) 291 | for x in np.linspace( self.range[0][0], self.range[0][1], 10): 292 | painter.drawLine( *self.to_plot_coords( x, 0 ), *self.to_plot_coords( x, 1 )) 293 | 294 | position_x, position_y = self.to_plot_coords( x, 0 ) 295 | painter.drawText( QPoint( position_x - 10, position_y + 12 ), "{:.2f}".format( x ) ) 296 | 297 | for i, y in enumerate( np.linspace( self.range[1][0], self.range[1][1], 10) ): 298 | painter.drawLine( *self.to_plot_coords( 0, y ), *self.to_plot_coords( 1, y )) 299 | 300 | position_x, position_y = self.to_plot_coords( 0, y ) 301 | painter.drawText( QPoint( position_x - 27, position_y + ( 0 if i == 0 else 10 if i == 9 else 5 ) ), "{:.2f}".format( y ) ) 302 | 303 | top_x, top_y = self.to_plot_coords( self.range[0][0], self.range[1][1] ) 304 | bottom_x, bottom_y = self.to_plot_coords( self.range[0][1], self.range[0][0] ) 305 | 306 | painter.drawPixmap( top_x, top_y, bottom_x - top_x, bottom_y - top_y, self.xyColorDiagram ) 307 | 308 | painter.setPen( QPen( QColor.fromRgbF(0.0, 0.0, 0.0, 1.0), 1.0, Qt.SolidLine ) ) 309 | for i in range( 0, len( self.locus_points ) ): 310 | x1, y1 = self.to_plot_coords( *self.locus_points[i-1] ) 311 | x2, y2 = self.to_plot_coords( *self.locus_points[i] ) 312 | painter.drawLine(x1, y1, x2, y2) 313 | 314 | for gamut_name in self.gamuts: 315 | gamut = self.gamuts[gamut_name] 316 | if "whitepoint" in gamut: 317 | painter.setPen( QPen( QColor.fromRgbF(1.0, 1.0, 1.0, 1.0), 1.0, Qt.SolidLine ) ) 318 | center = QPoint( *self.to_plot_coords( *gamut["whitepoint"] ) ) 319 | painter.drawEllipse( center, 3, 3 ) 320 | 321 | if "corners" in gamut: 322 | painter.setPen( QPen( QColor.fromRgbF(1.0, 1.0, 1.0, 1.0), 0.3, Qt.SolidLine ) ) 323 | position_x, position_y = self.to_plot_coords( *gamut["corners"][0] ) 324 | painter.drawText( QPoint( position_x - 10, position_y + 15 ), gamut_name ) 325 | 326 | painter.setPen( QPen( QColor.fromRgbF(0.5, 0.5, 0.5, 1.0), 1.0, Qt.SolidLine ) ) 327 | for i in range( 0, len( gamut["corners"] ) ): 328 | x1, y1 = self.to_plot_coords( *gamut["corners"][i-1] ) 329 | x2, y2 = self.to_plot_coords( *gamut["corners"][i] ) 330 | painter.drawEllipse( QPoint(x1, y1), 3, 3 ) 331 | painter.drawLine(x1, y1, x2, y2) 332 | 333 | for name in self.data: 334 | pen = QPen( self.data[name]["color"], 2, Qt.SolidLine ) 335 | painter.setPen(pen) 336 | 337 | x,y = self.to_plot_coords( *self.data[name]["data"] ) 338 | painter.setPen( QPen( QColor.fromRgbF(1.0, 1.0, 1.0, 1.0), 0.3, Qt.SolidLine ) ) 339 | painter.drawEllipse( QPoint( x, y ), 3, 3 ) 340 | 341 | painter.setPen( QPen( QColor.fromRgbF(1.0, 1.0, 1.0, 1.0), 0.3, Qt.SolidLine ) ) 342 | painter.drawText( QPoint( x - 10, y + 15 ), name ) 343 | 344 | 345 | class BasePaintListItem(QWidget): 346 | def __init__(self, text, bg_color, parent=None): 347 | super(BasePaintListItem, self).__init__(parent) 348 | self.init_ui(text) 349 | self.update_color(bg_color) 350 | self.paint_name = "" 351 | 352 | def set_paint_name( self, name): 353 | self.paint_name = name 354 | 355 | def get_paint_name(self): 356 | return self.paint_name 357 | 358 | def init_ui(self, text, parent=None): 359 | self.top_layout = QHBoxLayout(self) 360 | self.top_layout.setContentsMargins(0, 0, 0, 0) 361 | self.top_layout.setSpacing(0) 362 | 363 | self.containter = QFrame(self) 364 | self.top_layout.addWidget(self.containter) 365 | self.layout = QHBoxLayout(self.containter) 366 | self.layout.setContentsMargins(5, 5, 5, 5) 367 | self.layout.setSpacing(5) 368 | 369 | # Create the checkbox, align it to the center 370 | self.checkbox = QCheckBox() 371 | self.checkbox.setFixedSize(self.checkbox.sizeHint()) # Set fixed size for checkbox 372 | self.checkbox.setToolTip( "If checked, paint is used when solving for recipe") 373 | self.layout.addWidget(self.checkbox) 374 | self.layout.setAlignment(self.checkbox, Qt.AlignCenter) 375 | 376 | # Create the label with text 377 | self.label = QLabel(text) 378 | self.label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) 379 | self.label.setWordWrap(True) # Ensure word wrap is enabled 380 | self.layout.addWidget(self.label) 381 | 382 | # Adjust size policy to ensure the label can expand vertically 383 | self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 384 | 385 | def update_color(self, new_color): 386 | self.setStyleSheet(""" 387 | QWidget { 388 | background-color: """ + new_color.name() + """; 389 | border-radius: 10px; 390 | }""" ) 391 | 392 | text_color = get_text_color( new_color ) 393 | 394 | # Update styles 395 | self.label.setStyleSheet(f"color: {text_color.name()}; padding: 5px;") 396 | 397 | def sizeHint(self): 398 | return QSize(100, 80) 399 | 400 | 401 | class UsedPaintListItem(QWidget): 402 | def __init__(self, text, bg_color, parent=None): 403 | super(UsedPaintListItem, self).__init__(parent) 404 | self.init_ui(text) 405 | self.update_color(bg_color) 406 | self.paint_name = "" 407 | 408 | def set_paint_name( self, name): 409 | self.paint_name = name 410 | 411 | def get_paint_name(self): 412 | return self.paint_name 413 | 414 | def init_ui(self, text, parent=None): 415 | self.top_layout = QHBoxLayout(self) 416 | self.top_layout.setContentsMargins(0, 0, 0, 0) 417 | self.top_layout.setSpacing(0) 418 | 419 | self.containter = QFrame(self) 420 | self.top_layout.addWidget(self.containter) 421 | self.layout = QVBoxLayout(self.containter) 422 | self.layout.setContentsMargins(5, 5, 5, 5) 423 | self.layout.setSpacing(1) 424 | 425 | # Create the label with text 426 | self.containter = QFrame(self) 427 | self.containter_layout = QHBoxLayout(self.containter) 428 | self.containter_layout.setContentsMargins(0, 0, 10, 0) 429 | self.containter_layout.setSpacing(0) 430 | self.layout.addWidget(self.containter) 431 | 432 | self.checkbox = QCheckBox() 433 | self.checkbox.setFixedSize(self.checkbox.sizeHint()) # Set fixed size for checkbox 434 | self.checkbox.setToolTip( "If checked, paint reflectance is visible in the mixing plot") 435 | self.checkbox.setChecked( True ) 436 | self.containter_layout.addWidget(self.checkbox) 437 | self.containter_layout.setAlignment(self.checkbox, Qt.AlignCenter) 438 | 439 | self.label = QLabel(text) 440 | self.label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) 441 | self.containter_layout.addWidget(self.label) 442 | 443 | self.amount_label = QLabel("0.45") 444 | self.containter_layout.setAlignment(Qt.AlignRight | Qt.AlignVCenter) 445 | self.containter_layout.addWidget(self.amount_label) 446 | 447 | # Create the checkbox, align it to the center 448 | self.slider = QSlider( Qt.Horizontal ) 449 | self.slider.setMinimum( 0 ) 450 | self.slider.setMaximum( PAINT_AMOUNT_SLIDER_SCALE ) 451 | self.slider.setSingleStep( 1 ) 452 | self.layout.addWidget(self.slider) 453 | 454 | # Adjust size policy to ensure the label can expand vertically 455 | self.label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 456 | self.slider.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) 457 | 458 | self.slider.valueChanged.connect(self.slider_value_changed) 459 | 460 | def slider_value_changed( self, value ): 461 | self.amount_label.setText( "{:.3f}".format( value / PAINT_AMOUNT_SLIDER_SCALE ) ) 462 | def update_color(self, new_color): 463 | self.setStyleSheet(""" 464 | QWidget { 465 | background-color: """ + new_color.name() + """; 466 | border-radius: 10px; 467 | }""" ) 468 | 469 | text_color = get_text_color( new_color ) 470 | 471 | # Update styles 472 | self.label.setStyleSheet(f"color: {text_color.name()}; padding: 5px;") 473 | self.amount_label.setStyleSheet(f"color: {text_color.name()}; padding: 5px;") 474 | 475 | def sizeHint(self): 476 | return QSize(100, 60) 477 | 478 | 479 | 480 | class PaintListWidget(QListWidget): 481 | def __init__(self, parent=None, droppable=True): 482 | super(PaintListWidget, self).__init__(parent) 483 | self.setSpacing( 2 ) 484 | self.setStyleSheet(""" 485 | QListWidget { 486 | border: none; /* Remove border */ 487 | } 488 | """) 489 | 490 | self.setDragEnabled(True) # Enable dragging 491 | self.setAcceptDrops(True) # Accept drops if specified 492 | self.setDropIndicatorShown(False) # Show drop indicator 493 | self.dropCallback = None 494 | 495 | def setDropCallback( self, callback ): 496 | self.dropCallback = callback 497 | 498 | def startDrag(self, supportedActions): 499 | drag = QDrag(self) 500 | 501 | mimeData = self.mimeData(self.selectedItems()) 502 | if mimeData: 503 | mimeData.setText( self.itemWidget(self.selectedItems()[0]).get_paint_name() ) 504 | drag.setMimeData(mimeData) 505 | drag.exec_(Qt.MoveAction) # Use copy action for dragging 506 | 507 | def dropEvent(self, event): 508 | # Handle drop event to customize item addition 509 | if event.source() == self: 510 | event.ignore() # Ignore if source and target are the same 511 | else: 512 | if self.dropCallback: 513 | self.dropCallback( event ) 514 | else: 515 | event.ignore() 516 | 517 | 518 | 519 | class PaintRecipeListItem(QWidget): 520 | def __init__(self, target_color, paint_database, item, parent=None): 521 | super(PaintRecipeListItem, self).__init__(parent) 522 | self.item = item 523 | self.paint_database = paint_database 524 | self.target_color = target_color 525 | self.recipe_picked_handler = None 526 | self.init_ui() 527 | self.update_size() 528 | 529 | def init_ui(self, parent=None): 530 | self.top_layout = QHBoxLayout(self) 531 | self.top_layout.setContentsMargins(0, 0, 0, 0) 532 | self.top_layout.setSpacing(0) 533 | 534 | self.containter = QFrame(self) 535 | self.containter.setStyleSheet(""" 536 | QWidget { 537 | background-color: """ + self.target_color.name() + """; 538 | border-radius: 10px; 539 | }""" ) 540 | self.top_layout.addWidget(self.containter) 541 | 542 | self.layout = QVBoxLayout(self.containter) 543 | self.layout.setContentsMargins(70, 5, 5, 5) 544 | self.layout.setSpacing(5) 545 | 546 | self.recipes = {} 547 | 548 | def add_recipe( self, name, components ): 549 | mixing_components = [ ( self.paint_database.get_paint(paint_name), paint_amount ) for ( paint_name, paint_amount ) in components ] 550 | mixing_amount_sum = sum( paint_amount for ( paint_name, paint_amount ) in components ) 551 | 552 | if len( mixing_components ) > 0 and mixing_amount_sum > 0: 553 | mixed_spectrum = self.paint_database.get_mixing_model().mix( mixing_components ) 554 | 555 | mixed_color_rgb = PaintMixing.Colorimetry.reflectance_to_rgb( mixed_spectrum ) 556 | mixed_color = QColor.fromRgbF( *mixed_color_rgb ) 557 | 558 | recipe_containter = QFrame(self) 559 | recipe_containter.setStyleSheet(""" 560 | QWidget { 561 | background-color: """ + mixed_color.name() + """; 562 | }""" ) 563 | inner_layout = QVBoxLayout( recipe_containter ) 564 | 565 | #text = "RGB ( {}, {}, {} )\n".format( mixed_color.red(), mixed_color.green(), mixed_color.blue() ) 566 | text = get_color_desc( mixed_color ) + "\n" 567 | for paint_name, paint_amount in components: 568 | text = text + paint_name + " : " + "{:.3f}".format( paint_amount ) + "\n" 569 | 570 | text_color = get_text_color( mixed_color ) 571 | 572 | recipe_label = QLabel( text ) 573 | recipe_label.setStyleSheet(f"color: {text_color.name()}") 574 | recipe_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) 575 | recipe_label.mouseDoubleClickEvent = lambda event: self.recipe_picked( name ) 576 | inner_layout.addWidget( recipe_label ) 577 | 578 | self.layout.addWidget( recipe_containter ) 579 | 580 | self.recipes[name] = { "components" : components, 581 | "text" : text } 582 | 583 | self.update_size() 584 | 585 | def set_recipe_picked_handler( self, handler ): 586 | self.recipe_picked_handler = handler 587 | 588 | def recipe_picked( self, name ): 589 | if self.recipe_picked_handler: 590 | self.recipe_picked_handler( self.recipes[name]["components"] ) 591 | 592 | def update_size( self ): 593 | num_lines_total = 0 594 | for recipe in self.recipes.values(): 595 | num_lines_total = num_lines_total + recipe["text"].count('\n') + 1 596 | 597 | self.item.setSizeHint( QSize( 100, 22 * num_lines_total ) ) 598 | 599 | 600 | class MainWindow(QMainWindow): 601 | def __init__(self, paint_database ): 602 | super().__init__() 603 | 604 | self.paint_database = paint_database 605 | self.used_paints = {} 606 | self.all_paints = {} 607 | 608 | self.setContentsMargins(5, 5, 5, 5) 609 | 610 | # Initialize the splitter layout 611 | self.splitter_main = QSplitter(Qt.Horizontal) 612 | self.setCentralWidget(self.splitter_main) 613 | 614 | # All paints 615 | self.list_allPaints_group_box = QGroupBox(self) 616 | self.list_allPaints_group_box.setTitle("Base paints") 617 | self.splitter_main.addWidget(self.list_allPaints_group_box) 618 | 619 | self.list_allPaints_group_box_layout = QVBoxLayout(self.list_allPaints_group_box) 620 | 621 | self.list_allPaints = PaintListWidget() 622 | self.list_allPaints.setDropCallback( lambda event: self.dropToAllPainsList( event ) ) 623 | self.list_allPaints_group_box_layout.addWidget(self.list_allPaints) 624 | 625 | # Used paints 626 | self.list_usedPaints_group_box = QGroupBox(self) 627 | self.list_usedPaints_group_box.setTitle("Used paints") 628 | self.splitter_main.addWidget(self.list_usedPaints_group_box) 629 | 630 | self.list_usedPaints_group_box_layout = QVBoxLayout(self.list_usedPaints_group_box) 631 | 632 | self.list_usedPaints = PaintListWidget() 633 | self.list_usedPaints.setDropCallback( lambda event: self.dropNewPaintToUse( event ) ) 634 | self.list_usedPaints_group_box_layout.addWidget(self.list_usedPaints) 635 | 636 | # Section 'c' - mixed color, reflectance plot, Yxy space with color marked 637 | 638 | self.mixed_color_pane = QFrame(self) 639 | self.mixed_color_layout = QVBoxLayout(self.mixed_color_pane) 640 | self.mixed_color_layout.setContentsMargins(0, 0, 0, 0) 641 | self.mixed_color_layout.setSpacing(0) 642 | 643 | self.mixed_color = ShowColorWidget( QColor.fromRgb( 255, 255, 255 ) ) 644 | self.mixed_color_layout.addWidget( self.mixed_color ) 645 | 646 | #self.figure = plt.figure() 647 | #self.canvas = FigureCanvas(self.figure) 648 | self.spectra_plot = SpectraPlotWidget() 649 | self.mixed_color_layout.addWidget( self.spectra_plot ) 650 | 651 | self.locus_plot = xyPlotWidget() 652 | self.mixed_color_layout.addWidget( self.locus_plot ) 653 | 654 | self.splitter_main.addWidget(self.mixed_color_pane) 655 | 656 | # Section 'e' - paint recipes 657 | self.list_recipes_group_box = QGroupBox(self) 658 | self.list_recipes_group_box.setTitle("Recipes") 659 | self.splitter_main.addWidget(self.list_recipes_group_box) 660 | self.list_recipes_group_box_layout = QVBoxLayout(self.list_recipes_group_box) 661 | 662 | #self.list_recipes_group_box_layout.setContentsMargins(0, 0, 0, 0) 663 | #self.list_recipes_group_box_layout.setSpacing(0) 664 | 665 | self.picked_color = ShowColorWidget( QColor.fromRgb( 255, 255, 255 ) ) 666 | self.list_recipes_group_box_layout.addWidget( self.picked_color ) 667 | 668 | self.recipe_buttons_group = QFrame(self) 669 | self.recipe_buttons_layout = QHBoxLayout(self.recipe_buttons_group) 670 | 671 | self.pick_button = QPushButton('Pick Color') 672 | self.pick_button.clicked.connect(self.pick_color) 673 | self.recipe_buttons_layout.addWidget( self.pick_button ) 674 | 675 | self.solve_button = QPushButton('Solve') 676 | self.solve_button.clicked.connect(self.solve_color) 677 | self.recipe_buttons_layout.addWidget( self.solve_button ) 678 | 679 | self.list_recipes_group_box_layout.addWidget( self.recipe_buttons_group ) 680 | 681 | self.paintRecipeList = QListWidget() 682 | self.paintRecipeList.setSpacing( 2 ) 683 | self.paintRecipeList.setStyleSheet(""" 684 | QListWidget { 685 | border: none; /* Remove border */ 686 | } 687 | """) 688 | 689 | self.list_recipes_group_box_layout.addWidget(self.paintRecipeList) 690 | 691 | # init paints list 692 | self.populate_all_paints_list() 693 | 694 | def populate_all_paints_list(self): 695 | base_paints = self.paint_database.get_base_paints() 696 | 697 | for i, paint in enumerate( base_paints ): 698 | rgb = PaintMixing.Colorimetry.reflectance_to_rgb( self.paint_database.get_paint(paint)["reflectance"] ) 699 | bg_color = QColor.fromRgb( int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255) ) 700 | 701 | item = QListWidgetItem(self.list_allPaints) 702 | custom_widget = BasePaintListItem(f"{paint}\n" + get_color_desc( bg_color ), bg_color) 703 | custom_widget.set_paint_name( paint ) 704 | custom_widget.checkbox.setChecked( True ) 705 | item.setSizeHint(custom_widget.sizeHint()) 706 | self.list_allPaints.addItem(item) 707 | self.list_allPaints.setItemWidget(item, custom_widget) 708 | 709 | self.all_paints[paint] = item 710 | 711 | 712 | def dropToAllPainsList( self, event ): 713 | if event.source() == self.list_usedPaints: 714 | # user dragged a paint from "used paints" onto "all paints list" - remove it from used paints 715 | paint_name = event.mimeData().text() 716 | self.remove_used_paint( paint_name ) 717 | 718 | 719 | def dropNewPaintToUse( self, event ): 720 | if event.source() == self.list_allPaints: 721 | # user dragged a paint from "all paints list" onto "used paints" - add it to used paints 722 | paint_name = event.mimeData().text() 723 | self.add_used_paint( paint_name, 0.5 ) 724 | 725 | def add_used_paint( self, paint_name, amount ): 726 | if paint_name in self.used_paints.keys(): 727 | return 728 | 729 | rgb = PaintMixing.Colorimetry.reflectance_to_rgb( self.paint_database.get_paint(paint_name)["reflectance"] ) 730 | bg_color = QColor.fromRgb( int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255) ) 731 | 732 | item = QListWidgetItem(self.list_usedPaints) 733 | custom_widget = UsedPaintListItem(paint_name, bg_color) 734 | custom_widget.set_paint_name( paint_name ) 735 | custom_widget.slider.setValue( int( amount * PAINT_AMOUNT_SLIDER_SCALE ) ) 736 | custom_widget.slider.valueChanged.connect(self.mixing_ratios_changed) 737 | custom_widget.checkbox.stateChanged.connect(lambda state: self.used_paint_flipped( paint_name, bg_color, state ) ) 738 | item.setSizeHint(custom_widget.sizeHint()) 739 | self.list_usedPaints.addItem(item) 740 | self.list_usedPaints.setItemWidget(item, custom_widget) 741 | 742 | self.used_paints[paint_name] = item 743 | 744 | self.spectra_plot.add_data( paint_name, self.paint_database.get_paint(paint_name)["reflectance"], bg_color ) 745 | self.locus_plot.add_data( paint_name, self.paint_database.get_paint(paint_name)["reflectance"], bg_color ) 746 | self.mixing_ratios_changed() 747 | 748 | def remove_used_paint( self, paint_name ): 749 | if paint_name not in self.used_paints.keys(): 750 | return 751 | 752 | used_paint_list_item = self.used_paints[paint_name] 753 | 754 | if used_paint_list_item: 755 | self.list_usedPaints.takeItem( self.list_usedPaints.indexFromItem( used_paint_list_item ).row() ) 756 | del self.used_paints[paint_name] 757 | 758 | self.spectra_plot.remove_data( paint_name ) 759 | self.locus_plot.remove_data( paint_name ) 760 | self.mixing_ratios_changed() 761 | 762 | def remove_all_used_paints( self ): 763 | all_used_paints = list( self.used_paints.keys() ) 764 | 765 | for paint_name in all_used_paints: 766 | self.remove_used_paint( paint_name ) 767 | 768 | def used_paint_flipped( self, paint_name, bg_color, state ): 769 | if state: 770 | self.spectra_plot.add_data( paint_name, self.paint_database.get_paint(paint_name)["reflectance"], bg_color ) 771 | self.locus_plot.add_data( paint_name, self.paint_database.get_paint(paint_name)["reflectance"], bg_color ) 772 | else: 773 | self.spectra_plot.remove_data( paint_name ) 774 | self.locus_plot.remove_data( paint_name ) 775 | 776 | def mixing_ratios_changed( self, value = None ): 777 | self.spectra_plot.remove_data( "mixed" ); 778 | self.locus_plot.remove_data( "mixed" ); 779 | 780 | all_used_paints = list( self.used_paints.keys() ) 781 | 782 | mixing_amounts = [ ( self.list_usedPaints.itemWidget( self.used_paints[paint_name] ).slider.value() / PAINT_AMOUNT_SLIDER_SCALE ) for paint_name in all_used_paints ] 783 | pigments = [ self.paint_database.get_paint(paint_name) for paint_name in all_used_paints ] 784 | 785 | components = list( zip( pigments, mixing_amounts ) ) 786 | 787 | if len( components ) > 0 and sum( mixing_amounts ) > 0: 788 | mixed_spectrum = self.paint_database.get_mixing_model().mix( components ) 789 | mixed_color_rgb = PaintMixing.Colorimetry.reflectance_to_rgb( mixed_spectrum ) 790 | 791 | mixed_color = QColor.fromRgbF( *mixed_color_rgb ) 792 | self.mixed_color.update_color( mixed_color ) 793 | 794 | if len( components ) > 1 : 795 | self.spectra_plot.add_data( "mixed", mixed_spectrum, mixed_color ); 796 | self.locus_plot.add_data( "mixed", mixed_spectrum, mixed_color ); 797 | else: 798 | if len( components ) == 0: 799 | self.mixed_color.update_color( QColor.fromRgbF( 1, 1, 1 ) ) 800 | 801 | 802 | def recipe_picked( self, components ): 803 | self.remove_all_used_paints() 804 | for paint, amount in components: 805 | self.add_used_paint( paint, amount ) 806 | 807 | def pick_color(self): 808 | color = QColorDialog.getColor() 809 | if color.isValid(): 810 | self.picked_color.update_color( color ) 811 | self.locus_plot.add_data_rgb( "target", color ) 812 | 813 | def solve_color(self): 814 | target_color = self.picked_color.color 815 | 816 | self.solve_button.setEnabled( False ) 817 | self.solve_button.setText( "Solving (0/{})...".format( MAX_NUM_PAINTS_IN_RECIPE ) ) 818 | 819 | self.paintRecipeList.clear() 820 | 821 | target_rgb = np.array( ( target_color.red(), target_color.green(), target_color.blue() ) ) / 255.0 822 | paints_to_use = [paint_name for paint_name in self.all_paints.keys() if self.list_allPaints.itemWidget( self.all_paints[paint_name] ).checkbox.isChecked()] 823 | 824 | self.worker = RecipeSolverWorker( target_rgb, self.paint_database, paints_to_use ) 825 | self.thread = QThread() 826 | self.worker.moveToThread( self.thread ) 827 | 828 | self.thread.started.connect(self.worker.run) 829 | self.worker.finished.connect(self.thread.quit) 830 | self.worker.finished.connect(self.worker.deleteLater) 831 | self.thread.finished.connect(self.thread.deleteLater) 832 | self.worker.progress.connect(lambda three_best: self.add_solved_recipe( three_best, target_color ) ) 833 | 834 | self.thread.start() 835 | 836 | self.thread.finished.connect( self.solve_finished ) 837 | 838 | def solve_finished( self ): 839 | self.solve_button.setEnabled( True ) 840 | self.solve_button.setText( "Solve" ) 841 | 842 | def add_solved_recipe( self, num_paints_three_best, target_color ): 843 | num_paints = num_paints_three_best[0] 844 | 845 | self.solve_button.setText( "Solving ({}/{})...".format( num_paints, MAX_NUM_PAINTS_IN_RECIPE ) ) 846 | 847 | three_best = num_paints_three_best[1:] 848 | item = QListWidgetItem(self.paintRecipeList) 849 | custom_widget = PaintRecipeListItem( target_color, self.paint_database, item ) 850 | custom_widget.set_recipe_picked_handler( self.recipe_picked ) 851 | 852 | for recipe_index, best_mix in enumerate( three_best ): 853 | num_paints = len( best_mix[2] ) 854 | print("rgb: {}: ".format( best_mix[0] ), end = "" ) 855 | 856 | recipe = [] 857 | for i, paint in enumerate( best_mix[2] ): 858 | recipe.append( ( paint, best_mix[3][i] ) ) 859 | 860 | custom_widget.add_recipe( "{}/{}".format( num_paints, recipe_index ), recipe ) 861 | 862 | self.paintRecipeList.addItem(item) 863 | self.paintRecipeList.setItemWidget(item, custom_widget) 864 | 865 | 866 | 867 | class RecipeSolverWorker(QObject): 868 | finished = pyqtSignal() 869 | progress = pyqtSignal(list) 870 | 871 | def __init__( self, target_rgb, paint_database, paints_to_use ): 872 | super().__init__() 873 | self.target_rgb = target_rgb 874 | self.paint_database = paint_database 875 | self.paints_to_use = paints_to_use 876 | 877 | def run(self): 878 | for num_paints in range( 1, MAX_NUM_PAINTS_IN_RECIPE + 1 ): 879 | paint_combinations = list( combinations( self.paints_to_use, num_paints ) ) 880 | 881 | with Pool(min( 61, os.cpu_count() )) as p: 882 | results = p.map( PaintMixing.RecipeOptimizer( self.paint_database.get_all_paints(), self.target_rgb, self.paint_database.get_mixing_model() ), paint_combinations ) 883 | 884 | three_best = heapq.nsmallest(3, results, key=lambda result: result[1]) 885 | self.progress.emit( [ num_paints, *three_best ] ) 886 | 887 | self.finished.emit() 888 | 889 | 890 | class PaintMixingApp(QApplication): 891 | def __init__( self, argv): 892 | super().__init__(argv) 893 | 894 | self.setStyleSheet(""" 895 | QMainWindow { 896 | background-color: #323232; 897 | } 898 | QGroupBox { 899 | border: 1px solid #4A4A4A; 900 | padding-top: 10px; 901 | } 902 | QListWidget { 903 | background-color: #424242; 904 | color: #FFFFFF; 905 | } 906 | QLabel { 907 | background-color: #323232; 908 | color: #FFFFFF; 909 | } 910 | QSpinBox { 911 | background-color: #424242; 912 | } 913 | QColorDialog { 914 | background-color: #424242; 915 | } 916 | QPushButton { 917 | background-color: #424242; 918 | color: #FFFFFF; 919 | border: 1px solid #4A4A4A; 920 | padding: 5px; 921 | } 922 | QPushButton:hover { 923 | background-color: #535353; 924 | } 925 | QPushButton:pressed { 926 | background-color: #2A2A2A; 927 | } 928 | QPushButton:disabled { 929 | background-color: #202020; 930 | } 931 | QSlider::groove:horizontal { 932 | border: 1px solid #999999; 933 | height: 8px; 934 | background: #3A3A3A; 935 | margin: 2px 0; 936 | } 937 | QSlider::handle:horizontal { 938 | background: #5C5C5C; 939 | border: 1px solid #5C5C5C; 940 | width: 18px; 941 | margin: -2px 0; 942 | border-radius: 3px; 943 | } 944 | QSplitter { 945 | background: #424242; 946 | } 947 | QSplitter::handle { 948 | background: #424242; 949 | } 950 | QSplitter::handle:hover { 951 | background: #535353; 952 | } 953 | QSplitter::handle:horizontal { 954 | width: 5px; 955 | } 956 | QSplitter::handle:vertical { 957 | height: 5px; 958 | } 959 | """) 960 | 961 | 962 | # 963 | 964 | bundle_dir = os.path.abspath(os.path.dirname(__file__)) 965 | paint_database = PaintMixing.PaintDatabase( [ os.path.join(bundle_dir, 'data/masstone.json'), os.path.join(bundle_dir, 'data/mix1.json') ] ) 966 | 967 | mainWin = MainWindow( paint_database ) 968 | mainWin.setWindowTitle("Paint Mixer") 969 | mainWin.show() 970 | 971 | 972 | if __name__ == '__main__': 973 | multiprocessing.freeze_support() 974 | app = PaintMixingApp(sys.argv) 975 | app.exec_() 976 | sys.exit(0) -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | matplotlib = "*" 8 | PyQt5 = "*" 9 | scipy = "*" 10 | 11 | [dev-packages] 12 | black = "*" 13 | flake8 = "*" 14 | mypy = "*" 15 | pytest = "*" 16 | vulture = "*" 17 | 18 | [requires] 19 | python_version = "3.11" 20 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "a283027a7bf6f513494ed7b3a7e3de5cedd2f468ccf0dd536103607e283b0cff" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": { 8 | "python_version": "3.11" 9 | }, 10 | "sources": [ 11 | { 12 | "name": "pypi", 13 | "url": "https://pypi.org/simple", 14 | "verify_ssl": true 15 | } 16 | ] 17 | }, 18 | "default": { 19 | "contourpy": { 20 | "hashes": [ 21 | "sha256:0274c1cb63625972c0c007ab14dd9ba9e199c36ae1a231ce45d725cbcbfd10a8", 22 | "sha256:0d7e03c0f9a4f90dc18d4e77e9ef4ec7b7bbb437f7f675be8e530d65ae6ef956", 23 | "sha256:11f8d2554e52f459918f7b8e6aa20ec2a3bce35ce95c1f0ef4ba36fbda306df5", 24 | "sha256:139d8d2e1c1dd52d78682f505e980f592ba53c9f73bd6be102233e358b401063", 25 | "sha256:16a7380e943a6d52472096cb7ad5264ecee36ed60888e2a3d3814991a0107286", 26 | "sha256:171f311cb758de7da13fc53af221ae47a5877be5a0843a9fe150818c51ed276a", 27 | "sha256:18fc2b4ed8e4a8fe849d18dce4bd3c7ea637758c6343a1f2bae1e9bd4c9f4686", 28 | "sha256:1c203f617abc0dde5792beb586f827021069fb6d403d7f4d5c2b543d87edceb9", 29 | "sha256:1c2559d6cffc94890b0529ea7eeecc20d6fadc1539273aa27faf503eb4656d8f", 30 | "sha256:1c88dfb9e0c77612febebb6ac69d44a8d81e3dc60f993215425b62c1161353f4", 31 | "sha256:1e9dc350fb4c58adc64df3e0703ab076f60aac06e67d48b3848c23647ae4310e", 32 | "sha256:247b9d16535acaa766d03037d8e8fb20866d054d3c7fbf6fd1f993f11fc60ca0", 33 | "sha256:266270c6f6608340f6c9836a0fb9b367be61dde0c9a9a18d5ece97774105ff3e", 34 | "sha256:34b9071c040d6fe45d9826cbbe3727d20d83f1b6110d219b83eb0e2a01d79488", 35 | "sha256:3d7d1f8871998cdff5d2ff6a087e5e1780139abe2838e85b0b46b7ae6cc25399", 36 | "sha256:461e3ae84cd90b30f8d533f07d87c00379644205b1d33a5ea03381edc4b69431", 37 | "sha256:464b423bc2a009088f19bdf1f232299e8b6917963e2b7e1d277da5041f33a779", 38 | "sha256:491b1917afdd8638a05b611a56d46587d5a632cabead889a5440f7c638bc6ed9", 39 | "sha256:4a1b1208102be6e851f20066bf0e7a96b7d48a07c9b0cfe6d0d4545c2f6cadab", 40 | "sha256:575bcaf957a25d1194903a10bc9f316c136c19f24e0985a2b9b5608bdf5dbfe0", 41 | "sha256:5c6b28956b7b232ae801406e529ad7b350d3f09a4fde958dfdf3c0520cdde0dd", 42 | "sha256:5d16edfc3fc09968e09ddffada434b3bf989bf4911535e04eada58469873e28e", 43 | "sha256:5fd1810973a375ca0e097dee059c407913ba35723b111df75671a1976efa04bc", 44 | "sha256:67b7f17679fa62ec82b7e3e611c43a016b887bd64fb933b3ae8638583006c6d6", 45 | "sha256:68ce4788b7d93e47f84edd3f1f95acdcd142ae60bc0e5493bfd120683d2d4316", 46 | "sha256:6d3364b999c62f539cd403f8123ae426da946e142312a514162adb2addd8d808", 47 | "sha256:6e739530c662a8d6d42c37c2ed52a6f0932c2d4a3e8c1f90692ad0ce1274abe0", 48 | "sha256:6fdd887f17c2f4572ce548461e4f96396681212d858cae7bd52ba3310bc6f00f", 49 | "sha256:78e6ad33cf2e2e80c5dfaaa0beec3d61face0fb650557100ee36db808bfa6843", 50 | "sha256:884c3f9d42d7218304bc74a8a7693d172685c84bd7ab2bab1ee567b769696df9", 51 | "sha256:8d8faf05be5ec8e02a4d86f616fc2a0322ff4a4ce26c0f09d9f7fb5330a35c95", 52 | "sha256:999c71939aad2780f003979b25ac5b8f2df651dac7b38fb8ce6c46ba5abe6ae9", 53 | "sha256:99ad97258985328b4f207a5e777c1b44a83bfe7cf1f87b99f9c11d4ee477c4de", 54 | "sha256:9e6c93b5b2dbcedad20a2f18ec22cae47da0d705d454308063421a3b290d9ea4", 55 | "sha256:ab459a1cbbf18e8698399c595a01f6dcc5c138220ca3ea9e7e6126232d102bb4", 56 | "sha256:b69303ceb2e4d4f146bf82fda78891ef7bcd80c41bf16bfca3d0d7eb545448aa", 57 | "sha256:b7caf9b241464c404613512d5594a6e2ff0cc9cb5615c9475cc1d9b514218ae8", 58 | "sha256:b95a225d4948b26a28c08307a60ac00fb8671b14f2047fc5476613252a129776", 59 | "sha256:bd2f1ae63998da104f16a8b788f685e55d65760cd1929518fd94cd682bf03e41", 60 | "sha256:be16975d94c320432657ad2402f6760990cb640c161ae6da1363051805fa8108", 61 | "sha256:ce96dd400486e80ac7d195b2d800b03e3e6a787e2a522bfb83755938465a819e", 62 | "sha256:dbd50d0a0539ae2e96e537553aff6d02c10ed165ef40c65b0e27e744a0f10af8", 63 | "sha256:dd10c26b4eadae44783c45ad6655220426f971c61d9b239e6f7b16d5cdaaa727", 64 | "sha256:ebeac59e9e1eb4b84940d076d9f9a6cec0064e241818bcb6e32124cc5c3e377a" 65 | ], 66 | "markers": "python_version >= '3.9'", 67 | "version": "==1.2.0" 68 | }, 69 | "cycler": { 70 | "hashes": [ 71 | "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", 72 | "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c" 73 | ], 74 | "markers": "python_version >= '3.8'", 75 | "version": "==0.12.1" 76 | }, 77 | "fonttools": { 78 | "hashes": [ 79 | "sha256:0404faea044577a01bb82d47a8fa4bc7a54067fa7e324785dd65d200d6dd1133", 80 | "sha256:07bc5ea02bb7bc3aa40a1eb0481ce20e8d9b9642a9536cde0218290dd6085828", 81 | "sha256:08877e355d3dde1c11973bb58d4acad1981e6d1140711230a4bfb40b2b937ccc", 82 | "sha256:0af65c720520710cc01c293f9c70bd69684365c6015cc3671db2b7d807fe51f2", 83 | "sha256:0ba0e00620ca28d4ca11fc700806fd69144b463aa3275e1b36e56c7c09915559", 84 | "sha256:1f255ce8ed7556658f6d23f6afd22a6d9bbc3edb9b96c96682124dc487e1bf42", 85 | "sha256:1fac1b7eebfce75ea663e860e7c5b4a8831b858c17acd68263bc156125201abf", 86 | "sha256:263832fae27481d48dfafcc43174644b6706639661e242902ceb30553557e16c", 87 | "sha256:29e89d0e1a7f18bc30f197cfadcbef5a13d99806447c7e245f5667579a808036", 88 | "sha256:33037d9e56e2562c710c8954d0f20d25b8386b397250d65581e544edc9d6b942", 89 | "sha256:33c584c0ef7dc54f5dd4f84082eabd8d09d1871a3d8ca2986b0c0c98165f8e86", 90 | "sha256:36c8865bdb5cfeec88f5028e7e592370a0657b676c6f1d84a2108e0564f90e22", 91 | "sha256:4145f91531fd43c50f9eb893faa08399816bb0b13c425667c48475c9f3a2b9b5", 92 | "sha256:4d418b1fee41a1d14931f7ab4b92dc0bc323b490e41d7a333eec82c9f1780c75", 93 | "sha256:768947008b4dc552d02772e5ebd49e71430a466e2373008ce905f953afea755a", 94 | "sha256:7c7125068e04a70739dad11857a4d47626f2b0bd54de39e8622e89701836eabd", 95 | "sha256:83a0d9336de2cba86d886507dd6e0153df333ac787377325a39a2797ec529814", 96 | "sha256:86eef6aab7fd7c6c8545f3ebd00fd1d6729ca1f63b0cb4d621bccb7d1d1c852b", 97 | "sha256:8fb022d799b96df3eaa27263e9eea306bd3d437cc9aa981820850281a02b6c9a", 98 | "sha256:9d95fa0d22bf4f12d2fb7b07a46070cdfc19ef5a7b1c98bc172bfab5bf0d6844", 99 | "sha256:a974c49a981e187381b9cc2c07c6b902d0079b88ff01aed34695ec5360767034", 100 | "sha256:ac9a745b7609f489faa65e1dc842168c18530874a5f5b742ac3dd79e26bca8bc", 101 | "sha256:af20acbe198a8a790618ee42db192eb128afcdcc4e96d99993aca0b60d1faeb4", 102 | "sha256:af281525e5dd7fa0b39fb1667b8d5ca0e2a9079967e14c4bfe90fd1cd13e0f18", 103 | "sha256:b050d362df50fc6e38ae3954d8c29bf2da52be384649ee8245fdb5186b620836", 104 | "sha256:b44a52b8e6244b6548851b03b2b377a9702b88ddc21dcaf56a15a0393d425cb9", 105 | "sha256:b607ea1e96768d13be26d2b400d10d3ebd1456343eb5eaddd2f47d1c4bd00880", 106 | "sha256:b85ec0bdd7bdaa5c1946398cbb541e90a6dfc51df76dfa88e0aaa41b335940cb", 107 | "sha256:bebd91041dda0d511b0d303180ed36e31f4f54b106b1259b69fade68413aa7ff", 108 | "sha256:c076a9e548521ecc13d944b1d261ff3d7825048c338722a4bd126d22316087b7", 109 | "sha256:cbe61b158deb09cffdd8540dc4a948d6e8f4d5b4f3bf5cd7db09bd6a61fee64e", 110 | "sha256:cdee3ab220283057e7840d5fb768ad4c2ebe65bdba6f75d5d7bf47f4e0ed7d29", 111 | "sha256:ce7033cb61f2bb65d8849658d3786188afd80f53dad8366a7232654804529532", 112 | "sha256:d00af0884c0e65f60dfaf9340e26658836b935052fdd0439952ae42e44fdd2be", 113 | "sha256:d647a0e697e5daa98c87993726da8281c7233d9d4ffe410812a4896c7c57c075", 114 | "sha256:d970ecca0aac90d399e458f0b7a8a597e08f95de021f17785fb68e2dc0b99717", 115 | "sha256:ea329dafb9670ffbdf4dbc3b0e5c264104abcd8441d56de77f06967f032943cb", 116 | "sha256:ebf46e7f01b7af7861310417d7c49591a85d99146fc23a5ba82fdb28af156321", 117 | "sha256:edc0cce355984bb3c1d1e89d6a661934d39586bb32191ebff98c600f8957c63e", 118 | "sha256:f3bbe672df03563d1f3a691ae531f2e31f84061724c319652039e5a70927167e", 119 | "sha256:fc11e5114f3f978d0cea7e9853627935b30d451742eeb4239a81a677bdee6bf6", 120 | "sha256:fdb54b076f25d6b0f0298dc706acee5052de20c83530fa165b60d1f2e9cbe3cb" 121 | ], 122 | "markers": "python_version >= '3.8'", 123 | "version": "==4.49.0" 124 | }, 125 | "kiwisolver": { 126 | "hashes": [ 127 | "sha256:00bd361b903dc4bbf4eb165f24d1acbee754fce22ded24c3d56eec268658a5cf", 128 | "sha256:040c1aebeda72197ef477a906782b5ab0d387642e93bda547336b8957c61022e", 129 | "sha256:05703cf211d585109fcd72207a31bb170a0f22144d68298dc5e61b3c946518af", 130 | "sha256:06f54715b7737c2fecdbf140d1afb11a33d59508a47bf11bb38ecf21dc9ab79f", 131 | "sha256:0dc9db8e79f0036e8173c466d21ef18e1befc02de8bf8aa8dc0813a6dc8a7046", 132 | "sha256:0f114aa76dc1b8f636d077979c0ac22e7cd8f3493abbab152f20eb8d3cda71f3", 133 | "sha256:11863aa14a51fd6ec28688d76f1735f8f69ab1fabf388851a595d0721af042f5", 134 | "sha256:11c7de8f692fc99816e8ac50d1d1aef4f75126eefc33ac79aac02c099fd3db71", 135 | "sha256:11d011a7574eb3b82bcc9c1a1d35c1d7075677fdd15de527d91b46bd35e935ee", 136 | "sha256:146d14bebb7f1dc4d5fbf74f8a6cb15ac42baadee8912eb84ac0b3b2a3dc6ac3", 137 | "sha256:15568384086b6df3c65353820a4473575dbad192e35010f622c6ce3eebd57af9", 138 | "sha256:19df6e621f6d8b4b9c4d45f40a66839294ff2bb235e64d2178f7522d9170ac5b", 139 | "sha256:1b04139c4236a0f3aff534479b58f6f849a8b351e1314826c2d230849ed48985", 140 | "sha256:210ef2c3a1f03272649aff1ef992df2e724748918c4bc2d5a90352849eb40bea", 141 | "sha256:2270953c0d8cdab5d422bee7d2007f043473f9d2999631c86a223c9db56cbd16", 142 | "sha256:2400873bccc260b6ae184b2b8a4fec0e4082d30648eadb7c3d9a13405d861e89", 143 | "sha256:2a40773c71d7ccdd3798f6489aaac9eee213d566850a9533f8d26332d626b82c", 144 | "sha256:2c5674c4e74d939b9d91dda0fae10597ac7521768fec9e399c70a1f27e2ea2d9", 145 | "sha256:3195782b26fc03aa9c6913d5bad5aeb864bdc372924c093b0f1cebad603dd712", 146 | "sha256:31a82d498054cac9f6d0b53d02bb85811185bcb477d4b60144f915f3b3126342", 147 | "sha256:32d5cf40c4f7c7b3ca500f8985eb3fb3a7dfc023215e876f207956b5ea26632a", 148 | "sha256:346f5343b9e3f00b8db8ba359350eb124b98c99efd0b408728ac6ebf38173958", 149 | "sha256:378a214a1e3bbf5ac4a8708304318b4f890da88c9e6a07699c4ae7174c09a68d", 150 | "sha256:39b42c68602539407884cf70d6a480a469b93b81b7701378ba5e2328660c847a", 151 | "sha256:3a2b053a0ab7a3960c98725cfb0bf5b48ba82f64ec95fe06f1d06c99b552e130", 152 | "sha256:3aba7311af82e335dd1e36ffff68aaca609ca6290c2cb6d821a39aa075d8e3ff", 153 | "sha256:3cd32d6c13807e5c66a7cbb79f90b553642f296ae4518a60d8d76243b0ad2898", 154 | "sha256:3edd2fa14e68c9be82c5b16689e8d63d89fe927e56debd6e1dbce7a26a17f81b", 155 | "sha256:4c380469bd3f970ef677bf2bcba2b6b0b4d5c75e7a020fb863ef75084efad66f", 156 | "sha256:4e66e81a5779b65ac21764c295087de82235597a2293d18d943f8e9e32746265", 157 | "sha256:53abb58632235cd154176ced1ae8f0d29a6657aa1aa9decf50b899b755bc2b93", 158 | "sha256:5794cf59533bc3f1b1c821f7206a3617999db9fbefc345360aafe2e067514929", 159 | "sha256:59415f46a37f7f2efeec758353dd2eae1b07640d8ca0f0c42548ec4125492635", 160 | "sha256:59ec7b7c7e1a61061850d53aaf8e93db63dce0c936db1fda2658b70e4a1be709", 161 | "sha256:59edc41b24031bc25108e210c0def6f6c2191210492a972d585a06ff246bb79b", 162 | "sha256:5a580c91d686376f0f7c295357595c5a026e6cbc3d77b7c36e290201e7c11ecb", 163 | "sha256:5b94529f9b2591b7af5f3e0e730a4e0a41ea174af35a4fd067775f9bdfeee01a", 164 | "sha256:5c7b3b3a728dc6faf3fc372ef24f21d1e3cee2ac3e9596691d746e5a536de920", 165 | "sha256:5c90ae8c8d32e472be041e76f9d2f2dbff4d0b0be8bd4041770eddb18cf49a4e", 166 | "sha256:5e7139af55d1688f8b960ee9ad5adafc4ac17c1c473fe07133ac092310d76544", 167 | "sha256:5ff5cf3571589b6d13bfbfd6bcd7a3f659e42f96b5fd1c4830c4cf21d4f5ef45", 168 | "sha256:620ced262a86244e2be10a676b646f29c34537d0d9cc8eb26c08f53d98013390", 169 | "sha256:6512cb89e334e4700febbffaaa52761b65b4f5a3cf33f960213d5656cea36a77", 170 | "sha256:6c08e1312a9cf1074d17b17728d3dfce2a5125b2d791527f33ffbe805200a355", 171 | "sha256:6c3bd3cde54cafb87d74d8db50b909705c62b17c2099b8f2e25b461882e544ff", 172 | "sha256:6ef7afcd2d281494c0a9101d5c571970708ad911d028137cd558f02b851c08b4", 173 | "sha256:7269d9e5f1084a653d575c7ec012ff57f0c042258bf5db0954bf551c158466e7", 174 | "sha256:72d40b33e834371fd330fb1472ca19d9b8327acb79a5821d4008391db8e29f20", 175 | "sha256:74d1b44c6cfc897df648cc9fdaa09bc3e7679926e6f96df05775d4fb3946571c", 176 | "sha256:74db36e14a7d1ce0986fa104f7d5637aea5c82ca6326ed0ec5694280942d1162", 177 | "sha256:763773d53f07244148ccac5b084da5adb90bfaee39c197554f01b286cf869228", 178 | "sha256:76c6a5964640638cdeaa0c359382e5703e9293030fe730018ca06bc2010c4437", 179 | "sha256:76d9289ed3f7501012e05abb8358bbb129149dbd173f1f57a1bf1c22d19ab7cc", 180 | "sha256:7931d8f1f67c4be9ba1dd9c451fb0eeca1a25b89e4d3f89e828fe12a519b782a", 181 | "sha256:7b8b454bac16428b22560d0a1cf0a09875339cab69df61d7805bf48919415901", 182 | "sha256:7e5bab140c309cb3a6ce373a9e71eb7e4873c70c2dda01df6820474f9889d6d4", 183 | "sha256:83d78376d0d4fd884e2c114d0621624b73d2aba4e2788182d286309ebdeed770", 184 | "sha256:852542f9481f4a62dbb5dd99e8ab7aedfeb8fb6342349a181d4036877410f525", 185 | "sha256:85267bd1aa8880a9c88a8cb71e18d3d64d2751a790e6ca6c27b8ccc724bcd5ad", 186 | "sha256:88a2df29d4724b9237fc0c6eaf2a1adae0cdc0b3e9f4d8e7dc54b16812d2d81a", 187 | "sha256:88b9f257ca61b838b6f8094a62418421f87ac2a1069f7e896c36a7d86b5d4c29", 188 | "sha256:8ab3919a9997ab7ef2fbbed0cc99bb28d3c13e6d4b1ad36e97e482558a91be90", 189 | "sha256:92dea1ffe3714fa8eb6a314d2b3c773208d865a0e0d35e713ec54eea08a66250", 190 | "sha256:9407b6a5f0d675e8a827ad8742e1d6b49d9c1a1da5d952a67d50ef5f4170b18d", 191 | "sha256:9408acf3270c4b6baad483865191e3e582b638b1654a007c62e3efe96f09a9a3", 192 | "sha256:955e8513d07a283056b1396e9a57ceddbd272d9252c14f154d450d227606eb54", 193 | "sha256:9db8ea4c388fdb0f780fe91346fd438657ea602d58348753d9fb265ce1bca67f", 194 | "sha256:9eaa8b117dc8337728e834b9c6e2611f10c79e38f65157c4c38e9400286f5cb1", 195 | "sha256:a51a263952b1429e429ff236d2f5a21c5125437861baeed77f5e1cc2d2c7c6da", 196 | "sha256:a6aa6315319a052b4ee378aa171959c898a6183f15c1e541821c5c59beaa0238", 197 | "sha256:aa12042de0171fad672b6c59df69106d20d5596e4f87b5e8f76df757a7c399aa", 198 | "sha256:aaf7be1207676ac608a50cd08f102f6742dbfc70e8d60c4db1c6897f62f71523", 199 | "sha256:b0157420efcb803e71d1b28e2c287518b8808b7cf1ab8af36718fd0a2c453eb0", 200 | "sha256:b3f7e75f3015df442238cca659f8baa5f42ce2a8582727981cbfa15fee0ee205", 201 | "sha256:b9098e0049e88c6a24ff64545cdfc50807818ba6c1b739cae221bbbcbc58aad3", 202 | "sha256:ba55dce0a9b8ff59495ddd050a0225d58bd0983d09f87cfe2b6aec4f2c1234e4", 203 | "sha256:bb86433b1cfe686da83ce32a9d3a8dd308e85c76b60896d58f082136f10bffac", 204 | "sha256:bbea0db94288e29afcc4c28afbf3a7ccaf2d7e027489c449cf7e8f83c6346eb9", 205 | "sha256:bbf1d63eef84b2e8c89011b7f2235b1e0bf7dacc11cac9431fc6468e99ac77fb", 206 | "sha256:c7940c1dc63eb37a67721b10d703247552416f719c4188c54e04334321351ced", 207 | "sha256:c9bf3325c47b11b2e51bca0824ea217c7cd84491d8ac4eefd1e409705ef092bd", 208 | "sha256:cdc8a402aaee9a798b50d8b827d7ecf75edc5fb35ea0f91f213ff927c15f4ff0", 209 | "sha256:ceec1a6bc6cab1d6ff5d06592a91a692f90ec7505d6463a88a52cc0eb58545da", 210 | "sha256:cfe6ab8da05c01ba6fbea630377b5da2cd9bcbc6338510116b01c1bc939a2c18", 211 | "sha256:d099e745a512f7e3bbe7249ca835f4d357c586d78d79ae8f1dcd4d8adeb9bda9", 212 | "sha256:d0ef46024e6a3d79c01ff13801cb19d0cad7fd859b15037aec74315540acc276", 213 | "sha256:d2e5a98f0ec99beb3c10e13b387f8db39106d53993f498b295f0c914328b1333", 214 | "sha256:da4cfb373035def307905d05041c1d06d8936452fe89d464743ae7fb8371078b", 215 | "sha256:da802a19d6e15dffe4b0c24b38b3af68e6c1a68e6e1d8f30148c83864f3881db", 216 | "sha256:dced8146011d2bc2e883f9bd68618b8247387f4bbec46d7392b3c3b032640126", 217 | "sha256:dfdd7c0b105af050eb3d64997809dc21da247cf44e63dc73ff0fd20b96be55a9", 218 | "sha256:e368f200bbc2e4f905b8e71eb38b3c04333bddaa6a2464a6355487b02bb7fb09", 219 | "sha256:e391b1f0a8a5a10ab3b9bb6afcfd74f2175f24f8975fb87ecae700d1503cdee0", 220 | "sha256:e57e563a57fb22a142da34f38acc2fc1a5c864bc29ca1517a88abc963e60d6ec", 221 | "sha256:e5d706eba36b4c4d5bc6c6377bb6568098765e990cfc21ee16d13963fab7b3e7", 222 | "sha256:ec20916e7b4cbfb1f12380e46486ec4bcbaa91a9c448b97023fde0d5bbf9e4ff", 223 | "sha256:f1d072c2eb0ad60d4c183f3fb44ac6f73fb7a8f16a2694a91f988275cbf352f9", 224 | "sha256:f846c260f483d1fd217fe5ed7c173fb109efa6b1fc8381c8b7552c5781756192", 225 | "sha256:f91de7223d4c7b793867797bacd1ee53bfe7359bd70d27b7b58a04efbb9436c8", 226 | "sha256:faae4860798c31530dd184046a900e652c95513796ef51a12bc086710c2eec4d", 227 | "sha256:fc579bf0f502e54926519451b920e875f433aceb4624a3646b3252b5caa9e0b6", 228 | "sha256:fcc700eadbbccbf6bc1bcb9dbe0786b4b1cb91ca0dcda336eef5c2beed37b797", 229 | "sha256:fd32ea360bcbb92d28933fc05ed09bffcb1704ba3fc7942e81db0fd4f81a7892", 230 | "sha256:fdb7adb641a0d13bdcd4ef48e062363d8a9ad4a182ac7647ec88f695e719ae9f" 231 | ], 232 | "markers": "python_version >= '3.7'", 233 | "version": "==1.4.5" 234 | }, 235 | "matplotlib": { 236 | "hashes": [ 237 | "sha256:04b36ad07eac9740fc76c2aa16edf94e50b297d6eb4c081e3add863de4bb19a7", 238 | "sha256:09074f8057917d17ab52c242fdf4916f30e99959c1908958b1fc6032e2d0f6d4", 239 | "sha256:1c5c8290074ba31a41db1dc332dc2b62def469ff33766cbe325d32a3ee291aea", 240 | "sha256:242489efdb75b690c9c2e70bb5c6550727058c8a614e4c7716f363c27e10bba1", 241 | "sha256:40321634e3a05ed02abf7c7b47a50be50b53ef3eaa3a573847431a545585b407", 242 | "sha256:4c6e00a65d017d26009bac6808f637b75ceade3e1ff91a138576f6b3065eeeba", 243 | "sha256:5184e07c7e1d6d1481862ee361905b7059f7fe065fc837f7c3dc11eeb3f2f900", 244 | "sha256:5745f6d0fb5acfabbb2790318db03809a253096e98c91b9a31969df28ee604aa", 245 | "sha256:5e431a09e6fab4012b01fc155db0ce6dccacdbabe8198197f523a4ef4805eb26", 246 | "sha256:5f557156f7116be3340cdeef7f128fa99b0d5d287d5f41a16e169819dcf22357", 247 | "sha256:6728dde0a3997396b053602dbd907a9bd64ec7d5cf99e728b404083698d3ca01", 248 | "sha256:7b416239e9ae38be54b028abbf9048aff5054a9aba5416bef0bd17f9162ce161", 249 | "sha256:7c42dae72a62f14982f1474f7e5c9959fc4bc70c9de11cc5244c6e766200ba65", 250 | "sha256:813925d08fb86aba139f2d31864928d67511f64e5945ca909ad5bc09a96189bb", 251 | "sha256:83c0653c64b73926730bd9ea14aa0f50f202ba187c307a881673bad4985967b7", 252 | "sha256:83e0f72e2c116ca7e571c57aa29b0fe697d4c6425c4e87c6e994159e0c008635", 253 | "sha256:b3c5f96f57b0369c288bf6f9b5274ba45787f7e0589a34d24bdbaf6d3344632f", 254 | "sha256:b97653d869a71721b639714b42d87cda4cfee0ee74b47c569e4874c7590c55c5", 255 | "sha256:bf5932eee0d428192c40b7eac1399d608f5d995f975cdb9d1e6b48539a5ad8d0", 256 | "sha256:c4af3f7317f8a1009bbb2d0bf23dfaba859eb7dd4ccbd604eba146dccaaaf0a4", 257 | "sha256:cd3a0c2be76f4e7be03d34a14d49ded6acf22ef61f88da600a18a5cd8b3c5f3c", 258 | "sha256:cf60138ccc8004f117ab2a2bad513cc4d122e55864b4fe7adf4db20ca68a078f", 259 | "sha256:d7e7e0993d0758933b1a241a432b42c2db22dfa37d4108342ab4afb9557cbe3e", 260 | "sha256:e7b49ab49a3bea17802df6872f8d44f664ba8f9be0632a60c99b20b6db2165b7", 261 | "sha256:e9764df0e8778f06414b9d281a75235c1e85071f64bb5d71564b97c1306a2afc", 262 | "sha256:ef6c1025a570354297d6c15f7d0f296d95f88bd3850066b7f1e7b4f2f4c13a39", 263 | "sha256:f386cf162b059809ecfac3bcc491a9ea17da69fa35c8ded8ad154cd4b933d5ec", 264 | "sha256:fa93695d5c08544f4a0dfd0965f378e7afc410d8672816aff1e81be1f45dbf2e" 265 | ], 266 | "index": "pypi", 267 | "version": "==3.8.3" 268 | }, 269 | "numpy": { 270 | "hashes": [ 271 | "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", 272 | "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", 273 | "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", 274 | "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", 275 | "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", 276 | "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", 277 | "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea", 278 | "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c", 279 | "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", 280 | "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", 281 | "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be", 282 | "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", 283 | "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", 284 | "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", 285 | "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", 286 | "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd", 287 | "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c", 288 | "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", 289 | "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0", 290 | "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c", 291 | "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", 292 | "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", 293 | "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", 294 | "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6", 295 | "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", 296 | "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", 297 | "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30", 298 | "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", 299 | "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", 300 | "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", 301 | "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", 302 | "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", 303 | "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764", 304 | "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", 305 | "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3", 306 | "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f" 307 | ], 308 | "markers": "python_version >= '3.9'", 309 | "version": "==1.26.4" 310 | }, 311 | "packaging": { 312 | "hashes": [ 313 | "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", 314 | "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" 315 | ], 316 | "markers": "python_version >= '3.7'", 317 | "version": "==23.2" 318 | }, 319 | "pillow": { 320 | "hashes": [ 321 | "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8", 322 | "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39", 323 | "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac", 324 | "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869", 325 | "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e", 326 | "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04", 327 | "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9", 328 | "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e", 329 | "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe", 330 | "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef", 331 | "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56", 332 | "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa", 333 | "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f", 334 | "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f", 335 | "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e", 336 | "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a", 337 | "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2", 338 | "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2", 339 | "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5", 340 | "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a", 341 | "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2", 342 | "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213", 343 | "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563", 344 | "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591", 345 | "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c", 346 | "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2", 347 | "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb", 348 | "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757", 349 | "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0", 350 | "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452", 351 | "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad", 352 | "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01", 353 | "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f", 354 | "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5", 355 | "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61", 356 | "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e", 357 | "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b", 358 | "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068", 359 | "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9", 360 | "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588", 361 | "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483", 362 | "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f", 363 | "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67", 364 | "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7", 365 | "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311", 366 | "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6", 367 | "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72", 368 | "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6", 369 | "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129", 370 | "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13", 371 | "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67", 372 | "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c", 373 | "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516", 374 | "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e", 375 | "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e", 376 | "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364", 377 | "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023", 378 | "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1", 379 | "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04", 380 | "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d", 381 | "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a", 382 | "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7", 383 | "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb", 384 | "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4", 385 | "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e", 386 | "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1", 387 | "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48", 388 | "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868" 389 | ], 390 | "markers": "python_version >= '3.8'", 391 | "version": "==10.2.0" 392 | }, 393 | "pyparsing": { 394 | "hashes": [ 395 | "sha256:32c7c0b711493c72ff18a981d24f28aaf9c1fb7ed5e9667c9e84e3db623bdbfb", 396 | "sha256:ede28a1a32462f5a9705e07aea48001a08f7cf81a021585011deba701581a0db" 397 | ], 398 | "markers": "python_full_version >= '3.6.8'", 399 | "version": "==3.1.1" 400 | }, 401 | "pyqt5": { 402 | "hashes": [ 403 | "sha256:501355f327e9a2c38db0428e1a236d25ebcb99304cd6e668c05d1188d514adec", 404 | "sha256:862cea3be95b4b0a2b9678003b3a18edf7bd5eafd673860f58820f246d4bf616", 405 | "sha256:93288d62ebd47b1933d80c27f5d43c7c435307b84d480af689cef2474e87e4c8", 406 | "sha256:b89478d16d4118664ff58ed609e0a804d002703c9420118de7e4e70fa1cb5486", 407 | "sha256:d46b7804b1b10a4ff91753f8113e5b5580d2b4462f3226288e2d84497334898a", 408 | "sha256:ff99b4f91aa8eb60510d5889faad07116d3340041916e46c07d519f7cad344e1" 409 | ], 410 | "index": "pypi", 411 | "version": "==5.15.10" 412 | }, 413 | "pyqt5-qt5": { 414 | "hashes": [ 415 | "sha256:7adff02a33f2f82b409ca115d57fc7e39b22e673d29e35c9d2594164039f0f71", 416 | "sha256:b4c56566683ae905d5f04dd6ab925f02a384d6ce5ff4986eea0720d453666c22" 417 | ], 418 | "version": "==5.15.12" 419 | }, 420 | "pyqt5-sip": { 421 | "hashes": [ 422 | "sha256:0f85fb633a522f04e48008de49dce1ff1d947011b48885b8428838973fbca412", 423 | "sha256:108a15f603e1886988c4b0d9d41cb74c9f9815bf05cefc843d559e8c298a10ce", 424 | "sha256:1c8371682f77852256f1f2d38c41e2e684029f43330f0635870895ab01c02f6c", 425 | "sha256:205cd449d08a2b024a468fb6100cd7ed03e946b4f49706f508944006f955ae1a", 426 | "sha256:29fa9cc964517c9fc3f94f072b9a2aeef4e7a2eda1879cb835d9e06971161cdf", 427 | "sha256:3188a06956aef86f604fb0d14421a110fad70d2a9e943dbacbfc3303f651dade", 428 | "sha256:3a4498f3b1b15f43f5d12963accdce0fd652b0bcaae6baf8008663365827444c", 429 | "sha256:5338773bbaedaa4f16a73c142fb23cc18c327be6c338813af70260b756c7bc92", 430 | "sha256:6e4ac714252370ca037c7d609da92388057165edd4f94e63354f6d65c3ed9d53", 431 | "sha256:773731b1b5ab1a7cf5621249f2379c95e3d2905e9bd96ff3611b119586daa876", 432 | "sha256:7f321daf84b9c9dbca61b80e1ef37bdaffc0e93312edae2cd7da25b953971d91", 433 | "sha256:7fe3375b508c5bc657d73b9896bba8a768791f1f426c68053311b046bcebdddf", 434 | "sha256:96414c93f3d33963887cf562d50d88b955121fbfd73f937c8eca46643e77bf61", 435 | "sha256:9a8cdd6cb66adcbe5c941723ed1544eba05cf19b6c961851b58ccdae1c894afb", 436 | "sha256:9b984c2620a7a7eaf049221b09ae50a345317add2624c706c7d2e9e6632a9587", 437 | "sha256:a7e3623b2c743753625c4650ec7696362a37fb36433b61824cf257f6d3d43cca", 438 | "sha256:bbc7cd498bf19e0862097be1ad2243e824dea56726f00c11cff1b547c2d31d01", 439 | "sha256:d5032da3fff62da055104926ffe76fd6044c1221f8ad35bb60804bcb422fe866", 440 | "sha256:db228cd737f5cbfc66a3c3e50042140cb80b30b52edc5756dbbaa2346ec73137", 441 | "sha256:ec60162e034c42fb99859206d62b83b74f987d58937b3a82bdc07b5c3d190dec", 442 | "sha256:fb4a5271fa3f6bc2feb303269a837a95a6d8dd16be553aa40e530de7fb81bfdf" 443 | ], 444 | "markers": "python_version >= '3.7'", 445 | "version": "==12.13.0" 446 | }, 447 | "python-dateutil": { 448 | "hashes": [ 449 | "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", 450 | "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" 451 | ], 452 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 453 | "version": "==2.8.2" 454 | }, 455 | "scipy": { 456 | "hashes": [ 457 | "sha256:196ebad3a4882081f62a5bf4aeb7326aa34b110e533aab23e4374fcccb0890dc", 458 | "sha256:408c68423f9de16cb9e602528be4ce0d6312b05001f3de61fe9ec8b1263cad08", 459 | "sha256:4bf5abab8a36d20193c698b0f1fc282c1d083c94723902c447e5d2f1780936a3", 460 | "sha256:4c1020cad92772bf44b8e4cdabc1df5d87376cb219742549ef69fc9fd86282dd", 461 | "sha256:5adfad5dbf0163397beb4aca679187d24aec085343755fcdbdeb32b3679f254c", 462 | "sha256:5e32847e08da8d895ce09d108a494d9eb78974cf6de23063f93306a3e419960c", 463 | "sha256:6546dc2c11a9df6926afcbdd8a3edec28566e4e785b915e849348c6dd9f3f490", 464 | "sha256:730badef9b827b368f351eacae2e82da414e13cf8bd5051b4bdfd720271a5371", 465 | "sha256:75ea2a144096b5e39402e2ff53a36fecfd3b960d786b7efd3c180e29c39e53f2", 466 | "sha256:78e4402e140879387187f7f25d91cc592b3501a2e51dfb320f48dfb73565f10b", 467 | "sha256:8b8066bce124ee5531d12a74b617d9ac0ea59245246410e19bca549656d9a40a", 468 | "sha256:8bee4993817e204d761dba10dbab0774ba5a8612e57e81319ea04d84945375ba", 469 | "sha256:913d6e7956c3a671de3b05ccb66b11bc293f56bfdef040583a7221d9e22a2e35", 470 | "sha256:95e5c750d55cf518c398a8240571b0e0782c2d5a703250872f36eaf737751338", 471 | "sha256:9c39f92041f490422924dfdb782527a4abddf4707616e07b021de33467f917bc", 472 | "sha256:a24024d45ce9a675c1fb8494e8e5244efea1c7a09c60beb1eeb80373d0fecc70", 473 | "sha256:a7ebda398f86e56178c2fa94cad15bf457a218a54a35c2a7b4490b9f9cb2676c", 474 | "sha256:b360f1b6b2f742781299514e99ff560d1fe9bd1bff2712894b52abe528d1fd1e", 475 | "sha256:bba1b0c7256ad75401c73e4b3cf09d1f176e9bd4248f0d3112170fb2ec4db067", 476 | "sha256:c3003652496f6e7c387b1cf63f4bb720951cfa18907e998ea551e6de51a04467", 477 | "sha256:e53958531a7c695ff66c2e7bb7b79560ffdc562e2051644c5576c39ff8efb563", 478 | "sha256:e646d8571804a304e1da01040d21577685ce8e2db08ac58e543eaca063453e1c", 479 | "sha256:e7e76cc48638228212c747ada851ef355c2bb5e7f939e10952bc504c11f4e372", 480 | "sha256:f5f00ebaf8de24d14b8449981a2842d404152774c1a1d880c901bf454cb8e2a1", 481 | "sha256:f7ce148dffcd64ade37b2df9315541f9adad6efcaa86866ee7dd5db0c8f041c3" 482 | ], 483 | "index": "pypi", 484 | "version": "==1.12.0" 485 | }, 486 | "six": { 487 | "hashes": [ 488 | "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", 489 | "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" 490 | ], 491 | "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", 492 | "version": "==1.16.0" 493 | } 494 | }, 495 | "develop": { 496 | "black": { 497 | "hashes": [ 498 | "sha256:057c3dc602eaa6fdc451069bd027a1b2635028b575a6c3acfd63193ced20d9c8", 499 | "sha256:08654d0797e65f2423f850fc8e16a0ce50925f9337fb4a4a176a7aa4026e63f8", 500 | "sha256:163baf4ef40e6897a2a9b83890e59141cc8c2a98f2dda5080dc15c00ee1e62cd", 501 | "sha256:1e08fb9a15c914b81dd734ddd7fb10513016e5ce7e6704bdd5e1251ceee51ac9", 502 | "sha256:4dd76e9468d5536abd40ffbc7a247f83b2324f0c050556d9c371c2b9a9a95e31", 503 | "sha256:4f9de21bafcba9683853f6c96c2d515e364aee631b178eaa5145fc1c61a3cc92", 504 | "sha256:61a0391772490ddfb8a693c067df1ef5227257e72b0e4108482b8d41b5aee13f", 505 | "sha256:6981eae48b3b33399c8757036c7f5d48a535b962a7c2310d19361edeef64ce29", 506 | "sha256:7e53a8c630f71db01b28cd9602a1ada68c937cbf2c333e6ed041390d6968faf4", 507 | "sha256:810d445ae6069ce64030c78ff6127cd9cd178a9ac3361435708b907d8a04c693", 508 | "sha256:93601c2deb321b4bad8f95df408e3fb3943d85012dddb6121336b8e24a0d1218", 509 | "sha256:992e451b04667116680cb88f63449267c13e1ad134f30087dec8527242e9862a", 510 | "sha256:9db528bccb9e8e20c08e716b3b09c6bdd64da0dd129b11e160bf082d4642ac23", 511 | "sha256:a0057f800de6acc4407fe75bb147b0c2b5cbb7c3ed110d3e5999cd01184d53b0", 512 | "sha256:ba15742a13de85e9b8f3239c8f807723991fbfae24bad92d34a2b12e81904982", 513 | "sha256:bce4f25c27c3435e4dace4815bcb2008b87e167e3bf4ee47ccdc5ce906eb4894", 514 | "sha256:ca610d29415ee1a30a3f30fab7a8f4144e9d34c89a235d81292a1edb2b55f540", 515 | "sha256:d533d5e3259720fdbc1b37444491b024003e012c5173f7d06825a77508085430", 516 | "sha256:d84f29eb3ee44859052073b7636533ec995bd0f64e2fb43aeceefc70090e752b", 517 | "sha256:e37c99f89929af50ffaf912454b3e3b47fd64109659026b678c091a4cd450fb2", 518 | "sha256:e8a6ae970537e67830776488bca52000eaa37fa63b9988e8c487458d9cd5ace6", 519 | "sha256:faf2ee02e6612577ba0181f4347bcbcf591eb122f7841ae5ba233d12c39dcb4d" 520 | ], 521 | "index": "pypi", 522 | "version": "==24.2.0" 523 | }, 524 | "click": { 525 | "hashes": [ 526 | "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", 527 | "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" 528 | ], 529 | "markers": "python_version >= '3.7'", 530 | "version": "==8.1.7" 531 | }, 532 | "flake8": { 533 | "hashes": [ 534 | "sha256:33f96621059e65eec474169085dc92bf26e7b2d47366b70be2f67ab80dc25132", 535 | "sha256:a6dfbb75e03252917f2473ea9653f7cd799c3064e54d4c8140044c5c065f53c3" 536 | ], 537 | "index": "pypi", 538 | "version": "==7.0.0" 539 | }, 540 | "iniconfig": { 541 | "hashes": [ 542 | "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", 543 | "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" 544 | ], 545 | "markers": "python_version >= '3.7'", 546 | "version": "==2.0.0" 547 | }, 548 | "mccabe": { 549 | "hashes": [ 550 | "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", 551 | "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e" 552 | ], 553 | "markers": "python_version >= '3.6'", 554 | "version": "==0.7.0" 555 | }, 556 | "mypy": { 557 | "hashes": [ 558 | "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6", 559 | "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d", 560 | "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02", 561 | "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d", 562 | "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3", 563 | "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3", 564 | "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3", 565 | "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66", 566 | "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259", 567 | "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835", 568 | "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd", 569 | "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d", 570 | "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8", 571 | "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07", 572 | "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b", 573 | "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e", 574 | "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6", 575 | "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae", 576 | "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9", 577 | "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d", 578 | "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a", 579 | "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592", 580 | "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218", 581 | "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817", 582 | "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4", 583 | "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410", 584 | "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55" 585 | ], 586 | "index": "pypi", 587 | "version": "==1.8.0" 588 | }, 589 | "mypy-extensions": { 590 | "hashes": [ 591 | "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", 592 | "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782" 593 | ], 594 | "markers": "python_version >= '3.5'", 595 | "version": "==1.0.0" 596 | }, 597 | "packaging": { 598 | "hashes": [ 599 | "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", 600 | "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" 601 | ], 602 | "markers": "python_version >= '3.7'", 603 | "version": "==23.2" 604 | }, 605 | "pathspec": { 606 | "hashes": [ 607 | "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", 608 | "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712" 609 | ], 610 | "markers": "python_version >= '3.8'", 611 | "version": "==0.12.1" 612 | }, 613 | "platformdirs": { 614 | "hashes": [ 615 | "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068", 616 | "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768" 617 | ], 618 | "markers": "python_version >= '3.8'", 619 | "version": "==4.2.0" 620 | }, 621 | "pluggy": { 622 | "hashes": [ 623 | "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981", 624 | "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be" 625 | ], 626 | "markers": "python_version >= '3.8'", 627 | "version": "==1.4.0" 628 | }, 629 | "pycodestyle": { 630 | "hashes": [ 631 | "sha256:41ba0e7afc9752dfb53ced5489e89f8186be00e599e712660695b7a75ff2663f", 632 | "sha256:44fe31000b2d866f2e41841b18528a505fbd7fef9017b04eff4e2648a0fadc67" 633 | ], 634 | "markers": "python_version >= '3.8'", 635 | "version": "==2.11.1" 636 | }, 637 | "pyflakes": { 638 | "hashes": [ 639 | "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f", 640 | "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a" 641 | ], 642 | "markers": "python_version >= '3.8'", 643 | "version": "==3.2.0" 644 | }, 645 | "pytest": { 646 | "hashes": [ 647 | "sha256:267f6563751877d772019b13aacbe4e860d73fe8f651f28112e9ac37de7513ae", 648 | "sha256:3e4f16fe1c0a9dc9d9389161c127c3edc5d810c38d6793042fb81d9f48a59fca" 649 | ], 650 | "index": "pypi", 651 | "version": "==8.0.1" 652 | }, 653 | "typing-extensions": { 654 | "hashes": [ 655 | "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783", 656 | "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd" 657 | ], 658 | "markers": "python_version >= '3.8'", 659 | "version": "==4.9.0" 660 | }, 661 | "vulture": { 662 | "hashes": [ 663 | "sha256:12d745f7710ffbf6aeb8279ba9068a24d4e52e8ed333b8b044035c9d6b823aba", 664 | "sha256:f0fbb60bce6511aad87ee0736c502456737490a82d919a44e6d92262cb35f1c2" 665 | ], 666 | "index": "pypi", 667 | "version": "==2.11" 668 | } 669 | } 670 | } 671 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | On Light, Colors, Mixing Paints, and Numerical Optimization. 2 | ----------------------- 3 | 4 | This is a short write-up that is supposed to serve as a rough description of what's going on in the paint mixing tool in this depot. 5 | 6 | The tool is a virtual paint mixing tool and a solver that can generate recipes for creating a particular color out of existing paints. The tool comes with data for Kimera paints that I measured. The tool is a Python 3 program; it comes with all the sources, and if you have a Python distribution, you can just run it. There's also a Windows executable created with PyInstaller (see 'Releases', on the right). I can probably create a MacOS version too, if need be (edit: I actually added one; there's a .dmg file, and it does have something in it, and if you double-click it, it does show up, so it seems to work, but honestly, I barely use Mac, so it's hard for me to say if this is the right way, or is something more expected...) 7 | 8 | If you just want to grab the tool and play with it, that's about it! Have fun, and I hope you find it at least somewhat useful. 9 | 10 | But below, you'll find a more or less complete description of how it works (and when it doesn't). So, if you have a bit of time to spare, read on! 11 | 12 | Introduction 13 | ------------ 14 | 15 | Very recently, I discovered miniature painting. I was never really into WH40K or anything related, but I have some fond memories of playing pen & paper RPGs years ago, and after watching a bunch of YouTube videos, I thought it looked easy enough to try. I still suck at it, but I somehow really enjoy the tranquilizing experience of putting thin layers of paint onto 3 cm tall figurines. 16 | 17 | 18 |
19 |
20 |
41 |
42 |
51 |
52 |
57 |
58 |
66 |
67 |
72 |
73 |
78 |
79 |
93 |
94 |
99 |
100 |
107 |
108 |
113 |
114 |
119 |
120 |
125 |
126 |
133 |
134 |
137 |
138 | =
139 |
140 | *
141 |
142 |
155 |
156 |
161 |
162 |
176 |
177 |
182 |
183 |
189 |
190 |
201 |
202 |
210 |
211 |
217 |
218 |
245 |
246 |
253 |
254 |
260 |
261 |
271 |
272 |
274 |
275 |
284 |
285 |
290 |
291 |
316 |
317 |
331 |
332 |
339 |
340 |
341 |
347 |
348 |
362 |
363 |
373 |
374 |
380 |
381 |
386 |
387 |
392 |
393 |
398 |
399 |
404 |
405 |
415 |
416 |
421 |
422 |
431 |
432 |
437 |
438 |