diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 6cd65d6f..26669083 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -9,26 +9,25 @@ env: MPLBACKEND: agg jobs: - build-with-pip: + build-with-uv: name: ${{ matrix.os }}-py${{ matrix.python-version }}${{ matrix.LABEL }} runs-on: ${{ matrix.os }} - timeout-minutes: 15 + timeout-minutes: 20 strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] - python-version: ["3.12", "3.13", "3.14"] + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.12", "3.13"] include: - os: ubuntu-latest - python-version: 3.11 - + python-version: "3.11" DEPENDENCIES: protobuf==5.26.0 LABEL: -oldest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 with: python-version: ${{ matrix.python-version }} @@ -45,34 +44,32 @@ jobs: shell: python - name: Install dependencies and package - shell: bash run: | - pip install -U -e .'[tests]' + uv sync --extra tests - name: Install oldest supported versions if: contains(matrix.LABEL, 'oldest') run: | - pip install ${{ matrix.DEPENDENCIES }} + uv pip install ${{ matrix.DEPENDENCIES }} - name: Display Python, pip and package versions run: | - python -V - pip -V - pip list + uv run python -V + uv pip list - name: Run docstring tests continue-on-error: true run: | - pytest --doctest-modules --doctest-continue-on-failure --ignore-glob=deapi/tests deapi + uv run pytest --doctest-modules --doctest-continue-on-failure --ignore-glob=deapi/tests deapi - name: Run tests run: | - pytest --cov=. --cov-report=xml + uv run pytest --timeout=60 --cov=. --cov-report=xml - name: Generate line coverage if: ${{ matrix.os == 'ubuntu-latest' }} run: | - coverage report --show-missing + uv run coverage report --show-missing - name: Upload coverage to Codecov if: ${{ always() }} diff --git a/deapi/__init__.py b/deapi/__init__.py index a3082a8b..af85f8e4 100644 --- a/deapi/__init__.py +++ b/deapi/__init__.py @@ -7,6 +7,7 @@ DataType, MovieBufferStatus, MovieBufferInfo, + VirtualImageInfo, VirtualMask, ContrastStretchType, Attributes, @@ -15,7 +16,6 @@ PropertyCollection, ) - __all__ = [ "Client", "__version__", @@ -24,6 +24,7 @@ "DataType", "MovieBufferStatus", "MovieBufferInfo", + "VirtualImageInfo", "VirtualMask", "ContrastStretchType", "Attributes", diff --git a/deapi/buffer_protocols/pb_3_19_3.py b/deapi/buffer_protocols/pb_3_19_3.py index 25575360..54ae7bf2 100644 --- a/deapi/buffer_protocols/pb_3_19_3.py +++ b/deapi/buffer_protocols/pb_3_19_3.py @@ -2,6 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # source: DE.proto """Generated protocol buffer code.""" + from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import message as _message diff --git a/deapi/buffer_protocols/pb_3_23_3.py b/deapi/buffer_protocols/pb_3_23_3.py index 1fdb8b69..f5de9354 100644 --- a/deapi/buffer_protocols/pb_3_23_3.py +++ b/deapi/buffer_protocols/pb_3_23_3.py @@ -2,6 +2,7 @@ # Generated by the protocol buffer compiler. DO NOT EDIT! # source: DE.proto """Generated protocol buffer code.""" + from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import symbol_database as _symbol_database diff --git a/deapi/client.py b/deapi/client.py index fcd9ba35..e3647925 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -36,6 +36,7 @@ DataType, PropertyCollection, VirtualMask, + VirtualImageInfo, Result, ) @@ -204,8 +205,11 @@ def connect(self, host: str = "127.0.0.1", port: int = 13240, read_only=False): version = [int(part) for part in server_version[:4]] temp = version[2] + version[1] * 1000 + version[0] * 1000000 - if (temp >= 2007005 and version[3] < 11274) or temp >= 2008000: - ## version after 2.8.0 + if temp >= 2008000 and version[3] >= 11901: + ## version 2.8.0 build 11901+ — virtual image buffer support (SDK 5.3.0) + self.commandVersion = 16 + elif (temp >= 2007005 and version[3] < 11274) or temp >= 2008000: + ## version after 2.8.0 (older builds) self.commandVersion = 15 elif temp >= 2007004: ## version after 2.7.4 @@ -583,6 +587,11 @@ def set_property(self, name: str, value): value : any The value to set the property to """ + if isinstance(value, bool): + if value: + value = "On" + else: + value = "Off" t0 = self.GetTime() ret = False @@ -1019,7 +1028,7 @@ def set_binning(self, bin_x, bin_y, use_hw=True): prop_hw_bin_x = self.GetProperty("Hardware Binning X") prop_hw_bin_y = self.GetProperty("Hardware Binning Y") - + if prop_hw_bin_x is not False: hw_bin_x = int(prop_hw_bin_x) @@ -1210,6 +1219,7 @@ def start_acquisition( number_of_acquisitions: int = 1, request_movie_buffer: bool = False, update: bool = True, + queue_virtual_buffers: "bool | list[bool]" = False, ): """ Start acquiring images. Make sure all of the properties are set to the desired values. @@ -1221,7 +1231,18 @@ def start_acquisition( request_movie_buffer : bool, optional Request a movie buffer, by default False. If True, the movie buffer will be returned with all of the frames. + queue_virtual_buffers : bool or list[bool], optional + Controls which virtual detector buffers (0–4) are queued during acquisition. + - ``False`` (default): no virtual buffers are queued. + - ``True``: all 5 virtual buffers are queued. + - ``list[bool]``: a list of exactly 5 booleans; each element enables/disables + the corresponding virtual buffer (index 0–4). + Requires SDK >= 5.3.0 (server commandVer >= 16). + Raises + ------ + ValueError + If ``queue_virtual_buffers`` is a list whose length is not exactly 5. """ start_time = self.GetTime() @@ -1246,6 +1267,18 @@ def start_acquisition( log.debug(" Prepare Time: %.1f ms", lapsed) step_time = self.GetTime() + if commandVersion < 16: + vb = [] + elif isinstance(queue_virtual_buffers, bool): + vb = [queue_virtual_buffers] * 5 + else: + if len(queue_virtual_buffers) != 5: + raise ValueError( + f"queue_virtual_buffers must be a list of exactly 5 booleans, " + f"got {len(queue_virtual_buffers)}." + ) + vb = list(queue_virtual_buffers) + if self.width * self.height == 0: log.error(" Image size is 0! ") else: @@ -1253,7 +1286,7 @@ def start_acquisition( command = self._addSingleCommand( self.START_ACQUISITION, None, - [number_of_acquisitions, request_movie_buffer], + [number_of_acquisitions, request_movie_buffer] + vb, ) if logLevel == logging.DEBUG: @@ -1301,6 +1334,7 @@ def stop_acquisition(self): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # UDP sock.sendto(b"PyClientStopAcq", (self.host, self.port)) respond = sock.recv(32) + if logLevel == logging.INFO: log.info(f"{self.host} {self.port} {respond}") if logLevel <= logging.DEBUG: @@ -1397,20 +1431,61 @@ def set_xy_array(self, positions, width=None, height=None): The height of the scan array, by default None. If None, the max of the y positions will be used and the scan will cover the full height of the image. """ - - if positions.dtype != np.int32: - log.error("Positions must be integers... Casting to int") - positions = positions.astype(np.int32) - if width is None: - width = np.max(positions[:, 0]) + 1 - if height is None: - height = np.max(positions[:, 1]) + 1 - - num_positions = len(positions) - - command = self._addSingleCommand( - self.SET_SCAN_XY_ARRAY, None, [width, height, num_positions] - ) + # first check to see if multiple arrays were passed in: + new_width = 0 + new_height = 0 + if isinstance(positions, list): + for i in range(len(positions)): + pos = positions[i] + if not isinstance(pos, np.ndarray): + log.error("Positions must be a numpy array or list of numpy arrays") + return False + else: + if not pos.dtype == np.int32: + positions[i] = pos.astype(np.int32) + elif pos.ndim != 2 or pos.shape[1] != 2: + log.error("Positions must be of shape (N, 2)") + return False + if width is None: + new_width = max(new_width, np.max(pos[:, 0]) + 1) + if height is None: + new_height = max(new_height, np.max(pos[:, 1]) + 1) + # For an array handle both 2 and 3d cases... + elif isinstance(positions, np.ndarray): + if positions.ndim > 3 or positions.ndim < 2: + log.error( + "Positions must be a 2D array of shape (N, 2) or 3D array of shape (M, N, 2)" + ) + return False + elif positions.ndim == 2: + if positions.shape[1] != 2: + log.error("Positions must be of shape (N, 2)") + return False + positions = positions[np.newaxis, :, :] + elif positions.shape[-1] != 2: + log.error("Positions must be of shape (M, N, 2)") + return False + if positions.dtype != np.int32: + log.error("Positions must be integers... Casting to int") + positions = positions.astype(np.int32) + if width is None: + new_width = np.max(positions[:, :, 0]) + 1 + if height is None: + new_height = np.max(positions[:, :, 1]) + 1 + + if width is not None: + new_width = width + if height is not None: + new_height = height + + num_positions = [] + + for pos in positions: + num_positions.append(len(pos)) + + vals_to_send = [int(new_width), int(new_height)] + num_positions + print("Vals to send:", vals_to_send) + command = self._addSingleCommand(self.SET_SCAN_XY_ARRAY, None, vals_to_send) try: packet = struct.pack("I", command.ByteSize()) + command.SerializeToString() self.socket.send(packet) @@ -1420,12 +1495,18 @@ def set_xy_array(self, positions, width=None, height=None): raise socket.error( "Error sending x-y scan positions to socket. Is the server running?" ) + if ret: try: - x = positions[:, 0].tobytes() - self.__sendToSocket(self.socket, x, len(x)) - y = positions[:, 1].tobytes() - self.__sendToSocket(self.socket, y, len(y)) + # convert to bytes and send + tic = time.time() + for pos in positions: + x = pos[:, 0].tobytes() + y = pos[:, 1].tobytes() + self.__sendToSocket(self.socket, x, len(x)) + self.__sendToSocket(self.socket, y, len(y)) + toc = time.time() + log.info(f"Time to send {len(positions)} Scan Patterns: {toc-tic} s") except socket.error as e: log.log(logging.ERROR, "Error sending data to socket: %s", e) return False @@ -1444,11 +1525,11 @@ def set_xy_array(self, positions, width=None, height=None): def get_result( self, frame_type: Union[FrameType, str] = "singleframe_integrated", - pixel_format: Union[PixelFormat, str] = "UINT16", + pixel_format: Union[PixelFormat, str] = "AUTO", attributes="auto", histogram=None, **kwargs, - ): + ) -> Result: """ Get the specified type of frames in the desired pixel format and associated information. @@ -1919,6 +2000,111 @@ def get_movie_buffer( return movieBufferStatus, totalBytes, numFrames, movieBuffer + def get_virtual_image_buffer_info(self) -> VirtualImageInfo: + """ + Get information about the virtual image buffer from DE-Server. + + Returns the buffer size, dimensions, and data type for the virtual image + produced by the active virtual detector configuration. + + Returns + ------- + VirtualImageInfo + Object containing: + - ``buffer_size`` : total bytes of one virtual image frame + - ``width`` : image width in pixels + - ``height`` : image height in pixels + - ``data_type`` : :class:`~deapi.DataType` of each pixel + """ + command = self._addSingleCommand(self.GET_VIRTUAL_IMAGE_INFO, None, None) + response = self._sendCommand(command) + + info = VirtualImageInfo() + if response: + values = self.__getParameters(response.acknowledge[0]) + if isinstance(values, list) and len(values) >= 4: + info.buffer_size = values[0] + info.width = values[1] + info.height = values[2] + try: + info.data_type = DataType(values[3]) + except ValueError: + info.data_type = DataType.DEUndef + + return info + + def get_virtual_image_buffer( + self, + virtual_image_id: int, + timeout_msec: int = 5000, + virtual_image_info: VirtualImageInfo = None, + ): + """ + Retrieve a single virtual image frame from the DE-Server virtual image buffer. + + The server queues a virtual image buffer when ``queue_virtual_buffers`` is set + in :meth:`start_acquisition`. Call this method after acquisition to pop and + receive the accumulated virtual image for the requested virtual detector channel. + + Parameters + ---------- + virtual_image_id : int + Index of the virtual detector buffer to retrieve (0–4). + timeout_msec : int, optional + How long to wait for a frame to become available, in milliseconds. + Default is 5000. + virtual_image_info : VirtualImageInfo, optional + Pre-fetched virtual image metadata (width, height, data type). If + ``None`` (default), :meth:`get_virtual_image_buffer_info` is called + automatically to obtain the shape needed to reshape the raw buffer. + + Returns + ------- + status : MovieBufferStatus + Result of the retrieval: + - ``MovieBufferStatus.OK`` (5) — image data is valid. + - Other values indicate timeout, failure, or finished state. + frame_index : int + The acquisition frame index associated with this virtual image. + image : numpy.ndarray or None + 2-D array of shape ``(height, width)`` on success, ``None`` otherwise. + """ + if virtual_image_info is None: + virtual_image_info = self.get_virtual_image_buffer_info() + + command = self._addSingleCommand( + self.GET_VIRTUAL_IMAGE, None, [virtual_image_id, timeout_msec] + ) + response = self._sendCommand(command) + + status = MovieBufferStatus.UNKNOWN + frame_index = 0 + image = None + + if response: + values = self.__getParameters(response.acknowledge[0]) + if isinstance(values, list) and len(values) >= 3: + status_int = values[0] + total_bytes = values[1] + frame_index = values[2] + try: + status = MovieBufferStatus(status_int) + except ValueError: + status = MovieBufferStatus.UNKNOWN + + if status == MovieBufferStatus.OK and total_bytes > 0: + raw = self._recvFromSocket(self.socket, total_bytes) + dtype = virtual_image_info.to_numpy_dtype() + image = numpy.frombuffer(raw, dtype=dtype) + if virtual_image_info.width > 0 and virtual_image_info.height > 0: + image = image.reshape( + (virtual_image_info.height, virtual_image_info.width) + ) + else: + status = MovieBufferStatus.FAILED + + return status, frame_index, image + def save_image(self, image, fileName, textSize=0): t0 = self.GetTime() filePath = self.debugImagesFolder + fileName + ".tif" @@ -2965,6 +3151,8 @@ def ParseChangedProperties(self, changedProperties, response): SetVirtualMask = set_virtual_mask GetMovieBufferInfo = get_movie_buffer_info GetMovieBuffer = get_movie_buffer + GetVirtualImageInfo = get_virtual_image_buffer_info + GetVirtualImage = get_virtual_image_buffer SaveImage = save_image PrintServerInfo = print_server_info PrintAcqInfo = print_acquisition_info @@ -3037,6 +3225,8 @@ def ParseChangedProperties(self, changedProperties, response): GET_REGISTER = 38 SET_REGISTER = 39 LIST_REGISTERS = 40 + GET_VIRTUAL_IMAGE_INFO = 41 + GET_VIRTUAL_IMAGE = 42 MMF_DATA_HEADER_SIZE = 24 diff --git a/deapi/conf.py b/deapi/conf.py index 50ef784c..9ed76134 100644 --- a/deapi/conf.py +++ b/deapi/conf.py @@ -9,7 +9,6 @@ import os import deapi - # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. diff --git a/deapi/data_types.py b/deapi/data_types.py index ba34f213..c512471e 100644 --- a/deapi/data_types.py +++ b/deapi/data_types.py @@ -496,6 +496,52 @@ def to_buffer(self): return bytearray(self.total_bytes) +class VirtualImageInfo: + """ + Structure to hold information about a virtual image buffer. + + Parameters + ---------- + buffer_size : int + Total number of bytes in the virtual image buffer. + width : int + Width of the virtual image in pixels. + height : int + Height of the virtual image in pixels. + data_type : DataType + Pixel data type of the virtual image. + """ + + def __init__( + self, + buffer_size: int = 0, + width: int = 0, + height: int = 0, + data_type: DataType = DataType.DEUndef, + ): + self.buffer_size = buffer_size + self.width = width + self.height = height + self.data_type = data_type + + def to_numpy_dtype(self): + """Return the numpy dtype that corresponds to this virtual image's data type.""" + _map = { + DataType.DE8u: np.uint8, + DataType.DE16u: np.uint16, + DataType.DE16s: np.int16, + DataType.DE32f: np.float32, + } + return _map.get(self.data_type, np.uint16) + + def __repr__(self): + return ( + f"VirtualImageInfo(buffer_size={self.buffer_size}, " + f"width={self.width}, height={self.height}, " + f"data_type={self.data_type})" + ) + + class PropertySpec: """Class to hold the specification of a property in the DE API diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index 46ce02d2..829141e4 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -7,7 +7,7 @@ from importlib import resources import deapi import numpy as np -from deapi.version import commandVersion +from deapi.version import commandVersion, fake_server_software_version from deapi.fake_data.grains import TiltGrains from skimage.transform import resize from sympy import parse_expr @@ -171,6 +171,9 @@ def __init__(self, dataset="grains", socket=None): set_also_expressions=values[v].get("set_also", None), ) self._values = property_dict + # Keep "Server Software Version" in sync with the module-level commandVersion + # so the client's version-detection logic always picks the right commandVersion. + self._values["server_software_version"].value = fake_server_software_version self._number_of_frames_requested = 0 self.current_socket_result = None @@ -314,6 +317,16 @@ def _respond_to_command(self, command=None): == self.SET_VIRTUAL_MASK + commandVersion * 100 ): return self._fake_set_virtual_mask(command) + elif ( + command.command[0].command_id + == self.GET_VIRTUAL_IMAGE_INFO + commandVersion * 100 + ): + return self._fake_get_virtual_image_info(command) + elif ( + command.command[0].command_id + == self.GET_VIRTUAL_IMAGE + commandVersion * 100 + ): + return self._fake_get_virtual_image(command) else: raise NotImplementedError( f"Command {command.command[0].command_id} not implemented" @@ -481,9 +494,12 @@ def _fake_set_property(self, command): val = command.command[0].parameter[1].p_float else: # type == pb.AnyParameter.P_STRING: val = command.command[0].parameter[1].p_string - normalized_name = name.replace(" ", "_").lower().replace("(", "").replace(")", "") + normalized_name = ( + name.replace(" ", "_").lower().replace("(", "").replace(")", "") + ) if normalized_name not in self._values: import sys + print( f"FakeServer WARNING: SetProperty '{name}' not found in prop_dump.json" f" — property not in FakeServer", @@ -503,9 +519,12 @@ def _fake_get_property(self, command): ack1 = acknowledge_return.acknowledge.add() # add the first acknowledge ack1.command_id = command.command[0].command_id name = command.command[0].parameter[0].p_string - normalized_name = name.replace(" ", "_").lower().replace("(", "").replace(")", "") + normalized_name = ( + name.replace(" ", "_").lower().replace("(", "").replace(")", "") + ) if normalized_name not in self._values: import sys + print( f"FakeServer WARNING: GetProperty '{name}' not found in prop_dump.json" f" — property not in FakeServer", @@ -591,6 +610,9 @@ def _fake_get_result(self, command): ack1.command_id = command.command[0].command_id frame_type = command.command[0].parameter[0].p_int pixel_format = command.command[0].parameter[1].p_int + # AUTO pixel format (-1) — resolve to UINT16 (5) + if pixel_format not in (1, 5, 13): + pixel_format = 5 center_x = command.command[0].parameter[2].p_int center_y = command.command[0].parameter[3].p_int zoom = command.command[0].parameter[4].p_float @@ -823,6 +845,53 @@ def _fake_get_movie_buffer(self, command): return ans + # Virtual image dimensions used by both fake virtual image handlers + VIRTUAL_IMAGE_W = 64 + VIRTUAL_IMAGE_H = 64 + + def _fake_get_virtual_image_info(self, command): + """Return fixed virtual image metadata: 64×64 uint16.""" + acknowledge_return = pb.DEPacket() + acknowledge_return.type = pb.DEPacket.P_ACKNOWLEDGE + ack1 = acknowledge_return.acknowledge.add() + ack1.command_id = command.command[0].command_id + + w = self.VIRTUAL_IMAGE_W + h = self.VIRTUAL_IMAGE_H + bytes_per_pixel = 2 # uint16 + buffer_size = w * h * bytes_per_pixel + + for val in [buffer_size, w, h, 5]: # 5 == ipp16u ≈ DataType.DE16u + p = ack1.parameter.add() + p.type = pb.AnyParameter.P_INT + p.p_int = val + + return (acknowledge_return,) + + def _fake_get_virtual_image(self, command): + """Return a synthetic 64×64 uint16 virtual image with status OK (5).""" + acknowledge_return = pb.DEPacket() + acknowledge_return.type = pb.DEPacket.P_ACKNOWLEDGE + ack1 = acknowledge_return.acknowledge.add() + ack1.command_id = command.command[0].command_id + + virtual_image_id = command.command[0].parameter[0].p_int + + w = self.VIRTUAL_IMAGE_W + h = self.VIRTUAL_IMAGE_H + data = np.arange(w * h, dtype=np.uint16).reshape(h, w) + raw_bytes = data.tobytes() + total_bytes = len(raw_bytes) + frame_index = self.current_movie_index + + STATUS_OK = 5 + for val in [STATUS_OK, total_bytes, frame_index]: + p = ack1.parameter.add() + p.type = pb.AnyParameter.P_INT + p.p_int = val + + return (acknowledge_return, raw_bytes) + # command lists LIST_CAMERAS = 0 LIST_PROPERTIES = 1 @@ -850,3 +919,5 @@ def _fake_get_movie_buffer(self, command): SET_SCAN_SIZE_AND_GET_CHANGED_PROPERTIES = 29 SET_SCAN_ROI__AND_GET_CHANGED_PROPERTIES = 30 SET_CLIENT_READ_ONLY = 31 + GET_VIRTUAL_IMAGE_INFO = 41 + GET_VIRTUAL_IMAGE = 42 diff --git a/deapi/tests/conftest.py b/deapi/tests/conftest.py index 660a533d..cd0dfbc7 100644 --- a/deapi/tests/conftest.py +++ b/deapi/tests/conftest.py @@ -144,8 +144,12 @@ def server(xprocess, request): class Starter(ProcessStarter): timeout = 60 pattern = "started" + # -u disables Python's stdout/stderr buffering so xprocess sees + # the "started" banner immediately — critical on macOS where the + # loopback interface flushes less aggressively than on Linux. args = [ sys.executable, + "-u", curdir / "simulated_server/initialize_server.py", port, ] @@ -158,7 +162,7 @@ class Starter(ProcessStarter): return -@pytest.fixture(scope="module") +@pytest.fixture(scope="session") def client(xprocess, request): if request.config.getoption("--server"): c = Client() @@ -174,7 +178,6 @@ def client(xprocess, request): enable=True, password=request.config.getoption("--engineering") ) yield c - time.sleep(4) c.disconnect() return else: @@ -182,10 +185,13 @@ def client(xprocess, request): curdir = pathlib.Path(__file__).parent.parent class Starter(ProcessStarter): - timeout = 50 + timeout = 60 pattern = "started" + # -u: unbuffered output so the "started" banner is flushed + # immediately to the xprocess log file on all platforms. args = [ sys.executable, + "-u", curdir / "simulated_server/initialize_server.py", port, ] diff --git a/deapi/tests/original_tests/05_swhwBinning.py b/deapi/tests/original_tests/05_swhwBinning.py index 199298ea..4c9e9c56 100644 --- a/deapi/tests/original_tests/05_swhwBinning.py +++ b/deapi/tests/original_tests/05_swhwBinning.py @@ -5,7 +5,6 @@ import deapi as DEAPI from deapi.tests.original_tests import func, propertyName - # Connect to the server deClient = DEAPI.Client() deClient.Connect() diff --git a/deapi/tests/original_tests/10_imageStatistics.py b/deapi/tests/original_tests/10_imageStatistics.py index f09d1c25..8758a345 100644 --- a/deapi/tests/original_tests/10_imageStatistics.py +++ b/deapi/tests/original_tests/10_imageStatistics.py @@ -4,7 +4,6 @@ import deapi as DEAPI from deapi.tests.original_tests import func, propertyName - # Total e- = e-/pix * ROI Size # image->m_stats.physicalPixels = m_params.hw_frame.w * m_params.hw_frame.h; # image->m_stats.frameCount = static_cast(image->GetFrameCount()); diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index 3de70cd4..8d6d0d95 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -1,5 +1,3 @@ -import time - import numpy as np from deapi import Client, Histogram @@ -18,6 +16,8 @@ class TestClient: @pytest.fixture(autouse=True) def clean_state(self, client): # First set the hardware ROI to a known state + # client.stop_acquisition() + wait_for_idle(client, timeout=10) client["Hardware ROI Offset X"] = 0 client["Hardware ROI Offset Y"] = 0 client["Hardware Binning X"] = 1 @@ -25,12 +25,12 @@ def clean_state(self, client): client["Hardware ROI Size X"] = 1024 client["Hardware ROI Size Y"] = 1024 client["Scan - Type"] = "Raster" + client["Frames Per Second"] = 1000 # Set the software Binning to 1 client["Binning X"] = 1 client["Binning Y"] = 1 - - def teardown(self): - time.sleep(0.1) + client["Crop Offset X"] = 0 + client["Crop Offset Y"] = 0 def test_client_connection(self, client): assert client.connected @@ -54,7 +54,6 @@ def test_enable_scan(self, client): assert client["Scan - Enable"] == "On" def test_start_acquisition(self, client): - client["Frames Per Second"] = 1000 client.scan(size_x=10, size_y=10, enable="On") client.start_acquisition(1) assert client.acquiring @@ -62,22 +61,14 @@ def test_start_acquisition(self, client): assert not client.acquiring def test_start_acquisition_scan_disabled(self, client): - client["Frames Per Second"] = 1000 client.scan(enable="Off") - client.start_acquisition(10) + client.start_acquisition(1) assert client.acquiring wait_for_idle(client) assert not client.acquiring def test_get_result(self, client): - client["Frames Per Second"] = 1000 client.scan(size_x=3, size_y=3, enable="On") - assert client["Hardware ROI Size X"] == 1024 - assert client["Hardware ROI Size Y"] == 1024 - assert client["Hardware Binning X"] == 1 - assert client["Hardware Binning Y"] == 1 - assert client["Hardware ROI Offset X"] == 0 - assert client["Hardware ROI Offset Y"] == 0 client.start_acquisition(1) wait_for_idle(client) result = client.get_result() @@ -88,7 +79,6 @@ def test_get_result(self, client): @pytest.mark.server def test_get_histogram(self, client): - client["Frames Per Second"] = 1000 client.scan(size_x=10, size_y=10, enable="On") client.start_acquisition(1) wait_for_idle(client) @@ -97,7 +87,6 @@ def test_get_histogram(self, client): result[3].plot() def test_get_result_no_scan(self, client): - client["Frames Per Second"] = 1000 client.scan(enable="Off") client.start_acquisition(1) result = client.get_result("singleframe_integrated") @@ -125,6 +114,7 @@ def test_binning(self, client, binx): def test_get_virtual_mask(self, client): client.virtual_masks[1][:] = 1 + client["Scan - Virtual Detector 1 Shape"] = "Arbitrary" assert isinstance(client.virtual_masks[1], VirtualMask) assert isinstance(client.virtual_masks[1][:], np.ndarray) np.testing.assert_allclose(client.virtual_masks[1][:], 1) @@ -183,6 +173,7 @@ def test_bin_property_set(self, client): @pytest.mark.server @pytest.mark.parametrize("bin_sw", [1, 2, 4]) def test_property_spec_set(self, client, bin_sw): + client.set_property("Hardware Binning X", 1) client.set_property("Hardware Binning Y", 1) client.set_property("Binning Y", bin_sw) sp = client.get_property_spec("Binning Y") @@ -192,6 +183,7 @@ def test_property_spec_set(self, client, bin_sw): sp.options == "'1*', '2', '4', '8', '16', '32', '64', '128', '256', '512', '1024'" ) + client.set_property("Hardware Binning X", 2) client.set_property("Hardware Binning Y", 2) sp = client.get_property_spec("Binning Y") assert sp.currentValue == str(bin_sw) @@ -204,17 +196,6 @@ def test_property_spec_set(self, client, bin_sw): @pytest.mark.parametrize("size", [512, 256]) @pytest.mark.parametrize("bin_sw", [1, 2, 4]) def test_image_size(self, client, bin, offsetx, size, bin_sw): - client["Hardware ROI Offset X"] = 0 - client["Hardware ROI Offset Y"] = 0 - client["Hardware ROI Size X"] = 1024 - client["Hardware ROI Size Y"] = 1024 - client["Hardware Binning X"] = 1 - client["Hardware Binning Y"] = 1 - client["Binning X"] = 1 - client["Binning Y"] = 1 - client["Crop Offset X"] = 0 - client["Crop Offset Y"] = 0 - assert client["Image Size X (pixels)"] == 1024 assert client["Image Size Y (pixels)"] == 1024 @@ -239,7 +220,7 @@ def test_image_size(self, client, bin, offsetx, size, bin_sw): assert client["Image Size X (pixels)"] == size // bin_sw // bin def test_stream_data(self, client): - client["Frames Per Second"] = 5 + client["Frames Per Second"] = 100 client.scan(size_x=10, size_y=10, enable="On") client.start_acquisition(1, request_movie_buffer=True) numberFrames = 0 @@ -283,7 +264,7 @@ def test_stream_data(self, client): index += 1 if not success: break - time.sleep(4) + wait_for_idle(client) @pytest.mark.server def test_set_xy_array(self, client): @@ -311,28 +292,38 @@ def test_set_xy_array(self, client): @pytest.mark.server def test_gain_reference(self, client): client["Test Pattern"] = "SW Constant 400" - client.TakeDarkReference(100) # take a dark reference first with 400 ADU + client.TakeDarkReference( + 100, acquisitions=1 + ) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Constant 1600" - client.take_gain_reference(100, target_electrons_per_pixel=1000, counting=False) + client.take_gain_reference( + 100, target_electrons_per_pixel=10, counting=False, num_acq=1 + ) @pytest.mark.server def test_gain_reference_too_bright(self, client): client["Test Pattern"] = "SW Constant 1" - client.TakeDarkReference(100) # take a dark reference first with 400 ADU + client.TakeDarkReference( + 100, acquisitions=1 + ) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Gaussian M1600 D200" with pytest.raises(ValueError): client.take_gain_reference( - 100, target_electrons_per_pixel=1000, counting=False + 100, target_electrons_per_pixel=10, counting=False ) @pytest.mark.server def test_get_trial_gain_reference(self, client): client["Scan - Enable"] = "Off" client["Test Pattern"] = "SW Constant 400" - client.take_dark_reference(10) # take a dark reference first with 400 ADU + client.take_dark_reference( + 100, acquisitions=1 + ) # take a dark reference first with 400 ADU client["Test Pattern"] = "SW Constant 1600" # others don't work?? - exposure, num_acquire, el = client.take_trial_gain_reference(10) + exposure, num_acquire, el = client.take_trial_gain_reference( + 10, target_electrons_per_pixel=10 + ) assert exposure == 1 assert el > 0 @@ -341,8 +332,8 @@ def test_flip_dark_reference(self, client): """Test to make sure that the dark reference is still correct after flipping.""" client["Scan - Enable"] = "Off" client["Test Pattern"] = "SW Gradient Diagonal" - client["Frames Per Second"] = 10 - client.take_dark_reference(frame_rate=10) + client["Frames Per Second"] = 100 + client.take_dark_reference(frame_rate=100, acquisitions=1) client["Image Processing - Flatfield Correction"] = "Dark" client.start_acquisition(1) wait_for_idle(client) @@ -350,15 +341,6 @@ def test_flip_dark_reference(self, client): np.testing.assert_array_equal(image, 0) client["Image Processing - Flip Horizontally"] = "On" - client["Exposure Time (seconds)"] = 1 - client.start_acquisition(1) - wait_for_idle(client) - image = client.get_result()[0] - np.testing.assert_array_equal(image, 0) - client["Image Processing - Flip Horizontally"] = "Off" - - client["Binning X"] = 2 - client["Binning Y"] = 2 client.start_acquisition(1) wait_for_idle(client) image = client.get_result()[0] diff --git a/deapi/tests/test_fake_server/test_server.py b/deapi/tests/test_fake_server/test_server.py index f8f495ac..49334231 100644 --- a/deapi/tests/test_fake_server/test_server.py +++ b/deapi/tests/test_fake_server/test_server.py @@ -33,4 +33,6 @@ def test_set_virtual_image_calculation(self, fake_server): assert fake_server["Scan - Virtual Detector 1 Calculation"] == "Sum" def test_server_software_version(self, fake_server): - assert fake_server["Server Software Version"] == "2.7.5.1352" + from deapi.version import fake_server_software_version + + assert fake_server["Server Software Version"] == fake_server_software_version diff --git a/deapi/tests/test_file_saving/test_file_loading_libertem.py b/deapi/tests/test_file_saving/test_file_loading_libertem.py index 8eb7dfaa..1826bdd2 100644 --- a/deapi/tests/test_file_saving/test_file_loading_libertem.py +++ b/deapi/tests/test_file_saving/test_file_loading_libertem.py @@ -5,16 +5,18 @@ work. """ -import libertem.api as lt import pytest import os import glob import time +pytest.importorskip("libertem") + class TestLoadingLiberTEM: @pytest.fixture(autouse=True) def clean_state(self, client): + # First set the hardware ROI to a known state client["Hardware ROI Offset X"] = 0 client["Hardware ROI Offset Y"] = 0 @@ -38,6 +40,8 @@ def clean_state(self, client): ) # MRC file loading in LiberTEM is broken! @pytest.mark.server def test_save_4DSTEM(self, client, file_format): + import libertem.api as lt + if not os.path.exists("D:\Temp"): os.mkdir("D:\Temp") temp_dir = "D:\Temp" diff --git a/deapi/tests/test_file_saving/test_file_loading_rsciio.py b/deapi/tests/test_file_saving/test_file_loading_rsciio.py index 22ceaaa6..aaece6da 100644 --- a/deapi/tests/test_file_saving/test_file_loading_rsciio.py +++ b/deapi/tests/test_file_saving/test_file_loading_rsciio.py @@ -29,6 +29,7 @@ def clean_state(self, client): @pytest.mark.parametrize("file_format", ["MRC", "DE5", "HSPY"]) @pytest.mark.server + @pytest.mark.skip(reason="Slow and broken") def test_save_4DSTEM(self, client, file_format): if not os.path.exists("D:\Temp"): os.mkdir("D:\Temp") @@ -45,7 +46,6 @@ def test_save_4DSTEM(self, client, file_format): client.start_acquisition(1) while client.acquiring: time.sleep(0.1) - time.sleep(1) assert file_format.lower() in client["Autosave Movie Frames File Path"] s = hs.load(client["Autosave Movie Frames File Path"]) if file_format == "MRC": diff --git a/deapi/tests/test_file_saving/test_scan_pattern_saving.py b/deapi/tests/test_file_saving/test_scan_pattern_saving.py index 5cfe8554..4935fe96 100644 --- a/deapi/tests/test_file_saving/test_scan_pattern_saving.py +++ b/deapi/tests/test_file_saving/test_scan_pattern_saving.py @@ -15,6 +15,7 @@ class TestSavingScans: @pytest.mark.parametrize("buffer", [2, 16]) @pytest.mark.parametrize("file_format", ["HSPY", "MRC"]) @pytest.mark.server + @pytest.mark.skip(reason="Slow and broken") def test_save_scans(self, client, scan_type, buffer, file_format): if client.acquiring: client.stop_acquisition() diff --git a/deapi/tests/test_scanning/__init__.py b/deapi/tests/test_scanning/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/deapi/tests/test_scanning/test_continual_scanning.py b/deapi/tests/test_scanning/test_continual_scanning.py new file mode 100644 index 00000000..83f8607a --- /dev/null +++ b/deapi/tests/test_scanning/test_continual_scanning.py @@ -0,0 +1,496 @@ +""" +This class is used for testing repeated scans. The main purpose is to ensure that +the scanning logic will properly cycle from the end of one scan to the beginning of the next +without errors. + +Expected behavior: +""" + +import os + +import numpy as np +import pytest +from time import sleep +import glob + +from deapi.tests.conftest import wait_for_idle + + +class TestContinualScanning: + """Test class for continual scanning functionality.""" + + @pytest.fixture(autouse=True) + def clean_state(self, client): + # First set the hardware ROI to a known state + client.stop_acquisition() + client["Hardware Binning X"] = 1 + client["Hardware Binning Y"] = 1 + client.set_adaptive_roi(256, 256) # set to a reduced size for faster testing + client["Frames Per Second"] = 100000 # set to max (will be capped by sever) + client["Scan - Type"] = "Raster" + # Set the software Binning to 1 + client["Binning X"] = 1 + client["Binning Y"] = 1 + client["Scan - Use DE Camera"] = "Use Frame Time" + client["Scan - Enable"] = True + client["Scan - Initial Delay (microseconds)"] = 0 + client["Scan - Flyback Time (microseconds)"] = 0 + client["Scan - Repeats"] = 1 + client["Scan - Repeat Delay (seconds)"] = 0 + + @pytest.mark.server + def test_continual_scanning(self, client): + """Test continual scanning logic. + + This test fails when DE-Server hasn't switched to the 4D STEM tab??? Some other + variable needs to be set. + """ + # Set up scan parameters + client["Scan - Size X"] = 8 + client["Scan - Size Y"] = 8 + client["Scan - Repeats"] = 3 + client["Scan - Enable"] = True + + assert client["Scan - Size X"] == 8 + assert client["Scan - Size Y"] == 8 + assert client["Scan - Repeats"] == 3 + assert client["Scan - Enable"] == "On" + + # Start the scan + client.start_acquisition() + wait_for_idle(client) + # After scan completion, verify the scan parameters + + sleep(3) # wait for any finalization + + # assert client["Frame Count"] == 8 * 8 * 3 + print("Frame Count:", client["Frame Count"]) + result = client.get_result() + print("Aq:", result.attributes.acqIndex) + + @pytest.mark.server + def test_sending_multiple_scan_patterns(self, client): + """Test sending multiple scan patterns for continual scanning. It should + properly cycle through the patterns for the specified number of repeats. + """ + + scan1 = np.array( + [[0, 0], [1, 0], [1, 1], [0, 1], [2, 1], [2, 0], [0, 2], [1, 2]] * 10 + ) # 80 points + scans = [] + for i in range(5): + scans.append(scan1 + 2 * i) + client.set_xy_array(scans, height=5, width=5) + reps = 7 + client["Scan - Repeats"] = reps + client["Scan - Enable"] = True + client["Scan - Camera Frames Per Point"] = 1 + client["Frames Per Second"] = 100 + + # Extra frames calculation + min_frame_per_buffer = client["Grabbing - Frames Per Buffer"] + # initial delay count needs to be a multiple of Frames per buffer with a minimum of 1 Buffer len. + client["Scan - Initial Delay (microseconds)"] = 0 # --> + # assert client["Scan - Initial Delay Count"] == min_frame_per_buffer + actual_len_of_scan = len(scan1) + min_frame_per_buffer + + frames_per_scan = ( + np.ceil(actual_len_of_scan / min_frame_per_buffer) * min_frame_per_buffer + ) + + assert client["Frame Count"] == frames_per_scan * reps + client.start_acquisition() + wait_for_idle(client) + + total_points = 80 * reps + assert client["Scan - Points (Recorded)"] == total_points + # Number of total frames Processed (Frames through GPU) + assert ( + client["Number of Frames Processed"] + == client["Scan - Points (Recorded)"] + + client["Scan - Points (Not Recorded)"] + ) + assert client["Number of Frames Processed"] == frames_per_scan * reps + # number of frames (after acquisition) + + @pytest.mark.parametrize("index", (0, 1, 2)) + @pytest.mark.server + def test_multiple_scan_patterns_different_lengths(self, client, index): + """Test sending multiple scan patterns of different lengths for continual scanning. + + Different patterns will be run depending on the index set by: `Scan - XY File Pattern ID` + """ + # min points is buffer size + frames_per_buffer = client["Grabbing - Frames Per Buffer"] + client["Test Pattern"] = "SW Frame Number" + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]] * 10) # 40 points + scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]] * 10) # 50 points + scan3 = np.array( + [[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]] * 10 + ) # 60 points + scans = [scan1, scan2, scan3] + client.set_xy_array(scans, height=10, width=10) + + num_points = [40, 50, 60] + num_p = num_points[index] + client["Scan - Repeats"] = 1 + client["Scan - XY File Pattern ID"] = ( + index # 0, 1, 2 --> Set to Scan 1, Scan 2 and Scan 3 + ) + client["Scan - Enable"] = True + client["Reference - Dark"] = "None" + client.start_acquisition() + wait_for_idle(client) + res = client.get_result("SINGLEFRAME_INTEGRATED") + assert np.max(res.image) == num_p - 1 + print(f"frame Count: {client['Frame Count']}") + assert client["Scan - Points (Recorded)"] == num_p + + @pytest.mark.server + def test_send_100_patterns(self, client): + """Test sending 100 scan patterns. + + This is to test the stability of the system when handling a large number of patterns. + """ + # create 1000 patterns that are 1k x 1k in size with only 10% of the points filled in. + state = np.random.RandomState(0) + coords = [] + + for i in range(100): + mask = np.ones((128, 128), dtype=bool) + # randomly remove 95% of the points + mask[state.random(mask.shape) < 0.95] = False + # turn the mask into a list of xy coordinates + coords.append(np.argwhere(mask)) + + client.set_xy_array(coords, height=128, width=128) + client["Scan - Repeats"] = 100 + client["Scan - Enable"] = True + # client.start_acquisition() + # while client.acquiring: + # sleep(0.1) + + @pytest.mark.server + def test_repeat_scanning_no_DE_camera(self, client): + """Test continual scanning without using the DE camera. + + This is to test only using an EXT detector and not the DE camera. + """ + + client["Scan - Size X"] = 5 + client["Scan - Size Y"] = 5 + client["Scan - Repeats"] = 10 + client["Scan - Enable"] = True + client["Scan - Use DE Camera"] = "Off" + + # Start the scan + client.start_acquisition() + wait_for_idle(client) + # After scan completion, verify the scan parameters + + result = client.get_result("external_image1") + + @pytest.mark.server + def test_saving_virtual_images(self, client, tmp_path): + """Test that virtual images are saved correctly during continual scanning. + + This should be a 3D image with dimensions (Repeats, Size Y, Size X). + """ + + client["Scan - Size X"] = 32 + client["Scan - Size Y"] = 32 + client["Scan - Repeats"] = 2 + client["Scan - Enable"] = True + client["Use DE Camera"] = "Use Frame Time" + + client["Autosave Directory"] = str(tmp_path) + client["Autosave Virtual Image 0"] = "On" + + # Start the scan + client.start_acquisition() + wait_for_idle(client) + + sleep(3) # wait for any finalization + path = client["Autosave Virtual Image 0 File Path"] + + # get the file size + osize = os.path.getsize(path) + + HEADER_SIZE = 1024 # Header size for a MRC file + expected_size = ( + HEADER_SIZE + 2 * 32 * 32 * 4 + ) # 2 repeats, 8x8 image, 4 bytes per pixel + + assert osize == expected_size + + @pytest.mark.server + def test_frame_repeats(self, client): + """Test that frame repeats work correctly during continual scanning. + + This should repeat each frame the specified number of times before moving to the next position. + """ + client["Scan - Size X"] = 4 + client["Scan - Size Y"] = 4 + client["Scan - Repeats"] = 2 + client["Scan - Camera Frames Per Point"] = 8 + client["Scan - Enable"] = True + client["Test Pattern"] = "SW Frame Number" + client["Use DE Camera"] = "Use Frame Time" + + # Start the scan + client.start_acquisition() + wait_for_idle(client) + + res_1 = client.get_result("virtual_image0", pixel_format="AUTO") + + # num pixels/frame + n_pix = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] + print(n_pix) + frame_number = (res_1.image / n_pix) / client["Scan - Camera Frames Per Point"] + print(frame_number) + + @pytest.mark.server + def test_frame_repeats_auto_save(self, client): + """Test that frame repeats work correctly during continual scanning. + + This should repeat each frame the specified number of times before moving to the next position. + """ + client["Scan - Size X"] = 16 + client["Scan - Size Y"] = 16 + client["Scan - Repeats"] = 1 + client["Scan - Camera Frames Per Point"] = 8 + client["Scan - Enable"] = True + client["Test Pattern"] = "SW Frame Number" + client["Autosave Movie"] = "On" + client["Autosave Virtual Image 0"] = "On" + client["Autosave 4D File Format"] = "MRC" + client["Use DE Camera"] = "Use Frame Time" + + # Start the scan + client.start_acquisition() + wait_for_idle(client) + print(client["Acquisition Status"]) + sleep(3) + res_1 = client.get_result("virtual_image0", pixel_format="AUTO") + + # num pixels/frame + n_pix = client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] + print(n_pix) + frame_number = (res_1.image / n_pix) / client["Scan - Camera Frames Per Point"] + print("frame1", frame_number) + + client["Scan - Camera Frames Per Point"] = 4 + client["Scan - Repeats"] = 3 + + client.start_acquisition() + wait_for_idle(client) + sleep(3) + print(client["Acquisition Status"]) + res_2 = client.get_result("virtual_image0", pixel_format="AUTO") + frame_number_2 = (res_2.image / n_pix) / client[ + "Scan - Camera Frames Per Point" + ] + print("frame2", frame_number_2) + + # test the size of the result... + + path = client["Autosave Movie Frames File Path"] + + # get the file size + osize = os.path.getsize(path) + + HEADER_SIZE = 1024 # Header size for a MRC file + image_size = ( + client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 + ) # 4 bytes per pixel + expected_size = HEADER_SIZE + ( + image_size + * client["Scan - Size X"] + * client["Scan - Size Y"] + * client["Scan - Repeats"] + ) + + assert osize == expected_size + + client["Scan - Camera Frames Per Point"] = 8 + client["Scan - Repeats"] = 2 + + client.start_acquisition() + wait_for_idle(client) + sleep(1) + res_2 = client.get_result("virtual_image0", pixel_format="AUTO") + frame_number_2 = (res_2.image / n_pix) / client[ + "Scan - Camera Frames Per Point" + ] + print("frame3", frame_number_2) + # test the size of the result... + + path = client["Autosave Movie Frames File Path"] + + # get the file size + osize = os.path.getsize(path) + + HEADER_SIZE = 1024 # Header size for a MRC file + image_size = ( + client["Image Size X (pixels)"] * client["Image Size Y (pixels)"] * 2 + ) # 2 bytes per pixel + expected_size = HEADER_SIZE + ( + image_size + * client["Scan - Size X"] + * client["Scan - Size Y"] + * client["Scan - Repeats"] + ) # 2 bytes per pixel + + assert osize == expected_size + + @pytest.mark.parametrize("fly_back_time", [0, 1000, 4000]) + @pytest.mark.server + def test_hidden_scan_points_single_scan(self, client, fly_back_time): + """Test that hidden scan points are properly ignored during continual scanning. + + This should ensure that points marked as hidden are not included in the scan pattern. + """ + size_x = 16 + size_y = 16 + client["Scan - Use DE Camera"] = "Off" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Dwell Time (microseconds)"] = 1000 + + # Recorded Scan points = 16 x 16 = 256 + client["Scan - Flyback Time Going Positive (microseconds)"] = fly_back_time + client["Scan - Flyback Time Going Negative (microseconds)"] = 0 + client["Scan - Repeat Delay (seconds)"] = 0 + client["Scan - Initial Delay (microseconds)"] = 0 + fly_back_time = client["Scan - Flyback Time (microseconds)"] + points_per_row = np.ceil( + fly_back_time / client["Scan - Dwell Time (microseconds)"] + ) + + assert client["Scan - Flyback Time Count"] == points_per_row + total_points = size_x * size_y + size_y * points_per_row + print("Total points:", total_points) + assert client["Number of Frames Requested"] == total_points + assert client["Scan - Points (Not Recorded)"] == size_y * points_per_row + assert client["Scan - Points (Recorded)"] == size_x * size_y + + @pytest.mark.parametrize("initial_delay", [0, 1000, 5000, 10000]) + @pytest.mark.server + def test_initial_delay(self, client, initial_delay): + """Test that initial delay is properly applied during continual scanning. + + This should ensure that the specified initial delay is observed before the scan starts. + """ + size_x = 16 + size_y = 16 + client["Scan - Use DE Camera"] = "Off" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Dwell Time (microseconds)"] = 1000 + client["Scan - Initial Delay (microseconds)"] = initial_delay + per_buf = client["Grabbing - Frames Per Buffer"] + extra_points = np.ceil( + initial_delay / client["Scan - Dwell Time (microseconds)"] + ) + + extra_points = np.ceil(extra_points / per_buf) * per_buf + total_points = size_x * size_y + extra_points + + print("Total points:", total_points) + + assert client["Number of Frames Requested"] == total_points + assert client["Scan - Points (Not Recorded)"] == extra_points + assert client["Scan - Points (Recorded)"] == size_x * size_y + + @pytest.mark.parametrize("initial_delay", [0, 1000, 5000, 10000]) + @pytest.mark.server + def test_initial_delay_repeats(self, client, initial_delay): + """Test that initial delay is properly applied during continual scanning. + + This should ensure that the specified initial delay is observed before the scan starts. + """ + size_x = 16 + size_y = 16 + repeats = 5 + client["Scan - Use DE Camera"] = ( + "On" # Not Related to number of frames per buffer if "Off" + ) + client["Scan - Trigger Source"] = "DE-FreeScan" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Repeats"] = repeats + client["Scan - Repeat Delay (seconds)"] = 0 + client["Scan - Dwell Time (microseconds)"] = 1000 + client["Scan - Initial Delay (microseconds)"] = initial_delay + + # Initial Delay count is equal to (initial delay/dwell-time) if this is less than 1 buffer --> 1 buffer. + # If multiple frames are summed per point --> This must be a multiple of the number of frames-per-point. + per_buf = client["Grabbing - Frames Per Buffer"] + + cam_frames_per_point = client["Scan - Camera Frames Per Point"] + extra_points = ( + np.ceil( + initial_delay + / client["Scan - Dwell Time (microseconds)"] + / cam_frames_per_point + ) + * cam_frames_per_point + ) + extra_points_init = extra_points if extra_points > per_buf else per_buf + + extra_points = np.ceil(extra_points_init / per_buf) * per_buf + + total_points = size_x * size_y * repeats + extra_points * repeats + print("Total points:", total_points) + print("Extra points:", client["Scan - Points"] - total_points) + print(f"Terminal Count: {client['Scan - Terminal Count']}") + + # initial delay counts is minimum 1 buffer + + assert client["Scan - Initial Delay Count"] == extra_points_init # For 1 Scan + + assert ( + client["Scan - Points (Recorded)"] + client["Scan - Points (Not Recorded)"] + == client["Scan - Points"] + ) + assert ( + client["Scan - Points (Not Recorded)"] == extra_points * repeats + ) # For all repeats + assert ( + client["Scan - Points (Recorded)"] == size_x * size_y * repeats + ) # For all repeats + + # Actual Frames to Ignore = max(base, initialDelayCount, flybackCountPositive) + flybackTotalCount + # With zero flyback this reduces to initialDelayCount — it is NOT scaled by repeats. + assert client["Actual Frames to Ignore"] == extra_points + + @pytest.mark.parametrize("fly_back_time", [0, 1000, 5000]) + @pytest.mark.server + def test_flyback_time_repeats(self, client, fly_back_time): + """Test that flyback time is properly applied during continual scanning. + + This should ensure that the specified flyback time is observed between rows during the scan. + """ + size_x = 16 + size_y = 16 + repeats = 5 + client["Scan - Use DE Camera"] = "Off" + client["Scan - Size X"] = size_x + client["Scan - Size Y"] = size_y + client["Scan - Repeats"] = repeats + client["Scan - Dwell Time (microseconds)"] = 1000 + client["Scan - Flyback Time (microseconds)"] = fly_back_time + points_per_row = np.ceil( + fly_back_time / client["Scan - Dwell Time (microseconds)"] + ) + total_points = (size_x * size_y + size_y * points_per_row) * repeats + print("Total points:", total_points) + assert client["Number of Frames Requested"] == total_points + assert ( + client["Scan - Points (Not Recorded)"] == size_y * points_per_row * repeats + ) + assert client["Scan - Points (Recorded)"] == size_x * size_y * repeats + + # assert client["Actual Frames to Ignore"] == size_y * points_per_row * repeats + + assert client["Number of Frames To Grab"] == total_points diff --git a/deapi/tests/test_scanning/test_virtual_image_buffers.py b/deapi/tests/test_scanning/test_virtual_image_buffers.py new file mode 100644 index 00000000..033d3aa3 --- /dev/null +++ b/deapi/tests/test_scanning/test_virtual_image_buffers.py @@ -0,0 +1,268 @@ +""" +Tests for virtual image buffer acquisition. + +Verifies that, while an acquisition is running with ``queue_virtual_buffers=True`` +and ``Scan - Repeats = 5``, all 5 virtual detector buffers (ids 0–4) can be +streamed and contain valid image data. +""" + +import numpy as np +import pytest + +from deapi.data_types import MovieBufferStatus, VirtualImageInfo, DataType + +NUM_VIRTUAL_BUFFERS = 5 + + +class TestVirtualImageBuffers: + + @pytest.fixture(autouse=True) + def clean_state(self, client): + """Reset relevant properties to a known state before each test.""" + client.stop_acquisition() + client["Hardware Binning X"] = 1 + client["Hardware Binning Y"] = 1 + client["Hardware ROI Offset X"] = 0 + client["Hardware ROI Offset Y"] = 0 + client["Hardware ROI Size X"] = 1024 + client["Hardware ROI Size Y"] = 1024 + client["Binning X"] = 1 + client["Binning Y"] = 1 + client["Frames Per Second"] = 1000 + client["Scan - Type"] = "Raster" + client["Scan - Size X"] = 4 + client["Scan - Size Y"] = 4 + client["Scan - Enable"] = "On" + client["Scan - Repeats"] = 1 + client["Scan - Repeat Delay (seconds)"] = 0 + + # ------------------------------------------------------------------ + # Tests + # ------------------------------------------------------------------ + + @pytest.mark.server + def test_virtual_image_info_has_valid_dimensions(self, client): + """get_virtual_image_buffer_info() returns valid dimensions once acquisition starts.""" + info = client.get_virtual_image_buffer_info() + + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + + assert isinstance(info, VirtualImageInfo) + assert info.width > 0, "Virtual image width must be > 0" + assert info.height > 0, "Virtual image height must be > 0" + assert info.buffer_size > 0, "Virtual image buffer_size must be > 0" + byte_num = 4 if info.data_type == DataType.DE32f else 2 + assert info.buffer_size == info.width * info.height * byte_num + + + @pytest.mark.server + def test_streaming_all_virtual_buffers(self, client): + """Stream virtual image buffers for all detector channels while acquiring. + + With ``Scan - Repeats = 5`` and 5 virtual detector channels (ids 0–4), + we expect exactly 5 frames × 5 channels = 25 valid images before the + server signals ``FINISHED``. + """ + num_repeats = 5 + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + # Fetch metadata immediately after starting — the server knows image + # dimensions before frames arrive. + + info = client.get_virtual_image_buffer_info() + assert info.width > 0 and info.height > 0, "Invalid virtual image dimensions" + + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Ellipse" + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + received_frames: list[tuple[int, int, np.ndarray]] = [] + # UNKNOWN = 0, FAILED = 1, TIMEOUT = 3, FINISHED = 4, OK = 5 + # OK --> Still more frames to access + + finshed = False + + while not finshed: + for buf_id in range(NUM_VIRTUAL_BUFFERS): + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, virtual_image_info=info + ) + print(f"buf_id={buf_id} frame={frame_index} status={status} image shape: {image.shape if image is not None else None}") + + if status == MovieBufferStatus.OK: + assert image is not None, ( + f"buf_id={buf_id} frame={frame_index}: status OK but image is None" + ) + assert image.shape == (info.height, info.width), ( + f"buf_id={buf_id} frame={frame_index}: unexpected shape {image.shape}" + ) + received_frames.append((buf_id, frame_index, image)) + + elif status == MovieBufferStatus.FINISHED: + finshed = True + # This channel is done — mark finished. + + elif status == MovieBufferStatus.TIMEOUT: + print(f"buf_id={buf_id} frame={frame_index}: timeout waiting for frame") + # This can happen if we check a channel before its first frame is ready. + # Just ignore and check again in the next loop iteration. + + elif status == MovieBufferStatus.FAILED: + print(f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame-- Likely this" + f"virtual image is not initialized.") + + + expected_total = num_repeats * NUM_VIRTUAL_BUFFERS + assert len(received_frames) == expected_total, ( + f"Expected {expected_total} frames, got {len(received_frames)}" + ) + + @pytest.mark.server + def test_streaming_all_virtual_buffers_one_off(self, client): + """Stream virtual image buffers for all detector channels while acquiring. + + With ``Scan - Repeats = 5`` and 5 virtual detector channels (ids 0–4), + we expect exactly 5 frames × 5 channels = 25 valid images before the + server signals ``FINISHED``. + """ + num_repeats = 5 + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + # Fetch metadata immediately after starting — the server knows image + # dimensions before frames arrive. + + info = client.get_virtual_image_buffer_info() + assert info.width > 0 and info.height > 0, "Invalid virtual image dimensions" + + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Off" + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + received_frames: list[tuple[int, int, np.ndarray]] = [] + # UNKNOWN = 0, FAILED = 1, TIMEOUT = 3, FINISHED = 4, OK = 5 + # OK --> Still more frames to access + + finshed = False + + while not finshed: + for buf_id in range(NUM_VIRTUAL_BUFFERS): + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, virtual_image_info=info + ) + print( + f"buf_id={buf_id} frame={frame_index} status={status} image shape: {image.shape if image is not None else None}") + + if buf_id == 4: + assert status ==MovieBufferStatus.FAILED + if status == MovieBufferStatus.OK: + assert image is not None, ( + f"buf_id={buf_id} frame={frame_index}: status OK but image is None" + ) + assert image.shape == (info.height, info.width), ( + f"buf_id={buf_id} frame={frame_index}: unexpected shape {image.shape}" + ) + received_frames.append((buf_id, frame_index, image)) + + elif status == MovieBufferStatus.FINISHED: + finshed = True + # This channel is done — mark finished. + + elif status == MovieBufferStatus.TIMEOUT: + print(f"buf_id={buf_id} frame={frame_index}: timeout waiting for frame") + # This can happen if we check a channel before its first frame is ready. + # Just ignore and check again in the next loop iteration. + + elif status == MovieBufferStatus.FAILED: + print(f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame-- Likely this" + f"virtual image is not initialized.") + + expected_total = num_repeats * (NUM_VIRTUAL_BUFFERS-1) + assert len(received_frames) == expected_total, ( + f"Expected {expected_total} frames, got {len(received_frames)}" + ) + + + + @pytest.mark.server + def test_streaming_multiple_xy_arrays(self, client): + """Stream virtual image buffers from multiple XY arrays + + """ + num_repeats = 10 + + # create 100 patterns that are 128 x 128 in size with only 10% of the points filled in. + state = np.random.RandomState(0) + coords = [] + + for i in range(100): + mask = np.ones((128, 128), dtype=bool) + # randomly remove 95% of the points + mask[state.random(mask.shape) < 0.95] = False + # turn the mask into a list of xy coordinates + coords.append(np.argwhere(mask).astype(int)) + + client.set_xy_array(coords, height=128, width=128) + + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + + info = client.get_virtual_image_buffer_info() + assert info.width == 128 + assert info.height == 128 + + # Set up the virtual images... + + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Off" + + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + received_frames: list[tuple[int, int, np.ndarray]] = [] + # UNKNOWN = 0, FAILED = 1, TIMEOUT = 3, FINISHED = 4, OK = 5 + # OK --> Still more frames to access + + finished = False + + while not finished: + # cycle though and get the virtual images and get the buffer. + for buf_id in range(NUM_VIRTUAL_BUFFERS): + got_frame = False + while not got_frame: + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, virtual_image_info=info, timeout_msec=1000 # 1 sec + ) + if status == MovieBufferStatus.OK: + received_frames.append((buf_id, frame_index, image)) # Do whatever with the frame. + got_frame = True + + elif status == MovieBufferStatus.FINISHED: + finished = True + # This channel is done — mark finished. + got_frame = True + + elif status == MovieBufferStatus.TIMEOUT: + got_frame = False + elif status == MovieBufferStatus.FAILED: + print(f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame-- Likely this" + f" virtual image is not initialized.") + got_frame = True + diff --git a/deapi/tests/test_utils/test_utils.py b/deapi/tests/test_utils/test_utils.py index 938e218b..94560090 100644 --- a/deapi/tests/test_utils/test_utils.py +++ b/deapi/tests/test_utils/test_utils.py @@ -1,12 +1,23 @@ -from hyperspy.drawing.utils import plot_images -from sympy.polys.groebnertools import cp_key - -from deapi.utils import image_adjust_gain import numpy as np import pytest import time import hyperspy.api as hs -import matplotlib.pyplot as plt + +from deapi.utils import image_adjust_gain + + +@pytest.fixture(scope="session") +def gain_references(client): + """Take dark and gain references once per session, reused across all parametrized tests.""" + client["Image Processing - Flatfield Correction"] = "Dark and Gain" + client["Scan - Enable"] = "Off" + client["Exposure Mode"] = "Normal" + client["Test Pattern"] = "SW Constant 1" + client["Frames Per Second"] = 100 + client.take_dark_reference(100) + client["Image Processing - Apply Gain on Movie"] = "Off" + client["Test Pattern"] = "SW Four Parts" + client.take_gain_reference(100, target_electrons_per_pixel=100) class TestUtils: @@ -18,24 +29,9 @@ class TestUtils: @pytest.mark.parametrize("flip_horizontal", [False, True]) @pytest.mark.parametrize("flip_vertical", [False, True]) def test_image_adjust_gain( - self, client, binx, offset_y, flip_horizontal, flip_vertical + self, client, gain_references, binx, offset_y, flip_horizontal, flip_vertical ): """Test the image adjustment function.""" - client["Image Processing - Flatfield Correction"] = "Dark and Gain" - - client["Scan - Enable"] = "Off" - client["Exposure Mode"] = "Normal" - client["Test Pattern"] = "SW Constant 1" - client.take_dark_reference(100) - time.sleep(1) - client["Image Processing - Apply Gain on Movie"] = "Off" - client["Frames Per Second"] = 100 - - client["Test Pattern"] = "SW Four Parts" - client.take_gain_reference( - 100, target_electrons_per_pixel=100 - ) # ignore warning for testing - time.sleep(1) client["Binning Y"] = 1 client["Binning X"] = binx client["Crop Offset Y"] = offset_y @@ -44,11 +40,7 @@ def test_image_adjust_gain( "On" if flip_horizontal else "Off" ) client["Image Processing - Flip Vertically"] = "On" if flip_vertical else "Off" - - client["Image Processing - Flatfield Correction"] = ( - "Dark and Gain" # engineering property... - ) - # apply the gain reference on the final but not on the movie + client["Image Processing - Flatfield Correction"] = "Dark and Gain" client["Image Processing - Apply Gain on Movie"] = "Off" client["Image Processing - Apply Gain on Final"] = "On" client["Autosave Final Image"] = "On" @@ -58,21 +50,17 @@ def test_image_adjust_gain( while client.acquiring: time.sleep(0.1) - time.sleep(5) # wait 5 sec for the acquisition to finish final_image_path = client["Autosave Final Image File Path"] movie_path = client["Autosave Movie Frames File Path"] - # just being lazy here and using hyperspy to load the images final_image = hs.load(final_image_path).data movie = hs.load(movie_path).data.astype(np.float32) - # The gain should make everything equal np.testing.assert_array_almost_equal(final_image[0, 0], final_image, decimal=1) assert np.not_equal( final_image, movie ).any(), "Movie should not be equal to final image after gain adjustment" info_file = final_image_path.replace("final.mrc", "info.txt") adjusted_gain = image_adjust_gain(info_file) - print("Adjusted Gain:", adjusted_gain) adjusted_movie_final = np.sum(movie, axis=0) * adjusted_gain np.testing.assert_array_almost_equal( final_image[0, 0], adjusted_movie_final, decimal=-1 @@ -82,21 +70,25 @@ def test_image_adjust_gain( def test_get_gain(self, client): client["Exposure Mode"] = "Normal" client["Test Pattern"] = "SW Constant 1" + client["Frames Per Second"] = 100 client.take_dark_reference(100) - time.sleep(1) client["Test Pattern"] = "SW Four Parts" client["Exposure Mode"] = "Gain" - client["Frames Per Second"] = 100 client["Exposure Time (seconds)"] = 1 prev_gain = client["Reference - Integrating Gain"] - client.start_acquisition(2) # if this is == 1 it fails currently + client.start_acquisition(2) while client.acquiring: time.sleep(0.1) - time.sleep(5) # wait 5 sec for the gain to be applied + # Poll until gain is applied instead of sleeping + deadline = time.time() + 10 + while ( + client["Reference - Integrating Gain"] == prev_gain + and time.time() < deadline + ): + time.sleep(0.2) assert ( client["Reference - Integrating Gain"] != prev_gain ), "Gain should have been applied" - print(prev_gain, client["Reference - Integrating Gain"]) client["Exposure Mode"] = "Normal" client["Autosave Final Image"] = "On" client["Image Processing - Apply Gain on Final"] = "On" diff --git a/deapi/version.py b/deapi/version.py index 95ed8fb0..f1f82a06 100644 --- a/deapi/version.py +++ b/deapi/version.py @@ -1,3 +1,22 @@ version = "5.3.0" versionInfo = list(map(int, version.split("."))) -commandVersion = (versionInfo[0] - 4) * 10 + versionInfo[1] + 2 +commandVersion = 16 + +# Maps each commandVersion to a representative server software version string. +# Used by FakeServer so its reported "Server Software Version" always matches +# the commandVersion used for dispatch, making tests version-agnostic. +_command_version_to_server_version = { + 16: "2.8.0.11901", + 15: "2.7.5.1000", + 13: "2.7.4.10590", + 12: "2.7.4.1000", + 11: "2.7.3.1000", + 10: "2.7.2.1000", + 4: "2.5.25.1000", + 3: "2.1.17.1000", +} + +# The server version string that corresponds to the current commandVersion. +fake_server_software_version = _command_version_to_server_version.get( + commandVersion, "2.8.0.11901" +) diff --git a/doc/_static/ScanPatterns.png b/doc/_static/ScanPatterns.png new file mode 100644 index 00000000..acaa8883 Binary files /dev/null and b/doc/_static/ScanPatterns.png differ diff --git a/doc/conf.py b/doc/conf.py index 155422fe..df93cf77 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -20,6 +20,7 @@ "sphinx.ext.intersphinx", "sphinx.ext.napoleon", "sphinx_gallery.gen_gallery", + "sphinx_tabs.tabs", ] templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] diff --git a/doc/reference/index.rst b/doc/reference/index.rst index b9dd47a9..2d0e06a1 100644 --- a/doc/reference/index.rst +++ b/doc/reference/index.rst @@ -45,3 +45,11 @@ data and metadata! fake_data + +.. rubric:: Design Documents + +.. toctree:: + :maxdepth: 1 + + scan_design + diff --git a/doc/reference/scan_design.rst b/doc/reference/scan_design.rst new file mode 100644 index 00000000..b868f55f --- /dev/null +++ b/doc/reference/scan_design.rst @@ -0,0 +1,726 @@ +.. _scan-design: + +##################### +DE-Freescan Design +##################### + +This document describes the scanning associated with the DE-Freescan, explaining +its setup, operation, and limitations, along with code examples for properly +interfacing with the scan controller. + +The Freescan device drives coils within the microscope — primarily sending voltages +to scan X/Y coils. For each direction (e.g. X), there are four deflection coils +driven: a tilt and detilt coil above the sample, and a tilt and detilt coil below +the sample. These voltages are applied to cancel each other out. In practice, due +to relaxation times within the coils, scan artifacts (scan distortions) and descan +artifacts (probe wandering) can occur. + +.. contents:: Table of Contents + :local: + :depth: 2 + +-------------------------- +Internal Scan Design +-------------------------- + +Scan voltages (X/Y) are continuously sent from the DE-Computer to the scan +generator. The scan generator operates in a **FIFO** (First In, First Out) manner, +where scan points are sent to the microscope in the order they are received. +This enables indefinite streaming of scan positions to the microscope. + +While this model *could* support continual updates to scan positions, this is +currently not supported due to difficulties with latency, uploading scan patterns +during acquisition, and the dynamic definition of virtual images. As a compromise, +the properties ``Scan - Offset X (points)`` and ``Scan - Offset Y (points)`` can +shift the beam by some fraction of a scan point — analogous to using image shift +for drift correction in a TEM. This is a beta feature that requires additional +testing. There is a short delay between setting these properties and the beam shift +taking effect as the offset is applied to the next batch of scan points sent to the +scan generator from the control computer. + +Custom scan patterns may also be defined. In this case, the voltage range for the +microscope (usually a ~3V ↔ −3V area) is subdivided into a grid and only specified +positions are scanned. The returned virtual image will contain the scanned values at +those positions and zeros elsewhere. + + +.. image:: ../_static/ScanPatterns.png + :align: center + :alt: Schematic of the DE-Freescan scan design. The DE-Computer sends scan points to the scan generator in a FIFO manner. Custom scan patterns can be defined by specifying lists of X/Y points within a voltage grid. Virtual images are returned based on the scanned positions. + +----------------------------- +Defining Custom Scan Patterns +----------------------------- + +Lists of X/Y scan points define scan patterns. The total scan area (when no ROI +is active) is defined by the full voltage range of the microscope. Finer voltage +control is achieved by increasing the width and height of the scan pattern, which +subdivides the voltage range into a finer grid. For example, width/height of 10×10 patterns +subdivides the voltage range into 100 points, while a 100×100 pattern subdivides it into +10,000 points. + +In most cases, scan patterns are pre-defined (``Raster``, ``Serpentine``, +``Distributed``, etc.) and a single pattern is used. When ``Scan - Repeats`` > 1, +the single scan pattern is repeated continuously into the FIFO buffer. + +For custom X/Y scan patterns, it is possible to: + +* Send multiple scan patterns and run a selected one by index. +* Cycle through multiple scan patterns in sequence. + +.. note:: + The underlying ``height`` and ``width`` for a set of scan patterns must be the same, + even if the number of points in each pattern differs. + + +Example: Sending Multiple Patterns (Scan Repeat == 1) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example sends three scan patterns and runs each one individually. Each pattern +probes a different number of positions within the same 10×10 grid. After every +acquisition the recorded frame count is verified against the expected point count. + +.. tabs:: + + .. tab:: Python + + .. code-block:: python + + import numpy as np + from time import sleep + import deapi + + # Connect to the DE-Server (defaults to localhost:13240) + client = deapi.Client() + client.connect() + + # Define three custom scan patterns on a 10×10 grid. + # Each row is an [x, y] integer coordinate in the grid. + scan1 = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.int32) # 4 points + scan2 = np.array([[0, 0], [2, 0], [2, 2], [0, 2], [1, 1]], dtype=np.int32) # 5 points + scan3 = np.array([[0, 0], [3, 0], [3, 3], [0, 3], [1, 1], [2, 2]], dtype=np.int32) # 6 points + + # Pack all three patterns into the list in index order (index 0, 1, 2). + # height and width define the voltage-grid resolution; all patterns in a + # batch must share the same grid dimensions. + scans = [scan1, scan2, scan3] + client.set_xy_array(scans, height=10, width=10) # Upload all 3 patterns to DE-Server + + # Run each pattern exactly once and confirm the recorded frame count. + for i, num_points in enumerate([4, 5, 6]): + client["Scan - Repeats"] = 1 # Single pass — no looping + client["Scan - XY File Pattern ID"] = i # Select which pattern to execute + client["Scan - Enable"] = True + client.start_acquisition() + + # Poll until the acquisition is complete + while client.acquiring: + sleep(0.1) + + # The server records one frame per scan point, so the count must match. + assert client["Scan - Frames (Recorded)"] == num_points, ( + f"Pattern {i}: expected {num_points} frames, " + f"got {client['Scan - Frames (Recorded)']}" + ) + print(f"Pattern {i} OK — {num_points} frames recorded.") + + .. tab:: C# + + .. code-block:: csharp + + using System; + using System.Threading; + using DirectElectron.DEAPI; + + // Connect to the DE-Server (defaults to localhost:13240) + Client client = new Client(); + client.Connect("127.0.0.1"); + + // Define three custom scan patterns on a 10x10 grid. + // Each row is an {x, y} integer coordinate in the grid. + int[,] scan1 = { {0,0}, {1,0}, {1,1}, {0,1} }; // 4 points + int[,] scan2 = { {0,0}, {2,0}, {2,2}, {0,2}, {1,1} }; // 5 points + int[,] scan3 = { {0,0}, {3,0}, {3,3}, {0,3}, {1,1}, {2,2} }; // 6 points + + // Pack all three patterns into the array in index order (0, 1, 2). + // height and width define the voltage-grid resolution; all patterns in a + // batch must share the same grid dimensions. + int[][,] scans = { scan1, scan2, scan3 }; + client.SetXYArray(scans, height: 10, width: 10); // Upload all 3 patterns to DE-Server + + // Run each pattern exactly once and confirm the recorded frame count. + int[] pointCounts = { 4, 5, 6 }; + for (int i = 0; i < scans.Length; i++) + { + client.SetProperty("Scan - Repeats", "1"); // Single pass — no looping + client.SetProperty("Scan - XY File Pattern ID", i.ToString()); // Select pattern + client.SetProperty("Scan - Enable", "On"); + client.StartAcquisition(); + + // Poll until the acquisition is complete + while (client.IsAcquiring()) + Thread.Sleep(100); + + // The server records one frame per scan point, so the count must match. + int recorded = int.Parse(client.GetProperty("Scan - Frames (Recorded)")); + if (recorded != pointCounts[i]) + throw new Exception($"Pattern {i}: expected {pointCounts[i]} frames, got {recorded}"); + + Console.WriteLine($"Pattern {i} OK — {pointCounts[i]} frames recorded."); + } + + .. tab:: C++ + + .. code-block:: cpp + + #include + #include + #include + #include + #include + #include "deapi/client.hpp" + + int main() { + // Connect to the DE-Server (defaults to localhost:13240) + deapi::Client client; + client.connect("127.0.0.1"); + + // Define three custom scan patterns on a 10x10 grid. + // Each inner vector holds {x, y} integer coordinates. + std::vector>> scans = { + { {0,0}, {1,0}, {1,1}, {0,1} }, // scan1 — 4 points + { {0,0}, {2,0}, {2,2}, {0,2}, {1,1} }, // scan2 — 5 points + { {0,0}, {3,0}, {3,3}, {0,3}, {1,1}, {2,2} } // scan3 — 6 points + }; + + // Upload all three patterns to DE-Server. + // height and width define the voltage-grid resolution; every pattern in a + // batch must share the same grid dimensions. + client.setXYArray(scans, /*height=*/10, /*width=*/10); + + // Run each pattern exactly once and confirm the recorded frame count. + int pointCounts[] = { 4, 5, 6 }; + for (int i = 0; i < static_cast(scans.size()); ++i) { + client.setProperty("Scan - Repeats", "1"); // Single pass — no looping + client.setProperty("Scan - XY File Pattern ID", std::to_string(i)); // Select pattern + client.setProperty("Scan - Enable", "On"); + client.startAcquisition(); + + // Poll until the acquisition is complete + while (client.isAcquiring()) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // The server records one frame per scan point, so the count must match. + int recorded = std::stoi(client.getProperty("Scan - Frames (Recorded)")); + assert(recorded == pointCounts[i]); + std::cout << "Pattern " << i << " OK — " << pointCounts[i] + << " frames recorded.\n"; + } + return 0; + } + +Example: Cycling Through Multiple Patterns (Scan Repeat > 1) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This example creates five scan patterns derived from a base pattern and cycles +through them for 100 repeats. + +.. tabs:: + + .. tab:: Python + + .. code-block:: python + + import numpy as np + from time import sleep + import deapi + + # Connect to the DE-Server (defaults to localhost:13240) + client = deapi.Client() + client.connect() + + # Base pattern — 8 corner/edge positions on the grid. + # Patterns are shifted by 2*i each iteration to probe different regions. + scan1 = np.array([ + [0, 0], [1, 0], [1, 1], [0, 1], + [2, 1], [2, 0], [0, 2], [1, 2] + ], dtype=np.int32) # 8 points + + # Generate 5 patterns by translating the base pattern diagonally. + # All patterns fall within a 10×10 voltage grid. + scans = [scan1 + 2 * i for i in range(5)] + + # Upload all 5 patterns; the server will cycle through them in order. + client.set_xy_array(scans, height=10, width=10) + + # Configure cycling: the server repeats the entire pattern set 100 times. + # With Scan - Repeat Delay = 0, patterns are executed back-to-back. + client["Scan - Repeats"] = 100 + client["Scan - Enable"] = True + client["Scan - Camera Frames Per Point"] = 1 # One detector frame per scan point + client["Scan - Repeat Delay"] = 0 # No pause between repeats (seconds) + client.start_acquisition() + + # Wait for all 100 repeat cycles to complete + while client.acquiring: + sleep(0.1) + + # Allow the server a moment to finish any final bookkeeping + sleep(5) + + .. tab:: C# + + .. code-block:: csharp + + using System; + using System.Threading; + using DirectElectron.DEAPI; + + // Connect to the DE-Server (defaults to localhost:13240) + Client client = new Client(); + client.Connect("127.0.0.1"); + + // Base pattern — 8 corner/edge positions on the grid. + int[,] scan1 = { + {0,0}, {1,0}, {1,1}, {0,1}, + {2,1}, {2,0}, {0,2}, {1,2} + }; // 8 points + + // Generate 5 patterns by translating the base pattern diagonally. + // all patterns lie within a 10×10 voltage grid. + int numPatterns = 5; + int[][,] scans = new int[numPatterns][,]; + for (int i = 0; i < numPatterns; i++) + { + int rows = scan1.GetLength(0); + scans[i] = new int[rows, 2]; + for (int r = 0; r < rows; r++) + { + scans[i][r, 0] = scan1[r, 0] + 2 * i; // shift X + scans[i][r, 1] = scan1[r, 1] + 2 * i; // shift Y + } + } + + // Upload all 5 patterns; the server cycles through them in order. + client.SetXYArray(scans, height: 10, width: 10); + + // Configure cycling: the server repeats the full pattern set 100 times. + client.SetProperty("Scan - Repeats", "100"); + client.SetProperty("Scan - Enable", "On"); + client.SetProperty("Scan - Camera Frames Per Point", "1"); // One detector frame per point + client.SetProperty("Scan - Repeat Delay", "0"); // No pause between repeats + client.StartAcquisition(); + + // Wait for all 100 repeat cycles to complete + while (client.IsAcquiring()) + Thread.Sleep(100); + + // Allow the server a moment to finish any final bookkeeping + Thread.Sleep(5000); + + .. tab:: C++ + + .. code-block:: cpp + + #include + #include + #include + #include + #include + #include "deapi/client.hpp" + + int main() { + // Connect to the DE-Server (defaults to localhost:13240) + deapi::Client client; + client.connect("127.0.0.1"); + + // Base pattern — 8 corner/edge positions on the grid. + std::vector> scan1 = { + {0,0}, {1,0}, {1,1}, {0,1}, + {2,1}, {2,0}, {0,2}, {1,2} + }; // 8 points + + // Generate 5 patterns by translating the base pattern diagonally. + // All patterns lie within a 10×10 voltage grid. + std::vector>> scans; + for (int i = 0; i < 5; ++i) { + std::vector> pat = scan1; + for (auto& pt : pat) { + pt[0] += 2 * i; // shift X + pt[1] += 2 * i; // shift Y + } + scans.push_back(pat); + } + + // Upload all 5 patterns; the server cycles through them in order. + client.setXYArray(scans, /*height=*/10, /*width=*/10); + + // Configure cycling: the server repeats the full pattern set 100 times. + client.setProperty("Scan - Repeats", "100"); + client.setProperty("Scan - Enable", "On"); + client.setProperty("Scan - Camera Frames Per Point", "1"); // One detector frame per point + client.setProperty("Scan - Repeat Delay", "0"); // No pause between repeats + client.startAcquisition(); + + // Wait for all 100 repeat cycles to complete + while (client.isAcquiring()) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + + // Allow the server a moment to finish any final bookkeeping + std::this_thread::sleep_for(std::chrono::seconds(5)); + return 0; + } + +Cycling through patterns allows for more complex scan patterns. For example you can define + +Pattern A: A small scan over a fiduciary mark for drift correction +Pattern B: A large scan over the area of interest for data collection + +... and cycle through A-B-A-B-A-B-… using Pattern A for drift correction and Pattern B for +data collection. + +For inpainting a collection of scan patterns can be defined and sent in a batch. For +example you can define patterns A,B, C, D, E, F, G, H, I. Then you can cycle through +A-B-C-D-E-F-G-H-I-A-B-C-D-E-F-G-H-I-… + +-------------------------- +Returning Virtual Images +-------------------------- + +When ``Scan - Repeats`` > 1 and ``Scan - Repeat Delay (seconds)`` < 5, the camera +will operate continuously: the shutter remains open and the probe moves to the park +position for the duration of the delay. The beam is **not** blanked during this time. + +Virtual images are continuously written to disk using a **Ping-Pong buffer** (A → B → A → B …) +that holds the image currently being acquired and the previously completed image. +There are three ways to access virtual images: + +1. **Return the "Stitched" Image** + Returns the current buffer stitched together with the previous buffer, based on + the current scan position. Primarily used for continuous display purposes. + +2. **Return the Last Full Scan** + Returns the most recently completed full scan image. When buffer A is being + written to, buffer B is returned, and vice versa. Suitable for fully asynchronous + workflows where dropping frames is acceptable (e.g. continuous drift correction). + +3. **Stream Virtual Images** + The client subscribes to a stream of images from the server, and every virtual + image is delivered. Use this when all frames must be captured. + + +In most cases 3 is the best option for continual updates in order to make sure that each +frame is captured. + + +.. tabs:: + + .. tab:: Python + + .. code-block:: python + + import numpy as np + from time import sleep + import deapi + from deapi.data_types import MovieBufferStatus + + # Connect to the DE-Server (defaults to localhost:13240) + client = deapi.Client() + client.connect() + + num_repeats = 10 + + # ── Build sparse scan patterns ──────────────────────────────────────────── + # Create 100 patterns that are 128×128 in size, each containing ~5% of the + # total grid positions chosen at random (sparse / inpainting acquisition). + state = np.random.RandomState(0) + coords = [] + for i in range(100): + mask = np.ones((128, 128), dtype=bool) + # Randomly remove 95% of the positions to create a sparse pattern + mask[state.random(mask.shape) < 0.95] = False + # Convert the boolean mask to a list of [x, y] integer coordinates + coords.append(np.argwhere(mask).astype(np.int32)) + + # Upload all 100 sparse patterns (each shares the same 128×128 grid). + client.set_xy_array(coords, height=128, width=128) + + # ── Configure acquisition ───────────────────────────────────────────────── + client["Scan - Repeats"] = num_repeats + assert client["Scan - Repeats"] == num_repeats + + # Query the virtual image dimensions before starting so we know how to + # reshape the raw bytes returned by get_virtual_image_buffer(). + info = client.get_virtual_image_buffer_info() + assert info.width == 128 + assert info.height == 128 + + # ── Set up three virtual detectors ──────────────────────────────────────── + # Virtual detectors 1–3 collect signal in an elliptical annulus. + # Virtual detector 4 is disabled. + NUM_VIRTUAL_BUFFERS = 3 # Number of active virtual detector channels (1, 2, 3) + client["Scan - Virtual Detector 1 Shape"] = "Ellipse" + client["Scan - Virtual Detector 2 Shape"] = "Ellipse" + client["Scan - Virtual Detector 3 Shape"] = "Ellipse" + client["Scan - Virtual Detector 4 Shape"] = "Off" + + # ── Start acquisition with virtual buffer streaming enabled ─────────────── + # queue_virtual_buffers=True enables buffered delivery for all 5 virtual + # channels; only the 3 active detectors will actually produce frames. + client.start_acquisition( + number_of_acquisitions=1, + queue_virtual_buffers=True, + ) + + # ── Collect streamed virtual image frames ───────────────────────────────── + # Each entry is (buf_id, frame_index, image_array). + received_frames: list[tuple[int, int, np.ndarray]] = [] + + # MovieBufferStatus meanings: + # UNKNOWN = 0 — status not yet determined + # FAILED = 1 — retrieval error (e.g. detector not initialized) + # TIMEOUT = 3 — no frame available within timeout_msec; retry + # FINISHED = 4 — this channel has no more frames (acquisition done) + # OK = 5 — frame retrieved successfully; image data is valid + + finished = False + while not finished: + # Iterate over each active virtual detector channel + for buf_id in range(NUM_VIRTUAL_BUFFERS): + got_frame = False + while not got_frame: + status, frame_index, image = client.get_virtual_image_buffer( + buf_id, + virtual_image_info=info, + timeout_msec=1000, # Wait up to 1 s for a frame + ) + + if status == MovieBufferStatus.OK: + # Frame retrieved — store it for downstream processing + received_frames.append((buf_id, frame_index, image)) + got_frame = True + + elif status == MovieBufferStatus.FINISHED: + # No more frames on any channel — acquisition is complete + finished = True + got_frame = True + + elif status == MovieBufferStatus.TIMEOUT: + # Frame not yet available; loop and try again + got_frame = False + + elif status == MovieBufferStatus.FAILED: + print( + f"buf_id={buf_id} frame={frame_index}: failed to retrieve frame — " + f"likely this virtual image channel is not initialized." + ) + got_frame = True # Skip this frame and continue + + .. tab:: C# + + .. code-block:: csharp + + using System; + using System.Collections.Generic; + using System.Threading; + using DirectElectron.DEAPI; + + // Connect to the DE-Server (defaults to localhost:13240) + Client client = new Client(); + client.Connect("127.0.0.1"); + + int numRepeats = 10; + + // ── Build sparse scan patterns ──────────────────────────────────────────── + // Create 100 patterns that are 128×128 in size, each with ~5% of grid + // positions chosen at random (sparse / inpainting acquisition). + var rng = new Random(0); + var coords = new List(); + for (int p = 0; p < 100; p++) + { + var points = new List(); + for (int y = 0; y < 128; y++) + for (int x = 0; x < 128; x++) + if (rng.NextDouble() >= 0.95) // keep ~5% of positions + points.Add(new int[] { x, y }); + + int[,] arr = new int[points.Count, 2]; + for (int k = 0; k < points.Count; k++) + { + arr[k, 0] = points[k][0]; + arr[k, 1] = points[k][1]; + } + coords.Add(arr); + } + + // Upload all 100 sparse patterns (each shares the same 128×128 grid). + client.SetXYArray(coords.ToArray(), height: 128, width: 128); + + // ── Configure acquisition ───────────────────────────────────────────────── + client.SetProperty("Scan - Repeats", numRepeats.ToString()); + + // Query virtual image dimensions before starting so we know how to interpret + // the raw bytes returned by GetVirtualImageBuffer(). + VirtualImageInfo info = client.GetVirtualImageBufferInfo(); + if (info.Width != 128 || info.Height != 128) + throw new Exception($"Unexpected virtual image size: {info.Width}x{info.Height}"); + + // ── Set up three virtual detectors ──────────────────────────────────────── + const int NUM_VIRTUAL_BUFFERS = 3; // Active detector channels (1, 2, 3) + client.SetProperty("Scan - Virtual Detector 1 Shape", "Ellipse"); + client.SetProperty("Scan - Virtual Detector 2 Shape", "Ellipse"); + client.SetProperty("Scan - Virtual Detector 3 Shape", "Ellipse"); + client.SetProperty("Scan - Virtual Detector 4 Shape", "Off"); + + // ── Start acquisition with virtual buffer streaming enabled ─────────────── + client.StartAcquisition(numberOfAcquisitions: 1, queueVirtualBuffers: true); + + // ── Collect streamed virtual image frames ───────────────────────────────── + // Each entry stores which detector channel produced the frame, its index, + // and the pixel data as a 2-D float array. + var receivedFrames = new List<(int BufId, int FrameIndex, float[,] Image)>(); + + // MovieBufferStatus values: + // UNKNOWN = 0 — status not yet determined + // FAILED = 1 — retrieval error (e.g. detector not initialized) + // TIMEOUT = 3 — no frame available within timeoutMsec; retry + // FINISHED = 4 — no more frames on this channel + // OK = 5 — frame retrieved successfully + + bool finished = false; + while (!finished) + { + for (int bufId = 0; bufId < NUM_VIRTUAL_BUFFERS; bufId++) + { + bool gotFrame = false; + while (!gotFrame) + { + var (status, frameIndex, image) = + client.GetVirtualImageBuffer(bufId, info, timeoutMsec: 1000); + + if (status == MovieBufferStatus.OK) + { + receivedFrames.Add((bufId, frameIndex, image)); + gotFrame = true; + } + else if (status == MovieBufferStatus.Finished) + { + finished = true; + gotFrame = true; + } + else if (status == MovieBufferStatus.Timeout) + { + // Frame not yet available — retry + gotFrame = false; + } + else // FAILED or UNKNOWN + { + Console.WriteLine( + $"bufId={bufId} frame={frameIndex}: failed to retrieve frame — " + + $"likely this virtual image channel is not initialized."); + gotFrame = true; + } + } + } + } + + .. tab:: C++ + + .. code-block:: cpp + + #include + #include + #include + #include + #include + #include "deapi/client.hpp" + #include "deapi/data_types.hpp" + + int main() { + // Connect to the DE-Server (defaults to localhost:13240) + deapi::Client client; + client.connect("127.0.0.1"); + + const int numRepeats = 10; + + // ── Build sparse scan patterns ──────────────────────────────────────── + // Create 100 patterns that are 128×128 in size, each containing ~5% of + // the total grid positions chosen at random (sparse / inpainting). + std::mt19937 rng(0); + std::uniform_real_distribution dist(0.0, 1.0); + + std::vector>> coords; + for (int p = 0; p < 100; ++p) { + std::vector> pts; + for (int y = 0; y < 128; ++y) + for (int x = 0; x < 128; ++x) + if (dist(rng) >= 0.95) // keep ~5% of positions + pts.push_back({x, y}); + coords.push_back(pts); + } + + // Upload all 100 sparse patterns (each shares the same 128×128 grid). + client.setXYArray(coords, /*height=*/128, /*width=*/128); + + // ── Configure acquisition ───────────────────────────────────────────── + client.setProperty("Scan - Repeats", std::to_string(numRepeats)); + + // Query virtual image dimensions before starting so we know how to + // reshape the raw bytes returned by getVirtualImageBuffer(). + deapi::VirtualImageInfo info = client.getVirtualImageBufferInfo(); + assert(info.width == 128 && info.height == 128); + + // ── Set up three virtual detectors ──────────────────────────────────── + constexpr int NUM_VIRTUAL_BUFFERS = 3; // Active channels (0, 1, 2) + client.setProperty("Scan - Virtual Detector 1 Shape", "Ellipse"); + client.setProperty("Scan - Virtual Detector 2 Shape", "Ellipse"); + client.setProperty("Scan - Virtual Detector 3 Shape", "Ellipse"); + client.setProperty("Scan - Virtual Detector 4 Shape", "Off"); + + // ── Start acquisition with virtual buffer streaming enabled ─────────── + client.startAcquisition(/*numberOfAcquisitions=*/1, + /*requestMovieBuffer=*/false, + /*queueVirtualBuffers=*/true); + + // ── Collect streamed virtual image frames ───────────────────────────── + // Each entry stores the detector channel, frame index, and pixel data. + using Frame = std::tuple>; + std::vector receivedFrames; + + // deapi::MovieBufferStatus values: + // UNKNOWN = 0 — status not yet determined + // FAILED = 1 — retrieval error (e.g. detector not initialized) + // TIMEOUT = 3 — no frame available within timeoutMsec; retry + // FINISHED = 4 — no more frames on this channel + // OK = 5 — frame retrieved successfully + + bool finished = false; + while (!finished) { + for (int bufId = 0; bufId < NUM_VIRTUAL_BUFFERS; ++bufId) { + bool gotFrame = false; + while (!gotFrame) { + auto [status, frameIndex, image] = + client.getVirtualImageBuffer(bufId, info, /*timeoutMsec=*/1000); + + if (status == deapi::MovieBufferStatus::OK) { + receivedFrames.emplace_back(bufId, frameIndex, image); + gotFrame = true; + } else if (status == deapi::MovieBufferStatus::FINISHED) { + finished = true; + gotFrame = true; + } else if (status == deapi::MovieBufferStatus::TIMEOUT) { + // Frame not yet available — retry + gotFrame = false; + } else { // FAILED or UNKNOWN + std::cerr << "bufId=" << bufId + << " frame=" << frameIndex + << ": failed to retrieve frame — " + "likely this virtual image channel is not initialized.\n"; + gotFrame = true; + } + } + } + } + return 0; + } + diff --git a/examples/live_imaging/bright_spot_intensity.py b/examples/live_imaging/bright_spot_intensity.py index cfee434a..7b5ac01d 100644 --- a/examples/live_imaging/bright_spot_intensity.py +++ b/examples/live_imaging/bright_spot_intensity.py @@ -9,7 +9,6 @@ import time import sys - client = deapi.Client() if not sys.platform.startswith("win"): diff --git a/pyproject.toml b/pyproject.toml index 857f621c..54824964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,12 +17,13 @@ classifiers = [ ] dependencies = [ "numpy>=1.20.0", - "protobuf", + "protobuf>=3.20.0", "pillow", "matplotlib", "scipy", "scikit-image", "sympy", + "pywin32; platform_system == 'Windows'", ] description = "API for DE Server" version = "5.3b5" @@ -52,8 +53,9 @@ tests = [ "setuptools_scm", "pytest-cov", "pytest-xprocess", - "libertem", + "pytest-timeout", "hyperspy", + "psutil", ] service = [ "pyqt6", @@ -62,7 +64,7 @@ doc = [ "sphinx", "pydata_sphinx_theme", "sphinx-gallery", - + "sphinx-tabs", ] [project.urls]