Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion src/View/ImageFusion/ImageFusionProgressWindow.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import platform
import platform, queue
from pathlib import Path

from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QMessageBox
from PySide6 import QtCore, QtGui, QtWidgets
from src.View.ProgressWindow import ProgressWindow
Expand Down Expand Up @@ -70,3 +72,19 @@ def prompt_calc_dvh(self):
self.signal_advise_calc_dvh.emit(True)
else:
self.signal_advise_calc_dvh.emit(False)

def set_progress_queue(self, progress_queue):
self._progress_queue = progress_queue
self._progress_timer = QTimer(self)
self._progress_timer.timeout.connect(self._poll_progress_queue)
self._progress_timer.start(100) # poll every 100 ms

def _poll_progress_queue(self):
if not hasattr(self, "_progress_queue"):
return
try:
while True:
msg = self._progress_queue.get_nowait()
self.update_progress(msg)
except queue.Empty:
pass
105 changes: 75 additions & 30 deletions src/View/ImageFusion/ImageFusionWindow.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import threading
import threading, queue, traceback

from PySide6 import QtGui, QtWidgets
from PySide6.QtCore import QThreadPool, Qt
Expand All @@ -7,6 +7,8 @@
QMessageBox, QHBoxLayout, QVBoxLayout, \
QLabel, QLineEdit, QSizePolicy, QPushButton

from pydicom import dcmread

from src.Model.PatientDictContainer import PatientDictContainer
from src.Model import ImageLoading
from src.Model.DICOM import DICOMDirectorySearch
Expand Down Expand Up @@ -187,6 +189,10 @@ def setup_ui(self, open_image_fusion_select_instance):

self.open_patient_window_instance_vertical_box.addWidget(fusion_mode_container)

# Re-validate selection when fusion mode changes ---
self.manual_radio.toggled.connect(self._on_fusion_mode_changed)
self.auto_radio.toggled.connect(self._on_fusion_mode_changed)

# Create a horizontal box to hold the Cancel and Open button
self.open_patient_window_patient_open_actions_horizontal_box = \
QHBoxLayout()
Expand Down Expand Up @@ -651,28 +657,37 @@ def confirm_button_clicked(self):

if self.manual_radio.isChecked():
# Use the progress window and a manual loader for manual fusion
# loader = ManualFusionLoader(selected_files, self.progress_window)
# loader.signal_loaded.connect(self.on_loaded)
# loader.signal_error.connect(self.on_loading_error)
# # Start loading in a thread (simulate progress window behavior)
# self.progress_window.start(loader.load)
from src.View.ImageFusion.ManualFusionLoader import ManualFusionLoader
pdc = PatientDictContainer()
patient_name = None
patient_id = None
filepaths = pdc.filepaths
if filepaths and isinstance(filepaths, dict):
if image_keys := [k for k in filepaths.keys() if str(k).isdigit()]:
first_key = sorted(image_keys, key=lambda x: int(x))[0]
from pydicom import dcmread
ds_fixed = dcmread(filepaths[first_key], stop_before_pixels=True)
patient_name = getattr(ds_fixed, "PatientName", None)
patient_id = getattr(ds_fixed, "PatientID", None)

loader = ManualFusionLoader(selected_files, self.progress_window, patient_name=patient_name,
patient_id=patient_id)
start_method = lambda: self.progress_window.start(loader.load)

progress_queue = queue.Queue()
self.progress_window.set_progress_queue(progress_queue)

def run_loader():
try:
loader.load(self.progress_window.interrupt_flag, progress_queue.put)
except Exception as e:
stack = traceback.format_exc()
loader.signal_error.emit((False, f"{e}\n{stack}"))

self.progress_window.show()
thread = threading.Thread(target=run_loader, daemon=True)
thread.start()

signal_source = loader
start_method = lambda: None # dummy method


elif self.auto_radio.isChecked():
loader = None # No separate loader needed
Expand All @@ -692,32 +707,42 @@ def confirm_button_clicked(self):

