# -*- coding: utf-8 -*-
"""
nDTomoGUI: A GUI for Chemical Imaging Data Visualization and Analysis
This application provides a PyQt5-based graphical user interface (GUI)
for handling and visualizing chemical imaging data. It includes tools
for exploring hyperspectral data, defining regions of interest (ROI),
extracting local patterns, and performing peak fitting.
Features:
---------
- **Image and Spectrum Visualization:**
Display hyperspectral images and spectra interactively.
- **ROI Selection and Export:**
Select regions of interest and export extracted images or spectra.
- **Segmentation and Pattern Extraction:**
Apply thresholding to segment images and extract local diffraction patterns.
- **Peak Fitting Module:**
Perform single peak fitting (Gaussian, Lorentzian, or Pseudo-Voigt profiles)
across datasets.
- **File Handling:**
Supports reading and writing `.hdf5` and `.h5` files, along with export to
`.asc` and `.xy` formats.
@author: Dr Antony Vamvakeros
"""
#%%
from __future__ import unicode_literals
from matplotlib import use as u
try:
u('Qt5Agg')
except:
print("Warning: Failed to set backend to Qt5Agg. Ensure PyQt5 is installed.")
from PyQt5 import QtCore, QtWidgets
from PyQt5.QtCore import pyqtSignal, QThread
import h5py, sys
import numpy as np
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
import matplotlib.pyplot as plt
import scipy.optimize as sciopt
from scipy.signal import find_peaks
from qtconsole.rich_jupyter_widget import RichJupyterWidget
from qtconsole.inprocess import QtInProcessKernelManager
[docs]
class nDTomoGUI(QtWidgets.QMainWindow):
"""
A PyQt5 GUI for visualizing and analyzing chemical imaging datasets.
This interface enables interaction with hyperspectral or diffraction tomography data.
Users can load datasets, explore image/spectrum slices, extract ROIs, perform peak fitting,
and export results. Features include segmentation, dynamic cursor inspection, batch fitting,
and in-GUI IPython console support.
Attributes
----------
volume : np.ndarray
The loaded 3D data volume (X, Y, Channels).
xaxis : np.ndarray
The 1D array for the spectral or scattering axis.
image : np.ndarray
2D image slice shown in the main display.
spectrum : np.ndarray
1D spectrum shown in the spectral viewer.
cmap : str
The active colormap for image display.
xaxislabel : str
Label for the x-axis of the spectrum (e.g. 'Channel', '2theta').
chi, chf : int
Start and end channel indices for ROI selection.
res : dict
Dictionary storing the latest peak fitting results.
mask : np.ndarray
Binary mask (same XY shape as image) used for ROI operations.
peaktype : str
Type of peak profile used ('Gaussian', 'Lorentzian', or 'Pseudo-Voigt').
"""
def __init__(self):
"""
Initialize the nDTomoGUI application.
This method sets up the PyQt5 main window and initializes all necessary GUI elements,
including menus, tabs, image and spectrum display areas, widgets for ROI selection,
segmentation, peak fitting controls, and embedded IPython console support. It also
initializes internal data containers and default parameters used throughout the GUI.
Main Components Initialized
---------------------------
- File menu: open, append, save, and quit actions for chemical imaging datasets.
- Advanced menu: create phantom dataset and launch IPython console.
- Help menu: about dialog with citation and licensing info.
- Tabbed interface:
• Tab 1: Image and spectrum explorer with colormap selector and export tools.
• Tab 2: ROI image generation and visualization.
• Tab 3: ROI segmentation and pattern extraction.
• Tab 4: Single-peak fitting with configurable parameters and batch fitting tools.
- Dock widgets: interactive Matplotlib canvases for image and spectrum display.
- Controls for selecting colormaps, fitting profiles, and managing real-time cursor updates.
Attributes Initialized
----------------------
- self.volume : np.ndarray
Loaded 3D chemical imaging data volume.
- self.xaxis : np.ndarray
1D axis corresponding to spectral or scattering dimension.
- self.cmap : str
Default colormap for image visualization.
- self.peaktype : str
Default peak profile ('Gaussian').
- self.Area, self.FWHM : float
Default initial values for area and FWHM fitting.
- self.loaded_dataset_names : list
Tracks loaded dataset filenames for display.
Raises
------
RuntimeError
If PyQt5 is not installed or Qt5Agg backend cannot be set.
"""
super(nDTomoGUI, self).__init__()
self.volume = np.zeros(())
self.xaxis = np.zeros(())
self.image = np.zeros(())
self.spectrum = np.zeros(())
self.cbar = None
self.im = None
self.vline = None
self.hdf_fileName = ''
self.c = 3E8
self.h = 6.620700406E-34
self.cmap = 'inferno'
self.xaxislabel = 'Channel'
self.chi = 0
self.chf = 1
self.cmap_list = ['gray', 'inferno', 'viridis','plasma','magma','cividis','flag',
'prism', 'ocean', 'gist_earth', 'terrain', 'gist_stern',
'gnuplot', 'gnuplot2', 'CMRmap', 'cubehelix', 'brg', 'hsv',
'gist_rainbow', 'rainbow', 'jet', 'nipy_spectral', 'gist_ncar']
self.loaded_dataset_names = []
self.mask = None
self.peaktype = 'Gaussian'
self.Area = 0.5; self.Areamin = 0.; self.Areamax = 10.
self.FWHM = 1.; self.FWHMmin = 0.1; self.FWHMmax = 5.
QtWidgets.QMainWindow.__init__(self)
self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
self.setWindowTitle("nDTomoGUI")
self.file_menu = QtWidgets.QMenu('&File', self)
self.file_menu.addAction('&Open data', self.fileOpen, QtCore.Qt.CTRL + QtCore.Qt.Key_O)
self.file_menu.addAction('&Append data', self.append_file)
self.file_menu.addAction('&Save data', self.savechemvol)
self.file_menu.addAction('&Quit', self.fileQuit, QtCore.Qt.CTRL + QtCore.Qt.Key_Q)
self.menuBar().addMenu(self.file_menu)
# Advanced menu
self.advanced_menu = QtWidgets.QMenu('&Advanced', self)
self.advanced_menu.addAction('Create Phantom Dataset', self.create_phantom)
self.advanced_menu.addAction('Open IPython Console', self.init_console)
self.menuBar().addMenu(self.advanced_menu)
self.help_menu = QtWidgets.QMenu('&Help', self)
self.help_menu.addAction('&About', self.about)
self.menuBar().addSeparator()
self.menuBar().addMenu(self.help_menu)
# Initialize tab screen
self.tabs = QtWidgets.QTabWidget()
self.tab1 = QtWidgets.QWidget()
self.tab2 = QtWidgets.QWidget()
self.tab3 = QtWidgets.QWidget()
self.tab4 = QtWidgets.QWidget()
# Add tabs
self.tabs.addTab(self.tab1,"Hyperexplorer")
self.tabs.addTab(self.tab2,"ROI image")
self.tabs.addTab(self.tab3,"ROI pattern")
self.tabs.addTab(self.tab4,"Peak fitting")
self.tab1.layout = QtWidgets.QGridLayout()
self.tab2.layout = QtWidgets.QGridLayout()
self.tab3.layout = QtWidgets.QGridLayout()
self.tab4.layout = QtWidgets.QGridLayout()
############ Tab1 - Hyperxplorer ############
# Create left panel for the image display
self.fig_image = Figure()
self.ax_image = self.fig_image.add_subplot(111)
self.canvas_image = FigureCanvas(self.fig_image)
self.ax_image.set_title("Image")
# Create right panel for the spectrum plot
self.fig_spectrum = Figure()
self.ax_spectrum = self.fig_spectrum.add_subplot(111)
self.canvas_spectrum = FigureCanvas(self.fig_spectrum)
self.ax_spectrum.set_title("Histogram")
# set up the mapper
self.mapperWidget = QtWidgets.QWidget(self)
self.mapperExplorerDock = QtWidgets.QDockWidget("Image", self)
self.mapperExplorerDock.setWidget(self.mapperWidget)
self.mapperExplorerDock.setFloating(False)
self.mapperToolbar = NavigationToolbar(self.canvas_image, self)
vbox1 = QtWidgets.QVBoxLayout()
vbox1.addWidget(self.mapperToolbar)
vbox1.addWidget(self.canvas_image)
self.mapperWidget.setLayout(vbox1)
#set up the plotter
self.plotterWidget = QtWidgets.QWidget(self)
self.plotterExplorerDock = QtWidgets.QDockWidget("Histogram", self)
self.plotterExplorerDock.setWidget(self.plotterWidget)
self.plotterExplorerDock.setFloating(False)
self.plotterToolbar = NavigationToolbar(self.canvas_spectrum, self)
vbox2 = QtWidgets.QVBoxLayout()
vbox2.addWidget(self.plotterToolbar)
vbox2.addWidget(self.canvas_spectrum) # starting row, starting column, row span, column span
self.plotterWidget.setLayout(vbox2)
self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.mapperExplorerDock)
self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.plotterExplorerDock)
self.DatasetLabel = QtWidgets.QLabel(self)
self.DatasetLabel.setText('Dataset:')
self.tab1.layout.addWidget(self.DatasetLabel,0,0)
self.DatasetNameLabel = QtWidgets.QLabel(self)
self.tab1.layout.addWidget(self.DatasetNameLabel,0,1)
self.ChooseColormap = QtWidgets.QLabel(self)
self.ChooseColormap.setText('Choose colormap')
self.tab1.layout.addWidget(self.ChooseColormap,0,2)
self.ChooseColormapType = QtWidgets.QComboBox(self)
self.ChooseColormapType.addItems(self.cmap_list)
self.ChooseColormapType.currentIndexChanged.connect(self.changecolormap)
self.ChooseColormapType.setMaximumWidth(170)
self.tab1.layout.addWidget(self.ChooseColormapType,0,3)
self.ExportDPbutton = QtWidgets.QPushButton("Export local diffraction pattern",self)
self.ExportDPbutton.clicked.connect(self.exportdp)
self.tab1.layout.addWidget(self.ExportDPbutton,1,2)
self.ExportIMbutton = QtWidgets.QPushButton("Export image of interest",self)
self.ExportIMbutton.clicked.connect(self.exportim)
self.tab1.layout.addWidget(self.ExportIMbutton,1,3)
############ Tab2 - ROI ############
self.Labelbkg = QtWidgets.QLabel(self)
self.Labelbkg.setText('Set the channels for the ROI image')
self.tab2.layout.addWidget(self.Labelbkg,0,0)
self.Channel1 = QtWidgets.QLabel(self)
self.Channel1.setText('Initial channel')
self.tab2.layout.addWidget(self.Channel1,1,0)
self.crspinbox1 = QtWidgets.QSpinBox(self)
self.crspinbox1.valueChanged.connect(self.channel_initial)
self.crspinbox1.setMinimum(1)
self.tab2.layout.addWidget(self.crspinbox1,1,1)
self.crspinbox1.valueChanged.connect(self.sync_peak_position_from_roi)
self.Channel2 = QtWidgets.QLabel(self)
self.Channel2.setText('Final channel')
self.tab2.layout.addWidget(self.Channel2,1,2)
self.crspinbox2 = QtWidgets.QSpinBox(self)
self.crspinbox2.valueChanged.connect(self.channel_final)
self.crspinbox2.setMinimum(1)
self.tab2.layout.addWidget(self.crspinbox2,1,3)
self.crspinbox2.valueChanged.connect(self.sync_peak_position_from_roi)
self.pbutton1 = QtWidgets.QPushButton("Plot mean image", self)
self.pbutton1.clicked.connect(self.plot_mean_image)
self.tab2.layout.addWidget(self.pbutton1, 2, 0)
self.pbutton2 = QtWidgets.QPushButton("Plot mean image with mean bkg subtraction", self)
self.pbutton2.clicked.connect(self.plot_mean_image_mean_bkg)
self.tab2.layout.addWidget(self.pbutton2, 2, 1)
self.pbutton3 = QtWidgets.QPushButton("Plot mean image with linear bkg subtraction", self)
self.pbutton3.clicked.connect(self.plot_mean_image_linear_bkg)
self.tab2.layout.addWidget(self.pbutton3, 2, 2)
self.expimroi = QtWidgets.QPushButton("Export image",self)
self.expimroi.clicked.connect(self.export_roi_image)
self.tab2.layout.addWidget(self.expimroi,2,3)
############ Tab3 - Segmentation and extraction of local pattern ############
self.Labelbkg = QtWidgets.QLabel(self)
self.Labelbkg.setText('Set the threshold to segment the ROI image - range is between 0-100%')
self.tab3.layout.addWidget(self.Labelbkg,0,0)
self.Channel3 = QtWidgets.QLabel(self)
self.Channel3.setText('Threshold')
self.tab3.layout.addWidget(self.Channel3,1,0)
self.crspinbox3 = QtWidgets.QSpinBox(self)
self.crspinbox3.valueChanged.connect(self.set_thr)
self.crspinbox3.setMinimum(0)
self.crspinbox3.setMaximum(100)
self.tab3.layout.addWidget(self.crspinbox3,1,1)
self.pbutton4 = QtWidgets.QPushButton("Apply the threshold", self)
self.pbutton4.clicked.connect(self.segment_image)
self.tab3.layout.addWidget(self.pbutton4, 2, 0)
self.pbutton5 = QtWidgets.QPushButton("Use mask to extract ROI pattern from the volume", self)
self.pbutton5.clicked.connect(self.plot_roi_pattern)
self.tab3.layout.addWidget(self.pbutton5, 2, 1)
self.exppatroi = QtWidgets.QPushButton("Export ROI pattern",self)
self.exppatroi.clicked.connect(self.export_roi_pattern)
self.tab3.layout.addWidget(self.exppatroi,2,3)
self.pbutton_suggest_peaks = QtWidgets.QPushButton("Suggest peak positions", self)
self.pbutton_suggest_peaks.clicked.connect(self.suggest_peak_positions)
self.tab3.layout.addWidget(self.pbutton_suggest_peaks, 3, 0)
############ Tab4 - Peak fitting ############
self.Labelbkg = QtWidgets.QLabel(self)
self.Labelbkg.setText('Single peak fitting')
self.tab4.layout.addWidget(self.Labelbkg,0,0)
# Define x-axis channel range for fitting
self.label_range = QtWidgets.QLabel("Fit range (channels):")
self.tab4.layout.addWidget(self.label_range, 0, 1)
self.xfit_min_spin = QtWidgets.QSpinBox()
self.xfit_min_spin.setMinimum(0)
self.xfit_min_spin.setMaximum(1) # will be updated after data load
self.xfit_min_spin.setValue(0)
self.tab4.layout.addWidget(self.xfit_min_spin, 0, 2)
self.xfit_max_spin = QtWidgets.QSpinBox()
self.xfit_max_spin.setMinimum(0)
self.xfit_max_spin.setMaximum(1) # will be updated after data load
self.xfit_max_spin.setValue(1)
self.tab4.layout.addWidget(self.xfit_max_spin, 0, 3)
self.set_xfit_button = QtWidgets.QPushButton("Set fit range", self)
self.set_xfit_button.clicked.connect(self.set_fit_range)
self.tab4.layout.addWidget(self.set_xfit_button, 0, 4)
self.LabelTypePeak = QtWidgets.QLabel(self)
self.LabelTypePeak.setText('Function')
self.tab4.layout.addWidget(self.LabelTypePeak,1,0)
self.ChooseFunction = QtWidgets.QComboBox(self)
self.ChooseFunction.addItems(["Gaussian", "Lorentzian", "Pseudo-Voigt"])
self.ChooseFunction.currentIndexChanged.connect(self.profile_function)
self.ChooseFunction.setEnabled(True)
self.tab4.layout.addWidget(self.ChooseFunction,1,1)
# Peak Area controls
self.label_area = QtWidgets.QLabel("Area")
self.tab4.layout.addWidget(self.label_area, 2, 0)
self.area_spin = QtWidgets.QDoubleSpinBox()
self.area_spin.setRange(0.0, 100.0)
self.area_spin.setValue(self.Area)
self.area_spin.setSingleStep(0.1)
self.tab4.layout.addWidget(self.area_spin, 2, 1)
self.label_area_min = QtWidgets.QLabel("Min")
self.tab4.layout.addWidget(self.label_area_min, 2, 2)
self.area_min_spin = QtWidgets.QDoubleSpinBox()
self.area_min_spin.setRange(0.0, 100.0)
self.area_min_spin.setValue(self.Areamin)
self.tab4.layout.addWidget(self.area_min_spin, 2, 3)
self.label_area_max = QtWidgets.QLabel("Max")
self.tab4.layout.addWidget(self.label_area_max, 2, 4)
self.area_max_spin = QtWidgets.QDoubleSpinBox()
self.area_max_spin.setRange(0.0, 100.0)
self.area_max_spin.setValue(self.Areamax)
self.tab4.layout.addWidget(self.area_max_spin, 2, 5)
# FWHM controls
self.label_fwhm = QtWidgets.QLabel("FWHM")
self.tab4.layout.addWidget(self.label_fwhm, 3, 0)
self.fwhm_spin = QtWidgets.QDoubleSpinBox()
self.fwhm_spin.setRange(0.01, 20.0)
self.fwhm_spin.setValue(self.FWHM)
self.fwhm_spin.setSingleStep(0.1)
self.tab4.layout.addWidget(self.fwhm_spin, 3, 1)
self.label_fwhm_min = QtWidgets.QLabel("Min")
self.tab4.layout.addWidget(self.label_fwhm_min, 3, 2)
self.fwhm_min_spin = QtWidgets.QDoubleSpinBox()
self.fwhm_min_spin.setRange(0.01, 20.0)
self.fwhm_min_spin.setValue(self.FWHMmin)
self.tab4.layout.addWidget(self.fwhm_min_spin, 3, 3)
self.label_fwhm_max = QtWidgets.QLabel("Max")
self.tab4.layout.addWidget(self.label_fwhm_max, 3, 4)
self.fwhm_max_spin = QtWidgets.QDoubleSpinBox()
self.fwhm_max_spin.setRange(0.01, 20.0)
self.fwhm_max_spin.setValue(self.FWHMmax)
self.tab4.layout.addWidget(self.fwhm_max_spin, 3, 5)
# Central position
self.label_pos = QtWidgets.QLabel("Position")
self.tab4.layout.addWidget(self.label_pos, 4, 0)
self.pos_spin = QtWidgets.QDoubleSpinBox()
self.pos_spin.setDecimals(2)
self.pos_spin.setRange(0.0, 1e6)
self.pos_spin.setSingleStep(0.1)
self.tab4.layout.addWidget(self.pos_spin, 4, 1)
# Position min
self.label_pos_min = QtWidgets.QLabel("Min")
self.tab4.layout.addWidget(self.label_pos_min, 4, 2)
self.pos_min_spin = QtWidgets.QDoubleSpinBox()
self.pos_min_spin.setDecimals(2)
self.pos_min_spin.setRange(0.0, 1e6)
self.tab4.layout.addWidget(self.pos_min_spin, 4, 3)
# Position max
self.label_pos_max = QtWidgets.QLabel("Max")
self.tab4.layout.addWidget(self.label_pos_max, 4, 4)
self.pos_max_spin = QtWidgets.QDoubleSpinBox()
self.pos_max_spin.setDecimals(2)
self.pos_max_spin.setRange(0.0, 1e6)
self.tab4.layout.addWidget(self.pos_max_spin, 4, 5)
# Pseudo-Voigt mixing parameter (gamma)
self.label_fraction = QtWidgets.QLabel("Mixing γ")
self.tab4.layout.addWidget(self.label_fraction, 5, 0)
self.fraction_spin = QtWidgets.QDoubleSpinBox()
self.fraction_spin.setRange(0.0, 1.0)
self.fraction_spin.setSingleStep(0.05)
self.fraction_spin.setValue(0.5)
self.tab4.layout.addWidget(self.fraction_spin, 5, 1)
self.label_fraction.setVisible(False)
self.fraction_spin.setVisible(False)
self.area_spin.setToolTip("Initial guess for peak area")
self.fraction_spin.setToolTip("Mixing fraction between Gaussian (0) and Lorentzian (1)")
self.label_area.setToolTip("Initial guess for peak area")
self.label_area_min.setToolTip("Minimum allowed peak area")
self.label_area_max.setToolTip("Maximum allowed peak area")
self.pbutton_fit = QtWidgets.QPushButton("Perform batch peak fitting",self)
self.pbutton_fit.clicked.connect(self.batchpeakfit)
self.tab4.layout.addWidget(self.pbutton_fit,1,2)
self.progressbar_fit = QtWidgets.QProgressBar(self)
self.tab4.layout.addWidget(self.progressbar_fit,1,3)
self.pbutton_stop = QtWidgets.QPushButton("Stop",self)
self.pbutton_stop.clicked.connect(self.stopfit)
self.tab4.layout.addWidget(self.pbutton_stop,1,4)
self.LabelLive = QtWidgets.QLabel("Live view:")
self.tab4.layout.addWidget(self.LabelLive, 1, 5)
self.ChooseLive = QtWidgets.QComboBox(self)
self.ChooseLive.addItems(['Area','Position','FWHM'])
self.ChooseLive.setEnabled(True)
self.tab4.layout.addWidget(self.ChooseLive, 1, 6)
self.LabelRes = QtWidgets.QLabel(self)
self.LabelRes.setText('Display peak fitting results')
self.tab4.layout.addWidget(self.LabelRes,2,6)
self.ChooseRes = QtWidgets.QComboBox(self)
self.ChooseRes.addItems(['Area','Position', 'FWHM', 'Slope', 'Intercept'])
self.ChooseRes.currentIndexChanged.connect(self.plot_fit_results)
self.ChooseRes.setEnabled(False)
self.tab4.layout.addWidget(self.ChooseRes,2,7)
self.pbutton_expfit = QtWidgets.QPushButton("Export fit results",self)
self.pbutton_expfit.clicked.connect(self.savefitresults)
self.pbutton_expfit.setEnabled(False)
self.tab4.layout.addWidget(self.pbutton_expfit,2,8)
self.check_diag_mode = QtWidgets.QCheckBox("Inspect Fit Diagnostics (live overlay)", self)
self.check_diag_mode.setChecked(False)
self.tab4.layout.addWidget(self.check_diag_mode, 3, 6)
############
self.tabs.setFocus()
self.tab1.setLayout(self.tab1.layout)
self.tab2.setLayout(self.tab2.layout)
self.tab3.setLayout(self.tab3.layout)
self.tab4.setLayout(self.tab4.layout)
self.setCentralWidget(self.tabs)
self.show()
####################### Methods #######################
[docs]
def explore(self):
"""
Display the mean image and mean spectrum from the loaded chemical imaging dataset.
This method calculates the average projection of the 3D volume along the spectral axis
and displays it in the image panel. It also computes the mean spectrum across all spatial
positions and displays it in the spectrum panel. Interactive tools are initialized to allow
real-time cursor-based inspection of local spectra and image slices.
Functionality
-------------
- Computes and displays the mean image (`volume.mean(axis=2)`) using the current colormap.
- Computes and displays the mean spectrum (`volume.mean(axis=(0, 1))`) as a 1D plot.
- Adds a vertical cursor line to the spectrum panel (movable by mouse hover).
- Enables interactive updates:
• Hover over the image shows the corresponding spectrum at that pixel.
• Hover over the spectrum shows the corresponding image slice at that channel.
• Scroll wheel zooms on both image and spectrum panels.
• Left-click toggles real-time updates on; right-click toggles them off.
Raises
------
ValueError
If no data volume has been loaded (`self.volume.size == 0`).
"""
if self.volume.size == 0:
raise ValueError("No data loaded. Please load an HDF5 file first.")
# Calculate mean image and spectrum
mean_image = np.mean(self.volume, axis=2)
mean_spectrum = np.mean(self.volume, axis=(0, 1))
self.fig_image.clear()
self.ax_image = self.fig_image.add_subplot(111)
self.im = self.ax_image.imshow(mean_image.T, cmap=self.cmap)
# Safely remove any existing colorbar
try:
if hasattr(self, 'cbar') and self.cbar is not None and self.cbar.ax:
self.cbar.remove()
except Exception as e:
print("Warning: Failed to remove colorbar:", e)
self.cbar = None
self.cbar = self.fig_image.colorbar(self.im, ax=self.ax_image, fraction=0.046, pad=0.04)
self.ax_spectrum.clear()
self.ax_spectrum.plot(self.xaxis, mean_spectrum, color='b')
self.ax_spectrum.set_xlabel(self.xaxislabel)
# Add vertical line for spectrum cursor
self.vline = self.ax_spectrum.axvline(x=self.xaxis[0], color='r', linestyle='--', lw=1)
# Connect events
self.canvas_image.mpl_connect('motion_notify_event', self.update_spectrum)
self.canvas_spectrum.mpl_connect('motion_notify_event', self.update_image)
self.canvas_image.mpl_connect('button_press_event', self.toggle_real_time_spectrum)
self.canvas_spectrum.mpl_connect('button_press_event', self.toggle_real_time_image)
self.canvas_image.mpl_connect('scroll_event', self.on_canvas_scroll)
self.canvas_spectrum.mpl_connect('scroll_event', self.on_canvas_scroll)
self.real_time_update_image = True
self.real_time_update_spectrum = True
self.canvas_image.draw()
self.canvas_spectrum.draw()
[docs]
def update_spectrum(self, event):
"""
Update the 1D spectrum plot based on mouse hover over the 2D image.
When the user moves the mouse over the image display (ax_image), this method
retrieves the spectrum at the corresponding pixel coordinates and plots it.
If diagnostic mode is enabled and fitting results are available, the fitted
peak model and residual (difference between raw and fitted spectra) are also shown.
Parameters
----------
event : matplotlib.backend_bases.MouseEvent
The mouse event object containing position and axis metadata.
Behavior
--------
- Plots the raw spectrum at (x, y).
- If diagnostic overlay is enabled and the pixel is within a fitted mask:
- Plots the model fit (red line) and residuals (green line).
- Supports Gaussian, Lorentzian, and Pseudo-Voigt peak profiles.
- Updates plot title and legend accordingly.
Notes
-----
- Requires valid `self.volume`, `self.xaxis`, and axis handles to be initialized.
- `self.mask` must be defined for diagnostics to overlay.
- Fit parameters are read from `self.res`, produced after peak fitting.
"""
if self.real_time_update_spectrum and event.inaxes == self.ax_image:
self.x, self.y = int(event.xdata), int(event.ydata)
if 0 <= self.x < self.image_width and 0 <= self.y < self.image_height:
self.spectrum = self.volume[self.x, self.y, :]
# Clear all lines except the vertical cursor
for line in self.ax_spectrum.get_lines():
if line != self.vline:
line.remove()
# Decide raw plot style
show_diag = hasattr(self, 'check_diag_mode') and self.check_diag_mode.isChecked()
has_fit = hasattr(self, 'res')
# Overlay fit and residuals if diagnostics are on and fit exists
if show_diag and has_fit and self.mask[self.x, self.y] > 0:
try:
ch_min = self.xfit_min_spin.value()
ch_max = self.xfit_max_spin.value()
fit_x_ch = np.arange(ch_min, ch_max)
fit_x_native = self.xaxis[ch_min:ch_max]
raw_subset = self.spectrum[ch_min:ch_max]
area = self.res['Area'][self.x, self.y]
pos = self.res['Position'][self.x, self.y]
fwhm = self.res['FWHM'][self.x, self.y]
bkg1 = self.res['Background1'][self.x, self.y]
bkg2 = self.res['Background2'][self.x, self.y]
if self.peaktype == "Gaussian":
fit_y = self.PeakFitData.gmodel(fit_x_ch, area, pos, fwhm, bkg1, bkg2)
elif self.peaktype == "Lorentzian":
fit_y = self.PeakFitData.lmodel(fit_x_ch, area, pos, fwhm, bkg1, bkg2)
elif self.peaktype == "Pseudo-Voigt":
frac = self.res['Fraction'][self.x, self.y]
fit_y = self.PeakFitData.pvmodel(fit_x_ch, area, pos, fwhm, bkg1, bkg2, frac)
else:
fit_y = None
if fit_y is not None:
self.ax_spectrum.plot(self.xaxis, self.spectrum, 'b--', label='Raw')
self.ax_spectrum.plot(fit_x_native, fit_y, 'r', label='Fit')
residual = raw_subset - fit_y
self.ax_spectrum.plot(fit_x_native, residual, 'g', label='Residual')
except Exception as e:
print(f"[Diagnostic overlay error]: {e}")
else:
self.ax_spectrum.plot(self.xaxis, self.spectrum, 'b', label='Raw')
self.ax_spectrum.set_title(f"Spectrum at ({self.x}, {self.y})")
self.ax_spectrum.set_xlabel(self.xaxislabel)
self.ax_spectrum.legend()
self.canvas_spectrum.draw()
[docs]
def valid_fit_for_pixel(self, x, y):
"""
Check if the fitted peak parameters at pixel (x, y) are valid.
A fit is considered valid if:
- 'Area', 'Position', and 'FWHM' exist at the pixel,
- All values are finite (not NaN or Inf),
- The FWHM value is strictly positive.
Parameters
----------
x : int
X-coordinate (row index) of the pixel.
y : int
Y-coordinate (column index) of the pixel.
Returns
-------
bool
True if the pixel has valid fit parameters, False otherwise.
"""
try:
return (
np.isfinite(self.res['Area'][x, y]) and
np.isfinite(self.res['Position'][x, y]) and
np.isfinite(self.res['FWHM'][x, y]) and
self.res['FWHM'][x, y] > 0
)
except:
return False
[docs]
def update_image(self, event):
"""
Update the 2D image view based on mouse hover over the spectrum plot.
When the user moves the mouse cursor along the spectral axis (ax_spectrum),
this method determines the corresponding index in the spectral axis and displays
the corresponding 2D slice from the volume.
Parameters
----------
event : matplotlib.backend_bases.MouseEvent
Mouse event object triggered by hover in the spectrum axis.
Behavior
--------
- Translates the hovered x-axis position to the nearest spectral index.
- Extracts the corresponding image slice and updates the image view.
- Moves the red vertical cursor line in the spectrum plot to match.
- Automatically adjusts color limits and updates the colorbar.
Notes
-----
- Handles both default x-axis and inverted axes (e.g., `d-spacing`).
- Ensures compatibility with mouse wheel zoom and interaction events.
"""
if self.real_time_update_image and event.inaxes == self.ax_spectrum and event.xdata is not None:
self.index = event.xdata
if self.index >= 0 and self.index < self.nbins:
# try:
if self.xaxislabel == 'd':
self.index = len(self.xaxis) - np.searchsorted(self.xaxisd, [self.index])[0]
else:
self.index = np.searchsorted(self.xaxis.flatten(), [self.index])[0] - 1
self.index = max(0, min(self.index, len(self.xaxis) - 1))
# Get image at selected index
self.image = self.volume[:, :, self.index]
# Ensure the image object exists
if hasattr(self, 'im') and self.im is not None:
self.im.set_data(self.image.T)
self.im.set_clim(np.min(self.image), np.max(self.image))
else:
self.im = self.ax_image.imshow(self.image.T, cmap=self.cmap)
self.cbar = self.fig_image.colorbar(self.im, ax=self.ax_image, fraction=0.046, pad=0.04)
if hasattr(self, 'cbar') and self.cbar is not None and hasattr(self, 'im') and self.im is not None:
self.cbar.update_normal(self.im)
if self.xaxislabel == 'Channel':
self.ax_image.set_title(f"Image: Channel {self.index}")
else:
self.ax_image.set_title("Image: Channel %d, %s %.3f" %
(self.index, self.xaxislabel, self.xaxis[self.index]))
# Move vertical line on spectrum plot
if hasattr(self, 'vline'):
self.vline.set_xdata([self.xaxis[self.index]])
self.canvas_spectrum.draw()
self.canvas_image.draw()
# except Exception as e:
# print(f"Error in update_image: {e}")
[docs]
def toggle_real_time_spectrum(self, event):
"""
Toggle real-time spectrum updates when hovering over the image plot.
Enables or disables live spectrum display depending on which mouse button is pressed
while clicking on the image plot.
Parameters
----------
event : matplotlib.backend_bases.MouseEvent
Mouse event object. Left-click enables, right-click disables real-time updates.
"""
if event.button == 1:
self.real_time_update_spectrum = True
elif event.button == 3:
self.real_time_update_spectrum = False
[docs]
def toggle_real_time_image(self, event):
"""
Toggle real-time image updates when hovering over the spectrum plot.
Enables or disables live image updates depending on which mouse button is pressed
while clicking on the spectrum plot.
Parameters
----------
event : matplotlib.backend_bases.MouseEvent
Mouse event object. Left-click enables, right-click disables real-time updates.
"""
if event.button == 1:
self.real_time_update_image = True
elif event.button == 3:
self.real_time_update_image = False
[docs]
def exportdp(self):
"""
Export the current spectrum (diffraction pattern) to HDF5 and text-based formats.
Saves the currently displayed 1D spectrum (from the selected pixel) and its corresponding
x-axis to three separate files:
- `.h5` file containing datasets `I` and `xaxis`
- `.asc` text file with two columns: x-axis and intensity
- `.xy` text file with the same format as `.asc`
Output filenames include the pixel coordinates (x, y).
Notes
-----
This function requires that `self.hdf_fileName`, `self.spectrum`, `self.xaxis`,
and pixel coordinates `self.x`, `self.y` are defined.
"""
# Check all prerequisites
if not hasattr(self, 'hdf_fileName') or not self.hdf_fileName:
print("Missing or invalid HDF5 filename.")
return
if not hasattr(self, 'spectrum') or not isinstance(self.spectrum, np.ndarray) or self.spectrum.size == 0:
print("Spectrum not selected or empty.")
return
if not hasattr(self, 'xaxis') or not isinstance(self.xaxis, np.ndarray) or self.xaxis.size != self.spectrum.size:
print("X-axis not defined or does not match spectrum size.")
return
if not all(hasattr(self, attr) for attr in ['x', 'y']):
print("Pixel coordinates (x, y) are not defined.")
return
# Safe to proceed with export
base = self.hdf_fileName.rsplit('.h5', 1)[0]
tag = f"{base}_{self.x}_{self.y}"
h5_name = f"{tag}.h5"
asc_name = f"{tag}.asc"
xy_name = f"{tag}.xy"
print(h5_name)
with h5py.File(h5_name, "w") as h5f:
h5f.create_dataset('I', data=self.spectrum)
h5f.create_dataset('xaxis', data=self.xaxis)
xy_data = np.column_stack((self.xaxis, self.spectrum))
np.savetxt(asc_name, xy_data, fmt="%.5f")
np.savetxt(xy_name, xy_data, fmt="%.5f")
[docs]
def exportim(self):
"""
Export the current spectral/scattering image to HDF5 and PNG formats.
Saves the currently displayed 2D image (at a specific spectral index) to:
- `.h5` file containing datasets `I` (image) and `Channel` (index)
- `.png` file visualizing the image using the active colormap
Notes
-----
This function requires `self.hdf_fileName`, `self.image`, and `self.index` to be defined and valid.
"""
# Check required attributes exist and are valid
if not hasattr(self, 'hdf_fileName') or not self.hdf_fileName:
print("Missing or invalid HDF5 filename.")
return
if not hasattr(self, 'image') or not isinstance(self.image, np.ndarray) or self.image.size == 0:
print("No image selected or image is empty.")
return
if not hasattr(self, 'index'):
print("No spectral index defined yet.")
return
# Safe to export
base = self.hdf_fileName.rsplit('.h5', 1)[0]
h5_name = f"{base}_channel_{self.index}.h5"
png_name = f"{base}_channel_{self.index}.png"
print(h5_name)
with h5py.File(h5_name, "w") as h5f:
h5f.create_dataset('I', data=self.image)
h5f.create_dataset('Channel', data=self.index)
plt.imsave(png_name, self.image, cmap=self.cmap)
[docs]
def changecolormap(self, ind):
"""
Change the active colormap used for image display.
Parameters
----------
ind : int
Index of the selected colormap from `self.cmap_list`.
Notes
-----
Updates the display immediately to reflect the new colormap.
"""
self.cmap = self.cmap_list[ind]
print(f"Colormap changed to: {self.cmap}")
try:
if hasattr(self, 'im') and self.im is not None:
self.im.set_cmap(self.cmap)
self.canvas_image.draw()
if hasattr(self, 'cbar') and self.cbar is not None:
self.cbar.update_normal(self.im)
except Exception as e:
print(f"Error updating colormap: {e}")
[docs]
def selXRDCTdata(self):
"""
Open a file dialog to select one or more XRD-CT datasets.
Extracts dataset names and base paths from the selected files,
and populates the GUI list widget (`self.datalist`) and `self.pathslist`.
Notes
-----
- Uses the custom `FileDialog` class with multi-selection enabled.
- Expects filenames to end with `.hdf5`.
- Silently ignores or logs any parsing errors.
"""
self.dialog = FileDialog()
if self.dialog.exec_() == QtWidgets.QDialog.Accepted:
self.paths = (self.dialog.selectedFiles())
for ii in range(0,len(self.paths)):
oldpath = str(self.paths[ii])
try:
newpath = oldpath.split("/")
self.datasetname = newpath[-1]
try:
newfile = self.datasetname.split(".hdf5")
newfile = newfile[0]
if len(self.datasetname)>0 and len(newfile)>0:
self.datalist.addItem(self.datasetname)
newpath = oldpath.split(self.datasetname)
newpath = newpath[0]
self.pathslist.append(newpath)
except:
print('Something is wrong with the selected datasets')
except:
pass
[docs]
def fileOpen(self):
"""
Open and load a chemical imaging dataset via file dialog.
Opens a file browser to select an HDF5 (.h5 or .hdf5) file, loads the dataset into memory,
updates the dataset label in the GUI, and calls `explore()` to display the data.
Notes
-----
- Internally calls `self.loadchemvol()` to read the file contents.
- Only one dataset is loaded at a time using this method.
"""
self.hdf_fileName, _ = QtWidgets.QFileDialog.getOpenFileName(self, 'Open data', "", "*.hdf5 *.h5")
if len(self.hdf_fileName) > 0:
self.loadchemvol()
self.loaded_dataset_names = [self.hdf_fileName.split("/")[-1]] # <-- track dataset name
self.DatasetNameLabel.setText(" + ".join(self.loaded_dataset_names))
self.explore()
[docs]
def loadchemvol(self):
"""
Load a chemical imaging volume and spectral axis from the current HDF5 file.
Reads the 3D dataset from `/data` and an associated axis (e.g., `/twotheta`, `/q`, etc.)
from the file `self.hdf_fileName`, performing shape and compatibility checks.
Updates GUI elements such as channel range spin boxes and sets the axis label.
Raises
------
FileNotFoundError
If `self.hdf_fileName` is not set.
KeyError
If the expected dataset `/data` is not found in the file.
"""
xaxis_labels = ['/d', '/q', '/twotheta', '/Energy', '/tth', '/energy']
if not self.hdf_fileName:
raise FileNotFoundError("No file selected. Please choose an HDF5 file.")
with h5py.File(self.hdf_fileName,'r') as f:
try:
print("Available datasets:", list(f.keys()))
if '/data' not in f:
raise KeyError("Dataset '/data' not found in HDF5 file.")
self.volume = f['/data'][:]
self.check_and_transpose()
self.image_width, self.image_height, self.nbins = self.volume.shape
self.xaxis = np.arange(0, self.volume.shape[2])
self.crspinbox1.setMaximum(self.nbins - 1)
self.crspinbox2.setMaximum(self.nbins)
for xaxis_label in xaxis_labels:
if xaxis_label in f and len(f[xaxis_label][:])==self.volume.shape[2]:
self.xaxis = f[xaxis_label][:]
self.xaxislabel = xaxis_label.lstrip('/')
break # Stop once we find a match
self.xfit_min_spin.setMaximum(self.volume.shape[2] - 1)
self.xfit_max_spin.setMaximum(self.volume.shape[2])
self.xfit_max_spin.setValue(self.volume.shape[2])
except KeyError as e:
print("Error:", e)
except Exception as e:
print("Unexpected error:", e)
print("Loaded data shape:", self.volume.shape)
[docs]
def append_file(self):
"""
Append an additional chemical imaging dataset to the current volume along the X-axis (axis=0).
Opens a file dialog to select a new HDF5 dataset and checks for compatibility with the currently
loaded volume. If the height and spectral dimensions match, the new volume is concatenated
column-wise. The GUI is updated accordingly.
Notes
-----
- Only appends if the new volume has the same shape in dimensions 1 and 2 (height and spectral).
- If necessary, the new volume is transposed to match the shape of the existing dataset.
- Updates internal volume, dataset label, and spinbox limits.
Raises
------
KeyError
If the '/data' dataset is not found in the appended file.
ValueError
If shape compatibility is not met (different height or spectral axis).
"""
append_fileName, _ = QtWidgets.QFileDialog.getOpenFileName(self, 'Append data', "", "*.hdf5 *.h5")
if not append_fileName or self.volume.size == 0:
QtWidgets.QMessageBox.warning(self, "Append Error", "No file selected or no dataset loaded yet.")
return
try:
with h5py.File(append_fileName, 'r') as f:
if '/data' not in f:
raise KeyError("Dataset '/data' not found in file.")
# Only check dimension 1 and 2 (height and spectral)
shape_new = f['/data'].shape
if shape_new[1] != self.volume.shape[1] or shape_new[2] != self.volume.shape[2]:
raise ValueError("Cannot append: mismatch in height or spectral dimension.")
# Read and transpose only if necessary
vol_new = f['/data'][:]
if vol_new.shape[0] != vol_new.shape[1] and vol_new.shape[0] == vol_new.shape[2]:
vol_new = np.transpose(vol_new, (0, 2, 1))
elif vol_new.shape[0] != vol_new.shape[1] and vol_new.shape[1] == vol_new.shape[2]:
vol_new = np.transpose(vol_new, (1, 2, 0))
# Append column-wise (axis=1)
self.volume = np.concatenate((self.volume, vol_new), axis=0)
del vol_new # free memory
# Update GUI
self.image_width, self.image_height, self.nbins = self.volume.shape
self.crspinbox1.setMaximum(self.nbins - 1)
self.crspinbox2.setMaximum(self.nbins)
# Track and show appended filename
appended_name = append_fileName.split("/")[-1]
self.loaded_dataset_names.append(appended_name)
self.DatasetNameLabel.setText(" + ".join(self.loaded_dataset_names))
self.explore()
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Append Failed", f"Error appending dataset:\n{e}")
[docs]
def check_and_transpose(self):
"""
Check the shape of the loaded volume and apply a transpose if necessary.
Ensures that the volume has shape (X, Y, Channels). If the first dimension matches the spectral
length instead of spatial axes, the array is transposed accordingly to bring it to the expected format.
Notes
-----
- Transposes (0, 2, 1) if volume.shape[0] == volume.shape[2]
- Transposes (1, 2, 0) if volume.shape[1] == volume.shape[2]
"""
dims = self.volume.shape
if dims[0] != dims[1] and dims[0] == dims[2]:
# Transpose the array so that the first dimension becomes the last dimension
self.volume = np.transpose(self.volume, (0, 2, 1))
elif dims[0] != dims[1] and dims[1] == dims[2]:
# Transpose the array so that the second dimension becomes the last dimension
self.volume = np.transpose(self.volume, (1, 2, 0))
[docs]
def savechemvol(self):
"""
Save the current chemical imaging dataset to an HDF5 file.
Prompts the user to select a save location and writes the current 3D volume
and x-axis array to disk. Adds a `.h5` extension if not present.
File contents
-------------
- 'data': The 3D chemical imaging volume.
- 'xaxis': The spectral or scattering axis used for plotting.
"""
self.fn, _ = QtWidgets.QFileDialog.getSaveFileName(self, 'Save data', "", "*.h5")
if len(self.fn)>0:
st = self.fn.split(".h5")
if len(st)<2:
self.fn = "%s.h5" %self.fn
print(self.fn)
h5f = h5py.File(self.fn, "w")
h5f.create_dataset('data', data=self.volume)
h5f.create_dataset('xaxis', data=self.xaxis)
h5f.close()
[docs]
def fileQuit(self):
"""
Trigger the application close event.
"""
self.close()
[docs]
def closeEvent(self, ce):
"""
Override the Qt closeEvent handler to ensure proper shutdown.
Parameters
----------
ce : QCloseEvent
The close event triggered by the window manager.
"""
self.fileQuit()
[docs]
def about(self):
message = '<b>nDTomoGUI<p>'
message += '<p><i>Created by Antony Vamvakeros. Running under license under GPLv3'
message += '<p>Please cite using the following:<p>'
message += '<p>Vamvakeros, A. et al., nDTomo software suite, 2019, url: https://github.com/antonyvam/nDTomo<p>'
message += '\t '
d = QtWidgets.QMessageBox()
d.setWindowTitle('About')
d.setText(message)
d.exec_()
####################### ROI image #######################
[docs]
def channel_initial(self, value):
"""
Set the initial channel index for the ROI (Region of Interest) image.
Parameters
----------
value : int
The starting spectral channel to define the ROI range.
"""
self.chi = value
print(self.chi)
[docs]
def channel_final(self, value):
"""
Set the final channel index for the ROI (Region of Interest) image.
Parameters
----------
value : int
The ending spectral channel to define the ROI range.
"""
self.chf = value
print(self.chf)
[docs]
def plot_mean_image(self):
"""
Compute and display the mean ROI image based on selected channel range.
The method:
- Calculates a mean image from the 3D volume using channels from `chi` to `chf`.
- Normalizes the image to a 0-100 range.
- Automatically estimates a central peak position for fitting.
- Generates a binary mask from the image using a fixed threshold (5%).
- Updates the image canvas with the new ROI visualization.
"""
# Ensure chi < chf and both are within volume bounds
n_channels = self.volume.shape[2]
if self.chi > self.chf:
self.chi, self.chf = self.chf, self.chi
if hasattr(self, 'crspinbox1') and hasattr(self, 'crspinbox2'):
self.crspinbox1.setValue(self.chi)
self.crspinbox2.setValue(self.chf)
self.chi = max(0, min(self.chi, n_channels - 1))
self.chf = max(self.chi + 1, min(self.chf, n_channels))
roi = np.arange(self.chi,self.chf)
# Compute central position as float
pos = 0.5 * (self.chi + self.chf)
# Set Tab 4 controls automatically
self.pos_spin.setValue(pos)
self.pos_min_spin.setValue(max(0.0, pos - 5.0))
self.pos_max_spin.setValue(pos + 5.0)
self.volroi = self.volume[:,:,roi]
self.volroi = self.volroi/np.max(self.volroi)
self.xroi = np.arange(self.chi, self.chf)
if self.chf<self.chi:
self.chf = self.chi + 1
self.crspinbox2.setValue(self.chf)
# Update the image display
self.ax_image.clear()
self.image = np.sum(self.volume[:,:,self.chi:self.chf], axis = 2)
self.image = 100*(self.image/np.max(self.image))
self.mask = np.copy(self.image)
self.mask[self.mask<5] = 0
self.mask[self.mask>0] = 1
self.ax_image.imshow(self.image.T, cmap=self.cmap)
self.ax_image.set_title("ROI image")
self.canvas_image.draw()
[docs]
def plot_mean_image_mean_bkg(self):
"""
Compute and display a mean ROI image with constant background subtraction.
The background is estimated as the mean of the start (`chi`) and end (`chf`) channels,
and subtracted from the sum across the selected ROI channel range.
Steps:
- Normalize the ROI volume.
- Subtract mean of boundary channels from the summed signal.
- Clip negative values and rescale to 0-100.
- Generate a binary mask (thresholded at 5%).
- Display the processed image in the main viewer.
"""
# Ensure chi < chf and both are within volume bounds
n_channels = self.volume.shape[2]
if self.chi > self.chf:
self.chi, self.chf = self.chf, self.chi
if hasattr(self, 'crspinbox1') and hasattr(self, 'crspinbox2'):
self.crspinbox1.setValue(self.chi)
self.crspinbox2.setValue(self.chf)
self.chi = max(0, min(self.chi, n_channels - 1))
self.chf = max(self.chi + 1, min(self.chf, n_channels))
# Update the image display
self.ax_image.clear()
roi = np.arange(self.chi,self.chf)
# Compute central position as float
pos = 0.5 * (self.chi + self.chf)
# Set Tab 4 controls automatically
self.pos_spin.setValue(pos)
self.pos_min_spin.setValue(max(0.0, pos - 5.0))
self.pos_max_spin.setValue(pos + 5.0)
self.volroi = self.volume[:,:,roi]
self.volroi = self.volroi/np.max(self.volroi)
self.xroi = np.arange(self.chi, self.chf)
self.image = np.sum(self.volume[:,:,roi],axis=2)-len(roi)*np.mean(self.volume[:,:,[self.chi,self.chf]],axis=2)
self.image[self.image<0] = 0
self.image = 100*(self.image/np.max(self.image))
self.mask = np.copy(self.image)
self.mask[self.mask<5] = 0
self.mask[self.mask>0] = 1
self.ax_image.imshow(self.image.T, cmap=self.cmap)
self.ax_image.set_title("ROI image")
self.canvas_image.draw()
[docs]
def plot_mean_image_linear_bkg(self):
"""
Compute and display a mean ROI image with linear background subtraction.
The background is estimated per-pixel as a linear interpolation between the values
at the start (`chi`) and end (`chf`) channels. This estimated background is subtracted
from the raw ROI before summing.
Steps:
- Fit a linear background for each pixel across the ROI channel range.
- Subtract the background from the ROI signal.
- Clip negative values and rescale to 0-100.
- Create a binary mask (values above 5%).
- Display the result in the image viewer.
"""
# Ensure chi < chf and both are within volume bounds
n_channels = self.volume.shape[2]
if self.chi > self.chf:
self.chi, self.chf = self.chf, self.chi
if hasattr(self, 'crspinbox1') and hasattr(self, 'crspinbox2'):
self.crspinbox1.setValue(self.chi)
self.crspinbox2.setValue(self.chf)
self.chi = max(0, min(self.chi, n_channels - 1))
self.chf = max(self.chi + 1, min(self.chf, n_channels))
# Update the image display
self.ax_image.clear()
roi = np.arange(self.chi,self.chf)
# Compute central position as float
pos = 0.5 * (self.chi + self.chf)
# Set Tab 4 controls automatically
self.pos_spin.setValue(pos)
self.pos_min_spin.setValue(max(0.0, pos - 5.0))
self.pos_max_spin.setValue(pos + 5.0)
self.xroi = np.arange(self.chi, self.chf)
self.volroi = self.volume[:,:,roi]
slope = (self.volroi[:,:,-1] - self.volroi[:,:,0])/len(roi)
slope = np.reshape(slope, (self.image_width*self.image_height,1))
inter = self.volroi[:,:,0]
inter = np.reshape(inter, (self.image_width*self.image_height,1))
volroi = np.reshape(self.volroi, (self.image_width*self.image_height,len(roi)))
self.volroi = self.volroi/np.max(self.volroi)
self.image = volroi - (slope*np.arange(0,len(roi)) + inter)
self.image = np.sum(self.image, axis = 1)
self.image = np.reshape(self.image, (self.image_width, self.image_height))
self.image[self.image<0] = 0
self.image = 100*(self.image/np.max(self.image))
self.mask = np.copy(self.image)
self.mask[self.mask<5] = 0
self.mask[self.mask>0] = 1
self.ax_image.imshow(self.image.T, cmap=self.cmap)
self.ax_image.set_title("ROI image")
self.canvas_image.draw()
[docs]
def export_roi_image(self):
"""
Export the currently displayed ROI image to disk.
This method saves both the raw image data and a rendered PNG:
- HDF5 file containing the ROI image (`I`) and the selected channel range (`chi`, `chf`).
- PNG image of the ROI visualized with the current colormap.
The filenames are generated based on the original HDF5 filename and the ROI range.
Notes
-----
This function assumes that `self.image` and `self.hdf_fileName` are valid.
If no image or filename is available, it prints a warning and exits.
"""
if len(self.hdf_fileName)>0 and len(self.image)>0:
s = self.hdf_fileName.split('.h5'); s = s[0]
sn = "%s_roi_%sto%s.h5" %(s,str(self.chi), str(self.chf))
print(sn)
h5f = h5py.File(sn, "w")
h5f.create_dataset('I', data=self.image)
h5f.create_dataset('chi', data=self.chi)
h5f.create_dataset('chf', data=self.chf)
h5f.close()
sn = "%s_roi_%sto%s.png" %(s,str(self.chi), str(self.chf))
plt.imsave(sn,self.image,cmap=self.cmap)
else:
print("Something is wrong with the data")
####################### ROI pattern #######################
[docs]
def set_thr(self, value):
"""
Set the threshold value used for segmenting the ROI image.
Parameters
----------
value : int
Threshold value (0–100) used to binarize the ROI image.
"""
self.thr = value
print(self.thr)
[docs]
def segment_image(self):
"""
Segment the ROI image using the current threshold value.
Applies a simple threshold to the currently displayed ROI image to generate a binary mask.
Pixels below the threshold are set to 0, others to 1. The resulting mask is displayed in the image view.
"""
# Update the image display
self.ax_image.clear()
self.mask = np.copy(self.image)
self.mask[self.mask<self.thr] = 0
self.mask[self.mask>0] = 1
self.ax_image.imshow(self.mask.T, cmap=self.cmap)
self.ax_image.set_title("Mask")
self.canvas_image.draw()
[docs]
def plot_roi_pattern(self):
"""
Compute and display the ROI-averaged pattern based on the current segmentation mask.
Multiplies each spectral slice by the binary mask to isolate the ROI region,
then integrates across all pixels to generate a 1D spectrum. The spectrum is
normalized and plotted in the spectral view.
"""
voln = np.zeros_like(self.volume)
for ii in range(self.volume.shape[2]):
voln[:,:,ii] = self.volume[:,:,ii]*self.mask
self.spectrum = np.sum(voln, axis = (0,1))
self.spectrum = self.spectrum/np.max(self.spectrum)
self.ax_spectrum.clear()
self.ax_spectrum.plot(self.xaxis, self.spectrum, color='b')
self.ax_spectrum.set_title("ROI pattern")
self.ax_spectrum.set_xlabel(self.xaxislabel)
self.canvas_spectrum.draw()
[docs]
def export_roi_pattern(self):
"""
Export the segmented ROI-averaged pattern to multiple file formats.
Saves the 1D ROI pattern (`self.spectrum`) and corresponding x-axis values (`self.xaxis`)
to an HDF5 file (`.h5`) and two plain text formats (`.asc` and `.xy`).
Filenames include the current threshold value used for segmentation.
Notes
-----
This method will print a warning if `self.spectrum` is empty or no HDF5 file was previously loaded.
"""
if len(self.hdf_fileName)>0 and len(self.spectrum)>0:
s = self.hdf_fileName.split('.h5'); s = s[0]
sn = "%s_ROI_thr%s.h5" %(s,str(self.thr))
print(sn)
h5f = h5py.File(sn, "w")
h5f.create_dataset('I', data=self.spectrum)
h5f.create_dataset('xaxis', data=self.xaxis)
h5f.close()
xy = np.column_stack((self.xaxis,self.spectrum))
sn = "%s_ROI_thr%s.asc" %(s,str(self.thr))
np.savetxt(sn,xy, fmt="%.5f")
xy = np.column_stack((self.xaxis,self.spectrum))
sn = "%s_ROI_thr%s.xy" %(s,str(self.thr))
np.savetxt(sn,xy, fmt="%.5f")
else:
print("Something is wrong with the data")
[docs]
def suggest_peak_positions(self):
"""
Automatically detect and visualize candidate peak positions in the ROI-averaged spectrum.
Uses `scipy.signal.find_peaks` to identify prominent peaks based on height and spacing thresholds.
Detected peaks are displayed as vertical lines overlaid on the spectrum plot:
- The first peak is highlighted in red.
- All other peaks are shown in green.
The image viewer is updated to show the spectral slice corresponding to the first detected peak.
Detected peak positions are also stored internally for future reference.
Raises
------
Warning dialog if no spectrum is available or no peaks are found.
"""
if self.spectrum is None or len(self.spectrum) == 0:
QtWidgets.QMessageBox.warning(self, "No Spectrum", "Please extract the ROI pattern first.")
return
# Detect peaks
peaks, _ = find_peaks(self.spectrum, height=0.1 * np.max(self.spectrum), distance=3)
if len(peaks) == 0:
QtWidgets.QMessageBox.information(self, "No Peaks Found", "No peaks could be detected in the spectrum.")
return
# Display peak list
msg = "Suggested peak positions:\n"
for p in peaks:
msg += f" {self.xaxis[p]:.2f}\n"
QtWidgets.QMessageBox.information(self, "Peak Suggestions", msg)
# Rebuild spectrum figure
self.fig_spectrum.clear()
self.ax_spectrum = self.fig_spectrum.add_subplot(111)
self.ax_spectrum.plot(self.xaxis, self.spectrum, color='b')
self.ax_spectrum.set_title("ROI pattern with suggested peaks")
self.ax_spectrum.set_xlabel(self.xaxislabel)
# Add red vline (first peak)
self.vline = self.ax_spectrum.axvline(self.xaxis[peaks[0]], color='r', linestyle='--', lw=1)
# Add green lines for all peaks
for p in peaks:
self.ax_spectrum.axvline(x=self.xaxis[p], color='g', linestyle='--', lw=1.2)
# Reconnect spectrum panel interactivity
self.canvas_spectrum.mpl_connect('motion_notify_event', self.update_image)
self.canvas_spectrum.mpl_connect('button_press_event', self.toggle_real_time_image)
self.canvas_spectrum.mpl_connect('scroll_event', self.on_canvas_scroll)
self.canvas_spectrum.draw()
# Force image update to match first peak
self.index = peaks[0]
self.image = self.volume[:, :, self.index]
# Recreate image plot fully to ensure canvas update
self.fig_image.clear()
self.ax_image = self.fig_image.add_subplot(111)
self.im = self.ax_image.imshow(self.image.T, cmap=self.cmap)
self.ax_image.set_title(f"Image: Channel {self.index}")
self.cbar = self.fig_image.colorbar(self.im, ax=self.ax_image, fraction=0.046, pad=0.04)
self.canvas_image.mpl_connect('motion_notify_event', self.update_spectrum)
self.canvas_image.mpl_connect('button_press_event', self.toggle_real_time_spectrum)
self.canvas_image.mpl_connect('scroll_event', self.on_canvas_scroll)
self.canvas_image.draw()
# Store peaks for future use
self.detected_peaks = peaks
####################### Peak fitting #######################
[docs]
def set_fit_range(self):
"""
Define the spectral fitting region and normalize the data volume accordingly.
This method updates the region of interest (`xroi`, `volroi`) based on the
spinbox values for initial and final channels. It also normalizes the ROI data
and updates the peak position spinboxes to reflect the new fitting range.
Raises
------
QtWidgets.QMessageBox
If the selected channel range is invalid (i.e., final ≤ initial).
"""
ch_min = self.xfit_min_spin.value()
ch_max = self.xfit_max_spin.value()
if ch_max <= ch_min:
QtWidgets.QMessageBox.warning(self, "Invalid Range", "Final channel must be greater than initial channel.")
return
self.xroi = np.arange(ch_min, ch_max)
self.volroi = self.volume[:, :, ch_min:ch_max]
self.volroi = self.volroi / np.max(self.volroi)
# Set peak position controls based on the range
pos = 0.5 * (ch_min + ch_max)
pos_min = ch_min
pos_max = ch_max
self.pos_spin.setValue(pos)
self.pos_min_spin.setValue(pos_min)
self.pos_max_spin.setValue(pos_max)
print(f"Fitting range set to channels {ch_min} to {ch_max} ({ch_max - ch_min} channels)")
print(f"Updated peak position guess: {pos:.2f} [{pos_min:.2f}, {pos_max:.2f}]")
[docs]
def sync_peak_position_from_roi(self):
"""
Automatically update peak position guess based on ROI channel selection.
This method sets the peak position (`pos_spin`) and bounds (`pos_min_spin`, `pos_max_spin`)
using the midpoint of the selected channel range in the ROI tab. It helps keep peak fitting
parameters in sync with image generation settings.
"""
chi = self.crspinbox1.value()
chf = self.crspinbox2.value()
if chf <= chi:
return
pos = 0.5 * (chi + chf)
self.pos_spin.setValue(pos)
self.pos_min_spin.setValue(max(0.0, pos - 5.0))
self.pos_max_spin.setValue(pos + 5.0)
[docs]
def profile_function(self, ind):
"""
Set the peak profile type for curve fitting based on user selection.
Updates internal `peaktype` and toggles the visibility of the mixing fraction
(γ) parameter used for Pseudo-Voigt profiles.
Parameters
----------
ind : int
Index from the dropdown selector:
- 0: Gaussian
- 1: Lorentzian
- 2: Pseudo-Voigt
"""
if ind == 0:
self.peaktype = "Gaussian"
print("Gaussian profile")
self.label_fraction.setVisible(False)
self.fraction_spin.setVisible(False)
elif ind == 1:
self.peaktype = "Lorentzian"
print("Lorentzian profile")
self.label_fraction.setVisible(False)
self.fraction_spin.setVisible(False)
elif ind == 2:
self.peaktype = "Pseudo-Voigt"
print("Pseudo-Voigt profile")
self.label_fraction.setVisible(True)
self.fraction_spin.setVisible(True)
[docs]
def batchpeakfit(self):
"""
Perform batch single-peak fitting over the masked region of the dataset.
This method collects all peak fitting parameters from the GUI (initial guesses and bounds)
and applies them to the currently selected volume region (`volroi`) using a user-defined
peak model (Gaussian, Lorentzian, or Pseudo-Voigt). It applies the segmentation mask to restrict
the fitting region, initializes a `FitData` thread for asynchronous processing, and connects
the progress and result signals for GUI updates.
Notes
-----
- If the fitting type is Pseudo-Voigt, the mixing fraction is also passed to the fitting class.
- The fitting thread is non-blocking and results are streamed progressively.
- Disables interactive diagnostic display and the "Fit" button until completion.
"""
self.check_diag_mode.setEnabled(False)
self.pbutton_fit.setEnabled(False)
# Read GUI values
self.Area = self.area_spin.value()
self.Areamin = self.area_min_spin.value()
self.Areamax = self.area_max_spin.value()
self.FWHM = self.fwhm_spin.value()
self.FWHMmin = self.fwhm_min_spin.value()
self.FWHMmax = self.fwhm_max_spin.value()
self.Pos = self.pos_spin.value()
self.Posmin = self.pos_min_spin.value()
self.Posmax = self.pos_max_spin.value()
# Override Posmin/Posmax if custom set
if self.pos_min_spin.value() < self.pos_max_spin.value():
self.Posmin = self.pos_min_spin.value()
self.Posmax = self.pos_max_spin.value()
if self.mask is None or not isinstance(self.mask, np.ndarray):
print("No mask found, using default mask (all 1s)")
self.mask = np.ones((self.volume.shape[0], self.volume.shape[1]))
for ii in range(self.volroi.shape[2]):
self.volroi[:,:,ii] = self.volroi[:,:,ii]*self.mask
fraction = self.fraction_spin.value() if self.peaktype == "Pseudo-Voigt" else None
self.PeakFitData = FitData(self.peaktype, self.volroi, self.xroi,
self.Area, self.Areamin, self.Areamax,
self.Pos, self.Posmin, self.Posmax,
self.FWHM, self.FWHMmin, self.FWHMmax)
if self.peaktype == "Pseudo-Voigt":
self.PeakFitData.fraction_init = fraction
self.PeakFitData.start()
self.PeakFitData.progress_fit.connect(self.progressbar_fit.setValue)
self.PeakFitData.result_partial.connect(self.update_live_fit_image)
self.PeakFitData.fitdone.connect(self.updatefitdata)
[docs]
def update_live_fit_image(self, live_data):
"""
Update the image display with intermediate peak fitting results.
This method is triggered during batch fitting to provide real-time visual feedback
of the parameter currently selected in the "Live view" dropdown (Area, Position, or FWHM).
It redraws the image canvas using the latest fitting results for the selected parameter
and reconnects all interactive events to maintain responsiveness.
Parameters
----------
live_data : np.ndarray
A 2D array representing the latest fitting result for a single parameter (typically area),
used if no predefined mapping is selected.
"""
param = self.ChooseLive.currentText()
if param == "Area":
img = self.PeakFitData.phase
vmin, vmax = None, None # autoscale
elif param == "Position":
img = self.PeakFitData.cen
vmin, vmax = self.Posmin, self.Posmax
elif param == "FWHM":
img = self.PeakFitData.wid
vmin, vmax = None, None # autoscale
else:
img = live_data
vmin, vmax = np.nanmin(img), np.nanmax(img)
# Recreate the figure to avoid layout shrinking
self.fig_image.clear()
self.ax_image = self.fig_image.add_subplot(111)
if vmin is not None and vmax is not None:
self.im = self.ax_image.imshow(img.T, cmap='jet', vmin=vmin, vmax=vmax)
else:
self.im = self.ax_image.imshow(img.T, cmap='jet')
self.ax_image.set_title(f"Live fit: {param}")
self.cbar = self.fig_image.colorbar(self.im, ax=self.ax_image, fraction=0.046, pad=0.04)
# IMPORTANT: reconnect interactions here to restore hover after each update
self.canvas_image.mpl_connect('motion_notify_event', self.update_spectrum)
self.canvas_image.mpl_connect('button_press_event', self.toggle_real_time_spectrum)
self.canvas_image.mpl_connect('scroll_event', self.on_canvas_scroll)
self.canvas_image.draw()
[docs]
def updatefitdata(self):
"""
Finalize and display peak fitting results after batch processing.
This method is called when the fitting thread signals completion. It updates the internal
`res` dictionary with the final results (e.g. Area, Position, FWHM), re-enables UI controls,
and redraws the image panel to show the fitted peak area map. It also reconnects interactive
mouse events to restore full responsiveness of the GUI.
"""
if not hasattr(self.PeakFitData, 'res'):
print("Peak fitting was stopped before completion.")
return
try:
self.PeakFitData.result_partial.disconnect()
except TypeError:
pass
self.res = self.PeakFitData.res
self.pbutton_fit.setEnabled(True)
self.ChooseRes.setEnabled(True)
self.pbutton_expfit.setEnabled(True)
# Recreate figure and axis
self.fig_image.clear()
self.ax_image = self.fig_image.add_subplot(111)
img = self.res['Area']
self.im = self.ax_image.imshow(img.T, cmap='jet', vmin=self.Areamin, vmax=self.Areamax)
self.ax_image.set_title("Peak area")
self.cbar = self.fig_image.colorbar(self.im, ax=self.ax_image, fraction=0.046, pad=0.04)
# Reconnect event handlers (so update_image still works)
self.canvas_image.mpl_connect('motion_notify_event', self.update_spectrum)
self.canvas_image.mpl_connect('button_press_event', self.toggle_real_time_spectrum)
self.canvas_image.mpl_connect('scroll_event', self.on_canvas_scroll)
self.canvas_image.draw()
self.check_diag_mode.setEnabled(True)
[docs]
def stopfit(self):
"""
Stop the ongoing peak fitting process.
Requests the fitting thread to stop gracefully, resets the progress bar,
and re-enables the fit button for user interaction.
"""
if hasattr(self, 'PeakFitData'):
self.PeakFitData.request_stop()
self.progressbar_fit.setValue(0)
self.pbutton_fit.setEnabled(True)
[docs]
def plot_fit_results(self, ind):
"""
Plot a selected parameter map from the peak fitting results.
Parameters
----------
ind : int
Index of the parameter to display:
- 0: Peak area
- 1: Peak position
- 2: FWHM
- 3: Background 1 slope
- 4: Background 2 intercept
The method updates the image panel with the selected map and reconnects
interactive controls.
"""
self.fig_image.clear()
self.ax_image = self.fig_image.add_subplot(111)
# Map selection to data and optional bounds
if ind == 0:
label, data = "Area", self.res['Area']
vmin, vmax = None, None # autoscale
elif ind == 1:
label, data = "Position", self.res['Position']
vmin, vmax = self.Posmin, self.Posmax # fixed
elif ind == 2:
label, data = "FWHM", self.res['FWHM']
vmin, vmax = None, None # autoscale
elif ind == 3:
label, data = "Background 1", self.res['Background1']
vmin, vmax = None, None # autoscale
elif ind == 4:
label, data = "Background 2", self.res['Background2']
vmin, vmax = None, None # autoscale
# Plot with optional scaling
if vmin is not None and vmax is not None:
self.im = self.ax_image.imshow(data.T, cmap='jet', vmin=vmin, vmax=vmax)
else:
self.im = self.ax_image.imshow(data.T, cmap='jet')
self.ax_image.set_title(label)
self.cbar = self.fig_image.colorbar(self.im, ax=self.ax_image, fraction=0.046, pad=0.04)
# Reconnect interactivity
self.canvas_image.mpl_connect('motion_notify_event', self.update_spectrum)
self.canvas_image.mpl_connect('button_press_event', self.toggle_real_time_spectrum)
self.canvas_image.mpl_connect('scroll_event', self.on_canvas_scroll)
self.canvas_image.draw()
[docs]
def savefitresults(self):
"""
Export the peak fitting results to an HDF5 file.
Saves the fitted parameter maps (area, position, FWHM, background slope and intercept)
to a new `.h5` file using the current dataset filename as a base. If the Pseudo-Voigt
profile is used, the mixing parameter ('gamma') is also saved.
The resulting file contains:
- 'Area': Peak area map
- 'Position': Peak center position map
- 'FWHM': Full width at half maximum map
- 'bkga': Background slope map
- 'bkgb': Background intercept map
- 'gamma' (if applicable): Mixing fraction between Gaussian and Lorentzian
Notes
-----
This method assumes a dataset has been previously loaded and fitting has been completed.
"""
if len(self.hdf_fileName)>0:
s = self.hdf_fileName.split('.h5'); s = s[0]
sn = "%s_fit_results.h5" %(s)
with h5py.File(sn, 'w') as h5f:
h5f.create_dataset('Area', data=self.res['Area'])
h5f.create_dataset('Position', data=self.res['Position'])
h5f.create_dataset('FWHM', data=self.res['FWHM'])
h5f.create_dataset('bkga', data=self.res['Background1'])
h5f.create_dataset('bkgb', data=self.res['Background2'])
if self.peaktype == "Pseudo-Voigt":
h5f.create_dataset('gamma', data=self.res['Fraction'])
else:
print("Something is wrong with the data")
####################### Create synthetic phantom #######################
[docs]
def create_phantom(self):
"""
Generate and load a synthetic phantom XRD-CT dataset into the GUI.
This method creates a 3D synthetic chemical imaging volume based on known diffraction
patterns of common elements (Al, Cu, Fe, Pt, Zn). It uses pre-defined 2D spatial templates
and overlays them with their respective spectral signatures to construct a realistic
simulated XRD-CT dataset.
The generated volume is loaded into the viewer, and internal data structures are updated
(including axis labels, spinbox limits, and dataset name). An initial visualization is
triggered via `self.explore()`.
If the process fails (e.g. missing dependencies or runtime errors), a critical message box is shown.
Notes
-----
This function relies on modules within the `nDTomo.sim` and `nDTomo.methods` namespaces.
"""
try:
from nDTomo.sim.phantoms import load_example_patterns, nDTomophantom_2D, nDTomophantom_3D
from nDTomo.methods.plots import showspectra, showim
# Load example patterns
dpAl, dpCu, dpFe, dpPt, dpZn, tth, q = load_example_patterns()
spectra = [dpAl, dpCu, dpFe, dpPt, dpZn]
# Generate 2D images
npix = 200
imAl, imCu, imFe, imPt, imZn = nDTomophantom_2D(npix, nim='Multiple')
iml = [imAl, imCu, imFe, imPt, imZn]
# Create synthetic 3D dataset
chemct = nDTomophantom_3D(npix, use_spectra='Yes', spectra=spectra, imgs=iml, indices='All', norm='No')
self.volume = chemct
self.xaxis = tth
self.xaxislabel = '2theta'
self.image_width, self.image_height, self.nbins = chemct.shape
self.DatasetNameLabel.setText("Synthetic Phantom")
self.crspinbox1.setMaximum(self.nbins - 1)
self.crspinbox2.setMaximum(self.nbins)
self.explore()
QtWidgets.QMessageBox.information(self, "Phantom Created", "Synthetic phantom XRD-CT dataset has been created.")
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Error", f"Failed to create phantom:\n{e}")
######################## IPython console #######################
[docs]
def init_console(self):
"""
Launch an embedded IPython console inside the GUI.
This method creates a dockable widget hosting a fully interactive IPython
(Jupyter) console. The console allows the user to inspect and manipulate
GUI-level variables such as `volume`, `image`, `spectrum`, and `xaxis`
using NumPy and Matplotlib interactively within the same application.
If the console has already been initialized, this method brings it back into view.
Notes
-----
The console is powered by `qtconsole` and runs an in-process kernel,
meaning it shares memory with the main application context.
"""
if hasattr(self, 'console_dock') and self.console_dock is not None:
self.console_dock.show()
return
self.console_dock = QtWidgets.QDockWidget("IPython Console", self)
self.console_dock.setAllowedAreas(QtCore.Qt.BottomDockWidgetArea | QtCore.Qt.RightDockWidgetArea)
self.console_widget = RichJupyterWidget()
self.kernel_manager = QtInProcessKernelManager()
self.kernel_manager.start_kernel()
self.kernel = self.kernel_manager.kernel
self.kernel.gui = 'qt'
self.kernel_client = self.kernel_manager.client()
self.kernel_client.start_channels()
self.kernel.shell.push({
'gui': self,
'volume': self.volume,
'image': self.image,
'spectrum': self.spectrum,
'xaxis': self.xaxis,
'np': np,
'plt': plt
})
self.console_widget.kernel_manager = self.kernel_manager
self.console_widget.kernel_client = self.kernel_client
self.console_widget.exit_requested.connect(self.stop_console)
self.console_dock.setWidget(self.console_widget)
self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, self.console_dock)
[docs]
def stop_console(self):
"""
Shut down the embedded IPython console and its kernel.
This method stops the running IPython kernel and disconnects the console client.
It is triggered when the user exits the docked console interface.
"""
self.kernel_client.stop_channels()
self.kernel_manager.shutdown_kernel()
[docs]
class FileDialog(QtWidgets.QFileDialog):
"""
Initialize the custom file dialog with multi-selection support.
This constructor configures the dialog to:
- Use a non-native file dialog (to allow enhanced widget access).
- Enable multi-selection mode for both QListView and QTreeView components,
allowing the user to select multiple files at once.
Parameters
----------
*args : tuple
Arguments passed to the base QFileDialog constructor.
"""
def __init__(self, *args):
QtWidgets.QFileDialog.__init__(self, *args)
self.setOption(self.DontUseNativeDialog, True)
for view in self.findChildren((QtWidgets.QListView, QtWidgets.QTreeView)):
if isinstance(view.model(), QtWidgets.QFileSystemModel):
view.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection)
[docs]
class FitData(QThread):
"""
Worker thread for batch single-peak fitting on hyperspectral data.
This class performs pixel-wise non-linear fitting of a single peak model (with linear background)
using SciPy's curve_fit. Runs asynchronously and emits progress and results.
Parameters
----------
peaktype : str
Type of peak profile to fit ('Gaussian', 'Lorentzian', 'Pseudo-Voigt').
data : np.ndarray
3D hyperspectral data to fit (X, Y, Channels).
x : np.ndarray
1D array of x-axis values corresponding to spectral channels.
Area, Areamin, Areamax : float
Initial, minimum, and maximum values for peak area.
Pos, Posmin, Posmax : float
Initial, minimum, and maximum values for peak position.
FWHM, FWHMmin, FWHMmax : float
Initial, minimum, and maximum values for full width at half maximum.
Signals
-------
fitdone : pyqtSignal
Emitted when the fitting is complete.
progress_fit : pyqtSignal(int)
Emits fitting progress (0–100).
result_partial : pyqtSignal(np.ndarray)
Emits a partial result image (area) during processing.
"""
fitdone = pyqtSignal()
progress_fit = pyqtSignal(int)
result_partial = pyqtSignal(np.ndarray)
def __init__(self, peaktype, data, x, Area, Areamin, Areamax, Pos, Posmin, Posmax, FWHM, FWHMmin, FWHMmax):
"""
Initialize the FitData worker thread for single-peak fitting.
This sets up the initial parameters and allocates output arrays for the
batch fitting of hyperspectral or tomographic data. The fitting is done
using SciPy's non-linear curve fitting (`curve_fit`) on a per-pixel basis.
Parameters
----------
peaktype : str
Type of peak to fit; must be one of: "Gaussian", "Lorentzian", "Pseudo-Voigt".
data : np.ndarray
3D array of shape (X, Y, Channels) representing the volume to fit.
x : np.ndarray
1D array of length Channels representing the spectral axis.
Area : float
Initial guess for peak area.
Areamin : float
Minimum allowed value for peak area.
Areamax : float
Maximum allowed value for peak area.
Pos : float
Initial guess for peak position (in x units).
Posmin : float
Minimum allowed value for peak position.
Posmax : float
Maximum allowed value for peak position.
FWHM : float
Initial guess for full width at half maximum.
FWHMmin : float
Minimum allowed FWHM.
FWHMmax : float
Maximum allowed FWHM.
"""
QThread.__init__(self)
self._stop_requested = False
self.peaktype = peaktype
self.fitdata = data
shape = (self.fitdata.shape[0], self.fitdata.shape[1])
self.phase = np.zeros(shape, dtype='float32')
self.cen = np.full(shape, Pos, dtype='float32')
self.wid = np.full(shape, FWHMmin, dtype='float32')
self.bkg1 = np.zeros(shape, dtype='float32')
self.bkg2 = np.zeros(shape, dtype='float32')
self.fr = np.full(shape, 0.5, dtype='float32')
self.xroi = x
self.Area = Area; self.Areamin = Areamin; self.Areamax = Areamax
self.Pos = Pos; self.Posmin = Posmin; self.Posmax = Posmax
self.FWHM = FWHM; self.FWHMmin = FWHMmin; self.FWHMmax = FWHMmax
msk = np.sum(self.fitdata,axis = 2)
msk[msk>0] = 1
self.i, self.j = np.where(msk > 0)
[docs]
def request_stop(self):
"""
Signal the fitting thread to stop.
Sets an internal flag that is checked periodically during fitting.
Allows the fitting process to be gracefully interrupted without killing the thread.
"""
self._stop_requested = True
[docs]
def run(self):
"""
Entry point for the QThread.
Invokes the batch fitting routine (`batchfit`) when the thread is started.
"""
self.batchfit()
[docs]
def batchfit(self):
"""
Perform batch pixel-wise single-peak fitting over a 3D hyperspectral dataset.
The method fits a Gaussian, Lorentzian, or pseudo-Voigt peak with a linear background
to each spectrum in the dataset where the mask is non-zero. It uses SciPy's `curve_fit`
for non-linear least squares optimization with parameter bounds. Progress is emitted
after each row to support responsive GUI updates.
For each valid pixel:
- Attempts to fit the selected peak model.
- On success, stores peak parameters (area, position, FWHM, background terms, and Voigt fraction if applicable).
- On failure, stores midpoint values for peak position and width as fallback.
Emits
-----
progress_fit : pyqtSignal(int)
Signal emitting fitting progress in percentage (0–100).
result_partial : pyqtSignal(np.ndarray)
Signal emitting current 'Area' map (transposed) for live updating.
fitdone : pyqtSignal()
Signal emitted upon completion of fitting.
Notes
-----
- Uses `request_stop()` flag to allow asynchronous termination from GUI.
- Results are stored in the `self.res` dictionary.
- The method ensures fitting is done only where data is present (non-zero mask).
"""
if self.peaktype == "Pseudo-Voigt":
x0 = np.array([float(self.Area), float(self.Pos), float(self.FWHM), 0., 0., 0.5], dtype=float)
else:
x0 = np.array([float(self.Area), float(self.Pos), float(self.FWHM), 0., 0.], dtype=float)
if self.peaktype == "Pseudo-Voigt":
param_bounds=([float(self.Areamin),float(self.Posmin),float(self.FWHMmin),-0.5,-1.0,0],
[float(self.Areamax),float(self.Posmax),float(self.FWHMmax),0.5,1.0,1])
else:
param_bounds=([float(self.Areamin),float(self.Posmin),float(self.FWHMmin),-0.5,-1.0],
[float(self.Areamax),float(self.Posmax),float(self.FWHMmax),0.5,1.0])
rows = np.unique(self.i)
if len(rows) == 0:
print("No data to fit (mask may be empty)")
self.progress_fit.emit(0)
return
for irow, row in enumerate(rows):
if self._stop_requested:
break
cols = self.j[self.i == row]
for jj in cols:
if self._stop_requested:
break
dp = self.fitdata[row, jj, :]
try:
if self.peaktype == "Gaussian":
params, _ = sciopt.curve_fit(self.gmodel, self.xroi, dp, p0=x0, bounds=param_bounds)
elif self.peaktype == "Lorentzian":
params, _ = sciopt.curve_fit(self.lmodel, self.xroi, dp, p0=x0, bounds=param_bounds)
elif self.peaktype == "Pseudo-Voigt":
params, _ = sciopt.curve_fit(self.pvmodel, self.xroi, dp, p0=x0, bounds=param_bounds)
self.phase[row, jj] = params[0]
self.cen[row, jj] = params[1]
self.wid[row, jj] = params[2]
self.bkg1[row, jj] = params[3]
self.bkg2[row, jj] = params[4]
if self.peaktype == "Pseudo-Voigt":
self.fr[row, jj] = params[5]
except Exception:
self.cen[row, jj] = (param_bounds[0][1] + param_bounds[1][1]) / 2
self.wid[row, jj] = (param_bounds[0][2] + param_bounds[1][2]) / 2
# Emit the current partial result image after this row
self.result_partial.emit(self.phase.T.copy())
# Progress bar update
progress = int(100 * (irow + 1) / len(rows))
self.progress_fit.emit(progress)
self.phase = np.where(self.phase<0,0,self.phase)
if self.peaktype == "Pseudo-Voigt":
self.res = {'Area':self.phase, 'Position':self.cen, 'FWHM':self.wid, 'Background1':self.bkg1, 'Background2':self.bkg2, 'Fraction':self.fr}
else:
self.res = {'Area':self.phase, 'Position':self.cen, 'FWHM':self.wid, 'Background1':self.bkg1, 'Background2':self.bkg2}
self.fitdone.emit()
[docs]
def gmodel(self, x, A, m, w, a, b):
"""
Gaussian peak model with linear background.
Parameters
----------
x : np.ndarray
The x-axis values (e.g., channel, energy, or 2θ).
A : float
Area under the Gaussian peak.
m : float
Peak center (mean).
w : float
Full width at half maximum (FWHM) of the peak.
a : float
Linear background slope.
b : float
Linear background intercept.
Returns
-------
np.ndarray
The evaluated Gaussian function with background.
"""
return (A / (np.sqrt(2 * np.pi) * w)) * np.exp(- (x - m)**2 / (2 * w**2)) + a * x + b
[docs]
def lmodel(self, x, A, m, w, a, b):
"""
Lorentzian peak model with linear background.
Parameters
----------
x : np.ndarray
The x-axis values.
A : float
Area under the Lorentzian peak.
m : float
Peak center.
w : float
FWHM of the peak.
a : float
Linear background slope.
b : float
Linear background intercept.
Returns
-------
np.ndarray
The evaluated Lorentzian function with background.
"""
return (A / (1 + ((x - m) / w)**2)) / (np.pi * w) + a * x + b
[docs]
def pvmodel(self, x, A, m, w, a, b, fr):
"""
Pseudo-Voigt peak model with linear background.
This is a linear combination of a Gaussian and Lorentzian profile.
Parameters
----------
x : np.ndarray
The x-axis values.
A : float
Total area under the peak.
m : float
Peak center.
w : float
FWHM of the peak.
a : float
Linear background slope.
b : float
Linear background intercept.
fr : float
Fraction of Lorentzian contribution (0 = pure Gaussian, 1 = pure Lorentzian).
Returns
-------
np.ndarray
The evaluated Pseudo-Voigt function with background.
"""
gauss = (A / (np.sqrt(2 * np.pi) * w)) * np.exp(- (x - m)**2 / (2 * w**2))
lorentz = (A / (1 + ((x - m) / w)**2)) / (np.pi * w)
return (1 - fr) * gauss + fr * lorentz + a * x + b
[docs]
def main():
qApp = QtWidgets.QApplication(sys.argv)
aw = nDTomoGUI()
aw.show()
sys.exit(qApp.exec_())
qApp.exec_()
if __name__ == "__main__":
main()