diff --git a/= b/= new file mode 100644 index 000000000..3be271ce0 Binary files /dev/null and b/= differ diff --git a/imswitch/__init__.py b/imswitch/__init__.py index 795da0a96..ff4fa3cb3 100644 --- a/imswitch/__init__.py +++ b/imswitch/__init__.py @@ -10,7 +10,7 @@ from .config import get_config # used to be, but actions will replace this with the current release TAG -> >2.1.0 -__version__ = "2.1.95" +__version__ = "2.1.104" __httpport__ = 8001 __socketport__ = 8002 __ssl__ = True diff --git a/imswitch/_data/user_defaults/imcontrol_setups/example_xiao.json b/imswitch/_data/user_defaults/imcontrol_setups/example_xiao.json index d531c6b90..630a95760 100644 --- a/imswitch/_data/user_defaults/imcontrol_setups/example_xiao.json +++ b/imswitch/_data/user_defaults/imcontrol_setups/example_xiao.json @@ -31,6 +31,7 @@ "Settings", "View", "Recording", - "Image" + "Image", + "DMD" ] } \ No newline at end of file diff --git a/imswitch/imcommon/controller/CheckUpdatesController.py b/imswitch/imcommon/controller/CheckUpdatesController.py index d84c76535..51e992b58 100644 --- a/imswitch/imcommon/controller/CheckUpdatesController.py +++ b/imswitch/imcommon/controller/CheckUpdatesController.py @@ -45,13 +45,14 @@ def checkForUpdates(self): class CheckUpdatesThread(Thread): + sigFailed = Signal() + sigNoUpdate = Signal() + sigNewVersionPyInstaller = Signal(str) # (latestVersion) + sigNewVersionPyPI = Signal(str) # (latestVersion) + sigNewVersionShowInfo = Signal(str) # (someText) + def __init__(self): super().__init__() - self.sigFailed = Signal() - self.sigNoUpdate = Signal() - self.sigNewVersionPyInstaller = Signal(str) # (latestVersion) - self.sigNewVersionPyPI = Signal(str) # (latestVersion) - self.sigNewVersionShowInfo = Signal(str) # (someText) self.__logger = initLogger(self, tryInheritParent=True) def run(self): diff --git a/imswitch/imcontrol/controller/MasterController.py b/imswitch/imcontrol/controller/MasterController.py index cd40c201e..c94841789 100644 --- a/imswitch/imcontrol/controller/MasterController.py +++ b/imswitch/imcontrol/controller/MasterController.py @@ -121,7 +121,12 @@ def __init__(self, setupInfo, commChannel, moduleCommChannel): if "Workflow" in self.__setupInfo.availableWidgets: self.workflowManager = WorkflowManager() if "Arkitekt" in self.__setupInfo.availableWidgets: - self.arkitektManager = ArkitektManager(self.__setupInfo.arkitekt) + if ArkitektManager is not None: + self.arkitektManager = ArkitektManager(self.__setupInfo.arkitekt) + else: + self.__logger.warning( + "Arkitekt widget requested but ArkitektManager is unavailable. " + "Install optional dependencies to enable it.") # load all implugin-related managers and add them to the class # try to get it from the plugins # If there is a imswitch_sim_manager, we want to add this as self.imswitch_sim_widget to the diff --git a/imswitch/imcontrol/controller/controllers/DMDController.py b/imswitch/imcontrol/controller/controllers/DMDController.py new file mode 100644 index 000000000..390a176e5 --- /dev/null +++ b/imswitch/imcontrol/controller/controllers/DMDController.py @@ -0,0 +1,383 @@ +import time +import threading +import numpy as np +try: + from skimage.filters import gaussian as _gaussian_filter +except Exception: # pragma: no cover + _gaussian_filter = None +import requests +from imswitch import IS_HEADLESS +from imswitch.imcommon.model import APIExport, initLogger +from ..basecontrollers import ImConWidgetController +from imswitch.imcontrol.model import SaveMode + + +class DMDController(ImConWidgetController): + """Controller for DMDWidget: send commands to Raspberry Pi FastAPI and + orchestrate 3-shot capture with the camera. + + Expected FastAPI endpoints on the Raspberry Pi: + - GET {host}/display/{pattern_id} + - GET {host}/health (optional) + + Workflow for 3-shot: + 1) display 0 -> snap + 2) wait 0.2s -> display 1 -> snap + 3) wait 0.2s -> display 2 -> snap + 4) reconstruct IOS and show in napari + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__logger = initLogger(self) + self._last_images = [] # store last 3 single-frame numpy images + + if not IS_HEADLESS: + # Connect signals from the widget + self._widget.sigDisplayPattern.connect(self.displayPattern) + self._widget.sigRunThreeShot.connect(self.runThreeShot) + self._widget.sigReconstruct.connect(self.reconstructIOS) + self._widget.sigCheckStatus.connect(self.checkStatus) + + # --- HTTP helpers --- + def _get_host(self) -> str: + # Priority: value in the widget field; otherwise infer from setup ossim host/port; else default. + if not IS_HEADLESS: + txt = (self._widget.getHost() or '').strip() + if txt: + return txt + + # Try to infer from setup: many users keep a single FastAPI for both OSSIM and DMD + try: + cfg = getattr(self._setupInfo, "_catchAll", None) + if isinstance(cfg, dict) and isinstance(cfg.get("ossim"), dict): + host = cfg["ossim"].get("host") + port = cfg["ossim"].get("port") + if host and port: + return f"http://{host}:{port}" + except Exception: + pass + + # Fallback default + return "http://192.168.137.2:8000" + + def _http_get(self, path: str, timeout: float = 2.0) -> dict: + url = f"{self._get_host().rstrip('/')}{path}" + try: + r = requests.get(url, timeout=timeout) + r.raise_for_status() + try: + return r.json() + except Exception: + return {"status": "ok"} + except Exception as e: + self.__logger.error(f"HTTP GET failed {url}: {e}") + if not IS_HEADLESS: + self._widget.setStatus(f"HTTP error: {e}") + return {"status": "error", "message": str(e)} + + # --- DMD actions --- + @APIExport(runOnUIThread=False) + def displayPattern(self, pattern_id: int): + self.__logger.info(f"Display DMD pattern {pattern_id}") + res = self._http_get(f"/display/{pattern_id}") + if not IS_HEADLESS: + self._widget.setStatus(f"Display {pattern_id}: {res.get('status')}") + return res + + @APIExport(runOnUIThread=False) + def checkStatus(self): + """Check DMD health via /health and show result in UI.""" + res = self._http_get("/health") + if not IS_HEADLESS: + if res.get("status") == "healthy": + total = res.get("patterns_loaded", "?") + self._widget.setStatus(f"DMD healthy. Patterns loaded: {total}") + else: + self._widget.setStatus(f"DMD status: {res}") + return res + + # --- Camera and workflow --- + def _snap_single(self) -> np.ndarray: + """Acquire a single image from the current detector as numpy array via RecordingManager.""" + det_name = self._master.detectorsManager.getCurrentDetectorName() + # Minimal HDF5 attrs from shared attributes + attrs = {det_name: self._commChannel.sharedAttrs.getHDF5Attributes()} + images = self._master.recordingManager.snap( + [det_name], savename="", saveMode=SaveMode.Numpy, saveFormat=None, attrs=attrs + ) + return images[det_name] + + @APIExport(runOnUIThread=False) + def runThreeShot(self): + """Run 3-shot synchronized capture with DMD 0-1-2 patterns and collect images.""" + def worker(): + try: + if not IS_HEADLESS: + self._widget.setStatus("Starting 3-shot...") + imgs = [] + + # Track if live view was active to restore afterwards + dm = self._master.detectorsManager + was_live = dm.checkIfIsLiveView() + + # Use liveView acquisition if it was already on + handle = dm.startAcquisition(liveView=was_live) + det_name = self._master.detectorsManager.getCurrentDetectorName() + det = self._master.detectorsManager[det_name] + + # Get user-defined delay (seconds) from widget (default 0.2 if fails) + try: + delay_s = self._widget.getDelaySeconds() + except Exception: + delay_s = 0.2 + + def next_frame(timeout=2.0): + # Wait for the next available frame from the detector buffer + deadline = time.time() + timeout + while time.time() < deadline: + try: + frames, idx = det.getChunk() + if isinstance(frames, np.ndarray) and frames.size > 0: + # frames shape: (n, H, W) -> take last + return frames[-1] + elif isinstance(frames, (list, tuple)) and len(frames) > 0: + return np.asarray(frames[-1]) + except Exception: + pass + time.sleep(0.01) + # Fallback + try: + return det.getLatestFrame() + except Exception: + raise RuntimeError("No frame available from detector") + + # Step 1: display 0 -> wait -> flush -> snap + self._http_get("/display/0") + time.sleep(delay_s) + try: + det.flushBuffers() + except Exception: + pass + img0 = next_frame() + imgs.append(img0) + + # Step 2: display 1 -> wait -> flush -> snap + self._http_get("/display/1") + time.sleep(delay_s) + try: + det.flushBuffers() + except Exception: + pass + img1 = next_frame() + imgs.append(img1) + + # Step 3: display 2 -> wait -> flush -> snap + self._http_get("/display/2") + time.sleep(delay_s) + try: + det.flushBuffers() + except Exception: + pass + img2 = next_frame() + imgs.append(img2) + + self._last_images = imgs + if not IS_HEADLESS: + self._widget.setStatus("3-shot done. Ready to reconstruct.") + # Stop acquisition only if it wasn't live before (so we don't kill user's live view) + try: + if not was_live: + dm.stopAcquisition(handle) + else: + # If live view, we started with liveView=True so keep it running + pass + except Exception: + pass + except Exception as e: + self.__logger.error(f"3-shot failed: {e}") + if not IS_HEADLESS: + self._widget.setStatus(f"3-shot error: {e}") + + threading.Thread(target=worker, daemon=True).start() + + @APIExport(runOnUIThread=False) + def reconstructIOS(self): + """Compute Classic IOS sqrt((I1-I2)^2 + (I1-I3)^2 + (I2-I3)^2) and show on napari. + Adds optional constant offset subtraction (from widget) to reduce squared background noise. + """ + if len(self._last_images) != 3: + if not IS_HEADLESS: + self._widget.setStatus("No images captured. Run 3-shot first.") + return {"status": "error", "message": "need 3 images"} + + try: + # Preserve raw frames and dtypes for bit-depth report/export + raw1, raw2, raw3 = [np.asarray(im) for im in self._last_images] + dtypes = [raw1.dtype, raw2.dtype, raw3.dtype] + + # Work in float32 for math + I1, I2, I3 = [arr.astype(np.float32, copy=False) for arr in (raw1, raw2, raw3)] + + # Optional offset subtraction (units: ADU counts). If widget lacks control, offset=0. + offset_val = 0.0 + if not IS_HEADLESS: + try: + offset_val = float(getattr(self._widget, 'getOffset', lambda: 0.0)()) + except Exception: + offset_val = 0.0 + if offset_val != 0.0: + I1 = np.clip(I1 - offset_val, 0, None) + I2 = np.clip(I2 - offset_val, 0, None) + I3 = np.clip(I3 - offset_val, 0, None) + + ios = np.sqrt((I1 - I2) ** 2 + (I1 - I3) ** 2 + (I2 - I3) ** 2) + widefield = (I1 + I2 + I3) / 3.0 + + # Optional Gaussian filtering (post-reconstruction) to suppress artifacts + sigma = 0.0 + if not IS_HEADLESS: + try: + sigma = float(self._widget.getSigma()) + except Exception: + sigma = 0.0 + # UI toggle: gaussian enable + gaussian_enabled = True + widefield_enabled = True + export_enabled = False + export_raw_enabled = False + export_base = "dmd_recon" + if not IS_HEADLESS: + try: + gaussian_enabled = self._widget.isGaussianEnabled() + except Exception: + gaussian_enabled = True + try: + widefield_enabled = self._widget.isWidefieldEnabled() + except Exception: + widefield_enabled = True + try: + export_enabled = self._widget.isExportEnabled() + except Exception: + export_enabled = False + try: + export_raw_enabled = self._widget.isExportRawEnabled() + except Exception: + export_raw_enabled = False + try: + export_base = self._widget.getExportBaseName() + except Exception: + export_base = "dmd_recon" + + if gaussian_enabled and sigma > 0 and _gaussian_filter is not None: + try: + ios = _gaussian_filter(ios, sigma=sigma, preserve_range=True) + except Exception: + pass + + # Normalize to 0..1 for viewing (both ios and widefield) + def _norm(a: np.ndarray): + m, M = float(np.min(a)), float(np.max(a)) + if M > m: + return (a - m) / (M - m) + return np.zeros_like(a) + + ios_norm = _norm(ios) + wf_norm = _norm(widefield) + + if not IS_HEADLESS: + # Bit-depth heuristic from raw dtypes + try: + base_dtype = max(dtypes, key=lambda dt: np.dtype(dt).itemsize) + if np.issubdtype(base_dtype, np.integer): + bits = np.dtype(base_dtype).itemsize * 8 + bit_info = "16-bit" if bits >= 16 else "8-bit" + else: + bit_info = str(base_dtype) + except Exception: + bit_info = "unknown" + + self._widget.showImage(ios_norm, name="DMD Reconstruction") + if widefield_enabled: + try: + self._widget.showImage(wf_norm, name="DMD Widefield") + except Exception: + pass + + # Optional export + export_msg = "" + if export_enabled or export_raw_enabled: + try: + import tifffile, os, datetime, pathlib + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + # Determine base export directory: user provided overrides everything + export_dir = None + try: + user_dir = self._widget.getExportDirectory() + if user_dir: + export_dir = user_dir + except Exception: + pass + if not export_dir: + try: + recman = getattr(self._master, 'recordingManager', None) + if recman is not None: + for attr in ('_targetDir', 'baseDirectory', 'outputDir', 'saveDir'): + p = getattr(recman, attr, None) + if p: + export_dir = p + break + except Exception: + pass + if not export_dir: + # Fallback: Documents/ImSwitchConfig/recordings or CWD + try: + from qtpy import QtCore as _QtCore + docs = _QtCore.QStandardPaths.writableLocation(_QtCore.QStandardPaths.DocumentsLocation) + if docs: + export_dir = os.path.join(docs, 'ImSwitchConfig', 'recordings') + except Exception: + pass + if not export_dir: + export_dir = os.getcwd() + pathlib.Path(export_dir).mkdir(parents=True, exist_ok=True) + base = os.path.join(export_dir, f"{export_base}_{timestamp}") + ios_path = base + "_ios.tif" + tifffile.imwrite(ios_path, (ios_norm * 65535).astype(np.uint16)) + if widefield_enabled: + wf_path = base + "_wf.tif" + tifffile.imwrite(wf_path, (wf_norm * 65535).astype(np.uint16)) + export_msg = f" Exported: {os.path.basename(ios_path)}, {os.path.basename(wf_path)}" + else: + export_msg = f" Exported: {os.path.basename(ios_path)}" + + if export_raw_enabled: + # Save raw (original dtype) frames + try: + i1_path = base + "_i1_raw.tif" + i2_path = base + "_i2_raw.tif" + i3_path = base + "_i3_raw.tif" + tifffile.imwrite(i1_path, raw1) + tifffile.imwrite(i2_path, raw2) + tifffile.imwrite(i3_path, raw3) + export_msg += f" + raw frames (i1,i2,i3)" + except Exception as eraw: + export_msg += f" (raw export failed: {eraw})" + except Exception as ee: + export_msg = f" Export failed: {ee}" # keep going + + self._widget.setStatus( + f"Reconstruction displayed. dtype~{bit_info} offset={offset_val if offset_val != 0.0 else 'off'} " + f"sigma={sigma if gaussian_enabled else 'off'} wf={'on' if widefield_enabled else 'off'} " + f"raw={'on' if export_raw_enabled else 'off'}{export_msg}" + ) + return {"status": "ok"} + except Exception as e: + self.__logger.error(f"Reconstruction failed: {e}") + if not IS_HEADLESS: + self._widget.setStatus(f"Reconstruction error: {e}") + return {"status": "error", "message": str(e)} + + +# Copyright (C) 2020-2025 ImSwitch developers +# GPLv3 License \ No newline at end of file diff --git a/imswitch/imcontrol/controller/controllers/LaserController.py b/imswitch/imcontrol/controller/controllers/LaserController.py index 5ebab93ff..5c0cbe50e 100644 --- a/imswitch/imcontrol/controller/controllers/LaserController.py +++ b/imswitch/imcontrol/controller/controllers/LaserController.py @@ -1,7 +1,7 @@ from typing import List, Union from imswitch.imcommon.framework import Signal from imswitch import IS_HEADLESS -from imswitch.imcommon.model import APIExport +from imswitch.imcommon.model import APIExport, initLogger from imswitch.imcontrol.model import configfiletools from imswitch.imcontrol.view import guitools from ..basecontrollers import ImConWidgetController @@ -12,6 +12,7 @@ class LaserController(ImConWidgetController): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self._logger = initLogger(self) self.settingAttr = False self.presetBeforeScan = None @@ -72,28 +73,33 @@ def closeEvent(self): def toggleLaser(self, laserName, enabled): """ Enable or disable laser (on/off).""" + self._logger.debug(f"toggleLaser: {laserName} -> enabled={enabled}") self._master.lasersManager[laserName].setEnabled(enabled) self.setSharedAttr(laserName, _enabledAttr, enabled) def valueChanged(self, laserName, magnitude): """ Change magnitude. """ + self._logger.debug(f"valueChanged: {laserName} -> value={magnitude}") self._master.lasersManager[laserName].setValue(magnitude) self.setSharedAttr(laserName, _valueAttr, magnitude) if not IS_HEADLESS: self._widget.setValue(laserName, magnitude) def toggleModulation(self, laserName, enabled): """ Enable or disable laser modulation (on/off). """ + self._logger.debug(f"toggleModulation: {laserName} -> enabled={enabled}") self._master.lasersManager[laserName].setModulationEnabled(enabled) self.setSharedAttr(laserName, _freqEnAttr, enabled) def frequencyChanged(self, laserName, frequency): """ Change modulation frequency. """ + self._logger.debug(f"frequencyChanged: {laserName} -> frequency={frequency}") self._master.lasersManager[laserName].setModulationFrequency(frequency) self.setSharedAttr(laserName, _freqAttr, frequency) if not IS_HEADLESS: self._widget.setModulationFrequency(laserName, frequency) def dutyCycleChanged(self, laserName, dutyCycle): """ Change modulation duty cycle. """ + self._logger.debug(f"dutyCycleChanged: {laserName} -> dutyCycle={dutyCycle}") self._master.lasersManager[laserName].setModulationDutyCycle(dutyCycle) self.setSharedAttr(laserName, _dcAttr, dutyCycle) if not IS_HEADLESS: self._widget.setModulationDutyCycle(laserName, dutyCycle) diff --git a/imswitch/imcontrol/controller/controllers/OSSIMController.py b/imswitch/imcontrol/controller/controllers/OSSIMController.py new file mode 100644 index 000000000..2949fe812 --- /dev/null +++ b/imswitch/imcontrol/controller/controllers/OSSIMController.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +from imswitch.imcontrol.controller.basecontrollers import ImConWidgetController +from imswitch.imcontrol.model.managers.OSSIMManager import OSSIMManager +from imswitch.imcommon.framework import Signal + +class OSSIMController(ImConWidgetController): + """ + Controller: glue between Manager and Widget. + NOTE: no 'from imswitch.imcontrol.model import model' needed. + """ + def __init__(self, *args, host: str = "192.168.137.2", port: int = 8000, + default_display_time: float = 0.2, **kwargs): + super().__init__(*args, **kwargs) + self.manager = OSSIMManager(host=host, port=port, default_display_time=default_display_time) + self.sigStatus = self.manager.sigStatus # forward status to widget + + # methods the widget will call + def health(self): return self.manager.health() + def list(self): return self.manager.list_patterns() + def set_pause(self, s: float): return self.manager.set_pause(s) + def display(self, i: int): return self.manager.display(i) + def start(self, cycles: int = -1): return self.manager.start(cycles) + def stop(self): return self.manager.stop() diff --git a/imswitch/imcontrol/controller/controllers/__init__.py b/imswitch/imcontrol/controller/controllers/__init__.py index e69de29bb..0490e2ea9 100644 --- a/imswitch/imcontrol/controller/controllers/__init__.py +++ b/imswitch/imcontrol/controller/controllers/__init__.py @@ -0,0 +1,17 @@ +""" +Lightweight controller package exports. + +Avoid eager-importing every controller so optional/missing controllers do not +break startup. Controllers are loaded dynamically by name elsewhere. +Only export minimal symbols needed by relative imports between controllers. +""" + +# Minimal exports referenced by other controllers through relative imports +from .PositionerController import PositionerController # used by Standa* controllers + +# Keep these optional helpers local to avoid import-time hard failures; users +# that require these controllers should import them directly by name. +# Example dynamic import path used elsewhere: +# imswitch.imcontrol.controller.controllers.Controller + +# Optional controllers can be imported lazily in their usage sites. \ No newline at end of file diff --git a/imswitch/imcontrol/model/managers/ArkitektManager.py b/imswitch/imcontrol/model/managers/ArkitektManager.py index 79d866dfe..43d1980a3 100644 --- a/imswitch/imcontrol/model/managers/ArkitektManager.py +++ b/imswitch/imcontrol/model/managers/ArkitektManager.py @@ -6,21 +6,26 @@ Configuration is loaded from setupInfo similar to FocusLockController. """ -from typing import Optional, Dict, Any +from typing import Optional, Dict, Any, Any as _Any from contextvars import copy_context, Context -from imswitch.imcommon.model import initLogger +import os +from imswitch.imcommon.model import initLogger, dirtools +import json -# Import this to make sure that mikro_next is available when ArkitektManager is used -from mikro_next.api.schema import Image +def ensure_context_in_thread(context: Optional[Context]): + """Best-effort: ensure context variables are available in the current thread. -def ensure_context_in_thread(context: Context): - """Ensure context variables are available in the current thread.""" - for ctx, value in context.items(): - ctx.set(value) + Note: Python's contextvars does not provide a portable API to copy values + from one Context into another thread. For our use case we only need to make + sure the Arkitekt client was entered and a context exists; so we keep this + as a no-op if a context is present. + """ + return -ARKITEKT_CONTEXT: Context | None = None +# Global held context copied from the Arkitekt thread +ARKITEKT_CONTEXT: Optional[Context] = None def ensure_global_context(): @@ -163,7 +168,7 @@ def _initialize_arkitekt(self) -> None: self.__logger.error(f"Failed to initialize Arkitekt: {e}") self._config["enabled"] = False - def get_arkitekt_app(self) -> Optional[Any]: + def get_arkitekt_app(self) -> Optional[_Any]: """Get the Arkitekt application instance.""" return self.__arkitekt @@ -202,7 +207,16 @@ def update_config(self, new_config: Dict[str, Any]) -> None: self.__logger.info("Arkitekt configuration updated") - def upload_and_deconvolve_image(self, image) -> Optional[Any]: + def _save_config(self, path: str) -> None: + """Persist current Arkitekt config to a JSON file.""" + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(self._config, f, indent=2) + except Exception as e: + self.__logger.warning(f"Failed to save Arkitekt config to {path}: {e}") + + def upload_and_deconvolve_image(self, image) -> Optional[_Any]: """ Upload an image to Arkitekt and perform deconvolution. @@ -248,7 +262,7 @@ def upload_and_deconvolve_image(self, image) -> Optional[Any]: self.__logger.error(f"Failed to perform image deconvolution: {e}") return None - def find_action(self, action_hash: str) -> Optional[Any]: + def find_action(self, action_hash: str) -> Optional[_Any]: """ Find an Arkitekt action by its hash. @@ -275,7 +289,7 @@ def find_action(self, action_hash: str) -> Optional[Any]: self.__logger.error(f"Failed to find action {action_hash}: {e}") return None - def execute_action(self, action_hash: str, **kwargs) -> Optional[Any]: + def execute_action(self, action_hash: str, **kwargs) -> Optional[_Any]: """ Execute an Arkitekt action with given parameters. diff --git a/imswitch/imcontrol/model/managers/OSSIMManager.py b/imswitch/imcontrol/model/managers/OSSIMManager.py new file mode 100644 index 000000000..7c151577c --- /dev/null +++ b/imswitch/imcontrol/model/managers/OSSIMManager.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from typing import Optional, Dict, Any +import threading +from imswitch.imcommon.framework import Signal +from .ossim_client import OSSIMClient # 你的客户端就放在同目录 + +class OSSIMManager: + """Talks to your FastAPI DMD server.""" + def __init__(self, host: str, port: int, default_display_time: Optional[float] = None): + self._client = OSSIMClient(URL=host, PORT=port) + self._display_time = default_display_time + self.sigStatus = Signal(dict) + self._poll = False + self._poll_thread: Optional[threading.Thread] = None + + def health(self) -> Dict[str, Any]: + return self._client.health() or {"status": "error"} + + def list_patterns(self): + return self._client.list_patterns() + + def set_pause(self, seconds: float): + self._display_time = float(seconds) + self._client.set_pause(self._display_time) + return {"status": "ok", "display_time": self._display_time} + + def display(self, i: int): + return self._client.display_pattern(int(i)) + + def start(self, cycles: int = -1): + if self._display_time is not None: + self._client.set_pause(self._display_time) + return self._client.start_viewer() if cycles == -1 else self._client.start_viewer_single_loop(int(cycles)) + + def stop(self): + return self._client.stop_loop() + + # optional: background status polling (call from controller/widget if needed) + def start_poll(self, interval_s: float = 0.5): + if self._poll: return + self._poll = True + def _loop(): + while self._poll: + st = self._client.status() + if isinstance(st, dict): + self.sigStatus.emit(st) + threading.Event().wait(interval_s) + self._poll_thread = threading.Thread(target=_loop, daemon=True); self._poll_thread.start() + + def stop_poll(self): + self._poll = False diff --git a/imswitch/imcontrol/model/managers/__init__.py b/imswitch/imcontrol/model/managers/__init__.py index 2087cca40..3b05a08ef 100644 --- a/imswitch/imcontrol/model/managers/__init__.py +++ b/imswitch/imcontrol/model/managers/__init__.py @@ -35,4 +35,14 @@ from .LepmonManager import LepmonManager from .FlatfieldManager import FlatfieldManager from .PixelCalibrationManager import PixelCalibrationManager -from .ArkitektManager import ArkitektManager +# from .ISMManager import ISMManager +from .OSSIMManager import OSSIMManager + +# Optional: Arkitekt integration. Import guarded so missing optional deps don't break app startup. +try: + from .ArkitektManager import ArkitektManager +except Exception as _arkitekt_err: # pragma: no cover - optional dependency + warnings.warn( + f"ArkitektManager unavailable: {type(_arkitekt_err).__name__}: {_arkitekt_err}. " + "Arkitekt features will be disabled unless dependencies are installed.") + ArkitektManager = None # type: ignore \ No newline at end of file diff --git a/imswitch/imcontrol/model/managers/lasers/ESP32LEDLaserManager.py b/imswitch/imcontrol/model/managers/lasers/ESP32LEDLaserManager.py index 90d5d90cd..5527b649e 100644 --- a/imswitch/imcontrol/model/managers/lasers/ESP32LEDLaserManager.py +++ b/imswitch/imcontrol/model/managers/lasers/ESP32LEDLaserManager.py @@ -51,11 +51,13 @@ def __init__(self, laserInfo, name, **lowLevelManagers): def setEnabled(self, enabled, getReturn=False): """Turn on (N) or off (F) laser emission""" self.enabled = enabled + self.__logger.debug(f"setEnabled(enabled={enabled}, getReturn={getReturn}) channel_index={self.channel_index} power={self.power}") if self.channel_index == "LED": #self._led.send_LEDMatrix_full(intensity = (self.power*self.enabled,self.power*self.enabled,self.power*self.enabled), getReturn=getReturn) #self._led.setIntensity(intensity=(self.power*self.enabled,self.power*self.enabled,self.power*self.enabled), getReturn=getReturn) self._led.setAll(state=self.enabled, intensity=(self.power, self.power, self.power), getReturn=getReturn) else: + self.__logger.debug("Sending _laser.set_laser for enable/disable") self._laser.set_laser(self.channel_index, int(self.power*self.enabled), despeckleAmplitude = self.laser_despeckle_amplitude, @@ -67,17 +69,20 @@ def setValue(self, power, getReturn=False): Sends a RS232 command to the laser specifying the new intensity. """ self.power = power + self.__logger.debug(f"setValue(power={power}, getReturn={getReturn}) enabled={self.enabled} channel_index={self.channel_index}") if self.enabled: if self.channel_index == "LED": # ensure that in case it's not initialized yet, we display an all-on pattern if self._led.ledpattern[0,0]==-1: self._led.ledpattern[:]=1 + self.__logger.debug("Sending _led.setAll for power change") self._led.setAll(state=1, intensity=(self.power, self.power, self.power), getReturn=getReturn) #self._led.setAll(intensity=(self.power*self.enabled,self.power*self.enabled,self.power*self.enabled), getReturn=getReturn) self.ledIntesity=self.power #self._led.send_LEDMatrix_full(intensity = (self.power*self.enabled,self.power*self.enabled,self.power*self.enabled), getReturn=getReturn) else: + self.__logger.debug("Sending _laser.set_laser for power change") self._laser.set_laser(self.channel_index, int(self.power), despeckleAmplitude = self.laser_despeckle_amplitude, diff --git a/imswitch/imcontrol/model/managers/ossim_client.py b/imswitch/imcontrol/model/managers/ossim_client.py new file mode 100644 index 000000000..51226e2cf --- /dev/null +++ b/imswitch/imcontrol/model/managers/ossim_client.py @@ -0,0 +1,216 @@ +# ossim_client.py +import time +from typing import Any, Dict, List, Optional, Union + +import requests + + +class OSSIMClient: + """ + A drop-in style client for the DMD FastAPI server you provided. + Method names mirror the SIMClient used in ImSwitch for easier integration. + + Key mappings: + - start_viewer() -> POST /start (cycles=-1) + - start_viewer_single_loop(n) -> POST /start (cycles=n) + - wait_for_viewer_completion() -> poll GET /status until not running / cycles done + - set_pause(period) -> set display_time; if running, restart loop + - stop_loop() -> POST /stop + - display_pattern(i) -> GET /display/{i} + + Extra helpers: + - health() -> GET /health + - status() -> GET /status + - list_patterns() -> GET /patterns + - reload_patterns(dir, files) -> POST /reload (json) + """ + + def __init__( + self, + URL: str, + PORT: Union[int, str] = 8000, + *, + default_timeout: float = 1.0, + poll_interval: float = 0.2, + ): + self.base_url = f"http://{URL}:{PORT}" + self.default_timeout = float(default_timeout) + self.poll_interval = float(poll_interval) + self.session = requests.Session() + + # Keep same style as SIMClient (key names only for readability) + self.commands = { + "health": "/health", + "status": "/status", + "patterns": "/patterns", + "display_pattern": "/display/", # + {pattern_id} + "start": "/start", + "stop_loop": "/stop", + "reload": "/reload", + } + + # Client-side state + self._display_time: Optional[float] = None # seconds; None -> use server default + + # -------------- low-level HTTP helpers -------------- + def _get(self, path: str, *, params: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None): + try: + r = self.session.get(self.base_url + path, params=params, timeout=timeout or self.default_timeout) + r.raise_for_status() + return r.json() + except Exception as e: + print(f"[OSSIMClient][GET {path}] {e}") + return -1 + + def _post( + self, + path: str, + *, + params: Optional[Dict[str, Any]] = None, + json: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + ): + try: + r = self.session.post(self.base_url + path, params=params, json=json, timeout=timeout or self.default_timeout) + r.raise_for_status() + return r.json() + except Exception as e: + print(f"[OSSIMClient][POST {path}] {e}") + return -1 + + # -------------- ImSwitch-compatible methods -------------- + def start_viewer(self) -> Union[int, Dict[str, Any]]: + """ + Start infinite pattern cycling (cycles = -1). + Uses self._display_time if set via set_pause(), otherwise server default. + """ + params = {"cycles": -1} + if self._display_time is not None: + params["display_time"] = float(self._display_time) + + resp = self._post(self.commands["start"], params=params) + # If already running and user just changed pause, try to restart with new pause + if isinstance(resp, dict) and resp.get("status") == "error" and "Already running" in str(resp.get("message", "")): + # Graceful restart with new display_time + self.stop_loop() + resp = self._post(self.commands["start"], params=params) + return resp + + def start_viewer_single_loop(self, number_of_runs: int, timeout: float = 2.0) -> Union[int, Dict[str, Any]]: + """ + Start a finite number of cycles (cycles = number_of_runs). + Uses self._display_time if set via set_pause(), otherwise server default. + """ + params = {"cycles": int(number_of_runs)} + if self._display_time is not None: + params["display_time"] = float(self._display_time) + return self._post(self.commands["start"], params=params, timeout=timeout) + + def wait_for_viewer_completion(self, *, timeout: Optional[float] = None) -> None: + """ + Block until the server reports not running OR the finite cycles have completed. + For infinite loops, this will block until stop_loop() is called or timeout is reached. + """ + start_t = time.time() + while True: + st = self.status() + if st == -1: + # transient error; keep trying unless timed out + if timeout is not None and (time.time() - start_t) > timeout: + print("[OSSIMClient] wait_for_viewer_completion timed out (status fetch errors).") + return + time.sleep(self.poll_interval) + continue + + running = bool(st.get("running", False)) + max_cycles = int(st.get("max_cycles", -1)) + cur_cycle = int(st.get("current_cycle", 0)) + + if not running: + return + + # If a finite run is requested and completed + if max_cycles > 0 and cur_cycle >= max_cycles: + return + + if timeout is not None and (time.time() - start_t) > timeout: + print("[OSSIMClient] wait_for_viewer_completion timed out.") + return + + time.sleep(self.poll_interval) + + def set_pause(self, period: float) -> None: + """ + Set the per-pattern display time (seconds). If a loop is running, restart with new pause. + """ + self._display_time = float(period) + st = self.status() + if st != -1 and bool(st.get("running", False)): + # Restart with same cycles if finite; otherwise infinite + max_cycles = int(st.get("max_cycles", -1)) + self.stop_loop() + if max_cycles > 0: + self.start_viewer_single_loop(max_cycles) + else: + self.start_viewer() + + def stop_loop(self) -> Union[int, Dict[str, Any]]: + return self._post(self.commands["stop_loop"]) + + def display_pattern(self, iPattern: int) -> Union[int, Dict[str, Any]]: + """ + Display a single pattern immediately. This does not start cycling. + """ + return self._get(self.commands["display_pattern"] + str(int(iPattern))) + + # -------------- extra helpers -------------- + def health(self) -> Union[int, Dict[str, Any]]: + return self._get(self.commands["health"]) + + def status(self) -> Union[int, Dict[str, Any]]: + return self._get(self.commands["status"]) + + def list_patterns(self) -> Union[int, Dict[str, Any]]: + return self._get(self.commands["patterns"]) + + def reload_patterns( + self, + pattern_dir: Optional[str] = None, + pattern_files: Optional[List[str]] = None, + ) -> Union[int, Dict[str, Any]]: + """ + Reload patterns on the server. Provide either directory, file list, or both. + """ + payload: Dict[str, Any] = {} + if pattern_dir is not None: + payload["pattern_dir"] = pattern_dir + if pattern_files is not None: + payload["pattern_files"] = list(pattern_files) + return self._post(self.commands["reload"], json=payload or {}) + + +# ----------------------- usage example ----------------------- +if __name__ == "__main__": + # Example IP/PORT; change to your server + client = OSSIMClient(URL="169.254.165.4", PORT=8000) + + print("Health:", client.health()) + print("Patterns:", client.list_patterns()) + + # Show a single pattern + client.display_pattern(0) + + # Infinite loop with 0.5 s per pattern + client.set_pause(0.5) + client.start_viewer() + + # Wait a few cycles then stop + time.sleep(3) + print("Status:", client.status()) + client.stop_loop() + + # Run exactly 5 cycles with 0.2 s per pattern + client.set_pause(0.2) + client.start_viewer_single_loop(5) + client.wait_for_viewer_completion() + print("Done single run. Status:", client.status()) diff --git a/imswitch/imcontrol/view/widgets/DMDWidget.py b/imswitch/imcontrol/view/widgets/DMDWidget.py new file mode 100644 index 000000000..aa3ee6215 --- /dev/null +++ b/imswitch/imcontrol/view/widgets/DMDWidget.py @@ -0,0 +1,200 @@ +from qtpy import QtCore, QtWidgets +import numpy as np +from .basewidgets import NapariHybridWidget + + +class DMDWidget(NapariHybridWidget): + """Simple DMD control panel to talk to a Raspberry Pi FastAPI service. + + Controls: + - Host field (default http://192.168.137.2:8000) + - Buttons to display pattern 0/1/2 + - Button to run synchronized 3-shot capture (DMD 0->1->2 with camera) + - Button to run reconstruction (Classic IOS) from the last 3 images + """ + + sigDisplayPattern = QtCore.Signal(int) + sigRunThreeShot = QtCore.Signal() + sigReconstruct = QtCore.Signal() + sigCheckStatus = QtCore.Signal() + + def __post_init__(self): + self.setObjectName("DMDWidget") + self._lastLayer = None + + grid = QtWidgets.QGridLayout() + self.setLayout(grid) + + # Host input + self.hostEdit = QtWidgets.QLineEdit("http://192.168.137.2:8000") + self.hostEdit.setToolTip("Raspberry Pi FastAPI base URL") + grid.addWidget(QtWidgets.QLabel("DMD FastAPI URL"), 0, 0, 1, 1) + grid.addWidget(self.hostEdit, 0, 1, 1, 3) + + # Status check + self.btnCheck = QtWidgets.QPushButton("Check Status") + self.btnCheck.setToolTip("Call /health to verify DMD connectivity") + grid.addWidget(self.btnCheck, 1, 0) + + # Pattern display buttons + self.btnShow0 = QtWidgets.QPushButton("Show 0") + self.btnShow1 = QtWidgets.QPushButton("Show 1") + self.btnShow2 = QtWidgets.QPushButton("Show 2") + grid.addWidget(self.btnShow0, 1, 1) + grid.addWidget(self.btnShow1, 1, 2) + grid.addWidget(self.btnShow2, 1, 3) + # Timing (ms) between pattern changes + grid.addWidget(QtWidgets.QLabel("Delay (ms)"), 2, 0) + self.delaySpin = QtWidgets.QSpinBox() + self.delaySpin.setRange(0, 10000) + self.delaySpin.setSingleStep(10) + self.delaySpin.setValue(50) # default 0.2 s + self.delaySpin.setToolTip("Delay between pattern switches (ms). Original default 200 ms") + grid.addWidget(self.delaySpin, 2, 1) + # Gaussian sigma for reconstruction + grid.addWidget(QtWidgets.QLabel("Sigma"), 2, 2) + self.sigmaSpin = QtWidgets.QDoubleSpinBox() + self.sigmaSpin.setDecimals(2) + self.sigmaSpin.setRange(0.0, 50.0) + self.sigmaSpin.setSingleStep(0.25) + self.sigmaSpin.setValue(1.0) + self.sigmaSpin.setToolTip("Gaussian filter sigma (pixels) applied after reconstruction. Set 0 to disable") + grid.addWidget(self.sigmaSpin, 2, 3) + + # Offset subtraction to suppress background in RMS (units: ADU counts) + grid.addWidget(QtWidgets.QLabel("Offset"), 3, 0) + self.offsetSpin = QtWidgets.QDoubleSpinBox() + self.offsetSpin.setDecimals(1) + self.offsetSpin.setRange(0.0, 65535.0) + self.offsetSpin.setSingleStep(1.0) + self.offsetSpin.setValue(2.0) + self.offsetSpin.setToolTip("Constant value subtracted from each frame before IOS RMS. Set based on background intensity. 0 disables.") + grid.addWidget(self.offsetSpin, 3, 1) + + # Toggles: Gaussian enable, Widefield enable, Export enable + filename + self.chkGaussian = QtWidgets.QCheckBox("Use Gaussian") + self.chkGaussian.setChecked(True) + self.chkGaussian.setToolTip("Apply Gaussian filter (effective when sigma > 0)") + grid.addWidget(self.chkGaussian, 3, 3) + + # 3-shot + reconstruction buttons (moved to row 4) + self.btnThreeShot = QtWidgets.QPushButton("Run 3-shot (0-1-2)") + self.btnReconstruct = QtWidgets.QPushButton("Reconstruction (IOS)") + grid.addWidget(self.btnThreeShot, 4, 0, 1, 2) + grid.addWidget(self.btnReconstruct, 4, 2) + + self.chkWidefield = QtWidgets.QCheckBox("Show Widefield") + self.chkWidefield.setChecked(True) + self.chkWidefield.setToolTip("Display averaged widefield (mean of 3 frames)") + grid.addWidget(self.chkWidefield, 5, 0) + + self.chkExport = QtWidgets.QCheckBox("Export") + self.chkExport.setChecked(False) + self.chkExport.setToolTip("Export TIFF after reconstruction (IOS and optional Widefield)") + grid.addWidget(self.chkExport, 5, 1) + + self.chkExportRaw = QtWidgets.QCheckBox("Raw 3 frames") + self.chkExportRaw.setChecked(False) + self.chkExportRaw.setToolTip("Also export the three raw frames I1/I2/I3 (original data type)") + grid.addWidget(self.chkExportRaw, 5, 2) + + self.exportNameEdit = QtWidgets.QLineEdit("dmd_recon") + self.exportNameEdit.setToolTip("Base file name (without extension), saved in recording directory (if exists) or current directory") + grid.addWidget(QtWidgets.QLabel("Base Name"), 6, 0) + grid.addWidget(self.exportNameEdit, 6, 1, 1, 3) + + # Export directory selection + default_dir = QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.DocumentsLocation) + candidate = QtCore.QDir.toNativeSeparators(default_dir + "/ImSwitchConfig/recordings") + self.exportDirEdit = QtWidgets.QLineEdit(candidate) + self.exportDirEdit.setToolTip("Export directory (editable). Default Documents/ImSwitchConfig/recordings") + self.btnBrowseDir = QtWidgets.QPushButton("...") + self.btnBrowseDir.setFixedWidth(30) + grid.addWidget(QtWidgets.QLabel("Dir"), 7, 0) + grid.addWidget(self.exportDirEdit, 7, 1, 1, 2) + grid.addWidget(self.btnBrowseDir, 7, 3) + + # Status (scrollable, fixed height) + self.statusEdit = QtWidgets.QPlainTextEdit() + self.statusEdit.setReadOnly(True) + self.statusEdit.setFixedHeight(80) + self.statusEdit.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.statusEdit.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + grid.addWidget(self.statusEdit, 8, 0, 1, 4) + + # Connections + self.btnCheck.clicked.connect(self.sigCheckStatus.emit) + self.btnShow0.clicked.connect(lambda: self.sigDisplayPattern.emit(0)) + self.btnShow1.clicked.connect(lambda: self.sigDisplayPattern.emit(1)) + self.btnShow2.clicked.connect(lambda: self.sigDisplayPattern.emit(2)) + self.btnThreeShot.clicked.connect(self.sigRunThreeShot.emit) + self.btnReconstruct.clicked.connect(self.sigReconstruct.emit) + self.btnBrowseDir.clicked.connect(self._browseExportDir) + + # UI helpers + def getHost(self) -> str: + return self.hostEdit.text().strip() + + def getDelaySeconds(self) -> float: + """Return inter-pattern delay in seconds (spin box is ms).""" + return self.delaySpin.value() / 1000.0 + + def getSigma(self) -> float: + return float(self.sigmaSpin.value()) + + def getOffset(self) -> float: + return float(self.offsetSpin.value()) + + def isGaussianEnabled(self) -> bool: + return self.chkGaussian.isChecked() + + def isWidefieldEnabled(self) -> bool: + return self.chkWidefield.isChecked() + + def isExportEnabled(self) -> bool: + return self.chkExport.isChecked() + + def isExportRawEnabled(self) -> bool: + return self.chkExportRaw.isChecked() + + def getExportBaseName(self) -> str: + return self.exportNameEdit.text().strip() or "dmd_recon" + + def getExportDirectory(self) -> str: + return self.exportDirEdit.text().strip() + + def _browseExportDir(self): + d = QtWidgets.QFileDialog.getExistingDirectory(self, "Select export directory", self.getExportDirectory() or "") + if d: + self.exportDirEdit.setText(d) + + def setStatus(self, text: str): + """Replace full status text (scrollable, not resizing layout).""" + self.statusEdit.setPlainText(text or "") + + # (Optional helper if you later want incremental logs) + # def appendStatus(self, line: str): + # self.statusEdit.appendPlainText(line) + + def showImage(self, im: np.ndarray, name: str = "DMD Reconstruction"): + """Display or update an image in napari.""" + if self._lastLayer is None or name not in self.viewer.layers: + # Create new layer + self._lastLayer = self.viewer.add_image( + im, name=name, rgb=False, blending="translucent", colormap="gray" + ) + else: + self._lastLayer.data = im + + # Autoscale a bit + try: + vmin = float(np.min(im)) + vmax = float(np.max(im)) + if vmax > vmin: + self._lastLayer.contrast_limits = (vmin, vmax) + except Exception: + pass + + +# Copyright (C) 2020-2025 ImSwitch developers +# GPLv3 License \ No newline at end of file diff --git a/imswitch/imcontrol/view/widgets/OSSIMWidget.py b/imswitch/imcontrol/view/widgets/OSSIMWidget.py new file mode 100644 index 000000000..0ea73958f --- /dev/null +++ b/imswitch/imcontrol/view/widgets/OSSIMWidget.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +from qtpy import QtWidgets + +class OSSIMWidget(QtWidgets.QWidget): + displayName = "OSSIM" # 必须与 setup 里的 availableWidgets 一致 + + def __init__(self, *args, **kwargs): + # 工厂会传各种 kw,比如 napariViewer/parent/options/setupInfo/... + parent = kwargs.pop("parent", None) + # 把可能用到的对象先收起来(名称跟随 ImSwitch 习惯) + self.napariViewer = kwargs.pop("napariViewer", None) + self.mainView = kwargs.pop("mainView", None) + self.options = kwargs.pop("options", None) + self.setupInfo = kwargs.pop("setupInfo", None) + # 其余未知 kw 丢弃,避免传进 Qt + kwargs.clear() + + super().__init__(parent) + self.controller = None # setController() 注入 + + self._build() # 先搭 UI(此时不绑定事件) + + def setController(self, controller): + """工厂在创建后会调用这个注入 controller。""" + self.controller = controller + self._wire() + self._refresh() + + # ---------- UI ---------- + def _build(self): + lay = QtWidgets.QVBoxLayout(self) + + self.lbl = QtWidgets.QLabel("Server: ?") + lay.addWidget(self.lbl) + + row = QtWidgets.QHBoxLayout(); lay.addLayout(row) + row.addWidget(QtWidgets.QLabel("Pause (s):")) + self.spinPause = QtWidgets.QDoubleSpinBox() + self.spinPause.setRange(0, 10) + self.spinPause.setDecimals(3) + self.spinPause.setValue(0.2) + row.addWidget(self.spinPause) + self.btnSetPause = QtWidgets.QPushButton("Set pause"); row.addWidget(self.btnSetPause) + + row2 = QtWidgets.QHBoxLayout(); lay.addLayout(row2) + self.btnStart = QtWidgets.QPushButton("Start ∞") + self.btnStartN = QtWidgets.QPushButton("Start N") + self.spinN = QtWidgets.QSpinBox(); self.spinN.setRange(1, 10000); self.spinN.setValue(5) + self.btnStop = QtWidgets.QPushButton("Stop") + row2.addWidget(self.btnStart); row2.addWidget(self.btnStartN); row2.addWidget(self.spinN); row2.addWidget(self.btnStop) + + row3 = QtWidgets.QHBoxLayout(); lay.addLayout(row3) + row3.addWidget(QtWidgets.QLabel("Display idx:")) + self.spinIdx = QtWidgets.QSpinBox(); self.spinIdx.setRange(0, 9999) + self.btnDisplay = QtWidgets.QPushButton("Display") + row3.addWidget(self.spinIdx); row3.addWidget(self.btnDisplay) + + self.log = QtWidgets.QPlainTextEdit(); self.log.setReadOnly(True); self.log.setMinimumHeight(100) + lay.addWidget(self.log) + lay.addStretch(1) + + def _wire(self): + if self.controller is None: + return + self.btnSetPause.clicked.connect(lambda: self._call(self.controller.set_pause, self.spinPause.value())) + self.btnStart.clicked.connect(lambda: self._call(self.controller.start, -1)) + self.btnStartN.clicked.connect(lambda: self._call(self.controller.start, self.spinN.value())) + self.btnStop.clicked.connect(lambda: self._call(self.controller.stop)) + self.btnDisplay.clicked.connect(lambda: self._call(self.controller.display, self.spinIdx.value())) + if hasattr(self.controller, "sigStatus"): + self.controller.sigStatus.connect(lambda st: self._append(f"status: {st}")) + + def _refresh(self): + if self.controller is None: + self.lbl.setText("Server: (no controller)") + return + try: + self.lbl.setText(f"Server: {self.controller.health()}") + except Exception as e: + self.lbl.setText(f"Server: ERR {e}") + + def _append(self, s: str): + self.log.appendPlainText(str(s)) + + def _call(self, fn, *a): + try: + self._append(fn(*a)) + except Exception as e: + self._append(f"ERR: {e}") diff --git a/imswitch/imcontrol/view/widgets/__init__.py b/imswitch/imcontrol/view/widgets/__init__.py index e0339a3d5..c9d359f33 100644 --- a/imswitch/imcontrol/view/widgets/__init__.py +++ b/imswitch/imcontrol/view/widgets/__init__.py @@ -63,7 +63,9 @@ from .ULensesWidget import ULensesWidget from .ViewWidget import ViewWidget from .WatcherWidget import WatcherWidget + from .DMDWidget import DMDWidget try: from .HyphaWidget import HyphaWidget except ModuleNotFoundError: warnings.warn("HyphaWidget not available; please install imjoy-rpc module") + from .OSSIMWidget import OSSIMWidget diff --git a/imswitch/imscripting/view/ImScrMainView.py b/imswitch/imscripting/view/ImScrMainView.py index 5b72010ab..0c3cc48c8 100644 --- a/imswitch/imscripting/view/ImScrMainView.py +++ b/imswitch/imscripting/view/ImScrMainView.py @@ -6,7 +6,8 @@ try: from .EditorView import EditorView editorViewAvailable = True -except: +except Exception as e: + print(e) editorViewAvailable = False from .FilesView import FilesView from .OutputView import OutputView