def on_loaded(self, results):
"""
Executes when the progress bar finishes loaded the selected files.
Emits a wrapper object that provides update_progress for compatibility with the main window.
"""
# Handle both manual and auto fusion result types
if isinstance(results, tuple) and results[0] is True:
# Manual fusion: results is (True, images_dict)
images = results[1]
if isinstance(images, dict) and "vtk_engine" in images:
# Manual fusion: add dummy keys for main window compatibility
images["fixed_image"] = None
images["moving_image"] = None
# --- Set manual_fusion in PatientDictContainer ---
loader = ManualFusionLoader([], None) # dummy loader for static call
loader.on_manual_fusion_loaded((True, images))
Executes when the progress bar finishes loaded the selected files.
Emits a wrapper object that provides update_progress for compatibility with the main window.
"""

wrapper = FusionResultWrapper(images, self.progress_window)
self.image_fusion_info_initialized.emit(wrapper)
# Handle both manual and auto fusion result types
if isinstance(results, tuple):
if results[0] is True:
# Manual fusion: results is (True, images_dict)
images = results[1]
if isinstance(images, dict) and "vtk_engine" in images:
# Manual fusion: add dummy keys for main window compatibility
images["fixed_image"] = None
images["moving_image"] = None
# --- Set manual_fusion in PatientDictContainer ---
loader = ManualFusionLoader([], None) # dummy loader for static call
loader.on_manual_fusion_loaded((True, images))

wrapper = FusionResultWrapper(images, self.progress_window)
self.image_fusion_info_initialized.emit(wrapper)
else:
# Tuple but not success: treat as error
error_msg = results[1] if len(results) > 1 else "Unknown error"
QMessageBox.warning(self.progress_window, "Fusion Error", f"Fusion failed: {error_msg}")
elif hasattr(results, "update_progress"):
# Autofusion: results is a ProgressWindow or similar
wrapper = FusionResultWrapper(results, self.progress_window)
self.image_fusion_info_initialized.emit(wrapper)
elif results is True:
images = {}
wrapper = FusionResultWrapper(images, self.progress_window)
self.image_fusion_info_initialized.emit(wrapper)
elif results is False:
QMessageBox.warning(self.progress_window, "Fusion Error", "Auto fusion failed to load images.")
else:
# Unexpected result type
QMessageBox.warning(self, "Fusion Error", "Unexpected result type returned from fusion loader.")


QMessageBox.warning(self.progress_window,
"Fusion Error",
f"Unexpected result type returned from fusion loader: {type(results)}")


def on_loading_error(self, exception):
Expand All @@ -737,6 +762,26 @@ def on_loading_error(self, exception):
" unsupported DICOM classes.")
self.progress_window.close()

def _on_fusion_mode_changed(self):
"""
Called when the fusion mode radio button is toggled.
Re-checks the selected items and disables confirm if requirements are not met.
"""
# Find the currently selected patient (if any)
root = self.open_patient_window_patients_tree.invisibleRootItem()
checked_nodes = self.get_checked_nodes(root)
# If any node is checked, find its top-level parent (the patient)
selected_patient = None
if checked_nodes:
selected_patient = checked_nodes[0]
while selected_patient.parent() is not None:
selected_patient = selected_patient.parent()
# Re-run the selection check
if selected_patient is not None:
self.check_selected_items(selected_patient)
else:
self.open_patient_window_confirm_button.setDisabled(True)


# This is to allow for dropping a directory into the input text.
class UIImageFusionWindowDragAndDropEvent(QLineEdit):
Expand Down
58 changes: 38 additions & 20 deletions src/View/ImageFusion/ManualFusionLoader.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import time

from PySide6 import QtCore, QtWidgets
import logging
import os
import pydicom
import platform
import numpy as np
from pydicom import dcmread
from vtkmodules.util import numpy_support
Expand Down Expand Up @@ -59,21 +62,34 @@ def load(self, interrupt_flag=None, progress_callback=None):
Returns:
None. Emits the result via the signal_loaded or signal_error signals.
"""
# Store interrupt flag

# Wrap the progress_callback so it always runs in the main thread
def main_thread_progress_callback(*args, **kwargs):
if progress_callback is not None:
# If progress_callback happens to be a Qt Signal, use QMetaObject.invokeMethod to ensure main thread execution
if hasattr(progress_callback, "emit"):
QtCore.QMetaObject.invokeMethod(
progress_callback,
"emit",
QtCore.Qt.QueuedConnection,
QtCore.Q_ARG(object, args if len(args) > 1 else args[0])
)
else:
progress_callback(*args, **kwargs)

self._interrupt_flag = interrupt_flag
try:
# Check for interrupt before starting
if self._interrupt_flag is not None and self._interrupt_flag.is_set():
if progress_callback is not None:
progress_callback.emit(("Loading cancelled", 0))
self.signal_error.emit((False, "Loading cancelled"))
main_thread_progress_callback(("Loading cancelled", 0))
if hasattr(progress_callback, "emit"):
self.signal_error.emit((False, "Loading cancelled"))
return
self._load_with_vtk(progress_callback)
self._load_with_vtk(main_thread_progress_callback)
except Exception as e:
if progress_callback is not None:
progress_callback.emit(("Error loading images", e))
logging.exception("Error loading images: %s\n%s", e)
self.signal_error.emit((False, e))
main_thread_progress_callback(("Error loading images", e))
logging.exception("Error loading images: %s\n%s", e)
self.signal_error.emit((False, f"{e}"))

