from __future__ import annotations
import copy
import datetime
import importlib.util
import math
import os
import sys
import textwrap
import time
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any
import matplotlib
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
import numpy as np
from PyQt6.QtCore import QPointF, QRectF, QSettings, QSize, QTimer, QUrl, Qt, QVariant, pyqtSignal
from PyQt6.QtGui import (
QColor,
QDesktopServices,
QFont,
QIcon,
QPalette,
QPainter,
QPen,
QPixmap,
QPolygonF,
QTextOption,
)
from PyQt6.QtWidgets import (
QApplication,
QBoxLayout,
QCheckBox,
QColorDialog,
QComboBox,
QDialog,
QDoubleSpinBox,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMessageBox,
QProgressBar,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedLayout,
QStackedWidget,
QTabWidget,
QTextEdit,
QToolButton,
QVBoxLayout,
QWidget,
)
from magscope.ipc_commands import (
ExecuteXYLockCommand,
ExecuteZLockCommand,
GetCameraSettingCommand,
LoadScriptCommand,
PauseScriptCommand,
ResumeScriptCommand,
SetAcquisitionDirCommand,
SetAcquisitionDirOnCommand,
SetAcquisitionModeCommand,
SetAcquisitionOnCommand,
SetCameraSettingCommand,
SetXYLockIntervalCommand,
SetXYLockMaxCommand,
SetXYLockOnCommand,
SetXYLockWindowCommand,
SetZLockBeadCommand,
SetZLockIntervalCommand,
SetZLockMaxCommand,
SetZLockOnCommand,
SetZLockTargetCommand,
SetZLockWindowCommand,
StartScriptCommand,
UpdateScriptStepCommand,
UpdateTrackingOptionsCommand,
UpdateSettingsCommand,
)
from magscope.scripting import ScriptStatus
from magscope.settings import (
DEFAULT_GUI_ACCENT_COLOR,
GUI_ACCENT_COLOR_SETTING,
GUI_LIVE_PLOT_PROGRESS_BAR_SETTING,
MagScopeSettings,
default_tracking_options,
export_preferences_bundle,
import_preferences_bundle,
save_tracking_options_to_qsettings,
tracking_options_from_mapping,
tracking_options_from_qsettings,
)
from magscope.ui.search import (
PanelControlTarget,
PreferencesSettingTarget,
PreferencesWidgetTarget,
SearchTarget,
)
from magscope.ui.theme import PANEL_BACKGROUND_COLOR, get_accent_color
from magscope.ui.widgets import (
CollapsibleGroupBox,
FlashLabel,
LabeledCheckbox,
LabeledLineEdit,
LabeledLineEditWithValue,
)
from magscope.utils import AcquisitionMode, crop_stack_to_rois
# Import only for the type check to avoid circular import
if TYPE_CHECKING:
from magscope.ui.ui import UIManager
[docs]
def _panel_control_target(
label: str,
panel_id: str,
widget_attr: str,
*,
context: str,
aliases: tuple[str, ...] = (),
description: str = '',
keywords: tuple[str, ...] = (),
) -> PanelControlTarget:
return PanelControlTarget(
label=label,
aliases=aliases,
context=context,
description=description,
keywords=keywords,
panel_id=panel_id,
widget_path=(widget_attr,),
)
[docs]
class ControlPanelBase(QWidget):
def __init__(
self,
manager: 'UIManager',
title: str,
collapsed_by_default: bool = False,
collapsible: bool = False,
):
super().__init__()
[docs]
self.manager: UIManager = manager
[docs]
self.groupbox: CollapsibleGroupBox | None = None
[docs]
self._content_layout: QBoxLayout | None = None
outer_layout = QVBoxLayout()
outer_layout.setContentsMargins(0, 0, 0, 0)
super().setLayout(outer_layout)
content_layout = QVBoxLayout()
if title or collapsible:
self.groupbox = CollapsibleGroupBox(
title=title,
collapsed=collapsed_by_default,
collapsible=collapsible,
)
outer_layout.addWidget(self.groupbox)
else:
content_layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(content_layout)
[docs]
def set_title(self, text: str) -> None:
if self.groupbox is not None:
self.groupbox.setTitle(text)
[docs]
def setLayout(self, layout: QBoxLayout) -> None:
if self.groupbox is None:
self._content_layout = layout
super().layout().addLayout(layout)
return
self.groupbox.setContentLayout(layout)
self._content_layout = self.groupbox.content_area.layout()
[docs]
def layout(self) -> QBoxLayout:
if self._content_layout is None:
raise RuntimeError('Control panel layout has not been initialized')
return self._content_layout
[docs]
def set_highlighted(self, enabled: bool) -> None:
if self.groupbox is None:
return
if enabled:
self.groupbox.set_highlight_border(get_accent_color())
else:
self.groupbox.set_highlight_border(None)
[docs]
class MatplotlibCleanupMixin:
[docs]
def _init_matplotlib_cleanup(self) -> None:
self._matplotlib_disposed = False
self.destroyed.connect(self._dispose_matplotlib) # type: ignore[arg-type]
[docs]
def _dispose_matplotlib(self, *_args: object) -> None:
if getattr(self, '_matplotlib_disposed', False):
return
self._matplotlib_disposed = True
canvas = getattr(self, 'canvas', None)
figure = getattr(self, 'figure', None)
if canvas is not None:
try:
canvas.hide()
except RuntimeError:
pass
try:
canvas.setParent(None)
except RuntimeError:
pass
if figure is not None:
try:
figure.clear()
except Exception:
pass
if canvas is not None:
try:
canvas.close()
except RuntimeError:
pass
try:
canvas.deleteLater()
except RuntimeError:
pass
if hasattr(self, 'axes'):
self.axes = None
if hasattr(self, 'figure'):
self.figure = None
if hasattr(self, 'canvas'):
self.canvas = None
[docs]
def closeEvent(self, event) -> None: # type: ignore[override]
self._dispose_matplotlib()
super().closeEvent(event)
[docs]
class ResponsivePlotCanvas(FigureCanvas):
"""Figure canvas that grows taller when constrained to a narrow panel."""
def __init__(
self,
figure: Figure,
*,
minimum_height: int = 210,
maximum_height: int | None = 235,
height_for_width: float = 0.72,
):
super().__init__(figure)
[docs]
self._minimum_height = minimum_height
[docs]
self._maximum_height = maximum_height
[docs]
self._height_for_width = height_for_width
[docs]
self._preferred_height = minimum_height
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._apply_preferred_height(minimum_height)
[docs]
def _apply_preferred_height(self, height: int) -> None:
self._preferred_height = height
self.setMinimumHeight(height)
self.setMaximumHeight(height)
self.updateGeometry()
[docs]
def _update_preferred_height(self, width: int) -> None:
target_height = max(self._minimum_height, int(width * self._height_for_width))
if self._maximum_height is not None:
target_height = min(target_height, self._maximum_height)
if target_height == self._preferred_height:
return
self._apply_preferred_height(target_height)
[docs]
def resizeEvent(self, event): # type: ignore[override]
super().resizeEvent(event)
self._update_preferred_height(event.size().width())
[docs]
def sizeHint(self) -> QSize: # type: ignore[override]
hint = super().sizeHint()
width = self.width() if self.width() > 0 else hint.width()
return QSize(width, self._preferred_height)
[docs]
class HelpPanel(QFrame):
"""Clickable panel that links to the MagScope documentation."""
[docs]
HELP_URL = QUrl("https://magscope.readthedocs.io")
def __init__(self, manager: 'UIManager'):
super().__init__()
self.setObjectName("HelpPanelFrame")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFrameShape(QFrame.Shape.NoFrame)
layout = QVBoxLayout()
layout.setContentsMargins(14, 10, 14, 10)
layout.setSpacing(2)
self.setLayout(layout)
[docs]
self.title_label = QLabel("Need help?")
font = self.title_label.font()
font.setPointSize(font.pointSize() + 2)
font.setBold(True)
self.title_label.setFont(font)
self.title_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
[docs]
self.description_label = QLabel("Click to open the MagScope documentation")
self.description_label.setWordWrap(True)
layout.addWidget(self.title_label)
layout.addWidget(self.description_label)
[docs]
self._is_hovered = False
self._apply_styles()
[docs]
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
if self.rect().contains(event.pos()):
QDesktopServices.openUrl(self.HELP_URL)
event.accept()
return
super().mousePressEvent(event)
[docs]
def enterEvent(self, event):
self._is_hovered = True
self._apply_styles()
super().enterEvent(event)
[docs]
def leaveEvent(self, event):
self._is_hovered = False
self._apply_styles()
super().leaveEvent(event)
[docs]
def _apply_styles(self):
text_color = "black" if self._is_hovered else "white"
background_color = "white" if self._is_hovered else "transparent"
self.setStyleSheet(
f"""
#HelpPanelFrame {{
border: 1px solid #5b5b5b;
border-radius: 6px;
background-color: {background_color};
}}
#HelpPanelFrame QLabel {{
color: {text_color};
}}
"""
)
[docs]
class MotorsPlaceholderPanel(ControlPanelBase):
"""Panel shown when no user hardware managers are configured."""
[docs]
HELP_URL = QUrl("https://magscope.readthedocs.io/en/latest/connect_hardware.html")
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Hardware Managers', collapsed_by_default=False)
description_label = QLabel(
"No user hardware managers are registered. MagScope includes a framework "
"for adding motors, stages, and other devices to your acquisition workflow."
)
description_label.setWordWrap(True)
docs_button = QPushButton("Open hardware connection guide")
docs_button.setToolTip("Open the MagScope hardware manager documentation")
docs_button.clicked.connect(
lambda _checked=False: QDesktopServices.openUrl(self.HELP_URL)
)
self.layout().addWidget(description_label)
self.layout().addWidget(docs_button)
[docs]
class MagScopeSettingsPanel(QWidget):
"""Allow importing, exporting, and editing MagScope configuration values."""
[docs]
_SETTING_GROUPS: tuple[tuple[str, tuple[str, ...]], ...] = (
("Imaging", ("ROI", "magnification")),
(
"Data Buffers",
(
"tracks max datapoints",
"video buffer n images",
"video buffer n stacks",
"video processors n",
),
),
(
"XY Lock Defaults",
("xy-lock default interval", "xy-lock default max", "xy-lock default window"),
),
(
"Z Lock Defaults",
("z-lock default interval", "z-lock default max", "z-lock default window"),
),
)
def __init__(
self,
manager: "UIManager",
*,
collapsible: bool = True,
file_status_label: QLabel | None = None,
):
super().__init__()
[docs]
self._current_settings = manager.settings.clone()
[docs]
self._setting_value_labels: dict[str, QLabel] = {}
self.setMaximumWidth(760)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 6, 20, 12)
layout.setSpacing(6)
if file_status_label is not None:
file_status_label.setWordWrap(True)
file_status_label.setVisible(bool(file_status_label.text()))
layout.addWidget(file_status_label)
for group_title, keys in self._SETTING_GROUPS:
group = self._build_setting_group(group_title, keys)
layout.addWidget(group)
layout.addStretch(1)
@staticmethod
[docs]
def search_targets() -> list[SearchTarget]:
targets: list[SearchTarget] = []
for key in MagScopeSettings.magscope_panel_keys():
spec = MagScopeSettings.spec_for(key)
if key == "ROI":
targets.append(
PreferencesSettingTarget(
label="ROI Size",
aliases=("ROI", "ROI size", "ROI (pixels)", "bead ROI", "region of interest"),
context="Preferences > MagScope",
setting_key="ROI",
)
)
else:
targets.append(
PreferencesSettingTarget(
label=spec.label,
aliases=(key,),
context="Preferences > MagScope",
setting_key=key,
)
)
return targets
[docs]
def _show_error(self, message: str) -> None:
QMessageBox.critical(self, "Settings", message)
[docs]
def _push_settings(self, settings: MagScopeSettings) -> None:
self._current_settings = settings.clone()
self.manager.settings = settings.clone()
apply_accent_color = getattr(self.manager, "_apply_accent_color", None)
if callable(apply_accent_color):
apply_accent_color(settings[GUI_ACCENT_COLOR_SETTING])
apply_progress_indicator_enabled = getattr(
self.manager,
"_apply_live_plot_progress_indicator_enabled",
None,
)
if callable(apply_progress_indicator_enabled):
apply_progress_indicator_enabled()
command = UpdateSettingsCommand(settings=settings.clone())
self.manager.send_ipc(command)
self._refresh_fields()
[docs]
def _refresh_fields(self) -> None:
for key, lineedit in self._setting_inputs.items():
value = self._current_settings[key]
lineedit.setText(str(value))
if key in self._setting_value_labels:
self._update_saved_label_for_input(key)
[docs]
def reset_defaults(self) -> None:
defaults = MagScopeSettings()
defaults[GUI_ACCENT_COLOR_SETTING] = self._current_settings[GUI_ACCENT_COLOR_SETTING]
defaults[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING] = self._current_settings[
GUI_LIVE_PLOT_PROGRESS_BAR_SETTING
]
self._push_settings(defaults)
[docs]
def _build_setting_group(self, title: str, keys: tuple[str, ...]) -> QWidget:
group = QWidget(self)
group_layout = QVBoxLayout(group)
group_layout.setContentsMargins(0, 0, 0, 0)
group_layout.setSpacing(5)
title_label = QLabel(title, group)
title_label.setObjectName("preferencesGroupTitle")
group_layout.addWidget(title_label)
panel = QFrame(group)
panel.setObjectName("preferencesGroupPanel")
grid = QGridLayout(panel)
grid.setContentsMargins(14, 10, 14, 10)
grid.setHorizontalSpacing(10)
grid.setVerticalSpacing(3)
for row, key in enumerate(keys):
spec = MagScopeSettings.spec_for(key)
label = QLabel(spec.label)
label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
label.setFixedWidth(155)
grid.addWidget(label, row, 0)
lineedit = QLineEdit(str(self._current_settings[key]))
lineedit.setFixedWidth(120)
lineedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
lineedit.editingFinished.connect( # type: ignore[arg-type]
lambda k=key: self._apply_setting_from_input(k)
)
lineedit.textChanged.connect( # type: ignore[arg-type]
lambda _text, k=key: self._update_saved_label_for_input(k)
)
grid.addWidget(lineedit, row, 1)
self._setting_inputs[key] = lineedit
saved_label = QLabel("")
saved_label.setObjectName("preferencesSavedLabel")
saved_label.setVisible(False)
grid.addWidget(saved_label, row, 2)
self._setting_value_labels[key] = saved_label
grid.setColumnStretch(0, 0)
grid.setColumnStretch(1, 0)
grid.setColumnStretch(2, 1)
group_layout.addWidget(panel)
return group
[docs]
class AcquisitionPanel(ControlPanelBase):
[docs]
NO_DIRECTORY_SELECTED_TEXT = 'No save folder selected'
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Recording and Saving', collapsed_by_default=True)
self.layout().setSpacing(4)
controls_grid = QGridLayout()
controls_grid.setContentsMargins(0, 0, 0, 0)
controls_grid.setHorizontalSpacing(6)
controls_grid.setVerticalSpacing(4)
self.layout().addLayout(controls_grid)
[docs]
self.acquisition_on_checkbox = LabeledCheckbox(
label_text='Acquire',
default=self.manager._acquisition_on,
callback=self.callback_acquisition_on)
self.acquisition_on_checkbox.setSizePolicy(
QSizePolicy.Policy.Fixed,
QSizePolicy.Policy.Preferred,
)
controls_grid.addWidget(self.acquisition_on_checkbox, 0, 0)
mode_selection_label = QLabel('Data:')
controls_grid.addWidget(mode_selection_label, 0, 1)
[docs]
self.acquisition_mode_combobox = QComboBox()
controls_grid.addWidget(self.acquisition_mode_combobox, 0, 2, 1, 2)
acquisition_modes = [
AcquisitionMode.TRACK,
AcquisitionMode.TRACK_AND_VIDEO_ROIS,
AcquisitionMode.TRACK_AND_VIDEO_FULL,
AcquisitionMode.VIDEO_ROIS,
AcquisitionMode.VIDEO_FULL,
]
for mode in acquisition_modes:
self.acquisition_mode_combobox.addItem(mode)
self.acquisition_mode_combobox.setCurrentText(self.manager._acquisition_mode)
self.acquisition_mode_combobox.currentIndexChanged.connect(
self.callback_acquisition_mode) # type: ignore
[docs]
self.acquisition_dir_on_checkbox = LabeledCheckbox(
label_text='Saving',
default=self.manager._acquisition_dir_on,
callback=self.callback_acquisition_dir_on)
self.acquisition_dir_on_checkbox.setSizePolicy(
QSizePolicy.Policy.Fixed,
QSizePolicy.Policy.Preferred,
)
controls_grid.addWidget(self.acquisition_dir_on_checkbox, 1, 0)
directory_label = QLabel('Folder:')
controls_grid.addWidget(directory_label, 1, 1)
[docs]
self.acquisition_dir_textedit = QLineEdit()
self.acquisition_dir_textedit.setReadOnly(True)
self.acquisition_dir_textedit.setAlignment(
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter,
)
self.acquisition_dir_textedit.setSizePolicy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
controls_grid.addWidget(self.acquisition_dir_textedit, 1, 2)
self.acquisition_dir_button.clicked.connect(self.callback_acquisition_dir) # type: ignore
controls_grid.addWidget(self.acquisition_dir_button, 1, 3)
controls_grid.setColumnStretch(2, 1)
self.set_acquisition_dir_text(self.manager._acquisition_dir)
self.update_save_highlight(self.acquisition_dir_on_checkbox.checkbox.isChecked())
[docs]
def set_acquisition_dir_text(self, path: str | None) -> None:
display_text = path or self.NO_DIRECTORY_SELECTED_TEXT
self.acquisition_dir_textedit.setText(display_text)
self.acquisition_dir_textedit.setToolTip(path or '')
self.acquisition_dir_textedit.setCursorPosition(0)
[docs]
def callback_acquisition_on(self):
is_enabled: bool = self.acquisition_on_checkbox.checkbox.isChecked()
command = SetAcquisitionOnCommand(value=is_enabled)
self.manager.send_ipc(command)
[docs]
def callback_acquisition_dir_on(self):
should_save: bool = self.acquisition_dir_on_checkbox.checkbox.isChecked()
self.update_save_highlight(should_save)
command = SetAcquisitionDirOnCommand(value=should_save)
self.manager.send_ipc(command)
[docs]
def callback_acquisition_mode(self):
selected_mode: AcquisitionMode = self.acquisition_mode_combobox.currentText()
command = SetAcquisitionModeCommand(mode=selected_mode)
self.manager.send_ipc(command)
[docs]
def callback_acquisition_dir(self):
settings = QSettings('MagScope', 'MagScope')
last_directory = settings.value(
'last acquisition_dir',
os.path.expanduser("~"),
type=str
)
selected_directory = QFileDialog.getExistingDirectory(
None,
'Select Folder',
last_directory)
if selected_directory:
self.set_acquisition_dir_text(selected_directory)
settings.setValue('last acquisition_dir', QVariant(selected_directory))
else:
selected_directory = None
self.set_acquisition_dir_text(None)
command = SetAcquisitionDirCommand(value=selected_directory)
self.manager.send_ipc(command)
[docs]
def update_save_highlight(self, should_save: bool) -> None:
self.set_highlighted(should_save)
[docs]
def search_targets(self) -> list[SearchTarget]:
return [
_panel_control_target(
'Acquire',
'AcquisitionPanel',
'acquisition_on_checkbox',
context='Recording and Saving',
aliases=('acquisition on', 'start acquisition', 'record'),
),
_panel_control_target(
'Data Mode',
'AcquisitionPanel',
'acquisition_mode_combobox',
context='Recording and Saving',
aliases=('acquisition mode', 'mode', 'recording mode', 'save mode'),
),
_panel_control_target(
'Save Recording',
'AcquisitionPanel',
'acquisition_dir_on_checkbox',
context='Recording and Saving',
aliases=('save', 'save data', 'save acquisition'),
),
_panel_control_target(
'Save Folder',
'AcquisitionPanel',
'acquisition_dir_button',
context='Recording and Saving',
aliases=('save directory', 'output folder', 'acquisition folder'),
),
]
[docs]
class BeadSelectionPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Bead Selection', collapsed_by_default=False)
# Instructions
note_text = textwrap.dedent(
"""
<b>Add a bead:</b> Left-click on the video<br>
<b>Activate a bead:</b> Left-click on the bead ROI<br>
<b>Move a bead:</b> Drag the active bead ROI<br>
<b>Remove a bead:</b> Right-click on the bead
"""
).strip()
note = QLabel(note_text)
note.setWordWrap(True)
self.layout().addWidget(note)
next_id_row = QHBoxLayout()
self.layout().addLayout(next_id_row)
[docs]
self.next_bead_id_label = QLabel()
next_id_row.addWidget(self.next_bead_id_label)
next_id_row.addStretch(1)
self.reset_id_button.clicked.connect(self.manager.reset_bead_ids) # type: ignore
next_id_row.addWidget(self.reset_id_button)
self.update_next_bead_id_label(self.manager.bead_next_id)
# ROI
roi_row = QHBoxLayout()
self.layout().addLayout(roi_row)
roi_row.addWidget(QLabel('Current ROI:'))
roi = self.manager.settings['ROI']
[docs]
self.roi_size_label = QLabel(f'{roi} x {roi} pixels')
roi_row.addWidget(self.roi_size_label)
roi_row.addStretch(1)
# Row
button_row = QHBoxLayout()
self.layout().addLayout(button_row)
# Remove All Beads
self.clear_button.setEnabled(True)
self.clear_button.clicked.connect(self.manager.clear_beads) # type: ignore
button_row.addWidget(self.clear_button)
[docs]
def search_targets(self) -> list[SearchTarget]:
return [
PanelControlTarget(
label='Remove All Beads',
aliases=('clear beads', 'delete beads'),
context='Bead Selection',
panel_id='BeadSelectionPanel',
widget_path=('clear_button',),
),
PanelControlTarget(
label='Reassign IDs',
aliases=('reset bead ids', 'renumber beads'),
context='Bead Selection',
panel_id='BeadSelectionPanel',
widget_path=('reset_id_button',),
),
]
[docs]
def update_next_bead_id_label(self, next_bead_id: int) -> None:
self.next_bead_id_label.setText(f"Next Bead ID: {next_bead_id}")
[docs]
class CameraPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Camera Settings', collapsed_by_default=True)
self.layout().setSpacing(2)
[docs]
self._last_settings_update: datetime.datetime | None = None
for setting_name in self.manager.camera_type.settings:
self.settings[setting_name] = LabeledLineEditWithValue(
label_text=setting_name,
widths=(0, 100, 50),
callback=lambda n=setting_name: self.callback_set_camera_setting(n))
self.layout().addWidget(self.settings[setting_name])
refresh_row = QHBoxLayout()
self.layout().addLayout(refresh_row)
self.refresh_button.clicked.connect(self.callback_refresh) # noqa PyUnresolvedReferences
refresh_row.addWidget(self.refresh_button)
refresh_row.addStretch(1)
[docs]
self.last_update_label = QLabel(self._format_last_update_text())
self.last_update_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
refresh_row.addWidget(self.last_update_label)
[docs]
def callback_refresh(self):
for name in self.manager.camera_type.settings:
command = GetCameraSettingCommand(name=name)
self.manager.send_ipc(command)
[docs]
def callback_set_camera_setting(self, name):
setting_value = self.settings[name].lineedit.text()
if not setting_value:
return
self.settings[name].lineedit.setText('')
self.settings[name].value_label.setText('')
command = SetCameraSettingCommand(name=name, value=setting_value)
self.manager.send_ipc(command)
[docs]
def update_camera_setting(self, name: str, value: str):
self.settings[name].value_label.setText(value)
self._last_settings_update = datetime.datetime.now()
self.last_update_label.setText(self._format_last_update_text())
[docs]
def _format_last_update_text(self) -> str:
if self._last_settings_update is None:
return 'Last updated: not yet'
return f"Last updated: {self._last_settings_update.strftime('%Y-%m-%d %H:%M:%S')}"
[docs]
class HistogramPanel(MatplotlibCleanupMixin, ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Histogram', collapsed_by_default=True)
[docs]
self.update_interval: float = 1 # seconds
[docs]
self._update_last_time: float = 0
# ===== First Row ===== #
controls_row = QHBoxLayout()
controls_row.setContentsMargins(0, 0, 0, 0)
controls_row.setSpacing(8)
self.layout().addLayout(controls_row)
[docs]
self.enable_checkbox = LabeledCheckbox(
label_text='Enabled',
callback=self.enabled_callback,
widths=(50, 0),
default=False)
controls_row.addWidget(self.enable_checkbox)
# Keep enabled state synced with collapse/expand so highlighting matches behavior
self.groupbox.toggle_button.toggled.connect(self._groupbox_toggled)
[docs]
self.only_beads_checkbox = LabeledCheckbox(
label_text='Only ROIs', default=False)
controls_row.addWidget(self.only_beads_checkbox)
controls_row.addStretch(1)
# ===== Plot ===== #
self.figure.set_constrained_layout_pads(w_pad=0.02, h_pad=0.02, hspace=0.0, wspace=0.0)
[docs]
self.canvas = ResponsivePlotCanvas(
self.figure,
minimum_height=102,
maximum_height=123,
height_for_width=0.32,
)
[docs]
self.axes = self.figure.subplots(nrows=1, ncols=1)
_, _, self.bars = self.axes.hist(
[],
bins=self.n_bins,
edgecolor=None,
facecolor=get_accent_color(),
)
self.axes.set_facecolor(PANEL_BACKGROUND_COLOR)
self.axes.set_xlabel('Intensity')
self.axes.set_ylabel('Count')
plot_font_size = self.font().pointSizeF()
if plot_font_size <= 0:
plot_font_size = float(self.font().pointSize() or 9)
plot_font_size = max(6.0, plot_font_size - 1.5)
self.axes.xaxis.label.set_size(plot_font_size)
self.axes.yaxis.label.set_size(plot_font_size)
self.axes.tick_params(axis='both', labelsize=plot_font_size)
self.axes.set_yticks([])
self.axes.set_xticks([])
self.axes.spines['left'].set_visible(True)
self.axes.spines['top'].set_visible(False)
self.axes.spines['right'].set_visible(False)
self.axes.set_xlim(0, 1)
self.layout().addWidget(self.canvas)
self._init_matplotlib_cleanup()
[docs]
def enabled_callback(self, enabled: bool) -> None:
effective_enabled = enabled and not self.groupbox.collapsed
self._apply_enabled_state(effective_enabled)
[docs]
def _groupbox_toggled(self, expanded: bool) -> None:
enabled = expanded and self.enable_checkbox.checkbox.isChecked()
self._apply_enabled_state(enabled)
[docs]
def _apply_enabled_state(self, enabled: bool) -> None:
self.set_highlighted(enabled)
self.clear()
[docs]
def update_plot(self, data):
if not self.enable_checkbox.checkbox.isChecked() or self.groupbox.collapsed:
return
current_time = time.time()
if current_time - self._update_last_time < self.update_interval:
return
self._update_last_time = current_time
image_dtype = self.manager.camera_type.dtype
max_intensity = 2 ** self.manager.camera_type.bits
image_shape = self.manager.video_buffer.image_shape
image = np.frombuffer(data, image_dtype).reshape(image_shape)
if self.only_beads_checkbox.checkbox.isChecked():
_, bead_rois = self.manager.get_cached_bead_rois()
if len(bead_rois) > 0:
image = crop_stack_to_rois(
np.swapaxes(image, 0, 1)[:, :, None], bead_rois)
else:
self.clear()
return
counts, _ = np.histogram(image, bins=256, range=(0, max_intensity))
# fast safe log to prevent log(0)
counts = np.log(counts + 1)
for count, rect in zip(counts, self.bars.patches):
rect.set_height(count)
rect.set_facecolor(get_accent_color())
max_count = counts.max() if len(counts) > 0 else 1
self.axes.set_ylim(0, max_count * 1.1)
self.canvas.draw()
[docs]
def clear(self):
for rect in self.bars.patches:
rect.set_height(0)
self.canvas.draw()
[docs]
class PlotSettingsPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Plot Settings', collapsed_by_default=True)
# Selected Bead
[docs]
self.selected_bead = LabeledLineEdit(
label_text='Selected Bead (red)',
default='0',
callback=self.selected_bead_callback,
)
self.layout().addWidget(self.selected_bead)
# Selected Reference Bead
[docs]
self.reference_bead = LabeledLineEdit(
label_text='Reference Bead (green)',
callback=self.reference_bead_callback,
)
self.layout().addWidget(self.reference_bead)
# =============== Limits ===============
[docs]
self.limits: dict[str, tuple[QLineEdit, QLineEdit]] = {}
# Limits Grid
[docs]
self.grid_layout = QGridLayout()
self.layout().addLayout(self.grid_layout)
# First row of labels
row_index = 0
limit_label_font = QFont()
limit_label_font.setBold(True)
limit_label = QLabel('Limits')
limit_label.setFont(limit_label_font)
self.grid_layout.addWidget(limit_label, row_index, 0)
self.grid_layout.addWidget(QLabel('Min'), row_index, 1)
self.grid_layout.addWidget(QLabel('Max'), row_index, 2)
# One row for each y-axis
for _, plot in enumerate(self.manager.plot_worker.plots):
row_index += 1
ylabel = plot.ylabel
self.limits[ylabel] = (QLineEdit(), QLineEdit())
self.limits[ylabel][0].textChanged.connect(self.limits_callback)
self.limits[ylabel][1].textChanged.connect(self.limits_callback)
self.limits[ylabel][0].setPlaceholderText('auto')
self.limits[ylabel][1].setPlaceholderText('auto')
self.grid_layout.addWidget(QLabel(ylabel), row_index, 0)
self.grid_layout.addWidget(self.limits[ylabel][0], row_index, 1)
self.grid_layout.addWidget(self.limits[ylabel][1], row_index, 2)
# Time axis mode selector sits between Z and Time rows
row_index += 1
self.grid_layout.addWidget(QLabel('Time axis mode'), row_index, 0)
[docs]
self.time_mode = QComboBox()
self.time_mode.addItems(['Absolute', 'Relative'])
self.time_mode.currentTextChanged.connect(self.time_mode_callback)
self.grid_layout.addWidget(self.time_mode, row_index, 1, 1, 2)
# Last row for "Time"
row_index += 1
[docs]
self.time_label = QLabel('Time (H:M:S)')
self.grid_layout.addWidget(self.time_label, row_index, 0)
# Absolute time inputs (min/max)
time_absolute_widget = QWidget()
time_absolute_layout = QHBoxLayout()
time_absolute_layout.setContentsMargins(0, 0, 0, 0)
time_absolute_layout.setSpacing(4)
time_absolute_widget.setLayout(time_absolute_layout)
[docs]
self.time_limits_absolute = (QLineEdit(), QLineEdit())
self.time_limits_absolute[0].setPlaceholderText('auto')
self.time_limits_absolute[1].setPlaceholderText('auto')
self.time_limits_absolute[0].textChanged.connect(self.limits_callback)
self.time_limits_absolute[1].textChanged.connect(self.limits_callback)
time_absolute_layout.addWidget(self.time_limits_absolute[0])
time_absolute_layout.addWidget(self.time_limits_absolute[1])
# Relative time input (single window box)
time_relative_widget = QWidget()
time_relative_layout = QHBoxLayout()
time_relative_layout.setContentsMargins(0, 0, 0, 0)
time_relative_layout.setSpacing(4)
time_relative_widget.setLayout(time_relative_layout)
[docs]
self.time_relative_window = QLineEdit('00:05:00')
self.time_relative_window.textChanged.connect(self.relative_time_window_callback)
time_relative_layout.addWidget(self.time_relative_window)
self.time_inputs_stack.addWidget(time_absolute_widget)
self.time_inputs_stack.addWidget(time_relative_widget)
time_inputs_container = QWidget()
time_inputs_container.setLayout(self.time_inputs_stack)
self.grid_layout.addWidget(time_inputs_container, row_index, 1, 1, 2)
self.limits['Time'] = self.time_limits_absolute
def _triangle_icon(direction: Qt.ArrowType) -> QIcon:
side = 9.0
height = math.sqrt(3) / 2 * side
size = int(math.ceil(max(side, height))) + 4
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.translate(size / 2, size / 2)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(self.palette().color(QPalette.ColorRole.WindowText))
if direction == Qt.ArrowType.DownArrow:
points = [
QPointF(0, height),
QPointF(-side / 2, 0),
QPointF(side / 2, 0),
]
else:
points = [
QPointF(height, 0),
QPointF(0, -side / 2),
QPointF(0, side / 2),
]
painter.drawPolygon(QPolygonF(points))
painter.end()
return QIcon(pixmap)
right_triangle_icon = _triangle_icon(Qt.ArrowType.RightArrow)
down_triangle_icon = _triangle_icon(Qt.ArrowType.DownArrow)
icon_size = (
right_triangle_icon.availableSizes()[0]
if right_triangle_icon.availableSizes()
else QSize(12, 12)
)
bead_options_toggle = QToolButton()
bead_options_toggle.setText('Advanced Options: Display bead centers')
bead_options_toggle.setCheckable(True)
bead_options_toggle.setChecked(False)
bead_options_toggle.setIcon(right_triangle_icon)
bead_options_toggle.setIconSize(icon_size)
bead_options_toggle.setToolButtonStyle(
Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
subtitle_font = bead_options_toggle.font()
subtitle_font.setPointSize(subtitle_font.pointSize() - 1)
subtitle_font.setBold(False)
bead_options_toggle.setFont(subtitle_font)
bead_options_toggle.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.layout().addWidget(bead_options_toggle)
bead_view_container = QWidget()
bead_view_container.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum)
bead_view_layout = QVBoxLayout()
bead_view_layout.setContentsMargins(0, 0, 0, 0)
bead_view_layout.setSpacing(4)
bead_view_container.setLayout(bead_view_layout)
# Show beads on view
[docs]
self.beads_in_view_on = LabeledCheckbox(
label_text='Show beads on video? (slow)',
default=False,
callback=self.beads_in_view_on_callback,
)
bead_view_layout.addWidget(self.beads_in_view_on)
# Number of timepoints to show
[docs]
self.beads_in_view_count = LabeledLineEdit(
label_text='Number of timepoints to show',
default='1',
callback=self.beads_in_view_count_callback,
)
bead_view_layout.addWidget(self.beads_in_view_count)
# Marker size
[docs]
self.beads_in_view_marker_size = LabeledLineEdit(
label_text='Marker size',
default='20',
callback=self.beads_in_view_marker_size_callback,
)
bead_view_layout.addWidget(self.beads_in_view_marker_size)
bead_view_container.setVisible(False)
def _toggle_bead_overlay_options(checked: bool) -> None:
bead_options_toggle.setIcon(
down_triangle_icon if checked else right_triangle_icon)
bead_view_container.setVisible(checked)
self.groupbox.layout().activate()
bead_options_toggle.toggled.connect(_toggle_bead_overlay_options)
self.layout().addWidget(bead_view_container)
[docs]
def search_targets(self) -> list[SearchTarget]:
return [
_panel_control_target(
'Selected Bead',
'PlotSettingsPanel',
'selected_bead',
context='Plot Settings',
aliases=('active bead', 'red bead'),
),
_panel_control_target(
'Reference Bead',
'PlotSettingsPanel',
'reference_bead',
context='Plot Settings',
aliases=('green bead', 'subtract bead'),
),
_panel_control_target(
'Time axis mode',
'PlotSettingsPanel',
'time_mode',
context='Plot Settings',
aliases=('time mode', 'absolute time', 'relative time'),
),
_panel_control_target(
'Relative Time',
'PlotSettingsPanel',
'time_relative_window',
context='Plot Settings',
aliases=('relative time window', 'time window'),
),
_panel_control_target(
'Show beads on video',
'PlotSettingsPanel',
'beads_in_view_on',
context='Plot Settings',
aliases=('show bead centers', 'bead overlay', 'plot beads on video'),
),
_panel_control_target(
'Number of timepoints to show',
'PlotSettingsPanel',
'beads_in_view_count',
context='Plot Settings',
aliases=('bead overlay history', 'timepoints'),
),
_panel_control_target(
'Marker size',
'PlotSettingsPanel',
'beads_in_view_marker_size',
context='Plot Settings',
aliases=('bead marker size', 'crosshair size'),
),
]
[docs]
def selected_bead_callback(self, value):
try:
bead = int(value)
except (TypeError, ValueError):
bead = -1
self.manager.set_selected_bead(bead)
[docs]
def reference_bead_callback(self, value):
value = self.reference_bead.lineedit.text()
try:
bead = int(value)
except (TypeError, ValueError):
bead = -1
self.manager.set_reference_bead(None if bead < 0 else bead)
[docs]
def time_mode_callback(self, value: str):
mode = value.lower()
is_relative = mode == 'relative'
self.time_inputs_stack.setCurrentIndex(1 if is_relative else 0)
self.time_label.setText('Relative Time (H:M:S)' if is_relative else 'Time (H:M:S)')
self.manager.plot_worker.time_mode_signal.emit(mode)
if is_relative:
self.relative_time_window_callback(self.time_relative_window.text())
else:
self.limits_callback(None)
[docs]
def relative_time_window_callback(self, _value):
text = self.time_relative_window.text()
window_seconds: float | None
try:
time_parts = text.replace('.', ':').split(':')
if len(time_parts) == 1:
hours, minutes, seconds = int(time_parts[0]), 0, 0
elif len(time_parts) == 2:
hours, minutes = map(int, time_parts)
seconds = 0
elif len(time_parts) == 3:
hours, minutes, seconds = map(int, time_parts)
else:
raise ValueError
window_seconds = hours * 3600 + minutes * 60 + seconds
if window_seconds <= 0:
window_seconds = None
except (TypeError, ValueError):
window_seconds = None
self.manager.plot_worker.relative_window_signal.emit(window_seconds)
[docs]
def limits_callback(self, _):
limits_payload = {}
today = datetime.date.today()
for axis_label, limit in self.limits.items():
raw_values = [limit[0].text(), limit[1].text()]
parsed_limits: list[float | None] = []
for raw_value in raw_values:
if axis_label == 'Time':
if self.time_mode.currentText().lower() == 'relative':
parsed_value = None
else:
try:
time_parts = raw_value.replace('.', ':').split(':')
parsed_value = datetime.datetime.combine(
today,
datetime.time(*map(int, time_parts)),
).timestamp()
except (TypeError, ValueError):
parsed_value = None
else:
try:
parsed_value = float(raw_value)
except (TypeError, ValueError):
parsed_value = None
parsed_limits.append(parsed_value)
limits_payload[axis_label] = tuple(parsed_limits)
self.manager.plot_worker.limits_signal.emit(limits_payload)
[docs]
def beads_in_view_on_callback(self):
value = self.beads_in_view_on.checkbox.isChecked()
self.manager.beads_in_view_on = value
[docs]
def beads_in_view_count_callback(self):
value = self.beads_in_view_count.lineedit.text()
try:
count = int(value)
except ValueError:
count = None
self.manager.beads_in_view_count = count
[docs]
def beads_in_view_marker_size_callback(self):
value = self.beads_in_view_marker_size.lineedit.text()
try:
size = int(value)
except ValueError:
size = 100
self.manager.beads_in_view_marker_size = size
[docs]
def has_tweezepy_support() -> bool:
return importlib.util.find_spec('tweezepy') is not None
[docs]
def load_tweezepy_avar() -> tuple[callable | None, str | None]:
cached_allanvar = sys.modules.get('tweezepy.allanvar')
if cached_allanvar is not None:
avar = getattr(cached_allanvar, 'avar', None)
if avar is not None:
return avar, None
try:
package_spec = importlib.util.find_spec('tweezepy')
except (ImportError, ValueError) as exc:
return None, str(exc).strip() or repr(exc)
if package_spec is None or package_spec.origin is None:
return None, 'tweezepy package not found'
package_dir = os.path.dirname(package_spec.origin)
allanvar_path = os.path.join(package_dir, 'allanvar.py')
module_name = 'magscope_optional_tweezepy_allanvar'
try:
module_spec = importlib.util.spec_from_file_location(module_name, allanvar_path)
if module_spec is None or module_spec.loader is None:
return None, 'could not load tweezepy allanvar module'
allanvar = importlib.util.module_from_spec(module_spec)
module_spec.loader.exec_module(allanvar)
except Exception as exc:
return None, str(exc).strip() or repr(exc)
avar = getattr(allanvar, 'avar', None)
if avar is None:
return None, 'tweezepy.allanvar.avar is unavailable'
return avar, None
[docs]
class AllanDeviationPanel(MatplotlibCleanupMixin, ControlPanelBase):
[docs]
_SETTINGS_GROUP = 'AllanDeviationPanel'
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Allan Deviation', collapsed_by_default=True)
refresh_row = QHBoxLayout()
self.layout().addLayout(refresh_row)
refresh_row.addStretch(1)
self.refresh_button.clicked.connect(self.refresh_plot) # type: ignore
refresh_row.addWidget(self.refresh_button)
refresh_row.addStretch(1)
history_row = QHBoxLayout()
self.layout().addLayout(history_row)
history_row.addWidget(QLabel('History window'))
[docs]
self.history_window = QLineEdit(self._load_setting('history_window', '05:00'))
self.history_window.setPlaceholderText('SS, MM:SS, or HH:MM:SS')
self.history_window.setToolTip('Accepted formats: SS, MM:SS, or HH:MM:SS')
history_row.addWidget(self.history_window)
[docs]
self.history_window_hint = QLabel('Format: SS, MM:SS, or HH:MM:SS')
self.history_window_hint.setStyleSheet('color: #aaaaaa;')
self.layout().addWidget(self.history_window_hint)
taus_row = QHBoxLayout()
self.layout().addLayout(taus_row)
taus_row.addWidget(QLabel('Taus'))
[docs]
self.taus_mode = QComboBox()
self.taus_mode.addItems(['Octave', 'Decade'])
self.taus_mode.setCurrentText(self._load_setting('taus_mode', 'Octave'))
taus_row.addWidget(self.taus_mode)
[docs]
self.canvas = ResponsivePlotCanvas(
self.figure,
minimum_height=210,
maximum_height=235,
height_for_width=0.72,
)
[docs]
self.axes = self.figure.subplots(nrows=1, ncols=1)
self.layout().addWidget(self.canvas)
[docs]
self.status_label = QLabel('Click Refresh to compute Allan deviation')
self.status_label.setWordWrap(True)
self.layout().addWidget(self.status_label)
self._configure_axes()
self._init_matplotlib_cleanup()
self.history_window.editingFinished.connect(self._persist_controls) # type: ignore
self.taus_mode.currentTextChanged.connect(lambda _value: self._persist_controls())
[docs]
def _settings(self) -> QSettings:
return QSettings('MagScope', 'MagScope')
[docs]
def _setting_key(self, name: str) -> str:
return f'{self._SETTINGS_GROUP}/{name}'
[docs]
def _load_setting(self, name: str, default: str) -> str:
return self._settings().value(self._setting_key(name), default, type=str)
[docs]
def _persist_controls(self) -> None:
settings = self._settings()
settings.setValue(self._setting_key('history_window'), self.history_window.text().strip())
settings.setValue(self._setting_key('taus_mode'), self.taus_mode.currentText())
[docs]
def refresh_plot(self) -> None:
self._persist_controls()
avar, import_error = load_tweezepy_avar()
if avar is None:
if not has_tweezepy_support():
self.clear('Tweezepy is not installed.')
return
self.clear(f'Tweezepy import failed: {import_error}')
return
window_seconds = self._parse_window_seconds(self.history_window.text())
if window_seconds is None or window_seconds <= 0:
self.clear('Enter a positive history window like 30, 05:00, or 01:00:00.')
return
tracks = self.manager.tracks_buffer.peak_unsorted()
if tracks is None or not hasattr(tracks, 'size') or tracks.size == 0:
self.clear('No track data available yet.')
return
tracks = np.asarray(tracks, dtype=np.float64)
tracks = tracks[np.argsort(tracks[:, 0], kind='stable')]
selected_bead = self.manager.selected_bead
reference_bead = self.manager.reference_bead
taus_mode = self.taus_mode.currentText().lower()
self._configure_axes()
plotted_axes: list[str] = []
skipped_axes: list[str] = []
for axis_name, color in (('X', 'r'), ('Y', 'lime'), ('Z', 'cyan')):
timestamps, values = self._extract_axis_series(
tracks,
axis_name=axis_name,
selected_bead=selected_bead,
reference_bead=reference_bead,
)
if timestamps.size < 4 or values.size < 4:
skipped_axes.append(f'{axis_name}: insufficient aligned track samples')
continue
windowed_timestamps, windowed_values = self._apply_history_window(
timestamps,
values,
window_seconds,
)
if windowed_timestamps.size < 4 or windowed_values.size < 4:
skipped_axes.append(f'{axis_name}: insufficient recent track samples')
continue
sampling_rate = self._estimate_sampling_rate(windowed_timestamps)
if sampling_rate is None:
skipped_axes.append(f'{axis_name}: invalid sampling rate')
continue
try:
taus, _edfs, variances = avar(
windowed_values,
rate=sampling_rate,
taus=taus_mode,
overlapping=True,
)
except Exception as exc:
skipped_axes.append(f'{axis_name}: Allan deviation calculation failed ({exc})')
continue
taus = np.asarray(taus, dtype=np.float64)
deviations = np.sqrt(np.asarray(variances, dtype=np.float64))
finite = np.isfinite(taus) & np.isfinite(deviations) & (taus > 0) & (deviations > 0)
taus = taus[finite]
deviations = deviations[finite]
if taus.size == 0 or deviations.size == 0:
skipped_axes.append(f'{axis_name}: no finite Allan deviation values')
continue
self.axes.plot(taus, deviations, color=color, label=axis_name)
plotted_axes.append(axis_name)
if not plotted_axes:
self.canvas.draw()
self.status_label.setText(
'Could not plot Allan deviation. ' + ' '.join(f'Skipped {reason}.' for reason in skipped_axes)
)
return
self.axes.legend(
loc='upper right',
frameon=False,
)
self.canvas.draw()
if reference_bead is None:
source_text = f'selected bead {selected_bead}'
else:
source_text = f'selected bead {selected_bead} minus reference bead {reference_bead}'
status_message = f'Refreshed Allan deviation for {", ".join(plotted_axes)} using {source_text}.'
if skipped_axes:
status_message += ' ' + ' '.join(f'Skipped {reason}.' for reason in skipped_axes)
self.status_label.setText(status_message)
[docs]
def clear(self, message: str = 'Click Refresh to compute Allan deviation') -> None:
self._configure_axes()
self.canvas.draw()
self.status_label.setText(message)
@staticmethod
[docs]
def _parse_window_seconds(value: str) -> float | None:
text = value.strip()
if not text:
return None
try:
if ':' not in text:
return float(text)
parts = [float(part) for part in text.split(':')]
except ValueError:
return None
if len(parts) == 3:
hours, minutes, seconds = parts
return hours * 3600 + minutes * 60 + seconds
if len(parts) == 2:
minutes, seconds = parts
return minutes * 60 + seconds
if len(parts) == 1:
return parts[0]
return None
@staticmethod
[docs]
def _estimate_sampling_rate(timestamps: np.ndarray) -> float | None:
diffs = np.diff(np.asarray(timestamps, dtype=np.float64))
diffs = diffs[np.isfinite(diffs) & (diffs > 0)]
if diffs.size == 0:
return None
median_diff = float(np.median(diffs))
if median_diff <= 0:
return None
return 1.0 / median_diff
@staticmethod
[docs]
def _apply_history_window(
timestamps: np.ndarray,
values: np.ndarray,
window_seconds: float,
) -> tuple[np.ndarray, np.ndarray]:
if timestamps.size == 0:
return timestamps, values
cutoff = float(np.max(timestamps)) - float(window_seconds)
keep = timestamps >= cutoff
return timestamps[keep], values[keep]
@staticmethod
[docs]
def _extract_axis_series(
tracks: np.ndarray,
*,
axis_name: str,
selected_bead: int | None,
reference_bead: int | None,
) -> tuple[np.ndarray, np.ndarray]:
axis_index = ['X', 'Y', 'Z'].index(axis_name) + 1
finite_rows = np.isfinite(tracks[:, [0, axis_index, 4]]).all(axis=1)
tracks = tracks[finite_rows]
if tracks.size == 0 or selected_bead is None or selected_bead < 0:
return np.asarray([]), np.asarray([])
timestamps = tracks[:, 0]
bead_ids = tracks[:, 4]
values = tracks[:, axis_index]
selected_mask = bead_ids == selected_bead
timestamps_selected = timestamps[selected_mask]
values_selected = values[selected_mask]
if reference_bead is None:
return timestamps_selected, values_selected
reference_mask = bead_ids == reference_bead
timestamps_reference = timestamps[reference_mask]
values_reference = values[reference_mask]
if timestamps_selected.size == 0 or timestamps_reference.size == 0:
return np.asarray([]), np.asarray([])
aligned_timestamps, index_selected, index_reference = np.intersect1d(
timestamps_selected,
timestamps_reference,
assume_unique=False,
return_indices=True,
)
aligned_values = values_selected[index_selected] - values_reference[index_reference]
if axis_name == 'Z':
aligned_values *= -1
return aligned_timestamps, aligned_values
[docs]
class ProfilePanel(MatplotlibCleanupMixin, ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Radial Profile Monitor', collapsed_by_default=True)
# Controls
controls_row = QHBoxLayout()
controls_row.setContentsMargins(0, 0, 0, 0)
controls_row.setSpacing(8)
self.layout().addLayout(controls_row)
[docs]
self.enable = LabeledCheckbox(
label_text='Enabled',
callback=self.enabled_callback,
)
controls_row.addWidget(self.enable)
self.groupbox.toggle_button.toggled.connect(self._groupbox_toggled)
controls_row.addWidget(QLabel('Bead:'))
[docs]
self.selected_bead_label = QLabel('')
self.selected_bead_label.setMinimumWidth(24)
controls_row.addWidget(self.selected_bead_label)
controls_row.addWidget(QLabel('Length:'))
[docs]
self.profile_length_label = QLabel('')
self.profile_length_label.setMinimumWidth(36)
controls_row.addWidget(self.profile_length_label)
controls_row.addStretch(1)
# Figure
self.figure.set_constrained_layout_pads(w_pad=0.02, h_pad=0.02, hspace=0.0, wspace=0.0)
[docs]
self.canvas = ResponsivePlotCanvas(
self.figure,
minimum_height=102,
maximum_height=123,
height_for_width=0.32,
)
self.layout().addWidget(self.canvas)
# Plot
[docs]
self.axes = self.figure.subplots(nrows=1, ncols=1)
self.axes.set_facecolor(PANEL_BACKGROUND_COLOR)
self.axes.set_xlabel('Radius (pixels)')
self.axes.set_ylabel('Intensity')
plot_font_size = self.font().pointSizeF()
if plot_font_size <= 0:
plot_font_size = float(self.font().pointSize() or 9)
plot_font_size = max(6.0, plot_font_size - 1.5)
self.axes.xaxis.label.set_size(plot_font_size)
self.axes.yaxis.label.set_size(plot_font_size)
self.axes.tick_params(axis='both', labelsize=plot_font_size)
self.axes.spines['top'].set_visible(False)
self.axes.spines['right'].set_visible(False)
self.axes.spines['left'].set_visible(True)
self.axes.set_yticks([])
self.line, = self.axes.plot([], [], color=get_accent_color(), linewidth=1.0)
self._init_matplotlib_cleanup()
[docs]
def enabled_callback(self, enabled: bool) -> None:
effective_enabled = enabled and not self.groupbox.collapsed
self._apply_enabled_state(effective_enabled)
[docs]
def _groupbox_toggled(self, expanded: bool) -> None:
enabled = expanded and self.enable.checkbox.isChecked()
self._apply_enabled_state(enabled)
[docs]
def _apply_enabled_state(self, enabled: bool) -> None:
self.set_highlighted(enabled)
self.manager.set_live_profile_monitor_enabled(enabled)
self.clear()
[docs]
def update_plot(self):
if not self.enable.checkbox.isChecked() or self.groupbox.collapsed:
return
selected_bead = self.manager.selected_bead
if selected_bead == -1:
self.selected_bead_label.setText('')
else:
self.selected_bead_label.setText(str(selected_bead))
if not self.manager.shared_values.live_profile_enabled.value:
self.clear()
return
buffer_data = self.manager.live_profile_buffer.peak_unsorted()
latest_entry = buffer_data[0]
profile_length = latest_entry[2] if np.isfinite(latest_entry[2]) else 0
bead_id = int(latest_entry[1]) if np.isfinite(latest_entry[1]) else -1
if selected_bead != bead_id or profile_length <= 0:
self.clear()
return
self.profile_length_label.setText(str(int(profile_length)))
cleaned_profile = latest_entry[3:3 + int(profile_length)]
radial_distances = np.arange(profile_length)
radial_distances = radial_distances[np.isfinite(cleaned_profile)]
cleaned_profile = cleaned_profile[np.isfinite(cleaned_profile)]
self.line.set_xdata(radial_distances)
self.line.set_ydata(cleaned_profile)
self.line.set_color(get_accent_color())
if len(cleaned_profile) > 0:
self.axes.set_xlim(0, max(radial_distances))
self.axes.set_ylim(0, max(cleaned_profile))
self.canvas.draw()
[docs]
def clear(self):
self.selected_bead_label.setText('')
self.profile_length_label.setText('')
self.line.set_xdata([])
self.line.set_ydata([])
self.canvas.draw()
[docs]
class TrackingOptionsPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager', *, collapsible: bool = True):
super().__init__(
manager=manager,
title='Tracking Options' if collapsible else '',
collapsed_by_default=True,
collapsible=collapsible,
)
[docs]
self._current_options: dict[str, Any] = tracking_options_from_qsettings()
[docs]
self._updating_fields = False
self.setMaximumWidth(760)
self.layout().setContentsMargins(20, 6, 20, 12)
self.layout().setSpacing(6)
note = QLabel(
textwrap.dedent(
"""
<a href="https://magtrack.readthedocs.io/en/stable/api/magtrack/core/index.html#magtrack.core.stack_to_xyzp_advanced">Advanced Tracking Options Guide</a>
<br>Configure the arguments forwarded to MagTrack's
stack_to_xyzp_advanced pipeline. Changes are applied when a field loses focus
or Enter is pressed. Defaults reflect MagTrack's standard parameters.
"""
).strip()
)
note.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
note.setOpenExternalLinks(True)
note.setWordWrap(True)
guide_group, guide_layout = self._build_preferences_group('Guide', self)
note.setObjectName("preferencesDescription")
guide_layout.addWidget(note)
self.layout().addWidget(guide_group)
general_group, general_layout = self._build_preferences_group('General', self)
background_row = QHBoxLayout()
background_row.setContentsMargins(0, 0, 0, 0)
background_row.setSpacing(10)
background_label = QLabel('Center-of-mass background')
background_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
background_label.setFixedWidth(155)
background_row.addWidget(background_label)
[docs]
self.background_combo = QComboBox()
self.background_combo.addItems(['none', 'mean', 'median'])
self.background_combo.setCurrentText(self._current_options['center_of_mass']['background'])
self.background_combo.currentTextChanged.connect(lambda _value: self._apply_options_from_inputs())
self.background_combo.setFixedWidth(120)
background_row.addWidget(self.background_combo)
background_row.addStretch(1)
general_layout.addLayout(background_row)
self.layout().addWidget(general_group)
auto_group, auto_layout = self._build_preferences_group('Auto Convolution', self)
[docs]
self.iterations = LabeledLineEditWithValue(
label_text='Auto-conv iterations',
default=str(self._current_options['n auto_conv_multiline_sub_pixel']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.iterations)
auto_layout.addWidget(self.iterations)
[docs]
self.line_ratio = LabeledLineEditWithValue(
label_text='Line ratio',
default=str(self._current_options['auto_conv_multiline_sub_pixel']['line_ratio']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.line_ratio)
auto_layout.addWidget(self.line_ratio)
[docs]
self.n_local = LabeledLineEditWithValue(
label_text='n_local (auto-conv)',
default=str(self._current_options['auto_conv_multiline_sub_pixel']['n_local']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.n_local)
auto_layout.addWidget(self.n_local)
self.layout().addWidget(auto_group)
profile_group, profile_layout = self._build_preferences_group('Profiles', self)
[docs]
self.use_fft = LabeledCheckbox(
label_text='Use FFT profile',
widths=(155, 0),
callback=self._use_fft_changed,
)
self._configure_checkbox_row(self.use_fft)
profile_layout.addWidget(self.use_fft)
[docs]
self.fft_oversample = LabeledLineEditWithValue(
label_text='FFT oversample',
default=str(self._current_options['fft_profile']['oversample']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.fft_oversample)
profile_layout.addWidget(self.fft_oversample)
[docs]
self.fft_rmin = LabeledLineEditWithValue(
label_text='FFT rmin',
default=str(self._current_options['fft_profile']['rmin']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.fft_rmin)
profile_layout.addWidget(self.fft_rmin)
[docs]
self.fft_rmax = LabeledLineEditWithValue(
label_text='FFT rmax',
default=str(self._current_options['fft_profile']['rmax']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.fft_rmax)
profile_layout.addWidget(self.fft_rmax)
[docs]
self.fft_gaus_factor = LabeledLineEditWithValue(
label_text='FFT gaus_factor',
default=str(self._current_options['fft_profile']['gaus_factor']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.fft_gaus_factor)
profile_layout.addWidget(self.fft_gaus_factor)
[docs]
self.radial_oversample = LabeledLineEditWithValue(
label_text='Radial oversample',
default=str(self._current_options['radial_profile']['oversample']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.radial_oversample)
profile_layout.addWidget(self.radial_oversample)
[docs]
self.lookup_n_local = LabeledLineEditWithValue(
label_text='lookup_z n_local',
default=str(self._current_options['lookup_z']['n_local']),
widths=(155, 120, 0),
)
self._configure_lineedit_row(self.lookup_n_local)
profile_layout.addWidget(self.lookup_n_local)
self.layout().addWidget(profile_group)
for widget in self._option_line_edits():
widget.lineedit.editingFinished.connect(self._apply_options_from_inputs) # type: ignore[arg-type]
self._update_value_labels()
self._populate_inputs_from_options()
self._sync_fft_enabled_state()
self.layout().addStretch(1)
@staticmethod
[docs]
def _build_preferences_group(title: str, parent: QWidget) -> tuple[QWidget, QVBoxLayout]:
group = QWidget(parent)
group_layout = QVBoxLayout(group)
group_layout.setContentsMargins(0, 0, 0, 0)
group_layout.setSpacing(5)
title_label = QLabel(title, group)
title_label.setObjectName("preferencesGroupTitle")
group_layout.addWidget(title_label)
panel = QFrame(group)
panel.setObjectName("preferencesGroupPanel")
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(14, 10, 14, 10)
panel_layout.setSpacing(3)
group_layout.addWidget(panel)
return group, panel_layout
@staticmethod
@staticmethod
@staticmethod
[docs]
def search_targets() -> list[SearchTarget]:
return _preference_widget_targets(
(
('use_fft', 'Use FFT profile', ('FFT profile', 'enable FFT profile')),
('fft_oversample', 'FFT oversample', ('FFT oversampling',)),
('fft_rmin', 'FFT rmin', ('FFT Rmin', 'rmin', 'FFT r min', 'minimum FFT radius')),
('fft_rmax', 'FFT rmax', ('FFT Rmax', 'rmax', 'FFT r max', 'maximum FFT radius')),
('fft_gaus_factor', 'FFT gaus_factor', ('FFT gaussian factor', 'FFT gaus factor')),
('radial_oversample', 'Radial oversample', ('radial oversampling',)),
('lookup_n_local', 'lookup_z n_local', ('lookup z n local', 'z lookup n local')),
('iterations', 'Auto-conv iterations', ('auto conv iterations', 'auto convolution iterations')),
('line_ratio', 'Line ratio', ('tracking line ratio',)),
('n_local', 'n_local (auto-conv)', ('n local auto conv', 'auto conv n local')),
),
tab_name='Tracking',
context='Preferences > Tracking',
)
[docs]
def _option_line_edits(self) -> tuple[LabeledLineEditWithValue, ...]:
return (
self.iterations,
self.line_ratio,
self.n_local,
self.fft_oversample,
self.fft_rmin,
self.fft_rmax,
self.fft_gaus_factor,
self.radial_oversample,
self.lookup_n_local,
)
[docs]
def _update_value_labels(self) -> None:
self.iterations.value_label.setText(str(self._current_options['n auto_conv_multiline_sub_pixel']))
self.line_ratio.value_label.setText(str(self._current_options['auto_conv_multiline_sub_pixel']['line_ratio']))
self.n_local.value_label.setText(str(self._current_options['auto_conv_multiline_sub_pixel']['n_local']))
self.radial_oversample.value_label.setText(str(self._current_options['radial_profile']['oversample']))
self.lookup_n_local.value_label.setText(str(self._current_options['lookup_z']['n_local']))
fft_settings = self._current_options['fft_profile']
self.fft_oversample.value_label.setText(str(fft_settings['oversample']))
self.fft_rmin.value_label.setText(str(fft_settings['rmin']))
self.fft_rmax.value_label.setText(str(fft_settings['rmax']))
self.fft_gaus_factor.value_label.setText(str(fft_settings['gaus_factor']))
self.use_fft.checkbox.blockSignals(True)
self.use_fft.checkbox.setChecked(bool(self._current_options['use fft_profile']))
self.use_fft.checkbox.blockSignals(False)
[docs]
def _sync_fft_enabled_state(self) -> None:
use_fft = self.use_fft.checkbox.isChecked()
for widget in (self.fft_oversample, self.fft_rmin, self.fft_rmax, self.fft_gaus_factor):
widget.setEnabled(use_fft)
self.radial_oversample.setEnabled(not use_fft)
[docs]
def _use_fft_changed(self, value: bool) -> None:
if self._updating_fields:
return
self._apply_options_from_inputs()
[docs]
def _set_options(
self,
options: dict[str, Any],
message: str | None = None,
*,
populate_inputs: bool = False,
) -> None:
self._current_options = tracking_options_from_mapping(options)
self._updating_fields = True
try:
self.background_combo.blockSignals(True)
self.background_combo.setCurrentText(self._current_options['center_of_mass']['background'])
self.background_combo.blockSignals(False)
self._update_value_labels()
self._populate_inputs_from_options()
self._sync_fft_enabled_state()
finally:
self.background_combo.blockSignals(False)
self._updating_fields = False
save_tracking_options_to_qsettings(self._current_options)
self.manager.send_ipc(UpdateTrackingOptionsCommand(value=copy.deepcopy(self._current_options)))
[docs]
def _load_options_from_mapping(self, loaded: Any) -> dict[str, Any]:
return tracking_options_from_mapping(loaded)
[docs]
def reset_defaults(self) -> None:
self._set_options(default_tracking_options(), 'Defaults restored', populate_inputs=True)
[docs]
class PreferencesDialog(QDialog):
"""Modal dialog for global MagScope preferences."""
def __init__(self, manager: 'UIManager'):
super().__init__(manager.windows[0] if getattr(manager, 'windows', None) else None)
self.setWindowTitle('Preferences')
self.setModal(True)
self.resize(880, 700)
self._apply_preferences_style(self.manager.settings[GUI_ACCENT_COLOR_SETTING])
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# --- header bar ---
header = QWidget(self)
header.setObjectName("preferencesHeader")
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(20, 8, 20, 8)
header_layout.setSpacing(8)
header_layout.addWidget(QLabel("Preferences file:"))
self.load_preferences_button.clicked.connect(self._on_load_preferences_clicked) # type: ignore[arg-type]
header_layout.addWidget(self.load_preferences_button)
self.save_preferences_button.clicked.connect(self._on_save_preferences_clicked) # type: ignore[arg-type]
header_layout.addWidget(self.save_preferences_button)
self.reset_all_preferences_button.clicked.connect(self._on_reset_all_preferences_clicked) # type: ignore[arg-type]
header_layout.addWidget(self.reset_all_preferences_button)
[docs]
self.preferences_file_status = FlashLabel()
self.preferences_file_status.setText("")
self.preferences_file_status.setVisible(False)
header_layout.addStretch(1)
layout.addWidget(header)
# --- separator ---
separator = QFrame(self)
separator.setObjectName("preferencesSeparator")
separator.setFrameShape(QFrame.Shape.HLine)
layout.addWidget(separator)
# --- content: sidebar + stacked pages ---
content_row = QHBoxLayout()
content_row.setSpacing(0)
[docs]
self.settings_panel = MagScopeSettingsPanel(
manager,
collapsible=False,
file_status_label=self.preferences_file_status,
)
[docs]
self.tracking_options_panel = TrackingOptionsPanel(manager, collapsible=False)
[docs]
self.appearance_layout_tab = self._create_appearance_layout_tab()
[docs]
self.stack = QStackedWidget(self)
self.stack.addWidget(self.settings_scroll)
self.stack.addWidget(self.tracking_scroll)
self.stack.addWidget(self.appearance_scroll)
right_panel = QWidget(self)
right_panel.setObjectName("preferencesRightPanel")
right_layout = QVBoxLayout(right_panel)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(0)
right_layout.addWidget(self.stack, 1)
footer = QWidget(right_panel)
footer.setObjectName("preferencesFooter")
footer_layout = QHBoxLayout(footer)
footer_layout.setContentsMargins(20, 6, 20, 8)
footer_layout.setSpacing(0)
footer_content = QWidget(footer)
footer_content.setMaximumWidth(760)
footer_content.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
footer_content_layout = QHBoxLayout(footer_content)
footer_content_layout.setContentsMargins(0, 0, 0, 0)
footer_content_layout.addStretch(1)
self.reset_section_button.clicked.connect(self._on_reset_current_section) # type: ignore[arg-type]
footer_content_layout.addWidget(self.reset_section_button)
footer_layout.addWidget(footer_content)
right_layout.addWidget(footer)
self.sidebar.setFixedWidth(210)
self.sidebar.setSpacing(0)
self.sidebar.currentRowChanged.connect(self._on_sidebar_selection) # type: ignore[arg-type]
icon_size = QSize(20, 20)
self.sidebar.setIconSize(icon_size)
icon_font = type(self.manager)._material_symbols_font(point_size=16)
for icon_name, label in self._SIDEBAR_SECTIONS:
icon = self._make_material_symbol_icon(icon_font, icon_name, "#888888", icon_size.width())
item = QListWidgetItem(icon, label)
item.setSizeHint(QSize(0, 48))
self.sidebar.addItem(item)
content_row.addWidget(self.sidebar)
content_row.addWidget(right_panel, 1)
layout.addLayout(content_row, 1)
self.sidebar.setCurrentRow(0)
self._refresh_sidebar_icons()
@staticmethod
[docs]
def _apply_preferences_style(self, accent: str) -> None:
selected_background = self._sidebar_selection_background(accent)
self.setStyleSheet(
f"""
QDialog {{
background-color: #111111;
}}
#preferencesHeader {{
background-color: #161616;
}}
#preferencesHeader QLabel {{
color: #bbbbbb;
background: transparent;
}}
#preferencesHeader QPushButton {{
background-color: #242424;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 4px 12px;
color: #cccccc;
}}
#preferencesHeader QPushButton:hover {{
background-color: #333333;
}}
#preferencesSeparator {{
background-color: #2a2a2a;
max-height: 1px;
}}
QListWidget {{
background-color: #1b1b1b;
border: none;
border-right: 1px solid #2a2a2a;
padding: 6px 0px;
outline: none;
}}
QListWidget::item {{
padding: 0px 14px;
margin: 0px;
border-radius: 0px;
border-left: 2px solid transparent;
color: #cccccc;
}}
QListWidget::item:selected {{
background-color: {selected_background};
border-left: 2px solid {accent};
color: #f0f0f0;
}}
QListWidget::item:hover:!selected {{
background-color: #222222;
border-left: 2px solid #333333;
}}
#preferencesRightPanel {{
background-color: #111111;
}}
QStackedWidget {{
background-color: #111111;
}}
QScrollArea {{
background-color: transparent;
border: none;
}}
#preferencesGroupPanel {{
background-color: #181818;
border: 1px solid #2a2a2a;
border-radius: 4px;
}}
#preferencesGroupTitle {{
color: #c6c6c6;
font-weight: bold;
}}
QLineEdit {{
background-color: #242424;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 2px 6px;
color: #e0e0e0;
selection-background-color: {accent};
}}
QLineEdit:focus {{
border: 1px solid {accent};
}}
QComboBox {{
background-color: #242424;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 3px 6px;
color: #e0e0e0;
}}
QComboBox::drop-down {{
border: none;
}}
QComboBox QAbstractItemView {{
background-color: #242424;
border: 1px solid #3a3a3a;
color: #e0e0e0;
selection-background-color: {selected_background};
selection-color: #f0f0f0;
}}
QLabel {{
color: #bbbbbb;
background: transparent;
}}
#preferencesSavedLabel {{
color: #777777;
font-size: 11px;
padding-left: 4px;
}}
#preferencesDescription {{
color: #888888;
}}
#preferencesFooter {{
background-color: #141414;
border-top: 1px solid #242424;
}}
#preferencesFooter QPushButton {{
background-color: #242424;
border: 1px solid #3a3a3a;
border-radius: 3px;
padding: 5px 16px;
color: #cccccc;
}}
#preferencesFooter QPushButton:hover {{
background-color: #333333;
}}
"""
)
[docs]
def _on_load_preferences_clicked(self) -> None:
path, _ = QFileDialog.getOpenFileName(
self,
'Import preferences',
'',
'YAML Files (*.yaml);;All Files (*)',
)
if not path:
return
try:
bundle = import_preferences_bundle(path)
validate_layout = getattr(self.manager, 'validate_appearance_layout_preferences', None)
if callable(validate_layout):
validate_layout(bundle['appearance_layout'])
import_layout = getattr(self.manager, 'import_appearance_layout_preferences', None)
if callable(import_layout):
import_layout(bundle['appearance_layout'])
self.settings_panel._push_settings(bundle['magscope'])
accent_color = self.manager.settings[GUI_ACCENT_COLOR_SETTING]
self._apply_preferences_style(accent_color)
self._refresh_sidebar_icons(accent_color)
self.accent_color_input.setText(accent_color)
self._update_accent_color_swatch(accent_color)
self.live_plot_progress_indicator_checkbox.checkbox.setChecked(
self.manager.settings[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING]
)
self.tracking_options_panel._set_options(
bundle['tracking'],
f'Imported preferences from {os.path.basename(path)}',
populate_inputs=True,
)
except (OSError, ValueError) as exc:
QMessageBox.critical(self, 'Preferences', str(exc))
return
self._set_preferences_file_status(f'Imported preferences from {os.path.basename(path)}')
[docs]
def _on_save_preferences_clicked(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self,
'Export preferences',
'magscope-preferences.yaml',
'YAML Files (*.yaml);;All Files (*)',
)
if not path:
return
export_layout = getattr(self.manager, 'export_appearance_layout_preferences', None)
appearance_layout = export_layout() if callable(export_layout) else {}
try:
export_preferences_bundle(
path,
magscope_settings=self.manager.settings.clone(),
tracking_options=self.tracking_options_panel._current_options,
appearance_layout=appearance_layout,
)
except (OSError, ValueError) as exc:
QMessageBox.critical(self, 'Preferences', str(exc))
return
self._set_preferences_file_status(f'Exported preferences to {os.path.basename(path)}')
[docs]
def _on_reset_all_preferences_clicked(self) -> None:
confirmation = QMessageBox.question(
self,
'Reset Preferences',
'Reset all MagScope, tracking, appearance, and layout preferences to defaults?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirmation != QMessageBox.StandardButton.Yes:
return
self.settings_panel._push_settings(MagScopeSettings())
self.tracking_options_panel.reset_defaults()
self._reset_appearance_layout(reset_accent=False)
accent_color = self.manager.settings[GUI_ACCENT_COLOR_SETTING]
self._apply_preferences_style(accent_color)
self._refresh_sidebar_icons(accent_color)
self.accent_color_input.setText(accent_color)
self._update_accent_color_swatch(accent_color)
self.live_plot_progress_indicator_checkbox.checkbox.setChecked(
self.manager.settings[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING]
)
self._set_preferences_file_status('All preferences reset to defaults')
[docs]
def _set_preferences_file_status(self, message: str) -> None:
self.preferences_file_status.setText(message)
self.preferences_file_status.setVisible(bool(message))
[docs]
def _create_appearance_layout_tab(self) -> QWidget:
tab = QWidget(self)
tab.setMaximumWidth(760)
layout = QVBoxLayout(tab)
layout.setContentsMargins(20, 6, 20, 12)
layout.setSpacing(6)
accent_group = QWidget(tab)
accent_group_layout = QVBoxLayout(accent_group)
accent_group_layout.setContentsMargins(0, 0, 0, 0)
accent_group_layout.setSpacing(5)
accent_title = QLabel("Accent", accent_group)
accent_title.setObjectName("preferencesGroupTitle")
accent_group_layout.addWidget(accent_title)
accent_panel = QFrame(accent_group)
accent_panel.setObjectName("preferencesGroupPanel")
accent_inner = QHBoxLayout(accent_panel)
accent_inner.setContentsMargins(14, 10, 14, 10)
accent_inner.setSpacing(10)
accent_label = QLabel("Accent color", accent_panel)
accent_label.setFixedWidth(155)
accent_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
accent_inner.addWidget(accent_label)
self.accent_color_input = QLineEdit(
self.manager.settings[GUI_ACCENT_COLOR_SETTING],
accent_panel,
)
self.accent_color_input.setObjectName("AccentColorInput")
self.accent_color_input.setFixedWidth(120)
self.accent_color_input.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.accent_color_input.setToolTip(
f"Use #RRGGBB hex format, for example {DEFAULT_GUI_ACCENT_COLOR}."
)
self.accent_color_input.editingFinished.connect( # type: ignore[arg-type]
self._apply_accent_color_setting
)
accent_inner.addWidget(self.accent_color_input)
self.accent_color_swatch = QPushButton("", accent_panel)
self.accent_color_swatch.setObjectName("AccentColorSwatch")
self.accent_color_swatch.setFixedSize(28, 28)
self.accent_color_swatch.setCursor(Qt.CursorShape.PointingHandCursor)
self.accent_color_swatch.setToolTip("Choose accent color")
self.accent_color_swatch.clicked.connect( # type: ignore[arg-type]
self._choose_accent_color
)
accent_inner.addWidget(self.accent_color_swatch)
accent_inner.addStretch(1)
accent_group_layout.addWidget(accent_panel)
layout.addWidget(accent_group)
self._update_accent_color_swatch(self.manager.settings[GUI_ACCENT_COLOR_SETTING])
live_plots_title = QLabel("Live Plots", tab)
live_plots_title.setObjectName("preferencesGroupTitle")
layout.addWidget(live_plots_title)
live_plots_panel = QFrame(tab)
live_plots_panel.setObjectName("preferencesGroupPanel")
live_plots_inner = QVBoxLayout(live_plots_panel)
live_plots_inner.setContentsMargins(14, 10, 14, 10)
live_plots_inner.setSpacing(6)
self.live_plot_progress_indicator_checkbox = LabeledCheckbox(
label_text="Show live plot loading indicator",
widths=(190, 0),
default=self.manager.settings[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING],
callback=self._apply_live_plot_progress_indicator_setting,
)
self.live_plot_progress_indicator_checkbox.setObjectName("LivePlotProgressIndicatorCheckbox")
live_plots_inner.addWidget(self.live_plot_progress_indicator_checkbox)
layout.addWidget(live_plots_panel)
self.appearance_status_label = FlashLabel()
self.appearance_status_label.setText("")
layout.addWidget(self.appearance_status_label)
layout.addStretch(1)
return tab
[docs]
def _choose_accent_color(self) -> None:
current_color = self.manager.settings[GUI_ACCENT_COLOR_SETTING]
color = QColorDialog.getColor(
QColor(current_color),
self,
'Choose accent color',
)
if color.isValid():
self.accent_color_input.setText(color.name())
self._apply_accent_color_setting()
[docs]
def _update_accent_color_swatch(self, color: str) -> None:
self.accent_color_swatch.setStyleSheet(
f"""
#AccentColorSwatch {{
background-color: {color};
border: 1px solid palette(mid);
border-radius: 3px;
}}
#AccentColorSwatch:hover {{
border: 1px solid palette(light);
}}
"""
)
[docs]
def _apply_accent_color_setting(self) -> None:
settings = self.manager.settings.clone()
try:
settings[GUI_ACCENT_COLOR_SETTING] = self.accent_color_input.text()
except ValueError as exc:
QMessageBox.critical(self, 'Accent Color', str(exc))
current_color = self.manager.settings[GUI_ACCENT_COLOR_SETTING]
self.accent_color_input.setText(current_color)
self._update_accent_color_swatch(current_color)
return
accent_color = settings[GUI_ACCENT_COLOR_SETTING]
if accent_color == self.manager.settings[GUI_ACCENT_COLOR_SETTING]:
self._apply_preferences_style(accent_color)
self._refresh_sidebar_icons(accent_color)
self.accent_color_input.setText(accent_color)
self._update_accent_color_swatch(accent_color)
return
self.manager.settings = settings.clone()
self.settings_panel._current_settings = settings.clone()
apply_accent_color = getattr(self.manager, '_apply_accent_color', None)
if callable(apply_accent_color):
apply_accent_color(accent_color)
self.manager.send_ipc(UpdateSettingsCommand(settings=settings.clone()))
self._apply_preferences_style(accent_color)
self._refresh_sidebar_icons(accent_color)
self.accent_color_input.setText(accent_color)
self._update_accent_color_swatch(accent_color)
self.settings_panel._refresh_fields()
self.appearance_status_label.setText('Accent color updated')
[docs]
def _apply_live_plot_progress_indicator_setting(self, checked: bool) -> None:
settings = self.manager.settings.clone()
settings[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING] = checked
if settings[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING] == self.manager.settings[
GUI_LIVE_PLOT_PROGRESS_BAR_SETTING
]:
return
self.manager.settings = settings.clone()
self.settings_panel._current_settings = settings.clone()
apply_progress_indicator_enabled = getattr(
self.manager,
'_apply_live_plot_progress_indicator_enabled',
None,
)
if callable(apply_progress_indicator_enabled):
apply_progress_indicator_enabled()
self.manager.send_ipc(UpdateSettingsCommand(settings=settings.clone()))
self.settings_panel._refresh_fields()
self.appearance_status_label.setText(
'Live plot loading indicator shown' if checked else 'Live plot loading indicator hidden'
)
@staticmethod
[docs]
def _make_material_symbol_icon(
font: QFont,
text: str,
color: str = "#888888",
size: int = 16,
) -> QIcon:
screen = QApplication.primaryScreen()
ratio = max(screen.devicePixelRatio() if screen is not None else 1.0, 1.0)
pixmap = QPixmap(round(size * ratio), round(size * ratio))
pixmap.setDevicePixelRatio(ratio)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing, True)
painter.setFont(font)
painter.setPen(QColor(color))
painter.drawText(0, 0, size, size, Qt.AlignmentFlag.AlignCenter, text)
painter.end()
return QIcon(pixmap)
[docs]
def _on_reset_current_section(self) -> None:
index = self.stack.currentIndex()
section_labels = [label for _, label in self._SIDEBAR_SECTIONS]
section_name = section_labels[index] if 0 <= index < len(section_labels) else "this section"
confirmation = QMessageBox.question(
self,
f"Reset {section_name}",
f"Reset {section_name} preferences to defaults?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirmation != QMessageBox.StandardButton.Yes:
return
if index == 0:
self.settings_panel.reset_defaults()
elif index == 1:
self.tracking_options_panel.reset_defaults()
elif index == 2:
self._reset_appearance_layout(reset_accent=True)
[docs]
def reveal_setting(self, setting_key: str) -> None:
self.reveal_magscope_setting(setting_key)
[docs]
def reveal_magscope_setting(self, setting_key: str) -> None:
widget = self.settings_panel._setting_inputs.get(setting_key)
if widget is None:
return
self._reveal_widget(self.settings_scroll, widget)
[docs]
def reveal_tracking_option(self, widget_attr: str) -> None:
self.reveal_widget('Tracking', widget_attr)
[docs]
def _on_reset_appearance_tab_clicked(self) -> None:
confirmation = QMessageBox.question(
self,
'Reset Appearance/Layout',
'Reset appearance and layout preferences to defaults?',
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirmation == QMessageBox.StandardButton.Yes:
self._reset_appearance_layout(reset_accent=True)
[docs]
def _reset_appearance_layout(self, *, reset_accent: bool) -> None:
if reset_accent:
settings = self.manager.settings.clone()
settings[GUI_ACCENT_COLOR_SETTING] = DEFAULT_GUI_ACCENT_COLOR
settings[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING] = MagScopeSettings()[
GUI_LIVE_PLOT_PROGRESS_BAR_SETTING
]
self.manager.settings = settings.clone()
self.settings_panel._current_settings = settings.clone()
apply_accent_color = getattr(self.manager, '_apply_accent_color', None)
if callable(apply_accent_color):
apply_accent_color(DEFAULT_GUI_ACCENT_COLOR)
apply_progress_indicator_enabled = getattr(
self.manager,
'_apply_live_plot_progress_indicator_enabled',
None,
)
if callable(apply_progress_indicator_enabled):
apply_progress_indicator_enabled()
self.manager.send_ipc(UpdateSettingsCommand(settings=settings.clone()))
self._apply_preferences_style(DEFAULT_GUI_ACCENT_COLOR)
self._refresh_sidebar_icons(DEFAULT_GUI_ACCENT_COLOR)
self.accent_color_input.setText(DEFAULT_GUI_ACCENT_COLOR)
self._update_accent_color_swatch(DEFAULT_GUI_ACCENT_COLOR)
self.live_plot_progress_indicator_checkbox.checkbox.setChecked(
settings[GUI_LIVE_PLOT_PROGRESS_BAR_SETTING]
)
self.settings_panel._refresh_fields()
reset_layout = getattr(self.manager, 'reset_appearance_layout_preferences', None)
if callable(reset_layout):
reset_layout()
self.appearance_status_label.setText('Appearance/Layout reset to defaults')
[docs]
class ScriptPanel(ControlPanelBase):
[docs]
NO_SCRIPT_SELECTED_TEXT = 'No script loaded'
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Scripting', collapsed_by_default=True)
[docs]
self.status_prefix = 'Status'
[docs]
self.status_label = QLabel('Status: Empty')
self.layout().addWidget(self.status_label)
[docs]
self.step_position_label = QLabel()
self.step_position_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.layout().addWidget(self.step_position_label)
[docs]
self.step_description_label = QLabel()
self.step_description_label.setWordWrap(True)
self.step_description_label.setAlignment(Qt.AlignmentFlag.AlignLeft)
self.layout().addWidget(self.step_description_label)
# Button Layout
self.layout().addLayout(self.button_layout)
# Buttons
self.button_layout.addWidget(self.load_button)
self.button_layout.addWidget(self.start_button)
self.button_layout.addWidget(self.pause_button)
self.load_button.clicked.connect(self.callback_load) # type: ignore
self.start_button.clicked.connect(self.callback_start) # type: ignore
self.pause_button.clicked.connect(self.callback_pause) # type: ignore
# Filepath
[docs]
self.filepath_textedit = QTextEdit(self.NO_SCRIPT_SELECTED_TEXT)
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.filepath_textedit.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
self.filepath_textedit.setFixedHeight(40)
self.filepath_textedit.setWordWrapMode(QTextOption.WrapMode.NoWrap)
self.filepath_textedit.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.layout().addWidget(self.filepath_textedit)
self.update_status(ScriptStatus.EMPTY)
self.update_step(None, 0, None)
[docs]
def update_status(self, status: ScriptStatus):
self.status_label.setText(f'{self.status_prefix}: {status}')
if status == ScriptStatus.PAUSED:
self.pause_button.setText('Resume')
else:
self.pause_button.setText('Pause')
self.start_button.setEnabled(status in (ScriptStatus.LOADED, ScriptStatus.FINISHED))
self.pause_button.setEnabled(status in (ScriptStatus.RUNNING, ScriptStatus.PAUSED))
if status == ScriptStatus.EMPTY:
self.filepath_textedit.setText(self.NO_SCRIPT_SELECTED_TEXT)
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
elif status == ScriptStatus.ERROR:
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
[docs]
def update_step(self, current_step: int | None, total_steps: int, description: str | None):
total_steps = max(total_steps, 0)
current_text = '-' if current_step is None else str(current_step)
total_text = '-' if total_steps == 0 else str(total_steps)
position_text = f'{current_text}/{total_text}'
self.step_position_label.setText(f'Step: {position_text}')
if description:
self.step_description_label.setText(description)
self.step_description_label.setVisible(True)
else:
self.step_description_label.clear()
self.step_description_label.setVisible(False)
[docs]
def callback_load(self):
settings = QSettings('MagScope', 'MagScope')
last_script_path = settings.value(
'last script filepath',
os.path.expanduser("~"),
type=str
)
script_path, _ = QFileDialog.getOpenFileName(None,
'Select Script File',
last_script_path,
'Script (*.py)')
command = LoadScriptCommand(path=script_path)
self.manager.send_ipc(command)
if not script_path: # user selected cancel
script_path = self.NO_SCRIPT_SELECTED_TEXT
else:
settings.setValue('last script filepath', QVariant(script_path))
self.filepath_textedit.setText(script_path)
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
[docs]
def callback_start(self):
command = StartScriptCommand()
self.manager.send_ipc(command)
[docs]
def callback_pause(self):
if self.pause_button.text() == 'Pause':
command = PauseScriptCommand()
self.manager.send_ipc(command)
else:
command = ResumeScriptCommand()
self.manager.send_ipc(command)
[docs]
class StatusPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Status', collapsed_by_default=False)
self.layout().setSpacing(0)
# GUI display rate
[docs]
self.display_rate_status = QLabel()
self.layout().addWidget(self.display_rate_status)
# Video Processors
[docs]
self.video_processors_status = QLabel()
self.layout().addWidget(self.video_processors_status)
# Video Buffer
[docs]
self.video_buffer_size_status = QLabel()
self._update_video_buffer_size_label()
self.layout().addWidget(self.video_buffer_size_status)
[docs]
self.video_buffer_status = QLabel()
self.layout().addWidget(self.video_buffer_status)
[docs]
self.video_buffer_status_bar = QProgressBar()
self.video_buffer_status_bar.setOrientation(Qt.Orientation.Horizontal)
self.layout().addWidget(self.video_buffer_status_bar)
# Video Buffer Purge
[docs]
self.video_buffer_purge_label = FlashLabel('Video Buffer Purged at: ')
self.layout().addWidget(self.video_buffer_purge_label)
[docs]
def update_display_rate(self, text):
self.dot_count = (self.dot_count + 1) % 4
dot_text = '.' * self.dot_count
self.display_rate_status.setText(f'Display Rate: {text} {dot_text}')
[docs]
def update_video_processors_status(self, status_text: str):
self.video_processors_status.setText(f'Video Processors: {status_text}')
[docs]
def update_video_buffer_status(self, status_text: str):
self.video_buffer_status.setText(f'Video Buffer: {status_text}')
try:
percent_full = int(status_text.split('%')[0])
except (ValueError, IndexError):
percent_full = 0
self.video_buffer_status_bar.setValue(percent_full)
[docs]
def _update_video_buffer_size_label(self) -> None:
video_buffer = getattr(self.manager, 'video_buffer', None)
if video_buffer is None or getattr(video_buffer, 'buffer_size', None) is None:
self.video_buffer_size_status.setText('Video Buffer Size: Unknown')
return
size_mb = video_buffer.buffer_size / 1e6
self.video_buffer_size_status.setText(f'Video Buffer Size: {size_mb:.1f} MB')
[docs]
def update_video_buffer_purge(self, timestamp: float):
timestamp_text = time.strftime("%I:%M:%S %p", time.localtime(timestamp))
self.video_buffer_purge_label.setText(f'Video Buffer Purged at: {timestamp_text}')
[docs]
class _LockStatusBadge(QLabel):
"""Compact color-coded status label with rounded background."""
def __init__(self, text: str = '', parent: QWidget | None = None):
super().__init__(text, parent)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setFixedHeight(20)
self.setMinimumWidth(60)
self.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
self._apply_style()
[docs]
def set_state(self, state: str) -> None:
self._state = state
self.setText(state)
self._apply_style()
[docs]
def _apply_style(self) -> None:
if self._state == 'Active':
bg, fg = '#1a472a', '#4caf50'
elif self._state == 'Inactive':
bg, fg = '#2a2a2a', '#888'
elif self._state == 'Arming':
bg, fg = '#3d3520', '#ffce5a'
elif self._state == 'Target not set':
bg, fg = '#3d3520', '#ffce5a'
else:
bg, fg = '#2a2a2a', '#888'
self.setStyleSheet(
f'QLabel {{ background-color: {bg}; color: {fg}; '
f'border: 1px solid #3a3a3a; border-radius: 3px; '
f'padding: 0px 6px; font-size: 10px; font-weight: bold; }}'
)
[docs]
class _LockActivityIndicator(QWidget):
"""Small circular ring indicator that fills over the lock interval and resets on correction."""
[docs]
_BACKGROUND_COLOR = '#666666'
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
[docs]
self._cycle_seconds = 10.0
[docs]
self._flash_mode = False
self.setFixedSize(self._SIZE, self._SIZE)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground, True)
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
[docs]
self._timer = QTimer(self)
self._timer.timeout.connect(self._tick)
self._recalc_interval()
[docs]
def set_cycle_duration(self, seconds: float) -> None:
self._cycle_seconds = max(0.1, float(seconds))
self._recalc_interval()
if self._active and not self._flash_mode:
self._progress = 0
self.update()
[docs]
def reset(self) -> None:
if not self._active:
return
self._progress = 0
self.update()
[docs]
def set_active(self, active: bool) -> None:
if active == self._active:
return
self._active = active
self._flash_mode = False
if active:
self._progress = 0
self._recalc_interval()
self._timer.start()
else:
self._timer.stop()
self._progress = 0
self.update()
[docs]
def trigger_once_flash(self) -> None:
self._flash_mode = True
self._progress = self._MAXIMUM
self._timer.setInterval(10)
if not self._timer.isActive():
self._timer.start()
self.update()
[docs]
def _recalc_interval(self) -> None:
ms = max(10, int(self._cycle_seconds * 1000 / self._MAXIMUM))
self._timer.setInterval(ms)
[docs]
def _tick(self) -> None:
if self._flash_mode:
self._progress = max(0, self._progress - 1)
self.update()
if self._progress <= 0:
self._flash_mode = False
if not self._active:
self._timer.stop()
else:
self._recalc_interval()
return
self._progress = min(self._MAXIMUM, self._progress + 1)
self.update()
[docs]
def paintEvent(self, _event) -> None:
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
painter.setBrush(Qt.BrushStyle.NoBrush)
ring_w = self._RING_WIDTH
margin = ring_w / 2 + 1.0
rect = QRectF(margin, margin, self.width() - 2 * margin, self.height() - 2 * margin)
bg_pen = QPen(QColor(self._BACKGROUND_COLOR), ring_w)
bg_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(bg_pen)
painter.drawEllipse(rect)
if self._active or self._flash_mode:
fraction = self._progress / self._MAXIMUM
accent_pen = QPen(QColor(get_accent_color()), ring_w)
accent_pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(accent_pen)
painter.drawArc(rect, 90 * 16, int(-360 * 16 * fraction))
[docs]
class XYLockPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='XY-Lock',
collapsed_by_default=False, collapsible=False)
title_layout = self.groupbox.layout().itemAt(0).widget().layout()
info_btn = _LockInfoButton(
'XY-Lock periodically recenters the selected bead in the '
'camera frame by applying small XY corrections.'
)
title_layout.addWidget(info_btn)
title_layout.addStretch(1)
[docs]
self._activity_indicator = _LockActivityIndicator()
title_layout.addWidget(self._activity_indicator)
content = self.layout()
content.setContentsMargins(6, 2, 6, 6)
content.setSpacing(4)
body = QHBoxLayout()
body.setContentsMargins(0, 0, 0, 0)
body.setSpacing(8)
left_col = QVBoxLayout()
left_col.setContentsMargins(0, 0, 0, 0)
left_col.setSpacing(6)
left_col.addStretch(1)
self.once_button.clicked.connect(self.once_callback)
self.once_button.setFixedHeight(22)
left_col.addWidget(self.once_button, alignment=Qt.AlignmentFlag.AlignHCenter)
[docs]
self.enabled = LabeledCheckbox(
label_text='Timer',
callback=self.enabled_callback,
)
left_col.addWidget(self.enabled, alignment=Qt.AlignmentFlag.AlignHCenter)
left_col.addStretch(1)
body.addLayout(left_col)
vline = QFrame()
vline.setFrameShape(QFrame.Shape.VLine)
vline.setFrameShadow(QFrame.Shadow.Sunken)
vline.setFixedWidth(1)
body.addWidget(vline)
right_col = QVBoxLayout()
right_col.setContentsMargins(0, 0, 0, 0)
right_col.setSpacing(4)
default_interval = self.manager.settings['xy-lock default interval']
[docs]
self.interval = _LockNumberInput(
label_text='Interval (sec)', default=default_interval, unit='sec',
callback=self.interval_callback, show_unit_label=False,
is_int=True,
)
right_col.addWidget(self.interval)
default_max = self.manager.settings['xy-lock default max']
[docs]
self.max = _LockNumberInput(
label_text='Max correction (px)', default=default_max, unit='px',
callback=self.max_callback, show_unit_label=False,
is_int=True,
)
right_col.addWidget(self.max)
default_window = self.manager.settings.get('xy-lock default window', 10)
[docs]
self.window = _LockNumberInput(
label_text='Averaging (frames)', default=default_window, unit='frames',
is_int=True, minimum=1, callback=self.window_callback,
show_unit_label=False,
)
right_col.addWidget(self.window)
right_col.addStretch(1)
body.addLayout(right_col)
body.addStretch(1)
content.addLayout(body)
self._activity_indicator.set_cycle_duration(default_interval)
self._update_badge()
[docs]
def notify_correction(self) -> None:
self._activity_indicator.reset()
[docs]
def _update_badge(self) -> None:
self._activity_indicator.set_active(self.enabled.checkbox.isChecked())
[docs]
def search_targets(self) -> list[SearchTarget]:
return [
_panel_control_target('XY-Lock Enabled', 'XYLockPanel', 'enabled', context='XY-Lock', aliases=('enable xy lock', 'xy lock on')),
_panel_control_target('XY-Lock Once', 'XYLockPanel', 'once_button', context='XY-Lock', aliases=('run xy lock once', 'center beads once')),
_panel_control_target('XY-Lock Interval', 'XYLockPanel', 'interval', context='XY-Lock', aliases=('xy lock frequency',)),
_panel_control_target('XY-Lock Max', 'XYLockPanel', 'max', context='XY-Lock', aliases=('xy lock maximum', 'xy lock max pixels')),
_panel_control_target('XY-Lock Averaging Window', 'XYLockPanel', 'window', context='XY-Lock', aliases=('xy lock window', 'xy lock averaging')),
]
[docs]
def enabled_callback(self):
is_enabled = self.enabled.checkbox.isChecked()
self.set_highlighted(is_enabled)
self._update_badge()
command = SetXYLockOnCommand(value=is_enabled)
self.manager.send_ipc(command)
[docs]
def once_callback(self):
command = ExecuteXYLockCommand()
self.manager.send_ipc(command)
self._activity_indicator.trigger_once_flash()
[docs]
def interval_callback(self):
interval_seconds = self.interval.spinbox.value()
if interval_seconds <= 0:
return
command = SetXYLockIntervalCommand(value=interval_seconds)
self.manager.send_ipc(command)
[docs]
def max_callback(self):
max_distance = self.max.spinbox.value()
if max_distance < 1:
return
command = SetXYLockMaxCommand(value=max_distance)
self.manager.send_ipc(command)
[docs]
def window_callback(self):
window_size = self.window.spinbox.value()
if window_size <= 0:
return
command = SetXYLockWindowCommand(value=window_size)
self.manager.send_ipc(command)
[docs]
def update_enabled(self, value: bool):
self.enabled.checkbox.blockSignals(True)
self.enabled.checkbox.setChecked(value)
self.enabled.checkbox.blockSignals(False)
self.set_highlighted(value)
self._update_badge()
[docs]
def update_interval(self, value: float):
if value is None:
value = ''
self.interval.value_label.setText(f'{value} sec')
if isinstance(value, (int, float)):
self._activity_indicator.set_cycle_duration(value)
[docs]
def update_max(self, value: float):
if value is None:
value = ''
self.max.value_label.setText(f'{value} pixels')
[docs]
def update_window(self, value: int):
if value is None:
value = ''
self.window.value_label.setText(f'{value} frames')
[docs]
class ZLockPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Z-Lock',
collapsed_by_default=False, collapsible=False)
title_layout = self.groupbox.layout().itemAt(0).widget().layout()
info_btn = _LockInfoButton(
'Z-Lock maintains the selected bead at a fixed Z position by '
'periodically adjusting the Z motor toward a target value.'
)
title_layout.addWidget(info_btn)
title_layout.addStretch(1)
[docs]
self._activity_indicator = _LockActivityIndicator()
title_layout.addWidget(self._activity_indicator)
content = self.layout()
content.setContentsMargins(6, 2, 6, 6)
content.setSpacing(4)
# Compact warning row
[docs]
self._warning_label = QLabel()
self._warning_label.setStyleSheet(
'color: #ffce5a; font-size: 10px; padding: 1px 0px;'
)
self._warning_label.setVisible(False)
content.addWidget(self._warning_label)
body = QHBoxLayout()
body.setContentsMargins(0, 0, 0, 0)
body.setSpacing(8)
left_col = QVBoxLayout()
left_col.setContentsMargins(0, 0, 0, 0)
left_col.setSpacing(6)
left_col.addStretch(1)
self._once_button.setFixedHeight(22)
self._once_button.clicked.connect(self._once_callback)
left_col.addWidget(self._once_button, alignment=Qt.AlignmentFlag.AlignHCenter)
[docs]
self.enabled = LabeledCheckbox(
label_text='Timer',
callback=self.enabled_callback,
)
left_col.addWidget(self.enabled, alignment=Qt.AlignmentFlag.AlignHCenter)
left_col.addStretch(1)
body.addLayout(left_col)
vline = QFrame()
vline.setFrameShape(QFrame.Shape.VLine)
vline.setFrameShadow(QFrame.Shadow.Sunken)
vline.setFixedWidth(1)
body.addWidget(vline)
right_col = QVBoxLayout()
right_col.setContentsMargins(0, 0, 0, 0)
right_col.setSpacing(4)
# Bead row
bead_row = QHBoxLayout()
bead_row.setContentsMargins(0, 0, 0, 0)
bead_row.setSpacing(6)
bead_label = QLabel('Bead')
bead_label.setFixedWidth(105)
bead_row.addWidget(bead_label)
[docs]
self._bead_combo = QComboBox()
self._bead_combo.setEditable(True)
self._bead_combo.setFixedWidth(64)
self._bead_combo.setMinimumContentsLength(1)
self._bead_combo.currentTextChanged.connect(self._bead_combo_callback)
bead_row.addWidget(self._bead_combo)
bead_row.addStretch(1)
right_col.addLayout(bead_row)
[docs]
self.bead = SimpleNamespace(
value_label=self._bead_combo,
lineedit=self._bead_combo.lineEdit(),
)
# Target row
target_row = QHBoxLayout()
target_row.setContentsMargins(0, 0, 0, 0)
target_row.setSpacing(6)
target_label = QLabel('Target (nm)')
target_label.setFixedWidth(105)
target_row.addWidget(target_label)
[docs]
self._target_spinbox = QDoubleSpinBox()
self._target_spinbox.setRange(-999999.0, 999999.0)
self._target_spinbox.setDecimals(1)
self._target_spinbox.setValue(0.0)
self._target_spinbox.setButtonSymbols(QDoubleSpinBox.ButtonSymbols.NoButtons)
self._target_spinbox.setFixedWidth(64)
self._target_spinbox.setAlignment(Qt.AlignmentFlag.AlignRight)
self._target_spinbox.editingFinished.connect(self._target_spinbox_callback)
target_row.addWidget(self._target_spinbox)
[docs]
self._target_unit_label = QLabel('nm')
self._target_unit_label.setStyleSheet('color: #999;')
[docs]
self.target = SimpleNamespace(
lineedit=self._target_spinbox.lineEdit(),
value_label=self._target_unit_label,
)
target_row.addStretch(1)
right_col.addLayout(target_row)
default_interval = self.manager.settings['z-lock default interval']
[docs]
self.interval = _LockNumberInput(
label_text='Interval (sec)', default=default_interval, unit='sec',
callback=self.interval_callback, show_unit_label=False,
is_int=True,
)
right_col.addWidget(self.interval)
default_max = self.manager.settings['z-lock default max']
[docs]
self.max = _LockNumberInput(
label_text='Max correction (nm)', default=default_max, unit='nm',
callback=self.max_callback, show_unit_label=False,
is_int=True,
)
right_col.addWidget(self.max)
default_window = self.manager.settings.get('z-lock default window', 10)
[docs]
self.window = _LockNumberInput(
label_text='Averaging (frames)', default=default_window, unit='frames',
is_int=True, minimum=1, callback=self.window_callback,
show_unit_label=False,
)
right_col.addWidget(self.window)
right_col.addStretch(1)
body.addLayout(right_col)
body.addStretch(1)
content.addLayout(body)
self._activity_indicator.set_cycle_duration(default_interval)
[docs]
self._target_is_set = False
self._update_badge()
self._refresh_bead_combo()
[docs]
def _refresh_bead_combo(self) -> None:
self._bead_combo.blockSignals(True)
current_text = self._bead_combo.currentText()
try:
bead_rois = self.manager._bead_rois
except (AttributeError, TypeError):
bead_rois = {}
self._bead_combo.clear()
for bead_id in sorted(bead_rois.keys()):
self._bead_combo.addItem(str(bead_id))
if current_text:
idx = self._bead_combo.findText(current_text)
if idx >= 0:
self._bead_combo.setCurrentIndex(idx)
self._bead_combo.blockSignals(False)
[docs]
def _bead_combo_callback(self, text: str) -> None:
try:
bead_index = int(text)
except ValueError:
return
if bead_index < 0:
return
command = SetZLockBeadCommand(value=bead_index)
self.manager.send_ipc(command)
[docs]
def _target_spinbox_callback(self) -> None:
target_nm = self._target_spinbox.value()
command = SetZLockTargetCommand(value=target_nm)
self.manager.send_ipc(command)
[docs]
def _once_callback(self) -> None:
command = ExecuteZLockCommand()
self.manager.send_ipc(command)
self._activity_indicator.trigger_once_flash()
[docs]
def _update_badge(self) -> None:
if not self._has_focus_motor():
self._activity_indicator.set_active(False)
self._warning_label.setText(
'No focus motor hardware registered \u2014 Z-Lock disabled'
)
self._warning_label.setVisible(True)
self._set_controls_enabled(False)
return
self._set_controls_enabled(True)
enabled = self.enabled.checkbox.isChecked()
self._activity_indicator.set_active(enabled)
if not enabled and not self._target_is_set:
self._warning_label.setText('Set a target Z before enabling')
self._warning_label.setVisible(True)
elif enabled and not self._target_is_set:
self._warning_label.setText('Target will latch from current bead Z')
self._warning_label.setVisible(True)
else:
self._warning_label.setVisible(False)
[docs]
def search_targets(self) -> list[SearchTarget]:
return [
_panel_control_target('Z-Lock Enabled', 'ZLockPanel', 'enabled', context='Z-Lock', aliases=('enable z lock', 'z lock on')),
_panel_control_target('Z-Lock Bead', 'ZLockPanel', 'bead', context='Z-Lock', aliases=('focus bead', 'z lock bead roi')),
_panel_control_target('Z-Lock Target', 'ZLockPanel', 'target', context='Z-Lock', aliases=('z target', 'focus target')),
_panel_control_target('Z-Lock Interval', 'ZLockPanel', 'interval', context='Z-Lock', aliases=('z lock frequency',)),
_panel_control_target('Z-Lock Max', 'ZLockPanel', 'max', context='Z-Lock', aliases=('z lock maximum', 'z lock max nm')),
_panel_control_target('Z-Lock Averaging Window', 'ZLockPanel', 'window', context='Z-Lock', aliases=('z lock window', 'z lock averaging')),
]
[docs]
def enabled_callback(self):
is_enabled = self.enabled.checkbox.isChecked()
self.set_highlighted(is_enabled)
self._update_badge()
command = SetZLockOnCommand(value=is_enabled)
self.manager.send_ipc(command)
[docs]
def interval_callback(self):
interval_seconds = self.interval.spinbox.value()
if interval_seconds <= 0:
return
command = SetZLockIntervalCommand(value=interval_seconds)
self.manager.send_ipc(command)
[docs]
def max_callback(self):
max_nm = self.max.spinbox.value()
if max_nm <= 1:
return
command = SetZLockMaxCommand(value=max_nm)
self.manager.send_ipc(command)
[docs]
def window_callback(self):
window_size = self.window.spinbox.value()
if window_size <= 0:
return
command = SetZLockWindowCommand(value=window_size)
self.manager.send_ipc(command)
[docs]
def update_enabled(self, value: bool):
self.enabled.checkbox.blockSignals(True)
self.enabled.checkbox.setChecked(value)
self.enabled.checkbox.blockSignals(False)
self.set_highlighted(value)
self._update_badge()
[docs]
def update_bead(self, value: int):
if value is None:
value = ''
self.bead.value_label.setCurrentText(str(value))
[docs]
def _has_focus_motor(self) -> bool:
from magscope.hardware import FocusMotorBase
try:
hardware_types = self.manager.hardware_types
except (AttributeError, TypeError):
return False
for hardware_type in hardware_types.values():
try:
if issubclass(hardware_type, FocusMotorBase):
return True
except TypeError:
continue
return False
[docs]
def _set_controls_enabled(self, enabled: bool) -> None:
self._bead_combo.setEnabled(enabled)
self._target_spinbox.setEnabled(enabled)
self.enabled.setEnabled(enabled)
self._once_button.setEnabled(enabled)
self.interval.setEnabled(enabled)
self.max.setEnabled(enabled)
self.window.setEnabled(enabled)
[docs]
def update_target(self, value: float):
if value is None:
self._target_is_set = False
self._target_unit_label.setText('Not set')
self._update_badge()
return
self._target_is_set = True
self._target_unit_label.setText(f'{value} nm')
self._activity_indicator.reset()
self._update_badge()
[docs]
def update_interval(self, value: float):
if value is None:
value = ''
self.interval.value_label.setText(f'{value} sec')
if isinstance(value, (int, float)):
self._activity_indicator.set_cycle_duration(value)
[docs]
def update_max(self, value: float):
if value is None:
value = ''
self.max.value_label.setText(f'{value} nm')
[docs]
def update_window(self, value: int):
if value is None:
value = ''
self.window.value_label.setText(f'{value} frames')
[docs]
class ZLUTGenerationPanel(ControlPanelBase):
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Z-LUT Generation', collapsed_by_default=True)
# ROI
roi_row = QHBoxLayout()
self.layout().addLayout(roi_row)
roi_row.addWidget(QLabel('Current ROI:'))
roi = self.manager.settings['ROI']
[docs]
self.roi_size_label = QLabel(f'{roi} x {roi} pixels')
roi_row.addWidget(self.roi_size_label)
roi_row.addStretch(1)
# Start
self.layout().addWidget(self.start_input)
# Step
self.layout().addWidget(self.step_input)
# Stop
self.layout().addWidget(self.stop_input)
# Measurements per step
self.measurements_input.lineedit.setText(str(self.manager.settings['video buffer n images']))
self.layout().addWidget(self.measurements_input)
# Generate button
buttons_row = QHBoxLayout()
self.layout().addLayout(buttons_row)
self.generate_button.clicked.connect(self.generate_callback)
buttons_row.addWidget(self.generate_button)
[docs]
def search_targets(self) -> list[SearchTarget]:
return [
_panel_control_target('Z-LUT Start', 'ZLUTGenerationPanel', 'start_input', context='Z-LUT Generation', aliases=('start nm', 'z lut start')),
_panel_control_target('Z-LUT Step', 'ZLUTGenerationPanel', 'step_input', context='Z-LUT Generation', aliases=('step nm', 'z lut step')),
_panel_control_target('Z-LUT Stop', 'ZLUTGenerationPanel', 'stop_input', context='Z-LUT Generation', aliases=('stop nm', 'z lut stop')),
_panel_control_target('Z-LUT Measurements per Step', 'ZLUTGenerationPanel', 'measurements_input', context='Z-LUT Generation', aliases=('measurements per step', 'captures per step')),
_panel_control_target('Generate Z-LUT', 'ZLUTGenerationPanel', 'generate_button', context='Z-LUT Generation', aliases=('generate', 'start z lut generation')),
]
[docs]
def generate_callback(self):
# Start
start_text = self.start_input.lineedit.text()
try:
start_nm = float(start_text)
except ValueError:
return
# Step
step_text = self.step_input.lineedit.text()
try:
step_nm = float(step_text)
except ValueError:
return
# Stop
stop_text = self.stop_input.lineedit.text()
try:
stop_nm = float(stop_text)
except ValueError:
return
measurements_text = self.measurements_input.lineedit.text()
try:
profiles_per_bead = int(measurements_text)
except ValueError:
return
if profiles_per_bead <= 0:
return
self.manager.start_zlut_generation(
start_nm=start_nm,
step_nm=step_nm,
stop_nm=stop_nm,
profiles_per_bead=profiles_per_bead,
)
[docs]
def update_state(
self,
status: str,
detail: str | None = None,
*,
running: bool = False,
can_cancel: bool = False,
phase: str = 'idle',
) -> None:
generation_blocked = running or phase in {'evaluating', 'waiting_focus_limits'}
self.generate_button.setEnabled(not generation_blocked)
[docs]
def update_progress(
self,
current_step: int,
total_steps: int,
capture_count: int,
capture_capacity: int,
motor_z_value: float | None = None,
) -> None:
_ = (current_step, total_steps, capture_count, capture_capacity, motor_z_value)
[docs]
class ZLUTGenerationSetupDialog(QDialog):
def __init__(
self,
parent: QWidget | None = None,
*,
roi_size: int,
default_measurements: int,
):
super().__init__(parent)
self.setWindowTitle('New Z-LUT')
self.setModal(True)
layout = QVBoxLayout(self)
roi_row = QHBoxLayout()
roi_row.addWidget(QLabel('Current ROI:'))
roi_row.addWidget(QLabel(f'{roi_size} x {roi_size} pixels'))
roi_row.addStretch(1)
layout.addLayout(roi_row)
layout.addWidget(self.start_input)
layout.addWidget(self.step_input)
layout.addWidget(self.stop_input)
self.measurements_input.lineedit.setText(str(default_measurements))
layout.addWidget(self.measurements_input)
button_row = QHBoxLayout()
button_row.addStretch(1)
self.cancel_button.clicked.connect(self.reject) # type: ignore
button_row.addWidget(self.cancel_button)
self.generate_button.clicked.connect(self._accept_if_valid) # type: ignore
button_row.addWidget(self.generate_button)
layout.addLayout(button_row)
[docs]
self._values: tuple[float, float, float, int] | None = None
@property
[docs]
def values(self) -> tuple[float, float, float, int] | None:
return self._values
[docs]
def _accept_if_valid(self) -> None:
try:
start_nm = float(self.start_input.lineedit.text())
step_nm = float(self.step_input.lineedit.text())
stop_nm = float(self.stop_input.lineedit.text())
profiles_per_bead = int(self.measurements_input.lineedit.text())
except ValueError:
QMessageBox.warning(self, 'Invalid Z-LUT settings', 'Enter numeric Z-LUT settings.')
return
if profiles_per_bead <= 0:
QMessageBox.warning(
self,
'Invalid Z-LUT settings',
'Measurements per step must be greater than zero.',
)
return
self._values = (start_nm, step_nm, stop_nm, profiles_per_bead)
self.accept()
[docs]
class ZLUTGenerationDialog(QDialog):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle('Z-LUT Generation')
self.setModal(True)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.resize(900, 700)
[docs]
self._evaluation_active = False
[docs]
self._startup_pending = False
[docs]
self._close_when_canceled = False
[docs]
self._selected_bead_id: int | None = None
layout = QVBoxLayout(self)
[docs]
self.status_label = QLabel('Preparing Z-LUT generation...')
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
[docs]
self.detail_label = QLabel('')
self.detail_label.setWordWrap(True)
layout.addWidget(self.detail_label)
[docs]
self.progress_label = QLabel('0 / 0 steps')
layout.addWidget(self.progress_label)
[docs]
self.progress_bar = QProgressBar()
self.progress_bar.setRange(0, 1)
self.progress_bar.setValue(0)
layout.addWidget(self.progress_bar)
layout.addWidget(self.preview_widget, 1)
evaluation_row = QHBoxLayout()
evaluation_row.addWidget(QLabel('Bead:'))
[docs]
self.bead_selector = QComboBox()
self.bead_selector.currentIndexChanged.connect(self._handle_bead_selection_changed)
self.bead_selector.setEnabled(False)
evaluation_row.addWidget(self.bead_selector)
evaluation_row.addStretch(1)
layout.addLayout(evaluation_row)
button_row = QHBoxLayout()
button_row.addStretch(1)
self.cancel_button.clicked.connect(self._handle_cancel_clicked)
button_row.addWidget(self.cancel_button)
self.save_button.clicked.connect(self._handle_save_clicked)
self.save_button.setEnabled(False)
button_row.addWidget(self.save_button)
self.save_and_load_button.clicked.connect(self._handle_save_and_load_clicked)
self.save_and_load_button.setEnabled(False)
button_row.addWidget(self.save_and_load_button)
self.close_button.clicked.connect(self._handle_close_clicked)
self.close_button.setEnabled(False)
button_row.addWidget(self.close_button)
layout.addLayout(button_row)
[docs]
self._cancel_callback = None
[docs]
self._close_callback = None
[docs]
self._save_callback = None
[docs]
self._save_and_load_callback = None
[docs]
self._select_bead_callback = None
[docs]
def set_cancel_callback(self, callback) -> None:
self._cancel_callback = callback
[docs]
def set_save_callback(self, callback) -> None:
self._save_callback = callback
[docs]
def set_save_and_load_callback(self, callback) -> None:
self._save_and_load_callback = callback
[docs]
def set_close_callback(self, callback) -> None:
self._close_callback = callback
[docs]
def set_select_bead_callback(self, callback) -> None:
self._select_bead_callback = callback
[docs]
def _handle_cancel_clicked(self) -> None:
if self._cancel_callback is not None:
self._close_when_canceled = True
self.cancel_button.setEnabled(False)
self._cancel_callback()
[docs]
def _handle_save_clicked(self) -> None:
if self._save_callback is not None and self._selected_bead_id is not None:
self._save_callback(self._selected_bead_id)
[docs]
def _handle_save_and_load_clicked(self) -> None:
if self._save_and_load_callback is not None and self._selected_bead_id is not None:
self._save_and_load_callback(self._selected_bead_id)
[docs]
def _handle_close_clicked(self) -> None:
if self._running or self._startup_pending:
return
if self._evaluation_active and self._close_callback is not None:
self._close_callback()
self._evaluation_active = False
self.close()
[docs]
def mark_starting(self) -> None:
self._running = True
self._startup_pending = True
self._evaluation_active = False
self.status_label.setText('Preparing Z-LUT generation...')
self.detail_label.setText('Submitting the sweep request and waiting for the first status update.')
self.cancel_button.setVisible(False)
self.cancel_button.setEnabled(False)
self.cancel_button.setText('Cancel')
self.close_button.setEnabled(False)
self.close_button.setText('Close')
[docs]
def _handle_bead_selection_changed(self, index: int) -> None:
if index < 0:
return
bead_id = self.bead_selector.itemData(index)
if bead_id is None:
return
self._selected_bead_id = int(bead_id)
if self._select_bead_callback is not None:
self._select_bead_callback(self._selected_bead_id)
[docs]
def update_state(
self,
status: str,
detail: str | None = None,
*,
running: bool = False,
can_cancel: bool = False,
phase: str = 'idle',
) -> None:
self._startup_pending = False
self._running = running
self._evaluation_active = phase == 'evaluating'
self.status_label.setText(status)
self.detail_label.setText(detail or '')
self.cancel_button.setVisible(running or can_cancel)
self.cancel_button.setEnabled(can_cancel)
self.cancel_button.setText('Cancel')
save_enabled = self._evaluation_active and self._selected_bead_id is not None
self.save_button.setEnabled(save_enabled)
self.save_and_load_button.setEnabled(save_enabled)
self.bead_selector.setEnabled(self.bead_selector.count() > 0)
self.close_button.setEnabled(not running)
self.close_button.setText('Cancel' if self._evaluation_active else 'Close')
if self._close_when_canceled and not running and phase == 'idle':
self._close_when_canceled = False
self.close()
[docs]
def update_progress(
self,
current_step: int,
total_steps: int,
capture_count: int,
capture_capacity: int,
motor_z_value: float | None = None,
) -> None:
progress_total = max(total_steps, 1)
progress_value = min(max(current_step, 0), progress_total)
self.progress_bar.setRange(0, progress_total)
self.progress_bar.setValue(progress_value)
progress_text = f'{current_step} / {total_steps} steps'
if capture_capacity > 0:
progress_text += f' | {capture_count} / {capture_capacity} captures'
if motor_z_value is not None:
progress_text += f' | Z = {motor_z_value:.1f} nm'
self.progress_label.setText(progress_text)
[docs]
def update_evaluation(self, *, active: bool, bead_ids: list[int], selected_bead_id: int | None) -> None:
self._evaluation_active = active
self.bead_selector.blockSignals(True)
self.bead_selector.clear()
for bead_id in bead_ids:
self.bead_selector.addItem(str(bead_id), bead_id)
if selected_bead_id is not None:
index = self.bead_selector.findData(selected_bead_id)
if index >= 0:
self.bead_selector.setCurrentIndex(index)
self._selected_bead_id = int(selected_bead_id)
else:
self._selected_bead_id = None
else:
self._selected_bead_id = None
self.bead_selector.blockSignals(False)
self.bead_selector.setEnabled(self.bead_selector.count() > 0)
save_enabled = active and self._selected_bead_id is not None
self.save_button.setEnabled(save_enabled)
self.save_and_load_button.setEnabled(save_enabled)
self.cancel_button.setVisible(self._running)
self.cancel_button.setEnabled(self._running)
self.cancel_button.setText('Cancel')
self.close_button.setText('Cancel' if active else 'Close')
[docs]
def force_close(self) -> None:
self._running = False
self._startup_pending = False
self._close_when_canceled = False
self._evaluation_active = False
self.close()
[docs]
def closeEvent(self, event) -> None:
if self._running or self._startup_pending:
event.ignore()
return
if self._evaluation_active and self._close_callback is not None:
self._close_callback()
self._evaluation_active = False
super().closeEvent(event)
[docs]
class CurrentZLUTDialog(MatplotlibCleanupMixin, QDialog):
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self.setWindowTitle('Current Z-LUT')
self.resize(720, 560)
layout = QVBoxLayout(self)
[docs]
self.preview_status_label = QLabel('No Z-LUT loaded')
self.preview_status_label.setWordWrap(True)
layout.addWidget(self.preview_status_label)
[docs]
self.canvas = FigureCanvas(self.figure)
self.canvas.setMinimumHeight(280)
layout.addWidget(self.canvas, 1)
[docs]
self.axes = self.figure.subplots(nrows=1, ncols=1)
self.axes.set_facecolor(PANEL_BACKGROUND_COLOR)
[docs]
self._image = self.axes.imshow(
np.zeros((1, 1), dtype=np.float64),
cmap=matplotlib.colormaps['gray'].copy(),
aspect='auto',
interpolation='nearest',
origin='lower',
)
self._clear_preview('No Z-LUT loaded')
self._init_matplotlib_cleanup()
metadata_layout = QVBoxLayout()
layout.addLayout(metadata_layout)
[docs]
self.min_value = self._add_metadata_row(metadata_layout, 'Min (nm):')
[docs]
self.max_value = self._add_metadata_row(metadata_layout, 'Max (nm):')
[docs]
self.step_value = self._add_metadata_row(metadata_layout, 'Step (nm):')
[docs]
self.profile_length_value = self._add_metadata_row(metadata_layout, 'Profile Length:')
[docs]
self.filepath_label = QLabel('File: No Z-LUT loaded')
self.filepath_label.setWordWrap(True)
self.filepath_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
layout.addWidget(self.filepath_label)
button_row = QHBoxLayout()
button_row.addStretch(1)
close_button = QPushButton('Close')
close_button.clicked.connect(self.close) # type: ignore
button_row.addWidget(close_button)
layout.addLayout(button_row)
[docs]
def update_zlut(
self,
filepath: str | None,
*,
z_min: float | None = None,
z_max: float | None = None,
step_size: float | None = None,
profile_length: int | None = None,
) -> None:
self.filepath_label.setText(f'File: {filepath}' if filepath else 'File: No Z-LUT loaded')
self.min_value.setText(self._format_number(z_min, suffix=' nm'))
self.max_value.setText(self._format_number(z_max, suffix=' nm'))
self.step_value.setText(self._format_number(step_size, suffix=' nm'))
self.profile_length_value.setText('' if profile_length is None else f'{profile_length}')
self._update_preview(filepath)
[docs]
def _clear_preview(self, message: str) -> None:
self.preview_status_label.setText(message)
self._image.set_data(np.zeros((1, 1), dtype=np.float64))
self._image.set_extent((-0.5, 0.5, -0.5, 0.5))
self._image.set_clim(0.0, 1.0)
self.axes.set_title('No Z-LUT preview available')
self.axes.set_xlabel('Z Position (nm)')
self.axes.set_ylabel('Profile Radius (px)')
self.axes.set_xlim(-0.5, 0.5)
self.axes.set_ylim(-0.5, 0.5)
self.canvas.draw()
[docs]
def _update_preview(self, filepath: str | None) -> None:
if not filepath:
self._clear_preview('No Z-LUT loaded')
return
try:
zlut_array = np.loadtxt(filepath)
if zlut_array.ndim != 2 or zlut_array.shape[0] < 2 or zlut_array.shape[1] < 2:
raise ValueError('Z-LUT must be a 2D array with z references and profile rows.')
except Exception as exc:
reason = str(exc).strip() or repr(exc)
self._clear_preview(f'Could not load Z-LUT preview: {reason}')
return
z_references = np.asarray(zlut_array[0, :], dtype=np.float64)
profiles = np.asarray(zlut_array[1:, :], dtype=np.float64)
finite_z_references = z_references[np.isfinite(z_references)]
if finite_z_references.size == 0:
self._clear_preview('No finite Z-LUT reference positions available')
return
finite_mask = np.isfinite(profiles)
if not np.any(finite_mask):
self._clear_preview('No finite Z-LUT profile values available')
return
finite_values = profiles[finite_mask]
vmin = float(np.min(finite_values))
vmax = float(np.max(finite_values))
if np.isclose(vmin, vmax):
vmax = vmin + 1.0
x_min = float(np.min(finite_z_references))
x_max = float(np.max(finite_z_references))
if np.isclose(x_min, x_max):
x_min -= 0.5
x_max += 0.5
self.preview_status_label.setText('')
self._image.set_data(np.ma.masked_invalid(profiles))
self._image.set_extent((x_min, x_max, -0.5, profiles.shape[0] - 0.5))
self._image.set_clim(vmin, vmax)
self.axes.set_title('Current Z-LUT')
self.axes.set_xlabel('Z Position (nm)')
self.axes.set_ylabel('Profile Radius (px)')
self.axes.set_xlim(x_min, x_max)
self.axes.set_ylim(-0.5, profiles.shape[0] - 0.5)
self.canvas.draw()
@staticmethod
[docs]
class ZLUTPanel(ControlPanelBase):
[docs]
zlut_file_selected = pyqtSignal(str)
[docs]
zlut_clear_requested = pyqtSignal()
[docs]
NO_ZLUT_SELECTED_TEXT = 'No Z-LUT file selected'
def __init__(self, manager: 'UIManager'):
super().__init__(manager=manager, title='Z-LUT', collapsed_by_default=True)
# Controls row
controls_row = QHBoxLayout()
self.layout().addLayout(controls_row)
self.select_button.clicked.connect(self._select_zlut_file) # type: ignore
controls_row.addWidget(self.select_button)
self.clear_button.clicked.connect(self._clear_zlut) # type: ignore
controls_row.addWidget(self.clear_button)
# Current filepath display
[docs]
self.filepath_textedit = QTextEdit(self.NO_ZLUT_SELECTED_TEXT)
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.filepath_textedit.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
self.filepath_textedit.setFixedHeight(40)
self.filepath_textedit.setWordWrapMode(QTextOption.WrapMode.NoWrap)
self.filepath_textedit.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.layout().addWidget(self.filepath_textedit)
# Metadata
self.layout().addLayout(self._metadata_layout)
[docs]
self.min_value = self._add_metadata_row('Min (nm):')
[docs]
self.max_value = self._add_metadata_row('Max (nm):')
[docs]
self.step_value = self._add_metadata_row('Step (nm):')
[docs]
self.profile_length_value = self._add_metadata_row('Profile Length:')
[docs]
def search_targets(self) -> list[SearchTarget]:
return [
_panel_control_target('Select Z-LUT File', 'ZLUTPanel', 'select_button', context='Z-LUT', aliases=('load z lut', 'choose z-lut file')),
_panel_control_target('Clear Z-LUT', 'ZLUTPanel', 'clear_button', context='Z-LUT', aliases=('remove z lut', 'reset z lut')),
]
[docs]
def _select_zlut_file(self):
settings = QSettings('MagScope', 'MagScope')
last_value = settings.value(
'last zlut directory',
os.path.expanduser("~"),
type=str
)
path, _ = QFileDialog.getOpenFileName(None,
'Select Z-LUT File',
last_value,
'Text Files (*.txt)')
if not path:
return
directory = os.path.dirname(path) or last_value
settings.setValue('last zlut directory', QVariant(directory))
self.filepath_textedit.setText(path)
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.clear_metadata()
self.zlut_file_selected.emit(path)
[docs]
def _clear_zlut(self):
self.set_filepath(None)
self.zlut_clear_requested.emit()
[docs]
def set_filepath(self, path: str | None):
if not path:
self.filepath_textedit.setText(self.NO_ZLUT_SELECTED_TEXT)
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.clear_metadata()
return
self.filepath_textedit.setText(path)
self.filepath_textedit.setAlignment(Qt.AlignmentFlag.AlignCenter)
settings = QSettings('MagScope', 'MagScope')
settings.setValue('last zlut directory', QVariant(os.path.dirname(path)))
@staticmethod