diff --git a/requirements.txt b/requirements.txt index a641a9f..b946c67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,6 +11,7 @@ pandas==2.2.3 pyfiglet==1.0.2 pyflakes==3.2.0 pyjokes==0.6.0 +PyOpenGL==3.1.7 pyqtdarktheme==2.1.0 pyqtgraph==0.13.7 pyserial==3.5 diff --git a/setup.py b/setup.py index 5871e7c..a5cb910 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setuptools.setup( name='microEye', - version='2.2.2', + version='2.2.2.post1', author='Mohammad Nour Alsamsam', author_email='nour.alsamsam@gmail.com', description='A python toolkit for fluorescence microscopy \ @@ -47,6 +47,7 @@ 'pyfiglet==1.0.2', 'pyflakes==3.2.0', 'pyjokes==0.6.0', + 'PyOpenGL==3.1.7', 'pyqtdarktheme==2.1.0', 'pyqtgraph==0.13.7', 'pyserial==3.5', diff --git a/src/microEye/__init__.py b/src/microEye/__init__.py index 9e86106..ff92ca0 100644 --- a/src/microEye/__init__.py +++ b/src/microEye/__init__.py @@ -38,7 +38,7 @@ def getArgs(): ARGS = getArgs() -VERSION = '2.2.2' +VERSION = '2.2.2.post1' from microEye.qt import * # noqa: I001, E402 import microEye.analysis.fitting.pyfit3Dcspline as pyfit3Dcspline # noqa: E402 diff --git a/src/microEye/analysis/cmosMaps.py b/src/microEye/analysis/cmosMaps.py index a3be4e0..abd33c7 100644 --- a/src/microEye/analysis/cmosMaps.py +++ b/src/microEye/analysis/cmosMaps.py @@ -74,7 +74,7 @@ def InitLayout(self): self.exp_spin = QtWidgets.QDoubleSpinBox() self.exp_spin.setMinimum(0) - self.exp_spin.setMaximum(1e4) + self.exp_spin.setMaximum(10000) self.exp_spin.setDecimals(5) self.exp_spin.setValue(self.expTime) @@ -94,10 +94,10 @@ def InitLayout(self): self.Y.setMinimum(0) self.W.setMinimum(0) self.H.setMinimum(0) - self.X.setMaximum(1e4) - self.Y.setMaximum(1e4) - self.W.setMaximum(1e4) - self.H.setMaximum(1e4) + self.X.setMaximum(10000) + self.Y.setMaximum(10000) + self.W.setMaximum(10000) + self.H.setMaximum(10000) self.main_layout.addRow(QtWidgets.QLabel('ROI X:'), self.X) self.main_layout.addRow(QtWidgets.QLabel('ROI Y:'), self.Y) diff --git a/src/microEye/analysis/fitting/nena.py b/src/microEye/analysis/fitting/nena.py index 2198655..b2631d0 100644 --- a/src/microEye/analysis/fitting/nena.py +++ b/src/microEye/analysis/fitting/nena.py @@ -220,7 +220,7 @@ def __init__( self.bins = QtWidgets.QSpinBox() self.bins.setMinimum(5) - self.bins.setMaximum(1e4) + self.bins.setMaximum(10000) self.bins.setValue(200) self.fitlay.addRow( diff --git a/src/microEye/analysis/fitting/psf/extract.py b/src/microEye/analysis/fitting/psf/extract.py index e15d1ba..ca5d4d2 100644 --- a/src/microEye/analysis/fitting/psf/extract.py +++ b/src/microEye/analysis/fitting/psf/extract.py @@ -7,22 +7,33 @@ import h5py import numba as nb import numpy as np +import pyqtgraph as pg +from scipy.optimize import curve_fit +from scipy.signal import find_peaks, peak_prominences from tqdm import tqdm from microEye.analysis.fitting.results import PARAMETER_HEADERS from microEye.utils.uImage import TiffSeqHandler, ZarrImageSequence, uImage +def gaussian(x, a, x0, sigma, offset): + return a * np.exp(-((x - x0) ** 2) / (2 * sigma**2)) + offset + + class PSFdata: def __init__( self, data_dict: dict[str, Union[int, np.ndarray, list, float]] ) -> None: self._data = data_dict + self._last_field = 0 def get_field_psf(self, grid_size: int = 3): ''' Get PSF field for the given grid size. ''' + if self._last_field == grid_size: + return + width, height = self.dim width *= self.upsample height *= self.upsample @@ -46,6 +57,8 @@ def get_grid_cell_index(x_start, y_start): zslice['field'][idx].append(zslice['rois'][i].copy()) zslice['field_idx'][i] = idx + self._last_field = grid_size + @property def zslices(self) -> list[dict[str, Union[int, np.ndarray, list, float]]]: return self._data.get('zslices') @@ -179,6 +192,501 @@ def __next__(self): else: raise StopIteration + def get_z_slice( + self, + z_index: int, + type: str = 'mean', + roi_index: int = 0, + grid_size: int = 1, + normalize: bool = False, + ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """ + Get the XY slice for the given z-index. + + Parameters: + ----------- + z_index : int + The z-index of the slice + type : str, optional + The type of data to return, by default 'mean' + Options: 'mean', 'std', 'median', 'roi' + roi_index : int, optional + The index of the ROI to return, by default 0 + grid_size : int, optional + The grid size for field PSF, by default 1 + normalize : bool, optional + Whether to normalize the data, by default False + + Returns: + -------- + tuple[np.ndarray, np.ndarray] + The data array and grid overlay + """ + # check if Z index is in bounds + if z_index >= len(self.zslices): + raise IndexError(f'Z index {z_index} out of bounds') + + # get the z-slice + zslice = self.zslices[z_index] + + # check if roi index is in bounds + if roi_index >= zslice['count']: + raise IndexError( + f'ROI index {roi_index} out of bounds for z-slice {z_index}' + ) + + # limit grid_size from 1 to 5 + grid_size = max(1, min(grid_size, 5)) + + # get the ROI + if type in ['mean', 'median', 'std', 'roi']: + if grid_size == 1: + if type == 'roi': + data = zslice['rois'][roi_index] + return None if np.isnan(data).any() else data, None + return zslice[type], None + else: + self.get_field_psf(grid_size) + colors = [ + pg.intColor(i, grid_size**2).getRgb() for i in range(grid_size**2) + ] + + roi_size = self.roi_size + size = roi_size * grid_size + + # Create empty arrays for visualization + psf_image = np.zeros((size, size)) + # RGBA for grid overlay + grid_overlay = np.zeros((size, size, 4)) + + for field_idx, field_rois in enumerate(zslice['field']): + if not field_rois: + continue + + field_rois = np.array(field_rois) + + if type == 'mean': + stat = np.nanmean(field_rois, axis=0) + elif type == 'median': + stat = np.nanmedian(field_rois, axis=0) + elif type == 'std': + stat = np.nanstd(field_rois, axis=0) + else: # Single ROI + stat = ( + field_rois[roi_index] + if roi_index < len(field_rois) + else None + ) + + if stat is not None: + y = field_idx // grid_size + x = field_idx % grid_size + psf_image[ + y * roi_size : (y + 1) * roi_size, + x * roi_size : (x + 1) * roi_size, + ] = 2**16 * stat / np.sum(stat) if normalize else stat + + # Add colored overlay for grid visualization + color = colors[field_idx] + grid_overlay[ + y * roi_size : (y + 1) * roi_size, + x * roi_size : (x + 1) * roi_size, + ] = [color[0], color[1], color[2], 128] + return psf_image, grid_overlay + else: + raise ValueError(f'Invalid type {type}') + + def get_longitudinal_slice( + self, + index: int, + type: str = 'mean', + grid_size: int = 1, + sagittal: bool = True, + normalize: bool = False, + ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """ + Get the longitudinal slice for the given index. + + Parameters + ---------- + index : int + The index of the slice + type : str, optional + The type of data to return, by default 'mean' + Options: 'mean', 'median', 'std' + grid_size : int, optional + The grid size for field PSF, by default 1 + sagittal : bool, optional + Whether to get the sagittal slice, or coronal, by default True + normalize : bool, optional + Whether to normalize the data, by default False + + Returns + ------- + tuple[np.ndarray, np.ndarray] + The data array and grid overlay + """ + # check if index is in bounds + if index >= self.roi_size: + raise IndexError(f'Y index {index} out of bounds') + + # limit grid_size from 1 to 5 + grid_size = np.clip(grid_size, 1, 5) + + roi_height, roi_width = self.roi_size, len(self.zslices) + + if type not in ['mean', 'median', 'std']: + raise ValueError(f'Invalid type {type}') + + if grid_size == 1: + data = np.array( + [ + zslice[type][index] if sagittal else zslice[type][:, index] + for zslice in self.zslices + ] + ).T + return data, None + + self.get_field_psf(grid_size) + colors = [pg.intColor(i, grid_size**2).getRgb() for i in range(grid_size**2)] + + height, width = roi_height * grid_size, roi_width * grid_size + + psf_image = np.zeros((height, width)) + grid_overlay = np.zeros((height, width, 4), dtype=np.uint8) + + stat_func = {'mean': np.nanmean, 'median': np.nanmedian, 'std': np.nanstd}[type] + + grid_painted = [] + for i, zslice in enumerate(self.zslices): + for field_idx, field_rois in enumerate(zslice['field']): + if not field_rois: + continue + + field_rois = ( + np.array(field_rois)[:, index, :] + if sagittal + else np.array(field_rois)[..., index] + ) + stat = stat_func(field_rois, axis=0) + + if stat is not None: + y, x = divmod(field_idx, grid_size) + psf_image[ + y * roi_height : (y + 1) * roi_height, x * roi_width + i + ] = stat + + if field_idx not in grid_painted: + color = colors[field_idx] + grid_overlay[ + y * roi_height : (y + 1) * roi_height, + x * roi_width : (x + 1) * roi_width, + ] = [*color[:3], 128] + grid_painted.append(field_idx) + + return psf_image, grid_overlay + + def get_x_slice( + self, x_index: int, type: str = 'mean', grid_size: int = 1 + ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """ + Get the YZ slice for the given x-index. + + Parameters + ---------- + x_index : int + The x-index of the slice + type : str, optional + The type of data to return, by default 'mean' + Options: 'mean', 'median', 'std' + grid_size : int, optional + The grid size for field PSF, by default 1 + + Returns + ------- + tuple[np.ndarray, np.ndarray] + The data array and grid overlay + """ + return self.get_longitudinal_slice( + x_index, type=type, grid_size=grid_size, sagittal=False + ) + + def get_y_slice( + self, y_index: int, type: str = 'mean', grid_size: int = 1 + ) -> tuple[Optional[np.ndarray], Optional[np.ndarray]]: + """ + Get the XZ slice for the given y-index. + + Parameters + ---------- + y_index : int + The y-index of the slice + type : str, optional + The type of data to return, by default 'mean' + Options: 'mean', 'median', 'std' + grid_size : int, optional + The grid size for field PSF, by default 1 + + Returns + ------- + tuple[np.ndarray, np.ndarray] + The data array and grid overlay + """ + return self.get_longitudinal_slice( + y_index, type=type, grid_size=grid_size, sagittal=True + ) + + def get_volume(self, type: str = 'mean'): + """ + Get the 3D volume for the given type. + + Parameters + ---------- + type : str, optional + The type of data to return, by default 'mean' + + Options: + 'mean' - Mean intensity + + 'median' - Median intensity + + 'std' - Standard deviation of intensity + + Returns + ------- + np.ndarray + The 3D volume data + """ + if type not in ['mean', 'median', 'std']: + raise ValueError(f'Invalid type {type}') + + volume_data = np.zeros((len(self), self.roi_size, self.roi_size)) + + for i, z_slice in enumerate(self.zslices): + volume_data[i] = ( + z_slice[type] + if z_slice[type] is not None + else np.zeros((self.roi_size, self.roi_size)) + ) + + return volume_data + + def get_ratio(self) -> float: + ''' + Get the ratio of Z step to lateral pixel size. + + Returns + ------- + float + The ratio of Z step to lateral pixel size + ''' + return self.z_step * self.upsample / self.pixel_size + + def get_intensity_stats(self): + ''' + Get the intensity statistics for all z-slices. + + Returns + ------- + _indices, _mean, _median, _std: tuple[np.ndarray, list, list, list] + The indices, mean, median, and standard deviation of the intensity values + ''' + _indices = (np.arange(len(self)) - self.zero_plane) * self.z_step + _mean = [] + _median = [] + _std = [] + + for z_slice in self.zslices: + if z_slice['rois'] is not None: + valid_rois = z_slice['rois'][ + ~np.isnan(z_slice['rois']).any(axis=(1, 2)) + ] + if len(valid_rois) > 0: + _mean.append(np.mean(valid_rois)) + _median.append(np.median(valid_rois)) + _std.append(np.std(valid_rois)) + else: + _mean.append(np.nan) + _median.append(np.nan) + _std.append(np.nan) + else: + _mean.append(np.nan) + _median.append(np.nan) + _std.append(np.nan) + + return _indices, _mean, _median, _std + + def get_stats(self, selected_stat: str): + """ + Get the statistic data for all z-slices. + + Parameters + ---------- + selected_stat : str + The selected statistic to return + Options: + 'Counts', 'Sigma', 'Sigma (sum)', 'Sigma (diff)', 'Sigma (abs(diff))', + 'Intensity', 'Background' + + Returns + ------- + z_indices, param_stat: tuple[np.ndarray, np.ndarray] + The z-indices and the statistic data for all z-slices + """ + z_indices = (np.arange(len(self)) - self.zero_plane) * self.z_step + header = PARAMETER_HEADERS[self.fitting_method] + + param_stat = [] + if selected_stat == 'Sigma': + if 'sigmax' in header: + param_stat.append([]) + if 'sigmay' in header: + param_stat.append([]) + + for z_slice in self.zslices: + if z_slice['rois'] is not None: + valid_rois = z_slice['rois'][ + ~np.isnan(z_slice['rois']).any(axis=(1, 2)) + ] + if selected_stat == 'Counts': + param_stat.append(len(valid_rois)) + elif len(valid_rois) > 0: + if 'Sigma' in selected_stat: + sigma_x = sigma_y = 0 + if 'sigmax' in header: + sigma_x = np.mean( + z_slice['params'][:, header.index('sigmax')] + ) + if 'sigmay' in header: + sigma_y = np.mean( + z_slice['params'][:, header.index('sigmay')] + ) + if selected_stat == 'Sigma': + param_stat[0].append(sigma_x) + param_stat[1].append(sigma_y) + elif selected_stat == 'Sigma (sum)': + param_stat.append(sigma_x + sigma_y) + elif selected_stat == 'Sigma (diff)': + param_stat.append(sigma_x - sigma_y) + elif selected_stat == 'Sigma (abs(diff))': + param_stat.append(abs(sigma_x - sigma_y)) + else: + param_stat.append(np.nan) + elif selected_stat == 'Intensity': + param_stat.append( + np.mean(z_slice['params'][:, header.index('intensity')]) + ) + elif selected_stat == 'Background': + param_stat.append( + np.mean(z_slice['params'][:, header.index('background')]) + ) + else: + if selected_stat == 'Sigma': + if 'sigmax' in header: + param_stat[0].append(np.nan) + if 'sigmay' in header: + param_stat[1].append(np.nan) + else: + param_stat.append(np.nan) + else: + param_stat.append(np.nan) + + return z_indices, np.array(param_stat) + + def adjust_zero_plane( + self, selected_stat: str, method: str, region: tuple[int, int] + ): + """ + Adjust the zero plane based on the selected statistic and method. + + Parameters + ---------- + selected_stat : str + The selected statistic to use for zero plane adjustment + + Options: + + 'Counts', 'Sigma', 'Sigma (sum)', 'Sigma (diff)', 'Sigma (abs(diff))', + 'Intensity', 'Background' + method : str + The method to use for zero plane adjustment + + Options: 'Peak', 'Valley', 'Gaussian Fit', 'Gaussian Fit (Inverted)', + 'Manual' + region : tuple[int, int] + The region to use for zero plane adjustment + + Returns + ------- + int + The new zero plane + """ + if selected_stat == 'Sigma': + selected_stat = 'Sigma (sum)' + + start, end = map( + int, + [v / self.z_step + self.zero_plane for v in region], + ) + + # Store the old zero plane for potential restoration + self.old_zero_plane = self.zero_plane + + _, stat_data = self.get_stats(selected_stat) + + # Slice the data according to the specified range + x = np.arange(start, end) + y = stat_data[start:end] + + if method == 'Peak': + # Find peaks + peaks, _ = find_peaks(y) + if len(peaks) > 0: + # Get peak prominences + prominences = peak_prominences(y, peaks)[0] + # Select the most prominent peak + max_peak = peaks[np.argmax(prominences)] + new_zero_plane = start + max_peak + else: + new_zero_plane = start + np.argmax(y) + elif method == 'Valley': + # Invert the data to find valleys as peaks + inverted_y = -y + valleys, _ = find_peaks(inverted_y) + if len(valleys) > 0: + # Get valley prominences + prominences = peak_prominences(inverted_y, valleys)[0] + # Select the most prominent valley + max_valley = valleys[np.argmax(prominences)] + new_zero_plane = start + max_valley + else: + new_zero_plane = start + np.argmin(y) + elif 'Gaussian Fit' in method: + if 'Inverted' in method: + y = -y + try: + # Initial guess for Gaussian parameters + a_init = np.max(y) - np.min(y) + x0_init = x[np.argmax(y)] + sigma_init = (end - start) / 4 + offset_init = np.min(y) + + # Perform Gaussian fit + popt, _ = curve_fit( + gaussian, x, y, p0=[a_init, x0_init, sigma_init, offset_init] + ) + new_zero_plane = int(popt[1]) # x0 is the center of the Gaussian + except Exception: + # Fallback to original plane if fitting fails + new_zero_plane = self.zero_plane + else: + return self.zero_plane # Keep current zero plane for 'Manual' method + + # Update the zero plane + self.zero_plane = new_zero_plane + + return self.zero_plane + def get_consistent_rois(self) -> 'PSFdata': ''' Creates a new PSFdata object with consistent ROIs across all z-slices. diff --git a/src/microEye/analysis/fitting/pyfit3Dcspline/CPU/CPUmleFit_LM.py b/src/microEye/analysis/fitting/pyfit3Dcspline/CPU/CPUmleFit_LM.py index 3d7af20..130bfe2 100644 --- a/src/microEye/analysis/fitting/pyfit3Dcspline/CPU/CPUmleFit_LM.py +++ b/src/microEye/analysis/fitting/pyfit3Dcspline/CPU/CPUmleFit_LM.py @@ -844,7 +844,7 @@ def kernel_MLEFit_LM_sigmaxy(d_data, PSFSigma, sz, iterations, d_varim=None): oldTheta[:] = newTheta[:] - for _ in range(iterations): + for kk in range(iterations): # noqa: B007 # Compute Jacobian and Hessian newErr = 0 jacobian.fill(0) diff --git a/src/microEye/analysis/fitting/tardis.py b/src/microEye/analysis/fitting/tardis.py index f67cbf8..1201a72 100644 --- a/src/microEye/analysis/fitting/tardis.py +++ b/src/microEye/analysis/fitting/tardis.py @@ -65,7 +65,7 @@ def __init__( self.bins = QtWidgets.QSpinBox() self.bins.setMinimum(5) - self.bins.setMaximum(1e4) + self.bins.setMaximum(10000) self.bins.setValue(1200) self.tardis_lay.addRow( @@ -74,7 +74,7 @@ def __init__( self.maxDist = QtWidgets.QSpinBox() self.maxDist.setMinimum(5) - self.maxDist.setMaximum(1e4) + self.maxDist.setMaximum(10000) self.maxDist.setValue(1200) self.tardis_lay.addRow( diff --git a/src/microEye/analysis/viewer/images.py b/src/microEye/analysis/viewer/images.py index 2e5ddf9..7e7d7af 100644 --- a/src/microEye/analysis/viewer/images.py +++ b/src/microEye/analysis/viewer/images.py @@ -757,7 +757,7 @@ def extraction_protocol(self, filename: str, **kwargs): params, crlbs, loglike, - method, + method.value, self.get_param(Parameters.PIXEL_SIZE).value(), self.get_param(Parameters.PSF_ZSTEP).value(), self.get_param(Parameters.ROI_SIZE).value(), diff --git a/src/microEye/analysis/viewer/psf.py b/src/microEye/analysis/viewer/psf.py index 0f4d114..867e7d4 100644 --- a/src/microEye/analysis/viewer/psf.py +++ b/src/microEye/analysis/viewer/psf.py @@ -5,8 +5,6 @@ import pyqtgraph as pg import pyqtgraph.opengl as gl from pyqtgraph import functions as fn -from scipy.optimize import curve_fit -from scipy.signal import find_peaks, peak_prominences from microEye.analysis.fitting.psf import PSFdata from microEye.analysis.fitting.results import PARAMETER_HEADERS, FittingMethod @@ -46,6 +44,9 @@ def __init__(self, psf_data: Union['PSFdata', str] = None): self.view_tabs = QtWidgets.QTabWidget() self.main_layout.addWidget(self.view_tabs) + # Default view mode + self.view_mode = 'XY' + # Setup different view tabs self.setup_merged_view_tab() self.setup_3d_view_tab() @@ -64,15 +65,15 @@ def setup_merged_view_tab(self): visual_layout = QtWidgets.QVBoxLayout() self.image_widget = pg.GraphicsLayoutWidget() - self.view_box = self.image_widget.addViewBox(row=0, col=0) + self.view_box: pg.ViewBox = self.image_widget.addViewBox(row=0, col=0) self.view_box.setAspectLocked(True) self.view_box.setAutoVisible(True) self.view_box.enableAutoRange() self.view_box.invertY(True) # Create two image items: one for PSF data and one for grid overlay - self.psf_image_item = pg.ImageItem() - self.grid_overlay_item = pg.ImageItem() + self.psf_image_item: pg.ImageItem = pg.ImageItem(axisOrder='row-major') + self.grid_overlay_item: pg.ImageItem = pg.ImageItem(axisOrder='row-major') self.view_box.addItem(self.psf_image_item) self.view_box.addItem(self.grid_overlay_item) @@ -86,27 +87,28 @@ def setup_merged_view_tab(self): visual_layout.addWidget(self.image_widget) - # Z-slice selection - z_slice_layout = QtWidgets.QHBoxLayout() - self.z_slice_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) - self.z_slice_spinbox = QtWidgets.QSpinBox() - self.z_slice_slider.setMinimum(0) - self.z_slice_spinbox.setMinimum(0) - self.z_slice_spinbox.setMinimumWidth(75) + # Modify Z-slice selection to be more generic + slice_layout = QtWidgets.QHBoxLayout() + self.slice_label = QtWidgets.QLabel('Z-Slice:') + self.slice_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) + self.slice_spinbox = QtWidgets.QSpinBox() + self.slice_slider.setMinimum(0) + self.slice_spinbox.setMinimum(0) + self.slice_spinbox.setMinimumWidth(75) - max_z = len(self.psf_data) - 1 - self.z_slice_slider.setMaximum(max_z) - self.z_slice_spinbox.setMaximum(max_z) + max_slice = len(self.psf_data) - 1 + self.slice_slider.setMaximum(max_slice) + self.slice_spinbox.setMaximum(max_slice) - self.z_slice_slider.valueChanged.connect(self.z_slice_spinbox.setValue) - self.z_slice_spinbox.valueChanged.connect(self.z_slice_slider.setValue) - self.z_slice_slider.valueChanged.connect(self.update_merged_view) + self.slice_slider.valueChanged.connect(self.slice_spinbox.setValue) + self.slice_spinbox.valueChanged.connect(self.slice_slider.setValue) + self.slice_slider.valueChanged.connect(self.update_merged_view) - z_slice_layout.addWidget(QtWidgets.QLabel('Z-Slice:')) - z_slice_layout.addWidget(self.z_slice_slider) - z_slice_layout.addWidget(self.z_slice_spinbox) + slice_layout.addWidget(self.slice_label) + slice_layout.addWidget(self.slice_slider) + slice_layout.addWidget(self.slice_spinbox) - visual_layout.addLayout(z_slice_layout) + visual_layout.addLayout(slice_layout) merged_layout.addLayout(visual_layout, stretch=2) @@ -128,7 +130,7 @@ def setup_merged_view_tab(self): grid_layout.addRow('Grid Size:', self.grid_size) # Grid overlay opacity - self.grid_opacity = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.grid_opacity = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.grid_opacity.setMinimum(0) self.grid_opacity.setMaximum(100) self.grid_opacity.setValue(50) @@ -142,6 +144,13 @@ def setup_merged_view_tab(self): display_group = QtWidgets.QGroupBox('Display Options') display_layout = QtWidgets.QVBoxLayout() + # Add view mode selector + display_layout.addWidget(QtWidgets.QLabel('View Mode:')) + self.view_mode_combo = QtWidgets.QComboBox() + self.view_mode_combo.addItems(['XY', 'XZ', 'YZ']) + self.view_mode_combo.currentTextChanged.connect(self.change_view_mode) + display_layout.addWidget(self.view_mode_combo) + self.display_mean = QtWidgets.QRadioButton('Mean') self.display_median = QtWidgets.QRadioButton('Median') self.display_std = QtWidgets.QRadioButton('Standard Deviation') @@ -159,7 +168,7 @@ def setup_merged_view_tab(self): # Add Single ROI selection single_roi_layout = QtWidgets.QHBoxLayout() - self.single_roi_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.single_roi_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.single_roi_spinbox = QtWidgets.QSpinBox() self.single_roi_slider.setMinimum(0) @@ -199,7 +208,11 @@ def setup_merged_view_tab(self): self.auto_level_checkbox = QtWidgets.QCheckBox('Auto Level') self.auto_level_checkbox.setChecked(True) self.auto_level_checkbox.stateChanged.connect(self.update_merged_view) + self.normalization_checkbox = QtWidgets.QCheckBox('Normalize (Field)') + self.normalization_checkbox.setChecked(True) + self.normalization_checkbox.stateChanged.connect(self.update_merged_view) auto_level_layout.addWidget(self.auto_level_checkbox) + auto_level_layout.addWidget(self.normalization_checkbox) auto_level_group.setLayout(auto_level_layout) controls_layout.addWidget(auto_level_group) @@ -216,9 +229,7 @@ def setup_merged_view_tab(self): self.refresh_btn = QtWidgets.QPushButton('Refresh View') self.refresh_btn.clicked.connect(self.update_merged_view) self.zero_btn = QtWidgets.QPushButton(f'Zero Plane {self.psf_data.zero_plane}') - self.zero_btn.clicked.connect( - lambda: self.z_slice_spinbox.setValue(self.psf_data.zero_plane) - ) + self.zero_btn.clicked.connect(self.update_slice_controls) controls_layout.addWidget(self.refresh_btn) controls_layout.addWidget(self.zero_btn) @@ -262,6 +273,7 @@ def setup_statistics_tab(self): 'Sigma', 'Sigma (sum)', 'Sigma (diff)', + 'Sigma (abs(diff))', 'Intensity', 'Background', ] @@ -374,7 +386,22 @@ def setup_3d_view_tab(self): type_group = QtWidgets.QGroupBox('Display Type') type_layout = QtWidgets.QVBoxLayout() self.view_3d_type = QtWidgets.QComboBox() - self.view_3d_type.addItems(['Mean', 'Median', 'Standard Deviation']) + display_types = [ + { + 'name': 'Mean', + 'value': 'mean', + }, + { + 'name': 'Median', + 'value': 'median', + }, + { + 'name': 'Standard Deviation', + 'value': 'std', + }, + ] + for t in display_types: + self.view_3d_type.addItem(t['name'], t['value']) self.view_3d_type.currentTextChanged.connect(self.update_3d_view) type_layout.addWidget(self.view_3d_type) type_group.setLayout(type_layout) @@ -383,7 +410,7 @@ def setup_3d_view_tab(self): # Opacity control opacity_group = QtWidgets.QGroupBox('Opacity') opacity_layout = QtWidgets.QVBoxLayout() - self.opacity_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.opacity_slider = QtWidgets.QSlider(QtCore.Qt.Orientation.Horizontal) self.opacity_slider.setMinimum(0) self.opacity_slider.setMaximum(100) self.opacity_slider.setValue(10) @@ -418,92 +445,41 @@ def set_3d_view(self, args: tuple[int]): self.gl_widget.opts['distance'] = 180 self.gl_widget.update() + def get_display_type(self) -> str: + '''Get the display type for the merged view.''' + if self.display_mean.isChecked(): + return 'mean' + elif self.display_median.isChecked(): + return 'median' + elif self.display_std.isChecked(): + return 'std' + else: + return 'roi' + def update_merged_view(self): - '''Update the merged slice/field visualization based on current settings.''' if self.psf_data is None: return - z_index = self.z_slice_slider.value() + slice_index = self.slice_slider.value() grid_size = self.grid_size.value() + # get display type (mean, median ... etc) + display_type = self.get_display_type() + norm = self.normalization_checkbox.isChecked() - if z_index >= len(self.psf_data): - return + if self.view_mode == 'XY': + roi_index = self.single_roi_slider.value() - # Get field PSF data if needed - if grid_size > 1: - self.psf_data.get_field_psf(grid_size) - - current_slice = self.psf_data.zslices[z_index] - roi_size = self.psf_data.roi_size - size = roi_size * grid_size - - # Update single ROI slider/spinbox maximum - max_rois = (current_slice['count'] - 1) if current_slice['count'] > 0 else 0 - self.single_roi_slider.setMaximum(max_rois) - self.single_roi_spinbox.setMaximum(max_rois) - - # Create empty arrays for visualization - psf_image = np.zeros((size, size)) - grid_overlay = np.zeros((size, size, 4)) # RGBA for grid overlay - - if grid_size == 1: - # Handle single grid case (original slice view) - if self.display_mean.isChecked(): - display_data = current_slice['mean'] - elif self.display_median.isChecked(): - display_data = current_slice['median'] - elif self.display_std.isChecked(): - display_data = current_slice['std'] - else: # Single ROI - if current_slice['rois'] is not None and current_slice['count'] > 0: - roi_index = self.single_roi_slider.value() - if roi_index < len(current_slice['rois']): - display_data = current_slice['rois'][roi_index] - else: - display_data = None - else: - display_data = None - - if display_data is not None and not np.isnan(display_data).all(): - psf_image = display_data - else: - # Handle multiple grid case (field view) - colors = [ - pg.intColor(i, grid_size**2).getRgb() for i in range(grid_size**2) - ] - - for field_idx, field_rois in enumerate(current_slice['field']): - if not field_rois: - continue - - field_rois = np.array(field_rois) - - if self.display_mean.isChecked(): - stat = np.nanmean(field_rois, axis=0) - elif self.display_median.isChecked(): - stat = np.nanmedian(field_rois, axis=0) - elif self.display_std.isChecked(): - stat = np.nanstd(field_rois, axis=0) - else: # Single ROI - roi_index = self.single_roi_slider.value() - stat = ( - field_rois[roi_index] if roi_index < len(field_rois) else None - ) - - if stat is not None: - y = field_idx // grid_size - x = field_idx % grid_size - psf_image[ - y * roi_size : (y + 1) * roi_size, - x * roi_size : (x + 1) * roi_size, - ] = stat - - # Add colored overlay for grid visualization - color = colors[field_idx] - grid_overlay[ - y * roi_size : (y + 1) * roi_size, - x * roi_size : (x + 1) * roi_size, - ] = [color[0], color[1], color[2], 128] + psf_image, grid_overlay = self.psf_data.get_z_slice( + slice_index, display_type, roi_index, grid_size, norm + ) + elif self.view_mode == 'XZ': + psf_image, grid_overlay = self.psf_data.get_y_slice( + slice_index, display_type, grid_size + ) + else: # YZ + psf_image, grid_overlay = self.psf_data.get_x_slice( + slice_index, display_type, grid_size + ) # Update the visualization if self.auto_level_checkbox.isChecked(): @@ -511,12 +487,20 @@ def update_merged_view(self): else: self.psf_image_item.setImage(psf_image, autoLevels=False) - self.grid_overlay_item.setImage(grid_overlay) - self.update_grid_opacity() + if grid_overlay is not None: + self.grid_overlay_item.setImage(grid_overlay) + self.update_grid_opacity() + else: + self.grid_overlay_item.clear() # Update metadata self.update_metadata() + def change_view_mode(self, mode): + self.view_mode = mode + self.update_slice_controls() + self.update_merged_view() + def update_metadata(self): '''Update the metadata display in the PSF view tab.''' if self.psf_data is None: @@ -566,30 +550,8 @@ def update_3d_view(self): zero = self.psf_data.zero_plane # Prepare volumetric data based on selected type - view_type = self.view_3d_type.currentText() - volume_data = np.zeros( - (len(self.psf_data), self.psf_data.roi_size, self.psf_data.roi_size) - ) - - for i, z_slice in enumerate(self.psf_data.zslices): - if view_type == 'Mean': - volume_data[i] = ( - z_slice['mean'] - if z_slice['mean'] is not None - else np.zeros(self.psf_data.dim) - ) - elif view_type == 'Median': - volume_data[i] = ( - z_slice['median'] - if z_slice['median'] is not None - else np.zeros(self.psf_data.dim) - ) - elif view_type == 'Standard Deviation': - volume_data[i] = ( - z_slice['std'] - if z_slice['std'] is not None - else np.zeros(self.psf_data.dim) - ) + view_type = self.view_3d_type.currentData() + volume_data = self.psf_data.get_volume(view_type) # Normalize data if np.all(volume_data == 0): @@ -615,12 +577,20 @@ def update_3d_view(self): opacity = self.opacity_slider.value() / 100.0 d2[..., 3] = (normalized_data * 255 * opacity).astype(np.ubyte) + # Set scaling ratios for X and Y axes + x_ratio = y_ratio = 1 # X and Y are the reference, Z scaled + # z_ratio + z_ratio = self.psf_data.get_ratio() + # Create and add volume item v = gl.GLVolumeItem(d2) + + v.scale(z_ratio, y_ratio, x_ratio) # Apply scaling in (z, y, x) order + v.translate( - -zero, - -volume_data.shape[1] // 2, - -volume_data.shape[2] // 2, + -zero * z_ratio, + -volume_data.shape[1] // 2 * y_ratio, + -volume_data.shape[2] // 2 * x_ratio, ) self.gl_widget.addItem(v) @@ -658,141 +628,24 @@ def update_range_roi(self): def restore_zero_plane(self): if hasattr(self, 'old_zero_plane'): - self.psf_data.zero_plane = self.old_zero_plane + self.psf_data.zero_plane = self.psf_data.old_zero_plane self.update_all_views() - def gaussian(self, x, a, x0, sigma, offset): - return a * np.exp(-((x - x0) ** 2) / (2 * sigma**2)) + offset - def adjust_zero_plane(self): if self.psf_data is None: return method = self.zero_plane_method.currentText() - start, end = map( - int, - [ - v / self.psf_data.z_step + self.psf_data.zero_plane - for v in self.range_roi.getRegion() - ], - ) - - # Store the old zero plane for potential restoration - self.old_zero_plane = self.psf_data.zero_plane - - # Get the current statistic data selected_stat = self.stat_combo.currentText() - stat_data = self.get_statistic_data(selected_stat) - - # Slice the data according to the specified range - x = np.arange(start, end) - y = stat_data[start:end] - - if method == 'Peak': - # Find peaks - peaks, _ = find_peaks(y) - if len(peaks) > 0: - # Get peak prominences - prominences = peak_prominences(y, peaks)[0] - # Select the most prominent peak - max_peak = peaks[np.argmax(prominences)] - new_zero_plane = start + max_peak - else: - new_zero_plane = start + np.argmax(y) - elif method == 'Valley': - # Invert the data to find valleys as peaks - inverted_y = -y - valleys, _ = find_peaks(inverted_y) - if len(valleys) > 0: - # Get valley prominences - prominences = peak_prominences(inverted_y, valleys)[0] - # Select the most prominent valley - max_valley = valleys[np.argmax(prominences)] - new_zero_plane = start + max_valley - else: - new_zero_plane = start + np.argmin(y) - elif 'Gaussian Fit' in method: - if 'Inverted' in method: - y = -y - try: - # Initial guess for Gaussian parameters - a_init = np.max(y) - np.min(y) - x0_init = x[np.argmax(y)] - sigma_init = (end - start) / 4 - offset_init = np.min(y) - - # Perform Gaussian fit - popt, _ = curve_fit( - self.gaussian, x, y, p0=[a_init, x0_init, sigma_init, offset_init] - ) - new_zero_plane = int(popt[1]) # x0 is the center of the Gaussian - except Exception: - # Fallback to original plane if fitting fails - new_zero_plane = self.psf_data.zero_plane - else: - return # Keep current zero plane for 'Manual' method - # Update the zero plane - self.psf_data.zero_plane = new_zero_plane + self.psf_data.adjust_zero_plane( + selected_stat, method, self.range_roi.getRegion() + ) # Update the plots self.update_roi_statistics() self.update_intensity_statistics() - def get_statistic_data(self, selected_stat): - stat_data = [] - header = PARAMETER_HEADERS[self.psf_data.fitting_method] - - for z_slice in self.psf_data: - if z_slice['rois'] is not None and len(z_slice['rois']) > 0: - valid_rois = z_slice['rois'][ - ~np.isnan(z_slice['rois']).any(axis=(1, 2)) - ] - if len(valid_rois) > 0: - if selected_stat == 'Counts': - stat_data.append(len(valid_rois)) - elif selected_stat == 'Sigma': - if 'sigmax' in header and 'sigmay' in header: - sigma_x = np.mean( - z_slice['params'][:, header.index('sigmax')] - ) - sigma_y = np.mean( - z_slice['params'][:, header.index('sigmay')] - ) - stat_data.append(np.mean([sigma_x, sigma_y])) - elif selected_stat == 'Sigma (sum)': - if 'sigmax' in header and 'sigmay' in header: - sigma_x = np.sum( - z_slice['params'][:, header.index('sigmax')] - ) - sigma_y = np.sum( - z_slice['params'][:, header.index('sigmay')] - ) - stat_data.append(np.sum([sigma_x, sigma_y])) - elif selected_stat == 'Sigma (diff)': - if 'sigmax' in header and 'sigmay' in header: - sigma_x = np.mean( - z_slice['params'][:, header.index('sigmax')] - ) - sigma_y = np.mean( - z_slice['params'][:, header.index('sigmay')] - ) - stat_data.append(abs(sigma_x - sigma_y)) - elif selected_stat == 'Intensity': - stat_data.append( - np.mean(z_slice['params'][:, header.index('intensity')]) - ) - elif selected_stat == 'Background': - stat_data.append( - np.mean(z_slice['params'][:, header.index('background')]) - ) - else: - stat_data.append(np.nan) - else: - stat_data.append(np.nan) - - return np.array(stat_data) - def update_roi_statistics(self): '''Update ROI statistics plot.''' if self.psf_data is None: @@ -801,87 +654,18 @@ def update_roi_statistics(self): self.roi_plot.clear() selected_stat = self.stat_combo.currentText() - z_indices = ( - np.arange(len(self.psf_data)) - self.psf_data.zero_plane - ) * self.psf_data.z_step - header = PARAMETER_HEADERS[self.psf_data.fitting_method] - - mean_param = [] - if selected_stat == 'Sigma': - if 'sigmax' in header: - mean_param.append([]) - if 'sigmay' in header: - mean_param.append([]) - - for z_slice in self.psf_data: - if z_slice['rois'] is not None: - valid_rois = z_slice['rois'][ - ~np.isnan(z_slice['rois']).any(axis=(1, 2)) - ] - if selected_stat == 'Counts': - mean_param.append(len(valid_rois)) - elif len(valid_rois) > 0: - if selected_stat == 'Sigma': - if 'sigmax' in header: - mean_param[0].append( - np.mean(z_slice['params'][:, header.index('sigmax')]) - * self.psf_data.pixel_size - ) - if 'sigmay' in header: - mean_param[1].append( - np.mean(z_slice['params'][:, header.index('sigmay')]) - * self.psf_data.pixel_size - ) - elif selected_stat == 'Sigma (sum)': - sum_sigma = 0 - if 'sigmax' in header: - sum_sigma += ( - np.mean(z_slice['params'][:, header.index('sigmax')]) - * self.psf_data.pixel_size - ) - if 'sigmay' in header: - sum_sigma += ( - np.mean(z_slice['params'][:, header.index('sigmay')]) - * self.psf_data.pixel_size - ) - mean_param.append(sum_sigma) - elif selected_stat == 'Sigma (diff)': - sigma_x = ( - np.mean(z_slice['params'][:, header.index('sigmax')]) - * self.psf_data.pixel_size - ) - sigma_y = ( - np.mean(z_slice['params'][:, header.index('sigmay')]) - * self.psf_data.pixel_size - ) - mean_param.append(abs(sigma_x - sigma_y)) - elif selected_stat == 'Intensity': - mean_param.append( - np.mean(z_slice['params'][:, header.index('intensity')]) - ) - elif selected_stat == 'Background': - mean_param.append( - np.mean(z_slice['params'][:, header.index('background')]) - ) - else: - if selected_stat == 'Sigma': - if 'sigmax' in header: - mean_param[0].append(np.nan) - if 'sigmay' in header: - mean_param[1].append(np.nan) - else: - mean_param.append(np.nan) - else: - mean_param.append(np.nan) + z_indices, param_stat = self.psf_data.get_stats(selected_stat) # Plot selected statistic if selected_stat == 'Sigma': - if 'sigmax' in header: - self.roi_plot.plot(z_indices, mean_param[0], pen='b', name='Sigma X') - if 'sigmay' in header: - self.roi_plot.plot(z_indices, mean_param[1], pen='r', name='Sigma Y') + if 1 <= len(param_stat) <= 2: + self.roi_plot.plot(z_indices, param_stat[0], pen='b', name='Sigma X') + if len(param_stat) == 2: + self.roi_plot.plot( + z_indices, param_stat[1], pen='r', name='Sigma Y' + ) else: - self.roi_plot.plot(z_indices, mean_param, pen='b', name=selected_stat) + self.roi_plot.plot(z_indices, param_stat, pen='b', name=selected_stat) # Update zero plane line zero_line_roi = pg.InfiniteLine(pos=0, angle=90, pen='w', movable=False) @@ -911,35 +695,12 @@ def update_intensity_statistics(self): self.intensity_plot.clear() - z_indices = ( - np.arange(len(self.psf_data)) - self.psf_data.zero_plane - ) * self.psf_data.z_step - mean_intensities = [] - median_intensities = [] - std_intensities = [] - - for z_slice in self.psf_data: - if z_slice['rois'] is not None: - valid_rois = z_slice['rois'][ - ~np.isnan(z_slice['rois']).any(axis=(1, 2)) - ] - if len(valid_rois) > 0: - mean_intensities.append(np.mean(valid_rois)) - median_intensities.append(np.median(valid_rois)) - std_intensities.append(np.std(valid_rois)) - else: - mean_intensities.append(np.nan) - median_intensities.append(np.nan) - std_intensities.append(np.nan) - else: - mean_intensities.append(np.nan) - median_intensities.append(np.nan) - std_intensities.append(np.nan) + _indices, _mean, _median, _std = self.psf_data.get_intensity_stats() # Plot intensity statistics - self.intensity_plot.plot(z_indices, mean_intensities, pen='r', name='Mean') - self.intensity_plot.plot(z_indices, median_intensities, pen='g', name='Median') - self.intensity_plot.plot(z_indices, std_intensities, pen='y', name='Std Dev') + self.intensity_plot.plot(_indices, _mean, pen='r', name='Mean') + self.intensity_plot.plot(_indices, _median, pen='g', name='Median') + self.intensity_plot.plot(_indices, _std, pen='y', name='Std Dev') # Update zero plane line zero_line_intensity = pg.InfiniteLine(pos=0, angle=90, pen='w', movable=False) @@ -962,6 +723,34 @@ def update_all_views(self, extra=True): self.update_roi_statistics() self.update_intensity_statistics() + def update_slice_controls(self): + if self.view_mode == 'XY': + self.slice_label.setText('Z-Slice:') + max_slice = len(self.psf_data) - 1 + + # set viewbox aspect ratio + ratio = 1 + zero = self.psf_data.zero_plane + else: + if self.view_mode == 'XZ': + self.slice_label.setText('Y-Slice:') + else: # YZ + self.slice_label.setText('X-Slice:') + + max_slice = self.psf_data.roi_size - 1 + + # set viewbox aspect ratio + ratio = self.psf_data.get_ratio() + zero = max_slice // 2 + + # set viewbox aspect ratio + self.view_box.setAspectLocked(True, ratio) + + self.slice_slider.setMaximum(max_slice) + self.slice_spinbox.setMaximum(max_slice) + self.zero_btn.setText(f'Zero Plane {zero}') + self.slice_slider.setValue(zero) + def set_psf_data(self, psf_data: 'PSFdata'): '''Set new PSF data and update all views. @@ -972,10 +761,7 @@ def set_psf_data(self, psf_data: 'PSFdata'): ''' self.psf_data = psf_data - # Update Z-slice slider and spinbox ranges - max_z = len(psf_data) - 1 - self.z_slice_slider.setMaximum(max_z) - self.z_slice_spinbox.setMaximum(max_z) - + # Update slice controls + self.update_slice_controls() # Update all views self.update_all_views() diff --git a/src/microEye/hardware/cams/micam.py b/src/microEye/hardware/cams/micam.py index 88cd78e..83797d1 100644 --- a/src/microEye/hardware/cams/micam.py +++ b/src/microEye/hardware/cams/micam.py @@ -24,10 +24,18 @@ def __init__(self, Cam_ID=0) -> None: def height(self): return self._height + @height.setter + def height(self, value): + self._height = value + @property def width(self): return self._width + @width.setter + def width(self, value): + self._width = value + def get_temperature(self): return self.temperature diff --git a/src/microEye/hardware/misc/reglo.py b/src/microEye/hardware/misc/reglo.py index 07a0932..6a7f43a 100644 --- a/src/microEye/hardware/misc/reglo.py +++ b/src/microEye/hardware/misc/reglo.py @@ -594,13 +594,13 @@ def LayoutInit(self): self.config_Layout.addLayout(pause_time_layout) self.cycle_label = QtWidgets.QLabel('Number of Cycles: 1') - self.cycle_slider = QtWidgets.QSlider(Qt.Horizontal) + self.cycle_slider = QtWidgets.QSlider(Qt.Orientation.Horizontal) self.cycle_slider.setMinimum(2) self.cycle_slider.setMaximum(50) self.cycle_slider.valueChanged.connect(self.cycles_changed) self.inc_label = QtWidgets.QLabel('Speed Increment: 1') - self.inc_slider = QtWidgets.QSlider(Qt.Horizontal) + self.inc_slider = QtWidgets.QSlider(Qt.Orientation.Horizontal) self.inc_slider.setMinimum(1) self.inc_slider.setMaximum(50) self.inc_slider.valueChanged.connect(self.inc_changed) diff --git a/src/microEye/hardware/port_config.py b/src/microEye/hardware/port_config.py index 99732ce..f4b7805 100644 --- a/src/microEye/hardware/port_config.py +++ b/src/microEye/hardware/port_config.py @@ -30,7 +30,7 @@ def __init__(self, parent=None, title='Serial Port Config.', **kwargs): # dialog buttons buttonBox = QtWidgets.QDialogButtonBox() - buttonBox.setOrientation(Qt.Horizontal) + buttonBox.setOrientation(Qt.Orientation.Horizontal) buttonBox.setStandardButtons( QtWidgets.QDialogButtonBox.Cancel | QtWidgets.QDialogButtonBox.Ok )