diff --git a/engines/ceus b/engines/ceus index 97d3d3a..b8044e4 160000 --- a/engines/ceus +++ b/engines/ceus @@ -1 +1 @@ -Subproject commit 97d3d3a8b03ee02bc5bda8dca2f1f316151d8210 +Subproject commit b8044e4a88136d642c0cc180c6f77e8a1d36ecec diff --git a/engines/qus b/engines/qus index d5531ed..ac6e2f0 160000 --- a/engines/qus +++ b/engines/qus @@ -1 +1 @@ -Subproject commit d5531ed6f48149931fb83cd33960b273517dd4c8 +Subproject commit ac6e2f0b8d13b6065a4cda9e889f3d48c5f070c9 diff --git a/requirements.txt b/requirements.txt index 63bcec3..97b7a07 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/src/ceus/application_model.py b/src/ceus/application_model.py index 2ae47fc..b98621b 100644 --- a/src/ceus/application_model.py +++ b/src/ceus/application_model.py @@ -88,6 +88,7 @@ class ApplicationModel(BaseModel): # Additional signals for application-specific events image_loaded = pyqtSignal(UltrasoundImage) + bmode_image_loaded = pyqtSignal(UltrasoundImage) segmentation_loaded = pyqtSignal(CeusSeg) def __init__(self): @@ -97,7 +98,10 @@ def __init__(self): self._scan_loaders: Dict[str, Any] = {} self._selected_scan_type: Optional[str] = None self._image_data: Optional[UltrasoundImage] = None + self._bmode_image_data: Optional[UltrasoundImage] = None self._scan_worker: Optional[ScanLoadingWorker] = None + self._bmode_scan_worker: Optional[ScanLoadingWorker] = None + self._pending_bmode_load: bool = False # True when B-mode load is in progress # Segmentation loading state self._seg_loaders: Dict[str, Any] = {} @@ -148,6 +152,11 @@ def image_data(self) -> Optional[UltrasoundImage]: """Get the currently loaded image data.""" return self._image_data + @property + def bmode_image_data(self) -> Optional[UltrasoundImage]: + """Get the currently loaded B-mode image data.""" + return self._bmode_image_data + def set_scan_type(self, scan_type_display_name: str) -> bool: """ Set the selected scan type. @@ -211,7 +220,7 @@ def get_image_loading_options(self) -> list: def load_image(self, image_path: str, scan_loader_kwargs: Dict[str, Any] = None) -> None: """ Load scan image data. - + Args: image_path: Path to image file scan_loader_kwargs: Additional loader arguments (optional) @@ -219,7 +228,12 @@ def load_image(self, image_path: str, scan_loader_kwargs: Dict[str, Any] = None) if not self._selected_scan_type: self._emit_error("No scan type selected") return - + + # Reset state for new load cycle + self._image_data = None + self._bmode_image_data = None + self._pending_bmode_load = False + if scan_loader_kwargs is None: scan_loader_kwargs = {} @@ -337,6 +351,31 @@ def enhance_image(self, image: UltrasoundImage, func_configs: List[Dict[str, Any print(f"DEBUG: enhance_image error: {e}") return image + def compute_ceus_noise_floor(self, image_data: UltrasoundImage, n_ref_frames: int, noise_std_multiplier: float) -> float: + """ + Compute the noise floor from pre-contrast frames. + + Args: + image_data: UltrasoundImage object + n_ref_frames: Number of reference frames + noise_std_multiplier: Multiplier for standard deviation + + Returns: + float: The computed noise floor + """ + try: + funcs = self.get_preprocessing_options() + if 'compute_ceus_noise_floor' in funcs: + return funcs['compute_ceus_noise_floor']( + image_data, + n_ref_frames=n_ref_frames, + noise_std_multiplier=noise_std_multiplier + ) + return 0.0 + except Exception as e: + print(f"WARNING: compute_ceus_noise_floor failed in model: {e}") + return 0.0 + def _validate_image_input(self, input_data: Dict[str, Any]) -> bool: """ Validate input data for scan loading. @@ -370,16 +409,14 @@ def _validate_image_input(self, input_data: Dict[str, Any]) -> bool: def _on_image_loading_complete(self, image_data: UltrasoundImage) -> None: """ Handle completion of scan loading. - + Args: image_data: Loaded ultrasound image data """ - self._set_loading(False) - # Check if loading was successful if isinstance(image_data, UltrasoundImage): self._image_data = image_data - + # Print NIfTI information if applicable scan_path = getattr(image_data, 'scan_path', '') if scan_path and scan_path.lower().endswith(('.nii', '.nii.gz')): @@ -389,9 +426,14 @@ def _on_image_loading_complete(self, image_data: UltrasoundImage) -> None: print(f"Pixel Dimensions: {getattr(image_data, 'pixdim', 'Unknown')}") print(f"Frame Rate: {getattr(image_data, 'frame_rate', 'Unknown')}") print(f"----------------------------------------\n") - + + # If B-mode is still loading, wait for it before emitting + if self._pending_bmode_load: + return + self._set_loading(False) self.image_loaded.emit(image_data) else: + self._set_loading(False) print(f"DEBUG: Image loading failed - invalid image data:") print(f" - scan_path: {getattr(image_data, 'scan_path', 'Missing')}") print(f" - has pixel_data: {hasattr(image_data, 'pixel_data')}") @@ -399,7 +441,66 @@ def _on_image_loading_complete(self, image_data: UltrasoundImage) -> None: print(f" - has intensity: {hasattr(image_data, 'intensities_for_analysis')}") print(f" - intensities_for_analysis is None: {getattr(image_data, 'intensities_for_analysis', None) is None}") self._emit_error("Failed to load image data - image loading was unsuccessful") - + + def load_bmode_image(self, bmode_path: str, scan_loader_kwargs: Dict[str, Any] = None) -> None: + """ + Load B-mode image data in the background. + + Args: + bmode_path: Path to B-mode image file + scan_loader_kwargs: Additional loader arguments (optional) + """ + if not self._selected_scan_type: + self._emit_error("No scan type selected for B-mode loading") + return + + if scan_loader_kwargs is None: + scan_loader_kwargs = {} + + if not os.path.exists(bmode_path): + self._emit_error(f"B-mode file not found: {bmode_path}") + return + + self._pending_bmode_load = True + + # Stop any existing B-mode worker + if self._bmode_scan_worker and self._bmode_scan_worker.isRunning(): + self._bmode_scan_worker.quit() + self._bmode_scan_worker.wait() + + self._bmode_scan_worker = ScanLoadingWorker( + self._selected_scan_type, + bmode_path, + scan_loader_kwargs + ) + + self._bmode_scan_worker.finished.connect(self._on_bmode_loading_complete) + self._bmode_scan_worker.error_msg.connect(self._on_bmode_loading_error) + self._bmode_scan_worker.start() + + def _on_bmode_loading_complete(self, image_data: UltrasoundImage) -> None: + """Handle completion of B-mode image loading.""" + self._pending_bmode_load = False + if isinstance(image_data, UltrasoundImage): + self._bmode_image_data = image_data + self.bmode_image_loaded.emit(image_data) + else: + self._emit_error("Failed to load B-mode image data") + + # If CEUS already finished loading, emit image_loaded now + if self._image_data is not None: + self._set_loading(False) + self.image_loaded.emit(self._image_data) + + def _on_bmode_loading_error(self, error_msg: str) -> None: + """Handle B-mode loading error — proceed with CEUS only.""" + self._pending_bmode_load = False + self._emit_error(error_msg) + # Still allow proceeding with CEUS image if it loaded successfully + if self._image_data is not None: + self._set_loading(False) + self.image_loaded.emit(self._image_data) + # Segmentation Loading Properties and Methods @property def seg_loaders(self) -> Dict[str, Any]: @@ -551,7 +652,12 @@ def cleanup(self) -> None: self._scan_worker.quit() self._scan_worker.wait() self._scan_worker = None - + + if self._bmode_scan_worker and self._bmode_scan_worker.isRunning(): + self._bmode_scan_worker.quit() + self._bmode_scan_worker.wait() + self._bmode_scan_worker = None + if self._seg_worker and self._seg_worker.isRunning(): self._seg_worker.quit() self._seg_worker.wait() diff --git a/src/ceus/image_loading/image_loading_controller.py b/src/ceus/image_loading/image_loading_controller.py index e8ff825..977dfd1 100644 --- a/src/ceus/image_loading/image_loading_controller.py +++ b/src/ceus/image_loading/image_loading_controller.py @@ -61,7 +61,7 @@ def _handle_scan_type_selection(self, scan_type_name: str) -> None: def _handle_image_loading(self, load_data: dict) -> None: """ Handle image loading request. - + Args: load_data: Dictionary with loading parameters """ @@ -70,7 +70,15 @@ def _handle_image_loading(self, load_data: dict) -> None: image_path=load_data['image_path'], scan_loader_kwargs=load_data['scan_loader_kwargs'] ) - + + # Load B-mode image if path was provided + bmode_path = load_data.get('bmode_path') + if bmode_path: + self.model.load_bmode_image( + bmode_path=bmode_path, + scan_loader_kwargs=load_data['scan_loader_kwargs'] + ) + except Exception as e: print(f"DEBUG: Error in image loading: {e}") import traceback diff --git a/src/ceus/image_loading/ui/file_selection.ui b/src/ceus/image_loading/ui/file_selection.ui index a926a8c..4ea3e81 100644 --- a/src/ceus/image_loading/ui/file_selection.ui +++ b/src/ceus/image_loading/ui/file_selection.ui @@ -474,7 +474,7 @@ - + 20 @@ -656,6 +656,119 @@ + + + + 20 + + + 20 + + + + + QLabel { + background-color: rgba(255, 255, 255, 0); + color: white; + font-size: 17px; +} + + + Input Path to B-mode Image (optional) + + + Qt::AlignCenter + + + Qt::NoTextInteraction + + + + + + + + 201 + 31 + + + + + 401 + 31 + + + + QLineEdit { + background-color: rgb(249, 249, 249); + color: black; +} + + + + + + + 6 + + + + + + 131 + 41 + + + + + 131 + 41 + + + + QPushButton { + color: white; + font-size: 16px; + background: rgb(90, 37, 255); + border-radius: 15px; +} + + + Choose File + + + + + + + + 131 + 41 + + + + + 131 + 41 + + + + QPushButton { + color: white; + font-size: 16px; + background: rgb(90, 37, 255); + border-radius: 15px; +} + + + Clear Path + + + + + + + diff --git a/src/ceus/image_loading/views/file_selection_widget.py b/src/ceus/image_loading/views/file_selection_widget.py index 5b095c6..b2c1af1 100644 --- a/src/ceus/image_loading/views/file_selection_widget.py +++ b/src/ceus/image_loading/views/file_selection_widget.py @@ -53,6 +53,8 @@ def _setup_ui(self) -> None: self._loading_widgets = [ self._ui.choose_image_path_button, self._ui.clear_image_path_button, + self._ui.choose_bmode_path_button, + self._ui.clear_bmode_path_button, self._ui.generate_image_button, self._ui.back_button ] @@ -76,6 +78,8 @@ def _connect_signals(self) -> None: """Connect UI signals to internal handlers.""" self._ui.choose_image_path_button.clicked.connect(self._on_choose_image_path) self._ui.clear_image_path_button.clicked.connect(self._ui.image_path_input.clear) + self._ui.choose_bmode_path_button.clicked.connect(self._on_choose_bmode_path) + self._ui.clear_bmode_path_button.clicked.connect(self._ui.bmode_path_input.clear) self._ui.generate_image_button.clicked.connect(self._on_generate_image) self._ui.back_button.clicked.connect(self._on_back_clicked) @@ -122,6 +126,15 @@ def _on_choose_image_path(self) -> None: self._ui.image_path_input.setText(dir_name) else: self._select_file_helper(self._ui.image_path_input, self._file_extensions) + + def _on_choose_bmode_path(self) -> None: + """Handle B-mode image file selection.""" + if self._file_extensions == ["FOLDER"]: + dir_name = QFileDialog.getExistingDirectory(self, "Select B-mode Directory") + if dir_name: + self._ui.bmode_path_input.setText(dir_name) + else: + self._select_file_helper(self._ui.bmode_path_input, self._file_extensions) def _on_generate_image(self) -> None: """Handle image generation request.""" @@ -160,10 +173,20 @@ def _on_generate_image(self) -> None: scan_loader_kwargs[option] = value - self.files_selected.emit({ + file_data = { 'image_path': image_path, 'scan_loader_kwargs': scan_loader_kwargs - }) + } + + # Include B-mode path if provided (optional) + bmode_path = self._ui.bmode_path_input.text().strip() + if bmode_path: + if not os.path.exists(bmode_path): + self.show_error(f"B-mode file does not exist: {os.path.basename(bmode_path)}") + return + file_data['bmode_path'] = bmode_path + + self.files_selected.emit(file_data) def _on_back_clicked(self) -> None: """Handle back button click.""" diff --git a/src/ceus/seg_loading/seg_loading_controller.py b/src/ceus/seg_loading/seg_loading_controller.py index eab66f6..874df85 100644 --- a/src/ceus/seg_loading/seg_loading_controller.py +++ b/src/ceus/seg_loading/seg_loading_controller.py @@ -31,7 +31,7 @@ def __init__(self, model: Optional[ApplicationModel] = None, custom_view=None): image_data = model.image_data if not image_data: raise ValueError("No image loaded in ApplicationModel") - view = SegLoadingViewCoordinator(image_data) + view = SegLoadingViewCoordinator(image_data, bmode_image_data=model.bmode_image_data) super().__init__(model, view) @@ -66,6 +66,10 @@ def handle_user_action(self, action_name: str, action_data: Any) -> None: self._handle_segmentation_loading(action_data) elif action_name == 'apply_preprocs_preview': self._handle_preprocs_preview(action_data) + elif action_name == 'compute_noise_floor': + self._handle_compute_noise_floor(action_data) + elif action_name == 'enhance_image_request': + self._handle_enhance_image_request(action_data) elif action_name == 'segmentation_confirmed': pass # Handle confirmation action in the application controller else: @@ -89,6 +93,26 @@ def _handle_preprocs_preview(self, preproc_data_list: list) -> None: self.view.preview_modified_image(image_data, frame_ix) + def _handle_compute_noise_floor(self, data: dict) -> None: + """Handle noise floor computation request.""" + value = self.model.compute_ceus_noise_floor( + data['image_data'], + data['n_ref_frames'], + data['noise_std_multiplier'] + ) + self.view.set_noise_floor(value) + + def _handle_enhance_image_request(self, data: dict) -> None: + """Handle image enhancement request.""" + enhanced = self.model.enhance_image( + data['image_data'], + data['func_configs'] + ) + # The enhanced image is returned; the view caller should handle usage + # This is primarily for the export worker which calls it synchronously + # through the coordinator's routed action. + data['callback'](enhanced) + def _handle_seg_type_selection(self, seg_type_name: str) -> None: """ Handle segmentation type selection. diff --git a/src/ceus/seg_loading/seg_loading_view_coordinator.py b/src/ceus/seg_loading/seg_loading_view_coordinator.py index 4f6d9d5..9635378 100644 --- a/src/ceus/seg_loading/seg_loading_view_coordinator.py +++ b/src/ceus/seg_loading/seg_loading_view_coordinator.py @@ -40,9 +40,10 @@ class SegLoadingViewCoordinator(QStackedWidget): # ============================================================================ - def __init__(self, image_data: UltrasoundImage, parent: Optional[QWidget] = None): + def __init__(self, image_data: UltrasoundImage, bmode_image_data: Optional[UltrasoundImage] = None, parent: Optional[QWidget] = None): super().__init__(parent) self._image_data = image_data + self._bmode_image_data = bmode_image_data # Widget instances self._seg_type_widget: Optional[SegTypeSelectionWidget] = None @@ -178,18 +179,42 @@ def show_file_selection(self, file_extensions: list) -> None: def show_voi_drawing(self) -> None: """Show the VOI drawing widget.""" - self._voi_drawing_widget = DrawVOIWidget(self._image_data) + self._voi_drawing_widget = DrawVOIWidget(self._image_data, bmode_image_data=self._bmode_image_data) # Connect signals to handle user actions self._voi_drawing_widget.back_requested.connect(self.reset_to_seg_type_selection) self._voi_drawing_widget.close_requested.connect(self.close_requested.emit) self._voi_drawing_widget.apply_preprocs_preview.connect(self._on_preprocs_preview_requested) + self._voi_drawing_widget.compute_noise_floor_requested.connect(self._on_noise_floor_requested) + self._voi_drawing_widget.enhance_image_requested.connect(self._on_enhance_image_requested) # Add to stack and show self.addWidget(self._voi_drawing_widget) self.setCurrentWidget(self._voi_drawing_widget) + def set_noise_floor(self, value: float) -> None: + """Route computed noise floor to the VOI drawing widget.""" + if self._voi_drawing_widget: + self._voi_drawing_widget.set_noise_floor(value) + + def _on_noise_floor_requested(self, image_data, n_ref, std_mult) -> None: + """Emit signal to controller to compute noise floor.""" + self._emit_user_action('compute_noise_floor', { + 'image_data': image_data, + 'n_ref_frames': n_ref, + 'noise_std_multiplier': std_mult + }) + + def _on_enhance_image_requested(self, image_data, func_configs, callback=None) -> None: + """Emit signal to controller to enhance an image (used by export).""" + self._emit_user_action('enhance_image_request', { + 'image_data': image_data, + 'func_configs': func_configs, + 'callback': callback + }) + def preview_modified_image(self, modified_image: UltrasoundImage, frame: int) -> None: + """Show the preprocessed data in the VOI drawing widget.""" if self._voi_drawing_widget: self._voi_drawing_widget.update_enhancement_cache(modified_image.pixel_data, frame) diff --git a/src/ceus/seg_loading/views/draw_voi_widget.py b/src/ceus/seg_loading/views/draw_voi_widget.py index 858a869..fa6374b 100644 --- a/src/ceus/seg_loading/views/draw_voi_widget.py +++ b/src/ceus/seg_loading/views/draw_voi_widget.py @@ -16,6 +16,7 @@ from scipy.spatial import ConvexHull from PyQt6.QtWidgets import QWidget, QLabel, QHBoxLayout, QSizePolicy, QFileDialog, QSlider, QVBoxLayout, QFrame, QCheckBox from PyQt6.QtCore import QEvent, pyqtSignal, Qt, QThread +from PyQt6.QtWidgets import QPushButton from ...mvc.base_view import BaseViewMixin from ..ui.draw_voi_ui import Ui_voi_drawer @@ -117,6 +118,23 @@ def run(self): self.finished.emit("VOI saved successfully.") except Exception as e: self.error_msg.emit(str(e)) + +class ExportVideoWorker(QThread): + """Worker thread for exporting the postprocessed CEUS video and VOI overlay as MP4.""" + finished = pyqtSignal(str) + error_msg = pyqtSignal(str) + progress = pyqtSignal(int) # 0-100 + + def __init__(self, parent_widget): + super().__init__() + self.parent_widget = parent_widget + + def run(self): + try: + self.parent_widget._export_video(self.progress) + self.finished.emit("Video exported successfully.") + except Exception as e: + self.error_msg.emit(str(e)) class DrawVOIWidget(QWidget, BaseViewMixin): @@ -131,26 +149,48 @@ class DrawVOIWidget(QWidget, BaseViewMixin): file_selected = pyqtSignal(dict) # {'seg_path': str, 'seg_type': str} back_requested = pyqtSignal() close_requested = pyqtSignal() + # B-mode/CEUS Toggle & Paramap Signals apply_preprocs_preview = pyqtSignal(list) # List of dicts with 'name' and 'kwargs' keys + compute_noise_floor_requested = pyqtSignal(object, int, float) # image_data, n_ref, std_mult + enhance_image_requested = pyqtSignal(object, list) # image_data, func_configs - def __init__(self, image_data: UltrasoundImage, parent: Optional[QWidget] = None): + def __init__(self, image_data: UltrasoundImage, bmode_image_data: Optional[UltrasoundImage] = None, parent: Optional[QWidget] = None): QWidget.__init__(self, parent) self.__init_base_view__(parent) self._ui = Ui_voi_drawer() self._image_data = image_data - self._pix_data = image_data.pixel_data - + + # B-mode overlay data + self._bmode_image_data = bmode_image_data + self._bmode_pix_data = bmode_image_data.pixel_data if bmode_image_data else None + self._bmode_alpha = 0.5 # 0.0 = invisible, 1.0 = fully opaque + self._ax_sag_cor_bmode_artists = [None, None, None] + # Enhancement parameters - self._clahe_clip_limit = 1.2 - self._gamma = 1.5 + # self._clahe_clip_limit = 1.2 + # self._gamma = 1.5 + + self._p_low_percentile = 4.0 + self._p_high_percentile = 98.0 + + self._n_ref_frames = 5 + self._noise_std_multiplier = 0.5 + self._ceus_p_high_percentile = 100 + self._last_noise_floor = 0.0 + self._width_scale_axial = 1.0 self._width_scale_sagittal = 1.0 self._width_scale_coronal = 1.0 self._use_philips_ceus = False + self._pix_data = image_data.pixel_data # keep raw data untouched + self._ceus_p_low = self._compute_noise_floor(image_data) # single scalar + # Cache for enhanced volume - self._enhanced_cache = None - self._enhanced_cache_frame = -1 + # self._enhanced_cache = None + # self._enhanced_cache_frame = -1 + + self._slice_cache: dict = {} # State collections self._drawing_widgets = [] @@ -199,13 +239,26 @@ def __init__(self, image_data: UltrasoundImage, parent: Optional[QWidget] = None self._update_scan_display() # Initial UI update self._refresh_frames() # Mark all planes for first update - def update_enhancement_cache(self, enhanced_frame: np.ndarray, frame: int) -> None: - """Update the displayed image data, e.g. after preprocessing.""" - assert enhanced_frame.shape[:-1] == self._pix_data.shape[:-1], "Enhanced pixel data must have the same shape as original" - self._enhanced_cache = enhanced_frame[:, :, :, 0] # Store only the current time frame in cache - self._enhanced_cache_frame = frame - self._refresh_frames() + # def update_enhancement_cache(self, enhanced_frame: np.ndarray, frame: int) -> None: + # """Update the displayed image data, e.g. after preprocessing.""" + # self._frame_cache = enhanced_frame[:, :, :, 0] if enhanced_frame.ndim == 4 else enhanced_frame + # self._frame_cache_t = frame + # self._refresh_frames() + + def _compute_noise_floor(self, image_data: UltrasoundImage) -> float: + """Request the CEUS noise floor scalar from the model.""" + self._last_noise_floor = 0.0 + self.compute_noise_floor_requested.emit( + image_data, + self._n_ref_frames, + self._noise_std_multiplier + ) + return self._last_noise_floor + def set_noise_floor(self, value: float) -> None: + """Callback from controller to set the computed noise floor.""" + self._last_noise_floor = value + # ======================= Matplotlib Mouse Interaction =================== def _connect_matplotlib_events(self): """Connect motion and click events on each plane's matplotlib canvas. @@ -397,12 +450,13 @@ def create_enh_column(label_text, min_val, max_val, current_val, callback): return col_widget, slider, val_lbl # Create the columns - clahe_col, self.clahe_slider, self.clahe_val_lbl = create_enh_column( - "CLAHE", 1, 100, int(self._clahe_clip_limit * 10), self._on_clahe_changed + p_low_col, self.p_low_slider, self.p_low_val_lbl = create_enh_column( + "P LOW", 1, 200, int(self._p_low_percentile * 10), self._on_p_low_changed ) - gamma_col, self.gamma_slider, self.gamma_val_lbl = create_enh_column( - "GAMMA", 1, 40, int(self._gamma * 10), self._on_gamma_changed + p_high_col, self.p_high_slider, self.p_high_val_lbl = create_enh_column( + "P HIGH", 500, 999, int(self._p_high_percentile * 10), self._on_p_high_changed ) + width_ax_col, self.width_ax_slider, self.width_ax_val_lbl = create_enh_column( "WIDTH (AX)", 1, 50, int(self._width_scale_axial * 10), self._on_width_axial_changed ) @@ -413,8 +467,8 @@ def create_enh_column(label_text, min_val, max_val, current_val, callback): "WIDTH (COR)", 1, 50, int(self._width_scale_coronal * 10), self._on_width_coronal_changed ) - row1_layout.addWidget(clahe_col) - row1_layout.addWidget(gamma_col) + row1_layout.addWidget(p_low_col) + row1_layout.addWidget(p_high_col) # Philips CEUS Toggle (Pseudocoloring) - now in row 1 self.philips_check = QCheckBox("Pseudocoloring") @@ -434,8 +488,52 @@ def create_enh_column(label_text, min_val, max_val, current_val, callback): container_layout.addLayout(row1_layout) container_layout.addLayout(row2_layout) + # B-mode alpha slider (only shown when B-mode data is loaded) + if self._bmode_pix_data is not None: + row3_layout = QHBoxLayout() + row3_layout.setSpacing(20) + bmode_alpha_col, self.bmode_alpha_slider, self.bmode_alpha_val_lbl = create_enh_column( + "B-MODE ALPHA", 0, 100, int(self._bmode_alpha * 100), self._on_bmode_alpha_changed + ) + self.bmode_alpha_val_lbl.setText(f"{self._bmode_alpha:.2f}") + row3_layout.addWidget(bmode_alpha_col) + container_layout.addLayout(row3_layout) + # Add to the layout below the current slice slider self._ui.verticalLayout_2.addWidget(enh_group) + + self.export_video_button = QPushButton("Export Video") + self.export_video_button.setStyleSheet( + "QPushButton { color: white; font-size: 14px; font-weight: bold; " + "background: rgb(90, 37, 255); border-radius: 10px; padding: 6px 12px; }" + "QPushButton:disabled { background: rgb(100, 100, 100); }" + ) + self.export_video_button.clicked.connect(self._on_export_video_clicked) + self._ui.verticalLayout_2.addWidget(self.export_video_button) + + def _on_export_video_clicked(self) -> None: + """Handle export video button click.""" + if not Path(self._ui.save_folder_input.text()).is_dir(): + self.show_error("Please select a valid save folder first (use the VOI save panel).") + return + + self.export_video_button.setEnabled(False) + self.export_video_button.setText("Exporting...") + + self._export_video_worker = ExportVideoWorker(self) + self._export_video_worker.finished.connect(self._on_export_video_finished) + self._export_video_worker.error_msg.connect(self._on_export_video_error) + self._export_video_worker.start() + + def _on_export_video_finished(self, msg: str) -> None: + self.export_video_button.setEnabled(True) + self.export_video_button.setText("Export Video") + print(msg) + + def _on_export_video_error(self, err: str) -> None: + self.export_video_button.setEnabled(True) + self.export_video_button.setText("Export Video") + self.show_error(f"Export failed: {err}") def _on_philips_toggled(self, state: int) -> None: """Handle Philips CEUS pseudocolor toggle.""" @@ -447,18 +545,26 @@ def _on_philips_toggled(self, state: int) -> None: artist.set_cmap(new_cmap) self._refresh_frames() - 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}") + def _on_bmode_alpha_changed(self, value: int) -> None: + """Handle B-mode overlay alpha change.""" + self._bmode_alpha = value / 100.0 + if hasattr(self, 'bmode_alpha_val_lbl'): + self.bmode_alpha_val_lbl.setText(f"{self._bmode_alpha:.2f}") + for artist in self._ax_sag_cor_bmode_artists: + if artist is not None: + artist.set_alpha(self._bmode_alpha) + self._refresh_frames() + + def _on_p_low_changed(self, value: int) -> None: + self._p_low_percentile = value / 10.0 + if hasattr(self, 'p_low_val_lbl'): + self.p_low_val_lbl.setText(f"{self._p_low_percentile:.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}") + def _on_p_high_changed(self, value: int) -> None: + self._p_high_percentile = value / 10.0 + if hasattr(self, 'p_high_val_lbl'): + self.p_high_val_lbl.setText(f"{self._p_high_percentile:.1f}") self._invalidate_enhancement_cache() def _on_width_axial_changed(self, value: int) -> None: @@ -524,15 +630,14 @@ def _update_aspect_ratios(self) -> None: def _reset_enhancement(self, _=None) -> None: """Reset enhancement parameters to defaults.""" - self.clahe_slider.setValue(12) # 1.2 - self.gamma_slider.setValue(15) # 1.5 + self.p_low_slider.setValue(20) # 2.0 + self.p_high_slider.setValue(980) # 98.0 self.width_ax_slider.setValue(10) # 1.0 self.width_sag_slider.setValue(10) # 1.0 self.width_cor_slider.setValue(10) # 1.0 def _invalidate_enhancement_cache(self) -> None: - """Invalidate the cache and trigger a refresh of all planes.""" - self._enhanced_cache = None + self._slice_cache.clear() self._refresh_frames() def _setup_matplotlib_canvases(self): @@ -587,13 +692,21 @@ def _initialize_plane_displays(self) -> None: mask_arr = self._get_mask_slice(plane_ix) current_cmap = philips_cmap if self._use_philips_ceus else 'gray' - artist = ax.imshow(slice_arr, cmap=current_cmap, aspect=float(aspect), zorder=1, animated=True) # add vmin and vmax for the 0 - 255 show + artist = ax.imshow(slice_arr, cmap=current_cmap, aspect=float(aspect), zorder=1, animated=True) + + # B-mode overlay layer (zorder=2, above CEUS, below masks) + if self._bmode_pix_data is not None: + bmode_slice = self._get_bmode_plane_slice(plane_ix) + bmode_artist = ax.imshow(bmode_slice, cmap='gray', aspect=float(aspect), + zorder=2, animated=True, alpha=self._bmode_alpha) + self._ax_sag_cor_bmode_artists[plane_ix] = bmode_artist + v_line = ax.axvline(x=0, color='yellow', lw=0.8, animated=True, zorder=11) h_line = ax.axhline(y=0, color='yellow', lw=0.8, animated=True, zorder=11) seg_mask = ax.imshow(mask_arr, zorder=8, aspect=float(aspect), animated=True) roi_plot = ax.plot([], [], c='cyan', lw=1, zorder=9, animated=True) point_scatter = ax.scatter([], [], c='red', s=5, marker='o', zorder=10, animated=True) - + self._ax_sag_cor_plane_artists[plane_ix] = artist self._ax_sag_cor_crosshair_lines[plane_ix] = (v_line, h_line) self._ax_sag_cor_point_scatters[plane_ix] = point_scatter @@ -606,62 +719,70 @@ def _initialize_plane_displays(self) -> None: self.show_error(f"Error initializing plane display {plane_ix}: {e}") def _get_plane_slice(self, plane_ix: int, initializing=False): - """Return 2D numpy slice for given plane index based on current crosshair.""" - idx = self._get_plane_indices(plane_ix) current_t = self._crosshair_xyzt[3] - - # Check if we need to enhance a new frame - if not initializing and (self._enhanced_cache is None or self._enhanced_cache_frame != current_t): - # Get the 3D volume for current time frame - current_frame_3d = self._pix_data[:, :, :, current_t] - - # Enhance the entire 3D volume ONCE per frame - self._enhance_volume(current_frame_3d) # performs enhancement SYNCHRONOUSLY - self._enhanced_cache_frame = current_t - elif initializing: - self._enhanced_cache = self._image_data.pixel_data[:, :, :, current_t] # Cache the initial frame without enhancement for faster startup - - # Extract the 2D slice from cached enhanced volume - slice_idx = list(idx[:3]) # Remove time dimension - arr = self._enhanced_cache[tuple(slice_idx)] - - if arr.ndim != 2: - arr = arr.squeeze() - # Axial plane (index 0) needs transpose for correct orientation - if plane_ix == 0: - arr = arr.T - return arr + # Include the fixed-dimension index in the cache key + cx, cy, cz = self._crosshair_xyzt[0], self._crosshair_xyzt[1], self._crosshair_xyzt[2] + fixed_indices = [cz, cx, cy] # axial fixed by z, sagittal by x, coronal by y + fixed_idx = fixed_indices[plane_ix] + key = ('ceus', current_t, plane_ix, fixed_idx) + + if key not in self._slice_cache: + idx = self._get_plane_indices(plane_ix) + raw = np.asarray(self._pix_data[:, :, :, current_t]) + arr = raw[tuple(list(idx[:3]))].astype(np.float32) + if arr.ndim != 2: + arr = arr.squeeze() + if plane_ix == 0: + arr = arr.T + if not initializing: + arr = self._enhance_slice_ceus(arr) + self._slice_cache[key] = arr.astype(np.uint8) + return self._slice_cache[key] + + def _get_bmode_plane_slice(self, plane_ix: int): + if self._bmode_pix_data is None: + return None + current_t = min(self._crosshair_xyzt[3], self._bmode_pix_data.shape[3] - 1) + cx, cy, cz = self._crosshair_xyzt[0], self._crosshair_xyzt[1], self._crosshair_xyzt[2] + fixed_indices = [cz, cx, cy] + fixed_idx = fixed_indices[plane_ix] + key = ('bmode', current_t, plane_ix, fixed_idx) + + if key not in self._slice_cache: + idx = self._get_plane_indices(plane_ix) + raw = np.asarray(self._bmode_pix_data[:, :, :, current_t]) + arr = raw[tuple(list(idx[:3]))].astype(np.float32) + if arr.ndim != 2: + arr = arr.squeeze() + if plane_ix == 0: + arr = arr.T + arr = self._enhance_slice_bmode(arr) + self._slice_cache[key] = arr.astype(np.uint8) + return self._slice_cache[key] - def _enhance_volume(self, volume_3d: np.ndarray) -> None: - """Enhance a 3D image volume using predefined enhancement methods in the backend engine.""" - # Create a temporary UltrasoundImage for the current frame - temp_im = UltrasoundImage(self._image_data.scan_path) - temp_im.pixel_data = volume_3d.T[None].T.copy() # Add back time dimension for processing - temp_im.pixdim = self._image_data.pixdim - temp_im.frame_rate = self._image_data.frame_rate - - clahe_preproc_dict = { - 'name': 'enhance_clahe', - 'image_data': temp_im, - 'frame_ix': self._crosshair_xyzt[3], - 'kwargs': { - 'clip_limit': self._clahe_clip_limit, - 'tile_grid_size': (8, 8), - } - } - - gamma_preproc_dict = { - 'name': 'enhance_gamma', - 'image_data': None, # signal to reuse the already CLAHE-enhanced image (all preprocs in the same batch share the same image input) - 'frame_ix': self._crosshair_xyzt[3], - 'kwargs': { - 'gamma': self._gamma, - } - } - - preproc_dicts = [clahe_preproc_dict, gamma_preproc_dict] - self.apply_preprocs_preview.emit(preproc_dicts) # synchronous call to apply the enhancements and update the cache via the connected slot - + def _enhance_slice_ceus(self, arr: np.ndarray) -> np.ndarray: + """Inline enhance_ceus_noise_norm on a single 2D slice.""" + nonzero = arr[arr != 0] + if nonzero.size == 0: + return np.zeros_like(arr) + p_high = np.percentile(nonzero, self._ceus_p_high_percentile) + if p_high <= self._ceus_p_low: + return np.zeros_like(arr) + clipped = np.clip(arr, self._ceus_p_low, p_high) + return ((clipped - self._ceus_p_low) / (p_high - self._ceus_p_low) * 255) + + def _enhance_slice_bmode(self, arr: np.ndarray) -> np.ndarray: + """Inline enhance_bmode_noise on a single 2D slice.""" + nonzero = arr[arr != 0] + if nonzero.size == 0: + return np.zeros_like(arr) + p_low = np.percentile(nonzero, self._p_low_percentile) + p_high = np.percentile(nonzero, self._p_high_percentile) + if p_high <= p_low: + return np.zeros_like(arr) + clipped = np.clip(arr, p_low, p_high) + return ((clipped - p_low) / (p_high - p_low) * 255) + def _get_mask_slice(self, plane_ix: int): """Return RGBA numpy slice for the mask of the given plane index.""" idx = self._get_plane_indices(plane_ix)[:-1] # no time dimension @@ -698,6 +819,12 @@ def _update(_frame): try: slice_arr = self._get_plane_slice(plane_ix) self._ax_sag_cor_plane_artists[plane_ix].set_array(slice_arr) + # Update B-mode overlay if present + bmode_artist = self._ax_sag_cor_bmode_artists[plane_ix] + if bmode_artist is not None: + bmode_slice = self._get_bmode_plane_slice(plane_ix) + if bmode_slice is not None: + bmode_artist.set_array(bmode_slice) self._update_crosshair_lines(plane_ix) except Exception as e: self.show_error(f"Plane {plane_ix} update error: {e}") @@ -707,12 +834,14 @@ def _update(_frame): self._update_roi_plot(plane_ix) self._update_point_scatter(plane_ix) self._update_seg_masks(plane_ix) - + v_line, h_line = self._ax_sag_cor_crosshair_lines[plane_ix] roi_plot = self._ax_sag_cor_roi_plots[plane_ix] scatter = self._ax_sag_cor_point_scatters[plane_ix] mask = self._ax_sag_cor_seg_masks[plane_ix] artists = [self._ax_sag_cor_plane_artists[plane_ix]] + bmode_artist = self._ax_sag_cor_bmode_artists[plane_ix] + if bmode_artist is not None: artists.append(bmode_artist) if v_line: artists.append(v_line) if h_line: artists.append(h_line) if roi_plot: artists.append(roi_plot[0]) @@ -1039,6 +1168,167 @@ def _on_export_voi_clicked(self): self._save_worker.finished.connect(self._on_save_voi_finished) self._save_worker.error_msg.connect(self._on_save_voi_error) self._save_worker.start() + + def _export_video(self, progress_signal=None) -> None: + """Export postprocessed CEUS and B-mode with VOI border overlay as a 2x3 MP4.""" + import cv2 + + out_folder = self._ui.save_folder_input.text() + out_name = Path(self._ui.save_name_input.text() or self._image_data.scan_name or "export").stem + if not Path(out_folder).is_dir(): + raise ValueError("Please select a valid output folder first.") + + out_path = str(Path(out_folder) / f"{out_name}_ceus.mp4") + + cx, cy, cz = self._crosshair_xyzt[0], self._crosshair_xyzt[1], self._crosshair_xyzt[2] + + # --- Helpers --- + + def _enhance_ceus_frame(raw_3d: np.ndarray) -> np.ndarray: + """Enhance a raw CEUS 3D frame, returns H x W x Z uint8.""" + temp_im = UltrasoundImage(self._image_data.scan_path) + temp_im.pixel_data = raw_3d.T[None].T.copy() + temp_im.pixdim = self._image_data.pixdim + temp_im.frame_rate = self._image_data.frame_rate + + # Use controller to enhance via signals + self._temp_enhanced = None + self.enhance_image_requested.emit(temp_im, [ + {'name': 'enhance_ceus_noise_norm', 'kwargs': { + 'p_low': self._ceus_p_low, + 'p_high_percentile': self._ceus_p_high_percentile + }} + ], lambda img: setattr(self, '_temp_enhanced', img)) + + # Wait for callback (this is synchronous because the action handler calls callback immediately) + if self._temp_enhanced: + return self._temp_enhanced.pixel_data[:, :, :, 0] + return raw_3d + + def _enhance_bmode_frame_3d(raw_3d: np.ndarray) -> np.ndarray: + """Enhance a raw B-mode 3D frame, returns H x W x Z uint8.""" + temp_im = UltrasoundImage(self._image_data.scan_path) + temp_im.pixel_data = raw_3d.T[None].T.copy() + temp_im.pixdim = self._image_data.pixdim + temp_im.frame_rate = self._image_data.frame_rate + + self._temp_enhanced = None + self.enhance_image_requested.emit(temp_im, [ + {'name': 'enhance_bmode_noise', 'kwargs': { + 'p_low_percentile': self._p_low_percentile, + 'p_high_percentile': self._p_high_percentile, + }} + ], lambda img: setattr(self, '_temp_enhanced', img)) + + if self._temp_enhanced: + return self._temp_enhanced.pixel_data[:, :, :, 0] + return raw_3d + + def _voi_border_2d(mask_2d: np.ndarray) -> np.ndarray: + binary = (mask_2d > 0).astype(np.uint8) * 255 + if not binary.any(): + return np.zeros_like(binary) + edges = cv2.Canny(binary, 100, 200) + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2)) + return cv2.dilate(edges, kernel, iterations=1) + + def _apply_border(gray_2d: np.ndarray, border_2d: np.ndarray) -> np.ndarray: + bgr = cv2.cvtColor(gray_2d, cv2.COLOR_GRAY2BGR) + bgr[border_2d > 0] = [0, 0, 255] + return bgr + + def _resize_to_height(img_bgr: np.ndarray, target_h: int) -> np.ndarray: + h, w = img_bgr.shape[:2] + if h == 0 or w == 0: + return np.zeros((target_h, target_h, 3), dtype=np.uint8) + scale = target_h / h + new_w = max(1, int(w * scale)) + return cv2.resize(img_bgr, (new_w, target_h), interpolation=cv2.INTER_LINEAR) + + def _build_row(enhanced_3d: np.ndarray, target_h: int) -> np.ndarray: + """Build a 1x3 BGR row (axial | sagittal | coronal) from an enhanced 3D volume.""" + # Axial: slice at z=cz, transpose to match widget orientation + ax_gray = enhanced_3d[:, :, cz].T + ax_mask = self._roi_masks_overlap[:, :, cz, 0].T + # Sagittal: slice at x=cx → (y_len, z_len) + sag_gray = enhanced_3d[cx, :, :] + sag_mask = self._roi_masks_overlap[cx, :, :, 0] + # Coronal: slice at y=cy → (x_len, z_len) + cor_gray = enhanced_3d[:, cy, :] + cor_mask = self._roi_masks_overlap[:, cy, :, 0] + + panels = [] + for gray, mask in [(ax_gray, ax_mask), (sag_gray, sag_mask), (cor_gray, cor_mask)]: + border = _voi_border_2d(mask) + bgr = _apply_border(gray, border) + panels.append(_resize_to_height(bgr, target_h)) + + return np.concatenate(panels, axis=1) + + def _add_label(row: np.ndarray, text: str) -> np.ndarray: + """Add a text label in the top-left corner of a row.""" + import cv2 + labeled = row.copy() + cv2.putText(labeled, text, (10, 24), + cv2.FONT_HERSHEY_SIMPLEX, 0.3, (255, 255, 255), 2, cv2.LINE_AA) + return labeled + + # --- Determine composite dimensions from first frame --- + + sample_ceus_raw = np.array(self._pix_data[:, :, :, 0]) + sample_ceus_enh = _enhance_ceus_frame(sample_ceus_raw) + target_h = sample_ceus_enh[:, :, cz].T.shape[0] + + has_bmode = self._bmode_pix_data is not None + if has_bmode: + sample_bmode_raw = np.array(self._bmode_pix_data[:, :, :, 0]) + sample_bmode_enh = _enhance_bmode_frame_3d(sample_bmode_raw) + bmode_target_h = sample_bmode_enh[:, :, cz].T.shape[0] + + ceus_row_sample = _build_row(sample_ceus_enh, target_h) + comp_w = ceus_row_sample.shape[1] + + if has_bmode: + bmode_row_sample = _build_row(sample_bmode_enh, bmode_target_h) + # Match bmode row width to ceus row width + if bmode_row_sample.shape[1] != comp_w: + bmode_row_sample = cv2.resize(bmode_row_sample, (comp_w, bmode_target_h)) + comp_h = ceus_row_sample.shape[0] + bmode_row_sample.shape[0] + else: + comp_h = ceus_row_sample.shape[0] + + fps = max(1, int(round(1.0 / self._image_data.frame_rate))) if self._image_data.frame_rate else 10 + fourcc = cv2.VideoWriter_fourcc(*'mp4v') + writer = cv2.VideoWriter(out_path, fourcc, fps, (comp_w, comp_h)) + + # --- Write frames --- + try: + for t in range(self._num_slices): + # CEUS row + ceus_raw = np.array(self._pix_data[:, :, :, t]) + ceus_enh = _enhance_ceus_frame(ceus_raw) + ceus_row = _add_label(_build_row(ceus_enh, target_h), "CEUS") + + if has_bmode: + # B-mode: clamp t to available frames + bmode_t = min(t, self._bmode_pix_data.shape[3] - 1) + bmode_raw = np.array(self._bmode_pix_data[:, :, :, bmode_t]) + bmode_enh = _enhance_bmode_frame_3d(bmode_raw) + bmode_row = _build_row(bmode_enh, bmode_target_h) + # Match width before stacking + if bmode_row.shape[1] != comp_w: + bmode_row = cv2.resize(bmode_row, (comp_w, bmode_target_h)) + bmode_row = _add_label(bmode_row, "B-mode") + composite = np.concatenate([ceus_row, bmode_row], axis=0) + else: + composite = ceus_row + + writer.write(composite) + + if progress_signal: + progress_signal.emit(int((t + 1) / self._num_slices * 100)) + finally: + writer.release() def _on_save_voi_finished(self, msg): self._ui.saving_voi_label.hide() diff --git a/src/qus/seg_loading/ui/roi_drawing.ui b/src/qus/seg_loading/ui/roi_drawing.ui index 4d73186..fb3989b 100644 --- a/src/qus/seg_loading/ui/roi_drawing.ui +++ b/src/qus/seg_loading/ui/roi_drawing.ui @@ -528,64 +528,47 @@ background: rgb(90, 37, 255); border-radius: 15px; } - - - 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 { + + + Back + + + + + + + + + + + + 10 + + + 30 + + + 10 + + + 30 + + + 10 + + + + + 5 + + + + + + + + + QLabel { font-size: 18px; color: rgb(255, 255, 255); background-color: rgba(255, 255, 255, 0); @@ -872,337 +855,50 @@ color: rgb(255, 255, 255); background-color: rgba(255, 255, 255, 0); } - - - 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 { + + + 0 + + + Qt::TextFormat::AutoText + + + false + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter + + + true + + + + + + + + + + + + + + + + + + 241 + 41 + + + + + 241 + 41 + + + + QPushButton { color: white; font-size: 16px; background: rgb(90, 37, 255);