From cdf3471f362d9cfd68945cc4701a8e705d969b90 Mon Sep 17 00:00:00 2001 From: K144U Date: Sun, 19 Apr 2026 11:53:43 +0530 Subject: [PATCH 1/3] rewrite GUI in customtkinter with live preview + threaded processing - three-pane layout: controls, matplotlib preview, progress/log panel - refactor loadfiles4CIE into pure functions (no Qt, no sys.exit); callers drive progress + error reporting via callbacks - fix white_point.csv + illuminants.csv to resolve relative to their module so the app works regardless of cwd (and when frozen) - add ColorLab.spec so pyinstaller can bundle everything into a single double-clickable .exe - drop PyQt5 dependency --- .gitignore | 5 + ColorLab.spec | 47 +++ README.md | 43 +-- clgui.py | 27 +- dataManager/CIE_XYZ.py | 8 +- dataManager/__init__.py | 1 - dataManager/loadfiles4CIE.py | 591 ++++++++++++++++++++--------------- requirements.txt | 31 +- ui/app.py | 199 ++++++++++++ ui/controls_panel.py | 247 +++++++++++++++ ui/log_panel.py | 68 ++++ ui/preview_panel.py | 52 +++ ui/worker.py | 72 +++++ 13 files changed, 1063 insertions(+), 328 deletions(-) create mode 100644 ColorLab.spec create mode 100644 ui/app.py create mode 100644 ui/controls_panel.py create mode 100644 ui/log_panel.py create mode 100644 ui/preview_panel.py create mode 100644 ui/worker.py diff --git a/.gitignore b/.gitignore index 2813002..5436264 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,7 @@ MANIFEST # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec +!ColorLab.spec # Installer logs pip-log.txt @@ -107,12 +108,16 @@ celerybeat.pid # Environments .env .venv +.venv-build/ env/ venv/ ENV/ env.bak/ venv.bak/ +# Local tool state +.claude/ + # Spyder project settings .spyderproject .spyproject diff --git a/ColorLab.spec b/ColorLab.spec new file mode 100644 index 0000000..0a6580e --- /dev/null +++ b/ColorLab.spec @@ -0,0 +1,47 @@ +# -*- mode: python ; coding: utf-8 -*- +"""PyInstaller spec — builds ColorLab into a single-file windowed executable.""" + +from PyInstaller.utils.hooks import collect_data_files, collect_submodules + +datas = [ + ('dataManager/illuminants.csv', 'dataManager'), + ('dataManager/white_point.csv', 'dataManager'), +] +datas += collect_data_files('customtkinter') + +hiddenimports = collect_submodules('customtkinter') + +a = Analysis( + ['clgui.py'], + pathex=[], + binaries=[], + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=['PyQt5', 'PyQt6', 'PySide2', 'PySide6', 'tests'], + noarchive=False, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='ColorLab', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/README.md b/README.md index b951d6e..0987947 100644 --- a/README.md +++ b/README.md @@ -9,35 +9,22 @@ ColorLab uses internationally-defined illuminants to translate absorbance spectr ## Running ColorLab -### Downloading ColorLab +ColorLab now ships with a modern customtkinter GUI. Python 3.9+ is required. -The latest release of ColorLab can be downloaded using the above DOI link on Zenodo. The cutting edge version of ColorLab can also be downloaded on GitHub by selecting the green "Code" dropdown menu and selecting "Download ZIP". +1. Download or clone this repository. +2. Open a terminal in the ColorLab folder. +3. Install dependencies: `pip install -r requirements.txt` +4. Launch the app: `python clgui.py` (or `python3 clgui.py` on macOS/Linux). -### Running on Windows +### Using the app -We recommend using Anaconda to run ColorLab, as it contains all of the required packages ColorLab uses. +- **Input folder** — pick a folder containing UV-Vis spectra files (`.csv`, `.xls`, or tab-separated). Filenames ending in `_.ext` enable the time-series color matrix; without timestamps, files render as uniform-width strips. +- **Illuminant** — reference light source. D65 is standard daylight and is the most common choice. +- **Data type** — Absorbance (raw A values), Transmission (%T), or AIPS (internal format). +- **Aspect ratio** — width-to-height ratio of the output image. +- **Image title** — shown on the rendered figure. +- **Preview first file** — fast check that your parameters make sense before running a full batch. +- **Process all files** — runs the whole folder; progress and per-file errors stream to the log on the right. +- **Save image as...** — writes the current preview to a PNG/JPEG of your choice. -1. Download [Anaconda here](https://docs.anaconda.com/anaconda/install/windows/) and follow installation instructions -1. Once installed, open the Anaconda Prompt. -1. Navigate to the ColorLab directory using the command "cd". Example, if starting in C:\Users\LISPEM\ and ColorLab is in Documents, navigate using "cd Documents" "cd *ColorLab folder name*" -1. Once in the main ColorLab folder, type "python clgui.py". This should open ColorLab - -### Running on Linux - -Python is already preinstalled on most Linux operating systems. - -1. Ensure the packages that are listed in requirements.txt are installed -1. In the main ColorLab directory, open a terminal -1. Type "python3 clgui.py". This should open ColorLab - -### Running on Mac - -We recommend using Anaconda on Mac to run ColorLab, as it contains all of the required packages. [Instructions for installing Anaconda on Mac OS X can be found here.](https://docs.anaconda.com/anaconda/install/mac-os/) - -Note: Python comes preinstalled on Mac, you can also individually install all of the packages listed in requirements.txt using your preferred package manager and run it. - -1. Ensure Anaconda OR required packages are installed on your Mac -1. Navigate to the ColorLab folder and open an instance of the Terminal in that folder. Alternatively, open Terminal and navigate to the ColorLab folder using "cd" -1. If using Anaconda, type "python clgui.py" to run. If using the preinstalled Python version, type "python3 clgui.py". This should open ColorLab. - -[Click here for more information about using ColorLab](https://arizona.box.com/s/jh7vkxpwik3q5xojpfgcho5ijw0rthgy) +[More information about ColorLab](https://arizona.box.com/s/jh7vkxpwik3q5xojpfgcho5ijw0rthgy) diff --git a/clgui.py b/clgui.py index 4ecc2aa..9493837 100644 --- a/clgui.py +++ b/clgui.py @@ -1,15 +1,12 @@ -# -*- 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 +"""ColorLab entry point.""" + +from ui.app import ColorLabApp + + +def main() -> None: + app = ColorLabApp() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/dataManager/CIE_XYZ.py b/dataManager/CIE_XYZ.py index 0aef7dd..33b7ea4 100644 --- a/dataManager/CIE_XYZ.py +++ b/dataManager/CIE_XYZ.py @@ -4,9 +4,13 @@ @author: priscillababiak """ -import pandas as pd +import os +import pandas as pd import numpy as np +_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) +_WHITE_POINT_PATH = os.path.join(_MODULE_DIR, "white_point.csv") + def data_cleanup(loaded_data): loaded_data['Wavelength'] = loaded_data['Wavelength'].astype(int) loaded_data = loaded_data.drop_duplicates(subset='Wavelength').reset_index() @@ -69,7 +73,7 @@ def bradford(CIE_X, CIE_Y, CIE_Z, spec_illum): 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") + whites = pd.read_csv(_WHITE_POINT_PATH) # 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]]) diff --git a/dataManager/__init__.py b/dataManager/__init__.py index b7d31f9..e69de29 100644 --- a/dataManager/__init__.py +++ b/dataManager/__init__.py @@ -1 +0,0 @@ -from dataManager.loadfiles4CIE import RGBImage \ No newline at end of file diff --git a/dataManager/loadfiles4CIE.py b/dataManager/loadfiles4CIE.py index 8e0ffda..974281b 100644 --- a/dataManager/loadfiles4CIE.py +++ b/dataManager/loadfiles4CIE.py @@ -1,258 +1,333 @@ -# -*- coding: utf-8 -*- -""" -Created on Mon Sep 28 19:40:09 2020 - -@author: priscillababiak -""" - -import os -import sys -import pandas as pd -import numpy as np -from dataManager.CIE_XYZ import CIElab -import matplotlib.pyplot as plt -from PyQt5.QtWidgets import QMainWindow, QFileDialog -from ui.testgui2 import Ui_MainWindow - -illum = pd.read_csv('dataManager/illuminants.csv') - -class RGBImage(QMainWindow): - - def __init__(self) -> None: - super().__init__() - self.gui = Ui_MainWindow() - self.gui.setupUi(self) - self.gui.pushButton.clicked.connect(self.click) - self.gui.pushButton_2.clicked.connect(self.loadFiles) - self.show() - - def click(self): - options = QFileDialog.Options() - options |= QFileDialog.DontUseNativeDialog - self.filepath = QFileDialog.getExistingDirectory(self,"Select File Directory") - self.gui.lineEdit.setText(self.filepath) - self.gui.lineEdit.setReadOnly(False) - - - - def loadFiles(self): - self.statusBar().clearMessage() - datatype = 0 - if self.gui.radioButton.isChecked(): - datatype = 0 - if self.gui.radioButton_2.isChecked(): - datatype = 1 - if self.gui.radioButton_3.isChecked(): - datatype = 2 - filelist = os.listdir(self.filepath) - filelist.sort() - spec_illum = str(self.gui.comboBox.currentText()) # specify the illuminant - image_title = str(self.gui.lineEdit_2.text()) - image_aspect = float(self.gui.lineEdit_3.text()) - calc_rgb = True # do you want to calculate rgb values? - - # xyz bar values for illuminant - x_bar = [0.001368,0.002236,0.004243,0.00765,0.01431,0.02319,0.04351,0.07763,0.13438,0.21477,0.2839,0.3285, \ - 0.34828,0.34806,0.3362,0.3187,0.2908,0.2511,0.19536,0.1421,0.09564,0.05795,0.03201,0.0147,0.0049, \ - 0.0024,0.0093,0.0291,0.06327,0.1096,0.1655,0.22575,0.2904,0.3597,0.43345,0.51205,0.5945,0.6784, \ - 0.7621,0.8425,0.9163,0.9786,1.0263,1.0567,1.0622,1.0456,1.0026,0.9384,0.85445,0.7514,0.6424,0.5419, \ - 0.4479,0.3608,0.2835,0.2187,0.1649,0.1212,0.0874,0.0636,0.04677,0.0329,0.0227,0.01584,0.011359, \ - 0.008111,0.00579,0.004109,0.002899,0.002049,0.00144,0.001,0.00069,0.000476,0.000332,0.000235,0.000166, \ - 0.000117,8.3e-05,5.9e-05,4.2e-05] - - y_bar= [3.9e-05,6.4e-05,0.00012,0.000217,0.000396,0.00064,0.00121,0.00218,0.004,0.0073,0.0116,0.01684,0.023, \ - 0.0298,0.038,0.048,0.06,0.0739,0.09098,0.1126,0.13902,0.1693,0.20802,0.2586,0.323,0.4073,0.503,0.6082,\ - 0.71,0.7932,0.862,0.91485,0.954,0.9803,0.99495,1,0.995,0.9786,0.952,0.9154,0.87,0.8163,0.757,0.6949, \ - 0.631,0.5668,0.503,0.4412,0.381,0.321,0.265,0.217,0.175,0.1382,0.107,0.0816,0.061,0.04458,0.032,0.0232, \ - 0.017,0.01192,0.00821,0.005723,0.004102,0.002929,0.002091,0.001484,0.001047,0.00074,0.00052,0.000361, \ - 0.000249,0.000172,0.00012,8.5e-05,6e-05,4.2e-05,3e-05,2.1e-05,1.5e-05] - - z_bar = [0.00645,0.01055,0.02005,0.03621,0.06785,0.1102,0.2074,0.3713,0.6456,1.03905,1.3856,1.62296,1.74706,1.7826, \ - 1.77211,1.7441,1.6692,1.5281,1.28764,1.0419,0.81295,0.6162,0.46518,0.3533,0.272,0.2123,0.1582,0.1117, \ - 0.07825,0.05725,0.04216,0.02984,0.0203,0.0134,0.00875,0.00575,0.0039,0.00275,0.0021,0.0018,0.00165,0.0014, \ - 0.0011,0.001,0.0008,0.0006,0.00034,0.00024,0.00019,0.0001,5e-05,3e-05,2e-05,1e-05,0,0,0,0,0,0,0,0,0,0,0,0,0, \ - 0,0,0,0,0,0,0,0,0,0,0,0,0,0] - - num_files = len(filelist) - - lab_values = np.zeros((num_files,6)) - delta = np.zeros((1,num_files)) - i = 0 - - # pull first timestamp - first_time = filelist[0].split('_')[-1] - first_t = [int(word) for word in first_time.split('.') if word.isdigit()] - - # check that image title name is valid - #re1 = re.compile(r"^[^<>/{}[\]~`]*$") - chars_to_be_removed = r'^[^<>/{}[\]~`]*$&#@!;,:' - filtered_chars = filter(lambda item: item not in chars_to_be_removed, image_title) - image_name = ''.join(filtered_chars) - if not os.path.isdir('images/'): - os.mkdir('images/') - image_name = r'images/' + image_name + '.png' - # if re1.match(image_title): - # print ("Image name is valid!") - # image_name = r'images/' + image_name + '.png' - # else: - # error_msg = 'Image name is invalid. Please rename your file.' - # print(error_msg) - # sys.exit() - - for file in filelist: - - #check if the file is a file, or a directory - base_dir = self.filepath - file_name = file - full_path = os.path.join(base_dir, file_name) - isdir = os.path.isdir(full_path) - if isdir: - print('Skipping ',file,' it is a directory!') - continue - else: - if file.endswith('.csv'): - try: - uvvis_data = pd.read_csv(r"{0}/{1}".format(self.filepath,file), sep=None, engine='python') - except: - print(file + ' is corrupt!') - - if file==filelist[-1]: - print('***************************') - print('All files are corrupt!') - print('***************************') - sys.exit(0) - else: - continue - if file.endswith('.xls'): - try: - uvvis_data = pd.read_excel(r"{0}/{1}".format(self.filepath,file)) - except: - print(file + ' is corrupt!') - - if file==filelist[-1]: - print('***************************') - print('All files are corrupt!') - print('***************************') - sys.exit(0) - else: - continue - - else: - try: - uvvis_data = pd.read_table(r"{0}/{1}".format(self.filepath,file), engine='python') - except: - print(file + ' is corrupt!') - - if file==filelist[-1]: - print('***************************') - print('All files are corrupt!') - print('***************************') - sys.exit(0) - else: - continue - - check_data = len(uvvis_data) - if check_data == 0: - continue - - try: - L,a,b,rr,gg,bb = CIElab(spec_illum,illum,datatype,uvvis_data,x_bar,y_bar,z_bar,calc_rgb) - lab_values[i,0] = L - lab_values[i,1] = a - lab_values[i,2] = b - lab_values[i,3] = rr - lab_values[i,4] = gg - lab_values[i,5] = bb - - # extract timestamp - curr_time = file.split('_')[-1] - curr_t = [int(word) for word in curr_time.split('.') if word.isdigit()] - if curr_t[0] > 3660: - seconds_convert = 3600 - units = 'Hours' - else: - seconds_convert = 60 - units = 'Minutes' - - delta[0,i] = (curr_t[0] - first_t[0])/seconds_convert - - i += 1 # end for loop - except: - print('***********************************************************') - print('Could not convert data in ' + file) - print('***********************************************************') - - if file==filelist[-1]: - sys.exit(0) - - # remove rows that were not filled - lab_values = lab_values[~np.all(lab_values == 0, axis=1)] - new_num_files = len(lab_values) - - if new_num_files == 1: - # generate image from the degradation data - scalar = 1 - newdim = scalar*new_num_files - n = 0 - - colormat = np.zeros([newdim,newdim,3], dtype=np.uint16) - for i in range(new_num_files): - colormat[:,n:n+scalar] = lab_values[i,3:] - n += scalar - else: - - if seconds_convert == 3600: delta = delta*60 - - # define the size of the matrix - delta_delta = np.around(np.diff(delta)) - first_t = 1 - - temp_dim = int(np.sum(delta_delta)) - colormat = np.zeros((temp_dim,temp_dim,3), dtype=np.uint8) - for i in range(new_num_files-1): - for k in range(int(delta_delta[0,i])): - if first_t: - colormat[:,i+k] = lab_values[i,3:] - curr_idx = i+k - first_t = 0 - else: - curr_idx = curr_idx+1 - colormat[:,curr_idx] = lab_values[i,3:] - - # resize array - colormat = colormat[0:curr_idx+1,0:curr_idx+1,:] - - len_colormat = len(colormat) - - if (len_colormat > 1): - fig, ax = plt.subplots(1,1) - # figure out axis ticks - # f_idx = new_num_files*0 - # s_idx = round(new_num_files*0.33) - # t_idx = round(new_num_files*0.66) - # l_idx = new_num_files-1 - # ax.set_xticks([f_idx,s_idx,t_idx,l_idx]) - # label_list = [str(round(delta[0,f_idx])),str(round(delta[0,s_idx])), - # str(round(delta[0,t_idx])),str(round(delta[0,l_idx]))] - # ax.set_xticklabels(label_list) - if seconds_convert == 3600: - ax.imshow(colormat,extent=[delta[0,0],np.max(delta)/60,delta[0,0],np.max(delta)/60], - aspect=image_aspect) - else: - ax.imshow(colormat,extent=[delta[0,0],np.max(delta),delta[0,0],np.max(delta)], - aspect=image_aspect) - ax.axes.get_yaxis().set_visible(False) - ax.set_xlabel(units) - ax.set_title(image_title) - fig.savefig(image_name) - else: - fig, ax = plt.subplots(1,1) - ax.imshow(colormat,aspect=image_aspect) - ax.axes.get_xaxis().set_visible(False) - ax.axes.get_yaxis().set_visible(False) - ax.set_title(image_title) - fig.savefig(image_name) - - - self.gui.statusbar.showMessage("Finished!") - +"""Pure processing functions for ColorLab. + +Refactored from the original PyQt5-coupled RGBImage class into callable +functions so any UI (customtkinter, CLI, notebook) can drive the pipeline. +All I/O, progress reporting, and error handling is pushed to the caller +via callbacks — this module never calls sys.exit. +""" + +from __future__ import annotations + +import os +import re +from dataclasses import dataclass +from typing import Callable, Iterator, Optional + +import numpy as np +import pandas as pd +import matplotlib +matplotlib.use("Agg") +from matplotlib.figure import Figure + +from dataManager.CIE_XYZ import CIElab + + +_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) +_ILLUMINANTS_PATH = os.path.join(_MODULE_DIR, "illuminants.csv") + +DATATYPE_ABSORBANCE = 0 +DATATYPE_TRANSMISSION = 1 +DATATYPE_AIPS = 2 + +DATATYPE_LABELS = { + "Absorbance": DATATYPE_ABSORBANCE, + "Transmission": DATATYPE_TRANSMISSION, + "AIPS": DATATYPE_AIPS, +} + +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, +] + + +class FileReadError(Exception): + """Raised when a single input file cannot be parsed.""" + + def __init__(self, filename: str, reason: str) -> None: + super().__init__(f"{filename}: {reason}") + self.filename = filename + self.reason = reason + + +class NoValidDataError(Exception): + """Raised when a batch produces zero usable files.""" + + +@dataclass +class ProcessingParams: + folder: str + datatype: int + spec_illum: str + title: str + aspect: float + only_first: bool = False + + +def load_illuminants_csv() -> pd.DataFrame: + """Load the illuminant spectra table from the package directory.""" + return pd.read_csv(_ILLUMINANTS_PATH) + + +def list_illuminant_names() -> list[str]: + """Return the column names in illuminants.csv excluding 'Wavelength'.""" + cols = list(load_illuminants_csv().columns) + return [c for c in cols if c != "Wavelength"] + + +def _read_spectrum_file(folder: str, filename: str) -> pd.DataFrame: + full_path = os.path.join(folder, filename) + try: + if filename.endswith(".csv"): + return pd.read_csv(full_path, sep=None, engine="python") + if filename.endswith(".xls"): + return pd.read_excel(full_path) + return pd.read_table(full_path, engine="python") + except Exception as exc: + raise FileReadError(filename, f"could not parse ({exc.__class__.__name__})") from exc + + +def iter_spectra_files(folder: str) -> Iterator[tuple[str, pd.DataFrame]]: + """Yield (filename, dataframe) for each parseable file in folder. + + Raises FileReadError for individual unreadable files — the caller decides + whether to skip or abort. + """ + if not os.path.isdir(folder): + raise FileNotFoundError(folder) + entries = sorted(os.listdir(folder)) + for name in entries: + full = os.path.join(folder, name) + if os.path.isdir(full): + continue + df = _read_spectrum_file(folder, name) + if len(df) == 0: + raise FileReadError(name, "file is empty") + yield name, df + + +def _extract_timestamp(filename: str) -> Optional[int]: + """Parse trailing `_.ext` from a filename. Returns seconds or None.""" + stem = filename.rsplit(".", 1)[0] + tail = stem.rsplit("_", 1)[-1] + digits = "".join(ch for ch in tail if ch.isdigit()) + return int(digits) if digits else None + + +def compute_rgb_row( + spec_illum: str, + illum_df: pd.DataFrame, + datatype: int, + uvvis_df: pd.DataFrame, +) -> tuple[float, float, float]: + """Run a single spectrum through the CIE pipeline and return (R, G, B).""" + _, _, _, r, g, b = CIElab( + spec_illum, illum_df, datatype, uvvis_df, X_BAR, Y_BAR, Z_BAR, True + ) + return r, g, b + + +def build_color_matrix( + rgb_rows: np.ndarray, timestamps: list[Optional[int]] +) -> tuple[np.ndarray, np.ndarray, str]: + """Assemble the time-series color matrix. + + rgb_rows: (N, 3) array of RGB values. + timestamps: list of integer seconds (one per row) or None entries. + Returns (colormat, delta, units). + """ + n = len(rgb_rows) + if n == 0: + raise NoValidDataError("no files produced valid color data") + + # Single file: emit a tiny 1x1 swatch. + if n == 1: + colormat = np.zeros((1, 1, 3), dtype=np.uint16) + colormat[0, 0] = rgb_rows[0] + return colormat, np.array([[0]]), "Minutes" + + valid_ts = [t for t in timestamps if t is not None] + if len(valid_ts) < 2: + # Fall back to uniform-spaced strips if filenames don't carry timestamps. + colormat = np.zeros((n, n, 3), dtype=np.uint8) + for i in range(n): + colormat[:, i] = rgb_rows[i] + return colormat, np.arange(n).reshape(1, -1), "Index" + + delta = np.zeros((1, n)) + first_t = valid_ts[0] + use_hours = any(t - first_t > 3660 for t in valid_ts) + seconds_convert = 3600 if use_hours else 60 + units = "Hours" if use_hours else "Minutes" + for i, t in enumerate(timestamps): + if t is None: + delta[0, i] = delta[0, i - 1] if i > 0 else 0 + else: + delta[0, i] = (t - first_t) / seconds_convert + + if seconds_convert == 3600: + delta_for_strips = delta * 60 + else: + delta_for_strips = delta + + delta_delta = np.around(np.diff(delta_for_strips)) + # Guard against zero/negative widths (out-of-order or duplicate timestamps). + delta_delta = np.clip(delta_delta, 1, None) + + temp_dim = int(np.sum(delta_delta)) + if temp_dim < 1: + temp_dim = n + + colormat = np.zeros((temp_dim, temp_dim, 3), dtype=np.uint8) + curr_idx = 0 + first = True + for i in range(n - 1): + for _ in range(int(delta_delta[0, i])): + if first: + colormat[:, curr_idx] = rgb_rows[i] + first = False + else: + curr_idx += 1 + if curr_idx >= temp_dim: + break + colormat[:, curr_idx] = rgb_rows[i] + if curr_idx >= temp_dim - 1: + break + + colormat = colormat[: curr_idx + 1, : curr_idx + 1, :] + return colormat, delta, units + + +_INVALID_TITLE_CHARS = set(r"<>/{}[]~`^$&#@!;,:") + + +def sanitize_title(title: str) -> str: + """Strip filesystem-unfriendly characters from a user-provided title.""" + return "".join(ch for ch in title if ch not in _INVALID_TITLE_CHARS).strip() + + +def render_figure( + colormat: np.ndarray, + delta: np.ndarray, + units: str, + title: str, + aspect: float, +) -> Figure: + """Render a matplotlib Figure from a color matrix. Does NOT save to disk.""" + fig = Figure(figsize=(6, 5), dpi=100) + ax = fig.add_subplot(1, 1, 1) + + safe_title = sanitize_title(title) or "ColorLab" + + if len(colormat) > 1 and delta.size > 1: + max_delta = np.max(delta) + if units == "Hours": + extent = [delta[0, 0], max_delta / 60, delta[0, 0], max_delta / 60] + else: + extent = [delta[0, 0], max_delta, delta[0, 0], max_delta] + ax.imshow(colormat, extent=extent, aspect=aspect) + ax.set_xlabel(units) + ax.axes.get_yaxis().set_visible(False) + else: + ax.imshow(colormat, aspect=aspect) + ax.axes.get_xaxis().set_visible(False) + ax.axes.get_yaxis().set_visible(False) + + ax.set_title(safe_title) + fig.tight_layout() + return fig + + +ProgressCallback = Callable[[int, int, str], None] +FileErrorCallback = Callable[[str, str], None] +CancelCallback = Callable[[], bool] + + +def process_batch( + params: ProcessingParams, + on_progress: Optional[ProgressCallback] = None, + on_file_error: Optional[FileErrorCallback] = None, + should_cancel: Optional[CancelCallback] = None, +) -> Figure: + """Run the full pipeline and return a rendered Figure. + + Callbacks: + on_progress(i, total, filename) — called after each file succeeds. + on_file_error(filename, reason) — called for files skipped due to errors. + should_cancel() -> bool — polled between files; if True, aborts with + whatever rows have been collected so far (if any). + """ + illum_df = load_illuminants_csv() + + filenames = sorted(os.listdir(params.folder)) + filenames = [ + f for f in filenames if not os.path.isdir(os.path.join(params.folder, f)) + ] + if params.only_first and filenames: + filenames = filenames[:1] + total = len(filenames) + if total == 0: + raise NoValidDataError("folder contains no files") + + rgb_rows: list[tuple[float, float, float]] = [] + timestamps: list[Optional[int]] = [] + + for idx, name in enumerate(filenames): + if should_cancel and should_cancel(): + break + try: + df = _read_spectrum_file(params.folder, name) + if len(df) == 0: + raise FileReadError(name, "file is empty") + rgb = compute_rgb_row(params.spec_illum, illum_df, params.datatype, df) + except FileReadError as exc: + if on_file_error: + on_file_error(exc.filename, exc.reason) + continue + except Exception as exc: # pragma: no cover - defensive + if on_file_error: + on_file_error(name, f"{exc.__class__.__name__}: {exc}") + continue + + rgb_rows.append(rgb) + timestamps.append(_extract_timestamp(name)) + if on_progress: + on_progress(idx + 1, total, name) + + if not rgb_rows: + raise NoValidDataError("every file in the folder failed to process") + + rgb_array = np.array(rgb_rows) + colormat, delta, units = build_color_matrix(rgb_array, timestamps) + return render_figure(colormat, delta, units, params.title, params.aspect) diff --git a/requirements.txt b/requirements.txt index bb55085..46792b2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,24 +1,7 @@ -astroid==2.6.6 -autopep8==1.5.7 -cycler==0.10.0 -isort==5.9.3 -kiwisolver==1.3.1 -lazy-object-proxy==1.6.0 -matplotlib==3.4.2 -mccabe==0.6.1 -numpy==1.21.1 -pandas==1.3.1 -Pillow==8.3.1 -pycodestyle==2.7.0 -pylint==2.9.6 -pyparsing==2.4.7 -PyQt5==5.15.6 -PyQt5-Qt5==5.15.2 -PyQt5-sip==12.9.0 -python-dateutil==2.8.2 -pytz==2021.1 -scipy==1.7.1 -six==1.16.0 -toml==0.10.2 -wrapt==1.12.1 -xlrd==2.0.1 +customtkinter>=5.2.0 +matplotlib>=3.4 +numpy>=1.21 +pandas>=1.3 +Pillow>=8.3 +scipy>=1.7 +xlrd>=2.0 diff --git a/ui/app.py b/ui/app.py new file mode 100644 index 0000000..0615465 --- /dev/null +++ b/ui/app.py @@ -0,0 +1,199 @@ +"""ColorLab main window — customtkinter.""" + +from __future__ import annotations + +import queue +from tkinter import filedialog, messagebox + +import customtkinter as ctk + +from dataManager.loadfiles4CIE import ( + DATATYPE_LABELS, + ProcessingParams, + list_illuminant_names, +) +from ui.controls_panel import ControlsPanel +from ui.log_panel import LogPanel +from ui.preview_panel import PreviewPanel +from ui.worker import ( + EVENT_CANCELLED, + EVENT_DONE, + EVENT_FATAL, + EVENT_FILE_ERROR, + EVENT_PROGRESS, + ProcessingWorker, +) + + +class ColorLabApp(ctk.CTk): + def __init__(self) -> None: + super().__init__() + ctk.set_appearance_mode("system") + ctk.set_default_color_theme("blue") + + self.title("ColorLab") + self.geometry("1200x760") + self.minsize(1000, 600) + + self._event_queue: "queue.Queue[tuple]" = queue.Queue() + self._worker = ProcessingWorker(self._event_queue) + + try: + illuminants = list_illuminant_names() + except Exception as exc: + messagebox.showerror( + "ColorLab", + f"Could not load illuminants.csv:\n{exc}\n\n" + "Make sure the dataManager folder is intact.", + ) + illuminants = [] + + self._build_layout(illuminants) + self.after(50, self._drain_queue) + + def _build_layout(self, illuminants: list[str]) -> None: + self.grid_columnconfigure(0, weight=0, minsize=320) + self.grid_columnconfigure(1, weight=1) + self.grid_columnconfigure(2, weight=0, minsize=320) + self.grid_rowconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=0) + + self.controls = ControlsPanel( + self, + illuminants=illuminants, + on_preview=self._handle_preview, + on_process=self._handle_process, + on_cancel=self._handle_cancel, + on_save=self._handle_save, + corner_radius=8, + ) + self.controls.grid(row=0, column=0, sticky="nsew", padx=(12, 6), pady=(12, 6)) + + self.preview = PreviewPanel(self, corner_radius=8) + self.preview.grid(row=0, column=1, sticky="nsew", padx=6, pady=(12, 6)) + + self.log = LogPanel(self, corner_radius=8) + self.log.grid(row=0, column=2, sticky="nsew", padx=(6, 12), pady=(12, 6)) + + self.status_var = ctk.StringVar(value="Ready") + status_bar = ctk.CTkFrame(self, corner_radius=0, height=26) + status_bar.grid(row=1, column=0, columnspan=3, sticky="ew") + ctk.CTkLabel(status_bar, textvariable=self.status_var, anchor="w").pack( + fill="x", padx=12, pady=4 + ) + + # ---- button handlers ---- + def _validate_and_build_params(self, only_first: bool) -> ProcessingParams | None: + folder = self.controls.folder_var.get().strip() + if not folder: + messagebox.showwarning("ColorLab", "Please choose an input folder first.") + return None + illum = self.controls.illum_var.get().strip() + if not illum: + messagebox.showwarning("ColorLab", "Please choose an illuminant.") + return None + aspect = self.controls.get_aspect() + if aspect is None: + messagebox.showwarning( + "ColorLab", "Aspect ratio must be a positive number." + ) + return None + datatype_label = self.controls.datatype_var.get() + datatype = DATATYPE_LABELS.get(datatype_label, 0) + title = self.controls.title_var.get().strip() or "ColorLab" + return ProcessingParams( + folder=folder, + datatype=datatype, + spec_illum=illum, + title=title, + aspect=aspect, + only_first=only_first, + ) + + def _handle_preview(self) -> None: + params = self._validate_and_build_params(only_first=True) + if params is None: + return + self._start_worker(params, label="Previewing first file...") + + def _handle_process(self) -> None: + params = self._validate_and_build_params(only_first=False) + if params is None: + return + self._start_worker(params, label="Processing all files...") + + def _handle_cancel(self) -> None: + if self._worker.is_running: + self._worker.cancel() + self.status_var.set("Cancelling...") + self.log.info("Cancel requested") + + def _handle_save(self) -> None: + fig = self.preview.current_figure + if fig is None: + messagebox.showinfo("ColorLab", "Generate a preview first.") + return + path = filedialog.asksaveasfilename( + title="Save image as", + defaultextension=".png", + filetypes=[("PNG image", "*.png"), ("JPEG image", "*.jpg"), ("All files", "*.*")], + initialfile="colorlab_result.png", + ) + if not path: + return + try: + fig.savefig(path, dpi=150, bbox_inches="tight") + self.status_var.set(f"Saved: {path}") + self.log.info(f"Saved image to {path}") + except Exception as exc: + messagebox.showerror("ColorLab", f"Could not save image:\n{exc}") + + # ---- worker lifecycle ---- + def _start_worker(self, params: ProcessingParams, label: str) -> None: + if self._worker.is_running: + return + self.log.reset() + self.log.info(label) + self.preview.show_loading(label) + self.controls.set_running(True) + self.controls.set_save_enabled(False) + self.status_var.set(label) + self._worker.start(params) + + def _drain_queue(self) -> None: + try: + while True: + event = self._event_queue.get_nowait() + self._handle_event(event) + except queue.Empty: + pass + self.after(50, self._drain_queue) + + def _handle_event(self, event: tuple) -> None: + kind = event[0] + if kind == EVENT_PROGRESS: + _, i, total, name = event + self.log.set_progress(i, total, name) + self.log.ok(name) + elif kind == EVENT_FILE_ERROR: + _, name, reason = event + self.log.error(name, reason) + elif kind == EVENT_DONE: + _, figure = event + self.preview.set_figure(figure) + self.controls.set_running(False) + self.controls.set_save_enabled(True) + self.status_var.set("Done") + self.log.info("Finished") + elif kind == EVENT_CANCELLED: + self.controls.set_running(False) + self.preview.show_empty() + self.status_var.set("Cancelled") + self.log.info("Cancelled") + elif kind == EVENT_FATAL: + _, msg = event + self.controls.set_running(False) + self.preview.show_empty() + self.status_var.set("Error") + self.log.error("batch", msg) + messagebox.showerror("ColorLab", msg) diff --git a/ui/controls_panel.py b/ui/controls_panel.py new file mode 100644 index 0000000..90d19ba --- /dev/null +++ b/ui/controls_panel.py @@ -0,0 +1,247 @@ +"""Left-hand controls: folder picker, illuminant, data type, action buttons.""" + +from __future__ import annotations + +from tkinter import filedialog +from typing import Callable + +import customtkinter as ctk + + +_DATATYPE_HELP = ( + "Absorbance: raw A(\u03bb) values. Standard UV-Vis CSV output.\n" + "Transmission: %T values 0-100.\n" + "AIPS: fractional transmission delta (internal format)." +) + +_ILLUM_HELP = ( + "The reference light source used to compute the color.\n" + "D65 = average daylight (most common). A = incandescent. " + "F-series = fluorescent." +) + +_ASPECT_HELP = ( + "Width-to-height ratio of the output image. 1 = square. " + "Increase to stretch horizontally." +) + + +class _Tooltip: + """Lightweight hover tooltip that avoids extra dependencies.""" + + def __init__(self, widget, text: str) -> None: + self.widget = widget + self.text = text + self._tip: ctk.CTkToplevel | None = None + widget.bind("", self._show) + widget.bind("", self._hide) + + def _show(self, _event=None) -> None: + if self._tip is not None: + return + x = self.widget.winfo_rootx() + 24 + y = self.widget.winfo_rooty() + self.widget.winfo_height() + 4 + self._tip = ctk.CTkToplevel(self.widget) + self._tip.overrideredirect(True) + self._tip.geometry(f"+{x}+{y}") + self._tip.attributes("-topmost", True) + label = ctk.CTkLabel( + self._tip, + text=self.text, + justify="left", + wraplength=280, + padx=10, + pady=6, + fg_color=("gray85", "gray20"), + corner_radius=6, + ) + label.pack() + + def _hide(self, _event=None) -> None: + if self._tip is not None: + self._tip.destroy() + self._tip = None + + +def _help_badge(parent, text: str) -> ctk.CTkLabel: + badge = ctk.CTkLabel( + parent, + text="?", + width=20, + height=20, + corner_radius=10, + fg_color=("gray75", "gray30"), + text_color=("gray10", "gray90"), + font=ctk.CTkFont(size=11, weight="bold"), + ) + _Tooltip(badge, text) + return badge + + +class ControlsPanel(ctk.CTkFrame): + def __init__( + self, + master, + illuminants: list[str], + on_preview: Callable[[], None], + on_process: Callable[[], None], + on_cancel: Callable[[], None], + on_save: Callable[[], None], + **kwargs, + ) -> None: + super().__init__(master, **kwargs) + self._on_preview = on_preview + self._on_process = on_process + self._on_cancel = on_cancel + self._on_save = on_save + + self.folder_var = ctk.StringVar(value="") + self.illum_var = ctk.StringVar(value=illuminants[0] if illuminants else "") + self.datatype_var = ctk.StringVar(value="Absorbance") + self.aspect_var = ctk.StringVar(value="1") + self.title_var = ctk.StringVar(value="ColorLab result") + + self._build(illuminants) + + def _build(self, illuminants: list[str]) -> None: + row = 0 + header = ctk.CTkLabel( + self, text="Inputs", font=ctk.CTkFont(size=16, weight="bold") + ) + header.grid(row=row, column=0, columnspan=2, sticky="w", padx=16, pady=(16, 8)) + row += 1 + + # Folder picker + ctk.CTkLabel(self, text="Input folder").grid( + row=row, column=0, sticky="w", padx=16 + ) + row += 1 + folder_frame = ctk.CTkFrame(self, fg_color="transparent") + folder_frame.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16) + folder_frame.grid_columnconfigure(0, weight=1) + ctk.CTkEntry(folder_frame, textvariable=self.folder_var).grid( + row=0, column=0, sticky="ew" + ) + ctk.CTkButton( + folder_frame, text="Browse", width=80, command=self._pick_folder + ).grid(row=0, column=1, padx=(6, 0)) + row += 1 + + # Illuminant + ctk.CTkLabel(self, text="Illuminant").grid( + row=row, column=0, sticky="w", padx=16, pady=(12, 0) + ) + _help_badge(self, _ILLUM_HELP).grid( + row=row, column=1, sticky="w", padx=(0, 16), pady=(12, 0) + ) + row += 1 + ctk.CTkComboBox( + self, + values=illuminants, + variable=self.illum_var, + state="readonly", + ).grid(row=row, column=0, columnspan=2, sticky="ew", padx=16) + row += 1 + + # Data type + ctk.CTkLabel(self, text="Data type").grid( + row=row, column=0, sticky="w", padx=16, pady=(12, 0) + ) + _help_badge(self, _DATATYPE_HELP).grid( + row=row, column=1, sticky="w", padx=(0, 16), pady=(12, 0) + ) + row += 1 + ctk.CTkSegmentedButton( + self, + values=["Absorbance", "Transmission", "AIPS"], + variable=self.datatype_var, + ).grid(row=row, column=0, columnspan=2, sticky="ew", padx=16) + row += 1 + + # Aspect + ctk.CTkLabel(self, text="Aspect ratio").grid( + row=row, column=0, sticky="w", padx=16, pady=(12, 0) + ) + _help_badge(self, _ASPECT_HELP).grid( + row=row, column=1, sticky="w", padx=(0, 16), pady=(12, 0) + ) + row += 1 + ctk.CTkEntry(self, textvariable=self.aspect_var).grid( + row=row, column=0, columnspan=2, sticky="ew", padx=16 + ) + row += 1 + + # Title + ctk.CTkLabel(self, text="Image title").grid( + row=row, column=0, sticky="w", padx=16, pady=(12, 0) + ) + row += 1 + ctk.CTkEntry(self, textvariable=self.title_var).grid( + row=row, column=0, columnspan=2, sticky="ew", padx=16 + ) + row += 1 + + # Separator spacing + ctk.CTkFrame(self, fg_color=("gray80", "gray25"), height=1).grid( + row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=16 + ) + row += 1 + + # Action buttons + self.preview_btn = ctk.CTkButton( + self, text="Preview first file", command=self._on_preview + ) + self.preview_btn.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=4) + row += 1 + + self.process_btn = ctk.CTkButton( + self, text="Process all files", command=self._on_process, + fg_color=("#2F6FEB", "#1F4FB7"), + hover_color=("#2458C2", "#163B8A"), + ) + self.process_btn.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=4) + row += 1 + + self.cancel_btn = ctk.CTkButton( + self, text="Cancel", command=self._on_cancel, + fg_color=("gray60", "gray40"), + hover_color=("gray50", "gray30"), + state="disabled", + ) + self.cancel_btn.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=4) + row += 1 + + ctk.CTkFrame(self, fg_color=("gray80", "gray25"), height=1).grid( + row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=16 + ) + row += 1 + + self.save_btn = ctk.CTkButton( + self, text="Save image as...", command=self._on_save, state="disabled" + ) + self.save_btn.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16, pady=4) + row += 1 + + self.grid_columnconfigure(0, weight=1) + + def _pick_folder(self) -> None: + path = filedialog.askdirectory(title="Select folder of spectra files") + if path: + self.folder_var.set(path) + + def set_running(self, running: bool) -> None: + state = "disabled" if running else "normal" + self.preview_btn.configure(state=state) + self.process_btn.configure(state=state) + self.cancel_btn.configure(state="normal" if running else "disabled") + + def set_save_enabled(self, enabled: bool) -> None: + self.save_btn.configure(state="normal" if enabled else "disabled") + + def get_aspect(self) -> float | None: + raw = self.aspect_var.get().strip() + try: + val = float(raw) + return val if val > 0 else None + except ValueError: + return None diff --git a/ui/log_panel.py b/ui/log_panel.py new file mode 100644 index 0000000..51b00f2 --- /dev/null +++ b/ui/log_panel.py @@ -0,0 +1,68 @@ +"""Right-hand log panel: progress bar + scrolling color-coded log.""" + +from __future__ import annotations + +import customtkinter as ctk + + +class LogPanel(ctk.CTkFrame): + def __init__(self, master, **kwargs) -> None: + super().__init__(master, **kwargs) + self._build() + + def _build(self) -> None: + header = ctk.CTkLabel( + self, text="Progress", font=ctk.CTkFont(size=16, weight="bold") + ) + header.pack(anchor="w", padx=16, pady=(16, 8)) + + self.progress = ctk.CTkProgressBar(self) + self.progress.pack(fill="x", padx=16) + self.progress.set(0) + + self.progress_label = ctk.CTkLabel( + self, text="Idle", text_color=("gray40", "gray70"), anchor="w" + ) + self.progress_label.pack(fill="x", padx=16, pady=(4, 12)) + + self.textbox = ctk.CTkTextbox( + self, wrap="word", font=ctk.CTkFont(family="Consolas", size=12) + ) + self.textbox.pack(fill="both", expand=True, padx=16, pady=(0, 16)) + self.textbox.configure(state="disabled") + self.textbox.tag_config("ok", foreground="#2E8B57") + self.textbox.tag_config("err", foreground="#D64545") + self.textbox.tag_config("info", foreground="#4A7FBF") + + def _append(self, text: str, tag: str | None = None) -> None: + self.textbox.configure(state="normal") + if tag: + self.textbox.insert("end", text, tag) + else: + self.textbox.insert("end", text) + self.textbox.see("end") + self.textbox.configure(state="disabled") + + def info(self, msg: str) -> None: + self._append(f" {msg}\n", tag="info") + + def ok(self, filename: str) -> None: + self._append(f" OK {filename}\n", tag="ok") + + def error(self, filename: str, reason: str) -> None: + self._append(f" ERR {filename} \u2014 {reason}\n", tag="err") + + def set_progress(self, i: int, total: int, filename: str = "") -> None: + fraction = 0 if total == 0 else min(1.0, i / total) + self.progress.set(fraction) + if filename: + self.progress_label.configure(text=f"{i}/{total} {filename}") + else: + self.progress_label.configure(text=f"{i}/{total}") + + def reset(self) -> None: + self.progress.set(0) + self.progress_label.configure(text="Idle") + self.textbox.configure(state="normal") + self.textbox.delete("1.0", "end") + self.textbox.configure(state="disabled") diff --git a/ui/preview_panel.py b/ui/preview_panel.py new file mode 100644 index 0000000..94531e0 --- /dev/null +++ b/ui/preview_panel.py @@ -0,0 +1,52 @@ +"""Matplotlib figure preview embedded in a customtkinter frame.""" + +from __future__ import annotations + +from typing import Optional + +import customtkinter as ctk +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure + + +class PreviewPanel(ctk.CTkFrame): + def __init__(self, master, **kwargs) -> None: + super().__init__(master, **kwargs) + self._mpl_canvas: Optional[FigureCanvasTkAgg] = None + self._placeholder: Optional[ctk.CTkLabel] = None + self._show_placeholder("Pick an input folder,\nthen click Preview or Process.") + + def _clear(self) -> None: + if self._mpl_canvas is not None: + self._mpl_canvas.get_tk_widget().destroy() + self._mpl_canvas = None + if self._placeholder is not None: + self._placeholder.destroy() + self._placeholder = None + + def _show_placeholder(self, text: str) -> None: + self._clear() + self._placeholder = ctk.CTkLabel( + self, + text=text, + font=ctk.CTkFont(size=14), + text_color=("gray40", "gray70"), + justify="center", + ) + self._placeholder.place(relx=0.5, rely=0.5, anchor="center") + + def set_figure(self, figure: Figure) -> None: + self._clear() + self._mpl_canvas = FigureCanvasTkAgg(figure, master=self) + self._mpl_canvas.draw() + self._mpl_canvas.get_tk_widget().pack(fill="both", expand=True, padx=8, pady=8) + + def show_loading(self, text: str = "Processing...") -> None: + self._show_placeholder(text) + + def show_empty(self) -> None: + self._show_placeholder("Pick an input folder,\nthen click Preview or Process.") + + @property + def current_figure(self) -> Optional[Figure]: + return self._mpl_canvas.figure if self._mpl_canvas else None diff --git a/ui/worker.py b/ui/worker.py new file mode 100644 index 0000000..bea6a78 --- /dev/null +++ b/ui/worker.py @@ -0,0 +1,72 @@ +"""Background worker that runs the ColorLab pipeline off the UI thread.""" + +from __future__ import annotations + +import queue +import threading +from typing import Optional + +from dataManager.loadfiles4CIE import ( + NoValidDataError, + ProcessingParams, + process_batch, +) + + +EVENT_PROGRESS = "progress" # (EVENT_PROGRESS, i, total, filename) +EVENT_FILE_ERROR = "file_error" # (EVENT_FILE_ERROR, filename, reason) +EVENT_DONE = "done" # (EVENT_DONE, figure) +EVENT_FATAL = "fatal" # (EVENT_FATAL, message) +EVENT_CANCELLED = "cancelled" # (EVENT_CANCELLED,) + + +class ProcessingWorker: + """Spawns a daemon thread and streams events back via a queue.""" + + def __init__(self, event_queue: "queue.Queue[tuple]") -> None: + self._queue = event_queue + self._thread: Optional[threading.Thread] = None + self._cancel = threading.Event() + + @property + def is_running(self) -> bool: + return self._thread is not None and self._thread.is_alive() + + def start(self, params: ProcessingParams) -> None: + if self.is_running: + return + self._cancel.clear() + self._thread = threading.Thread( + target=self._run, args=(params,), daemon=True + ) + self._thread.start() + + def cancel(self) -> None: + self._cancel.set() + + def _run(self, params: ProcessingParams) -> None: + try: + figure = process_batch( + params, + on_progress=lambda i, total, name: self._queue.put( + (EVENT_PROGRESS, i, total, name) + ), + on_file_error=lambda name, reason: self._queue.put( + (EVENT_FILE_ERROR, name, reason) + ), + should_cancel=self._cancel.is_set, + ) + except NoValidDataError as exc: + self._queue.put((EVENT_FATAL, str(exc))) + return + except FileNotFoundError as exc: + self._queue.put((EVENT_FATAL, f"Folder not found: {exc}")) + return + except Exception as exc: # pragma: no cover - defensive + self._queue.put((EVENT_FATAL, f"{exc.__class__.__name__}: {exc}")) + return + + if self._cancel.is_set(): + self._queue.put((EVENT_CANCELLED,)) + else: + self._queue.put((EVENT_DONE, figure)) From 64851b73b91b792dcd42936ac0ac0054aebf93aa Mon Sep 17 00:00:00 2001 From: K144U Date: Thu, 23 Apr 2026 16:22:49 +0530 Subject: [PATCH 2/3] switch to multi-file picker and normalize column names - controls panel now uses askopenfilenames instead of askdirectory, so users can multi-select .csv/.xls/.xlsx/.txt files from anywhere - ProcessingParams carries a list of filepaths; process_batch iterates it directly - added normalize_columns so common header variants ("wavelength (nm)", "abs", "%t", etc.) are accepted - added .xlsx support (pd.read_excel engine); openpyxl pinned in requirements --- dataManager/loadfiles4CIE.py | 37 ++++++++++++++++++++++++------------ requirements.txt | 1 + ui/app.py | 8 ++++---- ui/controls_panel.py | 34 ++++++++++++++++++++++----------- 4 files changed, 53 insertions(+), 27 deletions(-) diff --git a/dataManager/loadfiles4CIE.py b/dataManager/loadfiles4CIE.py index 974281b..80369fc 100644 --- a/dataManager/loadfiles4CIE.py +++ b/dataManager/loadfiles4CIE.py @@ -87,7 +87,7 @@ class NoValidDataError(Exception): @dataclass class ProcessingParams: - folder: str + filepaths: list[str] datatype: int spec_illum: str title: str @@ -106,14 +106,14 @@ def list_illuminant_names() -> list[str]: return [c for c in cols if c != "Wavelength"] -def _read_spectrum_file(folder: str, filename: str) -> pd.DataFrame: - full_path = os.path.join(folder, filename) +def _read_spectrum_file(full_path: str) -> pd.DataFrame: + filename = os.path.basename(full_path) try: if filename.endswith(".csv"): return pd.read_csv(full_path, sep=None, engine="python") - if filename.endswith(".xls"): + if filename.endswith((".xls", ".xlsx")): return pd.read_excel(full_path) - return pd.read_table(full_path, engine="python") + return pd.read_table(full_path, sep=None, engine="python") except Exception as exc: raise FileReadError(filename, f"could not parse ({exc.__class__.__name__})") from exc @@ -145,6 +145,20 @@ def _extract_timestamp(filename: str) -> Optional[int]: return int(digits) if digits else None +def normalize_columns(df: pd.DataFrame) -> pd.DataFrame: + """Rename common variations of column names to the expected standard.""" + col_map = {} + for col in df.columns: + scol = str(col).lower().strip() + if scol in ["wavelength", "wavelength (nm)", "nm", "w"]: + col_map[col] = "Wavelength" + elif scol in ["absorbance", "abs", "a"]: + col_map[col] = "Absorbance" + elif scol in ["transmission", "%t", "t"]: + col_map[col] = "Transmission" + return df.rename(columns=col_map) + + def compute_rgb_row( spec_illum: str, illum_df: pd.DataFrame, @@ -152,6 +166,7 @@ def compute_rgb_row( uvvis_df: pd.DataFrame, ) -> tuple[float, float, float]: """Run a single spectrum through the CIE pipeline and return (R, G, B).""" + uvvis_df = normalize_columns(uvvis_df) _, _, _, r, g, b = CIElab( spec_illum, illum_df, datatype, uvvis_df, X_BAR, Y_BAR, Z_BAR, True ) @@ -290,24 +305,22 @@ def process_batch( """ illum_df = load_illuminants_csv() - filenames = sorted(os.listdir(params.folder)) - filenames = [ - f for f in filenames if not os.path.isdir(os.path.join(params.folder, f)) - ] + filenames = params.filepaths if params.only_first and filenames: filenames = filenames[:1] total = len(filenames) if total == 0: - raise NoValidDataError("folder contains no files") + raise NoValidDataError("no files selected") rgb_rows: list[tuple[float, float, float]] = [] timestamps: list[Optional[int]] = [] - for idx, name in enumerate(filenames): + for idx, path in enumerate(filenames): + name = os.path.basename(path) if should_cancel and should_cancel(): break try: - df = _read_spectrum_file(params.folder, name) + df = _read_spectrum_file(path) if len(df) == 0: raise FileReadError(name, "file is empty") rgb = compute_rgb_row(params.spec_illum, illum_df, params.datatype, df) diff --git a/requirements.txt b/requirements.txt index 46792b2..fbec559 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pandas>=1.3 Pillow>=8.3 scipy>=1.7 xlrd>=2.0 +openpyxl>=3.0 diff --git a/ui/app.py b/ui/app.py index 0615465..078de93 100644 --- a/ui/app.py +++ b/ui/app.py @@ -84,9 +84,9 @@ def _build_layout(self, illuminants: list[str]) -> None: # ---- button handlers ---- def _validate_and_build_params(self, only_first: bool) -> ProcessingParams | None: - folder = self.controls.folder_var.get().strip() - if not folder: - messagebox.showwarning("ColorLab", "Please choose an input folder first.") + filepaths = getattr(self.controls, "selected_files", []) + if not filepaths: + messagebox.showwarning("ColorLab", "Please choose input files first.") return None illum = self.controls.illum_var.get().strip() if not illum: @@ -102,7 +102,7 @@ def _validate_and_build_params(self, only_first: bool) -> ProcessingParams | Non datatype = DATATYPE_LABELS.get(datatype_label, 0) title = self.controls.title_var.get().strip() or "ColorLab" return ProcessingParams( - folder=folder, + filepaths=filepaths, datatype=datatype, spec_illum=illum, title=title, diff --git a/ui/controls_panel.py b/ui/controls_panel.py index 90d19ba..f837317 100644 --- a/ui/controls_panel.py +++ b/ui/controls_panel.py @@ -96,6 +96,7 @@ def __init__( self._on_save = on_save self.folder_var = ctk.StringVar(value="") + self.selected_files: list[str] = [] self.illum_var = ctk.StringVar(value=illuminants[0] if illuminants else "") self.datatype_var = ctk.StringVar(value="Absorbance") self.aspect_var = ctk.StringVar(value="1") @@ -111,19 +112,19 @@ def _build(self, illuminants: list[str]) -> None: header.grid(row=row, column=0, columnspan=2, sticky="w", padx=16, pady=(16, 8)) row += 1 - # Folder picker - ctk.CTkLabel(self, text="Input folder").grid( + # File picker + ctk.CTkLabel(self, text="Input files").grid( row=row, column=0, sticky="w", padx=16 ) row += 1 - folder_frame = ctk.CTkFrame(self, fg_color="transparent") - folder_frame.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16) - folder_frame.grid_columnconfigure(0, weight=1) - ctk.CTkEntry(folder_frame, textvariable=self.folder_var).grid( + file_frame = ctk.CTkFrame(self, fg_color="transparent") + file_frame.grid(row=row, column=0, columnspan=2, sticky="ew", padx=16) + file_frame.grid_columnconfigure(0, weight=1) + ctk.CTkEntry(file_frame, textvariable=self.folder_var, state="disabled").grid( row=0, column=0, sticky="ew" ) ctk.CTkButton( - folder_frame, text="Browse", width=80, command=self._pick_folder + file_frame, text="Browse", width=80, command=self._pick_files ).grid(row=0, column=1, padx=(6, 0)) row += 1 @@ -224,10 +225,21 @@ def _build(self, illuminants: list[str]) -> None: self.grid_columnconfigure(0, weight=1) - def _pick_folder(self) -> None: - path = filedialog.askdirectory(title="Select folder of spectra files") - if path: - self.folder_var.set(path) + def _pick_files(self) -> None: + import os + paths = filedialog.askopenfilenames( + title="Select spectra files", + filetypes=[ + ("Spectra files", "*.txt *.csv *.xls *.xlsx"), + ("All files", "*.*"), + ] + ) + if paths: + self.selected_files = list(paths) + if len(self.selected_files) == 1: + self.folder_var.set(os.path.basename(self.selected_files[0])) + else: + self.folder_var.set(f"{len(self.selected_files)} files selected") def set_running(self, running: bool) -> None: state = "disabled" if running else "normal" From bfc27715e756deea210fd87e9f043359088a11f0 Mon Sep 17 00:00:00 2001 From: K144U Date: Sat, 25 Apr 2026 14:02:21 +0530 Subject: [PATCH 3/3] support wide multi-spectrum sheets and auto-scale percent transmission - loadfiles4CIE: fan out files with one Wavelength + multiple numeric columns into per-column single-spectrum frames before they hit CIElab. Existing single-spectrum files are unaffected. - CIE_XYZ: when Transmission max > 1.5, divide by 100 so 0-100 %T inputs produce the same fractional values that absorbance and AIPS branches do. - gitignore: ignore *.xls/*.xlsx so user spectra inputs don't get tracked. --- .gitignore | 6 +++- dataManager/CIE_XYZ.py | 4 +++ dataManager/loadfiles4CIE.py | 70 +++++++++++++++++++++++++++++++++--- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 5436264..4deb5f8 100644 --- a/.gitignore +++ b/.gitignore @@ -147,4 +147,8 @@ cython_debug/ # Image related stuff images/ -*.png \ No newline at end of file +*.png + +# User spectra inputs +*.xls +*.xlsx \ No newline at end of file diff --git a/dataManager/CIE_XYZ.py b/dataManager/CIE_XYZ.py index 33b7ea4..687e46b 100644 --- a/dataManager/CIE_XYZ.py +++ b/dataManager/CIE_XYZ.py @@ -23,6 +23,10 @@ def CIElab(spec_illum, illum, datatype, df_list, x_bar, y_bar, z_bar, calcRGB): subdf.rename(columns = {'Absorbance':'Transmission'}, inplace=True) elif datatype == 1: subdf = df_list[['Wavelength', 'Transmission']].copy() + # Auto-scale %T (0-100) to fractional (0-1). Values slightly above 100 + # are tolerated (instrument noise on near-transparent references). + if subdf['Transmission'].max() > 1.5: + subdf['Transmission'] = subdf['Transmission'] / 100 elif datatype == 2: subdf = df_list[['Wavelength', 'FT']].copy() subdf['FT'] = (1+subdf['FT']) * 100 diff --git a/dataManager/loadfiles4CIE.py b/dataManager/loadfiles4CIE.py index 80369fc..35aee34 100644 --- a/dataManager/loadfiles4CIE.py +++ b/dataManager/loadfiles4CIE.py @@ -159,6 +159,47 @@ def normalize_columns(df: pd.DataFrame) -> pd.DataFrame: return df.rename(columns=col_map) +_TARGET_COLUMN_BY_DATATYPE = { + DATATYPE_ABSORBANCE: "Absorbance", + DATATYPE_TRANSMISSION: "Transmission", + DATATYPE_AIPS: "FT", +} + + +def _iter_spectra_from_frame( + filename: str, df: pd.DataFrame, datatype: int +) -> Iterator[tuple[str, pd.DataFrame]]: + """Yield one (label, single-spectrum DataFrame) per spectrum in a file. + + Single-spectrum files (Wavelength + the expected data column) yield once + with the original frame. Wide multi-spectrum sheets (Wavelength + multiple + numeric columns) yield once per numeric column, with each frame reshaped + to look like a standard single-spectrum file so CIElab stays unchanged. + """ + df = normalize_columns(df) + if "Wavelength" not in df.columns: + raise FileReadError(filename, "no recognizable wavelength column") + + target = _TARGET_COLUMN_BY_DATATYPE.get(datatype, "Transmission") + + if target in df.columns: + yield filename, df + return + + numeric_cols = [ + c for c in df.columns + if c != "Wavelength" and pd.api.types.is_numeric_dtype(df[c]) + ] + if not numeric_cols: + raise FileReadError( + filename, f"no '{target}' column or numeric data columns found" + ) + + for col in numeric_cols: + sub = df[["Wavelength", col]].rename(columns={col: target}) + yield f"{filename} :: {col}", sub + + def compute_rgb_row( spec_illum: str, illum_df: pd.DataFrame, @@ -323,7 +364,7 @@ def process_batch( df = _read_spectrum_file(path) if len(df) == 0: raise FileReadError(name, "file is empty") - rgb = compute_rgb_row(params.spec_illum, illum_df, params.datatype, df) + spectra = list(_iter_spectra_from_frame(name, df, params.datatype)) except FileReadError as exc: if on_file_error: on_file_error(exc.filename, exc.reason) @@ -333,10 +374,29 @@ def process_batch( on_file_error(name, f"{exc.__class__.__name__}: {exc}") continue - rgb_rows.append(rgb) - timestamps.append(_extract_timestamp(name)) - if on_progress: - on_progress(idx + 1, total, name) + # Single-spectrum files keep the filename timestamp; wide multi-spectrum + # files share no real time axis, so they fall through to equal-width + # strips in build_color_matrix. + file_ts = _extract_timestamp(name) if len(spectra) == 1 else None + + for label, sub_df in spectra: + try: + rgb = compute_rgb_row( + params.spec_illum, illum_df, params.datatype, sub_df + ) + except FileReadError as exc: + if on_file_error: + on_file_error(exc.filename, exc.reason) + continue + except Exception as exc: # pragma: no cover - defensive + if on_file_error: + on_file_error(label, f"{exc.__class__.__name__}: {exc}") + continue + + rgb_rows.append(rgb) + timestamps.append(file_ts) + if on_progress: + on_progress(idx + 1, total, label) if not rgb_rows: raise NoValidDataError("every file in the folder failed to process")