diff --git a/.gitmodules b/.gitmodules index 52d9cfe..0edd8b7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,8 @@ [submodule "engines/ceus"] path = engines/ceus url = https://github.com/QuantUS-OpenSource/QuantUS-Backend-CEUS + branch = main [submodule "engines/qus"] path = engines/qus url = https://github.com/QuantUS-OpenSource/QuantUS-Backend-QUS + branch = main diff --git a/requirements.txt b/requirements.txt index 97b7a07..63bcec3 100644 Binary files a/requirements.txt and b/requirements.txt differ 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() 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: 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 diff --git a/src/qus/application_model.py b/src/qus/application_model.py index 3a6d558..30f9717 100644 --- a/src/qus/application_model.py +++ b/src/qus/application_model.py @@ -6,6 +6,7 @@ """ import os +import numpy as np from typing import Dict, Any, Optional, Tuple from PyQt6.QtCore import QThread, pyqtSignal @@ -201,6 +202,10 @@ def __init__(self): self._analysis_data: Optional[ParamapAnalysis] = None self._analysis_worker: Optional[AnalysisWorker] = None + # DICOM state + self._dicom_image: Optional[np.ndarray] = None + self._dicom_file_path: Optional[str] = None + # Visualization state self._visualization_types: Dict[str, Any] = {} self._visualization_functions: Dict[str, Any] = {} @@ -263,6 +268,48 @@ def image_data(self) -> Optional[UltrasoundRfImage]: """Get the currently loaded image data.""" return self._image_data + # DICOM Properties and Methods + @property + def dicom_available(self) -> bool: + """Check if DICOM data is available.""" + return self._dicom_image is not None + + @property + def dicom_image(self) -> Optional[np.ndarray]: + """Get the processed DICOM image data.""" + return self._dicom_image + + def load_dicom_file(self, dicom_file_path: str) -> bool: + """ + Load a DICOM file using DicomLoader. + + Args: + dicom_file_path: Path to the DICOM file + + Returns: + bool: True if loaded successfully, False otherwise + """ + from src.qus.image_loading.dicom_loader import DicomLoader + + try: + dicom_pixels = DicomLoader.load_dicom_file(dicom_file_path) + if dicom_pixels is not None: + self._dicom_image = dicom_pixels + self._dicom_file_path = dicom_file_path + return True + return False + except Exception as e: + self._emit_error(f"Error loading DICOM: {e}") + return False + + def get_dicom_data(self) -> dict: + """Get processed DICOM data for display.""" + return { + 'image': self._dicom_image, + 'file_path': self._dicom_file_path, + 'available': self.dicom_available + } + def set_scan_type(self, scan_type_display_name: str) -> bool: """ Set the selected scan type. diff --git a/src/qus/image_loading/__init__.py b/src/qus/image_loading/__init__.py index 56cec46..bd3884d 100644 --- a/src/qus/image_loading/__init__.py +++ b/src/qus/image_loading/__init__.py @@ -3,5 +3,6 @@ """ from .image_loading_controller import ImageLoadingController +from .dicom_loader import DicomLoader -__all__ = ['ImageLoadingController'] +__all__ = ['ImageLoadingController', 'DicomLoader'] diff --git a/src/qus/image_loading/dicom_loader.py b/src/qus/image_loading/dicom_loader.py new file mode 100644 index 0000000..dbeaa42 --- /dev/null +++ b/src/qus/image_loading/dicom_loader.py @@ -0,0 +1,97 @@ +import numpy as np +from pathlib import Path +from typing import Optional + +try: + import pydicom + PYDICOM_AVAILABLE = True +except ImportError: + PYDICOM_AVAILABLE = False + +class DicomLoader: + """ + Utility class for loading and processing DICOM files for ultrasound imaging. + """ + + @staticmethod + def load_dicom_file(dicom_file_path: str) -> Optional[np.ndarray]: + """ + Load a DICOM file and return the processed pixel data. + + Args: + dicom_file_path (str): Path to the DICOM file to load + + Returns: + np.ndarray: Processed pixel data as a 2D numpy array, or None if failed + """ + if not PYDICOM_AVAILABLE: + print("pydicom is not installed. Cannot load DICOM files.") + return None + + try: + dicom_path = Path(dicom_file_path) + if not dicom_path.exists() or not dicom_path.is_file(): + print(f"DICOM file not found: {dicom_file_path}") + return None + + # Read the DICOM file + dicom_data = pydicom.dcmread(str(dicom_path)) + + # Extract pixel data + if hasattr(dicom_data, 'pixel_array'): + dicom_pixels = dicom_data.pixel_array + + # Convert to grayscale if needed (handle different DICOM formats) + if len(dicom_pixels.shape) == 4: + # 4D DICOM (frames, height, width, channels) - take first frame + dicom_pixels = dicom_pixels[0] + if len(dicom_pixels.shape) == 3: + # RGB or multi-frame DICOM - convert to grayscale + if dicom_pixels.shape[2] == 3: # RGB + dicom_pixels = np.dot(dicom_pixels[...,:3], [0.2989, 0.5870, 0.1140]) + elif dicom_pixels.shape[0] < dicom_pixels.shape[2]: # Multi-frame + dicom_pixels = dicom_pixels[0] # Take first frame + + # Normalize to 0-255 range + if dicom_pixels.dtype != np.uint8: + dicom_pixels = ((dicom_pixels - dicom_pixels.min()) / + (max(1, dicom_pixels.max() - dicom_pixels.min())) * 255).astype(np.uint8) + + # Crop black regions from top and bottom + dicom_pixels = DicomLoader.crop_black_regions(dicom_pixels) + + return dicom_pixels + else: + print("No pixel data found in DICOM file") + return None + + except Exception as e: + print(f"Failed to load DICOM file: {e}") + return None + + @staticmethod + def crop_black_regions(image: np.ndarray) -> np.ndarray: + """ + Crop a fixed number of rows from top and bottom of the image. + + Args: + image: Input image as numpy array + + Returns: + Cropped image with specified rows removed + """ + # Define fixed number of rows to crop from top and bottom + crop_top = 175 # Number of rows to crop from top + crop_bottom = 175 # Number of rows to crop from bottom + + # Ensure we don't crop more than the image height + height = image.shape[0] + if crop_top + crop_bottom >= height: + # If we try to crop too much, crop half from each side + crop_top = crop_bottom = height // 4 + + # Crop the image + cropped_image = image[crop_top:height - crop_bottom, :] + + print(f"DICOM cropped: {image.shape} -> {cropped_image.shape} (removed {crop_top} from top, {crop_bottom} from bottom)") + return cropped_image diff --git a/src/qus/seg_loading/seg_loading_controller.py b/src/qus/seg_loading/seg_loading_controller.py index 3ccd4fd..cd760c0 100644 --- a/src/qus/seg_loading/seg_loading_controller.py +++ b/src/qus/seg_loading/seg_loading_controller.py @@ -32,6 +32,7 @@ def __init__(self, model: Optional[ApplicationModel] = None, custom_view=None): if not image_data: raise ValueError("No image loaded in ApplicationModel") view = SegLoadingViewCoordinator(image_data) + view.controller = self super().__init__(model, view) @@ -128,7 +129,23 @@ def get_loaded_segmentation(self) -> BmodeSeg: BmodeSeg: The loaded segmentation data, or None if no segmentation loaded """ return self.model.seg_data + + def load_dicom_file(self, dicom_file_path: str) -> bool: + """ + Load a DICOM file using the DicomLoader utility. + Args: + dicom_file_path (str): Path to the DICOM file to load + + Returns: + bool: True if the DICOM file was loaded successfully, False otherwise + """ + return self.model.load_dicom_file(dicom_file_path) + + def get_dicom_data(self) -> dict: + """Get processed DICOM data from the model.""" + return self.model.get_dicom_data() + def cleanup(self) -> None: """Clean up resources.""" self.model.cleanup() diff --git a/src/qus/seg_loading/seg_loading_view_coordinator.py b/src/qus/seg_loading/seg_loading_view_coordinator.py index 6a46f58..dadbcbe 100644 --- a/src/qus/seg_loading/seg_loading_view_coordinator.py +++ b/src/qus/seg_loading/seg_loading_view_coordinator.py @@ -46,6 +46,7 @@ class SegLoadingViewCoordinator(QStackedWidget): def __init__(self, image_data: UltrasoundRfImage, parent: Optional[QWidget] = None): super().__init__(parent) self._image_data = image_data + self.controller = None # Widget instances self._seg_type_widget: Optional[SegTypeSelectionWidget] = None @@ -193,6 +194,10 @@ def show_roi_drawing(self, frame=0) -> None: # Create ROI drawing widget with brightness value self._roi_drawing_widget = RoiDrawingWidget(self._image_data, frame, self._frame_brightness) + # Pass controller reference for DICOM loading via model + if hasattr(self, 'controller') and self.controller: + self._roi_drawing_widget._parent_controller = self.controller + # Connect signals self._roi_drawing_widget.seg_file_selected.connect(self._on_file_selected) self._roi_drawing_widget.back_requested.connect(self._on_roi_draw_back) diff --git a/src/qus/seg_loading/ui/roi_drawing.ui b/src/qus/seg_loading/ui/roi_drawing.ui index fb3989b..4d73186 100644 --- a/src/qus/seg_loading/ui/roi_drawing.ui +++ b/src/qus/seg_loading/ui/roi_drawing.ui @@ -528,47 +528,64 @@ background: rgb(90, 37, 255); border-radius: 15px; } - - - Back - - - - - - - - - - - - 10 - - - 30 - - - 10 - - - 30 - - - 10 - - - - - 5 - - - - - - - - - QLabel { + + + Back + + + + + + + + + + QLabel { + color: rgb(0, 255, 0); + font-size: 20px; + background-color: rgba(255, 255, 255, 0); +} + + + LOADING.... + + + Qt::AlignCenter + + + + + + + + + 10 + + + 30 + + + 10 + + + 30 + + + 10 + + + + + 5 + + + + + + + + + QLabel { font-size: 18px; color: rgb(255, 255, 255); background-color: rgba(255, 255, 255, 0); @@ -855,50 +872,337 @@ color: rgb(255, 255, 255); background-color: rgba(255, 255, 255, 0); } - - - 0 - - - Qt::TextFormat::AutoText - - - false - - - Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter - - - true - - - - - - - - - - - - - - - - - - 241 - 41 - - - - - 241 - 41 - - - - QPushButton { + + + 0 + + + Qt::AutoText + + + false + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter + + + true + + + + + + + + + + + + + + + 10 + + + + + + 80 + 41 + + + + + 80 + 41 + + + + QLabel { + font-size: 15px; + color: rgb(255, 255, 255); + background-color: rgba(255, 255, 255, 0); +} + + + Brightness: + + + Qt::AlignRight|Qt::AlignVCenter + + + + + + + + 200 + 41 + + + + + 200 + 41 + + + + QSlider::groove:horizontal { + border: 1px solid #999999; + height: 8px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #B1B1B1, stop:1 #c4c4c4); + margin: 2px 0; +} +QSlider::handle:horizontal { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f); + border: 1px solid #5c5c5c; + width: 18px; + margin: 2px 0; + border-radius: 3px; +} + + + 0 + + + 100 + + + 0 + + + Qt::Horizontal + + + + + + + + 40 + 41 + + + + + 40 + 41 + + + + QLabel { + font-size: 15px; + color: rgb(255, 255, 255); + background-color: rgba(255, 255, 255, 0); +} + + + 0 + + + Qt::AlignCenter + + + + + + + + + 10 + + + + + + 200 + 41 + + + + + 200 + 41 + + + + QCheckBox { + color: rgb(255, 255, 255); + font-size: 15px; + background-color: rgba(255, 255, 255, 0); + border: 0px; +} +QCheckBox::indicator { + width: 20px; + height: 20px; + border-radius: 10px; + background-color: rgb(90, 37, 255); + border: 2px solid rgb(255, 255, 255); +} +QCheckBox::indicator:checked { + background-color: rgb(90, 37, 255); + border: 2px solid rgb(255, 255, 255); +} + + + Show DICOM Overlay + + + + + + + + 100 + 41 + + + + + 100 + 41 + + + + QLabel { + font-size: 15px; + color: rgb(255, 255, 255); + background-color: rgba(255, 255, 255, 0); +} + + + Transparency: + + + Qt::AlignRight|Qt::AlignVCenter + + + + + + + + 200 + 41 + + + + + 200 + 41 + + + + QSlider::groove:horizontal { + border: 1px solid #999999; + height: 8px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #B1B1B1, stop:1 #c4c4c4); + margin: 2px 0; +} +QSlider::handle:horizontal { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f); + border: 1px solid #5c5c5c; + width: 18px; + margin: 2px 0; + border-radius: 3px; +} + + + 0 + + + 100 + + + 50 + + + Qt::Horizontal + + + + + + + + 40 + 41 + + + + + 40 + 41 + + + + QLabel { + font-size: 15px; + color: rgb(255, 255, 255); + background-color: rgba(255, 255, 255, 0); +} + + + 50 + + + Qt::AlignCenter + + + + + + + + + + 241 + 41 + + + + + 241 + 41 + + + + QPushButton { + color: white; + font-size: 16px; + background: rgb(90, 37, 255); + border-radius: 15px; +} +QPushButton:hover { + background-color: rgb(120, 67, 255); +} +QPushButton:pressed { + background-color: rgb(60, 17, 195); +} + + + Load DICOM File + + + + + + + + + + 241 + 41 + + + + + 241 + 41 + + + + QPushButton { color: white; font-size: 16px; background: rgb(90, 37, 255); diff --git a/src/qus/seg_loading/views/roi_drawing_widget.py b/src/qus/seg_loading/views/roi_drawing_widget.py index 4667bad..73d93b4 100644 --- a/src/qus/seg_loading/views/roi_drawing_widget.py +++ b/src/qus/seg_loading/views/roi_drawing_widget.py @@ -12,7 +12,8 @@ import scipy.interpolate as interpolate from PyQt6.QtCore import pyqtSignal, Qt -from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog, QSlider, QLabel, QVBoxLayout +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QFileDialog, QSlider, QLabel, QCheckBox, QPushButton from src.qus.mvc.base_view import BaseViewMixin from src.qus.seg_loading.ui.roi_drawing_ui import Ui_constructRoi @@ -52,6 +53,7 @@ def __init__(self, image_data: UltrasoundRfImage, frame: int = 0, brightness: in self._drawing = False # Flag to track if drawing is in progress self._frame = frame # Frame number for multi-frame images self._displayed_im: np.ndarray = None # Placeholder for the image to be displayed + self._dicom_available = False # Track if DICOM data is available # Brightness control variables self._brightness_slider: Optional[QSlider] = None @@ -65,6 +67,17 @@ def __init__(self, image_data: UltrasoundRfImage, frame: int = 0, brightness: in # Drawing parameters self._min_point_distance = 5.0 # Minimum distance between points in pixels + + # DICOM overlay control variables + self._dicom_overlay_checkbox: Optional[QCheckBox] = None + self._transparency_slider: Optional[QSlider] = None + self._transparency_label: Optional[QLabel] = None + self._transparency_value_label: Optional[QLabel] = None + self._load_dicom_button: Optional[QPushButton] = None + self._overlay_enabled = False # Track if overlay is currently enabled + self._transparency_value = 50 # Default transparency (0-100) + self._original_bmode_im = None # Store original B-mode for overlay blending + self._parent_controller = None # Reference to controller for model access self._setup_ui() self._connect_signals() @@ -138,6 +151,18 @@ def set_min_point_distance(self, distance: float) -> None: raise ValueError("Minimum distance must be non-negative") self._min_point_distance = distance + def show_loading(self) -> None: + """Show the loading screen label.""" + super().show_loading() + if hasattr(self._ui, 'loading_screen_label'): + self._ui.loading_screen_label.show() + + def hide_loading(self) -> None: + """Hide the loading screen label.""" + super().hide_loading() + if hasattr(self._ui, 'loading_screen_label'): + self._ui.loading_screen_label.hide() + def get_min_point_distance(self) -> float: """ Get the current minimum distance between points. @@ -147,6 +172,75 @@ def get_min_point_distance(self) -> float: """ return self._min_point_distance + def _apply_dicom_overlay(self, bmode_image: np.ndarray) -> np.ndarray: + """ + Apply DICOM overlay with adjustable transparency to B-mode image. + + Args: + bmode_image: The RF-derived B-mode image to blend with DICOM + + Returns: + Blended image with DICOM overlay applied if enabled, otherwise original B-mode + """ + if not self._overlay_enabled: + return bmode_image + + dicom_data = self._parent_controller.get_dicom_data() + self._dicom_available = dicom_data['available'] + + if not dicom_data or not self._dicom_available: + return bmode_image + + dicom_image = dicom_data.get('image') + if dicom_image is None: + return bmode_image + + try: + # Get transparency value (0-100) and convert to alpha (0.0-1.0) + alpha = self._transparency_value / 100.0 + + # Ensure both images have the same shape + dicom_im = dicom_image + + if dicom_im.shape != bmode_image.shape: + # Resize DICOM to match B-mode if needed + from skimage.transform import resize + dicom_im = resize(dicom_im, bmode_image.shape, preserve_range=True, anti_aliasing=True) + dicom_im = dicom_im.astype(np.uint8) + + # Normalize both images to 0-255 range if needed + bmode_norm = bmode_image.astype(np.float32) + dicom_norm = dicom_im.astype(np.float32) + + # Apply direct alpha blending: output = (1-alpha) * Bmode + alpha * DICOM + # At alpha=0 (transparency=0): output = Bmode (only B-mode visible) + # At alpha=1 (transparency=100): output = DICOM (only DICOM visible) + blended = (1.0 - alpha) * bmode_norm + alpha * dicom_norm + + # Clip and convert back to uint8 + blended = np.clip(blended, 0, 255).astype(np.uint8) + + return blended + + except Exception as e: + print(f"Warning: Failed to apply DICOM overlay: {e}") + return bmode_image + + def _update_overlay_display(self) -> None: + """ + Update the display when overlay settings change. + This method handles the real-time overlay updates. + """ + if self._original_bmode_im is not None: + # 1. Apply overlay to a copy of the original B-mode image + blended_im = self._apply_dicom_overlay(self._original_bmode_im.copy()) + + # 2. Apply brightness adjustment to the blended result + self._displayed_im = self._apply_brightness_adjustment(blended_im) + + # 3. Update the matplotlib display + self._update_display() + def _update_drawing_status(self) -> None: """Update the drawing status to show current settings.""" if self._current_drawing_mode == 'points': @@ -216,6 +310,11 @@ def _setup_ui(self) -> None: 'physical_roi_dims_label', 'physical_roi_height_label', 'physical_roi_height_val', 'physical_roi_width_label', 'physical_roi_width_val' ] + self._dicom_menu_objects = [ + 'dicom_overlay_checkbox', 'transparency_slider', + 'transparency_label', 'transparency_value_label', + 'load_dicom_button' + ] # Setup matplotlib canvas for ROI drawing self._setup_matplotlib_canvas() @@ -223,9 +322,13 @@ def _setup_ui(self) -> None: # Add brightness control self._setup_brightness_control() + # Add DICOM overlay control + self._setup_dicom_overlay_control() + # Display image for ROI drawing self._display_image_for_roi() + self._ui.loading_screen_label.hide() self._hide_save_menu() self._show_draw_type_selection() self._hide_roi_dims() @@ -245,66 +348,43 @@ def _setup_matplotlib_canvas(self) -> None: def _setup_brightness_control(self) -> None: """Setup brightness control slider and label.""" - # Create a horizontal layout for brightness control - brightness_layout = QHBoxLayout() - - # Create brightness label - self._brightness_label = QLabel("Brightness:") - self._brightness_label.setStyleSheet(""" - QLabel { - font-size: 15px; - color: rgb(255, 255, 255); - background-color: rgba(255, 255, 255, 0); - } - """) - self._brightness_label.setMinimumSize(80, 41) - self._brightness_label.setMaximumSize(80, 41) - self._brightness_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) - - # Create brightness slider - self._brightness_slider = QSlider() - self._brightness_slider.setOrientation(Qt.Orientation.Horizontal) - self._brightness_slider.setRange(0, 100) + self._brightness_label = self._ui.brightness_label + self._brightness_slider = self._ui.brightness_slider + self._brightness_value_label = self._ui.brightness_value_label + + # Initialize values self._brightness_slider.setValue(self._brightness_value) - self._brightness_slider.setMinimumSize(200, 41) - self._brightness_slider.setMaximumSize(200, 41) - self._brightness_slider.setStyleSheet(""" - QSlider::groove:horizontal { - border: 1px solid #999999; - height: 8px; - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #B1B1B1, stop:1 #c4c4c4); - margin: 2px 0; - } - QSlider::handle:horizontal { - background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #b4b4b4, stop:1 #8f8f8f); - border: 1px solid #5c5c5c; - width: 18px; - margin: 2px 0; - border-radius: 3px; - } - """) - - # Create brightness value label - self._brightness_value_label = QLabel(str(self._brightness_value)) - self._brightness_value_label.setStyleSheet(""" - QLabel { - font-size: 15px; - color: rgb(255, 255, 255); - background-color: rgba(255, 255, 255, 0); - } - """) - self._brightness_value_label.setMinimumSize(30, 41) - self._brightness_value_label.setMaximumSize(30, 41) - self._brightness_value_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Add widgets to layout - brightness_layout.addWidget(self._brightness_label) - brightness_layout.addWidget(self._brightness_slider) - brightness_layout.addWidget(self._brightness_value_label) - - # Add brightness control to the draw_roi_layout - # Insert it at the beginning of the layout (after the title) - self._ui.draw_roi_layout.insertLayout(1, brightness_layout) + self._brightness_value_label.setText(str(self._brightness_value)) + + def _setup_dicom_overlay_control(self) -> None: + """Setup DICOM overlay control checkbox and transparency slider.""" + # Get references to the UI controls from the .ui file + self._dicom_overlay_checkbox = self._ui.dicom_overlay_checkbox + self._transparency_slider = self._ui.transparency_slider + self._transparency_label = self._ui.transparency_label + self._transparency_value_label = self._ui.transparency_value_label + self._load_dicom_button = self._ui.load_dicom_button + + if not self._dicom_available: + # Hide overlay controls if DICOM is not available + self._dicom_overlay_checkbox.hide() + self._transparency_slider.hide() + self._transparency_label.hide() + self._transparency_value_label.hide() + # Show Load DICOM button + self._load_dicom_button.show() + else: + # Show overlay controls and set initial values + self._dicom_overlay_checkbox.show() + self._dicom_overlay_checkbox.setChecked(False) # Start with overlay disabled + self._transparency_slider.show() + self._transparency_slider.setValue(50) # Default transparency + self._transparency_slider.setEnabled(False) # Disabled until overlay is enabled + self._transparency_label.show() + self._transparency_value_label.show() + self._transparency_value_label.setText("50") + # Hide Load DICOM button since DICOM is already loaded + self._load_dicom_button.hide() def _connect_signals(self) -> None: """Connect UI signals to internal handlers.""" @@ -322,40 +402,120 @@ def _connect_signals(self) -> None: self._ui.back_from_save_button.clicked.connect(self._show_draw_type_selection) # Connect brightness slider - if self._brightness_slider: - self._brightness_slider.valueChanged.connect(self._on_brightness_changed) + self._brightness_slider.valueChanged.connect(self._on_brightness_changed) + + # Connect DICOM overlay controls + self._dicom_overlay_checkbox.toggled.connect(self._on_overlay_toggled) + self._transparency_slider.valueChanged.connect(self._on_transparency_changed) + self._load_dicom_button.clicked.connect(self._on_load_dicom_clicked) def _on_brightness_changed(self, value: int) -> None: """Handle brightness slider change.""" self._brightness_value = value if self._brightness_value_label: self._brightness_value_label.setText(str(value)) - self._apply_brightness_adjustment() - self._update_display() + + # Refresh the entire image display pipeline + self._update_overlay_display() - def _apply_brightness_adjustment(self) -> None: - """Apply brightness adjustment to the displayed image using exponential function.""" - if self._original_displayed_im is None: - return + def _on_overlay_toggled(self, checked: bool) -> None: + """Handle DICOM overlay checkbox toggle.""" + self._overlay_enabled = checked + + # Enable/disable transparency slider based on overlay state + if self._transparency_slider: + self._transparency_slider.setEnabled(checked) + + # Update display with new overlay state + self._update_overlay_display() + + def _on_transparency_changed(self, value: int) -> None: + """Handle transparency slider change.""" + self._transparency_value = value + if self._transparency_value_label: + self._transparency_value_label.setText(str(value)) + + # Update display if overlay is enabled + if self._overlay_enabled: + self._update_overlay_display() + + def _on_load_dicom_clicked(self) -> None: + """Handle Load DICOM button click.""" + # Open file dialog to select DICOM file + dicom_file, _ = QFileDialog.getOpenFileName( + self, + "Select DICOM File", + "", + "DICOM Files (*.dcm *.dicom *.DICOM);;All Files (*)" + ) + + if dicom_file: + # Show loading text + self.show_loading() + QApplication.processEvents() + + success = self._parent_controller.load_dicom_file(dicom_file) + + # Hide loading text + self.hide_loading() - # Calculate exponential coefficient based on brightness slider (0-100) - # Map 0-100 to 0.05-5.0 for more intense exponential range - exp_coefficient = 0.05 + (self._brightness_value / 100.0) * 4.95 + if success: + # Successfully loaded - update UI + if self._load_dicom_button: + self._load_dicom_button.hide() + if self._dicom_overlay_checkbox: + self._dicom_overlay_checkbox.show() + self._dicom_overlay_checkbox.setChecked(False) + if self._transparency_slider: + self._transparency_slider.show() + self._transparency_slider.setValue(50) + self._transparency_slider.setEnabled(False) + if self._transparency_label: + self._transparency_label.show() + if self._transparency_value_label: + self._transparency_value_label.show() + self._transparency_value_label.setText("50") + + # Update display to refresh overlay visibility state + self._update_overlay_display() + else: + # Failed to load - show error message + from PyQt6.QtWidgets import QMessageBox + QMessageBox.warning( + self, + "DICOM Load Error", + "Failed to load the selected DICOM file. Please check the file format and try again." + ) + + def _apply_brightness_adjustment(self, image: np.ndarray) -> np.ndarray: + """ + Apply brightness adjustment to an image using a gamma/power function. - # Apply exponential brightness adjustment - adjusted_im = self._original_displayed_im.astype(np.float32) + Args: + image: Input image (0-255 uint8) + + Returns: + Adjusted image (0-255 uint8) + """ + if image is None: + return None + + # Map brightness slider (0-100) to gamma value + # 50 is the identity (no change) + # Higher than 50 increases brightness (lower gamma) + # Lower than 50 decreases brightness (higher gamma) + # Range: ~0.1 to ~10.0 + exponent = 10.0 ** ((50 - self._brightness_value) / 50.0) - # Normalize to 0-1 range for exponential operation - normalized_im = adjusted_im / 255.0 + # Normalize to 0-1 range for power operation + normalized_im = image.astype(np.float32) / 255.0 - # Apply exponential function: I_out = I_in^exp_coefficient - # This will make the image brighter as exp_coefficient increases - adjusted_im = np.power(normalized_im, 1.0 / exp_coefficient) + # Apply power function: I_out = I_in^exponent + adjusted_im = np.power(normalized_im, exponent) # Scale back to 0-255 range and clip - adjusted_im = adjusted_im * 255.0 - adjusted_im = np.clip(adjusted_im, 0, 255) - self._displayed_im = adjusted_im.astype(np.uint8) + adjusted_im = np.clip(adjusted_im * 255.0, 0, 255).astype(np.uint8) + return adjusted_im def _update_display(self) -> None: """Update the image display with current brightness.""" @@ -415,13 +575,21 @@ def _display_image_for_roi(self) -> None: im = self._image_data.sc_bmode if self._image_data.sc_bmode is not None else self._image_data.bmode if im.ndim == 2: self._original_displayed_im = im.copy() + self._original_bmode_im = im.copy() # Store original B-mode for overlay else: if self._frame < 0 or self._frame >= im.shape[0]: raise ValueError(f"Frame {self._frame} is out of bounds for image with {im.shape[0]} frames") self._original_displayed_im = im[self._frame].copy() + self._original_bmode_im = im[self._frame].copy() # Store original B-mode for overlay + + # Apply DICOM overlay first (if enabled) + if self._overlay_enabled: + blended_im = self._apply_dicom_overlay(self._original_bmode_im.copy()) + else: + blended_im = self._original_bmode_im.copy() - # Apply initial brightness adjustment - self._apply_brightness_adjustment() + # Apply brightness adjustment on top + self._displayed_im = self._apply_brightness_adjustment(blended_im) self._plot_im_on_ax(self._ax) self._matplotlib_canvas.draw() @@ -715,6 +883,9 @@ def _show_save_menu(self) -> None: self._hide_draw_freehand_drag() self._hide_draw_rect_drag() self._hide_draw_pts() + self._hide_draw_type_selection() + self._hide_dicom_controls() + try: self._ax.figure.canvas.mpl_disconnect(self._cid_press) except Exception: @@ -753,6 +924,15 @@ def _hide_draw_type_selection(self) -> None: else: print(f"Warning: Widget '{obj_name}' not found in UI") + def _hide_dicom_controls(self) -> None: + """Hide the DICOM controls.""" + for obj_name in self._dicom_menu_objects: + widget = getattr(self._ui, obj_name, None) + if widget: + widget.hide() + else: + print(f"Warning: Widget '{obj_name}' not found in UI") + def _show_draw_type_selection(self) -> None: """Show the draw type selection layout.""" # Remove the current ROI @@ -794,6 +974,7 @@ def _show_draw_type_selection(self) -> None: self._hide_draw_freehand_drag() self._hide_draw_rect_drag() self._hide_draw_pts() + self._setup_dicom_overlay_control() for obj_name in self._draw_types_objects: widget = getattr(self._ui, obj_name, None)