diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d67d86fa9..0c361207e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -68,13 +68,17 @@ jobs: - name: Run the GUI test in its own process # Own invocation: the full-application GUI test leaves Qt/thread state # behind that deterministically segfaults later Qt tests (e.g. - # test_channel_sequence) when run in the same pytest process. - # Step-scoped pyqt5 pin: without it, pytest-qt picks PySide6 for qtbot - # while the app code (qtpy) uses PyQt5, and the mixed bindings fail - # with a QObject double-init error. + # test_channel_sequence) when run in the same pytest process: pytest-qt + # deleteLater()s each window at teardown, and destroying the full GUI + # crashes once a later test's event loop flushes that delete, so this + # file must be the last thing its process does (and hard-exit). + # Step-scoped pyqt6 pin: keeps pytest-qt's qtbot on the same binding as + # the app code (qtpy, QT_API=pyqt6). Mixed bindings fail with a QObject + # double-init error, and pointing this at a binding that is not installed + # (e.g. pyqt5, removed by setup_22.04.sh) aborts pytest at startup. run: python3 -m pytest tests/control/test_HighContentScreeningGui.py working-directory: ./software env: - QT_API: pyqt5 - PYTEST_QT_API: pyqt5 + QT_API: pyqt6 + PYTEST_QT_API: pyqt6 SQUID_PYTEST_HARD_EXIT: "1" diff --git a/README.md b/README.md index 1c0284cf1..7fca10907 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ See related work and applications on: www.cephla.com, www.squid-imaging.org ## How to Start ### Software #### Setting up and run Squid software on Linux -Ubuntu 22.04 is the most tested platform. Other Linux systems should work but performance is not guaranteed. +Ubuntu 22.04 is the most tested platform; Ubuntu 24.04 is supported with its own setup script (`software/setup_24.04.sh`). Other Linux systems should work but performance is not guaranteed. See [README](https://github.com/Cephla-Lab/Squid/blob/master/software/README.md) in `/software` directory for instructions. Toupcam and laser auto-focus camera dependencies will be installed automatically when you run the setup script. diff --git a/software/Python 3.14 Compatibility Check.md b/software/Python 3.14 Compatibility Check.md new file mode 100644 index 000000000..8aed45baa --- /dev/null +++ b/software/Python 3.14 Compatibility Check.md @@ -0,0 +1,67 @@ +# Python 3.14 Compatibility -- Squid Dependencies + +## Supported (confirmed) +| Package | Notes | +|---------|-------| +| numpy | Wheels for 3.14 since v2.3+ | +| scipy | Supported | +| pandas | Supported | +| pydantic (v2) | Supported. (pydantic v1 is not, but nothing in our stack needs it: napari 0.5.x already ran on pydantic 2.x) | +| Pillow | Supported | +| matplotlib | Supported | +| PyYAML | Supported | +| filelock | Supported | +| platformdirs | Supported | +| scikit-image | Wheels for 3.14 since v0.26 | +| napari (latest) | Supported -- ships both `[pyqt5]` and `[pyqt6]` extras (0.7.x and 0.9.x); we use PyQt6 | +| opencv-python-headless | Uses stable ABI (cp37-abi3), works | +| opencv-contrib-python-headless | Same stable ABI, works | +| torch (PyTorch) | Supported since ~2.13 | +| dask | Active releases in 2026 | +| zarr | Supported | +| tifffile | Supported | +| imageio | Supported | +| pyqtgraph | Pure Python, likely works | +| qtpy | Pure Python abstraction layer, works | +| PyQt5 | Installs on 3.14: PyQt5 5.15.11 wheels are `cp38-abi3` and PyQt5-sip 12.19.0 ships cp314 wheels. Not a blocker -- we moved to PyQt6 for other reasons | + +## Blocked / Incompatible +None confirmed in the pip stack. Earlier drafts listed PyQt5 and pydantic v1 here; both were wrong +(see the Supported table). The likely blockers are the old pins pulled in by unmaintained packages: +aicsimageio 4.14 pins `tifffile<2023.3.15`, `zarr<2.16`, `lxml<5`; basicpy pins `scipy<1.13` +(peng-lab/BaSiCPy#173). Whether those old releases have 3.14 wheels has not been checked. + +## Uncertain / Likely OK but unconfirmed +| Package | Notes | +|---------|-------| +| pyserial | Pure Python, last release 2020 -- probably works but untested | +| pyvisa | Likely works | +| hidapi | Cython extension -- no 3.14 wheels confirmed | +| GitPython | Marked as NOT supporting 3.14 on pyreadiness.org | +| aicsimageio | Unclear, niche package | +| basicpy | Unclear | +| ome-zarr | Unclear | +| pydantic-xml | Likely works if pydantic v2 is used | +| pyreadline3 | Windows-specific, unclear | +| qtconsole | Depends on Qt backend | +| lxml / lxml_html_clean | C extension -- needs checking | +| mcp | Unclear | +| Hardware drivers (PySpin, pyAndorSDK3, ids_peak, pyvcam, pm16) | Vendor-specific, unlikely to have 3.14 builds yet | + +## Bottom Line + +No confirmed hard blocker in the pip stack. The PyQt6 + napari 0.7 migration in this PR removes the +Qt-side uncertainty; what remains before a 3.14 move is: +1. Confirming 3.14 wheels (or dropping) the pinned-back packages aicsimageio and basicpy pull in +2. Verifying all hardware vendor SDKs have 3.14 builds + +**Recommendation: Stay on Python 3.10** for production use. It's the sweet spot where everything works. + +## Sources +- [Python 3.14 Readiness](https://pyreadiness.org/3.14/) +- [NumPy 2.3.0 Release Notes](https://numpy.org/devdocs/release/2.3.0-notes.html) +- [PyQt5 on PyPI](https://pypi.org/project/PyQt5/) +- [napari Installation](https://napari.org/stable/getting_started/installation.html) +- [PyTorch torch.compile Python 3.14 support](https://dev-discuss.pytorch.org/t/torch-compile-support-for-python-3-14-completed/3276) +- [Pydantic v1 Python 3.14 issue](https://github.com/pydantic/pydantic/issues/12618) +- [Anaconda Python 3.14 overview](https://www.anaconda.com/blog/python-3-14-what-data-scientists-developers-need-know) diff --git a/software/README.md b/software/README.md index 99f7db118..a487f1a83 100644 --- a/software/README.md +++ b/software/README.md @@ -1,11 +1,21 @@ ## Setting up the environments -Run the following script in terminal to clone the repo and set up the environment +Run the setup script for your Ubuntu release in a terminal to clone the repo and set up the environment. + +Ubuntu 22.04: ``` wget https://raw.githubusercontent.com/Cephla-Lab/Squid/master/software/setup_22.04.sh chmod +x setup_22.04.sh ./setup_22.04.sh ``` +Ubuntu 24.04: +``` +wget https://raw.githubusercontent.com/Cephla-Lab/Squid/master/software/setup_24.04.sh +chmod +x setup_24.04.sh +./setup_24.04.sh +``` +The two scripts install the same packages; the 24.04 one only adds what Python 3.12 requires (pip's `--break-system-packages`, apt setuptools). If you pip-install extras by hand on 24.04, add `--break-system-packages` to the pip command. + Reboot the computer to finish the installation. ## Optional or Hardware-specific dependencies diff --git a/software/control/console.py b/software/control/console.py index fed32978e..9accbb423 100644 --- a/software/control/console.py +++ b/software/control/console.py @@ -1,7 +1,7 @@ import os # set QT_API environment variable -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import qtpy from qtpy.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QPushButton, QLabel diff --git a/software/control/core/core.py b/software/control/core/core.py index 35ef4ad5a..6f4c0b623 100644 --- a/software/control/core/core.py +++ b/software/control/core/core.py @@ -4,7 +4,7 @@ import tempfile # qt libraries -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import qtpy import pyqtgraph as pg from qtpy.QtCore import * diff --git a/software/control/core/job_processing.py b/software/control/core/job_processing.py index ec27df65a..408c1a78c 100644 --- a/software/control/core/job_processing.py +++ b/software/control/core/job_processing.py @@ -935,11 +935,16 @@ def shutdown(self, timeout_s=1.0): if self.is_alive(): self.terminate() self.join(timeout=1.0) - # Clean up multiprocessing primitives to avoid semaphore leaks - self._input_queue.close() - self._input_queue.join_thread() - self._output_queue.close() - self._output_queue.join_thread() + # Clean up multiprocessing primitives to avoid semaphore leaks. cancel_join_thread() + # first: a Queue feeds its pipe from a background thread, and if the worker was + # terminated with unread data queued that thread is stuck in a write nobody will + # read, so join_thread() would wait forever (deterministic on Windows, where the + # write blocks once the pipe buffer fills). Both queues are being dropped, so + # discarding those bytes is fine. + for queue in (self._input_queue, self._output_queue): + queue.cancel_join_thread() + queue.close() + queue.join_thread() # Clear references to allow garbage collection of Event and Value semaphores self._input_queue = None self._output_queue = None diff --git a/software/control/core_PDAF.py b/software/control/core_PDAF.py index 935955b81..f89e7f6d8 100644 --- a/software/control/core_PDAF.py +++ b/software/control/core_PDAF.py @@ -1,7 +1,7 @@ # set QT_API environment variable import os -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" # qt libraries from qtpy.QtCore import * diff --git a/software/control/core_displacement_measurement.py b/software/control/core_displacement_measurement.py index c86794d3b..55336f55c 100644 --- a/software/control/core_displacement_measurement.py +++ b/software/control/core_displacement_measurement.py @@ -1,7 +1,7 @@ # set QT_API environment variable import os -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import qtpy # qt libraries diff --git a/software/control/core_usbspectrometer.py b/software/control/core_usbspectrometer.py index e67f2bd31..733065ec7 100644 --- a/software/control/core_usbspectrometer.py +++ b/software/control/core_usbspectrometer.py @@ -1,7 +1,7 @@ # set QT_API environment variable import os -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import qtpy # qt libraries diff --git a/software/control/core_volumetric_imaging.py b/software/control/core_volumetric_imaging.py index 9c15c81aa..ef9689522 100644 --- a/software/control/core_volumetric_imaging.py +++ b/software/control/core_volumetric_imaging.py @@ -1,7 +1,7 @@ # set QT_API environment variable import os -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import qtpy # qt libraries @@ -173,8 +173,8 @@ def __init__(self, window_title=""): self.setCentralWidget(self.widget) # set window size - desktopWidget = QDesktopWidget() - width = min(desktopWidget.height() * 0.9, 1000) # @@@TO MOVE@@@# + screen_height = QApplication.primaryScreen().size().height() + width = int(min(screen_height * 0.9, 1000)) # @@@TO MOVE@@@# height = width self.setFixedSize(width, height) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index ba8c93e05..d7efa882f 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -15,7 +15,7 @@ ClearedScanCoordinates, ) -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import re import time from enum import Enum, auto @@ -1411,8 +1411,7 @@ def _getMainWindowMinimumSize(self): We want our main window to fit on the primary screen, so grab the users primary screen and return something slightly smaller than that. """ - desktop_info = QDesktopWidget() - primary_screen_size = desktop_info.screen(desktop_info.primaryScreen()).size() + primary_screen_size = QApplication.primaryScreen().size() height_min = int(0.9 * primary_screen_size.height()) width_min = int(0.96 * primary_screen_size.width()) @@ -1497,10 +1496,26 @@ def make_connections(self): self.movement_updater.piezo_z_um.connect(self.piezoWidget.update_displacement_um_display) self.multipointController.signal_set_display_tabs.connect(self.setAcquisitionDisplayTabs) - # Unified mosaic widget save hooks: route the controller's start/finish - # signals over here, where self.unifiedMosaicWidget is reachable. + # Unified mosaic widget: save hooks and its data feed. The feed is not part of + # napari_connections because performance mode keeps it connected: the canvas still + # builds during the run and only its redraw is deferred, by keeping the tab hidden + # until the run finishes (UnifiedMosaicWidget.showEvent, toggleAcquisitionStart). self.multipointController.signal_acquisition_save_target.connect(self._on_acquisition_save_target) self.multipointController.timepoint_finished.connect(self._on_timepoint_finished) + if self.unifiedMosaicWidget is not None: + self.multipointController.mosaic_tile_update.connect(self.unifiedMosaicWidget.updateTile) + self.unifiedMosaicWidget.signal_coordinates_clicked.connect(self.move_from_click_mm) + self.unifiedMosaicWidget.signal_clear_viewer.connect(self.navigationViewer.clear_slide) + # plate_view_init can be emitted from the acquisition worker thread; the slot + # needs to run on the main thread. + self.multipointController.plate_view_init.connect( + self.unifiedMosaicWidget.setPlateLayout, Qt.QueuedConnection + ) + if ENABLE_WELLPLATE_MULTIPOINT: # ROI shape drawing in mosaic mode + self.wellplateMultiPointWidget.signal_manual_shape_mode.connect( + self.unifiedMosaicWidget.enable_shape_drawing + ) + self.unifiedMosaicWidget.signal_shape_drawn.connect(self.wellplateMultiPointWidget.update_manual_shape) # Laser engine readiness gate — modal progress dialog while waiting at timepoint boundaries. self.multipointController.signal_laser_engine_waiting.connect(self._show_laser_engine_dialog) @@ -1672,7 +1687,10 @@ def setup_movement_updater(self): self.movement_update_timer.start() def makeNapariConnections(self): - """Collect the (signal, slot[, connection_type]) pairs that feed the napari display widgets.""" + """Collect the (signal, slot[, connection_type]) pairs that performance mode disconnects. + + That is the multichannel view's feed; the mosaic's is permanent (see make_connections). + """ self.napari_connections = [] if not self.live_only_mode: @@ -1716,55 +1734,31 @@ def makeNapariConnections(self): ), ] - # Unified mosaic/plate view connections. - # plate_view_init uses Qt.QueuedConnection because it can be emitted from - # the acquisition worker thread; the slot needs to run on the main thread. - if self.unifiedMosaicWidget is not None: - self.napari_connections += [ - (self.multipointController.mosaic_tile_update, self.unifiedMosaicWidget.updateTile), - (self.unifiedMosaicWidget.signal_coordinates_clicked, self.move_from_click_mm), - (self.unifiedMosaicWidget.signal_clear_viewer, self.navigationViewer.clear_slide), - ( - self.multipointController.plate_view_init, - self.unifiedMosaicWidget.setPlateLayout, - Qt.QueuedConnection, - ), - ] - - # ROI shape drawing in mosaic mode (wellplate flow only). - if ENABLE_WELLPLATE_MULTIPOINT: - self.napari_connections += [ - ( - self.wellplateMultiPointWidget.signal_manual_shape_mode, - self.unifiedMosaicWidget.enable_shape_drawing, - ), - ( - self.unifiedMosaicWidget.signal_shape_drawn, - self.wellplateMultiPointWidget.update_manual_shape, - ), - ] - # Make initial connections self.updateNapariConnections() def updateNapariConnections(self): - """Performance mode disconnects the napari display widgets; leaving it reconnects them.""" + """Performance mode disconnects the multichannel napari view; leaving it reconnects it. + + Disconnect first so repeated calls are idempotent: plain connect() stacks a second + connection instead of raising. (Qt.UniqueConnection would do this but can't be OR'd + with the requested connection type under PyQt6.) + """ for signal, slot, *connection_type in self.napari_connections: try: - if self.performance_mode: - signal.disconnect(slot) - else: - signal.connect(slot, *connection_type) + signal.disconnect(slot) except TypeError: - pass # not connected / already connected + pass # not connected yet + if not self.performance_mode: + signal.connect(slot, *connection_type) - def toggleNapariTabs(self): - # Enable/disable Napari tabs based on performance mode + def _set_napari_tabs_enabled(self, enabled: bool): + """Enable or disable the napari display tabs; disabling also returns to Live View.""" for i in range(1, self.imageDisplayTabs.count()): if self.imageDisplayTabs.tabText(i) not in (self.LASER_BASED_FOCUS_TAB_NAME, self.FLUIDICS_TAB_NAME): - self.imageDisplayTabs.setTabEnabled(i, not self.performance_mode) + self.imageDisplayTabs.setTabEnabled(i, enabled) - if self.performance_mode: + if not enabled: self.imageDisplayTabs.setCurrentIndex(0) # Live View def togglePerformanceMode(self): @@ -1772,7 +1766,7 @@ def togglePerformanceMode(self): button_txt = "Disable" if self.performance_mode else "Enable" self.performanceModeToggle.setText(button_txt + " Performance Mode") self.updateNapariConnections() - self.toggleNapariTabs() + self._set_napari_tabs_enabled(not self.performance_mode) self.signal_performance_mode_changed.emit(self.performance_mode) print(f"Performance mode {'enabled' if self.performance_mode else 'disabled'}") @@ -2398,9 +2392,6 @@ def onDisplayTabChanged(self, index): self.toggleWellSelector(False) def onWellplateChanged(self, format_): - if isinstance(format_, QVariant): - format_ = format_.value() - # TODO(imo): Not sure why glass slide is so special here? It seems like it's just a "1 well plate". if format_ == "glass slide": self.toggleWellSelector(False) @@ -2502,6 +2493,11 @@ def toggleAcquisitionStart(self, acquisition_started): self.live_scan_grid_was_on = False # NOTE: RAM monitor widget is connected via multipointController.signal_acquisition_start # which fires AFTER the memory monitor is created (see make_connections) + + # Performance mode: hide the napari tabs for the run so the mosaic canvas builds + # without rendering (the previous run's completion re-enabled them). + if self.performance_mode: + self._set_napari_tabs_enabled(False) else: self.log.info("FINISHED ACQUISITION") if self.live_scan_grid_was_on: @@ -2509,6 +2505,13 @@ def toggleAcquisitionStart(self, acquisition_started): self.live_scan_grid_was_on = False # NOTE: RAM monitor widget is disconnected via multipointController.acquisition_finished + # Performance mode: show the assembled mosaic once, now that the run is done; + # becoming visible flushes its deferred redraw (UnifiedMosaicWidget.showEvent). + if self.performance_mode: + self._set_napari_tabs_enabled(True) + if self.unifiedMosaicWidget is not None: + self.imageDisplayTabs.setCurrentWidget(self.unifiedMosaicWidget) + # click to move off during acquisition self.navigationWidget.set_click_to_move(not acquisition_started) @@ -2823,6 +2826,14 @@ def _cleanup_common(self, for_restart: bool = False): except Exception: self.log.exception(f"Error closing multipoint controller during {context}") + # Stop the Slack notifier's worker thread. It is a plain daemon thread, so + # without this it outlives the window (and, in the test suite, the test). + if self.slackNotifier is not None: + try: + self.slackNotifier.close() + except Exception: + self.log.exception(f"Error closing Slack notifier during {context}") + # Clean up NDViewer if self.ndviewerTab is not None: try: diff --git a/software/control/single_instance.py b/software/control/single_instance.py index 309e2c896..60f2eee7c 100644 --- a/software/control/single_instance.py +++ b/software/control/single_instance.py @@ -11,7 +11,7 @@ # Pin the Qt binding before importing qtpy, matching the convention used by # every other Qt-using module in this codebase. -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import getpass from typing import NamedTuple, Optional diff --git a/software/control/widgets.py b/software/control/widgets.py index eaf79d884..9d49653ac 100644 --- a/software/control/widgets.py +++ b/software/control/widgets.py @@ -52,7 +52,17 @@ from squid.config import CameraPixelFormat # set QT_API environment variable -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" + + +def checkbox_state_is_checked(state) -> bool: + """True when a QCheckBox.stateChanged value means Checked, under PyQt5 and PyQt6. + + The signal carries an int on both bindings, but on PyQt6 Qt.Checked is a plain Enum + member, so ``state == Qt.Checked`` is always False there. + """ + return getattr(state, "value", state) == getattr(Qt.Checked, "value", Qt.Checked) + # qt libraries import qtpy @@ -1924,11 +1934,7 @@ def create_sim_checkbox(config_key): def _toggle_dev_tab_visibility(self, state): """Show or hide the Dev tab based on checkbox state.""" - # Handle both PyQt5 (int) and PyQt6 (CheckState enum) signal types - # PyQt6 enums have .value property, integers don't - use getattr for compatibility - state_value = getattr(state, "value", state) - checked_value = getattr(Qt.Checked, "value", Qt.Checked) - self.tab_widget.setTabVisible(self._dev_tab_index, state_value == checked_value) + self.tab_widget.setTabVisible(self._dev_tab_index, checkbox_state_is_checked(state)) # The four readers below go through control._def.conf_attribute_reader, the function the running software reads # the same ini with. It strips an inline comment ("30.0 # mm/s"), which ConfigParser keeps as part of the value. @@ -4682,7 +4688,7 @@ def add_components(self, show_trigger_options, show_autolevel, autolevel, stretc # connections self.entry_triggerFPS.valueChanged.connect(self.liveController.set_trigger_fps) - self.dropdown_modeSelection.activated[str].connect(self.select_new_microscope_mode_by_name) + self.dropdown_modeSelection.textActivated.connect(self.select_new_microscope_mode_by_name) self.dropdown_triggerManu.currentIndexChanged.connect(self.update_trigger_mode) self.btn_live.clicked.connect(self.toggle_live) self.btn_snap.clicked.connect(self.snap) @@ -12256,7 +12262,7 @@ def moveStage(self, x, y): self.stage.move_y(dy) def toggleClickToMove(self, state): - if state == Qt.Checked: + if checkbox_state_is_checked(state): self.live_viewer.signal_calibration_viewer_click.connect(self.viewerClicked) else: self.live_viewer.signal_calibration_viewer_click.disconnect(self.viewerClicked) @@ -13869,7 +13875,7 @@ def save_settings(self): json.dump(data, f) -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas from matplotlib.figure import Figure from mpl_toolkits.mplot3d import proj3d from scipy.interpolate import griddata @@ -14115,7 +14121,7 @@ def __init__(self, wavelength_nm=None, parent=None): self.na_label.setVisible(True) def _on_checkbox_changed(self, state): - checked = state == Qt.Checked + checked = checkbox_state_is_checked(state) self.spinbox.setVisible(checked) self.na_label.setVisible(not checked) @@ -14751,7 +14757,7 @@ def __init__(self, source_code=None, parent=None): self.na_label.setVisible(True) def _on_checkbox_changed(self, state): - checked = state == Qt.Checked + checked = checkbox_state_is_checked(state) self.spinbox.setVisible(checked) self.na_label.setVisible(not checked) diff --git a/software/control/widgets_mosaic.py b/software/control/widgets_mosaic.py index 17a6ae662..0eeb86c09 100644 --- a/software/control/widgets_mosaic.py +++ b/software/control/widgets_mosaic.py @@ -135,6 +135,7 @@ def __init__(self, objectiveStore, camera, contrastManager, parent=None): self.mode = _load_last_view_mode() self.layers_initialized = False + self._pending_refresh = False # a redraw was skipped while hidden; see _refresh_or_defer self.mosaic_dtype = None self.viewer_pixel_size_mm = None @@ -282,12 +283,38 @@ def setPlateLayout(self, plate_view_init): if PLATE_BOUNDARIES_LAYER in self.viewer.layers: self.viewer.layers.remove(self.viewer.layers[PLATE_BOUNDARIES_LAYER]) if canvas_changed and self.mode == DisplayMode.PLATE: - self._fit_view_to_plate() + self._refresh_or_defer(self._fit_view_to_plate) def _image_layers(self): """Iterate napari image layers, skipping shape/boundary overlays.""" return [lyr for lyr in self.viewer.layers if lyr.name not in NON_IMAGE_LAYERS and hasattr(lyr, "data")] + def _refresh_or_defer(self, gl_call): + """Run a GL-touching call now, or once the tab is next shown. + + Painting this napari/vispy canvas while its tab is hidden fails on PyQt6: the + hidden QOpenGLWidget has no valid framebuffer, which corrupts the shared GL + program and cascades into "Cannot SIZE object N" on later frames. Tile data is + written to the layers regardless (so saves stay correct); only the redraw waits + for showEvent, which replays every kind of deferred redraw at once. + """ + if self.isVisible(): + gl_call() + else: + self._pending_refresh = True + + def showEvent(self, event): + super().showEvent(event) + if self._pending_refresh: + self._pending_refresh = False + for lyr in self._image_layers(): + lyr.refresh() + if self.mode == DisplayMode.PLATE: + self._draw_plate_boundaries() + self._fit_view_to_plate() # not resetView(): bypasses the plate zoom clamp + else: + self.resetView() + def enable_shape_drawing(self, enable): """Set Manual-ROI drawing on/off. Idempotent: the upstream signal only emits True on entry to Manual mode (never False on exit), so @@ -535,8 +562,8 @@ def updateTile(self, update): layer = self.viewer.layers[channel_name] blit_tiles_to_canvas(layer.data, [(image, y_px, x_px)]) - layer.refresh() - self._draw_plate_boundaries() + self._refresh_or_defer(layer.refresh) + self._refresh_or_defer(self._draw_plate_boundaries) # The fit-the-whole-plate camera reset only fires when the canvas # is (re)allocated — in setPlateLayout when slot dims/coverage # change and in _create_channel_layer when a layer is created. @@ -567,14 +594,14 @@ def _update_mosaic_layer(self, layer, image, tl_x_mm, tl_y_mm, prev_top_left): x_end = min(x_offset + lyr.data.shape[1], new_data.shape[1]) new_data[y_offset:y_end, x_offset:x_end] = lyr.data[: y_end - y_offset, : x_end - x_offset] lyr.data = new_data - self.resetView() # Keep ROI vertices anchored to their stage-coordinate positions after the shift. self._update_shape_layer_position() + self._refresh_or_defer(self.resetView) y_pos = int(math.floor((tl_y_mm - self.top_left_coordinate[0]) / self.viewer_pixel_size_mm)) x_pos = int(math.floor((tl_x_mm - self.top_left_coordinate[1]) / self.viewer_pixel_size_mm)) blit_tiles_to_canvas(layer.data, [(image, y_pos, x_pos)]) - layer.refresh() + self._refresh_or_defer(layer.refresh) def _create_channel_layer(self, channel_name, reference_image): """Create a new napari image layer for a channel. @@ -615,7 +642,7 @@ def _create_channel_layer(self, channel_name, reference_image): # immediately sees the full plate; subsequent tiles preserve any # pan/zoom they've made since. if self.mode == DisplayMode.PLATE and self.num_rows > 0 and self.num_cols > 0: - self._fit_view_to_plate() + self._refresh_or_defer(self._fit_view_to_plate) def _convert_image_dtype(self, image, target_dtype): """Convert image to target dtype with range scaling.""" diff --git a/software/control/widgets_usbspectrometer.py b/software/control/widgets_usbspectrometer.py index 358348eb6..e5ff9c234 100644 --- a/software/control/widgets_usbspectrometer.py +++ b/software/control/widgets_usbspectrometer.py @@ -1,7 +1,7 @@ # set QT_API environment variable import os -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import qtpy # qt libraries diff --git a/software/main_hcs.py b/software/main_hcs.py index dddc81816..412f3a766 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -3,11 +3,12 @@ import logging import os -os.environ["QT_API"] = "pyqt5" +os.environ["QT_API"] = "pyqt6" import signal import sys # qt libraries +from qtpy.QtCore import Qt from qtpy.QtWidgets import * from qtpy.QtGui import * @@ -54,6 +55,14 @@ ) args = parser.parse_args() + # This GUI embeds several napari viewers (Live, Multichannel, Mosaic), each + # backed by its own vispy/OpenGL canvas, in a single process. Multiple vispy + # canvases must share one OpenGL context or vispy misroutes GLIR draw commands + # between them and rendering fails with "Cannot SIZE object N because it does + # not exist" (the mosaic image never appears). This attribute MUST be set + # before the QApplication is constructed. See napari's multiple-viewer example. + QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts, True) + # Construct QApplication first so the single-instance check can show a # QMessageBox before any other startup side effects (logging, migration). app = QApplication(["Squid"]) diff --git a/software/scripts/run_acquisition.py b/software/scripts/run_acquisition.py index aa5f824ca..47e90a79b 100755 --- a/software/scripts/run_acquisition.py +++ b/software/scripts/run_acquisition.py @@ -113,7 +113,7 @@ def launch_gui(simulation: bool = False, verbose: bool = False) -> subprocess.Po cmd.append("--verbose") env = os.environ.copy() - env["QT_API"] = "pyqt5" + env["QT_API"] = "pyqt6" if verbose: print(f"Launching GUI: {' '.join(cmd)}") diff --git a/software/setup_22.04.sh b/software/setup_22.04.sh index 8e4defd68..bd3e3dee4 100755 --- a/software/setup_22.04.sh +++ b/software/setup_22.04.sh @@ -37,8 +37,8 @@ sudo apt update # install packages sudo apt install python3-pip -y -sudo apt install python3-pyqtgraph python3-pyqt5 -y -sudo apt install python3-pyqt5.qtsvg +# Do not apt-install python3-pyqtgraph: it pulls in python3-pyqt5 (see the Qt-binding note +# below). pyqtgraph comes from pip. sudo apt-get install git -y ## clone the repo if we don't already have it. @@ -54,15 +54,30 @@ fi cd "$SQUID_SOFTWARE_ROOT" mkdir -p "$SQUID_SOFTWARE_ROOT/cache" -# Ubuntu 22.04 ships pip 22.0.2, whose resolver can't handle the -# napari==0.5.4 dependency graph on current PyPI (hits ResolutionTooDeep -# after hours of backtracking). Upgrade pip before installing libraries. +# Ubuntu 22.04 ships an old pip; upgrade it before resolving the dependency graph. python3 -m pip install --upgrade pip -# install libraries -pip3 install qtpy pyserial pandas imageio crc==1.3.0 lxml "numpy<2" tifffile scipy pyreadline3 +# Qt binding: the app targets PyQt6 (QT_API=pyqt6). Exactly ONE Qt binding may be installed: +# PyQt5 and PyQt6 side by side break napari/vispy OpenGL rendering (blank canvases, "Cannot +# SIZE object N because it does not exist"), so remove any pre-existing PyQt5 first. +sudo apt remove -y python3-pyqt5 python3-pyqt5.qtsvg 2>/dev/null || true +pip3 uninstall -y PyQt5 PyQt5-Qt5 PyQt5-sip 2>/dev/null || true + +# install libraries. No "numpy<2" pin: napari 0.7 only needs numpy>=1.24, and the rest of +# the current stack (opencv-python 5.x, pyqtgraph 0.14, ...) targets NumPy 2. +# +# aicsimageio and basicpy are deliberately NOT installed: basicpy pins scipy<1.13, which +# drags the environment back to numpy 1.26 and breaks napari and opencv (see the commit +# "stop installing basicpy/aicsimageio"). Their only consumer, control/stitcher.py, is not +# imported by the application; install them into a separate venv if you need it. +pip3 install pyqtgraph qtpy pyserial pandas imageio crc==1.3.0 lxml numpy tifffile scipy pyreadline3 pip3 install opencv-python-headless opencv-contrib-python-headless -pip3 install napari==0.5.4 scikit-image dask_image ome_zarr aicsimageio basicpy pytest pytest-qt pytest-xvfb gitpython matplotlib pydantic_xml pyvisa hidapi filelock lxml_html_clean psutil mcp ndv +# napari and PyQt6 pinned to the tested combination and resolved in one pip call, so the +# [pyqt6] extra checks the PyQt6 pins against napari's Qt blocklist (napari 0.7.1 blocks +# PyQt6-Qt6 6.11.0 and 6.11.1, napari/napari#9052): bump them together. +# tensorstore is required by control/ndviewer_light and by tests/control/core/test_zarr_writer.py, +# which begins with pytest.importorskip("tensorstore") -- without it ~79 zarr tests silently skip. +pip3 install "napari[pyqt6]==0.7.1" "PyQt6==6.11.0" "PyQt6-Qt6==6.11.2" "PyQt6-sip==13.12.0" scikit-image dask_image ome_zarr tensorstore pytest pytest-qt pytest-xvfb gitpython matplotlib pydantic_xml pyvisa hidapi filelock lxml_html_clean psutil mcp ndv # Optional: PI V-308 / C-414 focus stage (USE_PI_FOCUS_STAGE). Safe to skip if unused; # squid.stage.pi imports it lazily and only needs it to connect to real hardware, so diff --git a/software/setup_24.04.sh b/software/setup_24.04.sh new file mode 100755 index 000000000..440eb86ee --- /dev/null +++ b/software/setup_24.04.sh @@ -0,0 +1,155 @@ +#!/bin/bash +set -eo pipefail + +# Ubuntu 24.04 (Python 3.12) variant of setup_22.04.sh. Keep the two scripts in sync; the only +# intended differences are: +# * PEP 668: 24.04 marks the system Python as externally managed and pip refuses to install +# into it without --break-system-packages, so PIP_BREAK_SYSTEM_PACKAGES=1 is exported +# before the first pip call. +# * python3-setuptools and python3-wheel are apt-installed explicitly: Python 3.12 no longer +# ships distutils, and the Daheng camera API below is installed with a plain +# `setup.py build` / `setup.py install`, which needs setuptools. + +if [[ -n "$TRACE" ]]; then + echo "TRACE variable non-empty, turning on script tracing." + set -x +fi + +SQUID_REPO_PATH="$HOME/Desktop/Squid" + +for i in "$@"; do + case $i in + -r=*|--repo_path=*) + SQUID_REPO_PATH="$(cd "${i#*=}" && pwd)" + shift + ;; + -*|--*) + echo "Unknown option $i" + exit 1 + ;; + *) + ;; + esac +done + +echo "Using SQUID_REPO_PATH='${SQUID_REPO_PATH}'" + +readonly SQUID_REPO_HTTP="https://github.com/Cephla-Lab/Squid.git" +readonly SQUID_SOFTWARE_ROOT="${SQUID_REPO_PATH}/software" +readonly SQUID_REPO_PATH_PARENT="$(dirname "${SQUID_REPO_PATH}")" +readonly DAHENG_CAMERA_DRIVER_ROOT="$SQUID_SOFTWARE_ROOT/drivers and libraries/daheng camera/Galaxy_Linux-x86_Gige-U3_32bits-64bits_1.2.1911.9122" +readonly DAHENG_CAMERA_DRIVER_API_ROOT="$SQUID_SOFTWARE_ROOT/drivers and libraries/daheng camera/Galaxy_Linux_Python_1.0.1905.9081/api" +readonly TOUPCAM_UDEV_RULE_PATH="$SQUID_SOFTWARE_ROOT/drivers and libraries/toupcam/linux/udev/99-toupcam.rules" +readonly PI_UDEV_RULE_DIR="$SQUID_SOFTWARE_ROOT/drivers and libraries/pi/udev" +# update +sudo apt update + +# install packages. python3-setuptools/python3-wheel are listed explicitly (python3-pip pulls +# them in anyway): Python 3.12 has no distutils, and the Daheng API install below is a plain +# `setup.py build` / `setup.py install`, which needs setuptools. Ubuntu's setuptools 68 handles +# it; upstream has deprecated `setup.py install`, so do not pip-upgrade setuptools here. +sudo apt install python3-pip python3-setuptools python3-wheel -y +# Do not apt-install python3-pyqtgraph: it pulls in python3-pyqt5 (see the Qt-binding note +# below). pyqtgraph comes from pip. + +sudo apt-get install git -y +## clone the repo if we don't already have it. +# No matter, make sure the repo's parent dir is there +mkdir -p "${SQUID_REPO_PATH_PARENT}" +if [[ ! -d "${SQUID_REPO_PATH}" ]]; then + git clone "$SQUID_REPO_HTTP" "${SQUID_REPO_PATH}" +else + echo "Using existing repo at '${SQUID_REPO_PATH}' at HEAD=$(cd "${SQUID_REPO_PATH}" && git rev-parse HEAD)" +fi + + +cd "$SQUID_SOFTWARE_ROOT" +mkdir -p "$SQUID_SOFTWARE_ROOT/cache" + +# PEP 668: without this every pip call below fails with "externally-managed-environment". +# pip reads it as --break-system-packages; one export covers the installs, the PyQt5 uninstall +# and the editable fluidics install. Packages land in ~/.local, as on 22.04. +export PIP_BREAK_SYSTEM_PACKAGES=1 + +# Upgrade pip before resolving the dependency graph. +python3 -m pip install --upgrade pip + +# Qt binding: the app targets PyQt6 (QT_API=pyqt6). Exactly ONE Qt binding may be installed: +# PyQt5 and PyQt6 side by side break napari/vispy OpenGL rendering (blank canvases, "Cannot +# SIZE object N because it does not exist"), so remove any pre-existing PyQt5 first. +sudo apt remove -y python3-pyqt5 python3-pyqt5.qtsvg 2>/dev/null || true +pip3 uninstall -y PyQt5 PyQt5-Qt5 PyQt5-sip 2>/dev/null || true + +# install libraries. No "numpy<2" pin: napari 0.7 only needs numpy>=1.24, and the rest of +# the current stack (opencv-python 5.x, pyqtgraph 0.14, ...) targets NumPy 2. +# +# aicsimageio and basicpy are deliberately NOT installed: basicpy pins scipy<1.13, which +# drags the environment back to numpy 1.26 and breaks napari and opencv (see the commit +# "stop installing basicpy/aicsimageio"). Their only consumer, control/stitcher.py, is not +# imported by the application; install them into a separate venv if you need it. +pip3 install pyqtgraph qtpy pyserial pandas imageio crc==1.3.0 lxml numpy tifffile scipy pyreadline3 +pip3 install opencv-python-headless opencv-contrib-python-headless +# napari and PyQt6 pinned to the tested combination and resolved in one pip call, so the +# [pyqt6] extra checks the PyQt6 pins against napari's Qt blocklist (napari 0.7.1 blocks +# PyQt6-Qt6 6.11.0 and 6.11.1, napari/napari#9052): bump them together. +# tensorstore is required by control/ndviewer_light and by tests/control/core/test_zarr_writer.py, +# which begins with pytest.importorskip("tensorstore") -- without it ~79 zarr tests silently skip. +pip3 install "napari[pyqt6]==0.7.1" "PyQt6==6.11.0" "PyQt6-Qt6==6.11.2" "PyQt6-sip==13.12.0" scikit-image dask_image ome_zarr tensorstore pytest pytest-qt pytest-xvfb gitpython matplotlib pydantic_xml pyvisa hidapi filelock lxml_html_clean psutil mcp ndv + +# Optional: PI V-308 / C-414 focus stage (USE_PI_FOCUS_STAGE). Safe to skip if unused; +# squid.stage.pi imports it lazily and only needs it to connect to real hardware, so +# `||` keeps an install failure non-fatal under set -e. +pip3 install pipython || echo "WARNING: pipython install failed; continuing (only needed for USE_PI_FOCUS_STAGE)." >&2 + +# Fluidics library (RUN_FLUIDICS), vendored as the fluidics_v2 submodule. It declares PyQt5 as a +# dependency, so install it --no-deps to keep pip from pulling a PyQt5 wheel over apt's; its real +# runtime deps follow. `||` keeps a checkout without submodules working (fluidics simply stays off). +pip3 install cobs platformdirs ruamel.yaml pyyaml "pydantic>=2,<3" +pip3 install --no-deps -e fluidics_v2/software || echo "WARNING: fluidics library install failed; continuing (only needed for RUN_FLUIDICS)." >&2 + +# install camera drivers +cd "$DAHENG_CAMERA_DRIVER_ROOT" +./Galaxy_camera.run +cd "$DAHENG_CAMERA_DRIVER_API_ROOT" +python3 setup.py build +sudo python3 setup.py install +cd "$SQUID_SOFTWARE_ROOT" +sudo cp "$TOUPCAM_UDEV_RULE_PATH" /etc/udev/rules.d + +# PI C-414 focus stage (USE_PI_FOCUS_STAGE): bind the custom-VID FTDI to ftdi_sio so +# /dev/ttyUSB* appears, and lower the latency timer. Reload + trigger so it applies now. +sudo cp "$PI_UDEV_RULE_DIR/98-pi-c414-bind.rules" "$PI_UDEV_RULE_DIR/99-pi-ftdi-latency.rules" /etc/udev/rules.d +# Trigger with --action=add: the bind rule matches ACTION=="add", so a plain `udevadm +# trigger` (which defaults to action=change) would NOT bind an already-connected C-414. +sudo udevadm control --reload-rules && sudo udevadm trigger --action=add --subsystem-match=usb --subsystem-match=usb-serial --subsystem-match=tty + +# enable access to serial ports without sudo +sudo usermod -aG dialout $USER + +sudo apt autoremove -y + +echo "Holding kernel packages to prevent automatic updates..." +if sudo apt-mark hold \ + linux-image-generic linux-headers-generic linux-generic \ + "linux-image-$(uname -r)" "linux-headers-$(uname -r)"; then + echo "Kernel packages held. Run 'sudo apt-mark unhold linux-image-generic linux-headers-generic linux-generic linux-image-$(uname -r) linux-headers-$(uname -r)' to re-enable." +else + echo "Warning: Failed to hold kernel packages; automatic kernel updates remain enabled." >&2 +fi + +# create desktop shortcut +mkdir -p "$HOME/Desktop" +DESKTOP_FILE="$HOME/Desktop/Squid_hcs.desktop" +ICON_PATH="$SQUID_SOFTWARE_ROOT/icon/cephla_logo.svg" +cat > "$DESKTOP_FILE" << EOF +[Desktop Entry] +Name=Squid_hcs +Icon=$ICON_PATH +Exec=gnome-terminal --working-directory="$SQUID_SOFTWARE_ROOT" -e "/usr/bin/env python3 $SQUID_SOFTWARE_ROOT/main_hcs.py" +Type=Application +Terminal=true +EOF +chmod u+rwx "$DESKTOP_FILE" +# mark as trusted on GNOME +gio set "$DESKTOP_FILE" metadata::trusted true 2>/dev/null || true +echo "Desktop shortcut created at: $DESKTOP_FILE" diff --git a/software/setup_cuda_22.04.sh b/software/setup_cuda_22.04.sh index 744763fb3..b0047d2ba 100755 --- a/software/setup_cuda_22.04.sh +++ b/software/setup_cuda_22.04.sh @@ -10,6 +10,4 @@ pip install cuda-python pip install cupy-cuda12x pip3 install torch torchvision torchaudio -pip3 install "numpy<2" - sudo apt autoremove -y diff --git a/software/setup_cuda_24.04.sh b/software/setup_cuda_24.04.sh new file mode 100755 index 000000000..4e15033b7 --- /dev/null +++ b/software/setup_cuda_24.04.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# Ubuntu 24.04 variant of setup_cuda_22.04.sh: NVIDIA's ubuntu2404 apt repo, and pip needs +# --break-system-packages on 24.04 (PEP 668), set here through pip's environment variable. +export PIP_BREAK_SYSTEM_PACKAGES=1 +sudo apt update +# sudo apt install nvidia-driver-530 +cd ~/Downloads +wget https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb +sudo dpkg -i cuda-keyring_1.1-1_all.deb +sudo apt-get update +sudo apt-get -y install cuda +pip install cuda-python +pip install cupy-cuda12x +pip3 install torch torchvision torchaudio + +sudo apt autoremove -y diff --git a/software/squid/camera/utils.py b/software/squid/camera/utils.py index 196de07f7..7f7deb4c0 100644 --- a/software/squid/camera/utils.py +++ b/software/squid/camera/utils.py @@ -462,4 +462,4 @@ def get_frame_id(self) -> int: @debug_log def close(self): - pass + self.stop_streaming() diff --git a/software/tests/conftest.py b/software/tests/conftest.py index 7424900ed..b5a54e5f9 100644 --- a/software/tests/conftest.py +++ b/software/tests/conftest.py @@ -128,3 +128,18 @@ def cleanup_leaked_hardware(monkeypatch): # Last, after every writer above is closed: delete this test's simulated-acquisition output. cleanup_stub_acquisition_dirs() + + +@pytest.fixture(autouse=True, scope="session") +def use_leak_safe_log_records(): + """Shadow napari's session fixture of the same name (napari.utils._testsupport, napari 0.7). + + napari's pytest plugin swaps in a LogRecord factory that rewrites ``record.args`` as + ``tuple(str(a) for a in args)``. When a call logs a single dict, ``logging`` stores the dict + itself as ``args`` (so ``%(key)s`` formats work), and iterating it yields its keys: the + record then raises "not all arguments converted" and pytest's capture handler re-raises + that into the caller. The fluidics library logs each sequence that way, which failed every + fluidics run in the suite. None of these tests use napari's fixtures, so an inert + stand-in is enough. + """ + yield diff --git a/software/tests/control/core/test_zarr_writer.py b/software/tests/control/core/test_zarr_writer.py index c4e79e9ff..3890b526e 100644 --- a/software/tests/control/core/test_zarr_writer.py +++ b/software/tests/control/core/test_zarr_writer.py @@ -31,6 +31,11 @@ pytest.importorskip("tensorstore") +def as_posix_path(path: str) -> str: + """The builders use os.path.join; compare path structure, not the OS separator.""" + return path.replace(os.sep, "/") + + def make_test_capture_info( region_id: str = "A1", fov: int = 0, @@ -476,18 +481,18 @@ def test_zarr_writer_info_hcs_output_path(self): # Test single-letter row path = info.get_output_path("A1", 0) - assert path == "/tmp/experiment/plate.ome.zarr/A/1/0/0" + assert as_posix_path(path) == "/tmp/experiment/plate.ome.zarr/A/1/0/0" path = info.get_output_path("A1", 2) - assert path == "/tmp/experiment/plate.ome.zarr/A/1/2/0" + assert as_posix_path(path) == "/tmp/experiment/plate.ome.zarr/A/1/2/0" # Test multi-digit column path = info.get_output_path("B12", 2) - assert path == "/tmp/experiment/plate.ome.zarr/B/12/2/0" + assert as_posix_path(path) == "/tmp/experiment/plate.ome.zarr/B/12/2/0" # Test double-letter row (e.g., AA, AB) path = info.get_output_path("AA3", 0) - assert path == "/tmp/experiment/plate.ome.zarr/AA/3/0/0" + assert as_posix_path(path) == "/tmp/experiment/plate.ome.zarr/AA/3/0/0" def test_zarr_writer_info_non_hcs_per_fov_output_path(self): """Test non-HCS default: per-FOV zarr files (OME-NGFF compliant).""" @@ -502,12 +507,12 @@ def test_zarr_writer_info_non_hcs_per_fov_output_path(self): ) # Each FOV gets its own zarr file - get_output_path returns ARRAY path (group + /0) - assert info.get_output_path("region_1", 0) == "/tmp/experiment/zarr/region_1/fov_0.ome.zarr/0" - assert info.get_output_path("region_1", 1) == "/tmp/experiment/zarr/region_1/fov_1.ome.zarr/0" - assert info.get_output_path("region_1", 2) == "/tmp/experiment/zarr/region_1/fov_2.ome.zarr/0" + assert as_posix_path(info.get_output_path("region_1", 0)) == "/tmp/experiment/zarr/region_1/fov_0.ome.zarr/0" + assert as_posix_path(info.get_output_path("region_1", 1)) == "/tmp/experiment/zarr/region_1/fov_1.ome.zarr/0" + assert as_posix_path(info.get_output_path("region_1", 2)) == "/tmp/experiment/zarr/region_1/fov_2.ome.zarr/0" # Different region - assert info.get_output_path("region_2", 0) == "/tmp/experiment/zarr/region_2/fov_0.ome.zarr/0" + assert as_posix_path(info.get_output_path("region_2", 0)) == "/tmp/experiment/zarr/region_2/fov_0.ome.zarr/0" def test_zarr_writer_info_non_hcs_6d_output_path(self): """Test non-HCS with 6D mode: single zarr per region (non-standard).""" @@ -525,12 +530,12 @@ def test_zarr_writer_info_non_hcs_6d_output_path(self): path_fov0 = info.get_output_path("region_1", 0) path_fov1 = info.get_output_path("region_1", 1) - assert path_fov0 == "/tmp/experiment/zarr/region_1/acquisition.zarr" - assert path_fov1 == "/tmp/experiment/zarr/region_1/acquisition.zarr" + assert as_posix_path(path_fov0) == "/tmp/experiment/zarr/region_1/acquisition.zarr" + assert as_posix_path(path_fov1) == "/tmp/experiment/zarr/region_1/acquisition.zarr" # Different region path_region2 = info.get_output_path("region_2", 0) - assert path_region2 == "/tmp/experiment/zarr/region_2/acquisition.zarr" + assert as_posix_path(path_region2) == "/tmp/experiment/zarr/region_2/acquisition.zarr" def test_zarr_writer_info_get_fov_count(self): """Test get_fov_count returns correct counts for regions.""" @@ -1926,7 +1931,7 @@ def test_hcs_paths_use_shared_utility(self): for well_id, fov, expected_group_path, expected_array_path in test_cases: # Utility returns GROUP path (for NDViewer/readers) group_path = build_hcs_zarr_fov_path(base_path, well_id, fov) - assert group_path == expected_group_path, ( + assert as_posix_path(group_path) == expected_group_path, ( f"Group path mismatch for well={well_id}, fov={fov}: " f"got={group_path}, expected={expected_group_path}" ) @@ -1940,7 +1945,7 @@ def test_hcs_paths_use_shared_utility(self): is_hcs=True, ) writer_path = zarr_info.get_output_path(well_id, fov) - assert writer_path == expected_array_path, ( + assert as_posix_path(writer_path) == expected_array_path, ( f"Writer path mismatch for well={well_id}, fov={fov}: " f"writer={writer_path}, expected={expected_array_path}" ) @@ -1986,7 +1991,7 @@ def test_non_hcs_per_fov_paths_use_shared_utility(self): for region_id, fov, expected_group_path, expected_array_path in test_cases: # Utility returns GROUP path (for NDViewer/readers) group_path = build_per_fov_zarr_path(base_path, region_id, fov) - assert group_path == expected_group_path, ( + assert as_posix_path(group_path) == expected_group_path, ( f"Group path mismatch for region={region_id}, fov={fov}: " f"got={group_path}, expected={expected_group_path}" ) @@ -2001,7 +2006,7 @@ def test_non_hcs_per_fov_paths_use_shared_utility(self): use_6d_fov=False, ) writer_path = zarr_info.get_output_path(region_id, fov) - assert writer_path == expected_array_path, ( + assert as_posix_path(writer_path) == expected_array_path, ( f"Writer path mismatch for region={region_id}, fov={fov}: " f"writer={writer_path}, expected={expected_array_path}" ) @@ -2020,7 +2025,7 @@ def test_6d_paths_use_shared_utility(self): for region_id, expected_path in test_cases: # Test utility function directly util_path = build_6d_zarr_path(base_path, region_id) - assert util_path == expected_path, ( + assert as_posix_path(util_path) == expected_path, ( f"Utility path mismatch for region={region_id}: " f"got={util_path}, expected={expected_path}" ) diff --git a/software/tests/control/test_HighContentScreeningGui.py b/software/tests/control/test_HighContentScreeningGui.py index 7e8cd5eaf..148b2158a 100644 --- a/software/tests/control/test_HighContentScreeningGui.py +++ b/software/tests/control/test_HighContentScreeningGui.py @@ -240,3 +240,33 @@ def test_pi_focus_stage_is_a_referenced_z_even_without_cephla_z_homing(qtbot, mo cache_position.assert_called_once() assert cache_position.call_args.kwargs["validate_z"] is True + + +def test_performance_mode_keeps_mosaic_fed_and_shows_it_at_completion(qtbot, monkeypatch, confirm_exit_yes): + """Performance mode keeps the mosaic's data feed connected (its canvas builds during the + run) and defers only the rendering: the tab is disabled for the run, then re-enabled and + shown at completion, which flushes the deferred redraw (UnifiedMosaicWidget.showEvent).""" + # gui_hcs star-imports _def; force the mosaic view on so the widget exists regardless + # of this machine's cached [VIEWS] config. + monkeypatch.setattr(control.gui_hcs, "USE_NAPARI_FOR_MOSAIC_DISPLAY", True) + + scope = control.microscope.Microscope.build_from_global_config(True) + win = control.gui_hcs.HighContentScreeningGui(microscope=scope, is_simulation=True) + qtbot.add_widget(win) + + mosaic = win.unifiedMosaicWidget + assert mosaic is not None, "mosaic widget should be created when USE_NAPARI_FOR_MOSAIC_DISPLAY is on" + mosaic_idx = win.imageDisplayTabs.indexOf(mosaic) + controller = win.multipointController + assert controller.receivers(controller.mosaic_tile_update) == 1 + + win.performanceModeToggle.click() + assert win.performance_mode is True + assert controller.receivers(controller.mosaic_tile_update) == 1, "mosaic feed must stay connected" + + win.toggleAcquisitionStart(True) + assert not win.imageDisplayTabs.isTabEnabled(mosaic_idx) + + win.toggleAcquisitionStart(False) + assert win.imageDisplayTabs.isTabEnabled(mosaic_idx) + assert win.imageDisplayTabs.currentWidget() is mosaic diff --git a/software/tests/control/test_napari_connections.py b/software/tests/control/test_napari_connections.py new file mode 100644 index 000000000..ca5715d04 --- /dev/null +++ b/software/tests/control/test_napari_connections.py @@ -0,0 +1,68 @@ +"""Unit tests for HighContentScreeningGui.updateNapariConnections. + +Runs the method against a stand-in object instead of the full GUI so the tests can +assert exact receiver counts: toggling performance mode back and forth must never stack +duplicate connections (plain ``signal.connect`` silently adds a second one; it does not raise). +""" + +from types import SimpleNamespace + +from qtpy.QtCore import QObject, Qt, Signal + +from control.gui_hcs import HighContentScreeningGui + + +class _Emitter(QObject): + sig = Signal(int) + queued_sig = Signal(int) + + +class _View(QObject): + def slot(self, _value): + pass + + def queued_slot(self, _value): + pass + + +def _make(): + emitter, view = _Emitter(), _View() + gui = SimpleNamespace( + performance_mode=False, + napari_connections=[ + (emitter.sig, view.slot), + (emitter.queued_sig, view.queued_slot, Qt.QueuedConnection), + ], + ) + return emitter, view, gui + + +def _update(gui, performance_mode): + gui.performance_mode = performance_mode + HighContentScreeningGui.updateNapariConnections(gui) + + +def test_repeated_refreshes_keep_exactly_one_connection(qtbot): + emitter, _view, gui = _make() + + _update(gui, performance_mode=False) # initial wiring (makeNapariConnections) + _update(gui, performance_mode=False) # a redundant refresh must not double-connect + for mode in (True, False, True, False): # the user toggles performance mode a few times + _update(gui, performance_mode=mode) + + assert emitter.receivers(emitter.sig) == 1 + assert emitter.receivers(emitter.queued_sig) == 1 + + +def test_performance_mode_disconnects_and_leaving_it_reconnects_once(qtbot): + emitter, _view, gui = _make() + + _update(gui, performance_mode=False) + _update(gui, performance_mode=True) + _update(gui, performance_mode=True) # a redundant refresh must not raise + assert emitter.receivers(emitter.sig) == 0 + assert emitter.receivers(emitter.queued_sig) == 0 + + _update(gui, performance_mode=False) + assert emitter.receivers(emitter.sig) == 1 + assert emitter.receivers(emitter.queued_sig) == 1 diff --git a/software/tests/control/test_single_instance.py b/software/tests/control/test_single_instance.py index 4a05435ff..e021feb81 100644 --- a/software/tests/control/test_single_instance.py +++ b/software/tests/control/test_single_instance.py @@ -71,7 +71,7 @@ def test_stale_lock_from_dead_process_is_reclaimed(tmp_path): # Pin QT_API so qtpy in the child selects the same binding as the parent # regardless of what other Qt bindings are installed in the environment. - child_env = {**os.environ, "QT_API": "pyqt5"} + child_env = {**os.environ, "QT_API": "pyqt6"} result = subprocess.run( [sys.executable, "-c", child_script], capture_output=True, diff --git a/software/tests/control/widgets_fluidics/test_gui_wiring.py b/software/tests/control/widgets_fluidics/test_gui_wiring.py index 3828812d6..90595e394 100644 --- a/software/tests/control/widgets_fluidics/test_gui_wiring.py +++ b/software/tests/control/widgets_fluidics/test_gui_wiring.py @@ -42,7 +42,7 @@ def test_the_display_and_record_tabs_are_added(): def test_napari_toggle_exempts_the_fluidics_tab(): - assert "self.FLUIDICS_TAB_NAME" in _source("toggleNapariTabs") + assert "self.FLUIDICS_TAB_NAME" in _source("_set_napari_tabs_enabled") def test_signals_are_connected_and_the_imaging_port_feeds_napari(): diff --git a/software/tests/squid/test_camera.py b/software/tests/squid/test_camera.py index 0f2ba932c..0698d1006 100644 --- a/software/tests/squid/test_camera.py +++ b/software/tests/squid/test_camera.py @@ -190,3 +190,18 @@ def test_not_ready_for_trigger_while_settings_change_in_flight(): with sim_cam._trigger_lock: assert sim_cam.get_ready_for_trigger() sim_cam.stop_streaming() + + +def test_simulated_camera_close_stops_streaming_thread(): + """close() must stop a running streaming thread. Owners (Microscope.close, the GUI + shutdown path) rely on close() as the final stop; a stream thread that survives it keeps + pushing frames into callbacks whose Qt receivers are gone, which segfaulted later Qt + tests in the same pytest process.""" + sim_cam = squid.camera.utils.get_camera(squid.config.get_camera_config(), simulated=True) + sim_cam.start_streaming() + assert sim_cam.get_is_streaming() + + sim_cam.close() + + assert not sim_cam.get_is_streaming() + assert not any("stream_fn" in t.name for t in threading.enumerate()) diff --git a/software/tests/squid/test_stage.py b/software/tests/squid/test_stage.py index 02a646590..b227073b2 100644 --- a/software/tests/squid/test_stage.py +++ b/software/tests/squid/test_stage.py @@ -1,7 +1,6 @@ import builtins import logging import pytest -import tempfile import unittest.mock import squid.stage.cephla @@ -29,14 +28,14 @@ def test_simulated_cephla_stage_ops(): assert stage.get_pos() == squid.abc.Pos(x_mm=0.0, y_mm=0.0, z_mm=0.0, theta_rad=0.0) -def test_position_caching(): - (unused_temp_fd, temp_cache_path) = tempfile.mkstemp(".cache", "squid_testing_") +def test_position_caching(tmp_path): + cache_path = str(tmp_path / "squid_testing.cache") # Use 6 figures after the decimal so we test that we can capture nanometers p = squid.abc.Pos(x_mm=11.111111, y_mm=22.222222, z_mm=1.333333, theta_rad=None) - squid.stage.utils.cache_position(pos=p, stage_config=squid.config.get_stage_config(), cache_path=temp_cache_path) + squid.stage.utils.cache_position(pos=p, stage_config=squid.config.get_stage_config(), cache_path=cache_path) - p_read = squid.stage.utils.get_cached_position(cache_path=temp_cache_path) + p_read = squid.stage.utils.get_cached_position(cache_path=cache_path) assert p_read == p diff --git a/software/tools/view_laser_af_reference_image.py b/software/tools/view_laser_af_reference_image.py index fe6507c09..ae944235a 100644 --- a/software/tools/view_laser_af_reference_image.py +++ b/software/tools/view_laser_af_reference_image.py @@ -1,11 +1,15 @@ +import os + +os.environ["QT_API"] = "pyqt6" + import sys import json import base64 import numpy as np -from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget, QPushButton, QFileDialog -from PyQt5.QtCore import Qt +from qtpy.QtWidgets import QApplication, QMainWindow, QLabel, QVBoxLayout, QWidget, QPushButton, QFileDialog +from qtpy.QtCore import Qt import matplotlib.pyplot as plt -from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg try: import yaml