Auto-align: level the sample on the DC bias levels from the camera window
The operator frames a good spot, confirms the two DC levels the detector reads there, and the rig then measures its own tilt: step 1.5 mm either side on X and then on Y, and tilt the platform until those levels come back. The correction that fixes an offset point is the correction that levels the whole travel — height error and tilt effect are both proportional to the offset — so the procedure ends by applying it and leaving it applied. Both directions are measured from the same starting tilt and averaged, which makes their disagreement a flatness read-out rather than something averaged away silently. core/auto_align.py holds the geometry and the search, Qt-free. The three T-axes' azimuths are the whole geometry: T1 lies along +X so it alone tilts along X, and T0/T2 move as an equal-and-opposite pair to tilt along Y without touching X (tilt_response derives that, and the tests pin it — an axis map that drifts would still converge, on the wrong axis). The search is a secant null on the split-detector difference: probe once to learn what a microstep is worth, sign included, then step at the null. It refuses to servo on a scope that has not re-triggered, escalates a probe that reads as no response before calling an axis dead, and stops at a per-axis travel limit. gui/align_bridge.py runs it on a worker thread; stopping is a threading.Event rather than a queued command, because the worker is inside a long handler for the whole run. The camera window carries the button and the progress window, and locks the scan panel and the jog pads while a run owns the stage. Adds immediate MEAN measurements and an acquisition count to the scope driver, and read_bias_mv to core/scope_inspect — the one scalar the inspection state was missing. KNOWN_ISSUES.md records what only the rig can settle: the probe step, the travel limit, the hold current, and whether the piston the X phase applies alongside its tilt matters. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+369
-3
@@ -18,8 +18,8 @@ from PyQt6.QtCore import QThread, QTimer, Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QImage, QPixmap
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
|
||||
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPushButton,
|
||||
QSizePolicy, QVBoxLayout, QWidget,
|
||||
QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit,
|
||||
QPushButton, QSizePolicy, QVBoxLayout, QWidget,
|
||||
)
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
from matplotlib.figure import Figure
|
||||
@@ -27,6 +27,7 @@ from matplotlib.figure import Figure
|
||||
ROOT = Path(__file__).parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from core.auto_align import DEFAULT_ALIGN, DEFAULT_T_AXIS
|
||||
from core.config import ScanDefaults
|
||||
from core.rotation import DEFAULT_ROTATION, RotationAxis
|
||||
from core.scan_engine import (
|
||||
@@ -36,11 +37,13 @@ from core.scan_engine import (
|
||||
from core.saw_check import middle_row_plan
|
||||
from core.scan_geometry import ScanPlan, EtaEstimator, build_plan, format_eta
|
||||
from core.scan_resume import is_compatible, plan_resume
|
||||
from core.scope_inspect import BIAS_LABELS
|
||||
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
|
||||
from core.sras_format import (
|
||||
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, WRITABLE_VERSIONS, SrasFile,
|
||||
plan_from_header,
|
||||
)
|
||||
from gui.align_bridge import QtAutoAligner
|
||||
from gui.jog_panel import (
|
||||
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
|
||||
BBDJogPanel, T3RJogPanel,
|
||||
@@ -49,6 +52,7 @@ from gui.qt_t3r import QtT3RAdapter
|
||||
from gui.qt_workers import PollingQueueWorker, QueueWorker
|
||||
from gui.inspect_bridge import QtAngleInspector
|
||||
from gui.scan_bridge import QtScanController
|
||||
from gui.widgets import mono_font
|
||||
from hardware.helios_laser import HeliosLaser
|
||||
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
|
||||
from hardware.tektronix_base import TektronixOscilloscopeBase
|
||||
@@ -439,18 +443,62 @@ class HeliosWorker(PollingQueueWorker):
|
||||
|
||||
# ── Camera window popup ───────────────────────────────────────────────────────
|
||||
|
||||
# The channel map auto-align assumes. Nothing reconfigures the scope inputs —
|
||||
# CH3 is the max-velocity gate during a scan and the DC 1 monitor here — so the
|
||||
# operator is asked to confirm the cabling instead of being trusted to
|
||||
# remember it.
|
||||
ALIGN_SCOPE_CHANNELS = ((1, "SAW"), (2, "Trigger"), (3, "DC 1"), (4, "DC 2"))
|
||||
|
||||
|
||||
def _align_scope_prompt() -> str:
|
||||
channels = "\n".join(f" CH{ch} — {role}" for ch, role in ALIGN_SCOPE_CHANNELS)
|
||||
# The channels arrive on screen carrying the inspection labels, which are
|
||||
# the names for the same two monitors — say so, or the operator is left
|
||||
# comparing "DC 1" here against "Bias - A" on the instrument.
|
||||
labels = " and ".join(BIAS_LABELS[ch] for ch in sorted(BIAS_LABELS))
|
||||
return (
|
||||
"Auto-align reads the DC levels off CH3 and CH4. Check the scope is "
|
||||
"cabled this way before starting:\n\n"
|
||||
f"{channels}\n\n"
|
||||
f"The scope will label the last two {labels}.\n\n"
|
||||
"The laser must be pulsing and the detection beam on the sample — the "
|
||||
"procedure stops rather than servo on a scope that is not triggering.\n\n"
|
||||
"Start auto-align?"
|
||||
)
|
||||
|
||||
|
||||
def _align_reference_prompt(reading) -> str:
|
||||
return (
|
||||
"The detector reads, where the stage is standing:\n\n"
|
||||
f" DC 1 {reading.dc1_mv:8.1f} mV\n"
|
||||
f" DC 2 {reading.dc2_mv:8.1f} mV\n\n"
|
||||
"Is the image correct?\n\n"
|
||||
f"Yes takes these as the good values and holds them to within "
|
||||
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV at "
|
||||
f"{DEFAULT_ALIGN.offset_mm:.2f} mm either side of here, first on X "
|
||||
f"(T1) and then on Y (T0/T2 as a pair)."
|
||||
)
|
||||
|
||||
|
||||
class CameraWindow(QWidget):
|
||||
"""Camera display popup — auto-connects on show, auto-disconnects on close.
|
||||
|
||||
Focusing and framing are done by eye, so the T3R and BBD202 jog controls
|
||||
live beside the image. Both take the objects the main window already
|
||||
owns; passing neither leaves the window as a plain viewer.
|
||||
|
||||
Auto-align lives here for the same reason: the operator judges the image
|
||||
to decide the rig is on a good spot, and that judgement is the first step
|
||||
of the procedure. It needs the oscilloscope as well, so the button is
|
||||
only offered when all three are on hand.
|
||||
"""
|
||||
|
||||
closed = pyqtSignal()
|
||||
align_active = pyqtSignal(bool) # lock the scan panel while aligning
|
||||
|
||||
def __init__(self, t3r_driver: QtT3RAdapter | None = None,
|
||||
bbd_worker: BBD202Worker | None = None,
|
||||
oscope_worker: "OscopeWorker | None" = None,
|
||||
parent: QWidget | None = None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
|
||||
@@ -459,6 +507,14 @@ class CameraWindow(QWidget):
|
||||
self._camera: UC480Camera | None = None
|
||||
self._stream: CameraStreamThread | None = None
|
||||
|
||||
self._t3r_driver = t3r_driver
|
||||
self._bbd_worker = bbd_worker
|
||||
self._oscope_worker = oscope_worker
|
||||
self._align_thread: QThread | None = None
|
||||
self._align_worker: QtAutoAligner | None = None
|
||||
self._align_window: "AutoAlignWindow | None" = None
|
||||
self._align_available = True
|
||||
|
||||
# Banner warning when the camera shares a USB controller with serial
|
||||
# adapters — opening any of those ports (T3R, BBD202, …) collapses
|
||||
# the delivered frame rate to <2 fps (USB split-transaction
|
||||
@@ -489,6 +545,13 @@ class CameraWindow(QWidget):
|
||||
self.uc480_stop_btn.clicked.connect(self._stop_stream)
|
||||
self.uc480_close_window_btn.clicked.connect(self.close)
|
||||
|
||||
# Every one of the three is load-bearing: the T3R moves the tilt
|
||||
# platform, the BBD202 makes the 1.5 mm steps, and the scope is the
|
||||
# only thing that can say whether either helped.
|
||||
self.uc480_auto_align_btn.clicked.connect(self._on_auto_align)
|
||||
self.uc480_auto_align_btn.setVisible(
|
||||
None not in (t3r_driver, bbd_worker, oscope_worker))
|
||||
|
||||
self.t3r_jog_panel = self.bbd_jog_panel = None
|
||||
self._build_jog_column(t3r_driver, bbd_worker)
|
||||
|
||||
@@ -515,6 +578,8 @@ class CameraWindow(QWidget):
|
||||
self._start_stream()
|
||||
|
||||
def closeEvent(self, event):
|
||||
# An alignment outlives this window otherwise, and it owns the stage.
|
||||
self._teardown_align()
|
||||
# A jog whose button-release lands after the window is gone would
|
||||
# otherwise leave an axis running.
|
||||
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
|
||||
@@ -609,6 +674,298 @@ class CameraWindow(QWidget):
|
||||
self.uc480_exposure_slider.setEnabled(camera_ok)
|
||||
self.uc480_gain_slider.setEnabled(camera_ok)
|
||||
|
||||
# ── Auto-align ────────────────────────────────────────────────────────────
|
||||
|
||||
def _on_auto_align(self):
|
||||
if self._align_thread is not None and self._align_thread.isRunning():
|
||||
if self._align_window is not None:
|
||||
self._align_window.raise_()
|
||||
self._align_window.activateWindow()
|
||||
return
|
||||
|
||||
problem = self._align_prerequisite_problem()
|
||||
if problem:
|
||||
QMessageBox.warning(self, "Cannot Auto-Align", problem)
|
||||
return
|
||||
|
||||
if QMessageBox.question(self, "Check the Oscilloscope",
|
||||
_align_scope_prompt()) \
|
||||
!= QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
self._align_thread = QThread(self)
|
||||
self._align_worker = QtAutoAligner(
|
||||
stage=self._bbd_worker.controller,
|
||||
scope=self._oscope_worker.scope,
|
||||
t3r=self._t3r_driver.driver,
|
||||
# Same reason the scan and the inspector do it: the procedure
|
||||
# drives the stage from its own thread, and the position poll
|
||||
# shares the BBD TX queue.
|
||||
on_align_active=lambda active: setattr(
|
||||
self._bbd_worker, "scanning_active", active),
|
||||
)
|
||||
self._align_worker.moveToThread(self._align_thread)
|
||||
|
||||
window = AutoAlignWindow(self)
|
||||
self._align_window = window
|
||||
worker = self._align_worker
|
||||
worker.status_msg.connect(window.on_status)
|
||||
worker.reading_taken.connect(window.on_reading)
|
||||
worker.offset_done.connect(window.on_offset_done)
|
||||
worker.axis_done.connect(window.on_axis_done)
|
||||
worker.prepared.connect(self._on_align_prepared)
|
||||
worker.prepare_failed.connect(self._on_align_prepare_failed)
|
||||
worker.finished.connect(self._on_align_finished)
|
||||
worker.failed.connect(self._on_align_failed)
|
||||
worker.aborted.connect(self._on_align_aborted)
|
||||
worker.stopped.connect(self._release_align_thread)
|
||||
worker.error_occurred.connect(window.on_failed)
|
||||
window.abort_requested.connect(worker.request_abort)
|
||||
window.close_requested.connect(self._teardown_align)
|
||||
|
||||
self._align_thread.started.connect(worker.run)
|
||||
self._set_align_ui_active(True)
|
||||
window.show()
|
||||
self._align_thread.start()
|
||||
worker.request_prepare()
|
||||
|
||||
def _align_prerequisite_problem(self) -> str:
|
||||
"""Why auto-align cannot start, or "" if it can."""
|
||||
if self._oscope_worker is None or not self._oscope_worker.is_connected:
|
||||
return ("The oscilloscope is not connected, and it is the only "
|
||||
"thing that can read the DC levels. Connect it from the "
|
||||
"main window and try again.")
|
||||
if self._bbd_worker is None or not self._bbd_worker.is_connected:
|
||||
return ("The BBD202 stage is not connected, so the 1.5 mm steps "
|
||||
"cannot be made. Connect it from the main window and try "
|
||||
"again.")
|
||||
if self._t3r_driver is None or not self._t3r_driver.is_open:
|
||||
return ("The T3R is not connected, so the T-axes cannot be moved. "
|
||||
"Connect it from the T3R panel and try again.")
|
||||
return ""
|
||||
|
||||
def _on_align_prepared(self, reading):
|
||||
"""The rig is configured and standing on the point — ask the operator."""
|
||||
if self._align_window is None:
|
||||
return
|
||||
confirmed = QMessageBox.question(
|
||||
self, "Is the Image Correct?", _align_reference_prompt(reading)
|
||||
) == QMessageBox.StandardButton.Yes
|
||||
|
||||
if not confirmed:
|
||||
self._align_window.on_status(
|
||||
"Cancelled: the image was not confirmed. Nothing has moved — "
|
||||
"re-align by hand and start again.")
|
||||
self._finish_align()
|
||||
return
|
||||
|
||||
self._align_window.set_reference(reading)
|
||||
self._align_worker.request_run()
|
||||
|
||||
def _on_align_prepare_failed(self, message: str):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_failed(message)
|
||||
QMessageBox.warning(self, "Cannot Auto-Align", message)
|
||||
self._finish_align()
|
||||
|
||||
def _on_align_finished(self, result):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_finished(result)
|
||||
self._finish_align()
|
||||
|
||||
def _on_align_failed(self, message: str):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_failed(message)
|
||||
QMessageBox.warning(self, "Auto-Align Failed", message)
|
||||
self._finish_align()
|
||||
|
||||
def _on_align_aborted(self):
|
||||
if self._align_window is not None:
|
||||
self._align_window.on_aborted()
|
||||
self._finish_align()
|
||||
|
||||
def _finish_align(self):
|
||||
"""Park the rig and release the thread, leaving the summary on screen."""
|
||||
if self._align_worker is not None:
|
||||
self._align_worker.request_stop()
|
||||
|
||||
def _release_align_thread(self):
|
||||
"""Worker idle — take its thread down. Safe to call more than once."""
|
||||
if self._align_worker is not None:
|
||||
self._align_worker.stop_worker()
|
||||
if self._align_thread is not None:
|
||||
self._align_thread.quit()
|
||||
self._align_thread.wait(30000)
|
||||
self._align_thread = None
|
||||
self._align_worker = None
|
||||
self._set_align_ui_active(False)
|
||||
|
||||
def _teardown_align(self):
|
||||
"""Stop an alignment and close its window — the window or camera is
|
||||
going away, so the procedure cannot be left running."""
|
||||
window, self._align_window = self._align_window, None
|
||||
if self._align_worker is not None:
|
||||
self._align_worker.request_stop()
|
||||
self._release_align_thread()
|
||||
if window is not None:
|
||||
window.close()
|
||||
|
||||
def set_align_available(self, available: bool):
|
||||
"""The main window handing the rig over, or taking it back.
|
||||
|
||||
A scan or an angle inspection owns the same stage, so auto-align has
|
||||
to be off the table while either runs.
|
||||
"""
|
||||
self._align_available = available
|
||||
self._refresh_align_button()
|
||||
|
||||
def _refresh_align_button(self):
|
||||
self.uc480_auto_align_btn.setEnabled(
|
||||
self._align_available and self._align_thread is None)
|
||||
|
||||
def _set_align_ui_active(self, active: bool):
|
||||
"""A running alignment owns the stage and the T-axes; nothing else
|
||||
may drive them, here or in the main window."""
|
||||
self._refresh_align_button()
|
||||
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
|
||||
if panel is not None:
|
||||
panel.stop_jogs()
|
||||
panel.setEnabled(not active)
|
||||
self.align_active.emit(active)
|
||||
|
||||
|
||||
class AutoAlignWindow(QWidget):
|
||||
"""Live progress for one auto-align run, and the summary it ends with.
|
||||
|
||||
Shows the deviation from the good values rather than the raw levels: the
|
||||
procedure is a null search, so how far off it is says more than what it
|
||||
reads, and 5 mV out of ~400 mV does not show up in the raw number.
|
||||
"""
|
||||
|
||||
abort_requested = pyqtSignal()
|
||||
close_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, parent: QWidget | None = None):
|
||||
super().__init__(parent, Qt.WindowType.Window)
|
||||
self.setWindowTitle("Auto-Align")
|
||||
self.resize(560, 480)
|
||||
self._reference = None
|
||||
self._done = False
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
self.header_label = QLabel(
|
||||
f"Stepping {DEFAULT_ALIGN.offset_mm:.2f} mm either side of this "
|
||||
f"point on X (T1), then on Y (T0/T2 as a pair), re-tilting until "
|
||||
f"the DC levels come back to within "
|
||||
f"{DEFAULT_ALIGN.tolerance_mv:.0f} mV.\n"
|
||||
f"T-axes: {DEFAULT_T_AXIS.microsteps} µsteps, "
|
||||
f"{DEFAULT_T_AXIS.run_current_ma} mA.")
|
||||
self.header_label.setWordWrap(True)
|
||||
layout.addWidget(self.header_label)
|
||||
|
||||
self.reading_label = QLabel("—")
|
||||
self.reading_label.setFont(mono_font(13))
|
||||
self.reading_label.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(self.reading_label)
|
||||
|
||||
self.status_label = QLabel("Configuring the rig …")
|
||||
self.status_label.setWordWrap(True)
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# The verdict outlives the status line, which keeps reporting the
|
||||
# parking moves after the answer is known.
|
||||
self.verdict_label = QLabel()
|
||||
self.verdict_label.setWordWrap(True)
|
||||
self.verdict_label.setVisible(False)
|
||||
layout.addWidget(self.verdict_label)
|
||||
|
||||
self.log = QPlainTextEdit(self)
|
||||
self.log.setReadOnly(True)
|
||||
self.log.setFont(mono_font(11))
|
||||
layout.addWidget(self.log, 1)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self.stop_btn = QPushButton("Stop")
|
||||
self.stop_btn.setToolTip(
|
||||
"Stop at the next move and put the stage back on the reference "
|
||||
"point. Any tilt already applied stays applied.")
|
||||
self.stop_btn.clicked.connect(self._on_stop_clicked)
|
||||
self.close_btn = QPushButton("Close")
|
||||
self.close_btn.clicked.connect(self.close)
|
||||
self.close_btn.setEnabled(False)
|
||||
buttons.addWidget(self.stop_btn)
|
||||
buttons.addWidget(self.close_btn)
|
||||
layout.addLayout(buttons)
|
||||
|
||||
# ── Worker → window ───────────────────────────────────────────────────────
|
||||
|
||||
def set_reference(self, reading):
|
||||
self._reference = reading
|
||||
self._append(f"Reference: {reading.describe()}")
|
||||
|
||||
def on_status(self, msg: str):
|
||||
self.status_label.setText(msg)
|
||||
|
||||
def on_reading(self, reading):
|
||||
if self._reference is None:
|
||||
self.reading_label.setText(reading.describe())
|
||||
return
|
||||
d1, d2 = reading.error_vs(self._reference)
|
||||
self.reading_label.setText(
|
||||
f"DC1 {reading.dc1_mv:8.1f} mV ({d1:+6.1f}) "
|
||||
f"DC2 {reading.dc2_mv:8.1f} mV ({d2:+6.1f})")
|
||||
|
||||
def on_offset_done(self, result):
|
||||
self._append(f" {result.describe()}")
|
||||
|
||||
def on_axis_done(self, result):
|
||||
self._append(result.describe())
|
||||
|
||||
def on_finished(self, result):
|
||||
# The per-axis lines are already in the log, put there as they
|
||||
# happened; only the closing verdict is new.
|
||||
self._append("")
|
||||
self._append(result.verdict())
|
||||
self._set_verdict(result.verdict(), ok=result.ok)
|
||||
|
||||
def on_failed(self, message: str):
|
||||
self._append(f"FAILED: {message}")
|
||||
self._set_verdict(message, ok=False)
|
||||
|
||||
def on_aborted(self):
|
||||
message = ("Stopped by the operator. Any tilt already applied stays "
|
||||
"applied.")
|
||||
self._append(message)
|
||||
self._set_verdict(message, ok=False)
|
||||
|
||||
# ── Window → worker ───────────────────────────────────────────────────────
|
||||
|
||||
def _on_stop_clicked(self):
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.on_status("Stopping at the next move …")
|
||||
self.abort_requested.emit()
|
||||
|
||||
def _set_verdict(self, text: str, ok: bool):
|
||||
self.verdict_label.setText(text)
|
||||
self.verdict_label.setStyleSheet(
|
||||
f"font-weight: bold; color: {'green' if ok else '#b8860b'};")
|
||||
self.verdict_label.setVisible(True)
|
||||
self._done = True
|
||||
self.stop_btn.setEnabled(False)
|
||||
self.close_btn.setEnabled(True)
|
||||
|
||||
def _append(self, line: str):
|
||||
self.log.appendPlainText(line)
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Closing part-way through is a stop: the procedure owns the stage,
|
||||
# and nothing else can call it off once this window is gone.
|
||||
if not self._done:
|
||||
self.abort_requested.emit()
|
||||
self.close_requested.emit()
|
||||
super().closeEvent(event)
|
||||
|
||||
|
||||
# ── Helios laser panel ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1024,7 +1381,8 @@ class MainWindow(QMainWindow):
|
||||
self._helios_thread.started.connect(self._helios_worker.run)
|
||||
|
||||
# ── Popup windows ─────────────────────────────────────────────────────
|
||||
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker)
|
||||
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker,
|
||||
self._oscope_worker)
|
||||
self._helios_win = HeliosWindow(self._helios_worker)
|
||||
self._scan_progress = ScanProgressWindow()
|
||||
|
||||
@@ -1146,6 +1504,7 @@ class MainWindow(QMainWindow):
|
||||
self._camera_win.closed.connect(
|
||||
lambda: self._set_toggle(self.show_camera_toggle, False, "Show Camera Window")
|
||||
)
|
||||
self._camera_win.align_active.connect(self._on_camera_align_active)
|
||||
|
||||
# Helios
|
||||
self.show_helios_toggle.toggled.connect(self._on_helios_toggle)
|
||||
@@ -1331,6 +1690,13 @@ class MainWindow(QMainWindow):
|
||||
"""Both entry points drive the same rig, so they lock and unlock together."""
|
||||
self.start_scan_btn.setEnabled(enabled)
|
||||
self.saw_check_btn.setEnabled(enabled)
|
||||
# Auto-align lives in the camera window but drives this same stage.
|
||||
self._camera_win.set_align_available(enabled)
|
||||
|
||||
def _on_camera_align_active(self, active: bool):
|
||||
"""An alignment started or finished in the camera window."""
|
||||
self._set_scan_buttons_enabled(not active)
|
||||
self.inspect_angles_btn.setEnabled(not active)
|
||||
|
||||
def _on_browse_save_dir(self):
|
||||
d = QFileDialog.getExistingDirectory(
|
||||
|
||||
Reference in New Issue
Block a user