HoDA_Radio/app/ui/windows/dicom_window.py
2025-09-26 17:51:43 +02:00

507 lines
20 KiB
Python

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