Source code for scopexr.gui

# SCOPE-XR (Single-image Characterization Of PErformance in X-Ray systems)
# Copyright (C) 2026  Jacopo Altieri
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

"""GUI module for SCOPE-XR.

Provides a PyQt6-based graphical user interface for configuring and running
focal spot and PSF analysis on X-ray images. The interface allows users to
load images, adjust analysis parameters, and execute SCOPE-XR algorithms
with real-time output feedback.
"""

import os
import sys
import subprocess
import tempfile
from pathlib import Path
import platform
import yaml
from importlib import resources
from PyQt6.QtWidgets import (
    QApplication,
    QMainWindow,
    QWidget,
    QVBoxLayout,
    QHBoxLayout,
    QPushButton,
    QFileDialog,
    QTextEdit,
    QLabel,
    QSplitter,
    QTabWidget,
    QFormLayout,
    QSpinBox,
    QDoubleSpinBox,
    QCheckBox,
    QComboBox,
    QLineEdit,
    QScrollArea,
    QGroupBox,
    QRadioButton,
    QMessageBox,
)
import numpy as np
import pydicom
from PyQt6.QtCore import QThread, pyqtSignal, Qt
from PyQt6.QtGui import QPixmap, QIcon, QResizeEvent, QImage


[docs] def load_config(filename: str) -> dict: """Load a YAML configuration file and return its contents as a dictionary. Parameters ---------- filename Path to the YAML configuration file Returns ------- dict Configuration parameters loaded from the file, or an empty dict if the file cannot be loaded """ try: with open(filename, "r") as f: return yaml.safe_load(f) except FileNotFoundError: print(f"Warning: Config file '{filename}' not found. Using hardcoded defaults.") return {} except Exception as e: print(f"Warning: Error loading '{filename}': {e}. Using hardcoded defaults.") return {}
[docs] class RunThread(QThread): """Background thread for running SCOPE-XR analysis commands. This thread executes subprocess commands asynchronously to prevent the GUI from freezing during long-running analyses. Emits output signals that can be connected to GUI elements for real-time feedback. """ # Signal emitting subprocess output text. output = pyqtSignal(str) def __init__(self, cmd_list: list) -> None: """Initialize the run thread. Parameters ---------- cmd_list Command line arguments to execute as a subprocess """ super().__init__() self.cmd_list = cmd_list
[docs] def run(self) -> None: """Execute the command in a subprocess and emit output. Runs the configured command list, captures stdout/stderr, and emits output via the output signal. Handles platform-specific encoding (cp1252 for Windows, utf-8 for others) and provides detailed error messages on failure. """ try: is_windows = platform.system() == "Windows" self.output.emit(f"Running command: {' '.join(self.cmd_list)}\n") process = subprocess.run( self.cmd_list, capture_output=True, text=True, check=True, encoding="cp1252" if is_windows else "utf-8", shell=False, ) self.output.emit(process.stdout) except subprocess.CalledProcessError as e: error_message = f"--- ERROR ---\n{e.stderr}\n--- STDOUT ---\n{e.stdout}" self.output.emit(error_message) except FileNotFoundError: self.output.emit( f"Error: Script not found. Make sure {self.cmd_list[1]} is in the same directory." ) except Exception as e: self.output.emit(f"An unexpected error occurred: {str(e)}")
[docs] class PathSelector(QWidget): """Custom widget for selecting file or directory paths. Combines a text field (QLineEdit) with a "Browse" button to allow users to manually enter or interactively select file/directory paths. Attributes ---------- is_directory: bool If True, opens directory dialog; if False, file dialog line_edit: QLineEdit Text field displaying the selected path """ def __init__(self, is_directory: bool = False) -> None: """Initialize the path selector widget. Parameters ---------- is_directory Whether to select directories (True) or files (False), by default False """ super().__init__() self.is_directory = is_directory layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) self.line_edit = QLineEdit() layout.addWidget(self.line_edit) browse_btn = QPushButton("Browse...") browse_btn.clicked.connect(self.browse) layout.addWidget(browse_btn)
[docs] def browse(self) -> None: """Open file or directory browser dialog and update the text field. Opens either a directory selection dialog or a file selection dialog based on the is_directory attribute. Updates the line_edit widget with the selected path. """ if self.is_directory: path = QFileDialog.getExistingDirectory(self, "Select Directory") else: path, _ = QFileDialog.getOpenFileName( self, "Select File", "", "All Files (*)" ) if path: self.line_edit.setText(path)
[docs] def text(self) -> str: """Get the current path text. Returns ------- str Current text in the path input field """ return self.line_edit.text()
[docs] def setText(self, text: str) -> None: """Set the path text programmatically. Parameters ---------- text Path string to display in the input field """ self.line_edit.setText(text)
[docs] def create_radio_group( title: str, options: dict, default_key: str = "default" ) -> tuple: """Create a radio button group within a QGroupBox. Parameters ---------- title Title for the group box options Dictionary mapping option keys to display labels default_key Key of the option to be checked by default, by default 'default' Returns ------- tuple (QGroupBox, dict) where the dict maps option keys to QRadioButton objects """ group_box = QGroupBox(title) group_layout = QVBoxLayout() buttons = {} for key, text in options.items(): radio = QRadioButton(text) if key == default_key: radio.setChecked(True) group_layout.addWidget(radio) buttons[key] = radio group_box.setLayout(group_layout) return group_box, buttons
[docs] class ScopeXRApp(QMainWindow): """Main application window for the SCOPE-XR GUI. Provides a two-pane interface with: - Left pane: Image preview and loading controls - Right pane: Tabbed parameter configuration (Focal Spot and PSF), control buttons, and output console The GUI allows users to configure analysis parameters either through widgets or by loading YAML configuration files, then executes the analysis in a background thread. Attributes ---------- image_path : str or None Path to the currently loaded image file run_thread : RunThread or None Background thread for running analysis fs_config_data : dict Loaded focal spot configuration psf_config_data : dict Loaded PSF configuration """ def __init__(self) -> None: """Initialize the main application window and all GUI components.""" super().__init__() self.setWindowTitle("SCOPE-XR GUI") self.set_app_icon("scopexr_logo.png") self.resize(1100, 800) self.image_path = None self._temp_config_paths: list[str] = [] try: self.base_dir = Path(__file__).parent except NameError: self.base_dir = Path.cwd() fs_config_path = "./fs_args.yaml" psf_config_path = "./psf_args.yaml" self.fs_config_data = load_config(fs_config_path) self.psf_config_data = load_config(psf_config_path) splitter = QSplitter(Qt.Orientation.Horizontal) self.setCentralWidget(splitter) left_pane = QWidget() left_layout = QVBoxLayout(left_pane) self.load_image_btn = QPushButton("Load Image...") left_layout.addWidget(self.load_image_btn) self.image_display_label = QLabel("Load an image to see a preview") self.image_display_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.image_display_label.setMinimumSize(400, 400) self.image_display_label.setStyleSheet( "border: 1px dashed #aaa; background-color: #f0f0f0;" ) left_layout.addWidget(self.image_display_label, stretch=1) splitter.addWidget(left_pane) right_pane = QWidget() right_layout = QVBoxLayout(right_pane) self.tab_widget = QTabWidget() self.fs_tab = self.create_fs_tab(self.fs_config_data) self.psf_tab = self.create_psf_tab(self.psf_config_data) self.advanced_tab = self.create_advanced_tab( self.fs_config_data, self.psf_config_data ) self.tab_widget.addTab(self.fs_tab, "Focal Spot (FS)") self.tab_widget.addTab(self.psf_tab, "PSF") self.tab_widget.addTab(self.advanced_tab, "Advanced") right_layout.addWidget(self.tab_widget) button_layout = QHBoxLayout() self.edit_config_btn = QPushButton("Edit Default Config File") self.run_btn = QPushButton("Run Analysis") self.run_btn.setStyleSheet( "background-color: #4CAF50; color: white; font-weight: bold;" ) button_layout.addWidget(self.edit_config_btn) button_layout.addWidget(self.run_btn) right_layout.addLayout(button_layout) right_layout.addWidget(QLabel("Output:")) self.output_console = QTextEdit() self.output_console.setReadOnly(True) self.output_console.setPlaceholderText("Script output will appear here...") right_layout.addWidget(self.output_console, stretch=1) splitter.addWidget(right_pane) splitter.setSizes([500, 600]) self.load_image_btn.clicked.connect(self.open_image_file) self.edit_config_btn.clicked.connect(self.edit_config) self.run_btn.clicked.connect(self.run_script) self.run_thread = None
[docs] def set_app_icon(self, filename: str) -> None: """Set the application window icon from package resources. Parameters ---------- filename Name of the icon file within the scopexr package Notes ----- Fails silently if the icon file is not found. """ try: icon_resource = resources.files("scopexr").joinpath(filename) with resources.as_file(icon_resource) as path: self.setWindowIcon(QIcon(str(path))) except Exception: pass # Fail silently if icon is missing
def _create_scrollable_tab(self) -> tuple: """Create a scrollable tab widget with form layout. Returns ------- tuple (QWidget, QFormLayout) - The tab widget and its internal form layout """ tab_widget = QWidget() tab_layout = QVBoxLayout(tab_widget) scroll_area = QScrollArea() scroll_area.setWidgetResizable(True) scroll_content = QWidget() form_layout = QFormLayout(scroll_content) scroll_area.setWidget(scroll_content) tab_layout.addWidget(scroll_area) return tab_widget, form_layout
[docs] def update_fs_gui(self) -> None: """Update all focal spot GUI widgets from a loaded configuration file. Reads the file path from the FS config selector, loads the YAML file, and updates all focal spot parameter widgets with the loaded values. Shows warning dialogs if the file is not found or cannot be loaded. """ path = self.fs_config.text() if not path or not Path(path).exists(): QMessageBox.warning( self, "File Not Found", f"Could not find config file: {path}" ) return new_data = load_config(path) if not new_data: QMessageBox.warning(self, "Error", "Failed to load or empty config file.") return def set_spin(widget, key, dtype=float): if key in new_data: val = new_data[key] if val is not None: widget.setValue(dtype(val)) if "out_dir" in new_data: self.fs_output_dir.setText(new_data["out_dir"]) set_spin(self.fs_pixel_size, "pixel_size", float) set_spin(self.fs_diameter, "circle_diameter", float) if "no_hough" in new_data: self.fs_no_hough.setChecked(bool(new_data["no_hough"])) set_spin(self.fs_magnification, "m", float) min_n = new_data.get("min_n", new_data.get("n")) if min_n is not None: self.fs_min_pixels.setValue(int(min_n)) set_spin(self.fs_nangles, "n_angles", int) set_spin(self.fs_half_length, "profile_half_length", int) set_spin(self.fs_derivative_step, "derivative_step", int) set_spin(self.fs_axis_shifts, "axis_shifts", int) if "filter_name" in new_data: self.fs_filter.setCurrentText(str(new_data["filter_name"])) if "symmetrize" in new_data: self.fs_sym.setChecked(bool(new_data["symmetrize"])) manual_val = new_data.get("manual_shift") if manual_val is not None: self.fs_radio_manual.setChecked(True) self.fs_manual_shift_val.setValue(int(manual_val)) elif new_data.get("no_shift", False): self.fs_radio_no.setChecked(True) elif new_data.get("auto_shift", False): self.fs_radio_auto.setChecked(True) else: # auto_shift is default if nothing else is set self.fs_radio_auto.setChecked(True) if "show_plots" in new_data: self.fs_show.setChecked(bool(new_data["show_plots"])) hough_params = new_data.get("hough_params", {}) if hough_params: if "dp" in hough_params: self.fs_hough_dp.setValue(float(hough_params["dp"])) if "min_dist" in hough_params: self.fs_hough_min_dist.setValue(int(hough_params["min_dist"])) if "param1" in hough_params: self.fs_hough_param1.setValue(int(hough_params["param1"])) if "param2" in hough_params: self.fs_hough_param2.setValue(int(hough_params["param2"])) if "min_radius" in hough_params: self.fs_hough_min_radius.setValue(int(hough_params["min_radius"])) if "max_radius" in hough_params: self.fs_hough_max_radius.setValue(int(hough_params["max_radius"])) if "debug" in hough_params: self.fs_hough_debug.setChecked(bool(hough_params["debug"])) print(f"FS GUI updated from {path}")
[docs] def create_fs_tab(self, config_data: dict) -> QWidget: """Create the Focal Spot analysis configuration tab. Parameters ---------- config_data Initial configuration values for focal spot parameters Returns ------- QWidget The constructed focal spot tab widget with all parameter controls """ tab_widget, layout = self._create_scrollable_tab() config_layout = QHBoxLayout() self.fs_config = PathSelector(is_directory=False) load_btn = QPushButton("Load/Update GUI") load_btn.clicked.connect(self.update_fs_gui) config_layout.addWidget(self.fs_config) config_layout.addWidget(load_btn) layout.addRow("Config File [--config]:", config_layout) self.fs_output_dir = PathSelector(is_directory=True) self.fs_output_dir.setText(config_data.get("out_dir", "")) layout.addRow("Output Dir [--o]:", self.fs_output_dir) self.fs_pixel_size = QDoubleSpinBox() self.fs_pixel_size.setDecimals(10) self.fs_pixel_size.setValue(config_data.get("pixel_size", 0.1)) layout.addRow("Pixel Size (mm) [--p]:", self.fs_pixel_size) self.fs_diameter = QDoubleSpinBox() self.fs_diameter.setDecimals(2) self.fs_diameter.setRange(0.01, 1000.0) self.fs_diameter.setValue(config_data.get("circle_diameter", 1.0)) layout.addRow("Object Diameter (mm) [--d]:", self.fs_diameter) self.fs_no_hough = QCheckBox("Skip Hough Transform") self.fs_no_hough.setChecked(config_data.get("no_hough", False)) layout.addRow("[--no_hough]:", self.fs_no_hough) self.fs_magnification = QDoubleSpinBox() self.fs_magnification.setDecimals(3) self.fs_magnification.setValue(config_data.get("m", 0.0)) self.fs_magnification.setToolTip( "Set to 0.0 to let program estimate automatically." ) layout.addRow("Magnification [--m]:", self.fs_magnification) self.fs_min_pixels = QSpinBox() self.fs_min_pixels.setRange(0, 500) min_n = config_data.get("min_n", config_data.get("n", 10)) self.fs_min_pixels.setValue(min_n) layout.addRow("Min. Pixels [--n]:", self.fs_min_pixels) self.fs_nangles = QSpinBox() self.fs_nangles.setRange(90, 1080) self.fs_nangles.setValue(config_data.get("n_angles", 360)) layout.addRow("Num. Angles [--nangles]:", self.fs_nangles) self.fs_half_length = QSpinBox() self.fs_half_length.setRange(1, 10_000) self.fs_half_length.setValue(config_data.get("profile_half_length", 100)) layout.addRow("Profile Half-Length [--hl]:", self.fs_half_length) self.fs_derivative_step = QSpinBox() self.fs_derivative_step.setRange(1, 10) self.fs_derivative_step.setValue(config_data.get("derivative_step", 1)) layout.addRow("Derivative Step [--ds]:", self.fs_derivative_step) self.fs_axis_shifts = QSpinBox() self.fs_axis_shifts.setRange(0, 50) self.fs_axis_shifts.setValue(config_data.get("axis_shifts", 10)) layout.addRow("Shifts Search Range [--axis_shifts]:", self.fs_axis_shifts) self.fs_filter = QComboBox() self.fs_filter.addItems( ["ramp", "shepp-logan", "cosine", "hamming", "hann", "None"] ) self.fs_filter.setCurrentText(str(config_data.get("filter_name", "ramp"))) layout.addRow("Filter [--filter]:", self.fs_filter) self.fs_sym = QCheckBox("Symmetrize Sinogram") self.fs_sym.setChecked(config_data.get("symmetrize", False)) layout.addRow("[--sym]:", self.fs_sym) self.fs_show = QCheckBox("Show Matplotlib plots") self.fs_show.setChecked(config_data.get("show_plots", True)) layout.addRow("[--show]:", self.fs_show) fs_shift_box = QGroupBox("Sinogram Shifting") fs_shift_layout = QVBoxLayout() self.fs_radio_auto = QRadioButton("Auto Shift (--auto_shift)") self.fs_radio_no = QRadioButton("No Shift (--no_shift)") self.fs_radio_manual = QRadioButton("Manual Shift (--manual_shift)") self.fs_manual_shift_val = QSpinBox() self.fs_manual_shift_val.setRange(-1000, 1000) self.fs_manual_shift_val.setEnabled(False) self.fs_radio_manual.toggled.connect(self.fs_manual_shift_val.setEnabled) manual_layout = QHBoxLayout() manual_layout.addWidget(self.fs_radio_manual) manual_layout.addWidget(self.fs_manual_shift_val) fs_shift_layout.addWidget(self.fs_radio_auto) fs_shift_layout.addLayout(manual_layout) fs_shift_layout.addWidget(self.fs_radio_no) fs_shift_box.setLayout(fs_shift_layout) manual_val = config_data.get("manual_shift") if manual_val is not None: self.fs_radio_manual.setChecked(True) self.fs_manual_shift_val.setValue(int(manual_val)) elif config_data.get("no_shift", False): self.fs_radio_no.setChecked(True) else: self.fs_radio_auto.setChecked(True) layout.addRow(fs_shift_box) return tab_widget
[docs] def update_psf_gui(self) -> None: """Update all PSF GUI widgets from a loaded configuration file. Reads the file path from the PSF config selector, loads the YAML file, and updates all PSF parameter widgets with the loaded values. Shows warning dialogs if the file is not found or cannot be loaded. """ path = self.psf_config.text() if not path or not Path(path).exists(): QMessageBox.warning( self, "File Not Found", f"Could not find config file: {path}" ) return new_data = load_config(path) if not new_data: QMessageBox.warning(self, "Error", "Failed to load or empty config file.") return def set_spin(widget, key, dtype=float): if key in new_data: val = new_data[key] if val is not None: widget.setValue(dtype(val)) if "out_dir" in new_data: self.psf_output_dir.setText(new_data["out_dir"]) set_spin(self.psf_pixel_size, "pixel_size", float) set_spin(self.psf_diameter, "circle_diameter", float) if "no_hough" in new_data: self.psf_no_hough.setChecked(bool(new_data["no_hough"])) set_spin(self.psf_nangles, "n_angles", int) set_spin(self.psf_half_length, "profile_half_length", int) set_spin(self.psf_derivative_step, "derivative_step", int) set_spin(self.psf_axis_shifts, "axis_shifts", int) if "filter_name" in new_data: self.psf_filter.setCurrentText(str(new_data["filter_name"])) if "symmetrize" in new_data: self.psf_sym.setChecked(bool(new_data["symmetrize"])) set_spin(self.psf_dtheta, "dtheta", float) set_spin(self.psf_resample2, "resample2", float) set_spin(self.psf_gaussian_sigma, "gaussian_sigma", float) manual_val = new_data.get("manual_shift") if manual_val is not None: self.psf_radio_manual.setChecked(True) self.psf_manual_shift_val.setValue(int(manual_val)) elif new_data.get("no_shift", False): self.psf_radio_no.setChecked(True) elif new_data.get("auto_shift", False): self.psf_radio_auto.setChecked(True) else: self.psf_radio_auto.setChecked(True) # Oversample checkbox if "oversample" in new_data: self.psf_oversample_checkbox.setChecked(bool(new_data["oversample"])) self.update_psf_oversample_controls() if "show_plots" in new_data: self.psf_show.setChecked(bool(new_data["show_plots"])) hough_params = new_data.get("hough_params", {}) if hough_params: if "dp" in hough_params: self.psf_hough_dp.setValue(float(hough_params["dp"])) if "min_dist" in hough_params: self.psf_hough_min_dist.setValue(int(hough_params["min_dist"])) if "param1" in hough_params: self.psf_hough_param1.setValue(int(hough_params["param1"])) if "param2" in hough_params: self.psf_hough_param2.setValue(int(hough_params["param2"])) if "min_radius" in hough_params: self.psf_hough_min_radius.setValue(int(hough_params["min_radius"])) if "max_radius" in hough_params: self.psf_hough_max_radius.setValue(int(hough_params["max_radius"])) if "debug" in hough_params: self.psf_hough_debug.setChecked(bool(hough_params["debug"])) print(f"PSF GUI updated from {path}")
[docs] def create_psf_tab(self, config_data: dict) -> QWidget: """Create the PSF analysis configuration tab. Parameters ---------- config_data Initial configuration values for PSF parameters Returns ------- QWidget The constructed PSF tab widget with all parameter controls """ tab_widget, layout = self._create_scrollable_tab() config_layout = QHBoxLayout() self.psf_config = PathSelector(is_directory=False) load_btn = QPushButton("Load/Update GUI") load_btn.clicked.connect(self.update_psf_gui) config_layout.addWidget(self.psf_config) config_layout.addWidget(load_btn) layout.addRow("Config File [--config]:", config_layout) self.psf_output_dir = PathSelector(is_directory=True) self.psf_output_dir.setText(config_data.get("out_dir", "")) layout.addRow("Output Dir [--o]:", self.psf_output_dir) self.psf_pixel_size = QDoubleSpinBox() self.psf_pixel_size.setDecimals(10) self.psf_pixel_size.setValue(config_data.get("pixel_size", 0.1)) layout.addRow("Pixel Size (mm) [--p]:", self.psf_pixel_size) self.psf_diameter = QDoubleSpinBox() self.psf_diameter.setDecimals(2) self.psf_diameter.setRange(0.01, 1000.0) self.psf_diameter.setValue(config_data.get("circle_diameter", 1.0)) layout.addRow("Object Diameter (mm) [--d]:", self.psf_diameter) self.psf_no_hough = QCheckBox("Skip Hough Transform") self.psf_no_hough.setChecked(config_data.get("no_hough", False)) layout.addRow("[--no_hough]:", self.psf_no_hough) self.psf_nangles = QSpinBox() self.psf_nangles.setRange(90, 1080) self.psf_nangles.setValue(config_data.get("n_angles", 360)) layout.addRow("Num. Angles [--nangles]:", self.psf_nangles) self.psf_half_length = QSpinBox() self.psf_half_length.setRange(1, 10_000) self.psf_half_length.setValue(config_data.get("profile_half_length", 100)) layout.addRow("Profile Half-Length [--hl]:", self.psf_half_length) self.psf_derivative_step = QSpinBox() self.psf_derivative_step.setRange(1, 10) self.psf_derivative_step.setValue(config_data.get("derivative_step", 1)) layout.addRow("Derivative Step [--ds]:", self.psf_derivative_step) self.psf_axis_shifts = QSpinBox() self.psf_axis_shifts.setRange(0, 50) self.psf_axis_shifts.setValue(config_data.get("axis_shifts", 10)) layout.addRow("Shifts Search Range [--axis_shifts]:", self.psf_axis_shifts) self.psf_filter = QComboBox() self.psf_filter.addItems( ["ramp", "shepp-logan", "cosine", "hamming", "hann", "None"] ) self.psf_filter.setCurrentText(str(config_data.get("filter_name", "ramp"))) layout.addRow("Filter [--filter]:", self.psf_filter) self.psf_sym = QCheckBox("Symmetrize Sinogram") self.psf_sym.setChecked(config_data.get("symmetrize", False)) layout.addRow("[--sym]:", self.psf_sym) self.psf_dtheta = QDoubleSpinBox() self.psf_dtheta.setDecimals(2) self.psf_dtheta.setValue(config_data.get("dtheta", 10.0)) layout.addRow("Oversample Angle (deg) [--dtheta]:", self.psf_dtheta) self.psf_resample2 = QDoubleSpinBox() self.psf_resample2.setValue(config_data.get("resample2", 2)) layout.addRow("Resample factor [--resample2]:", self.psf_resample2) self.psf_gaussian_sigma = QDoubleSpinBox() self.psf_gaussian_sigma.setDecimals(2) self.psf_gaussian_sigma.setValue(config_data.get("gaussian_sigma", 0.0)) layout.addRow( "Gaussian Sigma (0=no blur) [--gaussian_sigma]:", self.psf_gaussian_sigma ) self.psf_oversample_checkbox = QCheckBox("Enable oversampling (--oversample)") self.psf_oversample_checkbox.setChecked(config_data.get("oversample", True)) self.psf_oversample_checkbox.stateChanged.connect( self.update_psf_oversample_controls ) layout.addRow("Oversampling:", self.psf_oversample_checkbox) self.psf_show = QCheckBox("Show Matplotlib plots") self.psf_show.setChecked(config_data.get("show_plots", True)) layout.addRow("[--show]:", self.psf_show) psf_shift_box = QGroupBox("Sinogram Shifting") psf_shift_layout = QVBoxLayout() self.psf_radio_auto = QRadioButton("Auto Shift (--auto_shift)") self.psf_radio_no = QRadioButton("No Shift (--no_shift)") self.psf_radio_manual = QRadioButton("Manual Shift (--manual_shift)") self.psf_manual_shift_val = QSpinBox() self.psf_manual_shift_val.setRange(-1000, 1000) self.psf_manual_shift_val.setEnabled(False) self.psf_radio_manual.toggled.connect(self.psf_manual_shift_val.setEnabled) manual_layout_psf = QHBoxLayout() manual_layout_psf.addWidget(self.psf_radio_manual) manual_layout_psf.addWidget(self.psf_manual_shift_val) psf_shift_layout.addWidget(self.psf_radio_auto) psf_shift_layout.addLayout(manual_layout_psf) psf_shift_layout.addWidget(self.psf_radio_no) psf_shift_box.setLayout(psf_shift_layout) manual_val_psf = config_data.get("manual_shift") if manual_val_psf is not None: self.psf_radio_manual.setChecked(True) self.psf_manual_shift_val.setValue(int(manual_val_psf)) elif config_data.get("no_shift", False): self.psf_radio_no.setChecked(True) else: self.psf_radio_auto.setChecked(True) layout.addRow(psf_shift_box) self.update_psf_oversample_controls() return tab_widget
[docs] def update_psf_oversample_controls(self) -> None: """Enable/disable PSF oversampling controls based on checkbox state.""" enabled = self.psf_oversample_checkbox.isChecked() self.psf_dtheta.setEnabled(enabled) self.psf_resample2.setEnabled(enabled) self.psf_gaussian_sigma.setEnabled(enabled)
[docs] def create_advanced_tab( self, fs_config_data: dict, psf_config_data: dict ) -> QWidget: """Create the Advanced tab for Hough transform parameters. Creates a scrollable tab containing separate sections for FS and PSF Hough circle detection parameters. These parameters control the circle detection algorithm used to locate the focal spot or pinhole in the image. Parameters ---------- fs_config_data : dict Configuration data for focal spot including hough_params psf_config_data : dict Configuration data for PSF including hough_params Returns ------- QWidget The advanced tab widget containing all Hough parameter controls """ tab_widget, layout = self._create_scrollable_tab() # Warning label at the top warning_label = QLabel( "⚠️ WARNING: These parameters should only be modified if Hough circle detection fails.\n" "The default values work for most cases. Only change these if you are sure what you are doing." ) warning_label.setWordWrap(True) warning_label.setStyleSheet( "background-color: #fff3cd; color: #856404; padding: 15px; " "border: 2px solid #ffc107; border-radius: 5px; font-weight: bold; margin-bottom: 10px;" ) layout.addRow(warning_label) # FS Hough Parameters Section fs_hough_group = QGroupBox("Focal Spot Hough Transform Parameters") fs_hough_layout = QFormLayout() fs_hough = fs_config_data.get("hough_params", {}) self.fs_hough_dp = QDoubleSpinBox() self.fs_hough_dp.setRange(0.1, 10.0) self.fs_hough_dp.setSingleStep(0.1) self.fs_hough_dp.setDecimals(1) self.fs_hough_dp.setValue(fs_hough.get("dp", 1.0)) fs_hough_layout.addRow("Inverse Ratio (dp):", self.fs_hough_dp) self.fs_hough_min_dist = QSpinBox() self.fs_hough_min_dist.setRange(1, 1000) self.fs_hough_min_dist.setValue(fs_hough.get("min_dist", 50)) fs_hough_layout.addRow("Min Distance (px):", self.fs_hough_min_dist) self.fs_hough_param1 = QSpinBox() self.fs_hough_param1.setRange(1, 500) self.fs_hough_param1.setValue(fs_hough.get("param1", 100)) fs_hough_layout.addRow("Canny High Threshold (param1):", self.fs_hough_param1) self.fs_hough_param2 = QSpinBox() self.fs_hough_param2.setRange(1, 500) self.fs_hough_param2.setValue(fs_hough.get("param2", 30)) fs_hough_layout.addRow("Accumulator Threshold (param2):", self.fs_hough_param2) self.fs_hough_min_radius = QSpinBox() self.fs_hough_min_radius.setRange(1, 2000) self.fs_hough_min_radius.setValue(fs_hough.get("min_radius", 100)) fs_hough_layout.addRow("Min Radius (px):", self.fs_hough_min_radius) self.fs_hough_max_radius = QSpinBox() self.fs_hough_max_radius.setRange(0, 2000) self.fs_hough_max_radius.setValue(fs_hough.get("max_radius", 500)) fs_hough_layout.addRow("Max Radius (px):", self.fs_hough_max_radius) self.fs_hough_debug = QCheckBox("Enable Debug Visualization") self.fs_hough_debug.setChecked(fs_hough.get("debug", False)) fs_hough_layout.addRow("Debug Mode:", self.fs_hough_debug) fs_hough_group.setLayout(fs_hough_layout) layout.addRow(fs_hough_group) # PSF Hough Parameters Section psf_hough_group = QGroupBox("PSF Hough Transform Parameters") psf_hough_layout = QFormLayout() psf_hough = psf_config_data.get("hough_params", {}) self.psf_hough_dp = QDoubleSpinBox() self.psf_hough_dp.setRange(0.1, 10.0) self.psf_hough_dp.setSingleStep(0.1) self.psf_hough_dp.setDecimals(1) self.psf_hough_dp.setValue(psf_hough.get("dp", 1.0)) psf_hough_layout.addRow("Inverse Ratio (dp):", self.psf_hough_dp) self.psf_hough_min_dist = QSpinBox() self.psf_hough_min_dist.setRange(1, 1000) self.psf_hough_min_dist.setValue(psf_hough.get("min_dist", 50)) psf_hough_layout.addRow("Min Distance (px):", self.psf_hough_min_dist) self.psf_hough_param1 = QSpinBox() self.psf_hough_param1.setRange(1, 500) self.psf_hough_param1.setValue(psf_hough.get("param1", 100)) psf_hough_layout.addRow("Canny High Threshold (param1):", self.psf_hough_param1) self.psf_hough_param2 = QSpinBox() self.psf_hough_param2.setRange(1, 500) self.psf_hough_param2.setValue(psf_hough.get("param2", 30)) psf_hough_layout.addRow( "Accumulator Threshold (param2):", self.psf_hough_param2 ) self.psf_hough_min_radius = QSpinBox() self.psf_hough_min_radius.setRange(1, 2000) self.psf_hough_min_radius.setValue(psf_hough.get("min_radius", 100)) psf_hough_layout.addRow("Min Radius (px):", self.psf_hough_min_radius) self.psf_hough_max_radius = QSpinBox() self.psf_hough_max_radius.setRange(0, 2000) self.psf_hough_max_radius.setValue(psf_hough.get("max_radius", 500)) psf_hough_layout.addRow("Max Radius (px):", self.psf_hough_max_radius) self.psf_hough_debug = QCheckBox("Enable Debug Visualization") self.psf_hough_debug.setChecked(psf_hough.get("debug", False)) psf_hough_layout.addRow("Debug Mode:", self.psf_hough_debug) psf_hough_group.setLayout(psf_hough_layout) layout.addRow(psf_hough_group) # Add explanation label help_text = QLabel( "These parameters control the Hough Circle Transform algorithm used to detect " "the circular region in the image. Adjust these if circle detection fails.\n\n" "• dp: Inverse ratio of accumulator resolution to image resolution\n" "• min_dist: Minimum distance between detected circle centers\n" "• param1: Higher threshold for Canny edge detector\n" "• param2: Accumulator threshold for circle centers (lower = more false circles)\n" "• min/max_radius: Range of circle radii to search for" ) help_text.setWordWrap(True) help_text.setStyleSheet("color: #666; font-size: 10pt; padding: 10px;") layout.addRow(help_text) return tab_widget
[docs] def open_image_file(self) -> None: """Open file dialog to select and load an image. Allows selection of PNG, TIF, RAW, or DICOM image files. Updates the image display with a preview (except for RAW files) and stores the file path for analysis. """ file_name, _ = QFileDialog.getOpenFileName( self, "Select Image", "", "Image Files (*.png *.tif *.tiff *.raw *.dcm)" ) if file_name: self.image_path = file_name ext = Path(file_name).suffix.lower() if ext == ".raw": self.image_display_label.setText( f"RAW file selected:\n{Path(file_name).name}\n(Preview not available)" ) self.image_display_label.setStyleSheet("") elif ext == ".dcm": pixmap = self._dicom_to_qpixmap(file_name) if pixmap is None: self.image_display_label.setText( f"DICOM file selected:\n{Path(file_name).name}\n(Preview not available)" ) self.image_display_label.setStyleSheet("") else: self.update_image_display(pixmap) self.image_display_label.setStyleSheet("") else: pixmap = QPixmap(self.image_path) self.update_image_display(pixmap) self.image_display_label.setStyleSheet("")
def _dicom_to_qpixmap(self, file_name: str) -> QPixmap | None: """Load a DICOM file and return a preview pixmap, if possible.""" try: dataset = pydicom.dcmread(file_name) data = dataset.pixel_array except Exception: return None if data is None: return None if data.ndim > 2: data = data[0] data = self._normalize_to_uint8(data) height, width = data.shape bytes_per_line = width qimage = QImage( data.tobytes(), width, height, bytes_per_line, QImage.Format.Format_Grayscale8, ) return QPixmap.fromImage(qimage) def _normalize_to_uint8(self, data: np.ndarray) -> np.ndarray: """Normalize a DICOM pixel array to 8-bit for preview.""" data = data.astype(np.float32) min_val = float(np.min(data)) max_val = float(np.max(data)) if max_val <= min_val: return np.zeros_like(data, dtype=np.uint8) scaled = (data - min_val) / (max_val - min_val) return np.clip(scaled * 255.0, 0, 255).astype(np.uint8)
[docs] def update_image_display(self, pixmap: QPixmap) -> None: """Update the image display label with a scaled pixmap. Parameters ---------- pixmap Image to display in the preview area """ scaled_pixmap = pixmap.scaled( self.image_display_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) self.image_display_label.setPixmap(scaled_pixmap)
[docs] def resizeEvent(self, event: QResizeEvent) -> None: """Handle window resize events and update image display accordingly. Parameters ---------- event The resize event """ if self.image_path: ext = Path(self.image_path).suffix.lower() if ext == ".raw": pass elif ext == ".dcm": pixmap = self._dicom_to_qpixmap(self.image_path) if pixmap is not None: self.update_image_display(pixmap) else: self.update_image_display(QPixmap(self.image_path)) super().resizeEvent(event)
[docs] def edit_config(self) -> None: """Open the default configuration file in the system's default editor. Opens either fs_args.yaml or psf_args.yaml depending on the currently active tab. Shows a warning dialog if the file is not found. """ current_tab_index = self.tab_widget.currentIndex() config_filename = "fs_args.yaml" if current_tab_index == 0 else "psf_args.yaml" config_file_path = Path(config_filename).resolve() if not config_file_path.exists(): QMessageBox.warning( self, "Config Not Found", f"The configuration file was not found in this folder:\n\n{config_file_path}\n\n" "Please create it manually or copy the examples.", ) return try: system = platform.system() if system == "Windows": os.startfile(config_file_path) elif system == "Darwin": subprocess.run(["open", config_file_path]) else: subprocess.run(["xdg-open", config_file_path]) except Exception as e: QMessageBox.critical(self, "Error", f"Could not open file:\n{e}")
[docs] def run_script(self) -> None: """Construct and execute the SCOPE-XR analysis command. Collects all parameter values from the active tab (FS or PSF), constructs the appropriate command line arguments, and launches the analysis in a background thread. Updates the GUI to show the analysis is running and disables the run button. """ if not self.image_path: self.output_console.setText("Please select an image file first.") return self.run_btn.setEnabled(False) self.run_btn.setText("Running...") self.output_console.setText( f"Starting analysis on {Path(self.image_path).name}...\n" ) command = [sys.executable, "-m"] current_tab_index = self.tab_widget.currentIndex() # Advanced tab doesn't run analysis if current_tab_index == 2: self.output_console.setText( "Please select the 'Focal Spot (FS)' or 'PSF' tab to run analysis.\nThe 'Advanced' tab is for configuring Hough transform parameters only." ) self.run_btn.setEnabled(True) self.run_btn.setText("Run Analysis") return # Update hough params in original YAML config from Advanced tab if current_tab_index == 0: # --- FOCAL SPOT --- command.append("scopexr.fs_main") # Module name command.extend(["--f", self.image_path]) # Update hough params in original config file (or temp config when none) config_path = self.fs_config.text() config_data = {} if config_path and Path(config_path).exists(): config_data = load_config(config_path) or {} hough_params = { "dp": self.fs_hough_dp.value(), "min_dist": self.fs_hough_min_dist.value(), "param1": self.fs_hough_param1.value(), "param2": self.fs_hough_param2.value(), "min_radius": self.fs_hough_min_radius.value(), "max_radius": self.fs_hough_max_radius.value(), "debug": self.fs_hough_debug.isChecked(), } config_data["hough_params"] = hough_params temp_config_path = self._write_temp_config(config_data, config_path) self._append_hough_params("FS", hough_params) command.extend(["--config", temp_config_path]) if self.fs_output_dir.text(): command.extend(["--o", self.fs_output_dir.text()]) command.extend(["--p", str(self.fs_pixel_size.value())]) command.extend(["--d", str(self.fs_diameter.value())]) if self.fs_no_hough.isChecked(): command.append("--no_hough") if self.fs_magnification.value() > 0.0: command.extend(["--m", str(self.fs_magnification.value())]) command.extend(["--n", str(self.fs_min_pixels.value())]) command.extend(["--nangles", str(self.fs_nangles.value())]) command.extend(["--hl", str(self.fs_half_length.value())]) command.extend(["--ds", str(self.fs_derivative_step.value())]) command.extend(["--axis_shifts", str(self.fs_axis_shifts.value())]) filter_text = self.fs_filter.currentText() if filter_text != "None": command.extend(["--filter", filter_text]) else: command.extend(["--filter", "None"]) if self.fs_sym.isChecked(): command.append("--sym") if self.fs_show.isChecked(): command.append("--show") if self.fs_radio_manual.isChecked(): command.extend( ["--manual_shift", str(self.fs_manual_shift_val.value())] ) elif self.fs_radio_auto.isChecked(): command.append("--auto_shift") elif self.fs_radio_no.isChecked(): command.append("--no_shift") elif current_tab_index == 1: # --- PSF --- command.append("scopexr.psf_main") # Module name command.extend(["--f", self.image_path]) # Update hough params in original config file (or temp config when none) config_path = self.psf_config.text() config_data = {} if config_path and Path(config_path).exists(): config_data = load_config(config_path) or {} hough_params = { "dp": self.psf_hough_dp.value(), "min_dist": self.psf_hough_min_dist.value(), "param1": self.psf_hough_param1.value(), "param2": self.psf_hough_param2.value(), "min_radius": self.psf_hough_min_radius.value(), "max_radius": self.psf_hough_max_radius.value(), "debug": self.psf_hough_debug.isChecked(), } config_data["hough_params"] = hough_params temp_config_path = self._write_temp_config(config_data, config_path) self._append_hough_params("PSF", hough_params) command.extend(["--config", temp_config_path]) if self.psf_output_dir.text(): command.extend(["--o", self.psf_output_dir.text()]) command.extend(["--p", str(self.psf_pixel_size.value())]) command.extend(["--d", str(self.psf_diameter.value())]) if self.psf_no_hough.isChecked(): command.append("--no_hough") command.extend(["--nangles", str(self.psf_nangles.value())]) command.extend(["--hl", str(self.psf_half_length.value())]) command.extend(["--ds", str(self.psf_derivative_step.value())]) command.extend(["--axis_shifts", str(self.psf_axis_shifts.value())]) filter_text = self.psf_filter.currentText() if filter_text != "None": command.extend(["--filter", filter_text]) else: command.extend(["--filter", "None"]) if self.psf_sym.isChecked(): command.append("--sym") command.extend(["--dtheta", str(self.psf_dtheta.value())]) # Oversample if self.psf_oversample_checkbox.isChecked(): command.append("--oversample") command.extend(["--dtheta", str(self.psf_dtheta.value())]) command.extend(["--resample2", str(self.psf_resample2.value())]) command.extend( ["--gaussian_sigma", str(self.psf_gaussian_sigma.value())] ) else: command.append("--no_oversample") if self.psf_radio_manual.isChecked(): command.extend( ["--manual_shift", str(self.psf_manual_shift_val.value())] ) elif self.psf_radio_auto.isChecked(): command.append("--auto_shift") elif self.psf_radio_no.isChecked(): command.append("--no_shift") if self.psf_show.isChecked(): command.append("--show") # Start Thread self.run_thread = RunThread(command) self.run_thread.output.connect(self.append_output) self.run_thread.finished.connect(self.on_run_finished) self.run_thread.start()
[docs] def append_output(self, text: str) -> None: """Append text to the output console. Parameters ---------- text Output text to append to the console """ self.output_console.moveCursor( self.output_console.textCursor().MoveOperation.End ) self.output_console.insertPlainText(text) self.output_console.ensureCursorVisible()
[docs] def on_run_finished(self) -> None: """Handle completion of the analysis thread. Re-enables the run button and updates its text to indicate the analysis has finished. """ self.run_btn.setEnabled(True) self.run_btn.setText("Run Analysis") self.output_console.insertPlainText("\n--- Analysis Finished ---\n") if self._temp_config_paths: for temp_path in self._temp_config_paths: try: os.remove(temp_path) except OSError: pass self._temp_config_paths.clear()
def _write_temp_config(self, config_data: dict, source_path: str | None) -> str: suffix = Path(source_path).suffix if source_path else ".yaml" with tempfile.NamedTemporaryFile( mode="w", suffix=suffix, prefix="scopexr_", delete=False, encoding="utf-8", ) as handle: yaml.safe_dump(config_data, handle, sort_keys=False) temp_path = handle.name self._temp_config_paths.append(temp_path) return temp_path def _append_hough_params(self, label: str, hough_params: dict) -> None: lines = [f"{label} Hough params:"] for key in ( "dp", "min_dist", "param1", "param2", "min_radius", "max_radius", "debug", ): if key in hough_params: lines.append(f" {key}: {hough_params[key]}") self.output_console.insertPlainText("\n".join(lines) + "\n")
[docs] def main() -> None: """Main entry point for the SCOPE-XR GUI application. Creates and displays the main application window. """ app = QApplication(sys.argv) window = ScopeXRApp() window.show() app.exec()
if __name__ == "__main__": main()