/doc/README.md (converted to HTML if markdown available)
+ 3. Fallback placeholder HTML
+ """
+ candidates = self._discover_doc_candidates_for(viewer_widget)
+ idx = candidates.get('index_html')
+ md = candidates.get('readme_md')
+
+ if idx is not None:
+ self.load_html_file(str(idx))
+ return
+
+ if md is not None:
+ try:
+ md_text = Path(md).read_text(encoding='utf-8')
+ except Exception:
+ md_text = f"# Documentation\n\nUnable to read file: {md}"
+ if MARKDOWN_AVAILABLE:
+ html = markdown.markdown(md_text, extensions=[
+ 'fenced_code', 'tables', 'toc'
+ ])
+ else:
+ # Minimal conversion: wrap in if markdown not available
+ html = f"{md_text}"
+ # Basic styling
+ styled_html = (
+ "" + html + ""
+ )
+ self.set_content_html(styled_html)
+ return
+
+ # Fallback: show a helpful placeholder
+ viewer_name = getattr(viewer_widget, 'viewer_name', viewer_widget.__class__.__name__)
+ placeholder = f"""
+
+
+
+ {viewer_name} Documentation
+ No documentation found.
+ Create one of the following files next to the viewer module:
+
+ doc/index.html (preferred)
+ doc/README.md (fallback, converted to HTML)
+
+ Tip: Put files under the module's directory, e.g., viewer/workbench/doc/index.html
+
+
+ """
+ self.set_content_html(placeholder)
diff --git a/viewer/hkl_3d_slice_window.py b/viewer/hkl_3d_slice_window.py
new file mode 100644
index 0000000..c9eaef1
--- /dev/null
+++ b/viewer/hkl_3d_slice_window.py
@@ -0,0 +1,1140 @@
+import sys
+import numpy as np
+import pyvista as pyv
+from pyvistaqt import QtInteractor
+from PyQt5 import uic
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import (
+ QApplication,
+ QMainWindow,
+ QFileDialog,
+ QMessageBox,
+ QSizePolicy,
+)
+import pathlib
+sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
+from utils import SizeManager, RSMConverter
+from utils.hdf5_loader import HDF5Loader
+
+
+class HKL3DSliceWindow(QMainWindow):
+ """3D Slice window (point-only viewer).
+
+ Point-only rendering with plane slicing:
+ - No volume/grid interpolation; pure point-cloud rendering
+ - Plane-based slice using a tolerance around the plane
+ - Axes, intensity range sliders, camera presets, colormap selection
+ - Data loading via RSMConverter.load_h5_to_3d
+ - Extract slice saves slice points projected to a 2D slice dataset
+ """
+
+ def __init__(self, parent=None):
+ super(HKL3DSliceWindow, self).__init__()
+ self.parent = parent
+ uic.loadUi('gui/hkl_3d_slice_window.ui', self)
+
+ # Initial UI availability
+ try:
+ self.actionSave.setEnabled(False) # volume-save removed
+ self.actionExtractSlice.setEnabled(False)
+ self._set_slice_controls_enabled(False)
+ except Exception:
+ pass
+ self.setWindowTitle('3D Slice')
+ pyv.set_plot_theme('dark')
+
+ # Hook up controls
+ try:
+ if hasattr(self, 'actionSlice'):
+ self.actionSlice.triggered.connect(lambda: self.open_controls_dialog(focus='slice'))
+ except Exception:
+ pass
+
+ # Toggles and controls (align naming/behavior with viewer/hkl_3d.py)
+ self.cbToggleSlicePointer.clicked.connect(self.toggle_pointer)
+ if hasattr(self, 'cbTogglePoints'):
+ try:
+ self.cbTogglePoints.clicked.connect(self.toggle_cloud_vol)
+ except Exception:
+ pass
+ if hasattr(self, 'cbLockSlice'):
+ try:
+ self.cbLockSlice.clicked.connect(self.toggle_slice_lock)
+ except Exception:
+ pass
+ self.cbColorMapSelect.currentIndexChanged.connect(self.change_color_map)
+ self.sbMinIntensity.editingFinished.connect(self.update_intensity)
+ self.sbMaxIntensity.editingFinished.connect(self.update_intensity)
+
+ # Actions
+ self.actionLoadData.triggered.connect(self.load_data)
+ self.actionExtractSlice.triggered.connect(self.extract_slice)
+
+ # State
+ self.cloud_mesh = None
+ # Actor handles follow naming used in viewer/hkl_3d.py
+ self.points_actor = None # actor for the full cloud (name: "cloud_volume")
+ self.slab = None # extracted slice points
+ self.slab_actor = None # actor for slice points (name: "slab_points")
+ self._plane_widget = None
+ self._slice_locked = False
+ self._plane_normal = None
+ self._plane_origin = None
+ self._slice_lock_text_actor = None
+ self.orig_shape = (0, 0)
+ self.curr_shape = (0, 0)
+ self.num_images = 0
+ self.current_file_path = None
+
+ # Slice/camera state
+ self._slice_translate_step = 0.01
+ self._slice_rotate_step_deg = 1.0
+ self._zoom_step = 1.5
+ self._cam_pos_selection = None
+ self._slice_orientation_selection = None
+ self._custom_normal = [0.0, 0.0, 1.0]
+
+ # LUTs
+ self.lut = pyv.LookupTable(cmap='jet')
+ self.lut.apply_opacity([0, 1])
+ self.lut2 = pyv.LookupTable(cmap='jet')
+ self.lut2.apply_opacity([0, 1])
+
+ # Plotter
+ self.plotter = QtInteractor()
+ try:
+ self.plotter.add_axes(xlabel='H', ylabel='K', zlabel='L', x_color='red', y_color='green', z_color='blue')
+ except Exception:
+ self.plotter.add_axes(xlabel='H', ylabel='K', zlabel='L')
+ self.plotter.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
+ self.plotter.setMinimumSize(300, 300)
+ self.viewer_3d_slicer_layout.addWidget(self.plotter, 1, 1)
+
+ # Wire slice/camera controls from UI
+ if hasattr(self, 'sbSliceTranslateStep'):
+ self.sbSliceTranslateStep.valueChanged.connect(self._on_translate_step_changed)
+ if hasattr(self, 'sbSliceRotateStep'):
+ self.sbSliceRotateStep.valueChanged.connect(self._on_rotate_step_changed)
+ if hasattr(self, 'cbSliceOrientation'):
+ self.cbSliceOrientation.currentIndexChanged.connect(self._on_orientation_changed)
+ if hasattr(self, 'sbNormH'):
+ self.sbNormH.editingFinished.connect(self._on_custom_normal_changed)
+ if hasattr(self, 'sbNormK'):
+ self.sbNormK.editingFinished.connect(self._on_custom_normal_changed)
+ if hasattr(self, 'sbNormL'):
+ self.sbNormL.editingFinished.connect(self._on_custom_normal_changed)
+
+ if hasattr(self, 'btnSliceUpNormal'):
+ self.btnSliceUpNormal.clicked.connect(lambda: self.nudge_along_normal(+1))
+ if hasattr(self, 'btnSliceDownNormal'):
+ self.btnSliceDownNormal.clicked.connect(lambda: self.nudge_along_normal(-1))
+ if hasattr(self, 'btnSlicePosH'):
+ self.btnSlicePosH.clicked.connect(lambda: self.nudge_along_axis('H', +1))
+ if hasattr(self, 'btnSliceNegH'):
+ self.btnSliceNegH.clicked.connect(lambda: self.nudge_along_axis('H', -1))
+ if hasattr(self, 'btnSlicePosK'):
+ self.btnSlicePosK.clicked.connect(lambda: self.nudge_along_axis('K', +1))
+ if hasattr(self, 'btnSliceNegK'):
+ self.btnSliceNegK.clicked.connect(lambda: self.nudge_along_axis('K', -1))
+ if hasattr(self, 'btnSlicePosL'):
+ self.btnSlicePosL.clicked.connect(lambda: self.nudge_along_axis('L', +1))
+ if hasattr(self, 'btnSliceNegL'):
+ self.btnSliceNegL.clicked.connect(lambda: self.nudge_along_axis('L', -1))
+ if hasattr(self, 'btnRotPlusH'):
+ self.btnRotPlusH.clicked.connect(lambda: self.rotate_about_axis('H', +self._slice_rotate_step_deg))
+ if hasattr(self, 'btnRotMinusH'):
+ self.btnRotMinusH.clicked.connect(lambda: self.rotate_about_axis('H', -self._slice_rotate_step_deg))
+ if hasattr(self, 'btnRotPlusK'):
+ self.btnRotPlusK.clicked.connect(lambda: self.rotate_about_axis('K', +self._slice_rotate_step_deg))
+ if hasattr(self, 'btnRotMinusK'):
+ self.btnRotMinusK.clicked.connect(lambda: self.rotate_about_axis('K', -self._slice_rotate_step_deg))
+ if hasattr(self, 'btnRotPlusL'):
+ self.btnRotPlusL.clicked.connect(lambda: self.rotate_about_axis('L', +self._slice_rotate_step_deg))
+ if hasattr(self, 'btnRotMinusL'):
+ self.btnRotMinusL.clicked.connect(lambda: self.rotate_about_axis('L', -self._slice_rotate_step_deg))
+ if hasattr(self, 'btnResetSlice'):
+ self.btnResetSlice.clicked.connect(self._on_reset_slice)
+
+ # Dialogs
+ def open_controls_dialog(self, focus=None):
+ try:
+ if hasattr(self, 'controls_dialog') and self.controls_dialog is not None and self.controls_dialog.isVisible():
+ try:
+ self.controls_dialog.raise_()
+ except Exception:
+ pass
+ try:
+ self.controls_dialog.activateWindow()
+ except Exception:
+ pass
+ if focus == 'camera':
+ try:
+ self.controls_dialog.focus_camera_section()
+ except Exception:
+ pass
+ elif focus == 'slice':
+ try:
+ self.controls_dialog.focus_slice_section()
+ except Exception:
+ pass
+ return
+ except Exception:
+ pass
+ try:
+ from viewer.hkl_controls_dialog import HKLControlsDialog
+ self.controls_dialog = HKLControlsDialog(self)
+ if focus == 'camera':
+ try:
+ self.controls_dialog.focus_camera_section()
+ except Exception:
+ pass
+ elif focus == 'slice':
+ try:
+ self.controls_dialog.focus_slice_section()
+ except Exception:
+ pass
+ self.controls_dialog.show()
+ except Exception:
+ pass
+
+ # Availability
+ def _is_data_loaded(self) -> bool:
+ return bool(self.cloud_mesh is not None)
+
+ def _slice_points_exist(self) -> bool:
+ try:
+ return (self.slab is not None) and (getattr(self.slab, 'n_points', 0) > 0)
+ except Exception:
+ return False
+
+ def _set_slice_controls_enabled(self, enabled: bool):
+ try:
+ for wname in ('gbSteps', 'gbOrientation', 'gbTranslate', 'gbRotate'):
+ w = getattr(self, wname, None)
+ if w:
+ w.setEnabled(bool(enabled))
+ if hasattr(self, 'tabsControls') and hasattr(self, 'tabSlice'):
+ try:
+ idx = self.tabsControls.indexOf(self.tabSlice)
+ self.tabsControls.setTabEnabled(idx, bool(enabled))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _refresh_availability(self):
+ try:
+ data_loaded = self._is_data_loaded()
+ slice_points_exist = self._slice_points_exist()
+ if hasattr(self, 'actionExtractSlice'):
+ self.actionExtractSlice.setEnabled(bool(slice_points_exist))
+ self._set_slice_controls_enabled(bool(data_loaded))
+ except Exception:
+ pass
+
+ # Data setup and create scene
+ def setup_3d_cloud(self, cloud, intensity, shape):
+ if cloud is None or (isinstance(cloud, np.ndarray) and cloud.size == 0):
+ self.cloud_mesh = None
+ return False
+ if isinstance(cloud, np.ndarray):
+ self.cloud_mesh = pyv.PolyData(cloud)
+ self.cloud_mesh['intensity'] = intensity
+ else:
+ self.cloud_mesh = cloud.copy(deep=True) if hasattr(cloud, 'copy') else cloud
+ self.cloud_mesh['intensity'] = intensity
+ self.orig_shape = shape
+ self.curr_shape = shape
+ return True
+
+ def create_3D(self, cloud=None, intensity=None):
+ # Clear previous actors/widgets but keep axes stable
+ try:
+ self.plotter.clear()
+ for name in ("cloud_volume", "slab_points", "origin_sphere", "normal_line"):
+ if name in getattr(self.plotter, 'actors', {}):
+ self.plotter.remove_actor(name, reset_camera=False)
+ except Exception:
+ pass
+
+ # Cloud
+ self.cloud_mesh = pyv.PolyData(cloud)
+ self.cloud_mesh['intensity'] = intensity
+ self.lut.scalar_range = (float(np.min(intensity)), float(np.max(intensity)))
+ self.lut2.scalar_range = (float(np.min(intensity)), float(np.max(intensity)))
+
+ # Points actor (use naming "cloud_volume" like viewer/hkl_3d.py)
+ self.points_actor = self.plotter.add_mesh(
+ self.cloud_mesh,
+ scalars='intensity',
+ cmap=self.lut,
+ point_size=5.0,
+ name='cloud_volume',
+ reset_camera=False,
+ show_edges=False,
+ show_scalar_bar=True,
+ )
+
+ # Bounds/axes
+ try:
+ self.plotter.show_bounds(
+ mesh=self.cloud_mesh,
+ xtitle='H Axis', ytitle='K Axis', ztitle='L Axis',
+ ticks='inside', minor_ticks=True,
+ n_xlabels=7, n_ylabels=7, n_zlabels=7,
+ x_color='red', y_color='green', z_color='blue',
+ font_size=20,
+ )
+ except Exception:
+ try:
+ self.plotter.show_bounds(mesh=self.cloud_mesh, xtitle='H Axis', ytitle='K Axis', ztitle='L Axis')
+ except Exception:
+ pass
+
+ # Plane widget
+ slice_normal = (0, 0, 1)
+ slice_origin = self.cloud_mesh.center
+ self._plane_widget = self.plotter.add_plane_widget(
+ callback=self.on_plane_update,
+ normal=slice_normal,
+ origin=slice_origin,
+ bounds=self.cloud_mesh.bounds,
+ factor=1.0,
+ implicit=True,
+ assign_to_axis=None,
+ tubing=False,
+ origin_translation=True,
+ outline_opacity=0,
+ )
+ # Initialize stored plane state
+ try:
+ self._plane_normal = np.array(slice_normal, dtype=float)
+ self._plane_origin = np.array(slice_origin, dtype=float)
+ except Exception:
+ self._plane_normal = np.array([0.0, 0.0, 1.0], dtype=float)
+ self._plane_origin = np.array(self.cloud_mesh.center, dtype=float) if self.cloud_mesh is not None else np.array([0.0, 0.0, 0.0], dtype=float)
+
+ # Ensure slice lock overlay exists and is hidden initially
+ try:
+ self._ensure_slice_lock_text_actor()
+ if self._slice_lock_text_actor is not None:
+ self._slice_lock_text_actor.SetVisibility(False)
+ except Exception:
+ pass
+
+ # Labels and sliders
+ try:
+ self.lbCurrentPointSizeNum.setText(str(len(cloud)))
+ self.lbCurrentResolutionX.setText(str(self.curr_shape[0]))
+ self.lbCurrentResolutionY.setText(str(self.curr_shape[1]))
+ except Exception:
+ pass
+ try:
+ imin, imax = int(np.min(intensity)), int(np.max(intensity))
+ self.sbMinIntensity.setRange(imin, imax)
+ self.sbMinIntensity.setValue(imin)
+ self.sbMaxIntensity.setRange(imin, imax)
+ self.sbMaxIntensity.setValue(imax)
+ except Exception:
+ pass
+
+ self.update_intensity()
+ try:
+ self.update_info_slice_labels()
+ self._refresh_availability()
+ except Exception:
+ pass
+
+ # Camera
+ try:
+ self.plotter.set_focus(self.cloud_mesh.center)
+ self.plotter.reset_camera()
+ except Exception:
+ pass
+
+ def _remove_plane_widget(self):
+ """Safely remove existing plane widget (if any)."""
+ try:
+ if self._plane_widget is not None:
+ try:
+ self._plane_widget.EnabledOff()
+ except Exception:
+ pass
+ try:
+ self.plotter.clear_plane_widgets()
+ except Exception:
+ pass
+ self._plane_widget = None
+ except Exception:
+ pass
+
+ # Loading
+ def load_data(self):
+ file_name, _ = QFileDialog.getOpenFileName(self, 'Select an HDF5 File', '', 'HDF5 Files (*.h5 *.hdf5);;All Files (*)')
+ if not file_name:
+ try:
+ QMessageBox.warning(self, 'File', 'No Valid File Selected')
+ except Exception:
+ pass
+ return
+ self.current_file_path = file_name
+ # reflect file path in UI if present
+ try:
+ if hasattr(self, 'leFilePathStr') and self.leFilePathStr is not None:
+ self.leFilePathStr.setText(file_name)
+ except Exception:
+ pass
+
+ original_title = self.windowTitle()
+ self.setEnabled(False)
+ self.setWindowTitle(f"{original_title} ***** Loading...")
+ QApplication.setOverrideCursor(Qt.WaitCursor)
+ QApplication.processEvents()
+
+ # Hard reset interactive widgets and prior actors
+ self._remove_plane_widget()
+ try:
+ for name in ("cloud_volume", "slab_points", "origin_sphere", "normal_line"):
+ if name in getattr(self.plotter, 'actors', {}):
+ self.plotter.remove_actor(name, reset_camera=False)
+ except Exception:
+ pass
+ try:
+ conv = RSMConverter()
+ points, intensities, num_images, shape = conv.load_h5_to_3d(file_name)
+ if points.size == 0 or intensities.size == 0:
+ QMessageBox.warning(self, 'Loading Warning', 'No valid point data found in HDF5 file')
+ return
+ if self.setup_3d_cloud(points, intensities, shape):
+ self.num_images = num_images
+ self.create_3D(cloud=points, intensity=intensities)
+ try:
+ self.groupBox3DViewer.setTitle(f'Viewing {num_images} Image(s)')
+ self.lbOriginalPointSizeNum.setText(str(len(points)))
+ self.lbOriginalResolutionX.setText(str(shape[0]))
+ self.lbOriginalResolutionY.setText(str(shape[1]))
+ # reflect current shape
+ self.curr_shape = shape
+ except Exception:
+ pass
+ try:
+ self.update_info_slice_labels()
+ self._refresh_availability()
+ except Exception:
+ pass
+ except Exception as e:
+ import traceback
+ error_msg = f"Error loading data: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
+ try:
+ QMessageBox.critical(self, 'Error Loading Data', error_msg)
+ except Exception:
+ pass
+ finally:
+ QApplication.restoreOverrideCursor()
+ self.setEnabled(True)
+ self.setWindowTitle(original_title)
+
+ # Slice points extraction
+ def on_plane_update(self, normal, origin):
+ # If slice is locked, immediately restore widget to stored state and do not update slice
+ try:
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ except Exception:
+ pass
+ if self.cloud_mesh is None:
+ return
+ normal = self.normalize_vector(np.array(normal, dtype=float))
+ origin = np.array(origin, dtype=float)
+
+ # Compute slice mask
+ vec = self.cloud_mesh.points - origin
+ dist = np.dot(vec, normal)
+ thickness = 0.002
+ mask = np.abs(dist) < thickness
+
+ # Extract masked points
+ try:
+ self.slab = self.cloud_mesh.extract_points(mask)
+ except Exception:
+ self.slab = None
+
+ # Remove previous slice points actor if present
+ try:
+ if 'slab_points' in getattr(self.plotter, 'actors', {}):
+ self.plotter.remove_actor('slab_points', reset_camera=False)
+ except Exception:
+ pass
+
+ # Add new slice points actor (name: slab_points)
+ if self.slab is not None and getattr(self.slab, 'n_points', 0) > 0:
+ self.slab_actor = self.plotter.add_mesh(
+ self.slab,
+ name='slab_points',
+ render_points_as_spheres=True,
+ point_size=10,
+ scalars='intensity',
+ cmap=self.lut2,
+ show_scalar_bar=False,
+ )
+ else:
+ self.slab_actor = None
+
+ # Sync plane widget
+ try:
+ if self._plane_widget is not None:
+ self._plane_widget.SetNormal(normal)
+ self._plane_widget.SetOrigin(origin)
+ except Exception:
+ pass
+
+ # Update labels and render
+ try:
+ self.update_info_slice_labels()
+ if hasattr(self, 'lbInfoPointsCurrVal'):
+ try:
+ n_curr = int(getattr(self.slab, 'n_points', 0) or 0)
+ except Exception:
+ n_curr = 0
+ self.lbInfoPointsCurrVal.setText(str(n_curr))
+ except Exception:
+ pass
+ try:
+ self.plotter.render()
+ except Exception:
+ pass
+ self._refresh_availability()
+
+ # Update stored plane state
+ try:
+ self._plane_normal = np.array(normal, dtype=float)
+ self._plane_origin = np.array(origin, dtype=float)
+ except Exception:
+ pass
+
+ # Intensity and colormap updates
+ def update_intensity(self):
+ try:
+ min_i = self.sbMinIntensity.value()
+ max_i = self.sbMaxIntensity.value()
+ except Exception:
+ return
+ if min_i > max_i:
+ min_i, max_i = max_i, min_i
+ try:
+ self.sbMinIntensity.setValue(min_i)
+ self.sbMaxIntensity.setValue(max_i)
+ except Exception:
+ pass
+ new_range = (float(min_i), float(max_i))
+
+ # Update points actor
+ try:
+ if self.points_actor is not None:
+ self.points_actor.mapper.scalar_range = new_range
+ except Exception:
+ pass
+ # Update slice points actor
+ try:
+ if self.slab_actor is not None:
+ self.slab_actor.mapper.scalar_range = new_range
+ except Exception:
+ pass
+ # Update scalar bars
+ try:
+ if hasattr(self.plotter, 'scalar_bars'):
+ for _, sb in self.plotter.scalar_bars.items():
+ if sb:
+ sb.GetLookupTable().SetTableRange(new_range[0], new_range[1])
+ sb.Modified()
+ except Exception:
+ pass
+ try:
+ self.plotter.render()
+ except Exception:
+ pass
+
+ # Maintain visibility based on toggles (match viewer/hkl_3d.py behavior)
+ try:
+ self.toggle_cloud_vol()
+ except Exception:
+ pass
+ try:
+ self.toggle_pointer()
+ except Exception:
+ pass
+
+ def change_color_map(self):
+ color_map_select = self.cbColorMapSelect.currentText()
+ new_lut = pyv.LookupTable(cmap=color_map_select)
+ new_lut2 = pyv.LookupTable(cmap=color_map_select)
+ new_lut.apply_opacity([0, 1])
+ new_lut2.apply_opacity([0, 1])
+ self.lut = new_lut
+ self.lut2 = new_lut2
+
+ try:
+ if self.points_actor is not None:
+ self.points_actor.mapper.lookup_table = self.lut
+ if self.slab_actor is not None:
+ self.slab_actor.mapper.lookup_table = self.lut2
+ except Exception:
+ pass
+ try:
+ if hasattr(self.plotter, 'scalar_bars'):
+ for _, sb in self.plotter.scalar_bars.items():
+ if sb:
+ sb.SetLookupTable(self.lut)
+ sb.Modified()
+ except Exception:
+ pass
+ try:
+ self.plotter.render()
+ except Exception:
+ pass
+
+ # Toggles
+ def toggle_pointer(self):
+ """Toggle visibility of the slice points and plane widget (like viewer/hkl_3d.py)."""
+ vis = True
+ try:
+ vis = bool(self.cbToggleSlicePointer.isChecked())
+ except Exception:
+ pass
+ try:
+ if 'slab_points' in getattr(self.plotter, 'actors', {}):
+ self.plotter.renderer._actors['slab_points'].SetVisibility(vis)
+ except Exception:
+ pass
+ try:
+ widgets = getattr(self.plotter, 'plane_widgets', [])
+ for pw in widgets or []:
+ try:
+ if vis:
+ pw.EnabledOn()
+ else:
+ pw.EnabledOff()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def toggle_cloud_vol(self):
+ """Toggle visibility of the full cloud actor (named 'cloud_volume')."""
+ vis = True
+ try:
+ # Use cbTogglePoints from this UI to drive cloud visibility
+ vis = bool(self.cbTogglePoints.isChecked())
+ except Exception:
+ pass
+ try:
+ if 'cloud_volume' in getattr(self.plotter, 'actors', {}):
+ self.plotter.renderer._actors['cloud_volume'].SetVisibility(vis)
+ except Exception:
+ pass
+ try:
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def _ensure_slice_lock_text_actor(self):
+ try:
+ if self._slice_lock_text_actor is None:
+ self._slice_lock_text_actor = self.plotter.add_text(
+ "Slice Locked",
+ position='upper_left',
+ font_size=16,
+ color='white'
+ )
+ except Exception:
+ self._slice_lock_text_actor = None
+
+ def toggle_slice_lock(self):
+ # Update lock state
+ try:
+ self._slice_locked = bool(self.cbLockSlice.isChecked())
+ except Exception:
+ self._slice_locked = not bool(getattr(self, '_slice_locked', False))
+ # Ensure overlay exists
+ self._ensure_slice_lock_text_actor()
+ # Set overlay visibility
+ try:
+ if self._slice_lock_text_actor is not None:
+ self._slice_lock_text_actor.SetVisibility(bool(self._slice_locked))
+ except Exception:
+ pass
+ # If we just locked, restore widget to stored plane state
+ if bool(self._slice_locked):
+ self._restore_locked_plane_widget()
+ try:
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def _restore_locked_plane_widget(self):
+ try:
+ if (self._plane_widget is not None) and (self._plane_normal is not None) and (self._plane_origin is not None):
+ self._plane_widget.SetNormal(np.array(self._plane_normal, dtype=float))
+ self._plane_widget.SetOrigin(np.array(self._plane_origin, dtype=float))
+ except Exception:
+ pass
+
+ # (Removed) Volume toggle: no volume actor in point-only slice window
+
+ # Extract slice (save slice points as 2D slice dataset)
+ def extract_slice(self):
+ if not self._slice_points_exist():
+ try:
+ QMessageBox.warning(self, 'No Slice', 'No slice points available to extract')
+ except Exception:
+ pass
+ return
+ default_name = f"slice_extract_{np.datetime64('now').astype('datetime64[s]').astype(str).replace(':', '-')}.h5"
+ file_path, _ = QFileDialog.getSaveFileName(self, 'Save hkl Slice Data', default_name, 'HDF5 Files (*.h5 *.hdf5);;All Files (*)')
+ if not file_path:
+ return
+
+ # Gather slice points and intensities
+ try:
+ # Use self.slab for consistency
+ slice_points = np.array(self.slab.points)
+ slice_intensities = np.array(self.slab['intensity'])
+ except Exception:
+ try:
+ QMessageBox.critical(self, 'Extract Error', 'Failed to read slice points/intensity')
+ except Exception:
+ pass
+ return
+
+ # Plane state
+ normal, origin = self.get_plane_state()
+
+ # Metadata
+ slice_metadata = {
+ 'data_type': 'slice',
+ 'slice_normal': list(map(float, self.normalize_vector(np.array(normal, dtype=float)))) ,
+ 'slice_origin': list(map(float, np.array(origin, dtype=float))),
+ 'num_points': int(len(slice_points)),
+ 'original_file': str(self.current_file_path or 'unknown'),
+ 'original_shape': list(map(int, self.orig_shape)) if isinstance(self.orig_shape, (tuple, list)) else [0, 0],
+ 'extraction_timestamp': str(np.datetime64('now')),
+ }
+
+ # Save via HDF5Loader
+ try:
+ loader = HDF5Loader()
+ success = loader.extract_slice(
+ file_path=file_path,
+ points=slice_points,
+ intensities=slice_intensities,
+ metadata=slice_metadata,
+ shape=self.orig_shape if isinstance(self.orig_shape, (tuple, list)) else None,
+ )
+ if success:
+ try:
+ QMessageBox.information(self, 'Success', f'Slice extracted and saved successfully!\n{len(slice_points)} points saved.')
+ except Exception:
+ pass
+ else:
+ try:
+ QMessageBox.critical(self, 'Error', f'Failed to save slice: {loader.get_last_error()}')
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ QMessageBox.critical(self, 'Extract Error', f'Error extracting slice: {str(e)}')
+ except Exception:
+ pass
+
+ # Camera controls
+ def zoom_in(self):
+ cam = self.plotter.camera
+ try:
+ step = float(self._zoom_step)
+ if not np.isfinite(step) or step <= 1.0:
+ step = 1.5
+ except Exception:
+ step = 1.5
+ cam.zoom(step)
+ self.plotter.render()
+
+ def zoom_out(self):
+ cam = self.plotter.camera
+ try:
+ step = float(self._zoom_step)
+ if not np.isfinite(step) or step <= 1.0:
+ step = 1.5
+ except Exception:
+ step = 1.5
+ cam.zoom(1.0 / step)
+ self.plotter.render()
+
+ def reset_camera(self):
+ self.plotter.reset_camera()
+ self.plotter.render()
+
+ def set_camera_position(self):
+ pos_src = getattr(self, '_cam_pos_selection', None)
+ if not pos_src:
+ try:
+ if hasattr(self, 'cbSetCamPos') and self.cbSetCamPos is not None:
+ pos_src = self.cbSetCamPos.currentText()
+ elif hasattr(self, 'camSetPosCombo') and self.camSetPosCombo is not None:
+ pos_src = self.camSetPosCombo.currentText()
+ except Exception:
+ pass
+ pos_text = (pos_src or '').strip().lower()
+ p = self.plotter
+ cam = getattr(p, 'camera', None)
+
+ def _set_focus_to_data_center():
+ try:
+ if self.cloud_mesh is not None and hasattr(self.cloud_mesh, 'center'):
+ p.set_focus(self.cloud_mesh.center)
+ except Exception:
+ pass
+
+ if ('xy' in pos_text) or ('hk' in pos_text):
+ _set_focus_to_data_center(); p.view_xy()
+ elif ('yz' in pos_text) or ('kl' in pos_text):
+ _set_focus_to_data_center(); p.view_yz()
+ elif ('xz' in pos_text) or ('hl' in pos_text):
+ _set_focus_to_data_center(); p.view_xz()
+ elif 'iso' in pos_text:
+ _set_focus_to_data_center()
+ try:
+ p.view_isometric()
+ except Exception:
+ try:
+ p.view_vector((1.0, 1.0, 1.0))
+ if cam is not None:
+ cam.view_up = (0.0, 0.0, 1.0)
+ except Exception:
+ pass
+ else:
+ _set_focus_to_data_center()
+ label = (pos_text or '')
+ try:
+ if ('h+' in label) or ('x+' in label):
+ p.view_vector((1.0, 0.0, 0.0)); cam.view_up = (0.0, 0.0, 1.0)
+ elif ('h-' in label) or ('x-' in label):
+ p.view_vector((-1.0, 0.0, 0.0)); cam.view_up = (0.0, 0.0, 1.0)
+ elif ('k+' in label) or ('y+' in label):
+ p.view_vector((0.0, 1.0, 0.0)); cam.view_up = (0.0, 0.0, 1.0)
+ elif ('k-' in label) or ('y-' in label):
+ p.view_vector((0.0, -1.0, 0.0)); cam.view_up = (0.0, 0.0, 1.0)
+ elif ('l+' in label) or ('z+' in label):
+ p.view_vector((0.0, 0.0, 1.0)); cam.view_up = (0.0, 1.0, 0.0)
+ elif ('l-' in label) or ('z-' in label):
+ p.view_vector((0.0, 0.0, -1.0)); cam.view_up = (0.0, 1.0, 0.0)
+ except Exception:
+ pass
+ try:
+ if cam is not None and hasattr(cam, 'orthogonalize_view_up'):
+ cam.orthogonalize_view_up()
+ except Exception:
+ pass
+ try:
+ p.render()
+ except Exception:
+ pass
+
+ def _apply_cam_preset_button(self, label: str):
+ try:
+ self._cam_pos_selection = label
+ except Exception:
+ pass
+ try:
+ self.set_camera_position()
+ except Exception:
+ try:
+ if 'hk' in label.lower() or 'xy' in label.lower():
+ self.plotter.view_xy()
+ elif 'kl' in label.lower() or 'yz' in label.lower():
+ self.plotter.view_yz()
+ elif 'hl' in label.lower() or 'xz' in label.lower():
+ self.plotter.view_xz()
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def view_slice_normal(self):
+ try:
+ normal, origin = self.get_plane_state()
+ normal = self.normalize_vector(np.array(normal, dtype=float))
+ origin = np.array(origin, dtype=float)
+ cam = getattr(self.plotter, 'camera', None)
+ if cam is None:
+ return
+ try:
+ rng = self.cloud_mesh.points.max(axis=0) - self.cloud_mesh.points.min(axis=0)
+ distance = float(np.linalg.norm(rng)) * 0.5
+ except Exception:
+ distance = 1.0
+ try:
+ cam.focal_point = origin.tolist()
+ cam.position = (origin + normal * distance).tolist()
+ up = np.array(getattr(cam, 'view_up', [0.0, 1.0, 0.0]), dtype=float)
+ upn = self.normalize_vector(up)
+ if abs(float(np.dot(upn, normal))) > 0.99:
+ new_up = np.array([0.0, 1.0, 0.0], dtype=float) if abs(normal[1]) < 0.99 else np.array([1.0, 0.0, 0.0], dtype=float)
+ cam.view_up = new_up.tolist()
+ except Exception:
+ pass
+ self.plotter.render()
+ except Exception:
+ pass
+
+ # Slice control helpers
+ def _on_translate_step_changed(self, val: float):
+ self._slice_translate_step = float(val)
+
+ def _on_rotate_step_changed(self, val: float):
+ self._slice_rotate_step_deg = float(val)
+
+ def _on_orientation_changed(self, idx: int):
+ if not self._ensure_data_loaded_or_warn():
+ return
+ preset = getattr(self, '_slice_orientation_selection', None)
+ if not preset:
+ try:
+ preset = self.cbSliceOrientation.currentText()
+ except Exception:
+ preset = 'HK(xy)'
+ self.set_plane_preset(preset)
+
+ def _on_custom_normal_changed(self):
+ # Guard: do nothing if slice is locked
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ if not self._ensure_data_loaded_or_warn():
+ return
+ preset = (str(getattr(self, '_slice_orientation_selection', '')) or '').lower()
+ if preset.startswith('custom'):
+ try:
+ n_raw = np.array(getattr(self, '_custom_normal', [0.0, 0.0, 1.0]), dtype=float)
+ except Exception:
+ n_raw = np.array([0.0, 0.0, 1.0], dtype=float)
+ n = self.normalize_vector(n_raw)
+ _, origin = self.get_plane_state()
+ self.on_plane_update(n, origin)
+
+ def _on_reset_slice(self):
+ # Guard: do nothing if slice is locked
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ if not self._ensure_data_loaded_or_warn():
+ return
+ try:
+ center = self.cloud_mesh.center if (self.cloud_mesh is not None) else np.array([0.0, 0.0, 0.0])
+ normal = np.array([0.0, 0.0, 1.0], dtype=float)
+ self.on_plane_update(normal, center)
+ except Exception:
+ pass
+
+ # Plane helpers
+ def get_plane_state(self):
+ # Prefer stored state once initialized for consistency under lock
+ try:
+ if (self._plane_normal is not None) and (self._plane_origin is not None):
+ return np.array(self._plane_normal, dtype=float), np.array(self._plane_origin, dtype=float)
+ except Exception:
+ pass
+ try:
+ if hasattr(self.plotter, 'plane_widgets') and self.plotter.plane_widgets:
+ pw = self.plotter.plane_widgets[0]
+ normal = np.array(pw.GetNormal(), dtype=float)
+ origin = np.array(pw.GetOrigin(), dtype=float)
+ return normal, origin
+ except Exception:
+ pass
+ normal = np.array([0.0, 0.0, 1.0], dtype=float)
+ try:
+ origin = np.array(self.cloud_mesh.center, dtype=float)
+ except Exception:
+ origin = np.array([0.0, 0.0, 0.0], dtype=float)
+ return normal, origin
+
+ def set_plane_state(self, normal, origin):
+ # Guard: do nothing if slice is locked
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ n = self.normalize_vector(np.array(normal, dtype=float))
+ o = np.array(origin, dtype=float)
+ self.on_plane_update(n, o)
+
+ def normalize_vector(self, v):
+ v = np.array(v, dtype=float)
+ n = float(np.linalg.norm(v))
+ if not np.isfinite(n) or n <= 0.0:
+ return np.array([0.0, 0.0, 1.0], dtype=float)
+ return v / n
+
+ def set_plane_preset(self, preset_text: str):
+ # Guard: do nothing if slice is locked
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ preset = preset_text.lower()
+ if 'xy' in preset or 'hk' in preset:
+ n = np.array([0.0, 0.0, 1.0], dtype=float)
+ elif 'yz' in preset or 'kl' in preset:
+ n = np.array([1.0, 0.0, 0.0], dtype=float)
+ elif 'xz' in preset or 'hl' in preset:
+ n = np.array([0.0, 1.0, 0.0], dtype=float)
+ else:
+ try:
+ n = np.array(getattr(self, '_custom_normal', [0.0, 0.0, 1.0]), dtype=float)
+ except Exception:
+ n = np.array([0.0, 0.0, 1.0], dtype=float)
+ n = self.normalize_vector(n)
+ _, origin = self.get_plane_state()
+ self.set_plane_state(n, origin)
+
+ def nudge_along_normal(self, sign: int):
+ # Guard: do nothing if slice is locked
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ if not self._ensure_data_loaded_or_warn():
+ return
+ normal, origin = self.get_plane_state()
+ step = float(self._slice_translate_step)
+ origin_new = origin + float(sign) * step * normal
+ self.set_plane_state(normal, origin_new)
+
+ def nudge_along_axis(self, axis: str, sign: int):
+ # Guard: do nothing if slice is locked
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ if not self._ensure_data_loaded_or_warn():
+ return
+ axis = axis.upper()
+ if axis == 'H':
+ d = np.array([1.0, 0.0, 0.0], dtype=float)
+ elif axis == 'K':
+ d = np.array([0.0, 1.0, 0.0], dtype=float)
+ else:
+ d = np.array([0.0, 0.0, 1.0], dtype=float)
+ normal, origin = self.get_plane_state()
+ step = float(self._slice_translate_step)
+ origin_new = origin + float(sign) * step * d
+ self.set_plane_state(normal, origin_new)
+
+ def rotate_about_axis(self, axis: str, deg: float):
+ # Guard: do nothing if slice is locked
+ if bool(getattr(self, '_slice_locked', False)):
+ self._restore_locked_plane_widget()
+ return
+ if not self._ensure_data_loaded_or_warn():
+ return
+ axis = axis.upper()
+ if axis == 'H':
+ u = np.array([1.0, 0.0, 0.0], dtype=float)
+ elif axis == 'K':
+ u = np.array([0.0, 1.0, 0.0], dtype=float)
+ else:
+ u = np.array([0.0, 0.0, 1.0], dtype=float)
+ normal, origin = self.get_plane_state()
+ theta = float(np.deg2rad(deg))
+ ux, uy, uz = u
+ c, s = np.cos(theta), np.sin(theta)
+ R = np.array([
+ [c+ux*ux*(1-c), ux*uy*(1-c)-uz*s, ux*uz*(1-c)+uy*s],
+ [uy*ux*(1-c)+uz*s, c+uy*uy*(1-c), uy*uz*(1-c)-ux*s],
+ [uz*ux*(1-c)-uy*s, uz*uy*(1-c)+ux*s, c+uz*uz*(1-c)]
+ ], dtype=float)
+ new_normal = R @ normal
+ new_normal = self.normalize_vector(new_normal)
+ self.set_plane_state(new_normal, origin)
+
+ # Info labels
+ def update_info_slice_labels(self):
+ try:
+ orient_text = getattr(self, '_slice_orientation_selection', None)
+ if not orient_text:
+ try:
+ if hasattr(self, 'cbSliceOrientation') and self.cbSliceOrientation is not None:
+ orient_text = self.cbSliceOrientation.currentText()
+ except Exception:
+ orient_text = '-'
+ if orient_text is None or orient_text == '':
+ orient_text = '-'
+ normal, origin = self.get_plane_state()
+ n = self.normalize_vector(np.array(normal, dtype=float))
+ o = np.array(origin, dtype=float)
+ # Display floats with 5 decimal places
+ n_str = f"[{n[0]:0.5f}, {n[1]:0.5f}, {n[2]:0.5f}]"
+ o_str = f"[{o[0]:0.5f}, {o[1]:0.5f}, {o[2]:0.5f}]"
+ try:
+ if hasattr(self, 'lbSliceOrientationVal'):
+ self.lbSliceOrientationVal.setText(str(orient_text))
+ except Exception:
+ pass
+ try:
+ if hasattr(self, 'lbSliceNormalVal'):
+ self.lbSliceNormalVal.setText(n_str)
+ except Exception:
+ pass
+ try:
+ if hasattr(self, 'lbSliceOriginVal'):
+ self.lbSliceOriginVal.setText(o_str)
+ except Exception:
+ pass
+ try:
+ pos_text = '-'
+ orient_lower = (str(orient_text) or '').lower()
+ if ('hk' in orient_lower) or ('xy' in orient_lower):
+ pos_text = f"L = {o[2]:0.5f}"
+ elif ('kl' in orient_lower) or ('yz' in orient_lower):
+ pos_text = f"H = {o[0]:0.5f}"
+ elif ('hl' in orient_lower) or ('xz' in orient_lower):
+ pos_text = f"K = {o[1]:0.5f}"
+ else:
+ s = float(np.dot(n, o))
+ pos_text = f"n·origin = {s:0.5f}"
+ if hasattr(self, 'lbSlicePositionVal'):
+ self.lbSlicePositionVal.setText(pos_text)
+ except Exception:
+ pass
+ # Reflect availability of Extract action based on existence
+ try:
+ if hasattr(self, 'actionExtractSlice'):
+ self.actionExtractSlice.setEnabled(self._slice_points_exist())
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _ensure_data_loaded_or_warn(self) -> bool:
+ try:
+ if self.cloud_mesh is not None:
+ return True
+ except Exception:
+ pass
+ try:
+ QMessageBox.warning(self, 'No Data', 'Load data before adjusting the slice.')
+ except Exception:
+ pass
+ return False
+
+
+# add main
+if __name__ == '__main__':
+ try:
+ app = QApplication(sys.argv)
+ window = HKL3DSliceWindow()
+ window.show()
+ size_manager = SizeManager(app=app)
+ sys.exit(app.exec_())
+ except KeyboardInterrupt:
+ sys.exit(0)
diff --git a/viewer/hkl_3d_viewer.py b/viewer/hkl_3d_viewer.py
index 83b8c85..0d6aeb7 100644
--- a/viewer/hkl_3d_viewer.py
+++ b/viewer/hkl_3d_viewer.py
@@ -1,77 +1,403 @@
-import sys
-import argparse
+import sys, pathlib
+import os
+import h5py
+import time
+import subprocess
import numpy as np
-import open3d as o3d
-import matplotlib.pyplot as plt
-# from pyqtgraph import colormap
-from PyQt5.QtWidgets import QApplication, QPushButton, QMainWindow
-#TODO: add axis, legend, and labels
-
-class HKL3DViewer(QMainWindow):
- def __init__(self, qx, qy, qz, intensity):
- super().__init__()
- self.qx = qx
- self.qy = qy
- self.qz = qz
- self.intensity = intensity
- self.initUI()
-
- def initUI(self):
- self.setWindowTitle("HKL Data Viewer")
- self.setGeometry(100, 100, 300, 200)
-
- # Button to open Open3D visualization
- btn = QPushButton("Show 3D Plot", self)
- btn.setGeometry(80, 80, 140, 40)
- btn.clicked.connect(self.show_3d_plot)
-
- def show_3d_plot(self) -> None:
- # open_3d_plot(self.qx, self.qy, self.qz, self.intensity)
- points = np.column_stack((self.qx, self.qy, self.qz))
+import os.path as osp
+import pyqtgraph as pg
+import pyvista as pyv
+import pyvistaqt as pyvqt
+from pyvistaqt import QtInteractor, BackgroundPlotter
+from PyQt5 import uic
+# from epics import caget
+from PyQt5.QtCore import QTimer, QThread, pyqtSignal, Qt
+from PyQt5.QtWidgets import QApplication, QMainWindow, QDialog, QFileDialog, QMessageBox
+# Custom imported classes
+# Add the parent directory to the path so the font_scaling.py file can be imported
+sys.path.append(str(pathlib.Path(__file__).resolve().parents[1]))
+from utils import PVAReader, HDF5Writer, SizeManager
+from hkl_3d_slice_window import HKL3DSliceWindow
+
+
+class ConfigDialog(QDialog):
+
+ def __init__(self):
+ """
+ Class that does initial setup for getting the pva prefix, collector address,
+ and the path to the json that stores the pvs that will be observed
+
+ Attributes:
+ input_channel (str): Input channel for PVA.
+ config_path (str): Path to the ROI configuration file.
+ """
+ super(ConfigDialog,self).__init__()
+ uic.loadUi('gui/pv_config.ui', self)
+ self.setWindowTitle('PV Config')
+ # initializing variables to pass to Image Viewer
+ self.input_channel = ""
+ self.config_path = ""
+ # class can be prefilled with text
+ self.init_ui()
- # Normalize qz for color mapping
- intensity_min, intensity_max = np.min(self.intensity), np.max(self.intensity)
- norm_intensity = (self.intensity - intensity_min) / (intensity_max - intensity_min)
+ # Connecting signasl to
+ self.btn_clear.clicked.connect(self.clear_pv_setup)
+ self.btn_browse.clicked.connect(self.browse_file_dialog)
+ self.btn_accept_reject.accepted.connect(self.dialog_accepted)
- # Apply a colormap
- cmap = plt.get_cmap("jet")
- colors = cmap(norm_intensity)[:, :3] # Extract RGB
+ def init_ui(self) -> None:
+ """
+ Prefills text in the Line Editors for the user.
+ """
+ self.le_input_channel.setText(self.le_input_channel.text())
+ self.le_config.setText(self.le_config.text())
+ def browse_file_dialog(self) -> None:
+ """
+ Opens a file dialog to select the path to a TOML configuration file.
+ """
+ self.pvs_path, _ = QFileDialog.getOpenFileName(self, 'Select TOML Config', 'pv_configs', '*.toml (*.toml)')
- # Create Open3D point cloud
- pcd = o3d.geometry.PointCloud()
- pcd.points = o3d.utility.Vector3dVector(points)
- pcd.colors = o3d.utility.Vector3dVector(colors)
+ self.le_config.setText(self.pvs_path)
+
+ def clear_pv_setup(self) -> None:
+ """
+ Clears line edit that tells image view where the config file is.
+ """
+ self.le_config.clear()
+ def dialog_accepted(self) -> None:
+ """
+ Handles the final step when the dialog's accept button is pressed.
+ Starts the HKLImageWindow process with filled information.
+ """
+ self.input_channel = self.le_input_channel.text()
+ self.config_path = self.le_config.text()
+ if osp.isfile(self.config_path) or (self.config_path == ''):
+ self.hkl_3d_viewer = HKLImageWindow(input_channel=self.input_channel,
+ file_path=self.config_path,)
+ else:
+ print(f'File Path {self.config_path} Doesn\'t Exitst')
+ #TODO: ADD ERROR Dialog rather than print message so message is clearer
+ self.new_dialog = ConfigDialog()
+ self.new_dialog.show()
- # Show the Open3D plot
- try:
- axes = o3d.geometry.TriangleMesh.create_coordinate_frame(size=1, origin=[0,0,0])
- o3d.visualization.draw_geometries([pcd, axes])
- # o3d.visualization.draw_geometries([pcd])
+class HKLImageWindow(QMainWindow):
+ images_plotted = pyqtSignal(bool)
+
+ def __init__(self, input_channel='s6lambda1:Pva1:Image', file_path=''):
+ """
+ Initializes the main window for real-time image visualization and manipulation.
+
+ Args:
+ input_channel (str): The PVA input channel for the detector.
+ file_path (str): The file path for loading configuration.
+ """
+ super(HKLImageWindow, self).__init__()
+ uic.loadUi('gui/hkl_viewer_window.ui', self)
+ self.setWindowTitle('HKL Viewer')
+ self.show()
+
+ # Initializing Viewer variables
+ self.reader = None
+ self.image = None
+ self.call_id_plot = 0
+ self.image_is_transposed = False
+ self._input_channel = input_channel
+ self.pv_prefix.setText(self._input_channel)
+ self._file_path = file_path
+
+ # Initializing but not starting timers so they can be reached by different functions
+ self.timer_labels = QTimer()
+ self.file_writer_thread = QThread()
+ self.timer_labels.timeout.connect(self.update_labels)
+
+ # HKL values
+ self.hkl_config = None
+ self.hkl_data = {}
+ self.qx = None
+ self.qy = None
+ self.qz = None
+ self.processes = {}
+
+ # Adding widgets manually to have better control over them
+ pyv.set_plot_theme('dark')
+ self.plotter = QtInteractor(self)
+ self.viewer_layout.addWidget(self.plotter,1,1)
+
+ # pyvista vars
+ self.actor = None
+ self.lut = None
+ self.cloud = None
+ self.min_intensity = 0.0
+ self.max_intensity = 0.0
+ self.min_opacity = 0.0
+ self.max_opacity = 1.0
+ self.plotter.add_axes(xlabel='H', ylabel='K', zlabel='L')
+
+ # Connecting the signals to the code that will be executed
+ self.pv_prefix.returnPressed.connect(self.start_live_view_clicked)
+ self.pv_prefix.textChanged.connect(self.update_pv_prefix)
+ self.start_live_view.clicked.connect(self.start_live_view_clicked)
+ self.stop_live_view.clicked.connect(self.stop_live_view_clicked)
+ # self.plotting_frequency.valueChanged.connect(self.start_timers)
+ # self.log_image.clicked.connect(self.update_image)
+ self.sbox_min_intensity.editingFinished.connect(self.update_intensity)
+ self.sbox_max_intensity.editingFinished.connect(self.update_intensity)
+ self.sbox_min_opacity.editingFinished.connect(self.update_opacity)
+ self.sbox_max_opacity.editingFinished.connect(self.update_opacity)
+ self.btn_3d_slice_window.clicked.connect(self.open_3d_slice_window)
+
+ def start_timers(self) -> None:
+ """
+ Starts timers for updating labels and plotting at specified frequencies.
+ """
+ self.timer_labels.start(int(1000/100))
+
+ def stop_timers(self) -> None:
+ """
+ Stops the updating of main window labels and plots.
+ """
+ self.timer_labels.stop()
+
+ def start_live_view_clicked(self) -> None:
+ """
+ Initializes the connections to the PVA channel using the provided Channel Name.
+
+ This method ensures that any existing connections are cleared and re-initialized.
+ Also starts monitoring the stats and adds ROIs to the viewer.
+ """
+ try:
+ # A double check to make sure there isn't a connection already when starting
+ self.stop_timers()
+ self.plotter.clear()
+ if self.reader is None:
+ self.reader = PVAReader(input_channel=self._input_channel,
+ config_filepath=self._file_path,
+ viewer_type='rsm')
+ self.file_writer = HDF5Writer(self.reader.OUTPUT_FILE_LOCATION, self.reader)
+ self.file_writer.moveToThread(self.file_writer_thread)
+ else:
+ self.btn_save_h5.clicked.disconnect()
+ self.btn_plot_cache.clicked.disconnect()
+ self.file_writer.hdf5_writer_finished.disconnect()
+ if self.reader.channel.isMonitorActive():
+ self.reader.stop_channel_monitor()
+ if self.file_writer_thread.isRunning():
+ self.file_writer_thread.quit()
+ self.file_writer_thread.wait()
+ del self.reader
+ self.reader = PVAReader(input_channel=self._input_channel,
+ config_filepath=self._file_path,
+ viewer_type='rsm')
+ self.file_writer.pva_reader = self.reader
+ self.btn_save_h5.clicked.connect(self.save_caches_clicked)
+ self.btn_plot_cache.clicked.connect(self.update_image_from_button)
+ self.reader.reader_scan_complete.connect(self.update_image_from_scan)
+ #self.images_plotted.connect(self.trigger_save_caches)
+ #self.file_writer.hdf5_writer_finished.connect(self.on_writer_finished)
+ if self.reader.CACHING_MODE == 'scan':
+ self.file_writer_thread.start()
except Exception as e:
- print(f'Failed to perform visualization:{e}')
- sys.exit(2)
+ print(f'Failed to Connect to {self._input_channel}: {e}')
+ del self.reader
+ self.reader = None
+ self.provider_name.setText('N/A')
+ self.is_connected.setText('Disconnected')
+
+ if self.reader is not None:
+ # self.set_pixel_ordering()
+ self.reader.start_channel_monitor()
+ self.start_timers()
+
+ def stop_live_view_clicked(self) -> None:
+ """
+ Clears the connection for the PVA channel and stops all active monitors.
-if __name__ == "__main__":
- parser = argparse.ArgumentParser(description="Visualize Q-space data using Open3D.")
- parser.add_argument("--qx-file", type=str, required=True, help="Path to NumPy file containing qx array.")
- parser.add_argument("--qy-file", type=str, required=True, help="Path to NumPy file containing qy array.")
- parser.add_argument("--qz-file", type=str, required=True, help="Path to NumPy file containing qz array.")
- parser.add_argument("--intensity-file", type=str, required=True, help="Path to NumPy file containing Intensity array.")
+ This method also updates the UI to reflect the disconnected state.
+ """
+ if self.reader is not None:
+ self.reader.stop_channel_monitor()
+ self.stop_timers()
+ self.provider_name.setText('N/A')
+ self.is_connected.setText('Disconnected')
+
+ def trigger_save_caches(self, clear_caches:bool=True) -> None:
+ if not self.file_writer_thread.isRunning():
+ self.file_writer_thread.start()
+ self.file_writer.save_caches_to_h5(clear_caches=clear_caches)
+
+ def save_caches_clicked(self) -> None:
+ if not self.reader.channel.isMonitorActive():
+ if not self.file_writer_thread.isRunning():
+ self.file_writer_thread.start()
+ self.file_writer.save_caches_to_h5()
+ else:
+ QMessageBox.critical(None,
+ 'Error',
+ 'Stop Live View to Save Cache',
+ QMessageBox.Ok)
+
+ def on_writer_finished(self, message) -> None:
+ print(message)
+ self.file_writer_thread.quit()
+ self.file_writer_thread.wait()
+
+ # def freeze_image_checked(self) -> None:
+ # """
+ # Toggles freezing/unfreezing of the plot based on the checked state
+ # without stopping the collection of PVA objects.
+ # """
+ # if self.reader is not None:
+ # if self.freeze_image.isChecked():
+ # self.stop_timers()
+ # else:
+ # self.start_timers()
+
+ def update_pv_prefix(self) -> None:
+ """
+ Updates the input channel prefix based on the value entered in the prefix field.
+ """
+ self._input_channel = self.pv_prefix.text()
+
+ def update_labels(self) -> None:
+ """
+ Updates the UI labels with current connection and cached data.
+ """
+ if self.reader is not None:
+ provider_name = f"{self.reader.provider if self.reader.channel.isMonitorActive() else 'N/A'}"
+ is_connected = 'Connected' if self.reader.channel.isMonitorActive() else 'Disconnected'
+ self.provider_name.setText(provider_name)
+ self.is_connected.setText(is_connected)
+ self.missed_frames_val.setText(f'{self.reader.frames_missed:d}')
+ self.frames_received_val.setText(f'{self.reader.frames_received:d}')
+
+ def update_image_from_scan(self) -> None:
+ self.update_image(is_scan_signal=True)
+
+ def update_image_from_button(self) -> None:
+ self.update_image(is_scan_signal=False)
+
+ def update_image(self, is_scan_signal:bool=False) -> None:
+ """
+ Redraws plots based on the configured update rate.
+
+ Processes the image data according to main window settings, such as rotation
+ and log transformations. Also sets initial min/max pixel values in the UI.
+ """
+ if self.reader is not None:
+ self.call_id_plot +=1
+ if self.reader.cached_images is not None and self.reader.cached_qx is not None:
+ self.plotter.clear()
+ try:
+ num_images = len(self.reader.cached_images)
+ num_rsm = len(self.reader.cached_qx)
+ if num_images != num_rsm:
+ raise ValueError(f'Size of caches are uneven:\nimages:{num_images}\nqxyz: {num_rsm}')
+ # connect all cached data
+ flat_intensity = np.concatenate(self.reader.cached_images, dtype=np.float32)
+ qx = np.concatenate(self.reader.cached_qx, dtype=np.float32)
+ qy = np.concatenate(self.reader.cached_qy, dtype=np.float32)
+ qz = np.concatenate(self.reader.cached_qz, dtype=np.float32)
+
+ points = np.column_stack((
+ qx, qy, qz
+ ))
+ except Exception as e:
+ print(f'[HKL Viewer] Failed to concatenate caches: {e}')
+
+
+ try:
+ if is_scan_signal:
+ clear_caches = True
+ self.images_plotted.emit(clear_caches)
+
+ self.min_intensity = np.min(flat_intensity)
+ self.max_intensity = np.max(flat_intensity)
+ self.sbox_max_intensity.setValue(self.max_intensity)
+
+ self.cloud = pyv.PolyData(points)
+ self.cloud['intensity'] = flat_intensity
+
+ self.lut = pyv.LookupTable(cmap='viridis')
+ self.lut.below_range_color = 'black'
+ self.lut.above_range_color = 'black'
+ self.lut.below_range_opacity = 0
+ self.lut.above_range_opacity = 0
+ self.update_opacity()
+ self.update_intensity()
+
+ self.actor = self.plotter.add_mesh(
+ self.cloud,
+ scalars='intensity',
+ cmap=self.lut,
+ point_size=3
+ )
+
+ self.plotter.show_bounds(xtitle='H Axis', ytitle='K Axis', ztitle='L Axis')
+ except Exception as e:
+ print(f"[HKL Viewer] Failed to update 3D plot: {e}")
+
+ def update_opacity(self) -> None:
+ """
+ Updates the min/max intensity levels in the HKL Viewer based on UI settings.
+ """
+ """
+ Updates the min/max intensity levels in the HKL Viewer based on UI settings.
+ """
+ self.min_opacity = self.sbox_min_opacity.value()
+ self.max_opacity = self.sbox_max_opacity.value()
+ if self.min_opacity > self.max_opacity:
+ self.min_opacity, self.max_opacity = self.max_opacity, self.min_opacity
+ self.sbox_min_opacity.setValue(self.min_opacity)
+ self.sbox_max_opacity.setValue(self.max_opacity)
+ if self.lut is not None:
+ self.lut.apply_opacity([self.min_opacity,self.max_opacity])
+
+ def update_intensity(self) -> None:
+ """
+ Updates the min/max intensity levels in the HKL Viewer based on UI settings.
+ """
+ self.min_intensity = self.sbox_min_intensity.value()
+ self.max_intensity = self.sbox_max_intensity.value()
+ if self.min_intensity > self.max_intensity:
+ self.min_intensity, self.max_intensity = self.max_intensity, self.min_intensity
+ self.sbox_min_intensity.setValue(self.min_intensity)
+ self.sbox_max_intensity.setValue(self.max_intensity)
+ if self.lut is not None:
+ self.lut.scalar_range = (self.min_intensity, self.max_intensity)
+ if self.actor is not None:
+ self.actor.mapper.scalar_range = (self.min_intensity,self.max_intensity)
+
+ def closeEvent(self, event):
+ """pass
+ Custom close event to clean up resources, including stat dialogs.
+
+ Args:
+ event (QCloseEvent): The close event triggered when the main window is closed.
+ """
+ if self.file_writer_thread.isRunning():
+ self.file_writer_thread.quit()
+ self.file_writer_thread
+ super(HKLImageWindow,self).closeEvent(event)
+
+ def open_3d_slice_window(self) -> None:
+ try:
+ self.slice_window = HKL3DSliceWindow(self)
+ self.slice_window.show()
+ except Exception as e:
+ import traceback
+ with open('error_output2.txt','w') as f:
+ f.write(f"Traceback:\n{traceback.format_exc()}\n\nError:\n{str(e)}")
- args = parser.parse_args()
+if __name__ == '__main__':
try:
- qx = np.load(args.qx_file)
- qy = np.load(args.qy_file)
- qz = np.load(args.qz_file)
- intensity = np.load(args.intensity_file)
- except Exception as e:
- print(f"Failed to Load Numpy File: {e}")
-
- app = QApplication(sys.argv)
- window = HKL3DViewer(qx, qy,qz,intensity)
- window.show()
- sys.exit(app.exec_())
+ app = QApplication(sys.argv)
+ window = ConfigDialog()
+ window.show()
+ size_manager = SizeManager(app=app)
+ sys.exit(app.exec_())
+ except KeyboardInterrupt:
+ sys.exit(0)
\ No newline at end of file
diff --git a/viewer/hkl_controls_dialog.py b/viewer/hkl_controls_dialog.py
new file mode 100644
index 0000000..28beb9e
--- /dev/null
+++ b/viewer/hkl_controls_dialog.py
@@ -0,0 +1,205 @@
+from PyQt5 import uic
+from PyQt5.QtWidgets import QDialog
+
+
+class HKLControlsDialog(QDialog):
+ """
+ Modeless dialog that encapsulates Slice and Camera controls for the HKL 3D viewer.
+ Wires UI signals to methods on the main HKL3DSliceWindow instance and updates small state
+ variables (e.g., _zoom_step, _cam_pos_selection) without the main window directly reading
+ dialog widgets.
+ """
+ def __init__(self, main):
+ super().__init__(parent=main)
+ self.main = main
+ uic.loadUi('gui/controls/hkl_controls_dialog.ui', self)
+ # Initialize slice orientation and custom normal from main
+ try:
+ if hasattr(self.main, '_slice_orientation_selection') and self.main._slice_orientation_selection:
+ self.cbSliceOrientation.setCurrentText(str(self.main._slice_orientation_selection))
+ except Exception:
+ pass
+ try:
+ cn = getattr(self.main, '_custom_normal', [0.0, 0.0, 1.0])
+ self.sbNormH.setValue(float(cn[0]))
+ self.sbNormK.setValue(float(cn[1]))
+ self.sbNormL.setValue(float(cn[2]))
+ except Exception:
+ pass
+
+ # Camera controls wiring
+ try:
+ self.btnZoomIn.clicked.connect(self.main.zoom_in)
+ except Exception:
+ pass
+ try:
+ self.btnZoomOut.clicked.connect(self.main.zoom_out)
+ except Exception:
+ pass
+ try:
+ self.btnResetCamera.clicked.connect(self.main.reset_camera)
+ except Exception:
+ pass
+ try:
+ # Keep local state in main; avoid main reading this widget directly
+ self.sbZoomStep.valueChanged.connect(self._on_zoom_step_changed)
+ # Initialize spinbox with main's current zoom step if available
+ if hasattr(self.main, '_zoom_step'):
+ try:
+ self.sbZoomStep.setValue(float(self.main._zoom_step))
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ # Camera preset selection: update main's state; execution triggered by Set button
+ self.cbSetCamPos.currentTextChanged.connect(self._on_cam_pos_changed)
+ self.btnSetCamPos.clicked.connect(self.main.set_camera_position)
+ except Exception:
+ pass
+ try:
+ self.btnHKView.clicked.connect(lambda: self.main._apply_cam_preset_button('HK(xy)'))
+ except Exception:
+ pass
+ try:
+ self.btnKLView.clicked.connect(lambda: self.main._apply_cam_preset_button('KL(yz)'))
+ except Exception:
+ pass
+ try:
+ self.btnHLView.clicked.connect(lambda: self.main._apply_cam_preset_button('HL(xz)'))
+ except Exception:
+ pass
+ try:
+ self.btnViewSliceNormal.clicked.connect(self.main.view_slice_normal)
+ except Exception:
+ pass
+
+ # Slice controls wiring
+ try:
+ self.sbSliceTranslateStep.valueChanged.connect(self.main._on_translate_step_changed)
+ except Exception:
+ pass
+ try:
+ self.sbSliceRotateStep.valueChanged.connect(self.main._on_rotate_step_changed)
+ except Exception:
+ pass
+ try:
+ self.cbSliceOrientation.currentIndexChanged.connect(self._on_slice_orientation_changed)
+ except Exception:
+ pass
+ try:
+ self.sbNormH.editingFinished.connect(self._on_custom_normal_spinboxes_changed)
+ self.sbNormK.editingFinished.connect(self._on_custom_normal_spinboxes_changed)
+ self.sbNormL.editingFinished.connect(self._on_custom_normal_spinboxes_changed)
+ except Exception:
+ pass
+
+ # Translate buttons
+ try:
+ self.btnSliceUpNormal.clicked.connect(lambda: self.main.nudge_along_normal(+1))
+ except Exception:
+ pass
+ try:
+ self.btnSliceDownNormal.clicked.connect(lambda: self.main.nudge_along_normal(-1))
+ except Exception:
+ pass
+ try:
+ self.btnSlicePosH.clicked.connect(lambda: self.main.nudge_along_axis('H', +1))
+ self.btnSliceNegH.clicked.connect(lambda: self.main.nudge_along_axis('H', -1))
+ except Exception:
+ pass
+ try:
+ self.btnSlicePosK.clicked.connect(lambda: self.main.nudge_along_axis('K', +1))
+ self.btnSliceNegK.clicked.connect(lambda: self.main.nudge_along_axis('K', -1))
+ except Exception:
+ pass
+ try:
+ self.btnSlicePosL.clicked.connect(lambda: self.main.nudge_along_axis('L', +1))
+ self.btnSliceNegL.clicked.connect(lambda: self.main.nudge_along_axis('L', -1))
+ except Exception:
+ pass
+
+ # Rotate buttons
+ try:
+ self.btnRotPlusH.clicked.connect(lambda: self.main.rotate_about_axis('H', +float(getattr(self.main, '_slice_rotate_step_deg', 1.0))))
+ self.btnRotMinusH.clicked.connect(lambda: self.main.rotate_about_axis('H', -float(getattr(self.main, '_slice_rotate_step_deg', 1.0))))
+ except Exception:
+ pass
+ try:
+ self.btnRotPlusK.clicked.connect(lambda: self.main.rotate_about_axis('K', +float(getattr(self.main, '_slice_rotate_step_deg', 1.0))))
+ self.btnRotMinusK.clicked.connect(lambda: self.main.rotate_about_axis('K', -float(getattr(self.main, '_slice_rotate_step_deg', 1.0))))
+ except Exception:
+ pass
+ try:
+ self.btnRotPlusL.clicked.connect(lambda: self.main.rotate_about_axis('L', +float(getattr(self.main, '_slice_rotate_step_deg', 1.0))))
+ self.btnRotMinusL.clicked.connect(lambda: self.main.rotate_about_axis('L', -float(getattr(self.main, '_slice_rotate_step_deg', 1.0))))
+ except Exception:
+ pass
+ try:
+ self.btnResetSlice.clicked.connect(self.main._on_reset_slice)
+ except Exception:
+ pass
+
+ # Dialog properties
+ try:
+ # Modeless by default; caller decides modality if needed
+ self.setModal(False)
+ except Exception:
+ pass
+
+ # ---------- Dialog-side slots updating main window state ----------
+ def _on_zoom_step_changed(self, val: float):
+ try:
+ self.main._zoom_step = float(val)
+ except Exception:
+ self.main._zoom_step = 1.5
+
+ def _on_cam_pos_changed(self, text: str):
+ try:
+ self.main._cam_pos_selection = str(text)
+ except Exception:
+ self.main._cam_pos_selection = None
+
+ def _on_slice_orientation_changed(self, idx: int):
+ # Update main state with current orientation selection then delegate
+ try:
+ text = self.cbSliceOrientation.currentText()
+ except Exception:
+ text = 'HK(xy)'
+ try:
+ self.main._slice_orientation_selection = text
+ except Exception:
+ pass
+ try:
+ self.main._on_orientation_changed(idx)
+ except Exception:
+ pass
+
+ def _on_custom_normal_spinboxes_changed(self):
+ # Update main state with current custom normal then delegate
+ try:
+ h = float(self.sbNormH.value())
+ k = float(self.sbNormK.value())
+ l = float(self.sbNormL.value())
+ self.main._custom_normal = [h, k, l]
+ except Exception:
+ self.main._custom_normal = [0.0, 0.0, 1.0]
+ try:
+ self.main._on_custom_normal_changed()
+ except Exception:
+ pass
+
+ # ---------- Focus helpers ----------
+ def focus_camera_section(self):
+ try:
+ # Focus movements group; optionally scroll if a scroll area is added later
+ self.gbCamMovements.setFocus()
+ except Exception:
+ pass
+
+ def focus_slice_section(self):
+ try:
+ # Focus steps group for convenience
+ self.gbSteps.setFocus()
+ except Exception:
+ pass
diff --git a/viewer/hkl_slice_2d_view.py b/viewer/hkl_slice_2d_view.py
new file mode 100644
index 0000000..0bce1ea
--- /dev/null
+++ b/viewer/hkl_slice_2d_view.py
@@ -0,0 +1,481 @@
+import numpy as np
+from typing import Optional, Tuple
+
+from PyQt5 import uic
+from PyQt5.QtCore import QTimer
+from PyQt5.QtWidgets import QWidget, QVBoxLayout
+import pyqtgraph as pg
+
+
+class HKLSlice2DView(QWidget):
+ """
+ Lightweight 2D slice view that mirrors the current slice from the parent HKL3DSliceWindow.
+ - No file I/O, no extra controls.
+ - Inherits min/max intensity and colormap directly from the parent.
+ - Updates are throttled with a QTimer to avoid re-rasterizing on every drag event.
+ """
+
+ def __init__(self, parent):
+ super().__init__(parent=parent)
+ self.parent = parent
+ # Load .ui and setup host layout for embedded 2D plot
+ uic.loadUi('gui/hkl_slice_2d_view.ui', self)
+
+ # Plot with ImageView + PlotItem for axis labeling
+ self.plot_item = pg.PlotItem()
+ self.image_view = pg.ImageView(view=self.plot_item)
+ # Axis labels via PlotItem
+ self.plot_item.setLabel('bottom', 'U')
+ self.plot_item.setLabel('left', 'V')
+ # Optional: lock aspect for square pixels
+ try:
+ self.image_view.view.setAspectLocked(True)
+ except Exception:
+ pass
+ try:
+ self.layoutPlotHost.addWidget(self.image_view)
+ except Exception:
+ # Fallback: attach directly if layout not found
+ fallback_layout = QVBoxLayout(self)
+ fallback_layout.setContentsMargins(6, 6, 6, 6)
+ fallback_layout.addWidget(self.image_view)
+ # Add a lightweight text overlay to indicate slice orientation and value of orthogonal axis
+ try:
+ self._slice_info_text = pg.TextItem("", color="w", anchor=(0, 1))
+ self.plot_item.addItem(self._slice_info_text)
+ except Exception:
+ self._slice_info_text = None
+
+ # Pending update queue + throttle timer
+ self._pending = None # type: Optional[Tuple[object, np.ndarray, np.ndarray]]
+ self._timer = QTimer(self)
+ self._timer.setInterval(100) # ~10 fps coalesced updates
+ self._timer.timeout.connect(self._flush_pending)
+
+ # Store initial parent settings for consistency
+ self._last_synced_levels = None
+ self._last_synced_colormap = None
+
+ # Initial sync of display settings from parent
+ try:
+ self.sync_levels()
+ except Exception:
+ pass
+ try:
+ self.sync_colormap()
+ except Exception:
+ pass
+
+ def schedule_update(self, slice_mesh, normal: np.ndarray, origin: np.ndarray) -> None:
+ """
+ Called by the parent after updating the 3D slice. Stores latest data and starts the coalescing timer.
+ """
+ try:
+ # Store latest references; slice_mesh is a PyVista dataset (PolyData)
+ self._pending = (slice_mesh, np.array(normal, dtype=float), np.array(origin, dtype=float))
+ if not self._timer.isActive():
+ self._timer.start()
+ except Exception:
+ # Silently ignore to avoid impacting parent
+ pass
+
+ def _flush_pending(self) -> None:
+ """
+ Timer slot: perform a single rasterization from the most recent pending slice and update the ImageItem,
+ with axes labeled to HK/KL/HL and plot ranges set to physical coordinates.
+ """
+ try:
+ self._timer.stop()
+ if not self._pending:
+ return
+ slice_mesh, normal, origin = self._pending
+ self._pending = None
+
+ # Extract points and intensities from the PyVista slice mesh
+ try:
+ pts = np.asarray(slice_mesh.points, dtype=float) # (N,3)
+ except Exception:
+ pts = np.empty((0, 3), dtype=float)
+ try:
+ vals = np.asarray(slice_mesh["intensity"], dtype=float).reshape(-1)
+ except Exception:
+ vals = np.zeros((len(pts),), dtype=float)
+
+ if pts.size == 0 or vals.size == 0 or pts.shape[0] != vals.shape[0]:
+ # Nothing to render
+ return
+
+ # Target raster shape: prefer parent's curr_shape, then orig_shape, else fallback
+ target_shape = self._get_target_shape()
+ H = max(int(target_shape[0]), 1)
+ W = max(int(target_shape[1]), 1)
+
+ # Rasterize to 2D image + axis ranges/orientation
+ result = self._rasterize_to_image(pts, vals, normal, origin, H, W)
+ if result is None:
+ return
+ image, U_min, U_max, V_min, V_max, orientation, orth_label, orth_value = result
+ if image is None or (hasattr(image, "size") and image.size == 0):
+ return
+
+ # Update the image content
+ try:
+ self.image_view.setImage(
+ image.astype(np.float32),
+ autoLevels=False,
+ autoRange=False,
+ autoHistogramRange=False
+ )
+ except Exception:
+ # Fallback to underlying ImageItem
+ try:
+ self.image_view.imageItem.setImage(image.astype(np.float32), autoLevels=False)
+ except Exception:
+ pass
+
+ # Apply item transform to map pixels to physical HKL coordinates
+ try:
+ it = self.image_view.imageItem
+ try:
+ it.resetTransform()
+ except Exception:
+ try:
+ it.setTransform(pg.QtGui.QTransform()) # identity
+ except Exception:
+ pass
+ sx = float(U_max - U_min) / float(W if W != 0 else 1)
+ sy = float(V_max - V_min) / float(H if H != 0 else 1)
+ if not np.isfinite(sx) or sx == 0.0:
+ sx = 1.0
+ if not np.isfinite(sy) or sy == 0.0:
+ sy = 1.0
+ try:
+ it.scale(sx, sy)
+ it.setPos(U_min, V_min)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Set axis ranges and labels based on orientation
+ try:
+ self.plot_item.setXRange(U_min, U_max, padding=0)
+ self.plot_item.setYRange(V_min, V_max, padding=0)
+ except Exception:
+ pass
+ try:
+ if orientation == "HK":
+ self.plot_item.setLabel('bottom', 'H')
+ self.plot_item.setLabel('left', 'K')
+ elif orientation == "KL":
+ self.plot_item.setLabel('bottom', 'K')
+ self.plot_item.setLabel('left', 'L')
+ elif orientation == "HL":
+ self.plot_item.setLabel('bottom', 'H')
+ self.plot_item.setLabel('left', 'L')
+ else:
+ self.plot_item.setLabel('bottom', 'U')
+ self.plot_item.setLabel('left', 'V')
+ except Exception:
+ pass
+
+ # Update slice info text (e.g., "HK plane (L = 1.23)")
+ try:
+ if getattr(self, "_slice_info_text", None):
+ txt = str(orientation)
+ if txt and txt != "Custom":
+ txt += " plane"
+ if orth_label is not None and orth_value is not None and np.isfinite(orth_value):
+ txt += f" ({orth_label} = {orth_value:.2f})"
+ self._slice_info_text.setText(txt)
+ try:
+ # Place near top-left of current view
+ self._slice_info_text.setPos(U_min, V_max)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Inherit levels and colormap
+ self.sync_levels()
+ self.sync_colormap()
+ except Exception:
+ # Keep errors contained to avoid breaking parent interactions
+ pass
+
+ def sync_levels(self) -> None:
+ """
+ Inherit min/max intensity levels from the parent and apply them to the ImageItem.
+ Only updates if levels have changed to avoid unnecessary operations.
+ """
+ try:
+ vmin = float(self.parent.sbMinIntensity.value())
+ vmax = float(self.parent.sbMaxIntensity.value())
+ if vmin > vmax:
+ vmin, vmax = vmax, vmin
+
+ # Check if levels have changed
+ current_levels = (vmin, vmax)
+ if self._last_synced_levels != current_levels:
+ try:
+ # ImageView supports setLevels(min, max)
+ self.image_view.setLevels(vmin, vmax)
+ except Exception:
+ try:
+ self.image_view.imageItem.setLevels((vmin, vmax))
+ except Exception:
+ pass
+ self._last_synced_levels = current_levels
+ except Exception:
+ pass
+
+ def sync_colormap(self) -> None:
+ """
+ Inherit the current colormap from the parent and apply it to the ImageItem.
+ Only updates if colormap has changed to avoid unnecessary operations.
+ """
+ try:
+ cmap_name = str(self.parent.cbColorMapSelect.currentText())
+ except Exception:
+ cmap_name = "viridis"
+
+ # Check if colormap has changed
+ if self._last_synced_colormap == cmap_name:
+ return
+
+ lut = None
+ # Try pyqtgraph ColorMap
+ try:
+ if hasattr(pg, "colormap") and hasattr(pg.colormap, "get"):
+ try:
+ cmap = pg.colormap.get(cmap_name)
+ except Exception:
+ # Some names may be in matplotlib but not in pg
+ cmap = None
+ if cmap is not None:
+ lut = cmap.getLookupTable(nPts=256)
+ except Exception:
+ lut = None
+
+ # Fallback via matplotlib if needed
+ if lut is None:
+ try:
+ import matplotlib.cm as cm
+ mpl_cmap = cm.get_cmap(cmap_name)
+ # Build LUT as uint8 Nx3
+ xs = np.linspace(0.0, 1.0, 256, dtype=float)
+ colors = mpl_cmap(xs, bytes=True) # returns Nx4 uint8
+ lut = colors[:, :3]
+ except Exception:
+ # Last resort: grayscale
+ xs = (np.linspace(0, 255, 256)).astype(np.uint8)
+ lut = np.column_stack([xs, xs, xs])
+
+ try:
+ self.image_view.imageItem.setLookupTable(lut)
+ self._last_synced_colormap = cmap_name
+ except Exception:
+ pass
+
+ def sync_all_settings(self) -> None:
+ """
+ Synchronize all rendering settings from parent (levels, colormap, etc.).
+ Called when the 2D view is first opened or when major changes occur.
+ """
+ try:
+ # Force sync by clearing cached values
+ self._last_synced_levels = None
+ self._last_synced_colormap = None
+
+ # Sync all settings
+ self.sync_levels()
+ self.sync_colormap()
+
+ # Apply any reduction factor settings if available
+ try:
+ if hasattr(self.parent, '_applied_reduction_factor'):
+ # The 2D view inherits the same reduction as applied to the 3D view
+ pass # Already handled through target shape
+ except Exception:
+ pass
+
+ except Exception:
+ pass
+
+ def _get_target_shape(self) -> Tuple[int, int]:
+ """
+ Determine target (H, W) for the raster image based on parent's known shapes.
+ Defaults to 512x512.
+ """
+ # Prefer current shape if valid
+ try:
+ cs = getattr(self.parent, "curr_shape", None)
+ if isinstance(cs, (tuple, list)) and len(cs) == 2 and int(cs[0]) > 0 and int(cs[1]) > 0:
+ return int(cs[0]), int(cs[1])
+ except Exception:
+ pass
+ # Fallback to original shape
+ try:
+ os_ = getattr(self.parent, "orig_shape", None)
+ if isinstance(os_, (tuple, list)) and len(os_) == 2 and int(os_[0]) > 0 and int(os_[1]) > 0:
+ return int(os_[0]), int(os_[1])
+ except Exception:
+ pass
+ # Default
+ return (512, 512)
+
+ def _infer_orientation_and_axes(self, normal: np.ndarray) -> Tuple[str, Optional[Tuple[int, int]], Optional[str]]:
+ """
+ Infer slice orientation from the plane normal.
+ Returns (orientation, (u_idx, v_idx) for axis-aligned mapping or None, orth_label).
+ Orientation is one of 'HK', 'KL', 'HL', or 'Custom'.
+ u_idx/v_idx map to columns of pts (0:H, 1:K, 2:L).
+ orth_label is the axis perpendicular to the plane ('L' for HK, 'H' for KL, 'K' for HL).
+ """
+ try:
+ n = np.array(normal, dtype=float)
+ n_norm = float(np.linalg.norm(n))
+ if not np.isfinite(n_norm) or n_norm <= 0.0:
+ n = np.array([0.0, 0.0, 1.0], dtype=float)
+ else:
+ n = n / n_norm
+ X = np.array([1.0, 0.0, 0.0], dtype=float) # H
+ Y = np.array([0.0, 1.0, 0.0], dtype=float) # K
+ Z = np.array([0.0, 0.0, 1.0], dtype=float) # L
+ tol = 0.95
+ dX = abs(float(np.dot(n, X)))
+ dY = abs(float(np.dot(n, Y)))
+ dZ = abs(float(np.dot(n, Z)))
+ if dZ >= tol:
+ # Normal ~ L → HK plane
+ return "HK", (0, 1), "L"
+ if dX >= tol:
+ # Normal ~ H → KL plane
+ return "KL", (1, 2), "H"
+ if dY >= tol:
+ # Normal ~ K → HL plane
+ return "HL", (0, 2), "K"
+ return "Custom", None, None
+ except Exception:
+ return "Custom", None, None
+
+ def _rasterize_to_image(
+ self,
+ pts: np.ndarray,
+ vals: np.ndarray,
+ normal: np.ndarray,
+ origin: np.ndarray,
+ H: int,
+ W: int,
+ ) -> Optional[Tuple[np.ndarray, float, float, float, float, str, Optional[str], Optional[float]]]:
+ """
+ Rasterize the slice to an HxW image and compute physical axis ranges and orientation.
+ Returns a tuple: (image, U_min, U_max, V_min, V_max, orientation, orth_label, orth_value)
+ - orientation in {'HK','KL','HL','Custom'}
+ - U/V correspond to physical axes when orientation is axis-aligned; otherwise derived basis projection.
+ - orth_label/orth_value represent the axis perpendicular to the slice plane (e.g., 'L' and origin[2] for HK).
+ """
+ try:
+ n = np.array(normal, dtype=float)
+ o = np.array(origin, dtype=float)
+
+ # Normalize normal
+ n_norm = float(np.linalg.norm(n))
+ if not np.isfinite(n_norm) or n_norm <= 0.0:
+ n = np.array([0.0, 0.0, 1.0], dtype=float)
+ else:
+ n = n / n_norm
+
+ orientation, uv_idxs, orth_label = self._infer_orientation_and_axes(n)
+
+ if uv_idxs is not None:
+ # Axis-aligned planes: use absolute HKL coordinates directly
+ u_idx, v_idx = uv_idxs
+ U = pts[:, u_idx].astype(float)
+ V = pts[:, v_idx].astype(float)
+ U_min, U_max = float(np.min(U)), float(np.max(U))
+ V_min, V_max = float(np.min(V)), float(np.max(V))
+ # Handle degenerate ranges
+ if (not np.isfinite(U_min)) or (not np.isfinite(U_max)) or (U_max == U_min):
+ U_min, U_max = -0.5, 0.5
+ if (not np.isfinite(V_min)) or (not np.isfinite(V_max)) or (V_max == V_min):
+ V_min, V_max = -0.5, 0.5
+ # Weighted histogram (average)
+ sum_img, _, _ = np.histogram2d(V, U, bins=[H, W], range=[[V_min, V_max], [U_min, U_max]], weights=vals)
+ cnt_img, _, _ = np.histogram2d(V, U, bins=[H, W], range=[[V_min, V_max], [U_min, U_max]])
+ with np.errstate(invalid="ignore", divide="ignore"):
+ img = np.zeros_like(sum_img, dtype=np.float32)
+ nz = cnt_img > 0
+ img[nz] = (sum_img[nz] / cnt_img[nz]).astype(np.float32)
+ img[~nz] = 0.0
+ # Orthogonal axis value from origin
+ orth_value = None
+ try:
+ if orth_label == "L":
+ orth_value = float(o[2])
+ elif orth_label == "H":
+ orth_value = float(o[0])
+ elif orth_label == "K":
+ orth_value = float(o[1])
+ except Exception:
+ orth_value = None
+ return img, U_min, U_max, V_min, V_max, orientation, orth_label, orth_value
+
+ # Custom orientation: fall back to in-plane basis projection
+ # Choose a reference axis not parallel to n to make in-plane basis
+ world_axes = [
+ np.array([1.0, 0.0, 0.0], dtype=float),
+ np.array([0.0, 1.0, 0.0], dtype=float),
+ np.array([0.0, 0.0, 1.0], dtype=float),
+ ]
+ ref = world_axes[0]
+ for ax in world_axes:
+ if abs(float(np.dot(ax, n))) < 0.9:
+ ref = ax
+ break
+ u = np.cross(n, ref)
+ u_norm = float(np.linalg.norm(u))
+ if not np.isfinite(u_norm) or u_norm <= 0.0:
+ ref = np.array([0.0, 1.0, 0.0], dtype=float)
+ u = np.cross(n, ref)
+ u_norm = float(np.linalg.norm(u))
+ if not np.isfinite(u_norm) or u_norm <= 0.0:
+ u = np.array([1.0, 0.0, 0.0], dtype=float)
+ u_norm = 1.0
+ u = u / u_norm
+ v = np.cross(n, u)
+ v_norm = float(np.linalg.norm(v))
+ if not np.isfinite(v_norm) or v_norm <= 0.0:
+ v = np.array([0.0, 1.0, 0.0], dtype=float)
+
+ # Project points into plane coordinates (origin-relative for custom)
+ rel = pts - o[None, :]
+ U = rel.dot(u) # shape (N,)
+ V = rel.dot(v) # shape (N,)
+
+ # Handle degenerate ranges
+ U_min, U_max = float(np.min(U)), float(np.max(U))
+ V_min, V_max = float(np.min(V)), float(np.max(V))
+ if not np.isfinite(U_min) or not np.isfinite(U_max) or (U_max == U_min):
+ U_min, U_max = -0.5, 0.5
+ if not np.isfinite(V_min) or not np.isfinite(V_max) or (V_max == V_min):
+ V_min, V_max = -0.5, 0.5
+
+ # Histogram to image
+ sum_img, _, _ = np.histogram2d(V, U, bins=[H, W], range=[[V_min, V_max], [U_min, U_max]], weights=vals)
+ cnt_img, _, _ = np.histogram2d(V, U, bins=[H, W], range=[[V_min, V_max], [U_min, U_max]])
+ with np.errstate(invalid="ignore", divide="ignore"):
+ img = np.zeros_like(sum_img, dtype=np.float32)
+ nz = cnt_img > 0
+ img[nz] = (sum_img[nz] / cnt_img[nz]).astype(np.float32)
+ img[~nz] = 0.0
+
+ # Orthogonal scalar position for custom
+ try:
+ orth_value = float(np.dot(n, o))
+ except Exception:
+ orth_value = None
+
+ return img, U_min, U_max, V_min, V_max, "Custom", None, orth_value
+ except Exception:
+ return None
diff --git a/viewer/launcher.py b/viewer/launcher.py
new file mode 100644
index 0000000..24a2f18
--- /dev/null
+++ b/viewer/launcher.py
@@ -0,0 +1,360 @@
+import sys
+import os, subprocess, sys
+from pathlib import Path
+from PyQt5 import uic
+from PyQt5.QtWidgets import QApplication, QDialog, QMessageBox, QLabel, QPushButton, QHBoxLayout
+from PyQt5.QtCore import QTimer, Qt
+from viewer.views_registry.registry import VIEWS
+
+
+class LauncherDialog(QDialog):
+ def __init__(self):
+ super(LauncherDialog, self).__init__()
+ uic.loadUi('gui/dashpva.ui', self)
+ self.processes = {}
+ self._timer = QTimer(self)
+ self._timer.setInterval(500)
+ self._timer.timeout.connect(self._poll_processes)
+ self._timer.start()
+
+ # Insert dynamic "Views" section (from registry) just before Post Analysis Tools
+ try:
+ self._insert_views_section()
+ except Exception:
+ # Robust to UI changes; if insertion fails, skip silently
+ pass
+
+ # Insert a Tools section with utility buttons (e.g., Metadata Converter) at the bottom
+ try:
+ self._insert_utils_section()
+ except Exception:
+ # Fail silently if layout changes; section is optional
+ pass
+
+
+ # Wire buttons to launchers
+ if hasattr(self, 'btn_hkl3d_viewer'):
+ self.btn_hkl3d_viewer.clicked.connect(
+ lambda: self.launch(
+ 'hkl3d_viewer',
+ [sys.executable, 'viewer/hkl_3d_viewer.py'],
+ self.btn_hkl3d_viewer,
+ 'HKL 3D Viewer — Running…'
+ )
+ )
+ if hasattr(self, 'btn_hkl3d_slicer'):
+ self.btn_hkl3d_slicer.clicked.connect(
+ lambda: self.launch(
+ 'hkl3d_slicer',
+ [sys.executable, 'viewer/hkl_3d_slice_window.py'],
+ self.btn_hkl3d_slicer,
+ 'HKL 3D Slicer — Running…'
+ )
+ )
+ if hasattr(self, 'btn_area_detector'):
+ self.btn_area_detector.clicked.connect(
+ lambda: self.launch(
+ 'area_detector',
+ [sys.executable, 'viewer/area_det_viewer.py'],
+ self.btn_area_detector,
+ 'Area Detector Viewer — Running…'
+ )
+ )
+ if hasattr(self, 'btn_pva_setup'):
+ self.btn_pva_setup.clicked.connect(
+ lambda: self.launch(
+ 'pva_setup',
+ [sys.executable, 'pva_setup/pva_workflow_setup_dialog.py'],
+ self.btn_pva_setup,
+ 'PVA Workflow Setup — Running…'
+ )
+ )
+ if hasattr(self, 'btn_sim_setup'):
+ self.btn_sim_setup.clicked.connect(
+ lambda: self.launch(
+ 'sim_setup',
+ [sys.executable, 'consumers/sim_rsm_data.py'],
+ self.btn_sim_setup,
+ 'caIOC(Name) — Running…',
+ quiet=True
+ )
+ )
+ if hasattr(self, 'btn_workbench'):
+ self.btn_workbench.clicked.connect(
+ lambda: self.launch(
+ 'workbench',
+ [sys.executable, 'viewer/workbench/workbench.py'],
+ self.btn_workbench,
+ 'Workbench — Running…'
+ )
+ )
+ if hasattr(self, 'btn_settings'):
+ self.btn_settings.clicked.connect(
+ lambda: self.launch(
+ 'settings',
+ [sys.executable, 'viewer/settings/settings_dialog.py'],
+ self.btn_settings,
+ 'Settings — Running…'
+ )
+ )
+ if hasattr(self, 'btn_exit'):
+ self.btn_exit.clicked.connect(self.request_close)
+ if hasattr(self, 'btn_shutdown_all'):
+ self.btn_shutdown_all.clicked.connect(self._confirm_shutdown_all)
+
+ self._update_status()
+
+
+ def _insert_views_section(self):
+ """Insert a Monitor header and a single Monitor button before Post Analysis Tools."""
+ layout = self.layout()
+ if layout is None:
+ return
+ target = getattr(self, 'lbl_post_analysis_header', None)
+ insert_at = layout.indexOf(target) if target is not None else -1
+ if insert_at < 0:
+ # Fallback: append near end
+ insert_at = layout.count()
+
+ # Header: Monitor
+ header = QLabel("Monitor", self)
+ header.setStyleSheet("font-weight: bold; color: #34495e; font-size: 12px;")
+ header.setAlignment(Qt.AlignCenter)
+ layout.insertWidget(insert_at, header)
+ insert_at += 1
+
+ # Single Monitor button
+ btn = QPushButton("Monitor", self)
+ btn.setToolTip("Open the scan monitor")
+ layout.insertWidget(insert_at, btn)
+ insert_at += 1
+
+ # Bind to existing launch(...) for process tracking
+ btn.clicked.connect(
+ lambda _=False: self.launch(
+ 'monitor_scan',
+ [sys.executable, 'viewer/scan_view.py'],
+ btn,
+ 'Monitor — Running…'
+ )
+ )
+
+ def _insert_utils_section(self):
+ """Insert a 'Tools' header and buttons for utility tools (like Metadata Converter)
+ below the entire 'Post Analysis Tools' section, but above the status and bottom bar."""
+ layout = self.layout()
+ if layout is None:
+ return
+ # Compute insertion point:
+ # Place just before the status label if present, otherwise before the bottom button bar,
+ # otherwise append at the end.
+ insert_at = -1
+ target_status = getattr(self, 'lbl_status', None)
+ if target_status is not None:
+ idx = layout.indexOf(target_status)
+ if idx >= 0:
+ insert_at = idx
+ if insert_at < 0:
+ target_bar = getattr(self, 'horizontalLayout', None)
+ if target_bar is not None:
+ idx = layout.indexOf(target_bar)
+ if idx >= 0:
+ insert_at = idx
+ if insert_at < 0:
+ insert_at = layout.count()
+
+ # Header
+ header = QLabel("Tools", self)
+ header.setStyleSheet("font-weight: bold; color: #34495e; font-size: 12px;")
+ header.setAlignment(Qt.AlignCenter)
+ layout.insertWidget(insert_at, header)
+ insert_at += 1
+
+ # Metadata Converter button
+ btn = QPushButton("Metadata Converter", self)
+ btn.setToolTip("Open the Metadata Converter tool")
+ layout.insertWidget(insert_at, btn)
+ insert_at += 1
+
+ # Bind to existing launch(...) for process tracking
+ btn.clicked.connect(
+ lambda _=False: self.launch(
+ 'metadata_converter',
+ [sys.executable, 'viewer/tools/metadata_converter_gui.py'],
+ btn,
+ 'Metadata Converter — Running…'
+ )
+ )
+
+ def launch(self, key, cmd, button, running_text, quiet=False):
+ """Start a child process and update UI indicators."""
+ if key in self.processes and self.processes[key]['popen'].poll() is None:
+ # Already running
+ return
+ original_text = button.text()
+ button.setEnabled(False)
+ button.setText(f"{original_text} — Launching…")
+
+ kwargs = {}
+ if quiet:
+ kwargs['stdout'] = subprocess.DEVNULL
+ kwargs['stderr'] = subprocess.DEVNULL
+
+ try:
+ p = subprocess.Popen(cmd, **kwargs)
+ button.setText(running_text)
+ self.processes[key] = {
+ 'popen': p,
+ 'button': button,
+ 'original_text': original_text,
+ 'running_text': running_text
+ }
+ except Exception as e:
+ QMessageBox.critical(
+ self,
+ 'Launch Failed',
+ f'Failed to launch:\n{" ".join(cmd)}\n\n{e}'
+ )
+ button.setText(original_text)
+ button.setEnabled(True)
+
+ self._update_status()
+
+ def _poll_processes(self):
+ """Periodic check for finished processes to restore UI state."""
+ finished = []
+ for key, entry in self.processes.items():
+ p = entry['popen']
+ if p.poll() is not None:
+ # Process ended
+ entry['button'].setText(entry['original_text'])
+ entry['button'].setEnabled(True)
+ finished.append(key)
+ for key in finished:
+ self.processes.pop(key, None)
+ self._update_status()
+
+ def _update_status(self):
+ """Update status label and button states."""
+ count = len(self.processes)
+ if hasattr(self, 'lbl_status'):
+ self.lbl_status.setText('No modules running' if count == 0 else f'{count} module(s) running')
+ if hasattr(self, 'btn_exit'):
+ # Exit remains enabled; closing will prompt if processes are running
+ self.btn_exit.setEnabled(True)
+ if hasattr(self, 'btn_shutdown_all'):
+ # Enable Shutdown All only when there are running modules
+ self.btn_shutdown_all.setEnabled(count > 0)
+
+ def _format_running_modules_list(self):
+ """Return a human-readable list of running modules and their PIDs."""
+ lines = []
+ for key, entry in self.processes.items():
+ p = entry.get('popen')
+ if p is None or p.poll() is not None:
+ continue
+ name = entry.get('running_text', key)
+ if ' — ' in name:
+ name = name.split(' — ')[0]
+ try:
+ pid = p.pid
+ except Exception:
+ pid = 'unknown'
+ lines.append(f"- {name} (PID {pid})")
+ if not lines:
+ return "Running modules:\nNone"
+ return "Running modules:\n" + "\n".join(lines)
+
+ def _terminate_proc(self, p, timeout=3.0):
+ """Attempt graceful terminate, then force kill if still alive."""
+ try:
+ if p.poll() is None:
+ p.terminate()
+ try:
+ p.wait(timeout=timeout)
+ except Exception:
+ pass
+ if p.poll() is None:
+ p.kill()
+ except Exception:
+ pass
+
+ def shutdown_all(self):
+ """Force-stop all running modules and restore UI state."""
+ for key, entry in list(self.processes.items()):
+ self._terminate_proc(entry['popen'])
+ entry['button'].setText(entry['original_text'])
+ entry['button'].setEnabled(True)
+ self.processes.pop(key, None)
+ self._update_status()
+
+ def _confirm_shutdown_all(self):
+ """Confirm and force-stop all running modules."""
+ count = len(self.processes)
+ if count == 0:
+ return
+ text = f"{self._format_running_modules_list()}\n\nAre you sure you want to force stop all running modules?\n\nData might be lost."
+ resp = QMessageBox.question(
+ self,
+ 'Shutdown All Modules',
+ text,
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if resp == QMessageBox.Yes:
+ self.shutdown_all()
+
+ def request_close(self):
+ """Prompt to force stop modules before exiting if any are running."""
+ try:
+ any_running = any(entry['popen'].poll() is None for entry in self.processes.values())
+ except Exception:
+ any_running = False
+ if any_running:
+ text = f"{self._format_running_modules_list()}\n\nForce stop all and exit?\n\nData might be lost."
+ resp = QMessageBox.question(
+ self,
+ 'Exit Launcher',
+ text,
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if resp == QMessageBox.Yes:
+ self.shutdown_all()
+ self.close()
+ else:
+ self.close()
+
+ def closeEvent(self, event):
+ """On close, prompt to force-stop modules if any are running."""
+ try:
+ any_running = any(entry['popen'].poll() is None for entry in self.processes.values())
+ except Exception:
+ any_running = False
+ if any_running:
+ text = f"{self._format_running_modules_list()}\n\nForce stop all and exit?\n\nData might be lost."
+ resp = QMessageBox.question(
+ self,
+ 'Exit Launcher',
+ text,
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+ if resp == QMessageBox.Yes:
+ self.shutdown_all()
+ event.accept()
+ else:
+ event.ignore()
+ else:
+ event.accept()
+
+
+def main():
+ app = QApplication(sys.argv)
+ dlg = LauncherDialog()
+ dlg.show()
+ app.exec_()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/viewer/pv_setup_dialog.py b/viewer/pv_setup_dialog.py
deleted file mode 100755
index 7558f91..0000000
--- a/viewer/pv_setup_dialog.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import json
-from PyQt5 import uic
-from PyQt5.QtCore import Qt
-from PyQt5.QtWidgets import QDialog, QFileDialog, QSizePolicy, QLabel, QFormLayout, QWidget, QFrame
-
-
-class PVSetupDialog(QDialog):
- def __init__(self, parent, file_mode, path=None):
- super(PVSetupDialog,self).__init__(parent)
- uic.loadUi('gui/edit_add_config_dialog.ui',self)
- self.config_dict = {}
- self.path = path
- self.file_mode = file_mode
-
- self.form_widget = QWidget()
- self.config_layout = QFormLayout(parent=self.form_widget)
- self.config_layout.setLabelAlignment(Qt.AlignRight)
- self.scroll_area.setWidget(self.form_widget)
- self.scroll_area.setWidgetResizable(True)
-
- self.load_config()
- self.show()
-
- def save_file_dialog(self):
- path, _ = QFileDialog.getSaveFileName(self, 'Save File', 'pv_configs', '.json (*.json)')
- return path
-
- def load_config(self):
- if self.file_mode == 'w':
- return
- with open(self.path, "r") as config_json:
- self.config_dict: dict = json.load(config_json)
- for key, value in self.config_dict.items():
- # set the label part of the form widget
- label = QLabel(key + ':')
- label.setMinimumHeight(35)
- label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
- # set the field part of the form widget
- field = QLabel(value)
- field.setMinimumHeight(35)
- field.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum)
- field.setFrameShape(QFrame.Shape.Box)
- field.setFrameShadow(QFrame.Shadow.Sunken)
-
- self.config_layout.addRow(label, field)
\ No newline at end of file
diff --git a/viewer/pva_reader.py b/viewer/pva_reader.py
deleted file mode 100644
index 4948c4c..0000000
--- a/viewer/pva_reader.py
+++ /dev/null
@@ -1,282 +0,0 @@
-import toml
-import numpy as np
-import pvaccess as pva
-import bitshuffle
-import blosc2
-import lz4.block
-from epics import camonitor, caget
-
-class PVAReader:
- def __init__(self, input_channel='s6lambda1:Pva1:Image', provider=pva.PVA, config_filepath: str = 'pv_configs/metadata_pvs.toml'):
- """
- Initializes the PVA Reader for monitoring connections and handling image data.
-
- Args:
- input_channel (str): Input channel for the PVA connection.
- provider (protocol): The protocol for the PVA channel.
- config_filepath (str): File path to the configuration TOML file.
- """
- # Each PVA ScalarType is enumerated in C++ starting 1-10
- # This means we map them as numbers to a numpy datatype which we parse from pva codec parameters
- # Then use this to correctly decompress the image depending on the codec used
- self.NUMPY_DATA_TYPE_MAP = {
- pva.UBYTE : np.dtype('uint8'),
- pva.BYTE : np.dtype('int8'),
- pva.USHORT : np.dtype('uint16'),
- pva.SHORT : np.dtype('int16'),
- pva.UINT : np.dtype('uint32'),
- pva.INT : np.dtype('int32'),
- pva.ULONG : np.dtype('uint64'),
- pva.LONG : np.dtype('int64'),
- pva.FLOAT : np.dtype('float32'),
- pva.DOUBLE : np.dtype('float64')
- }
- # This also means we can parse the pva codec parameters to show the correct datatype in viewer
- self. NTNDA_DATA_TYPE_MAP = {
- pva.UBYTE : 'ubyteValue',
- pva.BYTE : 'byteValue',
- pva.USHORT : 'ushortValue',
- pva.SHORT : 'shortValue',
- pva.UINT : 'uintValue',
- pva.INT : 'intValue',
- pva.ULONG : 'ulongValue',
- pva.LONG : 'longValue',
- pva.FLOAT : 'floatValue',
- pva.DOUBLE : 'doubleValue',
- }
-
- self.input_channel = input_channel
- self.provider = provider
- self.config_filepath = config_filepath
- self.channel = pva.Channel(self.input_channel, self.provider)
- self.pva_prefix = input_channel.split(":")[0]
- # variables that will store pva data
- self.pva_object = None
- self.image = None
- self.shape = (0,0)
- self.pixel_ordering = 'F'
- self.image_is_transposed = False
- self.attributes = []
- self.timestamp = None
- self.data_type = None
- self.display_dtype = None
- # variables used for parsing analysis PV
- self.analysis_index = None
- self.analysis_exists = False
- self.analysis_attributes = {}
- # variables used for later logic
- self.last_array_id = None
- self.frames_missed = 0
- self.frames_received = 0
- self.id_diff = 0
- # variables used for ROI and Stats PVs from config
- self.config = {}
- self.rois = {}
- self.stats = {}
-
- if self.config_filepath != '':
- with open(self.config_filepath, 'r') as toml_file:
- # loads the pvs in the toml file into a python dictionary
- self.config:dict = toml.load(toml_file)
- self.stats:dict = self.config["STATS"]
- if self.config["CONSUMER_TYPE"] == "spontaneous":
- # TODO: change to dictionaries that store postions as keys and pv as value
- self.analysis_cache_dict = {"Intensity": {},
- "ComX": {},
- "ComY": {},
- "Position": {}}
-
- def pva_callbackSuccess(self, pv) -> None:
- """
- Callback for handling monitored PV changes.
-
- Args:
- pv (PvaObject): The PV object received by the channel monitor.
- """
- self.pva_object = pv
- self.parse_image_data_type()
- self.pva_to_image()
- self.parse_pva_attributes()
- self.parse_roi_pvs()
- if (self.analysis_index is None) and (not(self.analysis_exists)): #go in with the assumption analysis Doesn't Exist, is changed to True otherwise
- self.analysis_index = self.locate_analysis_index()
- # Only runs if an analysis index was found
- if self.analysis_exists:
- self.analysis_attributes = self.attributes[self.analysis_index]
- if self.config["CONSUMER_TYPE"] == "spontaneous":
- # turns axis1 and axis2 into a tuple
- incoming_coord = (self.analysis_attributes["value"][0]["value"].get("Axis1", 0.0),
- self.analysis_attributes["value"][0]["value"].get("Axis2", 0.0))
- # use a tuple as a key so that we can check if there is a repeat position
- self.analysis_cache_dict["Intensity"].update({incoming_coord: self.analysis_cache_dict["Intensity"].get(incoming_coord, 0) + self.analysis_attributes["value"][0]["value"].get("Intensity", 0.0)})
- self.analysis_cache_dict["ComX"].update({incoming_coord: self.analysis_cache_dict["ComX"].get(incoming_coord, 0) + self.analysis_attributes["value"][0]["value"].get("ComX", 0.0)})
- self.analysis_cache_dict["ComY"].update({incoming_coord:self.analysis_cache_dict["ComY"].get(incoming_coord, 0) + self.analysis_attributes["value"][0]["value"].get("ComY", 0.0)})
- # double storing of the postion, will find out if needed
- self.analysis_cache_dict["Position"][incoming_coord] = incoming_coord
-
- def roi_backup_callback(self, pvname, value, **kwargs):
- name_components = pvname.split(":")
- roi_key = name_components[1]
- pv_key = name_components[2]
- pv_value = value
- # can't append simply by using 2 keys in a row (self.rois[roi_key][pv_key]), there must be an inner dict to call
- # then adds the key to the inner dictionary with update
- self.rois.setdefault(roi_key, {}).update({pv_key: pv_value})
-
- def parse_image_data_type(self) -> None:
- """
- Parses the PVA Object to determine the incoming data type.
- """
- if self.pva_object is not None:
- try:
- self.data_type = list(self.pva_object['value'][0].keys())[0]
- self.display_dtype = self.data_type if self.pva_object['codec']['name'] == '' else self.NTNDA_DATA_TYPE_MAP.get(self.pva_object['codec']['parameters'][0]['value'])
-
- except:
- self.display_dtype = "could not detect"
-
- def parse_pva_attributes(self) -> None:
- """
- Converts the PVA object to a Python dictionary and extracts its attributes.
- """
- if self.pva_object is not None:
- self.attributes: list = self.pva_object.get().get("attribute", [])
-
- def locate_analysis_index(self) -> int|None:
- """
- Locates the index of the analysis attribute in the PVA attributes.
-
- Returns:
- int: The index of the analysis attribute or None if not found.
- """
- if self.attributes:
- for i in range(len(self.attributes)):
- attr_pv: dict = self.attributes[i]
- if attr_pv.get("name", "") == "Analysis":
- self.analysis_exists = True
- return i
- else:
- return None
-
- def parse_roi_pvs(self) -> None:
- """
- Parses attributes to extract ROI-specific PV information.
- """
- if self.attributes:
- for i in range(len(self.attributes)):
- attr_pv: dict = self.attributes[i]
- attr_name:str = attr_pv.get("name", "")
- if "ROI" in attr_name:
- name_components = attr_name.split(":")
- prefix = name_components[0]
- roi_key = name_components[1]
- pv_key = name_components[2]
- pv_value = attr_pv["value"][0]["value"]
- # can't append simply by using 2 keys in a row, there must be a value to call to then add to
- # then adds the key to the inner dictionary with update
- self.rois.setdefault(roi_key, {}).update({pv_key: pv_value})
-
- def pva_to_image(self) -> None:
- """
- Converts the PVA Object to an image array and determines if a frame was missed.
- Handles bslz4 and lz4 compressed image data.
- """
- try:
- if 'dimension' in self.pva_object:
- self.shape = tuple([dim['size'] for dim in self.pva_object['dimension']])
-
- if self.pva_object['codec']['name'] == 'bslz4':
- # Handle BSLZ4 compressed data
- dtype = self.NUMPY_DATA_TYPE_MAP.get(self.pva_object['codec']['parameters'][0]['value'])
- uncompressed_size = self.pva_object['uncompressedSize'] // dtype.itemsize # size has to be divided by bytes needed to store dtype in bitshuffle
- uncompressed_shape = (uncompressed_size,)
- compressed_image = self.pva_object['value'][0][self.data_type]
- # Decompress numpy array to correct datatype
- self.image = bitshuffle.decompress_lz4(compressed_image, uncompressed_shape, dtype, 0)
-
- elif self.pva_object['codec']['name'] == 'lz4':
- # Handle LZ4 compressed data
- dtype = self.NUMPY_DATA_TYPE_MAP.get(self.pva_object['codec']['parameters'][0]['value'])
- uncompressed_size = self.pva_object['uncompressedSize'] # raw size is used to decompress it into an lz4 buffer
- compressed_image = self.pva_object['value'][0][self.data_type]
- # Decompress using lz4.block
- decompressed_bytes = lz4.block.decompress(compressed_image, uncompressed_size)
- # Convert bytes to numpy array with correct dtype
- self.image = np.frombuffer(decompressed_bytes, dtype=dtype) # dtype is used to convert from buffer to correct dtype from bytes
-
- elif self.pva_object['codec']['name'] == '':
- # Handle uncompressed data
- self.image = np.array(self.pva_object['value'][0][self.data_type])
-
- self.image = self.image.reshape(self.shape, order=self.pixel_ordering).T if self.image_is_transposed else self.image.reshape(self.shape, order=self.pixel_ordering)
- self.frames_received += 1
- else:
- self.image = None
-
- # Check for missed frame starts here
- current_array_id = self.pva_object['uniqueId']
- if self.last_array_id is not None:
- self.id_diff = current_array_id - self.last_array_id - 1
- if (self.id_diff > 0):
- self.frames_missed += self.id_diff
- else:
- self.id_diff = 0
- self.last_array_id = current_array_id
- except Exception as e:
- print(f"Failed to process image: {e}")
- self.frames_missed += 1
-
- def start_channel_monitor(self) -> None:
- """
- Subscribes to the PVA channel with a callback function and starts monitoring for PV changes.
- """
- self.channel.subscribe('pva callback success', self.pva_callbackSuccess)
- self.channel.startMonitor()
-
- def start_roi_backup_monitor(self) -> None:
- try:
- for roi_num, roi_dict in self.config['ROI'].items():
- for config_key, pv_name in roi_dict.items():
- name_components = pv_name.split(":")
-
- roi_key = name_components[1] # ROI1-ROI4
- pv_key = name_components[2] # MinX, MinY, SizeX, SizeY
-
- self.rois.setdefault(roi_key, {}).update({pv_key: caget(pv_name)})
- camonitor(pvname=pv_name, callback=self.roi_backup_callback)
- except Exception as e:
- print(f'Failed to setup backup ROI monitor: {e}')
-
- def stop_channel_monitor(self) -> None:
- """
- Stops all monitoring and callback functions.
- """
- self.channel.unsubscribe('pva callback success')
- self.channel.stopMonitor()
-
- def get_frames_missed(self) -> int:
- """
- Returns the number of frames missed.
-
- Returns:
- int: The number of missed frames.
- """
- return self.frames_missed
-
- def get_pva_image(self) -> np.ndarray:
- """
- Returns the current PVA image.
-
- Returns:
- numpy.ndarray: The current image array.
- """
- return self.image
-
- def get_attributes_dict(self) -> list[dict]:
- """
- Returns the attributes of the current PVA object.
-
- Returns:
- list: The attributes of the current PVA object.
- """
- return self.attributes
diff --git a/viewer/roi_cropping.py b/viewer/roi_cropping.py
new file mode 100755
index 0000000..f1442fd
--- /dev/null
+++ b/viewer/roi_cropping.py
@@ -0,0 +1,173 @@
+import pvaccess as pva
+from epics import camonitor, caget
+import numpy as np
+import matplotlib.pyplot as plt
+
+CROP_PADDING = 0
+ROI = 'ROI2'
+PVA = 'Pva1'
+TEST_ROW = 69
+
+class ROICropping:
+ """
+ Crop an image based on the ROI
+ """
+ def __init__(self):
+ self.channel : pva.Channel = None
+ self.pva_obj : pva.PVObject = None
+
+ # size
+ self.image : np.ndarray = None
+ self.shape : tuple = (0,0)
+ self.shaped_img : np.ndarray = None
+
+ # cropping
+ self.cropped_image : np.ndarray = None
+
+ # minx,maxx,miny,maxy
+ self.crop_size : tuple = (0,0,0,0)
+ self.cropped_col_avg : float = 0.0
+ self.cropped_row_avg : float = 0.0
+
+
+ def crop_img(self) -> None:
+ """
+ This function crops the Pva1 to the size of the ROI
+ and displays it on matplotlib
+
+ """
+
+ self.get_image()
+ self.get_roi()
+ self.shape_image()
+ self.crop_shaped_image(ROI_NUM=3)
+ self.calc_average()
+
+ # DEBUG
+ # print(f'\
+ # {PVA} Image: {self.image}\
+ # {ROI}: {self.roi_data}\
+ # Image Size: {self.shape}\
+ # Crop Size: {self.crop_size}')
+ # print(f'\
+ # Before Crop: {self.image}\
+ # After Crop: {self.cropped_image}\n\n\
+ # Average Column: {self.cropped_col_avg}\n\n\
+ # Average Row: {self.cropped_row_avg}\
+ # ')
+ print(type(self.shaped_img))
+ self.display_image()
+
+
+
+ def get_image(self) -> None:
+ # Gets a single image
+ self.channel = pva.Channel(f'dp-ADSim:{PVA}:Image', pva.PVA)
+
+ # The fields I want to be visible
+ self.pva_obj = self.channel.get('field(value,dimension,timeStamp,uniqueId)')
+
+ # Get and set the image from the dictionary
+ self.image = self.pva_obj['value'][0]['ubyteValue']
+
+
+
+ def get_roi(self) -> None:
+ # Get the ROI of that single image
+ self.roi_data = caget(f'dp-ADSim:{ROI}:MinX')
+
+
+
+ def shape_image(self) -> None:
+ """
+ Turns the PVAObject into an image
+
+ Return: None
+ """
+ # Check if dimensions are in image
+ if 'dimension' in self.pva_obj:
+ # grab the shape and store them in a tuple
+ self.shape = tuple([dim['size'] for dim in self.pva_obj['dimension']])
+
+ # Reshape into a 2d image
+ self.shaped_img = np.array(self.image).reshape(self.shape, order='F')
+
+ else:
+ print(f'Dimension not in {PVA} object')
+
+
+
+
+ def crop_shaped_image(self, ROI_NUM:int = None):
+ """
+ Crops the shaped_img to the specific ROI's size
+
+ Args: ROI_NUM(int) - For the specific ROI you want to crop to
+ """
+
+ # There is an ROI provided
+ if ROI_NUM:
+ # Get the ROI's dimension
+ min_x = caget(f'dp-ADSim:ROI{ROI_NUM}:MinX')
+ min_y = caget(f'dp-ADSim:ROI{ROI_NUM}:MinY')
+ max_x = caget(f'dp-ADSim:ROI{ROI_NUM}:SizeX')
+ max_y = caget(f'dp-ADSim:ROI{ROI_NUM}:SizeY')
+
+ # Slice the needed ROI dimensions from the image
+ self.cropped_image = self.shaped_img[min_x:min_x+max_x, min_y:min_y+max_y]
+
+ # If not provided use the images dimensions
+ else:
+ self.crop_size = reversed(self.shaped_img.shape)
+ self.cropped_image = self.shaped_img
+
+
+
+ def calc_average(self) -> None:
+ """
+ Calculate the averages of the column and rows
+ """
+ # Average the cropped images column
+ self.cropped_col_avg = np.mean(self.cropped_image,0)
+ # Average the cropped images row
+ self.cropped_row_avg = np.mean(self.cropped_image,1)
+
+
+
+ def display_image(self):
+ """
+ Display data using matplotlib
+ """
+
+ #
+ X = np.arange(0, self.cropped_image.shape[1], 1.0)
+ Y = np.arange(0, self.cropped_image.shape[0], 1.0)
+
+ # Used to plot multiple graphs
+ figure, axis = plt.subplots(2,2)
+
+ # Plot the graphs on the sublot
+ # Top Right
+ axis[0,0].title.set_text('Cropped ROI Image')
+ axis[0,0].imshow(self.cropped_image)
+
+ # Top Left
+ axis[0,1].title.set_text('Row Average')
+ axis[0,1].plot(self.cropped_row_avg, Y)
+ axis[0,1].invert_yaxis()
+
+ # Bottom Right
+ axis[1,0].title.set_text('Column Average')
+ axis[1,0].plot(X, self.cropped_col_avg)
+
+ # Bottom Left
+ axis[1,1].title.set_text('Full Image')
+ axis[1,1].imshow(self.shaped_img)
+
+ plt.show()
+
+
+
+# Call and start function
+roi_cropping = ROICropping()
+roi_cropping.crop_img()
diff --git a/viewer/scan_view.py b/viewer/scan_view.py
new file mode 100644
index 0000000..e3243b9
--- /dev/null
+++ b/viewer/scan_view.py
@@ -0,0 +1,436 @@
+import sys
+import toml
+from datetime import datetime
+from PyQt5 import uic
+from PyQt5.QtCore import QThread, pyqtSignal, QTimer, Qt
+from PyQt5.QtWidgets import QApplication, QMainWindow, QFileDialog, QVBoxLayout
+import pyqtgraph as pg
+
+from utils import PVAReader, HDF5Writer
+from epics import caput
+
+class ScanMonitorWindow(QMainWindow):
+ signal_start_monitor = pyqtSignal()
+
+ def __init__(self, channel: str = "", config_filepath: str = ""):
+ super(ScanMonitorWindow, self).__init__()
+ uic.loadUi('gui/scan_view.ui', self)
+ # Title comes from UI; ensure consistent naming in code comments
+
+ self.channel = channel
+ self.config_filepath = config_filepath
+ self.scan_state = False
+
+ # Track applied state for UI labels
+ self.applied_channel = None
+ self.applied_config = None
+ self._last_frames_received = 0
+
+ # Define Threads
+ self.reader_thread = QThread()
+ self.writer_thread = QThread()
+
+ self.reader: PVAReader = None
+ self.h5_handler: HDF5Writer = None
+
+ # Timer for updating info display
+ self.info_timer = QTimer()
+ self.info_timer.timeout.connect(self._update_info_display)
+
+ # Graph state
+ self.graph_plot = None
+ self.graph_curve = None
+ self.graph_x = []
+ self.graph_y = []
+ self._frames_baseline = 0
+ self.graph_window_seconds = 60 # sliding window length; newest point centered
+ # Separate timeline for activity monitor (distinct from actual scan time)
+ self.activity_start_time = None
+ # Track how long the monitor has been actively listening
+ self.listening_start_time = None
+
+ # Scan timing variables
+ self.scan_start_time = None
+ self.scan_end_time = None
+ self.last_scan_completion_time = None
+
+ # Setup Initial UI State
+ self._setup_ui_elements()
+ self._setup_graph()
+
+ def _setup_ui_elements(self):
+ if hasattr(self, 'label_mode'):
+ self.label_mode.setText("")
+ if hasattr(self, 'label_indicator'):
+ self.label_indicator.setText('scan: off')
+ self._apply_indicator_style()
+ if hasattr(self, 'label_listening'):
+ # Initialize listening elapsed time display as mm:ss
+ self.label_listening.setText('00:00')
+ self._apply_listening_style(False)
+
+ self.lineedit_channel.setText(self.channel or "")
+ self.lineedit_channel.textChanged.connect(self._on_channel_changed)
+ self.lineedit_config.setText(self.config_filepath or "")
+ self.lineedit_config.textChanged.connect(self._on_config_path_changed)
+
+ self.btn_browse_config.clicked.connect(self._on_browse_config_clicked)
+ self.btn_apply.clicked.connect(self._on_apply_clicked)
+
+ self._update_info_display()
+
+ def _setup_graph(self):
+ """Initialize the PyQtGraph PlotWidget inside the placeholder widget."""
+ try:
+ if hasattr(self, 'widget_graph') and self.widget_graph is not None:
+ # Create a layout for the placeholder widget if it doesn't have one
+ layout = self.widget_graph.layout() if hasattr(self.widget_graph, 'layout') else None
+ if layout is None:
+ layout = QVBoxLayout(self.widget_graph)
+ self.widget_graph.setLayout(layout)
+
+ # Create and configure the plot
+ self.graph_plot = pg.PlotWidget(background='w')
+ self.graph_plot.showGrid(x=True, y=True)
+ self.graph_plot.setLabel('bottom', 'Time', units='s')
+ self.graph_plot.setLabel('left', 'Frames collected')
+ # Keep Y auto-range; control X via sliding window
+ try:
+ self.graph_plot.enableAutoRange(x=False, y=True)
+ except Exception:
+ pass
+ self.graph_curve = self.graph_plot.plot([], [], pen=pg.mkPen(color=(30, 144, 255), width=2))
+
+ # Center indicator line; we'll place it at the center of the X range (latest time)
+ try:
+ self.center_line = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen(color=(128, 128, 128), style=Qt.DashLine))
+ self.graph_plot.addItem(self.center_line)
+ except Exception:
+ self.center_line = None
+
+ layout.addWidget(self.graph_plot)
+ self._reset_graph()
+ except Exception as e:
+ print(f"Graph setup error: {e}")
+
+ # ================================================================================================
+ # CORE LOGIC & THREADING
+ # ================================================================================================
+
+ def _on_apply_clicked(self) -> None:
+ """Initializes Reader and Writer on separate threads."""
+ if not self.channel or not self.config_filepath:
+ return
+
+ self._cleanup_existing_instances()
+
+ try:
+ # 1. Create instances
+ self.reader = PVAReader(
+ input_channel=self.channel,
+ config_filepath=self.config_filepath,
+ viewer_type='image'
+ )
+
+ self.h5_handler = HDF5Writer(
+ file_path="",
+ pva_reader=self.reader
+ )
+
+ # 2. Move to specific worker threads
+ self.reader.moveToThread(self.reader_thread)
+ self.h5_handler.moveToThread(self.writer_thread)
+
+ # 3. Connect Signals with QueuedConnection to bridge thread boundaries
+ self.reader.reader_scan_complete.connect(self._on_reader_scan_complete, Qt.QueuedConnection)
+ self.h5_handler.hdf5_writer_finished.connect(self._on_writer_finished, Qt.QueuedConnection)
+ self.signal_start_monitor.connect(self.reader.start_channel_monitor, Qt.QueuedConnection)
+
+ if hasattr(self.reader, 'scan_state_changed'):
+ self.reader.scan_state_changed.connect(self._on_scan_state_changed, Qt.QueuedConnection)
+
+ # 4. Start Thread Event Loops
+ self.reader_thread.start()
+ self.writer_thread.start()
+
+ # 5. Begin Monitoring
+ self.signal_start_monitor.emit()
+
+ # 6. Update UI Tracking
+ self.applied_channel = self.channel
+ self.applied_config = self.config_filepath
+ if hasattr(self, 'label_listening'):
+ self.label_listening.setText('True')
+ self._apply_listening_style(True)
+
+ self.info_timer.start(1000)
+ self._update_info_display()
+ # For continuous monitoring, start a fresh graph timeline on apply
+ self._reset_graph()
+ # Initialize a timeline for the activity monitor (separate from scan time)
+ self.activity_start_time = datetime.now()
+ # Reset frames baseline to current reader count if available
+ try:
+ self._frames_baseline = int(getattr(self.reader, 'frames_received', 0) or 0)
+ except Exception:
+ self._frames_baseline = 0
+
+ except Exception as e:
+ print(f"Apply Error: {e}")
+ self.reader = None
+ self.h5_handler = None
+
+ def _on_reader_scan_complete(self) -> None:
+ """Slot executed when PVAReader emits the completion signal."""
+ print(f"LOG: reader_scan_complete received by ScanMonitor at {datetime.now()}")
+ self._trigger_automatic_save()
+
+ def _trigger_automatic_save(self) -> None:
+ """Triggers the HDF5Writer save process."""
+ if self.h5_handler:
+ print("LOG: Triggering HDF5Writer.save_caches_to_h5...")
+ # This method runs in the writer_thread due to moveToThread earlier
+ self.h5_handler.save_caches_to_h5(clear_caches=True, compress=True)
+
+ def _on_writer_finished(self, message: str) -> None:
+ """Callback when the HDF5 file is finished writing."""
+ print(f"LOG: Writer finished - {message}")
+ if hasattr(self, 'label_indicator'):
+ self.label_indicator.setText('scan: off')
+ self._apply_indicator_style()
+ self.scan_state = False
+ self._update_button_states()
+
+ def _on_stop_scan_clicked(self) -> None:
+ if self.reader is None: return
+ try:
+ if getattr(self.reader, 'FLAG_PV', ''):
+ caput(self.reader.FLAG_PV, self.reader.STOP_SCAN)
+ else:
+ self.reader.stop_channel_monitor()
+ # If no flag PV triggers the reader, we trigger the complete sequence manually
+ self._on_reader_scan_complete()
+ except Exception as e:
+ print(f"Manual Stop Error: {e}")
+
+ def _on_start_scan_clicked(self) -> None:
+ if self.reader:
+ self.signal_start_monitor.emit()
+
+ def _cleanup_existing_instances(self) -> None:
+ if self.reader is not None:
+ try:
+ self.reader.stop_channel_monitor()
+ self.reader.reader_scan_complete.disconnect()
+ self.h5_handler.hdf5_writer_finished.disconnect()
+ except: pass
+
+ self.reader_thread.quit()
+ self.reader_thread.wait()
+ self.writer_thread.quit()
+ self.writer_thread.wait()
+
+ self.reader = None
+ self.h5_handler = None
+
+ # ================================================================================================
+ # UI STYLING & UPDATES
+ # ================================================================================================
+
+ def _on_channel_changed(self, text):
+ self.channel = text
+ self.applied_channel = None
+ if hasattr(self, 'label_listening'):
+ # Reset listening timer when channel changes
+ self.listening_start_time = None
+ self.label_listening.setText('0')
+ self._apply_listening_style(False)
+
+ def _on_config_path_changed(self, text):
+ self.config_filepath = text
+ self.applied_config = None
+ if hasattr(self, 'label_listening'):
+ # Reset listening timer when config changes
+ self.listening_start_time = None
+ self.label_listening.setText('0')
+ self._apply_listening_style(False)
+
+ def _on_browse_config_clicked(self):
+ fname, _ = QFileDialog.getOpenFileName(self, 'Select Config', '', 'TOML (*.toml)')
+ if fname: self.lineedit_config.setText(fname)
+
+ def _on_scan_state_changed(self, is_on: bool) -> None:
+ if is_on:
+ self.scan_start_time = datetime.now()
+ self.scan_end_time = None
+ self._reset_graph()
+ else:
+ self.scan_end_time = datetime.now()
+ self.last_scan_completion_time = self.scan_end_time
+
+ self.scan_state = is_on
+ if hasattr(self, 'label_indicator'):
+ self.label_indicator.setText('scan: on' if is_on else 'scan: off')
+ self._apply_indicator_style()
+ self._update_button_states()
+
+ def _apply_indicator_style(self):
+ if hasattr(self, 'label_indicator'):
+ color = "green" if "on" in self.label_indicator.text().lower() else "red"
+ self.label_indicator.setStyleSheet(f'color: {color}; font-weight: bold;')
+
+ def _apply_listening_style(self, state):
+ if hasattr(self, 'label_listening'):
+ color = "green" if state else "red"
+ self.label_listening.setStyleSheet(f'color: {color}; font-weight: bold;')
+
+ def _update_button_states(self):
+ if hasattr(self, 'btn_start_scan'): self.btn_start_scan.setEnabled(not self.scan_state)
+ if hasattr(self, 'btn_stop_scan'): self.btn_stop_scan.setEnabled(self.scan_state)
+
+ def _update_label_from_config(self):
+ if hasattr(self, 'label_mode'): self.label_mode.setText("")
+
+ def _update_info_display(self):
+ """Logic for periodically refreshing UI labels based on Reader state."""
+ try:
+ # Update Caching Mode
+ caching_mode = "Not set"
+ if self.config_filepath:
+ try:
+ with open(self.config_filepath, 'r') as f:
+ cfg = toml.load(f)
+ caching_mode = cfg.get('CACHE_OPTIONS', {}).get('CACHING_MODE', 'Not set')
+ except: pass
+ if hasattr(self, 'label_caching_mode'): self.label_caching_mode.setText(str(caching_mode))
+
+ # Update Flag PV
+ flag_pv = "Not set"
+ if self.reader and hasattr(self.reader, 'FLAG_PV'):
+ flag_pv = str(self.reader.FLAG_PV) if self.reader.FLAG_PV else "Not set"
+ if hasattr(self, 'label_flag_pv'): self.label_flag_pv.setText(flag_pv)
+
+ # Update Monitor Activity
+ channel_active = "No"
+ is_listening = False
+ if self.reader and hasattr(self.reader, 'channel'):
+ is_active = bool(self.reader.channel.isMonitorActive())
+ channel_active = "Yes" if is_active else "No"
+ is_listening = is_active and (self.applied_channel == self.channel)
+
+ if hasattr(self, 'label_channel_active'):
+ self.label_channel_active.setText(channel_active)
+
+ # Update Listening label to show elapsed listening time (positive integers)
+ if hasattr(self, 'label_listening'):
+ if is_listening:
+ if self.listening_start_time is None:
+ self.listening_start_time = datetime.now()
+ elapsed = int(max(0, (datetime.now() - self.listening_start_time).total_seconds()))
+ # Format as mm:ss
+ m, s = divmod(elapsed, 60)
+ self.label_listening.setText(f"{m:02d}:{s:02d}")
+ else:
+ # Reset when not listening
+ self.listening_start_time = None
+ self.label_listening.setText('00:00')
+ self._apply_listening_style(is_listening)
+
+ # Update Timing
+ if self.scan_start_time:
+ duration = (self.scan_end_time if self.scan_end_time else datetime.now()) - self.scan_start_time
+ s = int(duration.total_seconds())
+ m, s = divmod(s, 60); h, m = divmod(m, 60)
+ time_str = f"{h:02d}:{m:02d}:{s:02d}"
+ if not self.scan_end_time: time_str += " (running)"
+ if hasattr(self, 'label_scan_time'): self.label_scan_time.setText(time_str)
+
+ if self.last_scan_completion_time and hasattr(self, 'label_last_scan_date'):
+ self.label_last_scan_date.setText(self.last_scan_completion_time.strftime("%Y-%m-%d %H:%M:%S"))
+ # Update graph after refreshing labels
+ self._update_graph()
+ except: pass
+
+ def _reset_graph(self):
+ self.graph_x = []
+ self.graph_y = []
+ if self.graph_curve:
+ self.graph_curve.setData([], [])
+ # Reset baseline when graph resets
+ try:
+ self._frames_baseline = int(getattr(self.reader, 'frames_received', 0) or 0)
+ except Exception:
+ self._frames_baseline = 0
+ # Restart activity monitor timeline
+ self.activity_start_time = datetime.now()
+
+ def _update_graph(self):
+ """Append the latest frame count against elapsed time and update the curve."""
+ try:
+ if not self.graph_curve or not self.graph_plot:
+ return
+ # Use activity monitor time (separate from actual scan time)
+ if self.activity_start_time and self.reader is not None:
+ # Only update when monitor is actively listening
+ is_active = False
+ is_listening = False
+ try:
+ if hasattr(self.reader, 'channel') and self.reader.channel is not None:
+ is_active = bool(self.reader.channel.isMonitorActive())
+ # Listening indicates we applied the current channel successfully
+ is_listening = (self.applied_channel == self.channel) and is_active
+ except Exception:
+ is_active = False
+ is_listening = False
+ if not (is_active and is_listening):
+ return
+ t = int(max(0, (datetime.now() - self.activity_start_time).total_seconds()))
+ # Prefer frames collected during active caching; fallback to total frames_received
+ frames_total = getattr(self.reader, 'frames_received', None)
+ if frames_total is None:
+ return
+ # Continuous monitor: always update regardless of scan_state
+ self.graph_x.append(t)
+ # Plot delta frames collected since baseline
+ try:
+ delta = int(frames_total) - int(self._frames_baseline)
+ self.graph_y.append(max(0, int(delta)))
+ except Exception:
+ self.graph_y.append(int(max(0, frames_total)))
+ # Keep last N points to avoid excessive memory (e.g., last 600 seconds)
+ max_points = 600
+ if len(self.graph_x) > max_points:
+ self.graph_x = self.graph_x[-max_points:]
+ self.graph_y = self.graph_y[-max_points:]
+ self.graph_curve.setData(self.graph_x, self.graph_y)
+
+ # Sliding window: keep newest time centered; move the view range accordingly
+ try:
+ half = self.graph_window_seconds / 2.0
+ # Clamp x_min to 0 to avoid negative time display
+ x_min = max(0.0, float(t) - half)
+ x_max = x_min + self.graph_window_seconds
+ self.graph_plot.setXRange(x_min, x_max, padding=0)
+ if self.center_line:
+ self.center_line.setPos(float(t))
+ except Exception as _:
+ pass
+ except Exception as e:
+ print(f"Graph update error: {e}")
+
+ def closeEvent(self, event):
+ self.info_timer.stop()
+ self._cleanup_existing_instances()
+ super().closeEvent(event)
+
+if __name__ == '__main__':
+ import argparse
+ parser = argparse.ArgumentParser(description='Scan Monitor Window')
+ parser.add_argument('--channel', default='', help='PVA channel name')
+ parser.add_argument('--config', dest='config_path', default='', help='Path to TOML config file')
+ args = parser.parse_args()
+
+ app = QApplication(sys.argv)
+ window = ScanMonitorWindow(channel=args.channel, config_filepath=args.config_path)
+ window.show()
+ sys.exit(app.exec_())
\ No newline at end of file
diff --git a/viewer/settings/settings_dialog.py b/viewer/settings/settings_dialog.py
new file mode 100644
index 0000000..903ca9d
--- /dev/null
+++ b/viewer/settings/settings_dialog.py
@@ -0,0 +1,22 @@
+import sys
+from PyQt5 import QtWidgets, uic
+
+
+class SettingsDialog(QtWidgets.QDialog):
+ def __init__(self, parent=None):
+ super(SettingsDialog, self).__init__(parent)
+ # Load the placeholder UI
+ uic.loadUi('gui/settings/settings_dialog.ui', self)
+
+ # Wire dialog buttons if present
+ button_box = getattr(self, 'buttonBox', None)
+ if button_box is not None:
+ button_box.accepted.connect(self.accept)
+ button_box.rejected.connect(self.reject)
+
+
+if __name__ == '__main__':
+ app = QtWidgets.QApplication(sys.argv)
+ dlg = SettingsDialog()
+ dlg.show()
+ sys.exit(app.exec_())
diff --git a/viewer/tools/metadata_converter_gui.py b/viewer/tools/metadata_converter_gui.py
new file mode 100644
index 0000000..bf4f00f
--- /dev/null
+++ b/viewer/tools/metadata_converter_gui.py
@@ -0,0 +1,186 @@
+import sys
+import os
+from pathlib import Path
+from typing import List
+
+from PyQt5 import uic
+from PyQt5.QtWidgets import (
+ QApplication, QDialog, QFileDialog, QMessageBox
+)
+
+import h5py
+
+from utils.metadata_converter import convert_files_or_dir
+
+
+def is_already_formatted(h5_path: Path, base_group: str) -> bool:
+ """Return True if base_group/HKL exists AND there is at least one dataset named
+ 'NAME' under base_group/HKL/motor_positions (recursively).
+ """
+ try:
+ with h5py.File(str(h5_path), 'r') as h5:
+ hkl_group = f"{base_group}/HKL"
+ if hkl_group not in h5:
+ return False
+ motor_root = f"{hkl_group}/motor_positions"
+ if motor_root not in h5:
+ return False
+
+ found_name = False
+
+ def visitor(name, obj):
+ nonlocal found_name
+ if found_name:
+ return
+ try:
+ # Check leaf name equals 'NAME' and object is a dataset
+ leaf = name.split('/')[-1]
+ if leaf == 'NAME' and isinstance(obj, h5py.Dataset):
+ found_name = True
+ except Exception:
+ pass
+
+ h5[motor_root].visititems(visitor)
+ return found_name
+ except Exception:
+ return False
+
+
+class MetadataConverterDialog(QDialog):
+ def __init__(self):
+ super().__init__()
+ uic.loadUi('gui/tools/metadata_converter.ui', self)
+
+ # Wire up buttons
+ if hasattr(self, 'btn_browse_hdf5_file'):
+ self.btn_browse_hdf5_file.clicked.connect(self._browse_hdf5_file)
+ if hasattr(self, 'btn_browse_hdf5_dir'):
+ self.btn_browse_hdf5_dir.clicked.connect(self._browse_hdf5_dir)
+ if hasattr(self, 'btn_browse_toml'):
+ self.btn_browse_toml.clicked.connect(self._browse_toml)
+ if hasattr(self, 'btn_convert'):
+ self.btn_convert.clicked.connect(self._convert)
+ if hasattr(self, 'btn_close'):
+ self.btn_close.clicked.connect(self.close)
+
+ # Defaults (ensure they exist if UI changed)
+ if hasattr(self, 'txt_base_group') and not self.txt_base_group.text():
+ self.txt_base_group.setText('entry/data/metadata')
+ if hasattr(self, 'chk_include'):
+ self.chk_include.setChecked(True)
+ if hasattr(self, 'chk_in_place'):
+ self.chk_in_place.setChecked(True)
+
+ # ---------- Browsers ----------
+ def _browse_hdf5_file(self):
+ fname, _ = QFileDialog.getOpenFileName(self, 'Select HDF5 file', '', 'HDF5 Files (*.h5 *.hdf5);;All Files (*)')
+ if fname and hasattr(self, 'txt_hdf5_path'):
+ self.txt_hdf5_path.setText(fname)
+
+ def _browse_hdf5_dir(self):
+ dname = QFileDialog.getExistingDirectory(self, 'Select directory containing HDF5 files', '')
+ if dname and hasattr(self, 'txt_hdf5_path'):
+ self.txt_hdf5_path.setText(dname)
+
+ def _browse_toml(self):
+ fname, _ = QFileDialog.getOpenFileName(self, 'Select TOML mapping file', '', 'TOML Files (*.toml);;All Files (*)')
+ if fname and hasattr(self, 'txt_toml_path'):
+ self.txt_toml_path.setText(fname)
+
+ # ---------- Conversion ----------
+ def _append_log(self, text: str):
+ if hasattr(self, 'txt_log'):
+ self.txt_log.append(text)
+
+ def _validate_inputs(self) -> tuple:
+ hdf5_path = self.txt_hdf5_path.text().strip() if hasattr(self, 'txt_hdf5_path') else ''
+ toml_path = self.txt_toml_path.text().strip() if hasattr(self, 'txt_toml_path') else ''
+ base_group = self.txt_base_group.text().strip() if hasattr(self, 'txt_base_group') else 'entry/data/metadata'
+ include = bool(self.chk_include.isChecked()) if hasattr(self, 'chk_include') else True
+ in_place = bool(self.chk_in_place.isChecked()) if hasattr(self, 'chk_in_place') else True
+
+ if not toml_path:
+ QMessageBox.warning(self, 'Missing TOML', 'Please select a TOML mapping file.')
+ return '', '', '', False, False
+ if not hdf5_path:
+ QMessageBox.warning(self, 'Missing Source', 'Please select a HDF5 file or directory.')
+ return '', '', '', False, False
+ return hdf5_path, toml_path, base_group, include, in_place
+
+ def _convert(self):
+ hdf5_path, toml_path, base_group, include, in_place = self._validate_inputs()
+ if not hdf5_path:
+ return
+
+ src = Path(hdf5_path)
+ converted_count = 0
+ skipped_count = 0
+ errors: List[str] = []
+
+ try:
+ if src.is_file():
+ # single file
+ if is_already_formatted(src, base_group):
+ self._append_log(f"Skip (already formatted): {src}")
+ skipped_count += 1
+ else:
+ try:
+ outputs = convert_files_or_dir(
+ toml_path=toml_path,
+ hdf5_path=str(src),
+ base_group=base_group,
+ include=include,
+ in_place=in_place,
+ recursive=False
+ )
+ converted_count += 1 if outputs else 0
+ self._append_log(f"Converted: {src}")
+ except Exception as e:
+ errors.append(f"{src}: {e}")
+ self._append_log(f"Error converting {src}: {e}")
+ elif src.is_dir():
+ # directory: recurse
+ files = list(src.rglob('*.h5'))
+ if not files:
+ self._append_log('No .h5 files found in directory.')
+ for f in files:
+ if is_already_formatted(f, base_group):
+ skipped_count += 1
+ self._append_log(f"Skip (already formatted): {f}")
+ continue
+ try:
+ outputs = convert_files_or_dir(
+ toml_path=toml_path,
+ hdf5_path=str(f),
+ base_group=base_group,
+ include=include,
+ in_place=in_place,
+ recursive=False
+ )
+ converted_count += 1 if outputs else 0
+ self._append_log(f"Converted: {f}")
+ except Exception as e:
+ errors.append(f"{f}: {e}")
+ self._append_log(f"Error converting {f}: {e}")
+ else:
+ QMessageBox.critical(self, 'Invalid Path', 'The selected HDF5 path is not a file or directory.')
+ return
+ finally:
+ summary = f"Converted {converted_count} HDF5 file(s)."
+ if skipped_count:
+ summary += f" Skipped {skipped_count} already formatted."
+ if errors:
+ summary += f" Errors: {len(errors)}"
+ self._append_log(summary)
+ QMessageBox.information(self, 'Conversion Summary', summary)
+
+
+def main():
+ app = QApplication(sys.argv)
+ dlg = MetadataConverterDialog()
+ dlg.show()
+ app.exec_()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/viewer/views_registry/__init__.py b/viewer/views_registry/__init__.py
new file mode 100644
index 0000000..c4e3fa2
--- /dev/null
+++ b/viewer/views_registry/__init__.py
@@ -0,0 +1,5 @@
+"""Registry package for dynamic view buttons in the Launcher.
+
+Add new view definitions in registry.py (VIEWS list) to auto-populate
+buttons in the launcher.
+"""
diff --git a/viewer/views_registry/registry.py b/viewer/views_registry/registry.py
new file mode 100644
index 0000000..14bc497
--- /dev/null
+++ b/viewer/views_registry/registry.py
@@ -0,0 +1,13 @@
+import sys
+
+# Define view entries to be rendered as buttons in the Launcher.
+# To add a new view, append another dict with the same keys.
+VIEWS = [
+ {
+ 'key': 'scan_monitors',
+ 'label': 'Scan Monitors',
+ 'cmd': [sys.executable, 'dashpva.py', 'view', 'scan'],
+ 'running_text': 'Scan Monitors — Running…',
+ 'tooltip': 'Open Scan Monitors (CLI: dashpva.py view scan)'
+ },
+]
diff --git a/viewer/workbench/doc/index.html b/viewer/workbench/doc/index.html
new file mode 100644
index 0000000..0d2ab75
--- /dev/null
+++ b/viewer/workbench/doc/index.html
@@ -0,0 +1,66 @@
+
+
+
+
+
+ Workbench Documentation
+
+
+
+ Workbench Viewer Documentation
+ Overview and usage notes for the Workbench.
+
+ The Workbench viewer provides 2D, 3D, and 1D visualization of HDF5 datasets along with tools for ROI and speckle analysis.
+
+ Features
+
+ - Load single HDF5 files or entire folders
+ - Visualize datasets in 2D and 3D (when PyVista is available)
+ - ROI creation and statistics
+ - Playback of 3D stacks with FPS control
+
+
+ How to open this documentation
+ Use the menu Documentation → Open Documentation or press F1 within the Workbench window.
+
+ Directory structure
+
+viewer/
+ workbench/
+ workbench.py
+ doc/
+ index.html ← you are here
+ README.md ← optional alternative
+
+
+ 2D Viewer
+
+ Opening files
+ You can choose to open a file or a folder using the file button (h5 formats only)
+ Once selected selectable datasets will be highlighted in blue
+ You can edit the file however you want by right clicking on that file
+
+
+ ROI
+ You can select to draw as many ROI's as you want by clicking draw ROI
+ You will be able to see it docked on the right or left side this can be docked anywhere on the right or left window
+
+
+ Player
+
+
+
+ File edits at viewer/workbench/doc/index.html.
+
+
+
diff --git a/viewer/workbench/dock_window.py b/viewer/workbench/dock_window.py
new file mode 100644
index 0000000..00a661d
--- /dev/null
+++ b/viewer/workbench/dock_window.py
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+"""
+Dock Window
+A lightweight secondary QMainWindow intended to host dockable tools.
+Modeless (does not disable the main Workbench window), can be filled
+with QDockWidget-based panels like ROI Plot and ROI Math.
+"""
+
+from PyQt5.QtWidgets import QMainWindow, QWidget, QDockWidget, QLabel
+from PyQt5.QtCore import Qt
+
+
+class DockWindow(QMainWindow):
+ """
+ Secondary window for hosting dockable tools.
+
+ Attributes:
+ main (QMainWindow): Reference to the primary Workbench window for callbacks.
+ """
+
+ def __init__(self, main_window, title: str = None, width: int = 1000, height: int = 700):
+ super().__init__(parent=None) # top-level, modeless
+ self.main = main_window
+ self.setWindowTitle(title or "Dock Window")
+ self.resize(width, height)
+
+ # Central placeholder; docks will live around this
+ central = QWidget(self)
+ self.setCentralWidget(central)
+
+ # Create an initial empty dock so users can dock panels into this window
+ try:
+ self.empty_dock = QDockWidget("Dock", self)
+ self.empty_dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
+ placeholder = QLabel("Empty Dock — you can add dockable panels here", self.empty_dock)
+ placeholder.setAlignment(Qt.AlignCenter)
+ self.empty_dock.setWidget(placeholder)
+ self.addDockWidget(Qt.RightDockWidgetArea, self.empty_dock)
+ self.empty_dock.show()
+ except Exception:
+ # Even if dock creation fails, the window remains usable
+ pass
+
+ # Ensure deletion on close; Workbench keeps reference list to avoid GC while open
+ try:
+ self.setAttribute(Qt.WA_DeleteOnClose, True)
+ except Exception:
+ pass
+
+ def show_and_focus(self) -> None:
+ """Show the window modeless and bring it to the foreground."""
+ try:
+ self.show()
+ self.raise_()
+ self.activateWindow()
+ except Exception:
+ # Best-effort foregrounding only
+ self.show()
diff --git a/viewer/workbench/docks/base_dock.py b/viewer/workbench/docks/base_dock.py
new file mode 100644
index 0000000..ba87478
--- /dev/null
+++ b/viewer/workbench/docks/base_dock.py
@@ -0,0 +1,23 @@
+from PyQt5.QtWidgets import QDockWidget, QAction
+from PyQt5.QtCore import Qt
+
+class BaseDock(QDockWidget):
+ def __init__(self, title="", main_window=None, segment_name=None, dock_area=Qt.LeftDockWidgetArea):
+ super().__init__(title, main_window)
+ self.title = title
+ self.main_window = main_window
+ self.segment_name = (segment_name or "").strip().lower() if segment_name is not None else None
+ self.dock_area = dock_area
+ self.setup()
+
+ def setup(self):
+ # Dock
+ self.setWindowTitle(self.title)
+ self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
+ self.main_window.addDockWidget(self.dock_area, self)
+
+ # Register dock toggle under segmented Windows menu via BaseWindow helper
+ self.action_window_dock = self.main_window.add_dock_toggle_action(
+ self, self.title, segment_name=self.segment_name
+ )
+ self.visibilityChanged.connect(lambda visible: self.action_window_dock.setChecked(bool(visible)))
diff --git a/viewer/workbench/docks/dash_ai.py b/viewer/workbench/docks/dash_ai.py
new file mode 100644
index 0000000..669f260
--- /dev/null
+++ b/viewer/workbench/docks/dash_ai.py
@@ -0,0 +1,53 @@
+from viewer.workbench.docks.base_dock import BaseDock
+from viewer.base_window import BaseWindow
+from PyQt5.QtWidgets import QGroupBox, QMessageBox, QLabel, QVBoxLayout, QLineEdit, QPushButton
+from PyQt5.QtCore import Qt
+
+
+class DashAI(BaseDock):
+ """
+ DashAI dockable window.
+ """
+
+ def __init__(self, title="DashAI", main_window: BaseWindow=None, segment_name="2d", dock_area=Qt.RightDockWidgetArea):
+ # Call BaseDock with segment routing
+ super().__init__(title, main_window, segment_name=segment_name, dock_area=dock_area)
+ # Build the dock UI contents
+ self.build_dock()
+
+ def connect_all(self):
+ self.btn_segment.clicked.connect(self.run_segmentation)
+
+ def build_dock(self):
+ self.gb_dash_sam = QGroupBox(self.title)
+ layout = QVBoxLayout() # You need a layout to hold widgets
+
+ # Segmentation setup
+ # Use a QLabel for instructions
+ self.prompt_label = QLabel(
+ "Instructions:
"
+ "1. Click on the image to select points.
"
+ "2. Press 'Segment' to run DashAI.
"
+ "Add a prompt or message for DashAI to read"
+ )
+ self.prompt_label.setWordWrap(True)
+ layout.addWidget(self.prompt_label)
+
+ # 2. The Input Box (Where the user types)
+ self.text_prompt_input = QLineEdit()
+ self.text_prompt_input.setPlaceholderText("e.g., 'segment the large crystal'...")
+ layout.addWidget(self.text_prompt_input)
+
+ # 3. Action Button
+ self.btn_segment = QPushButton("Run DashAI Segmentation")
+ self.btn_segment.setStyleSheet("background-color: #4CAF50; color: white; font-weight: bold;")
+ # Connect this button to your SAM function later
+ # self.btn_segment.clicked.connect(self.run_segmentation)
+ layout.addWidget(self.btn_segment)
+
+ layout.addStretch() # Keeps everything at the top
+ self.gb_dash_sam.setLayout(layout)
+ self.setWidget(self.gb_dash_sam)
+
+ def run_segmentation(self):
+ print("Running segmentation called will be implemented soon")
diff --git a/viewer/workbench/docks/data_structure.py b/viewer/workbench/docks/data_structure.py
new file mode 100644
index 0000000..a8cef8e
--- /dev/null
+++ b/viewer/workbench/docks/data_structure.py
@@ -0,0 +1,292 @@
+from viewer.workbench.docks.base_dock import BaseDock
+from viewer.base_window import BaseWindow
+from viewer.workbench.workers import DatasetLoader
+from PyQt5.QtCore import Qt, QThread
+from PyQt5.QtWidgets import QAction, QVBoxLayout, QHBoxLayout, QTreeWidget, QGroupBox, QPushButton, QMessageBox
+from utils.hdf5_loader import HDF5Loader
+import os
+
+class DataStructureDock(BaseDock):
+ def __init__(self, title="Data Structure", main_window:BaseWindow=None, segment_name="other", dock_area=Qt.LeftDockWidgetArea):
+ super().__init__(title, main_window, segment_name=segment_name, dock_area=dock_area)
+ self.title = title
+ self.main_window = main_window
+ # Parent BaseDock.__init__ already performs setup; no need to call again
+ self.build_dock()
+
+ def setup(self):
+ try:
+ # Delegate core docking and Windows menu registration to BaseDock.setup
+ super().setup()
+ try:
+ self.connect()
+ except Exception:
+ pass
+ except Exception as e:
+ print(e)
+ pass
+
+ def build_dock(self):
+ """Build the UI Dock"""
+ # Create a group box to mirror the Workbench's "Data Structure" panel
+ self.gb_data_structure = QGroupBox()
+ self.gb_data_structure.setObjectName("groupBox_dataTree")
+
+ # Create the tree widget that will display the hierarchical dataset
+ self.tree_data = QTreeWidget()
+ self.tree_data.setObjectName("tree_data")
+ self.tree_data.setHeaderHidden(True)
+
+ # Layout the tree inside the group box with a simple Refresh button
+ layout = QVBoxLayout()
+ layout.setContentsMargins(8, 8, 8, 8)
+ header = QHBoxLayout()
+ self.btn_refresh = QPushButton("Refresh")
+ try:
+ self.btn_refresh.setToolTip("Refresh Data Structure")
+ self.btn_refresh.clicked.connect(self._on_refresh_clicked)
+ except Exception:
+ pass
+ header.addWidget(self.btn_refresh)
+ # Add Load Dataset button next to Refresh
+ self.btn_load = QPushButton("Load Dataset")
+ try:
+ self.btn_load.setToolTip("Load default dataset for selected file or the selected dataset")
+ self.btn_load.clicked.connect(self._on_load_clicked)
+ except Exception:
+ pass
+ header.addWidget(self.btn_load)
+ header.addStretch(1)
+ layout.addLayout(header)
+ layout.addWidget(self.tree_data)
+ self.gb_data_structure.setLayout(layout)
+
+ # Set the group box as the dock's main widget
+ self.setWidget(self.gb_data_structure)
+
+ def _on_refresh_clicked(self):
+ try:
+ # Perform refresh within the dock, using the same functions that populated the tree originally
+ self.refresh_data_structure_display()
+ except Exception as e:
+ QMessageBox.critical(self, "Refresh Error", f"Failed to refresh: {e}")
+
+ def _on_load_clicked(self):
+ try:
+ tree = getattr(self, 'tree_data', None)
+ mw = getattr(self, 'main_window', None)
+ if tree is None or mw is None:
+ QMessageBox.information(self, "Load Dataset", "Tree or main window not available.")
+ return
+ item = tree.currentItem()
+ if item is None:
+ # If no selection, try current file
+ fp = getattr(mw, 'current_file_path', None)
+ if fp and os.path.exists(fp):
+ self._load_main_data_for_path(fp)
+ else:
+ QMessageBox.information(self, "Load Dataset", "No selection or current file.")
+ return
+ item_type = item.data(0, Qt.UserRole + 2)
+ if item_type == "file_root":
+ path = item.data(0, Qt.UserRole + 1)
+ if path and os.path.exists(path):
+ self._load_main_data_for_path(path)
+ else:
+ QMessageBox.information(self, "Load Dataset", "Selected file is not available.")
+ return
+ # If a dataset/group is selected, try to load that selection; otherwise fall back to default
+ full_path = item.data(0, 32) # Qt.UserRole = 32
+ if full_path:
+ self._ensure_current_file_from_item(item)
+ try:
+ mw.selected_dataset_path = full_path
+ except Exception:
+ pass
+ mw.start_dataset_load()
+ else:
+ fp = getattr(mw, 'current_file_path', None)
+ if fp and os.path.exists(fp):
+ self._load_main_data_for_path(fp)
+ else:
+ QMessageBox.information(self, "Load Dataset", "No dataset path found.")
+ except Exception as e:
+ QMessageBox.critical(self, "Load Error", f"Failed to load dataset: {e}")
+
+ def _ensure_current_file_from_item(self, item):
+ try:
+ mw = getattr(self, 'main_window', None)
+ cur = item
+ while cur is not None:
+ t = cur.data(0, Qt.UserRole + 2)
+ if t == "file_root":
+ fp = cur.data(0, Qt.UserRole + 1)
+ if fp and mw is not None:
+ mw.current_file_path = fp
+ break
+ cur = cur.parent()
+ except Exception:
+ pass
+
+ def connect(self):
+ try:
+ if hasattr(self, 'tree_data') and self.tree_data is not None:
+ try:
+ self.tree_data.itemClicked.connect(self._on_tree_item_clicked)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _on_tree_item_clicked(self, item, column):
+ try:
+ # If a file root is clicked, load its default main dataset into the active workspace
+ item_type = item.data(0, Qt.UserRole + 2)
+ mw = getattr(self, 'main_window', None)
+ if item_type == "file_root":
+ path = item.data(0, Qt.UserRole + 1)
+ if path and os.path.exists(path):
+ self._load_main_data_for_path(path)
+ else:
+ # If a dataset node is clicked, load that dataset into the active workspace
+ full_path = item.data(0, 32)
+ if full_path and mw is not None:
+ # Ensure the main window knows the current file
+ cur = item
+ while cur is not None:
+ t = cur.data(0, Qt.UserRole + 2)
+ if t == "file_root":
+ fp = cur.data(0, Qt.UserRole + 1)
+ if fp:
+ mw.current_file_path = fp
+ break
+ cur = cur.parent()
+ try:
+ mw.selected_dataset_path = full_path
+ except Exception:
+ pass
+ mw.start_dataset_load()
+ except Exception:
+ pass
+
+ def refresh_data_structure_display(self, file_path=None):
+ """Refresh the data tree by reloading currently listed top-level items (files and folder sections).
+ This uses the main window's existing load functions to ensure identical population behavior.
+ If a specific file_path is provided, it will attempt to refresh only that entry; otherwise, refreshes all."""
+ try:
+ tree = getattr(self, 'tree_data', None)
+ if tree is None:
+ QMessageBox.information(self, "Refresh", "Data tree is not available.")
+ return
+ mw = getattr(self, 'main_window', None)
+ if mw is None:
+ QMessageBox.information(self, "Refresh", "Main window is not available.")
+ return
+
+ # Snapshot existing top-level items and their paths/types
+ snapshot = []
+ try:
+ if file_path:
+ # If a specific path was requested, try to locate it among top-level items
+ for i in range(tree.topLevelItemCount()):
+ item = tree.topLevelItem(i)
+ path = item.data(0, Qt.UserRole + 1)
+ if path == file_path:
+ item_type = item.data(0, Qt.UserRole + 2)
+ snapshot.append((item_type, path))
+ break
+ else:
+ for i in range(tree.topLevelItemCount()):
+ item = tree.topLevelItem(i)
+ item_type = item.data(0, Qt.UserRole + 2)
+ path = item.data(0, Qt.UserRole + 1)
+ snapshot.append((item_type, path))
+ except Exception:
+ snapshot = []
+
+ # Clear the tree before repopulating
+ try:
+ tree.clear()
+ except Exception:
+ pass
+
+ # Rebuild using the same loading functions used initially
+ rebuilt_any = False
+ for item_type, path in snapshot:
+ if not path:
+ continue
+ try:
+ if item_type == "file_root" and os.path.exists(path) and hasattr(mw, 'load_single_h5_file'):
+ mw.load_single_h5_file(path)
+ rebuilt_any = True
+ elif item_type == "folder_section" and os.path.isdir(path) and hasattr(mw, 'load_folder_content'):
+ mw.load_folder_content(path)
+ rebuilt_any = True
+ except Exception as e:
+ print(f"[DataStructureDock] Refresh failed for {path}: {e}")
+
+ # Fallback: if nothing was rebuilt, try the current file
+ if not rebuilt_any:
+ fp = getattr(mw, 'current_file_path', None)
+ try:
+ if fp and os.path.exists(fp) and hasattr(mw, 'load_single_h5_file'):
+ mw.load_single_h5_file(fp)
+ rebuilt_any = True
+ except Exception:
+ pass
+
+ if rebuilt_any:
+ # Suppress popup per user request - update status silently if available
+ if hasattr(self, 'update_status'):
+ try:
+ self.update_status("Data structure refreshed.")
+ except Exception:
+ pass
+ # If a dataset is selected, start background load to keep UI responsive
+ try:
+ if mw is not None and getattr(mw, 'selected_dataset_path', None):
+ mw.start_dataset_load()
+ except Exception:
+ pass
+ else:
+ # Suppress popup per user request - update status silently if available
+ if hasattr(self, 'update_status'):
+ try:
+ self.update_status("Nothing to refresh.")
+ except Exception:
+ pass
+ # If a dataset is selected, start background load to keep UI responsive
+ try:
+ if mw is not None and getattr(mw, 'selected_dataset_path', None):
+ mw.start_dataset_load()
+ except Exception:
+ pass
+ except Exception as e:
+ QMessageBox.critical(self, "Refresh Error", f"Failed to refresh: {e}")
+
+ def _load_data_structure(self):
+ try:
+ folder_name = os.path.basename(self.current_file_path)
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to load folder: {str(e)}")
+ self.update_status("Failed to load folder")
+
+ def _populate_tree_recursive(self):
+ pass
+
+ def _start_dataset_load(self):
+ """Create a worker thread to load dataset without blocking the UI."""
+ try:
+ self.update_status(f"Loading dataset: {self.selected_dataset_path}")
+ self._dataset_thread = QThread()
+ self._dataset_worker = DatasetLoader(self.current_file_path, self.selected_dataset_path)
+ self._dataset_worker.moveToThread(self._dataset_thread)
+ self._dataset_thread.started.connect(self._dataset_worker.run)
+ self._dataset_worker.loaded.connect(self.on_dataset_loaded)
+ self._dataset_worker.failed.connect(self.on_dataset_failed)
+ # Ensure thread quits after work
+ self._dataset_worker.loaded.connect(self._dataset_thread.quit)
+ self._dataset_worker.failed.connect(self._dataset_thread.quit)
+ self._dataset_thread.start()
+ except Exception as e:
+ self.update_status(f"Error starting dataset load: {e}")
diff --git a/viewer/workbench/docks/info_2d_dock.py b/viewer/workbench/docks/info_2d_dock.py
new file mode 100644
index 0000000..16b89c4
--- /dev/null
+++ b/viewer/workbench/docks/info_2d_dock.py
@@ -0,0 +1,118 @@
+from typing import Optional
+import numpy as np
+from PyQt5.QtCore import Qt
+
+from viewer.workbench.docks.information_dock_base import InformationDockBase
+
+
+class Info2DDock(InformationDockBase):
+ """Information dock specialized for 2D viewing state.
+
+ Shows number of points in the current frame and X/Y axis variable labels.
+ """
+
+ def __init__(
+ self,
+ main_window=None,
+ title: str = "2D Info",
+ segment_name: Optional[str] = "2d",
+ dock_area: Qt.DockWidgetArea = Qt.RightDockWidgetArea,
+ ):
+ super().__init__(title=title, main_window=main_window, segment_name=segment_name, dock_area=dock_area)
+
+ def refresh(self) -> None:
+ """Refresh the displayed information based on the main window's 2D state."""
+ mw = getattr(self, 'main_window', None)
+ if mw is None:
+ return
+ # Try to keep mouse info consistent when refresh occurs
+ try:
+ xy = getattr(mw, '_last_hover_xy', None)
+ frame = mw.get_current_frame_data() if hasattr(mw, 'get_current_frame_data') else None
+ intensity = None
+ H_val = K_val = L_val = None
+ pos = None
+ # Populate Mouse HKL even if no hover yet by falling back to center pixel
+ if frame is not None and frame.ndim == 2:
+ h, w = frame.shape
+ # Validate hover position
+ if xy is not None:
+ try:
+ x_hover, y_hover = int(xy[0]), int(xy[1])
+ if 0 <= x_hover < w and 0 <= y_hover < h:
+ x, y = x_hover, y_hover
+ pos = (x, y)
+ else:
+ x, y = w // 2, h // 2
+ pos = (x, y)
+ except Exception:
+ x, y = w // 2, h // 2
+ pos = (x, y)
+ else:
+ x, y = w // 2, h // 2
+ pos = (x, y)
+ # Intensity at chosen position
+ try:
+ intensity = float(frame[y, x])
+ except Exception:
+ intensity = None
+ # HKL from cached q-grids if present
+ try:
+ qxg = getattr(mw, '_qx_grid', None)
+ qyg = getattr(mw, '_qy_grid', None)
+ qzg = getattr(mw, '_qz_grid', None)
+ if qxg is not None and qyg is not None and qzg is not None:
+ if qxg.ndim == 3:
+ idx = int(mw.frame_spinbox.value()) if hasattr(mw, 'frame_spinbox') and mw.frame_spinbox.isEnabled() else 0
+ if 0 <= idx < qxg.shape[0]:
+ H_val = float(qxg[idx, y, x]); K_val = float(qyg[idx, y, x]); L_val = float(qzg[idx, y, x])
+ elif qxg.ndim == 2:
+ H_val = float(qxg[y, x]); K_val = float(qyg[y, x]); L_val = float(qzg[y, x])
+ except Exception:
+ H_val = K_val = L_val = None
+ # Update Mouse section in the dock
+ self.set_mouse_info(pos, intensity, H_val, K_val, L_val)
+ except Exception:
+ pass
+ # Points: show total points across data dimensions (include product, e.g., FxHxW = N)
+ points_str = None
+ low_val = None
+ high_val = None
+ try:
+ data = getattr(mw, 'current_2d_data', None)
+ if isinstance(data, np.ndarray):
+ # total points
+ total = int(data.size)
+ points_str = f"{total:,}"
+ # intensity low/high across dataset
+ try:
+ low_val = float(np.min(data))
+ except Exception:
+ low_val = None
+ try:
+ high_val = float(np.max(data))
+ except Exception:
+ high_val = None
+ except Exception:
+ points_str = None
+ self.set_points(points_str)
+ try:
+ self.set_intensity(low_val, high_val)
+ except Exception:
+ pass
+ # Axes: from WorkbenchWindow axis variables; annotate default source axes
+ try:
+ xlab = getattr(mw, 'axis_2d_x', None)
+ ylab = getattr(mw, 'axis_2d_y', None)
+ dx = xlab
+ dy = ylab
+ try:
+ if isinstance(xlab, str) and xlab.strip().lower() in ("columns", "column"):
+ dx = f"{xlab}(Source)"
+ if isinstance(ylab, str) and ylab.strip().lower() in ("row", "rows"):
+ dy = f"{ylab}(Source)"
+ except Exception:
+ pass
+ except Exception:
+ dx = None; dy = None
+ self.set_axes(dx, dy)
diff --git a/viewer/workbench/docks/info_3d_dock.py b/viewer/workbench/docks/info_3d_dock.py
new file mode 100644
index 0000000..090f5ec
--- /dev/null
+++ b/viewer/workbench/docks/info_3d_dock.py
@@ -0,0 +1,246 @@
+from typing import Optional, Tuple
+import numpy as np
+
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import QLabel, QFormLayout
+
+from viewer.workbench.docks.information_dock_base import InformationDockBase
+
+
+class Info3DDock(InformationDockBase):
+ """Information dock specialized for 3D slice state (HKL only).
+
+ Programmatically augments the base InformationDock UI with 3D-specific rows:
+ - Orientation (HK, KL, HL, or Custom)
+ - Slice position (orthogonal HKL axis/value; e.g., L = 1.23456, or n·origin = value)
+ - Origin (H,K,L) with 5 decimals
+ - Normal (H,K,L) with 5 decimals
+ - H/K/L ranges across current slice points (min..max)
+ - Image size (HxW) for rasterization target
+
+ Reuses base fields: Total Points, Intensity Low/High.
+ """
+
+ def __init__(
+ self,
+ main_window=None,
+ title: str = "3D Info",
+ segment_name: Optional[str] = "3d",
+ dock_area: Qt.DockWidgetArea = Qt.RightDockWidgetArea,
+ ):
+ super().__init__(title=title, main_window=main_window, segment_name=segment_name, dock_area=dock_area)
+ self._setup_extra_rows()
+
+ # UI augmentation
+ def _setup_extra_rows(self) -> None:
+ try:
+ form: QFormLayout = self._widget.findChild(QFormLayout, "formLayout")
+ if form is None:
+ return
+ # Helper to add a row and keep refs
+ def add_row(caption: str, obj_name: str) -> QLabel:
+ cap = QLabel(caption, self._widget)
+ val = QLabel("—", self._widget)
+ val.setObjectName(obj_name)
+ form.addRow(cap, val)
+ return val
+
+ # Orientation
+ self.lbl_orientation = add_row("Orientation:", "lbl_orientation")
+ self.lbl_orientation.setToolTip("Slice plane orientation in HKL coordinates")
+ # Slice position
+ self.lbl_slice_pos = add_row("Slice Position:", "lbl_slice_pos")
+ self.lbl_slice_pos.setToolTip("Orthogonal axis value for axis-aligned planes, or n·origin for custom")
+ # Origin
+ self.lbl_origin = add_row("Origin (H,K,L):", "lbl_origin")
+ self.lbl_origin.setToolTip("Slice plane origin in HKL coordinates")
+ # Normal
+ self.lbl_normal = add_row("Normal (H,K,L):", "lbl_normal")
+ self.lbl_normal.setToolTip("Slice plane normal in HKL coordinates")
+ # Ranges
+ self.lbl_H_range = add_row("H range:", "lbl_H_range")
+ self.lbl_K_range = add_row("K range:", "lbl_K_range")
+ self.lbl_L_range = add_row("L range:", "lbl_L_range")
+ self.lbl_H_range.setToolTip("Min..Max over slice points H component")
+ self.lbl_K_range.setToolTip("Min..Max over slice points K component")
+ self.lbl_L_range.setToolTip("Min..Max over slice points L component")
+ # Image size
+ self.lbl_image_size = add_row("Image size:", "lbl_image_size")
+ self.lbl_image_size.setToolTip("Rasterization target size (HxW)")
+ except Exception:
+ pass
+
+ # Public API
+ def update_from_slice(
+ self,
+ slice_mesh,
+ normal: np.ndarray,
+ origin: np.ndarray,
+ target_shape: Optional[Tuple[int, int]] = None,
+ ) -> None:
+ """Update all labels based on a PyVista slice mesh and plane definition.
+ - HKL-only computations (no U/V).
+ - Intensity low/high from slice_mesh['intensity'] if present.
+ - Image size prefers provided target_shape; falls back to 512×512.
+ """
+ try:
+ # Points
+ try:
+ npts = int(getattr(slice_mesh, 'n_points', 0))
+ except Exception:
+ npts = None
+ self.set_points(npts)
+
+ # Intensities
+ low = high = None
+ try:
+ vals = np.asarray(slice_mesh["intensity"], dtype=float).ravel()
+ if vals.size > 0:
+ low = float(np.min(vals))
+ high = float(np.max(vals))
+ except Exception:
+ pass
+ self.set_intensity(low, high)
+
+ # Origin, Normal formatting (5 decimals)
+ def fmt5(x: float) -> str:
+ try:
+ return f"{float(x):.5f}"
+ except Exception:
+ return str(x)
+
+ try:
+ o = np.array(origin, dtype=float).reshape(3)
+ except Exception:
+ o = np.array([np.nan, np.nan, np.nan], dtype=float)
+ try:
+ n = np.array(normal, dtype=float).reshape(3)
+ except Exception:
+ n = np.array([0.0, 0.0, 1.0], dtype=float)
+
+ try:
+ self.lbl_origin.setText(f"({fmt5(o[0])}, {fmt5(o[1])}, {fmt5(o[2])})")
+ except Exception:
+ pass
+ try:
+ self.lbl_normal.setText(f"({fmt5(n[0])}, {fmt5(n[1])}, {fmt5(n[2])})")
+ except Exception:
+ pass
+
+ # Orientation and orthogonal axis
+ orientation, uv_idxs, orth_label = self._infer_orientation_and_axes(n)
+ try:
+ orient_txt = orientation if orientation == "Custom" else f"{orientation} plane"
+ self.lbl_orientation.setText(orient_txt)
+ except Exception:
+ pass
+
+ # Slice position
+ try:
+ orth_val = None
+ if orth_label == "L":
+ orth_val = float(o[2])
+ elif orth_label == "H":
+ orth_val = float(o[0])
+ elif orth_label == "K":
+ orth_val = float(o[1])
+ if orth_val is not None:
+ self.lbl_slice_pos.setText(f"{orth_label} = {fmt5(orth_val)}")
+ else:
+ # Custom: n·origin
+ try:
+ n_unit = n / (np.linalg.norm(n) or 1.0)
+ except Exception:
+ n_unit = n
+ try:
+ val = float(np.dot(n_unit, o))
+ except Exception:
+ val = float('nan')
+ self.lbl_slice_pos.setText(f"n·origin = {fmt5(val)}")
+ except Exception:
+ pass
+
+ # Ranges over slice points
+ try:
+ pts = np.asarray(getattr(slice_mesh, 'points', np.empty((0, 3))), dtype=float)
+ except Exception:
+ pts = np.empty((0, 3), dtype=float)
+ def fmt_range(arr: np.ndarray) -> str:
+ if arr.size == 0:
+ return "—"
+ try:
+ amin = float(np.min(arr))
+ amax = float(np.max(arr))
+ if not np.isfinite(amin) or not np.isfinite(amax):
+ return "—"
+ return f"{amin:.6g}..{amax:.6g}"
+ except Exception:
+ return "—"
+ try:
+ self.lbl_H_range.setText(fmt_range(pts[:, 0] if pts.shape[1] >= 1 else np.array([])))
+ self.lbl_K_range.setText(fmt_range(pts[:, 1] if pts.shape[1] >= 2 else np.array([])))
+ self.lbl_L_range.setText(fmt_range(pts[:, 2] if pts.shape[1] >= 3 else np.array([])))
+ except Exception:
+ pass
+
+ # Image size (HxW)
+ try:
+ if not target_shape or not isinstance(target_shape, (tuple, list)) or len(target_shape) != 2:
+ target_shape = (0, 0)
+ try:
+ H, W = int(target_shape[0]), int(target_shape[1])
+ except Exception:
+ H, W = 0, 0
+ self.lbl_image_size.setText(f"HxW = {H}×{W}")
+ except Exception:
+ pass
+
+ # Optional: set base axes to match orientation
+ try:
+ if orientation == "HK":
+ self.set_axes("H", "K")
+ elif orientation == "KL":
+ self.set_axes("K", "L")
+ elif orientation == "HL":
+ self.set_axes("H", "L")
+ else:
+ self.set_axes("U", "V")
+ except Exception:
+ pass
+ except Exception:
+ # Keep errors contained
+ pass
+
+ # Logic reuse (HKL only)
+ def _infer_orientation_and_axes(self, normal: np.ndarray) -> Tuple[str, Optional[Tuple[int, int]], Optional[str]]:
+ """Infer slice orientation from the plane normal in HKL coordinates.
+ Returns (orientation, (u_idx, v_idx) for axis-aligned mapping or None, orth_label).
+ orientation in {'HK','KL','HL','Custom'}; u_idx/v_idx map to columns of pts (0:H, 1:K, 2:L).
+ orth_label is the axis perpendicular to the plane ('L' for HK, 'H' for KL, 'K' for HL).
+ """
+ try:
+ n = np.array(normal, dtype=float).reshape(3)
+ n_norm = float(np.linalg.norm(n))
+ if not np.isfinite(n_norm) or n_norm <= 0.0:
+ n = np.array([0.0, 0.0, 1.0], dtype=float)
+ else:
+ n = n / n_norm
+ X = np.array([1.0, 0.0, 0.0], dtype=float) # H
+ Y = np.array([0.0, 1.0, 0.0], dtype=float) # K
+ Z = np.array([0.0, 0.0, 1.0], dtype=float) # L
+ tol = 0.95
+ dX = abs(float(np.dot(n, X)))
+ dY = abs(float(np.dot(n, Y)))
+ dZ = abs(float(np.dot(n, Z)))
+ if dZ >= tol:
+ # Normal ~ L → HK plane
+ return "HK", (0, 1), "L"
+ if dX >= tol:
+ # Normal ~ H → KL plane
+ return "KL", (1, 2), "H"
+ if dY >= tol:
+ # Normal ~ K → HL plane
+ return "HL", (0, 2), "K"
+ return "Custom", None, None
+ except Exception:
+ return "Custom", None, None
diff --git a/viewer/workbench/docks/info_panel.py b/viewer/workbench/docks/info_panel.py
new file mode 100644
index 0000000..b9056ee
--- /dev/null
+++ b/viewer/workbench/docks/info_panel.py
@@ -0,0 +1,5 @@
+from viewer.workbench.docks.base_dock import BaseDock
+from viewer.base_window import BaseWindow
+
+class DataInformation(BaseDock):
+ pass
\ No newline at end of file
diff --git a/viewer/workbench/docks/information_dock_base.py b/viewer/workbench/docks/information_dock_base.py
new file mode 100644
index 0000000..81b40f3
--- /dev/null
+++ b/viewer/workbench/docks/information_dock_base.py
@@ -0,0 +1,153 @@
+from pathlib import Path
+from typing import Optional
+
+from PyQt5 import uic
+from PyQt5.QtWidgets import QWidget, QLabel
+from PyQt5.QtCore import Qt
+
+from viewer.workbench.docks.base_dock import BaseDock
+
+
+class InformationDockBase(BaseDock):
+ """Base information dock that loads a .ui and provides simple setters.
+
+ This dock is UI-driven via gui/workbench/docks/information_dock.ui
+ and exposes helpers to set points count and axis labels. It can be
+ reused by dimension-specific subclasses (e.g., 2D, 3D).
+ """
+
+ def __init__(
+ self,
+ title: str = "Information",
+ main_window=None,
+ segment_name: Optional[str] = None,
+ dock_area: Qt.DockWidgetArea = Qt.RightDockWidgetArea,
+ ):
+ # BaseDock will perform docking and Windows-menu registration
+ super().__init__(title=title, main_window=main_window, segment_name=segment_name, dock_area=dock_area)
+
+ # Load the UI into a QWidget and set as the dock widget
+ project_root = Path(__file__).resolve().parents[3]
+ ui_path = project_root / "gui" / "workbench" / "docks" / "information_dock.ui"
+ self._widget = QWidget(self)
+ try:
+ uic.loadUi(str(ui_path), self._widget)
+ except Exception as e:
+ # Fallback: create a minimal widget if UI load fails
+ self._widget = QWidget(self)
+ print(f"[InformationDockBase] Failed to load UI: {e}")
+ self.setWidget(self._widget)
+
+ # Cache label refs for fast updates
+ try:
+ self.lbl_points: QLabel = self._widget.findChild(QLabel, "lbl_points")
+ self.lbl_axis_x: QLabel = self._widget.findChild(QLabel, "lbl_axis_x")
+ self.lbl_axis_y: QLabel = self._widget.findChild(QLabel, "lbl_axis_y")
+ except Exception:
+ self.lbl_points = None
+ self.lbl_axis_x = None
+ self.lbl_axis_y = None
+ # Intensity labels
+ try:
+ self.lbl_int_low: QLabel = self._widget.findChild(QLabel, "lbl_int_low")
+ self.lbl_int_high: QLabel = self._widget.findChild(QLabel, "lbl_int_high")
+ except Exception:
+ self.lbl_int_low = None
+ self.lbl_int_high = None
+ # Mouse hover labels
+ try:
+ self.lbl_mouse_pos: QLabel = self._widget.findChild(QLabel, "lbl_mouse_pos")
+ self.lbl_mouse_int: QLabel = self._widget.findChild(QLabel, "lbl_mouse_int")
+ self.lbl_mouse_H: QLabel = self._widget.findChild(QLabel, "lbl_mouse_H")
+ self.lbl_mouse_K: QLabel = self._widget.findChild(QLabel, "lbl_mouse_K")
+ self.lbl_mouse_L: QLabel = self._widget.findChild(QLabel, "lbl_mouse_L")
+ except Exception:
+ self.lbl_mouse_pos = None
+ self.lbl_mouse_int = None
+ self.lbl_mouse_H = None
+ self.lbl_mouse_K = None
+ self.lbl_mouse_L = None
+
+ # Helper setters
+ def set_points(self, count: Optional[int]) -> None:
+ try:
+ if isinstance(count, int):
+ txt = f"{count:,}"
+ elif count is None:
+ txt = "—"
+ else:
+ try:
+ txt = f"{int(count):,}"
+ except Exception:
+ txt = str(count)
+ if self.lbl_points is not None:
+ self.lbl_points.setText(txt)
+ except Exception:
+ pass
+
+ def set_axes(self, x_label: Optional[str], y_label: Optional[str]) -> None:
+ try:
+ if self.lbl_axis_x is not None:
+ self.lbl_axis_x.setText(str(x_label) if x_label else "—")
+ if self.lbl_axis_y is not None:
+ self.lbl_axis_y.setText(str(y_label) if y_label else "—")
+ except Exception:
+ pass
+
+ def set_intensity(self, low: Optional[float], high: Optional[float]) -> None:
+ try:
+ def fmt(val):
+ if val is None:
+ return "—"
+ try:
+ return f"{float(val):.6g}"
+ except Exception:
+ return str(val)
+ if self.lbl_int_low is not None:
+ self.lbl_int_low.setText(fmt(low))
+ if self.lbl_int_high is not None:
+ self.lbl_int_high.setText(fmt(high))
+ except Exception:
+ pass
+
+ def set_mouse_info(self, pos: Optional[tuple], intensity: Optional[float], H: Optional[float], K: Optional[float], L: Optional[float]) -> None:
+ """Update the Mouse section with position, intensity, and HKL values, with HKL colors."""
+ try:
+ def fmtf(val):
+ if val is None:
+ return "—"
+ try:
+ return f"{float(val):.6g}"
+ except Exception:
+ return str(val)
+ def fmtpos(p):
+ if not p or len(p) < 2:
+ return "—"
+ try:
+ return f"({int(p[0])}, {int(p[1])})"
+ except Exception:
+ return str(p)
+ if getattr(self, 'lbl_mouse_pos', None) is not None:
+ self.lbl_mouse_pos.setText(fmtpos(pos))
+ if getattr(self, 'lbl_mouse_int', None) is not None:
+ self.lbl_mouse_int.setText(fmtf(intensity))
+ if getattr(self, 'lbl_mouse_H', None) is not None:
+ self.lbl_mouse_H.setText(fmtf(H))
+ try:
+ self.lbl_mouse_H.setStyleSheet("color: red;")
+ except Exception:
+ pass
+ if getattr(self, 'lbl_mouse_K', None) is not None:
+ self.lbl_mouse_K.setText(fmtf(K))
+ try:
+ self.lbl_mouse_K.setStyleSheet("color: green;")
+ except Exception:
+ pass
+ if getattr(self, 'lbl_mouse_L', None) is not None:
+ self.lbl_mouse_L.setText(fmtf(L))
+ try:
+ self.lbl_mouse_L.setStyleSheet("color: blue;")
+ except Exception:
+ pass
+ except Exception:
+ pass
diff --git a/viewer/workbench/docks/slice_plane.py b/viewer/workbench/docks/slice_plane.py
new file mode 100644
index 0000000..1123893
--- /dev/null
+++ b/viewer/workbench/docks/slice_plane.py
@@ -0,0 +1,149 @@
+from PyQt5 import uic
+from PyQt5.QtCore import Qt
+from PyQt5.QtWidgets import QWidget
+
+from viewer.workbench.docks.base_dock import BaseDock
+
+
+class SlicePlaneDock(BaseDock):
+ """
+ Slice Controls dock for manipulating the 3D slice plane and camera.
+ Loads its UI from gui/workbench/docks/slice_plane.ui and wires signals
+ into Workspace3D (tab_3d) methods.
+ """
+ def __init__(self, title: str = "Slice Controls", main_window=None, segment_name: str = "3d", dock_area: Qt.DockWidgetArea = Qt.LeftDockWidgetArea):
+ super().__init__(title=title, main_window=main_window, segment_name=segment_name, dock_area=dock_area)
+ self._widget = None
+ self._build()
+ self._wire()
+
+ def setup(self):
+ # BaseDock handles docking and Windows->segment toggle registration
+ super().setup()
+
+ def _build(self):
+ try:
+ self._widget = QWidget(self)
+ uic.loadUi('gui/workbench/docks/slice_plane.ui', self._widget)
+ self.setWidget(self._widget)
+ except Exception as e:
+ # If UI fails to load, keep an empty widget to avoid crashing
+ self._widget = QWidget(self)
+ self.setWidget(self._widget)
+ try:
+ if hasattr(self.main_window, 'update_status'):
+ self.main_window.update_status(f"SlicePlaneDock UI load failed: {e}")
+ except Exception:
+ pass
+
+ def _wire(self):
+ mw = self.main_window
+ if mw is None:
+ return
+ tab = getattr(mw, 'tab_3d', None)
+ if tab is None:
+ return
+ w = self._widget
+ try:
+ # Steps
+ if hasattr(w, 'sb_slice_translate_step'):
+ w.sb_slice_translate_step.setValue(0.01)
+ w.sb_slice_translate_step.valueChanged.connect(lambda v: setattr(tab, '_slice_translate_step', float(v)))
+ if hasattr(w, 'sb_slice_rotate_step_deg'):
+ w.sb_slice_rotate_step_deg.setValue(1.0)
+ w.sb_slice_rotate_step_deg.valueChanged.connect(lambda v: setattr(tab, '_slice_rotate_step_deg', float(v)))
+
+ # Orientation preset
+ if hasattr(w, 'cb_slice_orientation'):
+ w.cb_slice_orientation.currentTextChanged.connect(lambda txt: tab.set_plane_preset(str(txt)))
+
+ # Custom normal spinboxes
+ def _apply_custom_normal():
+ try:
+ h = float(w.sb_norm_h.value()) if hasattr(w, 'sb_norm_h') else 0.0
+ k = float(w.sb_norm_k.value()) if hasattr(w, 'sb_norm_k') else 0.0
+ l = float(w.sb_norm_l.value()) if hasattr(w, 'sb_norm_l') else 1.0
+ if hasattr(tab, 'set_custom_normal'):
+ tab.set_custom_normal([h, k, l])
+ # If Custom preset selected, apply immediately
+ cur = str(w.cb_slice_orientation.currentText()) if hasattr(w, 'cb_slice_orientation') else ''
+ if cur.lower().startswith('custom'):
+ tab.set_plane_preset('Custom')
+ except Exception:
+ pass
+ for name in ('sb_norm_h', 'sb_norm_k', 'sb_norm_l'):
+ spin = getattr(w, name, None)
+ if spin is not None:
+ try:
+ spin.editingFinished.connect(_apply_custom_normal)
+ except Exception:
+ pass
+
+ # Translate buttons
+ if hasattr(w, 'btn_up_normal'):
+ w.btn_up_normal.clicked.connect(lambda: tab.nudge_along_normal(+1))
+ if hasattr(w, 'btn_down_normal'):
+ w.btn_down_normal.clicked.connect(lambda: tab.nudge_along_normal(-1))
+ if hasattr(w, 'btn_pos_h'):
+ w.btn_pos_h.clicked.connect(lambda: tab.nudge_along_axis('H', +1))
+ if hasattr(w, 'btn_neg_h'):
+ w.btn_neg_h.clicked.connect(lambda: tab.nudge_along_axis('H', -1))
+ if hasattr(w, 'btn_pos_k'):
+ w.btn_pos_k.clicked.connect(lambda: tab.nudge_along_axis('K', +1))
+ if hasattr(w, 'btn_neg_k'):
+ w.btn_neg_k.clicked.connect(lambda: tab.nudge_along_axis('K', -1))
+ if hasattr(w, 'btn_pos_l'):
+ w.btn_pos_l.clicked.connect(lambda: tab.nudge_along_axis('L', +1))
+ if hasattr(w, 'btn_neg_l'):
+ w.btn_neg_l.clicked.connect(lambda: tab.nudge_along_axis('L', -1))
+
+ # Rotate buttons use current rotate-step from tab
+ if hasattr(w, 'btn_rot_plus_h'):
+ w.btn_rot_plus_h.clicked.connect(lambda: tab.rotate_about_axis('H', +getattr(tab, '_slice_rotate_step_deg', 1.0)))
+ if hasattr(w, 'btn_rot_minus_h'):
+ w.btn_rot_minus_h.clicked.connect(lambda: tab.rotate_about_axis('H', -getattr(tab, '_slice_rotate_step_deg', 1.0)))
+ if hasattr(w, 'btn_rot_plus_k'):
+ w.btn_rot_plus_k.clicked.connect(lambda: tab.rotate_about_axis('K', +getattr(tab, '_slice_rotate_step_deg', 1.0)))
+ if hasattr(w, 'btn_rot_minus_k'):
+ w.btn_rot_minus_k.clicked.connect(lambda: tab.rotate_about_axis('K', -getattr(tab, '_slice_rotate_step_deg', 1.0)))
+ if hasattr(w, 'btn_rot_plus_l'):
+ w.btn_rot_plus_l.clicked.connect(lambda: tab.rotate_about_axis('L', +getattr(tab, '_slice_rotate_step_deg', 1.0)))
+ if hasattr(w, 'btn_rot_minus_l'):
+ w.btn_rot_minus_l.clicked.connect(lambda: tab.rotate_about_axis('L', -getattr(tab, '_slice_rotate_step_deg', 1.0)))
+
+ # Reset
+ if hasattr(w, 'btn_reset_slice'):
+ w.btn_reset_slice.clicked.connect(tab.reset_slice)
+
+ # Visibility
+ if hasattr(w, 'cb_show_slice'):
+ w.cb_show_slice.toggled.connect(lambda checked: tab.toggle_3d_slice(bool(checked)))
+ # Show Points (main cloud)
+ if hasattr(w, 'cb_show_points'):
+ w.cb_show_points.toggled.connect(lambda checked: tab.toggle_3d_points(bool(checked)))
+
+ # Camera
+ if hasattr(w, 'cb_cam_preset'):
+ w.cb_cam_preset.currentTextChanged.connect(lambda txt: tab.set_camera_position(str(txt)))
+ if hasattr(w, 'btn_zoom_in'):
+ w.btn_zoom_in.clicked.connect(tab.zoom_in)
+ if hasattr(w, 'btn_zoom_out'):
+ w.btn_zoom_out.clicked.connect(tab.zoom_out)
+ if hasattr(w, 'btn_reset_camera'):
+ w.btn_reset_camera.clicked.connect(tab.reset_camera)
+ if hasattr(w, 'btn_view_slice_normal'):
+ w.btn_view_slice_normal.clicked.connect(tab.view_slice_normal)
+
+ # Initialize defaults
+ try:
+ if hasattr(w, 'cb_slice_orientation'):
+ w.cb_slice_orientation.setCurrentText('HK (xy)')
+ if hasattr(w, 'cb_show_slice'):
+ # Mirror current Tab state if available by checking actor existence
+ w.cb_show_slice.setChecked(True)
+ if hasattr(w, 'cb_show_points'):
+ w.cb_show_points.setChecked(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
diff --git a/viewer/workbench/hkl_3d_plot_dock.py b/viewer/workbench/hkl_3d_plot_dock.py
new file mode 100644
index 0000000..9dd034d
--- /dev/null
+++ b/viewer/workbench/hkl_3d_plot_dock.py
@@ -0,0 +1,286 @@
+#!/usr/bin/env python3
+"""
+HKL 3D Plot Dock for Workbench (Minimal)
+
+- No interpolation from points to volume
+- No slice plane or extra controls
+- Just plotting when 'Load Dataset' is clicked
+- Supports HDF5 (points or volume) and VTI volumes
+"""
+from PyQt5.QtWidgets import QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QMessageBox, QLabel
+from PyQt5.QtCore import Qt
+import numpy as np
+
+try:
+ import pyvista as pyv
+ from pyvistaqt import QtInteractor
+ PYVISTA_AVAILABLE = True
+except Exception:
+ PYVISTA_AVAILABLE = False
+
+# Import HDF5Loader
+import sys as _sys, pathlib as _pathlib
+_sys.path.append(str(_pathlib.Path(__file__).resolve().parents[2]))
+from utils.hdf5_loader import HDF5Loader
+
+class HKL3DPlotDock(QDockWidget):
+ def __init__(self, parent, title: str, main_window):
+ super().__init__(title, parent)
+ self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
+ self.main = main_window
+
+ container = QWidget(self)
+ layout = QVBoxLayout(container)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(6)
+
+ # Top row: Load button
+ top = QHBoxLayout()
+ self.btn_load = QPushButton("Load Dataset")
+ self.btn_load.clicked.connect(self.load_data)
+ top.addWidget(self.btn_load)
+ layout.addLayout(top)
+
+
+ # Plotter
+ self.plotter = None
+ if PYVISTA_AVAILABLE:
+ try:
+ pyv.set_plot_theme('dark')
+ except Exception:
+ pass
+ try:
+ self.plotter = QtInteractor(container)
+ try:
+ self.plotter.add_axes(xlabel='H', ylabel='K', zlabel='L')
+ except Exception:
+ pass
+ layout.addWidget(self.plotter)
+ except Exception:
+ self.plotter = None
+ self.btn_load.setEnabled(False)
+ msg = QLabel("3D (VTK) unavailable in tunnel mode.")
+ try:
+ msg.setAlignment(Qt.AlignCenter)
+ except Exception:
+ pass
+ try:
+ msg.setWordWrap(True)
+ except Exception:
+ pass
+ layout.addWidget(msg)
+ else:
+ # Fallback: button disabled and message
+ self.btn_load.setEnabled(False)
+ try:
+ QMessageBox.warning(self, "3D Viewer", "PyVista not available. Install pyvista and pyvistaqt to enable 3D plotting.")
+ except Exception:
+ pass
+
+ self.setWidget(container)
+
+ # State
+ self.current_file_path = None
+ self.current_dataset_path = None
+ self.cloud_mesh = None
+ self.volume_grid = None
+ self.h5loader = HDF5Loader()
+
+ def _clear_plot(self):
+ try:
+ if self.plotter is not None:
+ self.plotter.clear()
+ self.plotter.add_axes(xlabel='H', ylabel='K', zlabel='L')
+ except Exception:
+ pass
+ self.cloud_mesh = None
+ self.volume_grid = None
+
+ def _plot_points(self, points: np.ndarray, intensities: np.ndarray):
+ if self.plotter is None:
+ return
+ self._clear_plot()
+ # PolyData points with intensity scalars
+ import pyvista as pyv
+ self.cloud_mesh = pyv.PolyData(points)
+ self.cloud_mesh['intensity'] = intensities
+ self.plotter.add_mesh(
+ self.cloud_mesh,
+ scalars='intensity',
+ cmap='jet',
+ point_size=5.0,
+ name='points',
+ show_scalar_bar=True,
+ reset_camera=True,
+ )
+ try:
+ self.plotter.show_bounds(
+ mesh=self.cloud_mesh,
+ xtitle='H Axis', ytitle='K Axis', ztitle='L Axis',
+ bounds=self.cloud_mesh.bounds,
+ )
+ try:
+ ca = getattr(self.plotter.renderer, 'cube_axes_actor', None)
+ if ca:
+ ca.GetXAxesLinesProperty().SetColor(1.0, 0.0, 0.0)
+ ca.GetYAxesLinesProperty().SetColor(0.0, 1.0, 0.0)
+ ca.GetZAxesLinesProperty().SetColor(0.0, 0.0, 1.0)
+ ca.GetTitleTextProperty(0).SetColor(1.0, 0.0, 0.0)
+ ca.GetTitleTextProperty(1).SetColor(0.0, 1.0, 0.0)
+ ca.GetTitleTextProperty(2).SetColor(0.0, 0.0, 1.0)
+ ca.GetLabelTextProperty(0).SetColor(1.0, 0.0, 0.0)
+ ca.GetLabelTextProperty(1).SetColor(0.0, 1.0, 0.0)
+ ca.GetLabelTextProperty(2).SetColor(0.0, 0.0, 1.0)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self.plotter.reset_camera(); self.plotter.render()
+ except Exception:
+ pass
+
+ def _plot_volume_array(self, volume: np.ndarray, metadata: dict = None):
+ if self.plotter is None:
+ return
+ self._clear_plot()
+ import pyvista as pyv
+ grid = pyv.ImageData()
+ dims_cells = np.array(volume.shape, dtype=int)
+ grid.dimensions = (dims_cells + 1).tolist()
+ spacing = (metadata or {}).get('voxel_spacing') or (1.0, 1.0, 1.0)
+ origin = (metadata or {}).get('grid_origin') or (0.0, 0.0, 0.0)
+ try:
+ grid.spacing = tuple(float(x) for x in spacing)
+ except Exception:
+ grid.spacing = (1.0, 1.0, 1.0)
+ try:
+ grid.origin = tuple(float(x) for x in origin)
+ except Exception:
+ grid.origin = (0.0, 0.0, 0.0)
+ try:
+ arr_order = (metadata or {}).get('array_order', 'F') or 'F'
+ grid.cell_data['intensity'] = volume.flatten(order=arr_order)
+ except Exception:
+ grid.cell_data['intensity'] = volume.flatten(order='F')
+ self.volume_grid = grid
+ self.plotter.add_volume(
+ volume=self.volume_grid,
+ scalars='intensity',
+ name='cloud_volume',
+ reset_camera=True,
+ show_scalar_bar=True,
+ )
+ try:
+ self.plotter.show_bounds(
+ mesh=self.volume_grid,
+ xtitle='H Axis', ytitle='K Axis', ztitle='L Axis',
+ bounds=self.volume_grid.bounds,
+ )
+ # Color cube axes H/K/L
+ try:
+ ca = getattr(self.plotter.renderer, 'cube_axes_actor', None)
+ if ca:
+ ca.GetXAxesLinesProperty().SetColor(1.0, 0.0, 0.0)
+ ca.GetYAxesLinesProperty().SetColor(0.0, 1.0, 0.0)
+ ca.GetZAxesLinesProperty().SetColor(0.0, 0.0, 1.0)
+ ca.GetTitleTextProperty(0).SetColor(1.0, 0.0, 0.0)
+ ca.GetTitleTextProperty(1).SetColor(0.0, 1.0, 0.0)
+ ca.GetTitleTextProperty(2).SetColor(0.0, 0.0, 1.0)
+ ca.GetLabelTextProperty(0).SetColor(1.0, 0.0, 0.0)
+ ca.GetLabelTextProperty(1).SetColor(0.0, 1.0, 0.0)
+ ca.GetLabelTextProperty(2).SetColor(0.0, 0.0, 1.0)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ try:
+ self.plotter.reset_camera(); self.plotter.render()
+ except Exception:
+ pass
+
+ def load_data(self):
+ """Load dataset and plot based on type. Prefer Workbench selection; fallback to file dialog."""
+ if not PYVISTA_AVAILABLE:
+ QMessageBox.warning(self, '3D Viewer', 'PyVista is not available.')
+ return
+ # Prefer current selection from Workbench
+ file_path = getattr(self.main, 'current_file_path', None)
+ dataset_path = getattr(self.main, 'selected_dataset_path', None)
+ use_dialog = not (file_path and dataset_path)
+
+ if use_dialog:
+ from PyQt5.QtWidgets import QFileDialog
+ file_name, _ = QFileDialog.getOpenFileName(
+ self, 'Select HDF5 or VTI File', '', 'HDF5 or VTI Files (*.h5 *.hdf5 *.vti);;All Files (*)'
+ )
+ if not file_name:
+ QMessageBox.information(self, 'File', 'No file selected.')
+ return
+ self.current_file_path = file_name
+ # Inspect via loader info
+ loader = self.h5loader
+ # VTI path
+ try:
+ from pathlib import Path as _Path
+ if _Path(file_name).suffix.lower() == '.vti':
+ volume, vol_shape = loader.load_vti_volume_3d(file_name)
+ if volume is None or int(volume.size) == 0:
+ QMessageBox.warning(self, 'Load Error', f'No volume data found in VTI file.\nError: {loader.last_error}')
+ return
+ meta = getattr(loader, 'file_metadata', {}) or {}
+ self._plot_volume_array(volume, meta)
+ return
+ except Exception:
+ pass
+ # HDF5 decide type
+ try:
+ info = loader.get_file_info(file_name, style='dict')
+ except Exception:
+ info = {}
+ dt = str(info.get('data_type', '')).lower() or str(info.get('metadata', {}).get('data_type', '')).lower()
+ if dt == 'volume':
+ volume, vol_shape = loader.load_h5_volume_3d(file_name)
+ if volume is None or int(volume.size) == 0:
+ QMessageBox.warning(self, 'Load Error', f'No volume data found in HDF5 file.\nError: {loader.last_error}')
+ return
+ meta = getattr(loader, 'file_metadata', {}) or {}
+ self._plot_volume_array(volume, meta)
+ else:
+ points, intensities, num_images, shape = loader.load_h5_to_3d(file_name)
+ if int(points.size) == 0 or int(intensities.size) == 0:
+ QMessageBox.warning(self, 'Load Error', f'No valid 3D point data found.\nError: {loader.last_error}')
+ return
+ self._plot_points(points, intensities)
+ return
+
+ # Use Workbench selection
+ self.current_file_path = file_path
+ self.current_dataset_path = dataset_path
+ try:
+ import h5py
+ with h5py.File(file_path, 'r') as h5file:
+ if dataset_path not in h5file:
+ QMessageBox.warning(self, 'Load Error', 'Selected dataset not found in file.')
+ return
+ item = h5file[dataset_path]
+ if not hasattr(item, 'shape'):
+ QMessageBox.warning(self, 'Load Error', 'Selected item is not a dataset.')
+ return
+ data = np.asarray(item[...])
+ except Exception as e:
+ QMessageBox.critical(self, 'Error Loading Data', f'Failed to load dataset:\n{e}')
+ return
+ # Decide plotting
+ if data.ndim == 3:
+ self._plot_volume_array(data, metadata={'array_order': 'F'})
+ elif data.ndim >= 2:
+ # Flatten to points: H,K index grid with intensities from 2D
+ h, k = data.shape[-2], data.shape[-1]
+ X, Y = np.meshgrid(np.arange(k), np.arange(h))
+ Z = np.zeros_like(X, dtype=float)
+ points = np.column_stack([X.ravel().astype(float), Y.ravel().astype(float), Z.ravel()])
+ intens = np.asarray(data[-1] if data.ndim == 3 else data, dtype=float).ravel()
+ self._plot_points(points, intens)
+ else:
+ QMessageBox.information(self, 'Load', 'Dataset is not 2D/3D numeric; cannot plot.')
diff --git a/viewer/workbench/managers/roi_manager.py b/viewer/workbench/managers/roi_manager.py
new file mode 100644
index 0000000..068e6f4
--- /dev/null
+++ b/viewer/workbench/managers/roi_manager.py
@@ -0,0 +1,1001 @@
+"""
+ROI Manager for Workbench
+Centralizes ROI lifecycle, docks, stats computation, and interactions to shorten WorkbenchWindow.
+"""
+
+from typing import List, Optional
+from PyQt5.QtCore import Qt, QSize
+from PyQt5.QtGui import QCursor
+from PyQt5.QtWidgets import (
+ QDockWidget,
+ QListWidget,
+ QListWidgetItem,
+ QTableWidget,
+ QTableWidgetItem,
+ QMenu,
+ QAction,
+ QInputDialog,
+ QWidget,
+ QVBoxLayout,
+ QHBoxLayout,
+ QCheckBox,
+ QLabel,
+ QToolButton,
+ QStyle,
+ QFileDialog,
+)
+import numpy as np
+import pyqtgraph as pg
+import qtawesome as qta
+import h5py
+import os
+
+
+class ContextRectROI(pg.RectROI):
+ """Rectangular ROI with right-click context menu that delegates actions to main window handlers."""
+ def __init__(self, parent_window, pos, size, pen=None):
+ super().__init__(pos, size, pen=pen)
+ self.parent_window = parent_window
+ try:
+ self.setAcceptedMouseButtons(Qt.LeftButton | Qt.RightButton)
+ except Exception:
+ pass
+ # Make ROI rotatable: add a rotate handle at the top-right, rotating about center
+ try:
+ self.addRotateHandle([1, 0], [0.5, 0.5])
+ except Exception:
+ pass
+
+ def mouseClickEvent(self, ev):
+ try:
+ if ev.button() == Qt.RightButton:
+ menu = QMenu()
+ action_stats = QAction("Show ROI Stats", menu)
+ action_rename = QAction("Rename ROI", menu)
+ action_set_active = QAction("Set Active ROI", menu)
+ action_plot = QAction("Open ROI Plot", menu)
+ action_delete = QAction("Delete ROI", menu)
+
+ action_stats.triggered.connect(lambda: self.parent_window.roi_manager.show_roi_stats_for_roi(self))
+ action_rename.triggered.connect(lambda: self.parent_window.roi_manager.rename_roi(self))
+ action_set_active.triggered.connect(lambda: self.parent_window.roi_manager.set_active_roi(self))
+ action_plot.triggered.connect(lambda: self.parent_window.open_roi_plot_dock(self))
+ action_delete.triggered.connect(lambda: self.parent_window.roi_manager.delete_roi(self))
+
+ # Add actions and separator before Save
+ menu.addAction(action_stats)
+ menu.addAction(action_rename)
+ menu.addAction(action_set_active)
+ menu.addAction(action_plot)
+ menu.addAction(action_delete)
+ menu.addSeparator()
+ # Save ROI action
+ action_save = QAction("Save ROI", menu)
+ try:
+ action_save.triggered.connect(lambda: self.parent_window.roi_manager.save_roi(self))
+ except Exception:
+ pass
+ menu.addAction(action_save)
+ try:
+ menu.exec_(QCursor.pos())
+ except Exception:
+ menu.exec_(QCursor.pos())
+ ev.accept()
+ return
+ except Exception:
+ pass
+ # default behavior
+ try:
+ super().mouseClickEvent(ev)
+ except Exception:
+ pass
+
+
+class ROIManager:
+ def __init__(self, main_window):
+ self.main = main_window
+ # ROI collections/state
+ self.rois: List[pg.ROI] = []
+ self.current_roi: Optional[pg.ROI] = None
+ self.roi_by_item = {}
+ self.item_by_roi_id = {}
+ self.roi_names = {}
+ self.stats_row_by_roi_id = {}
+ # Mapping helpers for stats table and overlay labels
+ self.roi_by_stats_row = {}
+ self.roi_label_by_id = {}
+ self.show_names_checkbox = None
+ self.hidden_roi_ids = set()
+
+ # ----- Setup -----
+ def setup_docks(self) -> None:
+ """Create/attach ROI list dock and ROI stats dock to the main window."""
+ try:
+ # ROI list dock removed per request
+ pass
+ except Exception as e:
+ self.main.update_status(f"Error setting up ROI dock: {e}", level='error')
+
+ try:
+ # ROI stats dock (renamed to 'ROI') with selection and actions
+ self.main.roi_stats_dock = QDockWidget("ROI", self.main)
+ self.main.roi_stats_dock.setAllowedAreas(Qt.RightDockWidgetArea)
+
+ # Container widget to hold controls + table
+ container = QWidget(self.main.roi_stats_dock)
+ vlayout = QVBoxLayout(container)
+ try:
+ vlayout.setContentsMargins(6, 6, 6, 6)
+ vlayout.setSpacing(6)
+ except Exception:
+ pass
+
+ # Top controls: actions for selected
+ controls_layout = QHBoxLayout()
+ lbl_actions = QLabel("Actions for selected:")
+ self.show_names_checkbox = QCheckBox("Show names above ROIs")
+ try:
+ self.show_names_checkbox.toggled.connect(lambda _: self.update_all_roi_labels())
+ except Exception:
+ pass
+ controls_layout.addWidget(lbl_actions)
+ controls_layout.addWidget(self.show_names_checkbox)
+ controls_layout.addStretch(1)
+ vlayout.addLayout(controls_layout)
+
+ # ROI stats table with a selection checkbox column
+ self.main.roi_stats_table = QTableWidget(0, 13, container)
+ self.main.roi_stats_table.setHorizontalHeaderLabels([
+ "","Actions","Name","sum","min","max","mean","std","count","x","y","w","h"
+ ])
+ vlayout.addWidget(self.main.roi_stats_table)
+
+ # Set container as dock widget
+ self.main.roi_stats_dock.setWidget(container)
+ self.main.addDockWidget(Qt.RightDockWidgetArea, self.main.roi_stats_dock)
+ # Register toggle under Windows->2d submenu
+ self.main.add_dock_toggle_action(self.main.roi_stats_dock, "ROI", segment_name="2d")
+ try:
+ self.main.roi_stats_dock.visibilityChanged.connect(self.on_roi_stats_dock_visibility_changed)
+ except Exception:
+ pass
+ try:
+ self.main.roi_stats_table.itemChanged.connect(self.on_roi_stats_item_changed)
+ except Exception:
+ pass
+ except Exception as e:
+ self.main.update_status(f"Error setting up ROI stats dock: {e}", level='error')
+
+ # ----- ROI lifecycle -----
+ def create_and_add_roi(self) -> None:
+ """Create a new ROI and add it to the image view and docks."""
+ try:
+ if not hasattr(self.main, 'image_view') or not hasattr(self.main, 'current_2d_data') or self.main.current_2d_data is None:
+ self.main.update_status("Please load image data first", level='warning')
+ return
+
+ # cycle through a set of distinct colors
+ roi_colors = [
+ (255, 0, 0, 255),
+ (0, 255, 0, 255),
+ (0, 0, 255, 255),
+ (255, 255, 0, 255),
+ (255, 0, 255, 255),
+ (0, 255, 255, 255),
+ ]
+ pen = roi_colors[len(self.rois) % len(roi_colors)]
+ roi = ContextRectROI(self.main, [50, 50], [100, 100], pen=pen)
+ self.main.image_view.addItem(roi)
+ self.rois.append(roi)
+ # keep main.rois in sync for compatibility
+ try:
+ if hasattr(self.main, 'rois'):
+ self.main.rois.append(roi)
+ except Exception:
+ pass
+ # track in dock
+ try:
+ self.add_roi_to_dock(roi)
+ except Exception:
+ pass
+ # current_roi for compatibility
+ self.current_roi = roi
+ try:
+ roi.sigRegionChanged.connect(lambda r=roi: (self.show_roi_stats_for_roi(r), self.update_roi_item(r), self.refresh_label_for_roi(r)))
+ # Also update stats when drag/resize finishes (some pyqtgraph versions emit this)
+ if hasattr(roi, 'sigRegionChangeFinished'):
+ roi.sigRegionChangeFinished.connect(lambda r=roi: (self.show_roi_stats_for_roi(r), self.update_roi_item(r), self.refresh_label_for_roi(r)))
+ except Exception:
+ pass
+
+ # Populate stats immediately for the new ROI
+ try:
+ self.show_roi_stats_for_roi(roi)
+ except Exception:
+ pass
+
+ self.main.update_status("ROI added - drag to position and resize as needed")
+ except Exception as e:
+ self.main.update_status(f"Error drawing ROI: {e}", level='error')
+
+ def set_active_roi(self, roi) -> None:
+ try:
+ self.current_roi = roi
+ self.main.current_roi = roi # keep main in sync
+ self.main.update_status("Active ROI set")
+ except Exception as e:
+ self.main.update_status(f"Error setting active ROI: {e}", level='error')
+
+ def rename_roi(self, roi) -> None:
+ """Prompt user to rename an ROI and update docks/stats accordingly."""
+ try:
+ current_name = self.get_roi_name(roi)
+ text, ok = QInputDialog.getText(self.main, "Rename ROI", "Enter ROI name:", text=current_name)
+ if ok and str(text).strip():
+ new_name = str(text).strip()
+ self.roi_names[id(roi)] = new_name
+ # update dock list item text
+ self.update_roi_item(roi)
+ # update stats table name cell if exists (column 1)
+ row = self.stats_row_by_roi_id.get(id(roi))
+ if row is not None and hasattr(self.main, 'roi_stats_table'):
+ try:
+ self.main.roi_stats_table.setItem(row, 1, QTableWidgetItem(new_name))
+ except Exception:
+ pass
+ # update overlay label text if visible
+ try:
+ self.refresh_label_for_roi(roi)
+ except Exception:
+ pass
+ # update dockable ROI plot title if open
+ try:
+ if hasattr(self.main, 'update_roi_plot_dock_title'):
+ self.main.update_roi_plot_dock_title(roi)
+ except Exception:
+ pass
+ self.main.update_status(f"Renamed ROI to '{new_name}'")
+ except Exception as e:
+ self.main.update_status(f"Error renaming ROI: {e}", level='error')
+
+ def delete_roi(self, roi) -> None:
+ try:
+ # remove overlay label if present
+ try:
+ self.remove_label_for_roi(roi)
+ except Exception:
+ pass
+ if hasattr(self.main, 'image_view'):
+ try:
+ self.main.image_view.removeItem(roi)
+ except Exception:
+ pass
+ if roi in self.rois:
+ self.rois.remove(roi)
+ # keep main.rois in sync
+ try:
+ if hasattr(self.main, 'rois') and roi in self.main.rois:
+ self.main.rois.remove(roi)
+ except Exception:
+ pass
+ if getattr(self.main, 'current_roi', None) is roi:
+ self.main.current_roi = None
+ if self.current_roi is roi:
+ self.current_roi = None
+ # Update dock list
+ try:
+ item = self.item_by_roi_id.pop(id(roi), None)
+ if item is not None and hasattr(self.main, 'roi_list'):
+ row = self.main.roi_list.row(item)
+ self.main.roi_list.takeItem(row)
+ if item in self.roi_by_item:
+ self.roi_by_item.pop(item, None)
+ except Exception:
+ pass
+ # Rebuild ROI stats dock for remaining ROIs
+ try:
+ if hasattr(self.main, 'roi_stats_table') and self.main.roi_stats_table is not None:
+ self.main.roi_stats_table.setRowCount(0)
+ self.stats_row_by_roi_id = {}
+ self.roi_by_stats_row = {}
+ frame = self.get_current_frame_data()
+ for r in self.rois:
+ s = self.compute_roi_stats(frame, r)
+ if s:
+ self.update_stats_table_for_roi(r, s)
+ except Exception:
+ pass
+ self.main.update_status("ROI deleted")
+ except Exception as e:
+ self.main.update_status(f"Error deleting ROI: {e}", level='error')
+
+ def save_roi(self, roi) -> None:
+ """Save the selected ROI to the current HDF5 file under /entry/data/rois with same frame structure."""
+ try:
+ # Ensure we have a current HDF5 file path
+ file_path = getattr(self.main, "current_file_path", None)
+ if not file_path or not isinstance(file_path, str):
+ self.main.update_status("No current HDF5 file loaded", level='warning')
+ return
+
+ # Access current data and image item (for transform-aware extraction)
+ data = getattr(self.main, "current_2d_data", None)
+ if data is None:
+ frame = self.get_current_frame_data()
+ if frame is None:
+ self.main.update_status("No image data to save ROI from", level='warning')
+ return
+ data = frame
+ image_item = getattr(self.main.image_view, 'imageItem', None) if hasattr(self.main, 'image_view') else None
+
+ # Helper: extract ROI subarray from a frame
+ def extract_sub(frame):
+ sub = None
+ try:
+ if image_item is not None:
+ sub = roi.getArrayRegion(frame, image_item)
+ if sub is not None and hasattr(sub, 'ndim') and sub.ndim > 2:
+ sub = np.squeeze(sub)
+ except Exception:
+ sub = None
+ if sub is None or int(getattr(sub, 'size', 0)) == 0:
+ pos = roi.pos(); size = roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ hgt, wid = frame.shape
+ x1 = min(wid, x0 + w); y1 = min(hgt, y0 + h)
+ if x0 < x1 and y0 < y1:
+ sub = frame[y0:y1, x0:x1]
+ return sub
+
+ # Build ROI stack across frames (or single frame for 2D data)
+ # Build ROI-only stack: shape is (num_frames, h, w) for 3D data, or (h, w) for 2D
+ if isinstance(data, np.ndarray) and data.ndim == 3:
+ num_frames = int(data.shape[0])
+ samples = []
+ for i in range(num_frames):
+ frame = np.asarray(data[i], dtype=np.float32)
+ sub = extract_sub(frame)
+ if sub is None or int(getattr(sub, 'size', 0)) == 0:
+ # Fallback to zero array using current ROI box size
+ size = roi.size(); w = max(1, int(size.x())); h = max(1, int(size.y()))
+ samples.append(np.zeros((h, w), dtype=np.float32))
+ else:
+ samples.append(np.asarray(sub, dtype=np.float32))
+ # Ensure consistent shape across frames by trimming to smallest h,w
+ min_h = min(s.shape[0] for s in samples)
+ min_w = min(s.shape[1] for s in samples)
+ roi_stack = np.stack([s[:min_h, :min_w] for s in samples], axis=0)
+ else:
+ frame = np.asarray(data, dtype=np.float32)
+ sub = extract_sub(frame)
+ if sub is None or int(getattr(sub, 'size', 0)) == 0:
+ self.main.update_status("ROI appears empty; nothing to save", level='warning')
+ return
+ roi_stack = np.asarray(sub, dtype=np.float32)
+
+ # Write to HDF5 under /entry/data/rois
+ try:
+ with h5py.File(file_path, 'a') as h5f:
+ entry = h5f.require_group('entry')
+ data_grp = entry.get('data')
+ if data_grp is None or not isinstance(data_grp, h5py.Group):
+ data_grp = entry.require_group('data')
+ rois_grp = data_grp.require_group('rois')
+
+ # Dataset name based on ROI name
+ name = self.get_roi_name(roi)
+ ds_name = str(name).replace(' ', '_')
+ # Replace existing dataset if present
+ if ds_name in rois_grp:
+ try:
+ del rois_grp[ds_name]
+ except Exception:
+ pass
+ dset = rois_grp.create_dataset(ds_name, data=roi_stack, dtype=np.float32)
+ # Attach ROI metadata as dataset attributes: position/size and source dataset path
+ try:
+ pos = roi.pos(); size = roi.size()
+ x = max(0, int(pos.x())); y = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ dset.attrs['x'] = int(x)
+ dset.attrs['y'] = int(y)
+ dset.attrs['w'] = int(w)
+ dset.attrs['h'] = int(h)
+ src_path = getattr(self.main, 'selected_dataset_path', None) or '/entry/data/data'
+ dset.attrs['source_path'] = str(src_path)
+ except Exception:
+ pass
+
+ # Info group: original file name and frames used (blank for now)
+ info_grp = rois_grp.require_group('info')
+ try:
+ dt = h5py.string_dtype(encoding='utf-8')
+ if 'original_file_name' in info_grp:
+ del info_grp['original_file_name']
+ info_grp.create_dataset('original_file_name', data=np.array(os.path.basename(file_path), dtype=dt))
+ except Exception:
+ pass
+ try:
+ if 'frames' in info_grp:
+ del info_grp['frames']
+ info_grp.create_dataset('frames', data=np.array([], dtype=np.int32))
+ except Exception:
+ pass
+
+ self.main.update_status(f"ROI saved to HDF5 at /entry/data/rois/{ds_name}")
+ except Exception as e:
+ self.main.update_status(f"Error writing ROI to HDF5: {e}", level='error')
+ except Exception as e:
+ self.main.update_status(f"Error in save_roi: {e}", level='error')
+
+ def clear_all_rois(self) -> None:
+ try:
+ for r in list(self.rois):
+ self.delete_roi(r)
+ except Exception:
+ pass
+
+ def render_rois_for_dataset(self, file_path: str, dataset_path: str) -> None:
+ """Render ROI boxes associated with the given dataset by reading HDF5 ROI metadata."""
+ try:
+ if not file_path or not os.path.exists(file_path):
+ return
+ with h5py.File(file_path, 'r') as h5f:
+ rois_grp = h5f.get('/entry/data/rois')
+ if rois_grp is None:
+ return
+ # Iterate ROI datasets
+ for name in rois_grp.keys():
+ item = rois_grp.get(name)
+ if not isinstance(item, h5py.Dataset):
+ continue
+ src = item.attrs.get('source_path', None)
+ if src is None:
+ continue
+ if str(src) != str(dataset_path):
+ continue
+ # Read xywh
+ x = int(item.attrs.get('x', 0))
+ y = int(item.attrs.get('y', 0))
+ w = int(item.attrs.get('w', max(1, item.shape[-1] if len(item.shape) >= 2 else 1)))
+ h = int(item.attrs.get('h', max(1, item.shape[-2] if len(item.shape) >= 2 else 1)))
+ # Create ROI
+ pen = (255, 0, 0, 255)
+ roi = ContextRectROI(self.main, [x, y], [w, h], pen=pen)
+ try:
+ self.main.image_view.addItem(roi)
+ except Exception:
+ continue
+ # Track in manager structures
+ self.rois.append(roi)
+ try:
+ if hasattr(self.main, 'rois'):
+ self.main.rois.append(roi)
+ except Exception:
+ pass
+ # Name mapping: use dataset name
+ try:
+ self.roi_names[id(roi)] = str(name)
+ except Exception:
+ pass
+ # Wire signals and populate stats
+ try:
+ roi.sigRegionChanged.connect(lambda r=roi: (self.show_roi_stats_for_roi(r), self.update_roi_item(r), self.refresh_label_for_roi(r)))
+ if hasattr(roi, 'sigRegionChangeFinished'):
+ roi.sigRegionChangeFinished.connect(lambda r=roi: (self.show_roi_stats_for_roi(r), self.update_roi_item(r), self.refresh_label_for_roi(r)))
+ self.show_roi_stats_for_roi(roi)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # ----- ROI stats -----
+ def get_current_frame_data(self):
+ """Return the image currently displayed in the ImageView.
+ Falls back to the underlying current_2d_data if the ImageView has no image yet.
+ This ensures ROI stats reflect what the user sees (including frame/log/levels changes).
+ """
+ try:
+ # Prefer the image currently displayed in the ImageView
+ img = None
+ try:
+ if hasattr(self.main, 'image_view'):
+ # Try ImageView.getImage() first (includes display transforms)
+ if hasattr(self.main.image_view, 'getImage'):
+ try:
+ img = self.main.image_view.getImage()
+ if img is not None:
+ arr = np.asarray(img, dtype=np.float32)
+ if isinstance(arr, tuple) and len(arr) > 0:
+ arr = np.asarray(arr[0], dtype=np.float32)
+ if arr.ndim == 3:
+ arr = np.asarray(arr[0], dtype=np.float32)
+ if arr.ndim == 2 and arr.size > 0:
+
+ return arr
+ except Exception:
+ pass
+ # Fallback to imageItem.image
+ if hasattr(self.main.image_view, 'imageItem') and self.main.image_view.imageItem is not None:
+ img = getattr(self.main.image_view.imageItem, 'image', None)
+ except Exception:
+ img = None
+
+ if img is not None:
+ arr = np.asarray(img, dtype=np.float32)
+ # Some versions store a tuple (data, ...); ensure we pick array
+ if isinstance(arr, tuple) and len(arr) > 0:
+ arr = np.asarray(arr[0], dtype=np.float32)
+ # Ensure 2D slice
+ if arr.ndim == 3:
+ # Use first frame if a 3D stack somehow made it to imageItem
+ arr = np.asarray(arr[0], dtype=np.float32)
+ if arr.ndim == 2 and arr.size > 0:
+
+ return arr
+
+ # Fallback: use the underlying data model
+ if not hasattr(self.main, 'current_2d_data') or self.main.current_2d_data is None:
+ return None
+ if self.main.current_2d_data.ndim == 3:
+ frame_index = 0
+ if hasattr(self.main, 'frame_spinbox') and self.main.frame_spinbox.isEnabled():
+ frame_index = self.main.frame_spinbox.value()
+ if frame_index < 0 or frame_index >= self.main.current_2d_data.shape[0]:
+ frame_index = 0
+ arr = np.asarray(self.main.current_2d_data[frame_index], dtype=np.float32)
+
+ return arr
+ else:
+ arr = np.asarray(self.main.current_2d_data, dtype=np.float32)
+
+ return arr
+ except Exception:
+ return None
+
+ def compute_roi_stats(self, frame_data, roi):
+ """Compute stats for ROI using pyqtgraph's array-extraction helpers to honor image/item transforms.
+ Falls back to bounding-box slicing if needed. Returns None if ROI is empty/out-of-bounds.
+ """
+ try:
+ if frame_data is None or roi is None:
+ return None
+
+ # Try to extract ROI region via pyqtgraph (handles scale/transform/orientation)
+ image_item = getattr(self.main.image_view, 'imageItem', None) if hasattr(self.main, 'image_view') else None
+ sub = None
+ try:
+ if image_item is not None:
+ sub = roi.getArrayRegion(frame_data, image_item)
+ if sub is not None and hasattr(sub, 'ndim'):
+ # Ensure 2D (some returns may add an extra dim)
+ if sub.ndim > 2:
+ sub = np.squeeze(sub)
+ except Exception:
+ sub = None
+
+ # Compute xywh using getArraySlice if possible (pixel-space), else fallback to ROI pos/size
+ x0 = y0 = w = h = None
+ try:
+ if image_item is not None:
+ slc_info = roi.getArraySlice(frame_data, image_item)
+ # slc_info returns (slices, transform). slices is typically a tuple of (rows, cols)
+ slices = slc_info[0] if (isinstance(slc_info, (tuple, list)) and len(slc_info) > 0) else None
+ if isinstance(slices, (tuple, list)) and len(slices) >= 2:
+ rs, cs = slices[0], slices[1]
+ # Handle slice or numpy index arrays
+ def _bounds_from_index(idx, maxdim):
+ try:
+ if isinstance(idx, slice):
+ start = int(0 if idx.start is None else idx.start)
+ stop = int(maxdim if idx.stop is None else idx.stop)
+ return start, stop
+ idx_arr = np.asarray(idx)
+ if idx_arr.size > 0:
+ return int(np.min(idx_arr)), int(np.max(idx_arr) + 1)
+ except Exception:
+ pass
+ return 0, maxdim
+ y0_, y1_ = _bounds_from_index(rs, frame_data.shape[0])
+ x0_, x1_ = _bounds_from_index(cs, frame_data.shape[1])
+ x0, y0 = max(0, x0_), max(0, y0_)
+ w = max(0, x1_ - x0_)
+ h = max(0, y1_ - y0_)
+ except Exception:
+ pass
+
+ # If array-region failed or slices produced invalid region, fallback to bounding box from ROI pos/size
+ try:
+ if sub is None or (hasattr(sub, 'size') and int(sub.size) == 0) or any(v is None for v in (x0, y0, w, h)):
+ height, width = frame_data.shape
+ pos = roi.pos(); size = roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ x1 = min(width, x0 + w); y1 = min(height, y0 + h)
+ if x0 >= x1 or y0 >= y1:
+ return None
+ sub = frame_data[y0:y1, x0:x1]
+ w = x1 - x0; h = y1 - y0
+ except Exception:
+ return None
+
+ # Final safety: ensure we have a valid sub-region
+ if sub is None or int(sub.size) == 0:
+ return None
+
+ stats = {
+ 'x': int(x0) if x0 is not None else 0,
+ 'y': int(y0) if y0 is not None else 0,
+ 'w': int(w) if w is not None else int(sub.shape[1]) if sub.ndim == 2 else 0,
+ 'h': int(h) if h is not None else int(sub.shape[0]) if sub.ndim == 2 else 0,
+ 'sum': float(np.sum(sub)),
+ 'min': float(np.min(sub)),
+ 'max': float(np.max(sub)),
+ 'mean': float(np.mean(sub)),
+ 'std': float(np.std(sub)),
+ 'count': int(sub.size),
+ }
+
+ return stats
+ except Exception:
+ return None
+
+ def show_roi_stats_for_roi(self, roi) -> None:
+ try:
+ frame = self.get_current_frame_data()
+ stats = self.compute_roi_stats(frame, roi)
+ if stats is None:
+ self.main.update_status("ROI stats unavailable", level='warning')
+ return
+ text = (f"ROI [{stats['x']},{stats['y']} {stats['w']}x{stats['h']}] | "
+ f"sum={stats['sum']:.3f} min={stats['min']:.3f} max={stats['max']:.3f} "
+ f"mean={stats['mean']:.3f} std={stats['std']:.3f} count={stats['count']}")
+ if hasattr(self.main, 'roi_stats_label') and self.main.roi_stats_label is not None:
+ try:
+ self.main.roi_stats_label.setText(f"ROI Stats: {text}")
+ except Exception:
+ pass
+ try:
+ self.update_stats_table_for_roi(roi, stats)
+ except Exception:
+ pass
+ self.main.update_status("ROI stats computed")
+ except Exception as e:
+ self.main.update_status(f"Error showing ROI stats: {e}", level='error')
+
+ # ----- Batch stats refresh -----
+ def update_all_roi_stats(self):
+ try:
+ frame = self.get_current_frame_data()
+ if frame is None:
+ return
+ for r in list(self.rois):
+ s = self.compute_roi_stats(frame, r)
+ if s:
+ self.update_stats_table_for_roi(r, s)
+ except Exception:
+ pass
+
+ # ----- Dock/list helpers -----
+ def format_roi_text(self, roi):
+ try:
+ pos = roi.pos(); size = roi.size()
+ x = int(pos.x()); y = int(pos.y())
+ w = int(size.x()); h = int(size.y())
+ name = self.get_roi_name(roi)
+ return f"{name}: x={x}, y={y}, w={w}, h={h}"
+ except Exception:
+ return "ROI"
+
+ def get_roi_name(self, roi):
+ try:
+ name = self.roi_names.get(id(roi))
+ if name:
+ return name
+ idx = 1
+ if roi in self.rois:
+ idx = self.rois.index(roi) + 1
+ name = f"ROI {idx}"
+ self.roi_names[id(roi)] = name
+ return name
+ except Exception:
+ return "ROI"
+
+ def add_roi_to_dock(self, roi):
+ try:
+ if not hasattr(self.main, 'roi_list') or self.main.roi_list is None:
+ return
+ text = self.format_roi_text(roi)
+ item = QListWidgetItem(text)
+ self.main.roi_list.addItem(item)
+ self.roi_by_item[item] = roi
+ self.item_by_roi_id[id(roi)] = item
+ except Exception as e:
+ self.main.update_status(f"Error adding ROI to dock: {e}", level='error')
+
+ def update_roi_item(self, roi):
+ try:
+ item = self.item_by_roi_id.get(id(roi))
+ if item is not None:
+ item.setText(self.format_roi_text(roi))
+ except Exception:
+ pass
+
+ def on_roi_list_item_clicked(self, item):
+ try:
+ roi = self.roi_by_item.get(item)
+ if roi:
+ self.set_active_roi(roi)
+ except Exception as e:
+ self.main.update_status(f"Error selecting ROI from dock: {e}", level='error')
+
+ def on_roi_list_item_double_clicked(self, item):
+ try:
+ roi = self.roi_by_item.get(item)
+ if roi:
+ self.show_roi_stats_for_roi(roi)
+ except Exception as e:
+ self.main.update_status(f"Error showing ROI stats from dock: {e}", level='error')
+
+ def on_roi_stats_item_changed(self, item):
+ """Respond to selection checkbox toggles and name edits in the ROI stats table."""
+ try:
+ if item is None:
+ return
+ row = item.row()
+ col = item.column()
+ roi = self.roi_by_stats_row.get(row)
+ if not roi:
+ return
+ if col == 0:
+ # Selection checkbox toggled
+ self.update_label_visibility_for_roi(roi)
+ elif col == 2:
+ # Name edited; update internal mapping and overlay label
+ try:
+ new_name = item.text() if hasattr(item, 'text') else None
+ if new_name:
+ self.roi_names[id(roi)] = str(new_name)
+ # update overlay label text if visible
+ self.refresh_label_for_roi(roi)
+ # update dockable ROI plot title if open
+ try:
+ if hasattr(self.main, 'update_roi_plot_dock_title'):
+ self.main.update_roi_plot_dock_title(roi)
+ except Exception:
+ pass
+ # Update any dock list item if present
+ try:
+ self.update_roi_item(roi)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def on_rois_dock_visibility_changed(self, visible):
+ try:
+ if hasattr(self.main, 'action_show_rois_dock'):
+ self.main.action_show_rois_dock.setChecked(bool(visible))
+ except Exception:
+ pass
+
+ def on_roi_stats_dock_visibility_changed(self, visible):
+ try:
+ if hasattr(self.main, 'action_show_roi_stats_dock'):
+ self.main.action_show_roi_stats_dock.setChecked(bool(visible))
+ except Exception:
+ pass
+
+ # ----- Overlay label helpers -----
+ def update_all_roi_labels(self):
+ try:
+ for roi in list(self.rois):
+ self.update_label_visibility_for_roi(roi)
+ except Exception:
+ pass
+
+ def update_label_visibility_for_roi(self, roi):
+ """Show/hide ROI name label above ROI depending on selection and checkbox."""
+ try:
+ # Determine selection state from stats table
+ row = self.stats_row_by_roi_id.get(id(roi))
+ selected = False
+ if row is not None and hasattr(self.main, 'roi_stats_table'):
+ try:
+ sel_item = self.main.roi_stats_table.item(row, 0)
+ selected = bool(sel_item) and sel_item.checkState() == Qt.Checked
+ except Exception:
+ selected = False
+ show_names = bool(self.show_names_checkbox and self.show_names_checkbox.isChecked())
+ if selected and show_names:
+ # ensure label exists and update position/text
+ self.create_label_for_roi(roi)
+ self.refresh_label_for_roi(roi)
+ else:
+ # hide/remove label for this ROI
+ self.remove_label_for_roi(roi)
+ except Exception:
+ pass
+
+ def create_label_for_roi(self, roi):
+ try:
+ if id(roi) in self.roi_label_by_id:
+ return
+ if not hasattr(self.main, 'image_view'):
+ return
+ name = self.get_roi_name(roi)
+ label = pg.TextItem(text=name, color='w')
+ try:
+ label.setAnchor((0, 1)) # bottom-left anchor
+ except Exception:
+ pass
+ self.main.image_view.addItem(label)
+ self.roi_label_by_id[id(roi)] = label
+ except Exception:
+ pass
+
+ def refresh_label_for_roi(self, roi):
+ try:
+ label = self.roi_label_by_id.get(id(roi))
+ if not label:
+ return
+ name = self.get_roi_name(roi)
+ try:
+ label.setText(name)
+ except Exception:
+ pass
+ pos = roi.pos()
+ x = float(getattr(pos, 'x', lambda: 0)())
+ y = float(getattr(pos, 'y', lambda: 0)())
+ # place just above the ROI box
+ y = max(0.0, y - 5.0)
+ try:
+ label.setPos(x, y)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def remove_label_for_roi(self, roi):
+ try:
+ label = self.roi_label_by_id.pop(id(roi), None)
+ if label and hasattr(self.main, 'image_view'):
+ try:
+ self.main.image_view.removeItem(label)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def set_roi_visibility(self, roi, visible: bool):
+ """Hide/show the ROI graphics and related overlay label using QtAwesome controls."""
+ try:
+ if not hasattr(self.main, 'image_view'):
+ return
+ try:
+ roi.setVisible(bool(visible))
+ except Exception:
+ pass
+ if visible:
+ try:
+ self.hidden_roi_ids.discard(id(roi))
+ except Exception:
+ pass
+ self.update_label_visibility_for_roi(roi)
+ else:
+ try:
+ self.hidden_roi_ids.add(id(roi))
+ except Exception:
+ pass
+ self.remove_label_for_roi(roi)
+ except Exception:
+ pass
+
+ # ----- Stats table helpers -----
+ def ensure_stats_row_for_roi(self, roi):
+ try:
+ if id(roi) in self.stats_row_by_roi_id:
+ return self.stats_row_by_roi_id[id(roi)]
+ if not hasattr(self.main, 'roi_stats_table') or self.main.roi_stats_table is None:
+ return None
+ row = self.main.roi_stats_table.rowCount()
+ self.main.roi_stats_table.insertRow(row)
+ self.stats_row_by_roi_id[id(roi)] = row
+ self.roi_by_stats_row[row] = roi
+ # selection checkbox in column 0
+ try:
+ select_item = QTableWidgetItem()
+ select_item.setFlags(select_item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled)
+ select_item.setCheckState(Qt.Unchecked)
+ self.main.roi_stats_table.setItem(row, 0, select_item)
+ except Exception:
+ pass
+ # actions widget in column 1: hide/show and delete using QtAwesome icons
+ try:
+ actions_widget = QWidget()
+ h = QHBoxLayout(actions_widget)
+ h.setContentsMargins(0, 0, 0, 0)
+ h.setSpacing(2)
+ icon_visible = qta.icon('fa.eye', color='black')
+ icon_hidden = qta.icon('fa.eye-slash', color='black')
+ icon_trash = qta.icon('fa.trash', color='black')
+ # Match checkbox indicator size
+ try:
+ style = getattr(self.main, 'style', lambda: None)()
+ indicator_w = style.pixelMetric(QStyle.PM_IndicatorWidth) if style else 16
+ indicator_h = style.pixelMetric(QStyle.PM_IndicatorHeight) if style else 16
+ except Exception:
+ indicator_w, indicator_h = 16, 16
+ icon_size = QSize(indicator_w, indicator_h)
+ btn_eye = QToolButton(actions_widget)
+ btn_eye.setAutoRaise(True)
+ btn_eye.setCheckable(True)
+ visible = id(roi) not in self.hidden_roi_ids
+ btn_eye.setChecked(visible)
+ btn_eye.setIcon(icon_visible if visible else icon_hidden)
+ btn_eye.setIconSize(icon_size)
+ try:
+ btn_eye.setFixedSize(icon_size.width()+4, icon_size.height()+4)
+ except Exception:
+ pass
+ btn_eye.setToolTip("Hide/Show ROI")
+ btn_trash = QToolButton(actions_widget)
+ btn_trash.setAutoRaise(True)
+ btn_trash.setIcon(icon_trash)
+ btn_trash.setIconSize(icon_size)
+ try:
+ btn_trash.setFixedSize(icon_size.width()+4, icon_size.height()+4)
+ except Exception:
+ pass
+ btn_trash.setToolTip("Delete ROI")
+ # wire actions
+ def on_eye_toggled(checked, r=roi, b=btn_eye):
+ try:
+ self.set_roi_visibility(r, bool(checked))
+ b.setIcon(icon_visible if bool(checked) else icon_hidden)
+ except Exception:
+ pass
+ btn_eye.toggled.connect(on_eye_toggled)
+ btn_trash.clicked.connect(lambda _, r=roi: self.delete_roi(r))
+ h.addWidget(btn_eye)
+ h.addWidget(btn_trash)
+ h.addStretch(1)
+ self.main.roi_stats_table.setCellWidget(row, 1, actions_widget)
+ try:
+ self.main.roi_stats_table.setRowHeight(row, icon_size.height()+6)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # set name cell (column 2)
+ name = self.get_roi_name(roi)
+ self.main.roi_stats_table.setItem(row, 2, QTableWidgetItem(name))
+ return row
+ except Exception:
+ return None
+
+ def update_stats_table_for_roi(self, roi, stats):
+ try:
+ row = self.ensure_stats_row_for_roi(roi)
+ if row is None:
+ return
+ # debug: log computed stats
+
+ # keep name cell in sync (column 2)
+ self.main.roi_stats_table.setItem(row, 2, QTableWidgetItem(self.get_roi_name(roi)))
+ # fill numeric cells with xywh at the end starting column 3
+ self.main.roi_stats_table.setItem(row, 3, QTableWidgetItem(f"{stats['sum']:.3f}"))
+ self.main.roi_stats_table.setItem(row, 4, QTableWidgetItem(f"{stats['min']:.3f}"))
+ self.main.roi_stats_table.setItem(row, 5, QTableWidgetItem(f"{stats['max']:.3f}"))
+ self.main.roi_stats_table.setItem(row, 6, QTableWidgetItem(f"{stats['mean']:.3f}"))
+ self.main.roi_stats_table.setItem(row, 7, QTableWidgetItem(f"{stats['std']:.3f}"))
+ self.main.roi_stats_table.setItem(row, 8, QTableWidgetItem(str(stats['count'])))
+ self.main.roi_stats_table.setItem(row, 9, QTableWidgetItem(str(stats['x'])))
+ self.main.roi_stats_table.setItem(row, 10, QTableWidgetItem(str(stats['y'])))
+ self.main.roi_stats_table.setItem(row, 11, QTableWidgetItem(str(stats['w'])))
+ self.main.roi_stats_table.setItem(row, 12, QTableWidgetItem(str(stats['h'])))
+ except Exception:
+ pass
diff --git a/viewer/workbench/roi_math_dock.py b/viewer/workbench/roi_math_dock.py
new file mode 100644
index 0000000..d9bfa75
--- /dev/null
+++ b/viewer/workbench/roi_math_dock.py
@@ -0,0 +1,305 @@
+#!/usr/bin/env python3
+"""
+ROIMathDock: A dockable window for ROI math expressions (1D view)
+
+- Displays the ROI sub-image flattened to 1D (Index vs Value)
+- Lets user define multiple math expressions using x (indices), y (ROI values), numpy (np),
+ and common numpy functions (sin, cos, log, exp, sqrt, abs, clip, where)
+- Each expression renders as a separate colored curve with a legend entry
+- Updates automatically when the ROI region changes or when the Workbench frame changes
+"""
+
+from PyQt5.QtWidgets import (
+ QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
+ QListWidget, QListWidgetItem, QGroupBox, QMessageBox
+)
+from PyQt5.QtCore import Qt
+import numpy as np
+import pyqtgraph as pg
+
+class ROIMathDock(QDockWidget):
+ def __init__(self, parent, title: str, main_window, roi):
+ super().__init__(title, parent)
+ self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
+ self.main = main_window
+ self.roi = roi
+
+ # Container for plot + controls
+ container = QWidget(self)
+ layout = QVBoxLayout(container)
+ try:
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(6)
+ except Exception:
+ pass
+
+ # Plot setup
+ self.plot_item = pg.PlotItem()
+ self.plot_item.setLabel('bottom', 'Index')
+ self.plot_item.setLabel('left', 'Value')
+ self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
+ layout.addWidget(self.plot_widget)
+ try:
+ self.plot_item.addLegend()
+ except Exception:
+ pass
+
+ # ROI Math panel
+ self._setup_roi_math_panel(layout)
+
+ # Install central widget
+ self.setWidget(container)
+
+ # Internal storage for equations and their plotted items
+ self.math_items = {} # name -> {'expr': str, 'curve': PlotDataItem}
+ self._color_index = 0
+ self._colors = [
+ (31, 119, 180), (255, 127, 14), (44, 160, 44), (214, 39, 40),
+ (148, 103, 189), (140, 86, 75), (227, 119, 194), (127, 127, 127),
+ (188, 189, 34), (23, 190, 207)
+ ]
+
+ # Compute initial ROI vector and plot base curve
+ self._update_base_curve()
+
+ # Wire interactions to keep data in sync
+ self._wire_interactions()
+
+ # ----- UI setup -----
+ def _setup_roi_math_panel(self, parent_layout: QVBoxLayout):
+ gb = QGroupBox("ROI Math")
+ v = QVBoxLayout(gb)
+
+ # Instructions
+ lbl = QLabel(
+ "Define expressions using x (indices), y (ROI values), and numpy (np).\n"
+ "Examples: y*2, np.log1p(y), (y-y.mean())/(y.std()+1e-9), clip(y,0,1000)"
+ )
+ lbl.setWordWrap(True)
+ v.addWidget(lbl)
+
+ # Input row: name + expression + add button
+ row = QHBoxLayout()
+ self.eq_name_edit = QLineEdit(); self.eq_name_edit.setPlaceholderText("Equation name (optional)")
+ self.eq_edit = QLineEdit(); self.eq_edit.setPlaceholderText("Enter expression e.g., np.log1p(y)")
+ self.btn_add = QPushButton("Add Equation")
+ self.btn_add.clicked.connect(self._on_add_equation)
+ row.addWidget(self.eq_name_edit)
+ row.addWidget(self.eq_edit)
+ row.addWidget(self.btn_add)
+ v.addLayout(row)
+
+ # Buttons: recompute all, remove selected, clear all
+ btn_row = QHBoxLayout()
+ self.btn_recompute = QPushButton("Recompute & Plot All")
+ self.btn_recompute.clicked.connect(self._recompute_all)
+ self.btn_remove = QPushButton("Remove Selected")
+ self.btn_remove.clicked.connect(self._remove_selected)
+ self.btn_clear = QPushButton("Clear All")
+ self.btn_clear.clicked.connect(self._clear_all)
+ btn_row.addWidget(self.btn_recompute)
+ btn_row.addWidget(self.btn_remove)
+ btn_row.addWidget(self.btn_clear)
+ v.addLayout(btn_row)
+
+ # List of equations
+ self.eq_list = QListWidget()
+ self.eq_list.itemDoubleClicked.connect(self._edit_equation_item)
+ v.addWidget(self.eq_list)
+
+ parent_layout.addWidget(gb)
+
+ # ----- Data extraction -----
+ def _extract_roi_subimage(self):
+ """Extract the ROI sub-image for the current frame, honoring transforms via getArrayRegion."""
+ frame = None
+ try:
+ frame = self.main.get_current_frame_data()
+ except Exception:
+ frame = None
+ if frame is None:
+ return None
+ sub = None
+ try:
+ image_item = getattr(self.main.image_view, 'imageItem', None) if hasattr(self.main, 'image_view') else None
+ if image_item is not None:
+ sub = self.roi.getArrayRegion(frame, image_item)
+ if sub is not None and hasattr(sub, 'ndim') and sub.ndim > 2:
+ sub = np.squeeze(sub)
+ except Exception:
+ sub = None
+ if sub is None or int(getattr(sub, 'size', 0)) == 0:
+ # Fallback to axis-aligned bbox
+ try:
+ pos = self.roi.pos(); size = self.roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ hgt, wid = frame.shape
+ x1 = min(wid, x0 + w); y1 = min(hgt, y0 + h)
+ if x0 < x1 and y0 < y1:
+ sub = frame[y0:y1, x0:x1]
+ except Exception:
+ sub = None
+ return sub
+
+ def _update_base_curve(self):
+ sub = self._extract_roi_subimage()
+ if sub is None or int(getattr(sub, 'size', 0)) == 0:
+ # Plot an empty placeholder
+ self.x = np.array([0], dtype=int)
+ self.y = np.array([0.0], dtype=float)
+ else:
+ self.y = np.asarray(sub, dtype=np.float32).ravel()
+ self.x = np.arange(len(self.y))
+ # Plot base ROI curve
+ try:
+ self.plot_item.clear()
+ try:
+ self.plot_item.addLegend()
+ except Exception:
+ pass
+ self.base_curve = self.plot_item.plot(self.x, self.y, pen=pg.mkPen(color='y', width=1.5), name='ROI')
+ except Exception:
+ # Fallback
+ try:
+ self.base_curve = self.plot_widget.plot(self.x, self.y, pen='y', clear=True)
+ except Exception:
+ pass
+ # Recompute math curves after base update
+ self._recompute_all()
+
+ # ----- Signal wiring -----
+ def _wire_interactions(self):
+ # ROI changes -> update base and math curves
+ try:
+ if hasattr(self.roi, 'sigRegionChanged'):
+ self.roi.sigRegionChanged.connect(self._update_base_curve)
+ if hasattr(self.roi, 'sigRegionChangeFinished'):
+ self.roi.sigRegionChangeFinished.connect(self._update_base_curve)
+ except Exception:
+ pass
+ # Frame spinbox -> update base and math curves
+ try:
+ if hasattr(self.main, 'frame_spinbox'):
+ self.main.frame_spinbox.valueChanged.connect(lambda _: self._update_base_curve())
+ except Exception:
+ pass
+ # Log scale toggle -> update curves based on what image shows (optional)
+ try:
+ if hasattr(self.main, 'cbLogScale'):
+ self.main.cbLogScale.toggled.connect(lambda _: self._update_base_curve())
+ except Exception:
+ pass
+
+ # ----- Math engine -----
+ def _next_color(self):
+ color = self._colors[self._color_index % len(self._colors)]
+ self._color_index += 1
+ return pg.mkPen(color=color, width=1.5)
+
+ def _safe_eval(self, expr: str):
+ """Safely evaluate an expression using restricted namespace.
+ Returns a numpy array of shape (N,) or a scalar. Raises on error.
+ """
+ allowed = {
+ 'np': np,
+ 'x': self.x,
+ 'y': self.y,
+ 'sin': np.sin, 'cos': np.cos, 'log': np.log, 'exp': np.exp, 'sqrt': np.sqrt,
+ 'abs': np.abs, 'clip': np.clip, 'where': np.where,
+ }
+ globals_dict = {'__builtins__': {}}
+ return eval(expr, globals_dict, allowed)
+
+ def _plot_curve(self, name: str, y_curve):
+ # Convert scalar to horizontal line
+ if np.isscalar(y_curve):
+ y_curve = np.full_like(self.x, float(y_curve), dtype=float)
+ else:
+ y_curve = np.asarray(y_curve, dtype=float)
+ # Validate length
+ if y_curve.shape[0] != self.x.shape[0]:
+ raise ValueError(f"Expression result length {y_curve.shape[0]} does not match ROI length {self.x.shape[0]}")
+ # Remove old curve if re-plotting
+ if name in self.math_items and self.math_items[name]['curve'] is not None:
+ try:
+ self.plot_item.removeItem(self.math_items[name]['curve'])
+ except Exception:
+ pass
+ self.math_items[name]['curve'] = None
+ # Add new curve
+ pen = self._next_color()
+ curve = self.plot_item.plot(self.x, y_curve, pen=pen, name=name)
+ return curve
+
+ # ----- Handlers -----
+ def _on_add_equation(self):
+ expr = (self.eq_edit.text() or '').strip()
+ if not expr:
+ QMessageBox.information(self, "ROI Math", "Please enter an expression.")
+ return
+ name = (self.eq_name_edit.text() or '').strip()
+ if not name:
+ name = f"eq{len(self.math_items) + 1}"
+ try:
+ result = self._safe_eval(expr)
+ curve = self._plot_curve(name, result)
+ except Exception as e:
+ QMessageBox.critical(self, "ROI Math Error", f"Could not evaluate expression:\n{expr}\n\n{e}")
+ return
+ self.math_items[name] = {'expr': expr, 'curve': curve}
+ item = QListWidgetItem(f"{name}: {expr}")
+ item.setData(32, name)
+ self.eq_list.addItem(item)
+ self.eq_name_edit.clear()
+ self.eq_edit.clear()
+
+ def _edit_equation_item(self, item: QListWidgetItem):
+ name = item.data(32)
+ if not name:
+ return
+ expr = self.math_items.get(name, {}).get('expr', '')
+ self.eq_name_edit.setText(name)
+ self.eq_edit.setText(expr)
+
+ def _recompute_all(self):
+ # Recompute and update curves for all equations
+ for i in range(self.eq_list.count()):
+ item = self.eq_list.item(i)
+ name = item.data(32)
+ if not name:
+ continue
+ expr = self.math_items.get(name, {}).get('expr')
+ if not expr:
+ continue
+ try:
+ result = self._safe_eval(expr)
+ curve = self._plot_curve(name, result)
+ self.math_items[name]['curve'] = curve
+ except Exception as e:
+ QMessageBox.critical(self, "ROI Math Error", f"Error recomputing '{name}':\n{expr}\n\n{e}")
+
+ def _remove_selected(self):
+ item = self.eq_list.currentItem()
+ if not item:
+ return
+ name = item.data(32)
+ try:
+ curve = self.math_items.get(name, {}).get('curve')
+ if curve is not None:
+ self.plot_item.removeItem(curve)
+ except Exception:
+ pass
+ self.math_items.pop(name, None)
+ row = self.eq_list.row(item)
+ self.eq_list.takeItem(row)
+
+ def _clear_all(self):
+ for name, rec in list(self.math_items.items()):
+ try:
+ if rec.get('curve') is not None:
+ self.plot_item.removeItem(rec['curve'])
+ except Exception:
+ pass
+ self.math_items.clear()
+ self.eq_list.clear()
diff --git a/viewer/workbench/roi_plot_dialog.py b/viewer/workbench/roi_plot_dialog.py
new file mode 100644
index 0000000..b2103d9
--- /dev/null
+++ b/viewer/workbench/roi_plot_dialog.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+"""
+ROI Plot Dialog for Workbench (1D View)
+
+Displays a 1D graph using the same approach as the Workbench 1D viewer:
+- Flattens the ROI array to a 1D vector and plots Index vs Value
+- Uses PyQtGraph PlotWidget with labeled axes
+- Modeless dialog, does not block the main window
+
+Adds a ROI Math panel to define and plot arbitrary math expressions
+over the ROI data. You can add multiple equations; each renders as an
+additional curve with a legend entry.
+
+Variables available in expressions:
+- x: index array (0..N-1)
+- y: ROI values (flattened to 1D)
+- np: numpy module
+- Common numpy functions are also imported directly: sin, cos, log, exp, sqrt, abs, clip, where
+
+Examples:
+- y * 2
+- np.log1p(y)
+- (y - y.mean()) / (y.std() + 1e-9)
+- clip(y, 0, 1000)
+If an expression evaluates to a scalar, a horizontal line is plotted across x.
+"""
+
+from PyQt5.QtWidgets import (
+ QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton,
+ QListWidget, QListWidgetItem, QGroupBox, QMessageBox
+)
+import numpy as np
+import pyqtgraph as pg
+
+class ROIPlotDialog(QDialog):
+ def __init__(self, parent, roi_image: np.ndarray):
+ super().__init__(parent)
+ self.setWindowTitle("ROI 1D Plot")
+ layout = QVBoxLayout(self)
+
+ # Prepare 1D data: flatten ROI image into a vector
+ self.y = np.asarray(roi_image, dtype=np.float32).ravel()
+ self.x = np.arange(len(self.y))
+
+ # Create a PlotItem and PlotWidget like the 1D viewer
+ self.plot_item = pg.PlotItem()
+ self.plot_item.setLabel('bottom', 'Index')
+ self.plot_item.setLabel('left', 'Value')
+ self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
+ layout.addWidget(self.plot_widget)
+
+ # Add legend for multiple curves
+ try:
+ self.plot_item.addLegend()
+ except Exception:
+ pass
+
+ # Plot the base ROI 1D data
+ try:
+ self.base_curve = self.plot_item.plot(self.x, self.y, pen=pg.mkPen(color='y', width=1.5), name='ROI')
+ except Exception:
+ # Fallback: simple PlotWidget plot
+ self.base_curve = self.plot_widget.plot(self.x, self.y, pen='y')
+
+ # ROI Math group
+ self._setup_roi_math_panel(layout)
+
+ # Internal storage for equations and their plotted items
+ self.math_items = {} # name -> {'expr': str, 'curve': PlotDataItem}
+ self._color_index = 0
+ self._colors = [
+ (31, 119, 180), (255, 127, 14), (44, 160, 44), (214, 39, 40),
+ (148, 103, 189), (140, 86, 75), (227, 119, 194), (127, 127, 127),
+ (188, 189, 34), (23, 190, 207)
+ ]
+
+ def _setup_roi_math_panel(self, parent_layout: QVBoxLayout):
+ gb = QGroupBox("ROI Math")
+ v = QVBoxLayout(gb)
+
+ # Instructions
+ lbl = QLabel(
+ "Define expressions using x (indices), y (ROI values), and numpy (np).\n"
+ "Examples: y*2, np.log1p(y), (y-y.mean())/(y.std()+1e-9), clip(y,0,1000)"
+ )
+ lbl.setWordWrap(True)
+ v.addWidget(lbl)
+
+ # Input row: name + expression + add button
+ row = QHBoxLayout()
+ self.eq_name_edit = QLineEdit(); self.eq_name_edit.setPlaceholderText("Equation name (optional)")
+ self.eq_edit = QLineEdit(); self.eq_edit.setPlaceholderText("Enter expression e.g., np.log1p(y)")
+ self.btn_add = QPushButton("Add Equation")
+ self.btn_add.clicked.connect(self._on_add_equation)
+ row.addWidget(self.eq_name_edit)
+ row.addWidget(self.eq_edit)
+ row.addWidget(self.btn_add)
+ v.addLayout(row)
+
+ # Buttons: recompute all, remove selected, clear all
+ btn_row = QHBoxLayout()
+ self.btn_recompute = QPushButton("Recompute & Plot All")
+ self.btn_recompute.clicked.connect(self._recompute_all)
+ self.btn_remove = QPushButton("Remove Selected")
+ self.btn_remove.clicked.connect(self._remove_selected)
+ self.btn_clear = QPushButton("Clear All")
+ self.btn_clear.clicked.connect(self._clear_all)
+ btn_row.addWidget(self.btn_recompute)
+ btn_row.addWidget(self.btn_remove)
+ btn_row.addWidget(self.btn_clear)
+ v.addLayout(btn_row)
+
+ # List of equations
+ self.eq_list = QListWidget()
+ self.eq_list.itemDoubleClicked.connect(self._edit_equation_item)
+ v.addWidget(self.eq_list)
+
+ parent_layout.addWidget(gb)
+
+ def _next_color(self):
+ color = self._colors[self._color_index % len(self._colors)]
+ self._color_index += 1
+ return pg.mkPen(color=color, width=1.5)
+
+ def _safe_eval(self, expr: str):
+ """Safely evaluate an expression using restricted namespace.
+ Returns a numpy array of shape (N,) or a scalar. Raises on error.
+ """
+ # Allowed names
+ allowed = {
+ 'np': np,
+ 'x': self.x,
+ 'y': self.y,
+ # Common numpy functions directly for convenience
+ 'sin': np.sin, 'cos': np.cos, 'log': np.log, 'exp': np.exp, 'sqrt': np.sqrt,
+ 'abs': np.abs, 'clip': np.clip, 'where': np.where,
+ }
+ # No builtins
+ globals_dict = {'__builtins__': {}}
+ return eval(expr, globals_dict, allowed)
+
+ def _plot_curve(self, name: str, y_curve):
+ # Convert scalar to horizontal line
+ if np.isscalar(y_curve):
+ y_curve = np.full_like(self.x, float(y_curve), dtype=float)
+ else:
+ y_curve = np.asarray(y_curve, dtype=float)
+
+ # Validate length
+ if y_curve.shape[0] != self.x.shape[0]:
+ raise ValueError(f"Expression result length {y_curve.shape[0]} does not match ROI length {self.x.shape[0]}")
+
+ # Remove old curve if re-plotting
+ if name in self.math_items and self.math_items[name]['curve'] is not None:
+ try:
+ self.plot_item.removeItem(self.math_items[name]['curve'])
+ except Exception:
+ pass
+ self.math_items[name]['curve'] = None
+
+ # Add new curve
+ pen = self._next_color()
+ curve = self.plot_item.plot(self.x, y_curve, pen=pen, name=name)
+ return curve
+
+ def _on_add_equation(self):
+ expr = (self.eq_edit.text() or '').strip()
+ if not expr:
+ QMessageBox.information(self, "ROI Math", "Please enter an expression.")
+ return
+ name = (self.eq_name_edit.text() or '').strip()
+ if not name:
+ # Derive a default name
+ name = f"eq{len(self.math_items) + 1}"
+
+ # Evaluate and plot
+ try:
+ result = self._safe_eval(expr)
+ curve = self._plot_curve(name, result)
+ except Exception as e:
+ QMessageBox.critical(self, "ROI Math Error", f"Could not evaluate expression:\n{expr}\n\n{e}")
+ return
+
+ # Store and list
+ self.math_items[name] = {'expr': expr, 'curve': curve}
+ item = QListWidgetItem(f"{name}: {expr}")
+ item.setData(32, name) # store name for retrieval (Qt.UserRole=32)
+ self.eq_list.addItem(item)
+ # Clear inputs
+ self.eq_name_edit.clear()
+ self.eq_edit.clear()
+
+ def _edit_equation_item(self, item: QListWidgetItem):
+ # Simple inline edit via reusing input boxes: load into edits
+ name = item.data(32)
+ if not name:
+ return
+ expr = self.math_items.get(name, {}).get('expr', '')
+ self.eq_name_edit.setText(name)
+ self.eq_edit.setText(expr)
+
+ def _recompute_all(self):
+ # Recompute and update curves for all equations
+ for i in range(self.eq_list.count()):
+ item = self.eq_list.item(i)
+ name = item.data(32)
+ if not name:
+ continue
+ expr = self.math_items.get(name, {}).get('expr')
+ if not expr:
+ continue
+ try:
+ result = self._safe_eval(expr)
+ curve = self._plot_curve(name, result)
+ self.math_items[name]['curve'] = curve
+ except Exception as e:
+ QMessageBox.critical(self, "ROI Math Error", f"Error recomputing '{name}':\n{expr}\n\n{e}")
+
+ def _remove_selected(self):
+ item = self.eq_list.currentItem()
+ if not item:
+ return
+ name = item.data(32)
+ # Remove curve
+ try:
+ curve = self.math_items.get(name, {}).get('curve')
+ if curve is not None:
+ self.plot_item.removeItem(curve)
+ except Exception:
+ pass
+ # Remove from storage and list
+ self.math_items.pop(name, None)
+ row = self.eq_list.row(item)
+ self.eq_list.takeItem(row)
+
+ def _clear_all(self):
+ # Remove all curves
+ for name, rec in list(self.math_items.items()):
+ try:
+ if rec.get('curve') is not None:
+ self.plot_item.removeItem(rec['curve'])
+ except Exception:
+ pass
+ self.math_items.clear()
+ self.eq_list.clear()
+
+ # Optional: method to update ROI data and recompute curves (future use)
+ def update_roi_data(self, roi_image: np.ndarray):
+ self.y = np.asarray(roi_image, dtype=np.float32).ravel()
+ self.x = np.arange(len(self.y))
+ # Update base curve
+ try:
+ self.base_curve.setData(self.x, self.y)
+ except Exception:
+ pass
+ # Recompute all math curves
+ self._recompute_all()
diff --git a/viewer/workbench/roi_plot_dock.py b/viewer/workbench/roi_plot_dock.py
new file mode 100644
index 0000000..7de7b9b
--- /dev/null
+++ b/viewer/workbench/roi_plot_dock.py
@@ -0,0 +1,447 @@
+#!/usr/bin/env python3
+"""
+ROI Plot Dock for Workbench (Configurable X/Y Metrics)
+
+Provides a QDockWidget that displays a 1D plot of an ROI metric across frames.
+You can choose what the X and Y axes represent via dropdowns: time, sum, min,
+max, std. Includes a slider and an interactive vertical line to scrub frames;
+stays in sync with the Workbench's frame spinbox.
+"""
+
+from PyQt5.QtWidgets import (
+ QDockWidget, QWidget, QVBoxLayout, QHBoxLayout, QSlider, QLabel, QComboBox
+)
+from PyQt5.QtCore import Qt
+import numpy as np
+import pyqtgraph as pg
+
+METRIC_OPTIONS = ["time", "sum", "min", "max", "std"]
+AXIS_LABELS = {
+ "time": "Time (Frame Index)",
+ "sum": "ROI Sum",
+ "min": "ROI Min",
+ "max": "ROI Max",
+ "std": "ROI Std",
+}
+
+class ROIPlotDock(QDockWidget):
+ def __init__(self, parent, title: str, main_window, roi):
+ super().__init__(title, parent)
+ self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
+ self.main = main_window
+ self.roi = roi
+ # Ensure title starts with latest ROI name
+ try:
+ if hasattr(self.main, 'get_roi_name'):
+ self._update_title()
+ except Exception:
+ pass
+
+ # Container for controls + plot + slider
+ container = QWidget(self)
+ layout = QVBoxLayout(container)
+ layout.setContentsMargins(6, 6, 6, 6)
+ layout.setSpacing(6)
+
+ # Stats label above plot
+ self.stats_label = QLabel("ROI Stats: -")
+ try:
+ self.stats_label.setStyleSheet("color: #2c3e50; font-size: 11px;")
+ except Exception:
+ pass
+ layout.addWidget(self.stats_label)
+
+ # Axis selection controls
+ controls_row = QHBoxLayout()
+ lbl_x = QLabel("X:"); lbl_y = QLabel("Y:")
+ self.x_select = QComboBox(); self.x_select.addItems(METRIC_OPTIONS)
+ self.y_select = QComboBox(); self.y_select.addItems(METRIC_OPTIONS)
+ # Defaults
+ try:
+ self.x_select.setCurrentText("time")
+ except Exception:
+ pass
+ try:
+ self.y_select.setCurrentText("sum")
+ except Exception:
+ pass
+ controls_row.addWidget(lbl_x)
+ controls_row.addWidget(self.x_select)
+ controls_row.addSpacing(12)
+ controls_row.addWidget(lbl_y)
+ controls_row.addWidget(self.y_select)
+ layout.addLayout(controls_row)
+
+ # Plot setup
+ self.plot_item = pg.PlotItem()
+ self.plot_item.setLabel('bottom', AXIS_LABELS.get('time', 'Time'))
+ self.plot_item.setLabel('left', AXIS_LABELS.get('sum', 'ROI Sum'))
+ self.plot_widget = pg.PlotWidget(plotItem=self.plot_item)
+ layout.addWidget(self.plot_widget)
+
+ # Vertical line to indicate current frame (positioned in X-space)
+ self.frame_line = pg.InfiniteLine(angle=90, movable=True, pen='c')
+ try:
+ self.plot_item.addItem(self.frame_line)
+ except Exception:
+ pass
+
+ # Slider for scrubbing frames
+ self.slider = QSlider(Qt.Horizontal)
+ layout.addWidget(self.slider)
+
+ self.setWidget(container)
+
+ # Storage for series metrics
+ self.series = {m: np.array([0.0], dtype=float) for m in METRIC_OPTIONS}
+
+ # Compute initial series and wire interactions
+ self._compute_time_series()
+ self._wire_interactions()
+ # Initial labels
+ self._update_axis_labels()
+
+ def _get_roi_bounds(self):
+ """Return integer ROI bounds (x0, y0, w, h) based on ROI position/size."""
+ try:
+ pos = self.roi.pos(); size = self.roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ return x0, y0, w, h
+ except Exception:
+ return 0, 0, 1, 1
+
+ def _extract_roi_sub(self, frame, image_item):
+ """Extract ROI subarray from frame, respecting transforms if possible."""
+ sub = None
+ # Try transform-aware extraction
+ try:
+ if image_item is not None:
+ sub = self.roi.getArrayRegion(frame, image_item)
+ if sub is not None and hasattr(sub, 'ndim') and sub.ndim > 2:
+ sub = np.squeeze(sub)
+ except Exception:
+ sub = None
+ # Fallback to axis-aligned bounding box
+ if sub is None or int(getattr(sub, 'size', 0)) == 0:
+ x0, y0, w, h = self._get_roi_bounds()
+ hgt, wid = frame.shape
+ x1 = min(wid, x0 + w); y1 = min(hgt, y0 + h)
+ if x0 < x1 and y0 < y1:
+ sub = frame[y0:y1, x0:x1]
+ else:
+ sub = None
+ return sub
+
+ def _compute_time_series(self):
+ """Compute per-frame ROI metrics: sum, min, max, std, and time (index)."""
+ data = getattr(self.main, 'current_2d_data', None)
+ if data is None or not isinstance(data, np.ndarray):
+ # No data
+ self.series = {m: np.array([0.0], dtype=float) for m in METRIC_OPTIONS}
+ self.series['time'] = np.array([0], dtype=int)
+ self.slider.setEnabled(False)
+ self._update_stats_label()
+ self._update_plot()
+ return
+
+ image_item = getattr(self.main.image_view, 'imageItem', None) if hasattr(self.main, 'image_view') else None
+
+ if data.ndim == 3:
+ num_frames = data.shape[0]
+ sums, mins, maxs, stds = [], [], [], []
+ for i in range(num_frames):
+ frame = np.asarray(data[i], dtype=np.float32)
+ sub = self._extract_roi_sub(frame, image_item)
+ if sub is not None and int(getattr(sub, 'size', 0)) > 0:
+ s = float(np.sum(sub))
+ mn = float(np.min(sub))
+ mx = float(np.max(sub))
+ sd = float(np.std(sub))
+ else:
+ s = 0.0; mn = 0.0; mx = 0.0; sd = 0.0
+ sums.append(s); mins.append(mn); maxs.append(mx); stds.append(sd)
+ self.series = {
+ 'time': np.arange(num_frames, dtype=int),
+ 'sum': np.asarray(sums, dtype=float),
+ 'min': np.asarray(mins, dtype=float),
+ 'max': np.asarray(maxs, dtype=float),
+ 'std': np.asarray(stds, dtype=float),
+ }
+ self.slider.setEnabled(True)
+ try:
+ self.slider.setMinimum(0)
+ self.slider.setMaximum(max(num_frames - 1, 0))
+ cur = 0
+ if hasattr(self.main, 'frame_spinbox') and self.main.frame_spinbox.isEnabled():
+ try:
+ cur = int(self.main.frame_spinbox.value())
+ except Exception:
+ cur = 0
+ self.slider.setValue(cur)
+ except Exception:
+ pass
+ else:
+ # 2D image -> single point (frame 0)
+ frame = np.asarray(data, dtype=np.float32)
+ sub = self._extract_roi_sub(frame, image_item)
+ if sub is not None and int(getattr(sub, 'size', 0)) > 0:
+ s = float(np.sum(sub))
+ mn = float(np.min(sub))
+ mx = float(np.max(sub))
+ sd = float(np.std(sub))
+ else:
+ s = 0.0; mn = 0.0; mx = 0.0; sd = 0.0
+ self.series = {
+ 'time': np.array([0], dtype=int),
+ 'sum': np.array([s], dtype=float),
+ 'min': np.array([mn], dtype=float),
+ 'max': np.array([mx], dtype=float),
+ 'std': np.array([sd], dtype=float),
+ }
+ self.slider.setEnabled(False)
+ self._update_plot()
+
+ def _update_axis_labels(self):
+ try:
+ x_name = self.x_select.currentText()
+ except Exception:
+ x_name = 'time'
+ try:
+ y_name = self.y_select.currentText()
+ except Exception:
+ y_name = 'sum'
+ try:
+ self.plot_item.setLabel('bottom', AXIS_LABELS.get(x_name, x_name))
+ except Exception:
+ pass
+ try:
+ self.plot_item.setLabel('left', AXIS_LABELS.get(y_name, y_name))
+ except Exception:
+ pass
+
+ def _update_stats_label(self):
+ try:
+ # Refresh dock title to keep in sync with ROI name changes
+ try:
+ self._update_title()
+ except Exception:
+ pass
+ frame = None
+ try:
+ frame = self.main.get_current_frame_data()
+ except Exception:
+ frame = None
+ stats = None
+ if frame is not None and hasattr(self.main, 'roi_manager'):
+ try:
+ stats = self.main.roi_manager.compute_roi_stats(frame, self.roi)
+ except Exception:
+ stats = None
+ if stats:
+ text = (f"ROI [{stats['x']},{stats['y']} {stats['w']}x{stats['h']}] | "
+ f"sum={stats['sum']:.3f} min={stats['min']:.3f} max={stats['max']:.3f} "
+ f"mean={stats['mean']:.3f} std={stats['std']:.3f} count={stats['count']}")
+ else:
+ text = "ROI Stats: -"
+ try:
+ # Ensure label shows a consistent prefix
+ if text.startswith("ROI ["):
+ self.stats_label.setText(text)
+ else:
+ self.stats_label.setText(f"ROI Stats: {text}")
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _update_plot(self):
+ try:
+ # Choose data by selection
+ try:
+ x_sel = self.x_select.currentText()
+ except Exception:
+ x_sel = 'time'
+ try:
+ y_sel = self.y_select.currentText()
+ except Exception:
+ y_sel = 'sum'
+ x_data = np.asarray(self.series.get(x_sel, self.series.get('time')), dtype=float)
+ y_data = np.asarray(self.series.get(y_sel, self.series.get('sum')), dtype=float)
+
+ self.plot_item.clear()
+ self.plot_item.plot(x_data, y_data, pen='y')
+ # Re-add vertical line after clear
+ try:
+ self.plot_item.addItem(self.frame_line)
+ except Exception:
+ pass
+ # Position frame line to current frame value in x-space
+ cur = 0
+ if hasattr(self.main, 'frame_spinbox') and self.main.frame_spinbox.isEnabled():
+ try:
+ cur = int(self.main.frame_spinbox.value())
+ except Exception:
+ cur = 0
+ try:
+ if x_sel == 'time':
+ self.frame_line.setPos(cur)
+ else:
+ # Guard against index out of bounds
+ idx = np.clip(cur, 0, len(x_data) - 1)
+ self.frame_line.setPos(float(x_data[idx]))
+ except Exception:
+ pass
+ # Update axis labels
+ self._update_axis_labels()
+ except Exception:
+ # Fallback: simple plot call
+ try:
+ self.plot_widget.plot(self.series.get('time'), self.series.get('sum'), pen='y', clear=True)
+ except Exception:
+ pass
+
+ def _update_title(self):
+ try:
+ name = None
+ try:
+ if hasattr(self.main, 'get_roi_name'):
+ name = self.main.get_roi_name(self.roi)
+ except Exception:
+ name = None
+ if not name:
+ name = 'ROI'
+ try:
+ self.setWindowTitle(f"ROI: {name}")
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _wire_interactions(self):
+ # Slider -> change Workbench frame
+ try:
+ self.slider.valueChanged.connect(self._on_slider_changed)
+ except Exception:
+ pass
+ # Frame spinbox -> update slider and line
+ try:
+ if hasattr(self.main, 'frame_spinbox'):
+ self.main.frame_spinbox.valueChanged.connect(self._on_frame_spinbox_changed)
+ except Exception:
+ pass
+ # ROI changes -> recompute series
+ try:
+ if hasattr(self.roi, 'sigRegionChanged'):
+ self.roi.sigRegionChanged.connect(lambda: (self._compute_time_series(), self._update_stats_label()))
+ if hasattr(self.roi, 'sigRegionChangeFinished'):
+ self.roi.sigRegionChangeFinished.connect(lambda: (self._compute_time_series(), self._update_stats_label()))
+ except Exception:
+ pass
+ # Dragging the vertical line should also update frame
+ try:
+ self.frame_line.sigPositionChanged.connect(self._on_line_moved)
+ except Exception:
+ pass
+ # Axis selection changes -> replot
+ try:
+ self.x_select.currentTextChanged.connect(lambda _: self._update_plot())
+ self.y_select.currentTextChanged.connect(lambda _: self._update_plot())
+ except Exception:
+ pass
+
+ def _on_slider_changed(self, value):
+ # Update Workbench frame and vertical line
+ try:
+ if hasattr(self.main, 'frame_spinbox') and self.main.frame_spinbox.isEnabled():
+ self.main.frame_spinbox.setValue(int(value))
+ except Exception:
+ pass
+ try:
+ # Update line position in current x-space
+ x_sel = self.x_select.currentText() if hasattr(self, 'x_select') else 'time'
+ if x_sel == 'time':
+ self.frame_line.setPos(int(value))
+ else:
+ x_data = np.asarray(self.series.get(x_sel, self.series.get('time')), dtype=float)
+ idx = np.clip(int(value), 0, len(x_data) - 1)
+ self.frame_line.setPos(float(x_data[idx]))
+ except Exception:
+ pass
+ try:
+ self._update_stats_label()
+ except Exception:
+ pass
+
+ def _on_frame_spinbox_changed(self, value):
+ # Keep slider and line in sync with Workbench
+ try:
+ self.slider.blockSignals(True)
+ self.slider.setValue(int(value))
+ except Exception:
+ pass
+ try:
+ # Update line position in current x-space
+ x_sel = self.x_select.currentText() if hasattr(self, 'x_select') else 'time'
+ if x_sel == 'time':
+ self.frame_line.setPos(int(value))
+ else:
+ x_data = np.asarray(self.series.get(x_sel, self.series.get('time')), dtype=float)
+ idx = np.clip(int(value), 0, len(x_data) - 1)
+ self.frame_line.setPos(float(x_data[idx]))
+ except Exception:
+ pass
+ try:
+ self.slider.blockSignals(False)
+ except Exception:
+ pass
+ try:
+ self._update_stats_label()
+ except Exception:
+ pass
+
+ def _on_line_moved(self):
+ # When line is dragged, snap to nearest value and update frame
+ try:
+ pos_val = float(self.frame_line.value())
+ except Exception:
+ pos_val = 0.0
+ # Determine new frame index from x-space
+ try:
+ x_sel = self.x_select.currentText() if hasattr(self, 'x_select') else 'time'
+ except Exception:
+ x_sel = 'time'
+ try:
+ if x_sel == 'time':
+ pos = int(round(pos_val))
+ else:
+ x_data = np.asarray(self.series.get(x_sel, self.series.get('time')), dtype=float)
+ # Find nearest frame index by metric value
+ if len(x_data) == 0:
+ pos = 0
+ else:
+ pos = int(np.argmin(np.abs(x_data - pos_val)))
+ # Clamp
+ if hasattr(self.main, 'frame_spinbox') and self.main.frame_spinbox.isEnabled():
+ max_idx = int(self.slider.maximum()) if hasattr(self.slider, 'maximum') else len(self.series.get('time', [])) - 1
+ pos = int(np.clip(pos, 0, max_idx))
+ except Exception:
+ pos = 0
+ try:
+ self.frame_line.blockSignals(True)
+ # Reposition line to exact x-space of selected frame
+ x_sel = self.x_select.currentText() if hasattr(self, 'x_select') else 'time'
+ if x_sel == 'time':
+ self.frame_line.setPos(int(pos))
+ else:
+ x_data = np.asarray(self.series.get(x_sel, self.series.get('time')), dtype=float)
+ idx = np.clip(int(pos), 0, len(x_data) - 1)
+ self.frame_line.setPos(float(x_data[idx]))
+ self.frame_line.blockSignals(False)
+ except Exception:
+ pass
+ self._on_slider_changed(pos)
+ try:
+ self._update_stats_label()
+ except Exception:
+ pass
diff --git a/viewer/workbench/tabs/__init__.py b/viewer/workbench/tabs/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/viewer/workbench/tabs/base_tab.py b/viewer/workbench/tabs/base_tab.py
new file mode 100644
index 0000000..7230938
--- /dev/null
+++ b/viewer/workbench/tabs/base_tab.py
@@ -0,0 +1,52 @@
+from PyQt5.QtWidgets import QWidget
+from PyQt5 import uic
+
+class BaseTab(QWidget):
+ """
+ Base class for all tabs in the Workbench.
+ Provides common functionality and a consistent interface.
+ """
+ def __init__(self, ui_file, parent=None, main_window=None, title=""):
+ super().__init__(parent)
+ self.main_window = main_window
+ self.title = title
+ uic.loadUi(ui_file, self)
+ self.setObjectName(self.__class__.__name__) # Set object name for easier identification
+ self.setup()
+
+ def setup(self):
+ self.main_window.tabWidget_analysis.addTab(self, self.title)
+
+ def on_tab_selected(self):
+ """
+ Called when this tab is selected.
+ Can be overridden by subclasses to perform tab-specific actions.
+ """
+ pass
+
+ def on_tab_deselected(self):
+ """
+ Called when this tab is deselected.
+ Can be overridden by subclasses to perform tab-specific actions.
+ """
+ pass
+
+ def update_data(self, data_path: str):
+ """
+ Called when new data is loaded or the selected dataset changes.
+ Subclasses should implement this to update their display.
+ """
+ pass
+
+ def clear_data(self):
+ """
+ Called when the HDF5 file is closed or cleared.
+ Subclasses should implement this to clear their display.
+ """
+ pass
+
+ def get_tab_name(self) -> str:
+ """
+ Returns the display name for the tab.
+ """
+ return self.__class__.__name__.replace('Tab', '') # Default to class name without 'Tab'
diff --git a/viewer/workbench/tabs/workspace_3d.py b/viewer/workbench/tabs/workspace_3d.py
new file mode 100644
index 0000000..ba73871
--- /dev/null
+++ b/viewer/workbench/tabs/workspace_3d.py
@@ -0,0 +1,1057 @@
+from typing import Optional
+import os
+from PyQt5.QtWidgets import QDockWidget, QWidget, QVBoxLayout, QLabel, QMessageBox, QFileDialog, QSizePolicy
+from PyQt5.QtCore import Qt, QThread
+import numpy as np
+import pyvista as pv
+
+
+# Import BaseTab using existing tabs package alias
+from .base_tab import BaseTab
+
+# Import 3D visualization components
+try:
+ import pyvista as pyv
+ from pyvistaqt import QtInteractor
+ PYVISTA_AVAILABLE = True
+except ImportError:
+ PYVISTA_AVAILABLE = False
+
+# Worker for off-UI-thread 3D prep
+from viewer.workbench.workers import Render3D
+from utils.hdf5_loader import HDF5Loader, discover_hkl_axis_labels
+from utils.rsm_converter import RSMConverter
+
+class Workspace3D(BaseTab):
+ """
+ 3D Tab encapsulating 3D viewer setup, loading, and plotting operations.
+ Delegates UI widget access via main_window, but centralizes 3D actions here.
+ """
+ def __init__(self, parent=None, main_window=None, title="3D View"):
+ pv.set_plot_theme('dark')
+ try:
+ super().__init__(ui_file='gui/workbench/tabs/tab_3d.ui', parent=parent, main_window=main_window, title=title)
+ self.title = title
+ self.main_window = main_window
+ self.build()
+ self.connect_all()
+ except Exception as e:
+ print(e)
+
+ def connect_all(self):
+ """Wire up 3D controls to main window handlers."""
+ try:
+ self.btn_load_3d_data.clicked.connect(self.load_data)
+ self.cb_colormap_3d.currentTextChanged.connect(self.on_3d_colormap_changed)
+ self.cb_show_points.toggled.connect(self.toggle_3d_points)
+ self.cb_show_slice.toggled.connect(self.toggle_3d_slice)
+ self.sb_min_intensity_3d.editingFinished.connect(self.update_intensity)
+ self.sb_max_intensity_3d.editingFinished.connect(self.update_intensity)
+ except Exception as e:
+ try:
+ self.main_window.update_status(f"Error setting up 3D connections: {e}")
+ except Exception:
+ pass
+
+ def build(self):
+ # Try to create VTK QtInteractor; fall back if unavailable
+ try:
+ self.plotter = QtInteractor(self)
+ except Exception:
+ self.plotter = None
+
+ self.hkl_info_label = None
+
+ if self.plotter is None:
+ placeholder = QLabel("3D (VTK) unavailable in tunnel mode.")
+ try:
+ placeholder.setAlignment(Qt.AlignCenter)
+ except Exception:
+ pass
+ try:
+ placeholder.setWordWrap(True)
+ except Exception:
+ pass
+ try:
+ self.container.insertWidget(1, placeholder, stretch=1)
+ except Exception:
+ pass
+ # Disable 3D controls that would require the plotter
+ for w in [getattr(self, "btn_load_3d_data", None),
+ getattr(self, "cb_show_points", None),
+ getattr(self, "cb_show_slice", None),
+ getattr(self, "sb_min_intensity_3d", None),
+ getattr(self, "sb_max_intensity_3d", None)]:
+ try:
+ if w is not None:
+ w.setEnabled(False)
+ except Exception:
+ pass
+ # Initialize defaults
+ self.cloud_mesh_3d = None
+ self.slab_actor = None
+ self.plane_widget = None
+ self.lut = None
+ self.lut2 = None
+ # Default target raster shape (HxW) for slice rasterization
+ self.orig_shape = (0, 0)
+ self.curr_shape = (0, 0)
+ # Slice & Camera defaults
+ self._slice_translate_step = 0.01
+ self._slice_rotate_step_deg = 1.0
+ self._custom_normal = np.array([0.0, 0.0, 1.0], dtype=float)
+ self._zoom_step = 1.5
+ return
+
+ # If plotter exists, proceed to embed and configure
+ self.container.insertWidget(1, self.plotter, stretch=1)
+ try:
+ self.scrollArea_3d_controls.setMinimumWidth(280)
+ except Exception:
+ pass
+ try:
+ self.plotter.add_axes(xlabel='H', ylabel='K', zlabel='L', x_color='red', y_color='green', z_color='blue')
+ except Exception:
+ pass
+
+ # Color axes: H=red (X), K=green (Y), L=blue (Z)
+ try:
+ ca = getattr(self.plotter.renderer, 'cube_axes_actor', None)
+ if ca:
+ ca.GetXAxesLinesProperty().SetColor(1.0, 0.0, 0.0)
+ ca.GetYAxesLinesProperty().SetColor(0.0, 1.0, 0.0)
+ ca.GetZAxesLinesProperty().SetColor(0.0, 0.0, 1.0)
+ ca.GetTitleTextProperty(0).SetColor(1.0, 0.0, 0.0)
+ ca.GetTitleTextProperty(1).SetColor(0.0, 1.0, 0.0)
+ ca.GetTitleTextProperty(2).SetColor(0.0, 0.0, 1.0)
+ ca.GetLabelTextProperty(0).SetColor(1.0, 0.0, 0.0)
+ ca.GetLabelTextProperty(1).SetColor(0.0, 1.0, 0.0)
+ ca.GetLabelTextProperty(2).SetColor(0.0, 0.0, 1.0)
+ except Exception:
+ pass
+ self.cloud_mesh_3d = None
+ self.slab_actor = None
+ self.plane_widget = None
+ # Initialize LUTs similar to viewer/hkl_3d.py
+ try:
+ self.lut = pv.LookupTable(cmap='jet')
+ self.lut.apply_opacity([0, 1])
+ self.lut2 = pv.LookupTable(cmap='jet')
+ self.lut2.apply_opacity([0, 1])
+ except Exception:
+ self.lut = None
+ self.lut2 = None
+ # Default target raster shape (HxW) for slice rasterization
+ self.orig_shape = (0, 0)
+ self.curr_shape = (0, 0)
+ # Slice & Camera defaults
+ self._slice_translate_step = 0.01
+ self._slice_rotate_step_deg = 1.0
+ self._custom_normal = np.array([0.0, 0.0, 1.0], dtype=float)
+ self._zoom_step = 1.5
+ # Cached true data intensity bounds (set on data load)
+ self._data_intensity_min = None
+ self._data_intensity_max = None
+
+
+ def setup_plot_viewer(self):
+ """
+ Create and embed a PyVista QtInteractor into the 3D tab container.
+ """
+ mw = self.main_window
+ try:
+ if not PYVISTA_AVAILABLE:
+ return
+ pyv.set_plot_theme('dark')
+ mw.plotter_3d = QtInteractor()
+ mw.plotter_3d.add_axes(xlabel='H', ylabel='K', zlabel='L', x_color='red', y_color='green', z_color='blue')
+ if hasattr(mw, 'layout3DPlotHost') and mw.layout3DPlotHost is not None:
+ try:
+ # layout3DPlotHost may be a grid layout from the UI
+ mw.layout3DPlotHost.addWidget(mw.plotter_3d, 0, 0)
+ except Exception:
+ mw.layout3DPlotHost.addWidget(mw.plotter_3d)
+ else:
+ print("Warning: layout3DPlotHost not found, 3D plot may not display correctly")
+ # Info dock
+ try:
+ self._setup_info_dock()
+ except Exception:
+ pass
+ # Clear initial state
+ self.clear_plot()
+ except Exception as e:
+ try:
+ mw.update_status(f"Error setting up 3D plot viewer: {e}")
+ except Exception:
+ pass
+
+ def _setup_info_dock(self):
+ """Create a small 3D Info dock to display render metrics (e.g., render time)."""
+ mw = self.main_window
+ try:
+ mw.three_d_info_dock = QDockWidget("3D Info", mw)
+ mw.three_d_info_dock.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea)
+ container = QWidget(mw.three_d_info_dock)
+ layout = QVBoxLayout(container)
+ mw.three_d_info_label = QLabel("Render time: - ms")
+ layout.addWidget(mw.three_d_info_label)
+ mw.three_d_info_dock.setWidget(container)
+ mw.addDockWidget(Qt.RightDockWidgetArea, mw.three_d_info_dock)
+ try:
+ mw.add_dock_toggle_action(mw.three_d_info_dock, "3D Info", segment_name="3d")
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ mw.update_status(f"Error setting up 3D info dock: {e}")
+ except Exception:
+ pass
+
+ def toggle_3d_points(self, checked: bool):
+ """Shows/Hides the main HKL point cloud."""
+ try:
+ # Support either actor name used by different paths
+ actor = None
+ if "points" in getattr(self.plotter, 'actors', {}):
+ actor = self.plotter.actors.get("points")
+ elif "cloud_volume" in getattr(self.plotter, 'actors', {}):
+ actor = self.plotter.actors.get("cloud_volume")
+ if actor is not None:
+ actor.SetVisibility(bool(checked))
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def toggle_3d_slice(self, checked: bool):
+ """Shows/Hides the interactive plane and the extracted slice points."""
+ try:
+ # Toggle the points extracted by the plane
+ if "slab_points" in getattr(self.plotter, 'actors', {}):
+ try:
+ self.plotter.actors["slab_points"].SetVisibility(bool(checked))
+ except Exception:
+ try:
+ self.plotter.renderer._actors["slab_points"].SetVisibility(bool(checked))
+ except Exception:
+ pass
+
+ # Toggle the interactive plane widget tool
+ if self.plane_widget is not None:
+ try:
+ if checked:
+ # Try both enable methods to support different versions
+ try:
+ self.plane_widget.EnabledOn()
+ except Exception:
+ self.plane_widget.On()
+ else:
+ try:
+ self.plane_widget.EnabledOff()
+ except Exception:
+ self.plane_widget.Off()
+ except Exception:
+ pass
+ else:
+ # Fallback: use plotter.plane_widgets list if available
+ widgets = getattr(self.plotter, "plane_widgets", [])
+ for pw in widgets or []:
+ try:
+ if checked:
+ pw.EnabledOn()
+ else:
+ pw.EnabledOff()
+ except Exception:
+ pass
+
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def on_3d_colormap_changed(self):
+ """Apply selected colormap to the points (and slab if available)."""
+ try:
+ cmap_name = getattr(self.cb_colormap_3d, 'currentText', lambda: 'viridis')()
+ except Exception:
+ cmap_name = 'viridis'
+ # Primary LUT used for the main cloud volume/points
+ try:
+ self.lut = pv.LookupTable(cmap=cmap_name)
+ self.lut.apply_opacity([0, 1])
+ self.lut2 = pv.LookupTable(cmap=cmap_name)
+ self.lut2.apply_opacity([0, 1])
+ except Exception:
+ self.lut = None
+ self.lut2 = None
+
+ # Update points/cloud actor by changing the mapper's lookup table (no re-add)
+ try:
+ actors = getattr(self.plotter, 'actors', {}) or {}
+ tgt_name = 'points' if 'points' in actors else ('cloud_volume' if 'cloud_volume' in actors else None)
+ if tgt_name and self.lut is not None:
+ actor = actors.get(tgt_name)
+ try:
+ # Prefer direct mapper property
+ actor.mapper.lookup_table = self.lut
+ except Exception:
+ try:
+ actor.GetMapper().SetLookupTable(self.lut)
+ except Exception:
+ pass
+ # Maintain scalar range and visibility
+ try:
+ rng = [self.sb_min_intensity_3d.value(), self.sb_max_intensity_3d.value()]
+ actor.mapper.scalar_range = rng
+ except Exception:
+ pass
+ try:
+ actor.SetVisibility(bool(self.cb_show_points.isChecked()))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Attempt to update slab colormap (best-effort) using a separate LUT
+ try:
+ if self.slab_actor is not None:
+ try:
+ try:
+ # Prefer self.lut2 if available
+ self.slab_actor.mapper.lookup_table = (self.lut2 or self.lut)
+ except Exception:
+ try:
+ self.slab_actor.GetMapper().SetLookupTable(self.lut2 or self.lut)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # Keep visibility consistent
+ try:
+ self.slab_actor.SetVisibility(bool(self.cb_show_slice.isChecked()))
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # Render and ensure ranges/visibility remain in sync
+ try:
+ # Update existing scalar bars with the new LUT
+ if hasattr(self.plotter, 'scalar_bars') and self.lut is not None:
+ for _, scalar_bar in self.plotter.scalar_bars.items():
+ try:
+ scalar_bar.SetLookupTable(self.lut)
+ scalar_bar.Modified()
+ except Exception:
+ pass
+ self.plotter.render()
+ except Exception:
+ pass
+ try:
+ self.update_intensity()
+ except Exception:
+ pass
+
+ def update_info(self, render_ms: int):
+ """Update the 3D info dock with render timing in milliseconds."""
+ mw = self.main_window
+ try:
+ if hasattr(mw, 'three_d_info_label') and mw.three_d_info_label is not None:
+ mw.three_d_info_label.setText(f"Render time: {int(render_ms)} ms")
+ except Exception:
+ pass
+
+ # === Clear ===
+ def clear_plot(self):
+ try:
+ if hasattr(self, 'plotter') and self.plotter is not None:
+ self.plotter.clear()
+ self.current_3d_data = None
+ self.mesh = None
+ except Exception as e:
+ try:
+ self.main_window.update_status(f"Error clearing 3D plot: {e}")
+ except Exception:
+ pass
+
+ # === Loading & Plotting ===
+ def load_data(self):
+ """Load dataset and render using the tab's local plotter."""
+ print("Loading data into 3D viewer...")
+ mw = self.main_window
+ import time as _time
+ start_all = _time.perf_counter()
+
+ try:
+ if not PYVISTA_AVAILABLE:
+ QMessageBox.warning(self, "3D Viewer", "PyVista is not available.")
+ return
+
+ # 1. Get the file path
+ file_path = getattr(mw, 'current_file_path', None) or getattr(mw, 'selected_dataset_path', None)
+ if not file_path:
+ file_name, _ = QFileDialog.getOpenFileName(
+ self, 'Select HDF5 or VTI File', '', 'HDF5 Files (*.h5 *.hdf5 *.vti);;All Files (*)'
+ )
+ if not file_name: return
+ file_path = file_name
+ conv = RSMConverter()
+ # 2. Load the raw data
+ # if the data is uncompressed
+ data = conv.load_h5_to_3d(file_path)
+ points, intensities, num_images, shape = data
+
+ # 3. Define what happens when the worker finishes processing
+ def _on_ready():
+ try:
+ # IMPORTANT: Tell the worker to plot to THIS tab's plotter
+ # We pass 'self.plotter' instead of 'mw'
+ self._render3d_worker.plot_3d_points(self)
+ # Cache a reference to the main points/cloud actor for fast updates
+ try:
+ if "points" in self.plotter.actors:
+ self.points_actor = self.plotter.actors.get("points")
+ elif "cloud_volume" in self.plotter.actors:
+ self.points_actor = self.plotter.actors.get("cloud_volume")
+ except Exception:
+ self.points_actor = None
+
+ # Cache true data intensity bounds and set LUT scalar ranges
+ try:
+ self._data_intensity_min = float(np.min(intensities))
+ self._data_intensity_max = float(np.max(intensities))
+ if self.lut is not None:
+ self.lut.scalar_range = (self._data_intensity_min, self._data_intensity_max)
+ if self.lut2 is not None:
+ self.lut2.scalar_range = (self._data_intensity_min, self._data_intensity_max)
+ except Exception:
+ pass
+ # Apply LUTs to actors
+ try:
+ if self.points_actor is not None and self.lut is not None:
+ self.points_actor.mapper.lookup_table = self.lut
+ except Exception:
+ pass
+ try:
+ if self.slab_actor is not None and (self.lut2 or self.lut) is not None:
+ self.slab_actor.mapper.lookup_table = (self.lut2 or self.lut)
+ except Exception:
+ pass
+ # Show bounds like in hkl_3d
+ try:
+ self.plotter.show_bounds(
+ mesh=self.points_actor.mapper.input if self.points_actor is not None else None,
+ xtitle='H Axis', ytitle='K Axis', ztitle='L Axis',
+ ticks='inside', minor_ticks=True,
+ n_xlabels=7, n_ylabels=7, n_zlabels=7,
+ x_color='red', y_color='green', z_color='blue',
+ font_size=20
+ )
+ except Exception:
+ pass
+ # Sync scalar bars to primary LUT
+ try:
+ if hasattr(self.plotter, 'scalar_bars') and self.lut is not None:
+ for _, sb in self.plotter.scalar_bars.items():
+ try:
+ sb.SetLookupTable(self.lut)
+ sb.Modified()
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # Ensure visibility respects checkboxes
+ try:
+ self.toggle_3d_points(self.cb_show_points.isChecked())
+ self.toggle_3d_slice(self.cb_show_slice.isChecked())
+ except Exception:
+ pass
+
+ # Align current intensity range with data bounds and reflect in UI
+ try:
+ self.update_intensity()
+ except Exception:
+ pass
+
+ # Switch to this tab automatically
+ if hasattr(mw, 'tabWidget_analysis'):
+ idx = mw.tabWidget_analysis.indexOf(self)
+ mw.tabWidget_analysis.setCurrentIndex(idx)
+
+ self.main_window.update_status("3D Rendering Complete")
+ except Exception as e:
+ print(f"Render Error: {e}")
+
+ # 4. Threaded Execution
+ self._render_thread = QThread(self)
+ self._render3d_worker = Render3D(
+ points=points,
+ intensities=intensities,
+ num_images=num_images,
+ shape=shape
+ )
+
+ self._render3d_worker.moveToThread(self._render_thread)
+
+ # Connect signals
+ self._render_thread.started.connect(self._render3d_worker.run)
+ self._render3d_worker.render_ready.connect(_on_ready) # Use the local plotter
+
+ # Cleanup
+ self._render3d_worker.finished.connect(self._render_thread.quit)
+ self._render3d_worker.finished.connect(self._render3d_worker.deleteLater)
+ self._render_thread.finished.connect(self._render_thread.deleteLater)
+
+ self._render_thread.start()
+
+ except Exception as e:
+ QMessageBox.critical(self, "3D Viewer Error", f"Error: {str(e)}")
+ finally:
+ elapsed = int((_time.perf_counter() - start_all) * 1000)
+ self.update_info(elapsed)
+
+ def on_plane_update(self, normal, origin):
+ """Extracts points near the plane to simulate a 3D slice."""
+ if self.cloud_mesh_3d is None:
+ return
+
+ # Plane math: (Point - Origin) ⋅ Normal
+ vec = self.cloud_mesh_3d.points - origin
+ dist = np.dot(vec, normal)
+
+ # Thickness of the slice in HKL units (align with HKL3D)
+ thickness = 0.002
+ mask = np.abs(dist) < thickness
+
+ slab = self.cloud_mesh_3d.extract_points(mask)
+
+ if slab.n_points > 0:
+ self.slab_actor = self.plotter.add_mesh(
+ slab,
+ name="slab_points",
+ render_points_as_spheres=True,
+ point_size=8,
+ scalars='intensity',
+ cmap=(self.lut2 or self.lut),
+ show_scalar_bar=False
+ )
+ # Ensure the new slab respects the current checkbox state
+ self.slab_actor.SetVisibility(self.cb_show_slice.isChecked())
+
+ # Match current intensity
+ clim = [self.sb_min_intensity_3d.value(), self.sb_max_intensity_3d.value()]
+ self.slab_actor.mapper.scalar_range = clim
+
+ # Keep plane widget synchronized to final state
+ try:
+ widgets = getattr(self.plotter, 'plane_widgets', [])
+ if self.plane_widget is not None:
+ self.plane_widget.SetNormal(normal)
+ self.plane_widget.SetOrigin(origin)
+ elif widgets:
+ widgets[0].SetNormal(normal)
+ widgets[0].SetOrigin(origin)
+ except Exception:
+ pass
+
+ self.plotter.render()
+ # Respect slice toggle state after update
+ try:
+ self.toggle_3d_slice(self.cb_show_slice.isChecked())
+ except Exception:
+ pass
+
+ # Update the 3D Info dock with HKL slice information
+ try:
+ info_dock = getattr(self.main_window, 'info_3d_dock', None)
+ if info_dock is not None:
+ shape = None
+ try:
+ shape = tuple(getattr(self, 'curr_shape', None) or ())
+ if not (isinstance(shape, tuple) and len(shape) == 2):
+ shape = (0, 0)
+ except Exception:
+ shape = (0, 0)
+ info_dock.update_from_slice(
+ slab,
+ np.asarray(normal, dtype=float),
+ np.asarray(origin, dtype=float),
+ target_shape=shape
+ )
+ except Exception:
+ pass
+
+ def update_intensity(self):
+ """Updates the min/max intensity levels and scalar bar range"""
+ if not self.plotter:
+ return
+
+ # Read requested values from UI
+ try:
+ requested_min = float(self.sb_min_intensity_3d.value())
+ requested_max = float(self.sb_max_intensity_3d.value())
+ except Exception:
+ # Fallback to current mapper range if spinboxes unavailable
+ requested_min, requested_max = 0.0, 1.0
+
+ # Clamp to true data range if available
+ data_min = getattr(self, '_data_intensity_min', None)
+ data_max = getattr(self, '_data_intensity_max', None)
+ vmin = requested_min
+ vmax = requested_max
+ if data_min is not None and data_max is not None:
+ vmin = max(requested_min, data_min)
+ vmax = min(requested_max, data_max)
+
+ # Enforce ordering and non-zero span
+ if vmin > vmax:
+ vmin, vmax = vmax, vmin
+ if vmin == vmax:
+ vmax = vmin + 1e-6
+
+ # Reflect applied values back to the UI
+ try:
+ self.sb_min_intensity_3d.setValue(vmin)
+ self.sb_max_intensity_3d.setValue(vmax)
+ except Exception:
+ pass
+
+ # Define the new scalar range
+ new_range = [vmin, vmax]
+
+ # Update main cloud/points actor scalar range
+ try:
+ actors = getattr(self.plotter, 'actors', {}) or {}
+ if "points" in actors:
+ actors["points"].mapper.scalar_range = (new_range[0], new_range[1])
+ if "cloud_volume" in actors:
+ actors["cloud_volume"].mapper.scalar_range = (new_range[0], new_range[1])
+ except Exception:
+ pass
+
+ if "slab_points" in self.plotter.actors:
+ self.plotter.actors["slab_points"].mapper.scalar_range = (new_range[0], new_range[1])
+
+ # Update the volume actor by re-adding with new clim range
+ if hasattr(self.plotter, 'scalar_bars'):
+ for bar in self.plotter.scalar_bars.values():
+ try:
+ bar.GetLookupTable().SetTableRange(new_range[0], new_range[1])
+ except Exception:
+ pass
+
+ # Force update of all scalar bars with the new range
+ if hasattr(self.plotter, 'scalar_bars'):
+ for name, scalar_bar in self.plotter.scalar_bars.items():
+ if scalar_bar:
+ try:
+ scalar_bar.GetLookupTable().SetTableRange(new_range[0], new_range[1])
+ scalar_bar.Modified()
+ except Exception:
+ pass
+
+ # Update slice actor scalar range if it exists
+ if "slice" in self.plotter.actors:
+ slice_actor = self.plotter.actors["slice"]
+ if hasattr(slice_actor, 'mapper'):
+ try:
+ slice_actor.mapper.scalar_range = (new_range[0], new_range[1])
+ except Exception:
+ pass
+
+ # Force a re-render to apply the changes
+ self.plotter.render()
+ # Respect checkbox states after intensity update
+ try:
+ self.toggle_3d_points(self.cb_show_points.isChecked())
+ self.toggle_3d_slice(self.cb_show_slice.isChecked())
+ except Exception:
+ pass
+
+ # Update Info labels and availability after intensity changes (best-effort)
+ try:
+ self.update_info_slice_labels()
+ self._refresh_availability()
+ except Exception:
+ pass
+
+ # === Visibility & Colormap ===
+
+
+ def reset_slice(self):
+ """Reset slice to HK (xy) preset at the data center."""
+ try:
+ # Determine a reasonable center
+ origin = None
+ try:
+ if self.cloud_mesh_3d is not None and hasattr(self.cloud_mesh_3d, 'center'):
+ origin = np.array(self.cloud_mesh_3d.center, dtype=float)
+ elif self.mesh is not None and hasattr(self.mesh, 'center'):
+ origin = np.array(self.mesh.center, dtype=float)
+ except Exception:
+ origin = None
+ if origin is None:
+ origin = np.array([0.0, 0.0, 0.0], dtype=float)
+ # Normal along L for HK plane
+ normal = np.array([0.0, 0.0, 1.0], dtype=float)
+ self.set_plane_state(normal, origin)
+ except Exception as e:
+ try:
+ self.main_window.update_status(f"Error resetting 3D slice: {e}")
+ except Exception:
+ pass
+
+ def _remove_plane_widget(self):
+ """Safely remove existing plane widget (if any)."""
+ try:
+ # Use the same attribute name that is set by the Render3D worker
+ if self.plane_widget is not None:
+ try:
+ self.plane_widget.EnabledOff()
+ except Exception:
+ pass
+
+ try:
+ self.plotter.clear_plane_widgets()
+ except Exception:
+ pass
+
+ self.plane_widget = None
+ except Exception:
+ pass
+
+ def toggle_pointer(self, checked: bool):
+ """Enable/Disable the interactive plane widget and show/hide slab points."""
+ try:
+ # Plane widget visibility
+ if self.plane_widget is not None:
+ try:
+ if checked:
+ self.plane_widget.On()
+ else:
+ self.plane_widget.Off()
+ except Exception:
+ pass
+ else:
+ # Fallback: use plotter.plane_widgets list if available
+ widgets = getattr(self.plotter, "plane_widgets", [])
+ for pw in widgets or []:
+ try:
+ if checked:
+ pw.EnabledOn()
+ else:
+ pw.EnabledOff()
+ except Exception:
+ pass
+ # Slab points actor visibility
+ if "slab_points" in self.plotter.actors:
+ try:
+ self.plotter.actors["slab_points"].SetVisibility(bool(checked))
+ except Exception:
+ try:
+ self.plotter.renderer._actors["slab_points"].SetVisibility(bool(checked))
+ except Exception:
+ pass
+ self.plotter.render()
+ except Exception:
+ pass
+
+ # ===== Info/Availability (align with hkl_3d patterns) =====
+
+ def _refresh_availability(self):
+ """Enable/disable controls depending on plotter/data availability."""
+ try:
+ has_data = bool(self.cloud_mesh_3d is not None or getattr(self, 'points_actor', None) is not None)
+ for w in [getattr(self, "cb_show_points", None),
+ getattr(self, "cb_show_slice", None),
+ getattr(self, "sb_min_intensity_3d", None),
+ getattr(self, "sb_max_intensity_3d", None)]:
+ try:
+ if w is not None:
+ w.setEnabled(has_data)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ # ===== Slice Plane helpers =====
+ def get_plane_state(self):
+ """Return (normal, origin) for current plane; defaults to Z-axis and mesh center."""
+ try:
+ if self.plane_widget is not None:
+ try:
+ normal = np.array(self.plane_widget.GetNormal(), dtype=float)
+ origin = np.array(self.plane_widget.GetOrigin(), dtype=float)
+ return normal, origin
+ except Exception:
+ pass
+ # Fallback to first plane widget if present
+ widgets = getattr(self.plotter, 'plane_widgets', [])
+ if widgets:
+ pw = widgets[0]
+ try:
+ normal = np.array(pw.GetNormal(), dtype=float)
+ origin = np.array(pw.GetOrigin(), dtype=float)
+ return normal, origin
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # Defaults
+ normal = np.array([0.0, 0.0, 1.0], dtype=float)
+ try:
+ if self.cloud_mesh_3d is not None and hasattr(self.cloud_mesh_3d, 'center'):
+ origin = np.array(self.cloud_mesh_3d.center, dtype=float)
+ elif self.mesh is not None and hasattr(self.mesh, 'center'):
+ origin = np.array(self.mesh.center, dtype=float)
+ else:
+ origin = np.array([0.0, 0.0, 0.0], dtype=float)
+ except Exception:
+ origin = np.array([0.0, 0.0, 0.0], dtype=float)
+ return normal, origin
+
+ def set_plane_state(self, normal, origin):
+ """Programmatically set plane state and trigger slice update."""
+ try:
+ n = self.normalize_vector(np.array(normal, dtype=float))
+ o = np.array(origin, dtype=float)
+ # Update widget if available
+ if self.plane_widget is not None:
+ try:
+ self.plane_widget.SetNormal(n)
+ self.plane_widget.SetOrigin(o)
+ except Exception:
+ pass
+ else:
+ widgets = getattr(self.plotter, 'plane_widgets', [])
+ if widgets:
+ try:
+ widgets[0].SetNormal(n)
+ widgets[0].SetOrigin(o)
+ except Exception:
+ pass
+ # Refresh slice
+ try:
+ self.on_plane_update(n, o)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ @staticmethod
+ def normalize_vector(v):
+ try:
+ v = np.array(v, dtype=float)
+ norm = float(np.linalg.norm(v))
+ if norm <= 0.0:
+ return np.array([0.0, 0.0, 1.0], dtype=float)
+ return v / norm
+ except Exception:
+ return np.array([0.0, 0.0, 1.0], dtype=float)
+
+ def set_custom_normal(self, n):
+ try:
+ self._custom_normal = np.array(n, dtype=float)
+ except Exception:
+ self._custom_normal = np.array([0.0, 0.0, 1.0], dtype=float)
+
+ def set_plane_preset(self, preset_text: str):
+ """Set plane normal to preset HK/KL/HL or custom vector."""
+ try:
+ preset = (preset_text or '').lower()
+ except Exception:
+ preset = ''
+ if ('xy' in preset) or ('hk' in preset):
+ n = np.array([0.0, 0.0, 1.0], dtype=float)
+ elif ('yz' in preset) or ('kl' in preset):
+ n = np.array([1.0, 0.0, 0.0], dtype=float)
+ elif ('xz' in preset) or ('hl' in preset):
+ n = np.array([0.0, 1.0, 0.0], dtype=float)
+ else:
+ # Custom
+ n = self.normalize_vector(getattr(self, '_custom_normal', np.array([0.0, 0.0, 1.0], dtype=float)))
+ _, origin = self.get_plane_state()
+ self.set_plane_state(n, origin)
+
+ # ===== Translation =====
+ def nudge_along_normal(self, sign: int):
+ try:
+ normal, origin = self.get_plane_state()
+ step = float(getattr(self, '_slice_translate_step', 0.01))
+ origin_new = origin + float(sign) * step * normal
+ self.set_plane_state(normal, origin_new)
+ except Exception:
+ pass
+
+ def nudge_along_axis(self, axis: str, sign: int):
+ try:
+ axis = (axis or 'H').upper()
+ if axis == 'H':
+ d = np.array([1.0, 0.0, 0.0], dtype=float)
+ elif axis == 'K':
+ d = np.array([0.0, 1.0, 0.0], dtype=float)
+ else:
+ d = np.array([0.0, 0.0, 1.0], dtype=float)
+ normal, origin = self.get_plane_state()
+ step = float(getattr(self, '_slice_translate_step', 0.01))
+ origin_new = origin + float(sign) * step * d
+ self.set_plane_state(normal, origin_new)
+ except Exception:
+ pass
+
+ # ===== Rotation =====
+ def rotate_about_axis(self, axis: str, deg: float):
+ try:
+ axis = (axis or 'H').upper()
+ if axis == 'H':
+ u = np.array([1.0, 0.0, 0.0], dtype=float)
+ elif axis == 'K':
+ u = np.array([0.0, 1.0, 0.0], dtype=float)
+ else:
+ u = np.array([0.0, 0.0, 1.0], dtype=float)
+ normal, origin = self.get_plane_state()
+ theta = float(np.deg2rad(deg))
+ ux, uy, uz = u
+ c, s = np.cos(theta), np.sin(theta)
+ R = np.array([
+ [c+ux*ux*(1-c), ux*uy*(1-c)-uz*s, ux*uz*(1-c)+uy*s],
+ [uy*ux*(1-c)+uz*s, c+uy*uy*(1-c), uy*uz*(1-c)-ux*s],
+ [uz*ux*(1-c)-uy*s, uz*uy*(1-c)+ux*s, c+uz*uz*(1-c)]
+ ], dtype=float)
+ new_normal = R @ normal
+ new_normal = self.normalize_vector(new_normal)
+ self.set_plane_state(new_normal, origin)
+ except Exception:
+ pass
+
+ # ===== Camera =====
+ def zoom_in(self):
+ try:
+ step = float(getattr(self, '_zoom_step', 1.5))
+ if step <= 1.0:
+ step = 1.5
+ self.plotter.camera.zoom(step)
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def zoom_out(self):
+ try:
+ step = float(getattr(self, '_zoom_step', 1.5))
+ if step <= 1.0:
+ step = 1.5
+ self.plotter.camera.zoom(1.0 / step)
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def reset_camera(self):
+ try:
+ self.plotter.reset_camera()
+ self.plotter.render()
+ except Exception:
+ pass
+
+ def set_camera_position(self, preset: str):
+ try:
+ txt = (preset or '').strip().lower()
+ p = self.plotter
+ cam = getattr(p, 'camera', None)
+ # center focus
+ try:
+ if self.cloud_mesh_3d is not None and hasattr(self.cloud_mesh_3d, 'center'):
+ p.set_focus(self.cloud_mesh_3d.center)
+ except Exception:
+ pass
+ if txt in ('hk', 'xy'):
+ p.view_xy()
+ elif txt in ('kl', 'yz'):
+ p.view_yz()
+ elif txt in ('hl', 'xz'):
+ p.view_xz()
+ elif 'iso' in txt:
+ try:
+ p.view_isometric()
+ except Exception:
+ try:
+ p.view_vector((1.0, 1.0, 1.0))
+ if cam is not None:
+ cam.view_up = (0.0, 0.0, 1.0)
+ except Exception:
+ pass
+ else:
+ # Axis-aligned
+ if 'h+' in txt:
+ p.view_vector((1.0, 0.0, 0.0))
+ if cam is not None:
+ cam.view_up = (0.0, 0.0, 1.0)
+ elif 'h-' in txt:
+ p.view_vector((-1.0, 0.0, 0.0))
+ if cam is not None:
+ cam.view_up = (0.0, 0.0, 1.0)
+ elif 'k+' in txt:
+ p.view_vector((0.0, 1.0, 0.0))
+ if cam is not None:
+ cam.view_up = (0.0, 0.0, 1.0)
+ elif 'k-' in txt:
+ p.view_vector((0.0, -1.0, 0.0))
+ if cam is not None:
+ cam.view_up = (0.0, 0.0, 1.0)
+ elif 'l+' in txt:
+ p.view_vector((0.0, 0.0, 1.0))
+ if cam is not None:
+ cam.view_up = (0.0, 1.0, 0.0)
+ elif 'l-' in txt:
+ p.view_vector((0.0, 0.0, -1.0))
+ if cam is not None:
+ cam.view_up = (0.0, 1.0, 0.0)
+ try:
+ if cam is not None and hasattr(cam, 'orthogonalize_view_up'):
+ cam.orthogonalize_view_up()
+ except Exception:
+ pass
+ try:
+ p.render()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def view_slice_normal(self):
+ try:
+ normal, origin = self.get_plane_state()
+ normal = self.normalize_vector(normal)
+ origin = np.array(origin, dtype=float)
+ cam = getattr(self.plotter, 'camera', None)
+ if cam is None:
+ return
+ # distance heuristic
+ try:
+ rng = None
+ if self.cloud_mesh_3d is not None and hasattr(self.cloud_mesh_3d, 'points'):
+ rng = self.cloud_mesh_3d.points.max(axis=0) - self.cloud_mesh_3d.points.min(axis=0)
+ d = float(np.linalg.norm(rng)) * 0.5 if rng is not None else 1.0
+ except Exception:
+ d = 1.0
+ try:
+ cam.focal_point = origin.tolist()
+ except Exception:
+ pass
+ try:
+ cam.position = (origin + normal * d).tolist()
+ except Exception:
+ pass
+ # adjust view up if parallel
+ try:
+ up = np.array(getattr(cam, 'view_up', [0.0, 1.0, 0.0]), dtype=float)
+ upn = self.normalize_vector(up)
+ if abs(float(np.dot(upn, normal))) > 0.99:
+ new_up = np.array([0.0, 1.0, 0.0], dtype=float) if abs(normal[1]) < 0.99 else np.array([1.0, 0.0, 0.0], dtype=float)
+ cam.view_up = new_up.tolist()
+ except Exception:
+ pass
+ try:
+ self.plotter.render()
+ except Exception:
+ pass
+ except Exception:
+ pass
diff --git a/viewer/workbench/workbench.py b/viewer/workbench/workbench.py
new file mode 100644
index 0000000..0ab9a2d
--- /dev/null
+++ b/viewer/workbench/workbench.py
@@ -0,0 +1,3588 @@
+#!/usr/bin/env python3
+"""
+Workbench Window
+A PyQt-based application for analyzing HDF5 data with 2D visualization capabilities.
+Inherits from BaseWindow for consistent functionality across the application.
+"""
+
+import sys
+import os
+from pathlib import Path
+from PyQt5.QtWidgets import QApplication, QMessageBox, QTreeWidgetItem, QFileDialog, QMenu, QAction, QVBoxLayout, QDockWidget, QListWidget, QListWidgetItem, QInputDialog, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QWidget
+from PyQt5.QtCore import QTimer, Qt, pyqtSlot, QThread, QObject, pyqtSignal
+from PyQt5.QtGui import QBrush, QColor, QCursor
+import h5py
+import hdf5plugin # Import hdf5plugin for decompression support
+import glob
+import numpy as np
+import time
+import pyqtgraph as pg
+from viewer.workbench.tabs.workspace_3d import Workspace3D
+
+
+# Add the project root to the Python path
+project_root = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(project_root))
+
+from viewer.base_window import BaseWindow
+from utils.hdf5_loader import HDF5Loader
+
+# Dimension-specific controls
+from viewer.controls.controls_1d import Controls1D
+from viewer.controls.controls_2d import Controls2D
+from viewer.workbench.managers.roi_manager import ROIManager
+from viewer.workbench.dock_window import DockWindow
+from viewer.workbench.docks.data_structure import DataStructureDock
+from viewer.workbench.docks.info_2d_dock import Info2DDock
+from viewer.workbench.docks.info_3d_dock import Info3DDock
+from viewer.workbench.docks.slice_plane import SlicePlaneDock
+#from viewer.workbench.docks.dash_ai import DashAI
+
+
+class WorkbenchWindow(BaseWindow):
+ """
+ Workbench window for data analysis.
+ Inherits from BaseWindow and adds specific functionality for HDF5 analysis.
+ """
+
+ # === Initialization & UI Setup ===
+ def __init__(self):
+ """Initialize the Workbench window."""
+ super().__init__(ui_file_name="workbench/workbench.ui", viewer_name="Workbench")
+ self.setup_window_properties("Workbench - Data Analysis", 1600, 1000)
+
+ # ====== DOCKS START ====== #
+ # 2d
+ #self.dash_sam_dock = DashAI(main_window=self)
+
+ # 3d
+
+ # other
+ self.data_structure_dock = DataStructureDock(main_window=self, segment_name="other", dock_area=Qt.LeftDockWidgetArea)
+ # info dock (2D)
+ self.info_2d_dock = Info2DDock(main_window=self, title="2D Info", segment_name="2d", dock_area=Qt.RightDockWidgetArea)
+ # info dock (3D)
+ self.info_3d_dock = Info3DDock(main_window=self, title="3D Info", segment_name="3d", dock_area=Qt.RightDockWidgetArea)
+ # roi
+
+ # Alias Workbench's tree to the dock's tree widget
+ self.tree_data = self.data_structure_dock.tree_data
+ # Hide any fixed left panel from the UI and give space to analysis
+ if hasattr(self, 'leftPanel') and self.leftPanel is not None:
+ self.leftPanel.hide()
+ if hasattr(self, 'mainSplitter') and self.mainSplitter is not None:
+ self.mainSplitter.setSizes([0, self.width()])
+ # ======= DOCKS END ======== #
+
+
+ # ======= TABS START ======= #
+ self.tab_1d = None
+ self.tab_2d = None
+ self.tab_3d = Workspace3D(parent=self, main_window=self)
+ # Slice Controls dock (left, under Data Structure)
+ try:
+ self.slice_plane_dock = SlicePlaneDock(main_window=self, segment_name="3d", dock_area=Qt.LeftDockWidgetArea)
+ # Position below Data Structure dock
+ try:
+ self.splitDockWidget(self.data_structure_dock, self.slice_plane_dock, Qt.Vertical)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ # ======= TABS END ========= #
+
+
+ # ===== CONTROLS START ===== #
+ self.controls_1d = Controls1D(self)
+ self.controls_2d = Controls2D(self)
+ # ======== CONTROLS END ======= #
+
+ # ROI manager to centralize ROI logic
+ self.roi_manager = ROIManager(self)
+ # Track secondary dock windows (modeless)
+ self._dock_windows = []
+
+ self.setup_2d_workspace()
+ self.setup_1d_workspace()
+ self.setup_workbench_connections()
+
+ # Use shared HDF5 loader utility
+ self.h5loader = HDF5Loader()
+
+ # == FILE PATH INFO START ====== #
+ self.current_file_path = None
+ self.selected_dataset_path = None
+ # ==== FILE PATH INFO END ====== #
+
+
+ # ROI state
+ self.rois = []
+ self.current_roi = None
+ # ROI dock mappings
+ self.roi_by_item = {}
+ self.item_by_roi_id = {}
+ self.roi_names = {}
+ self.stats_row_by_roi_id = {}
+ self.roi_plot_docks_by_roi_id = {}
+ # Setup dock to track ROIs and stats
+ try:
+ self.roi_manager.setup_docks()
+ except Exception:
+ pass
+ # Initialize 2D axis variables
+ try:
+ self.axis_2d_x = "Columns"
+ self.axis_2d_y = "Row"
+ except Exception:
+ pass
+
+ def setup_roi_dock(self):
+ try:
+ self.roi_dock = QDockWidget("ROIs", self)
+ self.roi_dock.setAllowedAreas(Qt.RightDockWidgetArea)
+ self.roi_list = QListWidget()
+ try:
+ self.roi_list.itemClicked.connect(self.on_roi_list_item_clicked)
+ self.roi_list.itemDoubleClicked.connect(self.on_roi_list_item_double_clicked)
+ # Enable right-click context menu on ROI list
+ self.roi_list.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.roi_list.customContextMenuRequested.connect(self.show_roi_list_context_menu)
+ except Exception:
+ pass
+ self.roi_dock.setWidget(self.roi_list)
+ self.addDockWidget(Qt.RightDockWidgetArea, self.roi_dock)
+ try:
+ self.roi_dock.visibilityChanged.connect(self.on_rois_dock_visibility_changed)
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error setting up ROI dock: {e}")
+
+ def format_roi_text(self, roi):
+ try:
+ pos = roi.pos(); size = roi.size()
+ x = int(pos.x()); y = int(pos.y())
+ w = int(size.x()); h = int(size.y())
+ name = self.get_roi_name(roi)
+ return f"{name}: x={x}, y={y}, w={w}, h={h}"
+ except Exception:
+ return "ROI"
+
+ def add_roi_to_dock(self, roi):
+ try:
+ if not hasattr(self, 'roi_list') or self.roi_list is None:
+ return
+ text = self.format_roi_text(roi)
+ item = QListWidgetItem(text)
+ self.roi_list.addItem(item)
+ self.roi_by_item[item] = roi
+ self.item_by_roi_id[id(roi)] = item
+ except Exception as e:
+ self.update_status(f"Error adding ROI to dock: {e}")
+
+ def update_roi_item(self, roi):
+ try:
+ item = self.item_by_roi_id.get(id(roi))
+ if item is not None:
+ item.setText(self.format_roi_text(roi))
+ except Exception:
+ pass
+
+ def on_roi_list_item_clicked(self, item):
+ try:
+ roi = self.roi_by_item.get(item)
+ if roi:
+ self.set_active_roi(roi)
+ except Exception as e:
+ self.update_status(f"Error selecting ROI from dock: {e}")
+
+ def on_roi_list_item_double_clicked(self, item):
+ try:
+ roi = self.roi_by_item.get(item)
+ if roi:
+ self.show_roi_stats_for_roi(roi)
+ except Exception as e:
+ self.update_status(f"Error showing ROI stats from dock: {e}")
+
+ def show_roi_list_context_menu(self, position):
+ """Show context menu for ROI list items with an option to open a PyQtGraph view of the ROI."""
+ try:
+ if not hasattr(self, 'roi_list') or self.roi_list is None:
+ return
+ item = self.roi_list.itemAt(position)
+ if item is None:
+ return
+ roi = self.roi_by_item.get(item)
+ if roi is None:
+ return
+ menu = QMenu(self)
+ action_plot = QAction("Open ROI Plot", self)
+ action_plot.triggered.connect(lambda: self.open_roi_plot_dock(roi))
+ menu.addAction(action_plot)
+ # Also provide windowed ROI plot with ROI Math panel
+ action_plot_window = QAction("Open ROI Plot (Window)", self)
+ action_plot_window.triggered.connect(lambda: self.open_roi_plot(roi))
+ menu.addAction(action_plot_window)
+ # Also provide ROI Math dock
+ action_math_dock = QAction("Open ROI Math Dock", self)
+ action_math_dock.triggered.connect(lambda: self.open_roi_math_dock(roi))
+ menu.addAction(action_math_dock)
+ # Potential future actions can be added here
+ menu.exec_(self.roi_list.mapToGlobal(position))
+ except Exception as e:
+ self.update_status(f"Error showing ROI context menu: {e}")
+
+ def open_roi_plot(self, roi):
+ """Open a modeless window displaying a 1D plot of the selected ROI region."""
+ try:
+ frame_data = self.get_current_frame_data()
+ if frame_data is None:
+ QMessageBox.information(self, "ROI Plot", "No image data available.")
+ return
+ # Compute ROI bounds
+ pos = roi.pos(); size = roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ height, width = frame_data.shape
+ x1 = min(width, x0 + w); y1 = min(height, y0 + h)
+ if x0 >= x1 or y0 >= y1:
+ QMessageBox.information(self, "ROI Plot", "ROI area is empty or out of bounds.")
+ return
+ sub = frame_data[y0:y1, x0:x1]
+ # Create and show the 1D plot dialog (modeless)
+ try:
+ from viewer.workbench.roi_plot_dialog import ROIPlotDialog
+ except Exception:
+ ROIPlotDialog = None
+ if ROIPlotDialog is None:
+ QMessageBox.warning(self, "ROI Plot", "ROIPlotDialog not available.")
+ return
+ # Keep a reference to avoid GC
+ if not hasattr(self, '_roi_plot_dialogs'):
+ self._roi_plot_dialogs = []
+ dlg = ROIPlotDialog(self, sub)
+ dlg.setWindowTitle(f"ROI: {self.get_roi_name(roi)}")
+ dlg.resize(600, 500)
+ # Wire ROI & frame changes to update dialog data
+ def _update_dialog_data():
+ try:
+ frame = self.get_current_frame_data()
+ except Exception:
+ frame = None
+ if frame is None:
+ return
+ sub_img = None
+ try:
+ image_item = getattr(self.image_view, 'imageItem', None) if hasattr(self, 'image_view') else None
+ if image_item is not None:
+ sub_img = roi.getArrayRegion(frame, image_item)
+ if sub_img is not None and hasattr(sub_img, 'ndim') and sub_img.ndim > 2:
+ sub_img = np.squeeze(sub_img)
+ except Exception:
+ sub_img = None
+ if sub_img is None or int(getattr(sub_img, 'size', 0)) == 0:
+ # Fallback to axis-aligned bbox
+ pos = roi.pos(); size = roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ hgt, wid = frame.shape
+ x1 = min(wid, x0 + w); y1 = min(hgt, y0 + h)
+ if x0 < x1 and y0 < y1:
+ sub_img = frame[y0:y1, x0:x1]
+ if sub_img is not None and int(getattr(sub_img, 'size', 0)) > 0:
+ try:
+ dlg.update_roi_data(sub_img)
+ except Exception:
+ pass
+ try:
+ if hasattr(roi, 'sigRegionChanged'):
+ roi.sigRegionChanged.connect(_update_dialog_data)
+ if hasattr(roi, 'sigRegionChangeFinished'):
+ roi.sigRegionChangeFinished.connect(_update_dialog_data)
+ except Exception:
+ pass
+ try:
+ if hasattr(self, 'frame_spinbox'):
+ self.frame_spinbox.valueChanged.connect(lambda _: _update_dialog_data())
+ except Exception:
+ pass
+ dlg.show()
+ # Track alive dialogs
+ self._roi_plot_dialogs.append(dlg)
+ except Exception as e:
+ self.update_status(f"Error opening ROI plot: {e}")
+
+ def open_roi_math_dock(self, roi):
+ """Open a dockable ROI Math window on the right dock area."""
+ try:
+ # Ensure ROI exists
+ if roi is None:
+ QMessageBox.information(self, "ROI Math", "No ROI selected.")
+ return
+ # Import the ROIMathDock
+ try:
+ from viewer.workbench.roi_math_dock import ROIMathDock
+ except Exception:
+ ROIMathDock = None
+ if ROIMathDock is None:
+ QMessageBox.warning(self, "ROI Math", "ROIMathDock not available.")
+ return
+ # Create and add the dock widget
+ dock_title = f"ROI Math: {self.get_roi_name(roi)}"
+ dock = ROIMathDock(self, dock_title, self, roi)
+ self.addDockWidget(Qt.RightDockWidgetArea, dock)
+ # Register toggle under Windows->2d submenu
+ try:
+ self.add_dock_toggle_action(dock, dock_title, segment_name="2d")
+ except Exception:
+ pass
+ dock.show()
+ # Track alive docks
+ if not hasattr(self, '_roi_math_dock_widgets'):
+ self._roi_math_dock_widgets = []
+ self._roi_math_dock_widgets.append(dock)
+ try:
+ if not hasattr(self, 'roi_math_docks_by_roi_id') or self.roi_math_docks_by_roi_id is None:
+ self.roi_math_docks_by_roi_id = {}
+ self.roi_math_docks_by_roi_id.setdefault(id(roi), []).append(dock)
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error opening ROI Math dock: {e}")
+
+ def open_roi_plot_dock(self, roi):
+ """Open a dockable 1D plot of the selected ROI region on the right dock area."""
+ try:
+ frame_data = self.get_current_frame_data()
+ if frame_data is None:
+ QMessageBox.information(self, "ROI Plot", "No image data available.")
+ return
+ # Compute ROI bounds
+ pos = roi.pos(); size = roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ height, width = frame_data.shape
+ x1 = min(width, x0 + w); y1 = min(height, y0 + h)
+ if x0 >= x1 or y0 >= y1:
+ QMessageBox.information(self, "ROI Plot", "ROI area is empty or out of bounds.")
+ return
+ sub = frame_data[y0:y1, x0:x1]
+ # Create and add the dock widget
+ try:
+ from viewer.workbench.roi_plot_dock import ROIPlotDock
+ except Exception:
+ ROIPlotDock = None
+ if ROIPlotDock is None:
+ QMessageBox.warning(self, "ROI Plot", "ROIPlotDock not available.")
+ return
+ dock_title = f"ROI: {self.get_roi_name(roi)}"
+ dock = ROIPlotDock(self, dock_title, self, roi)
+ self.addDockWidget(Qt.RightDockWidgetArea, dock)
+ # Register toggle under Windows->2d submenu
+ try:
+ self.add_dock_toggle_action(dock, dock_title, segment_name="2d")
+ except Exception:
+ pass
+ dock.show()
+ # Track alive docks
+ if not hasattr(self, '_roi_plot_dock_widgets'):
+ self._roi_plot_dock_widgets = []
+ self._roi_plot_dock_widgets.append(dock)
+ try:
+ if not hasattr(self, 'roi_plot_docks_by_roi_id') or self.roi_plot_docks_by_roi_id is None:
+ self.roi_plot_docks_by_roi_id = {}
+ self.roi_plot_docks_by_roi_id.setdefault(id(roi), []).append(dock)
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error opening ROI plot dock: {e}")
+
+ def create_dock_window_and_show(self):
+ """Create a new modeless empty window to host dockables later."""
+ try:
+ win = DockWindow(self, title="Dock Window", width=1000, height=700)
+ # Keep reference to prevent garbage collection while open
+ self._dock_windows.append(win)
+ try:
+ win.destroyed.connect(lambda _: self._dock_windows.remove(win) if win in self._dock_windows else None)
+ except Exception:
+ pass
+ win.show()
+ # Do not disable main window; ensure modeless behavior
+ try:
+ win.raise_()
+ win.activateWindow()
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error creating Dock Window: {e}")
+
+ def setup_roi_stats_dock(self):
+ try:
+ self.roi_stats_dock = QDockWidget("ROI", self)
+ self.roi_stats_dock.setAllowedAreas(Qt.RightDockWidgetArea)
+ self.roi_stats_table = QTableWidget(0, 11, self.roi_stats_dock)
+ self.roi_stats_table.setHorizontalHeaderLabels(["Name","sum","min","max","mean","std","count","x","y","w","h"])
+ self.roi_stats_dock.setWidget(self.roi_stats_table)
+ self.addDockWidget(Qt.RightDockWidgetArea, self.roi_stats_dock)
+ try:
+ self.roi_stats_dock.visibilityChanged.connect(self.on_roi_stats_dock_visibility_changed)
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error setting up ROI stats dock: {e}")
+
+ def get_roi_name(self, roi):
+ try:
+ # Prefer ROIManager's naming to keep everything in sync (including renames)
+ if hasattr(self, 'roi_manager') and self.roi_manager is not None:
+ try:
+ return self.roi_manager.get_roi_name(roi)
+ except Exception:
+ pass
+ # Fallback to local mapping
+ name = self.roi_names.get(id(roi))
+ if name:
+ return name
+ idx = 1
+ if hasattr(self, 'rois') and roi in self.rois:
+ idx = self.rois.index(roi) + 1
+ name = f"ROI {idx}"
+ self.roi_names[id(roi)] = name
+ return name
+ except Exception:
+ return "ROI"
+
+ def rename_roi(self, roi):
+ """Delegate to ROIManager."""
+ try:
+ self.roi_manager.rename_roi(roi)
+ except Exception as e:
+ self.update_status(f"Error renaming ROI: {e}")
+
+ def update_roi_plot_dock_title(self, roi):
+ try:
+ name = self.get_roi_name(roi)
+ title = f"ROI: {name}"
+ try:
+ docks = self.roi_plot_docks_by_roi_id.get(id(roi), []) if hasattr(self, 'roi_plot_docks_by_roi_id') else []
+ except Exception:
+ docks = []
+ for dock in list(docks):
+ try:
+ dock.setWindowTitle(title)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def ensure_stats_row_for_roi(self, roi):
+ try:
+ if id(roi) in self.stats_row_by_roi_id:
+ return self.stats_row_by_roi_id[id(roi)]
+ if not hasattr(self, 'roi_stats_table') or self.roi_stats_table is None:
+ return None
+ row = self.roi_stats_table.rowCount()
+ self.roi_stats_table.insertRow(row)
+ self.stats_row_by_roi_id[id(roi)] = row
+ # set name cell
+ name = self.get_roi_name(roi)
+ self.roi_stats_table.setItem(row, 0, QTableWidgetItem(name))
+ return row
+ except Exception:
+ return None
+
+ def update_stats_table_for_roi(self, roi, stats):
+ try:
+ row = self.ensure_stats_row_for_roi(roi)
+ if row is None:
+ return
+ # name cell keep in sync
+ self.roi_stats_table.setItem(row, 0, QTableWidgetItem(self.get_roi_name(roi)))
+ # fill numeric cells with xywh at the end
+ self.roi_stats_table.setItem(row, 1, QTableWidgetItem(f"{stats['sum']:.3f}"))
+ self.roi_stats_table.setItem(row, 2, QTableWidgetItem(f"{stats['min']:.3f}"))
+ self.roi_stats_table.setItem(row, 3, QTableWidgetItem(f"{stats['max']:.3f}"))
+ self.roi_stats_table.setItem(row, 4, QTableWidgetItem(f"{stats['mean']:.3f}"))
+ self.roi_stats_table.setItem(row, 5, QTableWidgetItem(f"{stats['std']:.3f}"))
+ self.roi_stats_table.setItem(row, 6, QTableWidgetItem(str(stats['count'])))
+ self.roi_stats_table.setItem(row, 7, QTableWidgetItem(str(stats['x'])))
+ self.roi_stats_table.setItem(row, 8, QTableWidgetItem(str(stats['y'])))
+ self.roi_stats_table.setItem(row, 9, QTableWidgetItem(str(stats['w'])))
+ self.roi_stats_table.setItem(row, 10, QTableWidgetItem(str(stats['h'])))
+ except Exception:
+ pass
+
+ def on_rois_dock_visibility_changed(self, visible):
+ try:
+ if hasattr(self, 'action_show_rois_dock'):
+ self.action_show_rois_dock.setChecked(bool(visible))
+ except Exception:
+ pass
+
+ def on_roi_stats_dock_visibility_changed(self, visible):
+ try:
+ if hasattr(self, 'action_show_roi_stats_dock'):
+ self.action_show_roi_stats_dock.setChecked(bool(visible))
+ except Exception:
+ pass
+
+ def setup_workbench_connections(self):
+ """Set up connections specific to the workbench."""
+ # Tree widget connections
+ if hasattr(self, 'tree_data'):
+ self.tree_data.itemClicked.connect(self.on_tree_item_clicked)
+ self.tree_data.itemDoubleClicked.connect(self.on_tree_item_double_clicked)
+ self.tree_data.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.tree_data.customContextMenuRequested.connect(self.show_context_menu)
+
+ # View menu actions
+ if hasattr(self, 'actionCollapseAll'):
+ self.actionCollapseAll.triggered.connect(self.collapse_all)
+ if hasattr(self, 'actionExpandAll'):
+ self.actionExpandAll.triggered.connect(self.expand_all)
+
+ # Windows menu: add toggles to show/hide docks, with room for future items
+ # try:
+ # windows_menu = None
+ # if hasattr(self, 'menuBar') and self.menuBar is not None:
+ # try:
+ # windows_menu = self.menuBar.addMenu("Windows")
+ # except Exception:
+ # windows_menu = QMenu("Windows", self)
+ # try:
+ # self.menuBar().addMenu(windows_menu)
+ # except Exception:
+ # pass
+ # else:
+ # windows_menu = QMenu("Windows", self)
+ # try:
+ # self.menuBar().addMenu(windows_menu)
+ # except Exception:
+ # pass
+
+ # # ROI dock toggle (renamed from 'ROI Stats' to 'ROI')
+ # self.action_show_roi_stats_dock = QAction("ROI", self)
+ # self.action_show_roi_stats_dock.setCheckable(True)
+ # self.action_show_roi_stats_dock.setChecked(True if hasattr(self, 'roi_stats_dock') and self.roi_stats_dock.isVisible() else True)
+ # self.action_show_roi_stats_dock.toggled.connect(lambda checked: hasattr(self, 'roi_stats_dock') and self.roi_stats_dock.setVisible(checked))
+ # windows_menu.addAction(self.action_show_roi_stats_dock)
+
+ # # Open ROI Math dock for the active ROI
+ # self.action_open_roi_math_dock = QAction("ROI Math (Active ROI)", self)
+ # self.action_open_roi_math_dock.setToolTip("Open ROI Math dock for the currently active ROI")
+ # self.action_open_roi_math_dock.triggered.connect(lambda: hasattr(self, 'current_roi') and self.open_roi_math_dock(self.current_roi))
+ # windows_menu.addAction(self.action_open_roi_math_dock)
+
+ # # Add Window: open an empty, modeless window for dockables
+ # self.action_add_window = QAction("Add Window", self)
+ # self.action_add_window.setToolTip("Open a new empty window for dockable tools")
+ # self.action_add_window.triggered.connect(self.create_dock_window_and_show)
+ # windows_menu.addAction(self.action_add_window)
+ # except Exception:
+ # pass
+
+ # Set up default splitter sizes
+ self.setup_default_splitter_sizes()
+
+ # Initialize file info text box
+ self.initialize_file_info_display()
+
+ def setup_default_splitter_sizes(self):
+ """Set default splitter sizes for the horizontal splitter."""
+ if hasattr(self, 'mainSplitter'):
+ # Calculate 15% of window width for data structure panel
+ window_width = self.width()
+ data_panel_width = int(window_width * 0.15)
+ analysis_panel_width = window_width - data_panel_width
+
+ # Set the horizontal splitter sizes
+ self.mainSplitter.setSizes([data_panel_width, analysis_panel_width])
+
+ def initialize_file_info_display(self):
+ """Initialize the file information display."""
+ if hasattr(self, 'file_info_text'):
+ self.update_file_info_display("No file loaded", {})
+
+ def setup_2d_workspace(self):
+ """Set up the 2D workspace with PyQtGraph plotitem functionality."""
+ try:
+ # Setup the 2D plot viewer with PyQtGraph
+ self.setup_2d_plot_viewer()
+
+ # Setup 2D controls connections (delegated)
+ self.controls_2d.setup()
+
+ except Exception as e:
+ self.update_status(f"Error setting up 2D workspace: {e}")
+ # Fallback to keeping the placeholder if setup fails
+
+ def setup_2d_plot_viewer(self):
+ """Set up the 2D plot viewer with PyQtGraph PlotItem and ImageView."""
+ try:
+ # Create the plot item and image view similar to HKL slice 2D viewer
+ self.plot_item = pg.PlotItem()
+ self.image_view = pg.ImageView(view=self.plot_item)
+
+ # Set axis labels
+ self.plot_item.setLabel('bottom', 'Columns [pixels]')
+ self.plot_item.setLabel('left', 'Row [pixels]')
+
+ # Lock aspect ratio for square pixels
+ try:
+ self.image_view.view.setAspectLocked(True)
+ except Exception:
+ pass
+
+ # Add the image view directly to the plot host
+ if hasattr(self, 'layoutPlotHost'):
+ self.layoutPlotHost.addWidget(self.image_view)
+ else:
+ print("Warning: layoutPlotHost not found, 2D plot may not display correctly")
+
+
+ # Initialize with empty data
+ self.clear_2d_plot()
+
+ # Setup hover overlays and mouse tracking
+ self._setup_2d_hover()
+
+ # Set default hover enabled and preserve default context menu
+ try:
+ self._hover_enabled = True
+ if hasattr(self, 'image_view') and self.image_view is not None:
+ # Restore default context menu (do not override with custom)
+ self.image_view.setContextMenuPolicy(Qt.DefaultContextMenu)
+ except Exception:
+ pass
+
+ except Exception as e:
+ self.update_status(f"Error setting up 2D plot viewer: {e}")
+
+ # === Controls: 2D ===
+ def setup_controls_2d(self):
+ """Set up connections for the 2D viewer controls."""
+ try:
+ # Connect colormap selection
+ if hasattr(self, 'cbColorMapSelect_2d'):
+ self.cbColorMapSelect_2d.currentTextChanged.connect(self.on_colormap_changed)
+
+ # Connect auto levels checkbox
+ if hasattr(self, 'cbAutoLevels'):
+ self.cbAutoLevels.toggled.connect(self.on_auto_levels_toggled)
+
+ # Connect frame navigation controls from UI
+ if hasattr(self, 'btn_prev_frame'):
+ self.btn_prev_frame.clicked.connect(self.previous_frame)
+ if hasattr(self, 'btn_next_frame'):
+ self.btn_next_frame.clicked.connect(self.next_frame)
+ if hasattr(self, 'frame_spinbox'):
+ self.frame_spinbox.valueChanged.connect(self.on_frame_spinbox_changed)
+
+ # Connect new speckle analysis controls
+ if hasattr(self, 'cbLogScale'):
+ self.cbLogScale.toggled.connect(self.on_log_scale_toggled)
+
+ if hasattr(self, 'sbVmin'):
+ self.sbVmin.valueChanged.connect(self.on_vmin_changed)
+
+ if hasattr(self, 'sbVmax'):
+ self.sbVmax.valueChanged.connect(self.on_vmax_changed)
+
+ if hasattr(self, 'btnDrawROI'):
+ self.btnDrawROI.clicked.connect(self.on_draw_roi_clicked)
+
+ if hasattr(self, 'sbRefFrame'):
+ self.sbRefFrame.valueChanged.connect(self.on_ref_frame_changed)
+
+ if hasattr(self, 'sbOtherFrame'):
+ self.sbOtherFrame.valueChanged.connect(self.on_other_frame_changed)
+
+
+
+
+
+ # Playback controls for 3D stacks in 2D viewer (UI-defined or created programmatically)
+ try:
+ # Ensure playback timer exists
+ if not hasattr(self, 'play_timer') or self.play_timer is None:
+ self.play_timer = QTimer(self)
+ try:
+ self.play_timer.timeout.connect(self._advance_frame_playback)
+ print("[PLAYBACK] Created play_timer and wired timeout")
+ except Exception as e:
+ print(f"[PLAYBACK] ERROR wiring timer: {e}")
+
+ # Wire controls if present in UI
+ if hasattr(self, 'btn_play'):
+ try:
+ self.btn_play.clicked.connect(self.start_playback)
+ print("[PLAYBACK] Wired btn_play -> start_playback")
+ except Exception as e:
+ print(f"[PLAYBACK] ERROR wiring btn_play: {e}")
+ if hasattr(self, 'btn_pause'):
+ try:
+ self.btn_pause.clicked.connect(self.pause_playback)
+ print("[PLAYBACK] Wired btn_pause -> pause_playback")
+ except Exception as e:
+ print(f"[PLAYBACK] ERROR wiring btn_pause: {e}")
+ if hasattr(self, 'sb_fps'):
+ try:
+ self.sb_fps.valueChanged.connect(self.on_fps_changed)
+ print("[PLAYBACK] Wired sb_fps -> on_fps_changed")
+ except Exception as e:
+ print(f"[PLAYBACK] ERROR wiring sb_fps: {e}")
+ # cb_auto_replay is read in _advance_frame_playback; no signal wiring needed
+
+ # Default disabled; enabled when 3D data with >3 frames is loaded
+ try:
+ if hasattr(self, 'btn_play'):
+ self.btn_play.setEnabled(False)
+ if hasattr(self, 'btn_pause'):
+ self.btn_pause.setEnabled(False)
+ if hasattr(self, 'sb_fps'):
+ self.sb_fps.setEnabled(False)
+ if hasattr(self, 'cb_auto_replay'):
+ self.cb_auto_replay.setEnabled(False)
+ try:
+ # Select auto replay by default
+ self.cb_auto_replay.setChecked(True)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+
+
+ except Exception as e:
+ self.update_status(f"Error setting up 2D connections: {e}")
+
+ def setup_1d_workspace(self):
+ """Set up the 1D workspace with PyQtGraph PlotItem."""
+ try:
+ self.plot_item_1d = pg.PlotItem()
+ self.plot_widget_1d = pg.PlotWidget(plotItem=self.plot_item_1d)
+ self.plot_item_1d.setLabel('bottom', 'Index')
+ self.plot_item_1d.setLabel('left', 'Value')
+ if hasattr(self, 'layout1DPlotHost'):
+ self.layout1DPlotHost.addWidget(self.plot_widget_1d)
+ else:
+ print("Warning: layout1DPlotHost not found, 1D plot may not display correctly")
+ self.clear_1d_plot()
+ # Setup 1D controls connections (delegated)
+ self.controls_1d.setup()
+ except Exception as e:
+ self.update_status(f"Error setting up 1D workspace: {e}")
+
+ # === Controls: 1D ===
+ def setup_controls_1d(self):
+ """Set up connections for the 1D controls."""
+ try:
+ # Placeholder for future 1D controls (e.g., levels, scale, etc.)
+ pass
+ except Exception as e:
+ self.update_status(f"Error setting up 1D connections: {e}")
+
+ # === Controls: 3D ===
+ def setup_controls_3d(self):
+ pass
+
+ def setup_2d_file_display(self):
+ """Set up the 2D file information display in the main workspace."""
+ from PyQt5.QtWidgets import QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QGroupBox, QTabWidget, QSplitter
+ from PyQt5.QtCore import Qt
+
+ # Create a vertical splitter for the workspace
+ self.workspace_splitter = QSplitter(Qt.Vertical)
+
+ # Create top section for main workspace
+ self.main_workspace_widget = QGroupBox()
+ self.main_workspace_layout = QVBoxLayout(self.main_workspace_widget)
+
+ # File status label at the top
+ self.file_status_label = QLabel("No HDF5 file loaded")
+ self.file_status_label.setStyleSheet("font-size: 14px; font-weight: bold; color: #2c3e50; padding: 10px;")
+ self.file_status_label.setAlignment(Qt.AlignCenter)
+ self.main_workspace_layout.addWidget(self.file_status_label)
+
+ # Add a spacer to push content to top
+ from PyQt5.QtWidgets import QSpacerItem, QSizePolicy
+ spacer = QSpacerItem(20, 40, QSizePolicy.Minimum, QSizePolicy.Expanding)
+ self.main_workspace_layout.addItem(spacer)
+
+ # Create bottom section for tabs (compact)
+ self.info_tabs_widget = QGroupBox()
+ self.info_tabs_layout = QVBoxLayout(self.info_tabs_widget)
+ self.info_tabs_layout.setContentsMargins(6, 6, 6, 6)
+
+ # Create tab widget with compact size
+ self.info_tabs = QTabWidget()
+ self.info_tabs.setMaximumHeight(150) # Limit height to make it compact
+ self.info_tabs.setStyleSheet("""
+ QTabWidget::pane {
+ border: 1px solid #dee2e6;
+ border-radius: 4px;
+ background-color: #f8f9fa;
+ }
+ QTabBar::tab {
+ background-color: #e9ecef;
+ border: 1px solid #dee2e6;
+ padding: 6px 12px;
+ margin-right: 2px;
+ font-size: 9pt;
+ }
+ QTabBar::tab:selected {
+ background-color: #f8f9fa;
+ border-bottom: 1px solid #f8f9fa;
+ }
+ """)
+
+ # Dataset Information Tab
+ self.dataset_info_text = QTextEdit()
+ self.dataset_info_text.setStyleSheet("""
+ QTextEdit {
+ background-color: #f8f9fa;
+ border: none;
+ padding: 6px;
+ font-family: 'Consolas', monospace;
+ font-size: 9pt;
+ }
+ """)
+ self.dataset_info_text.setReadOnly(True)
+ self.dataset_info_text.setPlainText("Select a dataset from the tree to view detailed information.")
+
+ # File Information Tab
+ self.file_info_text = QTextEdit()
+ self.file_info_text.setStyleSheet("""
+ QTextEdit {
+ background-color: #f8f9fa;
+ border: none;
+ padding: 6px;
+ font-family: 'Consolas', monospace;
+ font-size: 9pt;
+ }
+ """)
+ self.file_info_text.setReadOnly(True)
+ self.file_info_text.setPlainText("Load an HDF5 file to view file information.")
+
+ # Add tabs
+ self.info_tabs.addTab(self.dataset_info_text, "Dataset Info")
+ self.info_tabs.addTab(self.file_info_text, "File Info")
+
+ # Add tab widget to bottom container
+ self.info_tabs_layout.addWidget(self.info_tabs)
+
+ # Add widgets to splitter
+ self.workspace_splitter.addWidget(self.main_workspace_widget)
+ self.workspace_splitter.addWidget(self.info_tabs_widget)
+
+ # Set splitter sizes (85% for main workspace, 15% for tabs)
+ self.workspace_splitter.setSizes([850, 150])
+
+ # Add the splitter to the analysis layout
+ self.analysisLayout.addWidget(self.workspace_splitter)
+
+ # === Supers: BaseWindow overrides ===
+ def get_file_filters(self):
+ """
+ Get file filters for HDF5 files.
+
+ Returns:
+ str: File filter string for QFileDialog
+ """
+ return "HDF5 Files (*.h5 *.hdf5);;All Files (*)"
+
+ # def load_file_content(self, file_path):
+ # """
+ # Load HDF5 file content and add it to the top of the data tree.
+
+ # Args:
+ # file_path (str): Path to the HDF5 file to load
+ # """
+ # try:
+ # # Update UI to show loading state
+ # self.update_status(f"Loading: {os.path.basename(file_path)}")
+
+ # # Store the current file path
+ # self.current_file_path = file_path
+
+ # # Update file info display
+ # self.update_file_info_display(file_path)
+
+ # # Check if this file is already loaded to avoid duplicates
+ # if hasattr(self, 'tree_data'):
+ # for i in range(self.tree_data.topLevelItemCount()):
+ # existing_item = self.tree_data.topLevelItem(i)
+ # existing_path = existing_item.data(0, Qt.UserRole + 1)
+ # if existing_path == file_path:
+ # # File already loaded, just select it and return
+ # self.tree_data.setCurrentItem(existing_item)
+ # self.update_status(f"File already loaded: {os.path.basename(file_path)}")
+ # return
+
+ # # Clear any existing visualizations
+ # self.clear_2d_plot()
+ # self.clear_3d_plot()
+
+ # # Reset selected dataset path
+ # self.selected_dataset_path = None
+
+ # # Open and read HDF5 file
+ # with h5py.File(file_path, 'r') as h5file:
+ # # Create root item
+ # root_item = QTreeWidgetItem([os.path.basename(file_path)])
+ # root_item.setData(0, Qt.UserRole + 1, file_path) # Store file path
+ # root_item.setData(0, Qt.UserRole + 2, "file_root") # Mark as file root
+
+ # # Insert at the top (index 0) instead of adding to the end
+ # self.tree_data.insertTopLevelItem(0, root_item)
+
+ # # Recursively populate tree
+ # self._populate_tree_recursive(h5file, root_item)
+
+ # # Keep the root item collapsed by default
+ # root_item.setExpanded(False)
+
+ # # Select the newly added item
+ # self.tree_data.setCurrentItem(root_item)
+
+ # # Update workspace displays
+ # if hasattr(self, 'file_status_label'):
+ # self.file_status_label.setText(f"HDF5 file loaded: {os.path.basename(file_path)}")
+ # if hasattr(self, 'dataset_info_text'):
+ # self.dataset_info_text.setPlainText("Select a dataset from the tree to view detailed information.")
+
+ # self.update_status("HDF5 File Loaded Successfully")
+
+ # except Exception as e:
+ # QMessageBox.critical(self, "Error", f"Failed to load HDF5 file: {str(e)}")
+ # self.update_status("Failed to load file")
+
+ def save_file_content(self, file_path):
+ """
+ Save analysis results.
+
+ Args:
+ file_path (str): Path to save the analysis to
+ """
+ try:
+ self.update_status(f"Saving: {os.path.basename(file_path)}")
+
+ # TODO: Implement actual save functionality
+ # This would involve:
+ # 1. Collecting current analysis state
+ # 2. Saving results to HDF5 or other format
+ # 3. Saving workspace configuration
+
+ # Simulate save delay
+ QTimer.singleShot(1000, lambda: self.update_status("Analysis Saved Successfully"))
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to save analysis: {str(e)}")
+ self.update_status("Failed to save file")
+
+ def load_folder_content(self, folder_path):
+ """
+ Load all HDF5 files from a folder and organize them under a folder section.
+
+ Args:
+ folder_path (str): Path to the folder containing HDF5 files
+ """
+ try:
+ # Update UI to show loading state
+ folder_name = os.path.basename(folder_path)
+ self.update_status(f"Loading folder: {folder_name}")
+
+ # Check if this folder is already loaded to avoid duplicates
+ if hasattr(self, 'tree_data'):
+ for i in range(self.tree_data.topLevelItemCount()):
+ existing_item = self.tree_data.topLevelItem(i)
+ existing_type = existing_item.data(0, Qt.UserRole + 2)
+ existing_path = existing_item.data(0, Qt.UserRole + 1)
+ if existing_type == "folder_section" and existing_path == folder_path:
+ # Folder already loaded, just select it and return
+ self.tree_data.setCurrentItem(existing_item)
+ self.update_status(f"Folder already loaded: {folder_name}")
+ return
+
+ # Find all HDF5 files in the folder
+ h5_patterns = ['*.h5', '*.hdf5', '*.H5', '*.HDF5']
+ h5_files = []
+ for pattern in h5_patterns:
+ h5_files.extend(glob.glob(os.path.join(folder_path, pattern)))
+
+ if not h5_files:
+ QMessageBox.information(self, "No Files", "No HDF5 files found in the selected folder.")
+ self.update_status("No HDF5 files found")
+ return
+
+ # Sort files for consistent ordering
+ h5_files.sort()
+
+ # Create folder section header at the top
+ folder_section_item = QTreeWidgetItem([f"📁 {folder_name} ({len(h5_files)} files)"])
+ folder_section_item.setData(0, Qt.UserRole + 1, folder_path) # Store folder path
+ folder_section_item.setData(0, Qt.UserRole + 2, "folder_section") # Mark as folder section
+
+ # Insert at the top (index 0)
+ self.tree_data.insertTopLevelItem(0, folder_section_item)
+
+ # Load each HDF5 file under the folder section
+ loaded_count = 0
+ for file_path in h5_files:
+ try:
+ self.load_single_h5_file_under_section(file_path, folder_section_item)
+ loaded_count += 1
+ except Exception as e:
+ self.update_status(f"Failed to load {file_path}: {e}")
+ continue
+
+ # Expand the folder section to show the files
+ folder_section_item.setExpanded(True)
+
+ # Select the folder section
+ self.tree_data.setCurrentItem(folder_section_item)
+
+ # Clear any existing visualizations
+ self.clear_2d_plot()
+
+ # Update workspace displays
+ if hasattr(self, 'file_status_label'):
+ self.file_status_label.setText(f"Folder loaded: {folder_name} ({loaded_count} files)")
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText("Select a dataset from the tree to view detailed information.")
+
+ self.update_status(f"Loaded {loaded_count} HDF5 files from folder: {folder_name}")
+
+ except Exception as e:
+ QMessageBox.critical(self, "Error", f"Failed to load folder: {str(e)}")
+ self.update_status("Failed to load folder")
+
+ # === Load utilities ===
+ def _populate_tree_recursive(self, h5_group, parent_item):
+ """
+ Recursively populate the tree widget with HDF5 structure.
+
+ Args:
+ h5_group: HDF5 group or file object
+ parent_item: QTreeWidgetItem to add children to
+ """
+ for key in h5_group.keys():
+ item = h5_group[key]
+
+ # Create tree item
+ tree_item = QTreeWidgetItem([key])
+ parent_item.addChild(tree_item)
+
+ # Store the full path as item data
+ full_path = item.name
+ tree_item.setData(0, 32, full_path) # Qt.UserRole = 32
+
+ if isinstance(item, h5py.Group):
+ # It's a group, add group indicator and recurse
+ tree_item.setText(0, f"{key} (Group)")
+ self._populate_tree_recursive(item, tree_item)
+ elif isinstance(item, h5py.Dataset):
+ # It's a dataset, show shape and dtype info
+ shape_str = f"{item.shape}" if item.shape else "scalar"
+ dtype_str = str(item.dtype)
+ tree_item.setText(0, f"{key} (Dataset: {shape_str}, {dtype_str})")
+ # Color renderable datasets blue (similar to speckle_thing visual hint)
+ if self.is_dataset_renderable(item):
+ tree_item.setForeground(0, QBrush(QColor('blue')))
+ tree_item.setData(0, Qt.UserRole + 3, True)
+
+ def load_single_h5_file(self, file_path):
+ """
+ Load a single HDF5 file and add it to the tree.
+
+ Args:
+ file_path (str): Path to the HDF5 file
+ """
+ with h5py.File(file_path, 'r') as h5file:
+ # Create root item for this file
+ root_item = QTreeWidgetItem([os.path.basename(file_path)])
+ root_item.setData(0, Qt.UserRole + 1, file_path) # Store file path for removal
+ root_item.setData(0, Qt.UserRole + 2, "file_root") # Mark as file root
+ self.tree_data.addTopLevelItem(root_item)
+
+ # Recursively populate tree
+ self._populate_tree_recursive(h5file, root_item)
+
+ # Keep the root item collapsed by default
+ root_item.setExpanded(False)
+
+ def load_single_h5_file_under_section(self, file_path, parent_section):
+ """
+ Load a single HDF5 file and add it under a folder section.
+
+ Args:
+ file_path (str): Path to the HDF5 file
+ parent_section (QTreeWidgetItem): Parent folder section item
+ """
+ with h5py.File(file_path, 'r') as h5file:
+ # Create root item for this file under the section
+ root_item = QTreeWidgetItem([os.path.basename(file_path)])
+ root_item.setData(0, Qt.UserRole + 1, file_path) # Store file path
+ root_item.setData(0, Qt.UserRole + 2, "file_root") # Mark as file root
+ parent_section.addChild(root_item)
+
+ # Recursively populate tree
+ self._populate_tree_recursive(h5file, root_item)
+
+ # Keep the root item collapsed by default
+ root_item.setExpanded(False)
+
+ def is_dataset_renderable(self, dset):
+ """Return True if dataset is numeric and can be rendered (2D/3D, or 1D perfect square)."""
+ try:
+ dtype = dset.dtype
+ ndim = len(dset.shape)
+ if np.issubdtype(dtype, np.number):
+ if ndim >= 2:
+ return True
+ if ndim == 1:
+ size = dset.size
+ if size >= 100:
+ side = int(np.sqrt(size))
+ return side * side == size
+ return False
+ except Exception:
+ return False
+
+ # Async dataset loader to prevent UI freeze
+ class DatasetLoader(QObject):
+ loaded = pyqtSignal(object) # numpy array
+ failed = pyqtSignal(str)
+
+ def __init__(self, file_path, dataset_path, max_frames=100):
+ super().__init__()
+ self.file_path = file_path
+ self.dataset_path = dataset_path
+ self.max_frames = max_frames
+
+ @pyqtSlot()
+ def run(self):
+ try:
+ import h5py, numpy as np
+ with h5py.File(self.file_path, 'r') as h5file:
+ if self.dataset_path not in h5file:
+ self.failed.emit("Dataset not found")
+ return
+ dset = h5file[self.dataset_path]
+ if not isinstance(dset, h5py.Dataset):
+ self.failed.emit("Selected item is not a dataset")
+ return
+
+ # Efficient loading to avoid blocking on huge datasets
+ if len(dset.shape) == 3:
+ max_frames = min(self.max_frames, dset.shape[0])
+ data = dset[:max_frames]
+ else:
+ # Guard against extremely large 2D datasets by center cropping
+ try:
+ estimated_size = dset.size * dset.dtype.itemsize
+ except Exception:
+ estimated_size = 0
+ if len(dset.shape) == 2 and estimated_size > 512 * 1024 * 1024: # >512MB
+ h, w = dset.shape
+ ch = min(h, 2048)
+ cw = min(w, 2048)
+ y0 = max(0, (h - ch) // 2)
+ x0 = max(0, (w - cw) // 2)
+ data = dset[y0:y0+ch, x0:x0+cw]
+ else:
+ data = dset[...]
+
+ data = np.asarray(data, dtype=np.float32)
+ # Clean high values
+ high_mask = data > 5e6
+ if np.any(high_mask):
+ data[high_mask] = 0
+
+ # 1D handling: emit raw 1D data for dedicated 1D view
+ if data.ndim == 1:
+ # keep as 1D; no failure
+ pass
+
+ self.loaded.emit(data)
+ except Exception as e:
+ self.failed.emit(f"Error loading dataset: {e}")
+
+ def load_dataset_robustly(self, dataset):
+ """
+ Load dataset with robust error handling and data cleaning like speckle_thing.py
+
+ Args:
+ dataset: h5py.Dataset object
+
+ Returns:
+ numpy.ndarray: Cleaned and processed data, or None if loading failed
+ """
+ try:
+ self.update_status("Loading dataset...")
+
+ # Load the data
+ if len(dataset.shape) == 3:
+ # For 3D datasets, load a reasonable number of frames (limit to 100 for memory)
+ max_frames = min(100, dataset.shape[0])
+ if max_frames < dataset.shape[0]:
+ self.update_status(f"Loading first {max_frames} frames of {dataset.shape[0]} total frames")
+ data = dataset[:max_frames]
+ else:
+ # For 2D datasets, load all data
+ data = dataset[...]
+
+ # Convert to float32 for consistent processing
+ data = np.asarray(data, dtype=np.float32)
+
+ # Clean up data - set all values above 5e6 to zero (like speckle_thing.py)
+ self.update_status("Cleaning data (setting values > 5e6 to zero)...")
+ high_values_mask = data > 5e6
+ if np.any(high_values_mask):
+ num_cleaned = np.count_nonzero(high_values_mask)
+ data[high_values_mask] = 0
+ print(f"Cleaned {num_cleaned} pixels with values > 5e6")
+
+ # Check for valid data
+ if data.size == 0:
+ self.update_status("Error: Dataset is empty")
+ return None
+
+ # Check for all-zero data
+ if np.all(data == 0):
+ self.update_status("Warning: All data values are zero")
+
+ # 1D data: if perfect square and reasonably large, reshape to 2D; otherwise keep as 1D for 1D view
+ if data.ndim == 1:
+ side_length = int(np.sqrt(data.size))
+ if data.size >= 100 and side_length * side_length == data.size:
+ data = data.reshape(side_length, side_length)
+ self.update_status(f"Reshaped 1D data to {side_length}x{side_length}")
+ else:
+ self.update_status(f"Loaded 1D data of length {data.size}")
+ return data
+
+ self.update_status(f"Successfully loaded data with shape {data.shape}")
+ return data
+
+ except Exception as e:
+ error_msg = f"Error loading dataset robustly: {str(e)}"
+ self.update_status(error_msg)
+ print(error_msg)
+ import traceback
+ traceback.print_exc()
+ return None
+
+ def visualize_selected_dataset(self):
+ """Load and plot the selected dataset; clear/render ROIs depending on dataset type (image vs ROI)."""
+ if not self.current_file_path or not self.selected_dataset_path:
+ print("[DEBUG] visualize_selected_dataset: no current_file_path or selected_dataset_path")
+ return
+ try:
+ print(f"[DEBUG] visualize_selected_dataset: selected_dataset_path={self.selected_dataset_path}")
+ sel_path = str(self.selected_dataset_path)
+ is_image_data = sel_path.endswith("/entry/data/data") or sel_path == "/entry/data/data" or sel_path.endswith("entry/data/data")
+ is_roi_data = sel_path.startswith("/entry/data/rois") or "/entry/data/rois/" in sel_path
+
+ # Always clear existing ROI graphics before switching
+ try:
+ self.roi_manager.clear_all_rois()
+ except Exception:
+ pass
+
+ if is_image_data:
+ # Load original image dataset via HDF5Loader (preferred)
+ use_h5loader = True
+ valid = self.h5loader.validate_file(self.current_file_path)
+ print(f"[DEBUG] HDF5Loader.validate_file -> {valid}")
+ if not valid:
+ self.update_status(f"HDF5 validation failed: {self.h5loader.get_last_error()}")
+ return
+ volume, vol_shape = self.h5loader.load_h5_volume_3d(self.current_file_path)
+ print(f"[DEBUG] HDF5Loader.load_h5_volume_3d shape={getattr(volume,'shape',None)}")
+ if volume is None or volume.size == 0:
+ self.update_status("No data in /entry/data/data")
+ return
+ data = volume
+ # Display image data
+ self.display_2d_data(data)
+ if hasattr(self, 'tabWidget_analysis'):
+ self.tabWidget_analysis.setCurrentIndex(0)
+ # Render ROIs associated with this dataset
+ try:
+ self.roi_manager.render_rois_for_dataset(self.current_file_path, '/entry/data/data')
+ except Exception:
+ pass
+ elif is_roi_data:
+ # When clicking on an ROI dataset, clear existing ROI boxes and render the ROI dataset itself as the image
+ with h5py.File(self.current_file_path, 'r') as h5f:
+ exists = self.selected_dataset_path in h5f
+ print(f"[DEBUG] ROI dataset exists in file? {exists}")
+ if not exists:
+ self.update_status("ROI dataset not found")
+ return
+ dset = h5f[self.selected_dataset_path]
+ if not isinstance(dset, h5py.Dataset):
+ self.update_status("Selected ROI item is not a dataset")
+ return
+ data = np.asarray(dset[...], dtype=np.float32)
+ # Display ROI-only data (2D or 3D with frames)
+ if data.ndim >= 2 and np.issubdtype(data.dtype, np.number):
+ self.display_2d_data(data)
+ if hasattr(self, 'tabWidget_analysis'):
+ self.tabWidget_analysis.setCurrentIndex(0)
+ # No ROI overlays when viewing ROI-only dataset
+ self.update_status(f"Loaded ROI dataset: {self.selected_dataset_path}")
+ else:
+ # Non-visualizable
+ self.clear_2d_plot()
+ self.update_status("ROI dataset loaded but not visualizable")
+ else:
+ # Fallback: open generic dataset directly
+ with h5py.File(self.current_file_path, 'r') as h5file:
+ exists = self.selected_dataset_path in h5file
+ print(f"[DEBUG] Dataset exists in file? {exists}")
+ if not exists:
+ self.update_status("Dataset not found")
+ return
+ dataset = h5file[self.selected_dataset_path]
+ print(f"[DEBUG] Dataset type={type(dataset)} shape={getattr(dataset,'shape',None)}")
+ if not isinstance(dataset, h5py.Dataset):
+ self.update_status("Selected item is not a dataset")
+ return
+ data = self.load_dataset_robustly(dataset)
+ print(f"[DEBUG] load_dataset_robustly returned shape={getattr(data,'shape',None)}")
+ if data is None:
+ return
+ if data.ndim >= 2 and np.issubdtype(data.dtype, np.number):
+ self.display_2d_data(data)
+ if hasattr(self, 'tabWidget_analysis'):
+ self.tabWidget_analysis.setCurrentIndex(0)
+ # Render ROIs for this dataset if any
+ try:
+ self.roi_manager.render_rois_for_dataset(self.current_file_path, self.selected_dataset_path)
+ except Exception:
+ pass
+ elif data.ndim == 1:
+ self.display_1d_data(data)
+ self.update_status("Loaded 1D dataset")
+ else:
+ self.clear_2d_plot()
+ self.update_status("Dataset loaded but not visualizable")
+ except Exception as e:
+ error_msg = f"Error loading dataset: {str(e)}"
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText(error_msg)
+ self.update_status(error_msg)
+
+ def start_dataset_load(self):
+ """Create a worker thread to load dataset without blocking the UI."""
+ try:
+ self.update_status(f"Loading dataset: {self.selected_dataset_path}")
+ self._dataset_thread = QThread()
+ self._dataset_worker = self.DatasetLoader(self.current_file_path, self.selected_dataset_path)
+ self._dataset_worker.moveToThread(self._dataset_thread)
+ self._dataset_thread.started.connect(self._dataset_worker.run)
+ self._dataset_worker.loaded.connect(self.on_dataset_loaded)
+ self._dataset_worker.failed.connect(self.on_dataset_failed)
+ # Ensure thread quits after work
+ self._dataset_worker.loaded.connect(self._dataset_thread.quit)
+ self._dataset_worker.failed.connect(self._dataset_thread.quit)
+ self._dataset_thread.start()
+ except Exception as e:
+ self.update_status(f"Error starting dataset load: {e}")
+
+ @pyqtSlot(object)
+ def on_dataset_loaded(self, data):
+ """Handle dataset loaded event on main thread."""
+ try:
+ # Visualize data
+ if data is None:
+ self.update_status("Loaded empty dataset")
+ return
+ if data.ndim >= 2 and np.issubdtype(data.dtype, np.number):
+ self.display_2d_data(data)
+ if hasattr(self, 'tabWidget_analysis'):
+ self.tabWidget_analysis.setCurrentIndex(0)
+ # Build info
+ info_lines = []
+ info_lines.append(f"Dataset: {self.selected_dataset_path}")
+ # Read original shape/dtype quickly
+ try:
+ with h5py.File(self.current_file_path, 'r') as h5file:
+ dset = h5file[self.selected_dataset_path]
+ info_lines.append(f"Original Shape: {dset.shape}")
+ info_lines.append(f"Original Type: {dset.dtype}")
+ except Exception:
+ pass
+ info_lines.append(f"Loaded Shape: {data.shape}")
+ info_lines.append(f"Data Type: {data.dtype}")
+ info_lines.append(f"Size: {data.size:,} elements")
+ info_lines.append("\nData Statistics:")
+ info_lines.append(f"Min: {np.min(data):.6f}")
+ info_lines.append(f"Max: {np.max(data):.6f}")
+ info_lines.append(f"Mean: {np.mean(data):.6f}")
+ info_lines.append(f"Std: {np.std(data):.6f}")
+ # Memory usage
+ mem_size = data.size * data.dtype.itemsize
+ if mem_size < 1024:
+ mem_str = f"{mem_size} bytes"
+ elif mem_size < 1024 * 1024:
+ mem_str = f"{mem_size / 1024:.1f} KB"
+ elif mem_size < 1024 * 1024 * 1024:
+ mem_str = f"{mem_size / (1024 * 1024):.1f} MB"
+ else:
+ mem_str = f"{mem_size / (1024 * 1024 * 1024):.1f} GB"
+ info_lines.append(f"\nMemory Usage: {mem_str}")
+ info_text = "\n".join(info_lines)
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText(info_text)
+ if hasattr(self, 'file_status_label'):
+ self.file_status_label.setText(f"Loaded: {os.path.basename(self.selected_dataset_path)}")
+ self.update_status(f"Loaded dataset: {self.selected_dataset_path}")
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+ else:
+ if data.ndim == 1:
+ self.display_1d_data(data)
+ self.update_status("Loaded 1D dataset")
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+ else:
+ self.clear_2d_plot()
+ self.update_status("Dataset loaded but not visualizable")
+ except Exception as e:
+ self.update_status(f"Error handling loaded dataset: {e}")
+
+ @pyqtSlot(str)
+ def on_dataset_failed(self, message):
+ """Handle dataset load failure."""
+ try:
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText(message)
+ self.update_status(message)
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error updating failure status: {e}")
+
+ def load_3d_data(self):
+ """Delegate 3D data loading to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.load_data()
+ except Exception as e:
+ self.update_status(f"Error loading 3D data: {e}")
+
+ # === 2D Helpers ===
+ def set_2d_axes(self, x_axis, y_axis):
+ try:
+ self.axis_2d_x = str(x_axis) if x_axis else None
+ self.axis_2d_y = str(y_axis) if y_axis else None
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ try:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def clear_2d_plot(self):
+ """Clear the 2D plot and show placeholder."""
+ try:
+ if hasattr(self, 'image_view'):
+ # Create a small placeholder image
+ placeholder = np.zeros((100, 100), dtype=np.float32)
+ self.image_view.setImage(placeholder, autoLevels=False, autoRange=True)
+
+ # Remove any existing ROIs
+ if hasattr(self, 'rois') and isinstance(self.rois, list):
+ for roi in self.rois:
+ try:
+ self.image_view.removeItem(roi)
+ except Exception:
+ pass
+ self.rois.clear()
+ self.current_roi = None
+ # Clear docked ROI list
+ if hasattr(self, 'roi_list') and self.roi_list is not None:
+ try:
+ self.roi_list.clear()
+ self.roi_by_item = {}
+ self.item_by_roi_id = {}
+ except Exception:
+ pass
+ # Clear ROI stats dock
+ if hasattr(self, 'roi_stats_table') and self.roi_stats_table is not None:
+ try:
+ self.roi_stats_table.setRowCount(0)
+ self.stats_row_by_roi_id = {}
+ except Exception:
+ pass
+
+ # Set default axis labels
+ self.plot_item.setLabel('bottom', 'X')
+ self.plot_item.setLabel('left', 'Y')
+ try:
+ self.set_2d_axes("Columns", "Row")
+ except Exception:
+ pass
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+
+ # Update above-image info label with placeholder dimensions
+ if hasattr(self, 'image_info_label'):
+ try:
+ self.image_info_label.setText("Image Dimensions: 100x100 pixels")
+ except Exception:
+ pass
+
+ # Remove hover overlays and clear HKL caches
+ try:
+ view = self.image_view.getView() if hasattr(self.image_view, 'getView') else None
+ if view is not None:
+ if hasattr(self, '_hover_hline') and self._hover_hline is not None:
+ try:
+ view.removeItem(self._hover_hline)
+ except Exception:
+ pass
+ self._hover_hline = None
+ if hasattr(self, '_hover_vline') and self._hover_vline is not None:
+ try:
+ view.removeItem(self._hover_vline)
+ except Exception:
+ pass
+ self._hover_vline = None
+ if hasattr(self, '_hover_text') and self._hover_text is not None:
+ try:
+ view.removeItem(self._hover_text)
+ except Exception:
+ pass
+ self._hover_text = None
+ self._mouse_proxy = None
+ self._qx_grid = None
+ self._qy_grid = None
+ self._qz_grid = None
+ except Exception:
+ pass
+
+ except Exception as e:
+ self.update_status(f"Error clearing 2D plot: {e}")
+
+ def update_overlay_text(self, width, height, frame_info=None):
+ """Update the label above the image with dimensions and optional frame info.
+ Augmented to append current motor position (if available) for the selected frame.
+ """
+ try:
+ text = f"Image Dimensions: {width}x{height} pixels"
+ info = frame_info or ""
+ # Try to append motor position for current frame if 3D data
+ try:
+ if hasattr(self, 'current_2d_data') and self.current_2d_data is not None and self.current_2d_data.ndim == 3:
+ num_frames = int(self.current_2d_data.shape[0])
+ idx = 0
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ try:
+ idx = int(self.frame_spinbox.value())
+ except Exception:
+ idx = 0
+ motor_val = None
+ fp = getattr(self, 'current_file_path', None)
+ if fp and os.path.exists(fp):
+ try:
+ with h5py.File(fp, 'r') as h5f:
+ arr = self._find_motor_positions(h5f, num_frames)
+ if arr is not None and 0 <= idx < arr.size:
+ motor_val = float(arr[idx])
+ except Exception:
+ motor_val = None
+ if motor_val is not None:
+ if info:
+ info = f"{info} | Motor {motor_val:.6f}"
+ else:
+ info = f"Motor {motor_val:.6f}"
+ except Exception:
+ pass
+ if info:
+ text = f"{text} ({info})"
+ if hasattr(self, 'image_info_label'):
+ self.image_info_label.setText(text)
+ except Exception as e:
+ self.update_status(f"Error updating image info label: {e}")
+
+ def display_2d_data(self, data):
+ """Display 2D or 3D numeric data in the PyQtGraph ImageView."""
+ try:
+ if not hasattr(self, 'image_view'):
+ print("Warning: ImageView not initialized")
+ return
+
+ # Store the original data for frame navigation
+ self.current_2d_data = data
+ try:
+ print(f"[DISPLAY] data ndim={getattr(data,'ndim',None)}, shape={getattr(data,'shape',None)}")
+ except Exception:
+ pass
+
+ # Handle different data dimensions
+ if data.ndim == 2:
+ # 2D data - display directly
+ image_data = np.asarray(data, dtype=np.float32)
+
+ # Update frame controls for 2D data
+ self.update_frame_controls_for_2d_data()
+
+ height, width = image_data.shape
+ if hasattr(self, 'frame_info_label'):
+ self.frame_info_label.setText(f"Image Dimensions: {width}x{height} pixels")
+ # Update overlay text
+ self.update_overlay_text(width, height, None)
+
+ elif data.ndim == 3:
+ # 3D data - display first frame and set up navigation
+ image_data = np.asarray(data[0], dtype=np.float32)
+
+ # Update frame controls for 3D data
+ num_frames = data.shape[0]
+ self.update_frame_controls_for_3d_data(num_frames)
+
+ height, width = image_data.shape
+ if hasattr(self, 'frame_info_label'):
+ self.frame_info_label.setText(f"Image Dimensions: {width}x{height} pixels (frame 0 of {num_frames})")
+ # Update overlay text
+ self.update_overlay_text(width, height, f"Frame 0 of {num_frames}")
+
+ else:
+ print(f"Unsupported data dimensions: {data.ndim}")
+ return
+
+ # Set the image data
+ auto_levels = hasattr(self, 'cbAutoLevels') and self.cbAutoLevels.isChecked()
+ self.image_view.setImage(
+ image_data,
+ autoLevels=auto_levels,
+ autoRange=True,
+ autoHistogramRange=auto_levels
+ )
+ # Ensure hover overlays exist after any prior clear
+ try:
+ self._setup_2d_hover()
+ except Exception:
+ pass
+
+ # Update axis labels based on data shape
+ height, width = image_data.shape
+ self.plot_item.setLabel('bottom', f'Columns [pixels] (0 to {width-1})')
+ self.plot_item.setLabel('left', f'Row [pixels] (0 to {height-1})')
+ try:
+ self.set_2d_axes("Columns", "Row")
+ except Exception:
+ pass
+
+ # Apply current colormap
+ if hasattr(self, 'cbColorMapSelect_2d'):
+ current_colormap = self.cbColorMapSelect_2d.currentText()
+ self.apply_colormap(current_colormap)
+
+ # Update speckle analysis controls programmatically
+ self.update_speckle_controls_for_data(data)
+
+ # Update vmin/vmax controls based on data
+ self.update_vmin_vmax_controls_for_data(image_data)
+
+ # Refresh ROI stats for current frame/data
+ try:
+ self.roi_manager.update_all_roi_stats()
+ except Exception:
+ pass
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+ # Refresh any open ROI Plot docks to reflect dataset change (axes and series)
+ try:
+ docks = []
+ if hasattr(self, '_roi_plot_dock_widgets') and self._roi_plot_dock_widgets:
+ docks.extend(list(self._roi_plot_dock_widgets))
+ if hasattr(self, 'roi_plot_docks_by_roi_id') and self.roi_plot_docks_by_roi_id:
+ for lst in self.roi_plot_docks_by_roi_id.values():
+ docks.extend(list(lst))
+ for d in docks:
+ try:
+ if hasattr(d, 'refresh_for_dataset_change'):
+ d.refresh_for_dataset_change()
+ except Exception:
+ continue
+ except Exception:
+ pass
+
+ except Exception as e:
+ self.update_status(f"Error displaying 2D data: {e}")
+
+ def _show_image_context_menu(self, pos):
+ """Show right-click menu for the 2D image with hover toggle and HKL plotting."""
+ try:
+ menu = QMenu(self)
+ # Enable/Disable Hover
+ action_hover = QAction("Enable Hover", self)
+ action_hover.setCheckable(True)
+ action_hover.setChecked(bool(getattr(self, '_hover_enabled', True)))
+ action_hover.toggled.connect(self._toggle_hover_enabled)
+ menu.addAction(action_hover)
+ # Show current hover state explicitly (do not remove original options)
+ try:
+ state_text = "Hover: ON" if bool(getattr(self, '_hover_enabled', True)) else "Hover: OFF"
+ except Exception:
+ state_text = "Hover: ON"
+ action_state = QAction(state_text, self)
+ action_state.setEnabled(False)
+ menu.addAction(action_state)
+ # Show last HKL value if available (disabled info item)
+ hkl_label = "HKL: N/A"
+ try:
+ xy = getattr(self, '_last_hover_xy', None)
+ qxg = getattr(self, '_qx_grid', None)
+ qyg = getattr(self, '_qy_grid', None)
+ qzg = getattr(self, '_qz_grid', None)
+ if xy and qxg is not None and qyg is not None and qzg is not None:
+ x, y = int(xy[0]), int(xy[1])
+ if qxg.ndim == 3 and qyg.ndim == 3 and qzg.ndim == 3:
+ idx = 0
+ try:
+ idx = int(self.frame_spinbox.value()) if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled() else 0
+ except Exception:
+ idx = 0
+ if 0 <= idx < qxg.shape[0]:
+ H = float(qxg[idx, y, x]); K = float(qyg[idx, y, x]); L = float(qzg[idx, y, x])
+ hkl_label = f"HKL: H={H:.6f}, K={K:.6f}, L={L:.6f}"
+ elif qxg.ndim == 2 and qyg.ndim == 2 and qzg.ndim == 2:
+ H = float(qxg[y, x]); K = float(qyg[y, x]); L = float(qzg[y, x])
+ hkl_label = f"HKL: H={H:.6f}, K={K:.6f}, L={L:.6f}"
+ except Exception:
+ pass
+ action_hkl_info = QAction(hkl_label, self)
+ action_hkl_info.setEnabled(False)
+ menu.addAction(action_hkl_info)
+ # Plot HKL in 3D
+ action_plot_hkl = QAction("Plot HKL (3D)", self)
+ action_plot_hkl.setToolTip("Plot current frame intensity at HKL (qx,qy,qz) points")
+ action_plot_hkl.triggered.connect(self._plot_current_hkl_points)
+ menu.addAction(action_plot_hkl)
+ # Show menu at global position
+ try:
+ gpos = self.image_view.mapToGlobal(pos)
+ except Exception:
+ gpos = QCursor.pos()
+ menu.exec_(gpos)
+ except Exception as e:
+ self.update_status(f"Error showing image context menu: {e}")
+
+ def _toggle_hover_enabled(self, enabled: bool):
+ try:
+ self._hover_enabled = bool(enabled)
+ self._update_hover_visibility()
+ except Exception:
+ pass
+
+ def _update_hover_visibility(self):
+ try:
+ visible = bool(getattr(self, '_hover_enabled', True))
+ for item_name in ['_hover_hline', '_hover_vline', '_hover_text']:
+ it = getattr(self, item_name, None)
+ try:
+ if it is not None:
+ it.setVisible(visible)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _update_hover_text_at(self, x: int, y: int):
+ """Update hover crosshair and tooltip for given pixel coordinates on current frame."""
+ try:
+ frame = self.get_current_frame_data()
+ if frame is None or frame.ndim != 2:
+ return
+ height, width = frame.shape
+ if x < 0 or y < 0 or x >= width or y >= height:
+ return
+ # Update crosshair positions
+ try:
+ if hasattr(self, '_hover_hline') and self._hover_hline is not None:
+ self._hover_hline.setPos(float(y))
+ if hasattr(self, '_hover_vline') and self._hover_vline is not None:
+ self._hover_vline.setPos(float(x))
+ except Exception:
+ pass
+ # Intensity
+ try:
+ intensity = float(frame[x, y])
+ except Exception:
+ intensity = float('nan')
+ # HKL text
+ hkl_str = ""
+ try:
+ qxg = getattr(self, '_qx_grid', None)
+ qyg = getattr(self, '_qy_grid', None)
+ qzg = getattr(self, '_qz_grid', None)
+ if qxg is not None and qyg is not None and qzg is not None:
+ if qxg.ndim == 3 and qyg.ndim == 3 and qzg.ndim == 3:
+ idx = 0
+ try:
+ idx = int(self.frame_spinbox.value()) if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled() else 0
+ except Exception:
+ idx = 0
+ if 0 <= idx < qxg.shape[0]:
+ H = float(qxg[idx, y, x]); K = float(qyg[idx, y, x]); L = float(qzg[idx, y, x])
+ hkl_str = f" | H={H:.6f}, K={K:.6f}, L={L:.6f}"
+ elif qxg.ndim == 2 and qyg.ndim == 2 and qzg.ndim == 2:
+ H = float(qxg[y, x]); K = float(qyg[y, x]); L = float(qzg[y, x])
+ hkl_str = f" | H={H:.6f}, K={K:.6f}, L={L:.6f}"
+ except Exception:
+ hkl_str = ""
+ # Tooltip text removed; keep crosshair only
+ try:
+ if hasattr(self, '_hover_text') and self._hover_text is not None:
+ self._hover_text.setVisible(False)
+ except Exception:
+ pass
+ # Update 2D Info dock Mouse section even during playback
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ H_val = K_val = L_val = None
+ try:
+ qxg = getattr(self, '_qx_grid', None)
+ qyg = getattr(self, '_qy_grid', None)
+ qzg = getattr(self, '_qz_grid', None)
+ if qxg is not None and qyg is not None and qzg is not None:
+ if qxg.ndim == 3 and qyg.ndim == 3 and qzg.ndim == 3:
+ idx = int(self.frame_spinbox.value()) if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled() else 0
+ if 0 <= idx < qxg.shape[0]:
+ H_val = float(qxg[idx, y, x]); K_val = float(qyg[idx, y, x]); L_val = float(qzg[idx, y, x])
+ elif qxg.ndim == 2 and qyg.ndim == 2 and qzg.ndim == 2:
+ H_val = float(qxg[y, x]); K_val = float(qyg[y, x]); L_val = float(qzg[y, x])
+ except Exception:
+ H_val = K_val = L_val = None
+ self.info_2d_dock.set_mouse_info((x, y), intensity, H_val, K_val, L_val)
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _plot_current_hkl_points(self):
+ """Plot current frame intensities at HKL positions in an HKL 3D Plot Dock."""
+ try:
+ # Ensure q-grids are available
+ if getattr(self, '_qx_grid', None) is None or getattr(self, '_qy_grid', None) is None or getattr(self, '_qz_grid', None) is None:
+ try:
+ self._try_load_hkl_grids()
+ except Exception:
+ pass
+ qxg = getattr(self, '_qx_grid', None)
+ qyg = getattr(self, '_qy_grid', None)
+ qzg = getattr(self, '_qz_grid', None)
+ frame = self.get_current_frame_data()
+ if qxg is None or qyg is None or qzg is None or frame is None:
+ self.update_status("HKL grids or frame not available for plotting")
+ return
+ # Select frame index if 3D
+ idx = 0
+ try:
+ idx = int(self.frame_spinbox.value()) if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled() else 0
+ except Exception:
+ idx = 0
+ # Extract H,K,L arrays matching frame
+ try:
+ if qxg.ndim == 3:
+ qx = qxg[idx]; qy = qyg[idx]; qz = qzg[idx]
+ else:
+ qx = qxg; qy = qyg; qz = qzg
+ except Exception:
+ self.update_status("Error extracting HKL arrays for current frame")
+ return
+ # Build points and intensities
+ try:
+ H = np.asarray(qx, dtype=np.float32).ravel()
+ K = np.asarray(qy, dtype=np.float32).ravel()
+ L = np.asarray(qz, dtype=np.float32).ravel()
+ points = np.column_stack([H, K, L])
+ intens = np.asarray(frame, dtype=np.float32).ravel()
+ except Exception:
+ self.update_status("Error building HKL points")
+ return
+ # Create or reuse HKL 3D Plot Dock
+ try:
+ from viewer.workbench.hkl_3d_plot_dock import HKL3DPlotDock
+ except Exception:
+ HKL3DPlotDock = None
+ if HKL3DPlotDock is None:
+ self.update_status("HKL3DPlotDock not available")
+ return
+ if not hasattr(self, '_hkl3d_plot_dock') or self._hkl3d_plot_dock is None:
+ dock_title = "HKL 3D Plot"
+ dock = HKL3DPlotDock(self, dock_title, self)
+ self.addDockWidget(Qt.RightDockWidgetArea, dock)
+ try:
+ self.add_dock_toggle_action(dock, dock_title, segment_name="2d")
+ except Exception:
+ pass
+ dock.show()
+ self._hkl3d_plot_dock = dock
+ # Plot points
+ try:
+ self._hkl3d_plot_dock._plot_points(points, intens)
+ self.update_status("Plotted HKL points for current frame")
+ except Exception as e:
+ self.update_status(f"Error plotting HKL points: {e}")
+ except Exception as e:
+ self.update_status(f"Error in HKL plot: {e}")
+
+ def _update_hkl3d_plot_for_current_frame(self):
+ """If HKL 3D plot dock is open, update it to current frame."""
+ try:
+ if hasattr(self, '_hkl3d_plot_dock') and self._hkl3d_plot_dock is not None:
+ self._plot_current_hkl_points()
+ except Exception:
+ pass
+
+ def _setup_2d_hover(self):
+ """Create crosshair and tooltip overlays, and connect mouse move events via SignalProxy."""
+ try:
+ if not hasattr(self, 'image_view') or self.image_view is None:
+ return
+ view = self.image_view.getView() if hasattr(self.image_view, 'getView') else None
+ if view is None:
+ return
+ # Create overlays only once
+ if not hasattr(self, '_hover_hline') or self._hover_hline is None:
+ try:
+ self._hover_hline = pg.InfiniteLine(angle=0, movable=False, pen=pg.mkPen(color=(255, 255, 0, 150), width=1))
+ self.plot_item.addItem(self._hover_hline)
+ try:
+ self._hover_hline.setZValue(1000)
+ except Exception:
+ pass
+ except Exception:
+ self._hover_hline = None
+ if not hasattr(self, '_hover_vline') or self._hover_vline is None:
+ try:
+ self._hover_vline = pg.InfiniteLine(angle=90, movable=False, pen=pg.mkPen(color=(255, 255, 0, 150), width=1))
+ self.plot_item.addItem(self._hover_vline)
+ try:
+ self._hover_vline.setZValue(1000)
+ except Exception:
+ pass
+ except Exception:
+ self._hover_vline = None
+ if not hasattr(self, '_hover_text') or self._hover_text is None:
+ try:
+ self._hover_text = pg.TextItem("", color=(255, 255, 255))
+ try:
+ self._hover_text.setAnchor((0, 1))
+ except Exception:
+ pass
+ self.plot_item.addItem(self._hover_text)
+ try:
+ self._hover_text.setZValue(1000)
+ except Exception:
+ pass
+ except Exception:
+ self._hover_text = None
+ # Connect mouse move via SignalProxy to throttle updates
+ try:
+ vb = getattr(self.plot_item, 'vb', None)
+ scene = vb.scene() if vb is not None else self.plot_item.scene()
+ self._mouse_proxy = pg.SignalProxy(scene.sigMouseMoved, rateLimit=60, slot=self._on_2d_mouse_moved)
+ except Exception:
+ self._mouse_proxy = None
+ except Exception as e:
+ try:
+ self.update_status(f"Error setting up 2D hover: {e}")
+ except Exception:
+ pass
+
+ def _on_2d_mouse_moved(self, evt):
+ """Map scene coordinates to pixel indices; update crosshair and tooltip with intensity and HKL if available."""
+ try:
+ # evt may be (QPointF,) from SignalProxy
+ pos = evt[0] if isinstance(evt, (tuple, list)) and len(evt) > 0 else evt
+ if not hasattr(self, 'image_view') or self.image_view is None:
+ return
+ view = self.image_view.getView() if hasattr(self.image_view, 'getView') else None
+ image_item = getattr(self.image_view, 'imageItem', None)
+ if view is None or image_item is None:
+ return
+ # Map to data coordinates
+ try:
+ vb = getattr(self.plot_item, 'vb', None)
+ if vb is not None:
+ mouse_point = vb.mapSceneToView(pos)
+ else:
+ mouse_point = view.mapSceneToView(pos)
+ except Exception:
+ return
+ # Respect hover enabled flag
+ if not bool(getattr(self, '_hover_enabled', True)):
+ return
+ x = int(round(float(mouse_point.x())))
+ y = int(round(float(mouse_point.y())))
+ frame = self.get_current_frame_data()
+ if frame is None or frame.ndim != 2:
+ return
+ height, width = frame.shape
+ # Move crosshairs regardless, using float positions
+ try:
+ if hasattr(self, '_hover_hline') and self._hover_hline is not None:
+ self._hover_hline.setPos(mouse_point.y())
+ if hasattr(self, '_hover_vline') and self._hover_vline is not None:
+ self._hover_vline.setPos(mouse_point.x())
+ except Exception:
+ pass
+ if x < 0 or y < 0 or x >= width or y >= height:
+ return
+ # Remember last valid hover position
+ try:
+ self._last_hover_xy = (x, y)
+ except Exception:
+ pass
+ # Intensity at pixel
+ try:
+ intensity = float(frame[x, y])
+ except Exception:
+ intensity = float('nan')
+ # HKL from cached q-grids if present
+ hkl_str = ""
+ try:
+ qxg = getattr(self, '_qx_grid', None)
+ qyg = getattr(self, '_qy_grid', None)
+ qzg = getattr(self, '_qz_grid', None)
+ if qxg is not None and qyg is not None and qzg is not None:
+ if qxg.ndim == 3 and qyg.ndim == 3 and qzg.ndim == 3:
+ idx = 0
+ try:
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ idx = int(self.frame_spinbox.value())
+ except Exception:
+ idx = 0
+ if 0 <= idx < qxg.shape[0]:
+ H = float(qxg[idx, y, x])
+ K = float(qyg[idx, y, x])
+ L = float(qzg[idx, y, x])
+ hkl_str = f" | H={H:.6f}, K={K:.6f}, L={L:.6f}"
+ elif qxg.ndim == 2 and qyg.ndim == 2 and qzg.ndim == 2:
+ H = float(qxg[y, x])
+ K = float(qyg[y, x])
+ L = float(qzg[y, x])
+ hkl_str = f" | H={H:.6f}, K={K:.6f}, L={L:.6f}"
+ except Exception:
+ hkl_str = ""
+ # Update tooltip text near cursor
+ try:
+ if hasattr(self, '_hover_text') and self._hover_text is not None:
+ # Hide hover text; keep crosshair only
+ self._hover_text.setVisible(False)
+ # Update 2D Info dock Mouse section
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ # Derive H,K,L values again here for precision
+ H_val = K_val = L_val = None
+ try:
+ qxg = getattr(self, '_qx_grid', None)
+ qyg = getattr(self, '_qy_grid', None)
+ qzg = getattr(self, '_qz_grid', None)
+ if qxg is not None and qyg is not None and qzg is not None:
+ if qxg.ndim == 3 and qyg.ndim == 3 and qzg.ndim == 3:
+ idx = int(self.frame_spinbox.value()) if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled() else 0
+ if 0 <= idx < qxg.shape[0]:
+ H_val = float(qxg[idx, y, x]); K_val = float(qyg[idx, y, x]); L_val = float(qzg[idx, y, x])
+ elif qxg.ndim == 2 and qyg.ndim == 2 and qzg.ndim == 2:
+ H_val = float(qxg[y, x]); K_val = float(qyg[y, x]); L_val = float(qzg[y, x])
+ except Exception:
+ H_val = K_val = L_val = None
+ self.info_2d_dock.set_mouse_info((x, y), intensity, H_val, K_val, L_val)
+ except Exception:
+ pass
+ except Exception:
+ pass
+ except Exception:
+ pass
+
+ def _try_load_hkl_grids(self):
+ """Load and cache qx/qy/qz grids (supports 2D HxW and 3D FxHxW). Called after display_2d_data."""
+ try:
+ # Reset caches by default
+ self._qx_grid = None
+ self._qy_grid = None
+ self._qz_grid = None
+ if not getattr(self, 'current_file_path', None) or not getattr(self, 'selected_dataset_path', None):
+ return
+ with h5py.File(self.current_file_path, 'r') as h5f:
+ sel_path = str(self.selected_dataset_path)
+ parent_path = sel_path.rsplit('/', 1)[0] if '/' in sel_path else '/'
+ candidates = []
+ try:
+ if parent_path in h5f:
+ candidates.append(h5f[parent_path])
+ except Exception:
+ pass
+ try:
+ if '/entry/data' in h5f:
+ candidates.append(h5f['/entry/data'])
+ except Exception:
+ pass
+ qx = qy = qz = None
+ def find_in_group(g, name):
+ for key in g.keys():
+ try:
+ if isinstance(g[key], h5py.Dataset) and key.lower() == name:
+ return g[key]
+ except Exception:
+ pass
+ return None
+ # Try strict names first
+ for g in candidates:
+ if g is None:
+ continue
+ try:
+ qx = find_in_group(g, 'qx')
+ qy = find_in_group(g, 'qy')
+ qz = find_in_group(g, 'qz')
+ except Exception:
+ qx = qy = qz = None
+ if qx is not None and qy is not None and qz is not None:
+ break
+ # Fallback: case-insensitive suffix match within parent group
+ if (qx is None or qy is None or qz is None) and parent_path in h5f:
+ g = h5f[parent_path]
+ for key in g.keys():
+ try:
+ if not isinstance(g[key], h5py.Dataset):
+ continue
+ except Exception:
+ continue
+ lk = key.lower()
+ # Support additional naming conventions for HKL/q grids
+ if lk.endswith('qx') or lk == 'qx' or lk in ('q_x', 'qx_grid', 'qgrid_x', 'h', 'QX'.lower()):
+ qx = g[key]
+ elif lk.endswith('qy') or lk == 'qy' or lk in ('q_y', 'qy_grid', 'qgrid_y', 'k', 'QY'.lower()):
+ qy = g[key]
+ elif lk.endswith('qz') or lk == 'qz' or lk in ('q_z', 'qz_grid', 'qgrid_z', 'l', 'QZ'.lower()):
+ qz = g[key]
+ # Last resort: search entire file for datasets named like qx/qy/qz
+ if qx is None or qy is None or qz is None:
+ for group in [h5f]:
+ for key in group.keys():
+ try:
+ item = group[key]
+ if not isinstance(item, h5py.Dataset):
+ continue
+ lk = key.lower()
+ if qx is None and (lk.endswith('qx') or lk == 'qx' or lk in ('q_x', 'h')):
+ qx = item
+ elif qy is None and (lk.endswith('qy') or lk == 'qy' or lk in ('q_y', 'k')):
+ qy = item
+ elif qz is None and (lk.endswith('qz') or lk == 'qz' or lk in ('q_z', 'l')):
+ qz = item
+ except Exception:
+ continue
+ if qx is None or qy is None or qz is None:
+ return
+ # Read arrays
+ try:
+ qx_arr = np.asarray(qx[...], dtype=np.float32)
+ qy_arr = np.asarray(qy[...], dtype=np.float32)
+ qz_arr = np.asarray(qz[...], dtype=np.float32)
+ except Exception:
+ return
+ frame = self.get_current_frame_data()
+ if frame is None or frame.ndim != 2:
+ return
+ h, w = frame.shape
+ # Normalize shapes: transpose 2D grids if (w,h)
+ if qx_arr.ndim == 2 and qy_arr.ndim == 2 and qz_arr.ndim == 2:
+ if qx_arr.shape == (w, h) and qy_arr.shape == (w, h) and qz_arr.shape == (w, h):
+ try:
+ qx_arr = qx_arr.T; qy_arr = qy_arr.T; qz_arr = qz_arr.T
+ except Exception:
+ pass
+ if qx_arr.shape == (h, w) and qy_arr.shape == (h, w) and qz_arr.shape == (h, w):
+ self._qx_grid = qx_arr
+ self._qy_grid = qy_arr
+ self._qz_grid = qz_arr
+ else:
+ return
+ elif qx_arr.ndim == 3 and qy_arr.ndim == 3 and qz_arr.ndim == 3:
+ # Expect (F, H, W), but reorder axes if needed
+ def reorder_to_fhw(arr, h, w):
+ try:
+ shp = arr.shape
+ if len(shp) != 3:
+ return None
+ # Identify axes matching h and w
+ idx_h = None; idx_w = None
+ for i, d in enumerate(shp):
+ if d == h and idx_h is None:
+ idx_h = i
+ for i, d in enumerate(shp):
+ if d == w and i != idx_h and idx_w is None:
+ idx_w = i
+ if idx_h is None or idx_w is None:
+ return None
+ idx_f = [0, 1, 2]
+ idx_f.remove(idx_h); idx_f.remove(idx_w)
+ idx_f = idx_f[0]
+ order = [idx_f, idx_h, idx_w]
+ return np.transpose(arr, axes=order)
+ except Exception:
+ return None
+ if not (qx_arr.shape[1:] == (h, w) and qy_arr.shape[1:] == (h, w) and qz_arr.shape[1:] == (h, w)):
+ rqx = reorder_to_fhw(qx_arr, h, w)
+ rqy = reorder_to_fhw(qy_arr, h, w)
+ rqz = reorder_to_fhw(qz_arr, h, w)
+ if rqx is not None and rqy is not None and rqz is not None:
+ qx_arr, qy_arr, qz_arr = rqx, rqy, rqz
+ if qx_arr.shape[1:] == (h, w) and qy_arr.shape[1:] == (h, w) and qz_arr.shape[1:] == (h, w):
+ self._qx_grid = qx_arr
+ self._qy_grid = qy_arr
+ self._qz_grid = qz_arr
+ else:
+ return
+ else:
+ return
+ try:
+ self.update_status("HKL q-grids loaded for hover")
+ except Exception:
+ pass
+ try:
+ self.set_2d_axes("h", "k")
+ except Exception:
+ pass
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+ except Exception as e:
+ try:
+ self.update_status(f"HKL q-grids load failed: {e}")
+ except Exception:
+ pass
+
+ def clear_1d_plot(self):
+ """Clear the 1D plot."""
+ try:
+ if hasattr(self, 'plot_item_1d'):
+ self.plot_item_1d.clear()
+ except Exception as e:
+ self.update_status(f"Error clearing 1D plot: {e}")
+
+ def display_1d_data(self, data):
+ """Display 1D numeric data in the 1D View."""
+ try:
+ if not hasattr(self, 'plot_item_1d'):
+ print("Warning: 1D plot not initialized")
+ return
+ y = np.asarray(data, dtype=np.float32).ravel()
+ x = np.arange(len(y))
+ self.plot_item_1d.clear()
+ self.plot_item_1d.plot(x, y, pen='y')
+ # Switch to 1D view tab
+ if hasattr(self, 'tabWidget_analysis'):
+ for i in range(self.tabWidget_analysis.count()):
+ if self.tabWidget_analysis.tabText(i) == "1D View":
+ self.tabWidget_analysis.setCurrentIndex(i)
+ break
+ except Exception as e:
+ self.update_status(f"Error displaying 1D data: {e}")
+
+ def on_colormap_changed(self, colormap_name):
+ """Handle colormap changes."""
+ try:
+ self.apply_colormap(colormap_name)
+ except Exception as e:
+ self.update_status(f"Error changing colormap: {e}")
+
+ def on_auto_levels_toggled(self, enabled):
+ """Handle auto levels toggle."""
+ try:
+ if hasattr(self, 'image_view') and hasattr(self.image_view, 'imageItem'):
+ if enabled:
+ # Enable auto levels
+ self.image_view.autoLevels()
+ # If disabled, keep current levels
+ except Exception as e:
+ self.update_status(f"Error toggling auto levels: {e}")
+
+ def apply_colormap(self, colormap_name):
+ """Apply a colormap to the image view."""
+ try:
+ if not hasattr(self, 'image_view'):
+ return
+
+ lut = None
+ # Try pyqtgraph ColorMap first
+ try:
+ if hasattr(pg, "colormap") and hasattr(pg.colormap, "get"):
+ try:
+ cmap = pg.colormap.get(colormap_name)
+ except Exception:
+ cmap = None
+ if cmap is not None:
+ lut = cmap.getLookupTable(nPts=256)
+ except Exception:
+ lut = None
+
+ # Fallback to matplotlib if needed
+ if lut is None:
+ try:
+ import matplotlib.pyplot as plt
+ mpl_cmap = plt.get_cmap(colormap_name)
+ # Build LUT as uint8 Nx3
+ xs = np.linspace(0.0, 1.0, 256, dtype=float)
+ colors = mpl_cmap(xs, bytes=True) # returns Nx4 uint8
+ lut = colors[:, :3]
+ except Exception:
+ # Last resort: grayscale
+ xs = (np.linspace(0, 255, 256)).astype(np.uint8)
+ lut = np.column_stack([xs, xs, xs])
+
+ # Apply the lookup table
+ try:
+ self.image_view.imageItem.setLookupTable(lut)
+ except Exception:
+ pass
+
+ except Exception as e:
+ self.update_status(f"Error applying colormap: {e}")
+
+ # === 3D Helpers ===
+ def _set_3d_overlay(self, text: str):
+ pass
+
+ def _debug_3d_state(self, tag: str = ""):
+ pass
+ def clear_3d_plot(self):
+ """Delegate 3D plot clearing to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.clear_plot()
+ except Exception as e:
+ self.update_status(f"Error clearing 3D plot: {e}")
+
+ def create_3d_from_2d(self, data):
+ pass
+
+ def create_3d_from_3d(self, data):
+ pass
+
+
+
+ def update_intensity_controls(self, data):
+ """Update the intensity control ranges based on data."""
+ try:
+ min_val = int(np.min(data))
+ max_val = int(np.max(data))
+
+ if hasattr(self, 'sb_min_intensity_3d'):
+ self.sb_min_intensity_3d.setRange(min_val - 1000, max_val + 1000)
+ self.sb_min_intensity_3d.setValue(min_val)
+
+ if hasattr(self, 'sb_max_intensity_3d'):
+ self.sb_max_intensity_3d.setRange(min_val - 1000, max_val + 1000)
+ self.sb_max_intensity_3d.setValue(max_val)
+
+ except Exception as e:
+ self.update_status(f"Error updating intensity controls: {e}")
+
+ def apply_3d_visibility_settings(self):
+ pass
+
+ def on_3d_colormap_changed(self, colormap_name):
+ """Delegate 3D colormap change to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.on_colormap_changed(colormap_name)
+ except Exception as e:
+ self.update_status(f"Error changing 3D colormap: {e}")
+
+ def toggle_3d_volume(self, checked):
+ """Delegate 3D volume visibility toggle to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.toggle_volume(bool(checked))
+ except Exception as e:
+ self.update_status(f"Error toggling 3D volume: {e}")
+
+ def toggle_3d_slice(self, checked):
+ """Delegate 3D slice visibility toggle to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.toggle_slice(bool(checked))
+ except Exception as e:
+ self.update_status(f"Error toggling 3D slice: {e}")
+
+ def toggle_3d_pointer(self, checked):
+ """Delegate 3D pointer visibility toggle to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.toggle_pointer(bool(checked))
+ except Exception as e:
+ self.update_status(f"Error toggling 3D pointer: {e}")
+
+ def update_3d_intensity(self):
+ """Delegate 3D intensity update to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.update_intensity()
+ except Exception as e:
+ self.update_status(f"Error updating 3D intensity: {e}")
+
+ def change_slice_orientation(self, orientation):
+ """Delegate slice orientation change to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.change_slice_orientation(orientation)
+ except Exception as e:
+ self.update_status(f"Error changing slice orientation: {e}")
+
+ def reset_3d_slice(self):
+ """Delegate resetting 3D slice to Tab3D."""
+ try:
+ if hasattr(self, 'tab_3d') and self.tab_3d is not None:
+ self.tab_3d.reset_slice()
+ except Exception as e:
+ self.update_status(f"Error resetting 3D slice: {e}")
+
+ # === Tree & Context Menu Events ===
+ def _ensure_current_file_from_item(self, item):
+ """Ensure current_file_path is set by walking up the tree to the file root."""
+ try:
+ cur = item
+ while cur is not None:
+ item_type = cur.data(0, Qt.UserRole + 2)
+ if item_type == "file_root":
+ file_path = cur.data(0, Qt.UserRole + 1)
+ if file_path:
+ self.current_file_path = file_path
+ break
+ cur = cur.parent()
+ except Exception as e:
+ self.update_status(f"Error resolving current file: {e}")
+
+ def on_tree_item_clicked(self, item, column):
+ """
+ Handle tree item single-click events - only show selection info.
+
+ Args:
+ item: QTreeWidgetItem that was clicked
+ column: Column index (not used)
+ """
+ # Ensure we know which file this item belongs to
+ self._ensure_current_file_from_item(item)
+ # Get the full path stored in the item
+ full_path = item.data(0, 32) # Qt.UserRole = 32
+
+ if full_path:
+ # Update status to show selected item
+ self.update_status(f"Selected: {full_path} (Double-click to load)")
+
+ # Store selected dataset path
+ self.selected_dataset_path = full_path
+
+ # Update file info display with dataset details
+ self.update_file_info_with_dataset(full_path)
+
+ # Show dataset info in the workspace without loading
+ self.show_dataset_info(item)
+ else:
+ # Root item or group without data
+ item_text = item.text(0)
+ self.update_status(f"Selected: {item_text}")
+
+ # Show selection info for non-dataset items
+ if hasattr(self, 'file_status_label'):
+ self.file_status_label.setText(f"Selected: {item_text}")
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText("Double-click on a dataset to load it into the workspace.")
+
+ def on_tree_item_double_clicked(self, item, column):
+ """
+ Handle tree item double-click events - load dataset into workspace.
+
+ Args:
+ item: QTreeWidgetItem that was double-clicked
+ column: Column index (not used)
+ """
+ # Get the full path stored in the item
+ full_path = item.data(0, 32) # Qt.UserRole = 32
+ print(f"[DEBUG] Double-clicked item text: {item.text(0)}")
+ print(f"[DEBUG] Double-clicked item full_path (Qt.UserRole=32): {full_path}")
+ # Detect file root items to auto-open default dataset
+ try:
+ item_type = item.data(0, Qt.UserRole + 2)
+ except Exception:
+ item_type = None
+
+ # If a dataset/group path exists, load it as before
+ if full_path:
+ # Update status to show loading
+ self.update_status(f"Loading dataset: {full_path}")
+
+ # Ensure current_file_path points to the owning file
+ self._ensure_current_file_from_item(item)
+ # Store selected dataset path
+ self.selected_dataset_path = full_path
+ print(f"[DEBUG] selected_dataset_path set: {self.selected_dataset_path}")
+
+ # Load and visualize the dataset
+ try:
+ self.start_dataset_load()
+ print("[DEBUG] visualize_selected_dataset call completed")
+ except Exception as e:
+ self.update_status(f"Error in double-click load: {e}")
+ print(f"[DEBUG] Exception in double-click load: {e}")
+ else:
+ # If this is a file root, auto-select and visualize the default 2D dataset
+ if item_type == "file_root":
+ print("[DEBUG] Double-clicked file root; attempting to load default dataset '/entry/data/data'")
+ # Ensure current_file_path points to this file
+ self._ensure_current_file_from_item(item)
+ # Set the default dataset path
+ self.selected_dataset_path = '/entry/data/data'
+ # Verify the dataset exists; if not, show message
+ try:
+ with h5py.File(self.current_file_path, 'r') as h5f:
+ exists = self.selected_dataset_path in h5f
+ print(f"[DEBUG] Default dataset exists? {exists}")
+ if not exists:
+ self.update_status("Default dataset '/entry/data/data' not found in file")
+ return
+ except Exception as e:
+ self.update_status(f"Error verifying default dataset: {e}")
+ return
+ # Visualize using existing logic (will use HDF5Loader for image data)
+ try:
+ self.visualize_selected_dataset()
+ print("[DEBUG] Default dataset visualization completed")
+ except Exception as e:
+ self.update_status(f"Error loading default dataset: {e}")
+ else:
+ # Non-dataset/group: toggle expand/collapse
+ print("[DEBUG] Double-clicked a non-dataset (root/group). Toggling expand/collapse.")
+ if item.isExpanded():
+ item.setExpanded(False)
+ else:
+ item.setExpanded(True)
+
+ def show_context_menu(self, position):
+ """
+ Show context menu for tree items.
+
+ Args:
+ position: Position where the context menu was requested
+ """
+ item = self.tree_data.itemAt(position)
+ if not item:
+ return
+
+ # Check the item type
+ item_type = item.data(0, Qt.UserRole + 2)
+
+ if item_type == "file_root":
+ # Create context menu for file root
+ menu = QMenu(self)
+
+ # Add collapse/expand options
+ if item.isExpanded():
+ collapse_action = QAction("Collapse", self)
+ collapse_action.triggered.connect(lambda: self.collapse_item(item))
+ menu.addAction(collapse_action)
+ else:
+ expand_action = QAction("Expand", self)
+ expand_action.triggered.connect(lambda: self.expand_item(item))
+ menu.addAction(expand_action)
+
+ menu.addSeparator()
+
+ remove_action = QAction("Remove File", self)
+ remove_action.triggered.connect(lambda: self.remove_file(item))
+ menu.addAction(remove_action)
+
+ # Show menu at the requested position
+ menu.exec_(self.tree_data.mapToGlobal(position))
+
+ elif item_type == "folder_section":
+ # Create context menu for folder section
+ menu = QMenu(self)
+
+ # Add collapse/expand options
+ if item.isExpanded():
+ collapse_action = QAction("Collapse Folder", self)
+ collapse_action.triggered.connect(lambda: self.collapse_item(item))
+ menu.addAction(collapse_action)
+ else:
+ expand_action = QAction("Expand Folder", self)
+ expand_action.triggered.connect(lambda: self.expand_item(item))
+ menu.addAction(expand_action)
+
+ menu.addSeparator()
+
+ # Add option to collapse/expand all files in folder
+ collapse_all_files_action = QAction("Collapse All Files", self)
+ collapse_all_files_action.triggered.connect(lambda: self.collapse_all_files_in_folder(item))
+ menu.addAction(collapse_all_files_action)
+
+ expand_all_files_action = QAction("Expand All Files", self)
+ expand_all_files_action.triggered.connect(lambda: self.expand_all_files_in_folder(item))
+ menu.addAction(expand_all_files_action)
+
+ menu.addSeparator()
+
+ remove_folder_action = QAction("Remove Folder", self)
+ remove_folder_action.triggered.connect(lambda: self.remove_folder_section(item))
+ menu.addAction(remove_folder_action)
+
+ # Show menu at the requested position
+ menu.exec_(self.tree_data.mapToGlobal(position))
+
+ def remove_file(self, item):
+ """
+ Remove a file from the tree.
+
+ Args:
+ item: QTreeWidgetItem representing the file root to remove
+ """
+ file_path = item.data(0, Qt.UserRole + 1)
+ file_name = item.text(0)
+
+ # Confirm removal
+ reply = QMessageBox.question(
+ self,
+ "Remove File",
+ f"Are you sure you want to remove '{file_name}' from the tree?",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+
+ if reply == QMessageBox.Yes:
+ # Remove the item from the tree
+ root = self.tree_data.invisibleRootItem()
+ root.removeChild(item)
+
+ self.update_status(f"Removed file: {file_name}")
+
+ # Update analysis placeholder if no files remain
+ if self.tree_data.topLevelItemCount() == 0:
+ if hasattr(self, 'label_analysis_placeholder'):
+ self.label_analysis_placeholder.setText("Load HDF5 data to begin analysis")
+
+ def collapse_item(self, item):
+ """
+ Collapse a specific tree item.
+
+ Args:
+ item: QTreeWidgetItem to collapse
+ """
+ item.setExpanded(False)
+ self.update_status(f"Collapsed: {item.text(0)}")
+
+ def expand_item(self, item):
+ """
+ Expand a specific tree item.
+
+ Args:
+ item: QTreeWidgetItem to expand
+ """
+ item.setExpanded(True)
+ self.update_status(f"Expanded: {item.text(0)}")
+
+ def collapse_all(self):
+ """Collapse all items in the tree."""
+ if hasattr(self, 'tree_data'):
+ self.tree_data.collapseAll()
+ self.update_status("Collapsed all tree items")
+
+ def expand_all(self):
+ """Expand all items in the tree."""
+ if hasattr(self, 'tree_data'):
+ self.tree_data.expandAll()
+ self.update_status("Expanded all tree items")
+
+ def collapse_all_files_in_folder(self, folder_item):
+ """
+ Collapse all files within a folder section.
+
+ Args:
+ folder_item: QTreeWidgetItem representing the folder section
+ """
+ for i in range(folder_item.childCount()):
+ child_item = folder_item.child(i)
+ child_item.setExpanded(False)
+
+ folder_name = folder_item.text(0)
+ self.update_status(f"Collapsed all files in folder: {folder_name}")
+
+ def expand_all_files_in_folder(self, folder_item):
+ """
+ Expand all files within a folder section.
+
+ Args:
+ folder_item: QTreeWidgetItem representing the folder section
+ """
+ for i in range(folder_item.childCount()):
+ child_item = folder_item.child(i)
+ child_item.setExpanded(True)
+
+ folder_name = folder_item.text(0)
+ self.update_status(f"Expanded all files in folder: {folder_name}")
+
+ def remove_folder_section(self, folder_item):
+ """
+ Remove an entire folder section from the tree.
+
+ Args:
+ folder_item: QTreeWidgetItem representing the folder section to remove
+ """
+ folder_path = folder_item.data(0, Qt.UserRole + 1)
+ folder_name = folder_item.text(0)
+
+ # Confirm removal
+ reply = QMessageBox.question(
+ self,
+ "Remove Folder",
+ f"Are you sure you want to remove the entire folder section '{folder_name}' from the tree?\n\nThis will remove all files in this folder from the tree.",
+ QMessageBox.Yes | QMessageBox.No,
+ QMessageBox.No
+ )
+
+ if reply == QMessageBox.Yes:
+ # Remove the folder section from the tree
+ root = self.tree_data.invisibleRootItem()
+ root.removeChild(folder_item)
+
+ self.update_status(f"Removed folder section: {folder_name}")
+
+ # Update analysis placeholder if no files remain
+ if self.tree_data.topLevelItemCount() == 0:
+ if hasattr(self, 'file_status_label'):
+ self.file_status_label.setText("No HDF5 file loaded")
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText("Load HDF5 data to begin analysis")
+
+ def show_dataset_info(self, item):
+ """
+ Show information about the selected dataset without loading it.
+
+ Args:
+ item: QTreeWidgetItem representing the dataset
+ """
+ try:
+ full_path = item.data(0, 32)
+ if not full_path or not self.current_file_path:
+ return
+
+ # Get dataset information
+ with h5py.File(self.current_file_path, 'r') as h5file:
+ if full_path in h5file:
+ dataset = h5file[full_path]
+
+ if isinstance(dataset, h5py.Dataset):
+ # Show dataset information
+ shape_str = f"{dataset.shape}" if dataset.shape else "scalar"
+ dtype_str = str(dataset.dtype)
+ size_str = f"{dataset.size:,}" if dataset.size > 0 else "0"
+
+ info_text = (f"Dataset: {full_path}\n"
+ f"Shape: {shape_str}\n"
+ f"Data Type: {dtype_str}\n"
+ f"Size: {size_str} elements\n\n"
+ f"Double-click to load into workspace")
+
+ # Update 2D display
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText(info_text)
+ else:
+ # It's a group
+ group_info = f"Group: {full_path}\n\nContains {len(dataset)} items\n\nDouble-click to expand/collapse"
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText(group_info)
+
+ except Exception as e:
+ error_msg = f"Error reading dataset info: {str(e)}"
+ if hasattr(self, 'dataset_info_text'):
+ self.dataset_info_text.setPlainText(error_msg)
+
+ # === Frame Navigation ===
+ @pyqtSlot()
+ def previous_frame(self):
+ """Navigate to the previous frame."""
+ try:
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ current_frame = self.frame_spinbox.value()
+ if current_frame > 0:
+ self.frame_spinbox.setValue(current_frame - 1)
+ except Exception as e:
+ self.update_status(f"Error navigating to previous frame: {e}")
+
+ @pyqtSlot()
+ def next_frame(self):
+ """Navigate to the next frame."""
+ try:
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ current_frame = self.frame_spinbox.value()
+ max_frame = self.frame_spinbox.maximum()
+ if current_frame < max_frame:
+ self.frame_spinbox.setValue(current_frame + 1)
+ except Exception as e:
+ self.update_status(f"Error navigating to next frame: {e}")
+
+ @pyqtSlot(int)
+ def on_frame_spinbox_changed(self, frame_index):
+ """Handle frame spinbox changes for 3D data navigation."""
+ try:
+ if not hasattr(self, 'current_2d_data') or self.current_2d_data is None:
+ return
+
+ if self.current_2d_data.ndim != 3:
+ return
+
+ # Get the selected frame
+ if frame_index < 0 or frame_index >= self.current_2d_data.shape[0]:
+ frame_index = 0
+
+ frame_data = np.asarray(self.current_2d_data[frame_index], dtype=np.float32)
+
+ # Update the image view with the new frame
+ auto_levels = hasattr(self, 'cbAutoLevels') and self.cbAutoLevels.isChecked()
+ self.image_view.setImage(
+ frame_data,
+ autoLevels=auto_levels,
+ autoRange=False, # Don't auto-range when changing frames
+ autoHistogramRange=auto_levels
+ )
+
+ # Update frame info label and button states
+ num_frames = self.current_2d_data.shape[0]
+ print(f"[FRAME] on_frame_spinbox_changed: frame_index={frame_index}, num_frames={num_frames}")
+ height, width = frame_data.shape
+ if hasattr(self, 'frame_info_label'):
+ self.frame_info_label.setText(f"Image Dimensions: {width}x{height} pixels (frame {frame_index} of {num_frames})")
+ # Update overlay text
+ self.update_overlay_text(width, height, f"Frame {frame_index} of {num_frames}")
+
+ # Update hover tooltip/crosshair at last position during playback
+ try:
+ xy = getattr(self, '_last_hover_xy', None)
+ if xy and bool(getattr(self, '_hover_enabled', True)):
+ self._update_hover_text_at(int(xy[0]), int(xy[1]))
+ except Exception:
+ pass
+
+ # Update HKL 3D plot if open
+ try:
+ self._update_hkl3d_plot_for_current_frame()
+ except Exception:
+ pass
+
+ # Update button states
+ if hasattr(self, 'btn_prev_frame'):
+ self.btn_prev_frame.setEnabled(frame_index > 0)
+ if hasattr(self, 'btn_next_frame'):
+ self.btn_next_frame.setEnabled(frame_index < num_frames - 1)
+
+ # Refresh ROI stats when frame changes
+ try:
+ self.roi_manager.update_all_roi_stats()
+ except Exception:
+ pass
+ try:
+ if hasattr(self, 'info_2d_dock') and self.info_2d_dock is not None:
+ self.info_2d_dock.refresh()
+ except Exception:
+ pass
+
+ except Exception as e:
+ self.update_status(f"Error changing frame: {e}")
+
+ def start_playback(self):
+ """Start frame playback if a 3D stack is loaded and controls are enabled."""
+ try:
+ if not hasattr(self, 'current_2d_data') or self.current_2d_data is None:
+ return
+ if self.current_2d_data.ndim != 3:
+ return
+ # Only play if more than 1 frame
+ num_frames = self.current_2d_data.shape[0]
+ if num_frames <= 1:
+ print(f"[PLAYBACK] start_playback: num_frames={num_frames} -> not enough frames to play")
+ return
+ # Set timer interval from FPS
+ fps = 2
+ try:
+ if hasattr(self, 'sb_fps'):
+ fps = max(1, int(self.sb_fps.value()))
+ except Exception:
+ fps = 2
+ interval_ms = int(1000 / max(1, fps))
+ print(f"[PLAYBACK] start_playback: num_frames={num_frames}, fps={fps}, interval_ms={interval_ms}")
+ # Reset frame index to 0 at playback start to avoid stale index from previous data
+ try:
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ self.frame_spinbox.setValue(0)
+ except Exception:
+ pass
+ if hasattr(self, 'play_timer') and self.play_timer is not None:
+ try:
+ self.play_timer.setInterval(interval_ms)
+ self.play_timer.start()
+ try:
+ print(f"[PLAYBACK] timer state: {'active' if self.play_timer.isActive() else 'inactive'}")
+ except Exception:
+ pass
+ except Exception as e:
+ print(f"[PLAYBACK] ERROR starting timer: {e}")
+ # Update control states
+ try:
+ self.btn_play.setEnabled(False)
+ self.btn_pause.setEnabled(True)
+ except Exception:
+ pass
+ self.update_status("Playback started")
+ except Exception as e:
+ self.update_status(f"Error starting playback: {e}")
+
+ def pause_playback(self):
+ """Pause frame playback."""
+ try:
+ if hasattr(self, 'play_timer') and self.play_timer is not None:
+ try:
+ self.play_timer.stop()
+ except Exception:
+ pass
+ try:
+ self.btn_play.setEnabled(True)
+ self.btn_pause.setEnabled(False)
+ except Exception:
+ pass
+ self.update_status("Playback paused")
+ except Exception as e:
+ self.update_status(f"Error pausing playback: {e}")
+
+ def on_fps_changed(self, value):
+ """Update timer interval when FPS changes."""
+ try:
+ fps = max(1, int(value))
+ interval_ms = int(1000 / fps)
+ if hasattr(self, 'play_timer') and self.play_timer is not None:
+ try:
+ self.play_timer.setInterval(interval_ms)
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error updating FPS: {e}")
+
+ def _advance_frame_playback(self):
+ """Advance one frame; handle auto replay at end."""
+ try:
+ if not hasattr(self, 'current_2d_data') or self.current_2d_data is None:
+ return
+ if self.current_2d_data.ndim != 3:
+ return
+ num_frames = self.current_2d_data.shape[0]
+ if num_frames <= 1:
+ print("[PLAYBACK] tick: num_frames<=1 -> pausing")
+ self.pause_playback()
+ return
+ idx = 0
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ try:
+ idx = int(self.frame_spinbox.value())
+ except Exception:
+ idx = 0
+ # Clamp idx to valid range for current data
+ if idx < 0 or idx >= num_frames:
+ idx = 0
+ next_idx = idx + 1
+ if next_idx >= num_frames:
+ # Auto replay from beginning if checked
+ auto = False
+ try:
+ auto = bool(self.cb_auto_replay.isChecked()) if hasattr(self, 'cb_auto_replay') else False
+ except Exception:
+ auto = False
+ print(f"[PLAYBACK] tick: idx={idx}, next_idx={next_idx} reached end, auto_replay={auto}")
+ if auto:
+ next_idx = 0
+ else:
+ self.pause_playback()
+ return
+ print(f"[PLAYBACK] tick: advancing to next_idx={next_idx} of num_frames={num_frames}")
+ # Set via spinbox to reuse existing update logic
+ if hasattr(self, 'frame_spinbox'):
+ try:
+ self.frame_spinbox.setValue(next_idx)
+ except Exception as e:
+ print(f"[PLAYBACK] ERROR setting frame_spinbox: {e}")
+ except Exception:
+ pass
+
+ # === File Info Display ===
+ def update_file_info_display(self, file_path, additional_info=None):
+ """
+ Update the file information display with details about the current file.
+
+ Args:
+ file_path (str): Path to the current file or status message
+ additional_info (dict): Additional information to display
+ """
+ if not hasattr(self, 'file_info_text'):
+ return
+
+ try:
+ if file_path == "No file loaded" or not os.path.exists(file_path):
+ # Show default message
+ self.file_info_text.setPlainText("Load an HDF5 file to view file information.")
+ return
+
+ # Get file information
+ file_stats = os.stat(file_path)
+ file_size = file_stats.st_size
+ file_modified = os.path.getmtime(file_path)
+
+ # Format file size
+ if file_size < 1024:
+ size_str = f"{file_size} bytes"
+ elif file_size < 1024 * 1024:
+ size_str = f"{file_size / 1024:.1f} KB"
+ elif file_size < 1024 * 1024 * 1024:
+ size_str = f"{file_size / (1024 * 1024):.1f} MB"
+ else:
+ size_str = f"{file_size / (1024 * 1024 * 1024):.1f} GB"
+
+ # Format modification time
+ import datetime
+ mod_time = datetime.datetime.fromtimestamp(file_modified).strftime("%Y-%m-%d %H:%M:%S")
+
+ # Get HDF5 file information
+ info_lines = []
+ info_lines.append(f"File: {os.path.basename(file_path)}")
+ info_lines.append(f"Path: {os.path.dirname(file_path)}")
+ info_lines.append(f"Size: {size_str}")
+ info_lines.append(f"Modified: {mod_time}")
+
+ try:
+ with h5py.File(file_path, 'r') as h5file:
+ # Count groups and datasets
+ def count_items(group, counts):
+ for key in group.keys():
+ item = group[key]
+ if isinstance(item, h5py.Group):
+ counts['groups'] += 1
+ count_items(item, counts)
+ elif isinstance(item, h5py.Dataset):
+ counts['datasets'] += 1
+
+ counts = {'groups': 0, 'datasets': 0}
+ count_items(h5file, counts)
+
+ info_lines.append("")
+ info_lines.append("HDF5 Structure:")
+ info_lines.append(f"Groups: {counts['groups']}")
+ info_lines.append(f"Datasets: {counts['datasets']}")
+
+ # Get HDF5 attributes if any
+ if h5file.attrs:
+ info_lines.append(f"File Attributes: {len(h5file.attrs)}")
+
+ except Exception as e:
+ info_lines.append("")
+ info_lines.append(f"HDF5 Error: {str(e)}")
+
+ # Add additional info if provided
+ if additional_info:
+ info_lines.append("")
+ info_lines.append("Selection Details:")
+ for key, value in additional_info.items():
+ info_lines.append(f"{key}: {value}")
+
+ # Update the file info tab
+ self.file_info_text.setPlainText("\n".join(info_lines))
+
+ except Exception as e:
+ error_text = f"Error reading file information: {str(e)}"
+ self.file_info_text.setPlainText(error_text)
+
+ def update_file_info_with_dataset(self, dataset_path):
+ """
+ Update the file information display with details about the selected dataset.
+
+ Args:
+ dataset_path (str): Path to the selected dataset within the HDF5 file
+ """
+ if not self.current_file_path or not dataset_path:
+ return
+
+ try:
+ with h5py.File(self.current_file_path, 'r') as h5file:
+ if dataset_path in h5file:
+ item = h5file[dataset_path]
+
+ additional_info = {}
+
+ if isinstance(item, h5py.Dataset):
+ # Dataset information
+ additional_info['Selected Dataset'] = dataset_path
+ additional_info['Shape'] = str(item.shape) if item.shape else "scalar"
+ additional_info['Data Type'] = str(item.dtype)
+ additional_info['Size'] = f"{item.size:,} elements" if item.size > 0 else "0 elements"
+
+ # Memory size estimation
+ if item.size > 0:
+ mem_size = item.size * item.dtype.itemsize
+ if mem_size < 1024:
+ mem_str = f"{mem_size} bytes"
+ elif mem_size < 1024 * 1024:
+ mem_str = f"{mem_size / 1024:.1f} KB"
+ elif mem_size < 1024 * 1024 * 1024:
+ mem_str = f"{mem_size / (1024 * 1024):.1f} MB"
+ else:
+ mem_str = f"{mem_size / (1024 * 1024 * 1024):.1f} GB"
+ additional_info['Memory Size'] = mem_str
+
+ # Dataset attributes
+ if item.attrs:
+ additional_info['Dataset Attributes'] = f"{len(item.attrs)} attributes"
+
+ # Compression info
+ if item.compression:
+ additional_info['Compression'] = item.compression
+ if item.compression_opts:
+ additional_info['Compression Level'] = str(item.compression_opts)
+
+ elif isinstance(item, h5py.Group):
+ # Group information
+ additional_info['Selected Group'] = dataset_path
+ additional_info['Contains'] = f"{len(item)} items"
+
+ # Count subgroups and datasets
+ subgroups = sum(1 for key in item.keys() if isinstance(item[key], h5py.Group))
+ subdatasets = sum(1 for key in item.keys() if isinstance(item[key], h5py.Dataset))
+
+ if subgroups > 0:
+ additional_info['Subgroups'] = str(subgroups)
+ if subdatasets > 0:
+ additional_info['Subdatasets'] = str(subdatasets)
+
+ # Group attributes
+ if item.attrs:
+ additional_info['Group Attributes'] = f"{len(item.attrs)} attributes"
+
+ # Update the display with additional dataset/group info
+ self.update_file_info_display(self.current_file_path, additional_info)
+
+ except Exception as e:
+ # Fall back to basic file info if dataset reading fails
+ self.update_file_info_display(self.current_file_path,
+ {'Error': f"Could not read dataset info: {str(e)}"})
+
+ # === Speckle Analysis & ROI ===
+ def on_log_scale_toggled(self, checked):
+ """Handle log scale checkbox toggle."""
+ try:
+ if hasattr(self, 'image_view') and hasattr(self, 'current_2d_data') and self.current_2d_data is not None:
+ # Get current frame index
+ current_frame = 0
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ current_frame = self.frame_spinbox.value()
+
+ # Get the current frame data
+ if self.current_2d_data.ndim == 3:
+ frame_data = self.current_2d_data[current_frame]
+ else:
+ frame_data = self.current_2d_data
+
+ # Apply or remove log scale
+ if checked:
+ # Apply log scale (log1p to handle zeros)
+ display_data = np.log1p(np.maximum(frame_data, 0))
+ else:
+ # Use original data
+ display_data = frame_data
+
+ # Update the image view
+ auto_levels = hasattr(self, 'cbAutoLevels') and self.cbAutoLevels.isChecked()
+ self.image_view.setImage(
+ display_data,
+ autoLevels=auto_levels,
+ autoRange=False,
+ autoHistogramRange=auto_levels
+ )
+
+ # Apply current colormap
+ if hasattr(self, 'cbColorMapSelect_2d'):
+ current_colormap = self.cbColorMapSelect_2d.currentText()
+ self.apply_colormap(current_colormap)
+
+ # Update vmin/vmax controls for log scale
+ self.update_vmin_vmax_for_log_scale(frame_data, checked)
+
+ # Refresh ROI stats to reflect displayed image
+ try:
+ self.roi_manager.update_all_roi_stats()
+ except Exception:
+ pass
+
+ self.update_status(f"Log scale {'enabled' if checked else 'disabled'}")
+ else:
+ print("No image data available for log scale")
+ except Exception as e:
+ self.update_status(f"Error toggling log scale: {e}")
+
+ def update_vmin_vmax_for_log_scale(self, data, log_scale_enabled):
+ """Update vmin/vmax controls based on log scale state."""
+ try:
+ if log_scale_enabled:
+ # For log scale, set reasonable ranges
+ min_val = max(1, int(np.min(data[data > 0]))) if np.any(data > 0) else 1
+ max_val = int(np.max(data))
+
+ if hasattr(self, 'sbVmin'):
+ self.sbVmin.setRange(1, max_val)
+ self.sbVmin.setValue(min_val)
+
+ if hasattr(self, 'sbVmax'):
+ self.sbVmax.setRange(min_val + 1, max_val * 2)
+ self.sbVmax.setValue(max_val)
+ else:
+ # For linear scale, use full data range
+ min_val = int(np.min(data))
+ max_val = int(np.max(data))
+
+ if hasattr(self, 'sbVmin'):
+ self.sbVmin.setRange(min_val, max_val)
+ self.sbVmin.setValue(min_val)
+
+ if hasattr(self, 'sbVmax'):
+ self.sbVmax.setRange(min_val + 1, max_val * 2)
+ self.sbVmax.setValue(max_val)
+ except Exception as e:
+ self.update_status(f"Error updating vmin/vmax controls: {e}")
+
+ def on_vmin_changed(self, value):
+ """Handle vmin spinbox value change."""
+ try:
+ if hasattr(self, 'image_view') and hasattr(self, 'current_2d_data') and self.current_2d_data is not None:
+ # Get current vmax
+ vmax = self.sbVmax.value() if hasattr(self, 'sbVmax') else 100
+
+ # Ensure vmin < vmax
+ if value >= vmax:
+ return
+
+ # Apply log scale if enabled
+ if hasattr(self, 'cbLogScale') and self.cbLogScale.isChecked():
+ vmin_display = np.log1p(value)
+ vmax_display = np.log1p(vmax)
+ else:
+ vmin_display = value
+ vmax_display = vmax
+
+ # Update image levels
+ self.image_view.setLevels(min=vmin_display, max=vmax_display)
+ # Refresh ROI stats (based on displayed image)
+ try:
+ self.roi_manager.update_all_roi_stats()
+ except Exception:
+ pass
+ self.update_status(f"Vmin set to: {value}")
+ except Exception as e:
+ self.update_status(f"Error changing vmin: {e}")
+
+ def on_vmax_changed(self, value):
+ """Handle vmax spinbox value change."""
+ try:
+ if hasattr(self, 'image_view') and hasattr(self, 'current_2d_data') and self.current_2d_data is not None:
+ # Get current vmin
+ vmin = self.sbVmin.value() if hasattr(self, 'sbVmin') else 0
+
+ # Ensure vmax > vmin
+ if value <= vmin:
+ return
+
+ # Apply log scale if enabled
+ if hasattr(self, 'cbLogScale') and self.cbLogScale.isChecked():
+ vmin_display = np.log1p(vmin)
+ vmax_display = np.log1p(value)
+ else:
+ vmin_display = vmin
+ vmax_display = value
+
+ # Update image levels
+ self.image_view.setLevels(min=vmin_display, max=vmax_display)
+ # Refresh ROI stats (based on displayed image)
+ try:
+ self.roi_manager.update_all_roi_stats()
+ except Exception:
+ pass
+ self.update_status(f"Vmax set to: {value}")
+ except Exception as e:
+ self.update_status(f"Error changing vmax: {e}")
+
+ def on_draw_roi_clicked(self):
+ """Handle Draw ROI button click (delegated to ROIManager)."""
+ try:
+ self.roi_manager.create_and_add_roi()
+ except Exception as e:
+ self.update_status(f"Error drawing ROI: {e}")
+
+ def on_ref_frame_changed(self, value):
+ """Handle reference frame spinbox value change."""
+ try:
+ # Update the current frame to match reference frame
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ self.frame_spinbox.setValue(value)
+ self.update_status(f"Reference frame set to: {value}")
+ except Exception as e:
+ self.update_status(f"Error changing reference frame: {e}")
+
+ def on_other_frame_changed(self, value):
+ """Handle other frame spinbox value change."""
+ try:
+ self.update_status(f"Other frame set to: {value}")
+ except Exception as e:
+ self.update_status(f"Error changing other frame: {e}")
+
+
+
+
+
+
+
+ # === Control Updates ===
+ def update_frame_controls_for_2d_data(self):
+ """Update frame controls for 2D data (disable frame navigation)."""
+ try:
+ if hasattr(self, 'frame_spinbox'):
+ self.frame_spinbox.setEnabled(False)
+ self.frame_spinbox.setValue(0)
+ self.frame_spinbox.setMaximum(0)
+
+ if hasattr(self, 'btn_prev_frame'):
+ self.btn_prev_frame.setEnabled(False)
+ if hasattr(self, 'btn_next_frame'):
+ self.btn_next_frame.setEnabled(False)
+
+ # Stop playback timer and disable controls
+ try:
+ if hasattr(self, 'play_timer') and self.play_timer is not None:
+ self.play_timer.stop()
+ if hasattr(self, 'btn_play'):
+ self.btn_play.setEnabled(False)
+ if hasattr(self, 'btn_pause'):
+ self.btn_pause.setEnabled(False)
+ if hasattr(self, 'sb_fps'):
+ self.sb_fps.setEnabled(False)
+ if hasattr(self, 'cb_auto_replay'):
+ self.cb_auto_replay.setEnabled(False)
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error updating frame controls for 2D data: {e}")
+
+ def update_frame_controls_for_3d_data(self, num_frames):
+ """Update frame controls for 3D data (enable frame navigation)."""
+ try:
+ if hasattr(self, 'frame_spinbox'):
+ self.frame_spinbox.setEnabled(True)
+ self.frame_spinbox.setMaximum(num_frames - 1)
+ self.frame_spinbox.setValue(0)
+
+ if hasattr(self, 'btn_prev_frame'):
+ self.btn_prev_frame.setEnabled(False) # Disabled for frame 0
+ if hasattr(self, 'btn_next_frame'):
+ self.btn_next_frame.setEnabled(num_frames > 1)
+
+ # Enable or disable playback controls based on frame count
+ try:
+ enable_playback = num_frames > 1
+ if hasattr(self, 'btn_play'):
+ self.btn_play.setEnabled(enable_playback)
+ if hasattr(self, 'btn_pause'):
+ self.btn_pause.setEnabled(False) # initially paused
+ if hasattr(self, 'sb_fps'):
+ self.sb_fps.setEnabled(enable_playback)
+ if hasattr(self, 'cb_auto_replay'):
+ self.cb_auto_replay.setEnabled(enable_playback)
+ try:
+ # Select auto replay when playback becomes available
+ self.cb_auto_replay.setChecked(True)
+ except Exception:
+ pass
+ # Stop timer on reconfigure
+ if hasattr(self, 'play_timer') and self.play_timer is not None:
+ self.play_timer.stop()
+ except Exception:
+ pass
+ except Exception as e:
+ self.update_status(f"Error updating frame controls for 3D data: {e}")
+
+ def update_speckle_controls_for_data(self, data):
+ """Update speckle analysis controls based on loaded data."""
+ try:
+ if data.ndim == 3:
+ # 3D data - enable frame selection
+ max_frame = data.shape[0] - 1
+
+ if hasattr(self, 'sbRefFrame'):
+ self.sbRefFrame.setMaximum(max_frame)
+ self.sbRefFrame.setValue(0)
+ self.sbRefFrame.setEnabled(True)
+
+ if hasattr(self, 'sbOtherFrame'):
+ self.sbOtherFrame.setMaximum(max_frame)
+ self.sbOtherFrame.setValue(min(1, max_frame))
+ self.sbOtherFrame.setEnabled(True)
+ else:
+ # 2D data - disable frame selection
+ if hasattr(self, 'sbRefFrame'):
+ self.sbRefFrame.setValue(0)
+ self.sbRefFrame.setMaximum(0)
+ self.sbRefFrame.setEnabled(False)
+
+ if hasattr(self, 'sbOtherFrame'):
+ self.sbOtherFrame.setValue(0)
+ self.sbOtherFrame.setMaximum(0)
+ self.sbOtherFrame.setEnabled(False)
+
+ except Exception as e:
+ self.update_status(f"Error updating speckle controls: {e}")
+
+ def update_vmin_vmax_controls_for_data(self, data):
+ """Update vmin/vmax controls based on data range."""
+ try:
+ min_val = int(np.min(data))
+ max_val = int(np.max(data))
+
+ if hasattr(self, 'sbVmin'):
+ self.sbVmin.setRange(min_val, max_val)
+ self.sbVmin.setValue(min_val)
+
+ if hasattr(self, 'sbVmax'):
+ self.sbVmax.setRange(min_val + 1, max_val * 2)
+ self.sbVmax.setValue(max_val)
+
+ except Exception as e:
+ self.update_status(f"Error updating vmin/vmax controls: {e}")
+
+ # === ROI Stats & Context Menu Actions ===
+ def get_current_frame_data(self):
+ try:
+ if not hasattr(self, 'current_2d_data') or self.current_2d_data is None:
+ return None
+ if self.current_2d_data.ndim == 3:
+ frame_index = 0
+ if hasattr(self, 'frame_spinbox') and self.frame_spinbox.isEnabled():
+ frame_index = self.frame_spinbox.value()
+ if frame_index < 0 or frame_index >= self.current_2d_data.shape[0]:
+ frame_index = 0
+ return np.asarray(self.current_2d_data[frame_index], dtype=np.float32)
+ else:
+ return np.asarray(self.current_2d_data, dtype=np.float32)
+ except Exception:
+ return None
+
+ def compute_roi_stats(self, frame_data, roi):
+ try:
+ if frame_data is None or roi is None:
+ return None
+ height, width = frame_data.shape
+ pos = roi.pos(); size = roi.size()
+ x0 = max(0, int(pos.x())); y0 = max(0, int(pos.y()))
+ w = max(1, int(size.x())); h = max(1, int(size.y()))
+ x1 = min(width, x0 + w); y1 = min(height, y0 + h)
+ if x0 >= x1 or y0 >= y1:
+ return None
+ sub = frame_data[y0:y1, x0:x1]
+ stats = {
+ 'x': x0, 'y': y0, 'w': x1 - x0, 'h': y1 - y0,
+ 'sum': float(np.sum(sub)),
+ 'min': float(np.min(sub)),
+ 'max': float(np.max(sub)),
+ 'mean': float(np.mean(sub)),
+ 'std': float(np.std(sub)),
+ 'count': int(sub.size),
+ }
+ return stats
+ except Exception:
+ return None
+
+ def show_roi_stats_for_roi(self, roi):
+ """Delegate to ROIManager."""
+ try:
+ self.roi_manager.show_roi_stats_for_roi(roi)
+ except Exception as e:
+ self.update_status(f"Error showing ROI stats: {e}")
+
+ def set_active_roi(self, roi):
+ """Delegate to ROIManager."""
+ try:
+ self.roi_manager.set_active_roi(roi)
+ except Exception as e:
+ self.update_status(f"Error setting active ROI: {e}")
+
+ def delete_roi(self, roi):
+ """Delegate to ROIManager."""
+ try:
+ self.roi_manager.delete_roi(roi)
+ except Exception as e:
+ self.update_status(f"Error deleting ROI: {e}")
+
+ # === File & Dataset Info Helpers ===
+
+# === Entrypoint ===
+def main():
+ """Main entry point for the Workbench application."""
+ app = QApplication(sys.argv)
+
+ # Set application properties
+ app.setApplicationName("Workbench")
+ app.setApplicationVersion("1.0.0")
+ app.setOrganizationName("DashPVA")
+
+ # Global excepthook to log unhandled errors to error_output.txt
+ def _log_excepthook(exctype, value, tb):
+ try:
+ import datetime, traceback
+ error_file = project_root / "error_output.txt"
+ with open(error_file, "a") as f:
+ f.write(f"[{datetime.datetime.now().isoformat()}] Unhandled exception: {exctype.__name__}: {value}\n")
+ traceback.print_tb(tb, file=f)
+ except Exception:
+ pass
+ sys.excepthook = _log_excepthook
+
+ # Create and show the main window
+ window = WorkbenchWindow()
+ window.show()
+
+ # Start the event loop
+ sys.exit(app.exec_())
+
+if __name__ == "__main__":
+ main()
diff --git a/viewer/workbench/workers/__init__.py b/viewer/workbench/workers/__init__.py
new file mode 100644
index 0000000..1fb4062
--- /dev/null
+++ b/viewer/workbench/workers/__init__.py
@@ -0,0 +1,182 @@
+from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot
+import numpy as np
+
+class Render3D(QObject):
+ finished = pyqtSignal()
+ render_ready = pyqtSignal(object)
+
+ def __init__(self, *, points=None, intensities=None, num_images=None, shape=None, parent=None):
+ super().__init__(parent)
+ self.points = points
+ self.intensities = intensities
+ self.num_images = int(num_images) if num_images is not None else 0
+ self.shape = tuple(shape) if shape is not None else (0, 0)
+
+ @pyqtSlot()
+ def run(self):
+ try:
+ pts = np.asarray(self.points, dtype=float) if self.points is not None else np.empty((0, 3), dtype=float)
+ ints = np.asarray(self.intensities, dtype=float).ravel() if self.intensities is not None else np.empty((0,), dtype=float)
+
+ if pts.ndim != 2 or (pts.size > 0 and pts.shape[1] != 3):
+ pts = np.empty((0, 3), dtype=float)
+
+ if ints.size:
+ high_mask = ints > 5e6
+ if np.any(high_mask):
+ ints[high_mask] = 0.0
+
+ self.points = pts
+ self.intensities = ints
+ self.render_ready.emit(self)
+ finally:
+ self.finished.emit()
+
+ def plot_3d_points(self, target_tab):
+ """
+ target_tab: The Tab3D instance.
+ It provides access to self.plotter and the local UI widgets.
+ """
+ try:
+ import pyvista as pyv
+ pts = self.points
+ ints = self.intensities
+
+ # 1. Use the plotter local to the Tab
+ plotter = target_tab.plotter
+
+ if pts.ndim != 2 or pts.shape[1] != 3 or ints.size != pts.shape[0]:
+ from PyQt5.QtWidgets import QMessageBox
+ QMessageBox.warning(target_tab, '3D Viewer', 'Invalid point cloud data.')
+ return
+
+ plotter.clear()
+ plotter.add_axes(xlabel='H', ylabel='K', zlabel='L')
+
+ # --- Setup LUTs ---
+ lut = pyv.LookupTable(cmap='viridis')
+ lut.below_range_color = 'black'
+ lut.above_range_color = (1.0, 1.0, 0.0)
+ lut.below_range_opacity = 0
+ lut.apply_opacity([0, 1])
+ lut.above_range_opacity = 1
+
+ # --- Create Mesh ---
+ mesh = pyv.PolyData(pts)
+ mesh['intensity'] = ints
+
+ # Store references on the Tab instance for intensity updates later
+ target_tab.cloud_mesh_3d = mesh
+ target_tab.lut = lut
+
+ # --- Add to Plotter ---
+ plotter.add_mesh(
+ mesh,
+ scalars='intensity',
+ cmap=lut,
+ point_size=5.0,
+ name='points',
+ show_scalar_bar=True,
+ nan_opacity=0.0,
+ show_edges=False
+ )
+
+ plotter.show_bounds(
+ mesh=mesh,
+ xtitle='H Axis', ytitle='K Axis', ztitle='L Axis',
+ bounds=mesh.bounds,
+ )
+ plotter.reset_camera()
+
+ # -- slice --
+ slice_normal = (0, 0, 1)
+ slice_origin = mesh.center
+
+ target_tab.plane_widget = plotter.add_plane_widget(
+ callback=target_tab.on_plane_update,
+ normal=slice_normal,
+ origin=slice_origin,
+ bounds=mesh.bounds,
+ factor=1.0,
+ implicit=True,
+ assign_to_axis=None,
+ tubing=False,
+ origin_translation=True,
+ outline_opacity=0
+ )
+
+ # --- Update Local Tab UI Widgets ---
+ # Note: We now look for names from tab_3d.ui
+ ints_range = (int(np.min(ints)), int(np.max(ints)))
+
+ if hasattr(target_tab, 'sb_min_intensity_3d'):
+ target_tab.sb_min_intensity_3d.setRange(*ints_range) # Expand range
+ target_tab.sb_min_intensity_3d.setValue(int(np.min(ints)))
+
+ if hasattr(target_tab, 'sb_max_intensity_3d'):
+ target_tab.sb_max_intensity_3d.setRange(*ints_range)
+ target_tab.sb_max_intensity_3d.setValue(int(np.max(ints)))
+
+ # Call intensity update logic local to the tab
+ if hasattr(target_tab, 'update_intensity'):
+ target_tab.update_intensity()
+
+
+ plotter.render()
+
+ except Exception as e:
+ print(f"Error in plot_3d_points: {e}")
+
+class DatasetLoader(QObject):
+ loaded = pyqtSignal(object) # numpy array
+ failed = pyqtSignal(str)
+
+ def __init__(self, file_path, dataset_path, max_frames=100):
+ super().__init__()
+ self.file_path = file_path
+ self.dataset_path = dataset_path
+ self.max_frames = max_frames
+
+ @pyqtSlot()
+ def run(self):
+ try:
+ import h5py
+ with h5py.File(self.file_path, 'r') as h5file:
+ if self.dataset_path not in h5file:
+ self.failed.emit("Dataset not found")
+ return
+ dset = h5file[self.dataset_path]
+ if not isinstance(dset, h5py.Dataset):
+ self.failed.emit("Selected item is not a dataset")
+ return
+
+ # Efficient loading to avoid blocking on huge datasets
+ if len(dset.shape) == 3:
+ max_frames = min(self.max_frames, dset.shape[0])
+ data = dset[:max_frames]
+ else:
+ # Guard against extremely large 2D datasets by center cropping
+ try:
+ estimated_size = dset.size * dset.dtype.itemsize
+ except Exception:
+ estimated_size = 0
+ if len(dset.shape) == 2 and estimated_size > 512 * 1024 * 1024: # >512MB
+ h, w = dset.shape
+ ch = min(h, 2048)
+ cw = min(w, 2048)
+ y0 = max(0, (h - ch) // 2)
+ x0 = max(0, (w - cw) // 2)
+ data = dset[y0:y0+ch, x0:x0+cw]
+ else:
+ data = dset[...]
+
+ data = np.asarray(data, dtype=np.float32)
+ # Clean high values
+ high_mask = data > 5e6
+ if np.any(high_mask):
+ data[high_mask] = 0
+
+ # 1D handling: emit raw 1D data for dedicated 1D view
+ self.loaded.emit(data)
+ except Exception as e:
+ self.failed.emit(f"Error loading dataset: {e}")