"""Main application window for COSEDA UI."""
from coseda.logging_utils import log_print
import sys
import os
import webbrowser
import h5py
import cv2
import numpy as np
import matplotlib.pyplot as plt
from matplotlib import colors
from PIL import Image, ImageQt, ImageDraw, ImageEnhance
import configparser
from typing import Optional
import math
from functools import partial
from cosedaUI.metadata_window import MetadataWindow
from coseda.io import config_to_paths
from coseda.nexus.paths import get_mask_dataset
from cosedaUI.update import check_for_updates
from cosedaUI.workflow_wizzard import (
check_peakfinding_allowed,
check_findcenters_allowed,
check_refinecenters_allowed,
)
from cosedaUI import (
MapWindow,
open_about_window,
get_version_from_log,
set_style,
load_changelog,
CenterFinderSettingsWindow,
PeakFinderSettingsWindow,
CenterRefinementSettingsWindow,
ImportTiffWindow,
ImportMRCWindow,
ImportEMDWindow
)
from cosedaUI.workspace_functions import (
save_workspace_state,
load_last_directory,
add_ini_files,
add_ini_paths,
remove_ini_files,
save_workspace,
open_workspace,
new_workspace,
load_last_workspace_state)
# Import ImportH5Window
from cosedaUI.importers import ImportH5Window
# Add StripFilesWindow import
from cosedaUI.strip_file_window import StripFilesWindow
from cosedaUI.merging_window import MergingSettingsWindow
from cosedaUI.export import ExportMtzWindow
from cosedaUI.workflow_wizzard import check_index_integrate_allowed
from coseda.peakfinding.findpeaks import process_single_frame
from coseda.initialize import log_start, log_result
from coseda.dask_client_manager import DaskClientManager
from diffractem.peakfinder8_extension import peakfinder_8
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QFileDialog,
QMessageBox,
QLabel,
QMenuBar,
QVBoxLayout,
QHBoxLayout,
QListWidget,
QCheckBox,
QSlider,
QComboBox,
QPushButton,
QLineEdit,
QFrame,
QScrollArea,
QSpinBox,
QDialog,
QInputDialog,
QTextEdit,
QGroupBox,
QGridLayout,
QSizePolicy,
)
# --- AboutDialog definition ---
from cosedaUI.about_window import AboutWindow
from PyQt6.QtCore import Qt, pyqtSignal, QObject, QSize, QRect, QCoreApplication, QTimer, QStandardPaths
from PyQt6.QtCore import QThread, pyqtSignal
from cosedaUI.image_tools import ImageInspector
from cosedaUI.image_tools import export_raw_image, export_display_image
from PyQt6.QtGui import QPixmap, QImage, QIcon, QSurfaceFormat, QAction
from PIL.ImageQt import ImageQt
from cosedaUI.indexing_stream_manager import IndexingStreamManager
from cosedaUI.h5_metadata_loader import H5MetadataLoader
from cosedaUI.mainwindow_sections import (
ClusterMixin,
ImportMixin,
IndexingMixin,
ViewerMixin,
WorkflowMixin,
WorkspaceMixin,
)
try:
from CFstreamparser import parse_stream_file
except ImportError:
parse_stream_file = None
APP_NAME = "COSEDA"
ORGANIZATION_NAME = "Stockholm University"
[docs]
def enable_opengl():
format = QSurfaceFormat()
format.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
format.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
format.setVersion(4, 1) # Adjust the OpenGL version if needed
QSurfaceFormat.setDefaultFormat(format)
[docs]
def user_config_dir():
"""
Returns the path to the user configuration directory.
"""
return QStandardPaths.writableLocation(QStandardPaths.StandardLocation.ConfigLocation)
# --- AspectRatioWidget definition ---
[docs]
class cosedaUI(
ClusterMixin,
WorkflowMixin,
ImportMixin,
IndexingMixin,
ViewerMixin,
WorkspaceMixin,
QMainWindow,
):
"""Primary UI shell: workspace browser, viewers, and workflow launchers."""
ini_selection_changed = pyqtSignal(str) # emits absolute INI path when selection changes in main window
def __init__(self):
super().__init__()
# Load icon from filesystem instead of Qt resource
base_dir = os.path.dirname(__file__)
icon_file = os.path.join(base_dir, "resources", "app_icon_512.png")
self.setWindowIcon(QIcon(icon_file))
# Workspace INI paths mapping: display name -> full path
self.workspace_ini_paths = {}
# Path to saved workspace file
self.workspace_file_path = None
# Initialize variables
self.current_frame_index = 0
self.total_frames = 0
self.hdf5_path = None
self.original_image = None
self.ini_directory = None
self.is_slider_moving = False
self.live_preview_active = False
self.current_peak_settings = {}
self.full_ini_file_path = None
self._peakfinding_active = False
self.settings_window_instance = None
self.centerfindersettings_window_instance = None
self.centerrefinementsettings_window_instance = None
self.aurora_plot_window = None
self.strongest_frame_index = None
self.log_file_path = None
self.x_scale_factor = 1.0
self.y_scale_factor = 1.0
self.window_title = APP_NAME
self.changelog = str(load_changelog('CHANGELOG'))
self.theme = set_style()
self.config = configparser.ConfigParser()
self.config_file_path = os.path.join(user_config_dir(), 'cosedaUI_config.ini')
self._pending_frame_restore = None
self._h5_loader_active = False
self.coordinate_selection_active = False
self.coordinate_selection_field = None
self._indexing_toggle_guard = False
self._index_original_to_new = {}
self._last_peak_mismatch_frame = None
self._h5_width = None
self._h5_height = None
self._strong_peaks_order = []
self._strong_peaks_rank = {}
self._maxres_order = []
self._maxres_rank = {}
self._maxres_order_active = False
self.indexing_manager = IndexingStreamManager(
stream_parser=parse_stream_file,
get_hdf5_path=lambda: self.hdf5_path,
get_total_frames=lambda: self.total_frames,
log_fn=self._indexing_debug,
)
self.init_ui()
# Initialize ImageInspector
self._inspector = ImageInspector(
self.image_label,
lambda: self.original_image,
self
)
# Delegate mouse events to inspector and preserve original handler
self._original_mouse_press = self.image_label.mousePressEvent
self.image_label.mousePressEvent = (
lambda e: self._inspector.on_mouse_press(e) or self._original_mouse_press(e)
)
self.image_label.mouseMoveEvent = lambda e: self._inspector.on_mouse_move(e)
self.image_label.mouseReleaseEvent = lambda e: self._inspector.on_mouse_release(e)
# Connect Inspect Image menu action to inspector
self.inspect_image_action.triggered.connect(lambda checked=False: self._inspector.start_inspect())
# Initialize image display frame as square before loading any image
self.image_aspect_widget.set_aspect_ratio(1.0)
initial_width = self.image_aspect_widget.width()
self.image_aspect_widget.setFixedHeight(initial_width)
# Debounce resize updates to redraw image on window resize
self.resize_timer = QTimer(self)
self.resize_timer.setSingleShot(True)
self.resize_timer.timeout.connect(self.update_image)
self.resizeInterval = 200 # milliseconds debounce interval
# Restore workspace state on startup
self.load_last_workspace_state()
# Auto-load the first INI file in the workspace if one exists
if self.ini_listbox.count() > 0:
self.ini_listbox.setCurrentRow(0)
[docs]
def show_about_window(self):
"""
Opens the About dialog defined in about_window.py.
"""
dlg = AboutWindow(self.theme, self.changelog, parent=self)
dlg.exec()
[docs]
def init_ui(self):
self.setWindowTitle(self.window_title)
#self.setGeometry(100, 100, 1200, 1000)
self.create_menu()
self.create_widgets()
self.create_layout()
self.show()
# Inspect Image button removed; now in View menu
[docs]
def create_layout(self):
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QHBoxLayout(central_widget)
# Left column container for workspace and view options
left_container = QWidget()
# Constrain left column to a maximum width
left_container.setMaximumWidth(260)
left_container_layout = QVBoxLayout(left_container)
left_container_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Left GroupBox (Equivalent to LabelFrame)
left_group = QGroupBox("Workspace")
left_layout = QVBoxLayout()
# Align workspace contents to top
left_layout.setAlignment(Qt.AlignmentFlag.AlignTop)
# Display current workspace name
left_layout.addWidget(self.workspace_label)
# Workspace controls: New, Open, Save
left_layout.addWidget(self.new_workspace_btn)
left_layout.addWidget(self.open_workspace_btn)
left_layout.addWidget(self.save_workspace_btn)
# Workspace list
left_layout.addWidget(self.ini_listbox)
# Limit the INI list height without constraining the entire column
self.ini_listbox.setMaximumHeight(300)
# Reload current file button sits above Add/Remove controls
left_layout.addWidget(self.reload_file_btn)
# Add/Remove INI buttons below list
btn_layout = QHBoxLayout()
btn_layout.addWidget(self.add_ini_btn)
btn_layout.addWidget(self.remove_ini_btn)
left_layout.addLayout(btn_layout)
left_group.setLayout(left_layout)
# Add workspace box into left container
left_container_layout.addWidget(left_group)
# Finally add the combined left column to the main layout
main_layout.addWidget(left_container)
main_layout.setAlignment(left_container, Qt.AlignmentFlag.AlignTop)
# Right panel (Contains multiple sections)
right_container = QWidget()
right_layout = QVBoxLayout(right_container)
# Control Frame Layout inside Right GroupBox
control_group = QGroupBox("Info")
control_layout = QVBoxLayout()
info_layout = QHBoxLayout()
info_layout.addWidget(self.frame_label)
info_layout.addWidget(self.n_peaks_label)
info_layout.addWidget(self.beam_center_label)
control_layout.addLayout(info_layout)
control_layout.addWidget(self.file_name_label)
control_group.setLayout(control_layout)
right_layout.addWidget(control_group)
# Image Frame Layout inside Right GroupBox
image_group = QGroupBox("Image Display")
image_layout = QVBoxLayout()
# Use the aspect-ratio widget (no scroll area)
image_layout.addWidget(self.image_aspect_widget)
image_group.setLayout(image_layout)
# Add image display without forcing extra vertical stretch
right_layout.addWidget(image_group)
# Slider Frame Layout inside Right GroupBox
slider_group = QGroupBox("Navigate Frames")
slider_layout = QVBoxLayout()
slider_row = QHBoxLayout()
slider_row.addWidget(self.prev_button)
slider_row.addWidget(self.next_button)
slider_row.addWidget(self.frame_slider)
slider_row.addWidget(self.frame_entry)
slider_layout.addLayout(slider_row)
self.indexed_navigation_widget = QWidget()
indexed_layout = QHBoxLayout(self.indexed_navigation_widget)
indexed_layout.setContentsMargins(0, 0, 0, 0)
indexed_layout.setSpacing(6)
indexed_layout.addWidget(self.prev_indexed_frame_button)
indexed_layout.addWidget(self.next_indexed_frame_button)
indexed_layout.addWidget(self.latest_indexed_frame_button)
self.indexed_navigation_widget.setVisible(False)
slider_layout.addWidget(self.indexed_navigation_widget)
slider_group.setLayout(slider_layout)
right_layout.addWidget(slider_group)
# View Options Frame Layout inside Right GroupBox
view_options_group = QGroupBox("View Options")
view_options_layout = QVBoxLayout()
# Checkboxes Layout
checkboxes_layout = QVBoxLayout()
checkboxes_layout.addWidget(self.show_point_checkbox)
checkboxes_layout.addWidget(self.show_peaks_checkbox)
checkboxes_layout.addWidget(self.show_indexing_checkbox)
checkboxes_layout.addWidget(self.show_resolution_rings_checkbox)
checkboxes_layout.addWidget(self.log_checkbox)
# Colormap selector layout
cmap_layout = QHBoxLayout()
cmap_layout.addWidget(QLabel("Colormap"))
cmap_layout.addWidget(self.cmap_combo)
checkboxes_layout.addLayout(cmap_layout)
view_options_layout.addLayout(checkboxes_layout)
# Gamma adjustment row
gamma_row = QHBoxLayout()
gamma_row.addWidget(QLabel("Gamma"))
gamma_row.addWidget(self.gamma_slider)
gamma_row.addWidget(self.gamma_label)
view_options_layout.addLayout(gamma_row)
# Brightness adjustment row
brightness_row = QHBoxLayout()
brightness_row.addWidget(QLabel("Brightness"))
brightness_row.addWidget(self.brightness_slider)
brightness_row.addWidget(self.brightness_label)
view_options_layout.addLayout(brightness_row)
# Contrast adjustment row
contrast_row = QHBoxLayout()
contrast_row.addWidget(QLabel("Contrast"))
contrast_row.addWidget(self.contrast_slider)
contrast_row.addWidget(self.contrast_label)
view_options_layout.addLayout(contrast_row)
# Buttons at bottom
buttons_layout = QHBoxLayout()
buttons_layout.addWidget(self.go_to_strongest_frame_button)
view_options_layout.addLayout(buttons_layout)
# Frame order selector at the bottom of the settings group
frame_order_layout = QHBoxLayout()
frame_order_layout.addWidget(QLabel("Frame order"))
frame_order_layout.addWidget(self.frame_order_combo)
view_options_layout.addLayout(frame_order_layout)
view_options_group.setLayout(view_options_layout)
# Add view options below workspace in left container
left_container_layout.addWidget(view_options_group)
status_group = QGroupBox("Status")
status_layout = QVBoxLayout()
status_layout.addWidget(self.status_label)
status_group.setLayout(status_layout)
left_container_layout.addWidget(status_group)
# Container already has its layout assigned via QVBoxLayout(right_container)
# Add right column and top-align it
main_layout.addWidget(right_container, 0, Qt.AlignmentFlag.AlignTop)
# main_layout.setAlignment(right_container, Qt.AlignmentFlag.AlignTop) # (removed as now redundant)
# --- Workflow Group (Rightmost Column) ---
workflow_group = QGroupBox("Workflow")
workflow_layout = QVBoxLayout()
# Add Preflight Check button at the start of workflow
self.workflow_check_metadata_btn = QPushButton("Preflight Check")
self.workflow_check_metadata_btn.setEnabled(False)
# Tooltip explaining preflight check purpose
self.workflow_check_metadata_btn.setToolTip(
"Before we start processing, we want to ensure we have all necessary metadata. "
"Most of this information should be automatically obtained from the metadata. "
"If anything is missing, you can fill it in manually."
)
workflow_layout.addWidget(self.workflow_check_metadata_btn)
self.workflow_check_metadata_btn.clicked.connect(self.open_check_metadata_window)
arrow_label0 = QLabel("↓")
arrow_label0.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label0.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label0)
# Add disabled buttons for each workflow step, with arrows between them
self.workflow_find_peaks_btn = QPushButton("Find peaks")
self.workflow_find_peaks_btn.setToolTip("Open peakfinder settings and find diffraction spots")
self.workflow_find_peaks_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_find_peaks_btn)
self.workflow_find_peaks_btn.clicked.connect(self.open_peakfinder_settings_window)
arrow_label1 = QLabel("↓")
arrow_label1.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label1.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label1)
# Insert Strip Files button and arrow
self.workflow_strip_files_btn = QPushButton("Remove Empty Frames")
self.workflow_strip_files_btn.setToolTip("Remove frames without reflections before processing. This step is optional but recommended in order to reduce uneccessary disk space usage.")
self.workflow_strip_files_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_strip_files_btn)
self.workflow_strip_files_btn.clicked.connect(self.open_strip_files_window)
arrow_label_strip = QLabel("↓")
arrow_label_strip.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label_strip.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label_strip)
self.workflow_find_centers_btn = QPushButton("Find Centers")
self.workflow_find_centers_btn.setToolTip("Find beam center positions")
self.workflow_find_centers_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_find_centers_btn)
self.workflow_find_centers_btn.clicked.connect(self.open_centerfinder_settings_window)
arrow_label2 = QLabel("↓")
arrow_label2.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label2.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label2)
self.workflow_refine_centers_btn = QPushButton("Refine Centers")
self.workflow_refine_centers_btn.setToolTip("Refine beam center positions to improve indexing")
self.workflow_refine_centers_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_refine_centers_btn)
self.workflow_refine_centers_btn.clicked.connect(self.open_centerrefinement_settings_window)
arrow_label3 = QLabel("↓")
arrow_label3.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label3.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label3)
# Index & Integrate
self.workflow_index_integrate_btn = QPushButton("Index && Integrate")
self.workflow_index_integrate_btn.setToolTip("Index reflections and integrate intensities")
self.workflow_index_integrate_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_index_integrate_btn)
self.workflow_index_integrate_btn.clicked.connect(self.open_index_integrate_window)
# Shared arrow down to Disambiguate
arrow_label4 = QLabel("↓")
arrow_label4.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label4.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label4)
self.workflow_disambiguate_btn = QPushButton("Disambiguate")
self.workflow_disambiguate_btn.setToolTip("Resolve indexing ambiguities")
self.workflow_disambiguate_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_disambiguate_btn)
# self.workflow_disambiguate_btn.clicked.connect(self.open_disambiguate_window)
arrow_label_merge = QLabel("↓")
arrow_label_merge.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label_merge.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label_merge)
self.workflow_merge_btn = QPushButton("Merge")
self.workflow_merge_btn.setToolTip("Merge reflections")
self.workflow_merge_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_merge_btn)
self.workflow_merge_btn.clicked.connect(self.open_merge_window)
arrow_label7 = QLabel("↓")
arrow_label7.setAlignment(Qt.AlignmentFlag.AlignHCenter)
arrow_label7.setStyleSheet("font-size: 22px; color: #888;")
workflow_layout.addWidget(arrow_label7)
self.workflow_export_btn = QPushButton("Export")
self.workflow_export_btn.setToolTip("Export the final results to files")
self.workflow_export_btn.setEnabled(False)
workflow_layout.addWidget(self.workflow_export_btn)
self.workflow_export_btn.clicked.connect(self.open_export_window)
# Only size the workflow panel to its content
workflow_group.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Preferred)
workflow_group.setLayout(workflow_layout)
# Workflow column: the workflow group with a saved-map preview below it.
self.workflow_group = workflow_group
workflow_column = QVBoxLayout()
workflow_column.setAlignment(Qt.AlignmentFlag.AlignTop)
workflow_column.addWidget(workflow_group)
self.map_preview_group = QGroupBox("Atlas")
map_preview_layout = QVBoxLayout()
self.map_thumbnail = QLabel()
self.map_thumbnail.setAlignment(Qt.AlignmentFlag.AlignCenter)
# Ignored width so the thumbnail never forces the column wider than the workflow.
self.map_thumbnail.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
map_preview_layout.addWidget(self.open_map_button)
map_preview_layout.addWidget(self.map_thumbnail)
self.map_preview_group.setLayout(map_preview_layout)
self.map_preview_group.setVisible(False)
workflow_column.addWidget(self.map_preview_group)
workflow_column.addStretch(1)
workflow_container = QWidget()
workflow_container.setLayout(workflow_column)
main_layout.addWidget(workflow_container)
main_layout.setAlignment(workflow_container, Qt.AlignmentFlag.AlignTop)
# Entry point function for launching the cosedaUI application.
def _configure_macos_app_identity():
"""Set process metadata that macOS may read before QApplication exists."""
if sys.platform != "darwin":
return
try:
import importlib
foundation = importlib.import_module("Foundation")
nsbundle = getattr(foundation, "NSBundle", None)
if nsbundle is not None:
bundle = nsbundle.mainBundle()
if bundle is not None:
info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
if info is not None:
info["CFBundleName"] = APP_NAME
info["CFBundleDisplayName"] = APP_NAME
except Exception:
pass
try:
from ctypes import cdll, c_char_p
libc = cdll.LoadLibrary("libc.dylib")
if hasattr(libc, "setprogname"):
libc.setprogname(c_char_p(APP_NAME.encode("utf-8")))
except Exception:
pass
[docs]
def run_app():
"""
Entry point for launching the cosedaUI application.
"""
# Silence noisy Qt QPA/Wayland debug output unless the user explicitly overrides it
os.environ.setdefault("QT_LOGGING_RULES", "qt.qpa.*=false")
enable_opengl()
log_print("Starting the application...", level="debug")
_configure_macos_app_identity()
QCoreApplication.setApplicationName(APP_NAME)
QCoreApplication.setOrganizationName(ORGANIZATION_NAME)
app = QApplication(sys.argv)
if sys.platform == "darwin":
# Keep native style by default; allow override via env (e.g. Fusion).
qt_style = os.getenv("COSEDA_QT_STYLE", "").strip()
if qt_style:
try:
app.setStyle(qt_style)
log_print(f"Qt style set to '{qt_style}'", level="debug")
except Exception as exc:
log_print(f"Failed to set Qt style '{qt_style}': {exc}", level="debug")
# Load global icon from filesystem
base_dir = os.path.dirname(__file__)
icon_file = os.path.join(base_dir, "resources", "app_icon_512.png")
app.setWindowIcon(QIcon(icon_file))
app.setApplicationName(APP_NAME)
app.setApplicationDisplayName(APP_NAME)
if sys.platform.startswith("linux"):
app.setDesktopFileName("coseda")
app.setOrganizationName(ORGANIZATION_NAME)
log_print("Initializing the viewer...", level="debug")
viewer = cosedaUI()
log_print("Running the app...", level="debug")
return app.exec()
if __name__ == '__main__':
sys.exit(run_app())