def _load_with_vtk(self, progress_callback):
"""
Expand All @@ -97,7 +113,7 @@ def _load_with_vtk(self, progress_callback):

# Progress: loading fixed image
if progress_callback is not None:
progress_callback.emit(("Loading fixed image (VTK)...", 10))
progress_callback(("Loading fixed image (VTK)...", 10))

# Check for interrupt before loading fixed
if self._interrupt_flag is not None and self._interrupt_flag.is_set():
Expand All @@ -123,7 +139,7 @@ def _load_with_vtk(self, progress_callback):
"Manual fusion requires all files to be from the same directory."
)
if progress_callback is not None:
progress_callback.emit(("Error loading images", error_msg))
progress_callback(("Error loading images", error_msg))
logging.error("manualFusionLoader.py_load_with_vtk: ", error_msg)
self.signal_error.emit((False, error_msg))
return
Expand All @@ -145,9 +161,11 @@ def _load_with_vtk(self, progress_callback):
logging.warning("<manualFusionLoader.py_load_with_vtk>Error reading DICOM file", e)
continue


# Populate moving model container before processing with VTK so origin can be read the same way as ROI Transfer logic
moving_image_loader = MovingImageLoader(selected_image_files, None, self)
moving_model_populated = moving_image_loader.load_manual_mode(self._interrupt_flag, progress_callback)
moving_model_populated = moving_image_loader.load_manual_mode(self._interrupt_flag,
progress_callback)

if not moving_model_populated:
# Check if interrupted, emit cancel signal immediately
Expand All @@ -167,7 +185,7 @@ def _load_with_vtk(self, progress_callback):
raise RuntimeError("Failed to load fixed image with VTK.")

if progress_callback is not None:
progress_callback.emit(("Loading overlay image (VTK)...", 50))
progress_callback(("Loading overlay image (VTK)...", 50))

# Check for interrupt before loading moving
if self._interrupt_flag is not None and self._interrupt_flag.is_set():
Expand All @@ -184,7 +202,7 @@ def _load_with_vtk(self, progress_callback):
if transform_file is not None:
try:
if progress_callback is not None:
progress_callback.emit(("Extracting saved transform...", 80))
progress_callback(("Extracting saved transform...", 80))
ds = pydicom.dcmread(transform_file)

# See explanation at top for more details on private tags
Expand All @@ -195,14 +213,15 @@ def _load_with_vtk(self, progress_callback):
except Exception as e:
logging.error(f"Error extracting transform from {transform_file}: {e}")
if progress_callback is not None:
progress_callback.emit(("Error extracting transform", 80))
progress_callback(("Error extracting transform", 80))
self.signal_error.emit((False, f"Error extracting transform: {e}"))
return

# Do any overlay generation or heavy work here if needed
# Last message before overlay loaded
if progress_callback is not None:
progress_callback.emit(("Preparing overlays...", 90))
progress_callback(("Preparing overlays...", 90))
QtCore.QCoreApplication.processEvents()
time.sleep(0.05)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

while it is only 0.05 seconds. Is this a thread blocking? could a non-thread blocking option be used here

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it doesn't need to be there at all it just goes too quick and would go 50% to done, was just thinking it would be better for the user to see it go to 90% for a very quick time before loading. can be removed if best


# Final interrupt check before emitting loaded signal
if self._interrupt_flag is not None and self._interrupt_flag.is_set():
Expand All @@ -217,7 +236,7 @@ def _load_with_vtk(self, progress_callback):

# Emit 100% progress just before closing/loading is complete
if progress_callback is not None:
progress_callback.emit(("Complete", 100))
progress_callback(("Complete", 100))
QtCore.QCoreApplication.processEvents()

def _extracted_from__load_with_vtk_62(self, ds, np, transform_file):
Expand Down Expand Up @@ -353,5 +372,4 @@ def on_manual_fusion_loaded(self, result):
level = 40
# Set these as the initial fusion window/level
patient_dict_container.set("fusion_window", window)
patient_dict_container.set("fusion_level", level)

patient_dict_container.set("fusion_level", level)
Loading
Loading