From b7c3a77eeb5bd4f69ef2ffb4f0176503156648b8 Mon Sep 17 00:00:00 2001 From: omid Date: Mon, 9 Feb 2026 10:38:19 +0100 Subject: [PATCH 1/3] feat: update FileSelectionWidget and ceus engine for single DICOM selection --- engines/ceus | 2 +- .../views/file_selection_widget.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/engines/ceus b/engines/ceus index 8e0f5bd..a5a8dd4 160000 --- a/engines/ceus +++ b/engines/ceus @@ -1 +1 @@ -Subproject commit 8e0f5bd915a22ca6bb1202b7af43bafa3154ec77 +Subproject commit a5a8dd48c729cc6c2cedc90f53ae495e3a3b4d49 diff --git a/src/ceus/image_loading/views/file_selection_widget.py b/src/ceus/image_loading/views/file_selection_widget.py index 5b095c6..23f8d09 100644 --- a/src/ceus/image_loading/views/file_selection_widget.py +++ b/src/ceus/image_loading/views/file_selection_widget.py @@ -116,7 +116,8 @@ def _show_loading_message(self) -> None: def _on_choose_image_path(self) -> None: """Handle image file selection.""" - if self._file_extensions == ["FOLDER"]: + is_folder = any(ext.upper() == "FOLDER" for ext in self._file_extensions) + if is_folder: dir_name = QFileDialog.getExistingDirectory(self, "Select Directory") if dir_name: self._ui.image_path_input.setText(dir_name) @@ -133,12 +134,16 @@ def _on_generate_image(self) -> None: if not os.path.exists(image_path): self.show_error(f"Image file does not exist: {os.path.basename(image_path)}") return - if not image_path.endswith(tuple(self._file_extensions)) and self._file_extensions != ['FOLDER']: - self.show_error(f"Image file must have one of the following extensions: {', '.join(self._file_extensions)}") - return - if self._file_extensions == ["FOLDER"] and not os.path.isdir(image_path): - self.show_error("Input path must be a folder!") - return + + is_folder = any(ext.upper() == "FOLDER" for ext in self._file_extensions) + if not is_folder: + if not image_path.endswith(tuple(self._file_extensions)): + self.show_error(f"Image file must have one of the following extensions: {', '.join(self._file_extensions)}") + return + else: + if not os.path.isdir(image_path): + self.show_error("Input path must be a folder!") + return self.clear_error() From 0eec1da64dce5a26243a1faf9683e05dea5575fe Mon Sep 17 00:00:00 2001 From: omid Date: Mon, 9 Feb 2026 14:54:50 +0100 Subject: [PATCH 2/3] Fix frame slider synchronization and frame-based display in CEUS GUI --- src/ceus/seg_loading/views/draw_voi_widget.py | 46 +++++++++++++++++-- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/src/ceus/seg_loading/views/draw_voi_widget.py b/src/ceus/seg_loading/views/draw_voi_widget.py index 420da4a..9797fe9 100644 --- a/src/ceus/seg_loading/views/draw_voi_widget.py +++ b/src/ceus/seg_loading/views/draw_voi_widget.py @@ -196,6 +196,8 @@ def __init__(self, image_data: UltrasoundImage, parent: Optional[QWidget] = None self._connect_signals() self._connect_matplotlib_events() self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self._update_scan_display() # Initial UI update + self._refresh_frames() # Mark all planes for first update # ======================= Matplotlib Mouse Interaction =================== def _connect_matplotlib_events(self): @@ -325,6 +327,7 @@ def _setup_ui(self) -> None: self._ui.scan_name_input.setText(self._image_data.scan_name) self._ui.toggle_crosshair_visibility_button.setText('Hide Crosshair') + self._ui.cur_slice_label.setText("Current Frame:") self._ui.interp_loading_label.hide(); self._ui.saving_voi_label.hide() self._ui.navigating_label.hide(); self._ui.undo_last_roi_button.hide() @@ -685,9 +688,6 @@ def _update(_frame): if scatter: artists.append(scatter) if mask: artists.append(mask) - # Only update frame counters occasionally or when pending refreshed - if self._ax_sag_cor_pending[plane_ix]: - self._update_scan_display() return artists self._ax_sag_cor_animations[plane_ix] = anim.FuncAnimation( @@ -745,6 +745,7 @@ def set_crosshair(self, x: Optional[int] = None, y: Optional[int] = None, if [cx, cy, cz, ct] != self._crosshair_xyzt: self._crosshair_xyzt = [cx, cy, cz, ct] self._refresh_frames() + self._update_scan_display() return cx, cy, cz, ct def _update_seg_masks(self, plane_ix): @@ -806,11 +807,35 @@ def _connect_signals(self) -> None: self._ui.toggle_crosshair_visibility_button.clicked.connect(self._on_toggle_crosshair_visibility) self._ui.save_voi_button.clicked.connect(self._on_save_voi_clicked) + # Configure slice/time controls self._ui.cur_slice_slider.setMinimum(0) self._ui.cur_slice_slider.setMaximum(max(0, self._num_slices - 1)) self._ui.cur_slice_slider.setValue(self._crosshair_xyzt[3]) self._ui.cur_slice_slider.valueChanged.connect(self._on_time_slider_changed) + # Configure spin box for frame-based navigation + self._ui.cur_slice_spin_box.setRange(1, self._num_slices) + self._ui.cur_slice_spin_box.setSingleStep(1) + self._ui.cur_slice_spin_box.setDecimals(0) + self._ui.cur_slice_spin_box.setValue(self._crosshair_xyzt[3] + 1) + self._ui.cur_slice_spin_box.valueChanged.connect(self._on_time_spin_box_changed) + + # Set initial total frames for all planes + self._ui.ax_total_frames.setText(str(self._z_len)) + self._ui.sag_total_frames.setText(str(self._x_len)) + self._ui.cor_total_frames.setText(str(self._y_len)) + self._ui.cur_slice_total.setText(str(self._num_slices)) + + def _on_time_spin_box_changed(self, value: float): + """Handle user changing the time spin box.""" + frame_idx = int(value) - 1 + + # Clamp to valid range + frame_idx = max(0, min(self._num_slices - 1, frame_idx)) + + if self._ui.cur_slice_slider.value() != frame_idx: + self._ui.cur_slice_slider.setValue(frame_idx) + def _on_time_slider_changed(self, value: int): # type: ignore """Handle user sliding through time dimension (t).""" # Clamp safety (though slider should enforce) @@ -1018,6 +1043,21 @@ def _update_scan_display(self): self._ui.sag_frame_num.setText(str(self._crosshair_xyzt[0] + 1)) self._ui.cor_frame_num.setText(str(self._crosshair_xyzt[1] + 1)) + # Update total frame labels + self._ui.ax_total_frames.setText(str(self._z_len)) + self._ui.sag_total_frames.setText(str(self._x_len)) + self._ui.cor_total_frames.setText(str(self._y_len)) + + # Update current slice/frame display + current_t = self._crosshair_xyzt[3] + + # Block signals to avoid feedback loop + self._ui.cur_slice_spin_box.blockSignals(True) + self._ui.cur_slice_spin_box.setValue(current_t + 1) + self._ui.cur_slice_spin_box.blockSignals(False) + + self._ui.cur_slice_total.setText(str(self._num_slices)) + def mousePressEvent(self, a0): super().mousePressEvent(a0) self._crosshair_active = not self._crosshair_active From 37911ec5be2888ed3e1b31bb549dac331a0eb9ea Mon Sep 17 00:00:00 2001 From: omid Date: Thu, 12 Feb 2026 12:24:26 +0100 Subject: [PATCH 3/3] Add compact enhancement controls and pseudo-colouring to CEUS ROI drawing view --- src/ceus/seg_loading/views/draw_roi_widget.py | 151 ++++++++++++++---- 1 file changed, 121 insertions(+), 30 deletions(-) diff --git a/src/ceus/seg_loading/views/draw_roi_widget.py b/src/ceus/seg_loading/views/draw_roi_widget.py index f72738f..6ae71a3 100644 --- a/src/ceus/seg_loading/views/draw_roi_widget.py +++ b/src/ceus/seg_loading/views/draw_roi_widget.py @@ -11,13 +11,24 @@ from scipy import interpolate from PIL import Image, ImageDraw from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.colors import LinearSegmentedColormap -from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog, QSlider, QVBoxLayout, QFrame, QCheckBox, QLabel from PyQt6.QtCore import pyqtSignal, Qt from ...mvc.base_view import BaseViewMixin from ..ui.draw_roi_ui import Ui_constructRoi from engines.ceus.src.data_objs import UltrasoundImage +from engines.ceus.src.image_preprocessing.functions import enhance_clahe, enhance_gamma + +# Philips CEUS Colormap: Grayscale -> Red -> Yellow +philips_colors = [ + (0.0, 0.0, 0.0), # 0% - Black + (0.4, 0.4, 0.4), # 40% - Gray + (0.8, 0.0, 0.0), # 80% - Red + (1.0, 1.0, 0.0) # 100% - Yellow +] +philips_cmap = LinearSegmentedColormap.from_list("philips_ceus", philips_colors) class DrawROIWidget(QWidget, BaseViewMixin): @@ -59,8 +70,18 @@ def __init__(self, image_data: UltrasoundImage, parent: Optional[QWidget] = None self._target_frame = 0 # Target frame for smooth transitions self._frame_update_pending = False + # Enhancement parameters + self._clahe_clip_limit = 1.2 + self._gamma = 1.5 self._width_scale = 1.0 + # Enhancement parameters + self._clahe_clip_limit = 1.2 + self._gamma = 1.5 + self._use_philips_ceus = False + self._enhanced_cache = None # Cache for enhanced current frame + self._enhanced_cache_idx = -1 + self._setup_ui() self._connect_signals() self._show_draw_type_selection() @@ -256,52 +277,112 @@ def _update_aspect_ratio(self) -> None: self._matplotlib_canvas.draw_idle() def _setup_enhancement_controls(self) -> None: - """Add enhancement sliders to the sidebar.""" - from PyQt6.QtWidgets import QVBoxLayout, QLabel, QSlider, QFrame - + """Add enhancement sliders beside the frame slider in a single horizontal line.""" + # Container frame for enhancement controls enh_group = QFrame() enh_group.setStyleSheet("background-color: rgba(255, 255, 255, 0); border: none;") - container_layout = QVBoxLayout(enh_group) - container_layout.setContentsMargins(0, 10, 0, 10) + + # Main horizontal layout for the enhancement section + container_layout = QHBoxLayout(enh_group) + container_layout.setContentsMargins(0, 0, 15, 0) container_layout.setSpacing(15) - def create_enh_column(label_text, min_val, max_val, current_val, callback): - col_widget = QWidget() - col_layout = QVBoxLayout(col_widget) - col_layout.setContentsMargins(0, 0, 0, 0) - col_layout.setSpacing(5) + def create_compact_control(label_text, min_val, max_val, current_val, callback): + # Widget to hold label, slider, and value in ONE line + ctrl_widget = QWidget() + ctrl_layout = QHBoxLayout(ctrl_widget) + ctrl_layout.setContentsMargins(0, 0, 0, 0) + ctrl_layout.setSpacing(5) lbl = QLabel(label_text) - lbl.setStyleSheet("font-size: 14px; color: white; font-weight: bold;") - lbl.setAlignment(Qt.AlignmentFlag.AlignCenter) - col_layout.addWidget(lbl) + lbl.setStyleSheet("font-size: 10px; color: white; font-weight: bold;") + ctrl_layout.addWidget(lbl) - row_layout = QHBoxLayout() + # Slider slider = QSlider(Qt.Orientation.Horizontal) slider.setRange(min_val, max_val) slider.setValue(current_val) - slider.setMinimumWidth(100) - slider.setMaximumWidth(120) + slider.setStyleSheet(self._ui.frame_slider.styleSheet()) + slider.setFixedWidth(70) + slider.setFixedHeight(12) slider.valueChanged.connect(callback) - + ctrl_layout.addWidget(slider) + val_lbl = QLabel(f"{current_val/10.0:.1f}") - val_lbl.setMinimumWidth(40) - val_lbl.setStyleSheet("color: #3498db; font-weight: bold; font-size: 14px;") + val_lbl.setStyleSheet("color: #3498db; font-weight: bold; font-size: 10px;") + val_lbl.setMinimumWidth(22) + val_lbl.setAlignment(Qt.AlignmentFlag.AlignLeft) + ctrl_layout.addWidget(val_lbl) - row_layout.addWidget(slider) - row_layout.addWidget(val_lbl) - col_layout.addLayout(row_layout) - - return col_widget, slider, val_lbl + return ctrl_widget, slider, val_lbl - width_col, self.width_slider, self.width_val_lbl = create_enh_column( + # Create controls + clahe_w, self.clahe_slider, self.clahe_val_lbl = create_compact_control( + "CLAHE", 1, 100, int(self._clahe_clip_limit * 10), self._on_clahe_changed + ) + gamma_w, self.gamma_slider, self.gamma_val_lbl = create_compact_control( + "GAMMA", 1, 40, int(self._gamma * 10), self._on_gamma_changed + ) + width_w, self.width_slider, self.width_val_lbl = create_compact_control( "WIDTH", 1, 50, int(self._width_scale * 10), self._on_width_changed ) - container_layout.addWidget(width_col) + # Pseudo colouring toggle nicely aligned + self.philips_check = QCheckBox("Pseudo colouring") + self.philips_check.setStyleSheet("color: white; font-weight: bold; font-size: 11px;") + self.philips_check.stateChanged.connect(self._on_philips_toggled) + + # Add to horizontal layout + container_layout.addWidget(clahe_w) + container_layout.addWidget(gamma_w) + container_layout.addWidget(width_w) + container_layout.addWidget(self.philips_check) + + # Add to the layout beside the frame slider (below the image) + self._ui.frameControlsLayout.insertWidget(0, enh_group) + + def _on_clahe_changed(self, value: int) -> None: + """Handle CLAHE clip limit change.""" + self._clahe_clip_limit = value / 10.0 + if hasattr(self, 'clahe_val_lbl'): + self.clahe_val_lbl.setText(f"{self._clahe_clip_limit:.1f}") + self._invalidate_enhancement_cache() + + def _on_gamma_changed(self, value: int) -> None: + """Handle gamma change.""" + self._gamma = value / 10.0 + if hasattr(self, 'gamma_val_lbl'): + self.gamma_val_lbl.setText(f"{self._gamma:.1f}") + self._invalidate_enhancement_cache() + + def _on_philips_toggled(self, state: int) -> None: + """Handle Philips CEUS pseudocolor toggle.""" + self._use_philips_ceus = state == Qt.CheckState.Checked.value + # Update colormap on artist + if self._im_artist: + new_cmap = philips_cmap if self._use_philips_ceus else 'gray' + self._im_artist.set_cmap(new_cmap) + self._matplotlib_canvas.draw_idle() - # Add to the layout below the frame slider - self._ui.side_bar_layout.addWidget(enh_group) + def _invalidate_enhancement_cache(self) -> None: + """Invalidate the enhancement cache and trigger display update.""" + self._enhanced_cache = None + self._enhanced_cache_idx = -1 + self._force_frame_update() + + def _enhance_frame(self, frame_2d: np.ndarray) -> np.ndarray: + """Enhance a 2D image frame using backend engine functions.""" + # Create a temporary UltrasoundImage for processing + temp_im = UltrasoundImage(self._image_data.scan_path) + temp_im.pixel_data = frame_2d + temp_im.pixdim = self._image_data.pixdim + temp_im.frame_rate = self._image_data.frame_rate + + # Apply enhancements + temp_im = enhance_clahe(temp_im, clip_limit=self._clahe_clip_limit) + temp_im = enhance_gamma(temp_im, gamma=self._gamma) + + return temp_im.pixel_data def _on_frame_changed(self, value: int) -> None: """Handle frame slider change with optimized performance.""" @@ -312,8 +393,18 @@ def _on_frame_changed(self, value: int) -> None: def _update_frame_display(self, frame_index: int) -> None: """Update the frame display with consistent parameters.""" if self._im_artist: - self._displayed_im = self._all_frames[frame_index] + # Update cache if needed + if self._enhanced_cache is None or self._enhanced_cache_idx != frame_index: + self._enhanced_cache = self._enhance_frame(self._all_frames[frame_index]) + self._enhanced_cache_idx = frame_index + + self._displayed_im = self._enhanced_cache self._im_artist.set_array(self._displayed_im) + + # Ensure correct colormap is applied (e.g. after initialization) + new_cmap = philips_cmap if self._use_philips_ceus else 'gray' + self._im_artist.set_cmap(new_cmap) + self._ui.cur_frame_label.setText(str(np.round(frame_index*self._image_data.frame_rate, decimals=2))) def _force_frame_update(self) -> None: