From 25e929b3713771e51e1ed8cfa9c619fc908139da Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Tue, 30 Jun 2026 10:37:35 -0700 Subject: [PATCH 1/5] feat: Add Ubuntu 26.04 installation script Add setup_26.04.sh, adapted from setup_22.04.sh for Ubuntu 26.04 LTS, which ships Python 3.14 as the system interpreter. That breaks the 22.04 script in two ways, addressed here: - pip3 install --break-system-packages: system Python is externally managed (PEP 668), so plain pip3 install is rejected. Non-sudo install keeps packages in the user site, leaving apt-managed packages intact. - numpy<2 -> numpy (2.x): NumPy 1.x has no Python 3.14 wheels. - napari==0.5.4 -> napari: the old pin predates 3.14. - Drop aicsimageio and basicpy: only imported by the orphaned control/stitcher.py (nothing loads it; the active stitcher is the ImageJ-based tools/stitcher.py). These were the main 3.14 blocker. Qt stays installed via apt (not subject to PEP 668). All version-agnostic steps (camera drivers, udev rules, dialout, kernel hold, desktop shortcut) are unchanged from the 22.04 script. Not yet runtime-tested on 26.04. Co-Authored-By: Claude Opus 4.8 (1M context) --- software/setup_26.04.sh | 134 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100755 software/setup_26.04.sh diff --git a/software/setup_26.04.sh b/software/setup_26.04.sh new file mode 100755 index 000000000..61bf11461 --- /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=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" From 496534318c4e2209387fac50101a8ec994d060d6 Mon Sep 17 00:00:00 2001 From: hongquan Date: Thu, 2 Jul 2026 15:32:11 -0700 Subject: [PATCH 2/5] fix(display): keep native Wayland+EGL so napari acquisition view isn't black (#568) On a Linux Wayland session, napari 0.7.1's napari/_wayland_fix.py forces QT_QPA_PLATFORM=xcb and PYOPENGL_PLATFORM=glx on Nvidia hardware, assuming XWayland+GLX is more stable. On recent hardware/drivers (e.g. RTX 5070 Ti Blackwell + Mutter, Ubuntu 26.04 / Python 3.14) this is backwards: XWayland renders napari's vispy GL surface black, and GLX context tracking fails with "Attempt to retrieve context when no valid context", so acquisition image layers never draw. Live View is unaffected because it uses pyqtgraph (raster). napari applies its defaults with os.environ.setdefault, so presetting both QT_QPA_PLATFORM=wayland and PYOPENGL_PLATFORM=egl before napari is imported neutralizes the workaround and keeps the proven-good native Wayland + EGL combination. Guarded to Linux Wayland sessions and respects explicit overrides, so it is a no-op on X11/macOS/Windows. Co-Authored-By: Claude Opus 4.8 (1M context) --- software/control/gui_hcs.py | 9 +++++++++ software/main_hcs.py | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index 5f9dcce5a..bc3afd0af 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 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 * From ca625bd9e73696112b8d0192257e865c378acb26 Mon Sep 17 00:00:00 2001 From: hongquan Date: Thu, 2 Jul 2026 15:42:48 -0700 Subject: [PATCH 3/5] fix(setup-26.04): desktop launcher uses ptyxis, not removed gnome-terminal Ubuntu 26.04 ships ptyxis as the default terminal; gnome-terminal is not installed and its `-e` command syntax was removed, so the generated launcher did nothing when double-clicked. Use `ptyxis --new-window --working-directory ... -- ` and set Terminal=false since we invoke the terminal explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) --- software/setup_26.04.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/software/setup_26.04.sh b/software/setup_26.04.sh index 61bf11461..c44f02cb4 100755 --- a/software/setup_26.04.sh +++ b/software/setup_26.04.sh @@ -124,9 +124,9 @@ 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" +Exec=ptyxis --new-window --working-directory="$SQUID_SOFTWARE_ROOT" -- /usr/bin/env python3 $SQUID_SOFTWARE_ROOT/main_hcs.py Type=Application -Terminal=true +Terminal=false EOF chmod u+rwx "$DESKTOP_FILE" # mark as trusted on GNOME From 3b254dfc44b4b1545da5be5ce8ff31b77baf2df4 Mon Sep 17 00:00:00 2001 From: hongquan Date: Thu, 2 Jul 2026 16:02:34 -0700 Subject: [PATCH 4/5] fix(display): kick Wayland surface so first-acquisition UI updates without a drag On a native Wayland session the whole UI stalls until the window is moved, but only on the first acquisition: the top-level surface stops receiving frame callbacks while the first napari (vispy/OpenGL) canvas is realized. When the napari display is first activated, generate a one-time window configure event (maximized: brief state toggle with geometry pinned; otherwise a 1px resize nudge) to restart frame callbacks. Guarded to Wayland; no-op elsewhere. Known limitation: on a maximized window a brief resize flicker is still visible; the un-stall itself works. Fully hiding the flicker is unresolved. Co-Authored-By: Claude Opus 4.8 (1M context) --- software/control/gui_hcs.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index bc3afd0af..e48f76f9d 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -2368,10 +2368,46 @@ 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.isMaximized() or self.isFullScreen(): + # A maximized/fullscreen Wayland window ignores resize requests. The + # window-state change itself produces the configure event we need, so + # drop the maximized state but pin the normal geometry to the current + # on-screen size — the size doesn't visibly change — then re-maximize. + geo = self.geometry() + self.showNormal() + self.setGeometry(geo) + 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: From 14943df62abf29307074e33383e8f0d0ca80e1b1 Mon Sep 17 00:00:00 2001 From: hongquanli Date: Thu, 2 Jul 2026 18:53:20 -0700 Subject: [PATCH 5/5] fix(display): don't clobber restore geometry in Wayland surface kick _do_wayland_surface_kick pinned the normal geometry to the maximized size via setGeometry() to hide the restore flash, but that overwrites the window's stored restore geometry. After the first-acquisition kick the maximize/restore toggle no longer changed anything and the window was stuck at full size. Toggle the window state without touching geometry (Qt preserves the real restore geometry across showNormal/ showMaximized), and handle fullscreen separately so a fullscreen window isn't downgraded to maximized. Co-Authored-By: Claude Opus 4.8 (1M context) --- software/control/gui_hcs.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/software/control/gui_hcs.py b/software/control/gui_hcs.py index e48f76f9d..1e5bb6021 100644 --- a/software/control/gui_hcs.py +++ b/software/control/gui_hcs.py @@ -2389,14 +2389,19 @@ def _kick_wayland_surface_once(self): QTimer.singleShot(250, self._do_wayland_surface_kick) def _do_wayland_surface_kick(self): - if self.isMaximized() or self.isFullScreen(): - # A maximized/fullscreen Wayland window ignores resize requests. The - # window-state change itself produces the configure event we need, so - # drop the maximized state but pin the normal geometry to the current - # on-screen size — the size doesn't visibly change — then re-maximize. - geo = self.geometry() + 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() - self.setGeometry(geo) QTimer.singleShot(0, self.showMaximized) else: geo = self.geometry()