diff --git a/clgui.py b/clgui.py index 4ecc2aa..6484734 100644 --- a/clgui.py +++ b/clgui.py @@ -1,15 +1,28 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Dec 28 12:24:04 2020 - -@author: priscillababiak -""" - -from PyQt5.QtWidgets import QApplication -import sys -from dataManager.loadfiles4CIE import RGBImage - -app = QApplication(sys.argv) -RGBImageApp = RGBImage() - -sys.exit(app.exec_()) \ No newline at end of file +# -*- coding: utf-8 -*- +"""ColorLab entry point.""" + +import os +import sys + +# When frozen as a PyInstaller bundle, anchor the working directory to the +# exe's folder so the calc layer's relative paths +# ('dataManager/illuminants.csv', 'dataManager/white_point.csv') resolve and +# generated images land in a writable spot next to the exe. +if getattr(sys, "frozen", False): + os.chdir(os.path.dirname(sys.executable)) + +from PyQt5.QtWidgets import QApplication + +from dataManager.loadfiles4CIE import RGBImage +from ui.theme import apply_theme, app_font, load_mode + +app = QApplication(sys.argv) +app.setApplicationName("ColorLab") +app.setOrganizationName("ColorLab") +app.setStyle("Fusion") +app.setFont(app_font(10)) +apply_theme(app, load_mode()) + +window = RGBImage() + +sys.exit(app.exec_()) diff --git a/dataManager/CIE_XYZ.py b/dataManager/CIE_XYZ.py index 0aef7dd..88a7734 100644 --- a/dataManager/CIE_XYZ.py +++ b/dataManager/CIE_XYZ.py @@ -1,169 +1,296 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Oct 26 19:19:19 2020 - -@author: priscillababiak -""" -import pandas as pd -import numpy as np - -def data_cleanup(loaded_data): - loaded_data['Wavelength'] = loaded_data['Wavelength'].astype(int) - loaded_data = loaded_data.drop_duplicates(subset='Wavelength').reset_index() - return loaded_data - -def CIElab(spec_illum, illum, datatype, df_list, x_bar, y_bar, z_bar, calcRGB): - if datatype == 0: - subdf = df_list[['Wavelength', 'Absorbance']].copy() - subdf['Absorbance'] = 10 ** (-subdf['Absorbance']) - subdf.rename(columns = {'Absorbance':'Transmission'}, inplace=True) - elif datatype == 1: - subdf = df_list[['Wavelength', 'Transmission']].copy() - elif datatype == 2: - subdf = df_list[['Wavelength', 'FT']].copy() - subdf['FT'] = (1+subdf['FT']) * 100 - subdf.rename(columns = {'FT':'Transmission'}, inplace=True) - else: - print('This should never print') - - - subdf = data_cleanup(subdf) - wavelength = subdf["Wavelength"] - - step_size_data = abs(wavelength[0] - wavelength[1]) - - step_size = int(abs(illum["Wavelength"][0]-illum["Wavelength"][1])/step_size_data) - - # trim input data to the range of 380-780nm - temp_1 = subdf.loc[subdf["Wavelength"]==380].index[0] - temp_2 = subdf.loc[subdf["Wavelength"]==780].index[0] - - trimmed_data = subdf.iloc[temp_1:temp_2+1,:] - trimmed_data = trimmed_data[['Wavelength','Transmission']] - trimmed_data = trimmed_data.iloc[::step_size] # resize data based on step size - trimmed_data.reset_index(inplace=True, drop=True) # reset indices - - # trim illum data to the range of 380-780nm - temp_3 = illum.loc[illum["Wavelength"]==380].index[0] - temp_4 = illum.loc[illum["Wavelength"]==780].index[0] - - trimmed_illum = illum.iloc[temp_3:temp_4+1,:] - trimmed_illum.reset_index(inplace=True, drop=True) # reset indices - T = trimmed_data['Transmission'] - - - def tristimCalc(T, spec_illum): - K = 1 / sum(y_bar*np.asarray(trimmed_illum[spec_illum])) # normalizing term - CIE_X = sum(T*x_bar*np.asarray(trimmed_illum[spec_illum])) * (K) - CIE_Y = sum(T*y_bar*np.asarray(trimmed_illum[spec_illum])) * (K) - CIE_Z = sum(T*z_bar*np.asarray(trimmed_illum[spec_illum])) * (K) - # print("calc CIE XYZ:", CIE_X, CIE_Y, CIE_Z) - return CIE_X, CIE_Y, CIE_Z - - - CIE_X, CIE_Y, CIE_Z = tristimCalc(T, spec_illum) - - - def bradford(CIE_X, CIE_Y, CIE_Z, spec_illum): - if spec_illum == "Standard Illuminant D65": - return CIE_X, CIE_Y, CIE_Z - else: - source = np.matrix([[CIE_X], [CIE_Y], [CIE_Z]]) - whites = pd.read_csv("dataManager/white_point.csv") - - # D65 will always be destination color - ma = np.matrix([[0.8951000, 0.266400, -0.1614000], [-0.7502000, 1.7135000, 0.036700], [0.0389000, -0.0685000, 1.0296000]]) - inv_ma = ma ** -1 - - d65_white = np.matrix([[0.95047], [1.0000], [1.08883]]) - - - src_white = np.matrix([[whites[spec_illum][0]], [whites[spec_illum][1]], [whites[spec_illum][2]]]) - - # cone response matrices - d65_cr = ma * d65_white - src_cr = ma * src_white - - term_matrix = np.matrix([[(d65_cr[0,0]/src_cr[0,0]), 0, 0], [0, (d65_cr[1, 0]/ src_cr[1,0]), 0], [0, 0 , (d65_cr[2,0]/src_cr[2,0])]]) - m = inv_ma * term_matrix * ma - destination = m * source - return destination[0,0], destination[1,0], destination[2,0] - - - CIE_X, CIE_Y, CIE_Z = bradford(CIE_X, CIE_Y, CIE_Z, spec_illum) - # print("post bradford CIE XYZ", CIE_X, CIE_Y, CIE_Z) - # norm = max(CIE_X, CIE_Y, CIE_Z) - # CIE_X = CIE_X / norm - # CIE_Y = CIE_Y / norm - # CIE_Z = CIE_Z / norm - # print("post norm CIE XYZ", CIE_X, CIE_Y, CIE_Z) - - - if calcRGB: # convert X,Y,Z tristimulus values to rgb - r,g,b = xyz2rbg(spec_illum,CIE_X,CIE_Y,CIE_Z) - else: - r = 0 - g = 0 - b = 0 - - #depreciated, too lazy to get rid of these vestigial variables. - CIE_L = 0 - CIE_a = 0 - CIE_b = 0 - - return CIE_L,CIE_a,CIE_b,r,g,b - -def xyz2rbg(spec_illum,X,Y,Z): - - - # sRGB - def sRGB(X, Y, Z): - R = (X * 3.2410) + (Y * -1.5374) + (Z * -0.4986) - G = (X * -0.9692) + (Y * 1.8760) + (Z * 0.0416) - B = (X * 0.0556) + (Y * -0.2040) + (Z * 1.0570) - # print("pre gamma RGB", R, G, B) - # print("RGB calc:", R, G, B) - - - def gamma_adj(C): - if C < 0.0031308: - return 12.92 * C - else: - return 1.055 * C**0.41666 - 0.055 - - def clipping(C): - if C > 1: - C = 1 - if C < 0: - C = 0 - return C - - - R = clipping(R) - G = clipping(G) - B = clipping(B) - - R = gamma_adj(R) - G = gamma_adj(G) - B = gamma_adj(B) - - return R, G, B - - - def adobeRGB(X,Y,Z): - print("in the works") - - # print('pre srgb:', X, Y, Z) - R, G, B = sRGB(X, Y, Z) - # print("post gamma RGB", R, G, B) - - - # sRGB conversion - r = round(R * 255, 0) - g = round(G * 255, 0) - b = round(B * 255, 0) - - # print(r,g,b) - - return r, g, b - \ No newline at end of file +# -*- coding: utf-8 -*- +"""CIE 1931 colorimetry: tristimulus, Bradford chromatic adaptation, sRGB. + +Designed so each spectrum costs roughly one (81,) dot product plus a 3x3 +matmul. All constants - color matching functions, illuminant tables, white +points, Bradford matrices - load once at import time; per-call work is just +the trimmed transmission vector and a few cached matrix lookups. +""" + +import functools +import pandas as pd +import numpy as np + + +# ---------------------------------------------------------------------------- +# Constants (loaded once per process). +# ---------------------------------------------------------------------------- + +# CIE 1931 2-degree standard observer color matching functions, sampled at +# 5nm from 380nm to 780nm (length 81). Values from CIE-1931 standard tables. +X_BAR = np.array([ + 0.001368, 0.002236, 0.004243, 0.00765, 0.01431, 0.02319, 0.04351, 0.07763, + 0.13438, 0.21477, 0.2839, 0.3285, 0.34828, 0.34806, 0.3362, 0.3187, 0.2908, + 0.2511, 0.19536, 0.1421, 0.09564, 0.05795, 0.03201, 0.0147, 0.0049, 0.0024, + 0.0093, 0.0291, 0.06327, 0.1096, 0.1655, 0.22575, 0.2904, 0.3597, 0.43345, + 0.51205, 0.5945, 0.6784, 0.7621, 0.8425, 0.9163, 0.9786, 1.0263, 1.0567, + 1.0622, 1.0456, 1.0026, 0.9384, 0.85445, 0.7514, 0.6424, 0.5419, 0.4479, + 0.3608, 0.2835, 0.2187, 0.1649, 0.1212, 0.0874, 0.0636, 0.04677, 0.0329, + 0.0227, 0.01584, 0.011359, 0.008111, 0.00579, 0.004109, 0.002899, 0.002049, + 0.00144, 0.001, 0.00069, 0.000476, 0.000332, 0.000235, 0.000166, 0.000117, + 8.3e-05, 5.9e-05, 4.2e-05, +], dtype=np.float64) + +Y_BAR = np.array([ + 3.9e-05, 6.4e-05, 0.00012, 0.000217, 0.000396, 0.00064, 0.00121, 0.00218, + 0.004, 0.0073, 0.0116, 0.01684, 0.023, 0.0298, 0.038, 0.048, 0.06, 0.0739, + 0.09098, 0.1126, 0.13902, 0.1693, 0.20802, 0.2586, 0.323, 0.4073, 0.503, + 0.6082, 0.71, 0.7932, 0.862, 0.91485, 0.954, 0.9803, 0.99495, 1, 0.995, + 0.9786, 0.952, 0.9154, 0.87, 0.8163, 0.757, 0.6949, 0.631, 0.5668, 0.503, + 0.4412, 0.381, 0.321, 0.265, 0.217, 0.175, 0.1382, 0.107, 0.0816, 0.061, + 0.04458, 0.032, 0.0232, 0.017, 0.01192, 0.00821, 0.005723, 0.004102, + 0.002929, 0.002091, 0.001484, 0.001047, 0.00074, 0.00052, 0.000361, + 0.000249, 0.000172, 0.00012, 8.5e-05, 6e-05, 4.2e-05, 3e-05, 2.1e-05, + 1.5e-05, +], dtype=np.float64) + +Z_BAR = np.array([ + 0.00645, 0.01055, 0.02005, 0.03621, 0.06785, 0.1102, 0.2074, 0.3713, + 0.6456, 1.03905, 1.3856, 1.62296, 1.74706, 1.7826, 1.77211, 1.7441, 1.6692, + 1.5281, 1.28764, 1.0419, 0.81295, 0.6162, 0.46518, 0.3533, 0.272, 0.2123, + 0.1582, 0.1117, 0.07825, 0.05725, 0.04216, 0.02984, 0.0203, 0.0134, + 0.00875, 0.00575, 0.0039, 0.00275, 0.0021, 0.0018, 0.00165, 0.0014, 0.0011, + 0.001, 0.0008, 0.0006, 0.00034, 0.00024, 0.00019, 0.0001, 5e-05, 3e-05, + 2e-05, 1e-05, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, +], dtype=np.float64) + +# Bradford chromatic adaptation transform (cone response basis). +_MA = np.array([ + [0.8951000, 0.2664000, -0.1614000], + [-0.7502000, 1.7135000, 0.0367000], + [0.0389000, -0.0685000, 1.0296000], +], dtype=np.float64) +_MA_INV = np.linalg.inv(_MA) + +_D65_WHITE = np.array([0.95047, 1.0000, 1.08883], dtype=np.float64) +_D65_CR = _MA @ _D65_WHITE # cone response for D65, computed once. + +# sRGB conversion matrix (D65 white point). +_M_SRGB = np.array([ + [3.2410, -1.5374, -0.4986], + [-0.9692, 1.8760, 0.0416], + [0.0556, -0.2040, 1.0570], +], dtype=np.float64) + +# Loaded once. Both files are bundled as PyInstaller datas and the entry +# point chdir's to the exe folder before this module is imported. +_WHITE_POINTS = pd.read_csv("dataManager/white_point.csv") +_ILLUM_DF = pd.read_csv("dataManager/illuminants.csv") + +D65_NAME = "Standard Illuminant D65" + + +# ---------------------------------------------------------------------------- +# Cached per-illuminant lookups. +# ---------------------------------------------------------------------------- + +@functools.lru_cache(maxsize=None) +def _trimmed_illum(spec_illum): + """Illuminant power, trimmed to 380-780nm at 5nm step (length 81).""" + wl = _ILLUM_DF["Wavelength"].to_numpy() + i380 = int(np.where(wl == 380)[0][0]) + i780 = int(np.where(wl == 780)[0][0]) + # Illuminant table is at 5nm step already (matches the bar arrays). + return _ILLUM_DF[spec_illum].to_numpy()[i380:i780 + 1] + + +@functools.lru_cache(maxsize=None) +def _bradford_matrix(spec_illum): + """3x3 chromatic-adaptation matrix from spec_illum to D65. + + Returns None when spec_illum is already D65 (no transform needed). + """ + if spec_illum == D65_NAME: + return None + src_white = np.array([ + _WHITE_POINTS[spec_illum][0], + _WHITE_POINTS[spec_illum][1], + _WHITE_POINTS[spec_illum][2], + ], dtype=np.float64) + src_cr = _MA @ src_white + diag = np.diag(_D65_CR / src_cr) + return _MA_INV @ diag @ _MA + + +# ---------------------------------------------------------------------------- +# Vectorized inner kernels. +# ---------------------------------------------------------------------------- + +def _gamma_srgb(c): + """sRGB gamma curve, vectorized.""" + return np.where(c < 0.0031308, 12.92 * c, 1.055 * np.power(c, 0.41666) - 0.055) + + +def xyz_to_rgb255(XYZ): + """Convert (..., 3) XYZ -> (..., 3) sRGB rounded uint16 in [0, 255]. + + Output dtype is float to match the existing return type from xyz2rbg + (which used `round(R * 255, 0)` returning a Python float). Caller + casts as needed. + """ + XYZ = np.atleast_2d(np.asarray(XYZ, dtype=np.float64)) + rgb_lin = XYZ @ _M_SRGB.T + rgb_lin = np.clip(rgb_lin, 0.0, 1.0) + rgb = _gamma_srgb(rgb_lin) + return np.round(rgb * 255.0) + + +def tristimulus_batch(T_matrix, spec_illum): + """Compute D65-adapted CIE XYZ for one or many transmission spectra. + + Parameters + ---------- + T_matrix : ndarray + Shape (81,) for a single spectrum or (N, 81) for N spectra. Values + in [0, 1] (fraction transmittance). + spec_illum : str + Illuminant column name (matches `dataManager/illuminants.csv`). + + Returns + ------- + XYZ : ndarray + Shape (3,) or (N, 3), in D65 reference white. + """ + T = np.asarray(T_matrix, dtype=np.float64) + illum_arr = _trimmed_illum(spec_illum) + wx = X_BAR * illum_arr + wy = Y_BAR * illum_arr + wz = Z_BAR * illum_arr + K = 1.0 / wy.sum() # normalizing constant + + if T.ndim == 1: + XYZ = np.array([np.dot(T, wx), np.dot(T, wy), np.dot(T, wz)]) * K + else: + weights = np.column_stack([wx, wy, wz]) # (81, 3) + XYZ = (T @ weights) * K # (N, 3) + + M = _bradford_matrix(spec_illum) + if M is not None: + XYZ = XYZ @ M.T + return XYZ + + +# ---------------------------------------------------------------------------- +# Public API. +# ---------------------------------------------------------------------------- + +def data_cleanup(loaded_data): + loaded_data["Wavelength"] = loaded_data["Wavelength"].astype(int) + return loaded_data.drop_duplicates(subset="Wavelength").reset_index() + + +def _trim_to_5nm(df, datatype): + """Convert datatype column -> Transmission, trim to 380-780nm at 5nm step. + + Returns the (81,) numpy array of transmission values. + """ + if datatype == 0: + sub = df[["Wavelength", "Absorbance"]].copy() + sub["Absorbance"] = 10 ** (-sub["Absorbance"]) + sub.rename(columns={"Absorbance": "Transmission"}, inplace=True) + elif datatype == 1: + sub = df[["Wavelength", "Transmission"]].copy() + elif datatype == 2: + sub = df[["Wavelength", "FT"]].copy() + sub["FT"] = (1 + sub["FT"]) * 100 + sub.rename(columns={"FT": "Transmission"}, inplace=True) + else: + raise ValueError(f"Unknown datatype {datatype!r}") + + sub = data_cleanup(sub) + wavelength = sub["Wavelength"].to_numpy() + + data_step = abs(int(wavelength[0]) - int(wavelength[1])) + illum_step = 5 # bar arrays are at 5nm; illuminant table matches + step = max(1, illum_step // data_step) + + i380 = int(np.where(wavelength == 380)[0][0]) + i780 = int(np.where(wavelength == 780)[0][0]) + T = sub["Transmission"].to_numpy()[i380:i780 + 1:step] + return T + + +def CIElab(spec_illum, datatype, df, calc_rgb=True, **_legacy_kwargs): + """Single-spectrum CIE pipeline. Returns (cx, cy, 0.0, r, g, b). + + `**_legacy_kwargs` swallows the historical (illum, x_bar, y_bar, z_bar) + positional parameters if any caller still passes them - they are now + module-level constants. + """ + T = _trim_to_5nm(df, datatype) + XYZ = tristimulus_batch(T, spec_illum) + CIE_X, CIE_Y, CIE_Z = float(XYZ[0]), float(XYZ[1]), float(XYZ[2]) + + if calc_rgb: + rgb = xyz_to_rgb255(XYZ).ravel() + r, g, b = float(rgb[0]), float(rgb[1]), float(rgb[2]) + else: + r = g = b = 0.0 + + denom = CIE_X + CIE_Y + CIE_Z + if denom: + cx = CIE_X / denom + cy = CIE_Y / denom + else: + cx = 0.0 + cy = 0.0 + + return cx, cy, 0.0, r, g, b + + +def CIElab_batch(spec_illum, datatype, wavelength_arr, T_matrix, calc_rgb=True): + """Batched CIE pipeline. + + Parameters + ---------- + spec_illum : str + datatype : int + 0 = Absorbance, 1 = Transmission, 2 = FT (matches the GUI radio). + wavelength_arr : ndarray, shape (M,) + Wavelength axis shared by all input spectra. + T_matrix : ndarray, shape (N, M) + Spectrum values in their native datatype (per `datatype`). + + Returns + ------- + lab_values : ndarray, shape (N, 6) + Columns: [cx, cy, 0, r, g, b]. + """ + wavelength_arr = np.asarray(wavelength_arr, dtype=np.float64) + T_matrix = np.asarray(T_matrix, dtype=np.float64) + if T_matrix.ndim != 2: + raise ValueError("T_matrix must be 2D (N, M)") + + if datatype == 0: + T = 10 ** (-T_matrix) + elif datatype == 1: + T = T_matrix + elif datatype == 2: + T = (1 + T_matrix) * 100 + else: + raise ValueError(f"Unknown datatype {datatype!r}") + + # Trim to 380-780 at 5nm step. + wl_int = wavelength_arr.astype(int) + data_step = abs(int(wl_int[0]) - int(wl_int[1])) + illum_step = 5 + step = max(1, illum_step // data_step) + i380 = int(np.where(wl_int == 380)[0][0]) + i780 = int(np.where(wl_int == 780)[0][0]) + T_trimmed = T[:, i380:i780 + 1:step] + + XYZ = tristimulus_batch(T_trimmed, spec_illum) # (N, 3) + + n = XYZ.shape[0] + out = np.zeros((n, 6), dtype=np.float64) + denom = XYZ.sum(axis=1) + nonzero = denom > 0 + out[nonzero, 0] = XYZ[nonzero, 0] / denom[nonzero] + out[nonzero, 1] = XYZ[nonzero, 1] / denom[nonzero] + + if calc_rgb: + rgb = xyz_to_rgb255(XYZ) # (N, 3) + out[:, 3:] = rgb + + return out diff --git a/dataManager/loadfiles4CIE.py b/dataManager/loadfiles4CIE.py index 8e0ffda..31c04d3 100644 --- a/dataManager/loadfiles4CIE.py +++ b/dataManager/loadfiles4CIE.py @@ -1,258 +1,512 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Sep 28 19:40:09 2020 - -@author: priscillababiak -""" - -import os -import sys -import pandas as pd -import numpy as np -from dataManager.CIE_XYZ import CIElab -import matplotlib.pyplot as plt -from PyQt5.QtWidgets import QMainWindow, QFileDialog -from ui.testgui2 import Ui_MainWindow - -illum = pd.read_csv('dataManager/illuminants.csv') - -class RGBImage(QMainWindow): - - def __init__(self) -> None: - super().__init__() - self.gui = Ui_MainWindow() - self.gui.setupUi(self) - self.gui.pushButton.clicked.connect(self.click) - self.gui.pushButton_2.clicked.connect(self.loadFiles) - self.show() - - def click(self): - options = QFileDialog.Options() - options |= QFileDialog.DontUseNativeDialog - self.filepath = QFileDialog.getExistingDirectory(self,"Select File Directory") - self.gui.lineEdit.setText(self.filepath) - self.gui.lineEdit.setReadOnly(False) - - - - def loadFiles(self): - self.statusBar().clearMessage() - datatype = 0 - if self.gui.radioButton.isChecked(): - datatype = 0 - if self.gui.radioButton_2.isChecked(): - datatype = 1 - if self.gui.radioButton_3.isChecked(): - datatype = 2 - filelist = os.listdir(self.filepath) - filelist.sort() - spec_illum = str(self.gui.comboBox.currentText()) # specify the illuminant - image_title = str(self.gui.lineEdit_2.text()) - image_aspect = float(self.gui.lineEdit_3.text()) - calc_rgb = True # do you want to calculate rgb values? - - # xyz bar values for illuminant - x_bar = [0.001368,0.002236,0.004243,0.00765,0.01431,0.02319,0.04351,0.07763,0.13438,0.21477,0.2839,0.3285, \ - 0.34828,0.34806,0.3362,0.3187,0.2908,0.2511,0.19536,0.1421,0.09564,0.05795,0.03201,0.0147,0.0049, \ - 0.0024,0.0093,0.0291,0.06327,0.1096,0.1655,0.22575,0.2904,0.3597,0.43345,0.51205,0.5945,0.6784, \ - 0.7621,0.8425,0.9163,0.9786,1.0263,1.0567,1.0622,1.0456,1.0026,0.9384,0.85445,0.7514,0.6424,0.5419, \ - 0.4479,0.3608,0.2835,0.2187,0.1649,0.1212,0.0874,0.0636,0.04677,0.0329,0.0227,0.01584,0.011359, \ - 0.008111,0.00579,0.004109,0.002899,0.002049,0.00144,0.001,0.00069,0.000476,0.000332,0.000235,0.000166, \ - 0.000117,8.3e-05,5.9e-05,4.2e-05] - - y_bar= [3.9e-05,6.4e-05,0.00012,0.000217,0.000396,0.00064,0.00121,0.00218,0.004,0.0073,0.0116,0.01684,0.023, \ - 0.0298,0.038,0.048,0.06,0.0739,0.09098,0.1126,0.13902,0.1693,0.20802,0.2586,0.323,0.4073,0.503,0.6082,\ - 0.71,0.7932,0.862,0.91485,0.954,0.9803,0.99495,1,0.995,0.9786,0.952,0.9154,0.87,0.8163,0.757,0.6949, \ - 0.631,0.5668,0.503,0.4412,0.381,0.321,0.265,0.217,0.175,0.1382,0.107,0.0816,0.061,0.04458,0.032,0.0232, \ - 0.017,0.01192,0.00821,0.005723,0.004102,0.002929,0.002091,0.001484,0.001047,0.00074,0.00052,0.000361, \ - 0.000249,0.000172,0.00012,8.5e-05,6e-05,4.2e-05,3e-05,2.1e-05,1.5e-05] - - z_bar = [0.00645,0.01055,0.02005,0.03621,0.06785,0.1102,0.2074,0.3713,0.6456,1.03905,1.3856,1.62296,1.74706,1.7826, \ - 1.77211,1.7441,1.6692,1.5281,1.28764,1.0419,0.81295,0.6162,0.46518,0.3533,0.272,0.2123,0.1582,0.1117, \ - 0.07825,0.05725,0.04216,0.02984,0.0203,0.0134,0.00875,0.00575,0.0039,0.00275,0.0021,0.0018,0.00165,0.0014, \ - 0.0011,0.001,0.0008,0.0006,0.00034,0.00024,0.00019,0.0001,5e-05,3e-05,2e-05,1e-05,0,0,0,0,0,0,0,0,0,0,0,0,0, \ - 0,0,0,0,0,0,0,0,0,0,0,0,0,0] - - num_files = len(filelist) - - lab_values = np.zeros((num_files,6)) - delta = np.zeros((1,num_files)) - i = 0 - - # pull first timestamp - first_time = filelist[0].split('_')[-1] - first_t = [int(word) for word in first_time.split('.') if word.isdigit()] - - # check that image title name is valid - #re1 = re.compile(r"^[^<>/{}[\]~`]*$") - chars_to_be_removed = r'^[^<>/{}[\]~`]*$@!;,:' - filtered_chars = filter(lambda item: item not in chars_to_be_removed, image_title) - image_name = ''.join(filtered_chars) - if not os.path.isdir('images/'): - os.mkdir('images/') - image_name = r'images/' + image_name + '.png' - # if re1.match(image_title): - # print ("Image name is valid!") - # image_name = r'images/' + image_name + '.png' - # else: - # error_msg = 'Image name is invalid. Please rename your file.' - # print(error_msg) - # sys.exit() - - for file in filelist: - - #check if the file is a file, or a directory - base_dir = self.filepath - file_name = file - full_path = os.path.join(base_dir, file_name) - isdir = os.path.isdir(full_path) - if isdir: - print('Skipping ',file,' it is a directory!') - continue - else: - if file.endswith('.csv'): - try: - uvvis_data = pd.read_csv(r"{0}/{1}".format(self.filepath,file), sep=None, engine='python') - except: - print(file + ' is corrupt!') - - if file==filelist[-1]: - print('***************************') - print('All files are corrupt!') - print('***************************') - sys.exit(0) - else: - continue - if file.endswith('.xls'): - try: - uvvis_data = pd.read_excel(r"{0}/{1}".format(self.filepath,file)) - except: - print(file + ' is corrupt!') - - if file==filelist[-1]: - print('***************************') - print('All files are corrupt!') - print('***************************') - sys.exit(0) - else: - continue - - else: - try: - uvvis_data = pd.read_table(r"{0}/{1}".format(self.filepath,file), engine='python') - except: - print(file + ' is corrupt!') - - if file==filelist[-1]: - print('***************************') - print('All files are corrupt!') - print('***************************') - sys.exit(0) - else: - continue - - check_data = len(uvvis_data) - if check_data == 0: - continue - - try: - L,a,b,rr,gg,bb = CIElab(spec_illum,illum,datatype,uvvis_data,x_bar,y_bar,z_bar,calc_rgb) - lab_values[i,0] = L - lab_values[i,1] = a - lab_values[i,2] = b - lab_values[i,3] = rr - lab_values[i,4] = gg - lab_values[i,5] = bb - - # extract timestamp - curr_time = file.split('_')[-1] - curr_t = [int(word) for word in curr_time.split('.') if word.isdigit()] - if curr_t[0] > 3660: - seconds_convert = 3600 - units = 'Hours' - else: - seconds_convert = 60 - units = 'Minutes' - - delta[0,i] = (curr_t[0] - first_t[0])/seconds_convert - - i += 1 # end for loop - except: - print('***********************************************************') - print('Could not convert data in ' + file) - print('***********************************************************') - - if file==filelist[-1]: - sys.exit(0) - - # remove rows that were not filled - lab_values = lab_values[~np.all(lab_values == 0, axis=1)] - new_num_files = len(lab_values) - - if new_num_files == 1: - # generate image from the degradation data - scalar = 1 - newdim = scalar*new_num_files - n = 0 - - colormat = np.zeros([newdim,newdim,3], dtype=np.uint16) - for i in range(new_num_files): - colormat[:,n:n+scalar] = lab_values[i,3:] - n += scalar - else: - - if seconds_convert == 3600: delta = delta*60 - - # define the size of the matrix - delta_delta = np.around(np.diff(delta)) - first_t = 1 - - temp_dim = int(np.sum(delta_delta)) - colormat = np.zeros((temp_dim,temp_dim,3), dtype=np.uint8) - for i in range(new_num_files-1): - for k in range(int(delta_delta[0,i])): - if first_t: - colormat[:,i+k] = lab_values[i,3:] - curr_idx = i+k - first_t = 0 - else: - curr_idx = curr_idx+1 - colormat[:,curr_idx] = lab_values[i,3:] - - # resize array - colormat = colormat[0:curr_idx+1,0:curr_idx+1,:] - - len_colormat = len(colormat) - - if (len_colormat > 1): - fig, ax = plt.subplots(1,1) - # figure out axis ticks - # f_idx = new_num_files*0 - # s_idx = round(new_num_files*0.33) - # t_idx = round(new_num_files*0.66) - # l_idx = new_num_files-1 - # ax.set_xticks([f_idx,s_idx,t_idx,l_idx]) - # label_list = [str(round(delta[0,f_idx])),str(round(delta[0,s_idx])), - # str(round(delta[0,t_idx])),str(round(delta[0,l_idx]))] - # ax.set_xticklabels(label_list) - if seconds_convert == 3600: - ax.imshow(colormat,extent=[delta[0,0],np.max(delta)/60,delta[0,0],np.max(delta)/60], - aspect=image_aspect) - else: - ax.imshow(colormat,extent=[delta[0,0],np.max(delta),delta[0,0],np.max(delta)], - aspect=image_aspect) - ax.axes.get_yaxis().set_visible(False) - ax.set_xlabel(units) - ax.set_title(image_title) - fig.savefig(image_name) - else: - fig, ax = plt.subplots(1,1) - ax.imshow(colormat,aspect=image_aspect) - ax.axes.get_xaxis().set_visible(False) - ax.axes.get_yaxis().set_visible(False) - ax.set_title(image_title) - fig.savefig(image_name) - - - self.gui.statusbar.showMessage("Finished!") - +# -*- coding: utf-8 -*- +"""ColorLab file loading + image generation.""" + +import os +import re +import sys +import traceback +import pandas as pd +import numpy as np + +import matplotlib +matplotlib.use("Agg") # non-interactive; safe to call savefig from any thread +import matplotlib.pyplot as plt +from matplotlib.path import Path as MplPath + +from PyQt5.QtCore import QObject, QThread, pyqtSignal +from PyQt5.QtWidgets import QMainWindow, QFileDialog + +from dataManager.CIE_XYZ import ( + CIElab, CIElab_batch, X_BAR, Y_BAR, Z_BAR, xyz_to_rgb255, +) +from ui.testgui2 import Ui_MainWindow + + +# Reuse the cached color matching arrays from CIE_XYZ throughout (no per-call +# list rebuild, no extra np.asarray copies). +_X_BAR = X_BAR +_Y_BAR = Y_BAR +_Z_BAR = Z_BAR + + +# ---------------------------------------------------------------------------- +# Chromaticity-diagram backdrop (gamut RGBA + locus polygon). +# +# The backdrop is data-independent - same picture every Process click - so we +# build it lazily on first use and cache for the rest of the process. +# ---------------------------------------------------------------------------- + +_GAMUT_CACHE = {} + + +def _gamut_backdrop(): + """Return (rgba, locus_xy, extent) for the chromaticity diagram, cached.""" + if _GAMUT_CACHE: + return (_GAMUT_CACHE["rgba"], _GAMUT_CACHE["locus"], _GAMUT_CACHE["extent"]) + + denom = _X_BAR + _Y_BAR + _Z_BAR + good = denom > 0 + locus_x = _X_BAR[good] / denom[good] + locus_y = _Y_BAR[good] / denom[good] + locus = np.column_stack([locus_x, locus_y]) + locus_closed = np.vstack([locus, locus[:1]]) + + res = 256 + grid_x = np.linspace(0.0, 0.8, res) + grid_y = np.linspace(0.0, 0.9, res) + gx, gy = np.meshgrid(grid_x, grid_y) + gz = 1.0 - gx - gy + with np.errstate(divide="ignore", invalid="ignore"): + X = np.where(gy > 0, gx / gy, 0.0) + Y = np.ones_like(gy) + Z = np.where(gy > 0, gz / gy, 0.0) + R = X * 3.2410 + Y * -1.5374 + Z * -0.4986 + G = X * -0.9692 + Y * 1.8760 + Z * 0.0416 + B = X * 0.0556 + Y * -0.2040 + Z * 1.0570 + RGB = np.dstack([R, G, B]) + RGB = np.clip(RGB, 0.0, 1.0) + below = RGB < 0.0031308 + RGB = np.where(below, 12.92 * RGB, 1.055 * np.power(RGB, 0.41666) - 0.055) + peak = np.max(RGB, axis=2, keepdims=True) + RGB = np.where(peak > 0, RGB / np.maximum(peak, 1e-6), RGB) + RGB = np.clip(RGB, 0.0, 1.0) + + pts = np.column_stack([gx.ravel(), gy.ravel()]) + inside = MplPath(locus_closed).contains_points(pts).reshape(gx.shape) + alpha = inside.astype(float) + rgba = np.dstack([RGB, alpha]) + + extent = (0.0, 0.8, 0.0, 0.9) + _GAMUT_CACHE["rgba"] = rgba + _GAMUT_CACHE["locus"] = locus_closed + _GAMUT_CACHE["extent"] = extent + return rgba, locus_closed, extent + + +def save_chromaticity_diagram(lab_values, image_title, image_name): + """Save a CIE 1931 xy chromaticity diagram alongside the color strip.""" + rgba, locus_closed, extent = _gamut_backdrop() + + fig, ax = plt.subplots(1, 1, figsize=(7, 6.5)) + ax.imshow(rgba, origin="lower", extent=extent, aspect="auto", + interpolation="bilinear") + ax.plot(locus_closed[:, 0], locus_closed[:, 1], color="black", lw=1.0) + + d65_x, d65_y = 0.31271, 0.32902 + ax.plot(d65_x, d65_y, marker="o", markersize=6, + markerfacecolor="white", markeredgecolor="black") + ax.annotate("White (D65)", xy=(d65_x, d65_y), + xytext=(d65_x + 0.015, d65_y - 0.01), + fontsize=9, color="black") + + # Vectorize the data-point styling: clip RGB once, then iterate just for + # the matplotlib calls (cheap for typical N ~= 13). + if lab_values.size: + valid = ~((lab_values[:, 0] == 0) & (lab_values[:, 1] == 0)) + pts = lab_values[valid] + rgb = np.clip(pts[:, 3:] / 255.0, 0.0, 1.0) + for (cx, cy), color in zip(pts[:, :2], rgb): + color = tuple(color) + ax.plot([d65_x, cx], [d65_y, cy], color=color, lw=1.2) + ax.plot(cx, cy, marker="o", markersize=5, + markerfacecolor=color, markeredgecolor="black", + markeredgewidth=0.5) + + ax.set_xlim(0.0, 0.8) + ax.set_ylim(0.0, 0.9) + ax.set_xlabel("x co-ordinate") + ax.set_ylabel("y co-ordinate") + ax.set_title(image_title) + ax.grid(True, color="gray", alpha=0.25, linewidth=0.5) + + if image_name.lower().endswith(".png"): + out_path = image_name[:-4] + "_chromaticity.png" + else: + out_path = image_name + "_chromaticity.png" + fig.savefig(out_path, dpi=150) + plt.close(fig) + + +# ---------------------------------------------------------------------------- +# Colormat builder helpers. +# ---------------------------------------------------------------------------- + +# Strip image is rendered at this fixed pixel height and re-aspected by +# matplotlib via the user's `image_aspect` arg. Square (NxN) was wasteful. +_STRIP_HEIGHT = 64 + + +def _build_strip_from_gaps(rgb_per_band, gaps): + """Build (H, W, 3) uint8 strip by repeating each color over `gaps[i]` cols. + + rgb_per_band : (n, 3) - color per band + gaps : (n-1,) integers - width per band (last band omitted because + the original kinetic logic indexes lab_values[i] for + i in range(n-1) and never writes lab_values[n-1]) + """ + gaps = np.asarray(gaps, dtype=int) + width = int(gaps.sum()) + if width <= 0: + return np.zeros((1, 1, 3), dtype=np.uint8) + out = np.zeros((_STRIP_HEIGHT, width, 3), dtype=np.uint8) + start = 0 + for i, g in enumerate(gaps): + if g <= 0: + continue + out[:, start:start + g] = rgb_per_band[i].astype(np.uint8) + start += g + return out + + +# ---------------------------------------------------------------------------- +# Worker - runs the heavy compute off the GUI thread. +# ---------------------------------------------------------------------------- + +class _LoadFilesWorker(QObject): + progress = pyqtSignal(str) + finished = pyqtSignal(str) # final status message + failed = pyqtSignal(str) + + def __init__(self, params): + super().__init__() + self._p = params + + def run(self): + try: + _process(self._p, self.progress.emit) + self.finished.emit("Finished!") + except Exception: + traceback.print_exc() + self.failed.emit("Error during processing - see console") + + +def _process(p, emit): + """Pure-compute pipeline. p is the params dict assembled by RGBImage.click(). + + Emits status strings via `emit(msg)`. + """ + filepath = p["filepath"] + filelist = p["filelist"] + datatype = p["datatype"] + spec_illum = p["spec_illum"] + image_title = p["image_title"] + image_aspect = p["image_aspect"] + calc_rgb = True + image_name = p["image_name"] + + num_files = len(filelist) + + # Single-file wide-format detection. + if num_files == 1: + full_path = os.path.join(filepath, filelist[0]) + try: + if full_path.lower().endswith((".xls", ".xlsx")): + peek = pd.read_excel(full_path) + else: + peek = pd.read_csv(full_path, sep=None, engine="python") + except Exception: + traceback.print_exc() + peek = None + if peek is not None and peek.shape[1] > 2: + _process_concentration_series( + peek, datatype, spec_illum, image_title, + image_aspect, image_name, calc_rgb, emit, + ) + return + + # Kinetic / per-file path. + if num_files > 1: + first_time = filelist[0].split("_")[-1] + first_t = [int(w) for w in first_time.split(".") if w.isdigit()] + + lab_values = np.zeros((num_files, 6)) + delta = np.zeros((1, num_files)) + seconds_convert = 60 + units = "Minutes" + i = 0 + + for file in filelist: + full_path = os.path.join(filepath, file) + if os.path.isdir(full_path): + print("Skipping ", file, " it is a directory!") + continue + + emit(f"Reading {file}") + try: + if file.endswith(".csv"): + uvvis_data = pd.read_csv(full_path, sep=None, engine="python") + elif file.endswith((".xls", ".xlsx")): + uvvis_data = pd.read_excel(full_path) + else: + uvvis_data = pd.read_table(full_path, engine="python") + except Exception: + print(file + " is corrupt!") + if file == filelist[-1] and i == 0: + print("All files are corrupt!") + sys.exit(0) + continue + + if len(uvvis_data) == 0: + continue + + try: + cx, cy, _, rr, gg, bb = CIElab(spec_illum, datatype, uvvis_data, calc_rgb) + lab_values[i] = [cx, cy, 0, rr, gg, bb] + + if num_files > 1: + curr_time = file.split("_")[-1] + curr_t = [int(w) for w in curr_time.split(".") if w.isdigit()] + if curr_t[0] > 3660: + seconds_convert = 3600 + units = "Hours" + else: + seconds_convert = 60 + units = "Minutes" + delta[0, i] = (curr_t[0] - first_t[0]) / seconds_convert + + i += 1 + except Exception: + print("Could not convert data in " + file) + traceback.print_exc() + + # Drop unfilled rows. + mask = ~np.all(lab_values == 0, axis=1) + lab_values = lab_values[mask] + delta = delta[:, mask] + new_num_files = lab_values.shape[0] + + if new_num_files == 0: + emit("No spectra could be converted") + return + + if new_num_files == 1: + # Single-spectrum strip. + colormat = np.zeros((_STRIP_HEIGHT, _STRIP_HEIGHT, 3), dtype=np.uint8) + colormat[:] = lab_values[0, 3:].astype(np.uint8) + fig, ax = plt.subplots(1, 1) + ax.imshow(colormat, aspect=image_aspect) + ax.axes.get_xaxis().set_visible(False) + ax.axes.get_yaxis().set_visible(False) + ax.set_title(image_title) + fig.savefig(image_name) + plt.close(fig) + else: + # Kinetic strip - widths proportional to elapsed-time gaps. + if seconds_convert == 3600: + delta = delta * 60 + + delta_delta = np.around(np.diff(delta))[0] # shape (n-1,) + delta_delta = np.maximum(delta_delta.astype(int), 1) + + # rgb_per_band aligns with the original loop's lab_values[i] for + # i in range(n-1). The last band's color is never drawn (preserves + # original behavior). + rgb_per_band = lab_values[:-1, 3:] + colormat = _build_strip_from_gaps(rgb_per_band, delta_delta) + + fig, ax = plt.subplots(1, 1) + if seconds_convert == 3600: + ax.imshow(colormat, + extent=[delta[0, 0], np.max(delta) / 60, + delta[0, 0], np.max(delta) / 60], + aspect=image_aspect) + else: + ax.imshow(colormat, + extent=[delta[0, 0], np.max(delta), + delta[0, 0], np.max(delta)], + aspect=image_aspect) + ax.axes.get_yaxis().set_visible(False) + ax.set_xlabel(units) + ax.set_title(image_title) + fig.savefig(image_name) + plt.close(fig) + + try: + save_chromaticity_diagram(lab_values, image_title, image_name) + except Exception: + traceback.print_exc() + + +def _process_concentration_series(df, datatype, spec_illum, image_title, + image_aspect, image_name, calc_rgb, emit): + """Wide-format: 1 wavelength column + N spectrum columns. Vectorized.""" + wavelength_col = df.columns[0] + spectrum_cols = list(df.columns[1:]) + + header_re = re.compile(r"^\s*([+-]?\d+(?:\.\d+)?)\s*(\S.*?)?\s*$") + parsed = [] + for c in spectrum_cols: + m = header_re.match(str(c)) + if m: + value = float(m.group(1)) + unit = (m.group(2) or "").strip() + parsed.append((value, unit, c)) + + if not parsed: + emit("Could not parse concentration headers in selected file") + return + + parsed.sort(key=lambda t: t[0]) + concentrations = np.array([p[0] for p in parsed]) + unit = parsed[0][1] or "concentration" + cols_in_order = [p[2] for p in parsed] + + # De-duplicate wavelengths once (keep first occurrence). + wl_int = df[wavelength_col].astype(int).to_numpy() + _, unique_idx = np.unique(wl_int, return_index=True) + unique_idx = np.sort(unique_idx) + wl_int = wl_int[unique_idx] + + spectrum_block = df[cols_in_order].to_numpy(dtype=np.float64) + spectrum_block = spectrum_block[unique_idx] # (M, N) + T_matrix = spectrum_block.T # (N, M) + + # Auto-scale percent transmission to fraction (CIE math expects [0, 1]). + if datatype == 1: + sample_max = float(np.nanmax(T_matrix)) + if sample_max > 1.5: + T_matrix = T_matrix * 0.01 + + emit(f"Computing CIE for {T_matrix.shape[0]} spectra") + lab_values = CIElab_batch(spec_illum, datatype, wl_int, T_matrix, calc_rgb) + + # Drop rows that came back zero (e.g. fully opaque). + mask = ~np.all(lab_values == 0, axis=1) + lab_values = lab_values[mask] + concentrations = concentrations[mask] + n = lab_values.shape[0] + if n == 0: + emit("No spectra could be converted") + return + + if n > 1: + gaps = np.maximum(np.around(np.diff(concentrations)), 1).astype(int) + rgb_per_band = lab_values[:-1, 3:] + colormat = _build_strip_from_gaps(rgb_per_band, gaps) + + fig, ax = plt.subplots(1, 1) + ax.imshow(colormat, + extent=[concentrations[0], np.max(concentrations), + concentrations[0], np.max(concentrations)], + aspect=image_aspect) + ax.axes.get_yaxis().set_visible(False) + ax.set_xlabel(unit) + ax.set_title(image_title) + fig.savefig(image_name) + plt.close(fig) + else: + colormat = np.zeros((_STRIP_HEIGHT, _STRIP_HEIGHT, 3), dtype=np.uint8) + colormat[:] = lab_values[0, 3:].astype(np.uint8) + fig, ax = plt.subplots(1, 1) + ax.imshow(colormat, aspect=image_aspect) + ax.axes.get_xaxis().set_visible(False) + ax.axes.get_yaxis().set_visible(False) + ax.set_title(image_title) + fig.savefig(image_name) + plt.close(fig) + + try: + save_chromaticity_diagram(lab_values, image_title, image_name) + except Exception: + traceback.print_exc() + + +# ---------------------------------------------------------------------------- +# Qt main window. +# ---------------------------------------------------------------------------- + +class RGBImage(QMainWindow): + + def __init__(self) -> None: + super().__init__() + self.gui = Ui_MainWindow() + self.gui.setupUi(self) + self.gui.pushButton.clicked.connect(self.click) + self.gui.pushButton_2.clicked.connect(self.loadFiles) + self._thread = None + self._worker = None + self.show() + + def click(self): + files, _ = QFileDialog.getOpenFileNames( + self, + "Select Spectra File(s)", + "", + "Spectra (*.csv *.xls *.xlsx);;All files (*)", + ) + if not files: + return + self.filepath = os.path.dirname(files[0]) + self.filelist = sorted(os.path.basename(f) for f in files) + self.gui.lineEdit.setText("; ".join(self.filelist)) + self.gui.lineEdit.setReadOnly(False) + + def loadFiles(self): + if self._thread is not None and self._thread.isRunning(): + return # already running + + self.statusBar().clearMessage() + + if self.gui.radioButton.isChecked(): + datatype = 0 + elif self.gui.radioButton_2.isChecked(): + datatype = 1 + elif self.gui.radioButton_3.isChecked(): + datatype = 2 + else: + datatype = 0 + + spec_illum = str(self.gui.comboBox.currentText()) + image_title = str(self.gui.lineEdit_2.text()) + image_aspect = float(self.gui.lineEdit_3.text()) + + chars_to_be_removed = r'^[^<>/{}[\]~`]*$@!;,:' + filtered_chars = filter(lambda item: item not in chars_to_be_removed, image_title) + image_name_clean = "".join(filtered_chars) + if not os.path.isdir("images/"): + os.mkdir("images/") + image_name = r"images/" + image_name_clean + ".png" + + params = { + "filepath": self.filepath, + "filelist": self.filelist, + "datatype": datatype, + "spec_illum": spec_illum, + "image_title": image_title, + "image_aspect": image_aspect, + "image_name": image_name, + } + + self.gui.pushButton_2.setEnabled(False) + self.statusBar().showMessage("Working...") + + self._thread = QThread(self) + self._worker = _LoadFilesWorker(params) + self._worker.moveToThread(self._thread) + self._thread.started.connect(self._worker.run) + self._worker.progress.connect(self._on_progress) + self._worker.finished.connect(self._on_finished) + self._worker.failed.connect(self._on_failed) + self._worker.finished.connect(self._thread.quit) + self._worker.failed.connect(self._thread.quit) + self._thread.finished.connect(self._cleanup_thread) + self._thread.start() + + def _on_progress(self, msg): + self.statusBar().showMessage(msg) + + def _on_finished(self, msg): + self.gui.statusbar.showMessage(msg) + self.gui.pushButton_2.setEnabled(True) + + def _on_failed(self, msg): + self.gui.statusbar.showMessage(msg) + self.gui.pushButton_2.setEnabled(True) + + def _cleanup_thread(self): + if self._worker is not None: + self._worker.deleteLater() + self._worker = None + if self._thread is not None: + self._thread.deleteLater() + self._thread = None diff --git a/ui/testgui2.py b/ui/testgui2.py index b728bf6..db851c0 100644 --- a/ui/testgui2.py +++ b/ui/testgui2.py @@ -1,244 +1,449 @@ # -*- coding: utf-8 -*- +""" +ColorLab — main window UI definition. -# Form implementation generated from reading ui file 'testgui2.ui' -# -# Created by: PyQt5 UI code generator 5.15.6 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. +Hand-authored Ui_MainWindow used by dataManager.loadfiles4CIE.RGBImage. +Preserves the historical widget attribute names (lineEdit, lineEdit_2, +lineEdit_3, comboBox, radioButton, radioButton_2, radioButton_3, pushButton, +pushButton_2, statusbar) so the controller in dataManager/ runs unchanged. +""" +import os from PyQt5 import QtCore, QtGui, QtWidgets +from ui.theme import ( + PALETTES, + apply_theme, + app_font, + font_family, + load_mode, +) + + +ILLUMINANTS = [ + "Standard Illuminant D65", + "Standard Illuminant A", + "Illuminant C", + "Illuminant D50", + "Illuminant D55", + "Illuminant D75", + "F2", + "F7", + "F11", +] + +DATATYPES = [ + ("Absorbance", "radioButton"), + ("Transmission", "radioButton_2"), + ("AIPS / FT", "radioButton_3"), +] + + +def _sanitize_title(text): + """Mirror the title sanitization in dataManager/loadfiles4CIE.py:88-90.""" + chars_to_be_removed = r'^[^<>/{}[\]~`]*$@!;,:' + return "".join(c for c in text if c not in chars_to_be_removed) + class Ui_MainWindow(object): + + # ------------------------------------------------------------------ setup + def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") - MainWindow.resize(665, 466) - font = QtGui.QFont() - font.setFamily("Bahnschrift SemiBold SemiConden") - font.setPointSize(10) - font.setBold(True) - font.setWeight(75) - MainWindow.setFont(font) - MainWindow.setStyleSheet("background-color: rgb(245, 245, 245);") - MainWindow.setAnimated(True) - MainWindow.setUnifiedTitleAndToolBarOnMac(False) + MainWindow.resize(1200, 820) + MainWindow.setMinimumSize(980, 700) + MainWindow.setFont(app_font(10)) + + self._main_window = MainWindow + self._mode = load_mode() + self._app = QtWidgets.QApplication.instance() + self.centralwidget = QtWidgets.QWidget(MainWindow) - self.centralwidget.setObjectName("centralwidget") - self.label = QtWidgets.QLabel(self.centralwidget) - self.label.setGeometry(QtCore.QRect(10, 30, 641, 81)) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(24) - font.setBold(False) - font.setWeight(50) - self.label.setFont(font) - self.label.setStyleSheet("background-color:rgb(220, 220, 220)") - self.label.setAlignment(QtCore.Qt.AlignCenter) - self.label.setWordWrap(True) - self.label.setObjectName("label") - self.frame = QtWidgets.QFrame(self.centralwidget) - self.frame.setGeometry(QtCore.QRect(10, 110, 641, 331)) - self.frame.setStyleSheet("background-color:rgb(220, 220, 220)") - self.frame.setFrameShape(QtWidgets.QFrame.StyledPanel) - self.frame.setFrameShadow(QtWidgets.QFrame.Raised) - self.frame.setObjectName("frame") - self.layoutWidget = QtWidgets.QWidget(self.frame) - self.layoutWidget.setGeometry(QtCore.QRect(10, 90, 601, 38)) - self.layoutWidget.setObjectName("layoutWidget") - self.horizontalLayout_2 = QtWidgets.QHBoxLayout(self.layoutWidget) - self.horizontalLayout_2.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_2.setObjectName("horizontalLayout_2") - self.label_3 = QtWidgets.QLabel(self.layoutWidget) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setWeight(50) - self.label_3.setFont(font) - self.label_3.setObjectName("label_3") - self.horizontalLayout_2.addWidget(self.label_3) - self.comboBox = QtWidgets.QComboBox(self.layoutWidget) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setWeight(50) - self.comboBox.setFont(font) - self.comboBox.setStyleSheet("background-color: rgb(255, 255, 255)") - self.comboBox.setObjectName("comboBox") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.comboBox.addItem("") - self.horizontalLayout_2.addWidget(self.comboBox) - self.layoutWidget1 = QtWidgets.QWidget(self.frame) - self.layoutWidget1.setGeometry(QtCore.QRect(10, 30, 601, 41)) - self.layoutWidget1.setObjectName("layoutWidget1") - self.horizontalLayout = QtWidgets.QHBoxLayout(self.layoutWidget1) - self.horizontalLayout.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout.setObjectName("horizontalLayout") - self.label_2 = QtWidgets.QLabel(self.layoutWidget1) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setWeight(50) - self.label_2.setFont(font) - self.label_2.setWordWrap(False) - self.label_2.setObjectName("label_2") - self.horizontalLayout.addWidget(self.label_2) - self.lineEdit = QtWidgets.QLineEdit(self.layoutWidget1) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - self.lineEdit.setFont(font) - self.lineEdit.setStyleSheet("background-color: rgb(255, 255, 255);") - self.lineEdit.setObjectName("lineEdit") - self.horizontalLayout.addWidget(self.lineEdit) - self.pushButton = QtWidgets.QPushButton(self.layoutWidget1) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setItalic(False) - font.setWeight(50) - font.setStyleStrategy(QtGui.QFont.NoAntialias) - self.pushButton.setFont(font) - self.pushButton.setObjectName("pushButton") - self.horizontalLayout.addWidget(self.pushButton) - self.layoutWidget2 = QtWidgets.QWidget(self.frame) - self.layoutWidget2.setGeometry(QtCore.QRect(10, 133, 601, 38)) - self.layoutWidget2.setObjectName("layoutWidget2") - self.horizontalLayout_3 = QtWidgets.QHBoxLayout(self.layoutWidget2) - self.horizontalLayout_3.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_3.setObjectName("horizontalLayout_3") - self.label_8 = QtWidgets.QLabel(self.layoutWidget2) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setWeight(50) - self.label_8.setFont(font) - self.label_8.setObjectName("label_8") - self.horizontalLayout_3.addWidget(self.label_8) - self.radioButton = QtWidgets.QRadioButton(self.layoutWidget2) - self.radioButton.setEnabled(True) - self.radioButton.setChecked(True) - self.radioButton.setObjectName("radioButton") - self.horizontalLayout_3.addWidget(self.radioButton) - self.radioButton_2 = QtWidgets.QRadioButton(self.layoutWidget2) - self.radioButton_2.setObjectName("radioButton_2") - self.horizontalLayout_3.addWidget(self.radioButton_2) - self.radioButton_3 = QtWidgets.QRadioButton(self.layoutWidget2) - self.radioButton_3.setObjectName("radioButton_3") - self.horizontalLayout_3.addWidget(self.radioButton_3) - self.layoutWidget3 = QtWidgets.QWidget(self.frame) - self.layoutWidget3.setGeometry(QtCore.QRect(10, 180, 301, 41)) - self.layoutWidget3.setObjectName("layoutWidget3") - self.horizontalLayout_4 = QtWidgets.QHBoxLayout(self.layoutWidget3) - self.horizontalLayout_4.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_4.setObjectName("horizontalLayout_4") - self.label_6 = QtWidgets.QLabel(self.layoutWidget3) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setWeight(50) - self.label_6.setFont(font) - self.label_6.setObjectName("label_6") - self.horizontalLayout_4.addWidget(self.label_6) - self.lineEdit_2 = QtWidgets.QLineEdit(self.layoutWidget3) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - self.lineEdit_2.setFont(font) - self.lineEdit_2.setStyleSheet("background-color: rgb(255, 255, 255);") - self.lineEdit_2.setObjectName("lineEdit_2") - self.horizontalLayout_4.addWidget(self.lineEdit_2) - self.layoutWidget4 = QtWidgets.QWidget(self.frame) - self.layoutWidget4.setGeometry(QtCore.QRect(10, 240, 181, 41)) - self.layoutWidget4.setObjectName("layoutWidget4") - self.horizontalLayout_5 = QtWidgets.QHBoxLayout(self.layoutWidget4) - self.horizontalLayout_5.setContentsMargins(0, 0, 0, 0) - self.horizontalLayout_5.setObjectName("horizontalLayout_5") - self.label_7 = QtWidgets.QLabel(self.layoutWidget4) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setWeight(50) - self.label_7.setFont(font) - self.label_7.setObjectName("label_7") - self.horizontalLayout_5.addWidget(self.label_7) - self.lineEdit_3 = QtWidgets.QLineEdit(self.layoutWidget4) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - self.lineEdit_3.setFont(font) - self.lineEdit_3.setStyleSheet("background-color: rgb(255, 255, 255);") - self.lineEdit_3.setAlignment(QtCore.Qt.AlignCenter) - self.lineEdit_3.setObjectName("lineEdit_3") - self.horizontalLayout_5.addWidget(self.lineEdit_3) - self.pushButton_2 = QtWidgets.QPushButton(self.frame) - self.pushButton_2.setGeometry(QtCore.QRect(270, 290, 93, 28)) - font = QtGui.QFont() - font.setFamily("MS Shell Dlg 2") - font.setPointSize(10) - font.setBold(False) - font.setWeight(50) - self.pushButton_2.setFont(font) - self.pushButton_2.setObjectName("pushButton_2") - self.label_5 = QtWidgets.QLabel(self.centralwidget) - self.label_5.setGeometry(QtCore.QRect(20, 30, 281, 19)) - self.label_5.setStyleSheet("background-color:rgb(220, 220, 220)") - self.label_5.setOpenExternalLinks(True) - self.label_5.setObjectName("label_5") - self.label_9 = QtWidgets.QLabel(self.centralwidget) - self.label_9.setGeometry(QtCore.QRect(20, 60, 171, 20)) - self.label_9.setStyleSheet("background-color:rgb(220, 220, 220)") - self.label_9.setOpenExternalLinks(True) - self.label_9.setObjectName("label_9") + self.centralwidget.setObjectName("central") MainWindow.setCentralWidget(self.centralwidget) + + root = QtWidgets.QVBoxLayout(self.centralwidget) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + root.addWidget(self._build_header()) + + body = QtWidgets.QHBoxLayout() + body.setContentsMargins(0, 0, 0, 0) + body.setSpacing(0) + body.addWidget(self._build_sidebar(), 0) + body.addWidget(self._build_preview_pane(), 1) + root.addLayout(body, 1) + self.statusbar = QtWidgets.QStatusBar(MainWindow) self.statusbar.setObjectName("statusbar") + self.statusbar.setSizeGripEnabled(False) MainWindow.setStatusBar(self.statusbar) - self.actionGitHub = QtWidgets.QAction(MainWindow) - self.actionGitHub.setObjectName("actionGitHub") + self.statusbar.showMessage("Ready.") + self.statusbar.messageChanged.connect(self._on_status_changed) self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) + # Wire interactive bits. + self.themeToggle.clicked.connect(self._toggle_theme) + self.lineEdit.textChanged.connect(self._refresh_action_state) + self.lineEdit_2.textChanged.connect(self._refresh_action_state) + self.openFolderBtn.clicked.connect(self._open_image_folder) + + for label, attr in DATATYPES: + seg = self._segments[attr] + seg.clicked.connect(self._sync_segment_to_radio) + + self._refresh_action_state() + + # ------------------------------------------------------------------ header + + def _build_header(self): + header = QtWidgets.QWidget() + header.setObjectName("header") + header.setFixedHeight(64) + + h = QtWidgets.QHBoxLayout(header) + h.setContentsMargins(28, 0, 24, 0) + h.setSpacing(10) + + brand = QtWidgets.QLabel("ColorLab") + brand.setObjectName("brand") + dot = QtWidgets.QLabel("•") + dot.setObjectName("brandDot") + tag = QtWidgets.QLabel("Spectra → sRGB") + tag.setObjectName("tagline") + + h.addWidget(brand) + h.addWidget(dot) + h.addWidget(tag) + h.addStretch(1) + + self.themeToggle = QtWidgets.QPushButton() + self.themeToggle.setObjectName("themeToggle") + self.themeToggle.setCursor(QtCore.Qt.PointingHandCursor) + self.themeToggle.setMinimumWidth(110) + h.addWidget(self.themeToggle) + + return header + + # ----------------------------------------------------------------- sidebar + + def _build_sidebar(self): + sidebar = QtWidgets.QWidget() + sidebar.setObjectName("sidebar") + sidebar.setFixedWidth(400) + + wrap = QtWidgets.QVBoxLayout(sidebar) + wrap.setContentsMargins(28, 28, 28, 28) + wrap.setSpacing(20) + + section = QtWidgets.QLabel("CONFIGURATION") + section.setObjectName("sectionTitle") + wrap.addWidget(section) + + wrap.addWidget(self._build_directory_card()) + wrap.addWidget(self._build_datatype_card()) + wrap.addWidget(self._build_illuminant_card()) + wrap.addWidget(self._build_output_card()) + + wrap.addStretch(1) + + self.pushButton_2 = QtWidgets.QPushButton() + self.pushButton_2.setObjectName("primary") + self.pushButton_2.setCursor(QtCore.Qt.PointingHandCursor) + self.pushButton_2.setMinimumHeight(44) + wrap.addWidget(self.pushButton_2) + + self.progressBar = QtWidgets.QProgressBar() + self.progressBar.setObjectName("progressBar") + self.progressBar.setRange(0, 0) + self.progressBar.setTextVisible(False) + self.progressBar.setFixedHeight(6) + self.progressBar.hide() + wrap.addWidget(self.progressBar) + + return sidebar + + def _card(self, title): + card = QtWidgets.QFrame() + card.setObjectName("card") + v = QtWidgets.QVBoxLayout(card) + v.setContentsMargins(18, 16, 18, 16) + v.setSpacing(10) + + if title: + lbl = QtWidgets.QLabel(title) + lbl.setObjectName("fieldLabel") + v.addWidget(lbl) + + return card, v + + def _build_directory_card(self): + card, v = self._card("DATA DIRECTORY") + + row = QtWidgets.QHBoxLayout() + row.setSpacing(8) + + self.lineEdit = QtWidgets.QLineEdit() + self.lineEdit.setPlaceholderText("Choose a folder of spectra files…") + self.lineEdit.setMinimumHeight(38) + row.addWidget(self.lineEdit, 1) + + self.pushButton = QtWidgets.QPushButton("Browse") + self.pushButton.setCursor(QtCore.Qt.PointingHandCursor) + self.pushButton.setMinimumHeight(38) + row.addWidget(self.pushButton) + + v.addLayout(row) + return card + + def _build_datatype_card(self): + card, v = self._card("DATA TYPE") + + track = QtWidgets.QFrame() + track.setObjectName("segmentTrack") + track_h = QtWidgets.QHBoxLayout(track) + track_h.setContentsMargins(4, 4, 4, 4) + track_h.setSpacing(2) + + # Real radio buttons (kept for controller compatibility, hidden). + self._segments = {} + self._segment_group = QtWidgets.QButtonGroup(card) + self._segment_group.setExclusive(True) + + for idx, (label, attr) in enumerate(DATATYPES): + btn = QtWidgets.QPushButton(label) + btn.setObjectName("segment") + btn.setCheckable(True) + btn.setCursor(QtCore.Qt.PointingHandCursor) + btn.setMinimumHeight(32) + track_h.addWidget(btn, 1) + self._segment_group.addButton(btn, idx) + self._segments[attr] = btn + + radio = QtWidgets.QRadioButton(label, card) + radio.setObjectName(attr) + radio.hide() + setattr(self, attr, radio) + + # Default = Absorbance, matching prior UI. + self._segments["radioButton"].setChecked(True) + self.radioButton.setChecked(True) + + v.addWidget(track) + return card + + def _build_illuminant_card(self): + card, v = self._card("ILLUMINANT") + + self.comboBox = QtWidgets.QComboBox() + self.comboBox.setMinimumHeight(38) + for item in ILLUMINANTS: + self.comboBox.addItem(item) + v.addWidget(self.comboBox) + + return card + + def _build_output_card(self): + card = QtWidgets.QFrame() + card.setObjectName("card") + v = QtWidgets.QVBoxLayout(card) + v.setContentsMargins(18, 16, 18, 16) + v.setSpacing(8) + + title_label = QtWidgets.QLabel("IMAGE TITLE") + title_label.setObjectName("fieldLabel") + v.addWidget(title_label) + + self.lineEdit_2 = QtWidgets.QLineEdit() + self.lineEdit_2.setPlaceholderText("e.g. sample-degradation") + self.lineEdit_2.setMinimumHeight(38) + v.addWidget(self.lineEdit_2) + + v.addSpacing(8) + aspect_label = QtWidgets.QLabel("ASPECT RATIO") + aspect_label.setObjectName("fieldLabel") + v.addWidget(aspect_label) + + self.lineEdit_3 = QtWidgets.QLineEdit("1") + self.lineEdit_3.setAlignment(QtCore.Qt.AlignCenter) + self.lineEdit_3.setMaximumWidth(110) + self.lineEdit_3.setMinimumHeight(38) + self.lineEdit_3.setValidator(QtGui.QDoubleValidator(0.01, 99.99, 3)) + v.addWidget(self.lineEdit_3, 0, QtCore.Qt.AlignLeft) + + return card + + # ------------------------------------------------------------ preview pane + + def _build_preview_pane(self): + pane = QtWidgets.QWidget() + pane.setObjectName("previewPane") + + v = QtWidgets.QVBoxLayout(pane) + v.setContentsMargins(28, 28, 28, 28) + v.setSpacing(14) + + # Header row: section + status pill + open-folder. + head = QtWidgets.QHBoxLayout() + head.setSpacing(10) + + title = QtWidgets.QLabel("PREVIEW") + title.setObjectName("sectionTitle") + head.addWidget(title) + + head.addStretch(1) + + self.statusLabel = QtWidgets.QLabel("Idle") + self.statusLabel.setObjectName("statusLabel") + head.addWidget(self.statusLabel) + + self.openFolderBtn = QtWidgets.QPushButton("Open folder") + self.openFolderBtn.setObjectName("ghost") + self.openFolderBtn.setCursor(QtCore.Qt.PointingHandCursor) + self.openFolderBtn.setEnabled(False) + head.addWidget(self.openFolderBtn) + + v.addLayout(head) + + # The preview frame. + self.previewFrame = QtWidgets.QFrame() + self.previewFrame.setObjectName("previewFrame") + pf = QtWidgets.QVBoxLayout(self.previewFrame) + pf.setContentsMargins(20, 20, 20, 20) + + self.previewLabel = QtWidgets.QLabel() + self.previewLabel.setAlignment(QtCore.Qt.AlignCenter) + self.previewLabel.setMinimumSize(360, 360) + self.previewLabel.setObjectName("previewHint") + self.previewLabel.setText( + "No render yet.\n\nPick a directory, set an image title, then press Process." + ) + pf.addWidget(self.previewLabel, 1) + + v.addWidget(self.previewFrame, 1) + + # Footer meta. + self.previewMeta = QtWidgets.QLabel("") + self.previewMeta.setObjectName("previewMeta") + v.addWidget(self.previewMeta) + + # Resize hook so the preview rescales. + self.previewFrame.installEventFilter(_PreviewResizer(self)) + + # Cached unscaled pixmap. + self._raw_preview = None + self._last_image_path = None + + return pane + + # -------------------------------------------------------------- behaviour + def retranslateUi(self, MainWindow): - _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "Colorlab GUI")) - self.label.setText(_translate("MainWindow", "
ColorLab
")) - self.label_3.setText(_translate("MainWindow", "Select Illuminant
")) - self.comboBox.setItemText(0, _translate("MainWindow", "Standard Illuminant D65")) - self.comboBox.setItemText(1, _translate("MainWindow", "Standard Illuminant A")) - self.comboBox.setItemText(2, _translate("MainWindow", "Illuminant C")) - self.comboBox.setItemText(3, _translate("MainWindow", "Illuminant D50")) - self.comboBox.setItemText(4, _translate("MainWindow", "Illuminant D55")) - self.comboBox.setItemText(5, _translate("MainWindow", "Illuminant D75")) - self.comboBox.setItemText(6, _translate("MainWindow", "F2")) - self.comboBox.setItemText(7, _translate("MainWindow", "F7")) - self.comboBox.setItemText(8, _translate("MainWindow", "F11")) - self.label_2.setText(_translate("MainWindow", "File Directory
")) - self.pushButton.setText(_translate("MainWindow", "Browse")) - self.label_8.setText(_translate("MainWindow", "Data type?")) - self.radioButton.setText(_translate("MainWindow", "Absorbance")) - self.radioButton_2.setText(_translate("MainWindow", "Transmission")) - self.radioButton_3.setText(_translate("MainWindow", "AIPS")) - self.label_6.setText(_translate("MainWindow", "Image Title")) - self.label_7.setText(_translate("MainWindow", "Aspect Ratio")) - self.lineEdit_3.setText(_translate("MainWindow", "1")) - self.pushButton_2.setText(_translate("MainWindow", "Load Files")) - self.label_5.setText(_translate("MainWindow", "")) - self.label_9.setText(_translate("MainWindow", "")) - self.actionGitHub.setText(_translate("MainWindow", "GitHub")) + _ = QtCore.QCoreApplication.translate + MainWindow.setWindowTitle(_("MainWindow", "ColorLab")) + self.pushButton_2.setText(_("MainWindow", "Process")) + self._update_theme_toggle_text() + + def _update_theme_toggle_text(self): + next_label = "Dark" if self._mode == "light" else "Light" + glyph = "☾" if self._mode == "light" else "☀" + self.themeToggle.setText(f"{glyph} {next_label}") + + def _toggle_theme(self): + self._mode = "dark" if self._mode == "light" else "light" + if self._app is not None: + apply_theme(self._app, self._mode) + self._update_theme_toggle_text() + # Re-render the preview so the placeholder color follows the theme. + if self._raw_preview is None: + self.previewLabel.setText(self.previewLabel.text()) + + def _sync_segment_to_radio(self): + for attr, btn in self._segments.items(): + getattr(self, attr).setChecked(btn.isChecked()) + self._refresh_action_state() + + def _refresh_action_state(self): + ready = bool(self.lineEdit.text().strip()) and bool(self.lineEdit_2.text().strip()) + self.pushButton_2.setEnabled(ready) + + def _on_status_changed(self, message): + if not message: + self.statusLabel.setText("Idle") + self.progressBar.hide() + return + + if message.lower().startswith("finished"): + self.statusLabel.setText("Done") + self.progressBar.hide() + self._load_preview_from_inputs() + else: + self.statusLabel.setText(message) + self.progressBar.show() + + def _expected_image_path(self): + title = _sanitize_title(self.lineEdit_2.text()) + if not title: + return None + chromaticity = os.path.join("images", title + "_chromaticity.png") + if os.path.isfile(chromaticity): + return chromaticity + return os.path.join("images", title + ".png") + + def _load_preview_from_inputs(self): + path = self._expected_image_path() + if not path or not os.path.isfile(path): + self.previewMeta.setText("Finished, but the rendered image could not be located.") + return + + pixmap = QtGui.QPixmap(path) + if pixmap.isNull(): + self.previewMeta.setText(f"Could not load preview from {path}") + return + + self._raw_preview = pixmap + self._last_image_path = path + self._rescale_preview() + size = pixmap.size() + self.previewMeta.setText( + f"{path} • {size.width()}×{size.height()} px" + ) + self.openFolderBtn.setEnabled(True) + + def _rescale_preview(self): + if self._raw_preview is None: + return + target = self.previewLabel.size() + if target.width() < 16 or target.height() < 16: + return + scaled = self._raw_preview.scaled( + target, + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation, + ) + self.previewLabel.setPixmap(scaled) + + def _open_image_folder(self): + folder = os.path.abspath("images") + if not os.path.isdir(folder): + return + url = QtCore.QUrl.fromLocalFile(folder) + QtGui.QDesktopServices.openUrl(url) + + +class _PreviewResizer(QtCore.QObject): + """Rescales the cached preview pixmap when the frame resizes.""" + + def __init__(self, ui): + super().__init__() + self._ui = ui + + def eventFilter(self, obj, event): + if event.type() == QtCore.QEvent.Resize: + self._ui._rescale_preview() + return False diff --git a/ui/theme.py b/ui/theme.py new file mode 100644 index 0000000..d2a1c2e --- /dev/null +++ b/ui/theme.py @@ -0,0 +1,402 @@ +"""ColorLab theming: light/dark palettes and QSS generation.""" + +from PyQt5.QtCore import QSettings +from PyQt5.QtGui import QFont, QFontDatabase + +LIGHT = { + "bg": "#F5F6FA", + "surface": "#FFFFFF", + "surface_alt": "#EEF0F7", + "border": "#E0E3EC", + "border_soft": "#EDEFF5", + "text": "#1B1F2A", + "text_muted": "#6B7180", + "text_dim": "#9AA0AE", + "accent": "#6E5BFF", + "accent_hover":"#5A47F0", + "accent_soft": "#EEEAFF", + "success": "#1FA971", + "danger": "#E5484D", + "shadow": "rgba(20, 24, 40, 0.08)", + "track": "#E6E8F0", + "is_dark": False, +} + +DARK = { + "bg": "#0F1115", + "surface": "#161922", + "surface_alt": "#1C202B", + "border": "#262B38", + "border_soft": "#1F2330", + "text": "#E6E8EE", + "text_muted": "#9AA1B2", + "text_dim": "#6B7180", + "accent": "#8B7CFF", + "accent_hover":"#A294FF", + "accent_soft": "#23223C", + "success": "#3DD68C", + "danger": "#FF6369", + "shadow": "rgba(0, 0, 0, 0.5)", + "track": "#222735", + "is_dark": True, +} + +PALETTES = {"light": LIGHT, "dark": DARK} + + +def font_family(): + available = set(QFontDatabase().families()) + for candidate in ("Inter", "Segoe UI Variable", "Segoe UI", "SF Pro Text", + "Helvetica Neue", "system-ui"): + if candidate in available: + return candidate + return "Sans Serif" + + +def app_font(size=10, weight=QFont.Normal): + f = QFont(font_family(), size) + f.setWeight(weight) + f.setStyleStrategy(QFont.PreferAntialias) + return f + + +def build_qss(p): + arrow = "▾" # used as combobox indicator via image-less style + return f""" +* {{ + font-family: "{font_family()}", "Segoe UI", system-ui, sans-serif; + color: {p['text']}; +}} + +QMainWindow, QWidget#central {{ + background: {p['bg']}; +}} + +QWidget#header {{ + background: {p['surface']}; + border-bottom: 1px solid {p['border_soft']}; +}} + +QWidget#sidebar {{ + background: {p['surface']}; + border-right: 1px solid {p['border_soft']}; +}} + +QWidget#previewPane {{ + background: {p['bg']}; +}} + +QFrame#card {{ + background: {p['surface']}; + border: 1px solid {p['border_soft']}; + border-radius: 14px; +}} + +QFrame#previewFrame {{ + background: {p['surface']}; + border: 1px solid {p['border_soft']}; + border-radius: 16px; +}} + +QLabel {{ + background: transparent; +}} + +QLabel#brand {{ + color: {p['text']}; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.5px; +}} + +QLabel#brandDot {{ + color: {p['accent']}; + font-size: 18px; + font-weight: 800; +}} + +QLabel#tagline {{ + color: {p['text_muted']}; + font-size: 11px; + font-weight: 500; +}} + +QLabel#sectionTitle {{ + color: {p['text']}; + font-size: 11px; + font-weight: 700; + letter-spacing: 1.2px; + text-transform: uppercase; +}} + +QLabel#fieldLabel {{ + color: {p['text_muted']}; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.6px; + text-transform: uppercase; +}} + +QLabel#previewHint {{ + color: {p['text_dim']}; + font-size: 13px; + font-weight: 500; +}} + +QLabel#previewMeta {{ + color: {p['text_muted']}; + font-size: 11px; +}} + +QLabel#statusLabel {{ + color: {p['text_muted']}; + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + background: {p['surface_alt']}; + border-radius: 10px; +}} + +QLineEdit {{ + background: {p['surface_alt']}; + border: 1px solid {p['border']}; + border-radius: 10px; + padding: 9px 12px; + color: {p['text']}; + selection-background-color: {p['accent']}; + selection-color: white; + font-size: 13px; +}} + +QLineEdit:focus {{ + border: 1px solid {p['accent']}; + background: {p['surface']}; +}} + +QLineEdit:disabled {{ + color: {p['text_dim']}; +}} + +QComboBox {{ + background: {p['surface_alt']}; + border: 1px solid {p['border']}; + border-radius: 10px; + padding: 9px 12px; + color: {p['text']}; + font-size: 13px; + min-height: 18px; +}} + +QComboBox:hover {{ + border: 1px solid {p['accent']}; +}} + +QComboBox:focus {{ + border: 1px solid {p['accent']}; + background: {p['surface']}; +}} + +QComboBox::drop-down {{ + width: 24px; + border: none; + background: transparent; +}} + +QComboBox::down-arrow {{ + image: none; + width: 0; + height: 0; +}} + +QComboBox QAbstractItemView {{ + background: {p['surface']}; + border: 1px solid {p['border']}; + border-radius: 10px; + padding: 4px; + outline: 0; + selection-background-color: {p['accent_soft']}; + selection-color: {p['text']}; + color: {p['text']}; +}} + +QPushButton {{ + background: {p['surface_alt']}; + color: {p['text']}; + border: 1px solid {p['border']}; + border-radius: 10px; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; +}} + +QPushButton:hover {{ + background: {p['surface']}; + border: 1px solid {p['accent']}; + color: {p['accent']}; +}} + +QPushButton:pressed {{ + background: {p['accent_soft']}; +}} + +QPushButton:disabled {{ + color: {p['text_dim']}; + background: {p['surface_alt']}; + border: 1px solid {p['border_soft']}; +}} + +QPushButton#primary {{ + background: {p['accent']}; + color: white; + border: 1px solid {p['accent']}; + padding: 12px 18px; + font-size: 14px; + font-weight: 700; + letter-spacing: 0.4px; +}} + +QPushButton#primary:hover {{ + background: {p['accent_hover']}; + border: 1px solid {p['accent_hover']}; + color: white; +}} + +QPushButton#primary:pressed {{ + background: {p['accent_hover']}; +}} + +QPushButton#primary:disabled {{ + background: {p['track']}; + color: {p['text_dim']}; + border: 1px solid {p['border']}; +}} + +QPushButton#segment {{ + background: transparent; + color: {p['text_muted']}; + border: none; + border-radius: 8px; + padding: 8px 10px; + font-size: 12px; + font-weight: 600; +}} + +QPushButton#segment:hover {{ + color: {p['text']}; +}} + +QPushButton#segment:checked {{ + background: {p['surface']}; + color: {p['accent']}; + border: 1px solid {p['border']}; +}} + +QPushButton#ghost {{ + background: transparent; + border: 1px solid {p['border']}; + color: {p['text_muted']}; + padding: 6px 12px; + font-size: 12px; + font-weight: 600; +}} + +QPushButton#ghost:hover {{ + color: {p['accent']}; + border: 1px solid {p['accent']}; +}} + +QPushButton#themeToggle {{ + background: {p['surface_alt']}; + border: 1px solid {p['border']}; + border-radius: 18px; + padding: 6px 14px; + color: {p['text']}; + font-size: 12px; + font-weight: 600; +}} + +QPushButton#themeToggle:hover {{ + border: 1px solid {p['accent']}; + color: {p['accent']}; +}} + +QFrame#segmentTrack {{ + background: {p['surface_alt']}; + border: 1px solid {p['border_soft']}; + border-radius: 11px; +}} + +QProgressBar {{ + background: {p['track']}; + border: none; + border-radius: 4px; + height: 6px; + text-align: center; + color: transparent; +}} + +QProgressBar::chunk {{ + background: {p['accent']}; + border-radius: 4px; +}} + +QStatusBar {{ + background: {p['surface']}; + border-top: 1px solid {p['border_soft']}; + color: {p['text_muted']}; + font-size: 12px; + padding: 4px 12px; +}} + +QStatusBar::item {{ + border: none; +}} + +QToolTip {{ + background: {p['surface']}; + color: {p['text']}; + border: 1px solid {p['border']}; + border-radius: 6px; + padding: 4px 8px; +}} + +QScrollBar:vertical {{ + background: transparent; + width: 10px; + margin: 0; +}} + +QScrollBar::handle:vertical {{ + background: {p['border']}; + border-radius: 5px; + min-height: 24px; +}} + +QScrollBar::handle:vertical:hover {{ + background: {p['accent']}; +}} + +QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ + height: 0; +}} +""" + + +_SETTINGS_KEY = "ColorLab/theme" + + +def load_mode(): + s = QSettings("ColorLab", "ColorLab") + return s.value(_SETTINGS_KEY, "light") + + +def save_mode(mode): + s = QSettings("ColorLab", "ColorLab") + s.setValue(_SETTINGS_KEY, mode) + + +def apply_theme(app, mode): + palette = PALETTES.get(mode, LIGHT) + app.setStyleSheet(build_qss(palette)) + save_mode(mode) + return palette