From 121b2888e9437e87c9fc2b7261e3afa2d445d95e Mon Sep 17 00:00:00 2001
From: K144U
Date: Wed, 6 May 2026 15:48:47 +0530
Subject: [PATCH 1/2] add chromaticity diagram, xlsx + concentration-series
ingestion, file picker, GUI preview
- Switch ingestion to file picker; accept .xls/.xlsx alongside .csv and fix csv double-read.
- Detect wide-format concentration-series files and process them as a single image.
- Surface CIE 1931 (cx, cy) from CIElab and emit a chromaticity diagram alongside the strip with locus wavelength labels and a zoomed inset around D65.
- Show generated image in the GUI preview pane after Process completes.
- Add ui/theme.py.
Co-Authored-By: Claude Opus 4.7
---
clgui.py | 43 ++-
dataManager/CIE_XYZ.py | 16 +-
dataManager/loadfiles4CIE.py | 303 ++++++++++++++--
ui/testgui2.py | 653 +++++++++++++++++++++++------------
ui/theme.py | 402 +++++++++++++++++++++
5 files changed, 1142 insertions(+), 275 deletions(-)
create mode 100644 ui/theme.py
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..c670aab 100644
--- a/dataManager/CIE_XYZ.py
+++ b/dataManager/CIE_XYZ.py
@@ -106,12 +106,16 @@ def bradford(CIE_X, CIE_Y, CIE_Z, spec_illum):
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
+ # Chromaticity coordinates (replace previously-deprecated L,a,b slots).
+ 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 xyz2rbg(spec_illum,X,Y,Z):
diff --git a/dataManager/loadfiles4CIE.py b/dataManager/loadfiles4CIE.py
index 8e0ffda..0e2bf1b 100644
--- a/dataManager/loadfiles4CIE.py
+++ b/dataManager/loadfiles4CIE.py
@@ -6,8 +6,10 @@
"""
import os
+import re
import sys
-import pandas as pd
+import traceback
+import pandas as pd
import numpy as np
from dataManager.CIE_XYZ import CIElab
import matplotlib.pyplot as plt
@@ -27,10 +29,17 @@ def __init__(self) -> None:
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)
+ 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)
@@ -44,8 +53,7 @@ def loadFiles(self):
datatype = 1
if self.gui.radioButton_3.isChecked():
datatype = 2
- filelist = os.listdir(self.filepath)
- filelist.sort()
+ filelist = self.filelist
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())
@@ -79,9 +87,10 @@ def loadFiles(self):
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()]
+ # pull first timestamp (only meaningful for kinetic series)
+ if num_files > 1:
+ 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"^[^<>/{}[\]~`]*$")
@@ -91,6 +100,27 @@ def loadFiles(self):
if not os.path.isdir('images/'):
os.mkdir('images/')
image_name = r'images/' + image_name + '.png'
+
+ # If the user picked a single file with more than 2 columns, treat it
+ # as a wide-format concentration series (one wavelength column + one
+ # transmission/absorbance column per concentration).
+ if num_files == 1:
+ full_path = os.path.join(self.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:
+ self._loadConcentrationSeries(
+ peek, datatype, spec_illum, image_title, image_aspect,
+ image_name, calc_rgb, x_bar, y_bar, z_bar,
+ )
+ return
+
# if re1.match(image_title):
# print ("Image name is valid!")
# image_name = r'images/' + image_name + '.png'
@@ -123,7 +153,7 @@ def loadFiles(self):
sys.exit(0)
else:
continue
- if file.endswith('.xls'):
+ elif file.endswith(('.xls', '.xlsx')):
try:
uvvis_data = pd.read_excel(r"{0}/{1}".format(self.filepath,file))
except:
@@ -156,30 +186,32 @@ def loadFiles(self):
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
+ cx,cy,_,rr,gg,bb = CIElab(spec_illum,illum,datatype,uvvis_data,x_bar,y_bar,z_bar,calc_rgb)
+ lab_values[i,0] = cx
+ lab_values[i,1] = cy
+ lab_values[i,2] = 0
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
-
+ # extract timestamp (kinetic series only)
+ if num_files > 1:
+ 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)
+ traceback.print_exc()
print('***********************************************************')
if file==filelist[-1]:
@@ -252,7 +284,218 @@ def loadFiles(self):
ax.axes.get_yaxis().set_visible(False)
ax.set_title(image_title)
fig.savefig(image_name)
-
-
+
+ try:
+ self._saveChromaticityDiagram(lab_values, image_title, image_name,
+ x_bar, y_bar, z_bar)
+ except Exception:
+ traceback.print_exc()
+
self.gui.statusbar.showMessage("Finished!")
-
+
+ def _loadConcentrationSeries(self, df, datatype, spec_illum, image_title,
+ image_aspect, image_name, calc_rgb,
+ x_bar, y_bar, z_bar):
+ # First column is wavelength axis; remaining columns are spectra at
+ # successive concentrations. Header format expected: " ",
+ # e.g. "30 mM" or "12.5 uM".
+ 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:
+ self.gui.statusbar.showMessage(
+ "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]
+ n = len(parsed)
+
+ target_col = {0: 'Absorbance', 1: 'Transmission', 2: 'FT'}[datatype]
+
+ # Auto-scale percent transmission to fraction (existing CIE math
+ # assumes T in [0, 1]; user supplied 0-100 percent).
+ scale = 1.0
+ if datatype == 1:
+ sample_max = float(np.nanmax(df[cols_in_order].to_numpy()))
+ if sample_max > 1.5:
+ scale = 0.01
+
+ lab_values = np.zeros((n, 6))
+ for i, col in enumerate(cols_in_order):
+ sub = pd.DataFrame({
+ 'Wavelength': df[wavelength_col],
+ target_col: df[col] * scale if datatype == 1 else df[col],
+ })
+ try:
+ cx, cy, _, rr, gg, bb = CIElab(
+ spec_illum, illum, datatype, sub,
+ x_bar, y_bar, z_bar, calc_rgb,
+ )
+ lab_values[i] = [cx, cy, 0, rr, gg, bb]
+ except Exception:
+ print('Could not convert column ' + str(col))
+ traceback.print_exc()
+ continue
+
+ # Drop rows that stayed zero (failed conversions).
+ mask = ~np.all(lab_values == 0, axis=1)
+ lab_values = lab_values[mask]
+ concentrations = concentrations[mask]
+ n = len(lab_values)
+ if n == 0:
+ self.gui.statusbar.showMessage("No spectra could be converted")
+ return
+
+ if n > 1:
+ # Width of each color band proportional to the gap to the next
+ # concentration (rounded to integer pixels, minimum 1).
+ gaps = np.maximum(np.around(np.diff(concentrations)), 1).astype(int)
+ temp_dim = int(np.sum(gaps))
+ colormat = np.zeros((temp_dim, temp_dim, 3), dtype=np.uint8)
+ curr_idx = 0
+ first = True
+ for i in range(n - 1):
+ for k in range(int(gaps[i])):
+ if first:
+ colormat[:, i + k] = lab_values[i, 3:]
+ curr_idx = i + k
+ first = False
+ else:
+ curr_idx += 1
+ colormat[:, curr_idx] = lab_values[i, 3:]
+ colormat = colormat[0:curr_idx + 1, 0:curr_idx + 1, :]
+
+ 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)
+ else:
+ colormat = np.zeros((1, 1, 3), dtype=np.uint16)
+ colormat[0, 0] = lab_values[0, 3:]
+ 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)
+
+ try:
+ self._saveChromaticityDiagram(lab_values, image_title, image_name,
+ x_bar, y_bar, z_bar)
+ except Exception:
+ traceback.print_exc()
+
+ self.gui.statusbar.showMessage("Finished!")
+
+ def _saveChromaticityDiagram(self, lab_values, image_title, image_name,
+ x_bar, y_bar, z_bar):
+ """Save a CIE 1931 xy chromaticity diagram alongside the color strip.
+
+ lab_values rows are [cx, cy, _, r, g, b]. Row r/g/b are 0-255 ints
+ but may be floats; we normalise to 0-1 for matplotlib.
+ """
+ from matplotlib.path import Path
+
+ x_bar_a = np.asarray(x_bar, dtype=float)
+ y_bar_a = np.asarray(y_bar, dtype=float)
+ z_bar_a = np.asarray(z_bar, dtype=float)
+
+ # Spectral locus: chromaticity coords for each wavelength sample.
+ denom = x_bar_a + y_bar_a + z_bar_a
+ good = denom > 0
+ locus_x = x_bar_a[good] / denom[good]
+ locus_y = y_bar_a[good] / denom[good]
+ locus = np.column_stack([locus_x, locus_y])
+ locus_closed = np.vstack([locus, locus[:1]]) # close polygon for fill
+
+ # Build a filled-gamut RGB background by computing sRGB at each (x, y)
+ # cell on a grid, then masking points outside the locus polygon.
+ 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)
+ # Gamma (sRGB) - vectorized form of the per-channel function in CIE_XYZ.
+ below = RGB < 0.0031308
+ RGB = np.where(below, 12.92 * RGB, 1.055 * np.power(RGB, 0.41666) - 0.055)
+ # Normalize each cell so the brightest channel hits 1.0 - this matches
+ # the conventional appearance of CIE diagrams (Y=1 produces very dim
+ # colors otherwise).
+ 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)
+
+ # Mask points outside the spectral locus polygon.
+ locus_path = Path(locus_closed)
+ pts = np.column_stack([gx.ravel(), gy.ravel()])
+ inside = locus_path.contains_points(pts).reshape(gx.shape)
+ alpha = inside.astype(float)
+ rgba = np.dstack([RGB, alpha])
+
+ fig, ax = plt.subplots(1, 1, figsize=(7, 6.5))
+ ax.imshow(rgba, origin='lower', extent=[0.0, 0.8, 0.0, 0.9],
+ aspect='auto', interpolation='bilinear')
+
+ # Spectral locus outline + purple line.
+ ax.plot(locus_closed[:, 0], locus_closed[:, 1], color='black', lw=1.0)
+
+ # D65 white point.
+ 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')
+
+ # Data points + connector lines from D65.
+ for row in lab_values:
+ cx, cy = float(row[0]), float(row[1])
+ if cx == 0.0 and cy == 0.0:
+ continue
+ r, g, b = float(row[3]) / 255.0, float(row[4]) / 255.0, float(row[5]) / 255.0
+ r, g, b = max(0.0, min(1.0, r)), max(0.0, min(1.0, g)), max(0.0, min(1.0, b))
+ ax.plot([d65_x, cx], [d65_y, cy], color=(r, g, b), lw=1.2)
+ ax.plot(cx, cy, marker='o', markersize=5,
+ markerfacecolor=(r, g, b), 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)
+
+ out_path = image_name[:-4] + '_chromaticity.png' \
+ if image_name.lower().endswith('.png') else image_name + '_chromaticity.png'
+ fig.savefig(out_path, dpi=150, bbox_inches='tight')
+ plt.close(fig)
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", "
Ratcliff Group @ The University of Arizona
"))
- self.label_9.setText(_translate("MainWindow", "Get the latest version
"))
- 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
From 5081a6d3bf747aa8839abd4aabb4279f116cf00d Mon Sep 17 00:00:00 2001
From: K144U
Date: Wed, 6 May 2026 16:06:30 +0530
Subject: [PATCH 2/2] optimize CIE compute path and move work off the GUI
thread
- Hoist color-matching functions, illuminants, white-points, and Bradford/sRGB matrices to module-level constants in CIE_XYZ; replace numpy.matrix with ndarray @.
- Memoize the per-illuminant Bradford matrix and trimmed illuminant arrays via lru_cache (no more per-spectrum white_point.csv re-read).
- Add CIElab_batch + tristimulus_batch and use them for the concentration-series path; per-spectrum cost drops to a single dot product (~47x faster on the 13-column xlsx, bit-exact RGB output).
- Replace inner Python loops in colormat fill with slice assignment; switch strip to (64, W, 3) uint8.
- Cache the chromaticity-diagram gamut backdrop at module level (~2x speedup on subsequent saves).
- Move loadFiles work into a QThread/QObject worker so the window stays responsive during processing; pin matplotlib backend to Agg.
Co-Authored-By: Claude Opus 4.7
---
dataManager/CIE_XYZ.py | 469 ++++++++++------
dataManager/loadfiles4CIE.py | 1013 +++++++++++++++++-----------------
2 files changed, 808 insertions(+), 674 deletions(-)
diff --git a/dataManager/CIE_XYZ.py b/dataManager/CIE_XYZ.py
index c670aab..88a7734 100644
--- a/dataManager/CIE_XYZ.py
+++ b/dataManager/CIE_XYZ.py
@@ -1,173 +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
-
- # Chromaticity coordinates (replace previously-deprecated L,a,b slots).
- 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 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 0e2bf1b..31c04d3 100644
--- a/dataManager/loadfiles4CIE.py
+++ b/dataManager/loadfiles4CIE.py
@@ -1,501 +1,512 @@
-# -*- coding: utf-8 -*-
-"""
-Created on Mon Sep 28 19:40:09 2020
-
-@author: priscillababiak
-"""
-
-import os
-import re
-import sys
-import traceback
-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):
- 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):
- 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 = self.filelist
- 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 (only meaningful for kinetic series)
- if num_files > 1:
- 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 the user picked a single file with more than 2 columns, treat it
- # as a wide-format concentration series (one wavelength column + one
- # transmission/absorbance column per concentration).
- if num_files == 1:
- full_path = os.path.join(self.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:
- self._loadConcentrationSeries(
- peek, datatype, spec_illum, image_title, image_aspect,
- image_name, calc_rgb, x_bar, y_bar, z_bar,
- )
- return
-
- # 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
- elif file.endswith(('.xls', '.xlsx')):
- 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:
- cx,cy,_,rr,gg,bb = CIElab(spec_illum,illum,datatype,uvvis_data,x_bar,y_bar,z_bar,calc_rgb)
- lab_values[i,0] = cx
- lab_values[i,1] = cy
- lab_values[i,2] = 0
- lab_values[i,3] = rr
- lab_values[i,4] = gg
- lab_values[i,5] = bb
-
- # extract timestamp (kinetic series only)
- if num_files > 1:
- 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)
- traceback.print_exc()
- 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)
-
- try:
- self._saveChromaticityDiagram(lab_values, image_title, image_name,
- x_bar, y_bar, z_bar)
- except Exception:
- traceback.print_exc()
-
- self.gui.statusbar.showMessage("Finished!")
-
- def _loadConcentrationSeries(self, df, datatype, spec_illum, image_title,
- image_aspect, image_name, calc_rgb,
- x_bar, y_bar, z_bar):
- # First column is wavelength axis; remaining columns are spectra at
- # successive concentrations. Header format expected: " ",
- # e.g. "30 mM" or "12.5 uM".
- 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:
- self.gui.statusbar.showMessage(
- "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]
- n = len(parsed)
-
- target_col = {0: 'Absorbance', 1: 'Transmission', 2: 'FT'}[datatype]
-
- # Auto-scale percent transmission to fraction (existing CIE math
- # assumes T in [0, 1]; user supplied 0-100 percent).
- scale = 1.0
- if datatype == 1:
- sample_max = float(np.nanmax(df[cols_in_order].to_numpy()))
- if sample_max > 1.5:
- scale = 0.01
-
- lab_values = np.zeros((n, 6))
- for i, col in enumerate(cols_in_order):
- sub = pd.DataFrame({
- 'Wavelength': df[wavelength_col],
- target_col: df[col] * scale if datatype == 1 else df[col],
- })
- try:
- cx, cy, _, rr, gg, bb = CIElab(
- spec_illum, illum, datatype, sub,
- x_bar, y_bar, z_bar, calc_rgb,
- )
- lab_values[i] = [cx, cy, 0, rr, gg, bb]
- except Exception:
- print('Could not convert column ' + str(col))
- traceback.print_exc()
- continue
-
- # Drop rows that stayed zero (failed conversions).
- mask = ~np.all(lab_values == 0, axis=1)
- lab_values = lab_values[mask]
- concentrations = concentrations[mask]
- n = len(lab_values)
- if n == 0:
- self.gui.statusbar.showMessage("No spectra could be converted")
- return
-
- if n > 1:
- # Width of each color band proportional to the gap to the next
- # concentration (rounded to integer pixels, minimum 1).
- gaps = np.maximum(np.around(np.diff(concentrations)), 1).astype(int)
- temp_dim = int(np.sum(gaps))
- colormat = np.zeros((temp_dim, temp_dim, 3), dtype=np.uint8)
- curr_idx = 0
- first = True
- for i in range(n - 1):
- for k in range(int(gaps[i])):
- if first:
- colormat[:, i + k] = lab_values[i, 3:]
- curr_idx = i + k
- first = False
- else:
- curr_idx += 1
- colormat[:, curr_idx] = lab_values[i, 3:]
- colormat = colormat[0:curr_idx + 1, 0:curr_idx + 1, :]
-
- 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)
- else:
- colormat = np.zeros((1, 1, 3), dtype=np.uint16)
- colormat[0, 0] = lab_values[0, 3:]
- 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)
-
- try:
- self._saveChromaticityDiagram(lab_values, image_title, image_name,
- x_bar, y_bar, z_bar)
- except Exception:
- traceback.print_exc()
-
- self.gui.statusbar.showMessage("Finished!")
-
- def _saveChromaticityDiagram(self, lab_values, image_title, image_name,
- x_bar, y_bar, z_bar):
- """Save a CIE 1931 xy chromaticity diagram alongside the color strip.
-
- lab_values rows are [cx, cy, _, r, g, b]. Row r/g/b are 0-255 ints
- but may be floats; we normalise to 0-1 for matplotlib.
- """
- from matplotlib.path import Path
-
- x_bar_a = np.asarray(x_bar, dtype=float)
- y_bar_a = np.asarray(y_bar, dtype=float)
- z_bar_a = np.asarray(z_bar, dtype=float)
-
- # Spectral locus: chromaticity coords for each wavelength sample.
- denom = x_bar_a + y_bar_a + z_bar_a
- good = denom > 0
- locus_x = x_bar_a[good] / denom[good]
- locus_y = y_bar_a[good] / denom[good]
- locus = np.column_stack([locus_x, locus_y])
- locus_closed = np.vstack([locus, locus[:1]]) # close polygon for fill
-
- # Build a filled-gamut RGB background by computing sRGB at each (x, y)
- # cell on a grid, then masking points outside the locus polygon.
- 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)
- # Gamma (sRGB) - vectorized form of the per-channel function in CIE_XYZ.
- below = RGB < 0.0031308
- RGB = np.where(below, 12.92 * RGB, 1.055 * np.power(RGB, 0.41666) - 0.055)
- # Normalize each cell so the brightest channel hits 1.0 - this matches
- # the conventional appearance of CIE diagrams (Y=1 produces very dim
- # colors otherwise).
- 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)
-
- # Mask points outside the spectral locus polygon.
- locus_path = Path(locus_closed)
- pts = np.column_stack([gx.ravel(), gy.ravel()])
- inside = locus_path.contains_points(pts).reshape(gx.shape)
- alpha = inside.astype(float)
- rgba = np.dstack([RGB, alpha])
-
- fig, ax = plt.subplots(1, 1, figsize=(7, 6.5))
- ax.imshow(rgba, origin='lower', extent=[0.0, 0.8, 0.0, 0.9],
- aspect='auto', interpolation='bilinear')
-
- # Spectral locus outline + purple line.
- ax.plot(locus_closed[:, 0], locus_closed[:, 1], color='black', lw=1.0)
-
- # D65 white point.
- 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')
-
- # Data points + connector lines from D65.
- for row in lab_values:
- cx, cy = float(row[0]), float(row[1])
- if cx == 0.0 and cy == 0.0:
- continue
- r, g, b = float(row[3]) / 255.0, float(row[4]) / 255.0, float(row[5]) / 255.0
- r, g, b = max(0.0, min(1.0, r)), max(0.0, min(1.0, g)), max(0.0, min(1.0, b))
- ax.plot([d65_x, cx], [d65_y, cy], color=(r, g, b), lw=1.2)
- ax.plot(cx, cy, marker='o', markersize=5,
- markerfacecolor=(r, g, b), 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)
-
- out_path = image_name[:-4] + '_chromaticity.png' \
- if image_name.lower().endswith('.png') else image_name + '_chromaticity.png'
- fig.savefig(out_path, dpi=150, bbox_inches='tight')
- plt.close(fig)
+# -*- 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