diff --git a/dev.yaml b/dev.yaml new file mode 100644 index 0000000..10fa6dd --- /dev/null +++ b/dev.yaml @@ -0,0 +1,31 @@ +memory_buffer_size: 10000 +viewer_type: "static" + +encoder: + type: "wheel" + port: "COM4" + baudrate: 57600 + cpr: 2400 + diameter_mm: 80 + sample_interval_ms: 20 + development_mode: False + +cameras: + - id: "dev" + name: "devcam1" + backend: "micromanager" + micromanager_path: "C:/Program Files/Micro-Manager-2.0" + + + - id: "dev" + name: "devcam2" + backend: "micromanager" + micromanager_path: "C:/Program Files/Micro-Manager-thor" + + + + # - id: "arducam" + # name: "arducam" + # backend: "opencv" + # properties: + # fps: 30 \ No newline at end of file diff --git a/hardware.yaml b/hardware.yaml new file mode 100644 index 0000000..212e426 --- /dev/null +++ b/hardware.yaml @@ -0,0 +1,47 @@ +memory_buffer_size: 10000 +viewer_type: "static" + +encoder: + type: "wheel" + port: "COM4" + baudrate: 57600 + cpr: 2400 + diameter_mm: 80 + sample_interval_ms: 20 + development_mode: False + +cameras: + - id: "Dhyana" + name: "mesoscope" + backend: "micromanager" + micromanager_path: "C:/Program Files/Micro-Manager-2.0" + configuration_path: "C:/Program Files/Micro-Manager-2.0/mm-sipefield.cfg" + properties: + Dhyana: + Output Trigger Port: 2 + Gain: "HDR" + fps: 49 # IMPORTANT: This is validated externally and is a value used merely to calculate experiment duration and frames to capture + Arduino-Shutter: + OnOff: "1" + Arduino-Switch: + Sequence: "On" + Core: + Shutter: "Arduino-Shutter" + + - id: "ThorCam" + name: "pupil" + backend: "micromanager" + micromanager_path: "C:/Program Files/Micro-Manager-thor" + configuration_path: "C:/Program Files/Micro-Manager-2.0/ThorCam.cfg" + properties: + ThorCam: + Exposure: 20 + fps: 30 # IMPORTANT: This is validated externally and is a value used merely to calculate experiment duration and frames to capture + ROI: [440, 305, 509, 509] + + + # - id: "arducam" + # name: "arducam" + # backend: "opencv" + # properties: + # fps: 30 \ No newline at end of file diff --git a/mesofield/__main__.py b/mesofield/__main__.py index 42a7fbb..ce00f75 100644 --- a/mesofield/__main__.py +++ b/mesofield/__main__.py @@ -1,84 +1,83 @@ -"""Entry point for mesofield.""" +"""CLI Interface entry point for mesofield python package""" import os -# os.environ['NUMEXPR_MAX_THREADS'] = '4' -# os.environ['NUMEXPR_NUM_THREADS'] = '2' -# import numexpr as ne +import logging +import argparse -import click from PyQt6.QtWidgets import QApplication + from mesofield.gui.maingui import MainWindow from mesofield.config import ExperimentConfig -from mesofield.startup import Startup -''' -This is the client terminal command line interface - -The client terminal commands are: - - launch: Launch the mesofield acquisition interface - - dev: Set to True to launch in development mode with simulated MMCores - test_mda: Test the mesofield acquisition interface - -''' - - -@click.group() -def cli(): - """mesofields Command Line Interface""" - pass - -@cli.command() -@click.option('--dev', default=False, help='launch in development mode with simulated MMCores.') -@click.option('--params', default='params.json', help='Path to the config JSON file.') -def launch(dev, params): - """ Launch mesofield acquisition interface. - - This function initializes and launches the mesofield acquisition application. - - It sets up the necessary hardware and configuration based on - the provided parameters. - - Parameters: - `dev (str)`: The device identifier to be used for the acquisition. - `params (str)`: The path to the configuration file. Default is the params.json file in the current directory. - - """ - + +# Disable pymmcore-plus logger +package_logger = logging.getLogger('pymmcore-plus') + +# Set the logging level to CRITICAL to suppress lower-level logs +package_logger.setLevel(logging.CRITICAL) + +# Disable debugger warning about the use of frozen modules +os.environ["PYDEVD_DISABLE_FILE_VALIDATION"] = "1" + + + +def launch(params): + """Launch the mesofield acquisition interface.""" print('Launching mesofield acquisition interface...') app = QApplication([]) - config_path = params - config = ExperimentConfig(config_path, dev) - config.hardware.initialize_cores(config) + config = ExperimentConfig(params) + config.hardware.configure_engines(config) + print(config.hardware.cameras[0]) mesofield = MainWindow(config) mesofield.show() - app.exec_() + app.exec() -@cli.command() def controller(): """Launch the mesofield controller.""" - from mesofield.controller import Controller + from mesofield.gui.controller import Controller app = QApplication([]) - controller = Controller() - controller.show() - app.exec_() + c = Controller() + c.show() + app.exec() -@cli.command() -@click.option('--frames', default=100, help='Number of frames for the MDA test.') def test_mda(frames): - """ - Run a test of the mesofield Multi-Dimensional Acquisition (MDA) - """ + """Run a test of the mesofield Multi-Dimensional Acquisition (MDA).""" from mesofield.startup import test_mda -@cli.command() -def run_mda(): +def run_mda_command(): """Run the Multi-Dimensional Acquisition (MDA) without the GUI.""" - run_mda() - - -if __name__ == "__main__": # pragma: no cover - cli() - - - +def main(): + parser = argparse.ArgumentParser(description="mesofields Command Line Interface") + subparsers = parser.add_subparsers(dest='command', help='Available subcommands') + + # Subcommand: launch + parser_launch = subparsers.add_parser('launch', help='Launch the mesofield acquisition interface') + parser_launch.add_argument('--params', default='dev.yaml', + help='Path to the config file') + + # Subcommand: controller + parser_controller = subparsers.add_parser('controller', help='Launch the mesofield controller') + + # Subcommand: test_mda + parser_test_mda = subparsers.add_parser('test_mda', help='Run a test MDA') + parser_test_mda.add_argument('--frames', type=int, default=100, + help='Number of frames for the MDA test') + + # Subcommand: run_mda + subparsers.add_parser('run_mda', help='Run MDA without the GUI') + + args = parser.parse_args() + + if args.command == 'launch': + launch(args.params) + elif args.command == 'controller': + controller() + elif args.command == 'test_mda': + test_mda(args.frames) + elif args.command == 'run_mda': + run_mda_command() + else: + parser.print_help() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mesofield/config.py b/mesofield/config.py index 7327f36..1600a83 100644 --- a/mesofield/config.py +++ b/mesofield/config.py @@ -5,14 +5,15 @@ import os import useq import warnings -from pymmcore_plus import CMMCorePlus -from typing import TYPE_CHECKING +import yaml +from pymmcore_plus import CMMCorePlus +from mesofield.engines import DevEngine, MesoEngine, PupilEngine +from mesofield.io.encoder import SerialWorker +from mesofield.io.arducam import VideoThread from mesofield.io import SerialWorker -from mesofield.startup import Startup - class ExperimentConfig: """## Generate and store parameters loaded from a JSON file. @@ -35,32 +36,24 @@ class ExperimentConfig: ``` """ - def __init__(self, path: str = None, development_mode: bool = False): + def __init__(self, path: str = None): self._parameters = {} self._json_file_path = '' self._output_path = '' self._save_dir = '' - if development_mode: - self.hardware = Startup() - else: - self.hardware = Startup._from_json(os.path.join(os.path.dirname(__file__), path)) - - # Extract FPS values from Startup instance, if available - - self.dhyana_fps: int = self.hardware._dhyana_fps - self.thorcam_fps: int = self.hardware._thorcam_fps + self.hardware = HardwareManager(path) self.notes: list = [] @property - def _cores(self) -> tuple[CMMCorePlus, CMMCorePlus]: - """Return the two CMMCorePlus instances from the Startup cores.""" - return self.hardware.widefield.core, self.hardware.thorcam.core + def _cores(self) -> tuple[CMMCorePlus, ...]: + """Return the tuple of CMMCorePlus instances from the hardware cameras.""" + return tuple(cam.core for cam in self.hardware.cameras if hasattr(cam, 'core')) @property def encoder(self) -> SerialWorker: - return self.hardware.encoder.encoder + return self.hardware.encoder @property def save_dir(self) -> str: @@ -73,14 +66,6 @@ def save_dir(self, path: str): else: print(f"ExperimentConfig: \n Invalid save directory path: {path}") - @property - def protocol(self) -> str: - return self._parameters.get('protocol', 'protocol') - - @property - def wheel_encoder(self) -> dict: - return self._parameters.get('wheel_encoder', {'port': 'COM4', 'baudrate': 57600}) - @property def subject(self) -> str: return self._parameters.get('subject', 'sub') @@ -107,11 +92,11 @@ def trial_duration(self) -> int: @property def num_meso_frames(self) -> int: - return int(self.dhyana_fps * self.sequence_duration) + return int(self.hardware.Dhyana.fps * self.sequence_duration) @property def num_pupil_frames(self) -> int: - return int((self.thorcam_fps * self.sequence_duration)) + 100 + return int((self.hardware.ThorCam.fps * self.sequence_duration)) + 100 @property def num_trials(self) -> int: @@ -129,15 +114,10 @@ def meso_sequence(self) -> useq.MDASequence: def pupil_sequence(self) -> useq.MDASequence: return useq.MDASequence(time_plan={"interval": 0, "loops": self.num_pupil_frames}) - @property #currently unused - def filename(self): - return f"{self.protocol}-sub-{self.subject}_ses-{self.session}_task-{self.task}.tiff" - @property def bids_dir(self) -> str: """ Dynamic construct of BIDS directory path """ bids = os.path.join( - f"{self.protocol}", f"sub-{self.subject}", f"ses-{self.session}", ) @@ -146,15 +126,21 @@ def bids_dir(self) -> str: # Property to compute the full file path, handling existing files @property def meso_file_path(self): - file = f"{self.protocol}-sub-{self.subject}_ses-{self.session}_task-{self.task}_meso.ome.tiff" - return self._generate_unique_file_path(file, 'func') + return self._generate_unique_file_path(suffix="meso", extension="ome.tiff", bids_type="func") # Property for pupil file path, if needed @property def pupil_file_path(self): - file = f"{self.protocol}-sub-{self.subject}_ses-{self.session}_task-{self.task}_pupil.ome.tiff" - return self._generate_unique_file_path(file, 'func') + return self._generate_unique_file_path(suffix="pupil", extension="ome.tiff", bids_type="func") + @property + def notes_file_path(self): + return self._generate_unique_file_path(suffix="notes", extension="txt") + + @property + def encoder_file_path(self): + return self._generate_unique_file_path(suffix="encoder-data", extension="csv", bids_type='beh') + @property def dataframe(self): data = {'Parameter': list(self._parameters.keys()), @@ -199,25 +185,18 @@ def led_pattern(self, value: list) -> None: raise ValueError("led_pattern must be a list or a JSON string representing a list") # Helper method to generate a unique file path - def _generate_unique_file_path(self, file, bids_type: str = None): - """ - Generates a unique file path by adding a counter if the file already exists. - - file (str): Name of the file with extension. - - bids_type (str, optional): Subdirectory within the BIDS directory. Defaults to None. - - Returns: - - str: A unique file path within the specified bids_type directory. - - Example: - + def _generate_unique_file_path(self, suffix: str, extension: str, bids_type: str = None): + """ Example: ```py - unique_path = _generate_unique_file_path("example.txt", "func") + ExperimentConfig._generate_unique_file_path("images", "jpg", "func") + print(unique_path) ``` - + Output: + C:/save_dir/data/sub-id/ses-id/func/20250110_123456_sub-001_ses-01_task-example_images.jpg """ + import datetime + file = f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}_sub-{self.subject}_ses-{self.session}_task-{self.task}_{suffix}.{extension}" + if bids_type is None: bids_path = self.bids_dir else: @@ -231,12 +210,10 @@ def _generate_unique_file_path(self, file, bids_type: str = None): file_path = os.path.join(bids_path, f"{base}_{counter}{ext}") counter += 1 return file_path - + def load_parameters(self, json_file_path) -> None: - """ - Load parameters from a JSON file path into the config object. + """ Load parameters from a JSON file path into the config object. """ - try: with open(json_file_path, 'r') as f: self._parameters = json.load(f) @@ -248,35 +225,236 @@ def load_parameters(self, json_file_path) -> None: return def update_parameter(self, key, value) -> None: - """ Update a parameter in the config object """ self._parameters[key] = value def list_parameters(self) -> pd.DataFrame: - """ Create a DataFrame from the ExperimentConfig properties """ + """ Create a DataFrame from the ExperimentConfig properties + """ properties = [prop for prop in dir(self.__class__) if isinstance(getattr(self.__class__, prop), property)] - exclude_properties = {'dataframe', 'parameters'} + exclude_properties = {'dataframe', 'parameters', 'json_path', "_cores"} data = {prop: getattr(self, prop) for prop in properties if prop not in exclude_properties} return pd.DataFrame(data.items(), columns=['Parameter', 'Value']) def save_wheel_encoder_data(self, data): - """ Save the wheel encoder data to a CSV file """ - + """ Save the wheel encoder data to a CSV file + """ if isinstance(data, list): data = pd.DataFrame(data) + + try: + data.to_csv(self.encoder_file_path, index=False) + print(f"Encoder data saved to {self.encoder_file_path}") + except Exception as e: + print(f"Error saving encoder data: {e}") - encoder_file = f'{self.subject}_ses-{self.session}_encoder-data.csv' - encoder_path = self._generate_unique_file_path(encoder_file, 'beh') - - params_file = f'{self.subject}_ses-{self.session}_configuration.csv' - params_path = self._generate_unique_file_path(params_file) - + def save_configuration(self): + """ Save the configuration parameters to a CSV file + """ + params_path = self._generate_unique_file_path(suffix="configuration", extension="csv") try: params = self.list_parameters() params.to_csv(params_path, index=False) - data.to_csv(encoder_path, index=False) - print(f"Encoder data saved to {encoder_path}") + print(f"Configuration saved to {params_path}") except Exception as e: - print(f"Error saving encoder data: {e}") + print(f"Error saving configuration: {e}") + + try: + with open(self.notes_file_path, 'w') as f: + f.write('\n'.join(self.notes)) + print(f"Notes saved to {self.notes_file_path}") + except Exception as e: + print(f"Error saving notes: {e}") +class ConfigLoader: + def __init__(self, file_path: str): + self.file_path = file_path + self.parameters = self.load_parameters(file_path) + self.set_attributes() + + def load_parameters(self, file_path: str) -> dict: + if file_path.endswith('.json'): + with open(file_path, 'r') as f: + return json.load(f) + elif file_path.endswith(('.yaml', '.yml')): + with open(file_path, 'r') as f: + return yaml.safe_load(f) + else: + raise ValueError("Unsupported file format") + + def set_attributes(self): + for key, value in self.parameters.items(): + setattr(self, key, value) + +""" +Example refactoring of a startup script using YAML for configuration. +Preserves: +- Per-camera instantiation of CMMCorePlus +- Camera-Engine pairing +- Accessible hardware references +- Extensible for new hardware types (e.g., NI-DAQ) + +Requires: + - pyyaml (pip install pyyaml) + - pymmcore-plus (pip install pymmcore-plus) + + {self.__class__.__module__}.{self.__class__.__name__} +""" + +VALID_BACKENDS = {"micromanager", "opencv"} + + +class Daq: + """ + Represents an abstracted NI-DAQ device. + """ + + def __init__(self, config: dict): + self.config = config + self.type = config.get("type", "unknown") + self.port = config.get("port") + + + def __repr__(self): + return ( + f"" + ) + +class HardwareManager: + """ + High-level class that initializes all hardware (cameras, encoder, etc.) + using the ParameterManager. Keeps references easily accessible. + """ + + def __init__(self, config_file: str): + self.yaml = self._load_hardware_from_yaml(config_file) + self.cameras: tuple[Camera, ...] = () + self._initialize_cameras() + self._initialize_encoder() + + def __repr__(self): + return ( + "\n" + f" Cameras: {[cam.id for cam in self.cameras]}\n" + f" Encoder: {self.encoder}\n" + f" Config: {self.yaml}\n" + f" loaded_keys={list(self.params.keys())}\n" + "" + ) + + + def shutdown(self): + self.encoder.stop() + + def _load_hardware_from_yaml(self, path): + params = {} + + if not path: + raise FileNotFoundError(f"Cannot find config file at: {path}") + + with open(path, "r", encoding="utf-8") as file: + params = yaml.safe_load(file) or {} + + return params + + + def _initialize_encoder(self): + if self.yaml.get("encoder"): + params = self.yaml.get("encoder") + self.encoder = SerialWorker( + serial_port=params.get('port'), + baud_rate=params.get('baudrate'), + sample_interval=params.get('sample_interval_ms'), + wheel_diameter=params.get('diameter_mm'), + cpr=params.get('cpr'), + development_mode=params.get('development_mode') + ) + + + def _initialize_cameras(self): + cams = [] + for camera_config in self.yaml.get("cameras"): + camera_id = camera_config.get("id") + backend = camera_config.get("backend") + if backend == "micromanager": + core = self.get_core_object( + camera_config.get("micromanager_path"), + camera_config.get("configuration_path", None), + ) + camera_object = core.getDeviceObject(camera_id) + for device_id, props in camera_config.get("properties", {}).items(): + if isinstance(props, dict): + for property_id, value in props.items(): + if property_id == 'ROI': + print(f"<{__class__.__name__}>: Setting {device_id} {property_id} to {value}") + core.setROI(device_id, *value) # * operator used to unpack the {type(value)=list}: [x, y, width, height] + elif property_id == 'fps': + print(f"<{__class__.__name__}>: Setting {device_id} {property_id} to {value}") + setattr(camera_object, 'fps', value) + else: + print(f"<{__class__.__name__}>: Setting {device_id} {property_id} to {value}") + core.setProperty(device_id, property_id, value) + else: + pass + if camera_id == 'ThorCam': + engine = PupilEngine(core, use_hardware_sequencing=True) + core.mda.set_engine(engine) + print (f"{self.__class__.__module__}.{self.__class__.__name__}.engine: {engine}") + elif camera_id == 'Dhyana': + engine = MesoEngine(core, use_hardware_sequencing=True) + core.mda.set_engine(engine) + print (f"{self.__class__.__module__}.{self.__class__.__name__}.engine: {engine}") + else: + engine = DevEngine(core, use_hardware_sequencing=True) + core.mda.set_engine(engine) + print (f"{self.__class__.__module__}.{self.__class__.__name__}.engine: {engine}") + + elif backend == 'opencv': + camera_object = VideoThread() + + cams.append(camera_object) + setattr(self, camera_id, camera_object) + self.cameras = tuple(cams) + + + def get_core_object(self, mm_path, mm_cfg_path): + core = CMMCorePlus(mm_path) + if mm_path and mm_cfg_path is not None: + core.loadSystemConfiguration(mm_cfg_path) + elif mm_cfg_path is None and mm_path: + core.loadSystemConfiguration() + return core + + + def get_property_object(core : CMMCorePlus, device_id: str, property_id: str): + return core.getPropertyObject(device_id, property_id) + + + def configure_engines(self, cfg): + """ If using micromanager cameras, configure the engines + """ + for cam in self.cameras: + if isinstance: + cam.core.mda.engine.set_config(cfg) + + + def cam_backends(self, backend): + """ Generator to iterate through cameras with a specific backend. + """ + for cam in self.cameras: + if cam.backend == backend: + yield cam + + + def _test_camera_backends(self): + """ Test if the backend values of cameras are either 'micromanager' or 'opencv'. + """ + for cam in self.cam_backends("micromanager"): + assert cam.backend in VALID_BACKENDS, f"Invalid backend {cam.backend} for camera {cam.id}" + for cam in self.cam_backends("opencv"): + assert cam.backend in VALID_BACKENDS, f"Invalid backend {cam.backend} for camera {cam.id}" + + + diff --git a/mesofield/engines.py b/mesofield/engines.py new file mode 100644 index 0000000..8038a84 --- /dev/null +++ b/mesofield/engines.py @@ -0,0 +1,278 @@ +import useq +import time +from itertools import product + +from typing import TYPE_CHECKING, Iterable, Mapping, Sequence +if TYPE_CHECKING: + from pymmcore_plus.core._sequencing import SequencedEvent + from pymmcore_plus.mda.metadata import FrameMetaV1 # type: ignore + from numpy.typing import NDArray + from useq import MDAEvent + PImagePayload = tuple[NDArray, MDAEvent, FrameMetaV1] + +from pymmcore_plus.metadata import SummaryMetaV1 +from pymmcore_plus.mda import MDAEngine + +import logging +logging.basicConfig(filename='engine.log', level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from mesofield.io import SerialWorker + from pymmcore_plus import CMMCorePlus + +class MesoEngine(MDAEngine): + def __init__(self, mmc, use_hardware_sequencing: bool = True) -> None: + super().__init__(mmc) + self._mmc = mmc + self.use_hardware_sequencing = use_hardware_sequencing + self._config = None + self._encoder: SerialWorker = None + self._wheel_data = None + # TODO: adder triggerable parameter + def set_config(self, cfg) -> None: + self._config = cfg + self._encoder = cfg.encoder + + def setup_sequence(self, sequence: useq.MDASequence) -> SummaryMetaV1 | None: + """Perform setup required before the sequence is executed.""" + self._mmc.getPropertyObject('Arduino-Switch', 'State').loadSequence(self._config.led_pattern) + self._mmc.getPropertyObject('Arduino-Switch', 'State').setValue(4) # seems essential to initiate serial communication + self._mmc.getPropertyObject('Arduino-Switch', 'State').startSequence() + + logging.info(f'{self.__str__()} setup_sequence loaded LED sequence at time: {time.time()}') + + print('Arduino loaded') + return super().setup_sequence(sequence) + + def exec_sequenced_event(self, event: 'SequencedEvent') -> Iterable['PImagePayload']: + """Execute a sequenced (triggered) event and return the image data. + + This method is not part of the PMDAEngine protocol (it is called by + `exec_event`, which *is* part of the protocol), but it is made public + in case a user wants to subclass this engine and override this method. + + custom override sequencerunning loop jgronemeyer24 + """ + + # if self._encoder is not None: + # self._encoder.start() + + n_events = len(event.events) + + t0 = event.metadata.get("runner_t0") or time.perf_counter() + event_t0_ms = (time.perf_counter() - t0) * 1000 + + # Start sequence + # Note that the overload of startSequenceAcquisition that takes a camera + # label does NOT automatically initialize a circular buffer. So if this call + # is changed to accept the camera in the future, that should be kept in mind. + self._mmc.startSequenceAcquisition( + n_events, + 0, # intervalMS + True, # stopOnOverflow + ) + logging.info(f'{self.__str__()} exec_sequenced_event with {n_events} events at t0 {t0}') + self.post_sequence_started(event) + + n_channels = self._mmc.getNumberOfCameraChannels() + count = 0 + iter_events = product(event.events, range(n_channels)) + # block until the sequence is done, popping images in the meantime + while self._mmc.isSequenceRunning(): + if remaining := self._mmc.getRemainingImageCount(): + yield self._next_seqimg_payload( + *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms + ) + count += 1 + else: + if count == n_events: + logging.debug(f'{self.__str__()} stopped MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + break + #self._mmc.stopSequenceAcquisition() Might be source of early cutoff by not allowing engine to save the rest of image in buffer + #time.sleep(0.001) #does not seem to optimize performance either way + + if self._mmc.isBufferOverflowed(): # pragma: no cover + logging.debug(f'OVERFLOW {self.__str__()}; Images in buffer: {self.mmcore.getRemainingImageCount()}') + raise MemoryError("Buffer overflowed") + + while remaining := self._mmc.getRemainingImageCount(): + logging.debug(f'{self.__str__()} Saving Remaining Images in buffer \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + yield self._next_seqimg_payload( + *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms + ) + count += 1 + + def teardown_sequence(self, sequence: useq.MDASequence) -> None: + """Perform any teardown required after the sequence has been executed.""" + logging.info(f'{self.__str__()} teardown_sequence at time: {time.time()}') + + # Stop the Arduino LED Sequence + self._mmc.getPropertyObject('Arduino-Switch', 'State').stopSequence() + # Stop the SerialWorker collecting encoder data + self._encoder.stop() + # Get and store the encoder data + self._wheel_data = self._encoder.get_data() + self._config.save_wheel_encoder_data(self._wheel_data) + self._config.save_configuration() + pass + + + +class PupilEngine(MDAEngine): + def __init__(self, mmc, use_hardware_sequencing: bool = True) -> None: + super().__init__(mmc) + self._mmc = mmc + self.use_hardware_sequencing = use_hardware_sequencing + self._config = None + self._encoder: SerialWorker = None + self._wheel_data = None + # TODO: add triggerable parameter + + def set_config(self, cfg) -> None: + self._config = cfg + self._encoder = cfg.encoder + + def exec_sequenced_event(self, event: 'SequencedEvent') -> Iterable['PImagePayload']: + """Execute a sequenced (triggered) event and return the image data. + + This method is not part of the PMDAEngine protocol (it is called by + `exec_event`, which *is* part of the protocol), but it is made public + in case a user wants to subclass this engine and override this method. + + custom override sequencerunning loop jgronemeyer24 + """ + n_events = len(event.events) + + t0 = event.metadata.get("runner_t0") or time.perf_counter() + event_t0_ms = (time.perf_counter() - t0) * 1000 + + # Start sequence + # Note that the overload of startSequenceAcquisition that takes a camera + # label does NOT automatically initialize a circular buffer. So if this call + # is changed to accept the camera in the future, that should be kept in mind. + self._mmc.startSequenceAcquisition( + n_events, + 0, # intervalMS # TODO: add support for this + True, # stopOnOverflow + ) + logging.info(f'{self.__str__()} exec_sequenced_event with {n_events} events at t0 {t0}') + self.post_sequence_started(event) + + n_channels = self._mmc.getNumberOfCameraChannels() + count = 0 + iter_events = product(event.events, range(n_channels)) + # block until the sequence is done, popping images in the meantime + while True: + if self._mmc.isSequenceRunning(): + if remaining := self._mmc.getRemainingImageCount(): + yield self._next_seqimg_payload( + *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms + ) + count += 1 + else: + if count == n_events: + logging.debug(f'{self.__str__()} stopped MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + break + #self._mmc.stopSequenceAcquisition() + time.sleep(0.001) + else: + break + + if self._mmc.isBufferOverflowed(): # pragma: no cover + logging.debug(f'OVERFLOW {self.__str__()} MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + raise MemoryError("Buffer overflowed") + + while remaining := self._mmc.getRemainingImageCount(): + logging.debug(f'{self.__str__()} Saving Remaining Images in buffer \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + yield self._next_seqimg_payload( + *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms + ) + count += 1 + + def teardown_sequence(self, sequence: useq.MDASequence) -> None: + """Perform any teardown required after the sequence has been executed.""" + logging.info(f'{self.__str__()} teardown_sequence at time: {time.time()}') + pass + + + +class DevEngine(MDAEngine): + + def __init__(self, mmc, use_hardware_sequencing: bool = True) -> None: + super().__init__(mmc) + self._mmc = mmc + self.use_hardware_sequencing = use_hardware_sequencing + self._config = None + self._encoder: SerialWorker = None + print('DevEngine initialized') + + def set_config(self, cfg) -> None: + self._config = cfg + + def exec_sequenced_event(self, event: 'SequencedEvent') -> Iterable['PImagePayload']: + """Execute a sequenced (triggered) event and return the image data. + + This method is not part of the PMDAEngine protocol (it is called by + `exec_event`, which *is* part of the protocol), but it is made public + in case a user wants to subclass this engine and override this method. + + custom override sequencerunning loop jgronemeyer24 + """ + n_events = len(event.events) + + t0 = event.metadata.get("runner_t0") or time.perf_counter() + event_t0_ms = (time.perf_counter() - t0) * 1000 + # Start sequence + # Note that the overload of startSequenceAcquisition that takes a camera + # label does NOT automatically initialize a circular buffer. So if this call + # is changed to accept the camera in the future, that should be kept in mind. + self._mmc.startSequenceAcquisition( + n_events, + 0, # intervalMS + True, # stopOnOverflow + ) + logging.info(f'{self.__str__()} exec_sequenced_event with {n_events} events at t0 {t0}') + self.post_sequence_started(event) + + n_channels = self._mmc.getNumberOfCameraChannels() + count = 0 + iter_events = product(event.events, range(n_channels)) + # block until the sequence is done, popping images in the meantime + while True: + if self._mmc.isSequenceRunning(): + if remaining := self._mmc.getRemainingImageCount(): + yield self._next_seqimg_payload( + *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms + ) + count += 1 + else: + if count == n_events: + logging.debug(f'{self.__str__()} stopped MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + self._mmc.stopSequenceAcquisition() + break + time.sleep(0.001) + else: + break + + if self._mmc.isBufferOverflowed(): # pragma: no cover + logging.debug(f'OVERFLOW {self.__str__()} MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + raise MemoryError("Buffer overflowed") + + while remaining := self._mmc.getRemainingImageCount(): + logging.debug(f'{self.__str__()} Saving Remaining Images in buffer \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') + yield self._next_seqimg_payload( + *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms + ) + count += 1 + + def teardown_sequence(self, sequence: useq.MDASequence) -> None: + """Perform any teardown required after the sequence has been executed.""" + logging.info(f'{self.__str__()} teardown_sequence at time: {time.time()}') + self._encoder.stop() + # Get and store the encoder data + self._wheel_data = self._encoder.get_data() + self._config.save_wheel_encoder_data(self._wheel_data) + self._config.save_configuration() + pass \ No newline at end of file diff --git a/mesofield/engines/__init__.py b/mesofield/engines/__init__.py deleted file mode 100644 index 4aa5c81..0000000 --- a/mesofield/engines/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -import pymmcore_plus -from pymmcore_plus.metadata import SummaryMetaV1 -import useq -import time -from itertools import product - -from typing import TYPE_CHECKING - -from typing import TYPE_CHECKING, Iterable, Mapping, Sequence -if TYPE_CHECKING: - from pymmcore_plus.core._sequencing import SequencedEvent - from pymmcore_plus.mda.metadata import FrameMetaV1 # type: ignore - from numpy.typing import NDArray - from useq import MDAEvent - PImagePayload = tuple[NDArray, MDAEvent, FrameMetaV1] - -from pymmcore_plus.mda import MDAEngine - -from .enginedev import DevEngine -from .pupilengine import PupilEngine -from .mesoengine import MesoEngine - -import logging -logging.basicConfig(filename='engine.log', level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') \ No newline at end of file diff --git a/mesofield/engines/enginedev.py b/mesofield/engines/enginedev.py deleted file mode 100644 index f033d27..0000000 --- a/mesofield/engines/enginedev.py +++ /dev/null @@ -1,83 +0,0 @@ -from . import * - -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from mesofield.io import SerialWorker - -class DevEngine(MDAEngine): - - def __init__(self, mmc: pymmcore_plus.CMMCorePlus, use_hardware_sequencing: bool = True) -> None: - super().__init__(mmc) - self._mmc = mmc - self.use_hardware_sequencing = use_hardware_sequencing - self._config = None - self._encoder: SerialWorker = None - print('DevEngine initialized') - - def set_config(self, cfg) -> None: - self._config = cfg - - def exec_sequenced_event(self, event: 'SequencedEvent') -> Iterable['PImagePayload']: - """Execute a sequenced (triggered) event and return the image data. - - This method is not part of the PMDAEngine protocol (it is called by - `exec_event`, which *is* part of the protocol), but it is made public - in case a user wants to subclass this engine and override this method. - - custom override sequencerunning loop jgronemeyer24 - """ - n_events = len(event.events) - - t0 = event.metadata.get("runner_t0") or time.perf_counter() - event_t0_ms = (time.perf_counter() - t0) * 1000 - # Start sequence - # Note that the overload of startSequenceAcquisition that takes a camera - # label does NOT automatically initialize a circular buffer. So if this call - # is changed to accept the camera in the future, that should be kept in mind. - self._mmc.startSequenceAcquisition( - n_events, - 0, # intervalMS # TODO: add support for this - True, # stopOnOverflow - ) - logging.info(f'{self.__str__()} exec_sequenced_event with {n_events} events at t0 {t0}') - self.post_sequence_started(event) - - n_channels = self._mmc.getNumberOfCameraChannels() - count = 0 - iter_events = product(event.events, range(n_channels)) - # block until the sequence is done, popping images in the meantime - while True: - if self._mmc.isSequenceRunning(): - if remaining := self._mmc.getRemainingImageCount(): - yield self._next_seqimg_payload( - *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms - ) - count += 1 - else: - if count == n_events: - logging.debug(f'{self.__str__()} stopped MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - self._mmc.stopSequenceAcquisition() - break - time.sleep(0.001) - else: - break - - if self._mmc.isBufferOverflowed(): # pragma: no cover - logging.debug(f'OVERFLOW {self.__str__()} MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - raise MemoryError("Buffer overflowed") - - while remaining := self._mmc.getRemainingImageCount(): - logging.debug(f'{self.__str__()} Saving Remaining Images in buffer \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - yield self._next_seqimg_payload( - *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms - ) - count += 1 - - def teardown_sequence(self, sequence: useq.MDASequence) -> None: - """Perform any teardown required after the sequence has been executed.""" - logging.info(f'{self.__str__()} teardown_sequence at time: {time.time()}') - self._encoder.stop() - # Get and store the encoder data - self._wheel_data = self._encoder.get_data() - self._config.save_wheel_encoder_data(self._wheel_data) - pass \ No newline at end of file diff --git a/mesofield/engines/mesoengine.py b/mesofield/engines/mesoengine.py deleted file mode 100644 index 7448519..0000000 --- a/mesofield/engines/mesoengine.py +++ /dev/null @@ -1,101 +0,0 @@ -from . import * -import logging -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from mesofield.io import DataManager, SerialWorker - -class MesoEngine(MDAEngine): - def __init__(self, mmc: pymmcore_plus.CMMCorePlus, use_hardware_sequencing: bool = True) -> None: - super().__init__(mmc) - self._mmc = mmc - self.use_hardware_sequencing = use_hardware_sequencing - self._config = None - self._encoder: SerialWorker = None - self._wheel_data = None - - def set_config(self, cfg) -> None: - self._config = cfg - self._encoder = cfg.encoder - - def setup_sequence(self, sequence: useq.MDASequence) -> SummaryMetaV1 | None: - """Perform setup required before the sequence is executed.""" - self._mmc.getPropertyObject('Arduino-Switch', 'State').loadSequence(self._config.led_pattern) - self._mmc.getPropertyObject('Arduino-Switch', 'State').setValue(4) # seems essential to initiate serial communication - self._mmc.getPropertyObject('Arduino-Switch', 'State').startSequence() - - logging.info(f'{self.__str__()} setup_sequence loaded LED sequence at time: {time.time()}') - - print('Arduino loaded') - return super().setup_sequence(sequence) - - def exec_sequenced_event(self, event: 'SequencedEvent') -> Iterable['PImagePayload']: - """Execute a sequenced (triggered) event and return the image data. - - This method is not part of the PMDAEngine protocol (it is called by - `exec_event`, which *is* part of the protocol), but it is made public - in case a user wants to subclass this engine and override this method. - - custom override sequencerunning loop jgronemeyer24 - """ - - # if self._encoder is not None: - # self._encoder.start() - - n_events = len(event.events) - - t0 = event.metadata.get("runner_t0") or time.perf_counter() - event_t0_ms = (time.perf_counter() - t0) * 1000 - - # Start sequence - # Note that the overload of startSequenceAcquisition that takes a camera - # label does NOT automatically initialize a circular buffer. So if this call - # is changed to accept the camera in the future, that should be kept in mind. - self._mmc.startSequenceAcquisition( - n_events, - 0, # intervalMS # TODO: add support for this - True, # stopOnOverflow - ) - logging.info(f'{self.__str__()} exec_sequenced_event with {n_events} events at t0 {t0}') - self.post_sequence_started(event) - - n_channels = self._mmc.getNumberOfCameraChannels() - count = 0 - iter_events = product(event.events, range(n_channels)) - # block until the sequence is done, popping images in the meantime - while self._mmc.isSequenceRunning(): - if remaining := self._mmc.getRemainingImageCount(): - yield self._next_seqimg_payload( - *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms - ) - count += 1 - else: - if count == n_events: - logging.debug(f'{self.__str__()} stopped MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - break - #self._mmc.stopSequenceAcquisition() Might be source of early cutoff by not allowing engine to save the rest of image in buffer - #time.sleep(0.001) #does not seem to optimize performance either way - - if self._mmc.isBufferOverflowed(): # pragma: no cover - logging.debug(f'OVERFLOW {self.__str__()}; Images in buffer: {self.mmcore.getRemainingImageCount()}') - raise MemoryError("Buffer overflowed") - - while remaining := self._mmc.getRemainingImageCount(): - logging.debug(f'{self.__str__()} Saving Remaining Images in buffer \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - yield self._next_seqimg_payload( - *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms - ) - count += 1 - - def teardown_sequence(self, sequence: useq.MDASequence) -> None: - """Perform any teardown required after the sequence has been executed.""" - logging.info(f'{self.__str__()} teardown_sequence at time: {time.time()}') - - # Stop the Arduino LED Sequence - self._mmc.getPropertyObject('Arduino-Switch', 'State').stopSequence() - # Stop the SerialWorker collecting encoder data - self._encoder.stop() - # Get and store the encoder data - self._wheel_data = self._encoder.get_data() - self._config.save_wheel_encoder_data(self._wheel_data) - pass - diff --git a/mesofield/engines/pupilengine.py b/mesofield/engines/pupilengine.py deleted file mode 100644 index a69beed..0000000 --- a/mesofield/engines/pupilengine.py +++ /dev/null @@ -1,83 +0,0 @@ -from . import * -import logging -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from mesofield.io import DataManager, SerialWorker - -class PupilEngine(MDAEngine): - def __init__(self, mmc: pymmcore_plus.CMMCorePlus, use_hardware_sequencing: bool = True) -> None: - super().__init__(mmc) - self._mmc = mmc - self.use_hardware_sequencing = use_hardware_sequencing - self._config = None - self._encoder: SerialWorker = None - self._wheel_data = None - - def set_config(self, cfg) -> None: - self._config = cfg - self._encoder = cfg.encoder - - def exec_sequenced_event(self, event: 'SequencedEvent') -> Iterable['PImagePayload']: - """Execute a sequenced (triggered) event and return the image data. - - This method is not part of the PMDAEngine protocol (it is called by - `exec_event`, which *is* part of the protocol), but it is made public - in case a user wants to subclass this engine and override this method. - - custom override sequencerunning loop jgronemeyer24 - """ - n_events = len(event.events) - - t0 = event.metadata.get("runner_t0") or time.perf_counter() - event_t0_ms = (time.perf_counter() - t0) * 1000 - - # Start sequence - # Note that the overload of startSequenceAcquisition that takes a camera - # label does NOT automatically initialize a circular buffer. So if this call - # is changed to accept the camera in the future, that should be kept in mind. - self._mmc.startSequenceAcquisition( - n_events, - 0, # intervalMS # TODO: add support for this - True, # stopOnOverflow - ) - logging.info(f'{self.__str__()} exec_sequenced_event with {n_events} events at t0 {t0}') - self.post_sequence_started(event) - - n_channels = self._mmc.getNumberOfCameraChannels() - count = 0 - iter_events = product(event.events, range(n_channels)) - # block until the sequence is done, popping images in the meantime - while True: - if self._mmc.isSequenceRunning(): - if remaining := self._mmc.getRemainingImageCount(): - yield self._next_seqimg_payload( - *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms - ) - count += 1 - else: - if count == n_events: - logging.debug(f'{self.__str__()} stopped MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - break - #self._mmc.stopSequenceAcquisition() - time.sleep(0.001) - else: - break - - if self._mmc.isBufferOverflowed(): # pragma: no cover - logging.debug(f'OVERFLOW {self.__str__()} MDA: \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - raise MemoryError("Buffer overflowed") - - while remaining := self._mmc.getRemainingImageCount(): - logging.debug(f'{self.__str__()} Saving Remaining Images in buffer \n{self._mmc} with \n{count} events and \n{remaining} remaining with \n{self._mmc.getRemainingImageCount()} images in buffer') - yield self._next_seqimg_payload( - *next(iter_events), remaining=remaining - 1, event_t0=event_t0_ms - ) - count += 1 - - def teardown_sequence(self, sequence: useq.MDASequence) -> None: - """Perform any teardown required after the sequence has been executed.""" - logging.info(f'{self.__str__()} teardown_sequence at time: {time.time()}') - pass - - - diff --git a/mesofield/gui/widgets/controller.py b/mesofield/gui/controller.py similarity index 95% rename from mesofield/gui/widgets/controller.py rename to mesofield/gui/controller.py index aa130b0..e5dfeb4 100644 --- a/mesofield/gui/widgets/controller.py +++ b/mesofield/gui/controller.py @@ -23,11 +23,11 @@ ) from PyQt6.QtGui import QImage, QPixmap -from pymmcore_plus import CMMCorePlus from typing import TYPE_CHECKING if TYPE_CHECKING: from mesofield.config import ExperimentConfig + from pymmcore_plus import CMMCorePlus class ConfigController(QWidget): """AcquisitionEngine object for the napari-mesofield plugin. @@ -81,13 +81,18 @@ class ConfigController(QWidget): def __init__(self, cfg): super().__init__() - self._mmc1: CMMCorePlus = cfg._cores[0] - self._mmc2: CMMCorePlus = cfg._cores[1] + self.mmcores = cfg._cores + # TODO: Add a check for the number of cores, and adjust rest of controller accordingly + self.config: ExperimentConfig = cfg + self._mmc1: CMMCorePlus = self.mmcores[0] + self._mmc2: CMMCorePlus = self.mmcores[1] + self.psychopy_process = None # Create main layout self.layout = QVBoxLayout(self) + self.setFixedWidth(500) # ==================================== GUI Widgets ===================================== # @@ -188,14 +193,12 @@ def _save_image(self, image: np.ndarray, dialog: QDialog): """Save the snapped image to the specified directory with a unique filename.""" # Generate a unique filename with a timestamp - timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") - filename = self.config._generate_unique_file_path(f"snapped_{timestamp}", bids_type='func') - file_path = os.path.join(self.config.bids_dir, filename) + file_path = self.config._generate_unique_file_path(suffix="snapped", extension="png", bids_type="func") # Save the image as a PNG file using matplotlib import matplotlib.pyplot as plt - plt.imsave(file_path + '.png', image, cmap='gray') + plt.imsave(file_path, image, cmap='gray') # Close the dialog dialog.accept() @@ -205,6 +208,8 @@ def record(self): from mesofield.io import CustomWriter import threading + # TODO: Add a check for the MDA sequence and pupil sequence + # TODO: add a triggerable parameter thread1 = threading.Thread(target=self._mmc1.run_mda, args=(self.config.meso_sequence,), kwargs={'output': CustomWriter(self.config.meso_file_path)}) thread2 = threading.Thread(target=self._mmc2.run_mda, args=(self.config.pupil_sequence,), kwargs={'output': CustomWriter(self.config.pupil_file_path)}) @@ -323,6 +328,7 @@ def _test_led(self): """ try: led_pattern = self.config.led_pattern + self.config.hardware.Dhyana.core.getPropertyObject('Arduino-Switch', 'State').loadSequence(led_pattern) self._mmc1.getPropertyObject('Arduino-Switch', 'State').loadSequence(led_pattern) self._mmc1.getPropertyObject('Arduino-Switch', 'State').setValue(4) # seems essential to initiate serial communication self._mmc1.getPropertyObject('Arduino-Switch', 'State').startSequence() diff --git a/mesofield/gui/maingui.py b/mesofield/gui/maingui.py index f954686..3bca36e 100644 --- a/mesofield/gui/maingui.py +++ b/mesofield/gui/maingui.py @@ -1,7 +1,5 @@ import os -from pymmcore_plus import CMMCorePlus - # Necessary modules for the IPython console from qtconsole.rich_jupyter_widget import RichJupyterWidget from qtconsole.inprocess import QtInProcessKernelManager @@ -15,14 +13,18 @@ from PyQt6.QtGui import QIcon -from mesofield.gui.widgets import MDA, ConfigController, EncoderWidget -from mesofield.config import ExperimentConfig +from mesofield.gui.mdagui import MDA +from mesofield.gui.controller import ConfigController +from mesofield.gui.speedplotter import EncoderWidget +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from mesofield.config import ExperimentConfig class MainWindow(QMainWindow): - def __init__(self, cfg: ExperimentConfig): + def __init__(self, cfg): super().__init__() self.setWindowTitle("Mesofield") - self.config: ExperimentConfig = cfg + self.config = cfg window_icon = QIcon(os.path.join(os.path.dirname(__file__), "Mesofield_icon.png")) self.setWindowIcon(window_icon) @@ -84,7 +86,7 @@ def metrics(self): metrics_df = calculate_metrics(wheel_df, stim_df) print(metrics_df) - def initialize_console(self, cfg: ExperimentConfig): + def initialize_console(self, cfg): """Initialize the IPython console and embed it into the application.""" # Create an in-process kernel self.kernel_manager = QtInProcessKernelManager() @@ -103,13 +105,20 @@ def initialize_console(self, cfg: ExperimentConfig): # Expose variables to the console's namespace self.kernel.shell.push({ - 'mda': self.acquisition_gui.mda, + #'mda': self.acquisition_gui.mda, 'self': self, 'config': cfg, # Optional, so you can use 'self' directly in the console }) #----------------------------------------------------------------------------# + def closeEvent(self, event): + if hasattr(self.config.hardware.cameras[0], 'backend'): + if self.config.hardware.cameras[0].backend == 'opencv': + self.config.hardware.cameras[0].thread.stop() + self.config.hardware.shutdown() + event.accept() + #============================== Private Methods =============================# def _on_end(self) -> None: """Called when the MDA is finished.""" @@ -118,14 +127,6 @@ def _on_end(self) -> None: def _update_config(self, config): self.config: ExperimentConfig = config - self._refresh_mda_gui() - self._refresh_save_gui() - - def _refresh_mda_gui(self): - self.acquisition_gui.mda.setValue(self.config.meso_sequence) - - def _refresh_save_gui(self): - self.acquisition_gui.mda.save_info.setValue({'save_dir': str(self.config.bids_dir), 'save_name': str(self.config.meso_file_path), 'format': 'ome-tiff', 'should_save': True}) def _on_pause(self, state: bool) -> None: """Called when the MDA is paused.""" diff --git a/mesofield/gui/mdagui.py b/mesofield/gui/mdagui.py new file mode 100644 index 0000000..9f41464 --- /dev/null +++ b/mesofield/gui/mdagui.py @@ -0,0 +1,187 @@ +from pymmcore_plus import CMMCorePlus + +from PyQt6.QtWidgets import ( + QGroupBox, + QHBoxLayout, + QVBoxLayout, + QWidget, + QSizePolicy +) + +from pymmcore_widgets import ( + MDAWidget, + ExposureWidget, + LiveButton, + SnapButton, +) + +from mesofield.io.writer import CustomWriter +from mesofield.gui.viewer import ImagePreview, InteractivePreview + +class CustomMDAWidget(MDAWidget): + def run_mda(self) -> None: + """Run the MDA sequence experiment.""" + # in case the user does not press enter after editing the save name. + self.save_info.save_name.editingFinished.emit() + + sequence = self.value() + + # technically, this is in the metadata as well, but isChecked is more direct + if self.save_info.isChecked(): + save_path = self._update_save_path_from_metadata( + sequence, update_metadata=True + ) + else: + save_path = None + + # run the MDA experiment asynchronously + self._mmc.run_mda(sequence, output=CustomWriter(save_path)) + +class MDA(QWidget): + """ + The `MDAWidget` provides a GUI to construct a `useq.MDASequence` object. + This object describes a full multi-dimensional acquisition; + In this example, we set the `MDAWidget` parameter `include_run_button` to `True`, + meaning that a `run` button is added to the GUI. When pressed, a `useq.MDASequence` + is first built depending on the GUI values and is then passed to the + `CMMCorePlus.run_mda` to actually execute the acquisition. + For details of the corresponding schema and methods, see + https://github.com/pymmcore-plus/useq-schema and + https://github.com/pymmcore-plus/pymmcore-plus. + + """ + + def __init__(self, config) -> None: + """ + + The layout adapts the viewer based on the number of cores: + + Single Core Layout: + + +----------------------------------------+ + | Live Viewer | + | +-----------------+-----------------+ | + | | [Snap Button] | [Live Button} | | + | +-----------------+-----------------+ | + | | | | + | | Image Preview | | + | | | | + | +-----------------------------------+ | + +----------------------------------------+ + + Dual Core Layout: + + +-----------------------------------------------+ + | Live Viewer | + | +---------------------+-------------------+ | + | | Core 1 | Core 2 | | + | +---------------------+-------------------+ | + | | [Buttons] | [Buttons] | | + | +---------------------+-------------------+ | + | | | | | + | | Image Preview 1 | Image Preview 2 | | + | | | | | + | +---------------------+-------------------+ | + +-----------------------------------------------+ + + """ + super().__init__() + # get the CMMCore instance and load the default config + self.mmcores: tuple[CMMCorePlus, CMMCorePlus] = config._cores + + # instantiate the MDAWidget + #self.mda = MDAWidget(mmcore=self.mmcores[0]) + # ----------------------------------Auto-set MDASequence and save_info----------------------------------# + #self.mda.setValue(config.pupil_sequence) + #self.mda.save_info.setValue({'save_dir': r'C:/dev', 'save_name': 'file', 'format': 'ome-tiff', 'should_save': True}) + # -------------------------------------------------------------------------------------------------------# + self.setLayout(QHBoxLayout()) + + live_viewer = QGroupBox() + live_viewer.setLayout(QVBoxLayout()) + live_viewer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + buttons = QGroupBox() + buttons.setLayout(QHBoxLayout()) + + if len(config._cores) > 0: + if len(self.mmcores) == 1: + '''Single Core Layout''' + + self.mmc: CMMCorePlus = self.mmcores[0] + self.preview = InteractivePreview(mmcore=self.mmc)#, parent=self.mda) + self.snap_button = SnapButton(mmcore=self.mmc) + self.live_button = LiveButton(mmcore=self.mmc) + self.exposure = ExposureWidget(mmcore=self.mmc) + + #==================== Viewer Layout ===================# + core_layout = QGroupBox(title=f"{self.__module__}.{self.__class__.__name__}: Live Viewer") + core_layout.setLayout(QVBoxLayout()) + + viewer_layout = QVBoxLayout() + buttons = QGroupBox(f"{self.mmc.getCameraDevice()}") # f-string is needed to avoid TypeError: unable to convert a Python 'builtin_function_or_method' object to a C++ 'QString' instance + buttons.setLayout(QHBoxLayout()) + buttons.layout().addWidget(self.snap_button) + buttons.layout().addWidget(self.live_button) + viewer_layout.addWidget(buttons) + viewer_layout.addWidget(self.preview) + + core_layout.layout().addLayout(viewer_layout) + + #self.layout().addWidget(self.mda) + self.layout().addWidget(core_layout) + + elif len(self.mmcores) == 2: + '''Dual Core Layout''' + + self.preview1 = ImagePreview(mmcore=self.mmcores[0])#, parent=self.mda) + self.preview2 = ImagePreview(mmcore=self.mmcores[1])#, parent=self.mda) + snap_button1 = SnapButton(mmcore=self.mmcores[0]) + live_button1 = LiveButton(mmcore=self.mmcores[0]) + snap_button2 = SnapButton(mmcore=self.mmcores[1]) + live_button2 = LiveButton(mmcore=self.mmcores[1]) + + #==================== 2 Viewer Layout ===================# + cores_groupbox = QGroupBox(title=f"{self.__module__}.{self.__class__.__name__}: Live Viewer") + cores_groupbox.setLayout(QHBoxLayout()) + + #-------------------- Core 1 Viewer ---------------------# + core1_layout = QVBoxLayout() + + buttons1 = QGroupBox(title=f"{self.mmcores[0].getCameraDevice()}") # f-string is needed to avoid TypeError: unable to convert a Python 'builtin_function_or_method' object to a C++ 'QString' instance + buttons1.setLayout(QHBoxLayout()) + buttons1.layout().addWidget(snap_button1) + buttons1.layout().addWidget(live_button1) + core1_layout.addWidget(buttons1) + core1_layout.addWidget(self.preview1) + + #-------------------- Core 2 Viewer ---------------------# + core2_layout = QVBoxLayout() + + buttons2 = QGroupBox(title=f"{self.mmcores[1].getCameraDevice()}") # f-string is needed to avoid TypeError: unable to convert a Python 'builtin_function_or_method' object to a C++ 'QString' instance + buttons2.setLayout(QHBoxLayout()) + buttons2.layout().addWidget(snap_button2) + buttons2.layout().addWidget(live_button2) + core2_layout.addWidget(buttons2) + core2_layout.addWidget(self.preview2) + + #================ Add Widgets to Layout =================# + cores_groupbox.layout().addLayout(core1_layout) + cores_groupbox.layout().addLayout(core2_layout) + + #self.layout().addWidget(self.mda) + self.layout().addWidget(cores_groupbox) + + # else:# config.hardware.cam_backends == "opencv": + # #self.thread = config.hardware.arducam.thread + # core_layout = QGroupBox("Live Viewer") + # core_layout.setLayout(QVBoxLayout()) + + # self.preview = InteractivePreview(parent=self, image_payload=self.thread.image_ready) + + # viewer_layout = QVBoxLayout() + # viewer_layout.addWidget(self.preview) + + # core_layout.layout().addLayout(viewer_layout) + # self.layout().addWidget(core_layout) + + # self.thread.start() diff --git a/mesofield/gui/widgets/speedplotter.py b/mesofield/gui/speedplotter.py similarity index 90% rename from mesofield/gui/widgets/speedplotter.py rename to mesofield/gui/speedplotter.py index 7b51b39..2d277f4 100644 --- a/mesofield/gui/widgets/speedplotter.py +++ b/mesofield/gui/speedplotter.py @@ -1,8 +1,4 @@ -# encoder_widget.py - -import time from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QPushButton -from PyQt6.QtCore import QTimer import pyqtgraph as pg from typing import TYPE_CHECKING @@ -13,15 +9,17 @@ class EncoderWidget(QWidget): def __init__(self, cfg): super().__init__() self.config = cfg - self.encoder: SerialWorker = cfg.encoder + self.encoder: encoder = cfg.hardware.encoder self.init_ui() self.init_data() + self.setFixedHeight(300) def init_ui(self): self.layout = QVBoxLayout() # Status label to show connection status self.status_label = QLabel("Click 'Start Live View' to begin.") + self.info_label = QLabel(f'Viewing data from {self.encoder} at Port: {self.encoder.serial_port} | Baud: {self.encoder.baud_rate} | CPR: {self.encoder.cpr} | Diameter (mm): {self.encoder.diameter_mm}') self.start_button = QPushButton("Start Live View") self.start_button.setCheckable(True) self.plot_widget = pg.PlotWidget() @@ -30,6 +28,7 @@ def init_ui(self): self.start_button.setEnabled(True) self.layout.addWidget(self.status_label) + self.layout.addWidget(self.info_label) self.layout.addWidget(self.start_button) self.layout.addWidget(self.plot_widget) self.setLayout(self.layout) diff --git a/mesofield/gui/widgets/viewer.py b/mesofield/gui/viewer.py similarity index 69% rename from mesofield/gui/widgets/viewer.py rename to mesofield/gui/viewer.py index 3b0373b..b9b0a0b 100644 --- a/mesofield/gui/widgets/viewer.py +++ b/mesofield/gui/viewer.py @@ -320,3 +320,142 @@ def cmap(self, cmap: str = "grayscale") -> None: The colormap to use. """ self._cmap = cmap + + +import pyqtgraph as pg +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, QTimer +import numpy as np +from threading import Lock +from contextlib import suppress +from typing import Tuple, Union, Literal +from pymmcore_plus import CMMCorePlus + +class InteractivePreview(pg.ImageView): + def __init__(self, parent=None, mmcore=None, use_with_mda=True, image_payload=None): + super().__init__(parent=parent) + self._mmcore: CMMCorePlus = mmcore + self._use_with_mda = use_with_mda + self._clims: Union[Tuple[float, float], Literal["auto"]] = "auto" + self._current_frame = np.zeros((512, 512), dtype=np.uint8) + self._display_image(self._current_frame) + self._cmap: str = "grayscale" + self._current_frame = None + self._frame_lock = Lock() + + if image_payload is not None: + image_payload.connect(self._on_image_payload) + + if self._mmcore is not None: + self._mmcore.events.imageSnapped.connect(self._on_image_snapped) + self._mmcore.events.continuousSequenceAcquisitionStarted.connect(self._on_streaming_start) + self._mmcore.events.sequenceAcquisitionStarted.connect(self._on_streaming_start) + self._mmcore.events.sequenceAcquisitionStopped.connect(self._on_streaming_stop) + self._mmcore.events.exposureChanged.connect(self._on_exposure_changed) + + enev = self._mmcore.mda.events + enev.frameReady.connect(self._on_image_payload, type=Qt.ConnectionType.QueuedConnection) + if self._use_with_mda: + self._mmcore.mda.events.frameReady.connect(self._on_frame_ready) + + self.streaming_timer = QTimer(parent=self) + self.streaming_timer.setTimerType(Qt.TimerType.PreciseTimer) + self.streaming_timer.setInterval(10) + self.streaming_timer.timeout.connect(self._on_streaming_timeout) + + self.destroyed.connect(self._disconnect) + + def _disconnect(self) -> None: + if self._mmcore: + ev = self._mmcore.events + with suppress(TypeError): + ev.imageSnapped.disconnect() + ev.continuousSequenceAcquisitionStarted.disconnect() + ev.sequenceAcquisitionStarted.disconnect() + ev.sequenceAcquisitionStopped.disconnect() + ev.exposureChanged.disconnect() + enev = self._mmcore.mda.events + with suppress(TypeError): + enev.frameReady.disconnect() + + def _on_streaming_start(self) -> None: + if not self.streaming_timer.isActive(): + self.streaming_timer.start() + + def _on_streaming_stop(self) -> None: + if not self._mmcore.isSequenceRunning(): + self.streaming_timer.stop() + + def _on_exposure_changed(self, device: str, value: str) -> None: + exposure = self._mmcore.getExposure() or 10 + self.streaming_timer.setInterval(int(exposure) or 10) + + def _on_frame_ready(self, img: np.ndarray) -> None: + with self._frame_lock: + self._current_frame = img + + def _on_streaming_timeout(self) -> None: + frame = None + if not self._mmcore.mda.is_running(): + with suppress(RuntimeError, IndexError): + frame = self._mmcore.getLastImage() + else: + with self._frame_lock: + if self._current_frame is not None: + frame = self._current_frame + self._current_frame = None + if frame is not None: + self._display_image(frame) + + def _on_image_snapped(self, img: np.ndarray) -> None: + with self._frame_lock: + self._current_frame = img + self._display_image(img) + + def _on_image_payload(self, img: np.ndarray) -> None: + #img = self._adjust_image_data(img) + self.setImage(img.T, + autoHistogramRange=False, + autoRange=False, + levelMode='mono', + autoLevels=(self._clims == "auto"), + ) + + def _display_image(self, img: np.ndarray) -> None: + if img is None: + return + img = self._adjust_image_data(img) + self.setImage(img.T, + autoHistogramRange=False, + autoRange=False, + levelMode='mono', + autoLevels=(self._clims == "auto"), + ) + + def _adjust_image_data(self, img: np.ndarray) -> np.ndarray: + img = img.astype(np.float32, copy=False) + if self._clims == "auto": + min_val, max_val = np.min(img), np.max(img) + else: + min_val, max_val = self._clims + scale = 255.0 / (max_val - min_val) if max_val != min_val else 255.0 + img = np.clip((img - min_val) * scale, 0, 255).astype(np.uint8, copy=False) + return img + + # @property + # def clims(self) -> Union[Tuple[float, float], Literal["auto"]]: + # return self._clims + + # @clims.setter + # def clims(self, clims: Union[Tuple[float, float], Literal["auto"]] = "auto") -> None: + # self._clims = clims + # if self._current_frame is not None: + # self._display_image(self._current_frame) + + # @property + # def cmap(self) -> str: + # return self._cmap + + # @cmap.setter + # def cmap(self, cmap: str = "grayscale") -> None: + # self._cmap = cmap \ No newline at end of file diff --git a/mesofield/gui/widgets/__init__.py b/mesofield/gui/widgets/__init__.py deleted file mode 100644 index 2d10224..0000000 --- a/mesofield/gui/widgets/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .controller import ConfigController -from .mdagui import CustomMDAWidget, MDA -from .viewer import ImagePreview -from .speedplotter import EncoderWidget diff --git a/mesofield/gui/widgets/mdagui.py b/mesofield/gui/widgets/mdagui.py deleted file mode 100644 index 82596d1..0000000 --- a/mesofield/gui/widgets/mdagui.py +++ /dev/null @@ -1,161 +0,0 @@ -from pymmcore_plus import CMMCorePlus - -from PyQt6.QtWidgets import ( - QGroupBox, - QHBoxLayout, - QVBoxLayout, - QWidget, -) - -from pymmcore_widgets import ( - MDAWidget, - ExposureWidget, - LiveButton, - SnapButton, -) - -from mesofield.config import ExperimentConfig -from mesofield.io.writer import CustomWriter -from .viewer import ImagePreview - -class CustomMDAWidget(MDAWidget): - def run_mda(self) -> None: - """Run the MDA sequence experiment.""" - # in case the user does not press enter after editing the save name. - self.save_info.save_name.editingFinished.emit() - - sequence = self.value() - - # technically, this is in the metadata as well, but isChecked is more direct - if self.save_info.isChecked(): - save_path = self._update_save_path_from_metadata( - sequence, update_metadata=True - ) - else: - save_path = None - - # run the MDA experiment asynchronously - self._mmc.run_mda(sequence, output=CustomWriter(save_path)) - -class MDA(QWidget): - """ - The `MDAWidget` provides a GUI to construct a `useq.MDASequence` object. - This object describes a full multi-dimensional acquisition; - In this example, we set the `MDAWidget` parameter `include_run_button` to `True`, - meaning that a `run` button is added to the GUI. When pressed, a `useq.MDASequence` - is first built depending on the GUI values and is then passed to the - `CMMCorePlus.run_mda` to actually execute the acquisition. - For details of the corresponding schema and methods, see - https://github.com/pymmcore-plus/useq-schema and - https://github.com/pymmcore-plus/pymmcore-plus. - - """ - - def __init__(self, config: ExperimentConfig) -> None: - """ - - The layout adapts the viewer based on the number of cores: - - Single Core Layout: - - +-------------------+------------------------------------------------+ - | | Live Viewer | - | | +-----------------+------------------------+ | - | | | [Snap Button] | [Live Button] | | - | MDA | +-----------------+------------------------+ | - | | | | | - | | | Image Preview | | - | | | | | - | | +------------------------------------------+ | - +-------------------+------------------------------------------------+ - - Dual Core Layout: - - +-------------------+-------------------------------------------------+ - | | Live Viewer | - | | +-----------------------+-------------------+ | - | | | Core 1 | Core 2 | | - | | +-----------------------+-------------------+ | - | | | [Buttons] | [Buttons] | | - | MDA | +-----------------------+-------------------+ | - | | | | | | - | | | Image Preview 1 | Image Preview 2 | | - | | | | | | - | | +-----------------------+-------------------+ | - +-------------------+-------------------------------------------------+ - - """ - super().__init__() - # get the CMMCore instance and load the default config - self.mmcores: tuple[CMMCorePlus, CMMCorePlus] = config._cores - - # instantiate the MDAWidget - self.mda = CustomMDAWidget(mmcore=self.mmcores[0]) - # ----------------------------------Auto-set MDASequence and save_info----------------------------------# - self.mda.setValue(config.pupil_sequence) - self.mda.save_info.setValue({'save_dir': r'C:/dev', 'save_name': 'file', 'format': 'ome-tiff', 'should_save': True}) - # -------------------------------------------------------------------------------------------------------# - self.setLayout(QHBoxLayout()) - - live_viewer = QGroupBox() - live_viewer.setLayout(QVBoxLayout()) - buttons = QGroupBox() - buttons.setLayout(QHBoxLayout()) - - if len(self.mmcores) == 1: - '''Single Core Layout''' - - self.mmc = self.mmcores[0] - self.preview = ImagePreview(mmcore=self.mmc, parent=self.mda) - self.snap_button = SnapButton(mmcore=self.mmc) - self.live_button = LiveButton(mmcore=self.mmc) - self.exposure = ExposureWidget(mmcore=self.mmc) - - buttons.layout().addWidget(self.snap_button) - buttons.layout().addWidget(self.live_button) - live_viewer.layout().addWidget(self.preview) - - self.layout().addWidget(self.mda) - self.layout().addWidget(live_viewer) - - elif len(self.mmcores) == 2: - '''Dual Core Layout''' - - self.preview1 = ImagePreview(mmcore=self.mmcores[0], parent=self.mda) - self.preview2 = ImagePreview(mmcore=self.mmcores[1], parent=self.mda) - snap_button1 = SnapButton(mmcore=self.mmcores[0]) - live_button1 = LiveButton(mmcore=self.mmcores[0]) - snap_button2 = SnapButton(mmcore=self.mmcores[1]) - live_button2 = LiveButton(mmcore=self.mmcores[1]) - - #==================== 2 Viewer Layout ===================# - cores_groupbox = QGroupBox("Live Viewer") - cores_groupbox.setLayout(QHBoxLayout()) - - #-------------------- Core 1 Viewer ---------------------# - core1_layout = QVBoxLayout() - - buttons1 = QGroupBox("Core 1") - buttons1.setLayout(QHBoxLayout()) - buttons1.layout().addWidget(snap_button1) - buttons1.layout().addWidget(live_button1) - core1_layout.addWidget(buttons1) - core1_layout.addWidget(self.preview1) - - #-------------------- Core 2 Viewer ---------------------# - core2_layout = QVBoxLayout() - - buttons2 = QGroupBox("Core 2") - buttons2.setLayout(QHBoxLayout()) - buttons2.layout().addWidget(snap_button2) - buttons2.layout().addWidget(live_button2) - core2_layout.addWidget(buttons2) - core2_layout.addWidget(self.preview2) - - #================ Add Widgets to Layout =================# - cores_groupbox.layout().addLayout(core1_layout) - cores_groupbox.layout().addLayout(core2_layout) - - self.layout().addWidget(self.mda) - self.layout().addWidget(cores_groupbox) - diff --git a/mesofield/io/arducam.py b/mesofield/io/arducam.py new file mode 100644 index 0000000..eff3afe --- /dev/null +++ b/mesofield/io/arducam.py @@ -0,0 +1,66 @@ +""" +Adapted from https://gist.github.com/docPhil99/ca4da12c9d6f29b9cea137b617c7b8b1 + +""" + +from PyQt6.QtWidgets import QWidget, QApplication, QVBoxLayout +import sys +#import cv2 +from PyQt6.QtCore import pyqtSignal, QThread +import numpy as np + +class VideoThread(QThread): + image_ready = pyqtSignal(np.ndarray) + + def __init__(self): + super().__init__() + self._run_flag = True + + def run(self): + # capture from web cam + capture = cv2.VideoCapture(0) + while self._run_flag: + ret, img = capture.read() + if ret: + self.image_ready.emit(img) + # shut down capture system + capture.release() + + def stop(self): + """Sets run flag to False and waits for thread to finish""" + self._run_flag = False + self.wait() + + +class App(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("Qt live label demo") + + # Create a vertical layout + vbox = QVBoxLayout(self) + self.setLayout(vbox) + + # Create the video capture thread + self.thread = VideoThread() + + # Create an ImagePreview (PlotWidget) and pass the external signal + self.image_preview = ImagePreview( + parent=self, + mmcore=None, # Set appropriately or leave None + image_payload=self.thread.image_ready + ) + vbox.addWidget(self.image_preview) + + # Start the thread + self.thread.start() + + def closeEvent(self, event): + self.thread.stop() + event.accept() + +if __name__=="__main__": + app = QApplication(sys.argv) + a = App() + a.show() + sys.exit(app.exec()) \ No newline at end of file diff --git a/mesofield/io/encoder.py b/mesofield/io/encoder.py index 913cc3e..339b877 100644 --- a/mesofield/io/encoder.py +++ b/mesofield/io/encoder.py @@ -2,15 +2,11 @@ import time import math from queue import Queue - +import serial from PyQt6.QtCore import pyqtSignal, QThread from mesofield.io import DataManager -from typing import TYPE_CHECKING -if TYPE_CHECKING: - from mesofield.config import ExperimentConfig - class SerialWorker(QThread): # ===================== PyQt Signals ===================== # @@ -21,18 +17,15 @@ class SerialWorker(QThread): # ======================================================== # def __init__(self, - serial_port: str = None, - baud_rate: int = None, - sample_interval: int = None, - wheel_diameter: float = None, - cpr: int = None, - development_mode=False): + serial_port: str, + baud_rate: int, + sample_interval: int, + wheel_diameter: float, + cpr: int, + development_mode: bool = True): super().__init__() - self.data_manager = DataManager() - self.data_queue: Queue = self.data_manager.data_queue - self.development_mode = development_mode self.serial_port = serial_port @@ -63,13 +56,12 @@ def run(self): else: self.run_serial_mode() finally: - print("Simulation stopped.") + print("Encoder Stream stopped.") def run_serial_mode(self): try: - import serial self.arduino = serial.Serial(self.serial_port, self.baud_rate, timeout=0.1) - self.arduino.flushInput() # Flush any existing input + #self.arduino.flushInput() # Flush any existing input print("Serial port opened.") except serial.SerialException as e: print(f"Serial connection error: {e}") @@ -81,8 +73,6 @@ def run_serial_mode(self): data = self.arduino.readline().decode('utf-8').strip() if data: clicks = int(data) - self.stored_data.append(clicks) # Store data for later retrieval - self.data_queue.put(clicks) # Store data in the DataManager queue for access by other threads self.serialDataReceived.emit(clicks) # Emit PyQt signal for real-time plotting self.process_data(clicks) except ValueError: @@ -107,7 +97,6 @@ def run_development_mode(self): # Emit signals, store data, and push to the queue self.stored_data.append(clicks) # Store data for later retrieval - self.data_queue.put(clicks) # Store data in the DataManager queue for access by other threads self.serialDataReceived.emit(clicks) # Emit PyQt signal for real-time plotting # Optionally, simulate processing the data for speed calculation @@ -176,13 +165,7 @@ def __repr__(self): module_name = self.__module__ parent_classes = [cls.__name__ for cls in self.__class__.__bases__] return ( - f"<{class_name} {parent_classes} from {module_name}> \nAttributes: \n" - f"Serial Port: {self.serial_port}\n" - f"Baud Rate: {self.baud_rate}\n" - f"Sample Interval (ms): {self.sample_interval_ms}\n" - f"Wheel Diameter (mm): {self.diameter_mm}\n" - f"CPR: {self.cpr}\n" - f"Development Mode: {self.development_mode}\n" + f"<{class_name} {parent_classes} from {module_name}>" ) # Usage Example: diff --git a/mesofield/params.json b/mesofield/params.json deleted file mode 100644 index 454ce65..0000000 --- a/mesofield/params.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "_widefield_micromanager_path": "C:/Program Files/Micro-Manager-2.0gamma", - "_thorcam_micromanager_path": "C:/Program Files/Micro-Manager-thor", - "_memory_buffer_size": 10000, - "_dhyana_fps": 49, - "_thorcam_fps": 30, - "encoder": { - "type": "wheel", - "port": "COM4", - "baudrate": 57600, - "cpr": 2400, - "diameter_mm": 80, - "sample_interval_ms": 20 - }, - "thorcam": { - "name": "ThorCam", - "configuration_path": "C:/Program Files/Micro-Manager-2.0/ThorCam.cfg", - "use_hardware_sequencing": true, - "roi": [440, 305, 509, 509], - "properties": { - "Exposure": "20" - } - }, - "widefield": { - "name": "Dhyana", - "configuration_path": "C:/Program Files/Micro-Manager-2.0/mm-sipefield.cfg", - "use_hardware_sequencing": true, - "trigger_port": 2, - "properties": { - "Arduino-Switch": "Sequence", - "Arduino-Shutter": "OnOff", - "Core": "Shutter" - } - } -} - \ No newline at end of file diff --git a/mesofield/startup.py b/mesofield/startup.py deleted file mode 100644 index a8a761c..0000000 --- a/mesofield/startup.py +++ /dev/null @@ -1,258 +0,0 @@ -import useq -import logging - - -from dataclasses import dataclass, field -from typing import Optional, Dict, List -import json - -from pymmcore_plus import CMMCorePlus -from pymmcore_plus.mda import MDAEngine - -from mesofield.engines import DevEngine, MesoEngine, PupilEngine -from mesofield.io.encoder import SerialWorker - -# Disable pymmcore-plus logger -package_logger = logging.getLogger('pymmcore-plus') - -# Set the logging level to CRITICAL to suppress lower-level logs -package_logger.setLevel(logging.CRITICAL) - -@dataclass -class Encoder: - type: str = 'dev' - port: str = 'COM4' - baudrate: int = 57600 - cpr: int = 2400 - diameter_mm: float = 80 - sample_interval_ms: int = 20 - reverse: int = -1 - encoder: Optional[SerialWorker] = None - - def __post_init__(self): - # Create a SerialWorkerData instance with Encoder configurations - self.encoder = SerialWorker( - serial_port=self.port, - baud_rate=self.baudrate, - sample_interval=self.sample_interval_ms, - wheel_diameter=self.diameter_mm, - cpr=self.cpr, - development_mode=True if self.type == 'dev' else False, - ) - - def __repr__(self): - return ( - f"Encoder(\n" - f" encoder={repr(self.encoder)}" - ) - - def get_data(self): - return self.encoder.get_data() - -@dataclass -class Engine: - ''' Engine dataclass to create different engine types for MDA ''' - name: str - use_hardware_sequencing: bool = True - - def create_engine(self, mmcore: CMMCorePlus): - # Create an appropriate engine based on the given name - if self.name == 'DevEngine': - return DevEngine(mmcore, use_hardware_sequencing=self.use_hardware_sequencing) - elif self.name == 'MesoEngine': - return MesoEngine(mmcore, use_hardware_sequencing=self.use_hardware_sequencing) - elif self.name == 'PupilEngine': - return PupilEngine(mmcore, use_hardware_sequencing=self.use_hardware_sequencing) - else: - raise ValueError(f"Unknown engine type: {self.name}") - -@dataclass -class Core: - ''' MicroManager Core dataclass to manage MicroManager properties, configurations, and MDA engines - ''' - name: str - configuration_path: Optional[str] = None - memory_buffer_size: int = 2000 - use_hardware_sequencing: bool = True - roi: Optional[List[int]] = None # (x, y, width, height) - trigger_port: Optional[int] = None - properties: Dict[str, str] = field(default_factory=dict) - core: Optional[CMMCorePlus] = field(default=None, init=False) - engine: Optional[MDAEngine] = None - - def __repr__(self): - return ( - f"Core:\n" - f" name='{self.name}',\n" - f" core={repr(self.core)},\n" - f" engine={repr(self.engine)}\n" - f" configuration_path='{self.configuration_path}',\n" - f" memory_buffer_size={self.memory_buffer_size},\n" - f" properties={self.properties}\n\n" - ) - - def _load_core(self): - ''' Load the core with specified configurations ''' - self.core = CMMCorePlus() - if self.configuration_path: - print(f"Loading {self.name} MicroManager configuration from {self.configuration_path}...") - self.core.loadSystemConfiguration(self.configuration_path) - else: - print(f"Loading {self.name} MicroManager DEMO configuration...") - self.core.loadSystemConfiguration() - # Set memory buffer size - self.core.setCircularBufferMemoryFootprint(self.memory_buffer_size) - # Load additional properties and parameters for the core - #self.load_properties() - if self.configuration_path: - self._load_additional_params() - # Attach the specified engine to the core if available - if self.engine: - self._load_engine() - - def _load_engine(self): - ''' Load the engine for the core ''' - self.engine = self.engine.create_engine(self.core) - self.core.mda.set_engine(self.engine) - logging.info(f"Core loaded for {self.name} from {self.configuration_path} with memory footprint: {self.core.getCircularBufferMemoryFootprint()} MB and engine {self.engine}") - - def load_properties(self): - # Load specific properties into the core - for prop, value in self.properties.items(): - self.core.setProperty('Core', prop, value) - logging.info(f"Properties loaded for core {self.name}") - - def _load_additional_params(self): - ''' Load additional parameters that are specific to certain cores ''' - # ========================== ThorCam Parameters ========================== # - if self.name == 'ThorCam': - # Specific settings for ThorCam - if self.roi: - self.core.setROI(self.name, *self.roi) - self.core.setExposure(20) # Set default exposure - self.core.mda.engine.use_hardware_sequencing = self.use_hardware_sequencing - logging.info(f"Additional parameters loaded for {self.name}") - - # ========================== Dhyana Parameters ========================== # - elif self.name == 'Dhyana': - if self.trigger_port is not None: - self.core.setProperty('Dhyana', 'Output Trigger Port', str(self.trigger_port)) - # Configure Arduino switches and shutters for Dhyana setup - self.core.setProperty('Arduino-Switch', 'Sequence', 'On') - self.core.setProperty('Arduino-Shutter', 'OnOff', '1') - self.core.setProperty('Core', 'Shutter', 'Arduino-Shutter') - # Set channel group for Dhyana - self.core.setChannelGroup('Channel') - self.core.mda.engine.use_hardware_sequencing = self.use_hardware_sequencing - logging.info(f"Additional parameters loaded for {self.name}") - -@dataclass -class Startup: - ''' Startup dataclass for managing the initial configuration of cores and other components ''' - - _widefield_micromanager_path: str = 'C:/Program Files/Micro-Manager-2.0gamma' - _thorcam_micromanager_path: str = 'C:/Program Files/Micro-Manager-thor' - _memory_buffer_size: int = 10000 - _dhyana_fps: int = 49 - _thorcam_fps: int = 30 - - encoder: Encoder = field(default_factory=lambda: Encoder()) - - widefield: Core = field(default_factory=lambda: Core( - name='DevCam', - memory_buffer_size=10000, - use_hardware_sequencing=True, - engine=Engine(name='DevEngine', use_hardware_sequencing=True) - )) - - thorcam: Core = field(default_factory=lambda: Core( - name='DevCam', - memory_buffer_size=10000, - use_hardware_sequencing=True, - engine=Engine(name='DevEngine', use_hardware_sequencing=True) - )) - - def __repr__(self): - return ( - f"HARDWARE:\n" - f"====================\n" - f"encoder={repr(self.encoder)}\n" - f'\nCORES:\n' - f"====================\n" - f"MMCORE 1={repr(self.widefield)}" - f" dhyana_fps={self._dhyana_fps}\n\n" - f"MMCORE 2={repr(self.thorcam)}" - f" thorcam_fps={self._thorcam_fps} \n" - ) - - @classmethod - def _from_json(cls, file_path: str): - ''' Load configuration parameters from a JSON file ''' - - with open(file_path, 'r') as file: - json_data = json.load(file) - - if 'encoder' in json_data: - json_data['encoder'] = Encoder(**json_data['encoder']) - - if 'widefield' in json_data: - # Create Core instance without engine - core_data = json_data['widefield'] - core_instance = Core(**core_data) - # Manually set the engine - core_instance.engine = Engine(name='MesoEngine', use_hardware_sequencing=core_data.get('use_hardware_sequencing', True)) - json_data['widefield'] = core_instance - - if 'thorcam' in json_data: - core_data = json_data['thorcam'] - core_instance = Core(**core_data) - # Manually set the engine - core_instance.engine = Engine(name='PupilEngine', use_hardware_sequencing=core_data.get('use_hardware_sequencing', True)) - json_data['thorcam'] = core_instance - - return cls(**json_data) - - def initialize_cores(self, cfg): - # Initialize widefield and thorcam cores - self.widefield._load_core() - self.thorcam._load_core() - self.widefield.engine.set_config(cfg) - self.thorcam.engine.set_config(cfg) - logging.info("Cores initialized") - - - - -''' Example Default widefield and thorcam Core dataclass instances - -```python - - widefield: Core = field(default_factory=lambda: Core( - name='Dhyana', - configuration_path='C:/Program Files/Micro-Manager-2.0/mm-sipefield.cfg', - memory_buffer_size=10000, - use_hardware_sequencing=True, - trigger_port=2, - properties={ - 'Arduino-Switch': 'Sequence', - 'Arduino-Shutter': 'OnOff', - 'Core': 'Shutter' - }, - engine=Engine(name='MesoEngine', use_hardware_sequencing=True) - )) - - thorcam: Core = field(default_factory=lambda: Core( - name='ThorCam', - configuration_path='C:/Program Files/Micro-Manager-2.0/ThorCam.cfg', - memory_buffer_size=10000, - use_hardware_sequencing=True, - roi=[440, 305, 509, 509], - properties={ - 'Exposure': '20' - }, - engine=Engine(name='PupilEngine', use_hardware_sequencing=True) - )) - -``` - -''' diff --git a/mesofield/subprocesses/psychopy.py b/mesofield/subprocesses/psychopy.py index b3f0105..e040f7c 100644 --- a/mesofield/subprocesses/psychopy.py +++ b/mesofield/subprocesses/psychopy.py @@ -6,7 +6,6 @@ def launch(config, parent=None): args = [ "C:\\Program Files\\PsychoPy\\python.exe", f'{config.psychopy_path}', - f'{config.protocol}', f'{config.subject}', f'{config.session}', f'{config.save_dir}', diff --git a/setup.py b/setup.py index da53be5..50e11c2 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ def read(*paths, **kwargs): """Read the contents of a text file safely. >>> read("mesofield", "VERSION") - '0.1.0' + '0.9.7' >>> read("README.md") ... """ @@ -38,7 +38,6 @@ def read_requirements(path): long_description_content_type="text/markdown", author="Gronemeyer", packages=find_packages(exclude=["tests", ".github"]), - install_requires=read_requirements("requirements.txt"), entry_points={ "console_scripts": ["mesofield = mesofield.__main__:main"] },