from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QComboBox, QLabel, QSlider, QSizePolicy, QStackedWidget, QGridLayout ) from PyQt6.QtGui import QImage, QIcon from PyQt6.QtCore import Qt, QTimer, QSize, pyqtSignal import app.utils.paths as paths import app.utils.image_editor as image_editor from app.core.main_manager import MainManager, NotificationType from app.ui.widgets.subfolder_widget import SubfolderWidget from app.ui.widgets.image_viewer import ImageViewer import os class DicomWindow(QWidget): setup_progress = pyqtSignal(int) def __init__(self, parent=None): super().__init__(parent) self.main_manager = MainManager.get_instance() self.dicom_manager = self.main_manager.get_dicom_manager() self.language_manager = self.main_manager.get_language_manager() self.settings_manager = self.main_manager.get_settings_manager() self.observer_manager = self.main_manager.get_observer_manager() self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_texts) self.observer_manager.subscribe(NotificationType.DICOM, self.on_dicoms_imported) self.current_dicom_count = 0 # Track how many DICOMs we have displayed self.current_image_index = 0 self.timer = QTimer(self) self.timer.timeout.connect(self.go_to_next) self.selected_dicom = None self.is_brush_enabled = False self.init_ui() def init_ui(self): self.main_layout = QVBoxLayout() self.main_layout.setSpacing(5) self.main_layout.setContentsMargins(10, 10, 10, 10) self.setLayout(self.main_layout) # Create stacked widget for switching between states self.stacked_widget = QStackedWidget() self.main_layout.addWidget(self.stacked_widget) self.create_no_dicom_widget() self.create_dicom_viewer_widget() # Start with no dicom message self.stacked_widget.setCurrentIndex(0) def on_dicoms_imported(self): """Called when new DICOMs are imported""" current_dicoms = self.dicom_manager.get_dicoms() # Check if we have new DICOMs to add if len(current_dicoms) > self.current_dicom_count: new_dicoms = current_dicoms[self.current_dicom_count:] self.add_new_dicoms(new_dicoms) self.current_dicom_count = len(current_dicoms) # Select the LAST imported DICOM (not the first) if self.dicom_combobox.count() > 0: self.stacked_widget.setCurrentIndex(1) # Select the last item in the combobox (most recently added) last_index = self.dicom_combobox.count() - 1 self.dicom_combobox.setCurrentIndex(last_index) self.on_dicom_selected(last_index) def add_new_dicoms(self, new_dicoms): """Add only the new DICOMs to the combobox""" if not new_dicoms: return # Filter out invalid DICOMs valid_dicoms = [d for d in new_dicoms if d is not None and not d.is_empty()] if not valid_dicoms: return # Build set of existing DICOM paths (normalized) existing_paths = set() for i in range(self.dicom_combobox.count()): existing_dicom = self.dicom_combobox.itemData(i) if existing_dicom and existing_dicom.get_path(): existing_paths.add(os.path.normpath(existing_dicom.get_path())) # Filter out DICOMs with paths that already exist in combobox unique_dicoms = [] for dicom in valid_dicoms: if dicom.get_path(): normalized_path = os.path.normpath(dicom.get_path()) if normalized_path not in existing_paths: unique_dicoms.append(dicom) existing_paths.add(normalized_path) # Add to set to avoid duplicates within this batch if not unique_dicoms: return # Temporarily disconnect the signal to avoid triggering during batch add try: self.dicom_combobox.currentIndexChanged.disconnect() except: pass # Add each unique DICOM to the combobox for dicom in unique_dicoms: try: icon = dicom.get_icon() if icon is not None: self.dicom_combobox.addItem(icon, dicom.get_name(), dicom) else: self.dicom_combobox.addItem(dicom.get_name(), dicom) except Exception as e: continue # Reconnect the signal self.dicom_combobox.currentIndexChanged.connect(self.on_dicom_selected) def setup_ui(self): """Legacy method - now just calls on_dicoms_imported for compatibility""" self.on_dicoms_imported() def empty_dicom_combobox(self): """Empty the DICOM combobox and reset state""" try: self.dicom_combobox.currentIndexChanged.disconnect() except: pass self.dicom_combobox.clear() self.selected_dicom = None self.current_dicom_count = 0 def create_no_dicom_widget(self): """Create widget shown when no DICOM files are available""" no_dicom_widget = QWidget() no_dicom_layout = QVBoxLayout(no_dicom_widget) no_dicom_layout.setContentsMargins(0, 0, 0, 0) no_dicom_layout.setSpacing(0) self.no_dicom_label = QLabel(self.language_manager.get_text("no_dicom_files")) self.no_dicom_label.setAlignment(Qt.AlignmentFlag.AlignCenter) no_dicom_layout.addWidget(self.no_dicom_label) self.stacked_widget.addWidget(no_dicom_widget) def create_dicom_viewer_widget(self): """Create widget shown when DICOM files are available""" main_widget = QWidget() main_layout = QHBoxLayout(main_widget) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(10) left_layout = QVBoxLayout() left_layout.setContentsMargins(0, 0, 0, 0) left_layout.setSpacing(0) self.dicom_combobox = QComboBox() self.dicom_combobox.setMaximumWidth(200) left_layout.addWidget(self.dicom_combobox) self.subfolder_widget = SubfolderWidget(self) self.subfolder_widget.widgetSelected.connect(self.process_dicom) self.subfolder_widget.setMaximumWidth(200) left_layout.addWidget(self.subfolder_widget) main_layout.addLayout(left_layout) dicom_layout = QVBoxLayout() dicom_layout.setContentsMargins(0, 0, 0, 0) dicom_layout.setSpacing(0) self.controls_layout = QGridLayout() self.first_button = self.create_icon_button(paths.get_asset_svg_path("first"), self.go_to_first) self.controls_layout.addWidget(self.first_button, 0, 0) self.previous_button = self.create_icon_button(paths.get_asset_svg_path("back"), self.go_to_previous) self.controls_layout.addWidget(self.previous_button, 0, 1) self.play_button = self.create_icon_button(paths.get_asset_svg_path("play"), self.start_stop_playback) self.controls_layout.addWidget(self.play_button, 0, 2) self.next_button = self.create_icon_button(paths.get_asset_svg_path("next"), self.go_to_next) self.controls_layout.addWidget(self.next_button, 0, 3) self.last_button = self.create_icon_button(paths.get_asset_svg_path("last"), self.go_to_last) self.controls_layout.addWidget(self.last_button, 0, 4) self.slider = QSlider(Qt.Orientation.Horizontal) self.slider.setMinimum(0) self.slider.setValue(0) self.slider.setTickInterval(1) self.slider.valueChanged.connect(self.slider_changed) self.controls_layout.addWidget(self.slider, 0, 5, 1, 5) self.index_label = QLabel("0 / 0") self.index_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.controls_layout.addWidget(self.index_label, 0, 10) # Add the controls menu to the layout dicom_layout.addLayout(self.controls_layout) # Image viewer self.image_viewer = ImageViewer(self) self.image_viewer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) dicom_layout.addWidget(self.image_viewer, 1) # Bottom filename label self.bottom_filename_label = QLabel() self.bottom_filename_label.setAlignment(Qt.AlignmentFlag.AlignCenter) dicom_layout.addWidget(self.bottom_filename_label, 0) # Bottom controls self.bottom_layout = QHBoxLayout() self.bottom_layout.setContentsMargins(0, 0, 0, 0) self.bottom_layout.setSpacing(5) self.brush_button = QPushButton(self.language_manager.get_text("eraser")) self.brush_button.clicked.connect(self.enable_brush) self.bottom_layout.addWidget(self.brush_button) self.clear_button = QPushButton(self.language_manager.get_text("rebuild")) self.clear_button.clicked.connect(self.clear_all_masks) self.bottom_layout.addWidget(self.clear_button) dicom_layout.addLayout(self.bottom_layout, 0) main_layout.addLayout(dicom_layout) self.stacked_widget.addWidget(main_widget) def create_icon_button(self, icon_path, callback): btn = QPushButton() # Define the QPushButton instance btn.setIconSize(QSize(20, 20)) btn.setIcon(QIcon(icon_path)) btn.setFlat(True) btn.setStyleSheet("QPushButton { background: transparent; border: none; margin: 0 2px; }") btn.clicked.connect(callback) return btn def update_texts(self): self.brush_button.setText(self.language_manager.get_text("quit_eraser") if self.is_brush_enabled else self.language_manager.get_text("eraser")) self.clear_button.setText(self.language_manager.get_text("clear_mask")) self.no_dicom_label.setText(self.language_manager.get_text("no_dicom_files")) def on_dicom_selected(self, index): """Handle DICOM selection from combobox""" if index < 0 or index >= self.dicom_combobox.count(): return dicom = self.dicom_combobox.itemData(index) self.combobox_change(dicom) def combobox_change(self, dicom): """Handle DICOM combobox change event""" # Add null check to prevent AttributeError if dicom is None: self.bottom_filename_label.setText(self.language_manager.get_text("no_images_found")) self.selected_dicom = None self.current_image_index = 0 return if dicom.get_subfolders() or dicom.get_files(): self.selected_dicom = dicom self.current_image_index = 0 # Reset image index when changing DICOM self.set_subfolder_widget() self.process_dicom() else: self.bottom_filename_label.setText(self.language_manager.get_text("no_images_found")) self.selected_dicom = None self.current_image_index = 0 def process_dicom(self): """Process DICOM selection and update UI accordingly""" if self.get_displayed_dicom().get_files(): self.current_image_index = 0 self.update_slider_range() self.load_image() else: self.set_subfolder_widget() def update_slider_range(self): """Update slider range when new DICOM is loaded""" if not self.selected_dicom: self.slider.setMaximum(0) self.slider.setValue(0) self.index_label.setText("0 / 0") return dicom = self.get_displayed_dicom() files = dicom.get_files() if dicom else [] self.slider.setValue(0) self.slider.setMaximum(len(files)-1) def load_image(self): # Verify image viewer is still valid before using it if not hasattr(self, 'image_viewer') or not self.image_viewer: return try: # Test if the widget is still valid by calling a simple method self.image_viewer.parentWidget() except RuntimeError: # Widget has been deleted, skip loading return dicom = self.get_displayed_dicom() if not dicom: return mask = dicom.get_mask() used_list = dicom.get_files() if not used_list or self.current_image_index >= len(used_list): return current_image = used_list[self.current_image_index] # Check if the current image is valid if not hasattr(current_image, 'is_valid') or not current_image.is_valid: self.bottom_filename_label.setText(f"{current_image.get_name()} - Error loading") return try: pixel_array = current_image.get_scaled_image() if pixel_array is None: self.bottom_filename_label.setText(f"{current_image.get_name()} - No image data") return height, width, channel = pixel_array.shape bytes_per_line = channel * width qt_image = QImage(pixel_array.data, width, height, bytes_per_line, QImage.Format.Format_RGB888) # Apply existing mask if it exists for this subfolder if mask is not None: pixel_array_for_mask = self.dicom_manager.image_to_array(qt_image) masked_array = image_editor.apply_mask(pixel_array_for_mask, mask) qt_image = self.dicom_manager.array_to_image(masked_array) self.image_viewer.set_image(qt_image) self.bottom_filename_label.setText(current_image.get_name()) self.slider.setValue(self.current_image_index) self.index_label.setText(f"{self.current_image_index + 1} / {len(used_list)}") except Exception as e: self.bottom_filename_label.setText(self.language_manager.get_text("error_loading_image").replace("{x}", current_image.get_name())) def slider_changed(self, value): self.current_image_index = value self.load_image() def scroll_images(self, delta): new_index = self.current_image_index + delta if 0 <= new_index < len(self.get_displayed_dicom().get_files()): self.current_image_index = new_index self.load_image() def go_to_first(self): if self.get_displayed_dicom(): self.current_image_index = 0 self.load_image() def go_to_previous(self): if self.current_image_index > 0: self.current_image_index -= 1 self.load_image() def go_to_next(self): # Get the correct file list dicom = self.get_displayed_dicom() if dicom: files = dicom.get_files() else: files = [] if self.current_image_index < len(files) - 1: self.current_image_index += 1 self.load_image() elif self.timer.isActive(): self.current_image_index = 0 self.load_image() def go_to_last(self): if self.selected_dicom: dicom = self.get_displayed_dicom() if dicom: files = dicom.get_files() self.current_image_index = len(files) - 1 self.load_image() def start_stop_playback(self): if self.selected_dicom: if self.timer.isActive(): # Update the play button icon in the menu self.play_button.setIcon(QIcon(paths.get_asset_svg_path("play"))) self.timer.stop() else: # Update the play button icon in the menu self.play_button.setIcon(QIcon(paths.get_asset_svg_path("stop"))) if self.current_image_index > len(self.selected_dicom.get_files()) - 1: self.current_image_index = 0 self.slider.setValue(0) self.timer.start(100) def enable_brush(self): self.is_brush_enabled = not self.is_brush_enabled # Bascule l'état self.brush_button.setText(self.language_manager.get_text("quit_eraser") if self.is_brush_enabled else self.language_manager.get_text("eraser")) # Met à jour le texte du bouton # Change cursor when brush is enabled/disabled if self.is_brush_enabled: self.image_viewer.setCursor(Qt.CursorShape.CrossCursor) else: self.image_viewer.setCursor(Qt.CursorShape.ArrowCursor) def apply_brush(self, screen_points): if not self.is_brush_enabled: # Vérifie si le mode "masque" est activé return img = self.image_viewer.original_image if img is None: return img_shape = (img.height(), img.width(), 3) brush_coords = [] for sx, sy in screen_points: res = self.image_viewer.screen_to_image_coords(sx, sy) if res: x, y = res if 0 <= x < img_shape[1] and 0 <= y < img_shape[0]: brush_coords.append((x, y)) # Get the current subfolder path instead of filename dicom = self.get_displayed_dicom() # Get existing mask or create new one existing_mask = dicom.get_mask() # Create new mask from brush coords new_mask = image_editor.get_mask(brush_coords, img_shape) # Combine with existing mask if it exists if existing_mask is not None: combined_mask = image_editor.combine_masks(existing_mask, new_mask) else: combined_mask = new_mask # Update mask for current subfolder dicom.set_mask(combined_mask) # Refresh the display to show the updated mask self.load_image() def apply_brush_mask(self, new_mask): """Applique un mask numpy directement""" if not self.is_brush_enabled: return # Get the current subfolder dicom = self.get_displayed_dicom() # Get existing mask or create new one existing_mask = dicom.get_mask() # Combine with existing mask if it exists if existing_mask is not None: combined_mask = image_editor.combine_masks(existing_mask, new_mask) else: combined_mask = new_mask # Update mask for current subfolder dicom.set_mask(combined_mask) # Refresh the display to show the updated mask self.load_image() def clear_all_masks(self): self.get_displayed_dicom().set_mask(None) self.load_image() def hideEvent(self, event): if self.timer.isActive(): self.start_stop_playback() super().hideEvent(event) def showEvent(self, event): """Called when the widget is shown""" # Only update if we have a mismatch between displayed and actual DICOMs current_dicoms = self.dicom_manager.get_dicoms() if len(current_dicoms) != self.current_dicom_count: self.on_dicoms_imported() super().showEvent(event) def set_subfolder_widget(self): """Update the existing subfolder widget with the current DICOM's subfolders""" if hasattr(self, 'subfolder_widget') and self.subfolder_widget: # Clear existing widgets self.subfolder_widget.clear_widgets() # Always show the subfolder widget, but only add widgets if subfolders exist if self.selected_dicom and self.selected_dicom.get_subfolders(): self.subfolder_widget.create_and_add_widgets(self.selected_dicom.get_subfolders()) def get_displayed_dicom(self): """Return the currently displayed DICOM object""" if self.selected_dicom is None: return None if self.selected_dicom.get_subfolders(): selected_widget = self.subfolder_widget.get_selected_widget() if selected_widget: return selected_widget.dicom return self.selected_dicom