From e5076cfd7f5be0af93f4fea27abf240a63878abc Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:55:55 +0200 Subject: [PATCH] Complete agent-facing automation APIs --- README.md | 4 +- ScopeOneCore/include/scopeone/ScopeOneCore.h | 43 +- ScopeOneCore/internal/StageMosaicManager.h | 9 +- ScopeOneCore/python/scopeone/README.md | 26 +- .../python/scopeone/src/scopeone/client.py | 112 +++++ .../python/scopeone/src/scopeone/core.py | 61 +++ ScopeOneCore/src/ScopeOneCore.cpp | 98 +++- ScopeOneCore/src/StageMosaicManager.cpp | 80 +++- src/ImageToolsDialog.cpp | 27 +- src/ImageToolsDialog.h | 6 - src/MainWindow.cpp | 45 +- src/ScopeOneLocalApiServer.cpp | 432 +++++++++++++++++- src/ScopeOneMcpServer.cpp | 119 +++++ 13 files changed, 970 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 8a4b41b..addfc9b 100644 --- a/README.md +++ b/README.md @@ -146,7 +146,7 @@ build/ScopeOne ## 🤖 Automation and AI Agents -The desktop app exposes a language-neutral local control API and shared-memory frame channel. An AI agent does not run inside ScopeOne or depend on Python. A tool adapter can discover supported operation groups with the `capabilities` request, read a structured observation with `state_snapshot`, and invoke the exposed camera, stage, processing, experiment, recording, layer, and markup operations. Requests may carry an ID that is echoed by the app for correlation. +The desktop app exposes a language-neutral local control API and shared-memory frame channel. An AI agent does not run inside ScopeOne or depend on Python. A tool adapter can discover supported operation groups with the `capabilities` request, read a structured observation with `state_snapshot`, and invoke the exposed camera, stage, mosaic, processing, image analysis, experiment, recording, layer, and markup operations. Requests may carry an ID that is echoed by the app for correlation. The API reports which operations mutate hardware, write files, remove state, or may run for a long time. An agent adapter should request user confirmation before those operations and verify the result with the returned read-back value, experiment status, or a new state snapshot. The Python package is one optional client implementation. See the [Python client and Local API protocol guide](ScopeOneCore/python/scopeone/README.md) for protocol details and runnable examples. @@ -163,7 +163,7 @@ To use it: Use `scopeone` as the server name, `stdio` as the transport, the absolute path to `ScopeOneMcpServer.exe` as the command, and no command-line arguments. The exact configuration syntax depends on the agent host. -The MCP tool set mirrors the Local API operation catalog, including system state, configuration, preview layers, automatic display levels, source alignment, markups, device properties, exposure, ROI, stages, processing, experiments, recording sessions, frame transfer, and analysis. ScopeOne remains the authority for parameter validation and hardware read-back, and MCP tool calls are visible in the desktop UI through the same application state used by manual controls. +The MCP tool set mirrors the Local API operation catalog, including system state, configuration, preview layers, automatic display levels, source alignment, markups, device properties, exposure, ROI, stages, stage mosaics, processing, experiments, recording sessions, frame transfer, and analysis. Agents can read the current frame of any image layer, monitor live acquisition and writer progress, and optionally export or display particle masks. ScopeOne remains the authority for parameter validation and hardware read-back, and MCP tool calls are visible in the desktop UI through the same application state used by manual controls. ## 🔬 Tested Devices - Yokogawa CSU X1 diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index 7d701f1..70ae169 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -112,6 +112,45 @@ namespace scopeone::core QString gallerySaveDir; }; + enum class StageMosaicState + { + Idle, + Running, + Completed, + Canceled, + Failed + }; + + struct StageMosaicStatus + { + StageMosaicState state{StageMosaicState::Idle}; + int completedTiles{0}; + int totalTiles{0}; + QString message; + QString sessionId; + }; + + struct RecordingProgress + { + int phase{kRecordingPhaseIdle}; + qint64 frameCurrent{0}; + qint64 frameTarget{0}; + int burstCurrent{0}; + int burstTarget{0}; + qint64 waitRemainingMs{0}; + int timeIndex{0}; + int timeCount{0}; + int zIndex{0}; + int zCount{0}; + int positionIndex{0}; + int positionCount{0}; + bool hasXY{false}; + double x{0.0}; + double y{0.0}; + bool hasZ{false}; + double z{0.0}; + }; + class RecordingSaveResult { public: @@ -601,7 +640,7 @@ namespace scopeone::core QString* errorMessage = nullptr); void cancelStageMosaic(); bool isStageMosaicRunning() const; - + StageMosaicStatus stageMosaicStatus() const; QStringList xyStageDevices() const; QStringList zStageDevices() const; @@ -656,6 +695,8 @@ namespace scopeone::core void setRecordingMaxPendingWriteBytes(qint64 bytes); qint64 recordingMaxPendingWriteBytes() const; + RecordingProgress recordingProgress() const; + RecordingWriterStatus recordingWriterStatus() const; bool startRecording(const ExperimentPlan& plan, const QStringList& activeCameraIds); void stopRecording(); bool isRecording() const; diff --git a/ScopeOneCore/internal/StageMosaicManager.h b/ScopeOneCore/internal/StageMosaicManager.h index f5860d5..f7c0aa6 100644 --- a/ScopeOneCore/internal/StageMosaicManager.h +++ b/ScopeOneCore/internal/StageMosaicManager.h @@ -17,7 +17,11 @@ namespace scopeone::core::internal bool start(const ScopeOneCore::StageMosaicPlan& plan, QString* errorMessage); void cancel(); - bool isRunning() const { return m_running; } + bool isRunning() const + { + return m_status.state == ScopeOneCore::StageMosaicState::Running; + } + ScopeOneCore::StageMosaicStatus status() const { return m_status; } signals: void progressChanged(int completedTiles, int totalTiles, const QString& message); @@ -36,6 +40,7 @@ namespace scopeone::core::internal bool initializeMosaic(const ImageFrame& frame, QString& errorMessage); bool appendTile(const ImageFrame& frame, int row, int column); ImageFrame publishMosaicFrame(); + void reportProgress(int completedTiles, int totalTiles, const QString& message); void finish(bool success, bool canceled, const QString& message); ScopeOneCore* m_core{nullptr}; @@ -47,8 +52,8 @@ namespace scopeone::core::internal int m_currentTile{0}; int m_frameWaitSerial{0}; quint64 m_generation{0}; - bool m_running{false}; bool m_waitingForFrame{false}; bool m_startedPreview{false}; + ScopeOneCore::StageMosaicStatus m_status; }; } diff --git a/ScopeOneCore/python/scopeone/README.md b/ScopeOneCore/python/scopeone/README.md index f95e953..e1aa874 100644 --- a/ScopeOneCore/python/scopeone/README.md +++ b/ScopeOneCore/python/scopeone/README.md @@ -90,7 +90,7 @@ scopeone.close() The standalone C++ `ScopeOneMcpServer` included with the desktop app is the default integration for MCP-compatible agents. It does not use this Python package. This package remains available for scripts, notebooks, and custom Python-based adapters that intentionally use the Local API directly. -`capabilities()` returns `operationGroups`, `longRunningOperations`, `hardwareMutationOperations`, `filesystemMutationOperations`, and `destructiveOperations`. `state_snapshot()` returns application and configuration identity, hardware inventory, preview state, processing modules, image layers, markups, acquisition state, retained experiments, and recording sessions. It intentionally does not poll every hardware property; use `get_property(..., from_cache=False)`, position reads, exposure reads, or ROI reads when an action requires a fresh hardware value. +`capabilities()` returns `operationGroups`, `longRunningOperations`, `hardwareMutationOperations`, `filesystemMutationOperations`, and `destructiveOperations`. `state_snapshot()` returns application and configuration identity, hardware inventory, preview state, processing modules, image layers, markups, live acquisition and writer progress, retained experiments, and recording sessions. It intentionally does not poll every hardware property; use `get_property(..., from_cache=False)`, position reads, exposure reads, or ROI reads when an action requires a fresh hardware value. The Python client assigns a numeric `requestId` to every control request. The desktop app echoes it, and the client rejects a mismatched response. Raw protocol clients may provide their own string or numeric `requestId`. @@ -111,7 +111,9 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.stop_preview(camera="All")` - `ScopeOne.list_layers()` - `ScopeOne.get_layer_histogram(layer_key)` +- `ScopeOne.get_pixel_value(layer_key, x, y)` - `ScopeOne.get_line_profile(layer_key, x1, y1, x2, y2)` +- `ScopeOne.detect_particles(layer_key, threshold, min_area, max_area, max_particles=1000, export_mask=False, publish_mask=False)` - `ScopeOne.layer_options()` - `ScopeOne.set_layer_layout(layout)` - `ScopeOne.set_visible_layers(layer_keys)` @@ -121,6 +123,7 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.set_layer_auto_stretch(layer_key, enabled)` - `ScopeOne.get_source_display_transform(source_id)` - `ScopeOne.set_source_display_transform(source_id, offset_x=None, offset_y=None, zoom_percent=None, flip_x=None, flip_y=None)` +- `ScopeOne.reset_source_display_transform(source_id)` - `ScopeOne.move_layer(layer_key, offset)` - `ScopeOne.config_groups()` - `ScopeOne.configs(group)` @@ -154,6 +157,9 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.move_z_relative(dz, device=None)` - `ScopeOne.move_xy_to(x, y, device=None)` - `ScopeOne.move_z_to(z, device=None)` +- `ScopeOne.start_stage_mosaic(camera_id, xy_stage_id, rows=1, columns=1, pixel_size_um=1.0, step_x_um=0.0, step_y_um=0.0, settle_ms=150, return_to_start=True, gallery_save_dir=None)` +- `ScopeOne.stage_mosaic_status()` +- `ScopeOne.cancel_stage_mosaic()` - `ScopeOne.processing_state()` - `ScopeOne.processing_modules()` - `ScopeOne.set_processing_bit_depth(bit_depth)` @@ -181,6 +187,7 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.process_frame_mapping(camera=None, start_module_index=None, end_module_index=None)` - `ScopeOne.process_image(image, camera="python", bits_per_sample=None, start_module_index=None, end_module_index=None)` - `ScopeOne.latest_raw_frame(camera)` +- `ScopeOne.layer_frame(layer_key)` - `ScopeOne.show_frame_mapping_as_layer(layer_id="python_result", name="Python Result", camera=None)` - `ScopeOne.save_frame_mapping(save_dir, base_name, format="tiff", compression=False, compression_level=6, camera=None)` - `ScopeOne.continue_pipeline(frame, image=None)` @@ -218,9 +225,9 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `ping`: health check. - `version`: response `version` for ScopeOne and `coreVersion` for ScopeOneCore. -- `status`: response `version`, `coreVersion`, cameras, devices, running previews, processing state, and layer keys. +- `status`: response `version`, `coreVersion`, cameras, devices, running previews, processing state, layer keys, Stage Mosaic status, recording progress, and writer status. - `capabilities`: response `capabilities` with operation groups and hardware, filesystem, destructive, and long-running operation classifications. -- `state_snapshot`: response `snapshot` with application and Core versions, configuration, hardware inventory, preview, processing, scene, experiment, and session state. +- `state_snapshot`: response `snapshot` with application and Core versions, configuration, hardware inventory, preview, processing, scene, live acquisition and writer progress, experiment, and session state. - `frame_mapping_info`: response `mappingName`, `mappingSize`, `headerBytes`, `maxPayloadBytes`, and supported `pixelFormats`. - `load_config`: fields `configPath`; response `cameraIds`. - `unload_config`: unload current Micro-Manager config. @@ -230,7 +237,9 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `stop_preview`: fields `camera`, accepts a camera id or `"All"`. - `list_layers`: response `layers`. - `get_layer_histogram`: fields `layerKey`; response `histogram` with summary statistics and 256 `bins`. +- `get_pixel_value`: fields `layerKey`, `x`, `y`; response `value`. - `get_line_profile`: fields `layerKey`, `x1`, `y1`, `x2`, `y2`; response `values`. +- `detect_particles`: fields `layerKey`, `threshold`, `minArea`, `maxArea`, optional `maxParticles`, `exportMask`, and `publishMask`; response `particleCount`, effective thresholds, truncation state, particle measurements, optional shared-memory `mask` metadata, and optional `maskLayerKey`. - `layer_options`: response `layouts`, `colormaps`, `blendingModes`. - `set_layer_layout`: fields `layout`, accepts `side_by_side` or `overlay`. - `set_visible_layers`: fields `layerKeys`; response `visibleLayers`. @@ -240,6 +249,7 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `set_layer_auto_stretch`: fields `layerKey`, `enabled`; continuously updates display levels from new histograms. - `get_source_display_transform`: fields `sourceId`; returns source offset, zoom, and flip state. - `set_source_display_transform`: fields `sourceId` and optional `offsetX`, `offsetY`, `zoomPercent`, `flipX`, `flipY`. +- `reset_source_display_transform`: fields `sourceId`; restores zero offset, 100 percent zoom, and no flips. - `move_layer`: fields `layerKey`, `offset`; response ordered `layers`. - `config_groups`: response `groups`. - `configs`: fields `group`; response `configs`. @@ -273,6 +283,9 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `move_z_relative`: fields `device`, `dz`. - `move_xy_to`: fields `device`, `x`, `y`. - `move_z_to`: fields `device`, `z`. +- `start_stage_mosaic`: fields `cameraId`, `xyStageId`, and optional `rows`, `columns`, `pixelSizeUm`, `stepXUm`, `stepYUm`, `settleMs`, `returnToStart`, and `gallerySaveDir`; starts asynchronous mosaic acquisition and returns `status`. `gallerySaveDir` becomes the default directory if the resulting Gallery session is saved later. +- `stage_mosaic_status`: response `status` with `state`, tile progress, message, and completed session ID. +- `cancel_stage_mosaic`: cancels the running mosaic and returns its final `status`. - `processing_modules`: response `bitDepth`, `realTime`, and `modules`. - `set_processing_bit_depth`: fields `bitDepth`, accepts `8` or `16`. - `set_realtime_processing`: fields `enabled`. @@ -285,13 +298,14 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `save_experiment`: fields `filePath`, `document`; validates and atomically saves the document. - `load_experiment`: field `filePath`; replaces the shared UI document when no experiment is running and responds with `document`. - `start_experiment`: field `document`; starts a validated Draft asynchronously and responds with `experimentId`, `state`, and `document`. -- `experiment_status`: field `experimentId`; response `state`, `cancelRequested`, `document`, and completed recording session details when available. +- `experiment_status`: field `experimentId`; response `state`, `cancelRequested`, `document`, live `progress` and `writer` state while active, and completed recording session details when available. - `cancel_experiment`: field `experimentId`; requests cancellation and returns the current experiment status. - `record`: fields `frames`, `camera`, `timeoutMs`, `mdaIntervalMs`, `zPositions`, `positions`, `order`; response `sessionId`, `cameraIds`. - `session_info`: fields `sessionId`; response `cameraIds`, `frameCount`, `frameCounts`. - `session_close`: fields `sessionId`; releases the recorded session held by the app. - `session_frame`: fields `sessionId`, `camera`, `index`; response `mappingName`, `mappingSize`, and frame metadata. - `latest_raw_frame`: fields `camera`; response `mappingName`, `mappingSize`, and frame metadata. +- `layer_frame`: field `layerKey`; exports the current raw, processed, static, mosaic, or Gallery layer frame and returns shared-memory metadata. - `session_process_frame`: fields `sessionId`, `camera`, `index`, optional `startModuleIndex` or `endModuleIndex`; response `mappingName`, `mappingSize`, frame metadata, and optional stage metadata. - `process_frame_mapping`: optional fields `camera`, `startModuleIndex` or `endModuleIndex`; response `mappingName`, `mappingSize`, frame metadata, and optional stage metadata. - `show_frame_mapping_as_layer`: optional fields `camera`, `layerId`, `name`; imports the current shared memory frame as a preview layer and returns `layerKey`. @@ -330,9 +344,9 @@ Processing module editing follows the desktop UI rules: stop real-time processin - Python reads this through `scopeone.shm.frame_to_ndarray()` - Responses include `camera`, `width`, `height`, `stride`, string `payloadBytes`, `pixelFormat`, `bitsPerSample`, string `frameIndex`, string `timestampNs`, `sourceRoiX`, `sourceRoiY`, `sourceRoiWidth`, `sourceRoiHeight`, and `sourceRoiValid`. - Session frames can come from memory, saved TIFF stacks, or saved binary streams. -- `ScopeOne.latest_raw_frame(...)`, `RecordingSession.frame(...)`, `RecordingSession.frames(...)`, `RecordingSession.process_frame(...)`, and `ScopeOne.process_frame_mapping(...)` return `FrameResult` objects. +- `ScopeOne.latest_raw_frame(...)`, `ScopeOne.layer_frame(...)`, `RecordingSession.frame(...)`, `RecordingSession.frames(...)`, `RecordingSession.process_frame(...)`, and `ScopeOne.process_frame_mapping(...)` return `FrameResult` objects. -`latest_raw_frame` exports the current live raw frame for Python processing. `session_process_frame` reads a stored session frame, processes it through the current pipeline, and writes the result into the same shared memory block. Use `endModuleIndex` to stop after one stage. The response then includes `moduleIndex` and `nextModuleIndex`. Pass the edited numpy array to `ScopeOne.continue_pipeline(frame, image=edited_image)` to write it back and continue with the next pipeline stage. Pass a frame and optional edited image to `ScopeOne.show_frame(frame, image=edited_image)` to display it in the ScopeOne preview as a static layer. Use `ScopeOne.save_frame(frame, save_dir, base_name, image=edited_image)` to save the current Python/C++ result directly. `FrameResult.write()` requires the shared mapping to still contain the same frame metadata, so request the frame again after another frame export overwrites the mapping. `process_frame_mapping`, `show_frame_mapping_as_layer`, and `save_frame_mapping` reuse the last exported or explicitly imported camera id when `camera` is omitted. Provide `camera` the first time a mapping was written by an external client. +`latest_raw_frame` exports the current live raw frame for Python processing, while `layer_frame` accepts any key returned by `list_layers`. Particle detection can return its mask as a `FrameResult` with `export_mask=True` or publish the mask directly with `publish_mask=True`. `session_process_frame` reads a stored session frame, processes it through the current pipeline, and writes the result into the same shared memory block. Use `endModuleIndex` to stop after one stage. The response then includes `moduleIndex` and `nextModuleIndex`. Pass the edited numpy array to `ScopeOne.continue_pipeline(frame, image=edited_image)` to write it back and continue with the next pipeline stage. Pass a frame and optional edited image to `ScopeOne.show_frame(frame, image=edited_image)` to display it in the ScopeOne preview as a static layer. Use `ScopeOne.save_frame(frame, save_dir, base_name, image=edited_image)` to save the current Python/C++ result directly. `FrameResult.write()` requires the shared mapping to still contain the same frame metadata, so request the frame again after another frame export overwrites the mapping. `process_frame_mapping`, `show_frame_mapping_as_layer`, and `save_frame_mapping` reuse the last exported or explicitly imported camera id when `camera` is omitted. Provide `camera` the first time a mapping was written by an external client. Python can also publish a new numpy image without first requesting a ScopeOne frame. `ScopeOne.write_frame_mapping(...)` writes a 2D mono array into the shared frame mapping and returns a `FrameResult`; `ScopeOne.show_image(...)`, `ScopeOne.process_image(...)`, and `ScopeOne.save_image(...)` are convenience wrappers around that path. diff --git a/ScopeOneCore/python/scopeone/src/scopeone/client.py b/ScopeOneCore/python/scopeone/src/scopeone/client.py index 18bee88..fa34c0b 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/client.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/client.py @@ -372,6 +372,9 @@ def status(self) -> dict: "layers": list(response.get("layers", [])), "visibleLayers": list(response.get("visibleLayers", [])), "layerLayout": str(response.get("layerLayout", "")), + "stageMosaic": dict(response.get("stageMosaic", {})), + "recordingProgress": dict(response.get("recordingProgress", {})), + "recordingWriter": dict(response.get("recordingWriter", {})), } def capabilities(self) -> dict: @@ -411,6 +414,17 @@ def get_layer_histogram(self, layer_key: str) -> dict: ) return dict(response["histogram"]) + def get_pixel_value(self, layer_key: str, x: int, y: int) -> int: + response = self._request( + { + "type": "get_pixel_value", + "layerKey": layer_key, + "x": int(x), + "y": int(y), + } + ) + return int(response["value"]) + def get_line_profile( self, layer_key: str, @@ -431,6 +445,43 @@ def get_line_profile( ) return [int(value) for value in response.get("values", [])] + def detect_particles( + self, + layer_key: str, + threshold: int, + min_area: int, + max_area: int, + max_particles: int = 1000, + export_mask: bool = False, + publish_mask: bool = False, + ) -> dict: + response = self._request( + { + "type": "detect_particles", + "layerKey": layer_key, + "threshold": int(threshold), + "minArea": int(min_area), + "maxArea": int(max_area), + "maxParticles": int(max_particles), + "exportMask": bool(export_mask), + "publishMask": bool(publish_mask), + } + ) + result = { + "layerKey": str(response.get("layerKey", "")), + "threshold": int(response.get("threshold", 0)), + "minArea": int(response.get("minArea", 0)), + "maxArea": int(response.get("maxArea", 0)), + "particleCount": int(response.get("particleCount", 0)), + "truncated": bool(response.get("truncated", False)), + "particles": list(response.get("particles", [])), + } + if "maskLayerKey" in response: + result["maskLayerKey"] = str(response["maskLayerKey"]) + if "mask" in response: + result["mask"] = self._frame_result_from_mapping_response(dict(response["mask"])) + return result + def layer_options(self) -> dict: response = self._request({"type": "layer_options"}) return { @@ -554,6 +605,16 @@ def set_source_display_transform( request["flipY"] = bool(flip_y) return dict(self._request(request)) + def reset_source_display_transform(self, source_id: str) -> dict: + return dict( + self._request( + { + "type": "reset_source_display_transform", + "sourceId": source_id, + } + ) + ) + def move_layer(self, layer_key: str, offset: int) -> list[str]: response = self._request( { @@ -879,6 +940,44 @@ def move_z_to(self, z: float, device: str | None = None) -> None: } ) + def start_stage_mosaic( + self, + camera_id: str, + xy_stage_id: str, + rows: int = 1, + columns: int = 1, + pixel_size_um: float = 1.0, + step_x_um: float = 0.0, + step_y_um: float = 0.0, + settle_ms: int = 150, + return_to_start: bool = True, + gallery_save_dir: str | None = None, + ) -> dict: + request = { + "type": "start_stage_mosaic", + "cameraId": camera_id, + "xyStageId": xy_stage_id, + "rows": int(rows), + "columns": int(columns), + "pixelSizeUm": float(pixel_size_um), + "stepXUm": float(step_x_um), + "stepYUm": float(step_y_um), + "settleMs": int(settle_ms), + "returnToStart": bool(return_to_start), + } + if gallery_save_dir is not None: + request["gallerySaveDir"] = gallery_save_dir + response = self._request(request) + return dict(response.get("status", {})) + + def stage_mosaic_status(self) -> dict: + response = self._request({"type": "stage_mosaic_status"}) + return dict(response.get("status", {})) + + def cancel_stage_mosaic(self) -> dict: + response = self._request({"type": "cancel_stage_mosaic"}) + return dict(response.get("status", {})) + def processing_state(self) -> dict: response = self._request({"type": "processing_modules"}) return { @@ -1110,6 +1209,15 @@ def latest_raw_frame(self, camera: str) -> FrameResult: ) return self._frame_result_from_mapping_response(response) + def layer_frame(self, layer_key: str) -> FrameResult: + response = self._request( + { + "type": "layer_frame", + "layerKey": layer_key, + } + ) + return self._frame_result_from_mapping_response(response) + def show_frame_mapping_as_layer( self, layer_id: str = "python_result", @@ -1223,6 +1331,10 @@ def _experiment_status_result(response: dict) -> dict: result["sessionId"] = str(response["sessionId"]) result["cameraIds"] = list(response.get("cameraIds", [])) result["frameCount"] = int(response.get("frameCount", 0)) + if "progress" in response: + result["progress"] = dict(response["progress"]) + if "writer" in response: + result["writer"] = dict(response["writer"]) return result def _frame_result_from_mapping_response(self, response: dict) -> FrameResult: diff --git a/ScopeOneCore/python/scopeone/src/scopeone/core.py b/ScopeOneCore/python/scopeone/src/scopeone/core.py index eb0a6f8..9bfdf5b 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/core.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/core.py @@ -60,6 +60,9 @@ def list_layers(self): def get_layer_histogram(self, layer_key: str): return self._client.get_layer_histogram(layer_key) + def get_pixel_value(self, layer_key: str, x: int, y: int): + return self._client.get_pixel_value(layer_key, x, y) + def get_line_profile( self, layer_key: str, @@ -70,6 +73,26 @@ def get_line_profile( ): return self._client.get_line_profile(layer_key, x1, y1, x2, y2) + def detect_particles( + self, + layer_key: str, + threshold: int, + min_area: int, + max_area: int, + max_particles: int = 1000, + export_mask: bool = False, + publish_mask: bool = False, + ): + return self._client.detect_particles( + layer_key, + threshold, + min_area, + max_area, + max_particles, + export_mask, + publish_mask, + ) + def layer_options(self): return self._client.layer_options() @@ -129,6 +152,9 @@ def set_source_display_transform( flip_y, ) + def reset_source_display_transform(self, source_id: str): + return self._client.reset_source_display_transform(source_id) + def move_layer(self, layer_key: str, offset: int): return self._client.move_layer(layer_key, offset) @@ -253,6 +279,38 @@ def move_xy_to(self, x: float, y: float, device: str | None = None): def move_z_to(self, z: float, device: str | None = None): self._client.move_z_to(z, device) + def start_stage_mosaic( + self, + camera_id: str, + xy_stage_id: str, + rows: int = 1, + columns: int = 1, + pixel_size_um: float = 1.0, + step_x_um: float = 0.0, + step_y_um: float = 0.0, + settle_ms: int = 150, + return_to_start: bool = True, + gallery_save_dir: str | None = None, + ): + return self._client.start_stage_mosaic( + camera_id, + xy_stage_id, + rows, + columns, + pixel_size_um, + step_x_um, + step_y_um, + settle_ms, + return_to_start, + gallery_save_dir, + ) + + def stage_mosaic_status(self): + return self._client.stage_mosaic_status() + + def cancel_stage_mosaic(self): + return self._client.cancel_stage_mosaic() + def processing_state(self): return self._client.processing_state() @@ -333,6 +391,9 @@ def process_image( def latest_raw_frame(self, camera: str): return self._client.latest_raw_frame(camera) + def layer_frame(self, layer_key: str): + return self._client.layer_frame(layer_key) + def show_frame_mapping_as_layer( self, layer_id: str = "python_result", diff --git a/ScopeOneCore/src/ScopeOneCore.cpp b/ScopeOneCore/src/ScopeOneCore.cpp index 570989f..25522f8 100644 --- a/ScopeOneCore/src/ScopeOneCore.cpp +++ b/ScopeOneCore/src/ScopeOneCore.cpp @@ -1178,6 +1178,8 @@ namespace scopeone::core QString activeExperimentId; QStringList experimentStartedPreviewCameraIds; bool experimentCancelRequested{false}; + RecordingProgress recordingProgress; + RecordingWriterStatus recordingWriterStatus; }; // Return the compiled core version string @@ -1287,6 +1289,8 @@ namespace scopeone::core m_managers->recordingManager = new RecordingManager(this); m_managers->imageProcessingManager = new ImageProcessingManager(this); m_managers->stageMosaicManager = new StageMosaicManager(this, this); + m_managers->recordingWriterStatus.reset( + m_managers->recordingManager->recordedMaxBytes()); m_managers->recordingManager->setMultiProcessCameraManager(m_managers->mpcm); m_managers->recordingManager->setMMCore(m_managers->mmcoreManager->getCore()); m_managers->recordingManager->setLatestFrameFetcher( @@ -1310,9 +1314,67 @@ namespace scopeone::core this, &ScopeOneCore::handleIncomingRawFrame, Qt::QueuedConnection); connect(m_managers->recordingManager, &RecordingManager::progressChanged, - this, &ScopeOneCore::recordingProgressChanged); + this, + [this](int phase, + qint64 frameCurrent, + qint64 frameTarget, + int burstCurrent, + int burstTarget, + qint64 waitRemainingMs, + int timeIndex, + int timeCount, + int zIndex, + int zCount, + int positionIndex, + int positionCount, + bool hasXY, + double x, + double y, + bool hasZ, + double z) + { + RecordingProgress& progress = m_managers->recordingProgress; + progress.phase = phase; + progress.frameCurrent = frameCurrent; + progress.frameTarget = frameTarget; + progress.burstCurrent = burstCurrent; + progress.burstTarget = burstTarget; + progress.waitRemainingMs = waitRemainingMs; + progress.timeIndex = timeIndex; + progress.timeCount = timeCount; + progress.zIndex = zIndex; + progress.zCount = zCount; + progress.positionIndex = positionIndex; + progress.positionCount = positionCount; + progress.hasXY = hasXY; + progress.x = x; + progress.y = y; + progress.hasZ = hasZ; + progress.z = z; + emit recordingProgressChanged(phase, + frameCurrent, + frameTarget, + burstCurrent, + burstTarget, + waitRemainingMs, + timeIndex, + timeCount, + zIndex, + zCount, + positionIndex, + positionCount, + hasXY, + x, + y, + hasZ, + z); + }); connect(m_managers->recordingManager, &RecordingManager::writerStatusChanged, - this, &ScopeOneCore::recordingWriterStatusChanged); + this, [this](const RecordingWriterStatus& status) + { + m_managers->recordingWriterStatus = status; + emit recordingWriterStatusChanged(status); + }); connect(m_managers->recordingManager, &RecordingManager::recordingStateChanged, this, &ScopeOneCore::recordingStateChanged); connect(m_managers->recordingManager, &RecordingManager::recordingStopped, @@ -2319,6 +2381,11 @@ namespace scopeone::core return m_managers->stageMosaicManager->isRunning(); } + ScopeOneCore::StageMosaicStatus ScopeOneCore::stageMosaicStatus() const + { + return m_managers->stageMosaicManager->status(); + } + // Schedule throttled histogram work for a layer void ScopeOneCore::scheduleHistogramStats(const QString& cameraId, bool processed, @@ -3362,6 +3429,9 @@ namespace scopeone::core void ScopeOneCore::setRecordingMaxPendingWriteBytes(qint64 bytes) { m_managers->recordingManager->setRecordedMaxBytes(bytes); + m_managers->recordingWriterStatus.setMaxPendingWriteBytes( + m_managers->recordingManager->recordedMaxBytes()); + emit recordingWriterStatusChanged(m_managers->recordingWriterStatus); } qint64 ScopeOneCore::recordingMaxPendingWriteBytes() const @@ -3369,11 +3439,24 @@ namespace scopeone::core return m_managers->recordingManager->recordedMaxBytes(); } + // Return the latest recording progress snapshot + ScopeOneCore::RecordingProgress ScopeOneCore::recordingProgress() const + { + return m_managers->recordingProgress; + } + + // Return the latest recording writer snapshot + ScopeOneCore::RecordingWriterStatus ScopeOneCore::recordingWriterStatus() const + { + return m_managers->recordingWriterStatus; + } + // Start recording and suspend preview during MDA motion bool ScopeOneCore::startRecording(const ExperimentPlan& plan, const QStringList& activeCameraIds) { if (m_managers->recordingManager->isRecording() - || !m_managers->activeExperimentId.isEmpty()) + || !m_managers->activeExperimentId.isEmpty() + || isStageMosaicRunning()) { return false; } @@ -3391,6 +3474,9 @@ namespace scopeone::core { return false; } + m_managers->recordingProgress = RecordingProgress{}; + m_managers->recordingWriterStatus.reset(recordingMaxPendingWriteBytes()); + emit recordingWriterStatusChanged(m_managers->recordingWriterStatus); for (const QString& cameraId : activeCameraIds) { const QString normalizedCameraId = cameraId.trimmed(); @@ -3502,9 +3588,11 @@ namespace scopeone::core } const QString experimentId = document.plan.experimentId.trimmed(); - if (isRecording() || !m_managers->activeExperimentId.isEmpty()) + if (isRecording() + || !m_managers->activeExperimentId.isEmpty() + || isStageMosaicRunning()) { - if (errorMessage) *errorMessage = QStringLiteral("Another recording is already running"); + if (errorMessage) *errorMessage = QStringLiteral("Another acquisition is already running"); return false; } if (m_managers->experiments.contains(experimentId)) diff --git a/ScopeOneCore/src/StageMosaicManager.cpp b/ScopeOneCore/src/StageMosaicManager.cpp index 6790fc0..d0b23ca 100644 --- a/ScopeOneCore/src/StageMosaicManager.cpp +++ b/ScopeOneCore/src/StageMosaicManager.cpp @@ -56,11 +56,16 @@ namespace scopeone::core::internal bool StageMosaicManager::start(const ScopeOneCore::StageMosaicPlan& plan, QString* errorMessage) { - if (m_running) + if (isRunning()) { if (errorMessage) *errorMessage = QStringLiteral("A stage mosaic is already running"); return false; } + if (m_core->isRecording() || !m_core->activeExperimentId().isEmpty()) + { + if (errorMessage) *errorMessage = QStringLiteral("Another acquisition is already running"); + return false; + } ScopeOneCore::StageMosaicPlan normalized = plan; normalized.cameraId = normalized.cameraId.trimmed(); @@ -107,9 +112,10 @@ namespace scopeone::core::internal m_storage->count.release(); m_storage->tileWidth = 0; m_storage->tileHeight = 0; - m_running = true; + m_status = ScopeOneCore::StageMosaicStatus{}; + m_status.state = ScopeOneCore::StageMosaicState::Running; const quint64 generation = ++m_generation; - emit progressChanged(0, static_cast(tileCount), QStringLiteral("Starting mosaic capture")); + reportProgress(0, static_cast(tileCount), QStringLiteral("Starting mosaic capture")); QTimer::singleShot(qMax(50, m_plan.settleMs), this, [this, generation]() { captureNextTile(generation); }); return true; @@ -117,7 +123,7 @@ namespace scopeone::core::internal void StageMosaicManager::cancel() { - if (m_running) + if (isRunning()) { finish(false, true, QStringLiteral("Mosaic stopped")); } @@ -125,7 +131,7 @@ namespace scopeone::core::internal void StageMosaicManager::captureNextTile(quint64 generation) { - if (!m_running || generation != m_generation) + if (!isRunning() || generation != m_generation) { return; } @@ -140,11 +146,11 @@ namespace scopeone::core::internal const int column = m_currentTile % m_plan.columns; const double x = m_originX + column * m_plan.stepXUm; const double y = m_originY + row * m_plan.stepYUm; - emit progressChanged(m_currentTile, - totalTiles, - QStringLiteral("Moving to tile %1 of %2") - .arg(m_currentTile + 1) - .arg(totalTiles)); + reportProgress(m_currentTile, + totalTiles, + QStringLiteral("Moving to tile %1 of %2") + .arg(m_currentTile + 1) + .arg(totalTiles)); if (!m_core->moveXYTo(m_plan.xyStageId, x, y)) { finish(false, false, QStringLiteral("Stage move failed")); @@ -156,18 +162,18 @@ namespace scopeone::core::internal void StageMosaicManager::waitForTileFrame(quint64 generation) { - if (!m_running || generation != m_generation) + if (!isRunning() || generation != m_generation) { return; } m_waitingForFrame = true; const int waitSerial = ++m_frameWaitSerial; - emit progressChanged(m_currentTile, - m_plan.rows * m_plan.columns, - QStringLiteral("Waiting for camera frame")); + reportProgress(m_currentTile, + m_plan.rows * m_plan.columns, + QStringLiteral("Waiting for camera frame")); QTimer::singleShot(kFrameTimeoutMs, this, [this, generation, waitSerial]() { - if (m_running + if (isRunning() && generation == m_generation && m_waitingForFrame && waitSerial == m_frameWaitSerial) @@ -179,7 +185,7 @@ namespace scopeone::core::internal void StageMosaicManager::handleRawFrame(const ImageFrame& frame) { - if (!m_running || !m_waitingForFrame || frame.cameraId != m_plan.cameraId) + if (!isRunning() || !m_waitingForFrame || frame.cameraId != m_plan.cameraId) { return; } @@ -207,11 +213,11 @@ namespace scopeone::core::internal } emit frameUpdated(mosaicFrame); ++m_currentTile; - emit progressChanged(m_currentTile, - m_plan.rows * m_plan.columns, - QStringLiteral("Captured tile %1 of %2") - .arg(m_currentTile) - .arg(m_plan.rows * m_plan.columns)); + reportProgress(m_currentTile, + m_plan.rows * m_plan.columns, + QStringLiteral("Captured tile %1 of %2") + .arg(m_currentTile) + .arg(m_plan.rows * m_plan.columns)); const quint64 generation = m_generation; QTimer::singleShot(0, this, [this, generation]() { captureNextTile(generation); }); @@ -314,16 +320,36 @@ namespace scopeone::core::internal QStringLiteral("Stage Mosaic %1").arg(m_plan.cameraId)); } + void StageMosaicManager::reportProgress(int completedTiles, + int totalTiles, + const QString& message) + { + m_status.completedTiles = completedTiles; + m_status.totalTiles = totalTiles; + m_status.message = message; + emit progressChanged(completedTiles, totalTiles, message); + } + void StageMosaicManager::finish(bool success, bool canceled, const QString& message) { - if (!m_running) + if (!isRunning()) { return; } - m_running = false; m_waitingForFrame = false; ++m_generation; - + if (canceled) + { + m_status.state = ScopeOneCore::StageMosaicState::Canceled; + } + else if (success) + { + m_status.state = ScopeOneCore::StageMosaicState::Completed; + } + else + { + m_status.state = ScopeOneCore::StageMosaicState::Failed; + } std::shared_ptr session; QString finalMessage = message; if (success) @@ -341,6 +367,11 @@ namespace scopeone::core::internal if (!session) { finalMessage = QStringLiteral("Mosaic completed but the gallery session could not be created"); + m_status.state = ScopeOneCore::StageMosaicState::Failed; + } + else + { + m_status.sessionId = session->capturePlan().experimentId; } } @@ -353,6 +384,7 @@ namespace scopeone::core::internal m_core->stopPreview(m_plan.cameraId); } m_startedPreview = false; + reportProgress(m_currentTile, m_plan.rows * m_plan.columns, finalMessage); emit finished(session, finalMessage, canceled); } } diff --git a/src/ImageToolsDialog.cpp b/src/ImageToolsDialog.cpp index 5994003..6c8d93f 100644 --- a/src/ImageToolsDialog.cpp +++ b/src/ImageToolsDialog.cpp @@ -62,30 +62,19 @@ namespace scopeone::ui { m_statusLabel->setText(message); }); - connect(m_core, &ScopeOneCore::stageMosaicFrameUpdated, - this, [this](const ImageFrame&) - { - const QString layerKey = ScopeOneCore::staticLayerKey(QStringLiteral("stage_mosaic")); - m_core->imageSceneModel()->setLayerColormap(layerKey, QStringLiteral("Gray")); - m_core->imageSceneModel()->setLayerBlending(layerKey, QStringLiteral("Opaque")); - m_core->imageSceneModel()->setVisibleLayers({layerKey}); - m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); - }); connect(m_core, &ScopeOneCore::stageMosaicFinished, - this, [this](const std::shared_ptr& session, - const QString& message, + this, [this](const std::shared_ptr&, + const QString&, bool) { setMosaicRunning(false); - m_statusLabel->setText(message); - if (session) - { - emit gallerySessionCreated( - session, - tr("Stage Mosaic %1").arg(m_activeCameraId)); - m_statusLabel->setText(tr("Mosaic complete and added to Gallery")); - } }); + const ScopeOneCore::StageMosaicStatus status = m_core->stageMosaicStatus(); + if (status.state == ScopeOneCore::StageMosaicState::Running) + { + setMosaicRunning(true); + m_statusLabel->setText(status.message); + } } // Stop active capture before closing the dialog diff --git a/src/ImageToolsDialog.h b/src/ImageToolsDialog.h index 282c854..85823eb 100644 --- a/src/ImageToolsDialog.h +++ b/src/ImageToolsDialog.h @@ -4,7 +4,6 @@ #include #include -#include class QCheckBox; class QComboBox; @@ -29,11 +28,6 @@ namespace scopeone::ui void reject() override; - signals: - void gallerySessionCreated( - const std::shared_ptr& session, - const QString& title); - private: void setupUI(); void refreshDevices(); diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 6a7ebd6..b01257d 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -570,6 +570,37 @@ namespace scopeone::ui connect(m_settingsAction, &QAction::triggered, this, &MainWindow::openSettingsDialog); + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::stageMosaicFrameUpdated, + this, [this](const scopeone::core::ImageFrame&) + { + const QString layerKey = + scopeone::core::ScopeOneCore::staticLayerKey(QStringLiteral("stage_mosaic")); + m_imageSceneModel->setLayerColormap(layerKey, QStringLiteral("Gray")); + m_imageSceneModel->setLayerBlending(layerKey, QStringLiteral("Opaque")); + m_imageSceneModel->setVisibleLayers({layerKey}); + m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); + }); + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::stageMosaicFinished, + this, + [this](const std::shared_ptr& session, + const QString& message, + bool) + { + if (!session) + { + showStatusMessage(message, 8000); + return; + } + const QString title = tr("Stage Mosaic %1").arg(session->cameraIds().value(0)); + m_imageGalleryWidget->addSession(session, title); + m_scopeonecore->removeStaticFrame(QStringLiteral("stage_mosaic")); + if (!previewGallerySession(*m_scopeonecore, *m_previewWidget, session).isEmpty()) + { + registerGallerySessionFrameControls(session, 0); + } + showStatusMessage(tr("Mosaic added to Gallery"), 5000); + }); + connect(m_recordingWidget, &RecordingWidget::gallerySessionCaptured, this, [this](const std::shared_ptr& session) @@ -1235,20 +1266,6 @@ namespace scopeone::ui if (!m_stageMosaicDialog) { auto* dialog = new StageMosaicDialog(m_scopeonecore, m_previewWidget, this); - connect(dialog, - &StageMosaicDialog::gallerySessionCreated, - this, - [this](const std::shared_ptr& session, - const QString& title) - { - m_imageGalleryWidget->addSession(session, title); - m_scopeonecore->removeStaticFrame(QStringLiteral("stage_mosaic")); - if (!previewGallerySession(*m_scopeonecore, *m_previewWidget, session).isEmpty()) - { - registerGallerySessionFrameControls(session, 0); - } - showStatusMessage(tr("Mosaic added to Gallery"), 5000); - }); dialog->setAttribute(Qt::WA_DeleteOnClose); dialog->setModal(false); m_stageMosaicDialog = dialog; diff --git a/src/ScopeOneLocalApiServer.cpp b/src/ScopeOneLocalApiServer.cpp index 566d193..f9b31a5 100644 --- a/src/ScopeOneLocalApiServer.cpp +++ b/src/ScopeOneLocalApiServer.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -286,6 +287,133 @@ namespace scopeone::ui return object; } + QString stageMosaicStateName(scopeone::core::ScopeOneCore::StageMosaicState state) + { + using State = scopeone::core::ScopeOneCore::StageMosaicState; + switch (state) + { + case State::Idle: + return QStringLiteral("idle"); + case State::Running: + return QStringLiteral("running"); + case State::Completed: + return QStringLiteral("completed"); + case State::Canceled: + return QStringLiteral("canceled"); + case State::Failed: + return QStringLiteral("failed"); + } + return QStringLiteral("failed"); + } + + QJsonObject stageMosaicStatusToJson( + const scopeone::core::ScopeOneCore::StageMosaicStatus& status) + { + QJsonObject object; + object.insert(QStringLiteral("state"), stageMosaicStateName(status.state)); + object.insert(QStringLiteral("completedTiles"), status.completedTiles); + object.insert(QStringLiteral("totalTiles"), status.totalTiles); + object.insert(QStringLiteral("message"), status.message); + object.insert(QStringLiteral("sessionId"), status.sessionId); + return object; + } + + QString recordingPhaseName(int phase) + { + switch (phase) + { + case scopeone::core::kRecordingPhaseIdle: + return QStringLiteral("idle"); + case scopeone::core::kRecordingPhaseRecording: + return QStringLiteral("recording"); + case scopeone::core::kRecordingPhaseRecordingBurst: + return QStringLiteral("recording_burst"); + case scopeone::core::kRecordingPhaseRecordingMda: + return QStringLiteral("recording_mda"); + case scopeone::core::kRecordingPhaseWaitingNextBurst: + return QStringLiteral("waiting_next_burst"); + case scopeone::core::kRecordingPhaseStopped: + return QStringLiteral("stopped"); + } + return QStringLiteral("idle"); + } + + QJsonObject recordingProgressToJson( + const scopeone::core::ScopeOneCore::RecordingProgress& progress) + { + QJsonObject object; + object.insert(QStringLiteral("phase"), recordingPhaseName(progress.phase)); + object.insert(QStringLiteral("frameCurrent"), progress.frameCurrent); + object.insert(QStringLiteral("frameTarget"), progress.frameTarget); + object.insert(QStringLiteral("burstCurrent"), progress.burstCurrent); + object.insert(QStringLiteral("burstTarget"), progress.burstTarget); + object.insert(QStringLiteral("waitRemainingMs"), progress.waitRemainingMs); + object.insert(QStringLiteral("timeIndex"), progress.timeIndex); + object.insert(QStringLiteral("timeCount"), progress.timeCount); + object.insert(QStringLiteral("zIndex"), progress.zIndex); + object.insert(QStringLiteral("zCount"), progress.zCount); + object.insert(QStringLiteral("positionIndex"), progress.positionIndex); + object.insert(QStringLiteral("positionCount"), progress.positionCount); + object.insert(QStringLiteral("hasXY"), progress.hasXY); + object.insert(QStringLiteral("x"), progress.x); + object.insert(QStringLiteral("y"), progress.y); + object.insert(QStringLiteral("hasZ"), progress.hasZ); + object.insert(QStringLiteral("z"), progress.z); + return object; + } + + QString recordingWriterPhaseName( + scopeone::core::ScopeOneCore::RecordingWriterPhase phase) + { + using Phase = scopeone::core::ScopeOneCore::RecordingWriterPhase; + switch (phase) + { + case Phase::Idle: + return QStringLiteral("idle"); + case Phase::Starting: + return QStringLiteral("starting"); + case Phase::Writing: + return QStringLiteral("writing"); + case Phase::Stopping: + return QStringLiteral("stopping"); + case Phase::Completed: + return QStringLiteral("completed"); + case Phase::Failed: + return QStringLiteral("failed"); + } + return QStringLiteral("idle"); + } + + QJsonObject recordingWriterStatusToJson( + const scopeone::core::ScopeOneCore::RecordingWriterStatus& status) + { + QJsonObject object; + object.insert(QStringLiteral("phase"), recordingWriterPhaseName(status.phase())); + object.insert(QStringLiteral("pendingWriteBytes"), status.pendingWriteBytes()); + object.insert(QStringLiteral("maxPendingWriteBytes"), status.maxPendingWriteBytes()); + object.insert(QStringLiteral("framesWritten"), status.framesWritten()); + object.insert(QStringLiteral("error"), status.errorMessage()); + return object; + } + + QJsonObject particleMeasurementToJson( + const scopeone::core::ScopeOneCore::ParticleMeasurement& particle) + { + QJsonObject bounds; + bounds.insert(QStringLiteral("x"), particle.bounds.x()); + bounds.insert(QStringLiteral("y"), particle.bounds.y()); + bounds.insert(QStringLiteral("width"), particle.bounds.width()); + bounds.insert(QStringLiteral("height"), particle.bounds.height()); + QJsonObject centroid; + centroid.insert(QStringLiteral("x"), particle.centroid.x()); + centroid.insert(QStringLiteral("y"), particle.centroid.y()); + QJsonObject object; + object.insert(QStringLiteral("area"), particle.area); + object.insert(QStringLiteral("bounds"), bounds); + object.insert(QStringLiteral("centroid"), centroid); + return object; + } + // Converts one supported axis name into a recording axis bool axisFromName(const QString& name, scopeone::core::RecordingAxis& axis) { @@ -564,7 +692,8 @@ namespace scopeone::ui QStringLiteral("set_layer_display"), QStringLiteral("auto_layer_levels"), QStringLiteral("full_layer_levels"), QStringLiteral("set_layer_auto_stretch"), QStringLiteral("get_source_display_transform"), - QStringLiteral("set_source_display_transform"), QStringLiteral("move_layer"), + QStringLiteral("set_source_display_transform"), + QStringLiteral("reset_source_display_transform"), QStringLiteral("move_layer"), QStringLiteral("remove_static_layer"), QStringLiteral("clear_static_layers") })); groups.insert(QStringLiteral("markup"), QJsonArray::fromStringList(QStringList{ @@ -573,7 +702,8 @@ namespace scopeone::ui QStringLiteral("remove_markup"), QStringLiteral("clear_markups") })); groups.insert(QStringLiteral("analysis"), QJsonArray::fromStringList(QStringList{ - QStringLiteral("get_layer_histogram"), QStringLiteral("get_line_profile") + QStringLiteral("get_layer_histogram"), QStringLiteral("get_pixel_value"), + QStringLiteral("get_line_profile"), QStringLiteral("detect_particles") })); groups.insert(QStringLiteral("hardware"), QJsonArray::fromStringList(QStringList{ QStringLiteral("device_properties"), QStringLiteral("device_property_names"), @@ -587,6 +717,10 @@ namespace scopeone::ui QStringLiteral("move_xy_relative"), QStringLiteral("move_z_relative"), QStringLiteral("move_xy_to"), QStringLiteral("move_z_to") })); + groups.insert(QStringLiteral("mosaic"), QJsonArray::fromStringList(QStringList{ + QStringLiteral("start_stage_mosaic"), QStringLiteral("stage_mosaic_status"), + QStringLiteral("cancel_stage_mosaic") + })); groups.insert(QStringLiteral("processing"), QJsonArray::fromStringList(QStringList{ QStringLiteral("processing_modules"), QStringLiteral("set_processing_bit_depth"), QStringLiteral("set_realtime_processing"), QStringLiteral("add_processing_module"), @@ -607,7 +741,7 @@ namespace scopeone::ui QStringLiteral("session_save") })); groups.insert(QStringLiteral("frame"), QJsonArray::fromStringList(QStringList{ - QStringLiteral("process_frame_mapping"), + QStringLiteral("layer_frame"), QStringLiteral("process_frame_mapping"), QStringLiteral("show_frame_mapping_as_layer"), QStringLiteral("save_frame_mapping") })); @@ -622,7 +756,8 @@ namespace scopeone::ui scopeone::core::kExperimentDocumentSchemaVersion); object.insert(QStringLiteral("operationGroups"), groups); object.insert(QStringLiteral("longRunningOperations"), QJsonArray::fromStringList(QStringList{ - QStringLiteral("start_experiment"), QStringLiteral("record") + QStringLiteral("start_experiment"), QStringLiteral("record"), + QStringLiteral("start_stage_mosaic") })); object.insert(QStringLiteral("hardwareMutationOperations"), QJsonArray::fromStringList(QStringList{ QStringLiteral("load_config"), QStringLiteral("unload_config"), @@ -632,7 +767,8 @@ namespace scopeone::ui QStringLiteral("clear_roi"), QStringLiteral("move_xy_relative"), QStringLiteral("move_z_relative"), QStringLiteral("move_xy_to"), QStringLiteral("move_z_to"), QStringLiteral("start_experiment"), - QStringLiteral("cancel_experiment"), QStringLiteral("record") + QStringLiteral("cancel_experiment"), QStringLiteral("record"), + QStringLiteral("start_stage_mosaic"), QStringLiteral("cancel_stage_mosaic") })); object.insert(QStringLiteral("filesystemMutationOperations"), QJsonArray::fromStringList(QStringList{ QStringLiteral("save_experiment"), QStringLiteral("start_experiment"), @@ -643,7 +779,7 @@ namespace scopeone::ui QStringLiteral("clear_static_layers"), QStringLiteral("remove_markup"), QStringLiteral("clear_markups"), QStringLiteral("remove_processing_module"), QStringLiteral("load_experiment"), QStringLiteral("cancel_experiment"), - QStringLiteral("session_close") + QStringLiteral("session_close"), QStringLiteral("cancel_stage_mosaic") })); object.insert(QStringLiteral("frameTransport"), QStringLiteral("shared_memory")); return object; @@ -1135,6 +1271,12 @@ namespace scopeone::ui QJsonArray::fromStringList(m_sceneModel->visibleLayerIds())); response.insert(QStringLiteral("layerLayout"), layerLayoutModeName(m_previewWidget->layerLayoutMode())); + response.insert(QStringLiteral("stageMosaic"), + stageMosaicStatusToJson(m_scopeonecore->stageMosaicStatus())); + response.insert(QStringLiteral("recordingProgress"), + recordingProgressToJson(m_scopeonecore->recordingProgress())); + response.insert(QStringLiteral("recordingWriter"), + recordingWriterStatusToJson(m_scopeonecore->recordingWriterStatus())); return response; } @@ -1178,6 +1320,12 @@ namespace scopeone::ui const bool cancelRequested = m_scopeonecore->experimentCancelRequested(); acquisition.insert(QStringLiteral("activeExperimentId"), activeExperimentId); acquisition.insert(QStringLiteral("cancelRequested"), cancelRequested); + acquisition.insert(QStringLiteral("stageMosaic"), + stageMosaicStatusToJson(m_scopeonecore->stageMosaicStatus())); + acquisition.insert(QStringLiteral("progress"), + recordingProgressToJson(m_scopeonecore->recordingProgress())); + acquisition.insert(QStringLiteral("writer"), + recordingWriterStatusToJson(m_scopeonecore->recordingWriterStatus())); QJsonArray experiments; QStringList experimentIds = m_scopeonecore->experimentIds(); @@ -1349,6 +1497,30 @@ namespace scopeone::ui return response; } + if (type == QStringLiteral("get_pixel_value")) + { + const QString layerKey = request.value(QStringLiteral("layerKey")).toString().trimmed(); + int x = 0; + int y = 0; + int value = 0; + QJsonObject response = makeResponse(type, false); + if (layerKey.isEmpty() + || !intField(request, QStringLiteral("x"), x) + || !intField(request, QStringLiteral("y"), y) + || !m_scopeonecore->graphPixelValue(layerKey, QPoint(x, y), value)) + { + response.insert(QStringLiteral("error"), + QStringLiteral("Layer has no current frame or position is outside the image")); + return response; + } + response = makeResponse(type, true); + response.insert(QStringLiteral("layerKey"), layerKey); + response.insert(QStringLiteral("x"), x); + response.insert(QStringLiteral("y"), y); + response.insert(QStringLiteral("value"), value); + return response; + } + if (type == QStringLiteral("auto_layer_levels") || type == QStringLiteral("full_layer_levels")) { @@ -1441,6 +1613,119 @@ namespace scopeone::ui return response; } + if (type == QStringLiteral("detect_particles")) + { + const QString layerKey = request.value(QStringLiteral("layerKey")).toString().trimmed(); + int threshold = 0; + int minArea = 0; + int maxArea = 0; + int maxParticles = 1000; + const QJsonValue exportMaskValue = request.value(QStringLiteral("exportMask")); + const QJsonValue publishMaskValue = request.value(QStringLiteral("publishMask")); + QJsonObject response = makeResponse(type, false); + if (layerKey.isEmpty() + || !intField(request, QStringLiteral("threshold"), threshold) + || !intField(request, QStringLiteral("minArea"), minArea) + || !intField(request, QStringLiteral("maxArea"), maxArea) + || (request.contains(QStringLiteral("maxParticles")) + && !intField(request, QStringLiteral("maxParticles"), maxParticles)) + || threshold < 0 + || minArea <= 0 + || maxArea < minArea + || maxParticles <= 0 + || maxParticles > 10000 + || (!exportMaskValue.isUndefined() && !exportMaskValue.isBool()) + || (!publishMaskValue.isUndefined() && !publishMaskValue.isBool())) + { + response.insert(QStringLiteral("error"), QStringLiteral("Invalid particle detection parameters")); + return response; + } + + const scopeone::core::ImageFrame frame = m_scopeonecore->graphFrame(layerKey); + if (!frame.isValid() || (!frame.isMono8() && !frame.isMono16())) + { + response.insert(QStringLiteral("error"), + QStringLiteral("Layer has no supported current frame")); + return response; + } + threshold = qMin(threshold, frame.maxValue()); + + scopeone::core::ScopeOneCore::ParticleDetectionResult result; + if (!m_scopeonecore->detectParticles( + layerKey, threshold, minArea, maxArea, result, maxParticles)) + { + response.insert(QStringLiteral("error"), + QStringLiteral("Layer has no supported current frame")); + return response; + } + + QJsonArray particles; + for (const auto& particle : result.particles) + { + particles.append(particleMeasurementToJson(particle)); + } + response = makeResponse(type, true); + response.insert(QStringLiteral("layerKey"), layerKey); + response.insert(QStringLiteral("threshold"), threshold); + response.insert(QStringLiteral("minArea"), minArea); + response.insert(QStringLiteral("maxArea"), maxArea); + response.insert(QStringLiteral("particleCount"), static_cast(particles.size())); + response.insert(QStringLiteral("truncated"), result.truncated); + response.insert(QStringLiteral("particles"), particles); + + if (publishMaskValue.toBool(false)) + { + const QString maskLayerId = QStringLiteral("particle_mask"); + const scopeone::core::ImageFrame publishedMask = m_scopeonecore->publishStaticFrame( + maskLayerId, result.mask, QStringLiteral("Particle Mask")); + if (!publishedMask.isValid()) + { + response = makeResponse(type, false); + response.insert(QStringLiteral("error"), QStringLiteral("Failed to publish particle mask")); + return response; + } + const QString maskLayerKey = scopeone::core::ScopeOneCore::staticLayerKey(maskLayerId); + m_sceneModel->setLayerColormap(maskLayerKey, QStringLiteral("Magenta")); + m_sceneModel->setLayerOpacityPercent(maskLayerKey, 70); + m_sceneModel->setLayerBlending(maskLayerKey, QStringLiteral("Additive")); + QStringList visibleLayers = m_sceneModel->visibleLayerIds(); + if (!visibleLayers.contains(layerKey)) + { + visibleLayers.append(layerKey); + } + if (!visibleLayers.contains(maskLayerKey)) + { + visibleLayers.append(maskLayerKey); + } + m_sceneModel->setVisibleLayers(visibleLayers); + m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); + response.insert(QStringLiteral("maskLayerKey"), maskLayerKey); + } + + if (exportMaskValue.toBool(false)) + { + QString errorMessage; + scopeone::core::ImageFrame exportedMask; + if (!exportFrameToSharedMemory(result.mask, exportedMask, errorMessage)) + { + response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + errorMessage.isEmpty() + ? QStringLiteral("Failed to export particle mask") + : errorMessage); + return response; + } + QJsonObject mask; + mask.insert(QStringLiteral("mappingName"), kFrameMappingName); + mask.insert(QStringLiteral("mappingSize"), + static_cast(scopeone::core::kSharedFrameHeaderSize + + scopeone::core::kSharedFrameMaxBytes)); + addFrameMetadata(mask, exportedMask); + response.insert(QStringLiteral("mask"), mask); + } + return response; + } + if (type == QStringLiteral("layer_options")) { QJsonObject response = makeResponse(type, true); @@ -1566,6 +1851,27 @@ namespace scopeone::ui return response; } + if (type == QStringLiteral("reset_source_display_transform")) + { + const QString sourceId = request.value(QStringLiteral("sourceId")).toString().trimmed(); + QJsonObject response = makeResponse(type, false); + if (!m_sceneModel->resetSourceDisplayTransform(sourceId)) + { + response.insert(QStringLiteral("error"), QStringLiteral("Missing or unknown sourceId")); + return response; + } + const ImageSceneModel::SourceDisplayTransform transform = + m_sceneModel->sourceDisplayTransform(sourceId); + response = makeResponse(type, true); + response.insert(QStringLiteral("sourceId"), sourceId); + response.insert(QStringLiteral("offsetX"), transform.offsetX); + response.insert(QStringLiteral("offsetY"), transform.offsetY); + response.insert(QStringLiteral("zoomPercent"), transform.zoomPercent); + response.insert(QStringLiteral("flipX"), transform.flipX); + response.insert(QStringLiteral("flipY"), transform.flipY); + return response; + } + if (type == QStringLiteral("set_layer_display")) { const QString layerKey = request.value(QStringLiteral("layerKey")).toString().trimmed(); @@ -2382,6 +2688,87 @@ namespace scopeone::ui return response; } + if (type == QStringLiteral("start_stage_mosaic")) + { + scopeone::core::ScopeOneCore::StageMosaicPlan plan; + plan.cameraId = request.value(QStringLiteral("cameraId")).toString().trimmed(); + plan.xyStageId = request.value(QStringLiteral("xyStageId")).toString().trimmed(); + + auto readOptionalInt = [&request](const QString& key, int& value) + { + return !request.contains(key) || intField(request, key, value); + }; + auto readOptionalDouble = [&request](const QString& key, double& value) + { + if (!request.contains(key)) + { + return true; + } + const QJsonValue field = request.value(key); + if (!field.isDouble() || !std::isfinite(field.toDouble())) + { + return false; + } + value = field.toDouble(); + return true; + }; + + const QJsonValue returnToStartValue = request.value(QStringLiteral("returnToStart")); + const QJsonValue gallerySaveDirValue = request.value(QStringLiteral("gallerySaveDir")); + if (plan.cameraId.isEmpty() + || plan.xyStageId.isEmpty() + || !readOptionalInt(QStringLiteral("rows"), plan.rows) + || !readOptionalInt(QStringLiteral("columns"), plan.columns) + || !readOptionalDouble(QStringLiteral("pixelSizeUm"), plan.pixelSizeUm) + || !readOptionalDouble(QStringLiteral("stepXUm"), plan.stepXUm) + || !readOptionalDouble(QStringLiteral("stepYUm"), plan.stepYUm) + || !readOptionalInt(QStringLiteral("settleMs"), plan.settleMs) + || (!returnToStartValue.isUndefined() && !returnToStartValue.isBool()) + || (!gallerySaveDirValue.isUndefined() && !gallerySaveDirValue.isString())) + { + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), QStringLiteral("Invalid stage mosaic request")); + return response; + } + plan.returnToStart = returnToStartValue.toBool(true); + plan.gallerySaveDir = gallerySaveDirValue.toString().trimmed(); + + QString errorMessage; + if (!m_scopeonecore->startStageMosaic(plan, &errorMessage)) + { + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), errorMessage); + return response; + } + QJsonObject response = makeResponse(type, true); + response.insert(QStringLiteral("status"), + stageMosaicStatusToJson(m_scopeonecore->stageMosaicStatus())); + return response; + } + + if (type == QStringLiteral("stage_mosaic_status")) + { + QJsonObject response = makeResponse(type, true); + response.insert(QStringLiteral("status"), + stageMosaicStatusToJson(m_scopeonecore->stageMosaicStatus())); + return response; + } + + if (type == QStringLiteral("cancel_stage_mosaic")) + { + if (!m_scopeonecore->isStageMosaicRunning()) + { + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), QStringLiteral("No stage mosaic is running")); + return response; + } + m_scopeonecore->cancelStageMosaic(); + QJsonObject response = makeResponse(type, true); + response.insert(QStringLiteral("status"), + stageMosaicStatusToJson(m_scopeonecore->stageMosaicStatus())); + return response; + } + if (type == QStringLiteral("processing_modules")) { const QList modules = @@ -2839,21 +3226,29 @@ namespace scopeone::ui return response; } - if (type == QStringLiteral("latest_raw_frame")) + if (type == QStringLiteral("latest_raw_frame") + || type == QStringLiteral("layer_frame")) { + const bool rawFrameRequest = type == QStringLiteral("latest_raw_frame"); const QString cameraId = request.value(QStringLiteral("camera")).toString().trimmed(); + const QString layerKey = rawFrameRequest + ? scopeone::core::ScopeOneCore::rawLayerKey(cameraId) + : request.value(QStringLiteral("layerKey")).toString().trimmed(); QJsonObject response = makeResponse(type, false); - if (cameraId.isEmpty()) + if (layerKey.isEmpty()) { - response.insert(QStringLiteral("error"), QStringLiteral("Missing camera")); + response.insert(QStringLiteral("error"), + rawFrameRequest ? QStringLiteral("Missing camera") + : QStringLiteral("Missing layerKey")); return response; } - const scopeone::core::ImageFrame frame = - m_scopeonecore->graphFrame(scopeone::core::ScopeOneCore::rawLayerKey(cameraId)); + const scopeone::core::ImageFrame frame = m_scopeonecore->graphFrame(layerKey); if (!frame.isValid()) { - response.insert(QStringLiteral("error"), QStringLiteral("No latest raw frame for camera")); + response.insert(QStringLiteral("error"), + rawFrameRequest ? QStringLiteral("No latest raw frame for camera") + : QStringLiteral("Layer has no current frame")); return response; } @@ -2863,7 +3258,9 @@ namespace scopeone::ui { response.insert(QStringLiteral("error"), errorMessage.isEmpty() - ? QStringLiteral("Failed to export latest raw frame") + ? (rawFrameRequest + ? QStringLiteral("Failed to export latest raw frame") + : QStringLiteral("Failed to export layer frame")) : errorMessage); return response; } @@ -2873,6 +3270,7 @@ namespace scopeone::ui response.insert(QStringLiteral("mappingSize"), static_cast(scopeone::core::kSharedFrameHeaderSize + scopeone::core::kSharedFrameMaxBytes)); + response.insert(QStringLiteral("layerKey"), layerKey); addFrameMetadata(response, exportedFrame); return response; } @@ -3197,6 +3595,14 @@ namespace scopeone::ui response.insert(QStringLiteral("document"), scopeone::core::experimentDocumentToJson(experiment)); + if (m_scopeonecore->activeExperimentId() == experimentId) + { + response.insert(QStringLiteral("progress"), + recordingProgressToJson(m_scopeonecore->recordingProgress())); + response.insert(QStringLiteral("writer"), + recordingWriterStatusToJson(m_scopeonecore->recordingWriterStatus())); + } + const auto session = m_scopeonecore->recordingSession(experimentId); if (session) { diff --git a/src/ScopeOneMcpServer.cpp b/src/ScopeOneMcpServer.cpp index 078c619..684e721 100644 --- a/src/ScopeOneMcpServer.cpp +++ b/src/ScopeOneMcpServer.cpp @@ -349,6 +349,15 @@ namespace }), {QStringLiteral("sourceId")}, ToolAccess::StateChanging), + makeTool( + QStringLiteral("reset_source_display_transform"), + QStringLiteral("Reset preview alignment for one image source"), + inputProperties({ + {QStringLiteral("sourceId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Source ID from list_layers"))} + }), + {QStringLiteral("sourceId")}, + ToolAccess::StateChanging), makeTool( QStringLiteral("move_layer"), QStringLiteral("Move an image layer in display order"), @@ -383,6 +392,18 @@ namespace inputProperty(QStringLiteral("string"), QStringLiteral("Layer key from state_snapshot"))} }), {QStringLiteral("layerKey")}), + makeTool( + QStringLiteral("get_pixel_value"), + QStringLiteral("Read one image-space pixel value from a current layer"), + inputProperties({ + {QStringLiteral("layerKey"), + inputProperty(QStringLiteral("string"), QStringLiteral("Layer key from state_snapshot"))}, + {QStringLiteral("x"), + inputProperty(QStringLiteral("integer"), QStringLiteral("Image X coordinate"))}, + {QStringLiteral("y"), + inputProperty(QStringLiteral("integer"), QStringLiteral("Image Y coordinate"))} + }), + {QStringLiteral("layerKey"), QStringLiteral("x"), QStringLiteral("y")}), makeTool( QStringLiteral("get_line_profile"), QStringLiteral("Sample pixel values along an image-space line in a current layer"), @@ -400,6 +421,42 @@ namespace }), {QStringLiteral("layerKey"), QStringLiteral("x1"), QStringLiteral("y1"), QStringLiteral("x2"), QStringLiteral("y2")}), + makeTool( + QStringLiteral("detect_particles"), + QStringLiteral("Measure thresholded particles and optionally export or display their mask"), + inputProperties({ + {QStringLiteral("layerKey"), + inputProperty(QStringLiteral("string"), QStringLiteral("Layer key from state_snapshot"))}, + {QStringLiteral("threshold"), + withMinimum( + inputProperty(QStringLiteral("integer"), QStringLiteral("Inclusive intensity threshold")), + 0.0)}, + {QStringLiteral("minArea"), + withMinimum( + inputProperty(QStringLiteral("integer"), QStringLiteral("Minimum particle area in pixels")), + 1.0)}, + {QStringLiteral("maxArea"), + withMinimum( + inputProperty(QStringLiteral("integer"), QStringLiteral("Maximum particle area in pixels")), + 1.0)}, + {QStringLiteral("maxParticles"), + withMaximum( + withMinimum( + inputProperty(QStringLiteral("integer"), + QStringLiteral("Maximum returned particle count"), + 1000), + 1.0), + 10000.0)}, + {QStringLiteral("exportMask"), + inputProperty(QStringLiteral("boolean"), + QStringLiteral("Export the particle mask to shared memory"), false)}, + {QStringLiteral("publishMask"), + inputProperty(QStringLiteral("boolean"), + QStringLiteral("Publish the particle mask as a preview layer"), false)} + }), + {QStringLiteral("layerKey"), QStringLiteral("threshold"), + QStringLiteral("minArea"), QStringLiteral("maxArea")}, + ToolAccess::StateChanging), makeTool( QStringLiteral("create_line_markup"), QStringLiteral("Create an image-space line markup"), @@ -720,6 +777,60 @@ namespace }), {QStringLiteral("z")}, ToolAccess::Confirmed), + makeTool( + QStringLiteral("start_stage_mosaic"), + QStringLiteral("Start asynchronous XY stage mosaic acquisition"), + inputProperties({ + {QStringLiteral("cameraId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Camera ID"))}, + {QStringLiteral("xyStageId"), + inputProperty(QStringLiteral("string"), QStringLiteral("XY stage device label"))}, + {QStringLiteral("rows"), + withMaximum( + withMinimum( + inputProperty(QStringLiteral("integer"), QStringLiteral("Mosaic row count"), 1), + 1.0), + 10000.0)}, + {QStringLiteral("columns"), + withMaximum( + withMinimum( + inputProperty(QStringLiteral("integer"), QStringLiteral("Mosaic column count"), 1), + 1.0), + 10000.0)}, + {QStringLiteral("pixelSizeUm"), + withMinimum( + inputProperty(QStringLiteral("number"), QStringLiteral("Image pixel size in micrometers"), + 1.0), + 1e-12)}, + {QStringLiteral("stepXUm"), + inputProperty(QStringLiteral("number"), QStringLiteral("Horizontal tile step in micrometers"), + 0.0)}, + {QStringLiteral("stepYUm"), + inputProperty(QStringLiteral("number"), QStringLiteral("Vertical tile step in micrometers"), + 0.0)}, + {QStringLiteral("settleMs"), + withMinimum( + inputProperty(QStringLiteral("integer"), QStringLiteral("Stage settle time in milliseconds"), + 150), + 0.0)}, + {QStringLiteral("returnToStart"), + inputProperty(QStringLiteral("boolean"), QStringLiteral("Return the stage to its initial position"), + true)}, + {QStringLiteral("gallerySaveDir"), + inputProperty(QStringLiteral("string"), + QStringLiteral("Default directory for saving the resulting Gallery session"))} + }), + {QStringLiteral("cameraId"), QStringLiteral("xyStageId")}, + ToolAccess::Confirmed), + makeTool( + QStringLiteral("stage_mosaic_status"), + QStringLiteral("Read current or last XY stage mosaic status")), + makeTool( + QStringLiteral("cancel_stage_mosaic"), + QStringLiteral("Cancel the running XY stage mosaic"), + {}, + {}, + ToolAccess::Destructive), makeTool( QStringLiteral("processing_modules"), QStringLiteral("Read processing bit depth, real-time state, and pipeline modules")), @@ -910,6 +1021,14 @@ namespace inputProperty(QStringLiteral("string"), QStringLiteral("Camera ID"))} }), {QStringLiteral("camera")}), + makeTool( + QStringLiteral("layer_frame"), + QStringLiteral("Export the current frame of any image layer to shared memory"), + inputProperties({ + {QStringLiteral("layerKey"), + inputProperty(QStringLiteral("string"), QStringLiteral("Layer key from state_snapshot"))} + }), + {QStringLiteral("layerKey")}), makeTool( QStringLiteral("session_process_frame"), QStringLiteral("Process one retained session frame and export it to shared memory"),