generated from LouisMazin/PythonApplicationTemplate
507 lines
20 KiB
Python
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 |