diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 5f9dcce5a..1e5bb6021 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -16,6 +16,15 @@ ) os.environ["QT_API"] = "pyqt5" +# On a Linux Wayland session, override napari's _wayland_fix, which forces +# QT_QPA_PLATFORM=xcb + PYOPENGL_PLATFORM=glx on Nvidia. On recent hardware that +# is backwards (XWayland renders the napari GL surface black; GLX context +# tracking fails with "no valid context"). Native Wayland + EGL works; napari +# uses setdefault(), so presetting both before import neutralizes its fix. +# No-op on X11/macOS/Windows; respects an explicit override. +if sys.platform.startswith("linux") and os.environ.get("XDG_SESSION_TYPE") == "wayland": + os.environ.setdefault("QT_QPA_PLATFORM", "wayland") + os.environ.setdefault("PYOPENGL_PLATFORM", "egl") import re import time from enum import Enum, auto @@ -2359,10 +2368,51 @@ def resizeCurrentTab(self, tabWidget): tabWidget.updateGeometry() self.updateGeometry() + def _kick_wayland_surface_once(self): + """Force one window configure event the first time a napari canvas shows. + + On a native Wayland session, the top-level surface stops receiving frame + callbacks while the first napari (vispy/OpenGL) canvas is being realized, + so the whole UI only repaints after the user manually moves the window. + Generating a configure event once restarts the frame callbacks. No-op on + X11/macOS/Windows and after the first kick. + """ + if getattr(self, "_wayland_surface_kicked", False): + return + app = QApplication.instance() + if app is None or app.platformName() != "wayland": + self._wayland_surface_kicked = True + return + self._wayland_surface_kicked = True + # Defer so the kick lands after the canvas is realized and the stall has + # begun, otherwise the configure event fires too early to help. + QTimer.singleShot(250, self._do_wayland_surface_kick) + + def _do_wayland_surface_kick(self): + if self.isFullScreen(): + # A fullscreen Wayland window ignores resize requests; toggling the + # window state is what produces the configure event we need. Qt keeps + # the pre-fullscreen geometry across the toggle, so don't touch it. + self.showNormal() + QTimer.singleShot(0, self.showFullScreen) + elif self.isMaximized(): + # Same for a maximized window. Do NOT setGeometry() to the maximized + # size to hide the restore flash — that overwrites the window's stored + # restore geometry, after which the maximize/restore toggle no longer + # changes anything and the window is stuck at full size. Qt already + # preserves the real restore geometry across showNormal/showMaximized. + self.showNormal() + QTimer.singleShot(0, self.showMaximized) + else: + geo = self.geometry() + self.resize(geo.width() + 1, geo.height()) + QTimer.singleShot(0, lambda: self.resize(geo.width(), geo.height())) + def onDisplayTabChanged(self, index): current_widget = self.imageDisplayTabs.widget(index) if hasattr(current_widget, "viewer"): current_widget.activate() + self._kick_wayland_surface_once() # Stop focus camera live if not on laser focus tab if SUPPORT_LASER_AUTOFOCUS: diff --git a/software/main_hcs.py b/software/main_hcs.py index 47ee7b886..8bc9f4f1d 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -7,6 +7,18 @@ import signal import sys +# On a Linux Wayland session, napari's _wayland_fix (napari/__init__.py) forces +# QT_QPA_PLATFORM=xcb + PYOPENGL_PLATFORM=glx on Nvidia, assuming XWayland+GLX is +# more stable. On recent hardware/drivers (e.g. Blackwell + Mutter) that is +# backwards: XWayland renders the napari GL surface black, and GLX context +# tracking fails with "Attempt to retrieve context when no valid context" so +# image layers never draw. Native Wayland + EGL is what works here. napari uses +# setdefault(), so presetting both before it is imported neutralizes its fix. +# No-op on X11/macOS/Windows, and respects an explicit override. +if sys.platform.startswith("linux") and os.environ.get("XDG_SESSION_TYPE") == "wayland": + os.environ.setdefault("QT_QPA_PLATFORM", "wayland") + os.environ.setdefault("PYOPENGL_PLATFORM", "egl") + # qt libraries from qtpy.QtWidgets import * from qtpy.QtGui import * diff --git a/software/setup_26.04.sh b/software/setup_26.04.sh new file mode 100755 index 000000000..c44f02cb4 --- /dev/null +++ b/software/setup_26.04.sh @@ -0,0 +1,134 @@ +#!/bin/bash +set -eo pipefail + +# Squid setup script for Ubuntu 26.04 LTS. +# +# Differences from setup_22.04.sh, all driven by Ubuntu 26.04 shipping +# Python 3.14 as the system interpreter: +# +# 1. System pip is "externally managed" (PEP 668), so plain `pip3 install` +# into the system Python is rejected. We pass --break-system-packages. +# Without sudo, these land in the user site (~/.local), not the +# apt-managed system site, so apt's Python packages are left intact. +# +# 2. numpy is no longer capped at <2: NumPy 1.x has no Python 3.14 wheels +# (1.26 is the last 1.x and tops out at 3.12), so we use numpy 2.x. +# +# 3. napari==0.5.4 is unpinned: that pin predates Python 3.14, so we take +# the latest napari, which publishes 3.14 wheels. +# +# 4. aicsimageio and basicpy are NOT installed. Their only importer is +# control/stitcher.py, which nothing in the codebase imports (the +# active stitcher is tools/stitcher.py, an ImageJ/Fiji-based path). +# They were the main 3.14 blocker (aicsimageio is in maintenance mode +# with no 3.14 wheels), so dropping the dead dependency de-risks the +# install. If control/stitcher.py is ever revived, add them back here. +# +# Qt is still installed from apt (python3-pyqt5*, python3-pyqtgraph): apt +# packages are built against the system Python 3.14 and are not subject to +# PEP 668, so this is the lowest-risk way to get a working PyQt5. + +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" + +# Ubuntu 26.04's system Python (3.14) is externally managed (PEP 668); pip +# refuses to touch it without this flag. Installing without sudo keeps these +# in the user site rather than clobbering apt-managed system packages. +readonly PIP_INSTALL="pip3 install --break-system-packages" + +# update +sudo apt update + +# install packages +sudo apt install python3-pip -y +sudo apt install python3-pyqtgraph python3-pyqt5 python3-pyqt5.qtsvg -y + +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" + +# install libraries +# numpy unpinned to 2.x (1.x has no Python 3.14 wheels); napari unpinned +# (the old 0.5.4 pin predates 3.14). aicsimageio/basicpy intentionally +# omitted (dead dependency, see header). +$PIP_INSTALL qtpy pyserial pandas imageio crc==1.3.0 lxml tifffile scipy pyreadline3 numpy +$PIP_INSTALL opencv-python-headless opencv-contrib-python-headless +$PIP_INSTALL napari scikit-image dask_image ome_zarr pytest pytest-qt pytest-xvfb gitpython matplotlib pydantic_xml pyvisa hidapi filelock lxml_html_clean psutil mcp ndv + +# 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 + +# 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=ptyxis --new-window --working-directory="$SQUID_SOFTWARE_ROOT" -- /usr/bin/env python3 $SQUID_SOFTWARE_ROOT/main_hcs.py +Type=Application +Terminal=false +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"