diff --git a/.gitignore b/.gitignore index 6ebdb3c..0179a56 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ !/src/** !/ScopeOneCore/ !/ScopeOneCore/** +!/plugins/ +!/plugins/** +!/ScopeOneCuda/ +!/ScopeOneCuda/** /ScopeOneCore/install/ /ScopeOneCore/build/ /ScopeOneCore/external/* diff --git a/CMakeLists.txt b/CMakeLists.txt index b1f968b..e2039b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -71,16 +71,19 @@ set(SCOPEONE_UI_SOURCES src/MainWindow.cpp src/ImageProcessingWidget.cpp src/ImageGalleryWidget.cpp + src/ImageWorkspace.cpp src/ImageToolsDialog.cpp src/PreviewWidget.cpp src/ConsoleWidget.cpp src/AboutDialog.cpp src/SettingsDialog.cpp + src/PluginManagerDialog.cpp src/DeviceControlWidget.cpp src/RecordingWidget.cpp src/DevicePropertyWidget.cpp src/ConfigPresetWidget.cpp src/ScopeOneLocalApiServer.cpp + src/ScopeOneToolPlugin.cpp ${CMAKE_SOURCE_DIR}/resources/resources.qrc ) @@ -89,16 +92,19 @@ set(SCOPEONE_UI_HEADERS src/MainWindow.h src/ImageProcessingWidget.h src/ImageGalleryWidget.h + src/ImageWorkspace.h src/ImageToolsDialog.h src/PreviewWidget.h src/ConsoleWidget.h src/AboutDialog.h src/SettingsDialog.h + src/PluginManagerDialog.h src/DeviceControlWidget.h src/RecordingWidget.h src/DevicePropertyWidget.h src/ConfigPresetWidget.h src/ScopeOneLocalApiServer.h + src/ScopeOneToolPlugin.h ) if (WIN32) @@ -176,11 +182,20 @@ install(FILES DESTINATION . ) -file(GLOB SCOPEONE_CORE_RUNTIME_FILES - LIST_DIRECTORIES false - "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_BINDIR}/*.dll" - "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_BINDIR}/ScopeOne_Agent.exe" -) +if (WIN32) + file(GLOB SCOPEONE_CORE_RUNTIME_FILES + LIST_DIRECTORIES false + "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_BINDIR}/*.dll" + "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_BINDIR}/ScopeOne_DriverHost.exe" + ) +else () + file(GLOB SCOPEONE_CORE_RUNTIME_FILES + LIST_DIRECTORIES false + "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_BINDIR}/ScopeOne_DriverHost" + "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_LIBDIR}/libScopeOneCore.so*" + "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_LIBDIR}/libScopeWriter.so*" + ) +endif () if (SCOPEONE_CORE_RUNTIME_FILES) add_custom_command(TARGET ScopeOne POST_BUILD @@ -191,6 +206,18 @@ if (SCOPEONE_CORE_RUNTIME_FILES) ) endif () +foreach (_plugin_kind IN ITEMS processing hardware tools) + set(_plugin_dir "${ScopeOneCore_ROOT}/${CMAKE_INSTALL_BINDIR}/plugins/${_plugin_kind}") + if (EXISTS "${_plugin_dir}") + add_custom_command(TARGET ScopeOne POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${_plugin_dir}" + "$/plugins/${_plugin_kind}" + ) + install(DIRECTORY "${_plugin_dir}/" DESTINATION "plugins/${_plugin_kind}") + endif () +endforeach () + if (WIN32) if (WINDEPLOYQT_EXECUTABLE) add_custom_command(TARGET ScopeOne POST_BUILD @@ -227,7 +254,7 @@ if (WIN32) install(CODE " file(GLOB _core_runtime_files \"$/*.dll\" - \"$/ScopeOne_Agent.exe\" + \"$/ScopeOne_DriverHost.exe\" ) if (_core_runtime_files) file(INSTALL diff --git a/README.md b/README.md index 2c5621c..e5cadfe 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@

-ScopeOne is an open-source microscopy control software for multi-camera imaging, originally developed for in-house lab use. Built with C++ and Qt, it uses a native [MMCore](https://github.com/micro-manager/mmCoreAndDevices) backend for single-camera operation and one isolated camera agent per device for simultaneous multi-camera preview and acquisition. -It retains full compatibility with the [Micro-Manager](https://micro-manager.org/) device ecosystem and adds a modular real-time image processing pipeline with support for background calibration, temporal filtering, FFT analysis, and more. +ScopeOne is open-source microscopy control software built with C++ and Qt. Hardware is accessed through provider-independent device contracts; [Micro-Manager](https://micro-manager.org/) is the built-in provider, and isolated devices run through ScopeOne DriverHost processes. +It retains compatibility with the Micro-Manager device ecosystem while allowing native vendor providers and adds a modular real-time image processing pipeline with support for background calibration, temporal filtering, FFT analysis, and more.

-
+
Graphical User Interface of ScopeOne

@@ -63,6 +63,7 @@ The expected layout is: ```text ScopeOne/ ScopeOneCore/ + include/scopeone/ Core API and external plugin contracts external/ mmCoreAndDevices/ opencv-4.12.0/ @@ -71,6 +72,21 @@ ScopeOne/ ScopeWriter contains its filesystem Zarr V3 writer and carries libtiff, zlib, zstd and crc32c under its own `third_party` directory. It builds these dependencies from source without downloading packages during CMake configuration. +### Plugin layout + +ScopeOne loads external plugins from these directories beside the application: + +- `plugins/processing` adds processing modules to the shared pipeline +- `plugins/tools` adds optional workflow windows to the Tools menu +- `plugins/hardware` adds isolated hardware providers hosted by `ScopeOne_DriverHost` +- `plugins/hardware` also contains DAQ devices and signal source plugins + +All plugins use the installed `scopeone::PluginSDK` CMake target and the public contracts in `ScopeOneCore/include/scopeone`. The Core package owns the stable plugin-facing headers for image frames, hardware providers, DAQ devices, signal sources, processing modules, tool plugins, shared frames and manifests. A common manifest contains `id`, `name`, `version`, `scopeOneApi`, and `kind`. **Tools > Plugin Manager** installs plugins into the current user's application data directory. Hardware plugins can also be enabled and configured there; changes take effect after restart. Reference plugins are organized under `plugins/hardware`, `plugins/processing`, and `plugins/tools`. + +Micro-Manager remains the built-in camera provider and continues to load its Device Adapters from `.cfg` files. Native camera devices that do not belong in Micro-Manager use the `HardwareProvider` plugin contract. DAQ and signal acquisition are separate plugin contracts and are not linked into `ScopeOne.exe`. + +The Image Processing panel can process all live cameras or one selected camera. Recorded images and stacks open in independent windows, and the active image viewer becomes the target for Layers, Inspect, Image Processing, and Save As. Processing the current image or complete stack runs in the background and opens the result in a new window; stack results also remain available in Gallery. Temporal module state is preserved across each stack without changing live pipeline state. + **Windows Build Steps:** 1. Build and install `ScopeOneCore`: @@ -230,7 +246,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, 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. +The MCP tool set mirrors the Local API operation catalog, including system state, configuration, preview layers, independent image windows, automatic display levels, source alignment, markups, device properties, exposure, ROI, stages, stage mosaics, processing, experiments, recording sessions, frame transfer, and analysis. Agents can list, open, activate, process, save, and close independent image windows, 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. Configuration loading and unloading report an explicit lifecycle state through the Local API and MCP. A configuration with non-camera initialization warnings is reported as `partially_loaded` with failed device labels; camera backend startup failures are cleaned up and reported as errors. During `loading` or `unloading`, hardware mutations are rejected until the operation finishes. diff --git a/ScopeOneCore/CMakeLists.txt b/ScopeOneCore/CMakeLists.txt index 38568aa..c03f0da 100644 --- a/ScopeOneCore/CMakeLists.txt +++ b/ScopeOneCore/CMakeLists.txt @@ -31,7 +31,7 @@ if (WIN32) set(OpenCV_DIR "${CMAKE_CURRENT_SOURCE_DIR}/external/opencv-4.12.0/build") - find_package(OpenCV REQUIRED COMPONENTS core imgproc) + find_package(OpenCV REQUIRED COMPONENTS core imgproc imgcodecs) set(SCOPEONE_OPENCV_LIBRARIES opencv_world) file(GLOB MMCORE_RUNTIME_DLLS "${MMCORE_BIN_DIR}/*.dll") @@ -51,7 +51,7 @@ else () # Linux/Unix: use system OpenCV and link the MMCore static libraries # built from the sibling micro-manager checkout (symlinked into # external/mmCoreAndDevices). No runtime DLLs to stage. - find_package(OpenCV REQUIRED COMPONENTS core imgproc) + find_package(OpenCV REQUIRED COMPONENTS core imgproc imgcodecs) set(SCOPEONE_OPENCV_LIBRARIES ${OpenCV_LIBS}) set(MMCORE_BIN_DIR "") @@ -77,15 +77,30 @@ endfunction() set(CORE_SOURCES src/ExperimentDocument.cpp + src/AcquisitionEngine.cpp + src/DriverHostProviderProxy.cpp + src/DaqDeviceManager.cpp + src/HardwareProvider.cpp + src/PluginManifest.cpp + src/HardwareRuntime.cpp + src/MicroManagerProvider.cpp + src/SimulatorProvider.cpp src/ImageSceneModel.cpp src/MMCoreManager.cpp src/FrameBufferUtils.cpp src/ParticleAnalysis.cpp src/StageMosaicManager.cpp src/ImageProcessingFramework.cpp + src/ProcessingPipeline.cpp + src/ToolTask.cpp + src/ToolFrameStream.cpp + src/ProcessingModuleRegistry.cpp src/SpatiotemporalBinningModule.cpp src/GaussianBlurModule.cpp src/FFTModule.cpp + src/FrequencyDomainFilterModule.cpp + src/MaskModule.cpp + src/IFFTModule.cpp src/BackgroundCalibrationModule.cpp src/DifferentialRollingModule.cpp src/MDAManager.cpp @@ -93,37 +108,76 @@ set(CORE_SOURCES src/CameraBackend.cpp src/CameraManager.cpp src/NativeCameraBackend.cpp - src/AgentCameraBackend.cpp + src/DriverHostCameraBackend.cpp src/ScopeOneCore.cpp + src/ScanImageAssembler.cpp + src/SignalSourceManager.cpp ) set(CORE_HEADERS include/scopeone/ExperimentDocument.h + include/scopeone/CameraProvider.h + include/scopeone/DaqDevice.h + include/scopeone/DriverHostProviderPlugin.h + include/scopeone/HardwareCapabilities.h + include/scopeone/HardwareProvider.h + include/scopeone/HardwareTypes.h + include/scopeone/ImageFrame.h + include/scopeone/PluginManifest.h + include/scopeone/PluginSDK.h + include/scopeone/ProcessingPlugin.h + include/scopeone/ScanImageAssembler.h + include/scopeone/scopeone_sdk_export.h + include/scopeone/SharedFrame.h + include/scopeone/SignalSource.h + include/scopeone/ToolFrameStream.h + include/scopeone/ToolPlugin.h + include/scopeone/ToolTask.h + internal/AcquisitionEngine.h + internal/CameraRuntimeControl.h + include/scopeone/SimulatorProvider.h include/scopeone/ImageSceneModel.h + internal/HardwareRuntime.h + internal/MicroManagerProvider.h internal/MMCoreManager.h internal/FrameBufferUtils.h internal/ParticleAnalysis.h internal/StageMosaicManager.h internal/ImageProcessingFramework.h internal/ProcessingModule.h - internal/AgentProtocol.h + internal/ProcessingModuleRegistry.h + include/scopeone/ProcessingPipeline.h + internal/DriverHostProtocol.h + internal/SharedFrameRing.h + internal/DriverHostProviderProxy.h + internal/DaqDeviceManager.h + internal/SignalSourceManager.h internal/SpatiotemporalBinningModule.h internal/GaussianBlurModule.h internal/FFTModule.h + internal/FrequencyDomainFilterModule.h + internal/MaskModule.h + internal/IFFTModule.h internal/BackgroundCalibrationModule.h internal/DifferentialRollingModule.h internal/MDAManager.h internal/RecordingManager.h internal/CameraBackend.h internal/CameraManager.h - include/scopeone/ImageFrame.h - include/scopeone/SharedFrame.h include/scopeone/scopeone_core_export.h include/scopeone/ScopeOneCore.h ) add_library(ScopeOneCore SHARED ${CORE_SOURCES} ${CORE_HEADERS}) add_library(scopeone::ScopeOneCore ALIAS ScopeOneCore) +add_library(ScopeOnePluginSDK INTERFACE) +add_library(scopeone::PluginSDK ALIAS ScopeOnePluginSDK) +set_target_properties(ScopeOnePluginSDK PROPERTIES EXPORT_NAME PluginSDK) +target_link_libraries(ScopeOnePluginSDK INTERFACE ScopeOneCore Qt::Core Qt::Gui) +target_include_directories(ScopeOnePluginSDK INTERFACE + $ + $ +) target_link_libraries(ScopeOneCore PUBLIC Qt::Core @@ -187,24 +241,25 @@ add_custom_command(TARGET ScopeOneCore POST_BUILD $ ) -add_executable(ScopeOne_Agent - src/AgentMain.cpp +add_executable(ScopeOne_DriverHost + src/DriverHostMain.cpp ) -target_link_libraries(ScopeOne_Agent +target_link_libraries(ScopeOne_DriverHost + ScopeOneCore MMCore Qt::Core Qt::Network ) -target_compile_definitions(ScopeOne_Agent PRIVATE MMDEVICE_CLIENT_BUILD) -target_include_directories(ScopeOne_Agent PRIVATE +target_compile_definitions(ScopeOne_DriverHost PRIVATE MMDEVICE_CLIENT_BUILD) +target_include_directories(ScopeOne_DriverHost PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include ${CMAKE_CURRENT_SOURCE_DIR} ${MMCORE_INCLUDE_DIR} ${MMDEVICE_INCLUDE_DIR} ) -target_link_directories(ScopeOne_Agent PRIVATE ${MMCORE_BIN_DIR}) +target_link_directories(ScopeOne_DriverHost PRIVATE ${MMCORE_BIN_DIR}) -scopeone_add_runtime_copy_commands(ScopeOne_Agent $) +scopeone_add_runtime_copy_commands(ScopeOne_DriverHost $) # Fast Release build # if (MSVC) @@ -222,10 +277,10 @@ if (MSVC) $<$:/LTCG> ) - target_compile_options(ScopeOne_Agent PRIVATE + target_compile_options(ScopeOne_DriverHost PRIVATE $<$:/GL /fp:fast /arch:AVX2> ) - target_link_options(ScopeOne_Agent PRIVATE + target_link_options(ScopeOne_DriverHost PRIVATE $<$:/LTCG> ) endif () @@ -242,13 +297,13 @@ write_basic_package_version_file( COMPATIBILITY SameMajorVersion ) -install(TARGETS ScopeOneCore +install(TARGETS ScopeOneCore ScopeOnePluginSDK EXPORT ScopeOneCoreTargets RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" ) -install(TARGETS ScopeOne_Agent +install(TARGETS ScopeOne_DriverHost RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" ) install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/include/" diff --git a/ScopeOneCore/README.md b/ScopeOneCore/README.md index f504d66..8186681 100644 --- a/ScopeOneCore/README.md +++ b/ScopeOneCore/README.md @@ -2,13 +2,13 @@ `ScopeOneCore` is the reusable runtime library behind the desktop app. -The desktop app currently assumes `ScopeOneCore` is checked out under the `ScopeOne` repository root. +The desktop app consumes `ScopeOneCore` through its installed CMake package. ## Source Layout ```text ScopeOneCore/ -|-- include/scopeone/ Public C++ headers installed for consumers +|-- include/scopeone/ Core facade and all public plugin contracts |-- internal/ Private headers used only while building ScopeOneCore |-- src/ C++ implementations for both public and private types |-- python/scopeone/ External Python client for a running ScopeOne app @@ -18,20 +18,20 @@ ScopeOneCore/ `-- install/ Generated local installation consumed by the desktop app ``` -`include/scopeone` defines the installed C++ contract. A header belongs here only when the desktop app or another external consumer must compile against it. Consumers include these files with the installed prefix, for example: +`include/scopeone` contains the Core facade, Core-owned public models and all plugin contracts: ```cpp #include #include ``` -`internal` contains implementation contracts between Core managers, processing modules and the camera agent. These headers are available to the `ScopeOneCore` target through a private include path, are not installed, and may change without preserving source compatibility. Code outside `ScopeOneCore` must not include them. +`internal` contains implementation contracts between Core managers, processing modules and DriverHost. These headers are available to the `ScopeOneCore` target through a private include path, are not installed, and may change without preserving source compatibility. Code outside `ScopeOneCore` must not include them. `src` contains implementations. A public class such as `ScopeOneCore`, `ImageSceneModel` or `ExperimentDocument` still has its `.cpp` file in `src`; being public is determined by its header location and exported API, not by the location of its implementation. Use this placement rule: -- Put a stable type or function required by consumers in `include/scopeone`. +- Put every public Core model, plugin contract and shared plugin data type in `ScopeOneCore/include/scopeone`. - Put a Core-only manager, algorithm or protocol detail in `internal`. - Put executable implementation in `src`. - Keep desktop widgets and Qt UI behavior in the top-level ScopeOne `src` directory, outside `ScopeOneCore`. @@ -42,9 +42,10 @@ Use this placement rule: | Namespace | Purpose | Examples | |---|---|---| -| `scopeone::core` | Stable Core-facing types and public facades | `ScopeOneCore`, `ImageFrame`, `ExperimentDocument`, `ImageSceneModel` | +| `scopeone::core` | Stable Core-facing types and public facades | `ScopeOneCore`, `ExperimentDocument`, `ImageSceneModel` | +| `scopeone::core` SDK contracts | Stable plugin-facing types and interfaces | `ImageFrame`, `CameraProvider`, `DaqDevice`, `SignalSource`, `ProcessingPlugin`, `ToolPlugin` | | `scopeone::core::internal` | Core-only managers and processing implementations | `CameraManager`, `MMCoreManager`, `RecordingManager`, processing modules | -| `scopeone::core::internal::agent` | Private camera-agent protocol details | Agent request, response and frame transport types | +| `scopeone::core::internal::driverhost` | Shared DriverHost message framing | Versioned request, response and event envelopes | | `scopeone::ui` | Desktop application widgets and UI coordination outside this library | `MainWindow`, `PreviewWidget`, `InspectWidget` | Code in `src` that implements a public type remains in `scopeone::core`. Code that implements an `internal` header remains in `scopeone::core::internal`. The Python package named `scopeone` is an external client package and is not an embedded form of the C++ namespace. @@ -87,29 +88,45 @@ Outputs: - `build/Release/ScopeOneCore.dll` - `build/Release/ScopeOneCore.lib` -- `build/Release/ScopeOne_Agent.exe` +- `build/Release/ScopeOne_DriverHost.exe` +- External plugins are built under `plugins`. - `build/ScopeOneCoreConfig.cmake` - `install/bin/ScopeOneCore.dll` -- `install/bin/ScopeOne_Agent.exe` +- `install/bin/ScopeOne_DriverHost.exe` +- Hardware, DAQ and signal source plugins are installed under `plugins/hardware`. - `install/lib/cmake/ScopeOneCore/ScopeOneCoreConfig.cmake` -## Public API +## Core and SDK API -The installed headers are the source of truth for the public API: +The installed headers in `include/scopeone` are the source of truth for the public API: - `ScopeOneCore.h` provides the main hardware, acquisition, processing, recording and frame-graph facade. -- `ImageFrame.h` defines the image payload and metadata exchanged across Core features. -- `ExperimentDocument.h` defines experiment plans, results, persistence and provenance. -- `ImageSceneModel.h` defines shared image-layer, display-state and markup state. -- `SharedFrame.h` defines the language-neutral shared-memory frame layout. +- `SimulatorProvider.h`, `ProcessingPipeline.h`, `ExperimentDocument.h` and `ImageSceneModel.h` are Core-owned runtime models and services. +- The SDK provides `HardwareProvider.h`, `HardwareCapabilities.h`, `CameraProvider.h`, `DriverHostProviderPlugin.h`, `DaqDevice.h`, `SignalSource.h`, `ScanImageAssembler.h`, `ProcessingPlugin.h`, `ToolPlugin.h`, `PluginManifest.h`, `HardwareTypes.h`, `ImageFrame.h` and `SharedFrame.h`. - `scopeone_core_export.h` supplies DLL import and export declarations and is normally included indirectly. External code should enter through these headers and `scopeone::core::ScopeOneCore`. Internal managers are implementation details and must not become alternate access paths. +Providers use ScopeOne logical device IDs and publish `ImageFrame` objects through `CameraProvider::FrameSink`. Register in-process providers with `ScopeOneCore::registerHardwareProvider(...)`. Submit isolated module loading with `ScopeOneCore::registerDriverHostProvider(providerId, modulePath, options)` and observe `hardwareProviderRegistrationFinished` for the result. One DriverHost process owns the complete Provider and registers all of its cameras and control devices together. Micro-Manager remains the built-in provider, using the native camera path for one camera and isolated DriverHost camera processes for multiple cameras. + +## Plugin Boundaries + +- `plugins/hardware` contains native `HardwareProvider` modules. Each provider runs in an isolated DriverHost process. Micro-Manager Device Adapters remain under Micro-Manager and are not wrapped as ScopeOne plugins. +- `plugins/hardware` contains DAQ and signal source plugins alongside native `HardwareProvider` modules. The Core selects each plugin by its interface without linking DAQ vendor libraries into the application. +- `ScanImageAssembler` is implemented in Core as a provider-independent 1D-to-2D reconstruction algorithm, while its public contract is owned by the SDK. The Core publishes reconstructed frames through the shared frame graph and Gallery session path. +- `plugins/processing` contains `ProcessingPlugin` modules loaded by ScopeOneCore. A plugin publishes stable module IDs, parameter descriptors and factories. Built-in processing methods use the same registry. +- `plugins/tools` contains optional desktop `ScopeOneToolPlugin` modules. These receive a restricted UI context rather than direct access to `MainWindow` or `PreviewWidget`. Built-in Scale, Stage Mosaic and Particle Detection tools use the same registry. + +External projects consume the exported `scopeone::PluginSDK` CMake target. Every plugin manifest declares `id`, `name`, `version`, `scopeOneApi`, and `kind`; incompatible manifests are rejected before the plugin instance is created. + +Hardware, processing, DAQ, signal-source and tool contracts are installed SDK APIs. Desktop tool plugins target the ScopeOne application UI contract exposed by the SDK. + ## Processing Data Flow -`ImageFrame` is the frame model used by preview, processing, recording, gallery and the local API. Use `processFrameThrough(...)` to stop at one pipeline stage and `processFrameFrom(...)` to continue from a later module after an edited frame is written back. Saved OME-TIFF, OME-Zarr, TIFF and binary recording outputs are read back asynchronously through `ScopeOneCore::requestRecordingSessionFrame(...)`. Live preview processing and synchronous API processing use separate runtime pipeline state so offline frame edits do not change live module buffers. +`ImageFrame` is the frame model used by preview, processing, recording, gallery and the local API. Processing recipes persist stable module IDs rather than registry positions. Available modules and their parameter descriptors come from the processing registry. Use `processFrameThrough(...)` to stop at one pipeline stage and `processFrameFrom(...)` to continue from a later module after an edited frame is written back. Saved OME-TIFF, OME-Zarr, TIFF and binary recording outputs are read back asynchronously through `ScopeOneCore::requestRecordingSessionFrame(...)`. + +Real-time processing can consume all camera streams or one camera selected with `setRealTimeProcessingSource(...)`. `requestImageProcessing(...)` applies an isolated pipeline to one current image. `requestRecordingSessionStackProcessing(...)` applies one stateful isolated runtime to a complete session camera stack, reports progress, supports cancellation and creates a new in-memory Gallery session. These offline paths do not change live module buffers. The Local API can list, open, activate, process, save and close independent image windows backed by retained sessions. Raw live frames, processed live frames, static tool/gallery frames, external API frames and session frame sources are routed through the core frame graph. UI preview widgets keep only a render cache, and callers should use `ScopeOneCore` frame facade methods instead of reading camera managers, recording sessions or preview cache state directly. diff --git a/ScopeOneCore/external/ScopeWriter b/ScopeOneCore/external/ScopeWriter index 87809f6..69f6bcb 160000 --- a/ScopeOneCore/external/ScopeWriter +++ b/ScopeOneCore/external/ScopeWriter @@ -1 +1 @@ -Subproject commit 87809f65cf43b55c248a67b23b52c2057b14d976 +Subproject commit 69f6bcb952cecdcc01ffb7ebf76f3edcb17cbe87 diff --git a/ScopeOneCore/include/scopeone/CameraProvider.h b/ScopeOneCore/include/scopeone/CameraProvider.h new file mode 100644 index 0000000..97a95f4 --- /dev/null +++ b/ScopeOneCore/include/scopeone/CameraProvider.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +#include + +#include "scopeone/HardwareCapabilities.h" +#include "scopeone/ImageFrame.h" + +namespace scopeone::core +{ + class SCOPEONE_SDK_EXPORT CameraProvider : public DevicePropertyProvider + { + public: + using FrameSink = std::function; + using PreviewStateSink = std::function; + + ~CameraProvider() override; + + virtual void setFrameSink(FrameSink sink) = 0; + virtual void setPreviewStateSink(PreviewStateSink sink) = 0; + virtual bool startPreview() = 0; + virtual bool stopPreview() = 0; + virtual bool startPreviewFor(const QString& cameraId) = 0; + virtual bool stopPreviewFor(const QString& cameraId) = 0; + virtual bool isPreviewRunning(const QString& cameraId) const = 0; + virtual bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const = 0; + virtual bool setExposure(const QString& cameraIdOrAll, double exposureMs) = 0; + virtual bool setROI(const QString& cameraId, int x, int y, int width, int height) = 0; + virtual bool clearROI(const QString& cameraId) = 0; + virtual bool getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) = 0; + virtual bool captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs = 1500) = 0; + }; +} diff --git a/ScopeOneCore/include/scopeone/DaqDevice.h b/ScopeOneCore/include/scopeone/DaqDevice.h new file mode 100644 index 0000000..f669265 --- /dev/null +++ b/ScopeOneCore/include/scopeone/DaqDevice.h @@ -0,0 +1,192 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include "scopeone/scopeone_sdk_export.h" + +namespace scopeone::core +{ + enum class DaqChannelType + { + AnalogInput, + AnalogOutput, + DigitalInput, + DigitalOutput, + CounterInput, + CounterOutput + }; + + enum class DaqEdge + { + Rising, + Falling + }; + + enum class DaqState + { + Idle, + Armed, + Running, + Error + }; + + enum class DaqTaskDirection + { + Input, + Output + }; + + enum class DaqSampleMode + { + Finite, + Continuous + }; + + struct DaqChannelDescriptor + { + QString physicalName; + DaqChannelType type{DaqChannelType::DigitalInput}; + }; + + struct DaqDeviceDescriptor + { + QString id; + QString name; + QString provider; + QString product; + QList channels; + QStringList terminals; + }; + + struct DaqPulseTaskConfig + { + QString name; + QString counter; + QString outputTerminal; + double frequencyHz{1000.0}; + double dutyCycle{0.5}; + double initialDelaySeconds{0.0}; + QString startTrigger; + DaqEdge startEdge{DaqEdge::Rising}; + QString timebaseSource; + quint32 initialDelayTicks{0}; + quint32 lowTicks{0}; + quint32 highTicks{0}; + }; + + struct DaqTaskTiming + { + QString sampleClock; + double sampleRateHz{1000.0}; + DaqEdge sampleEdge{DaqEdge::Rising}; + DaqSampleMode sampleMode{DaqSampleMode::Finite}; + quint64 samplesPerChannel{1}; + QString startTrigger; + DaqEdge startEdge{DaqEdge::Rising}; + }; + + struct DaqAnalogTaskConfig + { + QString name; + DaqTaskDirection direction{DaqTaskDirection::Input}; + QStringList channels; + double minimumVolts{-10.0}; + double maximumVolts{10.0}; + DaqTaskTiming timing; + QVector outputSamplesByScan; + }; + + struct DaqDigitalTaskConfig + { + QString name; + DaqTaskDirection direction{DaqTaskDirection::Input}; + QStringList lines; + DaqTaskTiming timing; + QVector outputSamplesByScan; + }; + + struct DaqInputChunk + { + QString deviceId; + QString taskName; + QStringList channels; + quint64 firstSample{0}; + double nominalSampleRateHz{0.0}; + QVector analogSamplesByScan; + QVector digitalSamplesByScan; + }; + + struct DaqTerminalRoute + { + QString source; + QString destination; + bool inverted{false}; + }; + + struct DaqRasterScanConfig + { + QString name; + QString lineClock; + double nominalLineRateHz{1000.0}; + quint32 activeLines{512}; + quint32 flybackLines{16}; + QString yChannel; + double yStartVolts{-1.0}; + double yEndVolts{1.0}; + QString frameCounter; + QString lineOutputTerminal; + QString frameOutputTerminal; + }; + + struct DaqSessionConfig + { + QString deviceId; + QList rasterScans; + QList pulseTasks; + QList analogTasks; + QList digitalTasks; + QList routes; + }; + + class SCOPEONE_SDK_EXPORT DaqController : public QObject + { + Q_OBJECT + + public: + explicit DaqController(QObject* parent = nullptr); + ~DaqController() override; + + virtual bool start(const DaqSessionConfig& config, + QString* errorMessage = nullptr) = 0; + virtual void stop() = 0; + virtual DaqState state() const = 0; + virtual QString stateMessage() const = 0; + + signals: + void stateChanged(scopeone::core::DaqState state, + const QString& message); + void controllerError(const QString& errorMessage); + void inputDataReady(const scopeone::core::DaqInputChunk& chunk); + }; + + class DaqDevicePlugin + { + public: + virtual ~DaqDevicePlugin() = default; + virtual QList devices() const = 0; + virtual DaqController* createController(const QString& deviceId, + QObject* parent = nullptr) = 0; + }; +} + +#define SCOPEONE_DAQ_DEVICE_PLUGIN_IID "org.scopeone.DaqDevicePlugin/1.0" +Q_DECLARE_INTERFACE(scopeone::core::DaqDevicePlugin, SCOPEONE_DAQ_DEVICE_PLUGIN_IID) + +Q_DECLARE_METATYPE(scopeone::core::DaqState) +Q_DECLARE_METATYPE(scopeone::core::DaqInputChunk) diff --git a/ScopeOneCore/include/scopeone/DriverHostProviderPlugin.h b/ScopeOneCore/include/scopeone/DriverHostProviderPlugin.h new file mode 100644 index 0000000..5e7caec --- /dev/null +++ b/ScopeOneCore/include/scopeone/DriverHostProviderPlugin.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#include "scopeone/HardwareProvider.h" + +namespace scopeone::core +{ + class DriverHostProviderPlugin + { + public: + virtual ~DriverHostProviderPlugin() = default; + + virtual QString providerId() const = 0; + virtual HardwareProviderPtr createProvider(const QJsonObject& options, + QString* errorMessage = nullptr) = 0; + }; +} + +#define ScopeOneDriverHostProviderPlugin_iid "org.scopeone.DriverHostProviderPlugin/1.0" +Q_DECLARE_INTERFACE(scopeone::core::DriverHostProviderPlugin, + ScopeOneDriverHostProviderPlugin_iid) diff --git a/ScopeOneCore/include/scopeone/ExperimentDocument.h b/ScopeOneCore/include/scopeone/ExperimentDocument.h index 6dc7e41..cc59837 100644 --- a/ScopeOneCore/include/scopeone/ExperimentDocument.h +++ b/ScopeOneCore/include/scopeone/ExperimentDocument.h @@ -35,16 +35,6 @@ namespace scopeone::core XY = 2 }; - enum class ProcessingModuleKind - { - FFT = 0, - BackgroundCalibration = 2, - SpatiotemporalBinning = 3, - GaussianBlur = 4, - DifferentialRolling = 5, - Unknown = 255 - }; - enum class ProcessingBitDepth { Bit8 = 8, @@ -64,6 +54,7 @@ namespace scopeone::core { Raw, Processed, + Tool, Static, Gallery }; @@ -84,7 +75,7 @@ namespace scopeone::core struct ProcessingModuleRecipe { - ProcessingModuleKind kind{ProcessingModuleKind::Unknown}; + QString moduleId; int schemaVersion{kProcessingModuleSchemaVersion}; QVariantMap parameters; }; @@ -265,7 +256,6 @@ namespace scopeone::core }; SCOPEONE_CORE_EXPORT QString recordingAxisName(RecordingAxis axis); - SCOPEONE_CORE_EXPORT QString processingModuleKindName(ProcessingModuleKind kind); SCOPEONE_CORE_EXPORT QString experimentRunStateName(ExperimentRunState state); SCOPEONE_CORE_EXPORT QString documentLayerKindName(DocumentLayerKind kind); SCOPEONE_CORE_EXPORT QString documentMarkupTypeName(DocumentMarkupType type); diff --git a/ScopeOneCore/include/scopeone/HardwareCapabilities.h b/ScopeOneCore/include/scopeone/HardwareCapabilities.h new file mode 100644 index 0000000..6930952 --- /dev/null +++ b/ScopeOneCore/include/scopeone/HardwareCapabilities.h @@ -0,0 +1,102 @@ +#pragma once + +#include +#include + +#include "scopeone/scopeone_sdk_export.h" + +namespace scopeone::core +{ + class SCOPEONE_SDK_EXPORT DevicePropertyProvider + { + public: + virtual ~DevicePropertyProvider(); + + virtual QStringList listProperties(const QString& deviceId) = 0; + virtual QString getProperty(const QString& deviceId, + const QString& name, + bool fromCache = false) = 0; + virtual bool setProperty(const QString& deviceId, + const QString& name, + const QString& value, + QString* errorMessage = nullptr) = 0; + virtual QString getPropertyType(const QString& deviceId, const QString& name) = 0; + virtual bool isPropertyReadOnly(const QString& deviceId, const QString& name) = 0; + virtual bool isPropertyPreInit(const QString& deviceId, const QString& name) = 0; + virtual QStringList getAllowedPropertyValues(const QString& deviceId, + const QString& name) = 0; + virtual bool hasPropertyLimits(const QString& deviceId, const QString& name) = 0; + virtual double getPropertyLowerLimit(const QString& deviceId, const QString& name) = 0; + virtual double getPropertyUpperLimit(const QString& deviceId, const QString& name) = 0; + }; + + class SCOPEONE_SDK_EXPORT StageProvider + { + public: + virtual ~StageProvider(); + + virtual QString defaultXYStage() const = 0; + virtual QString defaultZStage() const = 0; + virtual bool getXYPosition(const QString& deviceId, + double& x, + double& y, + QString* errorMessage = nullptr) const = 0; + virtual bool getZPosition(const QString& deviceId, + double& z, + QString* errorMessage = nullptr) const = 0; + virtual bool setRelativeXYPosition(const QString& deviceId, + double dx, + double dy, + QString* errorMessage = nullptr) = 0; + virtual bool setRelativeZPosition(const QString& deviceId, + double dz, + QString* errorMessage = nullptr) = 0; + virtual bool setXYPosition(const QString& deviceId, + double x, + double y, + QString* errorMessage = nullptr) = 0; + virtual bool setZPosition(const QString& deviceId, + double z, + QString* errorMessage = nullptr) = 0; + }; + + class SCOPEONE_SDK_EXPORT ShutterProvider + { + public: + virtual ~ShutterProvider(); + + virtual bool isShutterOpen(const QString& deviceId, + bool& open, + QString* errorMessage = nullptr) const = 0; + virtual bool setShutterOpen(const QString& deviceId, + bool open, + QString* errorMessage = nullptr) = 0; + }; + + class SCOPEONE_SDK_EXPORT StateProvider + { + public: + virtual ~StateProvider(); + + virtual bool getState(const QString& deviceId, + long& state, + QString* errorMessage = nullptr) const = 0; + virtual bool setState(const QString& deviceId, + long state, + QString* errorMessage = nullptr) = 0; + virtual QString stateLabel(const QString& deviceId, long state) const = 0; + }; + + class SCOPEONE_SDK_EXPORT ConfigurationProvider + { + public: + virtual ~ConfigurationProvider(); + + virtual QStringList availableConfigGroups() const = 0; + virtual QStringList availableConfigs(const QString& groupName) const = 0; + virtual QString currentConfig(const QString& groupName) const = 0; + virtual bool setConfig(const QString& groupName, + const QString& configName, + QString* errorMessage = nullptr) = 0; + }; +} diff --git a/ScopeOneCore/include/scopeone/HardwareProvider.h b/ScopeOneCore/include/scopeone/HardwareProvider.h new file mode 100644 index 0000000..351b38d --- /dev/null +++ b/ScopeOneCore/include/scopeone/HardwareProvider.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include + +#include + +#include "scopeone/HardwareTypes.h" +#include "scopeone/scopeone_sdk_export.h" + +namespace scopeone::core +{ + class SCOPEONE_SDK_EXPORT HardwareProvider + { + public: + virtual ~HardwareProvider(); + + virtual HardwareProviderDescriptor descriptor() const = 0; + virtual QList devices() const = 0; + }; + + using HardwareProviderPtr = std::shared_ptr; +} diff --git a/ScopeOneCore/include/scopeone/HardwareTypes.h b/ScopeOneCore/include/scopeone/HardwareTypes.h new file mode 100644 index 0000000..19730af --- /dev/null +++ b/ScopeOneCore/include/scopeone/HardwareTypes.h @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include + +#include "scopeone/scopeone_sdk_export.h" + +namespace scopeone::core +{ + enum class HardwareDeviceKind + { + Unknown, + Camera, + XYStage, + ZStage, + Shutter, + State, + Hub, + Serial, + Generic, + AutoFocus, + ImageProcessor, + SignalIO, + Magnifier, + SLM, + Galvo, + PressurePump, + VolumetricPump + }; + + enum class HardwareDeviceState + { + Unknown, + Discovered, + Initialized, + Faulted, + Unavailable + }; + + enum class HardwareEndpointKind + { + InProcess, + DriverHost + }; + + struct SCOPEONE_SDK_EXPORT HardwareProviderDescriptor + { + QString id; + QString name; + QString version; + }; + + struct SCOPEONE_SDK_EXPORT HardwareDeviceDescriptor + { + QString logicalId; + QString providerId; + QString providerDeviceId; + QString hardwareId; + QString name; + HardwareDeviceKind kind{HardwareDeviceKind::Unknown}; + HardwareDeviceState state{HardwareDeviceState::Unknown}; + HardwareEndpointKind endpoint{HardwareEndpointKind::InProcess}; + QVariantMap properties; + }; + +} + +Q_DECLARE_METATYPE(scopeone::core::HardwareDeviceKind) +Q_DECLARE_METATYPE(scopeone::core::HardwareDeviceState) +Q_DECLARE_METATYPE(scopeone::core::HardwareEndpointKind) +Q_DECLARE_METATYPE(scopeone::core::HardwareProviderDescriptor) +Q_DECLARE_METATYPE(scopeone::core::HardwareDeviceDescriptor) diff --git a/ScopeOneCore/include/scopeone/PluginManifest.h b/ScopeOneCore/include/scopeone/PluginManifest.h new file mode 100644 index 0000000..b7dd545 --- /dev/null +++ b/ScopeOneCore/include/scopeone/PluginManifest.h @@ -0,0 +1,34 @@ +#pragma once + +#include "scopeone/scopeone_sdk_export.h" + +#include +#include + +namespace scopeone::core +{ + enum class PluginKind + { + Processing, + Tool, + Hardware + }; + + struct PluginManifest + { + QString id; + QString name; + QString version; + PluginKind kind{PluginKind::Processing}; + bool autoLoad{false}; + QJsonObject metadata; + }; + + inline constexpr int ScopeOnePluginApiVersion = 1; + + SCOPEONE_SDK_EXPORT QString pluginKindName(PluginKind kind); + SCOPEONE_SDK_EXPORT bool parsePluginManifest(const QJsonObject& metadata, + PluginKind expectedKind, + PluginManifest& manifest, + QString* errorMessage = nullptr); +} diff --git a/ScopeOneCore/include/scopeone/PluginSDK.h b/ScopeOneCore/include/scopeone/PluginSDK.h new file mode 100644 index 0000000..4cdb85a --- /dev/null +++ b/ScopeOneCore/include/scopeone/PluginSDK.h @@ -0,0 +1,15 @@ +#pragma once + +#include "scopeone/DriverHostProviderPlugin.h" +#include "scopeone/DaqDevice.h" +#include "scopeone/HardwareCapabilities.h" +#include "scopeone/HardwareProvider.h" +#include "scopeone/HardwareTypes.h" +#include "scopeone/ImageFrame.h" +#include "scopeone/PluginManifest.h" +#include "scopeone/ProcessingPlugin.h" +#include "scopeone/ScanImageAssembler.h" +#include "scopeone/SignalSource.h" +#include "scopeone/ToolFrameStream.h" +#include "scopeone/ToolPlugin.h" +#include "scopeone/ToolTask.h" diff --git a/ScopeOneCore/include/scopeone/ProcessingPipeline.h b/ScopeOneCore/include/scopeone/ProcessingPipeline.h new file mode 100644 index 0000000..a9d8948 --- /dev/null +++ b/ScopeOneCore/include/scopeone/ProcessingPipeline.h @@ -0,0 +1,34 @@ +#pragma once + +#include "scopeone/ProcessingPlugin.h" +#include "scopeone/scopeone_core_export.h" + +#include + +namespace scopeone::core +{ + class SCOPEONE_CORE_EXPORT ProcessingPipeline + { + public: + ProcessingPipeline(); + ~ProcessingPipeline(); + + ProcessingPipeline(ProcessingPipeline&&) noexcept; + ProcessingPipeline& operator=(ProcessingPipeline&&) noexcept; + + ProcessingPipeline(const ProcessingPipeline&) = delete; + ProcessingPipeline& operator=(const ProcessingPipeline&) = delete; + + bool addModule(std::unique_ptr module); + bool removeModule(int index); + bool setModuleParameters(int index, const QVariantMap& parameters); + bool resetModuleState(int index); + int moduleCount() const; + + ProcessingResult process(const ProcessingValue& input, int processingBitDepth = 16) const; + + private: + struct Impl; + std::unique_ptr m_impl; + }; +} diff --git a/ScopeOneCore/include/scopeone/ProcessingPlugin.h b/ScopeOneCore/include/scopeone/ProcessingPlugin.h new file mode 100644 index 0000000..d48b9d6 --- /dev/null +++ b/ScopeOneCore/include/scopeone/ProcessingPlugin.h @@ -0,0 +1,158 @@ +#pragma once + +#include "scopeone/ImageFrame.h" +#include "scopeone/scopeone_sdk_export.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::core +{ + enum class ProcessingParameterType + { + Integer, + Real, + Boolean, + Choice + }; + + struct ProcessingParameterChoice + { + QString name; + QVariant value; + }; + + struct ProcessingParameterDescriptor + { + QString key; + QString name; + ProcessingParameterType type{ProcessingParameterType::Integer}; + QVariant defaultValue; + QVariant minimum; + QVariant maximum; + QVariant step; + int decimals{0}; + QList choices; + }; + + struct ProcessingModuleDescriptor + { + QString id; + QString name; + int schemaVersion{1}; + QList parameters; + bool resettable{false}; + }; + + struct ComplexFrame + { + QString sourceId; + int width{0}; + int height{0}; + int stride{0}; + int sourceWidth{0}; + int sourceHeight{0}; + quint64 frameIndex{0}; + quint64 timestampNs{0}; + QByteArray real; + QByteArray imaginary; + + bool isValid() const + { + const qint64 samples = static_cast(stride) * height; + const qint64 bytes = samples * static_cast(sizeof(float)); + return width > 0 && height > 0 && stride >= width && bytes > 0 + && real.size() == bytes && imaginary.size() == bytes; + } + }; + + using ProcessingValue = std::variant; + + struct ProcessingResult + { + ImageFrame frame; + ProcessingValue value; + QString error; + + ProcessingResult() = default; + ProcessingResult(const ImageFrame& output, QString message = {}) + : frame(output), value(output), error(std::move(message)) {} + ProcessingResult(ImageFrame&& output, QString message = {}) + : frame(output), value(std::move(output)), error(std::move(message)) {} + ProcessingResult(ProcessingValue output, QString message = {}) + : value(std::move(output)), error(std::move(message)) + { + if (std::holds_alternative(value)) + { + frame = std::get(value); + } + } + + bool succeeded() const + { + if (!error.isEmpty()) + { + return false; + } + if (std::holds_alternative(value)) + { + return std::get(value).isValid(); + } + return std::get(value).isValid(); + } + + bool hasImage() const + { + return std::holds_alternative(value) + && std::get(value).isValid(); + } + }; + + class SCOPEONE_SDK_EXPORT ProcessingModule + { + public: + virtual ~ProcessingModule() = default; + + bool isEnabled() const { return m_enabled; } + void setEnabled(bool enabled) { m_enabled = enabled; } + + virtual QString id() const = 0; + virtual QString name() const = 0; + virtual QVariantMap parameters() const = 0; + virtual void setParameters(const QVariantMap& parameters) = 0; + virtual std::unique_ptr createRuntime() const = 0; + virtual bool resetState() { return false; } + virtual ProcessingResult process(const ImageFrame& frame, int processingBitDepth) = 0; + + virtual ProcessingResult processValue(const ProcessingValue& input, + int processingBitDepth) + { + if (!std::holds_alternative(input)) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Module requires an image input")); + } + return process(std::get(input), processingBitDepth); + } + + private: + bool m_enabled{true}; + }; + + class ProcessingPlugin + { + public: + virtual ~ProcessingPlugin() = default; + + virtual QList processingModules() const = 0; + virtual std::unique_ptr createProcessingModule(const QString& moduleId) = 0; + }; +} + +#define ScopeOneProcessingPlugin_iid "org.scopeone.ProcessingPlugin/1.0" +Q_DECLARE_INTERFACE(scopeone::core::ProcessingPlugin, ScopeOneProcessingPlugin_iid) diff --git a/ScopeOneCore/include/scopeone/ScanImageAssembler.h b/ScopeOneCore/include/scopeone/ScanImageAssembler.h new file mode 100644 index 0000000..a513681 --- /dev/null +++ b/ScopeOneCore/include/scopeone/ScanImageAssembler.h @@ -0,0 +1,51 @@ +#pragma once + +#include "scopeone/ImageFrame.h" +#include "scopeone/SignalSource.h" +#include "scopeone/scopeone_sdk_export.h" + +#include +#include + +namespace scopeone::core +{ + class SCOPEONE_SDK_EXPORT ScanImageAssembler final + { + public: + explicit ScanImageAssembler(const QString& sourceId, + const ScanImageConfig& config); + + bool isValid() const; + void reset(); + QList append(const TimestampedEventChunk& chunk); + QList finish(); + + private: + void handleMarker(quint64 tick, quint32 code); + void beginFrame(); + void beginLine(quint64 tick); + void finishLine(quint64 tick); + void finishFrame(quint64 tick); + void appendEvent(quint64 tick); + void emitFrame(const QVector& pixels, quint64 tick); + void emitAveragedFrame(quint64 tick); + + QString m_sourceId; + ScanImageConfig m_config; + QVector m_framePixels; + QVector m_accumulatedPixels; + QVector m_lineEventTicks; + QList m_readyFrames; + int m_nextRow{0}; + int m_accumulatedFrameCount{0}; + bool m_frameActive{false}; + bool m_lineActive{false}; + quint64 m_lineStartTick{0}; + quint64 m_lastLineDurationTicks{0}; + quint64 m_lastFrameTick{0}; + double m_tickPeriodSeconds{0.0}; + quint64 m_nextFrameIndex{0}; + }; +} + +Q_DECLARE_METATYPE(scopeone::core::ScanImageConfig) diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index 29104b5..958fb37 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -14,12 +16,19 @@ #include #include #include +#include #include #include #include #include "scopeone/ExperimentDocument.h" +#include "scopeone/DaqDevice.h" +#include "scopeone/HardwareTypes.h" +#include "scopeone/HardwareProvider.h" #include "scopeone/ImageFrame.h" +#include "scopeone/ProcessingPlugin.h" +#include "scopeone/ProcessingPipeline.h" +#include "scopeone/SignalSource.h" #include "scopeone/scopeone_core_export.h" class CMMCore; @@ -49,7 +58,6 @@ namespace scopeone::core public: using RecordingAxis = scopeone::core::RecordingAxis; - using ProcessingModuleKind = scopeone::core::ProcessingModuleKind; using ProcessingBitDepth = scopeone::core::ProcessingBitDepth; using RecordingFileManifest = scopeone::core::RecordingFileManifest; using RecordingOutputManifest = scopeone::core::RecordingOutputManifest; @@ -71,6 +79,7 @@ namespace scopeone::core int failCount{0}; int skippedCameraCount{0}; bool foundCamera{false}; + QList devices; }; struct HistogramStats @@ -447,17 +456,23 @@ namespace scopeone::core class ProcessingModuleInfo { public: - ProcessingModuleKind kind() const { return m_kind; } + const QString& id() const { return m_id; } const QString& name() const { return m_name; } const QVariantMap& parameters() const { return m_parameters; } - void setKind(ProcessingModuleKind kind) { m_kind = kind; } + const ProcessingModuleDescriptor& descriptor() const { return m_descriptor; } + bool enabled() const { return m_enabled; } + void setId(const QString& id) { m_id = id; } void setName(const QString& name) { m_name = name; } void setParameters(const QVariantMap& parameters) { m_parameters = parameters; } + void setDescriptor(const ProcessingModuleDescriptor& descriptor) { m_descriptor = descriptor; } + void setEnabled(bool enabled) { m_enabled = enabled; } private: - ProcessingModuleKind m_kind{ProcessingModuleKind::Unknown}; + QString m_id; QString m_name; QVariantMap m_parameters; + ProcessingModuleDescriptor m_descriptor; + bool m_enabled{true}; }; class DevicePropertyInfo @@ -508,10 +523,12 @@ namespace scopeone::core static QString getZlibVersion(); static QString rawLayerKey(const QString& cameraId); static QString processedLayerKey(const QString& cameraId); + static QString toolLayerKey(const QString& sourceId); static QString staticLayerKey(const QString& sourceId); static QString sourceIdFromLayerKey(const QString& layerKey); static bool isRawLayerKey(const QString& layerKey); static bool isProcessedLayerKey(const QString& layerKey); + static bool isToolLayerKey(const QString& layerKey); static bool isStaticLayerKey(const QString& layerKey); ImageSceneModel* imageSceneModel() const { return m_imageSceneModel; } @@ -527,6 +544,13 @@ namespace scopeone::core bool setAdditionalDeviceAdapterSearchPaths(const QStringList& paths); QStringList cameraIds() const { return m_cameraIds; } + QList hardwareDevices() const; + bool registerHardwareProvider(const HardwareProviderPtr& provider); + bool registerDriverHostProvider(const QString& providerId, + const QString& modulePath, + const QVariantMap& options = {}, + QString* errorMessage = nullptr); + bool unregisterHardwareProvider(const QString& providerId); QStringList runningPreviewCameraIds() const; double cameraPixelSizeUm(const QString& cameraId) const; bool setCameraPixelSizeUm(const QString& cameraId, double pixelSizeUm); @@ -538,8 +562,22 @@ namespace scopeone::core bool setHalfROI(const QString& cameraId); bool clearROI(const QString& cameraId); bool getROI(const QString& cameraId, int& x, int& y, int& width, int& height); + QList signalSources() const; + bool startSignalTrace(const SignalAcquisitionConfig& config, + QString* errorMessage = nullptr); + void stopSignalTrace(const QString& sourceId); + SignalSourceState signalSourceState(const QString& sourceId) const; + QString signalSourceStateMessage(const QString& sourceId) const; + QList daqDevices() const; + bool startDaqSession(const DaqSessionConfig& config, + QString* errorMessage = nullptr); + void stopDaqSession(const QString& deviceId); + DaqState daqState(const QString& deviceId) const; + QString daqStateMessage(const QString& deviceId) const; ImageFrame graphFrame(const QString& layerKey) const; QList graphFrames(const QStringList& layerKeys) const; + double layerFrameRate(const QString& layerKey) const; + QMap layerFrameRates() const; bool graphPixelValue(const QString& layerKey, const QPoint& imagePos, int& value) const; std::shared_ptr createFrameSession( const QList& frames, @@ -547,6 +585,17 @@ namespace scopeone::core ImageFrame publishStaticFrame(const QString& sourceId, const ImageFrame& frame, const QString& displayName = QString()); + ImageFrame importImageAsStaticLayer(const QString& filePath, + QString* outLayerKey = nullptr, + QString* errorMessage = nullptr); + void importImageAsStaticLayerAsync(const QString& filePath); + QString importSessionAsStaticLayer( + const std::shared_ptr& session); + int layerSliceCount(const QString& layerKey) const; + bool setLayerSliceIndex(const QString& layerKey, int sliceIndex); + ImageFrame publishToolStreamFrame(const QString& sourceId, + const ImageFrame& frame, + const QString& displayName = QString()); ImageFrame publishExternalFrame(const QString& sourceId, const ImageFrame& frame); void removeStaticFrame(const QString& sourceId); void clearStaticFrames(); @@ -568,6 +617,12 @@ namespace scopeone::core int minArea, int maxArea, int maxParticles = 10000); + quint64 detectParticles(const ImageFrame& frame, + const QString& resultLayerKey, + int threshold, + int minArea, + int maxArea, + int maxParticles = 10000); static bool computeHistogramStats(const ImageFrame& frame, HistogramStats& stats); bool startStageMosaic(const StageMosaicPlan& plan, @@ -586,6 +641,15 @@ namespace scopeone::core quint64 moveZRelative(const QString& zStageLabel, double dz); quint64 moveXYTo(const QString& xyStageLabel, double x, double y); quint64 moveZTo(const QString& zStageLabel, double z); + bool readShutterOpen(const QString& shutterLabel, bool& open) const; + bool setShutterOpen(const QString& shutterLabel, + bool open, + QString* errorMessage = nullptr); + bool readDeviceState(const QString& deviceLabel, long& state) const; + bool setDeviceState(const QString& deviceLabel, + long state, + QString* errorMessage = nullptr); + QString deviceStateLabel(const QString& deviceLabel, long state) const; bool readExposure(const QString& cameraIdOrAll, double& exposureMs) const; QStringList availableConfigGroups() const; @@ -617,16 +681,32 @@ namespace scopeone::core bool setRealTimeProcessingEnabled(bool enabled); ProcessingBitDepth processingBitDepth() const; bool setProcessingBitDepth(ProcessingBitDepth bitDepth); + QString realTimeProcessingSource() const; + bool setRealTimeProcessingSource(const QString& cameraId); ProcessingRecipe processingRecipe() const; bool applyProcessingRecipe(const ProcessingRecipe& recipe, QString* errorMessage = nullptr); ImageFrame processFrame(const ImageFrame& frame) const; ImageFrame processFrameFrom(int startModuleIndex, const ImageFrame& frame) const; ImageFrame processFrameThrough(int endModuleIndex, const ImageFrame& frame) const; + QList availableProcessingModules() const; + bool registerProcessingModule( + const ProcessingModuleDescriptor& descriptor, + std::function()> factory); + std::unique_ptr createProcessingModule(const QString& moduleId) const; + std::unique_ptr createProcessingPipeline() const; QList processingModules() const; - bool addProcessingModule(ProcessingModuleKind kind); + bool addProcessingModule(const QString& moduleId); bool removeProcessingModule(int index); + bool moveProcessingModule(int from, int to); + bool setProcessingModuleEnabled(int index, bool enabled); bool setProcessingModuleParameters(int index, const QVariantMap& parameters); bool resetProcessingModuleState(int index); + quint64 requestImageProcessing(const ImageFrame& frame, + const QString& sourceId = QString()); + quint64 requestRecordingSessionStackProcessing(const QString& sessionId, + const QString& cameraId); + quint64 requestLayerStackProcessing(const QString& layerKey); + bool cancelProcessingRequest(quint64 requestId); void setRecordingMaxPendingWriteBytes(qint64 bytes); @@ -655,6 +735,10 @@ namespace scopeone::core bool saveRecordingSession(const std::shared_ptr& session); bool saveRecordingSession(const std::shared_ptr& session, const RecordingSaveOptions& saveOptions); + bool saveRecordingSessionCamera(const std::shared_ptr& session, + const QString& cameraId, + const RecordingSaveOptions& saveOptions, + const ExperimentDocument* presentation = nullptr); quint64 requestRecordingSessionFrame( const std::shared_ptr& session, const QString& cameraId, @@ -666,6 +750,10 @@ namespace scopeone::core const QString& errorMessage); void configurationUnloadFinished(bool success, const QString& errorMessage); void hardwareConfigurationChanged(); + void hardwareDevicesChanged(); + void hardwareProviderRegistrationFinished(const QString& providerId, + bool success, + const QString& errorMessage); void deviceStateChanged(); void stagePositionChanged(); void stageMoveFinished(quint64 commandId, @@ -676,13 +764,31 @@ namespace scopeone::core void rawFramesAcquired(const QString& cameraId, quint64 frameCount); void previewRawFrameReady(const ImageFrame& frame); void previewStateChanged(bool running); - void agentControlServerListening(const QString& cameraId, const QString& serverName); + void signalTimeSeriesReady(const TimeSeriesChunk& chunk); + void timestampedSignalEventsReady(const TimestampedEventChunk& chunk); + void signalSourceStateChanged(const QString& sourceId, + SignalSourceState state, + const QString& message); + void signalSourceError(const QString& sourceId, + const QString& errorMessage); + void scanImageSessionReady( + const std::shared_ptr& session); + void daqStateChanged(const QString& deviceId, + DaqState state, + const QString& message); + void daqError(const QString& deviceId, + const QString& errorMessage); + void daqInputDataReady(const DaqInputChunk& chunk); + void driverHostControlServerListening(const QString& cameraId, const QString& serverName); void processedFrameReady(const ImageFrame& frame); void processedFramesCompleted(const QString& cameraId, quint64 frameCount); void previewProcessedFrameReady(const ImageFrame& frame); void staticFramePublished(const QString& sourceId, const QString& displayName, const ImageFrame& frame); + void toolStreamFramePublished(const QString& sourceId, + const QString& displayName, + const ImageFrame& frame); void staticFrameRemoved(const QString& sourceId); void staticFramesCleared(); void liveFramesCleared(const QString& cameraId); @@ -707,6 +813,19 @@ namespace scopeone::core void processingModulesChanged(); void processingModuleParametersChanged(int index); void processingSettingsChanged(); + void imageProcessingFinished(quint64 requestId, + const QString& sourceId, + const ImageFrame& frame, + const QString& errorMessage); + void stackProcessingProgress(quint64 requestId, qint64 completed, qint64 total); + void stackProcessingFinished( + quint64 requestId, + const std::shared_ptr& session, + const QString& errorMessage); + void layerStackProcessingFinished( + quint64 requestId, + const QString& outputLayerKey, + const QString& errorMessage); void recordingProgressChanged(int phase, qint64 frameCurrent, @@ -729,13 +848,23 @@ namespace scopeone::core void recordingStateChanged(bool isRecording); void recordingStopped(const std::shared_ptr& session); void recordingSessionSaveFinished(const std::shared_ptr& session); + void recordingSessionCameraSaveFinished( + const std::shared_ptr& session, + const QString& cameraId, + bool success, + const QString& message); void recordingSessionClosed(const QString& sessionId); + void recordingSessionsChanged(); void recordingSessionFrameReady( quint64 requestId, const std::shared_ptr& session, const QString& cameraId, int index, const ImageFrame& frame); + void staticImageImportProgress(const QString& filePath, int percent, const QString& statusText); + void staticImageImportFinished(const QString& filePath, const QString& layerKey, bool success, const QString& errorMessage); + void layerFrameRateChanged(const QString& layerKey, double fps); + void layerFrameRatesUpdated(const QMap& frameRates); private: struct Managers; @@ -744,6 +873,7 @@ namespace scopeone::core { Raw, Processed, + Tool, Static, External }; @@ -764,6 +894,7 @@ namespace scopeone::core QHash m_rawFrames; QHash m_processedFrames; + QHash m_toolFrames; QHash m_staticFrames; QHash m_externalFrames; }; @@ -779,8 +910,9 @@ namespace scopeone::core void unloadConfigurationForShutdown(); void applySystemShutdownPreset(); - void applyLoadedConfiguration(const QString& configPath, + bool applyLoadedConfiguration(const QString& configPath, const LoadConfigResult& result); + void synchronizeCameraIdsFromRegistry(); void finishConfigurationLoadFailure(const LoadConfigResult& result, const QString& errorMessage); void clearConfigurationRuntime(bool notify, bool shutdownCameraBackend); @@ -790,18 +922,25 @@ namespace scopeone::core void startConfigurationUnloadTask(); quint64 queueStageMove( const QString& deviceLabel, - std::function command); + std::function command); std::shared_ptr core() const; - bool isConfiguredCamera(const QString& deviceLabel) const; - bool isNativeCamera(const QString& deviceLabel) const; bool isPropertyPreInit(const QString& deviceLabel, const QString& name) const; void ensureSceneLayer(const QString& layerKey, const QString& sourceId, const QString& name, DocumentLayerKind kind); void handleIncomingRawFrame(const ImageFrame& frame); + void handleSignalTimeSeries(const TimeSeriesChunk& chunk); + void handleTimestampedSignalEvents(const TimestampedEventChunk& chunk); + void publishScanFrames(const QString& sourceId, + const QList& frames); + void finishScanImageSession(const QString& sourceId, + ExperimentRunState finalState, + const QString& message); void submitProcessingFrame(const ImageFrame& frame, quint64 processingToken = 0); void handleProcessedFrame(const ImageFrame& frame); + void recordLayerFrame(const QString& layerKey, quint64 count = 1); + void updateLayerFrameRates(); void flushProcessedFrames(); void queuePreviewRawFrame(const ImageFrame& frame); void schedulePreviewFlush(); @@ -812,12 +951,14 @@ namespace scopeone::core void clearLayerAnalysisByPrefix(const QString& prefix); void updateLineProfile(const QString& cameraId, bool processed, + bool toolSource, const ImageFrame& frame); bool updateStaticLineProfile(const QString& sourceId, const ImageFrame& frame); void setLineProfile(const QString& cameraId, const QPoint& start, const QPoint& end, - bool processed); + bool processed, + bool toolSource = false); void setStaticLineProfile(const QString& sourceId, const QPoint& start, const QPoint& end); @@ -825,6 +966,10 @@ namespace scopeone::core void syncLineProfileFromScene(); void registerRecordingSession(const std::shared_ptr& session); void finalizeActiveExperiment(const std::shared_ptr& session); + bool queueRecordingSessionSave( + const std::shared_ptr& sourceSession, + const std::shared_ptr& saveSession, + const QString& cameraId = QString()); struct ActiveLineProfile { @@ -832,6 +977,7 @@ namespace scopeone::core QPoint start; QPoint end; bool processed{false}; + bool toolSource{false}; bool staticSource{false}; bool active{false}; }; @@ -853,12 +999,19 @@ namespace scopeone::core std::unique_ptr m_hardwareThreadPool; std::unique_ptr m_analysisThreadPool; std::unique_ptr m_sessionFrameThreadPool; + std::unique_ptr m_offlineProcessingThreadPool; QString m_activeHistogramLayerKey; quint64 m_nextHistogramSequence{0}; QElapsedTimer m_lineProfileUpdateTimer; QElapsedTimer m_previewPublishTimer; QTimer* m_previewFlushTimer{nullptr}; + QTimer* m_layerFrameRateTimer{nullptr}; + mutable QMutex m_layerFrameRateMutex; + QElapsedTimer m_layerFrameRateElapsed; + QHash m_layerFrameCounts; + QMap m_layerFrameRates; QSet m_sessionsSaving; + QSet m_pendingProviderRegistrations; enum class ConfigurationState { Unloaded, @@ -875,6 +1028,10 @@ namespace scopeone::core quint64 m_nextAnalysisRequestId{0}; quint64 m_analysisGeneration{0}; quint64 m_nextSessionFrameRequestId{0}; + QString m_realTimeProcessingSource; + QHash> m_processingRequestCancelTokens; + quint64 m_nextProcessingRequestId{0}; + QHash> m_layerStacks; }; } diff --git a/ScopeOneCore/include/scopeone/SignalSource.h b/ScopeOneCore/include/scopeone/SignalSource.h new file mode 100644 index 0000000..3a937d4 --- /dev/null +++ b/ScopeOneCore/include/scopeone/SignalSource.h @@ -0,0 +1,222 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "scopeone/scopeone_sdk_export.h" + +namespace scopeone::core +{ + enum class SignalSourceState + { + Idle, + Starting, + Running, + Stopping, + Error + }; + + enum class SignalStreamType + { + TimeSeries, + TimestampedEvents + }; + + enum class SignalParameterType + { + Integer, + Real, + String, + File, + Choice + }; + + struct SignalParameterDescriptor + { + QString key; + QString name; + SignalParameterType type{SignalParameterType::String}; + QVariant defaultValue; + bool hasRange{false}; + double minimum{0.0}; + double maximum{0.0}; + QString suffix; + QVariantList choices; + QStringList choiceNames; + QString fileFilter; + }; + + struct SignalSourceDescriptor + { + QString id; + QString name; + QString provider; + QString quantity; + QString unit; + SignalStreamType streamType{SignalStreamType::TimeSeries}; + QList parameters; + }; + + struct ScanImageConfig + { + bool enabled{false}; + int width{256}; + int height{256}; + quint32 gain{1}; + int averageFrames{1}; + quint32 frameStartMarker{1}; + quint32 lineMarker{2}; + quint32 frameEndMarker{0}; + bool serpentine{false}; + bool mirrorHorizontal{false}; + }; + + struct SignalAcquisitionConfig + { + QString sourceId; + double sampleIntervalSeconds{0.01}; + int durationMs{360000000}; + bool publishTimestampedEvents{false}; + ScanImageConfig scanImage; + QVariantMap sourceSettings; + }; + + struct SignalMarker + { + double timeSeconds{0.0}; + quint32 code{0}; + + bool isValid() const + { + return qIsFinite(timeSeconds) && timeSeconds >= 0.0 && code != 0; + } + }; + + struct TimeSeriesChunk + { + QString sourceId; + QString quantity; + QString unit; + double startTimeSeconds{0.0}; + double sampleIntervalSeconds{0.0}; + QVector values; + QVector markers; + quint64 totalInputEvents{0}; + quint64 totalMarkers{0}; + + bool isValid() const + { + return !sourceId.isEmpty() + && sampleIntervalSeconds > 0.0 + && !values.isEmpty(); + } + }; + + struct TimestampedEventChunk + { + QString sourceId; + double tickPeriodSeconds{0.0}; + QVector eventTicks; + QVector eventCodes; + QVector markerTicks; + QVector markerCodes; + + bool isValid() const + { + return !sourceId.isEmpty() + && tickPeriodSeconds > 0.0 + && eventTicks.size() == eventCodes.size() + && markerTicks.size() == markerCodes.size() + && (!eventTicks.isEmpty() || !markerTicks.isEmpty()); + } + }; + + class SCOPEONE_SDK_EXPORT EventCountBinner + { + public: + EventCountBinner(const QString& sourceId, + const QString& quantity, + const QString& unit, + double tickPeriodSeconds, + double sampleIntervalSeconds); + + void addEvent(quint64 tick); + void addMarker(quint64 tick, quint32 code); + void advanceToTick(quint64 tick); + void advanceToElapsedSeconds(double elapsedSeconds); + bool hasReadyChunks() const; + QList takeReadyChunks(); + QList takeCompletedChunks(); + + private: + quint64 binForTick(quint64 tick) const; + void appendCompletedValue(double value); + void finishChunk(); + void advanceToBin(quint64 targetBin); + + QString m_sourceId; + QString m_quantity; + QString m_unit; + double m_tickPeriodSeconds{0.0}; + quint64 m_ticksPerSample{1}; + quint64 m_currentSampleTick{0}; + quint64 m_nextSampleTick{1}; + double m_sampleIntervalSeconds{0.0}; + quint64 m_currentSample{0}; + quint64 m_firstCompletedSample{0}; + quint64 m_currentEventCount{0}; + quint64 m_totalInputEvents{0}; + quint64 m_totalMarkers{0}; + QVector m_completedValues; + QVector m_pendingMarkers; + QList m_readyChunks; + }; + + class SCOPEONE_SDK_EXPORT SignalSource : public QObject + { + Q_OBJECT + + public: + explicit SignalSource(QObject* parent = nullptr); + ~SignalSource() override; + + virtual bool start(const SignalAcquisitionConfig& config, + QString* errorMessage = nullptr) = 0; + virtual void stop() = 0; + virtual SignalSourceState state() const = 0; + virtual QString stateMessage() const = 0; + + signals: + void timeSeriesReady(const scopeone::core::TimeSeriesChunk& chunk); + void timestampedEventsReady(const scopeone::core::TimestampedEventChunk& chunk); + void stateChanged(scopeone::core::SignalSourceState state, + const QString& message); + void sourceError(const QString& errorMessage); + }; + + class SignalSourcePlugin + { + public: + virtual ~SignalSourcePlugin() = default; + virtual QList signalSources() const = 0; + virtual SignalSource* createSignalSource(const QString& sourceId, + QObject* parent = nullptr) = 0; + }; +} + +#define SCOPEONE_SIGNAL_SOURCE_PLUGIN_IID "org.scopeone.SignalSourcePlugin/1.0" +Q_DECLARE_INTERFACE(scopeone::core::SignalSourcePlugin, SCOPEONE_SIGNAL_SOURCE_PLUGIN_IID) + +Q_DECLARE_METATYPE(scopeone::core::SignalSourceState) +Q_DECLARE_METATYPE(scopeone::core::SignalMarker) +Q_DECLARE_METATYPE(scopeone::core::TimeSeriesChunk) +Q_DECLARE_METATYPE(scopeone::core::TimestampedEventChunk) diff --git a/ScopeOneCore/include/scopeone/SimulatorProvider.h b/ScopeOneCore/include/scopeone/SimulatorProvider.h new file mode 100644 index 0000000..c653e04 --- /dev/null +++ b/ScopeOneCore/include/scopeone/SimulatorProvider.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include + +#include "scopeone/CameraProvider.h" +#include "scopeone/HardwareProvider.h" +#include "scopeone/scopeone_core_export.h" + +namespace scopeone::core +{ + class SCOPEONE_CORE_EXPORT SimulatorProvider final + : public QObject, + public HardwareProvider, + public CameraProvider + { + public: + explicit SimulatorProvider(const QString& logicalCameraId = QStringLiteral("camera.simulator"), + int width = 512, + int height = 512, + const QString& providerId = {}); + + HardwareProviderDescriptor descriptor() const override; + QList devices() const override; + void setFrameSink(FrameSink sink) override; + void setPreviewStateSink(PreviewStateSink sink) override; + bool startPreview() override; + bool stopPreview() override; + bool startPreviewFor(const QString& cameraId) override; + bool stopPreviewFor(const QString& cameraId) override; + bool isPreviewRunning(const QString& cameraId) const override; + bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const override; + bool setExposure(const QString& cameraIdOrAll, double exposureMs) override; + QStringList listProperties(const QString& cameraId) override; + QString getProperty(const QString& cameraId, + const QString& name, + bool fromCache) override; + bool setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) override; + QString getPropertyType(const QString& cameraId, const QString& name) override; + bool isPropertyReadOnly(const QString& cameraId, const QString& name) override; + bool isPropertyPreInit(const QString& cameraId, const QString& name) override; + QStringList getAllowedPropertyValues(const QString& cameraId, + const QString& name) override; + bool hasPropertyLimits(const QString& cameraId, const QString& name) override; + double getPropertyLowerLimit(const QString& cameraId, const QString& name) override; + double getPropertyUpperLimit(const QString& cameraId, const QString& name) override; + bool setROI(const QString& cameraId, int x, int y, int width, int height) override; + bool clearROI(const QString& cameraId) override; + bool getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) override; + bool captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs) override; + + private: + enum class ImageMode + { + Gradient, + Hologram + }; + + bool accepts(const QString& cameraIdOrAll) const; + ImageFrame makeFrame(); + void updateTimerInterval(); + + QString m_providerId; + QString m_cameraId; + int m_sensorWidth{512}; + int m_sensorHeight{512}; + QRect m_roi; + double m_exposureMs{10.0}; + ImageMode m_imageMode{ImageMode::Gradient}; + quint64 m_frameIndex{0}; + FrameSink m_frameSink; + PreviewStateSink m_previewStateSink; + QTimer m_timer; + mutable QMutex m_mutex; + }; +} diff --git a/ScopeOneCore/include/scopeone/ToolFrameStream.h b/ScopeOneCore/include/scopeone/ToolFrameStream.h new file mode 100644 index 0000000..1308627 --- /dev/null +++ b/ScopeOneCore/include/scopeone/ToolFrameStream.h @@ -0,0 +1,42 @@ +#pragma once + +#include "scopeone/ImageFrame.h" +#include "scopeone/scopeone_sdk_export.h" + +#include +#include + +namespace scopeone::core +{ + class ScopeOneCore; +} + +namespace scopeone::ui +{ + class SCOPEONE_SDK_EXPORT ScopeOneToolFrameStream final : public QObject + { + Q_OBJECT + + public: + explicit ScopeOneToolFrameStream(scopeone::core::ScopeOneCore& core, + QObject* parent = nullptr); + + void setSourceId(const QString& cameraId); + + void setEnabled(bool enabled); + void setProcessing(bool processing); + void clearPendingFrame(); + + signals: + void frameReady(const scopeone::core::ImageFrame& frame); + + private: + void acceptFrame(const scopeone::core::ImageFrame& frame); + + scopeone::core::ScopeOneCore& m_core; + QString m_sourceId; + scopeone::core::ImageFrame m_pendingFrame; + bool m_enabled{true}; + bool m_processing{false}; + }; +} diff --git a/ScopeOneCore/include/scopeone/ToolPlugin.h b/ScopeOneCore/include/scopeone/ToolPlugin.h new file mode 100644 index 0000000..71dd013 --- /dev/null +++ b/ScopeOneCore/include/scopeone/ToolPlugin.h @@ -0,0 +1,65 @@ +#pragma once + +#include "scopeone/ScopeOneCore.h" + +#include +#include +#include +#include +#include +#include + +class QWidget; + +namespace scopeone::ui +{ + enum class ToolWindowMode + { + Modal, + ModelessSingleton + }; + + struct ToolDescriptor + { + QString id; + QString name; + QString category; + ToolWindowMode windowMode{ToolWindowMode::ModelessSingleton}; + bool requiresCamera{false}; + }; + + class ScopeOneToolContext + { + public: + virtual ~ScopeOneToolContext() = default; + + virtual scopeone::core::ScopeOneCore& core() const = 0; + virtual QString currentLayerKey() const = 0; + virtual scopeone::core::ImageFrame currentFrame() const = 0; + virtual double layerFrameRate(const QString& layerKey) const = 0; + virtual QMap layerFrameRates() const = 0; + virtual scopeone::core::ImageFrame publishToolStreamFrame( + const QString& sourceId, + const scopeone::core::ImageFrame& frame, + const QString& displayName = QString()) = 0; + virtual void showLayers(const QStringList& layerKeys, bool sideBySide = false) = 0; + virtual void showToolStatus(const QString& message, int timeoutMs = 5000) = 0; + virtual void presentSession( + const std::shared_ptr& session, + const QString& title) = 0; + }; + + class ScopeOneToolPlugin + { + public: + virtual ~ScopeOneToolPlugin() = default; + + virtual QList tools() const = 0; + virtual QWidget* createTool(const QString& toolId, + ScopeOneToolContext& context, + QWidget* parent) = 0; + }; +} + +#define ScopeOneToolPlugin_iid "org.scopeone.ToolPlugin/1.0" +Q_DECLARE_INTERFACE(scopeone::ui::ScopeOneToolPlugin, ScopeOneToolPlugin_iid) diff --git a/ScopeOneCore/include/scopeone/ToolTask.h b/ScopeOneCore/include/scopeone/ToolTask.h new file mode 100644 index 0000000..fa2e131 --- /dev/null +++ b/ScopeOneCore/include/scopeone/ToolTask.h @@ -0,0 +1,37 @@ +#pragma once + +#include "scopeone/scopeone_sdk_export.h" + +#include +#include +#include +#include +#include + +namespace scopeone::ui +{ + class SCOPEONE_SDK_EXPORT ScopeOneToolTask final : public QObject + { + Q_OBJECT + + public: + using Work = std::function&)>; + + explicit ScopeOneToolTask(Work work, QObject* parent = nullptr); + ~ScopeOneToolTask() override; + + void start(); + void cancel(); + + signals: + void progressChanged(int percent); + void finished(); + void canceled(); + void failed(const QString& message); + + private: + struct Impl; + std::unique_ptr m_impl; + }; +} diff --git a/ScopeOneCore/include/scopeone/scopeone_sdk_export.h b/ScopeOneCore/include/scopeone/scopeone_sdk_export.h new file mode 100644 index 0000000..6460fba --- /dev/null +++ b/ScopeOneCore/include/scopeone/scopeone_sdk_export.h @@ -0,0 +1,15 @@ +#pragma once + +#if defined(_WIN32) || defined(__CYGWIN__) +# if defined(SCOPEONE_CORE_EXPORTS) +# define SCOPEONE_SDK_EXPORT __declspec(dllexport) +# else +# define SCOPEONE_SDK_EXPORT __declspec(dllimport) +# endif +#else +# if defined(__GNUC__) && __GNUC__ >= 4 +# define SCOPEONE_SDK_EXPORT __attribute__((visibility("default"))) +# else +# define SCOPEONE_SDK_EXPORT +# endif +#endif diff --git a/ScopeOneCore/internal/AcquisitionEngine.h b/ScopeOneCore/internal/AcquisitionEngine.h new file mode 100644 index 0000000..aae9b16 --- /dev/null +++ b/ScopeOneCore/internal/AcquisitionEngine.h @@ -0,0 +1,22 @@ +#pragma once + +#include + +#include "scopeone/CameraProvider.h" + +namespace scopeone::core::internal +{ + class DeviceRegistry; + + class AcquisitionEngine + { + public: + explicit AcquisitionEngine(DeviceRegistry& deviceRegistry); + + bool start(const QString& cameraIdOrAll); + bool stop(const QString& cameraIdOrAll); + + private: + DeviceRegistry& m_deviceRegistry; + }; +} diff --git a/ScopeOneCore/internal/BackgroundCalibrationModule.h b/ScopeOneCore/internal/BackgroundCalibrationModule.h index dd9285d..5bf0573 100644 --- a/ScopeOneCore/internal/BackgroundCalibrationModule.h +++ b/ScopeOneCore/internal/BackgroundCalibrationModule.h @@ -31,7 +31,7 @@ namespace scopeone::core::internal class BackgroundCalibrationModule : public ProcessingModule { public: - ProcessingModuleKind kind() const noexcept override { return ProcessingModuleKind::BackgroundCalibration; } + QString id() const override { return QStringLiteral("background_calibration"); } QString name() const override { return "Background Calibration"; } QVariantMap parameters() const override; void setParameters(const QVariantMap& params) override; diff --git a/ScopeOneCore/internal/CameraBackend.h b/ScopeOneCore/internal/CameraBackend.h index 99e3be1..c8fd814 100644 --- a/ScopeOneCore/internal/CameraBackend.h +++ b/ScopeOneCore/internal/CameraBackend.h @@ -52,7 +52,7 @@ namespace scopeone::core::internal enum class Kind { Native, - Agent + DriverHost }; explicit CameraBackend(ProcessingFrameGate& processingFrameGate, @@ -71,7 +71,7 @@ namespace scopeone::core::internal virtual bool configureNativeCamera(const std::shared_ptr& core, const QString& cameraId, double exposureMs); - virtual bool addAgentCamera(const QString& cameraId, + virtual bool addDriverHostCamera(const QString& cameraId, const QString& adapter, const QString& device, const QStringList& preInitProperties, @@ -113,7 +113,7 @@ namespace scopeone::core::internal void recordingFramesReady(const QList& frames); void frameDeliveryFailed(const QString& errorMessage, quint64 droppedFrames); void previewStateChanged(bool running); - void agentControlServerListening(const QString& cameraId, const QString& serverName); + void driverHostControlServerListening(const QString& cameraId, const QString& serverName); protected: bool applyWithPreviewRestart(const QString& cameraId, const std::function& operation); @@ -170,5 +170,5 @@ namespace scopeone::core::internal }; std::unique_ptr createNativeCameraBackend(ProcessingFrameGate& processingFrameGate); - std::unique_ptr createAgentCameraBackend(ProcessingFrameGate& processingFrameGate); + std::unique_ptr createDriverHostCameraBackend(ProcessingFrameGate& processingFrameGate); } diff --git a/ScopeOneCore/internal/CameraManager.h b/ScopeOneCore/internal/CameraManager.h index 9be2d48..d279088 100644 --- a/ScopeOneCore/internal/CameraManager.h +++ b/ScopeOneCore/internal/CameraManager.h @@ -9,12 +9,14 @@ #include #include "internal/CameraBackend.h" +#include "scopeone/CameraProvider.h" +#include "internal/CameraRuntimeControl.h" class CMMCore; namespace scopeone::core::internal { - class CameraManager : public QObject + class CameraManager : public QObject, public CameraProvider, public CameraRuntimeControl { Q_OBJECT @@ -22,10 +24,13 @@ namespace scopeone::core::internal explicit CameraManager(QObject* parent = nullptr); ~CameraManager() override; + void setFrameSink(FrameSink sink) override; + void setPreviewStateSink(PreviewStateSink sink) override; + bool configureNativeCamera(const std::shared_ptr& core, const QString& cameraId, double exposureMs = 0.0); - bool addAgentCamera(const QString& cameraId, + bool addDriverHostCamera(const QString& cameraId, const QString& adapter, const QString& device, const QStringList& preInitProperties = QStringList(), @@ -34,57 +39,60 @@ namespace scopeone::core::internal void shutdownNow(); void shutdown(std::function completion); - bool startPreview(); - bool stopPreview(); - bool usesAgentBackend() const; - bool startPreviewFor(const QString& cameraId); - bool stopPreviewFor(const QString& cameraId); - bool isPreviewRunning(const QString& cameraId) const; - void setFrameDeliveryPaused(bool paused); - bool setRecordingFrameDeliveryEnabled(bool enabled); - bool setHighRateFrameDeliveryEnabled(bool enabled); - bool isProcessingFrameTokenCurrent(const QString& cameraId, quint64 token); - void finishProcessingFrame(const QString& cameraId, quint64 token); + bool startPreview() override; + bool stopPreview() override; + bool usesDriverHostBackend() const; + bool startPreviewFor(const QString& cameraId) override; + bool stopPreviewFor(const QString& cameraId) override; + bool isPreviewRunning(const QString& cameraId) const override; + void setFrameDeliveryPaused(const QStringList& cameraIds, bool paused) override; + bool setRecordingFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) override; + bool setHighRateFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) override; + bool isProcessingFrameTokenCurrent(const QString& cameraId, quint64 token) override; + void finishProcessingFrame(const QString& cameraId, quint64 token) override; - bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const; - bool setExposure(const QString& cameraIdOrAll, double exposureMs); - QStringList listProperties(const QString& cameraId); + bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const override; + bool setExposure(const QString& cameraIdOrAll, double exposureMs) override; + QStringList listProperties(const QString& cameraId) override; QString getProperty(const QString& cameraId, const QString& name, - bool fromCache = false); + bool fromCache = false) override; bool setProperty(const QString& cameraId, const QString& name, const QString& value, - QString* errorMessage = nullptr); - QString getPropertyType(const QString& cameraId, const QString& name); - bool isPropertyReadOnly(const QString& cameraId, const QString& name); - bool isPropertyPreInit(const QString& cameraId, const QString& name); - QStringList getAllowedPropertyValues(const QString& cameraId, const QString& name); - bool hasPropertyLimits(const QString& cameraId, const QString& name); - double getPropertyLowerLimit(const QString& cameraId, const QString& name); - double getPropertyUpperLimit(const QString& cameraId, const QString& name); + QString* errorMessage = nullptr) override; + QString getPropertyType(const QString& cameraId, const QString& name) override; + bool isPropertyReadOnly(const QString& cameraId, const QString& name) override; + bool isPropertyPreInit(const QString& cameraId, const QString& name) override; + QStringList getAllowedPropertyValues(const QString& cameraId, const QString& name) override; + bool hasPropertyLimits(const QString& cameraId, const QString& name) override; + double getPropertyLowerLimit(const QString& cameraId, const QString& name) override; + double getPropertyUpperLimit(const QString& cameraId, const QString& name) override; - bool setROI(const QString& cameraId, int x, int y, int width, int height); - bool clearROI(const QString& cameraId); - bool getROI(const QString& cameraId, int& x, int& y, int& width, int& height); + bool setROI(const QString& cameraId, int x, int y, int width, int height) override; + bool clearROI(const QString& cameraId) override; + bool getROI(const QString& cameraId, int& x, int& y, int& width, int& height) override; bool captureEventFrame(const QString& cameraId, scopeone::core::ImageFrame& frame, - int timeoutMs = 1500); + int timeoutMs = 1500) override; signals: - void newRawFrameReady(const scopeone::core::ImageFrame& frame); void processingFrameReady(const scopeone::core::ImageFrame& frame, quint64 token); void rawFramesAcquired(const QString& cameraId, quint64 frameCount); void recordingFramesReady(const QList& frames); void frameDeliveryFailed(const QString& errorMessage, quint64 droppedFrames); void previewStateChanged(bool running); - void agentControlServerListening(const QString& cameraId, const QString& serverName); + void driverHostControlServerListening(const QString& cameraId, const QString& serverName); private: bool activateBackend(CameraBackend::Kind kind); ProcessingFrameGate m_processingFrameGate; std::unique_ptr m_backend; + FrameSink m_frameSink; + PreviewStateSink m_previewStateSink; bool m_recordingFrameDeliveryEnabled{false}; bool m_highRateFrameDeliveryEnabled{false}; QMap m_propertyNamesCache; diff --git a/ScopeOneCore/internal/CameraRuntimeControl.h b/ScopeOneCore/internal/CameraRuntimeControl.h new file mode 100644 index 0000000..7eab518 --- /dev/null +++ b/ScopeOneCore/internal/CameraRuntimeControl.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace scopeone::core::internal +{ + class CameraRuntimeControl + { + public: + virtual ~CameraRuntimeControl() = default; + + virtual void setFrameDeliveryPaused(const QStringList& cameraIds, bool paused) = 0; + virtual bool setRecordingFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) = 0; + virtual bool setHighRateFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) = 0; + virtual bool isProcessingFrameTokenCurrent(const QString& cameraId, + quint64 token) = 0; + virtual void finishProcessingFrame(const QString& cameraId, quint64 token) = 0; + }; +} diff --git a/ScopeOneCore/internal/DaqDeviceManager.h b/ScopeOneCore/internal/DaqDeviceManager.h new file mode 100644 index 0000000..e8bbc99 --- /dev/null +++ b/ScopeOneCore/internal/DaqDeviceManager.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "scopeone/DaqDevice.h" + +class QPluginLoader; + +namespace scopeone::core::internal +{ + class DaqDeviceManager final : public QObject + { + Q_OBJECT + + public: + explicit DaqDeviceManager(QObject* parent = nullptr); + ~DaqDeviceManager() override; + + QList devices() const; + bool start(const DaqSessionConfig& config, + QString* errorMessage = nullptr); + void stop(const QString& deviceId); + DaqState state(const QString& deviceId) const; + QString stateMessage(const QString& deviceId) const; + + signals: + void stateChanged(const QString& deviceId, + scopeone::core::DaqState state, + const QString& message); + void deviceError(const QString& deviceId, const QString& errorMessage); + void inputDataReady(const scopeone::core::DaqInputChunk& chunk); + + private: + void loadPlugins(); + DaqController* controller(const QString& deviceId, + QString* errorMessage); + + std::vector> m_loaders; + mutable bool m_pluginsLoaded{false}; + QHash m_plugins; + QHash m_descriptors; + QHash> m_controllers; + QHash m_states; + QHash m_messages; + }; +} diff --git a/ScopeOneCore/internal/DifferentialRollingModule.h b/ScopeOneCore/internal/DifferentialRollingModule.h index 60b1901..48915fc 100644 --- a/ScopeOneCore/internal/DifferentialRollingModule.h +++ b/ScopeOneCore/internal/DifferentialRollingModule.h @@ -10,7 +10,7 @@ namespace scopeone::core::internal class DifferentialRollingModule : public ProcessingModule { public: - ProcessingModuleKind kind() const noexcept override { return ProcessingModuleKind::DifferentialRolling; } + QString id() const override { return QStringLiteral("differential_rolling"); } QString name() const override { return "Differential Rolling"; } QVariantMap parameters() const override; void setParameters(const QVariantMap& params) override; diff --git a/ScopeOneCore/internal/AgentProtocol.h b/ScopeOneCore/internal/DriverHostProtocol.h similarity index 50% rename from ScopeOneCore/internal/AgentProtocol.h rename to ScopeOneCore/internal/DriverHostProtocol.h index ae1d07c..1e660b0 100644 --- a/ScopeOneCore/internal/AgentProtocol.h +++ b/ScopeOneCore/internal/DriverHostProtocol.h @@ -2,28 +2,48 @@ #include #include +#include #include #include +#include +#include #include -namespace scopeone::core::internal::agent +namespace scopeone::core::internal::driverhost { - inline constexpr quint32 kProtocolVersion = 3; + inline constexpr quint32 kProtocolVersion = 5; inline constexpr quint32 kMaxControlMessageBytes = 256 * 1024; inline const QString kEnvelopeKindField = QStringLiteral("kind"); inline const QString kEnvelopeVersionField = QStringLiteral("version"); inline const QString kEnvelopeRequestIdField = QStringLiteral("requestId"); inline const QString kMessageTypeField = QStringLiteral("type"); + inline const QString kProviderIdField = QStringLiteral("providerId"); + inline const QString kDeviceIdField = QStringLiteral("deviceId"); + inline const QString kDeviceKindField = QStringLiteral("deviceKind"); + inline const QString kCapabilitiesField = QStringLiteral("capabilities"); + inline const QString kDevicesField = QStringLiteral("devices"); + inline const QString kProviderNameField = QStringLiteral("providerName"); + inline const QString kProviderVersionField = QStringLiteral("providerVersion"); + inline const QString kProviderDeviceIdField = QStringLiteral("providerDeviceId"); + inline const QString kHardwareIdField = QStringLiteral("hardwareId"); + inline const QString kDeviceNameField = QStringLiteral("deviceName"); + inline const QString kDeviceStateField = QStringLiteral("deviceState"); + inline const QString kDevicePropertiesField = QStringLiteral("deviceProperties"); + inline const QString kSharedMemoryKeyField = QStringLiteral("sharedMemoryKey"); + inline const QString kDefaultXYStageField = QStringLiteral("defaultXYStage"); + inline const QString kDefaultZStageField = QStringLiteral("defaultZStage"); inline const QString kMessageKindRequest = QStringLiteral("Request"); inline const QString kMessageKindResponse = QStringLiteral("Response"); inline const QString kMessageKindEvent = QStringLiteral("Event"); + inline const QString kCommandDescribe = QStringLiteral("Describe"); inline const QString kCommandShutdown = QStringLiteral("Shutdown"); inline const QString kCommandStartPreview = QStringLiteral("StartPreview"); inline const QString kCommandStopPreview = QStringLiteral("StopPreview"); inline const QString kCommandSetFrameDeliveryMode = QStringLiteral("SetFrameDeliveryMode"); + inline const QString kCommandGetExposure = QStringLiteral("GetExposure"); inline const QString kCommandSetExposure = QStringLiteral("SetExposure"); inline const QString kCommandListProperties = QStringLiteral("ListProperties"); inline const QString kCommandGetProperty = QStringLiteral("GetProperty"); @@ -32,6 +52,28 @@ namespace scopeone::core::internal::agent inline const QString kCommandSetRoi = QStringLiteral("SetROI"); inline const QString kCommandClearRoi = QStringLiteral("ClearROI"); inline const QString kCommandGetRoi = QStringLiteral("GetROI"); + inline const QString kCommandGetXYPosition = QStringLiteral("GetXYPosition"); + inline const QString kCommandGetZPosition = QStringLiteral("GetZPosition"); + inline const QString kCommandSetRelativeXYPosition = QStringLiteral("SetRelativeXYPosition"); + inline const QString kCommandSetRelativeZPosition = QStringLiteral("SetRelativeZPosition"); + inline const QString kCommandSetXYPosition = QStringLiteral("SetXYPosition"); + inline const QString kCommandSetZPosition = QStringLiteral("SetZPosition"); + inline const QString kCommandGetShutterOpen = QStringLiteral("GetShutterOpen"); + inline const QString kCommandSetShutterOpen = QStringLiteral("SetShutterOpen"); + inline const QString kCommandGetState = QStringLiteral("GetState"); + inline const QString kCommandSetState = QStringLiteral("SetState"); + inline const QString kCommandGetStateLabel = QStringLiteral("GetStateLabel"); + inline const QString kCommandListConfigGroups = QStringLiteral("ListConfigGroups"); + inline const QString kCommandListConfigs = QStringLiteral("ListConfigs"); + inline const QString kCommandGetCurrentConfig = QStringLiteral("GetCurrentConfig"); + inline const QString kCommandSetConfig = QStringLiteral("SetConfig"); + + inline const QString kCapabilityCamera = QStringLiteral("Camera"); + inline const QString kCapabilityProperties = QStringLiteral("Properties"); + inline const QString kCapabilityStage = QStringLiteral("Stage"); + inline const QString kCapabilityShutter = QStringLiteral("Shutter"); + inline const QString kCapabilityState = QStringLiteral("State"); + inline const QString kCapabilityConfiguration = QStringLiteral("Configuration"); inline const QString kFrameDeliveryModePreviewLatest = QStringLiteral("PreviewLatest"); inline const QString kFrameDeliveryModeLatestOnly = QStringLiteral("LatestOnly"); @@ -40,18 +82,29 @@ namespace scopeone::core::internal::agent inline const QString kEventHello = QStringLiteral("Hello"); inline const QString kEventFrameAvailable = QStringLiteral("FrameAvailable"); inline const QString kEventPreviewState = QStringLiteral("PreviewState"); - inline const QString kEventAgentError = QStringLiteral("AgentError"); + inline const QString kEventDriverHostError = QStringLiteral("DriverHostError"); - inline const QString kExecutableFileName = QStringLiteral("ScopeOne_Agent.exe"); +#ifdef Q_OS_WIN + inline const QString kExecutableFileName = QStringLiteral("ScopeOne_DriverHost.exe"); +#else + inline const QString kExecutableFileName = QStringLiteral("ScopeOne_DriverHost"); +#endif - inline QString controlServerName(const QString& cameraId) + inline QString controlServerName(const QString& deviceId) { - return QStringLiteral("ScopeOne.%1.ctrl").arg(cameraId); + return QStringLiteral("ScopeOne.DriverHost.%1.ctrl").arg(deviceId); } - inline QString sharedMemoryKey(const QString& cameraId) + inline QString sharedMemoryKey(const QString& deviceId) { - return QStringLiteral("ScopeOne.%1.shm").arg(cameraId); + return QStringLiteral("ScopeOne.DriverHost.%1.shm").arg(deviceId); + } + + inline QString sharedMemoryKey(const QString& hostKey, int cameraIndex) + { + return QStringLiteral("ScopeOne.DriverHost.%1.%2.shm") + .arg(hostKey) + .arg(cameraIndex); } inline QString encodeUInt64(quint64 value) @@ -70,7 +123,7 @@ namespace scopeone::core::internal::agent if (value.isDouble()) { const double numeric = value.toDouble(static_cast(defaultValue)); - return (numeric >= 0.0) ? static_cast(numeric) : defaultValue; + return numeric >= 0.0 ? static_cast(numeric) : defaultValue; } return defaultValue; } @@ -79,15 +132,15 @@ namespace scopeone::core::internal::agent const QString& type, quint64 requestId = 0) { - QJsonObject obj; - obj.insert(kEnvelopeKindField, kind); - obj.insert(kEnvelopeVersionField, static_cast(kProtocolVersion)); - obj.insert(kMessageTypeField, type); + QJsonObject object; + object.insert(kEnvelopeKindField, kind); + object.insert(kEnvelopeVersionField, static_cast(kProtocolVersion)); + object.insert(kMessageTypeField, type); if (requestId != 0) { - obj.insert(kEnvelopeRequestIdField, encodeUInt64(requestId)); + object.insert(kEnvelopeRequestIdField, encodeUInt64(requestId)); } - return obj; + return object; } inline QByteArray encodeMessage(const QJsonObject& message) @@ -108,13 +161,14 @@ namespace scopeone::core::internal::agent Error }; - inline DecodeResult tryDecodeMessage(QByteArray& buffer, QJsonObject& message, QString* error = nullptr) + inline DecodeResult tryDecodeMessage(QByteArray& buffer, + QJsonObject& message, + QString* error = nullptr) { if (buffer.size() < static_cast(sizeof(quint32))) { return DecodeResult::Incomplete; } - const quint32 payloadSize = qFromLittleEndian(reinterpret_cast(buffer.constData())); if (payloadSize == 0 || payloadSize > kMaxControlMessageBytes) @@ -126,19 +180,18 @@ namespace scopeone::core::internal::agent buffer.clear(); return DecodeResult::Error; } - const int frameSize = static_cast(sizeof(quint32) + payloadSize); if (buffer.size() < frameSize) { return DecodeResult::Incomplete; } - const QByteArray payload = buffer.mid(static_cast(sizeof(quint32)), static_cast(payloadSize)); + const QByteArray payload = buffer.mid(static_cast(sizeof(quint32)), + static_cast(payloadSize)); buffer.remove(0, frameSize); - QJsonParseError parseError{}; - const QJsonDocument doc = QJsonDocument::fromJson(payload, &parseError); - if (parseError.error != QJsonParseError::NoError || !doc.isObject()) + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { if (error) { @@ -146,8 +199,7 @@ namespace scopeone::core::internal::agent } return DecodeResult::Error; } - - message = doc.object(); + message = document.object(); return DecodeResult::Complete; } -} // namespace scopeone::core::internal::agent +} diff --git a/ScopeOneCore/internal/DriverHostProviderProxy.h b/ScopeOneCore/internal/DriverHostProviderProxy.h new file mode 100644 index 0000000..d164a97 --- /dev/null +++ b/ScopeOneCore/internal/DriverHostProviderProxy.h @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +#include "scopeone/HardwareProvider.h" + +namespace scopeone::core::internal +{ + HardwareProviderPtr createDriverHostProviderProxy(const QString& providerId, + const QString& pluginPath, + const QJsonObject& options, + QString* errorMessage = nullptr); +} diff --git a/ScopeOneCore/internal/FFTModule.h b/ScopeOneCore/internal/FFTModule.h index 534bc99..35fb45d 100644 --- a/ScopeOneCore/internal/FFTModule.h +++ b/ScopeOneCore/internal/FFTModule.h @@ -9,47 +9,20 @@ namespace scopeone::core::internal class FFTModule : public ProcessingModule { public: - enum class FilterKind - { - Smooth = 0, - Hard = 1 - }; - - enum class OutputMode - { - Spectrum = 0, - BandpassSpectrum = 1, - BandpassImage = 2 - }; - - ProcessingModuleKind kind() const noexcept override { return ProcessingModuleKind::FFT; } + QString id() const override { return QStringLiteral("fft"); } QString name() const override { return "FFT"; } QVariantMap parameters() const override; void setParameters(const QVariantMap& params) override; std::unique_ptr createRuntime() const override; ProcessingResult process(const ImageFrame& frame, int processingBitDepth) override; + ProcessingResult processValue(const ProcessingValue& input, + int processingBitDepth) override; private: - const cv::Mat& maskForSize(const cv::Size& size); - void invalidateMask(); - - double m_minFeatureSize{2.0}; - double m_maxFeatureSize{10.0}; - FilterKind m_filterKind{FilterKind::Smooth}; - OutputMode m_outputMode{OutputMode::BandpassImage}; - - cv::Mat m_mask; - cv::Size m_maskSize; - double m_maskMinFeatureSize{-1.0}; - double m_maskMaxFeatureSize{-1.0}; - FilterKind m_maskFilterKind{FilterKind::Smooth}; - cv::Mat m_grayFloat; cv::Mat m_padded; cv::Mat m_complex; cv::Mat m_planes[2]; - cv::Mat m_filteredComplex; - cv::Mat m_filtered; cv::Mat m_spectrumMagnitude; cv::Mat m_shiftedSpectrum; }; diff --git a/ScopeOneCore/internal/FrequencyDomainFilterModule.h b/ScopeOneCore/internal/FrequencyDomainFilterModule.h new file mode 100644 index 0000000..5f081a1 --- /dev/null +++ b/ScopeOneCore/internal/FrequencyDomainFilterModule.h @@ -0,0 +1,55 @@ +#pragma once + +#include "internal/ProcessingModule.h" + +#include + +namespace scopeone::core::internal +{ + class FrequencyDomainFilterModule final : public ProcessingModule + { + public: + enum class FilterKind + { + Smooth = 0, + Hard = 1 + }; + + enum class OutputMode + { + Spectrum = 0, + FilteredSpectrum = 1, + FilteredImage = 2 + }; + + QString id() const override { return QStringLiteral("frequency_domain_filter"); } + QString name() const override { return QStringLiteral("Frequency Domain Filter"); } + QVariantMap parameters() const override; + void setParameters(const QVariantMap& parameters) override; + std::unique_ptr createRuntime() const override; + ProcessingResult process(const ImageFrame& frame, int processingBitDepth) override; + + private: + const cv::Mat& maskForSize(const cv::Size& size); + void invalidateMask(); + + double m_minFeatureSize{2.0}; + double m_maxFeatureSize{10.0}; + FilterKind m_filterKind{FilterKind::Smooth}; + OutputMode m_outputMode{OutputMode::FilteredImage}; + + cv::Mat m_mask; + cv::Size m_maskSize; + double m_maskMinFeatureSize{-1.0}; + double m_maskMaxFeatureSize{-1.0}; + FilterKind m_maskFilterKind{FilterKind::Smooth}; + cv::Mat m_grayFloat; + cv::Mat m_padded; + cv::Mat m_complex; + cv::Mat m_planes[2]; + cv::Mat m_filteredComplex; + cv::Mat m_filtered; + cv::Mat m_spectrumMagnitude; + cv::Mat m_shiftedSpectrum; + }; +} diff --git a/ScopeOneCore/internal/GaussianBlurModule.h b/ScopeOneCore/internal/GaussianBlurModule.h index 9b77c68..0ffb6e3 100644 --- a/ScopeOneCore/internal/GaussianBlurModule.h +++ b/ScopeOneCore/internal/GaussianBlurModule.h @@ -7,7 +7,7 @@ namespace scopeone::core::internal class GaussianBlurModule : public ProcessingModule { public: - ProcessingModuleKind kind() const noexcept override { return ProcessingModuleKind::GaussianBlur; } + QString id() const override { return QStringLiteral("gaussian_blur"); } QString name() const override { return "Gaussian Blur"; } QVariantMap parameters() const override; void setParameters(const QVariantMap& params) override; diff --git a/ScopeOneCore/internal/HardwareRuntime.h b/ScopeOneCore/internal/HardwareRuntime.h new file mode 100644 index 0000000..1c00444 --- /dev/null +++ b/ScopeOneCore/internal/HardwareRuntime.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +#include "scopeone/HardwareProvider.h" +#include "scopeone/CameraProvider.h" +#include "internal/AcquisitionEngine.h" +#include "internal/CameraRuntimeControl.h" + +namespace scopeone::core::internal +{ + class DeviceRegistry : public QObject + { + Q_OBJECT + + public: + explicit DeviceRegistry(QObject* parent = nullptr); + + void clear(); + bool registerProvider(const HardwareProviderPtr& provider, + const HardwareProviderDescriptor& descriptor, + const QList& devices); + void unregisterProvider(const QString& providerId); + QList providers() const; + QList devices() const; + HardwareDeviceDescriptor device(const QString& logicalId) const; + HardwareProviderPtr provider(const QString& providerId) const; + HardwareProviderPtr providerForDevice(const QString& logicalId) const; + + signals: + void changed(); + + private: + struct ProviderEntry + { + HardwareProviderPtr provider; + HardwareProviderDescriptor descriptor; + QList devices; + }; + + mutable QReadWriteLock m_lock; + QHash m_providers; + }; + + class HardwareRuntime : public QObject, + public CameraProvider, + public StageProvider, + public ShutterProvider, + public StateProvider, + public ConfigurationProvider, + public CameraRuntimeControl + { + Q_OBJECT + + public: + explicit HardwareRuntime(QObject* parent = nullptr); + + void setFrameSink(FrameSink sink) override; + void setPreviewStateSink(PreviewStateSink sink) override; + bool startPreview() override; + bool stopPreview() override; + bool startPreviewFor(const QString& cameraId) override; + bool stopPreviewFor(const QString& cameraId) override; + bool isPreviewRunning(const QString& cameraId) const override; + bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const override; + bool setExposure(const QString& cameraIdOrAll, double exposureMs) override; + QStringList listProperties(const QString& cameraId) override; + QString getProperty(const QString& cameraId, + const QString& name, + bool fromCache) override; + bool setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) override; + QString getPropertyType(const QString& cameraId, const QString& name) override; + bool isPropertyReadOnly(const QString& cameraId, const QString& name) override; + bool isPropertyPreInit(const QString& cameraId, const QString& name) override; + QStringList getAllowedPropertyValues(const QString& cameraId, + const QString& name) override; + bool hasPropertyLimits(const QString& cameraId, const QString& name) override; + double getPropertyLowerLimit(const QString& cameraId, const QString& name) override; + double getPropertyUpperLimit(const QString& cameraId, const QString& name) override; + bool setROI(const QString& cameraId, int x, int y, int width, int height) override; + bool clearROI(const QString& cameraId) override; + bool getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) override; + bool captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs) override; + void setFrameDeliveryPaused(const QStringList& cameraIds, bool paused) override; + bool setRecordingFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) override; + bool setHighRateFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) override; + bool isProcessingFrameTokenCurrent(const QString& cameraId, quint64 token) override; + void finishProcessingFrame(const QString& cameraId, quint64 token) override; + QString defaultXYStage() const override; + QString defaultZStage() const override; + bool getXYPosition(const QString& deviceId, + double& x, + double& y, + QString* errorMessage) const override; + bool getZPosition(const QString& deviceId, + double& z, + QString* errorMessage) const override; + bool setRelativeXYPosition(const QString& deviceId, + double dx, + double dy, + QString* errorMessage) override; + bool setRelativeZPosition(const QString& deviceId, + double dz, + QString* errorMessage) override; + bool setXYPosition(const QString& deviceId, + double x, + double y, + QString* errorMessage) override; + bool setZPosition(const QString& deviceId, + double z, + QString* errorMessage) override; + bool isShutterOpen(const QString& deviceId, + bool& open, + QString* errorMessage) const override; + bool setShutterOpen(const QString& deviceId, + bool open, + QString* errorMessage) override; + bool getState(const QString& deviceId, + long& state, + QString* errorMessage) const override; + bool setState(const QString& deviceId, + long state, + QString* errorMessage) override; + QString stateLabel(const QString& deviceId, long state) const override; + QStringList availableConfigGroups() const override; + QStringList availableConfigs(const QString& groupName) const override; + QString currentConfig(const QString& groupName) const override; + bool setConfig(const QString& groupName, + const QString& configName, + QString* errorMessage) override; + + DeviceRegistry* deviceRegistry() { return &m_registry; } + const DeviceRegistry* deviceRegistry() const { return &m_registry; } + void clear(); + bool registerProvider(const HardwareProviderPtr& provider); + void unregisterProvider(const QString& providerId); + bool refreshProvider(const QString& providerId); + bool stopPreviewForProvider(const QString& providerId); + + signals: + void frameReady(const ImageFrame& frame); + void devicesChanged(); + void previewStateChanged(bool running); + + private: + CameraProvider* cameraProviderForDevice(const QString& logicalId) const; + DevicePropertyProvider* propertyProviderForDevice(const QString& logicalId) const; + StageProvider* stageProviderForDevice(const QString& logicalId) const; + ShutterProvider* shutterProviderForDevice(const QString& logicalId) const; + StateProvider* stateProviderForDevice(const QString& logicalId) const; + ConfigurationProvider* configurationProviderForGroup(const QString& groupName) const; + QList cameraProviders() const; + QList> runtimeControlsFor( + const QStringList& cameraIds) const; + + DeviceRegistry m_registry; + AcquisitionEngine m_acquisitionEngine; + FrameSink m_frameSink; + PreviewStateSink m_previewStateSink; + }; +} diff --git a/ScopeOneCore/internal/IFFTModule.h b/ScopeOneCore/internal/IFFTModule.h new file mode 100644 index 0000000..85469a0 --- /dev/null +++ b/ScopeOneCore/internal/IFFTModule.h @@ -0,0 +1,19 @@ +#pragma once + +#include "internal/ProcessingModule.h" + +namespace scopeone::core::internal +{ + class IFFTModule final : public ProcessingModule + { + public: + QString id() const override { return QStringLiteral("ifft"); } + QString name() const override { return QStringLiteral("IFFT"); } + QVariantMap parameters() const override { return {}; } + void setParameters(const QVariantMap&) override {} + std::unique_ptr createRuntime() const override; + ProcessingResult process(const ImageFrame& frame, int processingBitDepth) override; + ProcessingResult processValue(const ProcessingValue& input, + int processingBitDepth) override; + }; +} diff --git a/ScopeOneCore/internal/ImageProcessingFramework.h b/ScopeOneCore/internal/ImageProcessingFramework.h index 0957d0c..da9f1ec 100644 --- a/ScopeOneCore/internal/ImageProcessingFramework.h +++ b/ScopeOneCore/internal/ImageProcessingFramework.h @@ -18,11 +18,12 @@ namespace scopeone::core::internal explicit ProcessingPipelineRuntime(std::vector> modules); ProcessingResult process(const ImageFrame& input, int processingBitDepth); + ProcessingResult processValue(const ProcessingValue& input, int processingBitDepth); ProcessingResult processFrom(int startModuleIndex, const ImageFrame& input, int processingBitDepth); ProcessingResult processThrough(int endModuleIndex, const ImageFrame& input, int processingBitDepth); private: - ProcessingResult processRange(const ImageFrame& input, + ProcessingResult processRange(const ProcessingValue& input, int processingBitDepth, int startModuleIndex, int endModuleIndexExclusive); @@ -35,6 +36,7 @@ namespace scopeone::core::internal public: void addModule(std::unique_ptr module); bool removeModule(int index); + bool moveModule(int from, int to); std::shared_ptr createRuntime() const; void forEachModule(const std::function& visitor) const; bool withModule(int index, const std::function& visitor); diff --git a/ScopeOneCore/internal/MDAManager.h b/ScopeOneCore/internal/MDAManager.h index 0f24a06..5c2ee8f 100644 --- a/ScopeOneCore/internal/MDAManager.h +++ b/ScopeOneCore/internal/MDAManager.h @@ -7,16 +7,12 @@ #include #include #include -#include - #include "scopeone/ExperimentDocument.h" - -class CMMCore; +#include "scopeone/CameraProvider.h" +#include "scopeone/HardwareCapabilities.h" namespace scopeone::core::internal { - class CameraManager; - struct MDAOutput { AcquisitionEvent event; @@ -32,12 +28,13 @@ namespace scopeone::core::internal Q_OBJECT public: - explicit MDAManager(std::shared_ptr core, QObject* parent = nullptr); + explicit MDAManager(QObject* parent = nullptr); ~MDAManager() override; bool isRunning() const { return m_running.load(); } - void setCameraManager(CameraManager* cameraManager); + void setCameraProvider(CameraProvider* cameraProvider); + void setStageProvider(StageProvider* stageProvider); bool start(const QList& events, bool block = false); void requestCancel(); void cancelAndWait(); @@ -51,15 +48,18 @@ namespace scopeone::core::internal private: bool setupEvent(const AcquisitionEvent& event, QString* errorMessage); bool execEvent(const AcquisitionEvent& event, MDAOutput& output, QString* errorMessage); - bool execEventSingleCamera(const AcquisitionEvent& event, MDAOutput& output, QString* errorMessage); - bool execEventMultiCamera(const AcquisitionEvent& event, MDAOutput& output, QString* errorMessage); - bool setExposure(double exposureMs, QString* errorMessage); + bool captureCameras(const AcquisitionEvent& event, + MDAOutput& output, + QString* errorMessage); + bool setExposure(const QStringList& cameraIds, + double exposureMs, + QString* errorMessage); bool moveXY(double x, double y, QString* errorMessage); bool moveZ(double z, QString* errorMessage); void runSequence(QList events); - std::shared_ptr m_mmcore; - CameraManager* m_cameraManager{nullptr}; + CameraProvider* m_cameraProvider{nullptr}; + StageProvider* m_stageProvider{nullptr}; QThreadPool m_threadPool; std::atomic m_running{false}; std::atomic m_cancelRequested{false}; diff --git a/ScopeOneCore/internal/MMCoreManager.h b/ScopeOneCore/internal/MMCoreManager.h index fc7f6e8..0f384c1 100644 --- a/ScopeOneCore/internal/MMCoreManager.h +++ b/ScopeOneCore/internal/MMCoreManager.h @@ -5,6 +5,7 @@ #include #include #include "MMCore.h" +#include "scopeone/HardwareTypes.h" namespace scopeone::core::internal { @@ -35,6 +36,7 @@ namespace scopeone::core::internal int skippedCameraCount{0}; bool foundCamera{false}; bool useSingleCamera{false}; + QList devices; }; explicit MMCoreManager(QObject* parent = nullptr); diff --git a/ScopeOneCore/internal/MaskModule.h b/ScopeOneCore/internal/MaskModule.h new file mode 100644 index 0000000..8cdbe35 --- /dev/null +++ b/ScopeOneCore/internal/MaskModule.h @@ -0,0 +1,41 @@ +#pragma once + +#include "internal/ProcessingModule.h" + +#include + +namespace scopeone::core::internal +{ + class MaskModule final : public ProcessingModule + { + public: + QString id() const override { return QStringLiteral("mask"); } + QString name() const override { return QStringLiteral("Mask"); } + QVariantMap parameters() const override; + void setParameters(const QVariantMap& parameters) override; + std::unique_ptr createRuntime() const override; + ProcessingResult process(const ImageFrame& frame, int processingBitDepth) override; + ProcessingResult processValue(const ProcessingValue& input, + int processingBitDepth) override; + + private: + const cv::Mat& maskForSize(const cv::Size& size); + const cv::Mat& frequencyMaskForSize(const cv::Size& size); + void invalidateMask(); + + int m_shape{0}; + double m_centerX{0.0}; + double m_centerY{0.0}; + double m_sizeX{0.1}; + double m_sizeY{0.1}; + double m_innerSize{0.0}; + double m_rotation{0.0}; + double m_edgeWidth{0.0}; + bool m_invert{false}; + + cv::Mat m_mask; + cv::Mat m_frequencyMask; + cv::Size m_maskSize; + QVariantMap m_maskParameters; + }; +} diff --git a/ScopeOneCore/internal/MicroManagerProvider.h b/ScopeOneCore/internal/MicroManagerProvider.h new file mode 100644 index 0000000..fd42de3 --- /dev/null +++ b/ScopeOneCore/internal/MicroManagerProvider.h @@ -0,0 +1,125 @@ +#pragma once + +#include + +#include + +#include "scopeone/HardwareProvider.h" +#include "scopeone/CameraProvider.h" +#include "internal/CameraRuntimeControl.h" + +class CMMCore; + +namespace scopeone::core::internal +{ + class MicroManagerProvider final : public HardwareProvider, + public CameraProvider, + public StageProvider, + public ShutterProvider, + public StateProvider, + public ConfigurationProvider, + public CameraRuntimeControl + { + public: + MicroManagerProvider(std::shared_ptr core, + CameraProvider* cameraProvider, + CameraRuntimeControl* cameraRuntimeControl); + + HardwareProviderDescriptor descriptor() const override; + QList devices() const override; + void setDevices(const QList& devices); + + void setFrameSink(FrameSink sink) override; + void setPreviewStateSink(PreviewStateSink sink) override; + bool startPreview() override; + bool stopPreview() override; + bool startPreviewFor(const QString& cameraId) override; + bool stopPreviewFor(const QString& cameraId) override; + bool isPreviewRunning(const QString& cameraId) const override; + bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const override; + bool setExposure(const QString& cameraIdOrAll, double exposureMs) override; + QStringList listProperties(const QString& cameraId) override; + QString getProperty(const QString& cameraId, + const QString& name, + bool fromCache) override; + bool setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) override; + QString getPropertyType(const QString& cameraId, const QString& name) override; + bool isPropertyReadOnly(const QString& cameraId, const QString& name) override; + bool isPropertyPreInit(const QString& cameraId, const QString& name) override; + QStringList getAllowedPropertyValues(const QString& cameraId, + const QString& name) override; + bool hasPropertyLimits(const QString& cameraId, const QString& name) override; + double getPropertyLowerLimit(const QString& cameraId, const QString& name) override; + double getPropertyUpperLimit(const QString& cameraId, const QString& name) override; + bool setROI(const QString& cameraId, int x, int y, int width, int height) override; + bool clearROI(const QString& cameraId) override; + bool getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) override; + bool captureEventFrame(const QString& cameraId, + scopeone::core::ImageFrame& frame, + int timeoutMs) override; + void setFrameDeliveryPaused(const QStringList& cameraIds, bool paused) override; + bool setRecordingFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) override; + bool setHighRateFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) override; + bool isProcessingFrameTokenCurrent(const QString& cameraId, quint64 token) override; + void finishProcessingFrame(const QString& cameraId, quint64 token) override; + QString defaultXYStage() const override; + QString defaultZStage() const override; + bool getXYPosition(const QString& deviceId, + double& x, + double& y, + QString* errorMessage) const override; + bool getZPosition(const QString& deviceId, + double& z, + QString* errorMessage) const override; + bool setRelativeXYPosition(const QString& deviceId, + double dx, + double dy, + QString* errorMessage) override; + bool setRelativeZPosition(const QString& deviceId, + double dz, + QString* errorMessage) override; + bool setXYPosition(const QString& deviceId, + double x, + double y, + QString* errorMessage) override; + bool setZPosition(const QString& deviceId, + double z, + QString* errorMessage) override; + bool isShutterOpen(const QString& deviceId, + bool& open, + QString* errorMessage) const override; + bool setShutterOpen(const QString& deviceId, + bool open, + QString* errorMessage) override; + bool getState(const QString& deviceId, + long& state, + QString* errorMessage) const override; + bool setState(const QString& deviceId, + long state, + QString* errorMessage) override; + QString stateLabel(const QString& deviceId, long state) const override; + QStringList availableConfigGroups() const override; + QStringList availableConfigs(const QString& groupName) const override; + QString currentConfig(const QString& groupName) const override; + bool setConfig(const QString& groupName, + const QString& configName, + QString* errorMessage) override; + + private: + bool isCamera(const QString& deviceId) const; + + std::shared_ptr m_core; + CameraProvider* m_cameraProvider{nullptr}; + CameraRuntimeControl* m_cameraRuntimeControl{nullptr}; + QList m_devices; + }; +} diff --git a/ScopeOneCore/internal/ProcessingModule.h b/ScopeOneCore/internal/ProcessingModule.h index a733e17..8d790c6 100644 --- a/ScopeOneCore/internal/ProcessingModule.h +++ b/ScopeOneCore/internal/ProcessingModule.h @@ -1,35 +1,10 @@ #pragma once -#include -#include -#include - -#include "scopeone/ExperimentDocument.h" +#include "scopeone/ProcessingPlugin.h" namespace scopeone::core::internal { using ImageFrame = scopeone::core::ImageFrame; - using ProcessingModuleKind = scopeone::core::ProcessingModuleKind; - - struct ProcessingResult - { - ImageFrame frame; - QString error; - - bool succeeded() const { return error.isEmpty() && frame.isValid(); } - }; - - class ProcessingModule - { - public: - virtual ~ProcessingModule() = default; - - virtual ProcessingModuleKind kind() const noexcept = 0; - virtual QString name() const = 0; - virtual QVariantMap parameters() const = 0; - virtual void setParameters(const QVariantMap& params) = 0; - virtual std::unique_ptr createRuntime() const = 0; - virtual bool resetState() { return false; } - virtual ProcessingResult process(const ImageFrame& frame, int processingBitDepth) = 0; - }; + using ProcessingModule = scopeone::core::ProcessingModule; + using ProcessingResult = scopeone::core::ProcessingResult; } diff --git a/ScopeOneCore/internal/ProcessingModuleRegistry.h b/ScopeOneCore/internal/ProcessingModuleRegistry.h new file mode 100644 index 0000000..7fd9198 --- /dev/null +++ b/ScopeOneCore/internal/ProcessingModuleRegistry.h @@ -0,0 +1,40 @@ +#pragma once + +#include "scopeone/ProcessingPlugin.h" + +#include +#include +#include +#include +#include + +class QPluginLoader; + +namespace scopeone::core::internal +{ + class ProcessingModuleRegistry + { + public: + using Factory = std::function()>; + + ProcessingModuleRegistry(); + ~ProcessingModuleRegistry(); + + bool registerModule(const ProcessingModuleDescriptor& descriptor, Factory factory); + QList descriptors() const; + ProcessingModuleDescriptor descriptor(const QString& moduleId) const; + std::unique_ptr create(const QString& moduleId) const; + QStringList loadPlugins(const QString& directoryPath); + + private: + struct Entry + { + ProcessingModuleDescriptor descriptor; + Factory factory; + }; + + QHash m_entries; + QStringList m_order; + std::vector> m_pluginLoaders; + }; +} diff --git a/ScopeOneCore/internal/RecordingManager.h b/ScopeOneCore/internal/RecordingManager.h index ae99e69..39dfa24 100644 --- a/ScopeOneCore/internal/RecordingManager.h +++ b/ScopeOneCore/internal/RecordingManager.h @@ -1,7 +1,10 @@ #pragma once #include "scopeone/ScopeOneCore.h" +#include "scopeone/CameraProvider.h" +#include "scopeone/HardwareCapabilities.h" #include "internal/MDAManager.h" +#include "internal/CameraRuntimeControl.h" #include #include #include @@ -12,8 +15,6 @@ #include #include -class CMMCore; - namespace scopeone::core::internal { using scopeone::core::RecordingFormat; @@ -25,8 +26,6 @@ namespace scopeone::core::internal using RecordingWriterPhase = scopeone::core::ScopeOneCore::RecordingWriterPhase; using RecordingWriterStatus = scopeone::core::ScopeOneCore::RecordingWriterStatus; - class CameraManager; - class RecordingManager : public QObject { Q_OBJECT @@ -35,9 +34,12 @@ namespace scopeone::core::internal explicit RecordingManager(QObject* parent = nullptr); ~RecordingManager() override; - void setCameraManager(CameraManager* cameraManager) { m_cameraManager = cameraManager; } - void setMMCore(const std::shared_ptr& core) { m_mmcore = core; } - + void setCameraProvider(CameraProvider* cameraProvider) { m_cameraProvider = cameraProvider; } + void setStageProvider(StageProvider* stageProvider) { m_stageProvider = stageProvider; } + void setCameraRuntimeControl(CameraRuntimeControl* cameraRuntimeControl) + { + m_cameraRuntimeControl = cameraRuntimeControl; + } void setLatestFrameFetcher(std::function fetcher) { m_latestFrameFetcher = std::move(fetcher); @@ -63,7 +65,9 @@ namespace scopeone::core::internal void onRawFramesReady(const QList& frames); void onFrameDeliveryFailed(const QString& errorMessage, quint64 droppedFrames); - static QString saveSessionToDisk(const std::shared_ptr& session); + static QString saveSessionToDisk( + const std::shared_ptr& session, + const std::shared_ptr& sourceSession = {}); signals: void mdaRawFrameReady(const scopeone::core::ImageFrame& frame); @@ -203,8 +207,9 @@ namespace scopeone::core::internal bool allCamerasReachedTarget() const; void advanceBurstStateIfNeeded(); - CameraManager* m_cameraManager{nullptr}; - std::shared_ptr m_mmcore; + CameraProvider* m_cameraProvider{nullptr}; + StageProvider* m_stageProvider{nullptr}; + CameraRuntimeControl* m_cameraRuntimeControl{nullptr}; std::function m_latestFrameFetcher; std::function m_sessionPreparationCallback; diff --git a/ScopeOneCore/internal/SharedFrameRing.h b/ScopeOneCore/internal/SharedFrameRing.h new file mode 100644 index 0000000..2d00f8a --- /dev/null +++ b/ScopeOneCore/internal/SharedFrameRing.h @@ -0,0 +1,70 @@ +#pragma once + +#include "scopeone/SharedFrame.h" + +#include +#include +#include + +namespace scopeone::core::internal::sharedframe +{ + static_assert(std::atomic_ref::is_always_lock_free, + "Shared frame state requires lock-free 32-bit atomics"); + + inline quint64 payloadSize(const SharedFrameHeader& header) + { + return static_cast(header.stride) * header.height; + } + + inline bool headerLooksSane(const SharedFrameHeader& header) + { + if (header.channels != 1 || header.width == 0 || header.height == 0 + || header.stride == 0) + { + return false; + } + const bool mono8 = header.pixelFormat == static_cast(SharedPixelFormat::Mono8); + const bool mono16 = header.pixelFormat == static_cast(SharedPixelFormat::Mono16); + if ((!mono8 && !mono16) + || (mono8 && header.bitsPerSample != 8) + || (mono16 && (header.bitsPerSample == 0 || header.bitsPerSample > 16))) + { + return false; + } + const quint32 bytesPerPixel = mono16 ? 2u : 1u; + const quint64 minimumStride = static_cast(header.width) * bytesPerPixel; + const quint64 bytes = payloadSize(header); + return header.width <= static_cast((std::numeric_limits::max)()) + && header.height <= static_cast((std::numeric_limits::max)()) + && header.stride <= static_cast((std::numeric_limits::max)()) + && header.stride >= minimumStride + && header.stride % bytesPerPixel == 0 + && bytes > 0 + && bytes <= static_cast(kSharedFrameMaxBytes); + } + + inline bool claimSlot(uchar* slot, SharedFrameHeader& header) + { + auto& stateValue = *reinterpret_cast(slot); + std::atomic_ref state(stateValue); + quint32 expected = 2; + if (!state.compare_exchange_strong(expected, + 3, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + return false; + } + std::memcpy(&header, slot, sizeof(header)); + header.state = 2; + if (headerLooksSane(header)) return true; + state.store(2, std::memory_order_release); + return false; + } + + inline void releaseSlot(uchar* slot) + { + auto& stateValue = *reinterpret_cast(slot); + std::atomic_ref(stateValue).store(2, std::memory_order_release); + } +} diff --git a/ScopeOneCore/internal/SignalSourceManager.h b/ScopeOneCore/internal/SignalSourceManager.h new file mode 100644 index 0000000..c3f1f8f --- /dev/null +++ b/ScopeOneCore/internal/SignalSourceManager.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +#include +#include + +#include "scopeone/SignalSource.h" + +class QPluginLoader; + +namespace scopeone::core::internal +{ + class SignalSourceManager final : public QObject + { + Q_OBJECT + + public: + explicit SignalSourceManager(QObject* parent = nullptr); + ~SignalSourceManager() override; + + QList sources() const; + bool startTrace(const SignalAcquisitionConfig& config, + QString* errorMessage = nullptr); + void stopTrace(const QString& sourceId); + SignalSourceState state(const QString& sourceId) const; + QString stateMessage(const QString& sourceId) const; + + signals: + void timeSeriesReady(const scopeone::core::TimeSeriesChunk& chunk); + void timestampedEventsReady(const scopeone::core::TimestampedEventChunk& chunk); + void sourceStateChanged(const QString& sourceId, + scopeone::core::SignalSourceState state, + const QString& message); + void sourceError(const QString& sourceId, const QString& errorMessage); + + private: + void loadPlugins(); + SignalSource* sourceInstance(const QString& sourceId, + QString* errorMessage); + + std::vector> m_loaders; + QHash m_plugins; + QHash m_descriptors; + QHash> m_sources; + QHash m_states; + QHash m_messages; + }; +} diff --git a/ScopeOneCore/internal/SpatiotemporalBinningModule.h b/ScopeOneCore/internal/SpatiotemporalBinningModule.h index c1b21d9..f2e7fb5 100644 --- a/ScopeOneCore/internal/SpatiotemporalBinningModule.h +++ b/ScopeOneCore/internal/SpatiotemporalBinningModule.h @@ -19,7 +19,7 @@ namespace scopeone::core::internal Skip = 4 }; - ProcessingModuleKind kind() const noexcept override { return ProcessingModuleKind::SpatiotemporalBinning; } + QString id() const override { return QStringLiteral("spatiotemporal_binning"); } QString name() const override { return "Spatiotemporal Binning"; } QVariantMap parameters() const override; void setParameters(const QVariantMap& params) override; diff --git a/ScopeOneCore/python/scopeone/README.md b/ScopeOneCore/python/scopeone/README.md index 429301e..330b669 100644 --- a/ScopeOneCore/python/scopeone/README.md +++ b/ScopeOneCore/python/scopeone/README.md @@ -2,6 +2,36 @@ Python client for ScopeOne's language-neutral Local API. It controls a running ScopeOne app and can be used by scripts, notebooks, or Python-based agent tool adapters. +## Installation + +Requires Python 3.10 or later and a running ScopeOne desktop application. + +Create and activate an isolated Conda environment: + +```powershell +conda create -n scopeone python=3.13 +conda activate scopeone +``` + +Install from this source checkout: + +```powershell +cd ScopeOneCore\python\scopeone +python -m pip install -e . +``` + +For a regular, non-editable installation, use: + +```powershell +python -m pip install . +``` + +`pip` installs the required `numpy` and `pywin32` dependencies automatically on Windows. Verify the connection after ScopeOne is running: + +```powershell +python -c "from scopeone import ScopeOne; print(ScopeOne().version())" +``` + ## Project layout - Python package project root: `ScopeOneCore/python/scopeone` @@ -109,6 +139,12 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.loaded_devices()` - `ScopeOne.start_preview(camera="All")` - `ScopeOne.stop_preview(camera="All")` +- `ScopeOne.image_windows()` +- `ScopeOne.open_image_window(session_id, title=None, camera_id=None)` +- `ScopeOne.activate_image_window(document_id)` +- `ScopeOne.close_image_window(document_id=None)` +- `ScopeOne.process_image_window(document_id=None, complete_stack=False)` +- `ScopeOne.save_image_window(save_dir, base_name, document_id=None, format="ome-tiff", compression=False, compression_level=6)` - `ScopeOne.list_layers()` - `ScopeOne.get_layer_histogram(layer_key)` - `ScopeOne.get_pixel_value(layer_key, x, y)` @@ -163,8 +199,8 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.processing_state()` - `ScopeOne.processing_modules()` - `ScopeOne.set_processing_bit_depth(bit_depth)` -- `ScopeOne.set_realtime_processing(enabled)` -- `ScopeOne.start_processing()` +- `ScopeOne.set_realtime_processing(enabled, camera_id=None)` +- `ScopeOne.start_processing(camera_id=None)` - `ScopeOne.stop_processing()` - `ScopeOne.add_processing_module(kind, parameters=None)` - `ScopeOne.remove_processing_module(index)` @@ -235,11 +271,17 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `loaded_devices`: response `devices`. - `start_preview`: fields `camera`, accepts a camera id or `"All"`. - `stop_preview`: fields `camera`, accepts a camera id or `"All"`. -- `list_layers`: response `layers`. +- `image_windows`: response `activeDocumentId` and `documents`; each document contains its ID, title, session, camera, current frame, frame count, readiness, and active state. +- `open_image_window`: fields `sessionId`, optional `title` and `cameraId`; opens matching retained session data, waits for the first frame, and returns `documentIds` and `activeDocumentId`. +- `activate_image_window`: field `documentId`; activates the window and returns `document`. +- `close_image_window`: optional field `documentId`; closes the selected or active window. +- `process_image_window`: optional fields `documentId` and `completeStack`; asynchronously processes the selected or active window and returns the new `document`. +- `save_image_window`: fields `saveDir`, `baseName`, `format` (`ome-tiff`, `ome-zarr`, `tiff` or `binary`), `compression`, and `compressionLevel`, plus optional `documentId`; asynchronously saves the selected or active window and returns `documentId` and `message`. +- `list_layers`: response `layers`; layer display, histogram, pixel, profile, and markup operations target the active image viewer. - `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`. +- `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 either `maskLayerKey` for Live or `maskDocumentId` for a static image window. - `layer_options`: response `layouts`, `colormaps`, `blendingModes`. - `set_layer_layout`: fields `layout`, accepts `side_by_side` or `overlay`. - `set_visible_layers`: fields `layerKeys`; response `visibleLayers`. @@ -286,9 +328,9 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `start_stage_mosaic`: fields `cameraId`, `xyStageId`, and optional `rows`, `columns`, `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`. +- `processing_modules`: response `bitDepth`, `realTime`, `realTimeSource`, `modules`, and descriptor list `availableModules`. - `set_processing_bit_depth`: fields `bitDepth`, accepts `8` or `16`. -- `set_realtime_processing`: fields `enabled`. +- `set_realtime_processing`: fields `enabled` and optional `cameraId`; an empty camera ID selects all cameras. - `add_processing_module`: fields `kind`, optional `parameters`; response `index`. - `remove_processing_module`: fields `index`. - `set_processing_module_parameters`: fields `index`, `parameters`. @@ -332,7 +374,7 @@ For timed MDA with more than one time point, `order` must begin with `time` so e The initially created document is a complete editable Draft with in-memory recording enabled by default. Set `plan.streamToDisk`, `plan.saveDir`, and `plan.baseName` together for streamed output. Experiment documents are parsed strictly: every schema field is required, unknown fields and unsupported schema versions are rejected, and `start_experiment` accepts only Draft documents whose camera IDs are currently available. `start_experiment` is non-blocking; use the returned `ExperimentSession` or the direct status and cancel methods to control the run. Call `ExperimentSession.close()` after completion to release retained recording frames while keeping document status available. -Processing module editing follows the desktop UI rules: stop real-time processing before changing bit depth, adding/removing modules, updating module parameters, or resetting module state. `add_processing_module` accepts `fft`, `background_calibration`, `spatiotemporal_binning`, `gaussian_blur`, and `differential_rolling`. +Processing module editing follows the desktop UI rules: stop real-time processing before changing bit depth, adding/removing modules, updating module parameters, or resetting module state. Pass `add_processing_module` a stable module ID returned in `processing_modules.availableModules`; this includes modules supplied by installed processing plugins. ### Frame transfer diff --git a/ScopeOneCore/python/scopeone/examples/example_config_stage_record.py b/ScopeOneCore/python/scopeone/examples/example_config_stage_record.py new file mode 100644 index 0000000..d033329 --- /dev/null +++ b/ScopeOneCore/python/scopeone/examples/example_config_stage_record.py @@ -0,0 +1,33 @@ +from scopeone import ScopeOne + + +CONFIG_PATH = r"C:\Users\T7910\Documents\Source\cpp\ScopeOne\config\MMConfig_demo.cfg" +SAVE_DIR = r"C:\Users\T7910\Downloads" + + +def main() -> None: + with ScopeOne() as scope: + scope.load_config(CONFIG_PATH) + + camera = scope.camera_ids()[0] + scope.set_exposure(10.0, camera) + + scope.set_property(camera, "Mode", "Fluorescent Beads") + + stage = scope.current_xy_stage_device() + print("Position:", scope.read_xy_position(stage)) + + with scope.record(frames=10, camera=camera) as first: + first.save(SAVE_DIR, "before_move", format="ome-tiff") + + scope.move_xy_relative(10.0, 0.0, stage) + print("Position:", scope.read_xy_position(stage)) + + with scope.record(frames=10, camera=camera) as second: + second.save(SAVE_DIR, "after_move", format="ome-tiff") + + scope.unload_config() + + +if __name__ == "__main__": + main() diff --git a/ScopeOneCore/python/scopeone/examples/example_raw_stream_process.py b/ScopeOneCore/python/scopeone/examples/example_raw_stream_process.py new file mode 100644 index 0000000..22b0315 --- /dev/null +++ b/ScopeOneCore/python/scopeone/examples/example_raw_stream_process.py @@ -0,0 +1,42 @@ +"""Invert a live camera stream in Python and show it beside the raw image.""" + +import time + +from scopeone import ScopeOne + + +def main() -> None: + with ScopeOne() as scope: + camera = scope.camera_ids()[0] + layer_id = "python_stream" + layer_key = f"static:{layer_id}" + + scope.start_preview(camera) + scope.set_layer_layout("side_by_side") + print(f"Streaming {camera}. Press Ctrl+C to stop.") + + last_frame = -1 + try: + while True: + frame = scope.latest_raw_frame(camera) + if frame.frame_index == last_frame: + time.sleep(0.005) + continue + last_frame = frame.frame_index + + max_val = (1 << frame.bits_per_sample) - 1 + scope.show_image( + max_val - frame.image, + layer_id=layer_id, + name="Python Processed", + camera=camera, + bits_per_sample=frame.bits_per_sample, + ) + except KeyboardInterrupt: + print("\nStopped.") + finally: + scope.remove_static_layer(layer_key) + + +if __name__ == "__main__": + main() diff --git a/ScopeOneCore/python/scopeone/pyproject.toml b/ScopeOneCore/python/scopeone/pyproject.toml index a038da9..833e9cd 100644 --- a/ScopeOneCore/python/scopeone/pyproject.toml +++ b/ScopeOneCore/python/scopeone/pyproject.toml @@ -28,8 +28,8 @@ classifiers = [ ] dependencies = [ - "numpy>=1.21.2", - "pywin32>=300; platform_system == 'Windows'", + "numpy==2.4.6", + "pywin32==311; platform_system == 'Windows'", ] [tool.setuptools] diff --git a/ScopeOneCore/python/scopeone/src/scopeone/client.py b/ScopeOneCore/python/scopeone/src/scopeone/client.py index e42e3ef..5835342 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/client.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/client.py @@ -412,6 +412,87 @@ def stop_preview(self, camera: str = "All") -> bool: self._request({"type": "stop_preview", "camera": camera}) return True + def image_windows(self) -> dict: + response = self._request({"type": "image_windows"}) + return { + "activeDocumentId": str(response.get("activeDocumentId", "")), + "documents": list(response.get("documents", [])), + } + + def open_image_window( + self, + session_id: str, + title: str | None = None, + camera_id: str | None = None, + ) -> dict: + request = { + "type": "open_image_window", + "sessionId": session_id, + } + if title is not None: + request["title"] = title + if camera_id is not None: + request["cameraId"] = camera_id + response = self._request(request) + return { + "documentIds": list(response.get("documentIds", [])), + "activeDocumentId": str(response.get("activeDocumentId", "")), + } + + def activate_image_window(self, document_id: str) -> dict: + response = self._request( + { + "type": "activate_image_window", + "documentId": document_id, + } + ) + return dict(response["document"]) + + def close_image_window(self, document_id: str | None = None) -> None: + request = {"type": "close_image_window"} + if document_id is not None: + request["documentId"] = document_id + self._request(request) + + def process_image_window( + self, + document_id: str | None = None, + complete_stack: bool = False, + ) -> dict: + request = { + "type": "process_image_window", + "completeStack": bool(complete_stack), + } + if document_id is not None: + request["documentId"] = document_id + response = self._request(request) + return dict(response["document"]) + + def save_image_window( + self, + save_dir: str, + base_name: str, + document_id: str | None = None, + format: str = "ome-tiff", + compression: bool = False, + compression_level: int = 6, + ) -> dict: + request = { + "type": "save_image_window", + "saveDir": save_dir, + "baseName": base_name, + "format": format, + "compression": bool(compression), + "compressionLevel": int(compression_level), + } + if document_id is not None: + request["documentId"] = document_id + response = self._request(request) + return { + "documentId": str(response["documentId"]), + "message": str(response.get("message", "")), + } + def list_layers(self) -> list[dict]: response = self._request({"type": "list_layers"}) return list(response.get("layers", [])) @@ -489,6 +570,8 @@ def detect_particles( } if "maskLayerKey" in response: result["maskLayerKey"] = str(response["maskLayerKey"]) + if "maskDocumentId" in response: + result["maskDocumentId"] = str(response["maskDocumentId"]) if "mask" in response: result["mask"] = self._frame_result_from_mapping_response(dict(response["mask"])) return result @@ -992,7 +1075,9 @@ def processing_state(self) -> dict: return { "bitDepth": int(response.get("bitDepth", 0)), "realTime": bool(response.get("realTime", False)), + "realTimeSource": str(response.get("realTimeSource", "")), "modules": list(response.get("modules", [])), + "availableModules": list(response.get("availableModules", [])), } def processing_modules(self) -> list[dict]: @@ -1006,18 +1091,19 @@ def set_processing_bit_depth(self, bit_depth: int) -> None: } ) - def set_realtime_processing(self, enabled: bool) -> bool: - self._request( - { - "type": "set_realtime_processing", - "enabled": bool(enabled), - } - ) + def set_realtime_processing(self, enabled: bool, camera_id: str | None = None) -> bool: + request = { + "type": "set_realtime_processing", + "enabled": bool(enabled), + } + if camera_id is not None: + request["cameraId"] = str(camera_id) + self._request(request) return True def add_processing_module( self, - kind: str | int, + kind: str, parameters: dict | None = None, ) -> int: request = { diff --git a/ScopeOneCore/python/scopeone/src/scopeone/core.py b/ScopeOneCore/python/scopeone/src/scopeone/core.py index 32ac2ab..6593335 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/core.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/core.py @@ -54,6 +54,48 @@ def start_preview(self, camera: str = "All"): def stop_preview(self, camera: str = "All"): return self._client.stop_preview(camera) + def image_windows(self): + return self._client.image_windows() + + def open_image_window( + self, + session_id: str, + title: str | None = None, + camera_id: str | None = None, + ): + return self._client.open_image_window(session_id, title, camera_id) + + def activate_image_window(self, document_id: str): + return self._client.activate_image_window(document_id) + + def close_image_window(self, document_id: str | None = None): + self._client.close_image_window(document_id) + + def process_image_window( + self, + document_id: str | None = None, + complete_stack: bool = False, + ): + return self._client.process_image_window(document_id, complete_stack) + + def save_image_window( + self, + save_dir: str, + base_name: str, + document_id: str | None = None, + format: str = "ome-tiff", + compression: bool = False, + compression_level: int = 6, + ): + return self._client.save_image_window( + save_dir, + base_name, + document_id, + format, + compression, + compression_level, + ) + def list_layers(self): return self._client.list_layers() @@ -318,16 +360,16 @@ def processing_modules(self): def set_processing_bit_depth(self, bit_depth: int): self._client.set_processing_bit_depth(bit_depth) - def set_realtime_processing(self, enabled: bool): - return self._client.set_realtime_processing(enabled) + def set_realtime_processing(self, enabled: bool, camera_id: str | None = None): + return self._client.set_realtime_processing(enabled, camera_id) - def start_processing(self): - self.set_realtime_processing(True) + def start_processing(self, camera_id: str | None = None): + self.set_realtime_processing(True, camera_id) def stop_processing(self): self.set_realtime_processing(False) - def add_processing_module(self, kind: str | int, parameters: dict | None = None): + def add_processing_module(self, kind: str, parameters: dict | None = None): return self._client.add_processing_module(kind, parameters) def remove_processing_module(self, index: int): diff --git a/ScopeOneCore/src/AcquisitionEngine.cpp b/ScopeOneCore/src/AcquisitionEngine.cpp new file mode 100644 index 0000000..9a86d93 --- /dev/null +++ b/ScopeOneCore/src/AcquisitionEngine.cpp @@ -0,0 +1,103 @@ +#include "internal/AcquisitionEngine.h" + +#include "scopeone/CameraProvider.h" +#include "internal/HardwareRuntime.h" + +namespace scopeone::core::internal +{ + AcquisitionEngine::AcquisitionEngine(DeviceRegistry& deviceRegistry) + : m_deviceRegistry(deviceRegistry) + { + } + + bool AcquisitionEngine::start(const QString& cameraIdOrAll) + { + const QString target = cameraIdOrAll.trimmed(); + if (target.isEmpty()) + { + return false; + } + if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + { + struct StartedCamera + { + CameraProvider* provider; + QString cameraId; + }; + QList startedCameras; + bool found = false; + for (const HardwareDeviceDescriptor& device : m_deviceRegistry.devices()) + { + if (device.kind != HardwareDeviceKind::Camera) + { + continue; + } + found = true; + const HardwareProviderPtr provider = m_deviceRegistry.provider(device.providerId); + auto* cameraProvider = dynamic_cast(provider.get()); + if (!cameraProvider) + { + for (auto it = startedCameras.crbegin(); it != startedCameras.crend(); ++it) + { + it->provider->stopPreviewFor(it->cameraId); + } + return false; + } + if (cameraProvider->isPreviewRunning(device.logicalId)) + { + continue; + } + if (!cameraProvider->startPreviewFor(device.logicalId)) + { + for (auto it = startedCameras.crbegin(); it != startedCameras.crend(); ++it) + { + it->provider->stopPreviewFor(it->cameraId); + } + return false; + } + startedCameras.append({cameraProvider, device.logicalId}); + } + return found; + } + const HardwareProviderPtr provider = m_deviceRegistry.providerForDevice(target); + auto* cameraProvider = dynamic_cast(provider.get()); + return cameraProvider && cameraProvider->startPreviewFor(target); + } + + bool AcquisitionEngine::stop(const QString& cameraIdOrAll) + { + const QString target = cameraIdOrAll.trimmed(); + if (target.isEmpty()) + { + return false; + } + if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + { + bool found = false; + bool stopped = true; + for (const HardwareDeviceDescriptor& device : m_deviceRegistry.devices()) + { + if (device.kind != HardwareDeviceKind::Camera) + { + continue; + } + found = true; + const HardwareProviderPtr provider = m_deviceRegistry.provider(device.providerId); + auto* cameraProvider = dynamic_cast(provider.get()); + if (!cameraProvider) + { + stopped = false; + continue; + } + if (cameraProvider->isPreviewRunning(device.logicalId)) + { + stopped = cameraProvider->stopPreviewFor(device.logicalId) && stopped; + } + } + return found && stopped; + } + const HardwareProviderPtr provider = m_deviceRegistry.providerForDevice(target); + auto* cameraProvider = dynamic_cast(provider.get()); + return cameraProvider && cameraProvider->stopPreviewFor(target); + } +} diff --git a/ScopeOneCore/src/AgentMain.cpp b/ScopeOneCore/src/AgentMain.cpp deleted file mode 100644 index d112669..0000000 --- a/ScopeOneCore/src/AgentMain.cpp +++ /dev/null @@ -1,1808 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "MMCore.h" -#include "internal/AgentProtocol.h" -#include "scopeone/SharedFrame.h" - -namespace scopeone::core::internal -{ - static_assert(std::atomic_ref::is_always_lock_free, - "Shared frame state requires lock-free 32-bit atomics"); - - using scopeone::core::SharedFrameHeader; - using scopeone::core::SharedMemoryControl; - using scopeone::core::SharedPixelFormat; - using scopeone::core::computeMaxFrameBytes; - using scopeone::core::kSharedFrameHeaderSize; - using scopeone::core::kSharedFrameMaxBytes; - using scopeone::core::kSharedFrameNumSlots; - using scopeone::core::kSharedFrameSlotStride; - using scopeone::core::kSharedMemoryControlSize; - - constexpr int kMinimumPollIntervalMs = 1; - constexpr int kMaximumPollIntervalMs = 50; - constexpr int kPreviewFrameDeliveryIntervalMs = 16; - - int pollingIntervalFor(double frameIntervalMs) - { - if (!std::isfinite(frameIntervalMs) || frameIntervalMs <= 0.0) - { - return kMinimumPollIntervalMs; - } - return static_cast(std::clamp(frameIntervalMs / 4.0, - static_cast(kMinimumPollIntervalMs), - static_cast(kMaximumPollIntervalMs))); - } - - // Normalize frame bit depth before publishing shared frame metadata - static quint16 normalizedSharedBitDepth(SharedPixelFormat format, int bitsPerSample) - { - if (format == SharedPixelFormat::Mono8) - { - return 8; - } - if (format == SharedPixelFormat::Mono16) - { - return bitsPerSample >= 1 && bitsPerSample <= 16 - ? static_cast(bitsPerSample) - : 16; - } - return 0; - } - - class ControlConnection final : public QObject - { - Q_OBJECT - - public: - // Wrap one local control socket connection - ControlConnection(quint64 connectionId, QLocalSocket* socket, QObject* parent = nullptr) - : QObject(parent) - , m_connectionId(connectionId) - , m_socket(socket) - { - if (!socket) - { - qFatal("ControlConnection requires QLocalSocket"); - } - m_socket->setParent(this); - - connect(m_socket, &QLocalSocket::readyRead, - this, &ControlConnection::onReadyRead); - connect(m_socket, &QLocalSocket::disconnected, - this, &ControlConnection::onDisconnected); - connect(m_socket, &QLocalSocket::errorOccurred, this, - [this](QLocalSocket::LocalSocketError socketError) - { - qWarning().noquote() - << QString("Agent control socket error (%1) on connection %2") - .arg(static_cast(socketError)) - .arg(m_connectionId); - }); - } - - // Send one encoded protocol message to the client - void sendMessage(const QJsonObject& message) - { - if (m_socket->state() != QLocalSocket::ConnectedState) - { - return; - } - m_socket->write(agent::encodeMessage(message)); - } - - signals: - void requestReceived(quint64 connectionId, - quint64 requestId, - const QString& type, - const QJsonObject& message); - void connectionClosed(quint64 connectionId); - - private slots: - // Decode queued socket bytes into control requests - void onReadyRead() - { - m_readBuffer += m_socket->readAll(); - while (true) - { - QJsonObject message; - QString error; - const agent::DecodeResult result = - agent::tryDecodeMessage(m_readBuffer, message, &error); - if (result == agent::DecodeResult::Incomplete) - { - return; - } - if (result == agent::DecodeResult::Error) - { - qWarning().noquote() - << QString("Agent control protocol error on connection %1: %2") - .arg(m_connectionId) - .arg(error); - m_socket->disconnectFromServer(); - return; - } - - if (message.value(agent::kEnvelopeVersionField).toInt(0) - != static_cast(agent::kProtocolVersion)) - { - qWarning().noquote() - << QString("Agent control protocol version mismatch on connection %1") - .arg(m_connectionId); - m_socket->disconnectFromServer(); - return; - } - - if (message.value(agent::kEnvelopeKindField).toString() - != agent::kMessageKindRequest) - { - qWarning().noquote() - << QString("Ignoring non-request control message on connection %1") - .arg(m_connectionId); - continue; - } - - const quint64 requestId = - agent::decodeUInt64(message.value(agent::kEnvelopeRequestIdField)); - const QString type = message.value(agent::kMessageTypeField).toString(); - if (requestId == 0 || type.isEmpty()) - { - qWarning().noquote() - << QString("Ignoring malformed request on connection %1") - .arg(m_connectionId); - continue; - } - - emit requestReceived(m_connectionId, requestId, type, message); - } - } - - // Notify the agent when this connection closes - void onDisconnected() - { - emit connectionClosed(m_connectionId); - deleteLater(); - } - - private: - quint64 m_connectionId{0}; - QLocalSocket* m_socket{nullptr}; - QByteArray m_readBuffer; - }; - - class AgentRuntime final : public QObject - { - Q_OBJECT - - public: - // Store launch settings for one camera runtime - AgentRuntime(QString cameraId, - QString adapter, - QString device, - QString shmKey, - QStringList preInitProperties, - QStringList properties, - double exposureMs, - bool autoPreview, - QObject* parent = nullptr) - : QObject(parent) - , m_cameraId(std::move(cameraId)) - , m_adapter(std::move(adapter)) - , m_device(std::move(device)) - , m_shmKey(std::move(shmKey)) - , m_preInitProperties(std::move(preInitProperties)) - , m_properties(std::move(properties)) - , m_exposureMs(exposureMs) - , m_autoPreview(autoPreview) - { - } - - QString lastError() const - { - return m_lastError; - } - - // Initialize MMCore camera state and shared memory transport - bool initializeRuntime() - { - m_lastError.clear(); - setState(State::Starting); - - if (!m_timer) - { - m_timer = new QTimer(this); - m_timer->setTimerType(Qt::PreciseTimer); - connect(m_timer, &QTimer::timeout, - this, &AgentRuntime::pollAndWrite); - } - - try - { - m_mmcore = std::make_unique(); - const std::string label = m_cameraId.toStdString(); - const std::string adapter = m_adapter.toStdString(); - const std::string device = m_device.toStdString(); - - m_mmcore->loadDevice(label.c_str(), adapter.c_str(), device.c_str()); - QString preInitError; - if (!applyProperties(m_preInitProperties, label, QStringLiteral("pre-init property"), &preInitError)) - { - m_lastError = preInitError; - setState(State::Error, m_lastError); - return false; - } - m_mmcore->initializeDevice(label.c_str()); - m_mmcore->setCameraDevice(label.c_str()); - - QString propertyError; - if (!applyProperties(m_properties, label, QStringLiteral("property"), &propertyError)) - { - m_lastError = propertyError; - setState(State::Error, m_lastError); - return false; - } - - try - { - m_mmcore->setCircularBufferMemoryFootprint(2048); - } - catch (const CMMError&) - { - } - - double finalExposure = 0.0; - if (m_exposureMs > 0.0) - { - m_mmcore->setExposure(m_exposureMs); - } - m_mmcore->waitForDevice(label.c_str()); - finalExposure = m_mmcore->getExposure(); - if (finalExposure <= 0.0) - { - m_lastError = QStringLiteral("Camera reported an invalid exposure"); - setState(State::Error, m_lastError); - return false; - } - m_exposureMs = finalExposure; - } - catch (const CMMError& error) - { - m_lastError = QString::fromStdString(error.getMsg()); - setState(State::Error, m_lastError); - return false; - } - - m_shm = std::make_unique(); - m_shm->setNativeKey(m_shmKey); - const int totalBytes = - kSharedMemoryControlSize + kSharedFrameNumSlots * kSharedFrameSlotStride; - if (!m_shm->create(totalBytes)) - { - if (m_shm->attach()) - { - m_shm->detach(); - } - if (!m_shm->create(totalBytes)) - { - m_lastError = QStringLiteral("Cannot create shared memory '%1'") - .arg(m_shmKey); - setState(State::Error, m_lastError); - return false; - } - } - - if (m_shm->lock()) - { - auto* base = static_cast(m_shm->data()); - if (base) - { - const SharedMemoryControl control{}; - memcpy(base, &control, sizeof(control)); - for (int i = 0; i < kSharedFrameNumSlots; ++i) - { - const SharedFrameHeader header{}; - uchar* slot = base + kSharedMemoryControlSize - + i * kSharedFrameSlotStride; - memcpy(slot, &header, sizeof(header)); - } - } - m_shm->unlock(); - } - - if (m_autoPreview) - { - QString error; - if (!startPreviewInternal(&error)) - { - m_lastError = error; - setState(State::Error, m_lastError); - return false; - } - } - else - { - setState(State::Idle); - } - - return true; - } - - public slots: - void publishHello(); - void handleRequest(quint64 connectionId, - quint64 requestId, - const QString& type, - const QJsonObject& message); - void stopForExit(); - - signals: - void responseReady(quint64 connectionId, const QJsonObject& response); - void eventReady(const QJsonObject& event); - void shutdownRequested(); - - private: - // Runtime state is mirrored to control clients - enum class State - { - Starting, - Idle, - Previewing, - Error, - ShuttingDown - }; - - enum class FrameDeliveryMode - { - PreviewLatest, - LatestOnly, - AllFrames - }; - - // Frame layout describes one shared memory payload shape - struct FrameLayout - { - unsigned width{0}; - unsigned height{0}; - unsigned bytesPerPixel{0}; - SharedPixelFormat format{SharedPixelFormat::Mono8}; - unsigned stride{0}; - quint64 byteCount{0}; - quint16 bitDepth{8}; - quint16 channels{1}; - }; - - bool previewRunning() const; - void setState(State state, const QString& error = QString()); - QJsonObject makeResponse(const QString& type, quint64 requestId, bool ok) const; - QJsonObject makeErrorResponse(const QString& type, - quint64 requestId, - const QString& error) const; - QJsonObject makeEvent(const QString& type) const; - bool applyProperties(const QStringList& encodedProperties, - const std::string& label, - const QString& propertyKind, - QString* errorMessage); - void emitPreviewStateEvent(); - void emitAgentErrorEvent(const QString& error); - void emitFrameAvailableEvent(quint64 frameIndex); - bool startPreviewInternal(QString* errorMessage); - bool stopPreviewInternal(QString* errorMessage); - bool captureEventFrameInternal(quint64& frameIndex, QString* errorMessage); - bool writeFrameToSharedMemory(const void* pixels, - quint64 frameAdvance = 1, - quint64* frameIndexOut = nullptr); - void pollAndWrite(); - void updatePollingInterval(quint64 frameCount); - bool ensureFrameLayout(unsigned width, unsigned height, unsigned bytesPerPixel); - void refreshSourceRoi(); - - QString m_cameraId; - QString m_adapter; - QString m_device; - QString m_shmKey; - QStringList m_preInitProperties; - QStringList m_properties; - double m_exposureMs{10.0}; - bool m_autoPreview{false}; - - QString m_lastError; - State m_state{State::Starting}; - - std::unique_ptr m_mmcore; - std::unique_ptr m_shm; - QTimer* m_timer{nullptr}; - QElapsedTimer m_frameIntervalTimer; - QElapsedTimer m_deliveryTimer; - double m_observedFrameIntervalMs{0.0}; - - FrameLayout m_frameLayout{}; - bool m_frameLayoutValid{false}; - bool m_loggedOversizedFrame{false}; - bool m_loggedUnsupportedFormat{false}; - int m_sourceRoiX{0}; - int m_sourceRoiY{0}; - int m_sourceRoiWidth{0}; - int m_sourceRoiHeight{0}; - - quint64 m_frameIndex{0}; - quint64 m_pendingFrameAdvance{0}; - int m_nextWriteSlot{0}; - FrameDeliveryMode m_frameDeliveryMode{FrameDeliveryMode::PreviewLatest}; - }; - - // Publish the initial hello event to connected clients - void AgentRuntime::publishHello() - { - emit eventReady(makeEvent(agent::kEventHello)); - } - - // Replay encoded cfg properties into the agent MMCore instance - bool AgentRuntime::applyProperties(const QStringList& encodedProperties, - const std::string& label, - const QString& propertyKind, - QString* errorMessage) - { - for (const QString& encodedProperty : encodedProperties) - { - if (encodedProperty.isEmpty()) - { - continue; - } - - QJsonParseError parseError; - const QJsonDocument doc = QJsonDocument::fromJson(encodedProperty.toUtf8(), &parseError); - if (parseError.error != QJsonParseError::NoError || !doc.isObject()) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("Invalid %1 payload for '%2'") - .arg(propertyKind, m_cameraId); - } - return false; - } - - const QJsonObject property = doc.object(); - const QString propertyName = property.value(QStringLiteral("name")).toString().trimmed(); - const QString propertyValue = property.value(QStringLiteral("value")).toString(); - if (propertyName.isEmpty()) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("Missing %1 name for '%2'") - .arg(propertyKind, m_cameraId); - } - return false; - } - - try - { - m_mmcore->setProperty(label.c_str(), - propertyName.toStdString().c_str(), - propertyValue.toStdString().c_str()); - } - catch (const CMMError& mmError) - { - if (errorMessage) - { - *errorMessage = QString("Failed to apply %1 '%2': %3") - .arg(propertyKind, propertyName, QString::fromStdString(mmError.getMsg())); - } - return false; - } - } - - return true; - } - - // Dispatch one control request and emit a matching response - void AgentRuntime::handleRequest(quint64 connectionId, - quint64 requestId, - const QString& type, - const QJsonObject& message) - { - if (requestId == 0 || type.isEmpty()) - { - return; - } - - if (!m_mmcore && type != agent::kCommandShutdown) - { - emit responseReady(connectionId, - makeErrorResponse(type, - requestId, - QStringLiteral("Agent runtime not initialized"))); - return; - } - - if (type == agent::kCommandStartPreview) - { - QString error; - const bool ok = startPreviewInternal(&error); - emit responseReady(connectionId, - ok - ? makeResponse(type, requestId, true) - : makeErrorResponse(type, requestId, error)); - return; - } - - if (type == agent::kCommandStopPreview) - { - QString error; - const bool ok = stopPreviewInternal(&error); - emit responseReady(connectionId, - ok - ? makeResponse(type, requestId, true) - : makeErrorResponse(type, requestId, error)); - return; - } - - if (type == agent::kCommandSetFrameDeliveryMode) - { - const QString mode = message.value(QStringLiteral("mode")).toString(); - if (mode == agent::kFrameDeliveryModePreviewLatest) - { - m_frameDeliveryMode = FrameDeliveryMode::PreviewLatest; - m_deliveryTimer.invalidate(); - } - else if (mode == agent::kFrameDeliveryModeLatestOnly) - { - m_frameDeliveryMode = FrameDeliveryMode::LatestOnly; - } - else if (mode == agent::kFrameDeliveryModeAllFrames) - { - m_frameDeliveryMode = FrameDeliveryMode::AllFrames; - m_pendingFrameAdvance = 0; - m_deliveryTimer.invalidate(); - } - else - { - emit responseReady(connectionId, - makeErrorResponse(type, - requestId, - QStringLiteral("Unknown frame delivery mode"))); - return; - } - - QJsonObject response = makeResponse(type, requestId, true); - response.insert(QStringLiteral("mode"), mode); - emit responseReady(connectionId, response); - return; - } - - if (type == agent::kCommandSetExposure) - { - const double exposureMs = message.value(QStringLiteral("value")).toDouble(-1.0); - bool ok = exposureMs > 0.0; - QString error; - if (!ok) - { - error = QStringLiteral("Invalid exposure value"); - } - else - { - try - { - m_mmcore->setExposure(exposureMs); - m_exposureMs = m_mmcore->getExposure(); - } - catch (const CMMError& mmError) - { - ok = false; - error = QString::fromStdString(mmError.getMsg()); - } - } - - if (ok) - { - QJsonObject response = makeResponse(type, requestId, true); - response.insert(QStringLiteral("exposureMs"), m_exposureMs); - emit responseReady(connectionId, response); - } - else - { - emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); - } - return; - } - - if (type == agent::kCommandListProperties) - { - QJsonArray properties; - QString error; - bool ok = true; - try - { - const auto names = - m_mmcore->getDevicePropertyNames(m_cameraId.toStdString().c_str()); - for (const auto& name : names) - { - properties.append(QString::fromStdString(name)); - } - } - catch (const CMMError& mmError) - { - ok = false; - error = QString::fromStdString(mmError.getMsg()); - } - - if (ok) - { - QJsonObject response = makeResponse(type, requestId, true); - response.insert(QStringLiteral("properties"), properties); - emit responseReady(connectionId, response); - } - else - { - emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); - } - return; - } - - if (type == agent::kCommandGetProperty) - { - const QString name = message.value(QStringLiteral("name")).toString(); - const bool fromCache = message.value(QStringLiteral("fromCache")).toBool(false); - const std::string camera = m_cameraId.toStdString(); - const std::string property = name.toStdString(); - QString value; - QString propertyType = QStringLiteral("Unknown"); - bool readOnly = true; - bool preInit = false; - QJsonArray allowedValues; - bool hasLimits = false; - double lowerLimit = 0.0; - double upperLimit = 0.0; - QString error; - bool ok = true; - - try - { - value = QString::fromStdString( - fromCache - ? m_mmcore->getPropertyFromCache(camera.c_str(), property.c_str()) - : m_mmcore->getProperty(camera.c_str(), property.c_str())); - - try - { - switch (m_mmcore->getPropertyType(camera.c_str(), property.c_str())) - { - case MM::String: - propertyType = QStringLiteral("String"); - break; - case MM::Float: - propertyType = QStringLiteral("Float"); - break; - case MM::Integer: - propertyType = QStringLiteral("Integer"); - break; - default: - propertyType = QStringLiteral("Unknown"); - break; - } - } - catch (const CMMError&) - { - } - - try - { - preInit = m_mmcore->isPropertyPreInit(camera.c_str(), property.c_str()); - } - catch (const CMMError&) - { - } - - try - { - readOnly = m_mmcore->isPropertyReadOnly(camera.c_str(), property.c_str()); - } - catch (const CMMError&) - { - } - - try - { - const auto values = - m_mmcore->getAllowedPropertyValues(camera.c_str(), property.c_str()); - for (const auto& allowedValue : values) - { - allowedValues.append(QString::fromStdString(allowedValue)); - } - } - catch (const CMMError&) - { - } - - try - { - hasLimits = m_mmcore->hasPropertyLimits(camera.c_str(), property.c_str()); - if (hasLimits) - { - lowerLimit = m_mmcore->getPropertyLowerLimit(camera.c_str(), property.c_str()); - upperLimit = m_mmcore->getPropertyUpperLimit(camera.c_str(), property.c_str()); - } - } - catch (const CMMError&) - { - hasLimits = false; - } - } - catch (const CMMError& mmError) - { - ok = false; - error = QString::fromStdString(mmError.getMsg()); - } - - if (ok) - { - QJsonObject response = makeResponse(type, requestId, true); - response.insert(QStringLiteral("value"), value); - response.insert(QStringLiteral("propertyType"), propertyType); - response.insert(QStringLiteral("readOnly"), readOnly); - response.insert(QStringLiteral("preInit"), preInit); - response.insert(QStringLiteral("allowedValues"), allowedValues); - response.insert(QStringLiteral("hasLimits"), hasLimits); - response.insert(QStringLiteral("lowerLimit"), lowerLimit); - response.insert(QStringLiteral("upperLimit"), upperLimit); - emit responseReady(connectionId, response); - } - else - { - emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); - } - return; - } - - if (type == agent::kCommandSetProperty) - { - const QString name = message.value(QStringLiteral("name")).toString(); - const QString value = message.value(QStringLiteral("value")).toString(); - QString error; - bool ok = true; - try - { - m_mmcore->setProperty(m_cameraId.toStdString().c_str(), - name.toStdString().c_str(), - value.toStdString().c_str()); - m_mmcore->waitForDevice(m_cameraId.toStdString().c_str()); - } - catch (const CMMError& mmError) - { - ok = false; - error = QString::fromStdString(mmError.getMsg()); - } - - emit responseReady(connectionId, - ok - ? makeResponse(type, requestId, true) - : makeErrorResponse(type, requestId, error)); - return; - } - - if (type == agent::kCommandSetRoi) - { - const int x = message.value(QStringLiteral("x")).toInt(0); - const int y = message.value(QStringLiteral("y")).toInt(0); - const int width = message.value(QStringLiteral("width")).toInt(0); - const int height = message.value(QStringLiteral("height")).toInt(0); - QString error; - bool ok = true; - try - { - m_mmcore->setROI(m_cameraId.toStdString().c_str(), x, y, width, height); - m_mmcore->waitForDevice(m_cameraId.toStdString().c_str()); - refreshSourceRoi(); - } - catch (const CMMError& mmError) - { - ok = false; - error = QString::fromStdString(mmError.getMsg()); - } - - emit responseReady(connectionId, - ok - ? makeResponse(type, requestId, true) - : makeErrorResponse(type, requestId, error)); - return; - } - - if (type == agent::kCommandClearRoi) - { - QString error; - bool ok = true; - try - { - m_mmcore->setCameraDevice(m_cameraId.toStdString().c_str()); - m_mmcore->clearROI(); - m_mmcore->waitForDevice(m_cameraId.toStdString().c_str()); - refreshSourceRoi(); - } - catch (const CMMError& mmError) - { - ok = false; - error = QString::fromStdString(mmError.getMsg()); - } - - emit responseReady(connectionId, - ok - ? makeResponse(type, requestId, true) - : makeErrorResponse(type, requestId, error)); - return; - } - - if (type == agent::kCommandGetRoi) - { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - QString error; - bool ok = true; - try - { - m_mmcore->getROI(m_cameraId.toStdString().c_str(), x, y, width, height); - } - catch (const CMMError& mmError) - { - ok = false; - error = QString::fromStdString(mmError.getMsg()); - } - - if (ok) - { - QJsonObject response = makeResponse(type, requestId, true); - response.insert(QStringLiteral("x"), x); - response.insert(QStringLiteral("y"), y); - response.insert(QStringLiteral("width"), width); - response.insert(QStringLiteral("height"), height); - emit responseReady(connectionId, response); - } - else - { - emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); - } - return; - } - - if (type == agent::kCommandCaptureEvent) - { - QString error; - quint64 frameIndex = 0; - const bool ok = captureEventFrameInternal(frameIndex, &error); - if (ok) - { - QJsonObject response = makeResponse(type, requestId, true); - response.insert(QStringLiteral("frameIndex"), agent::encodeUInt64(frameIndex)); - emit responseReady(connectionId, response); - } - else - { - emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); - } - return; - } - - if (type == agent::kCommandShutdown) - { - stopPreviewInternal(nullptr); - setState(State::ShuttingDown); - emit responseReady(connectionId, makeResponse(type, requestId, true)); - QTimer::singleShot(50, this, [this]() { emit shutdownRequested(); }); - return; - } - - emit responseReady(connectionId, - makeErrorResponse(type, - requestId, - QStringLiteral("Unknown control command"))); - } - - // Stop preview before the runtime thread exits - void AgentRuntime::stopForExit() - { - stopPreviewInternal(nullptr); - setState(State::ShuttingDown); - } - - // Check whether the runtime is currently previewing - bool AgentRuntime::previewRunning() const - { - return m_state == State::Previewing; - } - - // Update runtime state and publish state changes - void AgentRuntime::setState(State state, const QString& error) - { - const bool changed = (m_state != state); - m_state = state; - if (!error.isEmpty()) - { - m_lastError = error; - } - if (changed && (state == State::Previewing - || state == State::Idle - || state == State::Error - || state == State::ShuttingDown)) - { - emitPreviewStateEvent(); - } - if (!error.isEmpty()) - { - emitAgentErrorEvent(error); - } - } - - // Build a protocol response envelope - QJsonObject AgentRuntime::makeResponse(const QString& type, quint64 requestId, bool ok) const - { - QJsonObject response = - agent::makeEnvelope(agent::kMessageKindResponse, type, requestId); - response.insert(QStringLiteral("ok"), ok); - return response; - } - - // Build a protocol error response envelope - QJsonObject AgentRuntime::makeErrorResponse(const QString& type, - quint64 requestId, - const QString& error) const - { - QJsonObject response = makeResponse(type, requestId, false); - response.insert(QStringLiteral("error"), error); - return response; - } - - // Build a protocol event envelope - QJsonObject AgentRuntime::makeEvent(const QString& type) const - { - return agent::makeEnvelope(agent::kMessageKindEvent, type); - } - - // Publish current preview state to clients - void AgentRuntime::emitPreviewStateEvent() - { - QJsonObject event = makeEvent(agent::kEventPreviewState); - event.insert(QStringLiteral("running"), previewRunning()); - emit eventReady(event); - } - - // Publish an agent error event to clients - void AgentRuntime::emitAgentErrorEvent(const QString& error) - { - QJsonObject event = makeEvent(agent::kEventAgentError); - event.insert(QStringLiteral("error"), error); - emit eventReady(event); - } - - // Publish the newest shared memory frame index - void AgentRuntime::emitFrameAvailableEvent(quint64 frameIndex) - { - QJsonObject event = makeEvent(agent::kEventFrameAvailable); - event.insert(QStringLiteral("frameIndex"), agent::encodeUInt64(frameIndex)); - emit eventReady(event); - } - - // Start continuous acquisition and polling - bool AgentRuntime::startPreviewInternal(QString* errorMessage) - { - if (!m_mmcore) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("MMCore not available"); - } - return false; - } - if (m_state == State::ShuttingDown || m_state == State::Error) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("Agent is not in a runnable state"); - } - return false; - } - if (previewRunning()) - { - return true; - } - - try - { - while (m_mmcore->getRemainingImageCount() > 0) - { - m_mmcore->popNextImage(); - } - refreshSourceRoi(); - if (!ensureFrameLayout(m_mmcore->getImageWidth(), - m_mmcore->getImageHeight(), - m_mmcore->getBytesPerPixel())) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("Unsupported frame format"); - } - return false; - } - m_mmcore->startContinuousSequenceAcquisition(0.0); - m_observedFrameIntervalMs = 0.0; - m_pendingFrameAdvance = 0; - m_frameIntervalTimer.restart(); - m_deliveryTimer.invalidate(); - m_timer->setInterval(pollingIntervalFor(m_exposureMs)); - if (m_timer && !m_timer->isActive()) - { - m_timer->start(); - } - setState(State::Previewing); - return true; - } - catch (const CMMError& error) - { - if (errorMessage) - { - *errorMessage = QString::fromStdString(error.getMsg()); - } - return false; - } - } - - // Stop continuous acquisition and polling - bool AgentRuntime::stopPreviewInternal(QString* errorMessage) - { - if (!m_mmcore) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("MMCore not available"); - } - return false; - } - - try - { - if (m_mmcore->isSequenceRunning()) - { - m_mmcore->stopSequenceAcquisition(); - } - if (m_timer && m_timer->isActive()) - { - m_timer->stop(); - } - m_frameIntervalTimer.invalidate(); - m_deliveryTimer.invalidate(); - m_observedFrameIntervalMs = 0.0; - m_pendingFrameAdvance = 0; - if (m_state != State::ShuttingDown && m_state != State::Error) - { - setState(State::Idle); - } - return true; - } - catch (const CMMError& error) - { - if (errorMessage) - { - *errorMessage = QString::fromStdString(error.getMsg()); - } - return false; - } - } - - // Capture one frame for recording or API requests - bool AgentRuntime::captureEventFrameInternal(quint64& frameIndex, QString* errorMessage) - { - frameIndex = 0; - try - { - const void* pixels = nullptr; - if (previewRunning()) - { - QElapsedTimer waitTimer; - waitTimer.start(); - while (m_mmcore->getRemainingImageCount() <= 0 && waitTimer.elapsed() < 2000) - { - QThread::msleep(1); - } - if (m_mmcore->getRemainingImageCount() > 0) - { - pixels = m_mmcore->popNextImage(); - } - else if (errorMessage) - { - *errorMessage = QStringLiteral("No frame available from running sequence"); - } - } - else - { - m_mmcore->snapImage(); - pixels = m_mmcore->getImage(); - } - - if (!pixels) - { - if (errorMessage && errorMessage->isEmpty()) - { - *errorMessage = QStringLiteral("Empty image buffer"); - } - return false; - } - - const unsigned width = m_mmcore->getImageWidth(); - const unsigned height = m_mmcore->getImageHeight(); - const unsigned bytesPerPixel = m_mmcore->getBytesPerPixel(); - if (!ensureFrameLayout(width, height, bytesPerPixel)) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("Unsupported frame format"); - } - return false; - } - - const quint64 frameAdvance = m_pendingFrameAdvance + 1; - m_pendingFrameAdvance = 0; - if (!writeFrameToSharedMemory(pixels, frameAdvance, &frameIndex)) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("Shared memory unavailable"); - } - return false; - } - - if (m_frameDeliveryMode == FrameDeliveryMode::PreviewLatest) - { - m_deliveryTimer.restart(); - } - - emitFrameAvailableEvent(frameIndex); - return true; - } - catch (const CMMError& error) - { - if (errorMessage) - { - *errorMessage = QString::fromStdString(error.getMsg()); - } - return false; - } - } - - // Copy one camera frame into the shared memory ring buffer - bool AgentRuntime::writeFrameToSharedMemory(const void* pixels, - quint64 frameAdvance, - quint64* frameIndexOut) - { - if (!m_shm || !m_shm->isAttached()) - { - return false; - } - uchar* base = static_cast(m_shm->data()); - if (!base) - { - return false; - } - - const quint64 nextFrameIndex = m_frameIndex + (std::max)(quint64{1}, frameAdvance); - const int preferredSlotIndex = m_nextWriteSlot; - int slotIndex = -1; - uchar* ptr = nullptr; - for (int offset = 0; offset < kSharedFrameNumSlots; ++offset) - { - const int candidate = (preferredSlotIndex + offset) % kSharedFrameNumSlots; - uchar* candidatePtr = base + kSharedMemoryControlSize - + candidate * kSharedFrameSlotStride; - auto& stateValue = *reinterpret_cast(candidatePtr); - std::atomic_ref state(stateValue); - quint32 expected = state.load(std::memory_order_acquire); - while (expected == 0 || expected == 2) - { - if (state.compare_exchange_weak(expected, - 1, - std::memory_order_acq_rel, - std::memory_order_acquire)) - { - slotIndex = candidate; - ptr = candidatePtr; - break; - } - } - if (slotIndex >= 0) - { - break; - } - } - - if (slotIndex < 0 || !ptr) - { - m_frameIndex = nextFrameIndex; - if (frameIndexOut) - { - *frameIndexOut = m_frameIndex; - } - return false; - } - - auto* control = reinterpret_cast(base); - m_nextWriteSlot = (slotIndex + 1) % kSharedFrameNumSlots; - SharedFrameHeader header{}; - header.state = 1; - header.width = m_frameLayout.width; - header.height = m_frameLayout.height; - header.stride = m_frameLayout.stride; - header.pixelFormat = static_cast(m_frameLayout.format); - header.bitsPerSample = m_frameLayout.bitDepth; - header.channels = m_frameLayout.channels; - header.frameIndex = nextFrameIndex; - header.timestampNs = - static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; - setSharedFrameSourceRoi(header, - m_sourceRoiX, - m_sourceRoiY, - m_sourceRoiWidth, - m_sourceRoiHeight); - memcpy(ptr + sizeof(header.state), - reinterpret_cast(&header) + sizeof(header.state), - sizeof(header) - sizeof(header.state)); - - uchar* dst = ptr + kSharedFrameHeaderSize; - memcpy(dst, pixels, static_cast(m_frameLayout.byteCount)); - - auto& stateValue = *reinterpret_cast(ptr); - std::atomic_ref(stateValue).store(2, std::memory_order_release); - std::atomic_ref(control->latestSlotIndex) - .store(static_cast(slotIndex), std::memory_order_release); - m_frameIndex = nextFrameIndex; - if (frameIndexOut) - { - *frameIndexOut = m_frameIndex; - } - - return true; - } - - // Drain camera frames into shared memory and publish the newest index - void AgentRuntime::pollAndWrite() - { - if (!m_mmcore) - { - return; - } - - try - { - long remaining = m_mmcore->getRemainingImageCount(); - - if (remaining <= 0) - { - return; - } - - quint64 newestFrameIndex = 0; - quint64 acquiredFrameCount = 0; - if (m_frameDeliveryMode != FrameDeliveryMode::AllFrames) - { - const bool previewRateLimited = - m_frameDeliveryMode == FrameDeliveryMode::PreviewLatest; - const bool publishFrame = !previewRateLimited - || !m_deliveryTimer.isValid() - || m_deliveryTimer.elapsed() >= kPreviewFrameDeliveryIntervalMs; - while (remaining-- > 0) - { - const void* pixels = m_mmcore->popNextImage(); - if (!pixels) - { - break; - } - ++acquiredFrameCount; - ++m_pendingFrameAdvance; - if (remaining > 0) - { - continue; - } - if (publishFrame && m_frameLayoutValid) - { - quint64 writtenFrameIndex = 0; - const bool written = writeFrameToSharedMemory( - pixels, - m_pendingFrameAdvance, - &writtenFrameIndex); - m_pendingFrameAdvance = 0; - if (previewRateLimited) - { - m_deliveryTimer.restart(); - } - if (written) - { - newestFrameIndex = writtenFrameIndex; - } - } - } - } - else - { - while (remaining-- > 0) - { - const void* pixels = m_mmcore->popNextImage(); - if (!pixels) - { - break; - } - ++acquiredFrameCount; - - if (!m_frameLayoutValid) - { - break; - } - - quint64 writtenFrameIndex = 0; - if (!writeFrameToSharedMemory(pixels, 1, &writtenFrameIndex)) - { - break; - } - newestFrameIndex = writtenFrameIndex; - } - } - - if (newestFrameIndex != 0) - { - emitFrameAvailableEvent(newestFrameIndex); - } - updatePollingInterval(acquiredFrameCount); - } - catch (const CMMError& error) - { - const QString message = - QStringLiteral("Agent capture error: %1") - .arg(QString::fromStdString(error.getMsg())); - setState(State::Error, message); - } - } - - // Adapts MMCore polling to the observed camera frame interval - void AgentRuntime::updatePollingInterval(quint64 frameCount) - { - if (frameCount == 0 || !m_frameIntervalTimer.isValid()) - { - return; - } - - const double elapsedMs = static_cast(m_frameIntervalTimer.nsecsElapsed()) / 1000000.0; - m_frameIntervalTimer.restart(); - const double measuredIntervalMs = elapsedMs / static_cast(frameCount); - if (!std::isfinite(measuredIntervalMs) || measuredIntervalMs <= 0.0) - { - return; - } - - m_observedFrameIntervalMs = m_observedFrameIntervalMs > 0.0 - ? 0.75 * m_observedFrameIntervalMs - + 0.25 * measuredIntervalMs - : measuredIntervalMs; - const int intervalMs = pollingIntervalFor(m_observedFrameIntervalMs); - if (m_timer->interval() != intervalMs) - { - m_timer->setInterval(intervalMs); - } - } - - // Validate and cache the current frame memory layout - bool AgentRuntime::ensureFrameLayout(unsigned width, unsigned height, unsigned bytesPerPixel) - { - const auto logOversized = [this](quint64 byteCount) - { - if (!m_loggedOversizedFrame) - { - qWarning().noquote() - << QString("[Agent %1] Frame payload (%2 bytes) exceeds shared memory slot capacity (%3 bytes)") - .arg(m_cameraId) - .arg(static_cast(byteCount)) - .arg(kSharedFrameMaxBytes); - m_loggedOversizedFrame = true; - } - }; - - if (bytesPerPixel != 1 && bytesPerPixel != 2) - { - if (!m_loggedUnsupportedFormat) - { - qWarning().noquote() - << QString("[Agent %1] Unsupported bytes-per-pixel (%2). Only Mono8/Mono16 are supported.") - .arg(m_cameraId) - .arg(bytesPerPixel); - m_loggedUnsupportedFormat = true; - } - return false; - } - m_loggedUnsupportedFormat = false; - - const bool geometryChanged = - !m_frameLayoutValid - || m_frameLayout.width != width - || m_frameLayout.height != height - || m_frameLayout.bytesPerPixel != bytesPerPixel; - - if (!geometryChanged) - { - if (m_frameLayout.byteCount == 0 - || m_frameLayout.byteCount > static_cast(kSharedFrameMaxBytes)) - { - logOversized(m_frameLayout.byteCount); - return false; - } - if (m_sourceRoiWidth <= 0 || m_sourceRoiHeight <= 0) - { - refreshSourceRoi(); - } - m_loggedOversizedFrame = false; - return true; - } - - m_frameLayoutValid = false; - - FrameLayout updated; - updated.width = width; - updated.height = height; - updated.bytesPerPixel = bytesPerPixel; - updated.format = - (bytesPerPixel == 2) ? SharedPixelFormat::Mono16 : SharedPixelFormat::Mono8; - updated.channels = 1; - const quint64 stride = static_cast(width) * bytesPerPixel; - if (stride == 0 || stride > (std::numeric_limits::max)()) - { - logOversized(stride); - return false; - } - updated.stride = static_cast(stride); - updated.byteCount = computeMaxFrameBytes(width, height, updated.format); - - if (updated.byteCount == 0 - || updated.byteCount > static_cast(kSharedFrameMaxBytes)) - { - logOversized(updated.byteCount); - return false; - } - - quint16 bitDepth = - (updated.format == SharedPixelFormat::Mono16) ? 16 : 8; - try - { - bitDepth = normalizedSharedBitDepth( - updated.format, - static_cast(m_mmcore->getImageBitDepth())); - } - catch (const CMMError&) - { - bitDepth = - (updated.format == SharedPixelFormat::Mono16) ? 16 : 8; - } - updated.bitDepth = normalizedSharedBitDepth(updated.format, bitDepth); - - m_frameLayout = updated; - m_frameLayoutValid = true; - refreshSourceRoi(); - m_loggedOversizedFrame = false; - return true; - } - - // Read the active camera ROI used by following frame metadata - void AgentRuntime::refreshSourceRoi() - { - int x = 0; - int y = 0; - int width = 0; - int height = 0; - try - { - m_mmcore->getROI(m_cameraId.toStdString().c_str(), x, y, width, height); - } - catch (const CMMError&) - { - width = static_cast(m_frameLayout.width); - height = static_cast(m_frameLayout.height); - } - - if (width <= 0 || height <= 0) - { - width = static_cast(m_frameLayout.width); - height = static_cast(m_frameLayout.height); - } - - m_sourceRoiX = x; - m_sourceRoiY = y; - m_sourceRoiWidth = width; - m_sourceRoiHeight = height; - } - - class Agent final : public QObject - { - Q_OBJECT - - public: - // Create the control server wrapper for one camera agent - Agent(QString cameraId, - QString adapter, - QString device, - QString shmKey, - QStringList preInitProperties, - QStringList properties, - double exposureMs, - bool autoPreview, - QObject* parent = nullptr) - : QObject(parent) - , m_cameraId(std::move(cameraId)) - , m_adapter(std::move(adapter)) - , m_device(std::move(device)) - , m_shmKey(std::move(shmKey)) - , m_serverName(agent::controlServerName(m_cameraId)) - , m_preInitProperties(std::move(preInitProperties)) - , m_properties(std::move(properties)) - , m_exposureMs(exposureMs) - , m_autoPreview(autoPreview) - { - qRegisterMetaType("QJsonObject"); - qRegisterMetaType("quint64"); - } - - // Stop the runtime thread before destruction - ~Agent() override - { - stopRuntime(); - } - - bool start(); - - private slots: - void onNewControlConnection(); - void onRuntimeResponse(quint64 connectionId, const QJsonObject& response); - void broadcastEvent(const QJsonObject& event); - void onConnectionClosed(quint64 connectionId); - - private: - void stopRuntime(); - - QString m_cameraId; - QString m_adapter; - QString m_device; - QString m_shmKey; - QString m_serverName; - QStringList m_preInitProperties; - QStringList m_properties; - double m_exposureMs{0.0}; - bool m_autoPreview{false}; - - std::unique_ptr m_ctrlServer; - QThread m_runtimeThread; - AgentRuntime* m_runtime{nullptr}; - quint64 m_nextConnectionId{1}; - QHash m_connections; - }; - - // Start the runtime thread and local control server - bool Agent::start() - { - m_runtime = new AgentRuntime(m_cameraId, - m_adapter, - m_device, - m_shmKey, - m_preInitProperties, - m_properties, - m_exposureMs, - m_autoPreview); - m_runtime->moveToThread(&m_runtimeThread); - connect(&m_runtimeThread, &QThread::finished, - m_runtime, &QObject::deleteLater); - connect(m_runtime, &AgentRuntime::responseReady, - this, &Agent::onRuntimeResponse); - connect(m_runtime, &AgentRuntime::eventReady, - this, &Agent::broadcastEvent); - connect(m_runtime, &AgentRuntime::shutdownRequested, this, - []() { QCoreApplication::quit(); }); - - m_runtimeThread.start(); - - bool initialized = false; - QString initError; - QMetaObject::invokeMethod( - m_runtime, - [this, &initialized, &initError]() - { - initialized = m_runtime->initializeRuntime(); - initError = m_runtime->lastError(); - }, - Qt::BlockingQueuedConnection); - - if (!initialized) - { - qCritical().noquote() - << QString("Agent init failed for '%1': %2") - .arg(m_cameraId, initError); - stopRuntime(); - return false; - } - - QLocalServer::removeServer(m_serverName); - m_ctrlServer = std::make_unique(this); - connect(m_ctrlServer.get(), &QLocalServer::newConnection, - this, &Agent::onNewControlConnection); - if (!m_ctrlServer->listen(m_serverName)) - { - qCritical().noquote() - << QString("Agent control server failed to listen on %1") - .arg(m_serverName); - stopRuntime(); - return false; - } - - qInfo().noquote() - << QString("Agent control server listening on %1").arg(m_serverName); - return true; - } - - // Accept pending local control connections - void Agent::onNewControlConnection() - { - while (m_ctrlServer && m_ctrlServer->hasPendingConnections()) - { - QLocalSocket* socket = m_ctrlServer->nextPendingConnection(); - if (!socket) - { - continue; - } - - const quint64 connectionId = m_nextConnectionId++; - auto* connection = new ControlConnection(connectionId, socket, this); - m_connections.insert(connectionId, connection); - - connect(connection, &ControlConnection::requestReceived, - m_runtime, &AgentRuntime::handleRequest, - Qt::QueuedConnection); - connect(connection, &ControlConnection::connectionClosed, - this, &Agent::onConnectionClosed); - - QMetaObject::invokeMethod(m_runtime, - &AgentRuntime::publishHello, - Qt::QueuedConnection); - } - } - - // Route one runtime response back to its connection - void Agent::onRuntimeResponse(quint64 connectionId, const QJsonObject& response) - { - auto it = m_connections.find(connectionId); - if (it == m_connections.end() || !it.value()) - { - return; - } - it.value()->sendMessage(response); - } - - // Broadcast one runtime event to all clients - void Agent::broadcastEvent(const QJsonObject& event) - { - for (auto it = m_connections.begin(); it != m_connections.end(); ++it) - { - if (it.value()) - { - it.value()->sendMessage(event); - } - } - } - - // Remove a closed control connection - void Agent::onConnectionClosed(quint64 connectionId) - { - m_connections.remove(connectionId); - } - - // Stop the runtime worker thread cleanly - void Agent::stopRuntime() - { - if (!m_runtime) - { - return; - } - - QMetaObject::invokeMethod(m_runtime, - &AgentRuntime::stopForExit, - Qt::BlockingQueuedConnection); - m_runtimeThread.quit(); - m_runtimeThread.wait(); - m_runtime = nullptr; - } -} // namespace scopeone::core::internal - -// Launch one camera agent process from command line arguments -int main(int argc, char* argv[]) -{ - QCoreApplication app(argc, argv); - - QCommandLineParser parser; - parser.setApplicationDescription("ScopeOne Camera Agent"); - parser.addHelpOption(); - - QCommandLineOption optCamId({QStringLiteral("c"), QStringLiteral("cameraId")}, - QStringLiteral("Camera ID"), - QStringLiteral("id")); - QCommandLineOption optAdapter(QStringLiteral("adapter"), - QStringLiteral("MM adapter"), - QStringLiteral("adapter")); - QCommandLineOption optDevice(QStringLiteral("device"), - QStringLiteral("MM device"), - QStringLiteral("device")); - QCommandLineOption optShm(QStringLiteral("shm"), - QStringLiteral("Shared memory key"), - QStringLiteral("key")); - QCommandLineOption optExp(QStringLiteral("exposure"), - QStringLiteral("Exposure ms"), - QStringLiteral("ms")); - QCommandLineOption optPreInit(QStringLiteral("preinit"), - QStringLiteral("JSON-encoded pre-init property"), - QStringLiteral("json")); - QCommandLineOption optProperty(QStringLiteral("property"), - QStringLiteral("JSON-encoded initialized property"), - QStringLiteral("json")); - QCommandLineOption optAuto(QStringLiteral("autoPreview"), - QStringLiteral("Start preview immediately")); - - parser.addOption(optCamId); - parser.addOption(optAdapter); - parser.addOption(optDevice); - parser.addOption(optShm); - parser.addOption(optExp); - parser.addOption(optPreInit); - parser.addOption(optProperty); - parser.addOption(optAuto); - parser.process(app); - - if (!parser.isSet(optCamId) - || !parser.isSet(optAdapter) - || !parser.isSet(optDevice) - || !parser.isSet(optShm)) - { - qCritical().noquote() - << "Missing required arguments: --cameraId, --adapter, --device, --shm"; - return 2; - } - - scopeone::core::internal::Agent agent(parser.value(optCamId), - parser.value(optAdapter), - parser.value(optDevice), - parser.value(optShm), - parser.values(optPreInit), - parser.values(optProperty), - parser.isSet(optExp) - ? parser.value(optExp).toDouble() - : 0.0, - parser.isSet(optAuto)); - if (!agent.start()) - { - return 2; - } - - return app.exec(); -} - -#include "AgentMain.moc" diff --git a/ScopeOneCore/src/BackgroundCalibrationModule.cpp b/ScopeOneCore/src/BackgroundCalibrationModule.cpp index c88a54d..6b82098 100644 --- a/ScopeOneCore/src/BackgroundCalibrationModule.cpp +++ b/ScopeOneCore/src/BackgroundCalibrationModule.cpp @@ -213,7 +213,7 @@ namespace scopeone::core::internal const qint64 pixelCount = static_cast(workingFrame.width) * workingFrame.height; if (pixelCount <= 0) { - return {{}, QStringLiteral("Invalid running background dimensions")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid running background dimensions")); } if (m_runningSum.size() != static_cast(pixelCount)) { @@ -276,7 +276,7 @@ namespace scopeone::core::internal if (outputBytes.isEmpty()) { - return {{}, QStringLiteral("Failed to allocate running background output")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Failed to allocate running background output")); } ImageFrame output = makeFrameLike(workingFrame, @@ -293,7 +293,7 @@ namespace scopeone::core::internal { if (!frame.isValid()) { - return {{}, QStringLiteral("Invalid input")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid input")); } try @@ -301,7 +301,7 @@ namespace scopeone::core::internal ImageFrame workingFrame; if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth)) { - return {{}, QStringLiteral("Unsupported input frame")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported input frame")); } if ((!m_buffer.empty() && !m_buffer.front().isCompatibleWith(workingFrame)) @@ -396,7 +396,7 @@ namespace scopeone::core::internal } catch (const std::exception& e) { - return {{}, QString("Background calibration failed: %1").arg(e.what())}; + return ProcessingResult(ImageFrame{}, QString("Background calibration failed: %1").arg(e.what())); } } diff --git a/ScopeOneCore/src/CameraBackend.cpp b/ScopeOneCore/src/CameraBackend.cpp index 7cce6d0..b7b3877 100644 --- a/ScopeOneCore/src/CameraBackend.cpp +++ b/ScopeOneCore/src/CameraBackend.cpp @@ -85,7 +85,7 @@ namespace scopeone::core::internal return false; } - bool CameraBackend::addAgentCamera(const QString&, + bool CameraBackend::addDriverHostCamera(const QString&, const QString&, const QString&, const QStringList&, diff --git a/ScopeOneCore/src/CameraManager.cpp b/ScopeOneCore/src/CameraManager.cpp index 1703583..a17956f 100644 --- a/ScopeOneCore/src/CameraManager.cpp +++ b/ScopeOneCore/src/CameraManager.cpp @@ -1,6 +1,7 @@ #include "internal/CameraManager.h" #include +#include namespace scopeone::core::internal { @@ -28,6 +29,16 @@ namespace scopeone::core::internal shutdownNow(); } + void CameraManager::setFrameSink(FrameSink sink) + { + m_frameSink = std::move(sink); + } + + void CameraManager::setPreviewStateSink(PreviewStateSink sink) + { + m_previewStateSink = std::move(sink); + } + // Selects one camera backend and forwards its runtime signals bool CameraManager::activateBackend(CameraBackend::Kind kind) { @@ -39,14 +50,20 @@ namespace scopeone::core::internal shutdownNow(); m_backend = kind == CameraBackend::Kind::Native ? createNativeCameraBackend(m_processingFrameGate) - : createAgentCameraBackend(m_processingFrameGate); + : createDriverHostCameraBackend(m_processingFrameGate); if (!m_backend) { return false; } connect(m_backend.get(), &CameraBackend::rawFrameReady, - this, &CameraManager::newRawFrameReady); + this, [this](const scopeone::core::ImageFrame& frame) + { + if (m_frameSink) + { + m_frameSink(frame); + } + }); // Keeps processing input on the producer thread connect(m_backend.get(), &CameraBackend::processingFrameReady, this, &CameraManager::processingFrameReady, @@ -58,9 +75,13 @@ namespace scopeone::core::internal connect(m_backend.get(), &CameraBackend::frameDeliveryFailed, this, &CameraManager::frameDeliveryFailed); connect(m_backend.get(), &CameraBackend::previewStateChanged, - this, &CameraManager::previewStateChanged); - connect(m_backend.get(), &CameraBackend::agentControlServerListening, - this, &CameraManager::agentControlServerListening); + this, [this](bool running) + { + if (m_previewStateSink) m_previewStateSink(running); + emit previewStateChanged(running); + }); + connect(m_backend.get(), &CameraBackend::driverHostControlServerListening, + this, &CameraManager::driverHostControlServerListening); if (!m_backend->setHighRateFrameDeliveryEnabled(m_highRateFrameDeliveryEnabled) || !m_backend->setRecordingFrameDeliveryEnabled(m_recordingFrameDeliveryEnabled)) { @@ -88,8 +109,8 @@ namespace scopeone::core::internal return configured; } - // Adds one process isolated camera to the agent backend - bool CameraManager::addAgentCamera(const QString& cameraId, + // Add one process isolated camera to the DriverHost backend + bool CameraManager::addDriverHostCamera(const QString& cameraId, const QString& adapter, const QString& device, const QStringList& preInitProperties, @@ -98,8 +119,8 @@ namespace scopeone::core::internal { const QString normalizedId = normalizedCameraId(cameraId); const bool configured = !normalizedId.isEmpty() - && activateBackend(CameraBackend::Kind::Agent) - && m_backend->addAgentCamera(normalizedId, + && activateBackend(CameraBackend::Kind::DriverHost) + && m_backend->addDriverHostCamera(normalizedId, adapter, device, preInitProperties, @@ -179,10 +200,10 @@ namespace scopeone::core::internal return m_backend && m_backend->stopPreview(); } - // Report whether cameras are isolated in agent processes - bool CameraManager::usesAgentBackend() const + // Report whether cameras are isolated in DriverHost processes + bool CameraManager::usesDriverHostBackend() const { - return m_backend && m_backend->kind() == CameraBackend::Kind::Agent; + return m_backend && m_backend->kind() == CameraBackend::Kind::DriverHost; } bool CameraManager::startPreviewFor(const QString& cameraId) @@ -203,7 +224,7 @@ namespace scopeone::core::internal return !normalizedId.isEmpty() && m_backend && m_backend->isPreviewRunning(normalizedId); } - void CameraManager::setFrameDeliveryPaused(bool paused) + void CameraManager::setFrameDeliveryPaused(const QStringList&, bool paused) { if (m_backend) { @@ -212,7 +233,7 @@ namespace scopeone::core::internal } // Switches the active backend between preview and lossless recording delivery - bool CameraManager::setRecordingFrameDeliveryEnabled(bool enabled) + bool CameraManager::setRecordingFrameDeliveryEnabled(const QStringList&, bool enabled) { const bool ok = !m_backend || m_backend->setRecordingFrameDeliveryEnabled(enabled); if (ok || !enabled) @@ -223,7 +244,7 @@ namespace scopeone::core::internal } // Switches processing delivery independently of preview display rate - bool CameraManager::setHighRateFrameDeliveryEnabled(bool enabled) + bool CameraManager::setHighRateFrameDeliveryEnabled(const QStringList&, bool enabled) { const bool ok = !m_backend || m_backend->setHighRateFrameDeliveryEnabled(enabled); if (ok) diff --git a/ScopeOneCore/src/DaqDeviceManager.cpp b/ScopeOneCore/src/DaqDeviceManager.cpp new file mode 100644 index 0000000..763173f --- /dev/null +++ b/ScopeOneCore/src/DaqDeviceManager.cpp @@ -0,0 +1,271 @@ +#include "internal/DaqDeviceManager.h" +#include "scopeone/PluginManifest.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace scopeone::core +{ + DaqController::DaqController(QObject* parent) + : QObject(parent) + { + } + + DaqController::~DaqController() = default; +} + +namespace scopeone::core::internal +{ + namespace + { + void expandRasterScan(const DaqRasterScanConfig& scan, + DaqSessionConfig& session) + { + const quint64 frameLines = static_cast(scan.activeLines) + + static_cast(scan.flybackLines); + const QString name = scan.name.trimmed().isEmpty() + ? QStringLiteral("Raster scan") + : scan.name.trimmed(); + + if (!scan.lineOutputTerminal.trimmed().isEmpty()) + { + session.routes.append({scan.lineClock, + scan.lineOutputTerminal, + false}); + } + + DaqPulseTaskConfig framePulse; + framePulse.name = name + QStringLiteral(" frame clock"); + framePulse.counter = scan.frameCounter; + framePulse.outputTerminal = scan.frameOutputTerminal; + framePulse.timebaseSource = scan.lineClock; + framePulse.lowTicks = static_cast(frameLines - 2); + framePulse.highTicks = 2; + session.pulseTasks.append(framePulse); + + DaqAnalogTaskConfig slowAxis; + slowAxis.name = name + QStringLiteral(" slow axis"); + slowAxis.direction = DaqTaskDirection::Output; + slowAxis.channels = {scan.yChannel}; + slowAxis.minimumVolts = std::min(scan.yStartVolts, scan.yEndVolts); + slowAxis.maximumVolts = std::max(scan.yStartVolts, scan.yEndVolts); + slowAxis.timing.sampleClock = scan.lineClock; + slowAxis.timing.sampleRateHz = scan.nominalLineRateHz; + slowAxis.timing.sampleEdge = DaqEdge::Falling; + slowAxis.timing.sampleMode = DaqSampleMode::Continuous; + slowAxis.timing.samplesPerChannel = frameLines; + slowAxis.timing.startTrigger = scan.frameOutputTerminal; + slowAxis.outputSamplesByScan.reserve(static_cast(frameLines)); + + for (quint32 line = 0; line < scan.activeLines; ++line) + { + const double t = static_cast(line) + / static_cast(scan.activeLines - 1); + slowAxis.outputSamplesByScan.append( + scan.yStartVolts + (scan.yEndVolts - scan.yStartVolts) * t); + } + for (quint32 line = 0; line < scan.flybackLines; ++line) + { + const double t = static_cast(line + 1) + / static_cast(scan.flybackLines); + const double smooth = t * t * (3.0 - 2.0 * t); + slowAxis.outputSamplesByScan.append( + scan.yEndVolts + (scan.yStartVolts - scan.yEndVolts) * smooth); + } + session.analogTasks.append(std::move(slowAxis)); + } + } + + DaqDeviceManager::DaqDeviceManager(QObject* parent) + : QObject(parent) + { + } + + DaqDeviceManager::~DaqDeviceManager() + { + for (const QPointer& controller : std::as_const(m_controllers)) + { + delete controller.data(); + } + m_controllers.clear(); + m_loaders.clear(); + } + + QList DaqDeviceManager::devices() const + { + if (!m_pluginsLoaded) + { + const_cast(this)->loadPlugins(); + } + QList result = m_descriptors.values(); + std::sort(result.begin(), result.end(), + [](const DaqDeviceDescriptor& left, + const DaqDeviceDescriptor& right) + { + return left.name.compare(right.name, Qt::CaseInsensitive) < 0; + }); + return result; + } + + bool DaqDeviceManager::start(const DaqSessionConfig& config, + QString* errorMessage) + { + if (!m_pluginsLoaded) + { + loadPlugins(); + } + const QString deviceId = config.deviceId.trimmed(); + if (!m_descriptors.contains(deviceId)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Unknown DAQ device: %1").arg(deviceId); + } + return false; + } + DaqSessionConfig expanded = config; + expanded.deviceId = deviceId; + expanded.rasterScans.clear(); + for (const DaqRasterScanConfig& scan : config.rasterScans) + { + expandRasterScan(scan, expanded); + } + DaqController* deviceController = controller(deviceId, errorMessage); + return deviceController && deviceController->start(expanded, errorMessage); + } + + void DaqDeviceManager::stop(const QString& deviceId) + { + if (DaqController* deviceController = m_controllers.value(deviceId.trimmed())) + { + deviceController->stop(); + } + } + + DaqState DaqDeviceManager::state(const QString& deviceId) const + { + const QString id = deviceId.trimmed(); + const QPointer controller = m_controllers.value(id); + return controller ? controller->state() : m_states.value(id, DaqState::Idle); + } + + QString DaqDeviceManager::stateMessage(const QString& deviceId) const + { + const QString id = deviceId.trimmed(); + const QPointer controller = m_controllers.value(id); + return controller ? controller->stateMessage() + : m_messages.value(id, QStringLiteral("DAQ device is idle")); + } + + void DaqDeviceManager::loadPlugins() + { + m_pluginsLoaded = true; + const QStringList directories = { + QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("plugins/hardware")), + QDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)) + .filePath(QStringLiteral("plugins/hardware"))}; + + for (const QString& path : directories) + { + const QDir directory(path); + for (const QFileInfo& file : directory.entryInfoList(QDir::Files, QDir::Name)) + { + if (!QLibrary::isLibrary(file.absoluteFilePath())) + { + continue; + } + auto loader = std::make_unique(file.absoluteFilePath()); + PluginManifest manifest; + QString manifestError; + if (!parsePluginManifest( + loader->metaData().value(QStringLiteral("MetaData")).toObject(), + PluginKind::Hardware, + manifest, + &manifestError)) + { + qWarning().noquote() + << QStringLiteral("Failed to load DAQ plugin %1: %2") + .arg(file.fileName(), manifestError); + continue; + } + auto* plugin = qobject_cast(loader->instance()); + if (!plugin) + { + qWarning().noquote() + << QStringLiteral("Failed to load DAQ plugin %1: %2") + .arg(file.fileName(), loader->errorString()); + continue; + } + for (const DaqDeviceDescriptor& descriptor : plugin->devices()) + { + const QString deviceId = descriptor.id.trimmed(); + if (deviceId.isEmpty() || m_descriptors.contains(deviceId)) + { + continue; + } + m_descriptors.insert(deviceId, descriptor); + m_plugins.insert(deviceId, plugin); + m_states.insert(deviceId, DaqState::Idle); + m_messages.insert(deviceId, QStringLiteral("DAQ device is idle")); + } + m_loaders.push_back(std::move(loader)); + } + } + } + + DaqController* DaqDeviceManager::controller(const QString& deviceId, + QString* errorMessage) + { + const QString id = deviceId.trimmed(); + if (DaqController* existing = m_controllers.value(id)) + { + return existing; + } + DaqDevicePlugin* plugin = m_plugins.value(id); + if (!plugin) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Unknown DAQ device: %1").arg(id); + } + return nullptr; + } + DaqController* created = plugin->createController(id, this); + if (!created) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Failed to create DAQ controller: %1").arg(id); + } + return nullptr; + } + + connect(created, &DaqController::stateChanged, + this, [this, id](DaqState state, const QString& message) + { + m_states.insert(id, state); + m_messages.insert(id, message); + emit stateChanged(id, state, message); + }); + connect(created, &DaqController::controllerError, + this, [this, id](const QString& message) + { + emit deviceError(id, message); + }); + connect(created, &DaqController::inputDataReady, + this, &DaqDeviceManager::inputDataReady); + m_controllers.insert(id, created); + return created; + } +} diff --git a/ScopeOneCore/src/DifferentialRollingModule.cpp b/ScopeOneCore/src/DifferentialRollingModule.cpp index 4170c4c..f31f992 100644 --- a/ScopeOneCore/src/DifferentialRollingModule.cpp +++ b/ScopeOneCore/src/DifferentialRollingModule.cpp @@ -212,7 +212,7 @@ namespace scopeone::core::internal { if (!frame.isValid()) { - return {{}, QStringLiteral("Invalid input")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid input")); } try @@ -220,7 +220,7 @@ namespace scopeone::core::internal ImageFrame workingFrame; if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth)) { - return {{}, QStringLiteral("Unsupported input frame")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported input frame")); } const bool incompatibleBuffers = (!m_state.batchA.empty() && !m_state.batchA.front().isCompatibleWith( @@ -279,7 +279,7 @@ namespace scopeone::core::internal } catch (const std::exception& e) { - return {{}, QString("Differential rolling failed: %1").arg(e.what())}; + return ProcessingResult(ImageFrame{}, QString("Differential rolling failed: %1").arg(e.what())); } } diff --git a/ScopeOneCore/src/AgentCameraBackend.cpp b/ScopeOneCore/src/DriverHostCameraBackend.cpp similarity index 79% rename from ScopeOneCore/src/AgentCameraBackend.cpp rename to ScopeOneCore/src/DriverHostCameraBackend.cpp index 0f27673..b0f3100 100644 --- a/ScopeOneCore/src/AgentCameraBackend.cpp +++ b/ScopeOneCore/src/DriverHostCameraBackend.cpp @@ -1,5 +1,6 @@ #include "internal/CameraBackend.h" -#include "internal/AgentProtocol.h" +#include "internal/DriverHostProtocol.h" +#include "internal/SharedFrameRing.h" #include "scopeone/SharedFrame.h" #include @@ -30,9 +31,6 @@ namespace scopeone::core::internal { - static_assert(std::atomic_ref::is_always_lock_free, - "Shared frame state requires lock-free 32-bit atomics"); - using scopeone::core::ImageFrame; using scopeone::core::SharedFrameHeader; using scopeone::core::SharedMemoryControl; @@ -45,10 +43,10 @@ namespace scopeone::core::internal namespace { - constexpr int kAgentControlReadyTimeoutMs = 15000; + constexpr int kDriverHostControlReadyTimeoutMs = 15000; constexpr int kPreviewFrameDeliveryIntervalMs = 16; - enum class AgentFrameDeliveryMode + enum class DriverHostFrameDeliveryMode { PreviewLatest, LatestOnly, @@ -61,30 +59,30 @@ namespace scopeone::core::internal return cameraId.trimmed(); } - const QString& frameDeliveryModeName(AgentFrameDeliveryMode mode) + const QString& frameDeliveryModeName(DriverHostFrameDeliveryMode mode) { switch (mode) { - case AgentFrameDeliveryMode::PreviewLatest: - return agent::kFrameDeliveryModePreviewLatest; - case AgentFrameDeliveryMode::LatestOnly: - return agent::kFrameDeliveryModeLatestOnly; - case AgentFrameDeliveryMode::AllFrames: - return agent::kFrameDeliveryModeAllFrames; + case DriverHostFrameDeliveryMode::PreviewLatest: + return driverhost::kFrameDeliveryModePreviewLatest; + case DriverHostFrameDeliveryMode::LatestOnly: + return driverhost::kFrameDeliveryModeLatestOnly; + case DriverHostFrameDeliveryMode::AllFrames: + return driverhost::kFrameDeliveryModeAllFrames; } - return agent::kFrameDeliveryModePreviewLatest; + return driverhost::kFrameDeliveryModePreviewLatest; } - AgentFrameDeliveryMode nonRecordingDeliveryMode(bool highRate) + DriverHostFrameDeliveryMode nonRecordingDeliveryMode(bool highRate) { return highRate - ? AgentFrameDeliveryMode::LatestOnly - : AgentFrameDeliveryMode::PreviewLatest; + ? DriverHostFrameDeliveryMode::LatestOnly + : DriverHostFrameDeliveryMode::PreviewLatest; } } // namespace - struct AgentControlSession final : QObject + struct DriverHostControlSession final : QObject { struct PendingRequest { @@ -93,10 +91,12 @@ namespace scopeone::core::internal std::function completion; }; - explicit AgentControlSession(const QString& cameraId, - const QString& serverName, - QObject* parent = nullptr) + explicit DriverHostControlSession(const QString& providerId, + const QString& cameraId, + const QString& serverName, + QObject* parent = nullptr) : QObject(parent) + , m_providerId(providerId) , m_cameraId(cameraId) , m_serverName(serverName) { @@ -208,7 +208,7 @@ namespace scopeone::core::internal int timeoutMs, std::function completion) { - const QString type = request.value(agent::kMessageTypeField).toString(); + const QString type = request.value(driverhost::kMessageTypeField).toString(); if (type.isEmpty()) { return false; @@ -216,12 +216,12 @@ namespace scopeone::core::internal const quint64 requestId = m_nextRequestId++; QJsonObject envelope = request; - envelope.insert(agent::kEnvelopeKindField, agent::kMessageKindRequest); - envelope.insert(agent::kEnvelopeVersionField, static_cast(agent::kProtocolVersion)); - envelope.insert(agent::kEnvelopeRequestIdField, agent::encodeUInt64(requestId)); + envelope.insert(driverhost::kEnvelopeKindField, driverhost::kMessageKindRequest); + envelope.insert(driverhost::kEnvelopeVersionField, static_cast(driverhost::kProtocolVersion)); + envelope.insert(driverhost::kEnvelopeRequestIdField, driverhost::encodeUInt64(requestId)); PendingRequest pending; - pending.encoded = agent::encodeMessage(envelope); + pending.encoded = driverhost::encodeMessage(envelope); pending.completion = std::move(completion); pending.timer = new QTimer(this); pending.timer->setSingleShot(true); @@ -295,30 +295,30 @@ namespace scopeone::core::internal { QJsonObject message; QString error; - const agent::DecodeResult result = - agent::tryDecodeMessage(m_readBuffer, message, &error); - if (result == agent::DecodeResult::Incomplete) + const driverhost::DecodeResult result = + driverhost::tryDecodeMessage(m_readBuffer, message, &error); + if (result == driverhost::DecodeResult::Incomplete) { return; } - if (result == agent::DecodeResult::Error) + if (result == driverhost::DecodeResult::Error) { resetWithError(error); return; } - if (message.value(agent::kEnvelopeVersionField).toInt(0) - != static_cast(agent::kProtocolVersion)) + if (message.value(driverhost::kEnvelopeVersionField).toInt(0) + != static_cast(driverhost::kProtocolVersion)) { resetWithError(QStringLiteral("Control protocol version mismatch")); return; } - const QString kind = message.value(agent::kEnvelopeKindField).toString(); - if (kind == agent::kMessageKindResponse) + const QString kind = message.value(driverhost::kEnvelopeKindField).toString(); + if (kind == driverhost::kMessageKindResponse) { const quint64 requestId = - agent::decodeUInt64(message.value(agent::kEnvelopeRequestIdField)); + driverhost::decodeUInt64(message.value(driverhost::kEnvelopeRequestIdField)); if (requestId == 0) { continue; @@ -327,11 +327,21 @@ namespace scopeone::core::internal continue; } - if (kind == agent::kMessageKindEvent) + if (kind == driverhost::kMessageKindEvent) { - const QString type = message.value(agent::kMessageTypeField).toString(); - if (type == agent::kEventHello) + const QString type = message.value(driverhost::kMessageTypeField).toString(); + if (type == driverhost::kEventHello) { + const QJsonArray capabilities = + message.value(driverhost::kCapabilitiesField).toArray(); + if (message.value(driverhost::kProviderIdField).toString() + != m_providerId + || message.value(driverhost::kDeviceIdField).toString() != m_cameraId + || !capabilities.contains(driverhost::kCapabilityCamera)) + { + resetWithError(QStringLiteral("DriverHost identity mismatch")); + return; + } setReady(true); } for (const auto& handler : m_eventHandlers) @@ -392,6 +402,7 @@ namespace scopeone::core::internal } } + QString m_providerId; QString m_cameraId; QString m_serverName; QLocalSocket m_socket; @@ -406,12 +417,12 @@ namespace scopeone::core::internal bool m_closing{false}; }; - class AgentCameraBackend; + class DriverHostCameraBackend; - class AgentFrameWorker final : public QObject + class DriverHostFrameWorker final : public QObject { public: - explicit AgentFrameWorker(AgentCameraBackend* owner) + explicit DriverHostFrameWorker(DriverHostCameraBackend* owner) : m_owner(owner) { } @@ -448,36 +459,36 @@ namespace scopeone::core::internal QList& frames, quint64& acquiredFrameCount); - AgentCameraBackend* const m_owner; + DriverHostCameraBackend* const m_owner; QMap> m_readers; }; - struct AgentCameraSlot + struct DriverHostCameraSlot { QString cameraId; QString shmKey; std::shared_ptr process; - std::shared_ptr control; + std::shared_ptr control; bool isRunning{false}; double exposureMs{10.0}; bool frameReadQueued{false}; bool frameReadRequested{false}; }; - class AgentCameraBackend final : public CameraBackend + class DriverHostCameraBackend final : public CameraBackend { public: - explicit AgentCameraBackend(ProcessingFrameGate& processingFrameGate); - ~AgentCameraBackend() override; + explicit DriverHostCameraBackend(ProcessingFrameGate& processingFrameGate); + ~DriverHostCameraBackend() override; - Kind kind() const override { return Kind::Agent; } - bool addAgentCamera(const QString& cameraId, + Kind kind() const override { return Kind::DriverHost; } + bool addDriverHostCamera(const QString& cameraId, const QString& adapter, const QString& device, const QStringList& preInitProperties, const QStringList& properties, double exposureMs) override; - void removeAgentCamera(const QString& cameraId); + void removeDriverHostCamera(const QString& cameraId); bool isPreviewRunning(const QString& cameraId) const override { const auto it = m_cameras.constFind(cameraId); @@ -518,7 +529,7 @@ namespace scopeone::core::internal } startedCameraIds.append(cameraId); } - qInfo().noquote() << "Agent preview started"; + qInfo().noquote() << "DriverHost preview started"; return true; } @@ -531,7 +542,7 @@ namespace scopeone::core::internal } if (ok) { - qInfo().noquote() << "Agent preview stopped"; + qInfo().noquote() << "DriverHost preview stopped"; } return ok; } @@ -544,18 +555,18 @@ namespace scopeone::core::internal return false; } - AgentCameraSlot& slot = *m_cameras[cameraId]; + DriverHostCameraSlot& slot = *m_cameras[cameraId]; if (slot.isRunning) { return true; } - AgentFrameWorker* const worker = m_frameWorker; + DriverHostFrameWorker* const worker = m_frameWorker; QMetaObject::invokeMethod( worker, [worker, cameraId]() { worker->resetPreviewDeliveryState(cameraId); }, Qt::BlockingQueuedConnection); QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandStartPreview); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandStartPreview); QJsonObject resp; if (!sendControlCommand(cameraId, req, &resp, 1200)) { @@ -564,7 +575,7 @@ namespace scopeone::core::internal } if (!resp.value(QStringLiteral("ok")).toBool(false)) { - qWarning().noquote() << QString("Agent refused to start preview for '%1'").arg(cameraId); + qWarning().noquote() << QString("DriverHost refused to start preview for '%1'").arg(cameraId); return false; } @@ -581,7 +592,7 @@ namespace scopeone::core::internal { qWarning().noquote() << QString("Shared memory unavailable for '%1'").arg(cameraId); QJsonObject stopRequest; - stopRequest.insert(agent::kMessageTypeField, agent::kCommandStopPreview); + stopRequest.insert(driverhost::kMessageTypeField, driverhost::kCommandStopPreview); sendControlCommand(cameraId, stopRequest, nullptr, 1200); return false; } @@ -604,7 +615,7 @@ namespace scopeone::core::internal } QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandStopPreview); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandStopPreview); QJsonObject resp; if (!sendControlCommand(cameraId, req, &resp, 1200)) { @@ -667,19 +678,22 @@ namespace scopeone::core::internal bool readExposureFor(const QString& cameraId, double& exposureMs) const override { - const auto it = m_cameras.constFind(cameraId); - if (it == m_cameras.constEnd() || !it.value()) + QJsonObject request; + request.insert(driverhost::kMessageTypeField, driverhost::kCommandGetExposure); + QJsonObject response; + if (!sendControlCommand(cameraId, request, &response, 1200) + || !response.value(QStringLiteral("ok")).toBool(false)) { return false; } - exposureMs = it.value()->exposureMs; + exposureMs = response.value(QStringLiteral("exposureMs")).toDouble(0.0); return exposureMs > 0.0; } bool writeExposureFor(const QString& cameraId, double exposureMs) override { QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandSetExposure); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandSetExposure); req.insert(QStringLiteral("value"), exposureMs); QJsonObject resp; if (!sendControlCommand(cameraId, req, &resp, 1200) @@ -696,7 +710,7 @@ namespace scopeone::core::internal { QStringList out; QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandListProperties); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandListProperties); QJsonObject resp; if (!sendControlCommand(cameraId, req, &resp, 4000)) { @@ -720,7 +734,7 @@ namespace scopeone::core::internal CameraPropertyReadback& readback) override { QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandGetProperty); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandGetProperty); req.insert(QStringLiteral("name"), name); req.insert(QStringLiteral("fromCache"), fromCache); QJsonObject resp; @@ -755,7 +769,7 @@ namespace scopeone::core::internal QString* errorMessage) override { QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandSetProperty); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandSetProperty); req.insert(QStringLiteral("name"), name); req.insert(QStringLiteral("value"), value); QJsonObject resp; @@ -763,7 +777,7 @@ namespace scopeone::core::internal { if (errorMessage) { - *errorMessage = QStringLiteral("Agent control request failed"); + *errorMessage = QStringLiteral("DriverHost control request failed"); } return false; } @@ -781,7 +795,7 @@ namespace scopeone::core::internal bool setROIFor(const QString& cameraId, int x, int y, int width, int height) override { QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandSetRoi); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandSetRoi); req.insert(QStringLiteral("x"), x); req.insert(QStringLiteral("y"), y); req.insert(QStringLiteral("width"), width); @@ -804,7 +818,7 @@ namespace scopeone::core::internal bool clearROIFor(const QString& cameraId) override { QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandClearRoi); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandClearRoi); QJsonObject resp; if (!sendControlCommand(cameraId, req, &resp, 1200)) { @@ -823,7 +837,7 @@ namespace scopeone::core::internal bool getROIFor(const QString& cameraId, int& x, int& y, int& width, int& height) override { QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandGetRoi); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandGetRoi); QJsonObject resp; if (!sendControlCommand(cameraId, req, &resp, 1200)) { @@ -845,9 +859,9 @@ namespace scopeone::core::internal return true; } private: - friend class AgentFrameWorker; + friend class DriverHostFrameWorker; - bool waitForControlReady(AgentCameraSlot& slot, int timeoutMs); + bool waitForControlReady(DriverHostCameraSlot& slot, int timeoutMs); bool addFrameReader(const QString& cameraId, const QString& shmKey); void removeFrameReader(const QString& cameraId); bool prepareFrameReader(const QString& cameraId); @@ -857,13 +871,13 @@ namespace scopeone::core::internal bool sendControlCommand(const QString& cameraId, const QJsonObject& request, QJsonObject* response, - int timeoutMs); + int timeoutMs) const; bool sendFrameDeliveryModes(const QStringList& cameraIds, - AgentFrameDeliveryMode mode); - void stopAgentProcessesAsync(); + DriverHostFrameDeliveryMode mode); + void stopDriverHostProcessesAsync(); void completeAsyncShutdown(); - QMap> m_cameras; + QMap> m_cameras; std::atomic_bool m_frameDeliveryPaused{false}; bool m_shuttingDown{false}; bool m_shutdownHadRunningCamera{false}; @@ -872,97 +886,21 @@ namespace scopeone::core::internal QString m_shutdownError; std::function m_shutdownCompletion; QThread m_frameThread; - AgentFrameWorker* m_frameWorker{nullptr}; + DriverHostFrameWorker* m_frameWorker{nullptr}; }; - // Returns payload byte count from a shared frame header - static quint64 sharedFramePayloadSize(const SharedFrameHeader& header) - { - return static_cast(header.stride) * header.height; - } - - // Validates one shared frame header before reading pixels - static bool headerLooksSane(const SharedFrameHeader& header) - { - if (header.channels != 1) return false; - if (header.width == 0 || header.height == 0) return false; - if (header.stride == 0) return false; - if (header.pixelFormat != static_cast(SharedPixelFormat::Mono8) && - header.pixelFormat != static_cast(SharedPixelFormat::Mono16)) - { - return false; - } - - const quint32 bytesPerPixel = - (header.pixelFormat == static_cast(SharedPixelFormat::Mono16)) ? 2u : 1u; - if (header.pixelFormat == static_cast(SharedPixelFormat::Mono8) - && header.bitsPerSample != 8) - { - return false; - } - if (header.pixelFormat == static_cast(SharedPixelFormat::Mono16) - && (header.bitsPerSample == 0 || header.bitsPerSample > 16)) - { - return false; - } - const quint64 minimumStride = static_cast(header.width) * bytesPerPixel; - if (minimumStride > static_cast((std::numeric_limits::max)())) return false; - if (header.width > static_cast((std::numeric_limits::max)())) return false; - if (header.height > static_cast((std::numeric_limits::max)())) return false; - if (header.stride > static_cast((std::numeric_limits::max)())) return false; - if (header.stride < minimumStride) return false; - if ((header.stride % bytesPerPixel) != 0) return false; - - const quint64 rawSize = sharedFramePayloadSize(header); - if (rawSize == 0 || rawSize > static_cast(kSharedFrameMaxBytes)) return false; - - return true; - } - - // Claims one ready ring slot so the producer cannot overwrite it while copying - static bool claimFrameSlot(uchar* slotPtr, SharedFrameHeader& header) - { - auto& stateValue = *reinterpret_cast(slotPtr); - std::atomic_ref state(stateValue); - quint32 expected = 2; - if (!state.compare_exchange_strong(expected, - 3, - std::memory_order_acq_rel, - std::memory_order_acquire)) - { - return false; - } - - memcpy(&header, slotPtr, sizeof(header)); - header.state = 2; - if (headerLooksSane(header)) - { - return true; - } - - state.store(2, std::memory_order_release); - return false; - } - - // Releases one claimed ring slot back to the producer - static void releaseFrameSlot(uchar* slotPtr) - { - auto& stateValue = *reinterpret_cast(slotPtr); - std::atomic_ref(stateValue).store(2, std::memory_order_release); - } - - AgentCameraBackend::AgentCameraBackend(ProcessingFrameGate& processingFrameGate) + DriverHostCameraBackend::DriverHostCameraBackend(ProcessingFrameGate& processingFrameGate) : CameraBackend(processingFrameGate) { - m_frameThread.setObjectName(QStringLiteral("ScopeOneAgentFrameReader")); - m_frameWorker = new AgentFrameWorker(this); + m_frameThread.setObjectName(QStringLiteral("ScopeOneDriverHostFrameReader")); + m_frameWorker = new DriverHostFrameWorker(this); m_frameWorker->moveToThread(&m_frameThread); QObject::connect(&m_frameThread, &QThread::finished, m_frameWorker, &QObject::deleteLater); m_frameThread.start(); } - AgentCameraBackend::~AgentCameraBackend() + DriverHostCameraBackend::~DriverHostCameraBackend() { shutdownNow(); m_frameThread.quit(); @@ -970,7 +908,7 @@ namespace scopeone::core::internal m_frameWorker = nullptr; } - void AgentCameraBackend::shutdownNow() + void DriverHostCameraBackend::shutdownNow() { if (m_shuttingDown && m_cameras.isEmpty()) { @@ -987,7 +925,7 @@ namespace scopeone::core::internal } if (m_frameWorker && m_frameThread.isRunning()) { - AgentFrameWorker* const worker = m_frameWorker; + DriverHostFrameWorker* const worker = m_frameWorker; QMetaObject::invokeMethod(worker, [worker]() { worker->clear(); }, Qt::BlockingQueuedConnection); @@ -997,7 +935,7 @@ namespace scopeone::core::internal if (slot->control) { QJsonObject request; - request.insert(agent::kMessageTypeField, agent::kCommandShutdown); + request.insert(driverhost::kMessageTypeField, driverhost::kCommandShutdown); sendControlCommand(slot->cameraId, request, nullptr, 800); slot->control->stop(); } @@ -1019,8 +957,8 @@ namespace scopeone::core::internal } } - // Stops agent processes without waiting on the facade thread - void AgentCameraBackend::shutdown(std::function completion) + // Stop DriverHost processes without waiting on the facade thread + void DriverHostCameraBackend::shutdown(std::function completion) { if (m_shuttingDown) { @@ -1038,7 +976,7 @@ namespace scopeone::core::internal completion(m_cameras.isEmpty() ? QString() : m_shutdownError.isEmpty() - ? QStringLiteral("Camera agent shutdown is already in progress") + ? QStringLiteral("Camera DriverHost shutdown is already in progress") : m_shutdownError); } return; @@ -1056,8 +994,8 @@ namespace scopeone::core::internal } if (m_frameWorker && m_frameThread.isRunning()) { - AgentFrameWorker* const worker = m_frameWorker; - const QPointer guardedThis(this); + DriverHostFrameWorker* const worker = m_frameWorker; + const QPointer guardedThis(this); const bool queued = QMetaObject::invokeMethod( worker, [guardedThis, worker]() @@ -1071,7 +1009,7 @@ namespace scopeone::core::internal { if (guardedThis) { - guardedThis->stopAgentProcessesAsync(); + guardedThis->stopDriverHostProcessesAsync(); } }, Qt::QueuedConnection); @@ -1084,11 +1022,11 @@ namespace scopeone::core::internal } } - stopAgentProcessesAsync(); + stopDriverHostProcessesAsync(); } - // Requests agent exit and falls back to bounded process termination - void AgentCameraBackend::stopAgentProcessesAsync() + // Request DriverHost exit and fall back to bounded process termination + void DriverHostCameraBackend::stopDriverHostProcessesAsync() { if (!m_shutdownCompletion) { @@ -1131,7 +1069,7 @@ namespace scopeone::core::internal if (slot->control) { QJsonObject request; - request.insert(agent::kMessageTypeField, agent::kCommandShutdown); + request.insert(driverhost::kMessageTypeField, driverhost::kCommandShutdown); const QPointer guardedProcess(process); shutdownQueued = slot->control->sendRequest( request, @@ -1176,8 +1114,8 @@ namespace scopeone::core::internal }); } - // Releases agent state and completes the owning manager callback - void AgentCameraBackend::completeAsyncShutdown() + // Release DriverHost state and complete the owning manager callback + void DriverHostCameraBackend::completeAsyncShutdown() { if (!m_shutdownCompletion && m_shutdownError.isEmpty()) { @@ -1193,7 +1131,7 @@ namespace scopeone::core::internal } if (m_shutdownKillPollsRemaining <= 0) { - m_shutdownError = QStringLiteral("Camera agent process did not stop after termination"); + m_shutdownError = QStringLiteral("Camera DriverHost process did not stop after termination"); auto completion = std::move(m_shutdownCompletion); completion(m_shutdownError); return; @@ -1222,7 +1160,7 @@ namespace scopeone::core::internal } // Registers one camera shared memory reader on the worker thread - void AgentFrameWorker::addCamera(const QString& cameraId, const QString& shmKey) + void DriverHostFrameWorker::addCamera(const QString& cameraId, const QString& shmKey) { removeCamera(cameraId); auto slot = std::make_shared(); @@ -1234,19 +1172,19 @@ namespace scopeone::core::internal } // Removes one camera reader and releases its shared memory mapping - void AgentFrameWorker::removeCamera(const QString& cameraId) + void DriverHostFrameWorker::removeCamera(const QString& cameraId) { m_readers.remove(cameraId); } // Releases every shared memory reader owned by the worker - void AgentFrameWorker::clear() + void DriverHostFrameWorker::clear() { m_readers.clear(); } - // Resets preview coalescing after an agent delivery mode change - void AgentFrameWorker::resetPreviewDeliveryState(const QString& cameraId) + // Reset preview coalescing after a DriverHost delivery mode change + void DriverHostFrameWorker::resetPreviewDeliveryState(const QString& cameraId) { const auto it = m_readers.find(cameraId); if (it == m_readers.end()) @@ -1258,14 +1196,14 @@ namespace scopeone::core::internal } // Attaches the named camera reader when its mapping is unavailable - bool AgentFrameWorker::ensureSharedMemory(const QString& cameraId) + bool DriverHostFrameWorker::ensureSharedMemory(const QString& cameraId) { const auto it = m_readers.find(cameraId); return it != m_readers.end() && ensureSharedMemory(*it.value()); } // Validates and attaches one shared memory reader slot - bool AgentFrameWorker::ensureSharedMemory(ReaderSlot& slot) + bool DriverHostFrameWorker::ensureSharedMemory(ReaderSlot& slot) { const int expectedSize = kSharedMemoryControlSize + kSharedFrameNumSlots * kSharedFrameSlotStride; @@ -1298,20 +1236,20 @@ namespace scopeone::core::internal return true; } - // Waits for one agent control channel to become ready - bool AgentCameraBackend::waitForControlReady(AgentCameraSlot& slot, int timeoutMs) + // Wait for one DriverHost control channel to become ready + bool DriverHostCameraBackend::waitForControlReady(DriverHostCameraSlot& slot, int timeoutMs) { return slot.control && slot.control->waitForReady(timeoutMs); } // Creates one shared memory reader on the dedicated frame thread - bool AgentCameraBackend::addFrameReader(const QString& cameraId, const QString& shmKey) + bool DriverHostCameraBackend::addFrameReader(const QString& cameraId, const QString& shmKey) { if (!m_frameWorker || !m_frameThread.isRunning()) { return false; } - AgentFrameWorker* const worker = m_frameWorker; + DriverHostFrameWorker* const worker = m_frameWorker; return QMetaObject::invokeMethod( worker, [worker, cameraId, shmKey]() { worker->addCamera(cameraId, shmKey); }, @@ -1319,11 +1257,11 @@ namespace scopeone::core::internal } // Removes one shared memory reader on the dedicated frame thread - void AgentCameraBackend::removeFrameReader(const QString& cameraId) + void DriverHostCameraBackend::removeFrameReader(const QString& cameraId) { if (m_frameWorker && m_frameThread.isRunning()) { - AgentFrameWorker* const worker = m_frameWorker; + DriverHostFrameWorker* const worker = m_frameWorker; QMetaObject::invokeMethod( worker, [worker, cameraId]() { worker->removeCamera(cameraId); }, @@ -1332,14 +1270,14 @@ namespace scopeone::core::internal } // Attaches one reader before preview begins - bool AgentCameraBackend::prepareFrameReader(const QString& cameraId) + bool DriverHostCameraBackend::prepareFrameReader(const QString& cameraId) { if (!m_frameWorker || !m_frameThread.isRunning()) { return false; } bool attached = false; - AgentFrameWorker* const worker = m_frameWorker; + DriverHostFrameWorker* const worker = m_frameWorker; const bool invoked = QMetaObject::invokeMethod( worker, [worker, cameraId, &attached]() { attached = worker->ensureSharedMemory(cameraId); }, @@ -1347,11 +1285,11 @@ namespace scopeone::core::internal return invoked && attached; } - // Sends one JSON command to an agent control channel - bool AgentCameraBackend::sendControlCommand(const QString& cameraId, + // Send one JSON command to a DriverHost control channel + bool DriverHostCameraBackend::sendControlCommand(const QString& cameraId, const QJsonObject& request, QJsonObject* response, - int timeoutMs) + int timeoutMs) const { const QString normalizedId = normalizedCameraId(cameraId); const auto it = m_cameras.constFind(normalizedId); @@ -1412,12 +1350,12 @@ namespace scopeone::core::internal return true; } - // Switches multiple agent producers within one bounded wait - bool AgentCameraBackend::sendFrameDeliveryModes(const QStringList& cameraIds, - AgentFrameDeliveryMode mode) + // Switch multiple DriverHost producers within one bounded wait + bool DriverHostCameraBackend::sendFrameDeliveryModes(const QStringList& cameraIds, + DriverHostFrameDeliveryMode mode) { QJsonObject request; - request.insert(agent::kMessageTypeField, agent::kCommandSetFrameDeliveryMode); + request.insert(driverhost::kMessageTypeField, driverhost::kCommandSetFrameDeliveryMode); request.insert(QStringLiteral("mode"), frameDeliveryModeName(mode)); struct BatchState @@ -1471,12 +1409,12 @@ namespace scopeone::core::internal } // Copies one claimed shared memory slot into an owned frame - bool AgentFrameWorker::copyFrame(ReaderSlot& slot, + bool DriverHostFrameWorker::copyFrame(ReaderSlot& slot, const SharedFrameHeader& header, const uchar* pixelData, QList& frames) { - const quint64 rawSize = sharedFramePayloadSize(header); + const quint64 rawSize = sharedframe::payloadSize(header); QByteArray payload; payload.resize(static_cast(rawSize)); memcpy(payload.data(), pixelData, static_cast(rawSize)); @@ -1492,7 +1430,7 @@ namespace scopeone::core::internal } // Reads only the newest ready frame for responsive preview delivery - bool AgentFrameWorker::readLatestFrame(ReaderSlot& slot, + bool DriverHostFrameWorker::readLatestFrame(ReaderSlot& slot, QList& frames, quint64& acquiredFrameCount) { @@ -1515,10 +1453,10 @@ namespace scopeone::core::internal if (idx >= kSharedFrameNumSlots) return false; uchar* ptr = base + baseOffset + idx * slotStride; SharedFrameHeader header{}; - if (!claimFrameSlot(ptr, header)) return false; + if (!sharedframe::claimSlot(ptr, header)) return false; if (header.frameIndex <= slot.lastFrameIndex) { - releaseFrameSlot(ptr); + sharedframe::releaseSlot(ptr); return false; } capturedHeader = header; @@ -1537,12 +1475,12 @@ namespace scopeone::core::internal { uchar* ptr = base + baseOffset + i * slotStride; SharedFrameHeader header{}; - if (!claimFrameSlot(ptr, header)) continue; + if (!sharedframe::claimSlot(ptr, header)) continue; if (header.frameIndex > bestIndex) { if (capturedSlot) { - releaseFrameSlot(capturedSlot); + sharedframe::releaseSlot(capturedSlot); } bestIndex = header.frameIndex; capturedHeader = header; @@ -1550,7 +1488,7 @@ namespace scopeone::core::internal } else { - releaseFrameSlot(ptr); + sharedframe::releaseSlot(ptr); } } } @@ -1562,7 +1500,7 @@ namespace scopeone::core::internal frames); if (capturedSlot) { - releaseFrameSlot(capturedSlot); + sharedframe::releaseSlot(capturedSlot); } if (ok) { @@ -1575,7 +1513,7 @@ namespace scopeone::core::internal } // Reads every retained shared memory frame in order for recording delivery - bool AgentFrameWorker::readAllFrames(ReaderSlot& slot, + bool DriverHostFrameWorker::readAllFrames(ReaderSlot& slot, QList& frames, quint64& acquiredFrameCount) { @@ -1601,14 +1539,14 @@ namespace scopeone::core::internal { uchar* ptr = base + baseOffset + i * slotStride; SharedFrameHeader header{}; - if (!claimFrameSlot(ptr, header)) continue; + if (!sharedframe::claimSlot(ptr, header)) continue; if (header.frameIndex > lastIndex) { claimedSlots.push_back({ptr, header}); } else { - releaseFrameSlot(ptr); + sharedframe::releaseSlot(ptr); } } @@ -1633,7 +1571,7 @@ namespace scopeone::core::internal { maxIndex = claimed.header.frameIndex; } - releaseFrameSlot(claimed.ptr); + sharedframe::releaseSlot(claimed.ptr); } if (frames.isEmpty()) { @@ -1649,7 +1587,7 @@ namespace scopeone::core::internal } // Reads frames on the worker thread and optionally publishes the batch - ImageFrame AgentFrameWorker::consumeFrames(const QString& cameraId, bool publishFrames) + ImageFrame DriverHostFrameWorker::consumeFrames(const QString& cameraId, bool publishFrames) { const auto it = m_readers.find(cameraId); if (it == m_readers.end()) @@ -1717,21 +1655,21 @@ namespace scopeone::core::internal return slot.latestFrame; } - // Consumes one agent batch and reports completion on the backend thread - void AgentFrameWorker::consumeFramesAsync(const QString& cameraId) + // Consume one DriverHost batch and report completion on the backend thread + void DriverHostFrameWorker::consumeFramesAsync(const QString& cameraId) { if (!m_owner->m_frameDeliveryPaused.load(std::memory_order_relaxed)) { consumeFrames(cameraId, true); } - AgentCameraBackend* const owner = m_owner; + DriverHostCameraBackend* const owner = m_owner; QMetaObject::invokeMethod(owner, [owner, cameraId]() { owner->completeFrameRead(cameraId); }, Qt::QueuedConnection); } // Reads the newest frame synchronously for event capture - ImageFrame AgentCameraBackend::consumeFrameNow(const QString& cameraId) + ImageFrame DriverHostCameraBackend::consumeFrameNow(const QString& cameraId) { if (!m_frameWorker || !m_frameThread.isRunning()) { @@ -1739,7 +1677,7 @@ namespace scopeone::core::internal } ImageFrame frame; - AgentFrameWorker* const worker = m_frameWorker; + DriverHostFrameWorker* const worker = m_frameWorker; const bool invoked = QMetaObject::invokeMethod( worker, [worker, cameraId, &frame]() { frame = worker->consumeFrames(cameraId, false); }, @@ -1748,14 +1686,14 @@ namespace scopeone::core::internal } // Coalesces frame notifications into one worker request per camera - void AgentCameraBackend::scheduleFrameRead(const QString& cameraId) + void DriverHostCameraBackend::scheduleFrameRead(const QString& cameraId) { const auto it = m_cameras.find(cameraId); if (it == m_cameras.end() || !it.value() || !m_frameWorker || m_shuttingDown) { return; } - AgentCameraSlot& slot = *it.value(); + DriverHostCameraSlot& slot = *it.value(); if (!slot.isRunning || m_frameDeliveryPaused.load(std::memory_order_relaxed)) { return; @@ -1767,7 +1705,7 @@ namespace scopeone::core::internal } slot.frameReadQueued = true; - AgentFrameWorker* const worker = m_frameWorker; + DriverHostFrameWorker* const worker = m_frameWorker; if (!QMetaObject::invokeMethod( worker, [worker, cameraId]() { worker->consumeFramesAsync(cameraId); }, @@ -1778,14 +1716,14 @@ namespace scopeone::core::internal } // Schedules one deferred read when notifications arrived during a request - void AgentCameraBackend::completeFrameRead(const QString& cameraId) + void DriverHostCameraBackend::completeFrameRead(const QString& cameraId) { const auto it = m_cameras.find(cameraId); if (it == m_cameras.end() || !it.value()) { return; } - AgentCameraSlot& slot = *it.value(); + DriverHostCameraSlot& slot = *it.value(); slot.frameReadQueued = false; if (!slot.frameReadRequested) { @@ -1797,7 +1735,7 @@ namespace scopeone::core::internal } // Triggers one camera and waits for its event frame - bool AgentCameraBackend::captureEventFrame(const QString& cameraId, + bool DriverHostCameraBackend::captureEventFrame(const QString& cameraId, ImageFrame& frame, int timeoutMs) { @@ -1815,7 +1753,7 @@ namespace scopeone::core::internal : 0; QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandCaptureEvent); + req.insert(driverhost::kMessageTypeField, driverhost::kCommandCaptureEvent); QJsonObject resp; const int waitMs = (timeoutMs > 0) ? timeoutMs : 1500; if (!sendControlCommand(normalizedId, req, &resp, waitMs + 1000)) @@ -1828,7 +1766,7 @@ namespace scopeone::core::internal } const quint64 targetFrameIndex = - agent::decodeUInt64(resp.value(QStringLiteral("frameIndex"))); + driverhost::decodeUInt64(resp.value(QStringLiteral("frameIndex"))); QElapsedTimer timer; timer.start(); @@ -1850,7 +1788,7 @@ namespace scopeone::core::internal } // Pauses or resumes polling while another workflow controls capture - void AgentCameraBackend::setFrameDeliveryPaused(bool paused) + void DriverHostCameraBackend::setFrameDeliveryPaused(bool paused) { if (m_frameDeliveryPaused.exchange(paused, std::memory_order_relaxed) == paused) { @@ -1868,15 +1806,15 @@ namespace scopeone::core::internal } } - // Switches agent producers before changing the local recording consumer mode - bool AgentCameraBackend::setRecordingFrameDeliveryEnabled(bool enabled) + // Switch DriverHost producers before changing the local recording consumer mode + bool DriverHostCameraBackend::setRecordingFrameDeliveryEnabled(bool enabled) { if (enabled && recordingFrameDeliveryEnabled()) { return true; } - const AgentFrameDeliveryMode previewMode = + const DriverHostFrameDeliveryMode previewMode = nonRecordingDeliveryMode(highRateFrameDeliveryEnabled()); if (!enabled) { @@ -1885,7 +1823,7 @@ namespace scopeone::core::internal } const QStringList cameraIds = m_cameras.keys(); - if (!sendFrameDeliveryModes(cameraIds, AgentFrameDeliveryMode::AllFrames)) + if (!sendFrameDeliveryModes(cameraIds, DriverHostFrameDeliveryMode::AllFrames)) { sendFrameDeliveryModes(cameraIds, previewMode); return false; @@ -1904,7 +1842,7 @@ namespace scopeone::core::internal } // Switches preview producers between display-rate and processing-rate delivery - bool AgentCameraBackend::setHighRateFrameDeliveryEnabled(bool enabled) + bool DriverHostCameraBackend::setHighRateFrameDeliveryEnabled(bool enabled) { if (highRateFrameDeliveryEnabled() == enabled) { @@ -1916,8 +1854,8 @@ namespace scopeone::core::internal return CameraBackend::setHighRateFrameDeliveryEnabled(enabled); } - const AgentFrameDeliveryMode targetMode = nonRecordingDeliveryMode(enabled); - const AgentFrameDeliveryMode rollbackMode = + const DriverHostFrameDeliveryMode targetMode = nonRecordingDeliveryMode(enabled); + const DriverHostFrameDeliveryMode rollbackMode = nonRecordingDeliveryMode(highRateFrameDeliveryEnabled()); const QStringList cameraIds = m_cameras.keys(); if (!sendFrameDeliveryModes(cameraIds, targetMode)) @@ -1928,8 +1866,8 @@ namespace scopeone::core::internal return CameraBackend::setHighRateFrameDeliveryEnabled(enabled); } - // Starts one camera agent process and connects its control channel - bool AgentCameraBackend::addAgentCamera(const QString& cameraId, + // Start one camera DriverHost process and connect its control channel + bool DriverHostCameraBackend::addDriverHostCamera(const QString& cameraId, const QString& adapter, const QString& device, const QStringList& preInitProperties, @@ -1947,22 +1885,25 @@ namespace scopeone::core::internal { return true; } - auto slot = std::make_shared(); + auto slot = std::make_shared(); slot->cameraId = normalizedId; - slot->shmKey = agent::sharedMemoryKey(normalizedId); + slot->shmKey = driverhost::sharedMemoryKey(normalizedId); slot->process = std::make_shared(); - slot->control = std::make_shared(normalizedId, - agent::controlServerName(normalizedId)); + slot->control = std::make_shared( + QStringLiteral("micro-manager"), + normalizedId, + driverhost::controlServerName(normalizedId)); slot->exposureMs = exposureMs; - const QString agentPath = - QDir(QCoreApplication::applicationDirPath()).filePath(agent::kExecutableFileName); - if (!QFileInfo::exists(agentPath)) + const QString driverHostPath = + QDir(QCoreApplication::applicationDirPath()).filePath(driverhost::kExecutableFileName); + if (!QFileInfo::exists(driverHostPath)) { - qWarning().noquote() << QString("Agent executable not found: %1").arg(agentPath); + qWarning().noquote() << QString("DriverHost executable not found: %1").arg(driverHostPath); return false; } QStringList args; - args << "--cameraId" << normalizedId + args << "--provider" << QStringLiteral("micro-manager") + << "--deviceId" << normalizedId << "--adapter" << adapterName << "--device" << deviceName << "--shm" << slot->shmKey; @@ -1984,7 +1925,7 @@ namespace scopeone::core::internal args << "--property" << encodedProperty; } } - slot->process->setProgram(agentPath); + slot->process->setProgram(driverHostPath); slot->process->setArguments(args); slot->process->setProcessChannelMode(QProcess::MergedChannels); @@ -1998,7 +1939,7 @@ namespace scopeone::core::internal for (const QString& line : lines) { const QString trimmed = line.trimmed(); - qInfo().noquote() << QString("[Agent %1] %2").arg(normalizedId, trimmed); + qInfo().noquote() << QString("[DriverHost %1] %2").arg(normalizedId, trimmed); } } }); @@ -2024,7 +1965,7 @@ namespace scopeone::core::internal if (wasRecording) { emit frameDeliveryFailed( - QStringLiteral("Camera agent exited for '%1'").arg(normalizedId), + QStringLiteral("Camera DriverHost exited for '%1'").arg(normalizedId), 0); } }); @@ -2035,7 +1976,7 @@ namespace scopeone::core::internal { if (ready) { - emit agentControlServerListening(normalizedId, agent::controlServerName(normalizedId)); + emit driverHostControlServerListening(normalizedId, driverhost::controlServerName(normalizedId)); } }); slot->control->addEventHandler([this, normalizedId](const QJsonObject& event) @@ -2045,14 +1986,14 @@ namespace scopeone::core::internal { return; } - AgentCameraSlot& slot = *it.value(); - const QString type = event.value(agent::kMessageTypeField).toString(); - if (type == agent::kEventFrameAvailable) + DriverHostCameraSlot& slot = *it.value(); + const QString type = event.value(driverhost::kMessageTypeField).toString(); + if (type == driverhost::kEventFrameAvailable) { scheduleFrameRead(normalizedId); return; } - if (type == agent::kEventPreviewState) + if (type == driverhost::kEventPreviewState) { const bool wasRunning = slot.isRunning; slot.isRunning = event.value(QStringLiteral("running")).toBool(slot.isRunning); @@ -2062,9 +2003,9 @@ namespace scopeone::core::internal } return; } - if (type == agent::kEventAgentError) + if (type == driverhost::kEventDriverHostError) { - const QString error = QStringLiteral("Agent '%1' error: %2") + const QString error = QStringLiteral("DriverHost '%1' error: %2") .arg(slot.cameraId, event.value(QStringLiteral("error")).toString()); qWarning().noquote() << error; @@ -2081,7 +2022,7 @@ namespace scopeone::core::internal slot->process->start(); if (!slot->process->waitForStarted(3000)) { - qWarning().noquote() << QString("Failed to start agent for %1").arg(normalizedId); + qWarning().noquote() << QString("Failed to start DriverHost for %1").arg(normalizedId); return false; } m_cameras.insert(normalizedId, slot); @@ -2089,34 +2030,34 @@ namespace scopeone::core::internal { slot->control->start(); } - if (!waitForControlReady(*slot, kAgentControlReadyTimeoutMs)) + if (!waitForControlReady(*slot, kDriverHostControlReadyTimeoutMs)) { qWarning().noquote() - << QString("Agent control session did not become ready for %1 within %2 ms") + << QString("DriverHost control session did not become ready for %1 within %2 ms") .arg(normalizedId) - .arg(kAgentControlReadyTimeoutMs); - removeAgentCamera(normalizedId); + .arg(kDriverHostControlReadyTimeoutMs); + removeDriverHostCamera(normalizedId); return false; } if (!addFrameReader(normalizedId, slot->shmKey)) { - removeAgentCamera(normalizedId); + removeDriverHostCamera(normalizedId); return false; } - const AgentFrameDeliveryMode deliveryMode = recordingFrameDeliveryEnabled() - ? AgentFrameDeliveryMode::AllFrames + const DriverHostFrameDeliveryMode deliveryMode = recordingFrameDeliveryEnabled() + ? DriverHostFrameDeliveryMode::AllFrames : nonRecordingDeliveryMode( highRateFrameDeliveryEnabled()); if (!sendFrameDeliveryModes(QStringList{normalizedId}, deliveryMode)) { - removeAgentCamera(normalizedId); + removeDriverHostCamera(normalizedId); return false; } return true; } - // Stops one camera agent process and releases its resources - void AgentCameraBackend::removeAgentCamera(const QString& cameraId) + // Stop one camera DriverHost process and release its resources + void DriverHostCameraBackend::removeDriverHostCamera(const QString& cameraId) { const QString normalizedId = normalizedCameraId(cameraId); if (!m_cameras.contains(normalizedId)) @@ -2130,7 +2071,7 @@ namespace scopeone::core::internal if (slot->control) { QJsonObject request; - request.insert(agent::kMessageTypeField, agent::kCommandShutdown); + request.insert(driverhost::kMessageTypeField, driverhost::kCommandShutdown); sendControlCommand(normalizedId, request, nullptr, 800); } m_cameras.remove(normalizedId); @@ -2153,9 +2094,9 @@ namespace scopeone::core::internal } } - std::unique_ptr createAgentCameraBackend( + std::unique_ptr createDriverHostCameraBackend( ProcessingFrameGate& processingFrameGate) { - return std::make_unique(processingFrameGate); + return std::make_unique(processingFrameGate); } } diff --git a/ScopeOneCore/src/DriverHostMain.cpp b/ScopeOneCore/src/DriverHostMain.cpp new file mode 100644 index 0000000..cf10a62 --- /dev/null +++ b/ScopeOneCore/src/DriverHostMain.cpp @@ -0,0 +1,3097 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "MMCore.h" +#include "internal/DriverHostProtocol.h" +#include "scopeone/CameraProvider.h" +#include "scopeone/DriverHostProviderPlugin.h" +#include "scopeone/PluginManifest.h" +#include "scopeone/HardwareCapabilities.h" +#include "scopeone/SharedFrame.h" + +namespace scopeone::core::internal +{ + static_assert(std::atomic_ref::is_always_lock_free, + "Shared frame state requires lock-free 32-bit atomics"); + + using scopeone::core::SharedFrameHeader; + using scopeone::core::SharedMemoryControl; + using scopeone::core::SharedPixelFormat; + using scopeone::core::computeMaxFrameBytes; + using scopeone::core::kSharedFrameHeaderSize; + using scopeone::core::kSharedFrameMaxBytes; + using scopeone::core::kSharedFrameNumSlots; + using scopeone::core::kSharedFrameSlotStride; + using scopeone::core::kSharedMemoryControlSize; + + constexpr int kMinimumPollIntervalMs = 1; + constexpr int kMaximumPollIntervalMs = 50; + constexpr int kPreviewFrameDeliveryIntervalMs = 16; + + int pollingIntervalFor(double frameIntervalMs) + { + if (!std::isfinite(frameIntervalMs) || frameIntervalMs <= 0.0) + { + return kMinimumPollIntervalMs; + } + return static_cast(std::clamp(frameIntervalMs / 4.0, + static_cast(kMinimumPollIntervalMs), + static_cast(kMaximumPollIntervalMs))); + } + + // Normalize frame bit depth before publishing shared frame metadata + static quint16 normalizedSharedBitDepth(SharedPixelFormat format, int bitsPerSample) + { + if (format == SharedPixelFormat::Mono8) + { + return 8; + } + if (format == SharedPixelFormat::Mono16) + { + return bitsPerSample >= 1 && bitsPerSample <= 16 + ? static_cast(bitsPerSample) + : 16; + } + return 0; + } + + class ControlConnection final : public QObject + { + Q_OBJECT + + public: + // Wrap one local control socket connection + ControlConnection(quint64 connectionId, QLocalSocket* socket, QObject* parent = nullptr) + : QObject(parent) + , m_connectionId(connectionId) + , m_socket(socket) + { + m_socket->setParent(this); + + connect(m_socket, &QLocalSocket::readyRead, + this, &ControlConnection::onReadyRead); + connect(m_socket, &QLocalSocket::disconnected, + this, &ControlConnection::onDisconnected); + connect(m_socket, &QLocalSocket::errorOccurred, this, + [this](QLocalSocket::LocalSocketError socketError) + { + qWarning().noquote() + << QString("DriverHost control socket error (%1) on connection %2") + .arg(static_cast(socketError)) + .arg(m_connectionId); + }); + } + + // Send one encoded protocol message to the client + void sendMessage(const QJsonObject& message) + { + if (m_socket->state() != QLocalSocket::ConnectedState) + { + return; + } + m_socket->write(driverhost::encodeMessage(message)); + } + + signals: + void requestReceived(quint64 connectionId, + quint64 requestId, + const QString& type, + const QJsonObject& message); + void connectionClosed(quint64 connectionId); + + private slots: + // Decode queued socket bytes into control requests + void onReadyRead() + { + m_readBuffer += m_socket->readAll(); + while (true) + { + QJsonObject message; + QString error; + const driverhost::DecodeResult result = + driverhost::tryDecodeMessage(m_readBuffer, message, &error); + if (result == driverhost::DecodeResult::Incomplete) + { + return; + } + if (result == driverhost::DecodeResult::Error) + { + qWarning().noquote() + << QString("DriverHost control protocol error on connection %1: %2") + .arg(m_connectionId) + .arg(error); + m_socket->disconnectFromServer(); + return; + } + + if (message.value(driverhost::kEnvelopeVersionField).toInt(0) + != static_cast(driverhost::kProtocolVersion)) + { + qWarning().noquote() + << QString("DriverHost control protocol version mismatch on connection %1") + .arg(m_connectionId); + m_socket->disconnectFromServer(); + return; + } + + if (message.value(driverhost::kEnvelopeKindField).toString() + != driverhost::kMessageKindRequest) + { + qWarning().noquote() + << QString("Ignoring non-request control message on connection %1") + .arg(m_connectionId); + continue; + } + + const quint64 requestId = + driverhost::decodeUInt64(message.value(driverhost::kEnvelopeRequestIdField)); + const QString type = message.value(driverhost::kMessageTypeField).toString(); + if (requestId == 0 || type.isEmpty()) + { + qWarning().noquote() + << QString("Ignoring malformed request on connection %1") + .arg(m_connectionId); + continue; + } + + emit requestReceived(m_connectionId, requestId, type, message); + } + } + + // Notify DriverHost when this connection closes + void onDisconnected() + { + emit connectionClosed(m_connectionId); + deleteLater(); + } + + private: + quint64 m_connectionId{0}; + QLocalSocket* m_socket{nullptr}; + QByteArray m_readBuffer; + }; + + class DriverHostRuntime : public QObject + { + Q_OBJECT + + public: + using QObject::QObject; + virtual bool initializeRuntime() = 0; + virtual QString lastError() const = 0; + virtual void publishHello() = 0; + virtual void handleRequest(quint64 connectionId, + quint64 requestId, + const QString& type, + const QJsonObject& message) = 0; + virtual void stopForExit() = 0; + + signals: + void responseReady(quint64 connectionId, const QJsonObject& response); + void eventReady(const QJsonObject& event); + void shutdownRequested(); + }; + + class MicroManagerDriverRuntime final : public DriverHostRuntime + { + Q_OBJECT + + public: + // Store launch settings for one camera runtime + MicroManagerDriverRuntime(QString providerId, + QString cameraId, + QString adapter, + QString device, + QString shmKey, + QStringList preInitProperties, + QStringList properties, + double exposureMs, + bool autoPreview, + QObject* parent = nullptr) + : DriverHostRuntime(parent) + , m_providerId(std::move(providerId)) + , m_cameraId(std::move(cameraId)) + , m_adapter(std::move(adapter)) + , m_device(std::move(device)) + , m_shmKey(std::move(shmKey)) + , m_preInitProperties(std::move(preInitProperties)) + , m_properties(std::move(properties)) + , m_exposureMs(exposureMs) + , m_autoPreview(autoPreview) + { + } + + QString lastError() const override + { + return m_lastError; + } + + // Initialize MMCore camera state and shared memory transport + bool initializeRuntime() override + { + m_lastError.clear(); + setState(State::Starting); + + if (!m_timer) + { + m_timer = new QTimer(this); + m_timer->setTimerType(Qt::PreciseTimer); + connect(m_timer, &QTimer::timeout, + this, &MicroManagerDriverRuntime::pollAndWrite); + } + + try + { + m_mmcore = std::make_unique(); + const std::string label = m_cameraId.toStdString(); + const std::string adapter = m_adapter.toStdString(); + const std::string device = m_device.toStdString(); + + m_mmcore->loadDevice(label.c_str(), adapter.c_str(), device.c_str()); + QString preInitError; + if (!applyProperties(m_preInitProperties, label, QStringLiteral("pre-init property"), &preInitError)) + { + m_lastError = preInitError; + setState(State::Error, m_lastError); + return false; + } + m_mmcore->initializeDevice(label.c_str()); + m_mmcore->setCameraDevice(label.c_str()); + + QString propertyError; + if (!applyProperties(m_properties, label, QStringLiteral("property"), &propertyError)) + { + m_lastError = propertyError; + setState(State::Error, m_lastError); + return false; + } + + try + { + m_mmcore->setCircularBufferMemoryFootprint(2048); + } + catch (const CMMError&) + { + } + + double finalExposure = 0.0; + if (m_exposureMs > 0.0) + { + m_mmcore->setExposure(m_exposureMs); + } + m_mmcore->waitForDevice(label.c_str()); + finalExposure = m_mmcore->getExposure(); + if (finalExposure <= 0.0) + { + m_lastError = QStringLiteral("Camera reported an invalid exposure"); + setState(State::Error, m_lastError); + return false; + } + m_exposureMs = finalExposure; + } + catch (const CMMError& error) + { + m_lastError = QString::fromStdString(error.getMsg()); + setState(State::Error, m_lastError); + return false; + } + + m_shm = std::make_unique(); + m_shm->setNativeKey(m_shmKey); + const int totalBytes = + kSharedMemoryControlSize + kSharedFrameNumSlots * kSharedFrameSlotStride; + if (!m_shm->create(totalBytes)) + { + if (m_shm->attach()) + { + m_shm->detach(); + } + if (!m_shm->create(totalBytes)) + { + m_lastError = QStringLiteral("Cannot create shared memory '%1'") + .arg(m_shmKey); + setState(State::Error, m_lastError); + return false; + } + } + + if (m_shm->lock()) + { + auto* base = static_cast(m_shm->data()); + if (base) + { + const SharedMemoryControl control{}; + memcpy(base, &control, sizeof(control)); + for (int i = 0; i < kSharedFrameNumSlots; ++i) + { + const SharedFrameHeader header{}; + uchar* slot = base + kSharedMemoryControlSize + + i * kSharedFrameSlotStride; + memcpy(slot, &header, sizeof(header)); + } + } + m_shm->unlock(); + } + + if (m_autoPreview) + { + QString error; + if (!startPreviewInternal(&error)) + { + m_lastError = error; + setState(State::Error, m_lastError); + return false; + } + } + else + { + setState(State::Idle); + } + + return true; + } + + void publishHello() override; + void handleRequest(quint64 connectionId, + quint64 requestId, + const QString& type, + const QJsonObject& message) override; + void stopForExit() override; + + private: + // Runtime state is mirrored to control clients + enum class State + { + Starting, + Idle, + Previewing, + Error, + ShuttingDown + }; + + enum class FrameDeliveryMode + { + PreviewLatest, + LatestOnly, + AllFrames + }; + + // Frame layout describes one shared memory payload shape + struct FrameLayout + { + unsigned width{0}; + unsigned height{0}; + unsigned bytesPerPixel{0}; + SharedPixelFormat format{SharedPixelFormat::Mono8}; + unsigned stride{0}; + quint64 byteCount{0}; + quint16 bitDepth{8}; + quint16 channels{1}; + }; + + bool previewRunning() const; + void setState(State state, const QString& error = QString()); + QJsonObject makeResponse(const QString& type, quint64 requestId, bool ok) const; + QJsonObject makeErrorResponse(const QString& type, + quint64 requestId, + const QString& error) const; + QJsonObject makeEvent(const QString& type) const; + bool applyProperties(const QStringList& encodedProperties, + const std::string& label, + const QString& propertyKind, + QString* errorMessage); + void emitPreviewStateEvent(); + void emitDriverHostErrorEvent(const QString& error); + void emitFrameAvailableEvent(quint64 frameIndex); + bool startPreviewInternal(QString* errorMessage); + bool stopPreviewInternal(QString* errorMessage); + bool captureEventFrameInternal(quint64& frameIndex, QString* errorMessage); + bool writeFrameToSharedMemory(const void* pixels, + quint64 frameAdvance = 1, + quint64* frameIndexOut = nullptr); + void pollAndWrite(); + void updatePollingInterval(quint64 frameCount); + bool ensureFrameLayout(unsigned width, unsigned height, unsigned bytesPerPixel); + void refreshSourceRoi(); + + QString m_providerId; + QString m_cameraId; + QString m_adapter; + QString m_device; + QString m_shmKey; + QStringList m_preInitProperties; + QStringList m_properties; + double m_exposureMs{10.0}; + bool m_autoPreview{false}; + + QString m_lastError; + State m_state{State::Starting}; + + std::unique_ptr m_mmcore; + std::unique_ptr m_shm; + QTimer* m_timer{nullptr}; + QElapsedTimer m_frameIntervalTimer; + QElapsedTimer m_deliveryTimer; + double m_observedFrameIntervalMs{0.0}; + + FrameLayout m_frameLayout{}; + bool m_frameLayoutValid{false}; + bool m_loggedOversizedFrame{false}; + bool m_loggedUnsupportedFormat{false}; + int m_sourceRoiX{0}; + int m_sourceRoiY{0}; + int m_sourceRoiWidth{0}; + int m_sourceRoiHeight{0}; + + quint64 m_frameIndex{0}; + quint64 m_pendingFrameAdvance{0}; + int m_nextWriteSlot{0}; + FrameDeliveryMode m_frameDeliveryMode{FrameDeliveryMode::PreviewLatest}; + }; + + // Publish the initial hello event to connected clients + void MicroManagerDriverRuntime::publishHello() + { + QJsonObject event = makeEvent(driverhost::kEventHello); + event.insert(driverhost::kProviderIdField, m_providerId); + event.insert(driverhost::kDeviceIdField, m_cameraId); + event.insert(driverhost::kDeviceKindField, driverhost::kCapabilityCamera); + event.insert(driverhost::kCapabilitiesField, + QJsonArray{driverhost::kCapabilityCamera, + driverhost::kCapabilityProperties}); + emit eventReady(event); + } + + // Replay encoded cfg properties into the DriverHost MMCore instance + bool MicroManagerDriverRuntime::applyProperties(const QStringList& encodedProperties, + const std::string& label, + const QString& propertyKind, + QString* errorMessage) + { + for (const QString& encodedProperty : encodedProperties) + { + if (encodedProperty.isEmpty()) + { + continue; + } + + QJsonParseError parseError; + const QJsonDocument doc = QJsonDocument::fromJson(encodedProperty.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !doc.isObject()) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Invalid %1 payload for '%2'") + .arg(propertyKind, m_cameraId); + } + return false; + } + + const QJsonObject property = doc.object(); + const QString propertyName = property.value(QStringLiteral("name")).toString().trimmed(); + const QString propertyValue = property.value(QStringLiteral("value")).toString(); + if (propertyName.isEmpty()) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Missing %1 name for '%2'") + .arg(propertyKind, m_cameraId); + } + return false; + } + + try + { + m_mmcore->setProperty(label.c_str(), + propertyName.toStdString().c_str(), + propertyValue.toStdString().c_str()); + } + catch (const CMMError& mmError) + { + if (errorMessage) + { + *errorMessage = QString("Failed to apply %1 '%2': %3") + .arg(propertyKind, propertyName, QString::fromStdString(mmError.getMsg())); + } + return false; + } + } + + return true; + } + + // Dispatch one control request and emit a matching response + void MicroManagerDriverRuntime::handleRequest(quint64 connectionId, + quint64 requestId, + const QString& type, + const QJsonObject& message) + { + if (requestId == 0 || type.isEmpty()) + { + return; + } + + if (!m_mmcore && type != driverhost::kCommandShutdown) + { + emit responseReady(connectionId, + makeErrorResponse(type, + requestId, + QStringLiteral("DriverHost runtime not initialized"))); + return; + } + + if (type == driverhost::kCommandDescribe) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(driverhost::kProviderIdField, m_providerId); + response.insert(driverhost::kDeviceIdField, m_cameraId); + response.insert(driverhost::kDeviceKindField, driverhost::kCapabilityCamera); + response.insert(driverhost::kCapabilitiesField, + QJsonArray{driverhost::kCapabilityCamera, + driverhost::kCapabilityProperties}); + emit responseReady(connectionId, response); + return; + } + + if (type == driverhost::kCommandStartPreview) + { + QString error; + const bool ok = startPreviewInternal(&error); + emit responseReady(connectionId, + ok + ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + if (type == driverhost::kCommandStopPreview) + { + QString error; + const bool ok = stopPreviewInternal(&error); + emit responseReady(connectionId, + ok + ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + if (type == driverhost::kCommandSetFrameDeliveryMode) + { + const QString mode = message.value(QStringLiteral("mode")).toString(); + if (mode == driverhost::kFrameDeliveryModePreviewLatest) + { + m_frameDeliveryMode = FrameDeliveryMode::PreviewLatest; + m_deliveryTimer.invalidate(); + } + else if (mode == driverhost::kFrameDeliveryModeLatestOnly) + { + m_frameDeliveryMode = FrameDeliveryMode::LatestOnly; + } + else if (mode == driverhost::kFrameDeliveryModeAllFrames) + { + m_frameDeliveryMode = FrameDeliveryMode::AllFrames; + m_pendingFrameAdvance = 0; + m_deliveryTimer.invalidate(); + } + else + { + emit responseReady(connectionId, + makeErrorResponse(type, + requestId, + QStringLiteral("Unknown frame delivery mode"))); + return; + } + + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("mode"), mode); + emit responseReady(connectionId, response); + return; + } + + if (type == driverhost::kCommandGetExposure) + { + try + { + m_exposureMs = m_mmcore->getExposure(); + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("exposureMs"), m_exposureMs); + emit responseReady(connectionId, response); + } + catch (const CMMError& mmError) + { + emit responseReady( + connectionId, + makeErrorResponse(type, + requestId, + QString::fromStdString(mmError.getMsg()))); + } + return; + } + + if (type == driverhost::kCommandSetExposure) + { + const double exposureMs = message.value(QStringLiteral("value")).toDouble(-1.0); + bool ok = exposureMs > 0.0; + QString error; + if (!ok) + { + error = QStringLiteral("Invalid exposure value"); + } + else + { + try + { + m_mmcore->setExposure(exposureMs); + m_exposureMs = m_mmcore->getExposure(); + } + catch (const CMMError& mmError) + { + ok = false; + error = QString::fromStdString(mmError.getMsg()); + } + } + + if (ok) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("exposureMs"), m_exposureMs); + emit responseReady(connectionId, response); + } + else + { + emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); + } + return; + } + + if (type == driverhost::kCommandListProperties) + { + QJsonArray properties; + QString error; + bool ok = true; + try + { + const auto names = + m_mmcore->getDevicePropertyNames(m_cameraId.toStdString().c_str()); + for (const auto& name : names) + { + properties.append(QString::fromStdString(name)); + } + } + catch (const CMMError& mmError) + { + ok = false; + error = QString::fromStdString(mmError.getMsg()); + } + + if (ok) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("properties"), properties); + emit responseReady(connectionId, response); + } + else + { + emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); + } + return; + } + + if (type == driverhost::kCommandGetProperty) + { + const QString name = message.value(QStringLiteral("name")).toString(); + const bool fromCache = message.value(QStringLiteral("fromCache")).toBool(false); + const std::string camera = m_cameraId.toStdString(); + const std::string property = name.toStdString(); + QString value; + QString propertyType = QStringLiteral("Unknown"); + bool readOnly = true; + bool preInit = false; + QJsonArray allowedValues; + bool hasLimits = false; + double lowerLimit = 0.0; + double upperLimit = 0.0; + QString error; + bool ok = true; + + try + { + value = QString::fromStdString( + fromCache + ? m_mmcore->getPropertyFromCache(camera.c_str(), property.c_str()) + : m_mmcore->getProperty(camera.c_str(), property.c_str())); + + try + { + switch (m_mmcore->getPropertyType(camera.c_str(), property.c_str())) + { + case MM::String: + propertyType = QStringLiteral("String"); + break; + case MM::Float: + propertyType = QStringLiteral("Float"); + break; + case MM::Integer: + propertyType = QStringLiteral("Integer"); + break; + default: + propertyType = QStringLiteral("Unknown"); + break; + } + } + catch (const CMMError&) + { + } + + try + { + preInit = m_mmcore->isPropertyPreInit(camera.c_str(), property.c_str()); + } + catch (const CMMError&) + { + } + + try + { + readOnly = m_mmcore->isPropertyReadOnly(camera.c_str(), property.c_str()); + } + catch (const CMMError&) + { + } + + try + { + const auto values = + m_mmcore->getAllowedPropertyValues(camera.c_str(), property.c_str()); + for (const auto& allowedValue : values) + { + allowedValues.append(QString::fromStdString(allowedValue)); + } + } + catch (const CMMError&) + { + } + + try + { + hasLimits = m_mmcore->hasPropertyLimits(camera.c_str(), property.c_str()); + if (hasLimits) + { + lowerLimit = m_mmcore->getPropertyLowerLimit(camera.c_str(), property.c_str()); + upperLimit = m_mmcore->getPropertyUpperLimit(camera.c_str(), property.c_str()); + } + } + catch (const CMMError&) + { + hasLimits = false; + } + } + catch (const CMMError& mmError) + { + ok = false; + error = QString::fromStdString(mmError.getMsg()); + } + + if (ok) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("value"), value); + response.insert(QStringLiteral("propertyType"), propertyType); + response.insert(QStringLiteral("readOnly"), readOnly); + response.insert(QStringLiteral("preInit"), preInit); + response.insert(QStringLiteral("allowedValues"), allowedValues); + response.insert(QStringLiteral("hasLimits"), hasLimits); + response.insert(QStringLiteral("lowerLimit"), lowerLimit); + response.insert(QStringLiteral("upperLimit"), upperLimit); + emit responseReady(connectionId, response); + } + else + { + emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); + } + return; + } + + if (type == driverhost::kCommandSetProperty) + { + const QString name = message.value(QStringLiteral("name")).toString(); + const QString value = message.value(QStringLiteral("value")).toString(); + QString error; + bool ok = true; + try + { + m_mmcore->setProperty(m_cameraId.toStdString().c_str(), + name.toStdString().c_str(), + value.toStdString().c_str()); + m_mmcore->waitForDevice(m_cameraId.toStdString().c_str()); + } + catch (const CMMError& mmError) + { + ok = false; + error = QString::fromStdString(mmError.getMsg()); + } + + emit responseReady(connectionId, + ok + ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + if (type == driverhost::kCommandSetRoi) + { + const int x = message.value(QStringLiteral("x")).toInt(0); + const int y = message.value(QStringLiteral("y")).toInt(0); + const int width = message.value(QStringLiteral("width")).toInt(0); + const int height = message.value(QStringLiteral("height")).toInt(0); + QString error; + bool ok = true; + try + { + m_mmcore->setROI(m_cameraId.toStdString().c_str(), x, y, width, height); + m_mmcore->waitForDevice(m_cameraId.toStdString().c_str()); + refreshSourceRoi(); + } + catch (const CMMError& mmError) + { + ok = false; + error = QString::fromStdString(mmError.getMsg()); + } + + emit responseReady(connectionId, + ok + ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + if (type == driverhost::kCommandClearRoi) + { + QString error; + bool ok = true; + try + { + m_mmcore->setCameraDevice(m_cameraId.toStdString().c_str()); + m_mmcore->clearROI(); + m_mmcore->waitForDevice(m_cameraId.toStdString().c_str()); + refreshSourceRoi(); + } + catch (const CMMError& mmError) + { + ok = false; + error = QString::fromStdString(mmError.getMsg()); + } + + emit responseReady(connectionId, + ok + ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + if (type == driverhost::kCommandGetRoi) + { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + QString error; + bool ok = true; + try + { + m_mmcore->getROI(m_cameraId.toStdString().c_str(), x, y, width, height); + } + catch (const CMMError& mmError) + { + ok = false; + error = QString::fromStdString(mmError.getMsg()); + } + + if (ok) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("x"), x); + response.insert(QStringLiteral("y"), y); + response.insert(QStringLiteral("width"), width); + response.insert(QStringLiteral("height"), height); + emit responseReady(connectionId, response); + } + else + { + emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); + } + return; + } + + if (type == driverhost::kCommandCaptureEvent) + { + QString error; + quint64 frameIndex = 0; + const bool ok = captureEventFrameInternal(frameIndex, &error); + if (ok) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("frameIndex"), driverhost::encodeUInt64(frameIndex)); + emit responseReady(connectionId, response); + } + else + { + emit responseReady(connectionId, makeErrorResponse(type, requestId, error)); + } + return; + } + + if (type == driverhost::kCommandShutdown) + { + stopPreviewInternal(nullptr); + setState(State::ShuttingDown); + emit responseReady(connectionId, makeResponse(type, requestId, true)); + QTimer::singleShot(50, this, [this]() { emit shutdownRequested(); }); + return; + } + + emit responseReady(connectionId, + makeErrorResponse(type, + requestId, + QStringLiteral("Unknown control command"))); + } + + // Stop preview before the runtime thread exits + void MicroManagerDriverRuntime::stopForExit() + { + stopPreviewInternal(nullptr); + setState(State::ShuttingDown); + } + + // Check whether the runtime is currently previewing + bool MicroManagerDriverRuntime::previewRunning() const + { + return m_state == State::Previewing; + } + + // Update runtime state and publish state changes + void MicroManagerDriverRuntime::setState(State state, const QString& error) + { + const bool changed = (m_state != state); + m_state = state; + if (!error.isEmpty()) + { + m_lastError = error; + } + if (changed && (state == State::Previewing + || state == State::Idle + || state == State::Error + || state == State::ShuttingDown)) + { + emitPreviewStateEvent(); + } + if (!error.isEmpty()) + { + emitDriverHostErrorEvent(error); + } + } + + // Build a protocol response envelope + QJsonObject MicroManagerDriverRuntime::makeResponse(const QString& type, quint64 requestId, bool ok) const + { + QJsonObject response = + driverhost::makeEnvelope(driverhost::kMessageKindResponse, type, requestId); + response.insert(QStringLiteral("ok"), ok); + return response; + } + + // Build a protocol error response envelope + QJsonObject MicroManagerDriverRuntime::makeErrorResponse(const QString& type, + quint64 requestId, + const QString& error) const + { + QJsonObject response = makeResponse(type, requestId, false); + response.insert(QStringLiteral("error"), error); + return response; + } + + // Build a protocol event envelope + QJsonObject MicroManagerDriverRuntime::makeEvent(const QString& type) const + { + return driverhost::makeEnvelope(driverhost::kMessageKindEvent, type); + } + + // Publish current preview state to clients + void MicroManagerDriverRuntime::emitPreviewStateEvent() + { + QJsonObject event = makeEvent(driverhost::kEventPreviewState); + event.insert(QStringLiteral("running"), previewRunning()); + emit eventReady(event); + } + + // Publish a DriverHost error event to clients + void MicroManagerDriverRuntime::emitDriverHostErrorEvent(const QString& error) + { + QJsonObject event = makeEvent(driverhost::kEventDriverHostError); + event.insert(QStringLiteral("error"), error); + emit eventReady(event); + } + + // Publish the newest shared memory frame index + void MicroManagerDriverRuntime::emitFrameAvailableEvent(quint64 frameIndex) + { + QJsonObject event = makeEvent(driverhost::kEventFrameAvailable); + event.insert(QStringLiteral("frameIndex"), driverhost::encodeUInt64(frameIndex)); + emit eventReady(event); + } + + // Start continuous acquisition and polling + bool MicroManagerDriverRuntime::startPreviewInternal(QString* errorMessage) + { + if (!m_mmcore) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + if (m_state == State::ShuttingDown || m_state == State::Error) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("DriverHost is not in a runnable state"); + } + return false; + } + if (previewRunning()) + { + return true; + } + + try + { + while (m_mmcore->getRemainingImageCount() > 0) + { + m_mmcore->popNextImage(); + } + refreshSourceRoi(); + if (!ensureFrameLayout(m_mmcore->getImageWidth(), + m_mmcore->getImageHeight(), + m_mmcore->getBytesPerPixel())) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Unsupported frame format"); + } + return false; + } + m_mmcore->startContinuousSequenceAcquisition(0.0); + m_observedFrameIntervalMs = 0.0; + m_pendingFrameAdvance = 0; + m_frameIntervalTimer.restart(); + m_deliveryTimer.invalidate(); + m_timer->setInterval(pollingIntervalFor(m_exposureMs)); + if (m_timer && !m_timer->isActive()) + { + m_timer->start(); + } + setState(State::Previewing); + return true; + } + catch (const CMMError& error) + { + if (errorMessage) + { + *errorMessage = QString::fromStdString(error.getMsg()); + } + return false; + } + } + + // Stop continuous acquisition and polling + bool MicroManagerDriverRuntime::stopPreviewInternal(QString* errorMessage) + { + if (!m_mmcore) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + + try + { + if (m_mmcore->isSequenceRunning()) + { + m_mmcore->stopSequenceAcquisition(); + } + if (m_timer && m_timer->isActive()) + { + m_timer->stop(); + } + m_frameIntervalTimer.invalidate(); + m_deliveryTimer.invalidate(); + m_observedFrameIntervalMs = 0.0; + m_pendingFrameAdvance = 0; + if (m_state != State::ShuttingDown && m_state != State::Error) + { + setState(State::Idle); + } + return true; + } + catch (const CMMError& error) + { + if (errorMessage) + { + *errorMessage = QString::fromStdString(error.getMsg()); + } + return false; + } + } + + // Capture one frame for recording or API requests + bool MicroManagerDriverRuntime::captureEventFrameInternal(quint64& frameIndex, QString* errorMessage) + { + frameIndex = 0; + try + { + const void* pixels = nullptr; + if (previewRunning()) + { + QElapsedTimer waitTimer; + waitTimer.start(); + while (m_mmcore->getRemainingImageCount() <= 0 && waitTimer.elapsed() < 2000) + { + QThread::msleep(1); + } + if (m_mmcore->getRemainingImageCount() > 0) + { + pixels = m_mmcore->popNextImage(); + } + else if (errorMessage) + { + *errorMessage = QStringLiteral("No frame available from running sequence"); + } + } + else + { + m_mmcore->snapImage(); + pixels = m_mmcore->getImage(); + } + + if (!pixels) + { + if (errorMessage && errorMessage->isEmpty()) + { + *errorMessage = QStringLiteral("Empty image buffer"); + } + return false; + } + + const unsigned width = m_mmcore->getImageWidth(); + const unsigned height = m_mmcore->getImageHeight(); + const unsigned bytesPerPixel = m_mmcore->getBytesPerPixel(); + if (!ensureFrameLayout(width, height, bytesPerPixel)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Unsupported frame format"); + } + return false; + } + + const quint64 frameAdvance = m_pendingFrameAdvance + 1; + m_pendingFrameAdvance = 0; + if (!writeFrameToSharedMemory(pixels, frameAdvance, &frameIndex)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Shared memory unavailable"); + } + return false; + } + + if (m_frameDeliveryMode == FrameDeliveryMode::PreviewLatest) + { + m_deliveryTimer.restart(); + } + + emitFrameAvailableEvent(frameIndex); + return true; + } + catch (const CMMError& error) + { + if (errorMessage) + { + *errorMessage = QString::fromStdString(error.getMsg()); + } + return false; + } + } + + // Copy one camera frame into the shared memory ring buffer + bool MicroManagerDriverRuntime::writeFrameToSharedMemory(const void* pixels, + quint64 frameAdvance, + quint64* frameIndexOut) + { + if (!m_shm || !m_shm->isAttached()) + { + return false; + } + uchar* base = static_cast(m_shm->data()); + if (!base) + { + return false; + } + + const quint64 nextFrameIndex = m_frameIndex + (std::max)(quint64{1}, frameAdvance); + const int preferredSlotIndex = m_nextWriteSlot; + int slotIndex = -1; + uchar* ptr = nullptr; + for (int offset = 0; offset < kSharedFrameNumSlots; ++offset) + { + const int candidate = (preferredSlotIndex + offset) % kSharedFrameNumSlots; + uchar* candidatePtr = base + kSharedMemoryControlSize + + candidate * kSharedFrameSlotStride; + auto& stateValue = *reinterpret_cast(candidatePtr); + std::atomic_ref state(stateValue); + quint32 expected = state.load(std::memory_order_acquire); + while (expected == 0 || expected == 2) + { + if (state.compare_exchange_weak(expected, + 1, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + slotIndex = candidate; + ptr = candidatePtr; + break; + } + } + if (slotIndex >= 0) + { + break; + } + } + + if (slotIndex < 0 || !ptr) + { + m_frameIndex = nextFrameIndex; + if (frameIndexOut) + { + *frameIndexOut = m_frameIndex; + } + return false; + } + + auto* control = reinterpret_cast(base); + m_nextWriteSlot = (slotIndex + 1) % kSharedFrameNumSlots; + SharedFrameHeader header{}; + header.state = 1; + header.width = m_frameLayout.width; + header.height = m_frameLayout.height; + header.stride = m_frameLayout.stride; + header.pixelFormat = static_cast(m_frameLayout.format); + header.bitsPerSample = m_frameLayout.bitDepth; + header.channels = m_frameLayout.channels; + header.frameIndex = nextFrameIndex; + header.timestampNs = + static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; + setSharedFrameSourceRoi(header, + m_sourceRoiX, + m_sourceRoiY, + m_sourceRoiWidth, + m_sourceRoiHeight); + memcpy(ptr + sizeof(header.state), + reinterpret_cast(&header) + sizeof(header.state), + sizeof(header) - sizeof(header.state)); + + uchar* dst = ptr + kSharedFrameHeaderSize; + memcpy(dst, pixels, static_cast(m_frameLayout.byteCount)); + + auto& stateValue = *reinterpret_cast(ptr); + std::atomic_ref(stateValue).store(2, std::memory_order_release); + std::atomic_ref(control->latestSlotIndex) + .store(static_cast(slotIndex), std::memory_order_release); + m_frameIndex = nextFrameIndex; + if (frameIndexOut) + { + *frameIndexOut = m_frameIndex; + } + + return true; + } + + // Drain camera frames into shared memory and publish the newest index + void MicroManagerDriverRuntime::pollAndWrite() + { + if (!m_mmcore) + { + return; + } + + try + { + long remaining = m_mmcore->getRemainingImageCount(); + + if (remaining <= 0) + { + return; + } + + quint64 newestFrameIndex = 0; + quint64 acquiredFrameCount = 0; + if (m_frameDeliveryMode != FrameDeliveryMode::AllFrames) + { + const bool previewRateLimited = + m_frameDeliveryMode == FrameDeliveryMode::PreviewLatest; + const bool publishFrame = !previewRateLimited + || !m_deliveryTimer.isValid() + || m_deliveryTimer.elapsed() >= kPreviewFrameDeliveryIntervalMs; + while (remaining-- > 0) + { + const void* pixels = m_mmcore->popNextImage(); + if (!pixels) + { + break; + } + ++acquiredFrameCount; + ++m_pendingFrameAdvance; + if (remaining > 0) + { + continue; + } + if (publishFrame && m_frameLayoutValid) + { + quint64 writtenFrameIndex = 0; + const bool written = writeFrameToSharedMemory( + pixels, + m_pendingFrameAdvance, + &writtenFrameIndex); + m_pendingFrameAdvance = 0; + if (previewRateLimited) + { + m_deliveryTimer.restart(); + } + if (written) + { + newestFrameIndex = writtenFrameIndex; + } + } + } + } + else + { + while (remaining-- > 0) + { + const void* pixels = m_mmcore->popNextImage(); + if (!pixels) + { + break; + } + ++acquiredFrameCount; + + if (!m_frameLayoutValid) + { + break; + } + + quint64 writtenFrameIndex = 0; + if (!writeFrameToSharedMemory(pixels, 1, &writtenFrameIndex)) + { + break; + } + newestFrameIndex = writtenFrameIndex; + } + } + + if (newestFrameIndex != 0) + { + emitFrameAvailableEvent(newestFrameIndex); + } + updatePollingInterval(acquiredFrameCount); + } + catch (const CMMError& error) + { + const QString message = + QStringLiteral("DriverHost capture error: %1") + .arg(QString::fromStdString(error.getMsg())); + setState(State::Error, message); + } + } + + // Adapts MMCore polling to the observed camera frame interval + void MicroManagerDriverRuntime::updatePollingInterval(quint64 frameCount) + { + if (frameCount == 0 || !m_frameIntervalTimer.isValid()) + { + return; + } + + const double elapsedMs = static_cast(m_frameIntervalTimer.nsecsElapsed()) / 1000000.0; + m_frameIntervalTimer.restart(); + const double measuredIntervalMs = elapsedMs / static_cast(frameCount); + if (!std::isfinite(measuredIntervalMs) || measuredIntervalMs <= 0.0) + { + return; + } + + m_observedFrameIntervalMs = m_observedFrameIntervalMs > 0.0 + ? 0.75 * m_observedFrameIntervalMs + + 0.25 * measuredIntervalMs + : measuredIntervalMs; + const int intervalMs = pollingIntervalFor(m_observedFrameIntervalMs); + if (m_timer->interval() != intervalMs) + { + m_timer->setInterval(intervalMs); + } + } + + // Validate and cache the current frame memory layout + bool MicroManagerDriverRuntime::ensureFrameLayout(unsigned width, unsigned height, unsigned bytesPerPixel) + { + const auto logOversized = [this](quint64 byteCount) + { + if (!m_loggedOversizedFrame) + { + qWarning().noquote() + << QString("[DriverHost %1] Frame payload (%2 bytes) exceeds shared memory slot capacity (%3 bytes)") + .arg(m_cameraId) + .arg(static_cast(byteCount)) + .arg(kSharedFrameMaxBytes); + m_loggedOversizedFrame = true; + } + }; + + if (bytesPerPixel != 1 && bytesPerPixel != 2) + { + if (!m_loggedUnsupportedFormat) + { + qWarning().noquote() + << QString("[DriverHost %1] Unsupported bytes-per-pixel (%2). Only Mono8/Mono16 are supported.") + .arg(m_cameraId) + .arg(bytesPerPixel); + m_loggedUnsupportedFormat = true; + } + return false; + } + m_loggedUnsupportedFormat = false; + + const bool geometryChanged = + !m_frameLayoutValid + || m_frameLayout.width != width + || m_frameLayout.height != height + || m_frameLayout.bytesPerPixel != bytesPerPixel; + + if (!geometryChanged) + { + if (m_frameLayout.byteCount == 0 + || m_frameLayout.byteCount > static_cast(kSharedFrameMaxBytes)) + { + logOversized(m_frameLayout.byteCount); + return false; + } + if (m_sourceRoiWidth <= 0 || m_sourceRoiHeight <= 0) + { + refreshSourceRoi(); + } + m_loggedOversizedFrame = false; + return true; + } + + m_frameLayoutValid = false; + + FrameLayout updated; + updated.width = width; + updated.height = height; + updated.bytesPerPixel = bytesPerPixel; + updated.format = + (bytesPerPixel == 2) ? SharedPixelFormat::Mono16 : SharedPixelFormat::Mono8; + updated.channels = 1; + const quint64 stride = static_cast(width) * bytesPerPixel; + if (stride == 0 || stride > (std::numeric_limits::max)()) + { + logOversized(stride); + return false; + } + updated.stride = static_cast(stride); + updated.byteCount = computeMaxFrameBytes(width, height, updated.format); + + if (updated.byteCount == 0 + || updated.byteCount > static_cast(kSharedFrameMaxBytes)) + { + logOversized(updated.byteCount); + return false; + } + + quint16 bitDepth = + (updated.format == SharedPixelFormat::Mono16) ? 16 : 8; + try + { + bitDepth = normalizedSharedBitDepth( + updated.format, + static_cast(m_mmcore->getImageBitDepth())); + } + catch (const CMMError&) + { + bitDepth = + (updated.format == SharedPixelFormat::Mono16) ? 16 : 8; + } + updated.bitDepth = normalizedSharedBitDepth(updated.format, bitDepth); + + m_frameLayout = updated; + m_frameLayoutValid = true; + refreshSourceRoi(); + m_loggedOversizedFrame = false; + return true; + } + + // Read the active camera ROI used by following frame metadata + void MicroManagerDriverRuntime::refreshSourceRoi() + { + int x = 0; + int y = 0; + int width = 0; + int height = 0; + try + { + m_mmcore->getROI(m_cameraId.toStdString().c_str(), x, y, width, height); + } + catch (const CMMError&) + { + width = static_cast(m_frameLayout.width); + height = static_cast(m_frameLayout.height); + } + + if (width <= 0 || height <= 0) + { + width = static_cast(m_frameLayout.width); + height = static_cast(m_frameLayout.height); + } + + m_sourceRoiX = x; + m_sourceRoiY = y; + m_sourceRoiWidth = width; + m_sourceRoiHeight = height; + } + + class ProviderDriverRuntime final : public DriverHostRuntime + { + public: + ProviderDriverRuntime(QString pluginPath, + QString providerId, + QString hostKey, + QJsonObject options, + QObject* parent = nullptr) + : DriverHostRuntime(parent) + , m_pluginPath(std::move(pluginPath)) + , m_providerId(std::move(providerId)) + , m_hostKey(std::move(hostKey)) + , m_options(std::move(options)) + { + } + + ~ProviderDriverRuntime() override + { + stopForExit(); + } + + bool initializeRuntime() override; + QString lastError() const override { return m_lastError; } + void publishHello() override; + void handleRequest(quint64 connectionId, + quint64 requestId, + const QString& type, + const QJsonObject& message) override; + void stopForExit() override; + + private: + enum class FrameDeliveryMode + { + PreviewLatest, + LatestOnly, + AllFrames + }; + + struct CameraTransport + { + QString deviceId; + QString shmKey; + std::unique_ptr shm; + std::atomic deliveryMode{FrameDeliveryMode::PreviewLatest}; + QMutex frameMutex; + QMutex writeMutex; + ImageFrame latestFrame; + bool latestDispatchQueued{false}; + ImageFrame previewFrame; + QTimer* previewTimer{nullptr}; + QElapsedTimer deliveryTimer; + quint64 frameIndex{0}; + int nextWriteSlot{0}; + bool previewRunning{false}; + }; + + QJsonObject makeResponse(const QString& type, quint64 requestId, bool ok) const; + QJsonObject makeErrorResponse(const QString& type, + quint64 requestId, + const QString& error) const; + QJsonObject makeEvent(const QString& type) const; + QJsonArray capabilities(const HardwareDeviceDescriptor& device) const; + bool supportsCapability(const HardwareDeviceDescriptor& device, + const QString& capability) const; + QString deviceKindName(HardwareDeviceKind kind) const; + QJsonObject deviceDescription(const HardwareDeviceDescriptor& device) const; + const HardwareDeviceDescriptor* requestedDevice(const QJsonObject& message) const; + std::shared_ptr cameraTransport(const QString& deviceId) const; + bool createSharedMemory(CameraTransport& transport); + void enqueueFrame(const ImageFrame& frame); + void dispatchLatestFrame(const QString& deviceId); + void publishAllFrame(const QString& deviceId, const ImageFrame& frame); + void publishFrame(const QString& deviceId, const ImageFrame& frame); + void flushPreviewFrame(const QString& deviceId); + bool writeFrame(CameraTransport& transport, + const ImageFrame& frame, + quint64* frameIndexOut = nullptr); + void emitPreviewStateEvent(const QString& deviceId, bool running); + + QString m_pluginPath; + QString m_providerId; + QString m_hostKey; + QJsonObject m_options; + QString m_lastError; + HardwareProviderDescriptor m_descriptor; + QList m_devices; + QHash m_devicesById; + QHash> m_cameraTransports; + mutable QReadWriteLock m_cameraTransportsLock; + std::unique_ptr m_pluginLoader; + HardwareProviderPtr m_provider; + CameraProvider* m_camera{nullptr}; + DevicePropertyProvider* m_properties{nullptr}; + StageProvider* m_stage{nullptr}; + ShutterProvider* m_shutter{nullptr}; + StateProvider* m_state{nullptr}; + ConfigurationProvider* m_configuration{nullptr}; + std::atomic_bool m_acceptFrames{false}; + }; + + bool ProviderDriverRuntime::initializeRuntime() + { + m_lastError.clear(); + m_pluginLoader = std::make_unique(m_pluginPath); + PluginManifest manifest; + if (!parsePluginManifest( + m_pluginLoader->metaData().value(QStringLiteral("MetaData")).toObject(), + PluginKind::Hardware, + manifest, + &m_lastError)) + { + return false; + } + if (manifest.id != m_providerId + || manifest.metadata.value(QStringLiteral("providerId")).toString().trimmed() + != m_providerId) + { + m_lastError = QStringLiteral("Provider manifest identity mismatch"); + return false; + } + QObject* const instance = m_pluginLoader->instance(); + if (!instance) + { + m_lastError = m_pluginLoader->errorString(); + return false; + } + + auto* const factory = qobject_cast(instance); + if (!factory) + { + m_lastError = QStringLiteral("Module does not implement DriverHostProviderPlugin"); + return false; + } + if (factory->providerId() != m_providerId) + { + m_lastError = QStringLiteral("Provider module identity mismatch"); + return false; + } + + m_options.insert(QStringLiteral("providerId"), m_providerId); + m_provider = factory->createProvider(m_options, &m_lastError); + if (!m_provider) + { + if (m_lastError.isEmpty()) + { + m_lastError = QStringLiteral("Provider module returned no provider"); + } + return false; + } + m_descriptor = m_provider->descriptor(); + if (m_descriptor.id != m_providerId + || m_descriptor.id != m_descriptor.id.trimmed()) + { + m_lastError = QStringLiteral("Provider descriptor identity mismatch"); + return false; + } + + HardwareProvider* const provider = m_provider.get(); + m_camera = dynamic_cast(provider); + m_properties = dynamic_cast(provider); + m_stage = dynamic_cast(provider); + m_shutter = dynamic_cast(provider); + m_state = dynamic_cast(provider); + m_configuration = dynamic_cast(provider); + + QSet logicalIds; + int cameraIndex = 0; + for (HardwareDeviceDescriptor device : m_provider->devices()) + { + const QString logicalId = device.logicalId.trimmed(); + const bool capabilityAvailable = + (device.kind != HardwareDeviceKind::Camera || m_camera) + && ((device.kind != HardwareDeviceKind::XYStage + && device.kind != HardwareDeviceKind::ZStage) + || m_stage) + && (device.kind != HardwareDeviceKind::Shutter || m_shutter) + && (device.kind != HardwareDeviceKind::State || m_state); + if (logicalId.isEmpty() + || device.logicalId != logicalId + || device.providerId != m_providerId + || logicalIds.contains(logicalId) + || !capabilityAvailable) + { + m_lastError = QStringLiteral("Provider returned an invalid device catalog"); + return false; + } + logicalIds.insert(logicalId); + device.endpoint = HardwareEndpointKind::DriverHost; + m_devices.append(device); + m_devicesById.insert(logicalId, device); + + if (device.kind == HardwareDeviceKind::Camera) + { + auto transport = std::make_shared(); + transport->deviceId = logicalId; + transport->shmKey = driverhost::sharedMemoryKey(m_hostKey, cameraIndex++); + transport->previewTimer = new QTimer(this); + transport->previewTimer->setSingleShot(true); + transport->previewTimer->setTimerType(Qt::PreciseTimer); + connect(transport->previewTimer, &QTimer::timeout, this, + [this, logicalId]() { flushPreviewFrame(logicalId); }); + if (!createSharedMemory(*transport)) + { + return false; + } + m_cameraTransports.insert(logicalId, std::move(transport)); + } + } + + if (m_camera && !m_cameraTransports.isEmpty()) + { + m_acceptFrames.store(true, std::memory_order_release); + m_camera->setFrameSink([this](const ImageFrame& frame) { enqueueFrame(frame); }); + } + return true; + } + + QJsonObject ProviderDriverRuntime::makeResponse(const QString& type, + quint64 requestId, + bool ok) const + { + QJsonObject response = driverhost::makeEnvelope(driverhost::kMessageKindResponse, + type, + requestId); + response.insert(QStringLiteral("ok"), ok); + return response; + } + + QJsonObject ProviderDriverRuntime::makeErrorResponse(const QString& type, + quint64 requestId, + const QString& error) const + { + QJsonObject response = makeResponse(type, requestId, false); + response.insert(QStringLiteral("error"), error); + return response; + } + + QJsonObject ProviderDriverRuntime::makeEvent(const QString& type) const + { + QJsonObject event = driverhost::makeEnvelope(driverhost::kMessageKindEvent, type); + event.insert(driverhost::kProviderIdField, m_providerId); + return event; + } + + QJsonArray ProviderDriverRuntime::capabilities( + const HardwareDeviceDescriptor& device) const + { + QJsonArray result; + if (m_camera && device.kind == HardwareDeviceKind::Camera) + { + result.append(driverhost::kCapabilityCamera); + } + if (m_properties) result.append(driverhost::kCapabilityProperties); + if (m_stage + && (device.kind == HardwareDeviceKind::XYStage + || device.kind == HardwareDeviceKind::ZStage)) + { + result.append(driverhost::kCapabilityStage); + } + if (m_shutter && device.kind == HardwareDeviceKind::Shutter) + { + result.append(driverhost::kCapabilityShutter); + } + if (m_state && device.kind == HardwareDeviceKind::State) + { + result.append(driverhost::kCapabilityState); + } + if (m_configuration) result.append(driverhost::kCapabilityConfiguration); + return result; + } + + bool ProviderDriverRuntime::supportsCapability( + const HardwareDeviceDescriptor& device, + const QString& capability) const + { + return capabilities(device).contains(QJsonValue(capability)); + } + + QString ProviderDriverRuntime::deviceKindName(HardwareDeviceKind kind) const + { + switch (kind) + { + case HardwareDeviceKind::Camera: return QStringLiteral("Camera"); + case HardwareDeviceKind::XYStage: return QStringLiteral("XYStage"); + case HardwareDeviceKind::ZStage: return QStringLiteral("ZStage"); + case HardwareDeviceKind::Shutter: return QStringLiteral("Shutter"); + case HardwareDeviceKind::State: return QStringLiteral("State"); + case HardwareDeviceKind::Hub: return QStringLiteral("Hub"); + case HardwareDeviceKind::Serial: return QStringLiteral("Serial"); + case HardwareDeviceKind::Generic: return QStringLiteral("Generic"); + case HardwareDeviceKind::AutoFocus: return QStringLiteral("AutoFocus"); + case HardwareDeviceKind::ImageProcessor: return QStringLiteral("ImageProcessor"); + case HardwareDeviceKind::SignalIO: return QStringLiteral("SignalIO"); + case HardwareDeviceKind::Magnifier: return QStringLiteral("Magnifier"); + case HardwareDeviceKind::SLM: return QStringLiteral("SLM"); + case HardwareDeviceKind::Galvo: return QStringLiteral("Galvo"); + case HardwareDeviceKind::PressurePump: return QStringLiteral("PressurePump"); + case HardwareDeviceKind::VolumetricPump: return QStringLiteral("VolumetricPump"); + case HardwareDeviceKind::Unknown: break; + } + return QStringLiteral("Unknown"); + } + + QJsonObject ProviderDriverRuntime::deviceDescription( + const HardwareDeviceDescriptor& device) const + { + QJsonObject object; + object.insert(driverhost::kDeviceIdField, device.logicalId); + object.insert(driverhost::kProviderDeviceIdField, device.providerDeviceId); + object.insert(driverhost::kHardwareIdField, device.hardwareId); + object.insert(driverhost::kDeviceNameField, device.name); + object.insert(driverhost::kDeviceKindField, deviceKindName(device.kind)); + object.insert(driverhost::kDeviceStateField, static_cast(device.state)); + object.insert(driverhost::kDevicePropertiesField, + QJsonObject::fromVariantMap(device.properties)); + object.insert(driverhost::kCapabilitiesField, capabilities(device)); + if (const auto transport = cameraTransport(device.logicalId)) + { + object.insert(driverhost::kSharedMemoryKeyField, transport->shmKey); + } + return object; + } + + const HardwareDeviceDescriptor* ProviderDriverRuntime::requestedDevice( + const QJsonObject& message) const + { + const QString deviceId = message.value(driverhost::kDeviceIdField) + .toString().trimmed(); + const auto it = m_devicesById.constFind(deviceId); + return it == m_devicesById.cend() ? nullptr : &it.value(); + } + + std::shared_ptr + ProviderDriverRuntime::cameraTransport(const QString& deviceId) const + { + QReadLocker locker(&m_cameraTransportsLock); + return m_cameraTransports.value(deviceId.trimmed()); + } + + void ProviderDriverRuntime::publishHello() + { + QJsonObject event = makeEvent(driverhost::kEventHello); + event.insert(driverhost::kProviderIdField, m_providerId); + emit eventReady(event); + } + + bool ProviderDriverRuntime::createSharedMemory(CameraTransport& transport) + { + transport.shm = std::make_unique(); + transport.shm->setNativeKey(transport.shmKey); + const int totalBytes = + kSharedMemoryControlSize + kSharedFrameNumSlots * kSharedFrameSlotStride; + if (!transport.shm->create(totalBytes)) + { + if (transport.shm->attach()) + { + transport.shm->detach(); + } + if (!transport.shm->create(totalBytes)) + { + m_lastError = QStringLiteral("Cannot create shared memory '%1'") + .arg(transport.shmKey); + return false; + } + } + if (!transport.shm->lock()) + { + m_lastError = QStringLiteral("Cannot initialize shared memory '%1'") + .arg(transport.shmKey); + return false; + } + auto* const base = static_cast(transport.shm->data()); + const SharedMemoryControl control{}; + memcpy(base, &control, sizeof(control)); + for (int index = 0; index < kSharedFrameNumSlots; ++index) + { + const SharedFrameHeader header{}; + memcpy(base + kSharedMemoryControlSize + index * kSharedFrameSlotStride, + &header, + sizeof(header)); + } + transport.shm->unlock(); + return true; + } + + void ProviderDriverRuntime::enqueueFrame(const ImageFrame& frame) + { + if (!m_acceptFrames.load(std::memory_order_acquire)) + { + return; + } + const QString deviceId = frame.cameraId.trimmed(); + const auto transport = cameraTransport(deviceId); + if (!transport) + { + return; + } + if (transport->deliveryMode.load(std::memory_order_relaxed) + == FrameDeliveryMode::AllFrames) + { + publishAllFrame(deviceId, frame); + return; + } + + { + QMutexLocker locker(&transport->frameMutex); + transport->latestFrame = frame; + if (transport->latestDispatchQueued) + { + return; + } + transport->latestDispatchQueued = true; + } + QMetaObject::invokeMethod(this, + [this, deviceId]() { dispatchLatestFrame(deviceId); }, + Qt::QueuedConnection); + } + + void ProviderDriverRuntime::dispatchLatestFrame(const QString& deviceId) + { + const auto transport = cameraTransport(deviceId); + if (!transport) return; + ImageFrame frame; + { + QMutexLocker locker(&transport->frameMutex); + frame = transport->latestFrame; + transport->latestDispatchQueued = false; + } + publishFrame(deviceId, frame); + } + + void ProviderDriverRuntime::publishAllFrame(const QString& deviceId, + const ImageFrame& frame) + { + const auto transport = cameraTransport(deviceId); + if (!transport) return; + quint64 frameIndex = 0; + if (!writeFrame(*transport, frame, &frameIndex)) return; + + QJsonObject event = makeEvent(driverhost::kEventFrameAvailable); + event.insert(driverhost::kProviderIdField, m_providerId); + event.insert(driverhost::kDeviceIdField, deviceId); + event.insert(QStringLiteral("frameIndex"), driverhost::encodeUInt64(frameIndex)); + emit eventReady(event); + } + + void ProviderDriverRuntime::publishFrame(const QString& deviceId, + const ImageFrame& frame) + { + const auto transport = cameraTransport(deviceId); + if (!transport) return; + if (transport->deliveryMode.load(std::memory_order_relaxed) + == FrameDeliveryMode::PreviewLatest) + { + transport->previewFrame = frame; + const int remaining = transport->deliveryTimer.isValid() + ? kPreviewFrameDeliveryIntervalMs + - static_cast(transport->deliveryTimer.elapsed()) + : 0; + if (remaining > 0) + { + if (!transport->previewTimer->isActive()) + { + transport->previewTimer->start(remaining); + } + return; + } + } + + quint64 frameIndex = 0; + if (writeFrame(*transport, frame, &frameIndex)) + { + if (transport->deliveryMode.load(std::memory_order_relaxed) + == FrameDeliveryMode::PreviewLatest) + { + transport->deliveryTimer.restart(); + } + QJsonObject event = makeEvent(driverhost::kEventFrameAvailable); + event.insert(driverhost::kProviderIdField, m_providerId); + event.insert(driverhost::kDeviceIdField, deviceId); + event.insert(QStringLiteral("frameIndex"), driverhost::encodeUInt64(frameIndex)); + emit eventReady(event); + } + } + + void ProviderDriverRuntime::flushPreviewFrame(const QString& deviceId) + { + const auto transport = cameraTransport(deviceId); + if (!transport) return; + const ImageFrame frame = transport->previewFrame; + transport->previewFrame = {}; + quint64 frameIndex = 0; + if (writeFrame(*transport, frame, &frameIndex)) + { + transport->deliveryTimer.restart(); + QJsonObject event = makeEvent(driverhost::kEventFrameAvailable); + event.insert(driverhost::kProviderIdField, m_providerId); + event.insert(driverhost::kDeviceIdField, deviceId); + event.insert(QStringLiteral("frameIndex"), driverhost::encodeUInt64(frameIndex)); + emit eventReady(event); + } + } + + bool ProviderDriverRuntime::writeFrame(CameraTransport& transport, + const ImageFrame& frame, + quint64* frameIndexOut) + { + if (!transport.shm || !transport.shm->isAttached() || !frame.isValid() + || frame.cameraId.trimmed() != transport.deviceId + || frame.bytes.size() > kSharedFrameMaxBytes) + { + return false; + } + QMutexLocker writeLocker(&transport.writeMutex); + auto* const base = static_cast(transport.shm->data()); + if (!base) + { + return false; + } + + transport.frameIndex = (std::max)(transport.frameIndex + 1, frame.frameIndex); + const quint64 frameIndex = transport.frameIndex; + + int slotIndex = -1; + uchar* slot = nullptr; + for (int offset = 0; offset < kSharedFrameNumSlots; ++offset) + { + const int candidate = (transport.nextWriteSlot + offset) % kSharedFrameNumSlots; + uchar* const candidateSlot = base + kSharedMemoryControlSize + + candidate * kSharedFrameSlotStride; + auto& stateValue = *reinterpret_cast(candidateSlot); + std::atomic_ref state(stateValue); + quint32 expected = state.load(std::memory_order_acquire); + while (expected == 0 || expected == 2) + { + if (state.compare_exchange_weak(expected, + 1, + std::memory_order_acq_rel, + std::memory_order_acquire)) + { + slotIndex = candidate; + slot = candidateSlot; + break; + } + } + if (slot) + { + break; + } + } + if (!slot) + { + return false; + } + + transport.nextWriteSlot = (slotIndex + 1) % kSharedFrameNumSlots; + SharedFrameHeader header = frame.toSharedFrameHeader(); + header.state = 1; + header.frameIndex = frameIndex; + if (header.timestampNs == 0) + { + header.timestampNs = + static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; + } + memcpy(slot + sizeof(header.state), + reinterpret_cast(&header) + sizeof(header.state), + sizeof(header) - sizeof(header.state)); + memcpy(slot + kSharedFrameHeaderSize, + frame.bytes.constData(), + static_cast(frame.bytes.size())); + + auto& stateValue = *reinterpret_cast(slot); + std::atomic_ref(stateValue).store(2, std::memory_order_release); + auto* const control = reinterpret_cast(base); + std::atomic_ref(control->latestSlotIndex) + .store(static_cast(slotIndex), std::memory_order_release); + if (frameIndexOut) + { + *frameIndexOut = frameIndex; + } + return true; + } + + void ProviderDriverRuntime::emitPreviewStateEvent(const QString& deviceId, bool running) + { + QJsonObject event = makeEvent(driverhost::kEventPreviewState); + event.insert(driverhost::kProviderIdField, m_providerId); + event.insert(driverhost::kDeviceIdField, deviceId); + event.insert(QStringLiteral("running"), running); + emit eventReady(event); + } + + void ProviderDriverRuntime::handleRequest(quint64 connectionId, + quint64 requestId, + const QString& type, + const QJsonObject& message) + { + const auto reply = [this, connectionId](const QJsonObject& response) + { + emit responseReady(connectionId, response); + }; + const auto unsupported = [this, requestId, &type, &reply](const QString& capability) + { + reply(makeErrorResponse(type, + requestId, + QStringLiteral("Provider does not support %1").arg(capability))); + }; + + if (type == driverhost::kCommandDescribe) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(driverhost::kProviderIdField, m_providerId); + response.insert(driverhost::kProviderNameField, m_descriptor.name); + response.insert(driverhost::kProviderVersionField, m_descriptor.version); + QJsonArray devices; + for (const HardwareDeviceDescriptor& device : m_devices) + { + devices.append(deviceDescription(device)); + } + response.insert(driverhost::kDevicesField, devices); + if (m_stage) + { + response.insert(driverhost::kDefaultXYStageField, m_stage->defaultXYStage()); + response.insert(driverhost::kDefaultZStageField, m_stage->defaultZStage()); + } + reply(response); + return; + } + if (type == driverhost::kCommandShutdown) + { + stopForExit(); + reply(makeResponse(type, requestId, true)); + QTimer::singleShot(50, this, [this]() { emit shutdownRequested(); }); + return; + } + if (!m_provider) + { + reply(makeErrorResponse(type, requestId, QStringLiteral("Provider is not initialized"))); + return; + } + + const bool configurationCommand = + type == driverhost::kCommandListConfigGroups + || type == driverhost::kCommandListConfigs + || type == driverhost::kCommandGetCurrentConfig + || type == driverhost::kCommandSetConfig; + const HardwareDeviceDescriptor* const device = + configurationCommand ? nullptr : requestedDevice(message); + if (!configurationCommand && !device) + { + reply(makeErrorResponse(type, requestId, QStringLiteral("Unknown provider device"))); + return; + } + const QString deviceId = device ? device->logicalId : QString{}; + + if (type == driverhost::kCommandSetFrameDeliveryMode) + { + if (!supportsCapability(*device, driverhost::kCapabilityCamera)) + { + unsupported(driverhost::kCapabilityCamera); + return; + } + const auto transport = cameraTransport(deviceId); + if (!transport) + { + reply(makeErrorResponse(type, requestId, + QStringLiteral("Camera transport is unavailable"))); + return; + } + const QString mode = message.value(QStringLiteral("mode")).toString(); + if (mode == driverhost::kFrameDeliveryModePreviewLatest) + { + transport->deliveryMode.store(FrameDeliveryMode::PreviewLatest, + std::memory_order_relaxed); + } + else if (mode == driverhost::kFrameDeliveryModeLatestOnly) + { + transport->deliveryMode.store(FrameDeliveryMode::LatestOnly, + std::memory_order_relaxed); + transport->previewTimer->stop(); + transport->previewFrame = {}; + } + else if (mode == driverhost::kFrameDeliveryModeAllFrames) + { + transport->deliveryMode.store(FrameDeliveryMode::AllFrames, + std::memory_order_relaxed); + transport->previewTimer->stop(); + transport->previewFrame = {}; + } + else + { + reply(makeErrorResponse(type, + requestId, + QStringLiteral("Unknown frame delivery mode"))); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("mode"), mode); + reply(response); + return; + } + + if (type == driverhost::kCommandStartPreview + || type == driverhost::kCommandStopPreview) + { + if (!supportsCapability(*device, driverhost::kCapabilityCamera)) + { + unsupported(driverhost::kCapabilityCamera); + return; + } + const bool start = type == driverhost::kCommandStartPreview; + const bool ok = start + ? m_camera->startPreviewFor(deviceId) + : m_camera->stopPreviewFor(deviceId); + if (!ok) + { + reply(makeErrorResponse(type, + requestId, + QStringLiteral("Provider rejected preview request"))); + return; + } + if (const auto transport = cameraTransport(deviceId)) + { + transport->previewRunning = start; + } + reply(makeResponse(type, requestId, true)); + emitPreviewStateEvent(deviceId, start); + return; + } + + if (type == driverhost::kCommandGetExposure) + { + if (!supportsCapability(*device, driverhost::kCapabilityCamera)) + { + unsupported(driverhost::kCapabilityCamera); + return; + } + double exposureMs = 0.0; + if (!m_camera->getExposure(deviceId, exposureMs)) + { + reply(makeErrorResponse(type, + requestId, + QStringLiteral("Provider failed to read exposure"))); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("exposureMs"), exposureMs); + reply(response); + return; + } + + if (type == driverhost::kCommandSetExposure) + { + if (!supportsCapability(*device, driverhost::kCapabilityCamera)) + { + unsupported(driverhost::kCapabilityCamera); + return; + } + const double exposureMs = message.value(QStringLiteral("value")).toDouble(-1.0); + double actualExposureMs = 0.0; + if (exposureMs <= 0.0 + || !m_camera->setExposure(deviceId, exposureMs) + || !m_camera->getExposure(deviceId, actualExposureMs)) + { + reply(makeErrorResponse(type, + requestId, + QStringLiteral("Provider rejected exposure"))); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("exposureMs"), actualExposureMs); + reply(response); + return; + } + + if (type == driverhost::kCommandSetRoi + || type == driverhost::kCommandClearRoi + || type == driverhost::kCommandGetRoi) + { + if (!supportsCapability(*device, driverhost::kCapabilityCamera)) + { + unsupported(driverhost::kCapabilityCamera); + return; + } + if (type == driverhost::kCommandSetRoi) + { + const bool ok = m_camera->setROI(deviceId, + message.value(QStringLiteral("x")).toInt(), + message.value(QStringLiteral("y")).toInt(), + message.value(QStringLiteral("width")).toInt(), + message.value(QStringLiteral("height")).toInt()); + reply(ok ? makeResponse(type, requestId, true) + : makeErrorResponse(type, + requestId, + QStringLiteral("Provider rejected ROI"))); + return; + } + if (type == driverhost::kCommandClearRoi) + { + const bool ok = m_camera->clearROI(deviceId); + reply(ok ? makeResponse(type, requestId, true) + : makeErrorResponse(type, + requestId, + QStringLiteral("Provider rejected ROI reset"))); + return; + } + + int x = 0; + int y = 0; + int width = 0; + int height = 0; + if (!m_camera->getROI(deviceId, x, y, width, height)) + { + reply(makeErrorResponse(type, + requestId, + QStringLiteral("Provider failed to read ROI"))); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("x"), x); + response.insert(QStringLiteral("y"), y); + response.insert(QStringLiteral("width"), width); + response.insert(QStringLiteral("height"), height); + reply(response); + return; + } + + if (type == driverhost::kCommandCaptureEvent) + { + if (!supportsCapability(*device, driverhost::kCapabilityCamera)) + { + unsupported(driverhost::kCapabilityCamera); + return; + } + ImageFrame frame; + quint64 frameIndex = 0; + const auto transport = cameraTransport(deviceId); + const int timeoutMs = (std::max)(1, + message.value(QStringLiteral("timeoutMs")).toInt(1500)); + if (!transport + || !m_camera->captureEventFrame(deviceId, frame, timeoutMs) + || !writeFrame(*transport, frame, &frameIndex)) + { + reply(makeErrorResponse(type, + requestId, + QStringLiteral("Provider failed to capture a frame"))); + return; + } + QJsonObject event = makeEvent(driverhost::kEventFrameAvailable); + event.insert(driverhost::kProviderIdField, m_providerId); + event.insert(driverhost::kDeviceIdField, deviceId); + event.insert(QStringLiteral("frameIndex"), driverhost::encodeUInt64(frameIndex)); + emit eventReady(event); + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("frameIndex"), driverhost::encodeUInt64(frameIndex)); + reply(response); + return; + } + + if (type == driverhost::kCommandListProperties + || type == driverhost::kCommandGetProperty + || type == driverhost::kCommandSetProperty) + { + if (!supportsCapability(*device, driverhost::kCapabilityProperties)) + { + unsupported(driverhost::kCapabilityProperties); + return; + } + if (type == driverhost::kCommandListProperties) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("properties"), + QJsonArray::fromStringList( + m_properties->listProperties(deviceId))); + reply(response); + return; + } + + const QString name = message.value(QStringLiteral("name")).toString(); + if (type == driverhost::kCommandSetProperty) + { + QString error; + const bool ok = m_properties->setProperty( + deviceId, + name, + message.value(QStringLiteral("value")).toString(), + &error); + reply(ok ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("value"), + m_properties->getProperty( + deviceId, + name, + message.value(QStringLiteral("fromCache")).toBool(false))); + response.insert(QStringLiteral("propertyType"), + m_properties->getPropertyType(deviceId, name)); + response.insert(QStringLiteral("readOnly"), + m_properties->isPropertyReadOnly(deviceId, name)); + response.insert(QStringLiteral("preInit"), + m_properties->isPropertyPreInit(deviceId, name)); + response.insert(QStringLiteral("allowedValues"), + QJsonArray::fromStringList( + m_properties->getAllowedPropertyValues(deviceId, name))); + const bool hasLimits = m_properties->hasPropertyLimits(deviceId, name); + response.insert(QStringLiteral("hasLimits"), hasLimits); + response.insert(QStringLiteral("lowerLimit"), + hasLimits + ? m_properties->getPropertyLowerLimit(deviceId, name) + : 0.0); + response.insert(QStringLiteral("upperLimit"), + hasLimits + ? m_properties->getPropertyUpperLimit(deviceId, name) + : 0.0); + reply(response); + return; + } + + if (type == driverhost::kCommandGetXYPosition + || type == driverhost::kCommandGetZPosition + || type == driverhost::kCommandSetRelativeXYPosition + || type == driverhost::kCommandSetRelativeZPosition + || type == driverhost::kCommandSetXYPosition + || type == driverhost::kCommandSetZPosition) + { + if (!supportsCapability(*device, driverhost::kCapabilityStage)) + { + unsupported(driverhost::kCapabilityStage); + return; + } + QString error; + if (type == driverhost::kCommandGetXYPosition) + { + double x = 0.0; + double y = 0.0; + if (!m_stage->getXYPosition(deviceId, x, y, &error)) + { + reply(makeErrorResponse(type, requestId, error)); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("x"), x); + response.insert(QStringLiteral("y"), y); + reply(response); + return; + } + if (type == driverhost::kCommandGetZPosition) + { + double z = 0.0; + if (!m_stage->getZPosition(deviceId, z, &error)) + { + reply(makeErrorResponse(type, requestId, error)); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("z"), z); + reply(response); + return; + } + + bool ok = false; + if (type == driverhost::kCommandSetRelativeXYPosition) + { + ok = m_stage->setRelativeXYPosition( + deviceId, + message.value(QStringLiteral("x")).toDouble(), + message.value(QStringLiteral("y")).toDouble(), + &error); + } + else if (type == driverhost::kCommandSetRelativeZPosition) + { + ok = m_stage->setRelativeZPosition( + deviceId, + message.value(QStringLiteral("z")).toDouble(), + &error); + } + else if (type == driverhost::kCommandSetXYPosition) + { + ok = m_stage->setXYPosition( + deviceId, + message.value(QStringLiteral("x")).toDouble(), + message.value(QStringLiteral("y")).toDouble(), + &error); + } + else + { + ok = m_stage->setZPosition( + deviceId, + message.value(QStringLiteral("z")).toDouble(), + &error); + } + reply(ok ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + if (type == driverhost::kCommandGetShutterOpen + || type == driverhost::kCommandSetShutterOpen) + { + if (!supportsCapability(*device, driverhost::kCapabilityShutter)) + { + unsupported(driverhost::kCapabilityShutter); + return; + } + QString error; + if (type == driverhost::kCommandGetShutterOpen) + { + bool open = false; + if (!m_shutter->isShutterOpen(deviceId, open, &error)) + { + reply(makeErrorResponse(type, requestId, error)); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("open"), open); + reply(response); + return; + } + const bool ok = m_shutter->setShutterOpen( + deviceId, + message.value(QStringLiteral("open")).toBool(), + &error); + reply(ok ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + if (type == driverhost::kCommandGetState + || type == driverhost::kCommandSetState + || type == driverhost::kCommandGetStateLabel) + { + if (!supportsCapability(*device, driverhost::kCapabilityState)) + { + unsupported(driverhost::kCapabilityState); + return; + } + const long requestedState = static_cast( + message.value(QStringLiteral("state")).toDouble()); + if (type == driverhost::kCommandGetStateLabel) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("label"), + m_state->stateLabel(deviceId, requestedState)); + reply(response); + return; + } + QString error; + if (type == driverhost::kCommandSetState) + { + const bool ok = m_state->setState(deviceId, requestedState, &error); + reply(ok ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + long state = 0; + if (!m_state->getState(deviceId, state, &error)) + { + reply(makeErrorResponse(type, requestId, error)); + return; + } + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("state"), static_cast(state)); + response.insert(QStringLiteral("label"), m_state->stateLabel(deviceId, state)); + reply(response); + return; + } + + if (type == driverhost::kCommandListConfigGroups + || type == driverhost::kCommandListConfigs + || type == driverhost::kCommandGetCurrentConfig + || type == driverhost::kCommandSetConfig) + { + if (!m_configuration) + { + unsupported(driverhost::kCapabilityConfiguration); + return; + } + if (type == driverhost::kCommandListConfigGroups) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("groups"), + QJsonArray::fromStringList( + m_configuration->availableConfigGroups())); + reply(response); + return; + } + const QString group = message.value(QStringLiteral("group")).toString(); + if (type == driverhost::kCommandListConfigs) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("configs"), + QJsonArray::fromStringList( + m_configuration->availableConfigs(group))); + reply(response); + return; + } + if (type == driverhost::kCommandGetCurrentConfig) + { + QJsonObject response = makeResponse(type, requestId, true); + response.insert(QStringLiteral("config"), + m_configuration->currentConfig(group)); + reply(response); + return; + } + QString error; + const bool ok = m_configuration->setConfig( + group, + message.value(QStringLiteral("config")).toString(), + &error); + reply(ok ? makeResponse(type, requestId, true) + : makeErrorResponse(type, requestId, error)); + return; + } + + reply(makeErrorResponse(type, requestId, QStringLiteral("Unknown control command"))); + } + + void ProviderDriverRuntime::stopForExit() + { + m_acceptFrames.store(false, std::memory_order_release); + for (const auto& transport : m_cameraTransports) + { + if (transport && transport->previewTimer) + { + transport->previewTimer->stop(); + } + } + if (m_camera) + { + for (const auto& transport : m_cameraTransports) + { + if (transport && transport->previewRunning) + { + m_camera->stopPreviewFor(transport->deviceId); + transport->previewRunning = false; + } + } + m_camera->setFrameSink({}); + } + for (const auto& transport : m_cameraTransports) + { + if (!transport) continue; + QMutexLocker locker(&transport->frameMutex); + transport->latestFrame = {}; + transport->latestDispatchQueued = false; + transport->previewFrame = {}; + } + m_camera = nullptr; + m_properties = nullptr; + m_stage = nullptr; + m_shutter = nullptr; + m_state = nullptr; + m_configuration = nullptr; + m_provider.reset(); + { + QWriteLocker locker(&m_cameraTransportsLock); + m_cameraTransports.clear(); + } + m_devicesById.clear(); + m_devices.clear(); + if (m_pluginLoader) + { + m_pluginLoader->unload(); + m_pluginLoader.reset(); + } + } + + class DriverHost final : public QObject + { + Q_OBJECT + + public: + // Create the control server wrapper for one isolated runtime + DriverHost(QString hostKey, + DriverHostRuntime* runtime, + QObject* parent = nullptr) + : QObject(parent) + , m_hostKey(std::move(hostKey)) + , m_serverName(driverhost::controlServerName(m_hostKey)) + , m_runtime(runtime) + { + qRegisterMetaType("QJsonObject"); + qRegisterMetaType("quint64"); + } + + // Stop the runtime thread before destruction + ~DriverHost() override + { + stopRuntime(); + } + + bool start(); + + private slots: + void onNewControlConnection(); + void onRuntimeResponse(quint64 connectionId, const QJsonObject& response); + void broadcastEvent(const QJsonObject& event); + void onConnectionClosed(quint64 connectionId); + + private: + void stopRuntime(); + + QString m_hostKey; + QString m_serverName; + + std::unique_ptr m_ctrlServer; + QThread m_runtimeThread; + DriverHostRuntime* m_runtime{nullptr}; + quint64 m_nextConnectionId{1}; + QHash m_connections; + }; + + // Start the runtime thread and local control server + bool DriverHost::start() + { + if (!m_runtime) + { + return false; + } + m_runtime->moveToThread(&m_runtimeThread); + connect(&m_runtimeThread, &QThread::finished, + m_runtime, &QObject::deleteLater); + connect(m_runtime, &DriverHostRuntime::responseReady, + this, &DriverHost::onRuntimeResponse); + connect(m_runtime, &DriverHostRuntime::eventReady, + this, &DriverHost::broadcastEvent); + connect(m_runtime, &DriverHostRuntime::shutdownRequested, this, + []() { QCoreApplication::quit(); }); + + m_runtimeThread.start(); + + bool initialized = false; + QString initError; + QMetaObject::invokeMethod( + m_runtime, + [this, &initialized, &initError]() + { + initialized = m_runtime->initializeRuntime(); + initError = m_runtime->lastError(); + }, + Qt::BlockingQueuedConnection); + + if (!initialized) + { + qCritical().noquote() + << QString("DriverHost init failed for '%1': %2") + .arg(m_hostKey, initError); + stopRuntime(); + return false; + } + + QLocalServer::removeServer(m_serverName); + m_ctrlServer = std::make_unique(this); + connect(m_ctrlServer.get(), &QLocalServer::newConnection, + this, &DriverHost::onNewControlConnection); + if (!m_ctrlServer->listen(m_serverName)) + { + qCritical().noquote() + << QString("DriverHost control server failed to listen on %1") + .arg(m_serverName); + stopRuntime(); + return false; + } + + qInfo().noquote() + << QString("DriverHost control server listening on %1").arg(m_serverName); + return true; + } + + // Accept pending local control connections + void DriverHost::onNewControlConnection() + { + while (m_ctrlServer && m_ctrlServer->hasPendingConnections()) + { + QLocalSocket* socket = m_ctrlServer->nextPendingConnection(); + if (!socket) + { + continue; + } + + const quint64 connectionId = m_nextConnectionId++; + auto* connection = new ControlConnection(connectionId, socket, this); + m_connections.insert(connectionId, connection); + + connect(connection, &ControlConnection::requestReceived, + m_runtime, &DriverHostRuntime::handleRequest, + Qt::QueuedConnection); + connect(connection, &ControlConnection::connectionClosed, + this, &DriverHost::onConnectionClosed); + + DriverHostRuntime* const runtime = m_runtime; + QMetaObject::invokeMethod(runtime, + [runtime]() { runtime->publishHello(); }, + Qt::QueuedConnection); + } + } + + // Route one runtime response back to its connection + void DriverHost::onRuntimeResponse(quint64 connectionId, const QJsonObject& response) + { + auto it = m_connections.find(connectionId); + if (it == m_connections.end() || !it.value()) + { + return; + } + it.value()->sendMessage(response); + } + + // Broadcast one runtime event to all clients + void DriverHost::broadcastEvent(const QJsonObject& event) + { + for (auto it = m_connections.begin(); it != m_connections.end(); ++it) + { + if (it.value()) + { + it.value()->sendMessage(event); + } + } + } + + // Remove a closed control connection + void DriverHost::onConnectionClosed(quint64 connectionId) + { + m_connections.remove(connectionId); + } + + // Stop the runtime worker thread cleanly + void DriverHost::stopRuntime() + { + if (!m_runtime) + { + return; + } + + DriverHostRuntime* const runtime = m_runtime; + QMetaObject::invokeMethod(runtime, + [runtime]() { runtime->stopForExit(); }, + Qt::BlockingQueuedConnection); + m_runtimeThread.quit(); + m_runtimeThread.wait(); + m_runtime = nullptr; + } +} // namespace scopeone::core::internal + +// Launch one provider in an isolated DriverHost process +int main(int argc, char* argv[]) +{ + QCoreApplication app(argc, argv); + + QCommandLineParser parser; + parser.setApplicationDescription("ScopeOne DriverHost"); + parser.addHelpOption(); + + QCommandLineOption optProvider(QStringLiteral("provider"), + QStringLiteral("Hardware provider ID"), + QStringLiteral("id")); + QCommandLineOption optDeviceId(QStringLiteral("deviceId"), + QStringLiteral("Provider device ID"), + QStringLiteral("id")); + QCommandLineOption optPlugin(QStringLiteral("plugin"), + QStringLiteral("Provider module path"), + QStringLiteral("path")); + QCommandLineOption optHostKey(QStringLiteral("hostKey"), + QStringLiteral("DriverHost instance key"), + QStringLiteral("key")); + QCommandLineOption optProviderOption(QStringLiteral("option"), + QStringLiteral("JSON provider options"), + QStringLiteral("json")); + QCommandLineOption optAdapter(QStringLiteral("adapter"), + QStringLiteral("MM adapter"), + QStringLiteral("adapter")); + QCommandLineOption optDevice(QStringLiteral("device"), + QStringLiteral("MM device"), + QStringLiteral("device")); + QCommandLineOption optShm(QStringLiteral("shm"), + QStringLiteral("Shared memory key"), + QStringLiteral("key")); + QCommandLineOption optExp(QStringLiteral("exposure"), + QStringLiteral("Exposure ms"), + QStringLiteral("ms")); + QCommandLineOption optPreInit(QStringLiteral("preinit"), + QStringLiteral("JSON-encoded pre-init property"), + QStringLiteral("json")); + QCommandLineOption optProperty(QStringLiteral("property"), + QStringLiteral("JSON-encoded initialized property"), + QStringLiteral("json")); + QCommandLineOption optAuto(QStringLiteral("autoPreview"), + QStringLiteral("Start preview immediately")); + + parser.addOption(optProvider); + parser.addOption(optDeviceId); + parser.addOption(optPlugin); + parser.addOption(optHostKey); + parser.addOption(optProviderOption); + parser.addOption(optAdapter); + parser.addOption(optDevice); + parser.addOption(optShm); + parser.addOption(optExp); + parser.addOption(optPreInit); + parser.addOption(optProperty); + parser.addOption(optAuto); + parser.process(app); + + if (!parser.isSet(optProvider)) + { + qCritical().noquote() + << "Missing required argument: --provider"; + return 2; + } + + const QString providerId = parser.value(optProvider).trimmed(); + const QString deviceId = parser.value(optDeviceId).trimmed(); + if (providerId.isEmpty()) + { + qCritical().noquote() << "Provider ID cannot be empty"; + return 2; + } + + const double exposureMs = parser.isSet(optExp) + ? parser.value(optExp).toDouble() + : 0.0; + scopeone::core::internal::DriverHostRuntime* runtime = nullptr; + if (providerId == QStringLiteral("micro-manager")) + { + if (deviceId.isEmpty() + || !parser.isSet(optAdapter) + || !parser.isSet(optDevice) + || !parser.isSet(optShm)) + { + qCritical().noquote() + << "Micro-Manager requires --deviceId, --adapter, --device and --shm"; + return 2; + } + runtime = new scopeone::core::internal::MicroManagerDriverRuntime( + providerId, + deviceId, + parser.value(optAdapter), + parser.value(optDevice), + parser.value(optShm), + parser.values(optPreInit), + parser.values(optProperty), + exposureMs, + parser.isSet(optAuto)); + } + else + { + const QString pluginPath = parser.value(optPlugin).trimmed(); + const QString hostKey = parser.value(optHostKey).trimmed(); + if (pluginPath.isEmpty() || hostKey.isEmpty()) + { + qCritical().noquote() << "External providers require --plugin and --hostKey"; + return 2; + } + + QJsonObject providerOptions; + for (const QString& encodedOptions : parser.values(optProviderOption)) + { + QJsonParseError parseError; + const QJsonDocument document = + QJsonDocument::fromJson(encodedOptions.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + qCritical().noquote() << QStringLiteral("Invalid --option JSON: %1") + .arg(parseError.errorString()); + return 2; + } + const QJsonObject object = document.object(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) + { + providerOptions.insert(it.key(), it.value()); + } + } + + runtime = new scopeone::core::internal::ProviderDriverRuntime( + pluginPath, + providerId, + hostKey, + providerOptions); + } + + const QString serverKey = providerId == QStringLiteral("micro-manager") + ? deviceId + : parser.value(optHostKey).trimmed(); + scopeone::core::internal::DriverHost driverHost(serverKey, runtime); + if (!driverHost.start()) + { + return 2; + } + + return app.exec(); +} + +#include "DriverHostMain.moc" diff --git a/ScopeOneCore/src/DriverHostProviderProxy.cpp b/ScopeOneCore/src/DriverHostProviderProxy.cpp new file mode 100644 index 0000000..4b789e8 --- /dev/null +++ b/ScopeOneCore/src/DriverHostProviderProxy.cpp @@ -0,0 +1,1564 @@ +#include "internal/DriverHostProviderProxy.h" + +#include "internal/CameraRuntimeControl.h" +#include "internal/DriverHostProtocol.h" +#include "internal/SharedFrameRing.h" +#include "scopeone/CameraProvider.h" +#include "scopeone/HardwareCapabilities.h" +#include "scopeone/SharedFrame.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::core::internal +{ + namespace + { + constexpr int kControlReadyTimeoutMs = 15000; + constexpr int kControlRequestTimeoutMs = 10000; + + HardwareDeviceKind deviceKindFromName(const QString& name) + { + if (name == QStringLiteral("Camera")) return HardwareDeviceKind::Camera; + if (name == QStringLiteral("XYStage")) return HardwareDeviceKind::XYStage; + if (name == QStringLiteral("ZStage")) return HardwareDeviceKind::ZStage; + if (name == QStringLiteral("Shutter")) return HardwareDeviceKind::Shutter; + if (name == QStringLiteral("State")) return HardwareDeviceKind::State; + if (name == QStringLiteral("Hub")) return HardwareDeviceKind::Hub; + if (name == QStringLiteral("Serial")) return HardwareDeviceKind::Serial; + if (name == QStringLiteral("Generic")) return HardwareDeviceKind::Generic; + if (name == QStringLiteral("AutoFocus")) return HardwareDeviceKind::AutoFocus; + if (name == QStringLiteral("ImageProcessor")) return HardwareDeviceKind::ImageProcessor; + if (name == QStringLiteral("SignalIO")) return HardwareDeviceKind::SignalIO; + if (name == QStringLiteral("Magnifier")) return HardwareDeviceKind::Magnifier; + if (name == QStringLiteral("SLM")) return HardwareDeviceKind::SLM; + if (name == QStringLiteral("Galvo")) return HardwareDeviceKind::Galvo; + if (name == QStringLiteral("PressurePump")) return HardwareDeviceKind::PressurePump; + if (name == QStringLiteral("VolumetricPump")) return HardwareDeviceKind::VolumetricPump; + return HardwareDeviceKind::Unknown; + } + + bool responseSucceeded(const QJsonObject& response, QString* errorMessage) + { + if (response.value(QStringLiteral("ok")).toBool(false)) + { + if (errorMessage) errorMessage->clear(); + return true; + } + if (errorMessage) + { + *errorMessage = response.value(QStringLiteral("error")) + .toString(QStringLiteral("DriverHost request failed")); + } + return false; + } + + } + + class DriverHostProviderTransport final : public QObject + { + public: + using FrameHandler = std::function; + using PreviewHandler = std::function; + + DriverHostProviderTransport(QString providerId, + QString pluginPath, + QString hostKey, + QJsonObject options, + FrameHandler frameHandler, + PreviewHandler previewHandler) + : m_providerId(std::move(providerId)) + , m_pluginPath(std::move(pluginPath)) + , m_hostKey(std::move(hostKey)) + , m_options(std::move(options)) + , m_frameHandler(std::move(frameHandler)) + , m_previewHandler(std::move(previewHandler)) + { + } + + bool initialize(HardwareProviderDescriptor& descriptor, + QList& devices, + QString& defaultXYStage, + QString& defaultZStage, + QString* errorMessage) + { + m_socket = std::make_unique(); + connect(m_socket.get(), &QLocalSocket::readyRead, + this, [this]() { handleReadyRead(); }); + connect(m_socket.get(), &QLocalSocket::disconnected, + this, [this]() + { + m_lastError = QStringLiteral("DriverHost control connection closed"); + finishWaitingRequest(); + }); + const QString driverHostPath = QDir(QCoreApplication::applicationDirPath()) + .filePath(driverhost::kExecutableFileName); + if (!QFileInfo::exists(driverHostPath)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("DriverHost executable not found: %1") + .arg(driverHostPath); + } + return false; + } + if (!QFileInfo::exists(m_pluginPath)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Provider module not found: %1") + .arg(m_pluginPath); + } + return false; + } + + m_process = std::make_unique(); + m_process->setProcessChannelMode(QProcess::MergedChannels); + connect(m_process.get(), &QProcess::readyReadStandardOutput, + this, [this]() + { + const QString output = QString::fromUtf8( + m_process->readAllStandardOutput()).trimmed(); + if (!output.isEmpty()) + { + qInfo().noquote() + << QString("[DriverHost %1] %2").arg(m_providerId, output); + } + }); + connect(m_process.get(), &QProcess::finished, + this, [this](int, QProcess::ExitStatus) + { + m_lastError = QStringLiteral("DriverHost process exited"); + for (const QString& cameraId : m_cameraIds) + { + if (m_previewHandler) m_previewHandler(cameraId, false); + } + m_runningCameras.clear(); + finishWaitingRequest(); + }); + + QStringList arguments; + arguments << QStringLiteral("--provider") << m_providerId + << QStringLiteral("--plugin") << m_pluginPath + << QStringLiteral("--hostKey") << m_hostKey; + if (!m_options.isEmpty()) + { + arguments << QStringLiteral("--option") + << QString::fromUtf8( + QJsonDocument(m_options).toJson(QJsonDocument::Compact)); + } + m_process->setProgram(driverHostPath); + m_process->setArguments(arguments); + m_process->start(); + if (!m_process->waitForStarted(3000)) + { + if (errorMessage) *errorMessage = QStringLiteral("Failed to start DriverHost"); + return false; + } + + const QString serverName = driverhost::controlServerName(m_hostKey); + QElapsedTimer connectTimer; + connectTimer.start(); + while (connectTimer.elapsed() < kControlReadyTimeoutMs) + { + m_socket->abort(); + m_socket->connectToServer(serverName); + if (m_socket->waitForConnected(200)) + { + break; + } + if (m_process->state() == QProcess::NotRunning) + { + break; + } + } + if (m_socket->state() != QLocalSocket::ConnectedState) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("DriverHost control server did not become ready"); + } + cleanup(); + return false; + } + + QJsonObject request; + request.insert(driverhost::kMessageTypeField, driverhost::kCommandDescribe); + QJsonObject response; + if (!sendRequest(request, response, kControlRequestTimeoutMs, errorMessage) + || !responseSucceeded(response, errorMessage)) + { + cleanup(); + return false; + } + + descriptor.id = response.value(driverhost::kProviderIdField).toString(); + descriptor.name = response.value(driverhost::kProviderNameField).toString(); + descriptor.version = response.value(driverhost::kProviderVersionField).toString(); + if (descriptor.id != m_providerId) + { + if (errorMessage) *errorMessage = QStringLiteral("Provider identity mismatch"); + cleanup(); + return false; + } + + QSet logicalIds; + const QJsonArray deviceArray = response.value(driverhost::kDevicesField).toArray(); + for (const QJsonValue& value : deviceArray) + { + const QJsonObject object = value.toObject(); + HardwareDeviceDescriptor device; + device.logicalId = object.value(driverhost::kDeviceIdField).toString(); + device.providerId = descriptor.id; + device.providerDeviceId = + object.value(driverhost::kProviderDeviceIdField).toString(); + device.hardwareId = object.value(driverhost::kHardwareIdField).toString(); + device.name = object.value(driverhost::kDeviceNameField).toString(); + device.kind = deviceKindFromName( + object.value(driverhost::kDeviceKindField).toString()); + device.state = static_cast( + object.value(driverhost::kDeviceStateField) + .toInt(static_cast(HardwareDeviceState::Unknown))); + device.endpoint = HardwareEndpointKind::DriverHost; + device.properties = object.value(driverhost::kDevicePropertiesField) + .toObject().toVariantMap(); + if (device.logicalId.trimmed().isEmpty() + || device.logicalId != device.logicalId.trimmed() + || logicalIds.contains(device.logicalId)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("DriverHost returned an invalid device catalog"); + } + cleanup(); + return false; + } + logicalIds.insert(device.logicalId); + devices.append(device); + + if (device.kind == HardwareDeviceKind::Camera) + { + const QString shmKey = + object.value(driverhost::kSharedMemoryKeyField).toString(); + if (shmKey.isEmpty()) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Camera '%1' has no shared memory mapping") + .arg(device.logicalId); + } + cleanup(); + return false; + } + auto reader = std::make_shared(); + reader->cameraId = device.logicalId; + reader->shmKey = shmKey; + reader->shm = std::make_unique(); + reader->shm->setNativeKey(shmKey); + m_readers.insert(device.logicalId, std::move(reader)); + m_cameraIds.append(device.logicalId); + } + } + defaultXYStage = response.value(driverhost::kDefaultXYStageField).toString(); + defaultZStage = response.value(driverhost::kDefaultZStageField).toString(); + if (errorMessage) errorMessage->clear(); + return true; + } + + bool sendRequest(QJsonObject request, + QJsonObject& response, + int timeoutMs, + QString* errorMessage) + { + if (!m_socket + || m_socket->state() != QLocalSocket::ConnectedState + || m_waitingLoop) + { + if (errorMessage) + { + *errorMessage = m_waitingLoop + ? QStringLiteral("Nested DriverHost request") + : QStringLiteral("DriverHost is not connected"); + } + return false; + } + + m_waitingRequestId = m_nextRequestId++; + m_waitingResponse = {}; + m_lastError.clear(); + request.insert(driverhost::kEnvelopeKindField, driverhost::kMessageKindRequest); + request.insert(driverhost::kEnvelopeVersionField, + static_cast(driverhost::kProtocolVersion)); + request.insert(driverhost::kEnvelopeRequestIdField, + driverhost::encodeUInt64(m_waitingRequestId)); + + QEventLoop loop; + QTimer timer; + timer.setSingleShot(true); + connect(&timer, &QTimer::timeout, &loop, &QEventLoop::quit); + struct LoopGuard + { + QEventLoop*& target; + ~LoopGuard() { target = nullptr; } + } loopGuard{m_waitingLoop}; + m_waitingLoop = &loop; + m_socket->write(driverhost::encodeMessage(request)); + m_socket->flush(); + timer.start((std::max)(1, timeoutMs)); + loop.exec(); + + if (m_waitingResponse.isEmpty()) + { + if (errorMessage) + { + *errorMessage = m_lastError.isEmpty() + ? QStringLiteral("DriverHost request timed out") + : m_lastError; + } + m_waitingRequestId = 0; + return false; + } + response = m_waitingResponse; + m_waitingResponse = {}; + m_waitingRequestId = 0; + if (errorMessage) errorMessage->clear(); + return true; + } + + bool setFrameDeliveryMode(const QString& mode, QString* errorMessage) + { + const QString previousMode = m_deliveryMode; + QStringList changed; + for (const QString& cameraId : m_cameraIds) + { + QJsonObject request; + request.insert(driverhost::kMessageTypeField, + driverhost::kCommandSetFrameDeliveryMode); + request.insert(driverhost::kDeviceIdField, cameraId); + request.insert(QStringLiteral("mode"), mode); + QJsonObject response; + if (!sendRequest(request, response, kControlRequestTimeoutMs, errorMessage) + || !responseSucceeded(response, errorMessage)) + { + for (const QString& changedCameraId : changed) + { + QJsonObject rollback; + rollback.insert(driverhost::kMessageTypeField, + driverhost::kCommandSetFrameDeliveryMode); + rollback.insert(driverhost::kDeviceIdField, changedCameraId); + rollback.insert(QStringLiteral("mode"), previousMode); + QJsonObject ignored; + sendRequest(rollback, ignored, kControlRequestTimeoutMs, nullptr); + } + return false; + } + changed.append(cameraId); + } + m_deliveryMode = mode; + m_readAllFrames = mode == driverhost::kFrameDeliveryModeAllFrames; + return true; + } + + void setFrameDeliveryPaused(bool paused) + { + m_frameDeliveryPaused = paused; + if (!paused) + { + for (const QString& cameraId : m_runningCameras) + { + deliverAvailableFrames(cameraId, false); + } + } + } + + bool captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs, + QString* errorMessage) + { + auto it = m_readers.find(cameraId); + if (it == m_readers.end() || !ensureSharedMemory(*it.value())) + { + if (errorMessage) *errorMessage = QStringLiteral("Camera shared memory is unavailable"); + return false; + } + QList discarded; + readLatest(*it.value(), discarded); + const quint64 previousIndex = it.value()->lastFrameIndex; + + QJsonObject request; + request.insert(driverhost::kMessageTypeField, driverhost::kCommandCaptureEvent); + request.insert(driverhost::kDeviceIdField, cameraId); + QJsonObject response; + const int waitMs = (std::max)(1, timeoutMs); + request.insert(QStringLiteral("timeoutMs"), waitMs); + if (!sendRequest(request, response, waitMs + 500, errorMessage) + || !responseSucceeded(response, errorMessage)) + { + return false; + } + const quint64 targetIndex = driverhost::decodeUInt64( + response.value(QStringLiteral("frameIndex"))); + + QElapsedTimer timer; + timer.start(); + while (timer.elapsed() <= waitMs) + { + QList frames; + readLatest(*it.value(), frames); + const ImageFrame candidate = !frames.isEmpty() + ? frames.constLast() + : it.value()->latestFrame; + if (candidate.isValid() + && candidate.frameIndex > previousIndex + && (targetIndex == 0 || candidate.frameIndex >= targetIndex)) + { + frame = candidate; + if (errorMessage) errorMessage->clear(); + return true; + } + QEventLoop loop; + QTimer::singleShot(1, &loop, &QEventLoop::quit); + loop.exec(); + } + if (errorMessage) *errorMessage = QStringLiteral("Timed out waiting for captured frame"); + return false; + } + + void cleanup() + { + if (m_cleanedUp) return; + m_cleanedUp = true; + if (m_socket && m_socket->state() == QLocalSocket::ConnectedState) + { + QJsonObject request; + request.insert(driverhost::kMessageTypeField, driverhost::kCommandShutdown); + QJsonObject response; + sendRequest(request, response, 800, nullptr); + m_socket->disconnectFromServer(); + } + m_readers.clear(); + m_socket.reset(); + if (m_process && m_process->state() != QProcess::NotRunning) + { + if (!m_process->waitForFinished(1000)) + { + m_process->terminate(); + if (!m_process->waitForFinished(1000)) + { + m_process->kill(); + m_process->waitForFinished(1000); + } + } + } + m_process.reset(); + } + + private: + struct Reader + { + QString cameraId; + QString shmKey; + std::unique_ptr shm; + quint64 lastFrameIndex{0}; + ImageFrame latestFrame; + }; + + void finishWaitingRequest() + { + if (m_waitingLoop) m_waitingLoop->quit(); + } + + void handleReadyRead() + { + if (!m_socket) return; + m_readBuffer += m_socket->readAll(); + while (true) + { + QJsonObject message; + QString error; + const driverhost::DecodeResult result = + driverhost::tryDecodeMessage(m_readBuffer, message, &error); + if (result == driverhost::DecodeResult::Incomplete) return; + if (result == driverhost::DecodeResult::Error) + { + m_lastError = error; + m_socket->abort(); + finishWaitingRequest(); + return; + } + if (message.value(driverhost::kEnvelopeVersionField).toInt(0) + != static_cast(driverhost::kProtocolVersion)) + { + m_lastError = QStringLiteral("DriverHost protocol version mismatch"); + m_socket->abort(); + finishWaitingRequest(); + return; + } + + const QString kind = message.value(driverhost::kEnvelopeKindField).toString(); + if (kind == driverhost::kMessageKindResponse) + { + const quint64 requestId = driverhost::decodeUInt64( + message.value(driverhost::kEnvelopeRequestIdField)); + if (requestId == m_waitingRequestId) + { + m_waitingResponse = message; + finishWaitingRequest(); + } + continue; + } + if (kind == driverhost::kMessageKindEvent) + { + handleEvent(message); + } + } + } + + void handleEvent(const QJsonObject& event) + { + if (event.value(driverhost::kProviderIdField).toString() != m_providerId) + { + return; + } + const QString type = event.value(driverhost::kMessageTypeField).toString(); + const QString deviceId = event.value(driverhost::kDeviceIdField).toString(); + if (type == driverhost::kEventFrameAvailable + && !m_frameDeliveryPaused + && m_runningCameras.contains(deviceId)) + { + deliverAvailableFrames(deviceId, m_readAllFrames); + } + else if (type == driverhost::kEventPreviewState) + { + const bool running = event.value(QStringLiteral("running")).toBool(); + if (running) m_runningCameras.insert(deviceId); + else m_runningCameras.remove(deviceId); + if (m_previewHandler) m_previewHandler(deviceId, running); + } + else if (type == driverhost::kEventDriverHostError) + { + qWarning().noquote() + << QString("DriverHost '%1' error: %2") + .arg(m_providerId, + event.value(QStringLiteral("error")).toString()); + } + } + + bool ensureSharedMemory(Reader& reader) + { + const int expectedSize = + kSharedMemoryControlSize + kSharedFrameNumSlots * kSharedFrameSlotStride; + if (reader.shm->isAttached()) + { + return reader.shm->size() >= expectedSize; + } + if (!reader.shm->attach(QSharedMemory::ReadWrite)) + { + return false; + } + if (reader.shm->size() >= expectedSize) + { + return true; + } + reader.shm->detach(); + return false; + } + + bool copyFrame(Reader& reader, + const SharedFrameHeader& header, + const uchar* pixels, + QList& frames) + { + QByteArray payload; + payload.resize(static_cast(sharedframe::payloadSize(header))); + memcpy(payload.data(), pixels, static_cast(payload.size())); + ImageFrame frame = ImageFrame::fromSharedFrame(reader.cameraId, header, payload); + if (!frame.isValid()) return false; + reader.latestFrame = frame; + frames.append(std::move(frame)); + return true; + } + + bool readLatest(Reader& reader, QList& frames) + { + if (!ensureSharedMemory(reader)) return false; + auto* base = static_cast(reader.shm->data()); + if (!base) return false; + + auto* control = reinterpret_cast(base); + SharedFrameHeader capturedHeader{}; + uchar* capturedSlot = nullptr; + auto claim = [&](quint32 index) + { + if (index >= kSharedFrameNumSlots) return false; + uchar* slot = base + kSharedMemoryControlSize + + static_cast(index) * kSharedFrameSlotStride; + SharedFrameHeader header{}; + if (!sharedframe::claimSlot(slot, header)) return false; + if (header.frameIndex <= reader.lastFrameIndex) + { + sharedframe::releaseSlot(slot); + return false; + } + capturedHeader = header; + capturedSlot = slot; + return true; + }; + + const quint32 latest = std::atomic_ref(control->latestSlotIndex) + .load(std::memory_order_acquire); + claim(latest); + if (!capturedSlot) + { + quint64 bestIndex = reader.lastFrameIndex; + for (int index = 0; index < kSharedFrameNumSlots; ++index) + { + uchar* slot = base + kSharedMemoryControlSize + + index * kSharedFrameSlotStride; + SharedFrameHeader header{}; + if (!sharedframe::claimSlot(slot, header)) continue; + if (header.frameIndex > bestIndex) + { + if (capturedSlot) sharedframe::releaseSlot(capturedSlot); + bestIndex = header.frameIndex; + capturedHeader = header; + capturedSlot = slot; + } + else + { + sharedframe::releaseSlot(slot); + } + } + } + + const bool copied = capturedSlot + && copyFrame(reader, + capturedHeader, + capturedSlot + kSharedFrameHeaderSize, + frames); + if (capturedSlot) sharedframe::releaseSlot(capturedSlot); + if (copied) reader.lastFrameIndex = capturedHeader.frameIndex; + return copied; + } + + bool readAll(Reader& reader, QList& frames) + { + if (!ensureSharedMemory(reader)) return false; + auto* base = static_cast(reader.shm->data()); + if (!base) return false; + + struct Claimed + { + uchar* slot{nullptr}; + SharedFrameHeader header{}; + }; + std::vector claimed; + claimed.reserve(kSharedFrameNumSlots); + for (int index = 0; index < kSharedFrameNumSlots; ++index) + { + uchar* slot = base + kSharedMemoryControlSize + + index * kSharedFrameSlotStride; + SharedFrameHeader header{}; + if (!sharedframe::claimSlot(slot, header)) continue; + if (header.frameIndex > reader.lastFrameIndex) + { + claimed.push_back({slot, header}); + } + else + { + sharedframe::releaseSlot(slot); + } + } + std::sort(claimed.begin(), claimed.end(), [](const Claimed& lhs, const Claimed& rhs) + { + return lhs.header.frameIndex < rhs.header.frameIndex; + }); + quint64 lastIndex = reader.lastFrameIndex; + for (const Claimed& item : claimed) + { + if (copyFrame(reader, + item.header, + item.slot + kSharedFrameHeaderSize, + frames)) + { + lastIndex = item.header.frameIndex; + } + sharedframe::releaseSlot(item.slot); + } + reader.lastFrameIndex = lastIndex; + return !frames.isEmpty(); + } + + void deliverAvailableFrames(const QString& cameraId, bool allFrames) + { + auto it = m_readers.find(cameraId); + if (it == m_readers.end()) return; + QList frames; + if (allFrames) readAll(*it.value(), frames); + else readLatest(*it.value(), frames); + if (!m_frameHandler) return; + for (const ImageFrame& frame : frames) + { + m_frameHandler(frame); + } + } + + QString m_providerId; + QString m_pluginPath; + QString m_hostKey; + QJsonObject m_options; + FrameHandler m_frameHandler; + PreviewHandler m_previewHandler; + std::unique_ptr m_process; + std::unique_ptr m_socket; + QByteArray m_readBuffer; + QHash> m_readers; + QStringList m_cameraIds; + QSet m_runningCameras; + QString m_deliveryMode{driverhost::kFrameDeliveryModePreviewLatest}; + QEventLoop* m_waitingLoop{nullptr}; + QJsonObject m_waitingResponse; + QString m_lastError; + quint64 m_nextRequestId{1}; + quint64 m_waitingRequestId{0}; + bool m_frameDeliveryPaused{false}; + bool m_readAllFrames{false}; + bool m_cleanedUp{false}; + }; + + class DriverHostProviderProxy final : public QObject, + public HardwareProvider, + public CameraProvider, + public StageProvider, + public ShutterProvider, + public StateProvider, + public ConfigurationProvider, + public CameraRuntimeControl + { + public: + DriverHostProviderProxy(QString providerId, + QString pluginPath, + QJsonObject options) + : m_expectedProviderId(std::move(providerId)) + , m_pluginPath(std::move(pluginPath)) + , m_options(std::move(options)) + { + } + + ~DriverHostProviderProxy() override + { + if (m_worker && m_workerThread.isRunning()) + { + DriverHostProviderTransport* const worker = m_worker; + QMetaObject::invokeMethod(worker, + [worker]() { worker->cleanup(); }, + Qt::BlockingQueuedConnection); + m_workerThread.quit(); + m_workerThread.wait(); + } + m_worker = nullptr; + } + + bool initialize(QString* errorMessage) + { + const QString hostKey = QUuid::createUuid().toString(QUuid::WithoutBraces); + m_worker = new DriverHostProviderTransport( + m_expectedProviderId, + m_pluginPath, + hostKey, + m_options, + [this](const ImageFrame& frame) { deliverFrame(frame); }, + [this](const QString& cameraId, bool running) + { + bool anyRunning = false; + { + QMutexLocker locker(&m_stateMutex); + if (running) m_runningCameras.insert(cameraId); + else m_runningCameras.remove(cameraId); + anyRunning = !m_runningCameras.isEmpty(); + } + PreviewStateSink sink; + { + QMutexLocker locker(&m_sinkMutex); + sink = m_previewStateSink; + } + if (sink) sink(anyRunning); + }); + m_worker->moveToThread(&m_workerThread); + connect(&m_workerThread, &QThread::finished, + m_worker, &QObject::deleteLater); + m_workerThread.setObjectName( + QStringLiteral("ScopeOneProvider_%1").arg(m_expectedProviderId)); + m_workerThread.start(); + + bool initialized = false; + QString error; + DriverHostProviderTransport* const worker = m_worker; + QMetaObject::invokeMethod( + worker, + [this, worker, &initialized, &error]() + { + initialized = worker->initialize(m_descriptor, + m_devices, + m_defaultXYStage, + m_defaultZStage, + &error); + }, + Qt::BlockingQueuedConnection); + if (!initialized) + { + if (errorMessage) *errorMessage = error; + QMetaObject::invokeMethod(worker, + [worker]() { worker->cleanup(); }, + Qt::BlockingQueuedConnection); + m_workerThread.quit(); + m_workerThread.wait(); + m_worker = nullptr; + return false; + } + if (errorMessage) errorMessage->clear(); + return true; + } + + HardwareProviderDescriptor descriptor() const override { return m_descriptor; } + QList devices() const override { return m_devices; } + + void setFrameSink(FrameSink sink) override + { + QMutexLocker locker(&m_sinkMutex); + m_frameSink = std::move(sink); + } + + void setPreviewStateSink(PreviewStateSink sink) override + { + QMutexLocker locker(&m_sinkMutex); + m_previewStateSink = std::move(sink); + } + + bool startPreview() override + { + const QStringList ids = cameraIds(); + QStringList started; + for (const QString& cameraId : ids) + { + const bool wasRunning = isPreviewRunning(cameraId); + if (!startPreviewFor(cameraId)) + { + for (const QString& startedId : started) stopPreviewFor(startedId); + return false; + } + if (!wasRunning) started.append(cameraId); + } + return !ids.isEmpty(); + } + + bool stopPreview() override + { + bool ok = true; + const QStringList ids = cameraIds(); + for (const QString& cameraId : ids) ok = stopPreviewFor(cameraId) && ok; + return ok; + } + + bool startPreviewFor(const QString& cameraId) override + { + if (!isCamera(cameraId)) return false; + if (isPreviewRunning(cameraId)) return true; + QJsonObject response; + if (!request(driverhost::kCommandStartPreview, cameraId, {}, response, nullptr)) + { + return false; + } + QMutexLocker locker(&m_stateMutex); + m_runningCameras.insert(cameraId); + return true; + } + + bool stopPreviewFor(const QString& cameraId) override + { + if (!isCamera(cameraId)) return false; + if (!isPreviewRunning(cameraId)) return true; + QJsonObject response; + if (!request(driverhost::kCommandStopPreview, cameraId, {}, response, nullptr)) + { + return false; + } + QMutexLocker locker(&m_stateMutex); + m_runningCameras.remove(cameraId); + return true; + } + + bool isPreviewRunning(const QString& cameraId) const override + { + QMutexLocker locker(&m_stateMutex); + return m_runningCameras.contains(cameraId.trimmed()); + } + + bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const override + { + const QStringList targets = targetCameras(cameraIdOrAll); + bool found = false; + double common = 0.0; + for (const QString& cameraId : targets) + { + QJsonObject response; + if (!request(driverhost::kCommandGetExposure, + cameraId, + {}, + response, + nullptr)) + { + return false; + } + const double value = response.value(QStringLiteral("exposureMs")).toDouble(); + if (found && !qFuzzyCompare(common + 1.0, value + 1.0)) return false; + common = value; + found = true; + } + if (found) exposureMs = common; + return found; + } + + bool setExposure(const QString& cameraIdOrAll, double exposureMs) override + { + const QStringList targets = targetCameras(cameraIdOrAll); + if (targets.isEmpty() || exposureMs <= 0.0) return false; + for (const QString& cameraId : targets) + { + QJsonObject fields; + fields.insert(QStringLiteral("value"), exposureMs); + QJsonObject response; + if (!request(driverhost::kCommandSetExposure, + cameraId, + fields, + response, + nullptr)) + { + return false; + } + } + return true; + } + + QStringList listProperties(const QString& deviceId) override + { + QJsonObject response; + if (!request(driverhost::kCommandListProperties, + deviceId, + {}, + response, + nullptr)) + { + return {}; + } + QStringList properties; + for (const QJsonValue& value : response.value(QStringLiteral("properties")).toArray()) + { + properties.append(value.toString()); + } + return properties; + } + + QString getProperty(const QString& deviceId, + const QString& name, + bool fromCache) override + { + QJsonObject response; + QJsonObject fields; + fields.insert(QStringLiteral("name"), name); + fields.insert(QStringLiteral("fromCache"), fromCache); + return request(driverhost::kCommandGetProperty, + deviceId, + fields, + response, + nullptr) + ? response.value(QStringLiteral("value")).toString() + : QString{}; + } + + bool setProperty(const QString& deviceId, + const QString& name, + const QString& value, + QString* errorMessage) override + { + QJsonObject fields; + fields.insert(QStringLiteral("name"), name); + fields.insert(QStringLiteral("value"), value); + QJsonObject response; + return request(driverhost::kCommandSetProperty, + deviceId, + fields, + response, + errorMessage); + } + + QString getPropertyType(const QString& deviceId, const QString& name) override + { + return propertyDetails(deviceId, name).value(QStringLiteral("propertyType")) + .toString(QStringLiteral("Unknown")); + } + + bool isPropertyReadOnly(const QString& deviceId, const QString& name) override + { + return propertyDetails(deviceId, name).value(QStringLiteral("readOnly")).toBool(true); + } + + bool isPropertyPreInit(const QString& deviceId, const QString& name) override + { + return propertyDetails(deviceId, name).value(QStringLiteral("preInit")).toBool(false); + } + + QStringList getAllowedPropertyValues(const QString& deviceId, + const QString& name) override + { + QStringList result; + for (const QJsonValue& value : + propertyDetails(deviceId, name).value(QStringLiteral("allowedValues")).toArray()) + { + result.append(value.toString()); + } + return result; + } + + bool hasPropertyLimits(const QString& deviceId, const QString& name) override + { + return propertyDetails(deviceId, name).value(QStringLiteral("hasLimits")).toBool(); + } + + double getPropertyLowerLimit(const QString& deviceId, const QString& name) override + { + return propertyDetails(deviceId, name).value(QStringLiteral("lowerLimit")).toDouble(); + } + + double getPropertyUpperLimit(const QString& deviceId, const QString& name) override + { + return propertyDetails(deviceId, name).value(QStringLiteral("upperLimit")).toDouble(); + } + + bool setROI(const QString& cameraId, int x, int y, int width, int height) override + { + QJsonObject fields; + fields.insert(QStringLiteral("x"), x); + fields.insert(QStringLiteral("y"), y); + fields.insert(QStringLiteral("width"), width); + fields.insert(QStringLiteral("height"), height); + QJsonObject response; + return request(driverhost::kCommandSetRoi, + cameraId, + fields, + response, + nullptr); + } + + bool clearROI(const QString& cameraId) override + { + QJsonObject response; + return request(driverhost::kCommandClearRoi, + cameraId, + {}, + response, + nullptr); + } + + bool getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) override + { + QJsonObject response; + if (!request(driverhost::kCommandGetRoi, + cameraId, + {}, + response, + nullptr)) + { + return false; + } + x = response.value(QStringLiteral("x")).toInt(); + y = response.value(QStringLiteral("y")).toInt(); + width = response.value(QStringLiteral("width")).toInt(); + height = response.value(QStringLiteral("height")).toInt(); + return true; + } + + bool captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs) override + { + QMutexLocker requestLocker(&m_requestMutex); + if (!m_worker || !m_workerThread.isRunning()) return false; + bool ok = false; + DriverHostProviderTransport* const worker = m_worker; + const auto capture = [worker, &ok, &frame, cameraId, timeoutMs]() + { + ok = worker->captureEventFrame(cameraId, + frame, + (std::max)(1, timeoutMs), + nullptr); + }; + if (QThread::currentThread() == worker->thread()) capture(); + else QMetaObject::invokeMethod(worker, capture, Qt::BlockingQueuedConnection); + return ok; + } + + void setFrameDeliveryPaused(const QStringList&, bool paused) override + { + if (!m_worker || !m_workerThread.isRunning()) return; + DriverHostProviderTransport* const worker = m_worker; + const auto update = [worker, paused]() + { + worker->setFrameDeliveryPaused(paused); + }; + if (QThread::currentThread() == worker->thread()) update(); + else QMetaObject::invokeMethod(worker, update, Qt::BlockingQueuedConnection); + } + + bool setRecordingFrameDeliveryEnabled(const QStringList&, bool enabled) override + { + if (m_recordingDelivery == enabled) return true; + const QString mode = enabled + ? driverhost::kFrameDeliveryModeAllFrames + : m_highRateDelivery + ? driverhost::kFrameDeliveryModeLatestOnly + : driverhost::kFrameDeliveryModePreviewLatest; + if (!setDeliveryMode(mode)) return false; + m_recordingDelivery = enabled; + return true; + } + + bool setHighRateFrameDeliveryEnabled(const QStringList&, bool enabled) override + { + if (m_highRateDelivery == enabled) return true; + if (!m_recordingDelivery) + { + const QString mode = enabled + ? driverhost::kFrameDeliveryModeLatestOnly + : driverhost::kFrameDeliveryModePreviewLatest; + if (!setDeliveryMode(mode)) return false; + } + m_highRateDelivery = enabled; + return true; + } + + bool isProcessingFrameTokenCurrent(const QString&, quint64) override { return false; } + void finishProcessingFrame(const QString&, quint64) override {} + + QString defaultXYStage() const override { return m_defaultXYStage; } + QString defaultZStage() const override { return m_defaultZStage; } + + bool getXYPosition(const QString& deviceId, + double& x, + double& y, + QString* errorMessage) const override + { + QJsonObject response; + if (!request(driverhost::kCommandGetXYPosition, + deviceId, + {}, + response, + errorMessage)) + { + return false; + } + x = response.value(QStringLiteral("x")).toDouble(); + y = response.value(QStringLiteral("y")).toDouble(); + return true; + } + + bool getZPosition(const QString& deviceId, + double& z, + QString* errorMessage) const override + { + QJsonObject response; + if (!request(driverhost::kCommandGetZPosition, + deviceId, + {}, + response, + errorMessage)) + { + return false; + } + z = response.value(QStringLiteral("z")).toDouble(); + return true; + } + + bool setRelativeXYPosition(const QString& deviceId, + double dx, + double dy, + QString* errorMessage) override + { + return stageWrite(driverhost::kCommandSetRelativeXYPosition, + deviceId, + dx, + dy, + errorMessage); + } + + bool setRelativeZPosition(const QString& deviceId, + double dz, + QString* errorMessage) override + { + QJsonObject fields; + fields.insert(QStringLiteral("z"), dz); + QJsonObject response; + return request(driverhost::kCommandSetRelativeZPosition, + deviceId, + fields, + response, + errorMessage); + } + + bool setXYPosition(const QString& deviceId, + double x, + double y, + QString* errorMessage) override + { + return stageWrite(driverhost::kCommandSetXYPosition, + deviceId, + x, + y, + errorMessage); + } + + bool setZPosition(const QString& deviceId, + double z, + QString* errorMessage) override + { + QJsonObject fields; + fields.insert(QStringLiteral("z"), z); + QJsonObject response; + return request(driverhost::kCommandSetZPosition, + deviceId, + fields, + response, + errorMessage); + } + + bool isShutterOpen(const QString& deviceId, + bool& open, + QString* errorMessage) const override + { + QJsonObject response; + if (!request(driverhost::kCommandGetShutterOpen, + deviceId, + {}, + response, + errorMessage)) + { + return false; + } + open = response.value(QStringLiteral("open")).toBool(); + return true; + } + + bool setShutterOpen(const QString& deviceId, + bool open, + QString* errorMessage) override + { + QJsonObject fields; + fields.insert(QStringLiteral("open"), open); + QJsonObject response; + return request(driverhost::kCommandSetShutterOpen, + deviceId, + fields, + response, + errorMessage); + } + + bool getState(const QString& deviceId, + long& state, + QString* errorMessage) const override + { + QJsonObject response; + if (!request(driverhost::kCommandGetState, + deviceId, + {}, + response, + errorMessage)) + { + return false; + } + state = static_cast(response.value(QStringLiteral("state")).toDouble()); + return true; + } + + bool setState(const QString& deviceId, + long state, + QString* errorMessage) override + { + QJsonObject fields; + fields.insert(QStringLiteral("state"), static_cast(state)); + QJsonObject response; + return request(driverhost::kCommandSetState, + deviceId, + fields, + response, + errorMessage); + } + + QString stateLabel(const QString& deviceId, long state) const override + { + QJsonObject fields; + fields.insert(QStringLiteral("state"), static_cast(state)); + QJsonObject response; + return request(driverhost::kCommandGetStateLabel, + deviceId, + fields, + response, + nullptr) + ? response.value(QStringLiteral("label")).toString() + : QString{}; + } + + QStringList availableConfigGroups() const override + { + QJsonObject response; + if (!request(driverhost::kCommandListConfigGroups, + {}, + {}, + response, + nullptr)) + { + return {}; + } + return jsonStringList(response.value(QStringLiteral("groups")).toArray()); + } + + QStringList availableConfigs(const QString& groupName) const override + { + QJsonObject fields; + fields.insert(QStringLiteral("group"), groupName); + QJsonObject response; + return request(driverhost::kCommandListConfigs, + {}, + fields, + response, + nullptr) + ? jsonStringList(response.value(QStringLiteral("configs")).toArray()) + : QStringList{}; + } + + QString currentConfig(const QString& groupName) const override + { + QJsonObject fields; + fields.insert(QStringLiteral("group"), groupName); + QJsonObject response; + return request(driverhost::kCommandGetCurrentConfig, + {}, + fields, + response, + nullptr) + ? response.value(QStringLiteral("config")).toString() + : QString{}; + } + + bool setConfig(const QString& groupName, + const QString& configName, + QString* errorMessage) override + { + QJsonObject fields; + fields.insert(QStringLiteral("group"), groupName); + fields.insert(QStringLiteral("config"), configName); + QJsonObject response; + return request(driverhost::kCommandSetConfig, + {}, + fields, + response, + errorMessage); + } + + private: + void deliverFrame(const ImageFrame& frame) + { + FrameSink sink; + { + QMutexLocker locker(&m_sinkMutex); + sink = m_frameSink; + } + if (sink) sink(frame); + } + + QStringList cameraIds() const + { + QStringList result; + for (const HardwareDeviceDescriptor& device : m_devices) + { + if (device.kind == HardwareDeviceKind::Camera) result.append(device.logicalId); + } + return result; + } + + bool isCamera(const QString& cameraId) const + { + const QString normalized = cameraId.trimmed(); + return std::any_of(m_devices.cbegin(), m_devices.cend(), + [&normalized](const HardwareDeviceDescriptor& device) + { + return device.kind == HardwareDeviceKind::Camera + && device.logicalId == normalized; + }); + } + + QStringList targetCameras(const QString& cameraIdOrAll) const + { + const QString target = cameraIdOrAll.trimmed(); + if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + { + return cameraIds(); + } + return isCamera(target) ? QStringList{target} : QStringList{}; + } + + bool request(const QString& type, + const QString& deviceId, + const QJsonObject& fields, + QJsonObject& response, + QString* errorMessage) const + { + QMutexLocker requestLocker(&m_requestMutex); + if (!m_worker || !m_workerThread.isRunning()) + { + if (errorMessage) *errorMessage = QStringLiteral("DriverHost is not running"); + return false; + } + QJsonObject request = fields; + request.insert(driverhost::kMessageTypeField, type); + if (!deviceId.isEmpty()) + { + request.insert(driverhost::kDeviceIdField, deviceId.trimmed()); + } + bool transported = false; + QString transportError; + DriverHostProviderTransport* const worker = m_worker; + const auto send = [worker, request, &response, &transported, &transportError]() + { + transported = worker->sendRequest(request, + response, + kControlRequestTimeoutMs, + &transportError); + }; + if (QThread::currentThread() == worker->thread()) send(); + else QMetaObject::invokeMethod(worker, send, Qt::BlockingQueuedConnection); + if (!transported) + { + if (errorMessage) *errorMessage = transportError; + return false; + } + return responseSucceeded(response, errorMessage); + } + + QJsonObject propertyDetails(const QString& deviceId, const QString& name) + { + QJsonObject fields; + fields.insert(QStringLiteral("name"), name); + fields.insert(QStringLiteral("fromCache"), true); + QJsonObject response; + return request(driverhost::kCommandGetProperty, + deviceId, + fields, + response, + nullptr) + ? response + : QJsonObject{}; + } + + bool stageWrite(const QString& command, + const QString& deviceId, + double x, + double y, + QString* errorMessage) + { + QJsonObject fields; + fields.insert(QStringLiteral("x"), x); + fields.insert(QStringLiteral("y"), y); + QJsonObject response; + return request(command, deviceId, fields, response, errorMessage); + } + + bool setDeliveryMode(const QString& mode) + { + QMutexLocker requestLocker(&m_requestMutex); + if (!m_worker || !m_workerThread.isRunning()) return false; + bool ok = false; + DriverHostProviderTransport* const worker = m_worker; + const auto update = [worker, mode, &ok]() + { + ok = worker->setFrameDeliveryMode(mode, nullptr); + }; + if (QThread::currentThread() == worker->thread()) update(); + else QMetaObject::invokeMethod(worker, update, Qt::BlockingQueuedConnection); + return ok; + } + + static QStringList jsonStringList(const QJsonArray& array) + { + QStringList result; + for (const QJsonValue& value : array) result.append(value.toString()); + return result; + } + + QString m_expectedProviderId; + QString m_pluginPath; + QJsonObject m_options; + HardwareProviderDescriptor m_descriptor; + QList m_devices; + QString m_defaultXYStage; + QString m_defaultZStage; + QThread m_workerThread; + DriverHostProviderTransport* m_worker{nullptr}; + mutable QMutex m_requestMutex; + mutable QMutex m_sinkMutex; + FrameSink m_frameSink; + PreviewStateSink m_previewStateSink; + mutable QMutex m_stateMutex; + QSet m_runningCameras; + bool m_recordingDelivery{false}; + bool m_highRateDelivery{false}; + }; + + HardwareProviderPtr createDriverHostProviderProxy(const QString& providerId, + const QString& pluginPath, + const QJsonObject& options, + QString* errorMessage) + { + const QString normalizedProviderId = providerId.trimmed(); + const QString normalizedPluginPath = QFileInfo(pluginPath).absoluteFilePath(); + if (normalizedProviderId.isEmpty() || pluginPath.trimmed().isEmpty()) + { + if (errorMessage) *errorMessage = QStringLiteral("Provider ID and module path are required"); + return {}; + } + auto proxy = std::make_shared(normalizedProviderId, + normalizedPluginPath, + options); + if (!proxy->initialize(errorMessage)) return {}; + return proxy; + } +} diff --git a/ScopeOneCore/src/ExperimentDocument.cpp b/ScopeOneCore/src/ExperimentDocument.cpp index 8fbc0ca..24272c5 100644 --- a/ScopeOneCore/src/ExperimentDocument.cpp +++ b/ScopeOneCore/src/ExperimentDocument.cpp @@ -563,7 +563,7 @@ namespace scopeone::core return result; } - QVariantMap processingParametersFromJson(ProcessingModuleKind kind, + QVariantMap processingParametersFromJson(const QString& moduleId, const QJsonObject& object) { QVariantMap result = variantMapFromJson(object); @@ -589,36 +589,38 @@ namespace scopeone::core } }; - switch (kind) + if (moduleId == QStringLiteral("frequency_domain_filter") + || moduleId == QStringLiteral("cuda.frequency_filter")) { - case ProcessingModuleKind::FFT: normalizeDouble(QStringLiteral("min_feature_size")); normalizeDouble(QStringLiteral("max_feature_size")); normalizeInt(QStringLiteral("filter_kind")); normalizeInt(QStringLiteral("output_mode")); - break; - case ProcessingModuleKind::BackgroundCalibration: + } + else if (moduleId == QStringLiteral("background_calibration")) + { normalizeInt(QStringLiteral("calibration_frames")); normalizeInt(QStringLiteral("operation")); normalizeInt(QStringLiteral("method")); normalizeInt(QStringLiteral("mode")); - break; - case ProcessingModuleKind::SpatiotemporalBinning: + } + else if (moduleId == QStringLiteral("spatiotemporal_binning")) + { normalizeInt(QStringLiteral("spatial_bin_x")); normalizeInt(QStringLiteral("spatial_bin_y")); normalizeInt(QStringLiteral("temporal_bin")); normalizeInt(QStringLiteral("spatial_mode")); normalizeInt(QStringLiteral("temporal_mode")); - break; - case ProcessingModuleKind::GaussianBlur: + } + else if (moduleId == QStringLiteral("gaussian_blur") + || moduleId == QStringLiteral("cuda.gaussian_blur")) + { normalizeInt(QStringLiteral("kernel_size")); normalizeDouble(QStringLiteral("sigma")); - break; - case ProcessingModuleKind::DifferentialRolling: + } + else if (moduleId == QStringLiteral("differential_rolling")) + { normalizeInt(QStringLiteral("batch_size")); - break; - case ProcessingModuleKind::Unknown: - break; } return result; } @@ -698,39 +700,29 @@ namespace scopeone::core return false; } - bool parseProcessingModuleKind(const QString& name, ProcessingModuleKind& kind) + QString processingModuleIdFromDocument(const QString& name) { if (name == QStringLiteral("FFT")) { - kind = ProcessingModuleKind::FFT; - return true; + return QStringLiteral("fft"); } if (name == QStringLiteral("BackgroundCalibration")) { - kind = ProcessingModuleKind::BackgroundCalibration; - return true; + return QStringLiteral("background_calibration"); } if (name == QStringLiteral("SpatiotemporalBinning")) { - kind = ProcessingModuleKind::SpatiotemporalBinning; - return true; + return QStringLiteral("spatiotemporal_binning"); } if (name == QStringLiteral("GaussianBlur")) { - kind = ProcessingModuleKind::GaussianBlur; - return true; + return QStringLiteral("gaussian_blur"); } if (name == QStringLiteral("DifferentialRolling")) { - kind = ProcessingModuleKind::DifferentialRolling; - return true; + return QStringLiteral("differential_rolling"); } - if (name == QStringLiteral("Unknown")) - { - kind = ProcessingModuleKind::Unknown; - return true; - } - return false; + return name == QStringLiteral("Unknown") ? QString{} : name.trimmed(); } bool parseExperimentRunState(const QString& name, ExperimentRunState& state) @@ -780,6 +772,11 @@ namespace scopeone::core kind = DocumentLayerKind::Static; return true; } + if (name == QStringLiteral("Tool")) + { + kind = DocumentLayerKind::Tool; + return true; + } if (name == QStringLiteral("Gallery")) { kind = DocumentLayerKind::Gallery; @@ -1100,24 +1097,15 @@ namespace scopeone::core const QString modulePath = elementPath(memberPath(memberPath(path, QStringLiteral("processing")), QStringLiteral("modules")), index); - if (module.schemaVersion != kProcessingModuleSchemaVersion) + if (module.schemaVersion <= 0) { return fail(errorMessage, - QStringLiteral("%1.schemaVersion %2 is unsupported; expected %3") + QStringLiteral("%1.schemaVersion must be positive; got %2") .arg(modulePath) - .arg(module.schemaVersion) - .arg(kProcessingModuleSchemaVersion)); + .arg(module.schemaVersion)); } - switch (module.kind) + if (module.moduleId.trimmed().isEmpty()) { - case ProcessingModuleKind::FFT: - case ProcessingModuleKind::BackgroundCalibration: - case ProcessingModuleKind::SpatiotemporalBinning: - case ProcessingModuleKind::GaussianBlur: - case ProcessingModuleKind::DifferentialRolling: - break; - case ProcessingModuleKind::Unknown: - default: return fail(errorMessage, QStringLiteral("%1.kind must name a supported processing module").arg(modulePath)); } @@ -1621,7 +1609,7 @@ namespace scopeone::core for (const ProcessingModuleRecipe& module : plan.processing.modules) { QJsonObject moduleObject; - moduleObject.insert(QStringLiteral("kind"), processingModuleKindName(module.kind)); + moduleObject.insert(QStringLiteral("kind"), module.moduleId); moduleObject.insert(QStringLiteral("schemaVersion"), module.schemaVersion); moduleObject.insert(QStringLiteral("parameters"), variantMapToJson(module.parameters)); modules.append(canonicalJsonObject(moduleObject)); @@ -2098,21 +2086,21 @@ namespace scopeone::core { return false; } - if (!parseProcessingModuleKind(kindName, module.kind)) + module.moduleId = processingModuleIdFromDocument(kindName); + if (module.moduleId.isEmpty()) { return fail(errorMessage, QStringLiteral("%1.kind has unknown processing module name '%2'") .arg(modulePath, kindName)); } - if (module.schemaVersion != kProcessingModuleSchemaVersion) + if (module.schemaVersion <= 0) { return fail(errorMessage, - QStringLiteral("%1.schemaVersion %2 is unsupported; expected %3") + QStringLiteral("%1.schemaVersion must be positive; got %2") .arg(modulePath) - .arg(module.schemaVersion) - .arg(kProcessingModuleSchemaVersion)); + .arg(module.schemaVersion)); } - module.parameters = processingParametersFromJson(module.kind, parameters); + module.parameters = processingParametersFromJson(module.moduleId, parameters); parsed.processing.modules.append(module); } @@ -3172,26 +3160,6 @@ namespace scopeone::core return QStringLiteral("Unknown"); } - QString processingModuleKindName(ProcessingModuleKind kind) - { - switch (kind) - { - case ProcessingModuleKind::FFT: - return QStringLiteral("FFT"); - case ProcessingModuleKind::BackgroundCalibration: - return QStringLiteral("BackgroundCalibration"); - case ProcessingModuleKind::SpatiotemporalBinning: - return QStringLiteral("SpatiotemporalBinning"); - case ProcessingModuleKind::GaussianBlur: - return QStringLiteral("GaussianBlur"); - case ProcessingModuleKind::DifferentialRolling: - return QStringLiteral("DifferentialRolling"); - case ProcessingModuleKind::Unknown: - return QStringLiteral("Unknown"); - } - return QStringLiteral("Unknown"); - } - QString experimentRunStateName(ExperimentRunState state) { switch (state) @@ -3220,6 +3188,8 @@ namespace scopeone::core return QStringLiteral("Processed"); case DocumentLayerKind::Static: return QStringLiteral("Static"); + case DocumentLayerKind::Tool: + return QStringLiteral("Tool"); case DocumentLayerKind::Gallery: return QStringLiteral("Gallery"); } diff --git a/ScopeOneCore/src/FFTModule.cpp b/ScopeOneCore/src/FFTModule.cpp index 2b44438..9fe5653 100644 --- a/ScopeOneCore/src/FFTModule.cpp +++ b/ScopeOneCore/src/FFTModule.cpp @@ -1,10 +1,7 @@ #include "internal/FFTModule.h" + #include "internal/FrameBufferUtils.h" -#include -#include -#include -#include #include #include @@ -12,286 +9,142 @@ namespace scopeone::core::internal { namespace { - // Converts a mono frame into a float matrix for OpenCV DFT - bool frameToGrayFloat(const ImageFrame& frame, cv::Mat& output) + bool frameToFloat(const ImageFrame& frame, cv::Mat& output) { if (!frame.isValid() || (!frame.isMono8() && !frame.isMono16())) { return false; } - - const int cvType = frame.isMono16() ? CV_16UC1 : CV_8UC1; - cv::Mat input(frame.height, - frame.width, - cvType, - const_cast(frame.bytes.constData()), - frame.stride); + const int type = frame.isMono16() ? CV_16UC1 : CV_8UC1; + const cv::Mat input(frame.height, + frame.width, + type, + const_cast(frame.bytes.constData()), + frame.stride); input.convertTo(output, CV_32F); return true; } - // Normalizes an OpenCV matrix into an output image frame - ImageFrame matToOutputFrame(const cv::Mat& input, - const ImageFrame& reference) + void fftShift(const cv::Mat& input, cv::Mat& output) { - const int targetType = reference.isMono16() ? CV_16U : CV_8U; - const double targetMax = static_cast(reference.maxValue()); - QByteArray bytes = reference.isMono16() - ? allocatePixelBytes(input.cols, input.rows) - : allocatePixelBytes(input.cols, input.rows); - if (bytes.isEmpty()) - { - return {}; - } - cv::Mat normalized(input.rows, - input.cols, - targetType, - bytes.data(), - input.cols * reference.bytesPerPixel()); - cv::normalize(input, normalized, 0.0, targetMax, cv::NORM_MINMAX, targetType); - return makeFrameLike(reference, normalized.cols, normalized.rows, std::move(bytes)); - } - - // Moves the zero frequency component to the image center - void fftShift(const cv::Mat& image, cv::Mat& shifted) - { - shifted.create(image.size(), image.type()); - const int xOffset = image.cols / 2; - const int yOffset = image.rows / 2; - const size_t tailBytes = static_cast(image.cols - xOffset) - * sizeof(float); - const size_t headBytes = static_cast(xOffset) * sizeof(float); - parallelForImageRows(image.cols, image.rows, [&](int firstRow, int lastRow) + output.create(input.size(), input.type()); + const int xOffset = (input.cols + 1) / 2; + const int yOffset = (input.rows + 1) / 2; + parallelForImageRows(input.cols, input.rows, [&](int firstRow, int lastRow) { for (int y = firstRow; y < lastRow; ++y) { - const float* srcRow = image.ptr((y + yOffset) % image.rows); - float* dstRow = shifted.ptr(y); - std::memcpy(dstRow, srcRow + xOffset, tailBytes); - std::memcpy(dstRow + image.cols - xOffset, srcRow, headBytes); + const float* source = input.ptr((y + yOffset) % input.rows); + float* destination = output.ptr(y); + for (int x = 0; x < input.cols; ++x) + { + destination[x] = source[(x + xOffset) % input.cols]; + } } }); } - // Computes a log magnitude spectrum from complex DFT planes - void magnitudeSpectrum(const cv::Mat* planes, cv::Mat& magnitude, cv::Mat& shifted) + ImageFrame spectrumFrame(const cv::Mat* planes, + cv::Mat& magnitude, + cv::Mat& shifted, + const ImageFrame& reference) { cv::magnitude(planes[0], planes[1], magnitude); magnitude += cv::Scalar::all(1.0); cv::log(magnitude, magnitude); fftShift(magnitude, shifted); - } - // Crops the center region of a matrix to a target size - cv::Mat cropCenter(const cv::Mat& image, const cv::Size& size) - { - const int x = (image.cols - size.width) / 2; - const int y = (image.rows - size.height) / 2; - return image(cv::Rect(x, y, size.width, size.height)); - } - - // Builds the frequency domain bandpass mask - cv::Mat buildMask(const cv::Size& size, - double minFeatureSize, - double maxFeatureSize, - FFTModule::FilterKind filterKind) - { - constexpr double kTwoPi = 2.0 * std::numbers::pi_v; - cv::Mat centered(size, CV_32F); - for (int y = 0; y < size.height; ++y) + cv::Mat visible = shifted; + if (visible.cols != reference.width || visible.rows != reference.height) { - const double fy = (static_cast(y) - size.height / 2.0) / static_cast(size.height); - float* row = centered.ptr(y); - for (int x = 0; x < size.width; ++x) - { - const double fx = (static_cast(x) - size.width / 2.0) / static_cast(size.width); - const double rsq = (kTwoPi * fx) * (kTwoPi * fx) + (kTwoPi * fy) * (kTwoPi * fy); - if (filterKind == FFTModule::FilterKind::Hard) - { - row[x] = (rsq * maxFeatureSize * maxFeatureSize > 1.0 - && rsq * minFeatureSize * minFeatureSize < 1.0) - ? 1.0f - : 0.0f; - } - else - { - row[x] = static_cast( - std::exp(-rsq * minFeatureSize * minFeatureSize / 2.0) - - std::exp(-rsq * maxFeatureSize * maxFeatureSize / 2.0)); - } - } + const int x = (visible.cols - reference.width) / 2; + const int y = (visible.rows - reference.height) / 2; + visible = visible(cv::Rect(x, y, reference.width, reference.height)); } - - cv::Mat mask; - fftShift(centered, mask); - return mask; + cv::Mat preview; + cv::normalize(visible, preview, 0.0, 255.0, cv::NORM_MINMAX, CV_8U); + ImageFrame frame = makeMono8Frame(reference.cameraId, + preview.cols, + preview.rows, + copyMatBytes(preview)); + copyFrameMetadata(reference, frame); + return frame; } } - // Creates an independent FFT runtime std::unique_ptr FFTModule::createRuntime() const { - auto module = std::make_unique(); - module->setParameters(parameters()); - return module; + return std::make_unique(); } - // Returns a cached mask for the current FFT parameters - const cv::Mat& FFTModule::maskForSize(const cv::Size& size) + ProcessingResult FFTModule::process(const ImageFrame& frame, int processingBitDepth) { - if (m_mask.empty() - || m_maskSize != size - || m_maskMinFeatureSize != m_minFeatureSize - || m_maskMaxFeatureSize != m_maxFeatureSize - || m_maskFilterKind != m_filterKind) - { - m_mask = buildMask(size, m_minFeatureSize, m_maxFeatureSize, m_filterKind); - m_maskSize = size; - m_maskMinFeatureSize = m_minFeatureSize; - m_maskMaxFeatureSize = m_maxFeatureSize; - m_maskFilterKind = m_filterKind; - } - return m_mask; + return processValue(ProcessingValue{frame}, processingBitDepth); } - // Clears the cached FFT mask - void FFTModule::invalidateMask() + ProcessingResult FFTModule::processValue(const ProcessingValue& input, + int processingBitDepth) { - m_mask.release(); - m_maskSize = {}; - } + if (!std::holds_alternative(input)) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("FFT requires an image")); + } - // Runs FFT spectrum or bandpass processing on one frame - ProcessingResult FFTModule::process(const ImageFrame& frame, int processingBitDepth) - { - if (!frame.isValid()) + const ImageFrame& frame = std::get(input); + ImageFrame workingFrame; + if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth) + || !frameToFloat(workingFrame, m_grayFloat)) { - return {{}, QStringLiteral("Invalid input")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported FFT input")); } try { - ImageFrame workingFrame; - if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth)) - { - return {{}, QStringLiteral("Unsupported input frame")}; - } - - if (!frameToGrayFloat(workingFrame, m_grayFloat)) - { - return {{}, QStringLiteral("Failed to convert frame to grayscale")}; - } - - int optRows = cv::getOptimalDFTSize(m_grayFloat.rows); - int optCols = cv::getOptimalDFTSize(m_grayFloat.cols); + const int rows = cv::getOptimalDFTSize(m_grayFloat.rows); + const int columns = cv::getOptimalDFTSize(m_grayFloat.cols); cv::copyMakeBorder(m_grayFloat, m_padded, 0, - optRows - m_grayFloat.rows, + rows - m_grayFloat.rows, 0, - optCols - m_grayFloat.cols, + columns - m_grayFloat.cols, cv::BORDER_CONSTANT, 0); - cv::dft(m_padded, m_complex, cv::DFT_COMPLEX_OUTPUT); cv::split(m_complex, m_planes); - if (m_outputMode == OutputMode::Spectrum || m_outputMode == OutputMode::BandpassSpectrum) - { - if (m_outputMode == OutputMode::BandpassSpectrum) - { - const cv::Mat& mask = maskForSize(m_padded.size()); - cv::multiply(m_planes[0], mask, m_planes[0]); - cv::multiply(m_planes[1], mask, m_planes[1]); - } - - magnitudeSpectrum(m_planes, m_spectrumMagnitude, m_shiftedSpectrum); - cv::Mat spectrum = m_shiftedSpectrum; - if (spectrum.size() != m_grayFloat.size()) - { - spectrum = cropCenter(spectrum, m_grayFloat.size()); - } - return {matToOutputFrame(spectrum, workingFrame), {}}; - } - - const cv::Mat& mask = maskForSize(m_padded.size()); - cv::multiply(m_planes[0], mask, m_planes[0]); - cv::multiply(m_planes[1], mask, m_planes[1]); - - cv::merge(m_planes, 2, m_filteredComplex); - cv::dft(m_filteredComplex, m_filtered, cv::DFT_INVERSE | cv::DFT_REAL_OUTPUT | cv::DFT_SCALE); - - const cv::Mat cropped = m_filtered(cv::Rect(0, 0, m_grayFloat.cols, m_grayFloat.rows)); - return {matToOutputFrame(cropped, workingFrame), {}}; - } - catch (const std::exception& e) - { - return {{}, QString("FFT processing failed: %1").arg(e.what())}; + ComplexFrame output; + output.sourceId = frame.cameraId; + output.width = columns; + output.height = rows; + output.stride = columns; + output.sourceWidth = frame.width; + output.sourceHeight = frame.height; + output.frameIndex = frame.frameIndex; + output.timestampNs = frame.timestampNs; + output.real = copyMatBytes(m_planes[0]); + output.imaginary = copyMatBytes(m_planes[1]); + + ProcessingResult result(ProcessingValue{std::move(output)}); + result.frame = spectrumFrame(m_planes, + m_spectrumMagnitude, + m_shiftedSpectrum, + workingFrame); + return result; + } + catch (const std::exception& error) + { + return ProcessingResult(ImageFrame{}, + QString("FFT failed: %1").arg(error.what())); } } - // Returns the current FFT module parameters QVariantMap FFTModule::parameters() const { - QVariantMap params; - params["min_feature_size"] = m_minFeatureSize; - params["max_feature_size"] = m_maxFeatureSize; - params["filter_kind"] = static_cast(m_filterKind); - params["output_mode"] = static_cast(m_outputMode); - return params; + return {}; } - // Updates FFT module parameters and invalidates cached masks - void FFTModule::setParameters(const QVariantMap& params) + void FFTModule::setParameters(const QVariantMap&) { - bool maskChanged = false; - if (params.contains("min_feature_size")) - { - const double oldValue = m_minFeatureSize; - m_minFeatureSize = qMax(0.0, params.value("min_feature_size").toDouble()); - if (m_minFeatureSize != oldValue) - { - maskChanged = true; - } - } - if (params.contains("max_feature_size")) - { - const double oldValue = m_maxFeatureSize; - m_maxFeatureSize = qMax(0.0, params.value("max_feature_size").toDouble()); - if (m_maxFeatureSize != oldValue) - { - maskChanged = true; - } - } - if (m_minFeatureSize > m_maxFeatureSize) - { - std::swap(m_minFeatureSize, m_maxFeatureSize); - maskChanged = true; - } - if (params.contains("filter_kind")) - { - const int filterKind = params.value("filter_kind").toInt(); - if (filterKind == 0 || filterKind == 1) - { - const auto newFilterKind = static_cast(filterKind); - if (m_filterKind != newFilterKind) - { - maskChanged = true; - } - m_filterKind = newFilterKind; - } - } - if (params.contains("output_mode")) - { - const int outputMode = params.value("output_mode").toInt(); - if (outputMode >= 0 && outputMode <= 2) - { - m_outputMode = static_cast(outputMode); - } - } - if (maskChanged) - { - invalidateMask(); - } } } // namespace scopeone::core::internal diff --git a/ScopeOneCore/src/FrequencyDomainFilterModule.cpp b/ScopeOneCore/src/FrequencyDomainFilterModule.cpp new file mode 100644 index 0000000..3e749a5 --- /dev/null +++ b/ScopeOneCore/src/FrequencyDomainFilterModule.cpp @@ -0,0 +1,253 @@ +#include "internal/FrequencyDomainFilterModule.h" + +#include "internal/FrameBufferUtils.h" + +#include +#include +#include +#include +#include +#include + +namespace scopeone::core::internal +{ + namespace + { + bool frameToFloat(const ImageFrame& frame, cv::Mat& output) + { + if (!frame.isValid() || (!frame.isMono8() && !frame.isMono16())) + { + return false; + } + const int type = frame.isMono16() ? CV_16UC1 : CV_8UC1; + const cv::Mat input(frame.height, + frame.width, + type, + const_cast(frame.bytes.constData()), + frame.stride); + input.convertTo(output, CV_32F); + return true; + } + + ImageFrame outputFrame(const cv::Mat& input, const ImageFrame& reference) + { + const int type = reference.isMono16() ? CV_16U : CV_8U; + QByteArray bytes = reference.isMono16() + ? allocatePixelBytes(input.cols, input.rows) + : allocatePixelBytes(input.cols, input.rows); + if (bytes.isEmpty()) + { + return {}; + } + cv::Mat output(input.rows, + input.cols, + type, + bytes.data(), + input.cols * reference.bytesPerPixel()); + cv::normalize(input, + output, + 0.0, + static_cast(reference.maxValue()), + cv::NORM_MINMAX, + type); + return makeFrameLike(reference, output.cols, output.rows, std::move(bytes)); + } + + void fftShift(const cv::Mat& input, cv::Mat& output) + { + output.create(input.size(), input.type()); + const int xOffset = input.cols / 2; + const int yOffset = input.rows / 2; + const size_t tailBytes = static_cast(input.cols - xOffset) * sizeof(float); + const size_t headBytes = static_cast(xOffset) * sizeof(float); + parallelForImageRows(input.cols, input.rows, [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + const float* source = input.ptr((y + yOffset) % input.rows); + float* destination = output.ptr(y); + std::memcpy(destination, source + xOffset, tailBytes); + std::memcpy(destination + input.cols - xOffset, source, headBytes); + } + }); + } + + cv::Mat buildMask(const cv::Size& size, + double minFeatureSize, + double maxFeatureSize, + FrequencyDomainFilterModule::FilterKind filterKind) + { + constexpr double kTwoPi = 2.0 * std::numbers::pi_v; + cv::Mat centered(size, CV_32F); + for (int y = 0; y < size.height; ++y) + { + const double fy = (static_cast(y) - size.height / 2.0) / size.height; + float* row = centered.ptr(y); + for (int x = 0; x < size.width; ++x) + { + const double fx = (static_cast(x) - size.width / 2.0) / size.width; + const double radiusSquared = (kTwoPi * fx) * (kTwoPi * fx) + + (kTwoPi * fy) * (kTwoPi * fy); + if (filterKind == FrequencyDomainFilterModule::FilterKind::Hard) + { + row[x] = radiusSquared * maxFeatureSize * maxFeatureSize > 1.0 + && radiusSquared * minFeatureSize * minFeatureSize < 1.0 + ? 1.0f + : 0.0f; + } + else + { + row[x] = static_cast( + std::exp(-radiusSquared * minFeatureSize * minFeatureSize / 2.0) + - std::exp(-radiusSquared * maxFeatureSize * maxFeatureSize / 2.0)); + } + } + } + cv::Mat mask; + fftShift(centered, mask); + return mask; + } + + cv::Mat spectrum(const cv::Mat* planes, cv::Mat& magnitude, cv::Mat& shifted) + { + cv::magnitude(planes[0], planes[1], magnitude); + magnitude += cv::Scalar::all(1.0); + cv::log(magnitude, magnitude); + fftShift(magnitude, shifted); + return shifted; + } + } + + std::unique_ptr FrequencyDomainFilterModule::createRuntime() const + { + auto module = std::make_unique(); + module->setParameters(parameters()); + return module; + } + + const cv::Mat& FrequencyDomainFilterModule::maskForSize(const cv::Size& size) + { + if (m_mask.empty() + || m_maskSize != size + || m_maskMinFeatureSize != m_minFeatureSize + || m_maskMaxFeatureSize != m_maxFeatureSize + || m_maskFilterKind != m_filterKind) + { + m_mask = buildMask(size, m_minFeatureSize, m_maxFeatureSize, m_filterKind); + m_maskSize = size; + m_maskMinFeatureSize = m_minFeatureSize; + m_maskMaxFeatureSize = m_maxFeatureSize; + m_maskFilterKind = m_filterKind; + } + return m_mask; + } + + void FrequencyDomainFilterModule::invalidateMask() + { + m_mask.release(); + m_maskSize = {}; + } + + ProcessingResult FrequencyDomainFilterModule::process(const ImageFrame& frame, + int processingBitDepth) + { + ImageFrame workingFrame; + if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth) + || !frameToFloat(workingFrame, m_grayFloat)) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported frequency filter input")); + } + + try + { + const int rows = cv::getOptimalDFTSize(m_grayFloat.rows); + const int columns = cv::getOptimalDFTSize(m_grayFloat.cols); + cv::copyMakeBorder(m_grayFloat, + m_padded, + 0, + rows - m_grayFloat.rows, + 0, + columns - m_grayFloat.cols, + cv::BORDER_CONSTANT, + 0); + cv::dft(m_padded, m_complex, cv::DFT_COMPLEX_OUTPUT); + cv::split(m_complex, m_planes); + + if (m_outputMode != OutputMode::Spectrum) + { + const cv::Mat& mask = maskForSize(m_padded.size()); + cv::multiply(m_planes[0], mask, m_planes[0]); + cv::multiply(m_planes[1], mask, m_planes[1]); + } + + if (m_outputMode != OutputMode::FilteredImage) + { + cv::Mat visible = spectrum(m_planes, m_spectrumMagnitude, m_shiftedSpectrum); + if (visible.size() != m_grayFloat.size()) + { + const int x = (visible.cols - m_grayFloat.cols) / 2; + const int y = (visible.rows - m_grayFloat.rows) / 2; + visible = visible(cv::Rect(x, y, m_grayFloat.cols, m_grayFloat.rows)); + } + return ProcessingResult(outputFrame(visible, workingFrame)); + } + + cv::merge(m_planes, 2, m_filteredComplex); + cv::dft(m_filteredComplex, + m_filtered, + cv::DFT_INVERSE | cv::DFT_REAL_OUTPUT | cv::DFT_SCALE); + const cv::Mat visible = m_filtered(cv::Rect(0, + 0, + m_grayFloat.cols, + m_grayFloat.rows)); + return ProcessingResult(outputFrame(visible, workingFrame)); + } + catch (const std::exception& error) + { + return ProcessingResult(ImageFrame{}, + QString("Frequency domain filtering failed: %1") + .arg(error.what())); + } + } + + QVariantMap FrequencyDomainFilterModule::parameters() const + { + return {{QStringLiteral("min_feature_size"), m_minFeatureSize}, + {QStringLiteral("max_feature_size"), m_maxFeatureSize}, + {QStringLiteral("filter_kind"), static_cast(m_filterKind)}, + {QStringLiteral("output_mode"), static_cast(m_outputMode)}}; + } + + void FrequencyDomainFilterModule::setParameters(const QVariantMap& parameters) + { + const double oldMinimum = m_minFeatureSize; + const double oldMaximum = m_maxFeatureSize; + const FilterKind oldKind = m_filterKind; + m_minFeatureSize = qMax(0.0, + parameters.value(QStringLiteral("min_feature_size"), + m_minFeatureSize).toDouble()); + m_maxFeatureSize = qMax(0.0, + parameters.value(QStringLiteral("max_feature_size"), + m_maxFeatureSize).toDouble()); + if (m_minFeatureSize > m_maxFeatureSize) + { + std::swap(m_minFeatureSize, m_maxFeatureSize); + } + m_filterKind = static_cast(qBound( + 0, + parameters.value(QStringLiteral("filter_kind"), + static_cast(m_filterKind)).toInt(), + 1)); + m_outputMode = static_cast(qBound( + 0, + parameters.value(QStringLiteral("output_mode"), + static_cast(m_outputMode)).toInt(), + 2)); + if (oldMinimum != m_minFeatureSize + || oldMaximum != m_maxFeatureSize + || oldKind != m_filterKind) + { + invalidateMask(); + } + } +} // namespace scopeone::core::internal diff --git a/ScopeOneCore/src/GaussianBlurModule.cpp b/ScopeOneCore/src/GaussianBlurModule.cpp index 123157b..f9e5781 100644 --- a/ScopeOneCore/src/GaussianBlurModule.cpp +++ b/ScopeOneCore/src/GaussianBlurModule.cpp @@ -20,7 +20,7 @@ namespace scopeone::core::internal { if (!frame.isValid()) { - return {{}, QStringLiteral("Invalid input")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid input")); } try @@ -28,7 +28,7 @@ namespace scopeone::core::internal ImageFrame workingFrame; if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth)) { - return {{}, QStringLiteral("Unsupported input frame")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported input frame")); } const int cvType = workingFrame.isMono16() ? CV_16UC1 : CV_8UC1; @@ -40,7 +40,7 @@ namespace scopeone::core::internal : allocatePixelBytes(workingFrame.width, workingFrame.height); if (bytes.isEmpty()) { - return {{}, QStringLiteral("Failed to allocate Gaussian blur output")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Failed to allocate Gaussian blur output")); } cv::Mat blurred(workingFrame.height, workingFrame.width, @@ -53,7 +53,7 @@ namespace scopeone::core::internal } catch (const std::exception& e) { - return {{}, QString("Gaussian blur failed: %1").arg(e.what())}; + return ProcessingResult(ImageFrame{}, QString("Gaussian blur failed: %1").arg(e.what())); } } diff --git a/ScopeOneCore/src/HardwareProvider.cpp b/ScopeOneCore/src/HardwareProvider.cpp new file mode 100644 index 0000000..d1d1d02 --- /dev/null +++ b/ScopeOneCore/src/HardwareProvider.cpp @@ -0,0 +1,14 @@ +#include "scopeone/CameraProvider.h" +#include "scopeone/HardwareCapabilities.h" +#include "scopeone/HardwareProvider.h" + +namespace scopeone::core +{ + HardwareProvider::~HardwareProvider() = default; + DevicePropertyProvider::~DevicePropertyProvider() = default; + StageProvider::~StageProvider() = default; + ShutterProvider::~ShutterProvider() = default; + StateProvider::~StateProvider() = default; + ConfigurationProvider::~ConfigurationProvider() = default; + CameraProvider::~CameraProvider() = default; +} diff --git a/ScopeOneCore/src/HardwareRuntime.cpp b/ScopeOneCore/src/HardwareRuntime.cpp new file mode 100644 index 0000000..2df7493 --- /dev/null +++ b/ScopeOneCore/src/HardwareRuntime.cpp @@ -0,0 +1,933 @@ +#include "internal/HardwareRuntime.h" + +#include "scopeone/CameraProvider.h" + +#include +#include +#include +#include +#include + +namespace scopeone::core::internal +{ + DeviceRegistry::DeviceRegistry(QObject* parent) + : QObject(parent) + { + } + + void DeviceRegistry::clear() + { + { + QWriteLocker locker(&m_lock); + if (m_providers.isEmpty()) + { + return; + } + m_providers.clear(); + } + emit changed(); + } + + bool DeviceRegistry::registerProvider( + const HardwareProviderPtr& provider, + const HardwareProviderDescriptor& descriptor, + const QList& devices) + { + if (!provider) + { + return false; + } + const QString providerId = descriptor.id.trimmed(); + if (providerId.isEmpty() || descriptor.id != providerId) + { + return false; + } + QSet logicalIds; + for (const HardwareDeviceDescriptor& device : devices) + { + const QString logicalId = device.logicalId.trimmed(); + if (logicalId.isEmpty() + || device.logicalId != logicalId + || device.providerId != providerId + || logicalIds.contains(logicalId)) + { + return false; + } + logicalIds.insert(logicalId); + } + ProviderEntry entry; + entry.provider = provider; + entry.descriptor = descriptor; + entry.devices = devices; + { + QWriteLocker locker(&m_lock); + for (auto providerIt = m_providers.cbegin(); + providerIt != m_providers.cend(); + ++providerIt) + { + if (providerIt.key() == providerId) + { + continue; + } + for (const HardwareDeviceDescriptor& existing : providerIt->devices) + { + if (logicalIds.contains(existing.logicalId)) + { + return false; + } + } + } + m_providers.insert(providerId, std::move(entry)); + } + emit changed(); + return true; + } + + void DeviceRegistry::unregisterProvider(const QString& providerId) + { + bool removed = false; + { + QWriteLocker locker(&m_lock); + removed = m_providers.remove(providerId.trimmed()); + } + if (removed) + { + emit changed(); + } + } + + QList DeviceRegistry::providers() const + { + QReadLocker locker(&m_lock); + QList result; + result.reserve(m_providers.size()); + for (auto it = m_providers.constBegin(); it != m_providers.constEnd(); ++it) + { + result.append(it->descriptor); + } + std::sort(result.begin(), result.end(), [](const auto& lhs, const auto& rhs) + { + return lhs.id < rhs.id; + }); + return result; + } + + QList DeviceRegistry::devices() const + { + QReadLocker locker(&m_lock); + QList result; + for (auto it = m_providers.constBegin(); it != m_providers.constEnd(); ++it) + { + result.append(it->devices); + } + std::sort(result.begin(), result.end(), [](const auto& lhs, const auto& rhs) + { + return lhs.logicalId < rhs.logicalId; + }); + return result; + } + + HardwareDeviceDescriptor DeviceRegistry::device(const QString& logicalId) const + { + const QString normalizedId = logicalId.trimmed(); + QReadLocker locker(&m_lock); + for (auto it = m_providers.constBegin(); it != m_providers.constEnd(); ++it) + { + for (const HardwareDeviceDescriptor& candidate : it->devices) + { + if (candidate.logicalId == normalizedId) + { + return candidate; + } + } + } + return {}; + } + + HardwareProviderPtr DeviceRegistry::provider(const QString& providerId) const + { + QReadLocker locker(&m_lock); + const auto it = m_providers.constFind(providerId.trimmed()); + return it == m_providers.constEnd() ? HardwareProviderPtr{} : it->provider; + } + + HardwareProviderPtr DeviceRegistry::providerForDevice(const QString& logicalId) const + { + const QString normalizedId = logicalId.trimmed(); + QReadLocker locker(&m_lock); + for (auto providerIt = m_providers.constBegin(); + providerIt != m_providers.constEnd(); + ++providerIt) + { + for (const HardwareDeviceDescriptor& device : providerIt->devices) + { + if (device.logicalId == normalizedId) + { + return providerIt->provider; + } + } + } + return {}; + } + + HardwareRuntime::HardwareRuntime(QObject* parent) + : QObject(parent) + , m_registry(this) + , m_acquisitionEngine(m_registry) + { + connect(&m_registry, &DeviceRegistry::changed, + this, &HardwareRuntime::devicesChanged); + } + + void HardwareRuntime::setFrameSink(FrameSink sink) + { + m_frameSink = std::move(sink); + } + + void HardwareRuntime::setPreviewStateSink(PreviewStateSink sink) + { + m_previewStateSink = std::move(sink); + } + + CameraProvider* HardwareRuntime::cameraProviderForDevice(const QString& logicalId) const + { + const HardwareProviderPtr provider = m_registry.providerForDevice(logicalId); + return dynamic_cast(provider.get()); + } + + DevicePropertyProvider* HardwareRuntime::propertyProviderForDevice( + const QString& logicalId) const + { + const HardwareProviderPtr provider = m_registry.providerForDevice(logicalId); + return dynamic_cast(provider.get()); + } + + StageProvider* HardwareRuntime::stageProviderForDevice(const QString& logicalId) const + { + const HardwareProviderPtr provider = m_registry.providerForDevice(logicalId); + return dynamic_cast(provider.get()); + } + + ShutterProvider* HardwareRuntime::shutterProviderForDevice(const QString& logicalId) const + { + const HardwareProviderPtr provider = m_registry.providerForDevice(logicalId); + return dynamic_cast(provider.get()); + } + + StateProvider* HardwareRuntime::stateProviderForDevice(const QString& logicalId) const + { + const HardwareProviderPtr provider = m_registry.providerForDevice(logicalId); + return dynamic_cast(provider.get()); + } + + ConfigurationProvider* HardwareRuntime::configurationProviderForGroup( + const QString& groupName) const + { + const QString normalizedGroup = groupName.trimmed(); + if (normalizedGroup.isEmpty()) + { + return nullptr; + } + ConfigurationProvider* match = nullptr; + for (const HardwareProviderDescriptor& descriptor : m_registry.providers()) + { + const HardwareProviderPtr provider = m_registry.provider(descriptor.id); + auto* configurationProvider = dynamic_cast(provider.get()); + if (!configurationProvider + || !configurationProvider->availableConfigGroups().contains(normalizedGroup)) + { + continue; + } + if (match) + { + return nullptr; + } + match = configurationProvider; + } + return match; + } + + QList HardwareRuntime::cameraProviders() const + { + QList result; + QSet visited; + for (const HardwareDeviceDescriptor& device : m_registry.devices()) + { + if (device.kind != HardwareDeviceKind::Camera) + { + continue; + } + const HardwareProviderPtr provider = m_registry.provider(device.providerId); + auto* cameraProvider = dynamic_cast(provider.get()); + if (cameraProvider && !visited.contains(cameraProvider)) + { + visited.insert(cameraProvider); + result.append(cameraProvider); + } + } + return result; + } + + bool HardwareRuntime::startPreview() + { + return m_acquisitionEngine.start(QStringLiteral("All")); + } + + bool HardwareRuntime::stopPreview() + { + return m_acquisitionEngine.stop(QStringLiteral("All")); + } + + bool HardwareRuntime::startPreviewFor(const QString& cameraId) + { + return m_acquisitionEngine.start(cameraId); + } + + bool HardwareRuntime::stopPreviewFor(const QString& cameraId) + { + return m_acquisitionEngine.stop(cameraId); + } + + bool HardwareRuntime::isPreviewRunning(const QString& cameraId) const + { + CameraProvider* provider = cameraProviderForDevice(cameraId); + return provider && provider->isPreviewRunning(cameraId); + } + + bool HardwareRuntime::getExposure(const QString& cameraIdOrAll, double& exposureMs) const + { + const QString target = cameraIdOrAll.trimmed(); + if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) != 0) + { + CameraProvider* provider = cameraProviderForDevice(target); + return provider && provider->getExposure(target, exposureMs); + } + bool found = false; + double commonExposureMs = 0.0; + for (const HardwareDeviceDescriptor& device : m_registry.devices()) + { + if (device.kind != HardwareDeviceKind::Camera) + { + continue; + } + CameraProvider* provider = cameraProviderForDevice(device.logicalId); + double deviceExposureMs = 0.0; + if (!provider || !provider->getExposure(device.logicalId, deviceExposureMs)) + { + return false; + } + if (found && !qFuzzyCompare(commonExposureMs + 1.0, deviceExposureMs + 1.0)) + { + return false; + } + commonExposureMs = deviceExposureMs; + found = true; + } + if (found) + { + exposureMs = commonExposureMs; + } + return found; + } + + bool HardwareRuntime::setExposure(const QString& cameraIdOrAll, double exposureMs) + { + const QString target = cameraIdOrAll.trimmed(); + if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) != 0) + { + CameraProvider* provider = cameraProviderForDevice(target); + return provider && provider->setExposure(target, exposureMs); + } + const QList providers = cameraProviders(); + bool ok = !providers.isEmpty(); + for (CameraProvider* provider : providers) + { + ok = provider->setExposure(QStringLiteral("All"), exposureMs) && ok; + } + return ok; + } + + QStringList HardwareRuntime::listProperties(const QString& cameraId) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider ? provider->listProperties(cameraId) : QStringList{}; + } + + QString HardwareRuntime::getProperty(const QString& cameraId, + const QString& name, + bool fromCache) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider ? provider->getProperty(cameraId, name, fromCache) : QString{}; + } + + bool HardwareRuntime::setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider && provider->setProperty(cameraId, name, value, errorMessage); + } + + QString HardwareRuntime::getPropertyType(const QString& cameraId, const QString& name) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider ? provider->getPropertyType(cameraId, name) : QStringLiteral("Unknown"); + } + + bool HardwareRuntime::isPropertyReadOnly(const QString& cameraId, const QString& name) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return !provider || provider->isPropertyReadOnly(cameraId, name); + } + + bool HardwareRuntime::isPropertyPreInit(const QString& cameraId, const QString& name) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider && provider->isPropertyPreInit(cameraId, name); + } + + QStringList HardwareRuntime::getAllowedPropertyValues(const QString& cameraId, + const QString& name) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider ? provider->getAllowedPropertyValues(cameraId, name) : QStringList{}; + } + + bool HardwareRuntime::hasPropertyLimits(const QString& cameraId, const QString& name) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider && provider->hasPropertyLimits(cameraId, name); + } + + double HardwareRuntime::getPropertyLowerLimit(const QString& cameraId, const QString& name) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider ? provider->getPropertyLowerLimit(cameraId, name) : 0.0; + } + + double HardwareRuntime::getPropertyUpperLimit(const QString& cameraId, const QString& name) + { + DevicePropertyProvider* provider = propertyProviderForDevice(cameraId); + return provider ? provider->getPropertyUpperLimit(cameraId, name) : 0.0; + } + + bool HardwareRuntime::setROI(const QString& cameraId, + int x, + int y, + int width, + int height) + { + CameraProvider* provider = cameraProviderForDevice(cameraId); + return provider && provider->setROI(cameraId, x, y, width, height); + } + + bool HardwareRuntime::clearROI(const QString& cameraId) + { + CameraProvider* provider = cameraProviderForDevice(cameraId); + return provider && provider->clearROI(cameraId); + } + + bool HardwareRuntime::getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) + { + CameraProvider* provider = cameraProviderForDevice(cameraId); + return provider && provider->getROI(cameraId, x, y, width, height); + } + + bool HardwareRuntime::captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs) + { + CameraProvider* provider = cameraProviderForDevice(cameraId); + return provider && provider->captureEventFrame(cameraId, frame, timeoutMs); + } + + QList> HardwareRuntime::runtimeControlsFor( + const QStringList& cameraIds) const + { + QHash grouped; + for (const QString& cameraId : cameraIds) + { + const QString normalizedId = cameraId.trimmed(); + const HardwareProviderPtr provider = m_registry.providerForDevice(normalizedId); + if (auto* control = dynamic_cast(provider.get())) + { + QStringList& providerCameraIds = grouped[control]; + if (!providerCameraIds.contains(normalizedId)) + { + providerCameraIds.append(normalizedId); + } + } + } + QList> result; + result.reserve(grouped.size()); + for (auto it = grouped.cbegin(); it != grouped.cend(); ++it) + { + result.append({it.key(), it.value()}); + } + return result; + } + + void HardwareRuntime::setFrameDeliveryPaused(const QStringList& cameraIds, bool paused) + { + for (const auto& [control, providerCameraIds] : runtimeControlsFor(cameraIds)) + { + control->setFrameDeliveryPaused(providerCameraIds, paused); + } + } + + bool HardwareRuntime::setRecordingFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) + { + QList> changed; + for (const auto& entry : runtimeControlsFor(cameraIds)) + { + if (!entry.first->setRecordingFrameDeliveryEnabled(entry.second, enabled)) + { + for (const auto& previous : changed) + { + previous.first->setRecordingFrameDeliveryEnabled(previous.second, !enabled); + } + return false; + } + changed.append(entry); + } + return true; + } + + bool HardwareRuntime::setHighRateFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) + { + QList> changed; + for (const auto& entry : runtimeControlsFor(cameraIds)) + { + if (!entry.first->setHighRateFrameDeliveryEnabled(entry.second, enabled)) + { + for (const auto& previous : changed) + { + previous.first->setHighRateFrameDeliveryEnabled(previous.second, !enabled); + } + return false; + } + changed.append(entry); + } + return true; + } + + bool HardwareRuntime::isProcessingFrameTokenCurrent(const QString& cameraId, quint64 token) + { + const HardwareProviderPtr provider = m_registry.providerForDevice(cameraId); + auto* control = dynamic_cast(provider.get()); + return control && control->isProcessingFrameTokenCurrent(cameraId, token); + } + + void HardwareRuntime::finishProcessingFrame(const QString& cameraId, quint64 token) + { + const HardwareProviderPtr provider = m_registry.providerForDevice(cameraId); + if (auto* control = dynamic_cast(provider.get())) + { + control->finishProcessingFrame(cameraId, token); + } + } + + QString HardwareRuntime::defaultXYStage() const + { + QString match; + for (const HardwareProviderDescriptor& descriptor : m_registry.providers()) + { + const HardwareProviderPtr provider = m_registry.provider(descriptor.id); + if (auto* stageProvider = dynamic_cast(provider.get())) + { + const QString deviceId = stageProvider->defaultXYStage().trimmed(); + const HardwareDeviceDescriptor device = m_registry.device(deviceId); + if (device.providerId == descriptor.id + && device.kind == HardwareDeviceKind::XYStage) + { + if (!match.isEmpty() && match != deviceId) + { + return {}; + } + match = deviceId; + } + } + } + return match; + } + + QString HardwareRuntime::defaultZStage() const + { + QString match; + for (const HardwareProviderDescriptor& descriptor : m_registry.providers()) + { + const HardwareProviderPtr provider = m_registry.provider(descriptor.id); + if (auto* stageProvider = dynamic_cast(provider.get())) + { + const QString deviceId = stageProvider->defaultZStage().trimmed(); + const HardwareDeviceDescriptor device = m_registry.device(deviceId); + if (device.providerId == descriptor.id + && device.kind == HardwareDeviceKind::ZStage) + { + if (!match.isEmpty() && match != deviceId) + { + return {}; + } + match = deviceId; + } + } + } + return match; + } + + bool HardwareRuntime::getXYPosition(const QString& deviceId, + double& x, + double& y, + QString* errorMessage) const + { + StageProvider* provider = stageProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Stage provider not available"); + } + return false; + } + return provider->getXYPosition(deviceId, x, y, errorMessage); + } + + bool HardwareRuntime::getZPosition(const QString& deviceId, + double& z, + QString* errorMessage) const + { + StageProvider* provider = stageProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Stage provider not available"); + } + return false; + } + return provider->getZPosition(deviceId, z, errorMessage); + } + + bool HardwareRuntime::setRelativeXYPosition(const QString& deviceId, + double dx, + double dy, + QString* errorMessage) + { + StageProvider* provider = stageProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Stage provider not available"); + } + return false; + } + return provider->setRelativeXYPosition(deviceId, dx, dy, errorMessage); + } + + bool HardwareRuntime::setRelativeZPosition(const QString& deviceId, + double dz, + QString* errorMessage) + { + StageProvider* provider = stageProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Stage provider not available"); + } + return false; + } + return provider->setRelativeZPosition(deviceId, dz, errorMessage); + } + + bool HardwareRuntime::setXYPosition(const QString& deviceId, + double x, + double y, + QString* errorMessage) + { + StageProvider* provider = stageProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Stage provider not available"); + } + return false; + } + return provider->setXYPosition(deviceId, x, y, errorMessage); + } + + bool HardwareRuntime::setZPosition(const QString& deviceId, + double z, + QString* errorMessage) + { + StageProvider* provider = stageProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Stage provider not available"); + } + return false; + } + return provider->setZPosition(deviceId, z, errorMessage); + } + + bool HardwareRuntime::isShutterOpen(const QString& deviceId, + bool& open, + QString* errorMessage) const + { + ShutterProvider* provider = shutterProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Shutter provider not available"); + } + return false; + } + return provider->isShutterOpen(deviceId, open, errorMessage); + } + + bool HardwareRuntime::setShutterOpen(const QString& deviceId, + bool open, + QString* errorMessage) + { + ShutterProvider* provider = shutterProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Shutter provider not available"); + } + return false; + } + return provider->setShutterOpen(deviceId, open, errorMessage); + } + + bool HardwareRuntime::getState(const QString& deviceId, + long& state, + QString* errorMessage) const + { + StateProvider* provider = stateProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("State provider not available"); + } + return false; + } + return provider->getState(deviceId, state, errorMessage); + } + + bool HardwareRuntime::setState(const QString& deviceId, + long state, + QString* errorMessage) + { + StateProvider* provider = stateProviderForDevice(deviceId); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("State provider not available"); + } + return false; + } + return provider->setState(deviceId, state, errorMessage); + } + + QString HardwareRuntime::stateLabel(const QString& deviceId, long state) const + { + StateProvider* provider = stateProviderForDevice(deviceId); + return provider ? provider->stateLabel(deviceId, state) : QString{}; + } + + QStringList HardwareRuntime::availableConfigGroups() const + { + QHash groupCounts; + for (const HardwareProviderDescriptor& descriptor : m_registry.providers()) + { + const HardwareProviderPtr provider = m_registry.provider(descriptor.id); + if (auto* configurationProvider = dynamic_cast(provider.get())) + { + QSet providerGroups; + for (const QString& group : configurationProvider->availableConfigGroups()) + { + const QString normalizedGroup = group.trimmed(); + if (!normalizedGroup.isEmpty()) + { + providerGroups.insert(normalizedGroup); + } + } + for (const QString& group : providerGroups) + { + ++groupCounts[group]; + } + } + } + QStringList groups; + for (auto it = groupCounts.cbegin(); it != groupCounts.cend(); ++it) + { + if (it.value() == 1) + { + groups.append(it.key()); + } + } + std::sort(groups.begin(), groups.end()); + return groups; + } + + QStringList HardwareRuntime::availableConfigs(const QString& groupName) const + { + ConfigurationProvider* provider = configurationProviderForGroup(groupName); + return provider ? provider->availableConfigs(groupName) : QStringList{}; + } + + QString HardwareRuntime::currentConfig(const QString& groupName) const + { + ConfigurationProvider* provider = configurationProviderForGroup(groupName); + return provider ? provider->currentConfig(groupName) : QString{}; + } + + bool HardwareRuntime::setConfig(const QString& groupName, + const QString& configName, + QString* errorMessage) + { + ConfigurationProvider* provider = configurationProviderForGroup(groupName); + if (!provider) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Configuration provider not available"); + } + return false; + } + return provider->setConfig(groupName, configName, errorMessage); + } + + void HardwareRuntime::clear() + { + for (const HardwareProviderDescriptor& descriptor : m_registry.providers()) + { + const HardwareProviderPtr provider = m_registry.provider(descriptor.id); + if (auto* cameraProvider = dynamic_cast(provider.get())) + { + cameraProvider->stopPreview(); + cameraProvider->setFrameSink({}); + cameraProvider->setPreviewStateSink({}); + } + } + m_registry.clear(); + } + + bool HardwareRuntime::registerProvider(const HardwareProviderPtr& provider) + { + if (!provider) + { + return false; + } + const HardwareProviderDescriptor descriptor = provider->descriptor(); + if (descriptor.id.trimmed().isEmpty()) + { + return false; + } + const HardwareProviderPtr previous = m_registry.provider(descriptor.id); + if (previous && previous != provider) + { + return false; + } + auto* cameraProvider = dynamic_cast(provider.get()); + auto* stageProvider = dynamic_cast(provider.get()); + auto* shutterProvider = dynamic_cast(provider.get()); + auto* stateProvider = dynamic_cast(provider.get()); + const QList providerDevices = provider->devices(); + for (const HardwareDeviceDescriptor& device : providerDevices) + { + const bool supported = + (device.kind != HardwareDeviceKind::Camera || cameraProvider) + && ((device.kind != HardwareDeviceKind::XYStage + && device.kind != HardwareDeviceKind::ZStage) + || stageProvider) + && (device.kind != HardwareDeviceKind::Shutter || shutterProvider) + && (device.kind != HardwareDeviceKind::State || stateProvider); + if (!supported) + { + return false; + } + } + if (!m_registry.registerProvider(provider, descriptor, providerDevices)) + { + return false; + } + if (cameraProvider) + { + cameraProvider->setFrameSink([this](const ImageFrame& frame) + { + if (m_frameSink) + { + m_frameSink(frame); + } + emit frameReady(frame); + }); + cameraProvider->setPreviewStateSink([this](bool) + { + const auto publishState = [this]() + { + bool running = false; + for (const HardwareDeviceDescriptor& device : m_registry.devices()) + { + if (device.kind == HardwareDeviceKind::Camera + && isPreviewRunning(device.logicalId)) + { + running = true; + break; + } + } + if (m_previewStateSink) + { + m_previewStateSink(running); + } + emit previewStateChanged(running); + }; + if (QThread::currentThread() == thread()) publishState(); + else QMetaObject::invokeMethod(this, publishState, Qt::QueuedConnection); + }); + } + return true; + } + + void HardwareRuntime::unregisterProvider(const QString& providerId) + { + const HardwareProviderPtr provider = m_registry.provider(providerId); + if (auto* cameraProvider = dynamic_cast(provider.get())) + { + cameraProvider->stopPreview(); + cameraProvider->setFrameSink({}); + cameraProvider->setPreviewStateSink({}); + } + m_registry.unregisterProvider(providerId); + } + + bool HardwareRuntime::refreshProvider(const QString& providerId) + { + const HardwareProviderPtr provider = m_registry.provider(providerId); + return provider && registerProvider(provider); + } + + bool HardwareRuntime::stopPreviewForProvider(const QString& providerId) + { + const HardwareProviderPtr provider = m_registry.provider(providerId); + auto* cameraProvider = dynamic_cast(provider.get()); + return cameraProvider && cameraProvider->stopPreview(); + } +} diff --git a/ScopeOneCore/src/IFFTModule.cpp b/ScopeOneCore/src/IFFTModule.cpp new file mode 100644 index 0000000..01fa723 --- /dev/null +++ b/ScopeOneCore/src/IFFTModule.cpp @@ -0,0 +1,81 @@ +#include "internal/IFFTModule.h" + +#include "internal/FrameBufferUtils.h" + +#include +#include +#include + +namespace scopeone::core::internal +{ + std::unique_ptr IFFTModule::createRuntime() const + { + return std::make_unique(); + } + + ProcessingResult IFFTModule::process(const ImageFrame&, int) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("IFFT requires a complex field")); + } + + ProcessingResult IFFTModule::processValue(const ProcessingValue& input, int processingBitDepth) + { + if (!std::holds_alternative(input)) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("IFFT requires a complex field")); + } + const ComplexFrame& source = std::get(input); + if (!source.isValid()) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid complex field")); + } + + try + { + cv::Mat real(source.height, source.width, CV_32F, + const_cast(source.real.constData()), + source.stride * static_cast(sizeof(float))); + cv::Mat imaginary(source.height, source.width, CV_32F, + const_cast(source.imaginary.constData()), + source.stride * static_cast(sizeof(float))); + cv::Mat planes[] = {real, imaginary}; + cv::Mat complex; + cv::merge(planes, 2, complex); + cv::Mat inverse; + cv::dft(complex, inverse, cv::DFT_INVERSE | cv::DFT_REAL_OUTPUT | cv::DFT_SCALE); + + const cv::Mat visible = source.sourceWidth > 0 + && source.sourceHeight > 0 + && source.sourceWidth <= inverse.cols + && source.sourceHeight <= inverse.rows + ? inverse(cv::Rect(0, + 0, + source.sourceWidth, + source.sourceHeight)) + : inverse; + + const int bits = processingBitDepth >= 16 ? 16 : 8; + const int type = bits == 16 ? CV_16U : CV_8U; + const int maxValue = bits == 16 ? 65535 : 255; + cv::Mat output(visible.size(), type); + cv::normalize(visible, output, 0.0, maxValue, cv::NORM_MINMAX, type); + QByteArray bytes(static_cast(output.total() * output.elemSize()), Qt::Uninitialized); + std::memcpy(bytes.data(), output.data, static_cast(bytes.size())); + ImageFrame frame; + frame.cameraId = source.sourceId; + frame.width = output.cols; + frame.height = output.rows; + frame.stride = static_cast(output.step); + frame.bitsPerSample = bits; + frame.pixelFormat = bits == 16 ? ImagePixelFormat::Mono16 : ImagePixelFormat::Mono8; + frame.frameIndex = source.frameIndex; + frame.timestampNs = source.timestampNs; + frame.bytes = std::move(bytes); + return ProcessingResult(std::move(frame)); + } + catch (const std::exception& e) + { + return ProcessingResult(ImageFrame{}, QString("IFFT failed: %1").arg(e.what())); + } + } +} diff --git a/ScopeOneCore/src/ImageProcessingFramework.cpp b/ScopeOneCore/src/ImageProcessingFramework.cpp index 858e9c7..afaebbe 100644 --- a/ScopeOneCore/src/ImageProcessingFramework.cpp +++ b/ScopeOneCore/src/ImageProcessingFramework.cpp @@ -29,6 +29,12 @@ namespace scopeone::core::internal // Runs all runtime modules in order for one frame ProcessingResult ProcessingPipelineRuntime::process(const ImageFrame& input, int processingBitDepth) + { + return processValue(ProcessingValue{input}, processingBitDepth); + } + + ProcessingResult ProcessingPipelineRuntime::processValue(const ProcessingValue& input, + int processingBitDepth) { return processRange(input, processingBitDepth, @@ -41,7 +47,7 @@ namespace scopeone::core::internal const ImageFrame& input, int processingBitDepth) { - return processRange(input, + return processRange(ProcessingValue{input}, processingBitDepth, startModuleIndex, (std::numeric_limits::max)()); @@ -55,30 +61,36 @@ namespace scopeone::core::internal const int endModuleIndexExclusive = endModuleIndex >= (std::numeric_limits::max)() - 1 ? (std::numeric_limits::max)() : qMax(0, endModuleIndex + 1); - return processRange(input, + return processRange(ProcessingValue{input}, processingBitDepth, 0, endModuleIndexExclusive); } // Runs a runtime pipeline segment and returns its frame or error - ProcessingResult ProcessingPipelineRuntime::processRange(const ImageFrame& input, + ProcessingResult ProcessingPipelineRuntime::processRange(const ProcessingValue& input, int processingBitDepth, int startModuleIndex, int endModuleIndexExclusive) { - if (!input.isValid()) + if (std::holds_alternative(input) + && !std::get(input).isValid()) { - return {{}, QStringLiteral("Invalid processing input")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid processing input")); } - ImageFrame currentFrame(input); + ProcessingValue currentValue(input); + ImageFrame displayFrame; const int startIndex = std::clamp(startModuleIndex, 0, static_cast(m_modules.size())); const int endIndex = std::clamp(endModuleIndexExclusive, startIndex, static_cast(m_modules.size())); for (int moduleIndex = startIndex; moduleIndex < endIndex; ++moduleIndex) { ProcessingModule* module = m_modules[static_cast(moduleIndex)].get(); - ProcessingResult result = module->process(currentFrame, processingBitDepth); + if (!module->isEnabled()) + { + continue; + } + ProcessingResult result = module->processValue(currentValue, processingBitDepth); if (!result.succeeded()) { if (result.error.isEmpty()) @@ -88,19 +100,35 @@ namespace scopeone::core::internal return result; } - copyFrameMetadata(currentFrame, result.frame); - currentFrame = std::move(result.frame); + if (result.hasImage()) + { + ImageFrame image = std::holds_alternative(result.value) + ? std::get(result.value) + : result.frame; + if (std::holds_alternative(currentValue)) + { + copyFrameMetadata(std::get(currentValue), image); + } + result.value = image; + result.frame = image; + } + else if (result.frame.isValid()) + { + displayFrame = result.frame; + } + currentValue = std::move(result.value); + } + ProcessingResult result(std::move(currentValue)); + if (!result.hasImage()) + { + result.frame = std::move(displayFrame); } - return {std::move(currentFrame), {}}; + return result; } // Adds one module to the editable pipeline definition void ProcessingPipelineDefinition::addModule(std::unique_ptr module) { - if (!module) - { - qFatal("Processing pipeline requires a valid module"); - } QMutexLocker locker(&m_modulesMutex); m_modules.push_back(std::move(module)); } @@ -117,6 +145,25 @@ namespace scopeone::core::internal return false; } + // Moves one configured module to a new pipeline position + bool ProcessingPipelineDefinition::moveModule(int from, int to) + { + QMutexLocker locker(&m_modulesMutex); + if (from < 0 || from >= static_cast(m_modules.size()) + || to < 0 || to >= static_cast(m_modules.size())) + { + return false; + } + if (from == to) + { + return true; + } + auto module = std::move(m_modules[static_cast(from)]); + m_modules.erase(m_modules.begin() + from); + m_modules.insert(m_modules.begin() + to, std::move(module)); + return true; + } + // Creates an independent runtime from the current definition std::shared_ptr ProcessingPipelineDefinition::createRuntime() const { @@ -125,7 +172,9 @@ namespace scopeone::core::internal modules.reserve(m_modules.size()); for (const auto& module : m_modules) { - modules.push_back(module->createRuntime()); + auto runtimeModule = module->createRuntime(); + runtimeModule->setEnabled(module->isEnabled()); + modules.push_back(std::move(runtimeModule)); } return std::make_shared(std::move(modules)); } @@ -327,11 +376,13 @@ namespace scopeone::core::internal if (!result.succeeded()) { emit processingError(result.error.isEmpty() - ? QStringLiteral("Processing returned an invalid frame") + ? QStringLiteral("Processing pipeline returned no output") : result.error); return {}; } - return std::move(result.frame); + return result.hasImage() + ? std::get(std::move(result.value)) + : std::move(result.frame); } // Builds a stable key for camera specific processing state diff --git a/ScopeOneCore/src/ImageSceneModel.cpp b/ScopeOneCore/src/ImageSceneModel.cpp index 799eba1..e8ea56e 100644 --- a/ScopeOneCore/src/ImageSceneModel.cpp +++ b/ScopeOneCore/src/ImageSceneModel.cpp @@ -522,6 +522,9 @@ namespace scopeone::core QStringLiteral("Inferno"), QStringLiteral("Magma"), QStringLiteral("Cividis"), + QStringLiteral("Jet"), + QStringLiteral("Rainbow"), + QStringLiteral("Turbo"), }; } diff --git a/ScopeOneCore/src/MDAManager.cpp b/ScopeOneCore/src/MDAManager.cpp index 0015bbc..526c491 100644 --- a/ScopeOneCore/src/MDAManager.cpp +++ b/ScopeOneCore/src/MDAManager.cpp @@ -1,14 +1,13 @@ #include "internal/MDAManager.h" -#include "internal/CameraManager.h" -#include "MMCore.h" +#include "scopeone/CameraProvider.h" +#include "scopeone/HardwareCapabilities.h" #include #include #include #include #include -#include #include #include @@ -23,9 +22,8 @@ namespace scopeone::core::internal } // Creates the MDA manager and registers queued signal types - MDAManager::MDAManager(std::shared_ptr core, QObject* parent) + MDAManager::MDAManager(QObject* parent) : QObject(parent) - , m_mmcore(std::move(core)) { qRegisterMetaType("scopeone::core::internal::MDAOutput"); m_threadPool.setMaxThreadCount(1); @@ -38,9 +36,14 @@ namespace scopeone::core::internal } // Connects MDA capture to the active camera backend - void MDAManager::setCameraManager(CameraManager* cameraManager) + void MDAManager::setCameraProvider(CameraProvider* cameraProvider) { - m_cameraManager = cameraManager; + m_cameraProvider = cameraProvider; + } + + void MDAManager::setStageProvider(StageProvider* stageProvider) + { + m_stageProvider = stageProvider; } // Starts one immutable acquisition event sequence @@ -88,10 +91,10 @@ namespace scopeone::core::internal // Executes a precomputed event sequence in order void MDAManager::runSequence(QList events) { - if (!m_mmcore) + if (!m_cameraProvider) { m_running.store(false); - emit sequenceError(QStringLiteral("MMCore not available")); + emit sequenceError(QStringLiteral("Camera provider not available")); return; } @@ -150,13 +153,8 @@ namespace scopeone::core::internal // Moves hardware into place before capture bool MDAManager::setupEvent(const AcquisitionEvent& event, QString* errorMessage) { - if (!m_mmcore) - { - if (errorMessage) *errorMessage = QStringLiteral("MMCore not available"); - return false; - } - - if (event.exposureMs > 0.0 && !setExposure(event.exposureMs, errorMessage)) + if (event.exposureMs > 0.0 + && !setExposure(event.cameraIds, event.exposureMs, errorMessage)) { return false; } @@ -169,121 +167,29 @@ namespace scopeone::core::internal return false; } - try - { - m_mmcore->waitForSystem(); - } - catch (const CMMError& e) - { - if (errorMessage) *errorMessage = QString::fromStdString(e.getMsg()); - return false; - } return true; } // Routes one event to the active capture implementation bool MDAManager::execEvent(const AcquisitionEvent& event, MDAOutput& output, QString* errorMessage) { - if (event.cameraIds.size() > 1) - { - if (!m_cameraManager) - { - if (errorMessage) *errorMessage = QStringLiteral("CameraManager not available"); - return false; - } - return execEventMultiCamera(event, output, errorMessage); - } - return execEventSingleCamera(event, output, errorMessage); - } - - // Captures one event through the native MMCore camera path - bool MDAManager::execEventSingleCamera(const AcquisitionEvent& event, - MDAOutput& output, - QString* errorMessage) - { - if (!m_mmcore) + if (!m_cameraProvider) { - if (errorMessage) *errorMessage = QStringLiteral("MMCore not available"); + if (errorMessage) *errorMessage = QStringLiteral("Camera provider not available"); return false; } - - try + if (event.cameraIds.isEmpty()) { - m_mmcore->snapImage(); - - const unsigned width = m_mmcore->getImageWidth(); - const unsigned height = m_mmcore->getImageHeight(); - const unsigned bytesPerPixel = m_mmcore->getBytesPerPixel(); - if (bytesPerPixel != 1 && bytesPerPixel != 2) - { - if (errorMessage) *errorMessage = QStringLiteral("Unsupported pixel format"); - return false; - } - - const qint64 stride = static_cast(width) * bytesPerPixel; - const qint64 byteCount = stride * height; - if (width > static_cast((std::numeric_limits::max)()) - || height > static_cast((std::numeric_limits::max)()) - || stride > (std::numeric_limits::max)() - || byteCount <= 0 - || byteCount > (std::numeric_limits::max)()) - { - if (errorMessage) *errorMessage = QStringLiteral("Image frame is too large"); - return false; - } - - const unsigned char* ptr = static_cast(m_mmcore->getImage()); - if (!ptr) - { - if (errorMessage) *errorMessage = QStringLiteral("Empty image buffer"); - return false; - } - - ImageFrame frame; - frame.cameraId = event.cameraIds.isEmpty() ? QString() : event.cameraIds.first(); - frame.width = static_cast(width); - frame.height = static_cast(height); - frame.stride = static_cast(stride); - frame.pixelFormat = bytesPerPixel == 2 ? ImagePixelFormat::Mono16 : ImagePixelFormat::Mono8; - frame.bitsPerSample = ImageFrame::normalizedBitsPerSample( - frame.pixelFormat, - static_cast(m_mmcore->getImageBitDepth())); - frame.timestampNs = currentTimestampNs(); - frame.sourceRoiWidth = frame.width; - frame.sourceRoiHeight = frame.height; - if (!frame.cameraId.isEmpty()) - { - try - { - m_mmcore->getROI(frame.cameraId.toStdString().c_str(), - frame.sourceRoiX, - frame.sourceRoiY, - frame.sourceRoiWidth, - frame.sourceRoiHeight); - } - catch (const CMMError&) - { - frame.sourceRoiX = 0; - frame.sourceRoiY = 0; - frame.sourceRoiWidth = frame.width; - frame.sourceRoiHeight = frame.height; - } - } - frame.bytes = QByteArray(reinterpret_cast(ptr), static_cast(byteCount)); - output.frames.insert(frame.cameraId, frame); - return true; - } - catch (const CMMError& e) - { - if (errorMessage) *errorMessage = QString::fromStdString(e.getMsg()); + if (errorMessage) *errorMessage = QStringLiteral("No camera selected for acquisition event"); return false; } + return captureCameras(event, output, errorMessage); } - // Captures one event across active camera processes - bool MDAManager::execEventMultiCamera(const AcquisitionEvent& event, - MDAOutput& output, - QString* errorMessage) + // Captures one event across all selected camera providers + bool MDAManager::captureCameras(const AcquisitionEvent& event, + MDAOutput& output, + QString* errorMessage) { const int captureTimeoutMs = static_cast((std::max)(1500.0, event.exposureMs * 4.0 + 500.0)); @@ -303,7 +209,7 @@ namespace scopeone::core::internal { CaptureResult result; result.cameraId = cameraId; - if (!m_cameraManager->captureEventFrame(cameraId, result.frame, captureTimeoutMs)) + if (!m_cameraProvider->captureEventFrame(cameraId, result.frame, captureTimeoutMs)) { result.error = QStringLiteral("Failed to capture frame from camera: %1").arg(cameraId); return result; @@ -333,59 +239,61 @@ namespace scopeone::core::internal } // Applies exposure for the next event - bool MDAManager::setExposure(double exposureMs, QString* errorMessage) + bool MDAManager::setExposure(const QStringList& cameraIds, + double exposureMs, + QString* errorMessage) { - try + if (!m_cameraProvider || cameraIds.isEmpty()) { - m_mmcore->setExposure(exposureMs); - return true; + if (errorMessage) *errorMessage = QStringLiteral("Camera provider not available"); + return false; } - catch (const CMMError& e) + for (const QString& cameraId : cameraIds) { - if (errorMessage) *errorMessage = QString::fromStdString(e.getMsg()); - return false; + if (!m_cameraProvider->setExposure(cameraId, exposureMs)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Failed to set exposure for camera: %1") + .arg(cameraId); + } + return false; + } } + return true; } // Moves the active XY stage to an event position bool MDAManager::moveXY(double x, double y, QString* errorMessage) { - try + if (!m_stageProvider) { - const std::string stage = m_mmcore->getXYStageDevice(); - if (stage.empty()) - { - if (errorMessage) *errorMessage = QStringLiteral("No XY stage device configured"); - return false; - } - m_mmcore->setXYPosition(stage.c_str(), x, y); - return true; + if (errorMessage) *errorMessage = QStringLiteral("Stage provider not available"); + return false; } - catch (const CMMError& e) + const QString stage = m_stageProvider->defaultXYStage(); + if (stage.isEmpty()) { - if (errorMessage) *errorMessage = QString::fromStdString(e.getMsg()); + if (errorMessage) *errorMessage = QStringLiteral("No XY stage device configured"); return false; } + return m_stageProvider->setXYPosition(stage, x, y, errorMessage); } // Moves the active focus device to an event position bool MDAManager::moveZ(double z, QString* errorMessage) { - try + if (!m_stageProvider) { - const std::string focus = m_mmcore->getFocusDevice(); - if (focus.empty()) - { - if (errorMessage) *errorMessage = QStringLiteral("No focus device configured"); - return false; - } - m_mmcore->setPosition(focus.c_str(), z); - return true; + if (errorMessage) *errorMessage = QStringLiteral("Stage provider not available"); + return false; } - catch (const CMMError& e) + const QString focus = m_stageProvider->defaultZStage(); + if (focus.isEmpty()) { - if (errorMessage) *errorMessage = QString::fromStdString(e.getMsg()); + if (errorMessage) *errorMessage = QStringLiteral("No focus device configured"); return false; } + return m_stageProvider->setZPosition(focus, z, errorMessage); } } // namespace scopeone::core::internal diff --git a/ScopeOneCore/src/MMCoreManager.cpp b/ScopeOneCore/src/MMCoreManager.cpp index e1f30d5..7915533 100644 --- a/ScopeOneCore/src/MMCoreManager.cpp +++ b/ScopeOneCore/src/MMCoreManager.cpp @@ -16,6 +16,47 @@ namespace scopeone::core::internal { namespace { + scopeone::core::HardwareDeviceKind hardwareDeviceKind(MM::DeviceType type) + { + switch (type) + { + case MM::CameraDevice: + return scopeone::core::HardwareDeviceKind::Camera; + case MM::XYStageDevice: + return scopeone::core::HardwareDeviceKind::XYStage; + case MM::StageDevice: + return scopeone::core::HardwareDeviceKind::ZStage; + case MM::ShutterDevice: + return scopeone::core::HardwareDeviceKind::Shutter; + case MM::StateDevice: + return scopeone::core::HardwareDeviceKind::State; + case MM::HubDevice: + return scopeone::core::HardwareDeviceKind::Hub; + case MM::SerialDevice: + return scopeone::core::HardwareDeviceKind::Serial; + case MM::GenericDevice: + return scopeone::core::HardwareDeviceKind::Generic; + case MM::AutoFocusDevice: + return scopeone::core::HardwareDeviceKind::AutoFocus; + case MM::ImageProcessorDevice: + return scopeone::core::HardwareDeviceKind::ImageProcessor; + case MM::SignalIODevice: + return scopeone::core::HardwareDeviceKind::SignalIO; + case MM::MagnifierDevice: + return scopeone::core::HardwareDeviceKind::Magnifier; + case MM::SLMDevice: + return scopeone::core::HardwareDeviceKind::SLM; + case MM::GalvoDevice: + return scopeone::core::HardwareDeviceKind::Galvo; + case MM::PressurePumpDevice: + return scopeone::core::HardwareDeviceKind::PressurePump; + case MM::VolumetricPumpDevice: + return scopeone::core::HardwareDeviceKind::VolumetricPump; + default: + return scopeone::core::HardwareDeviceKind::Unknown; + } + } + struct DevicePropertyState { QStringList preInitProperties; @@ -85,7 +126,7 @@ namespace scopeone::core::internal QHash> startupProperties; }; - // Reads camera property entries that must be replayed by agent processes + // Read camera property entries that must be replayed by DriverHost processes ConfigPropertyReplay configPropertyReplay(const QString& configPath) { ConfigPropertyReplay replay; @@ -370,8 +411,28 @@ namespace scopeone::core::internal continue; } + scopeone::core::HardwareDeviceDescriptor descriptor; + descriptor.logicalId = deviceName; + descriptor.providerId = QStringLiteral("micro-manager"); + descriptor.providerDeviceId = deviceName; + descriptor.name = deviceName; + descriptor.kind = hardwareDeviceKind(deviceType); + descriptor.state = scopeone::core::HardwareDeviceState::Discovered; + descriptor.endpoint = deviceType == MM::CameraDevice && !useSingleCamera + ? scopeone::core::HardwareEndpointKind::DriverHost + : scopeone::core::HardwareEndpointKind::InProcess; + try + { + descriptor.hardwareId = QString::fromStdString( + m_mmcore->getDeviceName(label.c_str())); + } + catch (const CMMError&) + { + } + if (deviceType == MM::CameraDevice && !useSingleCamera) { + result.devices.append(descriptor); skippedCameraCount++; continue; } @@ -382,11 +443,13 @@ namespace scopeone::core::internal try { m_mmcore->initializeDevice(label.c_str()); + descriptor.state = scopeone::core::HardwareDeviceState::Initialized; successCount++; } catch (const CMMError& error) { failCount++; + descriptor.state = scopeone::core::HardwareDeviceState::Faulted; result.failedDevices.append(deviceName); qWarning().noquote() << QString("Failed to initialize device '%1': %2") @@ -395,8 +458,10 @@ namespace scopeone::core::internal } else { + descriptor.state = scopeone::core::HardwareDeviceState::Initialized; successCount++; } + result.devices.append(descriptor); } catch (const CMMError& error) { @@ -446,7 +511,7 @@ namespace scopeone::core::internal { const bool started = result.useSingleCamera ? cameraManager.configureNativeCamera(m_mmcore, camera.label, camera.exposureMs) - : cameraManager.addAgentCamera(camera.label, + : cameraManager.addDriverHostCamera(camera.label, camera.adapter, camera.device, camera.preInitProperties, @@ -454,6 +519,14 @@ namespace scopeone::core::internal camera.exposureMs); if (!started) { + for (auto& device : result.devices) + { + if (device.logicalId == camera.label) + { + device.state = scopeone::core::HardwareDeviceState::Faulted; + break; + } + } ++result.failCount; result.failedDevices.append(camera.label); result.failedDevices.removeDuplicates(); @@ -461,6 +534,14 @@ namespace scopeone::core::internal return false; } + for (auto& device : result.devices) + { + if (device.logicalId == camera.label) + { + device.state = scopeone::core::HardwareDeviceState::Initialized; + break; + } + } result.cameraIds.append(camera.label); } return true; diff --git a/ScopeOneCore/src/MaskModule.cpp b/ScopeOneCore/src/MaskModule.cpp new file mode 100644 index 0000000..92da108 --- /dev/null +++ b/ScopeOneCore/src/MaskModule.cpp @@ -0,0 +1,288 @@ +#include "internal/MaskModule.h" + +#include "internal/FrameBufferUtils.h" + +#include +#include +#include + +namespace scopeone::core::internal +{ + namespace + { + float smoothBoundary(float distance, double edgeWidth) + { + if (edgeWidth <= 0.0) + { + return distance <= 0.0f ? 1.0f : 0.0f; + } + const double value = qBound(0.0, 0.5 - static_cast(distance) / edgeWidth, 1.0); + return static_cast(value * value * (3.0 - 2.0 * value)); + } + + cv::Mat buildMask(const cv::Size& size, + int shape, + double centerX, + double centerY, + double sizeX, + double sizeY, + double innerSize, + double rotation, + double edgeWidth, + bool invert) + { + cv::Mat mask(size, CV_32F); + const double angle = rotation * std::numbers::pi_v / 180.0; + const double cosine = std::cos(angle); + const double sine = std::sin(angle); + const double halfWidth = qMax(sizeX, 1.0e-9) / 2.0; + const double halfHeight = qMax(sizeY, 1.0e-9) / 2.0; + const double innerRadius = qBound(0.0, innerSize, 1.0); + const double outerRadius = qMax(halfWidth, 1.0e-9); + + for (int y = 0; y < size.height; ++y) + { + float* row = mask.ptr(y); + const double frequencyY = (static_cast(y) - size.height / 2.0) + / static_cast(size.height); + for (int x = 0; x < size.width; ++x) + { + const double frequencyX = (static_cast(x) - size.width / 2.0) + / static_cast(size.width); + const double dx = frequencyX - centerX; + const double dy = frequencyY - centerY; + const double rotatedX = cosine * dx + sine * dy; + const double rotatedY = -sine * dx + cosine * dy; + float value = 0.0f; + + if (shape == 1) + { + const double distance = qMax(std::abs(rotatedX) - halfWidth, + std::abs(rotatedY) - halfHeight); + value = smoothBoundary(static_cast(distance), edgeWidth); + } + else if (shape == 2) + { + const double radius = std::sqrt(rotatedX * rotatedX + rotatedY * rotatedY); + const double outerDistance = radius - outerRadius; + const double innerDistance = innerRadius - radius; + value = qMin(smoothBoundary(static_cast(outerDistance), edgeWidth), + smoothBoundary(static_cast(innerDistance), edgeWidth)); + } + else + { + const double distance = std::sqrt( + (rotatedX / halfWidth) * (rotatedX / halfWidth) + + (rotatedY / halfHeight) * (rotatedY / halfHeight)) - 1.0; + value = smoothBoundary(static_cast(distance), edgeWidth); + } + row[x] = invert ? 1.0f - value : value; + } + } + return mask; + } + + ImageFrame maskPreview(const ComplexFrame& source, const cv::Mat& mask) + { + cv::Mat preview; + mask.convertTo(preview, CV_8U, 255.0); + return makeMono8Frame(source.sourceId, preview.cols, preview.rows, copyMatBytes(preview)); + } + } + + std::unique_ptr MaskModule::createRuntime() const + { + auto module = std::make_unique(); + module->setParameters(parameters()); + return module; + } + + const cv::Mat& MaskModule::maskForSize(const cv::Size& size) + { + const QVariantMap currentParameters = parameters(); + if (m_mask.empty() || m_maskSize != size || m_maskParameters != currentParameters) + { + m_mask = buildMask(size, + m_shape, + m_centerX, + m_centerY, + m_sizeX, + m_sizeY, + m_innerSize, + m_rotation, + m_edgeWidth, + m_invert); + m_maskSize = size; + m_maskParameters = currentParameters; + } + return m_mask; + } + + const cv::Mat& MaskModule::frequencyMaskForSize(const cv::Size& size) + { + const cv::Mat& centeredMask = maskForSize(size); + if (m_frequencyMask.empty() || m_frequencyMask.size() != size) + { + m_frequencyMask.create(size, CV_32F); + const int xOffset = size.width / 2; + const int yOffset = size.height / 2; + for (int y = 0; y < size.height; ++y) + { + const float* source = centeredMask.ptr((y + yOffset) % size.height); + float* output = m_frequencyMask.ptr(y); + for (int x = 0; x < size.width; ++x) + { + output[x] = source[(x + xOffset) % size.width]; + } + } + } + return m_frequencyMask; + } + + void MaskModule::invalidateMask() + { + m_mask.release(); + m_frequencyMask.release(); + m_maskSize = {}; + m_maskParameters.clear(); + } + + ProcessingResult MaskModule::process(const ImageFrame& frame, int processingBitDepth) + { + if (!frame.isValid()) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid mask input")); + } + + ImageFrame workingFrame; + if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth)) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported mask input")); + } + + const cv::Mat& mask = maskForSize({workingFrame.width, workingFrame.height}); + QByteArray bytes = workingFrame.isMono16() + ? allocatePixelBytes(workingFrame.width, workingFrame.height) + : allocatePixelBytes(workingFrame.width, workingFrame.height); + if (bytes.isEmpty()) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Failed to allocate mask output")); + } + + if (workingFrame.isMono16()) + { + parallelForImageRows(workingFrame.width, workingFrame.height, [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + const quint16* source = frameRowData(workingFrame, y); + const float* weights = mask.ptr(y); + quint16* output = reinterpret_cast( + bytes.data() + static_cast(y) * workingFrame.width * sizeof(quint16)); + for (int x = 0; x < workingFrame.width; ++x) + { + output[x] = static_cast(source[x] * weights[x]); + } + } + }); + } + else + { + parallelForImageRows(workingFrame.width, workingFrame.height, [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + const uchar* source = frameRowData(workingFrame, y); + const float* weights = mask.ptr(y); + uchar* output = reinterpret_cast( + bytes.data() + static_cast(y) * workingFrame.width); + for (int x = 0; x < workingFrame.width; ++x) + { + output[x] = static_cast(source[x] * weights[x]); + } + } + }); + } + return ProcessingResult(makeFrameLike(workingFrame, + workingFrame.width, + workingFrame.height, + std::move(bytes))); + } + + ProcessingResult MaskModule::processValue(const ProcessingValue& input, int processingBitDepth) + { + if (std::holds_alternative(input)) + { + return process(std::get(input), processingBitDepth); + } + if (!std::holds_alternative(input)) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported mask input")); + } + const ComplexFrame& source = std::get(input); + if (!source.isValid()) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid complex field")); + } + + try + { + const cv::Size size(source.width, source.height); + const cv::Mat& previewMask = maskForSize(size); + const cv::Mat& mask = frequencyMaskForSize(size); + const cv::Mat real(source.height, source.width, CV_32F, + const_cast(source.real.constData()), + source.stride * static_cast(sizeof(float))); + const cv::Mat imaginary(source.height, source.width, CV_32F, + const_cast(source.imaginary.constData()), + source.stride * static_cast(sizeof(float))); + cv::Mat maskedReal; + cv::Mat maskedImaginary; + cv::multiply(real, mask, maskedReal); + cv::multiply(imaginary, mask, maskedImaginary); + + ComplexFrame output = source; + output.real = copyMatBytes(maskedReal); + output.imaginary = copyMatBytes(maskedImaginary); + output.stride = source.width; + ProcessingResult result(ProcessingValue{std::move(output)}); + result.frame = maskPreview(source, previewMask); + return result; + } + catch (const std::exception& e) + { + return ProcessingResult(ImageFrame{}, QString("Mask processing failed: %1").arg(e.what())); + } + } + + QVariantMap MaskModule::parameters() const + { + return {{QStringLiteral("shape"), m_shape}, + {QStringLiteral("center_x"), m_centerX}, + {QStringLiteral("center_y"), m_centerY}, + {QStringLiteral("size_x"), m_sizeX}, + {QStringLiteral("size_y"), m_sizeY}, + {QStringLiteral("inner_size"), m_innerSize}, + {QStringLiteral("rotation"), m_rotation}, + {QStringLiteral("edge_width"), m_edgeWidth}, + {QStringLiteral("invert"), m_invert}}; + } + + void MaskModule::setParameters(const QVariantMap& parameters) + { + const QVariantMap oldParameters = this->parameters(); + m_shape = qBound(0, parameters.value(QStringLiteral("shape"), m_shape).toInt(), 2); + m_centerX = qBound(-0.5, parameters.value(QStringLiteral("center_x"), m_centerX).toDouble(), 0.5); + m_centerY = qBound(-0.5, parameters.value(QStringLiteral("center_y"), m_centerY).toDouble(), 0.5); + m_sizeX = qBound(0.001, parameters.value(QStringLiteral("size_x"), m_sizeX).toDouble(), 1.0); + m_sizeY = qBound(0.001, parameters.value(QStringLiteral("size_y"), m_sizeY).toDouble(), 1.0); + m_innerSize = qBound(0.0, parameters.value(QStringLiteral("inner_size"), m_innerSize).toDouble(), 1.0); + m_rotation = qBound(-180.0, parameters.value(QStringLiteral("rotation"), m_rotation).toDouble(), 180.0); + m_edgeWidth = qBound(0.0, parameters.value(QStringLiteral("edge_width"), m_edgeWidth).toDouble(), 0.5); + m_invert = parameters.value(QStringLiteral("invert"), m_invert).toBool(); + if (oldParameters != this->parameters()) + { + invalidateMask(); + } + } +} diff --git a/ScopeOneCore/src/MicroManagerProvider.cpp b/ScopeOneCore/src/MicroManagerProvider.cpp new file mode 100644 index 0000000..0b8fdda --- /dev/null +++ b/ScopeOneCore/src/MicroManagerProvider.cpp @@ -0,0 +1,912 @@ +#include "internal/MicroManagerProvider.h" + +#include "MMCore.h" + +#include +#include + +#include +#include +#include + +namespace scopeone::core::internal +{ + namespace + { + QStringList toQStringList(const std::vector& values) + { + QStringList result; + result.reserve(static_cast(values.size())); + for (const std::string& value : values) + { + result.append(QString::fromStdString(value)); + } + return result; + } + + void setError(QString* errorMessage, const CMMError& error) + { + if (errorMessage) + { + *errorMessage = QString::fromStdString(error.getMsg()); + } + } + } + + MicroManagerProvider::MicroManagerProvider(std::shared_ptr core, + CameraProvider* cameraProvider, + CameraRuntimeControl* cameraRuntimeControl) + : m_core(std::move(core)) + , m_cameraProvider(cameraProvider) + , m_cameraRuntimeControl(cameraRuntimeControl) + { + } + + HardwareProviderDescriptor MicroManagerProvider::descriptor() const + { + return { + QStringLiteral("micro-manager"), + QStringLiteral("Micro-Manager"), + QStringLiteral("MMCore") + }; + } + + QList MicroManagerProvider::devices() const + { + return m_devices; + } + + void MicroManagerProvider::setDevices(const QList& devices) + { + m_devices = devices; + } + + bool MicroManagerProvider::isCamera(const QString& deviceId) const + { + const QString normalizedId = deviceId.trimmed(); + return std::any_of(m_devices.cbegin(), m_devices.cend(), + [&normalizedId](const HardwareDeviceDescriptor& device) + { + return device.logicalId == normalizedId + && device.kind == HardwareDeviceKind::Camera; + }); + } + + void MicroManagerProvider::setFrameSink(FrameSink sink) + { + if (m_cameraProvider) + { + m_cameraProvider->setFrameSink(std::move(sink)); + } + } + + void MicroManagerProvider::setPreviewStateSink(PreviewStateSink sink) + { + if (m_cameraProvider) + { + m_cameraProvider->setPreviewStateSink(std::move(sink)); + } + } + + bool MicroManagerProvider::startPreview() + { + return m_cameraProvider && m_cameraProvider->startPreview(); + } + + bool MicroManagerProvider::stopPreview() + { + return m_cameraProvider && m_cameraProvider->stopPreview(); + } + + bool MicroManagerProvider::startPreviewFor(const QString& cameraId) + { + return m_cameraProvider && m_cameraProvider->startPreviewFor(cameraId); + } + + bool MicroManagerProvider::stopPreviewFor(const QString& cameraId) + { + return m_cameraProvider && m_cameraProvider->stopPreviewFor(cameraId); + } + + bool MicroManagerProvider::isPreviewRunning(const QString& cameraId) const + { + return m_cameraProvider && m_cameraProvider->isPreviewRunning(cameraId); + } + + bool MicroManagerProvider::getExposure(const QString& cameraIdOrAll, double& exposureMs) const + { + return m_cameraProvider && m_cameraProvider->getExposure(cameraIdOrAll, exposureMs); + } + + bool MicroManagerProvider::setExposure(const QString& cameraIdOrAll, double exposureMs) + { + return m_cameraProvider && m_cameraProvider->setExposure(cameraIdOrAll, exposureMs); + } + + QStringList MicroManagerProvider::listProperties(const QString& cameraId) + { + if (isCamera(cameraId)) + { + return m_cameraProvider ? m_cameraProvider->listProperties(cameraId) : QStringList{}; + } + if (!m_core) + { + return {}; + } + try + { + return toQStringList( + m_core->getDevicePropertyNames(cameraId.trimmed().toStdString().c_str())); + } + catch (const CMMError&) + { + return {}; + } + } + + QString MicroManagerProvider::getProperty(const QString& cameraId, + const QString& name, + bool fromCache) + { + if (isCamera(cameraId)) + { + return m_cameraProvider + ? m_cameraProvider->getProperty(cameraId, name, fromCache) + : QString{}; + } + if (!m_core) + { + return {}; + } + try + { + const std::string device = cameraId.trimmed().toStdString(); + const std::string property = name.trimmed().toStdString(); + return QString::fromStdString( + fromCache + ? m_core->getPropertyFromCache(device.c_str(), property.c_str()) + : m_core->getProperty(device.c_str(), property.c_str())); + } + catch (const CMMError&) + { + return {}; + } + } + + bool MicroManagerProvider::setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) + { + if (isCamera(cameraId)) + { + return m_cameraProvider + && m_cameraProvider->setProperty(cameraId, name, value, errorMessage); + } + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + const std::string device = cameraId.trimmed().toStdString(); + m_core->setProperty(device.c_str(), + name.trimmed().toStdString().c_str(), + value.toStdString().c_str()); + m_core->waitForDevice(device.c_str()); + m_core->updateSystemStateCache(); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + QString MicroManagerProvider::getPropertyType(const QString& cameraId, const QString& name) + { + if (isCamera(cameraId)) + { + return m_cameraProvider + ? m_cameraProvider->getPropertyType(cameraId, name) + : QStringLiteral("Unknown"); + } + if (!m_core) + { + return QStringLiteral("Unknown"); + } + try + { + const MM::PropertyType type = m_core->getPropertyType( + cameraId.trimmed().toStdString().c_str(), + name.trimmed().toStdString().c_str()); + switch (type) + { + case MM::String: return QStringLiteral("String"); + case MM::Float: return QStringLiteral("Float"); + case MM::Integer: return QStringLiteral("Integer"); + default: return QStringLiteral("Unknown"); + } + } + catch (const CMMError&) + { + return QStringLiteral("Unknown"); + } + } + + bool MicroManagerProvider::isPropertyReadOnly(const QString& cameraId, const QString& name) + { + if (isCamera(cameraId)) + { + return !m_cameraProvider || m_cameraProvider->isPropertyReadOnly(cameraId, name); + } + if (!m_core) + { + return true; + } + try + { + return m_core->isPropertyReadOnly(cameraId.trimmed().toStdString().c_str(), + name.trimmed().toStdString().c_str()); + } + catch (const CMMError&) + { + return true; + } + } + + bool MicroManagerProvider::isPropertyPreInit(const QString& cameraId, const QString& name) + { + if (isCamera(cameraId)) + { + return m_cameraProvider && m_cameraProvider->isPropertyPreInit(cameraId, name); + } + if (!m_core) + { + return false; + } + try + { + return m_core->isPropertyPreInit(cameraId.trimmed().toStdString().c_str(), + name.trimmed().toStdString().c_str()); + } + catch (const CMMError&) + { + return false; + } + } + + QStringList MicroManagerProvider::getAllowedPropertyValues(const QString& cameraId, + const QString& name) + { + if (isCamera(cameraId)) + { + return m_cameraProvider + ? m_cameraProvider->getAllowedPropertyValues(cameraId, name) + : QStringList{}; + } + if (!m_core) + { + return {}; + } + try + { + return toQStringList(m_core->getAllowedPropertyValues( + cameraId.trimmed().toStdString().c_str(), + name.trimmed().toStdString().c_str())); + } + catch (const CMMError&) + { + return {}; + } + } + + bool MicroManagerProvider::hasPropertyLimits(const QString& cameraId, const QString& name) + { + if (isCamera(cameraId)) + { + return m_cameraProvider && m_cameraProvider->hasPropertyLimits(cameraId, name); + } + if (!m_core) + { + return false; + } + try + { + return m_core->hasPropertyLimits(cameraId.trimmed().toStdString().c_str(), + name.trimmed().toStdString().c_str()); + } + catch (const CMMError&) + { + return false; + } + } + + double MicroManagerProvider::getPropertyLowerLimit(const QString& cameraId, const QString& name) + { + if (isCamera(cameraId)) + { + return m_cameraProvider ? m_cameraProvider->getPropertyLowerLimit(cameraId, name) : 0.0; + } + if (!m_core) + { + return 0.0; + } + try + { + return m_core->getPropertyLowerLimit(cameraId.trimmed().toStdString().c_str(), + name.trimmed().toStdString().c_str()); + } + catch (const CMMError&) + { + return 0.0; + } + } + + double MicroManagerProvider::getPropertyUpperLimit(const QString& cameraId, const QString& name) + { + if (isCamera(cameraId)) + { + return m_cameraProvider ? m_cameraProvider->getPropertyUpperLimit(cameraId, name) : 0.0; + } + if (!m_core) + { + return 0.0; + } + try + { + return m_core->getPropertyUpperLimit(cameraId.trimmed().toStdString().c_str(), + name.trimmed().toStdString().c_str()); + } + catch (const CMMError&) + { + return 0.0; + } + } + + bool MicroManagerProvider::setROI(const QString& cameraId, + int x, + int y, + int width, + int height) + { + return m_cameraProvider && m_cameraProvider->setROI(cameraId, x, y, width, height); + } + + bool MicroManagerProvider::clearROI(const QString& cameraId) + { + return m_cameraProvider && m_cameraProvider->clearROI(cameraId); + } + + bool MicroManagerProvider::getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) + { + return m_cameraProvider && m_cameraProvider->getROI(cameraId, x, y, width, height); + } + + bool MicroManagerProvider::captureEventFrame(const QString& cameraId, + scopeone::core::ImageFrame& frame, + int timeoutMs) + { + return m_cameraProvider && m_cameraProvider->captureEventFrame(cameraId, frame, timeoutMs); + } + + void MicroManagerProvider::setFrameDeliveryPaused(const QStringList& cameraIds, bool paused) + { + if (m_cameraRuntimeControl) + { + m_cameraRuntimeControl->setFrameDeliveryPaused(cameraIds, paused); + } + } + + bool MicroManagerProvider::setRecordingFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) + { + return !m_cameraRuntimeControl + || m_cameraRuntimeControl->setRecordingFrameDeliveryEnabled(cameraIds, enabled); + } + + bool MicroManagerProvider::setHighRateFrameDeliveryEnabled(const QStringList& cameraIds, + bool enabled) + { + return !m_cameraRuntimeControl + || m_cameraRuntimeControl->setHighRateFrameDeliveryEnabled(cameraIds, enabled); + } + + bool MicroManagerProvider::isProcessingFrameTokenCurrent(const QString& cameraId, + quint64 token) + { + return m_cameraRuntimeControl + && m_cameraRuntimeControl->isProcessingFrameTokenCurrent(cameraId, token); + } + + void MicroManagerProvider::finishProcessingFrame(const QString& cameraId, quint64 token) + { + if (m_cameraRuntimeControl) + { + m_cameraRuntimeControl->finishProcessingFrame(cameraId, token); + } + } + + QString MicroManagerProvider::defaultXYStage() const + { + if (!m_core) + { + return {}; + } + try + { + return QString::fromStdString(m_core->getXYStageDevice()); + } + catch (const CMMError&) + { + return {}; + } + } + + QString MicroManagerProvider::defaultZStage() const + { + if (!m_core) + { + return {}; + } + try + { + return QString::fromStdString(m_core->getFocusDevice()); + } + catch (const CMMError&) + { + return {}; + } + } + + bool MicroManagerProvider::getXYPosition(const QString& deviceId, + double& x, + double& y, + QString* errorMessage) const + { + x = 0.0; + y = 0.0; + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + m_core->getXYPosition(deviceId.trimmed().toStdString().c_str(), x, y); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::getZPosition(const QString& deviceId, + double& z, + QString* errorMessage) const + { + z = 0.0; + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + z = m_core->getPosition(deviceId.trimmed().toStdString().c_str()); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::setRelativeXYPosition(const QString& deviceId, + double dx, + double dy, + QString* errorMessage) + { + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + const std::string device = deviceId.trimmed().toStdString(); + m_core->setRelativeXYPosition(device.c_str(), dx, dy); + m_core->waitForDevice(device.c_str()); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::setRelativeZPosition(const QString& deviceId, + double dz, + QString* errorMessage) + { + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + const std::string device = deviceId.trimmed().toStdString(); + m_core->setRelativePosition(device.c_str(), dz); + m_core->waitForDevice(device.c_str()); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::setXYPosition(const QString& deviceId, + double x, + double y, + QString* errorMessage) + { + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + const std::string device = deviceId.trimmed().toStdString(); + m_core->setXYPosition(device.c_str(), x, y); + m_core->waitForDevice(device.c_str()); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::setZPosition(const QString& deviceId, + double z, + QString* errorMessage) + { + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + const std::string device = deviceId.trimmed().toStdString(); + m_core->setPosition(device.c_str(), z); + m_core->waitForDevice(device.c_str()); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::isShutterOpen(const QString& deviceId, + bool& open, + QString* errorMessage) const + { + open = false; + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + open = m_core->getShutterOpen(deviceId.trimmed().toStdString().c_str()); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::setShutterOpen(const QString& deviceId, + bool open, + QString* errorMessage) + { + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + const std::string device = deviceId.trimmed().toStdString(); + m_core->setShutterOpen(device.c_str(), open); + m_core->waitForDevice(device.c_str()); + m_core->updateSystemStateCache(); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::getState(const QString& deviceId, + long& state, + QString* errorMessage) const + { + state = 0; + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + state = m_core->getState(deviceId.trimmed().toStdString().c_str()); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + bool MicroManagerProvider::setState(const QString& deviceId, + long state, + QString* errorMessage) + { + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + try + { + const std::string device = deviceId.trimmed().toStdString(); + m_core->setState(device.c_str(), state); + m_core->waitForDevice(device.c_str()); + m_core->updateSystemStateCache(); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } + + QString MicroManagerProvider::stateLabel(const QString& deviceId, long state) const + { + if (!m_core || state < 0) + { + return {}; + } + try + { + const std::vector labels = + m_core->getStateLabels(deviceId.trimmed().toStdString().c_str()); + const size_t index = static_cast(state); + return index < labels.size() ? QString::fromStdString(labels[index]) : QString{}; + } + catch (const CMMError&) + { + return {}; + } + } + + QStringList MicroManagerProvider::availableConfigGroups() const + { + if (!m_core) + { + return {}; + } + try + { + return toQStringList(m_core->getAvailableConfigGroups()); + } + catch (const CMMError&) + { + return {}; + } + } + + QStringList MicroManagerProvider::availableConfigs(const QString& groupName) const + { + if (!m_core) + { + return {}; + } + try + { + return toQStringList( + m_core->getAvailableConfigs(groupName.trimmed().toStdString().c_str())); + } + catch (const CMMError&) + { + return {}; + } + } + + QString MicroManagerProvider::currentConfig(const QString& groupName) const + { + if (!m_core) + { + return {}; + } + try + { + const std::string group = groupName.trimmed().toStdString(); + const std::vector configs = m_core->getAvailableConfigs(group.c_str()); + QHash currentValues; + QSet failedProperties; + for (const std::string& config : configs) + { + const Configuration preset = m_core->getConfigData(group.c_str(), config.c_str()); + bool matches = true; + for (size_t index = 0; index < preset.size(); ++index) + { + const PropertySetting setting = preset.getSetting(index); + const QString device = QString::fromStdString(setting.getDeviceLabel()); + const QString property = QString::fromStdString(setting.getPropertyName()); + const QString key = device + QChar(0x1f) + property; + if (!currentValues.contains(key) && !failedProperties.contains(key)) + { + const QString value = const_cast(this)->getProperty( + device, property, false); + if (value.isNull()) + { + failedProperties.insert(key); + } + else + { + currentValues.insert(key, value); + } + } + if (failedProperties.contains(key) + || currentValues.value(key) + != QString::fromStdString(setting.getPropertyValue())) + { + matches = false; + break; + } + } + if (matches) + { + return QString::fromStdString(config); + } + } + } + catch (const CMMError&) + { + } + return {}; + } + + bool MicroManagerProvider::setConfig(const QString& groupName, + const QString& configName, + QString* errorMessage) + { + if (errorMessage) + { + errorMessage->clear(); + } + if (!m_core) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("MMCore not available"); + } + return false; + } + + try + { + const std::string group = groupName.trimmed().toStdString(); + const std::string config = configName.trimmed().toStdString(); + const Configuration preset = m_core->getConfigData(group.c_str(), config.c_str()); + std::vector pending; + pending.reserve(preset.size()); + for (size_t index = 0; index < preset.size(); ++index) + { + pending.push_back(preset.getSetting(index)); + } + + while (!pending.empty()) + { + std::vector failed; + QString failureDescription; + for (const PropertySetting& setting : pending) + { + const QString device = QString::fromStdString(setting.getDeviceLabel()); + const QString property = QString::fromStdString(setting.getPropertyName()); + const QString value = QString::fromStdString(setting.getPropertyValue()); + QString error; + if (!setProperty(device, property, value, &error)) + { + failed.push_back(setting); + failureDescription = QStringLiteral("%1.%2 = %3: %4") + .arg(device, property, value, error); + } + } + if (failed.empty()) + { + break; + } + if (failed.size() == pending.size()) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Failed to apply config preset %1 = %2 at %3") + .arg(groupName, configName, failureDescription); + } + return false; + } + pending = std::move(failed); + } + + m_core->waitForSystem(); + m_core->updateSystemStateCache(); + return true; + } + catch (const CMMError& error) + { + setError(errorMessage, error); + return false; + } + } +} diff --git a/ScopeOneCore/src/PluginManifest.cpp b/ScopeOneCore/src/PluginManifest.cpp new file mode 100644 index 0000000..6005aa5 --- /dev/null +++ b/ScopeOneCore/src/PluginManifest.cpp @@ -0,0 +1,74 @@ +#include "scopeone/PluginManifest.h" + +#include + +namespace scopeone::core +{ + QString pluginKindName(PluginKind kind) + { + switch (kind) + { + case PluginKind::Processing: + return QStringLiteral("processing"); + case PluginKind::Tool: + return QStringLiteral("tool"); + case PluginKind::Hardware: + return QStringLiteral("hardware"); + } + return {}; + } + + bool parsePluginManifest(const QJsonObject& metadata, + PluginKind expectedKind, + PluginManifest& manifest, + QString* errorMessage) + { + const QString id = metadata.value(QStringLiteral("id")).toString().trimmed(); + const QString name = metadata.value(QStringLiteral("name")).toString().trimmed(); + const QString version = metadata.value(QStringLiteral("version")).toString().trimmed(); + const QString kind = metadata.value(QStringLiteral("kind")).toString().trimmed(); + const int apiVersion = metadata.value(QStringLiteral("scopeOneApi")).toInt(); + const QString expectedKindName = pluginKindName(expectedKind); + + QString error; + static const QRegularExpression idPattern( + QStringLiteral("^[A-Za-z0-9][A-Za-z0-9._-]*$")); + if (id.isEmpty() || name.isEmpty() || version.isEmpty()) + { + error = QStringLiteral("plugin manifest requires id, name and version"); + } + else if (!idPattern.match(id).hasMatch()) + { + error = QStringLiteral("plugin id contains unsupported characters"); + } + else if (kind != expectedKindName) + { + error = QStringLiteral("plugin kind must be '%1'").arg(expectedKindName); + } + else if (apiVersion != ScopeOnePluginApiVersion) + { + error = QStringLiteral("unsupported ScopeOne plugin API %1").arg(apiVersion); + } + + if (!error.isEmpty()) + { + if (errorMessage) + { + *errorMessage = error; + } + return false; + } + + manifest.id = id; + manifest.name = name; + manifest.version = version; + manifest.kind = expectedKind; + manifest.autoLoad = metadata.value(QStringLiteral("autoLoad")).toBool(); + manifest.metadata = metadata; + if (errorMessage) + { + errorMessage->clear(); + } + return true; + } +} diff --git a/ScopeOneCore/src/ProcessingModuleRegistry.cpp b/ScopeOneCore/src/ProcessingModuleRegistry.cpp new file mode 100644 index 0000000..18a61bb --- /dev/null +++ b/ScopeOneCore/src/ProcessingModuleRegistry.cpp @@ -0,0 +1,331 @@ +#include "internal/ProcessingModuleRegistry.h" + +#include "internal/BackgroundCalibrationModule.h" +#include "internal/DifferentialRollingModule.h" +#include "internal/FFTModule.h" +#include "internal/FrequencyDomainFilterModule.h" +#include "internal/MaskModule.h" +#include "internal/GaussianBlurModule.h" +#include "internal/IFFTModule.h" +#include "internal/SpatiotemporalBinningModule.h" +#include "scopeone/PluginManifest.h" + +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +# include +#endif + +namespace scopeone::core::internal +{ + namespace + { + bool validDescriptor(const ProcessingModuleDescriptor& descriptor) + { + QSet parameterKeys; + for (const ProcessingParameterDescriptor& parameter : descriptor.parameters) + { + const QString key = parameter.key.trimmed(); + if (key.isEmpty() + || parameter.name.trimmed().isEmpty() + || parameterKeys.contains(key)) + { + return false; + } + parameterKeys.insert(key); + } + return !descriptor.id.trimmed().isEmpty() + && !descriptor.name.trimmed().isEmpty(); + } + + ProcessingParameterDescriptor integerParameter(const char* key, + const char* name, + int value, + int minimum, + int maximum, + int step = 1) + { + return {QString::fromLatin1(key), + QString::fromLatin1(name), + ProcessingParameterType::Integer, + value, + minimum, + maximum, + step}; + } + + ProcessingParameterDescriptor realParameter(const char* key, + const char* name, + double value, + double minimum, + double maximum, + double step, + int decimals) + { + ProcessingParameterDescriptor descriptor{ + QString::fromLatin1(key), + QString::fromLatin1(name), + ProcessingParameterType::Real, + value, + minimum, + maximum, + step}; + descriptor.decimals = decimals; + return descriptor; + } + + ProcessingParameterDescriptor booleanParameter(const char* key, + const char* name, + bool value) + { + return {QString::fromLatin1(key), + QString::fromLatin1(name), + ProcessingParameterType::Boolean, + value}; + } + + ProcessingParameterDescriptor choiceParameter( + const char* key, + const char* name, + int value, + std::initializer_list choices) + { + ProcessingParameterDescriptor descriptor{ + QString::fromLatin1(key), + QString::fromLatin1(name), + ProcessingParameterType::Choice, + value}; + int index = 0; + for (const char* choice : choices) + { + descriptor.choices.append({QString::fromLatin1(choice), index++}); + } + return descriptor; + } + + template + bool registerBuiltIn(ProcessingModuleRegistry& registry, + ProcessingModuleDescriptor descriptor) + { + return registry.registerModule(descriptor, []() + { + return std::make_unique(); + }); + } + + void registerBuiltIns(ProcessingModuleRegistry& registry) + { + registerBuiltIn( + registry, + {QStringLiteral("spatiotemporal_binning"), + QStringLiteral("Spatiotemporal Binning"), + 1, + {integerParameter("spatial_bin_x", "Spatial X", 1, 1, 64), + integerParameter("spatial_bin_y", "Spatial Y", 1, 1, 64), + integerParameter("temporal_bin", "Temporal", 1, 1, 256), + choiceParameter("spatial_mode", "Spatial mode", 0, + {"Mean", "Sum", "Minimum", "Maximum", "Skip"}), + choiceParameter("temporal_mode", "Temporal mode", 0, + {"Mean", "Sum", "Minimum", "Maximum", "Skip"})}, + true}); + + registerBuiltIn( + registry, + {QStringLiteral("gaussian_blur"), + QStringLiteral("Gaussian Blur"), + 1, + {integerParameter("kernel_size", "Kernel size", 3, 1, 99, 2), + realParameter("sigma", "Sigma", 0.0, 0.0, 100.0, 0.1, 2)}}); + + registerBuiltIn( + registry, + {QStringLiteral("fft"), QStringLiteral("FFT"), 1, {}}); + + registerBuiltIn( + registry, + {QStringLiteral("frequency_domain_filter"), + QStringLiteral("Frequency Domain Filter"), + 1, + {choiceParameter("output_mode", "Output", 2, + {"Spectrum", "Filtered spectrum", "Filtered image"}), + realParameter("min_feature_size", "Min feature size", 2.0, + 0.0, 1000.0, 0.1, 2), + realParameter("max_feature_size", "Max feature size", 10.0, + 0.0, 1000.0, 0.1, 2), + choiceParameter("filter_kind", "Filter kind", 0, + {"Smooth", "Hard"})}}); + + registerBuiltIn( + registry, + {QStringLiteral("mask"), + QStringLiteral("Mask"), + 1, + {choiceParameter("shape", "Shape", 0, {"Ellipse", "Rectangle", "Annulus"}), + realParameter("center_x", "Center X", 0.0, -0.5, 0.5, 0.001, 3), + realParameter("center_y", "Center Y", 0.0, -0.5, 0.5, 0.001, 3), + realParameter("size_x", "Size X", 0.1, 0.001, 1.0, 0.001, 3), + realParameter("size_y", "Size Y", 0.1, 0.001, 1.0, 0.001, 3), + realParameter("inner_size", "Inner size", 0.0, 0.0, 1.0, 0.001, 3), + realParameter("rotation", "Rotation", 0.0, -180.0, 180.0, 0.1, 1), + realParameter("edge_width", "Edge width", 0.0, 0.0, 0.5, 0.001, 3), + booleanParameter("invert", "Invert", false)}}); + + registerBuiltIn( + registry, + {QStringLiteral("ifft"), QStringLiteral("IFFT"), 1, {}}); + + registerBuiltIn( + registry, + {QStringLiteral("differential_rolling"), + QStringLiteral("Differential Rolling"), + 1, + {integerParameter("batch_size", "Batch size", 16, 1, 256), + booleanParameter("normalize", "Normalize by batch 1", true)}, + true}); + + registerBuiltIn( + registry, + {QStringLiteral("background_calibration"), + QStringLiteral("Background Calibration"), + 1, + {integerParameter("calibration_frames", "Frames", 101, 3, 1001, 2), + choiceParameter("mode", "Mode", 0, {"Snapshot", "Running"}), + choiceParameter("method", "Method", 0, + {"Median", "Mean", "Maximum", "Minimum"}), + choiceParameter("operation", "Operation", 0, + {"Subtract", "Add", "Multiply", "Divide"})}, + true}); + } + } + + ProcessingModuleRegistry::ProcessingModuleRegistry() + { + registerBuiltIns(*this); + } + + ProcessingModuleRegistry::~ProcessingModuleRegistry() = default; + + bool ProcessingModuleRegistry::registerModule(const ProcessingModuleDescriptor& descriptor, + Factory factory) + { + const QString id = descriptor.id.trimmed(); + if (!validDescriptor(descriptor) || !factory || m_entries.contains(id)) + { + return false; + } + + ProcessingModuleDescriptor normalized = descriptor; + normalized.id = id; + m_entries.insert(id, {std::move(normalized), std::move(factory)}); + m_order.append(id); + return true; + } + + QList ProcessingModuleRegistry::descriptors() const + { + QList result; + result.reserve(m_order.size()); + for (const QString& id : m_order) + { + result.append(m_entries.value(id).descriptor); + } + return result; + } + + ProcessingModuleDescriptor ProcessingModuleRegistry::descriptor(const QString& moduleId) const + { + return m_entries.value(moduleId.trimmed()).descriptor; + } + + std::unique_ptr ProcessingModuleRegistry::create(const QString& moduleId) const + { + const auto it = m_entries.constFind(moduleId.trimmed()); + if (it == m_entries.constEnd()) + { + return {}; + } + std::unique_ptr module = it->factory(); + return module && module->id() == it->descriptor.id ? std::move(module) : nullptr; + } + + QStringList ProcessingModuleRegistry::loadPlugins(const QString& directoryPath) + { + QStringList errors; + const QDir directory(directoryPath); +#ifdef Q_OS_WIN + SetDllDirectoryW(reinterpret_cast(directoryPath.utf16())); +#endif + for (const QFileInfo& file : directory.entryInfoList(QDir::Files, QDir::Name)) + { + if (!QLibrary::isLibrary(file.absoluteFilePath())) + { + continue; + } + + auto loader = std::make_unique(file.absoluteFilePath()); + const QJsonObject metadata = loader->metaData(); + if (metadata.value(QStringLiteral("IID")).toString() + != QStringLiteral(ScopeOneProcessingPlugin_iid)) + { + continue; + } + PluginManifest manifest; + QString manifestError; + if (!parsePluginManifest( + metadata.value(QStringLiteral("MetaData")).toObject(), + PluginKind::Processing, + manifest, + &manifestError)) + { + errors.append(QStringLiteral("%1: %2").arg(file.fileName(), manifestError)); + continue; + } + QObject* instance = loader->instance(); + auto* plugin = qobject_cast(instance); + if (!plugin) + { + errors.append(QStringLiteral("%1: %2") + .arg(file.fileName(), loader->errorString())); + continue; + } + + const QList pluginDescriptors = plugin->processingModules(); + QSet pluginIds; + bool valid = !pluginDescriptors.isEmpty(); + for (const ProcessingModuleDescriptor& descriptor : pluginDescriptors) + { + const QString id = descriptor.id.trimmed(); + if (!validDescriptor(descriptor) + || pluginIds.contains(id) + || m_entries.contains(id)) + { + valid = false; + break; + } + pluginIds.insert(id); + } + if (!valid) + { + errors.append(QStringLiteral("%1: invalid or duplicate processing module id") + .arg(file.fileName())); + loader->unload(); + continue; + } + + for (const ProcessingModuleDescriptor& descriptor : pluginDescriptors) + { + const QString id = descriptor.id.trimmed(); + registerModule(descriptor, [plugin, id]() + { + return plugin->createProcessingModule(id); + }); + } + m_pluginLoaders.push_back(std::move(loader)); + } + return errors; + } +} diff --git a/ScopeOneCore/src/ProcessingPipeline.cpp b/ScopeOneCore/src/ProcessingPipeline.cpp new file mode 100644 index 0000000..6d3c8a0 --- /dev/null +++ b/ScopeOneCore/src/ProcessingPipeline.cpp @@ -0,0 +1,86 @@ +#include "scopeone/ProcessingPipeline.h" + +#include "internal/ImageProcessingFramework.h" + +namespace scopeone::core +{ + struct ProcessingPipeline::Impl + { + internal::ProcessingPipelineDefinition definition; + std::shared_ptr runtime; + + void rebuildRuntime() + { + runtime = definition.createRuntime(); + } + }; + + ProcessingPipeline::ProcessingPipeline() + : m_impl(std::make_unique()) + { + m_impl->rebuildRuntime(); + } + + ProcessingPipeline::~ProcessingPipeline() = default; + ProcessingPipeline::ProcessingPipeline(ProcessingPipeline&&) noexcept = default; + ProcessingPipeline& ProcessingPipeline::operator=(ProcessingPipeline&&) noexcept = default; + + bool ProcessingPipeline::addModule(std::unique_ptr module) + { + if (!module) + { + return false; + } + m_impl->definition.addModule(std::move(module)); + m_impl->rebuildRuntime(); + return true; + } + + bool ProcessingPipeline::removeModule(int index) + { + const bool removed = m_impl->definition.removeModule(index); + if (removed) + { + m_impl->rebuildRuntime(); + } + return removed; + } + + bool ProcessingPipeline::setModuleParameters(int index, const QVariantMap& parameters) + { + const bool updated = m_impl->definition.withModule(index, [¶meters](ProcessingModule* module) + { + module->setParameters(parameters); + }); + if (updated) + { + m_impl->rebuildRuntime(); + } + return updated; + } + + bool ProcessingPipeline::resetModuleState(int index) + { + bool reset = false; + m_impl->definition.withModule(index, [&reset](ProcessingModule* module) + { + reset = module->resetState(); + }); + if (reset) + { + m_impl->rebuildRuntime(); + } + return reset; + } + + int ProcessingPipeline::moduleCount() const + { + return m_impl->definition.moduleCount(); + } + + ProcessingResult ProcessingPipeline::process(const ProcessingValue& input, + int processingBitDepth) const + { + return m_impl->runtime->processValue(input, processingBitDepth); + } +} diff --git a/ScopeOneCore/src/RecordingManager.cpp b/ScopeOneCore/src/RecordingManager.cpp index bd8b8ad..4c0773b 100644 --- a/ScopeOneCore/src/RecordingManager.cpp +++ b/ScopeOneCore/src/RecordingManager.cpp @@ -1,7 +1,6 @@ #include "internal/RecordingManager.h" -#include "internal/CameraManager.h" -#include "MMCore.h" +#include "scopeone/CameraProvider.h" #include #include @@ -802,10 +801,6 @@ namespace scopeone::core::internal plan.cameraIds.append(trimmedCameraId); } } - if (plan.cameraIds.isEmpty() && m_mmcore) - { - plan.cameraIds << QStringLiteral("Camera"); - } if (plan.cameraIds.isEmpty()) { errorMessage = QStringLiteral("No cameras available for recording"); @@ -833,7 +828,7 @@ namespace scopeone::core::internal { return false; } - if (!planUsesMda(plan) && !planStreamsMda(plan) && !m_cameraManager && !m_latestFrameFetcher) + if (!planUsesMda(plan) && !planStreamsMda(plan) && !m_cameraProvider && !m_latestFrameFetcher) { errorMessage = QStringLiteral("Frame source is not available for recording"); return false; @@ -850,7 +845,7 @@ namespace scopeone::core::internal // Returns whether native preview recording uses the requested frame interval bool RecordingManager::planStreamsMda(const ExperimentPlan& plan) const { - return m_mmcore && plan.cameraIds.size() == 1 && !planUsesMda(plan); + return m_cameraProvider && plan.cameraIds.size() == 1 && !planUsesMda(plan); } // Resets counters and MDA state for a new capture plan @@ -1329,8 +1324,9 @@ namespace scopeone::core::internal if (!usesMda) { primeLastFrameIndices(); - if (m_cameraManager - && !m_cameraManager->setRecordingFrameDeliveryEnabled(true)) + if (m_cameraRuntimeControl + && !m_cameraRuntimeControl->setRecordingFrameDeliveryEnabled( + m_captureState.activeCameraIds, true)) { if (plan.streamToDisk) { @@ -1346,9 +1342,10 @@ namespace scopeone::core::internal { if (!startStreamingOutputs(plan)) { - if (m_cameraManager) + if (m_cameraRuntimeControl) { - m_cameraManager->setRecordingFrameDeliveryEnabled(false); + m_cameraRuntimeControl->setRecordingFrameDeliveryEnabled( + m_captureState.activeCameraIds, false); } const QString writerError = writerErrorSnapshot(); qWarning().noquote() << (writerError.isEmpty() @@ -1403,6 +1400,7 @@ namespace scopeone::core::internal if (!m_captureState.isRecording) return; const bool streamToDisk = m_captureState.streamToDisk; + const QStringList activeCameraIds = m_captureState.activeCameraIds; auto session = m_activeSession; m_completionPending = true; if (streamToDisk) @@ -1414,17 +1412,17 @@ namespace scopeone::core::internal emit recordingStateChanged(false); emitProgress(true); - if (m_cameraManager) + if (m_cameraRuntimeControl) { - m_cameraManager->setRecordingFrameDeliveryEnabled(false); + m_cameraRuntimeControl->setRecordingFrameDeliveryEnabled(activeCameraIds, false); } if (m_mdaState.usingMda && m_mdaState.manager && m_mdaState.manager->isRunning()) { m_mdaState.manager->requestCancel(); } - if (m_cameraManager) + if (m_cameraRuntimeControl) { - m_cameraManager->setFrameDeliveryPaused(false); + m_cameraRuntimeControl->setFrameDeliveryPaused(activeCameraIds, false); } qInfo().noquote() << "Recording stopped"; @@ -1788,6 +1786,21 @@ namespace scopeone::core::internal return; } + const quint64 previousFrameIndex = m_captureState.lastFrameIndex.value(cameraId, 0); + if (!m_mdaState.usingMda + && !(m_mdaState.streamMda && m_mdaState.streamIntervalMs > 0.0) + && m_captureState.framesCapturedThisBurst.value(cameraId, 0) > 0 + && frame.frameIndex - previousFrameIndex > 1) + { + const quint64 droppedFrames = frame.frameIndex - previousFrameIndex - 1; + onFrameDeliveryFailed( + QStringLiteral("Recording frame delivery skipped %1 frame(s) for %2") + .arg(droppedFrames) + .arg(cameraId), + droppedFrames); + return; + } + const QString writerError = m_captureState.streamToDisk ? writerErrorSnapshot() : QString(); @@ -1961,9 +1974,9 @@ namespace scopeone::core::internal // Starts MDA driven recording capture bool RecordingManager::startMdaCapture(QString* errorMessage) { - if (!m_mmcore) + if (!m_cameraProvider) { - const QString message = QStringLiteral("MMCore not available for MDA"); + const QString message = QStringLiteral("Camera provider not available for MDA"); if (errorMessage) *errorMessage = message; qWarning().noquote() << message; return false; @@ -1975,9 +1988,9 @@ namespace scopeone::core::internal qWarning().noquote() << message; return false; } - if (m_captureState.activeCameraIds.size() > 1 && !m_cameraManager) + if (planUsesMda(m_mdaState.plan) && !m_stageProvider) { - const QString message = QStringLiteral("Multi-camera MDA requires CameraManager"); + const QString message = QStringLiteral("Stage provider not available for MDA"); if (errorMessage) *errorMessage = message; qWarning().noquote() << message; return false; @@ -1985,7 +1998,7 @@ namespace scopeone::core::internal if (!m_mdaState.manager) { - m_mdaState.manager = new MDAManager(m_mmcore, this); + m_mdaState.manager = new MDAManager(this); } if (m_mdaState.manager->isRunning()) { @@ -1994,11 +2007,13 @@ namespace scopeone::core::internal qWarning().noquote() << message; return false; } - m_mdaState.manager->setCameraManager(m_cameraManager); + m_mdaState.manager->setCameraProvider(m_cameraProvider); + m_mdaState.manager->setStageProvider(m_stageProvider); - if (m_captureState.activeCameraIds.size() > 1) + if (m_cameraRuntimeControl) { - m_cameraManager->setFrameDeliveryPaused(true); + m_cameraRuntimeControl->setFrameDeliveryPaused( + m_captureState.activeCameraIds, true); } m_mdaState.cameraId = m_captureState.activeCameraIds.first(); @@ -2202,12 +2217,15 @@ namespace scopeone::core::internal } // Saves buffered sessions and preserves direct writer outputs - QString RecordingManager::saveSessionToDisk(const std::shared_ptr& session) + QString RecordingManager::saveSessionToDisk( + const std::shared_ptr& session, + const std::shared_ptr& sourceSession) { if (!session) { return QStringLiteral("Error: Missing recording session"); } + const auto inputSession = sourceSession ? sourceSession : session->cloneForSave(); ExperimentPlan capturePlan = session->capturePlan(); if (capturePlan.cameraIds.isEmpty()) { @@ -2227,21 +2245,8 @@ namespace scopeone::core::internal return updateSessionResult(*session, QStringLiteral("Error: %1").arg(planError), false); } - if (!session->hasAnyFrames()) + if (!inputSession->hasAnyFrames() && !inputSession->hasRecordedOutput()) { - if (!session->saveMessage().isEmpty()) - { - return session->saveMessage(); - } - if (session->streamedToDisk() && session->hasRecordedOutput()) - { - return updateSessionResult( - *session, - saveSuccessMessage( - QStringLiteral("Success: Recording was already saved during acquisition"), - savedSessionOutputDir(*session)), - true); - } return updateSessionResult(*session, QStringLiteral("Error: No frames captured"), false); } @@ -2273,8 +2278,8 @@ namespace scopeone::core::internal QHash completedOutputs; for (const QString& cameraId : session->cameraIds()) { - const int frameCount = session->frameCount(cameraId); - if (frameCount <= 0) + const qint64 frameCount = inputSession->recordedFrameCount(cameraId); + if (frameCount <= 0 || frameCount > (std::numeric_limits::max)()) { continue; } @@ -2284,7 +2289,7 @@ namespace scopeone::core::internal tiffOpts.useDeflate = capturePlan.enableCompression; tiffOpts.zipQuality = capturePlan.compressionLevel; - const ImageFrame firstImageFrame = session->imageFrameAt(cameraId, 0); + const ImageFrame firstImageFrame = inputSession->imageFrameAt(cameraId, 0); if (!firstImageFrame.isValid()) { continue; @@ -2293,7 +2298,7 @@ namespace scopeone::core::internal if (capturePlan.format == RecordingFormat::OmeTiff || capturePlan.format == RecordingFormat::OmeZarr) { - for (const AcquisitionEventRecord& record : session->experimentDocument().events) + for (const AcquisitionEventRecord& record : inputSession->experimentDocument().events) { if (record.succeeded && record.frames.contains(cameraId)) { @@ -2316,13 +2321,13 @@ namespace scopeone::core::internal capturePlan, physicalPixelSizeUm(firstImageFrame.width, firstImageFrame.sourceRoiWidth, - session->cameraPixelSizeUm(cameraId)), + inputSession->cameraPixelSizeUm(cameraId)), physicalPixelSizeUm(firstImageFrame.height, firstImageFrame.sourceRoiHeight, - session->cameraPixelSizeUm(cameraId)), - session->experimentDocument().startedTimestampNs, + inputSession->cameraPixelSizeUm(cameraId)), + inputSession->experimentDocument().startedTimestampNs, cameraId, - session->experimentDocument().deviceProperties + inputSession->experimentDocument().deviceProperties .value(cameraId).toObject(), tiffOpts)) { @@ -2330,9 +2335,9 @@ namespace scopeone::core::internal return failSave(errorMessage); } int saved = 0; - for (int frameIndex = 0; frameIndex < frameCount; ++frameIndex) + for (int frameIndex = 0; frameIndex < static_cast(frameCount); ++frameIndex) { - const ImageFrame imageFrame = session->imageFrameAt(cameraId, frameIndex); + const ImageFrame imageFrame = inputSession->imageFrameAt(cameraId, frameIndex); if (!imageFrame.isValid()) { continue; diff --git a/ScopeOneCore/src/ScanImageAssembler.cpp b/ScopeOneCore/src/ScanImageAssembler.cpp new file mode 100644 index 0000000..acff2fc --- /dev/null +++ b/ScopeOneCore/src/ScanImageAssembler.cpp @@ -0,0 +1,302 @@ +#include "scopeone/ScanImageAssembler.h" + +#include +#include +#include +#include + +namespace scopeone::core +{ + namespace + { + bool matchesMarker(quint32 value, quint32 markerMask) + { + return markerMask != 0 && (value & markerMask) != 0; + } + } + + ScanImageAssembler::ScanImageAssembler(const QString& sourceId, + const ScanImageConfig& config) + : m_sourceId(sourceId.trimmed()) + , m_config(config) + { + reset(); + } + + bool ScanImageAssembler::isValid() const + { + const qint64 pixelCount = static_cast(m_config.width) + * static_cast(m_config.height); + return !m_sourceId.isEmpty() + && m_config.enabled + && m_config.width > 0 + && m_config.height > 0 + && m_config.gain > 0 + && m_config.averageFrames > 0 + && m_config.gain <= (std::numeric_limits::max)() + && pixelCount <= 64LL * 1024LL * 1024LL; + } + + void ScanImageAssembler::reset() + { + m_framePixels.clear(); + m_accumulatedPixels.clear(); + m_lineEventTicks.clear(); + m_readyFrames.clear(); + m_nextRow = 0; + m_accumulatedFrameCount = 0; + m_frameActive = false; + m_lineActive = false; + m_lineStartTick = 0; + m_lastLineDurationTicks = 0; + m_lastFrameTick = 0; + m_tickPeriodSeconds = 0.0; + if (isValid()) + { + m_framePixels.resize(m_config.width * m_config.height); + } + } + + QList ScanImageAssembler::append(const TimestampedEventChunk& chunk) + { + m_readyFrames.clear(); + if (!isValid() || chunk.sourceId != m_sourceId) + { + return {}; + } + m_tickPeriodSeconds = chunk.tickPeriodSeconds; + + qsizetype eventIndex = 0; + qsizetype markerIndex = 0; + while (eventIndex < chunk.eventTicks.size() + || markerIndex < chunk.markerTicks.size()) + { + if (markerIndex < chunk.markerTicks.size() + && (eventIndex >= chunk.eventTicks.size() + || chunk.markerTicks[markerIndex] <= chunk.eventTicks[eventIndex])) + { + handleMarker(chunk.markerTicks[markerIndex], + chunk.markerCodes[markerIndex]); + ++markerIndex; + } + else + { + appendEvent(chunk.eventTicks[eventIndex]); + ++eventIndex; + } + } + return std::exchange(m_readyFrames, QList{}); + } + + QList ScanImageAssembler::finish() + { + m_readyFrames.clear(); + if (m_lineActive && m_lastLineDurationTicks > 0) + { + finishLine(m_lineStartTick + m_lastLineDurationTicks); + } + if (m_accumulatedFrameCount > 0) + { + emitAveragedFrame(m_lastFrameTick); + } + m_frameActive = false; + m_lineActive = false; + m_lineEventTicks.clear(); + return std::exchange(m_readyFrames, QList{}); + } + + void ScanImageAssembler::handleMarker(quint64 tick, quint32 code) + { + if (matchesMarker(code, m_config.frameStartMarker)) + { + if (m_frameActive) + { + finishLine(tick); + if (m_nextRow > 0) + { + finishFrame(tick); + } + } + beginFrame(); + beginLine(tick); + } + + if (!m_frameActive) + { + if (m_config.frameStartMarker != 0) + { + return; + } + beginFrame(); + beginLine(tick); + } + if (matchesMarker(code, m_config.lineMarker)) + { + finishLine(tick); + beginLine(tick); + } + if (matchesMarker(code, m_config.frameEndMarker)) + { + finishLine(tick); + finishFrame(tick); + } + } + + void ScanImageAssembler::beginFrame() + { + m_framePixels.fill(0); + m_lineEventTicks.clear(); + m_nextRow = 0; + m_frameActive = true; + m_lineActive = false; + } + + void ScanImageAssembler::beginLine(quint64 tick) + { + if (!m_frameActive || m_nextRow >= m_config.height) + { + return; + } + m_lineEventTicks.clear(); + m_lineStartTick = tick; + m_lineActive = true; + } + + void ScanImageAssembler::finishLine(quint64 tick) + { + if (!m_lineActive) + { + return; + } + if (tick > m_lineStartTick && m_nextRow < m_config.height) + { + m_lastLineDurationTicks = tick - m_lineStartTick; + const bool reverse = m_config.mirrorHorizontal + != (m_config.serpentine && (m_nextRow % 2 == 1)); + const long double duration = static_cast(tick - m_lineStartTick); + for (quint64 eventTick : std::as_const(m_lineEventTicks)) + { + if (eventTick < m_lineStartTick || eventTick >= tick) + { + continue; + } + const long double phase = + static_cast(eventTick - m_lineStartTick) / duration; + const long double position = phase <= 0.5L + ? phase * 2.0L + : (1.0L - phase) * 2.0L; + const int x = std::min( + m_config.width - 1, + static_cast(position * m_config.width)); + const int outputX = reverse ? m_config.width - 1 - x : x; + quint16& pixel = m_framePixels[m_nextRow * m_config.width + outputX]; + const quint32 amplified = static_cast(pixel) + m_config.gain; + pixel = static_cast(std::min( + amplified, + static_cast((std::numeric_limits::max)()))); + } + ++m_nextRow; + } + m_lineEventTicks.clear(); + m_lineActive = false; + if (m_nextRow >= m_config.height) + { + finishFrame(tick); + } + } + + void ScanImageAssembler::finishFrame(quint64 tick) + { + if (!m_frameActive || m_nextRow <= 0) + { + m_frameActive = false; + m_lineActive = false; + return; + } + + m_lastFrameTick = tick; + if (m_config.averageFrames == 1) + { + emitFrame(m_framePixels, tick); + } + else + { + if (m_accumulatedPixels.isEmpty()) + { + m_accumulatedPixels.resize(m_framePixels.size()); + } + for (int index = 0; index < m_framePixels.size(); ++index) + { + m_accumulatedPixels[index] += m_framePixels[index]; + } + ++m_accumulatedFrameCount; + if (m_accumulatedFrameCount >= m_config.averageFrames) + { + emitAveragedFrame(tick); + } + } + m_frameActive = false; + m_lineActive = false; + m_lineEventTicks.clear(); + } + + void ScanImageAssembler::emitFrame(const QVector& pixels, + quint64 tick) + { + const qint64 byteCount = static_cast(m_config.width) + * static_cast(m_config.height) * sizeof(quint16); + ImageFrame frame; + frame.cameraId = m_sourceId; + frame.width = m_config.width; + frame.height = m_config.height; + frame.stride = m_config.width * static_cast(sizeof(quint16)); + frame.bitsPerSample = 16; + frame.pixelFormat = ImagePixelFormat::Mono16; + frame.frameIndex = m_nextFrameIndex++; + frame.timestampNs = m_tickPeriodSeconds > 0.0 + ? static_cast( + static_cast(tick) + * m_tickPeriodSeconds * 1.0e9) + : 0; + frame.bytes.resize(static_cast(byteCount)); + std::memcpy(frame.bytes.data(), + pixels.constData(), + static_cast(byteCount)); + m_readyFrames.append(std::move(frame)); + } + + void ScanImageAssembler::emitAveragedFrame(quint64 tick) + { + QVector pixels; + pixels.resize(m_framePixels.size()); + for (int index = 0; index < pixels.size(); ++index) + { + pixels[index] = static_cast( + m_accumulatedPixels[index] + / static_cast(m_accumulatedFrameCount)); + } + emitFrame(pixels, tick); + m_accumulatedPixels.fill(0); + m_accumulatedFrameCount = 0; + } + + void ScanImageAssembler::appendEvent(quint64 tick) + { + if (!m_frameActive) + { + if (m_config.frameStartMarker != 0) + { + return; + } + beginFrame(); + } + if (!m_lineActive) + { + beginLine(tick); + } + if (m_lineActive) + { + m_lineEventTicks.append(tick); + } + } +} diff --git a/ScopeOneCore/src/ScopeOneCore.cpp b/ScopeOneCore/src/ScopeOneCore.cpp index 1f8c909..833921c 100644 --- a/ScopeOneCore/src/ScopeOneCore.cpp +++ b/ScopeOneCore/src/ScopeOneCore.cpp @@ -1,17 +1,20 @@ #include "scopeone/ScopeOneCore.h" #include "scopeone/ImageSceneModel.h" +#include "scopeone/ScanImageAssembler.h" -#include "internal/BackgroundCalibrationModule.h" -#include "internal/DifferentialRollingModule.h" -#include "internal/FFTModule.h" -#include "internal/GaussianBlurModule.h" +#include "internal/AcquisitionEngine.h" #include "internal/ImageProcessingFramework.h" +#include "internal/ProcessingModuleRegistry.h" +#include "internal/DriverHostProviderProxy.h" +#include "internal/HardwareRuntime.h" #include "internal/MMCoreManager.h" +#include "internal/MicroManagerProvider.h" #include "internal/CameraManager.h" #include "internal/ParticleAnalysis.h" #include "internal/RecordingManager.h" -#include "internal/SpatiotemporalBinningModule.h" #include "internal/StageMosaicManager.h" +#include "internal/DaqDeviceManager.h" +#include "internal/SignalSourceManager.h" #include "MMCore.h" #include #include @@ -23,15 +26,21 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include #include #include +#include +#include #include +#include #include #include #include @@ -42,6 +51,67 @@ namespace { + std::atomic s_importCounter{1}; + + struct StaticImageImportTask + { + QString filePath; + QString displayName; + QString sourceId; + std::vector slices; + QString errorMessage; + }; + + scopeone::core::ImageFrame convertImportedImage( + const cv::Mat& image, + const QString& sourceId) + { + scopeone::core::ImageFrame frame; + cv::Mat gray; + if (image.channels() == 1) + { + gray = image; + } + else if (image.channels() == 4) + { + cv::cvtColor(image, gray, cv::COLOR_BGRA2GRAY); + } + else + { + cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY); + } + + if (gray.depth() == CV_8U) + { + frame.bitsPerSample = 8; + frame.pixelFormat = scopeone::core::ImagePixelFormat::Mono8; + frame.stride = static_cast(gray.step); + frame.bytes = QByteArray(reinterpret_cast(gray.data), + static_cast(gray.total() * gray.elemSize())); + } + else + { + cv::Mat gray16; + if (gray.depth() == CV_16U) + { + gray16 = gray; + } + else + { + gray.convertTo(gray16, CV_16U); + } + frame.bitsPerSample = 16; + frame.pixelFormat = scopeone::core::ImagePixelFormat::Mono16; + frame.stride = static_cast(gray16.step); + frame.bytes = QByteArray(reinterpret_cast(gray16.data), + static_cast(gray16.total() * gray16.elemSize())); + } + frame.cameraId = sourceId; + frame.width = gray.cols; + frame.height = gray.rows; + return frame; + } + // Histogram bins are fixed to keep UI cost stable constexpr int kHistogramBinCount = 256; // Auto stretch ignores a small tail on each side @@ -56,6 +126,20 @@ namespace constexpr bool kFrameRateDiagnosticsEnabled = false; constexpr int kFrameRateDiagnosticIntervalMs = 3000; + struct ProviderRegistrationResult + { + scopeone::core::HardwareProviderPtr provider; + QString errorMessage; + }; + + struct OfflineProcessingResult + { + QList frames; + scopeone::core::ExperimentPlan plan; + QString errorMessage; + bool canceled{false}; + }; + // Convert a histogram bin to its lower source value int histogramBinLowerValue(int binIndex, int maxValue) { @@ -135,18 +219,6 @@ namespace } } - // Convert MMCore string vectors into Qt string lists - QStringList toQStringList(const std::vector& values) - { - QStringList out; - out.reserve(static_cast(values.size())); - for (const auto& value : values) - { - out.append(QString::fromStdString(value)); - } - return out; - } - // Build the cache key for raw and processed histogram layers QString histogramLayerKey(const QString& cameraId, bool processed) { @@ -177,7 +249,7 @@ namespace { const auto& leftModule = left.modules.at(index); const auto& rightModule = right.modules.at(index); - if (leftModule.kind != rightModule.kind + if (leftModule.moduleId != rightModule.moduleId || leftModule.schemaVersion != rightModule.schemaVersion || leftModule.parameters != rightModule.parameters) { @@ -364,6 +436,7 @@ namespace facade.failCount = result.failCount; facade.skippedCameraCount = result.skippedCameraCount; facade.foundCamera = result.foundCamera; + facade.devices = result.devices; return facade; } @@ -444,39 +517,17 @@ namespace namespace scopeone::core { - using scopeone::core::internal::BackgroundCalibrationModule; - using scopeone::core::internal::FFTModule; - using scopeone::core::internal::GaussianBlurModule; using scopeone::core::internal::ImageProcessingManager; + using scopeone::core::internal::HardwareRuntime; using scopeone::core::internal::MMCoreManager; using scopeone::core::internal::CameraManager; + using scopeone::core::internal::CameraRuntimeControl; + using scopeone::core::internal::MicroManagerProvider; using scopeone::core::internal::ProcessingModule; using scopeone::core::internal::ProcessingPipelineDefinition; using scopeone::core::internal::RecordingManager; - using scopeone::core::internal::DifferentialRollingModule; - using scopeone::core::internal::SpatiotemporalBinningModule; using scopeone::core::internal::StageMosaicManager; - static std::unique_ptr createProcessingModule(ProcessingModuleKind kind) - { - switch (kind) - { - case ProcessingModuleKind::FFT: - return std::make_unique(); - case ProcessingModuleKind::BackgroundCalibration: - return std::make_unique(); - case ProcessingModuleKind::SpatiotemporalBinning: - return std::make_unique(); - case ProcessingModuleKind::GaussianBlur: - return std::make_unique(); - case ProcessingModuleKind::DifferentialRolling: - return std::make_unique(); - case ProcessingModuleKind::Unknown: - return {}; - } - return {}; - } - static bool equalCanonicalParameter(const QVariant& actual, const QVariant& expected) { return actual.metaType().id() == expected.metaType().id() @@ -506,6 +557,7 @@ namespace scopeone::core { m_rawFrames.clear(); m_processedFrames.clear(); + m_toolFrames.clear(); m_staticFrames.clear(); m_externalFrames.clear(); } @@ -568,6 +620,10 @@ namespace scopeone::core { return m_staticFrames; } + if (stream == FrameGraphStream::Tool) + { + return m_toolFrames; + } if (stream == FrameGraphStream::External) { return m_externalFrames; @@ -585,6 +641,10 @@ namespace scopeone::core { return m_staticFrames; } + if (stream == FrameGraphStream::Tool) + { + return m_toolFrames; + } if (stream == FrameGraphStream::External) { return m_externalFrames; @@ -911,11 +971,27 @@ namespace scopeone::core quint64 completedCount{0}; }; + HardwareRuntime* hardwareRuntime{nullptr}; + CameraProvider* cameraProvider{nullptr}; + CameraRuntimeControl* cameraRuntimeControl{nullptr}; + std::shared_ptr microManagerProvider; MMCoreManager* mmcoreManager{nullptr}; CameraManager* cameraManager{nullptr}; RecordingManager* recordingManager{nullptr}; ImageProcessingManager* imageProcessingManager{nullptr}; + std::unique_ptr processingModuleRegistry; + std::unique_ptr cudaLibrary; StageMosaicManager* stageMosaicManager{nullptr}; + internal::DaqDeviceManager* daqDeviceManager{nullptr}; + internal::SignalSourceManager* signalSourceManager{nullptr}; + struct ScanImageSessionState + { + std::shared_ptr session; + QString baseName; + quint64 lastTimestampNs{0}; + }; + QHash> scanImageAssemblers; + QHash scanImageSessions; QHash experiments; QHash> sessions; QString activeExperimentId; @@ -938,6 +1014,106 @@ namespace scopeone::core return QStringLiteral(SCOPEONE_CORE_VERSION_STRING); } + // Return the unified device catalog owned by the hardware runtime + QList ScopeOneCore::hardwareDevices() const + { + return m_managers->hardwareRuntime->deviceRegistry()->devices(); + } + + bool ScopeOneCore::registerHardwareProvider(const HardwareProviderPtr& provider) + { + const QString providerId = provider ? provider->descriptor().id.trimmed() : QString{}; + if (!provider + || providerId.isEmpty() + || providerId == QStringLiteral("micro-manager") + || m_managers->hardwareRuntime->deviceRegistry()->provider(providerId) + || m_configurationOperationRunning + || m_pendingStageCommands > 0 + || !m_managers->activeExperimentId.isEmpty() + || isRecording()) + { + return false; + } + return m_managers->hardwareRuntime->registerProvider(provider); + } + + bool ScopeOneCore::registerDriverHostProvider(const QString& providerId, + const QString& modulePath, + const QVariantMap& options, + QString* errorMessage) + { + if (errorMessage) errorMessage->clear(); + const QString normalizedId = providerId.trimmed(); + if (normalizedId.isEmpty() + || normalizedId == QStringLiteral("micro-manager") + || m_configurationOperationRunning + || m_pendingStageCommands > 0 + || !m_managers->activeExperimentId.isEmpty() + || isRecording() + || m_pendingProviderRegistrations.contains(normalizedId) + || m_managers->hardwareRuntime->deviceRegistry()->provider(normalizedId)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Provider cannot be registered in the current state"); + } + return false; + } + + m_pendingProviderRegistrations.insert(normalizedId); + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, + this, [this, watcher, normalizedId]() + { + ProviderRegistrationResult result = watcher->result(); + m_pendingProviderRegistrations.remove(normalizedId); + bool success = static_cast(result.provider); + if (success + && !m_managers->hardwareRuntime->registerProvider(result.provider)) + { + success = false; + result.errorMessage = QStringLiteral( + "Provider device catalog conflicts with registered hardware"); + } + emit hardwareProviderRegistrationFinished(normalizedId, + success, + result.errorMessage); + watcher->deleteLater(); + }); + const QJsonObject providerOptions = QJsonObject::fromVariantMap(options); + watcher->setFuture(QtConcurrent::run( + m_hardwareThreadPool.get(), + [normalizedId, modulePath, providerOptions]() + { + ProviderRegistrationResult result; + result.provider = internal::createDriverHostProviderProxy( + normalizedId, modulePath, providerOptions, &result.errorMessage); + return result; + })); + return true; + } + + bool ScopeOneCore::unregisterHardwareProvider(const QString& providerId) + { + const QString normalizedId = providerId.trimmed(); + if (normalizedId.isEmpty() + || normalizedId == QStringLiteral("micro-manager") + || m_configurationOperationRunning + || m_pendingStageCommands > 0 + || !m_managers->activeExperimentId.isEmpty() + || isRecording() + || m_pendingProviderRegistrations.contains(normalizedId)) + { + return false; + } + if (!m_managers->hardwareRuntime->deviceRegistry()->provider(normalizedId)) + { + return false; + } + m_managers->hardwareRuntime->unregisterProvider(normalizedId); + return true; + } + // Return the linked MMCore version QString ScopeOneCore::getMMCoreVersion() { @@ -977,6 +1153,12 @@ namespace scopeone::core return QStringLiteral("proc:%1").arg(cameraId.trimmed()); } + // Build the graph layer key for one tool stream + QString ScopeOneCore::toolLayerKey(const QString& sourceId) + { + return QStringLiteral("tool:%1").arg(sourceId.trimmed()); + } + // Build the graph layer key for one static source QString ScopeOneCore::staticLayerKey(const QString& sourceId) { @@ -1001,6 +1183,11 @@ namespace scopeone::core return layerKey.trimmed().startsWith(QStringLiteral("proc:")); } + bool ScopeOneCore::isToolLayerKey(const QString& layerKey) + { + return layerKey.trimmed().startsWith(QStringLiteral("tool:")); + } + bool ScopeOneCore::isStaticLayerKey(const QString& layerKey) { return layerKey.trimmed().startsWith(QStringLiteral("static:")); @@ -1039,6 +1226,50 @@ namespace scopeone::core qRegisterMetaType( "scopeone::core::ScopeOneCore::ParticleDetectionResult"); qRegisterMetaType("scopeone::core::ImageFrame"); + qRegisterMetaType( + "scopeone::core::SignalSourceState"); + qRegisterMetaType("scopeone::core::DaqState"); + qRegisterMetaType( + "scopeone::core::DaqInputChunk"); + qRegisterMetaType( + "scopeone::core::TimeSeriesChunk"); + qRegisterMetaType( + "scopeone::core::TimestampedEventChunk"); + qRegisterMetaType( + "scopeone::core::ScanImageConfig"); + m_managers->processingModuleRegistry = + std::make_unique(); + const QString cudaLibraryPath = QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("ScopeOneCuda")); + { + auto cudaLibrary = std::make_unique(cudaLibraryPath); + if (cudaLibrary->load()) + { + using RegisterProcessingModules = void (*)(ScopeOneCore*); + const auto registerProcessingModules = + reinterpret_cast( + cudaLibrary->resolve("scopeone_register_processing_modules")); + if (registerProcessingModules) + { + registerProcessingModules(this); + m_managers->cudaLibrary = std::move(cudaLibrary); + } + else + { + cudaLibrary->unload(); + } + } + } + const QStringList processingPluginErrors = m_managers->processingModuleRegistry->loadPlugins( + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("plugins/processing"))); + QStringList allProcessingPluginErrors = processingPluginErrors; + allProcessingPluginErrors.append(m_managers->processingModuleRegistry->loadPlugins( + QDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)) + .filePath(QStringLiteral("plugins/processing")))); + for (const QString& error : allProcessingPluginErrors) + { + qWarning().noquote() << QStringLiteral("Failed to load processing plugin %1").arg(error); + } m_histogramThreadPool = std::make_unique(); m_histogramThreadPool->setMaxThreadCount(1); m_hardwareThreadPool = std::make_unique(); @@ -1047,11 +1278,19 @@ namespace scopeone::core m_analysisThreadPool->setMaxThreadCount(1); m_sessionFrameThreadPool = std::make_unique(); m_sessionFrameThreadPool->setMaxThreadCount(1); + m_offlineProcessingThreadPool = std::make_unique(); + m_offlineProcessingThreadPool->setMaxThreadCount(1); m_previewFlushTimer = new QTimer(this); m_previewFlushTimer->setSingleShot(true); m_previewFlushTimer->setTimerType(Qt::PreciseTimer); connect(m_previewFlushTimer, &QTimer::timeout, this, &ScopeOneCore::flushPreviewFrames); + m_layerFrameRateTimer = new QTimer(this); + m_layerFrameRateTimer->setInterval(3000); + connect(m_layerFrameRateTimer, &QTimer::timeout, + this, &ScopeOneCore::updateLayerFrameRates); + m_layerFrameRateElapsed.start(); + m_layerFrameRateTimer->start(); if constexpr (kFrameRateDiagnosticsEnabled) { auto* frameRateDiagnosticTimer = new QTimer(this); @@ -1100,13 +1339,32 @@ namespace scopeone::core this, &ScopeOneCore::syncLineProfileFromScene); m_managers->mmcoreManager = new MMCoreManager(this); m_managers->cameraManager = new CameraManager(this); + m_managers->microManagerProvider = + std::make_shared(m_managers->mmcoreManager->getCore(), + m_managers->cameraManager, + m_managers->cameraManager); + m_managers->hardwareRuntime = new HardwareRuntime(this); + m_managers->cameraProvider = m_managers->hardwareRuntime; + m_managers->cameraRuntimeControl = m_managers->hardwareRuntime; + connect(m_managers->hardwareRuntime, &HardwareRuntime::devicesChanged, + this, [this]() + { + synchronizeCameraIdsFromRegistry(); + emit hardwareDevicesChanged(); + }); + connect(m_managers->hardwareRuntime, &HardwareRuntime::previewStateChanged, + this, &ScopeOneCore::previewStateChanged); + m_managers->hardwareRuntime->registerProvider(m_managers->microManagerProvider); m_managers->recordingManager = new RecordingManager(this); + m_managers->signalSourceManager = new internal::SignalSourceManager(this); + m_managers->daqDeviceManager = new internal::DaqDeviceManager(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->setCameraManager(m_managers->cameraManager); - m_managers->recordingManager->setMMCore(m_managers->mmcoreManager->getCore()); + m_managers->recordingManager->setCameraProvider(m_managers->cameraProvider); + m_managers->recordingManager->setStageProvider(m_managers->hardwareRuntime); + m_managers->recordingManager->setCameraRuntimeControl(m_managers->cameraRuntimeControl); m_managers->recordingManager->setLatestFrameFetcher( [this](const QString& cameraId, ImageFrame& frame) { @@ -1122,8 +1380,65 @@ namespace scopeone::core session.setPresentationState(layers, markups); }); - connect(m_managers->cameraManager, &CameraManager::newRawFrameReady, + connect(m_managers->signalSourceManager, + &internal::SignalSourceManager::timeSeriesReady, + this, + &ScopeOneCore::handleSignalTimeSeries); + connect(m_managers->signalSourceManager, + &internal::SignalSourceManager::timestampedEventsReady, + this, + &ScopeOneCore::handleTimestampedSignalEvents); + connect(m_managers->signalSourceManager, + &internal::SignalSourceManager::sourceStateChanged, + this, + [this](const QString& sourceId, + SignalSourceState state, + const QString& message) + { + if (state == SignalSourceState::Idle + || state == SignalSourceState::Error) + { + finishScanImageSession( + sourceId, + state == SignalSourceState::Error + ? ExperimentRunState::Failed + : ExperimentRunState::Completed, + message); + } + emit signalSourceStateChanged(sourceId, state, message); + }); + connect(m_managers->signalSourceManager, + &internal::SignalSourceManager::sourceError, + this, + &ScopeOneCore::signalSourceError); + connect(m_managers->daqDeviceManager, + &internal::DaqDeviceManager::stateChanged, + this, + &ScopeOneCore::daqStateChanged); + connect(m_managers->daqDeviceManager, + &internal::DaqDeviceManager::deviceError, + this, + &ScopeOneCore::daqError); + connect(m_managers->daqDeviceManager, + &internal::DaqDeviceManager::inputDataReady, + this, + &ScopeOneCore::daqInputDataReady); + + connect(m_managers->hardwareRuntime, &HardwareRuntime::frameReady, this, &ScopeOneCore::handleIncomingRawFrame); + connect(m_managers->hardwareRuntime, &HardwareRuntime::frameReady, + this, [this](const ImageFrame& frame) + { + const HardwareDeviceDescriptor device = + m_managers->hardwareRuntime->deviceRegistry()->device(frame.cameraId); + if (device.providerId == QStringLiteral("micro-manager")) + { + return; + } + emit rawFramesAcquired(frame.cameraId, 1); + submitProcessingFrame(frame); + m_managers->recordingManager->onRawFramesReady(QList{frame}); + }); connect(m_managers->cameraManager, &CameraManager::processingFrameReady, this, &ScopeOneCore::submitProcessingFrame, Qt::DirectConnection); @@ -1144,10 +1459,8 @@ namespace scopeone::core m_managers->recordingManager, &RecordingManager::onRawFramesReady); connect(m_managers->cameraManager, &CameraManager::frameDeliveryFailed, m_managers->recordingManager, &RecordingManager::onFrameDeliveryFailed); - connect(m_managers->cameraManager, &CameraManager::previewStateChanged, - this, &ScopeOneCore::previewStateChanged); - connect(m_managers->cameraManager, &CameraManager::agentControlServerListening, - this, &ScopeOneCore::agentControlServerListening); + connect(m_managers->cameraManager, &CameraManager::driverHostControlServerListening, + this, &ScopeOneCore::driverHostControlServerListening); connect(m_managers->recordingManager, &RecordingManager::mdaRawFrameReady, this, [this](const ImageFrame& frame) @@ -1238,10 +1551,11 @@ namespace scopeone::core this, &ScopeOneCore::handleProcessedFrame, Qt::DirectConnection); connect(m_managers->imageProcessingManager, &ImageProcessingManager::processingFrameFinished, - m_managers->cameraManager, &CameraManager::finishProcessingFrame, + m_managers->hardwareRuntime, &HardwareRuntime::finishProcessingFrame, Qt::DirectConnection); connect(m_managers->imageProcessingManager, &ImageProcessingManager::processingError, this, &ScopeOneCore::processingError); + } // Release loaded devices before the facade is destroyed @@ -1250,11 +1564,18 @@ namespace scopeone::core m_hardwareThreadPool->waitForDone(); m_analysisThreadPool->waitForDone(); m_sessionFrameThreadPool->waitForDone(); + for (const auto& token : std::as_const(m_processingRequestCancelTokens)) + { + token->store(true); + } + m_offlineProcessingThreadPool->waitForDone(); m_managers->recordingManager->shutdown(); m_pendingStageCommands = 0; m_configurationOperationRunning = false; unloadConfigurationForShutdown(); m_histogramThreadPool->waitForDone(); + delete m_managers->imageProcessingManager; + m_managers->imageProcessingManager = nullptr; } // Return the public configuration lifecycle state @@ -1284,39 +1605,13 @@ namespace scopeone::core return m_managers->mmcoreManager->getCore(); } - // Check whether a device is owned by the active camera backend - bool ScopeOneCore::isConfiguredCamera(const QString& deviceLabel) const - { - return m_cameraIds.contains(deviceLabel); - } - - // Check whether a device is a native MMCore camera - bool ScopeOneCore::isNativeCamera(const QString& deviceLabel) const - { - const QString device = deviceLabel.trimmed(); - if (m_configurationOperationRunning || device.isEmpty() || isConfiguredCamera(device)) - { - return false; - } - - auto handle = core(); - try - { - return handle->getDeviceType(device.toStdString().c_str()) == MM::CameraDevice; - } - catch (const CMMError&) - { - return false; - } - } - // Collect camera ids that currently have active previews QStringList ScopeOneCore::runningPreviewCameraIds() const { QStringList running; for (const QString& cameraId : m_cameraIds) { - if (m_managers->cameraManager->isPreviewRunning(cameraId)) + if (m_managers->cameraProvider->isPreviewRunning(cameraId)) { running.append(cameraId); } @@ -1333,7 +1628,10 @@ namespace scopeone::core { return overrideIt.value(); } - if (!m_managers->cameraManager->usesAgentBackend() + const HardwareDeviceDescriptor device = + m_managers->hardwareRuntime->deviceRegistry()->device(camera); + if (device.providerId == QStringLiteral("micro-manager") + && !m_managers->cameraManager->usesDriverHostBackend() && m_cameraIds.size() == 1 && m_cameraIds.first() == camera) { @@ -1374,7 +1672,6 @@ namespace scopeone::core { return false; } - QStringList normalizedPaths; for (const QString& path : paths) { @@ -1394,26 +1691,21 @@ namespace scopeone::core } // Applies a completed device load to the frame graph and public state - void ScopeOneCore::applyLoadedConfiguration(const QString& configPath, + bool ScopeOneCore::applyLoadedConfiguration(const QString& configPath, const LoadConfigResult& result) { - m_cameraIds = result.cameraIds; + m_managers->microManagerProvider->setDevices(result.devices); + if (!m_managers->hardwareRuntime->refreshProvider(QStringLiteral("micro-manager"))) + { + m_managers->microManagerProvider->setDevices({}); + m_managers->hardwareRuntime->refreshProvider(QStringLiteral("micro-manager")); + return false; + } m_configurationFailedDevices = result.failedDevices; m_configurationError.clear(); m_configurationState = result.failedDevices.isEmpty() ? ConfigurationState::Loaded : ConfigurationState::PartiallyLoaded; - for (const QString& cameraId : m_cameraIds) - { - ensureSceneLayer(rawLayerKey(cameraId), - cameraId, - QStringLiteral("%1 Raw").arg(cameraId), - DocumentLayerKind::Raw); - ensureSceneLayer(processedLayerKey(cameraId), - cameraId, - QStringLiteral("%1 Processed").arg(cameraId), - DocumentLayerKind::Processed); - } const QFileInfo configFile(configPath); m_loadedConfigPath = configPath.trimmed().isEmpty() ? QString() @@ -1425,7 +1717,52 @@ namespace scopeone::core m_loadedConfigSha256 = QString::fromLatin1( QCryptographicHash::hash(file.readAll(), QCryptographicHash::Sha256).toHex()); } - emit hardwareConfigurationChanged(); + return true; + } + + void ScopeOneCore::synchronizeCameraIdsFromRegistry() + { + QStringList nextCameraIds; + for (const HardwareDeviceDescriptor& device + : m_managers->hardwareRuntime->deviceRegistry()->devices()) + { + const QString logicalId = device.logicalId.trimmed(); + if (device.kind == HardwareDeviceKind::Camera + && !logicalId.isEmpty() + && !nextCameraIds.contains(logicalId)) + { + nextCameraIds.append(logicalId); + } + } + nextCameraIds.sort(Qt::CaseInsensitive); + + for (const QString& cameraId : m_cameraIds) + { + if (!nextCameraIds.contains(cameraId)) + { + clearLiveFrames(cameraId); + m_imageSceneModel->removeLayer(rawLayerKey(cameraId)); + m_imageSceneModel->removeLayer(processedLayerKey(cameraId)); + } + } + m_cameraIds = nextCameraIds; + if (!m_realTimeProcessingSource.isEmpty() + && !m_cameraIds.contains(m_realTimeProcessingSource)) + { + m_realTimeProcessingSource.clear(); + emit processingSettingsChanged(); + } + for (const QString& cameraId : m_cameraIds) + { + ensureSceneLayer(rawLayerKey(cameraId), + cameraId, + QStringLiteral("%1 Raw").arg(cameraId), + DocumentLayerKind::Raw); + ensureSceneLayer(processedLayerKey(cameraId), + cameraId, + QStringLiteral("%1 Processed").arg(cameraId), + DocumentLayerKind::Processed); + } } // Complete a failed configuration load and publish one consistent result @@ -1455,11 +1792,13 @@ namespace scopeone::core catch (const CMMError&) { } + m_managers->hardwareRuntime->clear(); } // Apply the configured hardware shutdown state before releasing devices void ScopeOneCore::applySystemShutdownPreset() { + m_managers->hardwareRuntime->stopPreviewForProvider(QStringLiteral("micro-manager")); auto handle = core(); try { @@ -1468,8 +1807,8 @@ namespace scopeone::core { return; } - m_managers->cameraManager->stopPreview(); - setConfig(MM::g_CFGGroup_System, MM::g_CFGGroup_System_Shutdown); + m_managers->microManagerProvider->setConfig( + MM::g_CFGGroup_System, MM::g_CFGGroup_System_Shutdown, nullptr); } catch (const CMMError& error) { @@ -1487,9 +1826,9 @@ namespace scopeone::core const bool processingWasEnabled = isRealTimeProcessingEnabled(); m_managers->imageProcessingManager->enableRealTimeProcessing(false); const QStringList cameraIds = m_cameraIds; + m_managers->hardwareRuntime->stopPreviewForProvider(QStringLiteral("micro-manager")); if (shutdownCameraBackend) { - m_managers->cameraManager->stopPreview(); m_managers->cameraManager->shutdownNow(); } @@ -1509,6 +1848,8 @@ namespace scopeone::core m_latestHistogramStats.clear(); m_activeHistogramLayerKey.clear(); m_imageSceneModel->reset(); + m_managers->microManagerProvider->setDevices(QList{}); + m_managers->hardwareRuntime->refreshProvider(QStringLiteral("micro-manager")); if (notify && processingWasEnabled) { emit processingSettingsChanged(); @@ -1538,6 +1879,11 @@ namespace scopeone::core m_configurationError = QStringLiteral("Another configuration operation is running"); return false; } + if (!m_pendingProviderRegistrations.isEmpty()) + { + m_configurationError = QStringLiteral("A hardware provider is still loading"); + return false; + } if (m_pendingStageCommands > 0) { m_configurationError = QStringLiteral("A stage command is running"); @@ -1604,8 +1950,28 @@ namespace scopeone::core watcher->deleteLater(); return; } + if (!applyLoadedConfiguration(path, result)) + { + const QString errorMessage = + QStringLiteral("Micro-Manager device catalog conflicts with registered hardware"); + m_managers->cameraManager->shutdown( + [this, result, errorMessage](const QString& shutdownError) + { + if (!shutdownError.isEmpty()) + { + finishConfigurationLoadFailure( + result, + QStringLiteral("%1; camera cleanup failed: %2") + .arg(errorMessage, shutdownError)); + return; + } + startConfigurationLoadCleanupTask(result, errorMessage); + }); + watcher->deleteLater(); + return; + } m_configurationOperationRunning = false; - applyLoadedConfiguration(path, result); + emit hardwareConfigurationChanged(); emit configurationLoadFinished(true, result, {}); } else @@ -1693,6 +2059,11 @@ namespace scopeone::core m_configurationError = QStringLiteral("Another configuration operation is running"); return false; } + if (!m_pendingProviderRegistrations.isEmpty()) + { + m_configurationError = QStringLiteral("A hardware provider is still loading"); + return false; + } if (m_pendingStageCommands > 0) { m_configurationError = QStringLiteral("A stage command is running"); @@ -1779,11 +2150,15 @@ namespace scopeone::core { return false; } - if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + const bool all = target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0; + const bool started = all + ? m_managers->hardwareRuntime->startPreview() + : m_managers->hardwareRuntime->startPreviewFor(target); + if (started) { - return m_managers->cameraManager->startPreview(); + emit previewStateChanged(true); } - return m_managers->cameraManager->startPreviewFor(target); + return started; } // Stop preview for one camera or the full camera set @@ -1794,11 +2169,15 @@ namespace scopeone::core { return false; } - if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + const bool all = target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0; + const bool stopped = all + ? m_managers->hardwareRuntime->stopPreview() + : m_managers->hardwareRuntime->stopPreviewFor(target); + if (stopped) { - return m_managers->cameraManager->stopPreview(); + emit previewStateChanged(!runningPreviewCameraIds().isEmpty()); } - return m_managers->cameraManager->stopPreviewFor(target); + return stopped; } // Submit exposure changes through the active camera manager @@ -1809,7 +2188,7 @@ namespace scopeone::core { return false; } - const bool ok = m_managers->cameraManager->setExposure(target, exposureMs); + const bool ok = m_managers->cameraProvider->setExposure(target, exposureMs); if (ok) { emit deviceStateChanged(); @@ -1825,7 +2204,7 @@ namespace scopeone::core { return false; } - const bool ok = m_managers->cameraManager->setROI(target, x, y, width, height); + const bool ok = m_managers->cameraProvider->setROI(target, x, y, width, height); if (ok) { clearLiveFrames(target); @@ -1888,7 +2267,7 @@ namespace scopeone::core bool changed = false; for (const QString& cameraId : targets) { - if (!m_managers->cameraManager->clearROI(cameraId)) + if (!m_managers->cameraProvider->clearROI(cameraId)) { ok = false; continue; @@ -1911,44 +2290,270 @@ namespace scopeone::core { return false; } - return m_managers->cameraManager->getROI(target, x, y, width, height); + return m_managers->cameraProvider->getROI(target, x, y, width, height); } - // Track the active line profile request for future frames - void ScopeOneCore::setLineProfile(const QString& cameraId, - const QPoint& start, - const QPoint& end, - bool processed) + // List signal sources discovered through external source plugins + QList ScopeOneCore::signalSources() const { - const QString trimmedCameraId = cameraId.trimmed(); - if (trimmedCameraId.isEmpty()) - { - clearLineProfile(); - return; - } - - m_activeLineProfile.sourceId = trimmedCameraId; - m_activeLineProfile.start = start; - m_activeLineProfile.end = end; - m_activeLineProfile.processed = processed; - m_activeLineProfile.staticSource = false; - m_activeLineProfile.active = true; - m_lineProfileUpdateTimer.invalidate(); + return m_managers->signalSourceManager->sources(); + } - if (processed) + // Start one external signal source and optionally enable scan reconstruction + bool ScopeOneCore::startSignalTrace(const SignalAcquisitionConfig& config, + QString* errorMessage) + { + const QString sourceId = config.sourceId.trimmed(); + std::shared_ptr assembler; + if (config.scanImage.enabled) { - const ImageFrame frame = graphFrame(processedLayerKey(trimmedCameraId)); - if (frame.isValid()) + assembler = std::make_shared(sourceId, config.scanImage); + if (!assembler->isValid()) { - updateLineProfile(trimmedCameraId, true, frame); - } + if (errorMessage) + { + *errorMessage = QStringLiteral("Invalid scan image configuration"); + } + return false; + } + } + + if (!m_managers->signalSourceManager->startTrace(config, errorMessage)) + { + return false; + } + + if (assembler) + { + m_managers->scanImageAssemblers.insert(sourceId, std::move(assembler)); + QString baseName = QFileInfo( + config.sourceSettings.value(QStringLiteral("filePath")).toString()) + .completeBaseName(); + if (baseName.isEmpty()) + { + baseName = QStringLiteral("scan_") + + QDateTime::currentDateTime().toString( + QStringLiteral("yyyyMMdd_hhmmss_zzz")); + } + else + { + baseName += QStringLiteral("_scan"); + } + Managers::ScanImageSessionState state; + state.baseName = baseName; + m_managers->scanImageSessions.insert(sourceId, std::move(state)); + } + else + { + m_managers->scanImageAssemblers.remove(sourceId); + m_managers->scanImageSessions.remove(sourceId); + } + return true; + } + + void ScopeOneCore::stopSignalTrace(const QString& sourceId) + { + m_managers->signalSourceManager->stopTrace(sourceId); + } + + SignalSourceState ScopeOneCore::signalSourceState(const QString& sourceId) const + { + return m_managers->signalSourceManager->state(sourceId); + } + + QString ScopeOneCore::signalSourceStateMessage(const QString& sourceId) const + { + return m_managers->signalSourceManager->stateMessage(sourceId); + } + + QList ScopeOneCore::daqDevices() const + { + return m_managers->daqDeviceManager->devices(); + } + + bool ScopeOneCore::startDaqSession(const DaqSessionConfig& config, + QString* errorMessage) + { + return m_managers->daqDeviceManager->start(config, errorMessage); + } + + void ScopeOneCore::stopDaqSession(const QString& deviceId) + { + m_managers->daqDeviceManager->stop(deviceId); + } + + DaqState ScopeOneCore::daqState(const QString& deviceId) const + { + return m_managers->daqDeviceManager->state(deviceId); + } + + QString ScopeOneCore::daqStateMessage(const QString& deviceId) const + { + return m_managers->daqDeviceManager->stateMessage(deviceId); + } + + void ScopeOneCore::handleSignalTimeSeries(const TimeSeriesChunk& chunk) + { + emit signalTimeSeriesReady(chunk); + } + + void ScopeOneCore::handleTimestampedSignalEvents(const TimestampedEventChunk& chunk) + { + const auto assembler = m_managers->scanImageAssemblers.value(chunk.sourceId); + if (assembler) + { + publishScanFrames(chunk.sourceId, assembler->append(chunk)); + } + emit timestampedSignalEventsReady(chunk); + } + + void ScopeOneCore::publishScanFrames(const QString& sourceId, + const QList& frames) + { + if (frames.isEmpty()) + { + return; + } + + auto stateIt = m_managers->scanImageSessions.find(sourceId); + if (stateIt == m_managers->scanImageSessions.end()) + { + return; + } + + const QString scanSourceId = QStringLiteral("scan:%1").arg(sourceId); + QList normalizedFrames; + normalizedFrames.reserve(frames.size()); + for (const ImageFrame& sourceFrame : frames) + { + ImageFrame frame = sourceFrame; + frame.cameraId = scanSourceId; + publishStaticFrame(scanSourceId, frame, + QStringLiteral("Scan %1").arg(sourceId)); + normalizedFrames.append(std::move(frame)); + } + + if (!stateIt->session) + { + ExperimentPlan plan; + plan.streamToDisk = false; + plan.baseName = stateIt->baseName; + stateIt->session = createFrameSession(normalizedFrames, plan); + if (!stateIt->session) + { + return; + } + stateIt->session->setRunState(ExperimentRunState::Running); + } + else + { + const QString cameraId = normalizedFrames.constFirst().cameraId; + quint64 sequenceIndex = static_cast( + stateIt->session->recordedFrameCount(cameraId)); + for (const ImageFrame& frame : normalizedFrames) + { + if (!stateIt->session->appendImageFrame(frame)) + { + continue; + } + AcquisitionEventRecord record; + record.event.sequenceIndex = sequenceIndex; + record.event.timeIndex = static_cast(sequenceIndex); + record.event.cameraIds = {cameraId}; + record.startedTimestampNs = frame.timestampNs; + record.completedTimestampNs = frame.timestampNs; + record.succeeded = true; + record.frames.insert(cameraId, frameRecordFromImageFrame(frame)); + stateIt->session->appendEventRecord(record); + ++sequenceIndex; + } + ExperimentPlan plan = stateIt->session->capturePlan(); + plan.framesPerBurst = static_cast( + stateIt->session->recordedFrameCount(cameraId)); + stateIt->session->setCapturePlan(plan); + } + stateIt->lastTimestampNs = normalizedFrames.constLast().timestampNs; + } + + void ScopeOneCore::finishScanImageSession(const QString& sourceId, + ExperimentRunState finalState, + const QString& message) + { + const auto assembler = m_managers->scanImageAssemblers.value(sourceId); + if (assembler) + { + publishScanFrames(sourceId, assembler->finish()); + } + + auto stateIt = m_managers->scanImageSessions.find(sourceId); + if (stateIt == m_managers->scanImageSessions.end()) + { + return; + } + if (!stateIt->session) + { + m_managers->scanImageSessions.erase(stateIt); + m_managers->scanImageAssemblers.remove(sourceId); + return; + } + + const auto session = stateIt->session; + session->setRunState( + finalState, + stateIt->lastTimestampNs, + finalState == ExperimentRunState::Failed ? message : QString()); + registerRecordingSession(session); + m_managers->scanImageSessions.erase(stateIt); + m_managers->scanImageAssemblers.remove(sourceId); + emit scanImageSessionReady(session); + } + + // Track the active line profile request for future frames + void ScopeOneCore::setLineProfile(const QString& cameraId, + const QPoint& start, + const QPoint& end, + bool processed, + bool toolSource) + { + const QString trimmedCameraId = cameraId.trimmed(); + if (trimmedCameraId.isEmpty()) + { + clearLineProfile(); + return; + } + + m_activeLineProfile.sourceId = trimmedCameraId; + m_activeLineProfile.start = start; + m_activeLineProfile.end = end; + m_activeLineProfile.processed = processed; + m_activeLineProfile.toolSource = toolSource; + m_activeLineProfile.staticSource = false; + m_activeLineProfile.active = true; + m_lineProfileUpdateTimer.invalidate(); + + if (toolSource) + { + const ImageFrame frame = graphFrame(toolLayerKey(trimmedCameraId)); + if (frame.isValid()) + { + updateLineProfile(trimmedCameraId, false, true, frame); + } + return; + } + if (processed) + { + const ImageFrame frame = graphFrame(processedLayerKey(trimmedCameraId)); + if (frame.isValid()) + { + updateLineProfile(trimmedCameraId, true, false, frame); + } return; } const ImageFrame frame = graphFrame(rawLayerKey(trimmedCameraId)); if (frame.isValid()) { - updateLineProfile(trimmedCameraId, false, frame); + updateLineProfile(trimmedCameraId, false, false, frame); } } @@ -2014,7 +2619,8 @@ namespace scopeone::core setLineProfile(markup.sourceId, markup.start, markup.end, - markup.layerKind == DocumentLayerKind::Processed); + markup.layerKind == DocumentLayerKind::Processed, + markup.layerKind == DocumentLayerKind::Tool); } return; } @@ -2037,19 +2643,22 @@ namespace scopeone::core ImageFrame normalizedFrame(frame); normalizedFrame.cameraId = cameraId; const QString layerKey = rawLayerKey(cameraId); + recordLayerFrame(layerKey); m_imageSceneModel->updateLayerFrame(layerKey, normalizedFrame); m_frameGraph.publishLatest(FrameGraphStream::Raw, normalizedFrame); emit newRawFrameReady(normalizedFrame); queuePreviewRawFrame(normalizedFrame); scheduleHistogramStats(layerKey, normalizedFrame); - updateLineProfile(cameraId, false, normalizedFrame); + updateLineProfile(cameraId, false, false, normalizedFrame); } // Submits one acquisition frame without crossing the UI event queue void ScopeOneCore::submitProcessingFrame(const ImageFrame& frame, quint64 processingToken) { if (!frame.isValid() - || !m_managers->imageProcessingManager->isRealTimeProcessingEnabled()) + || !m_managers->imageProcessingManager->isRealTimeProcessingEnabled() + || (!m_realTimeProcessingSource.isEmpty() + && frame.cameraId != m_realTimeProcessingSource)) { return; } @@ -2069,7 +2678,7 @@ namespace scopeone::core processingToken, [this, cameraId, processingToken]() { - return m_managers->cameraManager->isProcessingFrameTokenCurrent( + return m_managers->cameraRuntimeControl->isProcessingFrameTokenCurrent( cameraId, processingToken); }); @@ -2084,6 +2693,7 @@ namespace scopeone::core } bool queueFlush = false; + recordLayerFrame(processedLayerKey(frame.cameraId)); { QMutexLocker locker(&m_managers->processedDeliveryMutex); if (!m_managers->imageProcessingManager->isRealTimeProcessingEnabled()) @@ -2134,7 +2744,7 @@ namespace scopeone::core emit processedFramesCompleted(frame.cameraId, it.value().completedCount); m_pendingPreviewProcessedFrames.insert(frame.cameraId, frame); scheduleHistogramStats(layerKey, frame); - updateLineProfile(frame.cameraId, true, frame); + updateLineProfile(frame.cameraId, true, false, frame); } } @@ -2203,6 +2813,10 @@ namespace scopeone::core { return m_frameGraph.latest(FrameGraphStream::Static, sourceId); } + if (isToolLayerKey(trimmedLayerKey)) + { + return m_frameGraph.latest(FrameGraphStream::Tool, sourceId); + } if (trimmedLayerKey.startsWith(QStringLiteral("external:"))) { return m_frameGraph.latest(FrameGraphStream::External, sourceId); @@ -2226,6 +2840,18 @@ namespace scopeone::core return frames; } + double ScopeOneCore::layerFrameRate(const QString& layerKey) const + { + QMutexLocker locker(&m_layerFrameRateMutex); + return m_layerFrameRates.value(layerKey.trimmed(), 0.0); + } + + QMap ScopeOneCore::layerFrameRates() const + { + QMutexLocker locker(&m_layerFrameRateMutex); + return m_layerFrameRates; + } + // Read one pixel from a named frame graph layer bool ScopeOneCore::graphPixelValue(const QString& layerKey, const QPoint& imagePos, int& value) const { @@ -2312,141 +2938,447 @@ namespace scopeone::core return storedFrame; } - // Publish an externally supplied frame to the central graph - ImageFrame ScopeOneCore::publishExternalFrame(const QString& sourceId, const ImageFrame& frame) + int ScopeOneCore::layerSliceCount(const QString& layerKey) const { - if (!m_frameGraph.publishLatest(FrameGraphStream::External, sourceId, frame)) - { - return {}; - } - return graphFrame(QStringLiteral("external:%1").arg(sourceId.trimmed())); + const QString sourceId = sourceIdFromLayerKey(layerKey.trimmed()); + return static_cast(m_layerStacks.value(sourceId).size()); } - // Remove one static frame graph source - void ScopeOneCore::removeStaticFrame(const QString& sourceId) + void ScopeOneCore::recordLayerFrame(const QString& layerKey, quint64 count) { - const QString trimmedSourceId = sourceId.trimmed(); - if (trimmedSourceId.isEmpty()) - { - return; - } - - const QString layerKey = staticLayerKey(trimmedSourceId); - m_frameGraph.remove(FrameGraphStream::Static, trimmedSourceId); - m_imageSceneModel->removeLayer(layerKey); - clearLayerAnalysis(layerKey); - if (m_activeLineProfile.active - && m_activeLineProfile.staticSource - && m_activeLineProfile.sourceId == trimmedSourceId) - { - clearLineProfile(); - } - emit staticFrameRemoved(trimmedSourceId); + QMutexLocker locker(&m_layerFrameRateMutex); + m_layerFrameCounts[layerKey] += count; } - // Clear all static frame graph sources - void ScopeOneCore::clearStaticFrames() + void ScopeOneCore::updateLayerFrameRates() { - m_frameGraph.clear(FrameGraphStream::Static); - for (const QString& layerId : m_imageSceneModel->layerIds()) + const double elapsedSeconds = static_cast(m_layerFrameRateElapsed.restart()) / 1000.0; + QMap frameRates; { - DocumentLayer layer; - if (m_imageSceneModel->findLayer(layerId, layer) - && (layer.kind == DocumentLayerKind::Static - || layer.kind == DocumentLayerKind::Gallery)) + QMutexLocker locker(&m_layerFrameRateMutex); + frameRates = m_layerFrameRates; + for (auto it = frameRates.begin(); it != frameRates.end(); ++it) { - m_imageSceneModel->removeLayer(layerId); + it.value() = 0.0; + } + for (auto it = m_layerFrameCounts.cbegin(); it != m_layerFrameCounts.cend(); ++it) + { + frameRates[it.key()] = static_cast(it.value()) / elapsedSeconds; } + m_layerFrameCounts.clear(); + m_layerFrameRates = frameRates; } - clearLayerAnalysisByPrefix(QStringLiteral("static:")); - if (m_activeLineProfile.active && m_activeLineProfile.staticSource) + for (auto it = frameRates.cbegin(); it != frameRates.cend(); ++it) { - clearLineProfile(); + emit layerFrameRateChanged(it.key(), it.value()); } - emit staticFramesCleared(); + emit layerFrameRatesUpdated(frameRates); } - // Clear derived analysis data for one layer - void ScopeOneCore::clearLayerAnalysis(const QString& layerKey) + bool ScopeOneCore::setLayerSliceIndex(const QString& layerKey, int sliceIndex) { - const QString trimmedLayerKey = layerKey.trimmed(); - if (trimmedLayerKey.isEmpty()) - { - return; - } - - m_latestHistogramStats.remove(trimmedLayerKey); - m_histogramJobStates.remove(trimmedLayerKey); - emit layerAnalysisCleared(trimmedLayerKey); + const QString sourceId = sourceIdFromLayerKey(layerKey.trimmed()); + const auto stackIt = m_layerStacks.constFind(sourceId); + const std::vector& slices = stackIt.value(); + const ImageFrame& frame = slices.at(static_cast(sliceIndex)); + DocumentLayer layer; + m_imageSceneModel->findLayer(staticLayerKey(sourceId), layer); + publishStaticFrame(sourceId, frame, layer.name); + return true; } - // Clear derived analysis data for one graph stream - void ScopeOneCore::clearLayerAnalysisByPrefix(const QString& prefix) + // Import an external image file as a static frame layer + ImageFrame ScopeOneCore::importImageAsStaticLayer(const QString& filePath, + QString* outLayerKey, + QString* errorMessage) { - const QString trimmedPrefix = prefix.trimmed(); - if (trimmedPrefix.isEmpty()) + const QString cleanedPath = QDir::cleanPath(filePath.trimmed()); + if (cleanedPath.isEmpty() || !QFileInfo::exists(cleanedPath)) { - return; + if (errorMessage) + { + *errorMessage = QStringLiteral("File does not exist: %1").arg(filePath); + } + return {}; } - QStringList clearedKeys; - for (const QString& key : m_latestHistogramStats.keys()) + std::vector images; + if (!cv::imreadmulti(cleanedPath.toStdString(), images, cv::IMREAD_UNCHANGED)) { - if (key.startsWith(trimmedPrefix)) + if (errorMessage) { - m_latestHistogramStats.remove(key); - if (!clearedKeys.contains(key)) - { - clearedKeys.append(key); - } + *errorMessage = QStringLiteral("Failed to read image file: %1").arg(filePath); } + return {}; } - for (const QString& key : m_histogramJobStates.keys()) + + const QFileInfo fileInfo(cleanedPath); + const QString sourceId = QStringLiteral("imported:%1_%2") + .arg(fileInfo.completeBaseName()) + .arg(s_importCounter.fetch_add(1)); + + std::vector slices; + slices.reserve(images.size()); + for (const cv::Mat& image : images) { - if (key.startsWith(trimmedPrefix)) - { - m_histogramJobStates.remove(key); - if (!clearedKeys.contains(key)) - { - clearedKeys.append(key); - } - } + slices.push_back(convertImportedImage(image, sourceId)); } - for (const QString& key : clearedKeys) + + m_layerStacks.insert(sourceId, std::move(slices)); + const ImageFrame& frame = m_layerStacks[sourceId].front(); + + const ImageFrame published = publishStaticFrame(sourceId, frame, fileInfo.fileName()); + if (published.isValid()) { - emit layerAnalysisCleared(key); + const QString layerKey = staticLayerKey(sourceId); + if (outLayerKey) + { + *outLayerKey = layerKey; + } + if (m_imageSceneModel) + { + m_imageSceneModel->setLayerVisible(layerKey, true); + } } + return published; } - // Clear stale live frames for a camera source - void ScopeOneCore::clearLiveFrames(const QString& cameraId) + void ScopeOneCore::importImageAsStaticLayerAsync(const QString& filePath) { - const QString trimmedCameraId = cameraId.trimmed(); - if (trimmedCameraId.isEmpty()) + const QString cleanedPath = QDir::cleanPath(filePath.trimmed()); + const QFileInfo fileInfo(cleanedPath); + if (cleanedPath.isEmpty() || !fileInfo.isFile()) { + emit staticImageImportFinished( + filePath, + {}, + false, + QStringLiteral("File does not exist: %1").arg(filePath)); return; } - m_frameGraph.remove(FrameGraphStream::Raw, trimmedCameraId); - m_frameGraph.remove(FrameGraphStream::Processed, trimmedCameraId); - { - QMutexLocker locker(&m_managers->processedDeliveryMutex); - m_managers->pendingProcessedFrames.remove(trimmedCameraId); - } - m_pendingPreviewRawFrames.remove(trimmedCameraId); - m_pendingPreviewProcessedFrames.remove(trimmedCameraId); - const QString rawLayerKey = histogramLayerKey(trimmedCameraId, false); - const QString processedLayerKey = histogramLayerKey(trimmedCameraId, true); - m_imageSceneModel->clear(rawLayerKey); - m_imageSceneModel->clear(processedLayerKey); - clearLayerAnalysis(rawLayerKey); - clearLayerAnalysis(processedLayerKey); - if (m_activeLineProfile.active - && !m_activeLineProfile.staticSource - && m_activeLineProfile.sourceId == trimmedCameraId) - { - clearLineProfile(); - } + emit staticImageImportProgress( + filePath, + 0, + QStringLiteral("Reading %1...").arg(fileInfo.fileName())); + + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, + this, [this, watcher]() + { + StaticImageImportTask task = watcher->result(); + if (!task.errorMessage.isEmpty()) + { + emit staticImageImportFinished( + task.filePath, + {}, + false, + task.errorMessage); + watcher->deleteLater(); + return; + } + + m_layerStacks.insert(task.sourceId, std::move(task.slices)); + const ImageFrame& frame = m_layerStacks[task.sourceId].front(); + const ImageFrame published = publishStaticFrame( + task.sourceId, + frame, + task.displayName); + const QString layerKey = staticLayerKey(task.sourceId); + emit staticImageImportFinished( + task.filePath, + layerKey, + published.isValid(), + published.isValid() + ? QString() + : QStringLiteral("Failed to publish image layer")); + watcher->deleteLater(); + }); + + watcher->setFuture(QtConcurrent::run( + m_hardwareThreadPool.get(), + [this, cleanedPath, filePath]() + { + StaticImageImportTask task; + task.filePath = filePath; + const QFileInfo fileInfo(cleanedPath); + task.displayName = fileInfo.fileName(); + task.sourceId = QStringLiteral("imported:%1_%2") + .arg(fileInfo.completeBaseName()) + .arg(s_importCounter.fetch_add(1)); + + auto reportProgress = [this, filePath](int percent, const QString& statusText) + { + QMetaObject::invokeMethod( + this, + [this, filePath, percent, statusText]() + { + emit staticImageImportProgress(filePath, percent, statusText); + }, + Qt::QueuedConnection); + }; + + std::vector images; + if (!cv::imreadmulti(cleanedPath.toStdString(), + images, + cv::IMREAD_UNCHANGED)) + { + task.errorMessage = QStringLiteral("Failed to read image file: %1") + .arg(filePath); + return task; + } + + task.slices.reserve(images.size()); + for (size_t index = 0; index < images.size(); ++index) + { + task.slices.push_back(convertImportedImage(images[index], task.sourceId)); + const int percent = static_cast( + ((index + 1) * 100) / images.size()); + reportProgress( + percent, + QStringLiteral("Converting slice %1 / %2") + .arg(static_cast(index + 1)) + .arg(static_cast(images.size()))); + } + return task; + })); + } + + // Imports a gallery recording session as a static layer + QString ScopeOneCore::importSessionAsStaticLayer( + const std::shared_ptr& session) + { + if (!session || !session->hasRecordedOutput()) + { + return {}; + } + + const QString expId = session->capturePlan().experimentId.trimmed().isEmpty() + ? QString::number(s_importCounter.fetch_add(1)) + : session->capturePlan().experimentId.trimmed(); + const QStringList cameras = session->recordedCameraIds(); + QString lastLayerKey; + + for (const QString& camera : cameras) + { + const qint64 count = session->recordedFrameCount(camera); + if (count <= 0) + { + continue; + } + + const QString sourceId = QStringLiteral("gallery:%1_%2").arg(expId, camera); + const QString layerKey = staticLayerKey(sourceId); + + DocumentLayer existingLayer; + if (m_imageSceneModel->findLayer(layerKey, existingLayer)) + { + m_imageSceneModel->setLayerVisible(layerKey, true); + lastLayerKey = layerKey; + continue; + } + + std::vector slices; + slices.reserve(static_cast(count)); + for (int i = 0; i < count; ++i) + { + ImageFrame frame = session->imageFrameAt(camera, i); + if (frame.isValid()) + { + frame.cameraId = sourceId; + slices.push_back(std::move(frame)); + } + } + if (slices.empty()) + { + continue; + } + + const QString baseName = session->capturePlan().baseName.trimmed().isEmpty() + ? QStringLiteral("snapshot") + : session->capturePlan().baseName.trimmed(); + const QString displayName = cameras.size() > 1 + ? QStringLiteral("%1 - %2").arg(baseName, camera) + : baseName; + + m_layerStacks.insert(sourceId, std::move(slices)); + const ImageFrame& frame = m_layerStacks[sourceId].front(); + const ImageFrame published = publishStaticFrame(sourceId, frame, displayName); + if (published.isValid()) + { + lastLayerKey = layerKey; + } + } + return lastLayerKey; + } + + // Publish the latest frame of a tool-owned realtime stream + ImageFrame ScopeOneCore::publishToolStreamFrame(const QString& sourceId, + const ImageFrame& frame, + const QString& displayName) + { + if (!m_frameGraph.publishLatest(FrameGraphStream::Tool, sourceId, frame)) + { + return {}; + } + recordLayerFrame(toolLayerKey(sourceId)); + const ImageFrame storedFrame = graphFrame(toolLayerKey(sourceId)); + const QString layerKey = toolLayerKey(storedFrame.cameraId); + ensureSceneLayer(layerKey, + storedFrame.cameraId, + displayName.trimmed().isEmpty() ? storedFrame.cameraId : displayName.trimmed(), + DocumentLayerKind::Tool); + m_imageSceneModel->setLayerName( + layerKey, + displayName.trimmed().isEmpty() ? storedFrame.cameraId : displayName.trimmed()); + m_imageSceneModel->updateLayerFrame(layerKey, storedFrame); + m_imageSceneModel->setLayerVisible(layerKey, true); + m_latestHistogramStats.remove(layerKey); + scheduleHistogramStats(layerKey, storedFrame); + emit toolStreamFramePublished(storedFrame.cameraId, displayName.trimmed(), storedFrame); + updateLineProfile(storedFrame.cameraId, false, true, storedFrame); + return storedFrame; + } + + // Publish an externally supplied frame to the central graph + ImageFrame ScopeOneCore::publishExternalFrame(const QString& sourceId, const ImageFrame& frame) + { + if (!m_frameGraph.publishLatest(FrameGraphStream::External, sourceId, frame)) + { + return {}; + } + recordLayerFrame(QStringLiteral("external:%1").arg(sourceId.trimmed())); + return graphFrame(QStringLiteral("external:%1").arg(sourceId.trimmed())); + } + + // Remove one static frame graph source + void ScopeOneCore::removeStaticFrame(const QString& sourceId) + { + const QString trimmedSourceId = sourceId.trimmed(); + if (trimmedSourceId.isEmpty()) + { + return; + } + + const QString layerKey = staticLayerKey(trimmedSourceId); + m_frameGraph.remove(FrameGraphStream::Static, trimmedSourceId); + m_layerStacks.remove(trimmedSourceId); + m_imageSceneModel->removeLayer(layerKey); + clearLayerAnalysis(layerKey); + if (m_activeLineProfile.active + && m_activeLineProfile.staticSource + && m_activeLineProfile.sourceId == trimmedSourceId) + { + clearLineProfile(); + } + emit staticFrameRemoved(trimmedSourceId); + } + + // Clear all static frame graph sources + void ScopeOneCore::clearStaticFrames() + { + m_frameGraph.clear(FrameGraphStream::Static); + m_layerStacks.clear(); + for (const QString& layerId : m_imageSceneModel->layerIds()) + { + DocumentLayer layer; + if (m_imageSceneModel->findLayer(layerId, layer) + && (layer.kind == DocumentLayerKind::Static + || layer.kind == DocumentLayerKind::Gallery)) + { + m_imageSceneModel->removeLayer(layerId); + } + } + clearLayerAnalysisByPrefix(QStringLiteral("static:")); + if (m_activeLineProfile.active && m_activeLineProfile.staticSource) + { + clearLineProfile(); + } + emit staticFramesCleared(); + } + + // Clear derived analysis data for one layer + void ScopeOneCore::clearLayerAnalysis(const QString& layerKey) + { + const QString trimmedLayerKey = layerKey.trimmed(); + if (trimmedLayerKey.isEmpty()) + { + return; + } + + m_latestHistogramStats.remove(trimmedLayerKey); + m_histogramJobStates.remove(trimmedLayerKey); + emit layerAnalysisCleared(trimmedLayerKey); + } + + // Clear derived analysis data for one graph stream + void ScopeOneCore::clearLayerAnalysisByPrefix(const QString& prefix) + { + const QString trimmedPrefix = prefix.trimmed(); + if (trimmedPrefix.isEmpty()) + { + return; + } + + QStringList clearedKeys; + for (const QString& key : m_latestHistogramStats.keys()) + { + if (key.startsWith(trimmedPrefix)) + { + m_latestHistogramStats.remove(key); + if (!clearedKeys.contains(key)) + { + clearedKeys.append(key); + } + } + } + for (const QString& key : m_histogramJobStates.keys()) + { + if (key.startsWith(trimmedPrefix)) + { + m_histogramJobStates.remove(key); + if (!clearedKeys.contains(key)) + { + clearedKeys.append(key); + } + } + } + for (const QString& key : clearedKeys) + { + emit layerAnalysisCleared(key); + } + } + + // Clear stale live frames for a camera source + void ScopeOneCore::clearLiveFrames(const QString& cameraId) + { + const QString trimmedCameraId = cameraId.trimmed(); + if (trimmedCameraId.isEmpty()) + { + return; + } + + m_frameGraph.remove(FrameGraphStream::Raw, trimmedCameraId); + m_frameGraph.remove(FrameGraphStream::Processed, trimmedCameraId); + m_frameGraph.remove(FrameGraphStream::Tool, trimmedCameraId); + { + QMutexLocker locker(&m_managers->processedDeliveryMutex); + m_managers->pendingProcessedFrames.remove(trimmedCameraId); + } + m_pendingPreviewRawFrames.remove(trimmedCameraId); + m_pendingPreviewProcessedFrames.remove(trimmedCameraId); + const QString rawLayerKey = histogramLayerKey(trimmedCameraId, false); + const QString processedLayerKey = histogramLayerKey(trimmedCameraId, true); + m_imageSceneModel->clear(rawLayerKey); + m_imageSceneModel->clear(processedLayerKey); + clearLayerAnalysis(rawLayerKey); + clearLayerAnalysis(processedLayerKey); + if (m_activeLineProfile.active + && !m_activeLineProfile.staticSource + && m_activeLineProfile.sourceId == trimmedCameraId) + { + clearLineProfile(); + } emit liveFramesCleared(trimmedCameraId); } @@ -2631,7 +3563,18 @@ namespace scopeone::core int maxParticles) { const QString key = layerKey.trimmed(); - const ImageFrame frame = graphFrame(key); + return detectParticles(graphFrame(key), key, + threshold, minArea, maxArea, maxParticles); + } + + quint64 ScopeOneCore::detectParticles(const ImageFrame& frame, + const QString& resultLayerKey, + int threshold, + int minArea, + int maxArea, + int maxParticles) + { + const QString key = resultLayerKey.trimmed(); if (key.isEmpty() || !frame.isValid()) { return 0; @@ -2829,6 +3772,7 @@ namespace scopeone::core // Emit a line profile when the active request matches this frame void ScopeOneCore::updateLineProfile(const QString& cameraId, bool processed, + bool toolSource, const ImageFrame& frame) { if (!frame.isValid()) @@ -2838,6 +3782,7 @@ namespace scopeone::core if (!m_activeLineProfile.active || m_activeLineProfile.staticSource + || m_activeLineProfile.toolSource != toolSource || m_activeLineProfile.processed != processed || m_activeLineProfile.sourceId != cameraId) { @@ -2860,7 +3805,9 @@ namespace scopeone::core return; } - const QString layerKey = histogramLayerKey(cameraId, processed); + const QString layerKey = m_activeLineProfile.toolSource + ? toolLayerKey(cameraId) + : histogramLayerKey(cameraId, processed); emit lineProfileUpdated(cameraId, processed, values); emit layerLineProfileUpdated(layerKey, values); } @@ -2900,15 +3847,15 @@ namespace scopeone::core { return {}; } - auto handle = core(); - try - { - return toQStringList(handle->getLoadedDevicesOfType(MM::XYStageDevice)); - } - catch (const CMMError&) + QStringList devices; + for (const HardwareDeviceDescriptor& device : hardwareDevices()) { - return {}; + if (device.kind == HardwareDeviceKind::XYStage) + { + devices.append(device.logicalId); + } } + return devices; } QStringList ScopeOneCore::zStageDevices() const @@ -2917,15 +3864,15 @@ namespace scopeone::core { return {}; } - auto handle = core(); - try - { - return toQStringList(handle->getLoadedDevicesOfType(MM::StageDevice)); - } - catch (const CMMError&) + QStringList devices; + for (const HardwareDeviceDescriptor& device : hardwareDevices()) { - return {}; + if (device.kind == HardwareDeviceKind::ZStage) + { + devices.append(device.logicalId); + } } + return devices; } QString ScopeOneCore::currentXYStageDevice() const @@ -2934,15 +3881,7 @@ namespace scopeone::core { return {}; } - auto handle = core(); - try - { - return QString::fromStdString(handle->getXYStageDevice()); - } - catch (const CMMError&) - { - return {}; - } + return m_managers->hardwareRuntime->defaultXYStage(); } QString ScopeOneCore::currentFocusDevice() const @@ -2951,64 +3890,38 @@ namespace scopeone::core { return {}; } - auto handle = core(); - try - { - return QString::fromStdString(handle->getFocusDevice()); - } - catch (const CMMError&) - { - return {}; - } + return m_managers->hardwareRuntime->defaultZStage(); } - // Read the current XY stage position from MMCore + // Read the current XY stage position through its provider bool ScopeOneCore::readXYPosition(const QString& xyStageLabel, double& x, double& y) const { x = 0.0; y = 0.0; const QString label = xyStageLabel.trimmed(); - auto handle = core(); if (m_configurationOperationRunning || label.isEmpty()) { return false; } - try - { - handle->getXYPosition(label.toStdString().c_str(), x, y); - return true; - } - catch (const CMMError&) - { - return false; - } + return m_managers->hardwareRuntime->getXYPosition(label, x, y, nullptr); } - // Read the current Z stage position from MMCore + // Read the current Z stage position through its provider bool ScopeOneCore::readZPosition(const QString& zStageLabel, double& z) const { z = 0.0; const QString label = zStageLabel.trimmed(); - auto handle = core(); if (m_configurationOperationRunning || label.isEmpty()) { return false; } - try - { - z = handle->getPosition(label.toStdString().c_str()); - return true; - } - catch (const CMMError&) - { - return false; - } + return m_managers->hardwareRuntime->getZPosition(label, z, nullptr); } // Queues one stage command on the serialized hardware worker quint64 ScopeOneCore::queueStageMove( const QString& deviceLabel, - std::function command) + std::function command) { const QString label = deviceLabel.trimmed(); if (label.isEmpty() @@ -3036,23 +3949,12 @@ namespace scopeone::core watcher->deleteLater(); }); - const auto handle = core(); watcher->setFuture(QtConcurrent::run( m_hardwareThreadPool.get(), - [handle, label, command = std::move(command)]() + [command = std::move(command)]() { StageTaskResult task; - try - { - const std::string device = label.toStdString(); - command(*handle, device.c_str()); - handle->waitForDevice(device.c_str()); - task.success = true; - } - catch (const CMMError& error) - { - task.errorMessage = QString::fromStdString(error.getMsg()); - } + task.success = command(&task.errorMessage); return task; })); return commandId; @@ -3060,164 +3962,149 @@ namespace scopeone::core quint64 ScopeOneCore::moveXYRelative(const QString& xyStageLabel, double dx, double dy) { - return queueStageMove(xyStageLabel, [dx, dy](CMMCore& handle, const char* label) + HardwareRuntime* const runtime = m_managers->hardwareRuntime; + const QString device = xyStageLabel.trimmed(); + return queueStageMove(device, [runtime, device, dx, dy](QString* errorMessage) { - handle.setRelativeXYPosition(label, dx, dy); + return runtime->setRelativeXYPosition(device, dx, dy, errorMessage); }); } quint64 ScopeOneCore::moveZRelative(const QString& zStageLabel, double dz) { - return queueStageMove(zStageLabel, [dz](CMMCore& handle, const char* label) + HardwareRuntime* const runtime = m_managers->hardwareRuntime; + const QString device = zStageLabel.trimmed(); + return queueStageMove(device, [runtime, device, dz](QString* errorMessage) { - handle.setRelativePosition(label, dz); + return runtime->setRelativeZPosition(device, dz, errorMessage); }); } quint64 ScopeOneCore::moveXYTo(const QString& xyStageLabel, double x, double y) { - return queueStageMove(xyStageLabel, [x, y](CMMCore& handle, const char* label) + HardwareRuntime* const runtime = m_managers->hardwareRuntime; + const QString device = xyStageLabel.trimmed(); + return queueStageMove(device, [runtime, device, x, y](QString* errorMessage) { - handle.setXYPosition(label, x, y); + return runtime->setXYPosition(device, x, y, errorMessage); }); } quint64 ScopeOneCore::moveZTo(const QString& zStageLabel, double z) { - return queueStageMove(zStageLabel, [z](CMMCore& handle, const char* label) + HardwareRuntime* const runtime = m_managers->hardwareRuntime; + const QString device = zStageLabel.trimmed(); + return queueStageMove(device, [runtime, device, z](QString* errorMessage) { - handle.setPosition(label, z); + return runtime->setZPosition(device, z, errorMessage); }); } - // List available Micro Manager configuration groups - QStringList ScopeOneCore::availableConfigGroups() const + bool ScopeOneCore::readShutterOpen(const QString& shutterLabel, bool& open) const { - if (m_configurationOperationRunning) + open = false; + const QString device = shutterLabel.trimmed(); + return !m_configurationOperationRunning + && !device.isEmpty() + && m_managers->hardwareRuntime->isShutterOpen(device, open, nullptr); + } + + bool ScopeOneCore::setShutterOpen(const QString& shutterLabel, + bool open, + QString* errorMessage) + { + if (errorMessage) { - return {}; + errorMessage->clear(); } - auto handle = core(); - try + const QString device = shutterLabel.trimmed(); + if (m_configurationOperationRunning || device.isEmpty()) { - const auto groups = handle->getAvailableConfigGroups(); - QStringList result; - for (const auto& g : groups) + if (errorMessage) { - result.append(QString::fromStdString(g)); + *errorMessage = QStringLiteral("Invalid shutter target"); } - return result; + return false; } - catch (const CMMError&) + const bool ok = m_managers->hardwareRuntime->setShutterOpen( + device, open, errorMessage); + if (ok) { - return {}; + emit deviceStateChanged(); } + return ok; } - // List presets in a Micro Manager configuration group - QStringList ScopeOneCore::availableConfigs(const QString& configGroup) const + bool ScopeOneCore::readDeviceState(const QString& deviceLabel, long& state) const { - auto handle = core(); - if (m_configurationOperationRunning || configGroup.isEmpty()) + state = 0; + const QString device = deviceLabel.trimmed(); + return !m_configurationOperationRunning + && !device.isEmpty() + && m_managers->hardwareRuntime->getState(device, state, nullptr); + } + + bool ScopeOneCore::setDeviceState(const QString& deviceLabel, + long state, + QString* errorMessage) + { + if (errorMessage) { - return {}; + errorMessage->clear(); } - try + const QString device = deviceLabel.trimmed(); + if (m_configurationOperationRunning || device.isEmpty()) { - const auto configs = handle->getAvailableConfigs(configGroup.toStdString().c_str()); - QStringList result; - for (const auto& c : configs) + if (errorMessage) { - result.append(QString::fromStdString(c)); + *errorMessage = QStringLiteral("Invalid state device target"); } - return result; + return false; } - catch (const CMMError&) + const bool ok = m_managers->hardwareRuntime->setState(device, state, errorMessage); + if (ok) { - return {}; + emit deviceStateChanged(); } + return ok; } - // Read the current preset for a configuration group - QString ScopeOneCore::currentConfig(const QString& groupName) const + QString ScopeOneCore::deviceStateLabel(const QString& deviceLabel, long state) const { - auto handle = core(); - if (m_configurationOperationRunning || groupName.isEmpty()) + const QString device = deviceLabel.trimmed(); + return !m_configurationOperationRunning && !device.isEmpty() + ? m_managers->hardwareRuntime->stateLabel(device, state) + : QString{}; + } + + // List configuration groups exposed by registered providers + QStringList ScopeOneCore::availableConfigGroups() const + { + if (m_configurationOperationRunning) { return {}; } - try + return m_managers->hardwareRuntime->availableConfigGroups(); + } + + // List presets in a provider configuration group + QStringList ScopeOneCore::availableConfigs(const QString& configGroup) const + { + if (m_configurationOperationRunning || configGroup.isEmpty()) { - if (m_managers->cameraManager->usesAgentBackend()) - { - const std::string group = groupName.toStdString(); - const std::vector configs = handle->getAvailableConfigs(group.c_str()); - QHash currentValues; - QSet failedProperties; - for (const std::string& config : configs) - { - const Configuration preset = handle->getConfigData(group.c_str(), config.c_str()); - bool matches = true; - for (size_t index = 0; index < preset.size(); ++index) - { - const PropertySetting setting = preset.getSetting(index); - const QString device = QString::fromStdString(setting.getDeviceLabel()); - const QString property = QString::fromStdString(setting.getPropertyName()); - const QString key = device + QChar(0x1f) + property; - if (!currentValues.contains(key) && !failedProperties.contains(key)) - { - if (isConfiguredCamera(device)) - { - const QString value = m_managers->cameraManager->getProperty( - device, property, false); - if (value.isNull()) - { - failedProperties.insert(key); - } - else - { - currentValues.insert(key, value); - } - } - else - { - try - { - currentValues.insert( - key, - QString::fromStdString( - handle->getProperty( - setting.getDeviceLabel().c_str(), - setting.getPropertyName().c_str()))); - } - catch (const CMMError&) - { - failedProperties.insert(key); - } - } - } - if (failedProperties.contains(key) - || currentValues.value(key) - != QString::fromStdString(setting.getPropertyValue())) - { - matches = false; - break; - } - } - if (matches) - { - return QString::fromStdString(config); - } - } - return {}; - } - return QString::fromStdString( - handle->getCurrentConfig(groupName.toStdString().c_str())); + return {}; } - catch (const CMMError&) + return m_managers->hardwareRuntime->availableConfigs(configGroup); + } + + // Read the current preset for a configuration group + QString ScopeOneCore::currentConfig(const QString& groupName) const + { + if (m_configurationOperationRunning || groupName.isEmpty()) { return {}; } + return m_managers->hardwareRuntime->currentConfig(groupName); } // Apply a configuration preset while camera previews are paused @@ -3229,7 +4116,6 @@ namespace scopeone::core { errorMessage->clear(); } - auto handle = core(); if (m_configurationOperationRunning || m_pendingStageCommands > 0 || groupName.isEmpty() @@ -3255,106 +4141,9 @@ namespace scopeone::core const QStringList runningPreviewIds = runningPreviewCameraIds(); const bool ok = withSuspendedPreviews(this, runningPreviewIds, [&]() { - try - { - const std::string group = groupName.toStdString(); - const std::string config = configName.toStdString(); - const bool agentMode = m_managers->cameraManager->usesAgentBackend(); - if (!agentMode) - { - handle->setConfig(group.c_str(), config.c_str()); - } - else - { - const Configuration preset = handle->getConfigData(group.c_str(), config.c_str()); - std::vector pending; - pending.reserve(preset.size()); - for (size_t index = 0; index < preset.size(); ++index) - { - pending.push_back(preset.getSetting(index)); - } - while (!pending.empty()) - { - std::vector failed; - QString failureDescription; - for (const PropertySetting& setting : pending) - { - const QString device = QString::fromStdString(setting.getDeviceLabel()); - const QString property = QString::fromStdString(setting.getPropertyName()); - const QString value = QString::fromStdString(setting.getPropertyValue()); - if (isConfiguredCamera(device)) - { - QString error; - if (!m_managers->cameraManager->setProperty( - device, property, value, &error)) - { - failed.push_back(setting); - failureDescription = QString("%1.%2 = %3: %4") - .arg(device, property, value, error); - } - continue; - } - try - { - handle->setProperty(setting.getDeviceLabel().c_str(), - setting.getPropertyName().c_str(), - setting.getPropertyValue().c_str()); - } - catch (const CMMError& error) - { - failed.push_back(setting); - failureDescription = QString("%1.%2 = %3: %4") - .arg(device, - property, - value, - QString::fromStdString(error.getMsg())); - } - } - if (failed.empty()) - { - break; - } - if (failed.size() == pending.size()) - { - const QString message = - QString("Failed to apply config preset %1 = %2 at %3") - .arg(groupName, configName, failureDescription); - if (errorMessage) - { - *errorMessage = message; - } - qWarning().noquote() - << message; - return false; - } - pending = std::move(failed); - } - } - if (agentMode) - { - handle->waitForSystem(); - } - else - { - handle->waitForConfig(group.c_str(), config.c_str()); - } - handle->updateSystemStateCache(); - return true; - } - catch (const CMMError& error) - { - const QString message = QString("Failed to apply config preset %1 = %2: %3") - .arg(groupName, - configName, - QString::fromStdString(error.getMsg())); - if (errorMessage) - { - *errorMessage = message; - } - qWarning().noquote() - << message; - return false; - } + return m_managers->hardwareRuntime->setConfig(groupName, + configName, + errorMessage); }); if (ok) { @@ -3373,65 +4162,20 @@ namespace scopeone::core return false; } - QString resolvedTarget = target; - if (resolvedTarget.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) - { - if (m_cameraIds.isEmpty()) - { - return false; - } - resolvedTarget = m_cameraIds.first(); - } - if (m_cameraIds.contains(resolvedTarget) - && m_managers->cameraManager->getExposure(resolvedTarget, exposureMs)) - { - return true; - } - - auto handle = core(); - try - { - if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) - { - exposureMs = handle->getExposure(); - } - else - { - exposureMs = handle->getExposure(target.toStdString().c_str()); - } - return true; - } - catch (const CMMError&) - { - return false; - } + return m_managers->cameraProvider->getExposure(target, exposureMs); } - // Merge native MMCore devices with agent camera labels + // List devices from the unified registry QStringList ScopeOneCore::loadedDevices() const { if (m_configurationOperationRunning) { return {}; } - auto handle = core(); QStringList devices; - try - { - devices = toQStringList(handle->getLoadedDevices()); - } - catch (const CMMError&) - { - devices.clear(); - } - - // Agent cameras are not always loaded in the UI side MMCore instance - for (const QString& cameraId : m_cameraIds) + for (const HardwareDeviceDescriptor& device : hardwareDevices()) { - if (!devices.contains(cameraId)) - { - devices.append(cameraId); - } + devices.append(device.logicalId); } return devices; } @@ -3471,19 +4215,7 @@ namespace scopeone::core { return {}; } - if (isConfiguredCamera(device)) - { - return m_managers->cameraManager->listProperties(device); - } - auto handle = core(); - try - { - return toQStringList(handle->getDevicePropertyNames(device.toStdString().c_str())); - } - catch (const CMMError&) - { - return {}; - } + return m_managers->hardwareRuntime->listProperties(device); } // Read a property value from hardware or cache @@ -3495,26 +4227,7 @@ namespace scopeone::core { return {}; } - if (isConfiguredCamera(device)) - { - return m_managers->cameraManager->getProperty(device, property, fromCache); - } - auto handle = core(); - try - { - if (fromCache) - { - return QString::fromStdString( - handle->getPropertyFromCache(device.toStdString().c_str(), - property.toStdString().c_str())); - } - return QString::fromStdString( - handle->getProperty(device.toStdString().c_str(), property.toStdString().c_str())); - } - catch (const CMMError&) - { - return {}; - } + return m_managers->hardwareRuntime->getProperty(device, property, fromCache); } // Convert backend property types into UI strings @@ -3526,27 +4239,7 @@ namespace scopeone::core { return QStringLiteral("Unknown"); } - if (isConfiguredCamera(device)) - { - return m_managers->cameraManager->getPropertyType(device, property); - } - auto handle = core(); - try - { - const MM::PropertyType type = handle->getPropertyType(device.toStdString().c_str(), - property.toStdString().c_str()); - switch (type) - { - case MM::String: return QStringLiteral("String"); - case MM::Float: return QStringLiteral("Float"); - case MM::Integer: return QStringLiteral("Integer"); - default: return QStringLiteral("Unknown"); - } - } - catch (const CMMError&) - { - return QStringLiteral("Unknown"); - } + return m_managers->hardwareRuntime->getPropertyType(device, property); } // Check whether a property can be edited @@ -3558,19 +4251,7 @@ namespace scopeone::core { return true; } - if (isConfiguredCamera(device)) - { - return m_managers->cameraManager->isPropertyReadOnly(device, property); - } - auto handle = core(); - try - { - return handle->isPropertyReadOnly(device.toStdString().c_str(), property.toStdString().c_str()); - } - catch (const CMMError&) - { - return true; - } + return m_managers->hardwareRuntime->isPropertyReadOnly(device, property); } // Check whether a property must be set before initialization @@ -3582,19 +4263,7 @@ namespace scopeone::core { return false; } - if (isConfiguredCamera(device)) - { - return m_managers->cameraManager->isPropertyPreInit(device, property); - } - auto handle = core(); - try - { - return handle->isPropertyPreInit(device.toStdString().c_str(), property.toStdString().c_str()); - } - catch (const CMMError&) - { - return false; - } + return m_managers->hardwareRuntime->isPropertyPreInit(device, property); } // Return allowed values for enumerated properties @@ -3606,20 +4275,7 @@ namespace scopeone::core { return {}; } - if (isConfiguredCamera(device)) - { - return m_managers->cameraManager->getAllowedPropertyValues(device, property); - } - auto handle = core(); - try - { - return toQStringList( - handle->getAllowedPropertyValues(device.toStdString().c_str(), property.toStdString().c_str())); - } - catch (const CMMError&) - { - return {}; - } + return m_managers->hardwareRuntime->getAllowedPropertyValues(device, property); } // Return numeric limits for range constrained properties @@ -3637,32 +4293,13 @@ namespace scopeone::core { return false; } - if (isConfiguredCamera(device)) - { - if (!m_managers->cameraManager->hasPropertyLimits(device, property)) - { - return false; - } - lower = m_managers->cameraManager->getPropertyLowerLimit(device, property); - upper = m_managers->cameraManager->getPropertyUpperLimit(device, property); - return true; - } - - auto handle = core(); - try - { - if (!handle->hasPropertyLimits(device.toStdString().c_str(), property.toStdString().c_str())) - { - return false; - } - lower = handle->getPropertyLowerLimit(device.toStdString().c_str(), property.toStdString().c_str()); - upper = handle->getPropertyUpperLimit(device.toStdString().c_str(), property.toStdString().c_str()); - return true; - } - catch (const CMMError&) + if (!m_managers->hardwareRuntime->hasPropertyLimits(device, property)) { return false; } + lower = m_managers->hardwareRuntime->getPropertyLowerLimit(device, property); + upper = m_managers->hardwareRuntime->getPropertyUpperLimit(device, property); + return true; } // Set a property and refresh backend state after the device accepts it @@ -3696,49 +4333,8 @@ namespace scopeone::core return false; } - if (isConfiguredCamera(device)) - { - QString cameraError; - if (!m_managers->cameraManager->setProperty(device, property, value, &cameraError)) - { - if (errorMessage) - { - *errorMessage = cameraError.isEmpty() - ? QStringLiteral("Camera setProperty failed") - : cameraError; - } - return false; - } - emit deviceStateChanged(); - return true; - } - - auto handle = core(); - const bool isCamera = isNativeCamera(device); - const QStringList runningPreviewIds = isCamera ? runningPreviewCameraIds() : QStringList{}; - const auto applyProperty = [&]() -> bool - { - try - { - handle->setProperty(device.toStdString().c_str(), - property.toStdString().c_str(), - value.toStdString().c_str()); - handle->waitForDevice(device.toStdString().c_str()); - handle->updateSystemStateCache(); - return true; - } - catch (const CMMError& e) - { - if (errorMessage) - { - *errorMessage = QString::fromStdString(e.getMsg()); - } - return false; - } - }; - const bool ok = isCamera - ? withSuspendedPreviews(this, runningPreviewIds, applyProperty) - : applyProperty(); + const bool ok = m_managers->hardwareRuntime->setProperty( + device, property, value, errorMessage); if (ok) { emit deviceStateChanged(); @@ -3770,7 +4366,8 @@ namespace scopeone::core } if (m_managers->imageProcessingManager->isRealTimeProcessingEnabled() == enabled) { - if (!m_managers->cameraManager->setHighRateFrameDeliveryEnabled(enabled)) + if (!m_managers->cameraRuntimeControl->setHighRateFrameDeliveryEnabled( + m_cameraIds, enabled)) { return false; } @@ -3783,7 +4380,8 @@ namespace scopeone::core if (enabled) { m_managers->imageProcessingManager->enableRealTimeProcessing(true); - if (!m_managers->cameraManager->setHighRateFrameDeliveryEnabled(true)) + if (!m_managers->cameraRuntimeControl->setHighRateFrameDeliveryEnabled( + m_cameraIds, true)) { m_managers->imageProcessingManager->enableRealTimeProcessing(false); return false; @@ -3791,7 +4389,8 @@ namespace scopeone::core } else { - if (!m_managers->cameraManager->setHighRateFrameDeliveryEnabled(false)) + if (!m_managers->cameraRuntimeControl->setHighRateFrameDeliveryEnabled( + m_cameraIds, false)) { return false; } @@ -3828,6 +4427,28 @@ namespace scopeone::core return true; } + QString ScopeOneCore::realTimeProcessingSource() const + { + return m_realTimeProcessingSource; + } + + bool ScopeOneCore::setRealTimeProcessingSource(const QString& cameraId) + { + const QString source = cameraId.trimmed(); + if (isRealTimeProcessingEnabled() + || (!source.isEmpty() && !m_cameraIds.contains(source))) + { + return false; + } + if (m_realTimeProcessingSource == source) + { + return true; + } + m_realTimeProcessingSource = source; + emit processingSettingsChanged(); + return true; + } + // Captures the ordered processing pipeline as a replayable recipe ProcessingRecipe ScopeOneCore::processingRecipe() const { @@ -3838,8 +4459,8 @@ namespace scopeone::core for (const ProcessingModuleInfo& module : modules) { ProcessingModuleRecipe entry; - entry.kind = module.kind(); - entry.schemaVersion = kProcessingModuleSchemaVersion; + entry.moduleId = module.id(); + entry.schemaVersion = module.descriptor().schemaVersion; entry.parameters = module.parameters(); recipe.modules.append(std::move(entry)); } @@ -3866,25 +4487,37 @@ namespace scopeone::core modules.reserve(static_cast(recipe.modules.size())); for (const ProcessingModuleRecipe& entry : recipe.modules) { - if (entry.schemaVersion != kProcessingModuleSchemaVersion) + const ProcessingModuleDescriptor descriptor = + m_managers->processingModuleRegistry->descriptor(entry.moduleId); + if (descriptor.id.isEmpty()) { if (errorMessage) { - *errorMessage = QStringLiteral("Unsupported processing module schema version: %1") - .arg(entry.schemaVersion); + *errorMessage = QStringLiteral("Unsupported processing module: %1") + .arg(entry.moduleId); } return false; } - std::unique_ptr module = createProcessingModule(entry.kind); - if (!module) + if (entry.schemaVersion != descriptor.schemaVersion) { if (errorMessage) { - *errorMessage = QStringLiteral("Unsupported processing module: %1") - .arg(processingModuleKindName(entry.kind)); + *errorMessage = QStringLiteral( + "Unsupported schema version %1 for processing module %2; expected %3") + .arg(entry.schemaVersion) + .arg(entry.moduleId) + .arg(descriptor.schemaVersion); } return false; } + std::unique_ptr module = + m_managers->processingModuleRegistry->create(entry.moduleId); + if (!module) + { + if (errorMessage) *errorMessage = QStringLiteral("Failed to create processing module: %1") + .arg(entry.moduleId); + return false; + } module->setParameters(entry.parameters); if (!equalCanonicalParameters(module->parameters(), entry.parameters)) { @@ -3954,6 +4587,31 @@ namespace scopeone::core return m_managers->imageProcessingManager->processFrameThrough(endModuleIndex, frame); } + // Lists every registered processing module type + QList ScopeOneCore::availableProcessingModules() const + { + return m_managers->processingModuleRegistry->descriptors(); + } + + bool ScopeOneCore::registerProcessingModule( + const ProcessingModuleDescriptor& descriptor, + std::function()> factory) + { + return m_managers->processingModuleRegistry->registerModule(descriptor, + std::move(factory)); + } + + std::unique_ptr ScopeOneCore::createProcessingModule( + const QString& moduleId) const + { + return m_managers->processingModuleRegistry->create(moduleId); + } + + std::unique_ptr ScopeOneCore::createProcessingPipeline() const + { + return std::make_unique(); + } + // Export processing module descriptions for the UI QList ScopeOneCore::processingModules() const { @@ -3961,26 +4619,29 @@ namespace scopeone::core ProcessingPipelineDefinition& definition = m_managers->imageProcessingManager->definition(); out.reserve(definition.moduleCount()); - definition.forEachModule([&out](const ProcessingModule* module) + definition.forEachModule([this, &out](const ProcessingModule* module) { ProcessingModuleInfo info; - info.setKind(module->kind()); + info.setId(module->id()); info.setName(module->name()); info.setParameters(module->parameters()); + info.setDescriptor(m_managers->processingModuleRegistry->descriptor(module->id())); + info.setEnabled(module->isEnabled()); out.append(std::move(info)); }); return out; } // Add a processing module to the editable pipeline - bool ScopeOneCore::addProcessingModule(ProcessingModuleKind kind) + bool ScopeOneCore::addProcessingModule(const QString& moduleId) { if (isRealTimeProcessingEnabled()) { return false; } ProcessingPipelineDefinition& definition = m_managers->imageProcessingManager->definition(); - std::unique_ptr module = createProcessingModule(kind); + std::unique_ptr module = + m_managers->processingModuleRegistry->create(moduleId); if (!module) return false; definition.addModule(std::move(module)); @@ -4010,6 +4671,48 @@ namespace scopeone::core return true; } + // Move one editable processing module and rebuild runtime pipelines + bool ScopeOneCore::moveProcessingModule(int from, int to) + { + if (isRealTimeProcessingEnabled()) + { + return false; + } + ProcessingPipelineDefinition& definition = m_managers->imageProcessingManager->definition(); + if (!definition.moveModule(from, to)) + { + return false; + } + m_managers->imageProcessingManager->clearRuntimePipelines(); + emit processingModulesChanged(); + return true; + } + + // Enable or bypass one editable processing module + bool ScopeOneCore::setProcessingModuleEnabled(int index, bool enabled) + { + if (isRealTimeProcessingEnabled()) + { + return false; + } + ProcessingPipelineDefinition& definition = m_managers->imageProcessingManager->definition(); + bool updated = false; + if (!definition.withModule(index, [enabled, &updated](ProcessingModule* module) + { + updated = module->isEnabled() != enabled; + module->setEnabled(enabled); + })) + { + return false; + } + if (updated) + { + m_managers->imageProcessingManager->clearRuntimePipelines(); + emit processingModulesChanged(); + } + return true; + } + // Update module parameters and rebuild per camera runtime modules bool ScopeOneCore::setProcessingModuleParameters(int index, const QVariantMap& parameters) { @@ -4027,7 +4730,7 @@ namespace scopeone::core return false; } m_managers->imageProcessingManager->clearRuntimePipelines(); - emit processingModulesChanged(); + emit processingModuleParametersChanged(index); return true; } @@ -4056,6 +4759,275 @@ namespace scopeone::core return true; } + // Process one image without coupling the task to a UI layer + quint64 ScopeOneCore::requestImageProcessing(const ImageFrame& frame, + const QString& sourceId) + { + if (!frame.isValid() || processingModules().isEmpty()) + { + return 0; + } + + const quint64 requestId = ++m_nextProcessingRequestId; + auto cancelToken = std::make_shared(false); + m_processingRequestCancelTokens.insert(requestId, cancelToken); + auto pipeline = m_managers->imageProcessingManager->definition().createRuntime(); + const int bitDepth = static_cast(processingBitDepth()); + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, + this, [this, watcher, requestId, sourceId, cancelToken]() + { + ProcessingResult result = watcher->result(); + m_processingRequestCancelTokens.remove(requestId); + emit imageProcessingFinished( + requestId, + sourceId, + cancelToken->load() ? ImageFrame{} : result.frame, + cancelToken->load() ? QStringLiteral("Processing canceled") : result.error); + watcher->deleteLater(); + }); + watcher->setFuture(QtConcurrent::run( + m_offlineProcessingThreadPool.get(), + [pipeline = std::move(pipeline), frame, bitDepth, cancelToken]() + { + if (cancelToken->load()) + { + return ProcessingResult(ImageFrame{}, QStringLiteral("Processing canceled")); + } + return pipeline->process(frame, bitDepth); + })); + return requestId; + } + + // Process a complete recorded stack with one isolated stateful runtime + quint64 ScopeOneCore::requestRecordingSessionStackProcessing(const QString& sessionId, + const QString& cameraId) + { + const auto sourceSession = recordingSession(sessionId); + const QString sourceCameraId = cameraId.trimmed(); + const qint64 frameCount = sourceSession + ? sourceSession->recordedFrameCount(sourceCameraId) + : 0; + if (!sourceSession + || sourceCameraId.isEmpty() + || frameCount <= 0 + || frameCount > (std::numeric_limits::max)() + || processingModules().isEmpty()) + { + return 0; + } + + const quint64 requestId = ++m_nextProcessingRequestId; + auto cancelToken = std::make_shared(false); + m_processingRequestCancelTokens.insert(requestId, cancelToken); + auto pipeline = m_managers->imageProcessingManager->definition().createRuntime(); + const int bitDepth = static_cast(processingBitDepth()); + const ProcessingRecipe recipe = processingRecipe(); + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, + this, [this, watcher, requestId, cancelToken]() + { + OfflineProcessingResult result = watcher->result(); + m_processingRequestCancelTokens.remove(requestId); + result.canceled = result.canceled || cancelToken->load(); + std::shared_ptr outputSession; + if (!result.canceled && result.errorMessage.isEmpty()) + { + result.plan.experimentId = QUuid::createUuid().toString(QUuid::WithoutBraces); + result.plan.streamToDisk = false; + result.plan.saveDir.clear(); + result.plan.baseName += QStringLiteral("_processed"); + outputSession = createFrameSession(result.frames, result.plan); + if (!outputSession) + { + result.errorMessage = QStringLiteral("Failed to create processed stack"); + } + else + { + outputSession->setCapturePlan(result.plan); + } + } + if (result.canceled) + { + result.errorMessage = QStringLiteral("Processing canceled"); + } + emit stackProcessingFinished(requestId, outputSession, result.errorMessage); + watcher->deleteLater(); + }); + watcher->setFuture(QtConcurrent::run( + m_offlineProcessingThreadPool.get(), + [this, requestId, sourceSession, sourceCameraId, frameCount, bitDepth, recipe, + pipeline = std::move(pipeline), cancelToken]() + { + OfflineProcessingResult result; + result.plan = sourceSession->capturePlan(); + result.plan.cameraIds = {sourceCameraId}; + result.plan.processing = recipe; + result.frames.reserve(static_cast(frameCount)); + QElapsedTimer progressTimer; + progressTimer.start(); + for (int index = 0; index < static_cast(frameCount); ++index) + { + if (cancelToken->load()) + { + result.canceled = true; + break; + } + const ImageFrame frame = sourceSession->imageFrameAt(sourceCameraId, index); + if (!frame.isValid()) + { + result.errorMessage = QStringLiteral("Failed to read stack frame %1") + .arg(index + 1); + break; + } + ProcessingResult processed = pipeline->process(frame, bitDepth); + if (!processed.succeeded()) + { + result.errorMessage = processed.error; + break; + } + result.frames.append(std::move(processed.frame)); + if (progressTimer.elapsed() >= 100 || index + 1 == frameCount) + { + const qint64 completed = index + 1; + QMetaObject::invokeMethod(this, [this, requestId, completed, frameCount]() + { + emit stackProcessingProgress(requestId, completed, frameCount); + }); + progressTimer.restart(); + } + } + return result; + })); + return requestId; + } + + // Process a static layer image stack with an isolated runtime + quint64 ScopeOneCore::requestLayerStackProcessing(const QString& layerKey) + { + const QString sourceId = sourceIdFromLayerKey(layerKey); + if (sourceId.isEmpty() || !m_layerStacks.contains(sourceId) || processingModules().isEmpty()) + { + return 0; + } + + const auto inputSlices = m_layerStacks.value(sourceId); + const qsizetype frameCount = static_cast(inputSlices.size()); + if (frameCount <= 0) + { + return 0; + } + + QString cleanSourceId = sourceId; + if (cleanSourceId.startsWith(QStringLiteral("processed:"))) + { + cleanSourceId = cleanSourceId.mid(10); + } + const QString outputSourceId = QStringLiteral("processed:%1").arg(cleanSourceId); + const QString outputLayerKey = staticLayerKey(outputSourceId); + + DocumentLayer layer; + QString baseName; + if (m_imageSceneModel->findLayer(layerKey, layer) && !layer.name.isEmpty()) + { + baseName = layer.name; + } + else + { + baseName = sourceId; + } + const QString displayName = baseName.startsWith(QStringLiteral("Processed: ")) + ? baseName + : QStringLiteral("Processed: %1").arg(baseName); + + const quint64 requestId = ++m_nextProcessingRequestId; + auto cancelToken = std::make_shared(false); + m_processingRequestCancelTokens.insert(requestId, cancelToken); + auto pipeline = m_managers->imageProcessingManager->definition().createRuntime(); + const int bitDepth = static_cast(processingBitDepth()); + + struct LayerStackProcessingResult + { + std::vector frames; + QString errorMessage; + bool canceled{false}; + }; + + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, + this, [this, watcher, requestId, outputSourceId, outputLayerKey, displayName, cancelToken]() + { + LayerStackProcessingResult result = watcher->result(); + m_processingRequestCancelTokens.remove(requestId); + result.canceled = result.canceled || cancelToken->load(); + if (!result.canceled && result.errorMessage.isEmpty() && !result.frames.empty()) + { + m_layerStacks.insert(outputSourceId, std::move(result.frames)); + publishStaticFrame(outputSourceId, m_layerStacks[outputSourceId].front(), displayName); + } + if (result.canceled) + { + result.errorMessage = QStringLiteral("Processing canceled"); + } + emit layerStackProcessingFinished(requestId, outputLayerKey, result.errorMessage); + watcher->deleteLater(); + }); + + watcher->setFuture(QtConcurrent::run( + m_offlineProcessingThreadPool.get(), + [this, requestId, inputSlices, outputSourceId, frameCount, bitDepth, pipeline = std::move(pipeline), cancelToken]() + { + LayerStackProcessingResult result; + result.frames.reserve(static_cast(frameCount)); + QElapsedTimer progressTimer; + progressTimer.start(); + for (qsizetype index = 0; index < frameCount; ++index) + { + if (cancelToken->load()) + { + result.canceled = true; + break; + } + const ImageFrame& frame = inputSlices[static_cast(index)]; + if (!frame.isValid()) + { + result.errorMessage = QStringLiteral("Failed to read stack frame %1").arg(index + 1); + break; + } + ProcessingResult processed = pipeline->process(frame, bitDepth); + if (!processed.succeeded()) + { + result.errorMessage = processed.error; + break; + } + processed.frame.cameraId = outputSourceId; + result.frames.push_back(std::move(processed.frame)); + if (progressTimer.elapsed() >= 100 || index + 1 == frameCount) + { + const qint64 completed = index + 1; + QMetaObject::invokeMethod(this, [this, requestId, completed, frameCount]() + { + emit stackProcessingProgress(requestId, completed, frameCount); + }); + progressTimer.restart(); + } + } + return result; + })); + return requestId; + } + + bool ScopeOneCore::cancelProcessingRequest(quint64 requestId) + { + const auto token = m_processingRequestCancelTokens.value(requestId); + if (!token) + { + return false; + } + token->store(true); + return true; + } + void ScopeOneCore::setRecordingMaxPendingWriteBytes(qint64 bytes) { m_managers->recordingManager->setRecordedMaxBytes(bytes); @@ -4420,6 +5392,7 @@ namespace scopeone::core } m_managers->sessions.remove(id); emit recordingSessionClosed(id); + emit recordingSessionsChanged(); return true; } @@ -4437,6 +5410,7 @@ namespace scopeone::core } m_managers->sessions.insert(experimentId, session); m_managers->experiments.insert(experimentId, session->experimentDocument()); + emit recordingSessionsChanged(); } // Finalize the shared experiment state before notifying API and UI clients @@ -4537,6 +5511,48 @@ namespace scopeone::core return true; } + // Queue one session copy for background writing + bool ScopeOneCore::queueRecordingSessionSave( + const std::shared_ptr& sourceSession, + const std::shared_ptr& saveSession, + const QString& cameraId) + { + if (!sourceSession || !saveSession || m_sessionsSaving.contains(sourceSession.get())) + { + return false; + } + saveSession->m_frames.clear(); + saveSession->clearOutputFiles(); + saveSession->m_manifest.output.streamedToDisk = false; + m_sessionsSaving.insert(sourceSession.get()); + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, + this, [this, watcher, sourceSession, saveSession, cameraId]() + { + m_sessionsSaving.remove(sourceSession.get()); + if (cameraId.isEmpty()) + { + sourceSession->applySaveStateFrom(*saveSession); + registerRecordingSession(sourceSession); + emit recordingSessionSaveFinished(sourceSession); + } + else + { + emit recordingSessionCameraSaveFinished( + sourceSession, + cameraId, + saveSession->isSaved(), + saveSession->saveMessage()); + } + watcher->deleteLater(); + }); + watcher->setFuture(QtConcurrent::run([saveSession, sourceSession]() + { + return RecordingManager::saveSessionToDisk(saveSession, sourceSession); + })); + return true; + } + // Save a completed session on a worker thread bool ScopeOneCore::saveRecordingSession(const std::shared_ptr& session) { @@ -4544,50 +5560,59 @@ namespace scopeone::core { return false; } - if (m_sessionsSaving.contains(session.get())) + auto saveSession = session->cloneForSave(); + ExperimentPlan plan = saveSession->capturePlan(); + if (plan.metadataFileName.trimmed().isEmpty()) { - return false; + plan.metadataFileName = recordingMetadataFileName(plan.baseName); + saveSession->setCapturePlan(plan); } + return queueRecordingSessionSave(session, saveSession); + } - ExperimentPlan capturePlan = session->capturePlan(); - if (capturePlan.metadataFileName.trimmed().isEmpty()) + // Apply output options to a detached session copy before writing + bool ScopeOneCore::saveRecordingSession( + const std::shared_ptr& session, + const RecordingSaveOptions& saveOptions) + { + if (!session) { - capturePlan.metadataFileName = recordingMetadataFileName(capturePlan.baseName); - session->setCapturePlan(capturePlan); + return false; } - const std::shared_ptr saveSession = session->cloneForSave(); - m_sessionsSaving.insert(session.get()); - auto* watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, - this, - [this, watcher, session, saveSession]() + auto saveSession = session->cloneForSave(); + ExperimentPlan plan = saveSession->capturePlan(); + plan.format = saveOptions.format; + plan.enableCompression = saveOptions.enableCompression; + plan.compressionLevel = saveOptions.compressionLevel; + if (!saveOptions.saveDir.trimmed().isEmpty()) { - session->applySaveStateFrom(*saveSession); - registerRecordingSession(session); - m_sessionsSaving.remove(session.get()); - emit recordingSessionSaveFinished(session); - watcher->deleteLater(); - }); - - const auto future = QtConcurrent::run([saveSession]() + plan.saveDir = saveOptions.saveDir; + } + if (!saveOptions.baseName.trimmed().isEmpty()) { - return RecordingManager::saveSessionToDisk(saveSession); - }); - watcher->setFuture(future); - return true; + plan.baseName = saveOptions.baseName; + } + plan.metadataFileName = recordingMetadataFileName(plan.baseName); + saveSession->setCapturePlan(plan); + return queueRecordingSessionSave(session, saveSession); } - // Updates save options before dispatching the asynchronous writer - bool ScopeOneCore::saveRecordingSession( + // Save only the camera stack represented by one image document + bool ScopeOneCore::saveRecordingSessionCamera( const std::shared_ptr& session, - const RecordingSaveOptions& saveOptions) + const QString& cameraId, + const RecordingSaveOptions& saveOptions, + const ExperimentDocument* presentation) { - if (!session || m_sessionsSaving.contains(session.get())) + const QString sourceCameraId = cameraId.trimmed(); + if (!session || sourceCameraId.isEmpty() + || session->recordedFrameCount(sourceCameraId) <= 0) { return false; } - - ExperimentPlan plan = session->capturePlan(); + auto saveSession = session->cloneForSave(); + ExperimentPlan plan = saveSession->capturePlan(); + plan.cameraIds = {sourceCameraId}; plan.format = saveOptions.format; plan.enableCompression = saveOptions.enableCompression; plan.compressionLevel = saveOptions.compressionLevel; @@ -4599,8 +5624,33 @@ namespace scopeone::core { plan.baseName = saveOptions.baseName; } - session->setCapturePlan(plan); - return saveRecordingSession(session); + plan.metadataFileName = recordingMetadataFileName(plan.baseName); + saveSession->setCapturePlan(plan); + QList cameraEvents; + for (const AcquisitionEventRecord& record : saveSession->m_manifest.events) + { + const auto frame = record.frames.constFind(sourceCameraId); + if (frame == record.frames.constEnd()) + { + continue; + } + AcquisitionEventRecord cameraRecord = record; + cameraRecord.event.cameraIds = {sourceCameraId}; + cameraRecord.frames = {{sourceCameraId, frame.value()}}; + cameraEvents.append(std::move(cameraRecord)); + } + saveSession->m_manifest.events = std::move(cameraEvents); + if (presentation) + { + filterRecordingPresentation( + *presentation, saveSession->m_manifest.layers, saveSession->m_manifest.markups); + } + else + { + saveSession->m_manifest.layers.clear(); + saveSession->m_manifest.markups.clear(); + } + return queueRecordingSessionSave(session, saveSession, sourceCameraId); } // Reads one recording frame on the serialized session IO worker diff --git a/ScopeOneCore/src/SignalSourceManager.cpp b/ScopeOneCore/src/SignalSourceManager.cpp new file mode 100644 index 0000000..cd3220f --- /dev/null +++ b/ScopeOneCore/src/SignalSourceManager.cpp @@ -0,0 +1,333 @@ +#include "internal/SignalSourceManager.h" +#include "scopeone/PluginManifest.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace scopeone::core +{ + EventCountBinner::EventCountBinner(const QString& sourceId, + const QString& quantity, + const QString& unit, + double tickPeriodSeconds, + double sampleIntervalSeconds) + : m_sourceId(sourceId) + , m_quantity(quantity) + , m_unit(unit) + , m_tickPeriodSeconds(tickPeriodSeconds) + , m_ticksPerSample(std::max( + 1, + static_cast(std::llround( + sampleIntervalSeconds / tickPeriodSeconds)))) + , m_nextSampleTick(m_ticksPerSample) + , m_sampleIntervalSeconds( + static_cast(m_ticksPerSample) * tickPeriodSeconds) + { + } + + void EventCountBinner::addEvent(quint64 tick) + { + const quint64 bin = binForTick(tick); + advanceToBin(bin); + if (bin == m_currentSample) + { + ++m_currentEventCount; + ++m_totalInputEvents; + } + } + + void EventCountBinner::addMarker(quint64 tick, quint32 code) + { + advanceToTick(tick); + m_pendingMarkers.append({static_cast(tick) * m_tickPeriodSeconds, code}); + ++m_totalMarkers; + } + + void EventCountBinner::advanceToTick(quint64 tick) + { + advanceToBin(binForTick(tick)); + } + + void EventCountBinner::advanceToElapsedSeconds(double elapsedSeconds) + { + if (elapsedSeconds > 0.0) + { + advanceToBin(static_cast(elapsedSeconds / m_sampleIntervalSeconds)); + } + } + + bool EventCountBinner::hasReadyChunks() const + { + return !m_readyChunks.isEmpty(); + } + + QList EventCountBinner::takeReadyChunks() + { + return std::exchange(m_readyChunks, QList{}); + } + + QList EventCountBinner::takeCompletedChunks() + { + finishChunk(); + return takeReadyChunks(); + } + + void EventCountBinner::appendCompletedValue(double value) + { + constexpr qsizetype kMaximumChunkSamples = 65536; + m_completedValues.append(value); + if (m_completedValues.size() >= kMaximumChunkSamples) + { + finishChunk(); + } + } + + void EventCountBinner::finishChunk() + { + if (m_completedValues.isEmpty()) + { + return; + } + TimeSeriesChunk chunk; + chunk.sourceId = m_sourceId; + chunk.quantity = m_quantity; + chunk.unit = m_unit; + chunk.startTimeSeconds = static_cast(m_firstCompletedSample) + * m_sampleIntervalSeconds; + chunk.sampleIntervalSeconds = m_sampleIntervalSeconds; + chunk.values.swap(m_completedValues); + const double chunkEndTime = static_cast( + m_firstCompletedSample + static_cast(chunk.values.size())) + * m_sampleIntervalSeconds; + int markerCount = 0; + while (markerCount < m_pendingMarkers.size() + && m_pendingMarkers[markerCount].timeSeconds < chunkEndTime) + { + ++markerCount; + } + if (markerCount > 0) + { + chunk.markers = m_pendingMarkers.mid(0, markerCount); + m_pendingMarkers.remove(0, markerCount); + } + chunk.totalInputEvents = m_totalInputEvents; + chunk.totalMarkers = m_totalMarkers; + m_firstCompletedSample += static_cast(chunk.values.size()); + m_readyChunks.append(std::move(chunk)); + } + + quint64 EventCountBinner::binForTick(quint64 tick) const + { + if (tick >= m_currentSampleTick && tick < m_nextSampleTick) + { + return m_currentSample; + } + return tick / m_ticksPerSample; + } + + void EventCountBinner::advanceToBin(quint64 targetBin) + { + if (targetBin <= m_currentSample) + { + return; + } + while (m_currentSample < targetBin) + { + appendCompletedValue(static_cast(m_currentEventCount)); + m_currentEventCount = 0; + ++m_currentSample; + } + m_currentSampleTick = m_currentSample * m_ticksPerSample; + m_nextSampleTick = m_currentSampleTick + m_ticksPerSample; + } + + SignalSource::SignalSource(QObject* parent) + : QObject(parent) + { + } + + SignalSource::~SignalSource() = default; +} + +namespace scopeone::core::internal +{ + SignalSourceManager::SignalSourceManager(QObject* parent) + : QObject(parent) + { + loadPlugins(); + } + + SignalSourceManager::~SignalSourceManager() + { + for (const QPointer& source : std::as_const(m_sources)) + { + delete source.data(); + } + m_sources.clear(); + m_loaders.clear(); + } + + QList SignalSourceManager::sources() const + { + QList result = m_descriptors.values(); + std::sort(result.begin(), result.end(), + [](const SignalSourceDescriptor& left, + const SignalSourceDescriptor& right) + { + return left.name.compare(right.name, Qt::CaseInsensitive) < 0; + }); + return result; + } + + bool SignalSourceManager::startTrace(const SignalAcquisitionConfig& config, + QString* errorMessage) + { + const QString sourceId = config.sourceId.trimmed(); + if (!m_descriptors.contains(sourceId)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Unknown signal source: %1").arg(sourceId); + } + return false; + } + SignalSource* source = sourceInstance(sourceId, errorMessage); + return source && source->start(config, errorMessage); + } + + void SignalSourceManager::stopTrace(const QString& sourceId) + { + if (SignalSource* source = m_sources.value(sourceId.trimmed())) + { + source->stop(); + } + } + + SignalSourceState SignalSourceManager::state(const QString& sourceId) const + { + const QString id = sourceId.trimmed(); + const QPointer source = m_sources.value(id); + return source ? source->state() : m_states.value(id, SignalSourceState::Idle); + } + + QString SignalSourceManager::stateMessage(const QString& sourceId) const + { + const QString id = sourceId.trimmed(); + const QPointer source = m_sources.value(id); + return source ? source->stateMessage() + : m_messages.value(id, QStringLiteral("Signal source is idle")); + } + + void SignalSourceManager::loadPlugins() + { + const QStringList directories = { + QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("plugins/hardware")), + QDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)) + .filePath(QStringLiteral("plugins/hardware"))}; + + for (const QString& path : directories) + { + const QDir directory(path); + for (const QFileInfo& file : directory.entryInfoList(QDir::Files, QDir::Name)) + { + if (!QLibrary::isLibrary(file.absoluteFilePath())) + { + continue; + } + auto loader = std::make_unique(file.absoluteFilePath()); + PluginManifest manifest; + QString manifestError; + if (!parsePluginManifest( + loader->metaData().value(QStringLiteral("MetaData")).toObject(), + PluginKind::Hardware, + manifest, + &manifestError)) + { + qWarning().noquote() + << QStringLiteral("Failed to load signal source plugin %1: %2") + .arg(file.fileName(), manifestError); + continue; + } + auto* plugin = qobject_cast(loader->instance()); + if (!plugin) + { + qWarning().noquote() + << QStringLiteral("Failed to load signal source plugin %1: %2") + .arg(file.fileName(), loader->errorString()); + continue; + } + + for (const SignalSourceDescriptor& descriptor : plugin->signalSources()) + { + const QString sourceId = descriptor.id.trimmed(); + if (sourceId.isEmpty() || m_descriptors.contains(sourceId)) + { + continue; + } + m_descriptors.insert(sourceId, descriptor); + m_plugins.insert(sourceId, plugin); + m_states.insert(sourceId, SignalSourceState::Idle); + m_messages.insert(sourceId, QStringLiteral("Signal source is idle")); + } + m_loaders.push_back(std::move(loader)); + } + } + } + + SignalSource* SignalSourceManager::sourceInstance(const QString& sourceId, + QString* errorMessage) + { + const QString id = sourceId.trimmed(); + if (SignalSource* existing = m_sources.value(id)) + { + return existing; + } + SignalSourcePlugin* plugin = m_plugins.value(id); + if (!plugin) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Unknown signal source: %1").arg(id); + } + return nullptr; + } + SignalSource* source = plugin->createSignalSource(id, this); + if (!source) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Failed to create signal source: %1").arg(id); + } + return nullptr; + } + + connect(source, &SignalSource::timeSeriesReady, + this, &SignalSourceManager::timeSeriesReady); + connect(source, &SignalSource::timestampedEventsReady, + this, &SignalSourceManager::timestampedEventsReady); + connect(source, &SignalSource::stateChanged, + this, [this, id](SignalSourceState state, const QString& message) + { + m_states.insert(id, state); + m_messages.insert(id, message); + emit sourceStateChanged(id, state, message); + }); + connect(source, &SignalSource::sourceError, + this, [this, id](const QString& message) + { + emit sourceError(id, message); + }); + m_sources.insert(id, source); + return source; + } +} diff --git a/ScopeOneCore/src/SimulatorProvider.cpp b/ScopeOneCore/src/SimulatorProvider.cpp new file mode 100644 index 0000000..cadf40e --- /dev/null +++ b/ScopeOneCore/src/SimulatorProvider.cpp @@ -0,0 +1,420 @@ +#include "scopeone/SimulatorProvider.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::core +{ + SimulatorProvider::SimulatorProvider(const QString& logicalCameraId, + int width, + int height, + const QString& providerId) + : m_providerId(providerId.trimmed().isEmpty() + ? QStringLiteral("simulator.%1").arg( + QUuid::createUuid().toString(QUuid::WithoutBraces)) + : providerId.trimmed()) + , m_cameraId(logicalCameraId.trimmed().isEmpty() + ? QStringLiteral("camera.simulator") + : logicalCameraId.trimmed()) + , m_sensorWidth((std::max)(1, width)) + , m_sensorHeight((std::max)(1, height)) + , m_roi(0, 0, m_sensorWidth, m_sensorHeight) + { + m_timer.setTimerType(Qt::PreciseTimer); + updateTimerInterval(); + connect(&m_timer, &QTimer::timeout, this, [this]() + { + FrameSink sink; + { + QMutexLocker locker(&m_mutex); + sink = m_frameSink; + } + if (sink) sink(makeFrame()); + }); + } + + HardwareProviderDescriptor SimulatorProvider::descriptor() const + { + return {m_providerId, QStringLiteral("ScopeOne Simulator"), QStringLiteral("1")}; + } + + QList SimulatorProvider::devices() const + { + HardwareDeviceDescriptor camera; + camera.logicalId = m_cameraId; + camera.providerId = m_providerId; + camera.providerDeviceId = m_cameraId; + camera.hardwareId = m_cameraId; + camera.name = QStringLiteral("Simulator Camera"); + camera.kind = HardwareDeviceKind::Camera; + camera.state = HardwareDeviceState::Initialized; + camera.endpoint = HardwareEndpointKind::InProcess; + return {camera}; + } + + void SimulatorProvider::setFrameSink(FrameSink sink) + { + QMutexLocker locker(&m_mutex); + m_frameSink = std::move(sink); + } + + void SimulatorProvider::setPreviewStateSink(PreviewStateSink sink) + { + QMutexLocker locker(&m_mutex); + m_previewStateSink = std::move(sink); + } + + bool SimulatorProvider::startPreview() + { + { + QMutexLocker locker(&m_mutex); + if (!m_frameSink) return false; + } + m_timer.start(); + PreviewStateSink sink; + { + QMutexLocker locker(&m_mutex); + sink = m_previewStateSink; + } + if (sink) sink(true); + return true; + } + + bool SimulatorProvider::stopPreview() + { + const bool wasRunning = m_timer.isActive(); + m_timer.stop(); + PreviewStateSink sink; + { + QMutexLocker locker(&m_mutex); + sink = m_previewStateSink; + } + if (wasRunning && sink) sink(false); + return true; + } + + bool SimulatorProvider::startPreviewFor(const QString& cameraId) + { + return accepts(cameraId) && startPreview(); + } + + bool SimulatorProvider::stopPreviewFor(const QString& cameraId) + { + return accepts(cameraId) && stopPreview(); + } + + bool SimulatorProvider::isPreviewRunning(const QString& cameraId) const + { + return accepts(cameraId) && m_timer.isActive(); + } + + bool SimulatorProvider::getExposure(const QString& cameraIdOrAll, double& exposureMs) const + { + if (!accepts(cameraIdOrAll)) + { + return false; + } + QMutexLocker locker(&m_mutex); + exposureMs = m_exposureMs; + return true; + } + + bool SimulatorProvider::setExposure(const QString& cameraIdOrAll, double exposureMs) + { + if (!accepts(cameraIdOrAll) || !std::isfinite(exposureMs) || exposureMs <= 0.0) + { + return false; + } + { + QMutexLocker locker(&m_mutex); + m_exposureMs = exposureMs; + } + if (QThread::currentThread() == thread()) + { + updateTimerInterval(); + } + else + { + QMetaObject::invokeMethod(this, + [this]() { updateTimerInterval(); }, + Qt::QueuedConnection); + } + return true; + } + + QStringList SimulatorProvider::listProperties(const QString& cameraId) + { + return accepts(cameraId) + ? QStringList{QStringLiteral("Exposure"), + QStringLiteral("ImageMode"), + QStringLiteral("SensorWidth"), + QStringLiteral("SensorHeight")} + : QStringList{}; + } + + QString SimulatorProvider::getProperty(const QString& cameraId, + const QString& name, + bool) + { + if (!accepts(cameraId)) + { + return {}; + } + if (name == QStringLiteral("Exposure")) + { + QMutexLocker locker(&m_mutex); + return QString::number(m_exposureMs, 'g', 12); + } + if (name == QStringLiteral("ImageMode")) + { + QMutexLocker locker(&m_mutex); + return m_imageMode == ImageMode::Hologram + ? QStringLiteral("Hologram") + : QStringLiteral("Gradient"); + } + if (name == QStringLiteral("SensorWidth")) + { + return QString::number(m_sensorWidth); + } + if (name == QStringLiteral("SensorHeight")) + { + return QString::number(m_sensorHeight); + } + return {}; + } + + bool SimulatorProvider::setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) + { + if (errorMessage) + { + errorMessage->clear(); + } + if (!accepts(cameraId)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Property is not writable"); + } + return false; + } + if (name == QStringLiteral("ImageMode")) + { + const QString mode = value.trimmed(); + if (mode != QStringLiteral("Gradient") && mode != QStringLiteral("Hologram")) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Invalid image mode"); + } + return false; + } + QMutexLocker locker(&m_mutex); + m_imageMode = mode == QStringLiteral("Hologram") + ? ImageMode::Hologram + : ImageMode::Gradient; + return true; + } + if (name == QStringLiteral("Exposure")) + { + bool ok = false; + const double exposureMs = value.toDouble(&ok); + if (ok && setExposure(cameraId, exposureMs)) + { + return true; + } + if (errorMessage) + { + *errorMessage = QStringLiteral("Invalid exposure value"); + } + } + else if (errorMessage) + { + *errorMessage = QStringLiteral("Property is not writable"); + } + return false; + } + + QString SimulatorProvider::getPropertyType(const QString& cameraId, const QString& name) + { + return accepts(cameraId) && listProperties(cameraId).contains(name) + ? (name == QStringLiteral("Exposure") + ? QStringLiteral("Float") + : name == QStringLiteral("ImageMode") + ? QStringLiteral("String") + : QStringLiteral("Integer")) + : QStringLiteral("Unknown"); + } + + bool SimulatorProvider::isPropertyReadOnly(const QString& cameraId, const QString& name) + { + return !accepts(cameraId) + || (name != QStringLiteral("Exposure") + && name != QStringLiteral("ImageMode")); + } + + bool SimulatorProvider::isPropertyPreInit(const QString&, const QString&) + { + return false; + } + + QStringList SimulatorProvider::getAllowedPropertyValues(const QString& cameraId, + const QString& name) + { + if (accepts(cameraId) && name == QStringLiteral("ImageMode")) + { + return {QStringLiteral("Gradient"), QStringLiteral("Hologram")}; + } + return {}; + } + + bool SimulatorProvider::hasPropertyLimits(const QString& cameraId, const QString& name) + { + return accepts(cameraId) && name == QStringLiteral("Exposure"); + } + + double SimulatorProvider::getPropertyLowerLimit(const QString& cameraId, const QString& name) + { + return accepts(cameraId) && name == QStringLiteral("Exposure") ? 0.1 : 0.0; + } + + double SimulatorProvider::getPropertyUpperLimit(const QString& cameraId, const QString& name) + { + return accepts(cameraId) && name == QStringLiteral("Exposure") ? 1000.0 : 0.0; + } + + bool SimulatorProvider::setROI(const QString& cameraId, + int x, + int y, + int width, + int height) + { + const QRect roi(x, y, width, height); + const QRect sensor(0, 0, m_sensorWidth, m_sensorHeight); + if (!accepts(cameraId) || width <= 0 || height <= 0 || !sensor.contains(roi)) + { + return false; + } + QMutexLocker locker(&m_mutex); + m_roi = roi; + return true; + } + + bool SimulatorProvider::clearROI(const QString& cameraId) + { + if (!accepts(cameraId)) + { + return false; + } + QMutexLocker locker(&m_mutex); + m_roi = QRect(0, 0, m_sensorWidth, m_sensorHeight); + return true; + } + + bool SimulatorProvider::getROI(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) + { + if (!accepts(cameraId)) + { + return false; + } + QMutexLocker locker(&m_mutex); + x = m_roi.x(); + y = m_roi.y(); + width = m_roi.width(); + height = m_roi.height(); + return true; + } + + bool SimulatorProvider::captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int) + { + if (!accepts(cameraId)) + { + return false; + } + frame = makeFrame(); + return frame.isValid(); + } + + bool SimulatorProvider::accepts(const QString& cameraIdOrAll) const + { + const QString target = cameraIdOrAll.trimmed(); + return target == m_cameraId + || target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0; + } + + ImageFrame SimulatorProvider::makeFrame() + { + QMutexLocker locker(&m_mutex); + ImageFrame frame; + frame.cameraId = m_cameraId; + frame.width = m_roi.width(); + frame.height = m_roi.height(); + frame.stride = frame.width; + frame.bitsPerSample = 8; + frame.pixelFormat = ImagePixelFormat::Mono8; + frame.frameIndex = ++m_frameIndex; + frame.timestampNs = static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; + frame.sourceRoiX = m_roi.x(); + frame.sourceRoiY = m_roi.y(); + frame.sourceRoiWidth = m_roi.width(); + frame.sourceRoiHeight = m_roi.height(); + frame.bytes.resize(static_cast(frame.width) * frame.height); + const bool hologram = m_imageMode == ImageMode::Hologram; + constexpr double twoPi = 6.28318530717958647692; + for (int y = 0; y < frame.height; ++y) + { + uchar* row = reinterpret_cast(frame.bytes.data()) + + static_cast(y) * frame.stride; + for (int x = 0; x < frame.width; ++x) + { + if (!hologram) + { + row[x] = static_cast((x + y + frame.frameIndex) & 0xffu); + continue; + } + + const int sensorX = m_roi.x() + x; + const int sensorY = m_roi.y() + y; + const double nx = (sensorX - 0.5 * m_sensorWidth) / m_sensorWidth; + const double ny = (sensorY - 0.5 * m_sensorHeight) / m_sensorHeight; + const double objectAmplitude = + 0.65 * std::exp(-35.0 * (nx * nx + ny * ny)) + + 0.35 * std::exp(-90.0 * ((nx - 0.18) * (nx - 0.18) + + (ny + 0.12) * (ny + 0.12))); + const double objectPhase = 18.0 * (nx * nx + ny * ny) + + 0.015 * static_cast(frame.frameIndex); + const double carrier = twoPi * (48.0 * sensorX / m_sensorWidth + + 32.0 * sensorY / m_sensorHeight); + const double intensity = 70.0 + + 55.0 * objectAmplitude * objectAmplitude + + 120.0 * objectAmplitude + * std::cos(carrier + objectPhase); + row[x] = static_cast(std::clamp(intensity, 0.0, 255.0)); + } + } + return frame; + } + + void SimulatorProvider::updateTimerInterval() + { + double exposureMs = 0.0; + { + QMutexLocker locker(&m_mutex); + exposureMs = m_exposureMs; + } + m_timer.setInterval((std::max)(1, static_cast(std::ceil(exposureMs)))); + } +} diff --git a/ScopeOneCore/src/SpatiotemporalBinningModule.cpp b/ScopeOneCore/src/SpatiotemporalBinningModule.cpp index 1cbb97a..50986f8 100644 --- a/ScopeOneCore/src/SpatiotemporalBinningModule.cpp +++ b/ScopeOneCore/src/SpatiotemporalBinningModule.cpp @@ -352,7 +352,7 @@ namespace scopeone::core::internal { if (!frame.isValid()) { - return {{}, QStringLiteral("Invalid input")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Invalid input")); } try @@ -360,7 +360,7 @@ namespace scopeone::core::internal ImageFrame workingFrame; if (!convertFrameForProcessing(frame, workingFrame, processingBitDepth)) { - return {{}, QStringLiteral("Unsupported input frame")}; + return ProcessingResult(ImageFrame{}, QStringLiteral("Unsupported input frame")); } if (!m_frameBuffer.empty() && !m_frameBuffer.front().isCompatibleWith(workingFrame)) { @@ -418,7 +418,7 @@ namespace scopeone::core::internal } catch (const std::exception& e) { - return {{}, QString("Spatiotemporal binning failed: %1").arg(e.what())}; + return ProcessingResult(ImageFrame{}, QString("Spatiotemporal binning failed: %1").arg(e.what())); } } diff --git a/ScopeOneCore/src/ToolFrameStream.cpp b/ScopeOneCore/src/ToolFrameStream.cpp new file mode 100644 index 0000000..99c1829 --- /dev/null +++ b/ScopeOneCore/src/ToolFrameStream.cpp @@ -0,0 +1,65 @@ +#include "scopeone/ToolFrameStream.h" +#include "scopeone/ScopeOneCore.h" + +namespace scopeone::ui +{ + ScopeOneToolFrameStream::ScopeOneToolFrameStream(scopeone::core::ScopeOneCore& core, + QObject* parent) + : QObject(parent), m_core(core) + { + connect(&m_core, &scopeone::core::ScopeOneCore::previewRawFrameReady, + this, &ScopeOneToolFrameStream::acceptFrame); + } + + void ScopeOneToolFrameStream::setSourceId(const QString& cameraId) + { + m_sourceId = cameraId.trimmed(); + } + + void ScopeOneToolFrameStream::setEnabled(bool enabled) + { + m_enabled = enabled; + if (!m_enabled) + { + clearPendingFrame(); + } + } + + void ScopeOneToolFrameStream::setProcessing(bool processing) + { + if (m_processing == processing) + { + return; + } + m_processing = processing; + if (!m_processing && m_pendingFrame.isValid()) + { + const auto frame = m_pendingFrame; + m_pendingFrame = {}; + m_processing = true; + emit frameReady(frame); + } + } + + void ScopeOneToolFrameStream::clearPendingFrame() + { + m_pendingFrame = {}; + } + + void ScopeOneToolFrameStream::acceptFrame(const scopeone::core::ImageFrame& frame) + { + if (!m_enabled || !frame.isValid() + || (!m_sourceId.isEmpty() && frame.cameraId != m_sourceId)) + { + return; + } + if (m_processing) + { + m_pendingFrame = frame; + return; + } + m_processing = true; + emit frameReady(frame); + } + +} diff --git a/ScopeOneCore/src/ToolTask.cpp b/ScopeOneCore/src/ToolTask.cpp new file mode 100644 index 0000000..30085f7 --- /dev/null +++ b/ScopeOneCore/src/ToolTask.cpp @@ -0,0 +1,83 @@ +#include "scopeone/ToolTask.h" + +#include +#include +#include +#include + +namespace scopeone::ui +{ + struct ScopeOneToolTask::Impl + { + Work work; + std::atomic_bool cancelRequested{false}; + bool running{false}; + QString error; + QFutureWatcher watcher; + }; + + ScopeOneToolTask::ScopeOneToolTask(Work work, QObject* parent) + : QObject(parent), m_impl(std::make_unique()) + { + m_impl->work = std::move(work); + connect(&m_impl->watcher, &QFutureWatcher::finished, this, [this]() + { + m_impl->running = false; + if (!m_impl->error.isEmpty()) + { + emit failed(m_impl->error); + return; + } + if (m_impl->cancelRequested) + { + emit canceled(); + } + else + { + emit finished(); + } + }); + } + + ScopeOneToolTask::~ScopeOneToolTask() + { + cancel(); + m_impl->watcher.waitForFinished(); + } + + void ScopeOneToolTask::start() + { + if (!m_impl->work || m_impl->running) + { + return; + } + m_impl->running = true; + m_impl->cancelRequested = false; + m_impl->error.clear(); + const Work work = m_impl->work; + m_impl->watcher.setFuture(QtConcurrent::run([this, work]() + { + try + { + work(m_impl->cancelRequested, [this](int percent) + { + emit progressChanged(qBound(0, percent, 100)); + }); + } + catch (const std::exception& exception) + { + m_impl->error = QString::fromLocal8Bit(exception.what()); + } + catch (...) + { + m_impl->error = QStringLiteral("Tool task failed"); + } + })); + } + + void ScopeOneToolTask::cancel() + { + m_impl->cancelRequested = true; + } + +} diff --git a/ScopeOneCuda/CMakeLists.txt b/ScopeOneCuda/CMakeLists.txt new file mode 100644 index 0000000..434bd2b --- /dev/null +++ b/ScopeOneCuda/CMakeLists.txt @@ -0,0 +1,96 @@ +cmake_minimum_required(VERSION 3.23) + +include(GNUInstallDirs) +include(CMakePackageConfigHelpers) + +set(SCOPEONE_CUDA_VERSION 1.0.0) + +add_library(ScopeOneCuda SHARED + src/CudaKernels.cu + src/CudaDevice.cpp + src/CudaFrequencyFilterModule.cpp + src/CudaFrequencyFilterModule.h + src/CudaGaussianBlurModule.cpp + src/CudaGaussianBlurModule.h + src/CudaProcessingRegistration.cpp + src/CudaRealImageModule.cpp + src/GpuComplexFrame.cpp + src/GpuRealFrame.cpp + include/scopeone/cuda/CudaDevice.h + include/scopeone/cuda/CudaRealImageModule.h + include/scopeone/cuda/CudaKernelLaunch.h + include/scopeone/cuda/GpuComplexFrame.h + include/scopeone/cuda/CudaExport.h + include/scopeone/cuda/GpuRealFrame.h +) +add_library(scopeone::Cuda ALIAS ScopeOneCuda) + +set(SCOPEONE_CUDA_ARCHITECTURES "50;75;80;86;89" CACHE STRING + "Target CUDA architectures") +set_target_properties(ScopeOneCuda PROPERTIES + EXPORT_NAME Cuda + CUDA_SEPARABLE_COMPILATION OFF + CUDA_ARCHITECTURES "${SCOPEONE_CUDA_ARCHITECTURES}" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}" +) + +target_compile_features(ScopeOneCuda PUBLIC cxx_std_20) +target_compile_definitions(ScopeOneCuda PRIVATE SCOPEONE_CUDA_EXPORTS) +target_include_directories(ScopeOneCuda + PUBLIC + $ + $ +) +target_link_libraries(ScopeOneCuda + PUBLIC + scopeone::ScopeOneCore + CUDA::cudart + CUDA::cufft +) + +file(GLOB SCOPEONE_CUDA_RUNTIME_DLLS + "${CUDAToolkit_BIN_DIR}/cudart64_*.dll" + "${CUDAToolkit_BIN_DIR}/cufft64_*.dll" +) +if (WIN32) + foreach (_dll IN LISTS SCOPEONE_CUDA_RUNTIME_DLLS) + add_custom_command(TARGET ScopeOneCuda POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_dll}" + "$" + ) + endforeach () +endif () + +install(TARGETS ScopeOneCuda + EXPORT ScopeOneCudaTargets + RUNTIME DESTINATION "." + LIBRARY DESTINATION "." + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" +) +install(DIRECTORY include/ DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") +if (SCOPEONE_CUDA_RUNTIME_DLLS) + install(FILES ${SCOPEONE_CUDA_RUNTIME_DLLS} DESTINATION ".") +endif () +install(EXPORT ScopeOneCudaTargets + FILE ScopeOneCudaTargets.cmake + NAMESPACE scopeone:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/ScopeOneCuda" +) + +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/ScopeOneCudaConfig.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/ScopeOneCudaConfig.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/ScopeOneCuda" +) +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/ScopeOneCudaConfigVersion.cmake" + VERSION ${SCOPEONE_CUDA_VERSION} + COMPATIBILITY SameMajorVersion +) +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/ScopeOneCudaConfig.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/ScopeOneCudaConfigVersion.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/ScopeOneCuda" +) diff --git a/ScopeOneCuda/cmake/ScopeOneCudaConfig.cmake.in b/ScopeOneCuda/cmake/ScopeOneCudaConfig.cmake.in new file mode 100644 index 0000000..dae2453 --- /dev/null +++ b/ScopeOneCuda/cmake/ScopeOneCudaConfig.cmake.in @@ -0,0 +1,7 @@ +@PACKAGE_INIT@ + +include(CMakeFindDependencyMacro) +find_dependency(ScopeOneCore CONFIG REQUIRED) +find_dependency(CUDAToolkit REQUIRED) + +include("${CMAKE_CURRENT_LIST_DIR}/ScopeOneCudaTargets.cmake") diff --git a/ScopeOneCuda/include/scopeone/cuda/CudaDevice.h b/ScopeOneCuda/include/scopeone/cuda/CudaDevice.h new file mode 100644 index 0000000..932f827 --- /dev/null +++ b/ScopeOneCuda/include/scopeone/cuda/CudaDevice.h @@ -0,0 +1,12 @@ +#pragma once + +#include "scopeone/cuda/CudaExport.h" + +#include + +namespace scopeone::cuda +{ + SCOPEONE_CUDA_EXPORT bool isCudaDeviceAvailable(); + SCOPEONE_CUDA_EXPORT int cudaDeviceCount(); + SCOPEONE_CUDA_EXPORT QString cudaDeviceName(int deviceIndex = 0); +} diff --git a/ScopeOneCuda/include/scopeone/cuda/CudaExport.h b/ScopeOneCuda/include/scopeone/cuda/CudaExport.h new file mode 100644 index 0000000..68baad4 --- /dev/null +++ b/ScopeOneCuda/include/scopeone/cuda/CudaExport.h @@ -0,0 +1,11 @@ +#pragma once + +#if defined(_WIN32) +# if defined(SCOPEONE_CUDA_EXPORTS) +# define SCOPEONE_CUDA_EXPORT __declspec(dllexport) +# else +# define SCOPEONE_CUDA_EXPORT __declspec(dllimport) +# endif +#else +# define SCOPEONE_CUDA_EXPORT __attribute__((visibility("default"))) +#endif diff --git a/ScopeOneCuda/include/scopeone/cuda/CudaKernelLaunch.h b/ScopeOneCuda/include/scopeone/cuda/CudaKernelLaunch.h new file mode 100644 index 0000000..069aae9 --- /dev/null +++ b/ScopeOneCuda/include/scopeone/cuda/CudaKernelLaunch.h @@ -0,0 +1,47 @@ +#pragma once + +#include "scopeone/cuda/CudaExport.h" + +#include + +namespace scopeone::cuda::detail +{ + SCOPEONE_CUDA_EXPORT bool convertToFloat(const void* source, + int sourceStride, + int width, + int height, + int sourceBytesPerPixel, + void* destination, + std::size_t destinationPitchBytes); + + SCOPEONE_CUDA_EXPORT bool convertFromFloat(const void* source, + std::size_t sourcePitchBytes, + void* destination, + int destinationStride, + int width, + int height, + int destinationBytesPerPixel); + + SCOPEONE_CUDA_EXPORT bool launchGaussian(const void* input, + std::size_t inputPitchBytes, + void* output, + std::size_t outputPitchBytes, + int width, + int height, + int kernelSize, + float sigma); + + SCOPEONE_CUDA_EXPORT bool launchFrequencyFilter(const void* input, + void* output, + void* spectrum, + int width, + int height, + float minFeatureSize, + float maxFeatureSize, + int filterKind, + int outputMode, + int forwardPlan, + int inversePlan, + void* minMaxScratch, + float maxValue); +} diff --git a/ScopeOneCuda/include/scopeone/cuda/CudaRealImageModule.h b/ScopeOneCuda/include/scopeone/cuda/CudaRealImageModule.h new file mode 100644 index 0000000..96840e2 --- /dev/null +++ b/ScopeOneCuda/include/scopeone/cuda/CudaRealImageModule.h @@ -0,0 +1,29 @@ +#pragma once + +#include "scopeone/ProcessingPlugin.h" +#include "scopeone/cuda/CudaExport.h" +#include "scopeone/cuda/GpuRealFrame.h" + +namespace scopeone::cuda +{ + class SCOPEONE_CUDA_EXPORT CudaRealImageModule + : public scopeone::core::ProcessingModule + { + public: + explicit CudaRealImageModule(GpuMemoryLayout layout = GpuMemoryLayout::Pitched2D); + ~CudaRealImageModule() override; + + scopeone::core::ProcessingResult process( + const scopeone::core::ImageFrame& frame, + int processingBitDepth) final override; + + protected: + virtual bool processDevice(const GpuRealFrame& input, + GpuRealFrame& output, + int bitDepth) = 0; + + GpuRealFrame m_inputBuffer; + GpuRealFrame m_outputBuffer; + GpuMemoryLayout m_layout; + }; +} diff --git a/ScopeOneCuda/include/scopeone/cuda/GpuComplexFrame.h b/ScopeOneCuda/include/scopeone/cuda/GpuComplexFrame.h new file mode 100644 index 0000000..fc1c04f --- /dev/null +++ b/ScopeOneCuda/include/scopeone/cuda/GpuComplexFrame.h @@ -0,0 +1,36 @@ +#pragma once + +#include "scopeone/cuda/CudaExport.h" + +#include + +namespace scopeone::cuda +{ + class SCOPEONE_CUDA_EXPORT GpuComplexFrame + { + public: + GpuComplexFrame() = default; + ~GpuComplexFrame(); + + GpuComplexFrame(const GpuComplexFrame&) = delete; + GpuComplexFrame& operator=(const GpuComplexFrame&) = delete; + GpuComplexFrame(GpuComplexFrame&& other) noexcept; + GpuComplexFrame& operator=(GpuComplexFrame&& other) noexcept; + + bool allocateForRealImage(int width, int height); + void release(); + bool isValid() const; + + void* data() const; + int realWidth() const; + int spectrumWidth() const; + int height() const; + std::size_t elementCount() const; + + private: + void* m_data{nullptr}; + int m_realWidth{0}; + int m_spectrumWidth{0}; + int m_height{0}; + }; +} diff --git a/ScopeOneCuda/include/scopeone/cuda/GpuRealFrame.h b/ScopeOneCuda/include/scopeone/cuda/GpuRealFrame.h new file mode 100644 index 0000000..fefc115 --- /dev/null +++ b/ScopeOneCuda/include/scopeone/cuda/GpuRealFrame.h @@ -0,0 +1,55 @@ +#pragma once + +#include "scopeone/ImageFrame.h" +#include "scopeone/cuda/CudaExport.h" + +#include + +namespace scopeone::cuda +{ + enum class GpuMemoryLayout + { + Pitched2D, + Contiguous + }; + + class SCOPEONE_CUDA_EXPORT GpuRealFrame + { + public: + GpuRealFrame() = default; + GpuRealFrame(int width, int height, GpuMemoryLayout layout); + ~GpuRealFrame(); + + GpuRealFrame(const GpuRealFrame&) = delete; + GpuRealFrame& operator=(const GpuRealFrame&) = delete; + GpuRealFrame(GpuRealFrame&& other) noexcept; + GpuRealFrame& operator=(GpuRealFrame&& other) noexcept; + + bool allocate(int width, int height, GpuMemoryLayout layout); + void release(); + bool isValid() const; + + void* data() const; + std::size_t pitchBytes() const; + int width() const; + int height() const; + GpuMemoryLayout layout() const; + + bool upload(const scopeone::core::ImageFrame& frame); + bool download(scopeone::core::ImageFrame& frame) const; + + private: + bool ensureUploadBuffer(std::size_t bytes); + bool ensureDownloadBuffer(std::size_t bytes) const; + + float* m_data{nullptr}; + std::size_t m_pitchBytes{0}; + int m_width{0}; + int m_height{0}; + GpuMemoryLayout m_layout{GpuMemoryLayout::Pitched2D}; + unsigned char* m_uploadBuffer{nullptr}; + std::size_t m_uploadCapacity{0}; + mutable unsigned char* m_downloadBuffer{nullptr}; + mutable std::size_t m_downloadCapacity{0}; + }; +} diff --git a/ScopeOneCuda/src/CudaDevice.cpp b/ScopeOneCuda/src/CudaDevice.cpp new file mode 100644 index 0000000..a8874a3 --- /dev/null +++ b/ScopeOneCuda/src/CudaDevice.cpp @@ -0,0 +1,25 @@ +#include "scopeone/cuda/CudaDevice.h" + +#include + +namespace scopeone::cuda +{ + bool isCudaDeviceAvailable() + { + return cudaDeviceCount() > 0; + } + + int cudaDeviceCount() + { + int count = 0; + return cudaGetDeviceCount(&count) == cudaSuccess ? count : 0; + } + + QString cudaDeviceName(int deviceIndex) + { + cudaDeviceProp properties{}; + return cudaGetDeviceProperties(&properties, deviceIndex) == cudaSuccess + ? QString::fromUtf8(properties.name) + : QString(); + } +} diff --git a/ScopeOneCuda/src/CudaFrequencyFilterModule.cpp b/ScopeOneCuda/src/CudaFrequencyFilterModule.cpp new file mode 100644 index 0000000..585995a --- /dev/null +++ b/ScopeOneCuda/src/CudaFrequencyFilterModule.cpp @@ -0,0 +1,146 @@ +#include "CudaFrequencyFilterModule.h" + +#include "scopeone/cuda/CudaKernelLaunch.h" + +#include +#include + +namespace scopeone::cuda_plugin +{ + CudaFrequencyFilterModule::CudaFrequencyFilterModule() + : CudaRealImageModule(scopeone::cuda::GpuMemoryLayout::Contiguous) + { + } + + CudaFrequencyFilterModule::~CudaFrequencyFilterModule() + { + destroyPlans(); + releaseScratch(); + } + + QString CudaFrequencyFilterModule::id() const + { + return QStringLiteral("cuda.frequency_filter"); + } + + QString CudaFrequencyFilterModule::name() const + { + return QStringLiteral("CUDA Frequency Filter"); + } + + QVariantMap CudaFrequencyFilterModule::parameters() const + { + return {{QStringLiteral("min_feature_size"), m_minFeatureSize}, + {QStringLiteral("max_feature_size"), m_maxFeatureSize}, + {QStringLiteral("filter_kind"), m_filterKind}, + {QStringLiteral("output_mode"), m_outputMode}}; + } + + void CudaFrequencyFilterModule::setParameters(const QVariantMap& parameters) + { + if (parameters.contains(QStringLiteral("min_feature_size"))) + { + m_minFeatureSize = qMax(0.0f, + parameters.value(QStringLiteral("min_feature_size")).toFloat()); + } + if (parameters.contains(QStringLiteral("max_feature_size"))) + { + m_maxFeatureSize = qMax(0.0f, + parameters.value(QStringLiteral("max_feature_size")).toFloat()); + } + if (m_minFeatureSize > m_maxFeatureSize) + { + std::swap(m_minFeatureSize, m_maxFeatureSize); + } + m_filterKind = qBound(0, parameters.value(QStringLiteral("filter_kind"), m_filterKind).toInt(), 1); + m_outputMode = qBound(0, parameters.value(QStringLiteral("output_mode"), m_outputMode).toInt(), 2); + } + + std::unique_ptr + CudaFrequencyFilterModule::createRuntime() const + { + auto runtime = std::make_unique(); + runtime->setParameters(parameters()); + return runtime; + } + + bool CudaFrequencyFilterModule::resetState() + { + destroyPlans(); + m_spectrum.release(); + releaseScratch(); + return true; + } + + void CudaFrequencyFilterModule::destroyPlans() + { + if (m_forwardPlan != 0) + { + cufftDestroy(m_forwardPlan); + m_forwardPlan = 0; + } + if (m_inversePlan != 0) + { + cufftDestroy(m_inversePlan); + m_inversePlan = 0; + } + m_planWidth = 0; + m_planHeight = 0; + } + + void CudaFrequencyFilterModule::releaseScratch() + { + if (m_minMaxScratch) + { + cudaFree(m_minMaxScratch); + m_minMaxScratch = nullptr; + } + } + + bool CudaFrequencyFilterModule::processDevice( + const scopeone::cuda::GpuRealFrame& input, + scopeone::cuda::GpuRealFrame& output, + int bitDepth) + { + if (m_planWidth != input.width() || m_planHeight != input.height()) + { + destroyPlans(); + if (!m_spectrum.allocateForRealImage(input.width(), input.height()) + || cufftPlan2d(&m_forwardPlan, + input.height(), + input.width(), + CUFFT_R2C) != CUFFT_SUCCESS) + { + return false; + } + m_planWidth = input.width(); + m_planHeight = input.height(); + } + if (m_outputMode == 2 && m_inversePlan == 0 + && cufftPlan2d(&m_inversePlan, + input.height(), + input.width(), + CUFFT_C2R) != CUFFT_SUCCESS) + { + return false; + } + if (!m_minMaxScratch + && cudaMalloc(&m_minMaxScratch, 2 * sizeof(unsigned int)) != cudaSuccess) + { + return false; + } + return scopeone::cuda::detail::launchFrequencyFilter(input.data(), + output.data(), + m_spectrum.data(), + input.width(), + input.height(), + m_minFeatureSize, + m_maxFeatureSize, + m_filterKind, + m_outputMode, + m_forwardPlan, + m_inversePlan, + m_minMaxScratch, + bitDepth >= 16 ? 65535.0f : 255.0f); + } +} diff --git a/ScopeOneCuda/src/CudaFrequencyFilterModule.h b/ScopeOneCuda/src/CudaFrequencyFilterModule.h new file mode 100644 index 0000000..a04c419 --- /dev/null +++ b/ScopeOneCuda/src/CudaFrequencyFilterModule.h @@ -0,0 +1,43 @@ +#pragma once + +#include "scopeone/cuda/CudaRealImageModule.h" +#include "scopeone/cuda/GpuComplexFrame.h" + +#include + +namespace scopeone::cuda_plugin +{ + class CudaFrequencyFilterModule final : public scopeone::cuda::CudaRealImageModule + { + public: + CudaFrequencyFilterModule(); + ~CudaFrequencyFilterModule() override; + + QString id() const override; + QString name() const override; + QVariantMap parameters() const override; + void setParameters(const QVariantMap& parameters) override; + std::unique_ptr createRuntime() const override; + bool resetState() override; + + protected: + bool processDevice(const scopeone::cuda::GpuRealFrame& input, + scopeone::cuda::GpuRealFrame& output, + int bitDepth) override; + + private: + void destroyPlans(); + void releaseScratch(); + + scopeone::cuda::GpuComplexFrame m_spectrum; + cufftHandle m_forwardPlan{0}; + cufftHandle m_inversePlan{0}; + int m_planWidth{0}; + int m_planHeight{0}; + void* m_minMaxScratch{nullptr}; + float m_minFeatureSize{2.0f}; + float m_maxFeatureSize{10.0f}; + int m_filterKind{0}; + int m_outputMode{2}; + }; +} diff --git a/ScopeOneCuda/src/CudaGaussianBlurModule.cpp b/ScopeOneCuda/src/CudaGaussianBlurModule.cpp new file mode 100644 index 0000000..f6dd972 --- /dev/null +++ b/ScopeOneCuda/src/CudaGaussianBlurModule.cpp @@ -0,0 +1,66 @@ +#include "CudaGaussianBlurModule.h" + +#include "scopeone/cuda/CudaKernelLaunch.h" + +namespace scopeone::cuda_plugin +{ + CudaGaussianBlurModule::CudaGaussianBlurModule() + : CudaRealImageModule(scopeone::cuda::GpuMemoryLayout::Pitched2D) + { + } + + QString CudaGaussianBlurModule::id() const + { + return QStringLiteral("cuda.gaussian_blur"); + } + + QString CudaGaussianBlurModule::name() const + { + return QStringLiteral("CUDA Gaussian Blur"); + } + + QVariantMap CudaGaussianBlurModule::parameters() const + { + return {{QStringLiteral("kernel_size"), m_kernelSize}, + {QStringLiteral("sigma"), m_sigma}}; + } + + void CudaGaussianBlurModule::setParameters(const QVariantMap& parameters) + { + if (parameters.contains(QStringLiteral("kernel_size"))) + { + m_kernelSize = qMax(1, parameters.value(QStringLiteral("kernel_size")).toInt()); + if ((m_kernelSize % 2) == 0) + { + ++m_kernelSize; + } + } + if (parameters.contains(QStringLiteral("sigma"))) + { + m_sigma = qMax(0.0f, parameters.value(QStringLiteral("sigma")).toFloat()); + } + } + + std::unique_ptr + CudaGaussianBlurModule::createRuntime() const + { + auto runtime = std::make_unique(); + runtime->setParameters(parameters()); + return runtime; + } + + bool CudaGaussianBlurModule::processDevice( + const scopeone::cuda::GpuRealFrame& input, + scopeone::cuda::GpuRealFrame& output, + int) + { + return scopeone::cuda::detail::launchGaussian(input.data(), + input.pitchBytes(), + output.data(), + output.pitchBytes(), + input.width(), + input.height(), + m_kernelSize, + m_sigma); + } +} diff --git a/ScopeOneCuda/src/CudaGaussianBlurModule.h b/ScopeOneCuda/src/CudaGaussianBlurModule.h new file mode 100644 index 0000000..d016df0 --- /dev/null +++ b/ScopeOneCuda/src/CudaGaussianBlurModule.h @@ -0,0 +1,27 @@ +#pragma once + +#include "scopeone/cuda/CudaRealImageModule.h" + +namespace scopeone::cuda_plugin +{ + class CudaGaussianBlurModule final : public scopeone::cuda::CudaRealImageModule + { + public: + CudaGaussianBlurModule(); + + QString id() const override; + QString name() const override; + QVariantMap parameters() const override; + void setParameters(const QVariantMap& parameters) override; + std::unique_ptr createRuntime() const override; + + protected: + bool processDevice(const scopeone::cuda::GpuRealFrame& input, + scopeone::cuda::GpuRealFrame& output, + int bitDepth) override; + + private: + int m_kernelSize{3}; + float m_sigma{0.0f}; + }; +} diff --git a/ScopeOneCuda/src/CudaKernels.cu b/ScopeOneCuda/src/CudaKernels.cu new file mode 100644 index 0000000..7709208 --- /dev/null +++ b/ScopeOneCuda/src/CudaKernels.cu @@ -0,0 +1,433 @@ +#include "scopeone/cuda/CudaKernelLaunch.h" + +#include +#include + +#include +#include +#include + +namespace +{ + __global__ void convertToFloatKernel(const unsigned char* source, + int sourceStride, + int width, + int height, + int sourceBytesPerPixel, + float* destination, + std::size_t destinationPitchBytes) + { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= width || y >= height) + { + return; + } + + const unsigned char* sourceRow = source + static_cast(y) * sourceStride; + float* destinationRow = reinterpret_cast( + reinterpret_cast(destination) + + static_cast(y) * destinationPitchBytes); + if (sourceBytesPerPixel == 1) + { + destinationRow[x] = static_cast(sourceRow[x]); + } + else + { + destinationRow[x] = static_cast( + reinterpret_cast(sourceRow)[x]); + } + } + + __global__ void convertFromFloatKernel(const float* source, + std::size_t sourcePitchBytes, + unsigned char* destination, + int destinationStride, + int width, + int height, + int destinationBytesPerPixel) + { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= width || y >= height) + { + return; + } + + const float* sourceRow = reinterpret_cast( + reinterpret_cast(source) + + static_cast(y) * sourcePitchBytes); + unsigned char* destinationRow = destination + + static_cast(y) * destinationStride; + const float value = fminf(fmaxf(sourceRow[x], 0.0f), 65535.0f); + if (destinationBytesPerPixel == 1) + { + destinationRow[x] = static_cast(fminf(value, 255.0f) + 0.5f); + } + else + { + reinterpret_cast(destinationRow)[x] = + static_cast(value + 0.5f); + } + } + + __device__ int reflect101(int value, int size) + { + while (value < 0 || value >= size) + { + value = value < 0 ? -value : 2 * size - value - 2; + } + return value; + } + + __global__ void gaussianKernel(const float* input, + std::size_t inputPitchBytes, + float* output, + std::size_t outputPitchBytes, + int width, + int height, + int radius, + float sigma) + { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= width || y >= height) + { + return; + } + + float sum = 0.0f; + float weightSum = 0.0f; + for (int dy = -radius; dy <= radius; ++dy) + { + const int sampleY = reflect101(y + dy, height); + const float* inputRow = reinterpret_cast( + reinterpret_cast(input) + + static_cast(sampleY) * inputPitchBytes); + for (int dx = -radius; dx <= radius; ++dx) + { + const int sampleX = reflect101(x + dx, width); + const float distanceSquared = static_cast(dx * dx + dy * dy); + const float weight = expf(-distanceSquared / (2.0f * sigma * sigma)); + sum += inputRow[sampleX] * weight; + weightSum += weight; + } + } + + float* outputRow = reinterpret_cast( + reinterpret_cast(output) + + static_cast(y) * outputPitchBytes); + outputRow[x] = sum / weightSum; + } + + __global__ void frequencyMaskKernel(float2* spectrum, + int spectrumWidth, + int height, + int realWidth, + float minFeatureSize, + float maxFeatureSize, + int filterKind) + { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= spectrumWidth || y >= height) + { + return; + } + + constexpr float twoPi = 6.28318530717958647692f; + const float fx = twoPi * static_cast(x) / static_cast(realWidth); + const float fy = static_cast(y <= height / 2 ? y : y - height) + * twoPi / static_cast(height); + const float radiusSquared = fx * fx + fy * fy; + const float mask = filterKind == 1 + ? (radiusSquared * maxFeatureSize * maxFeatureSize > 1.0f + && radiusSquared * minFeatureSize * minFeatureSize < 1.0f + ? 1.0f + : 0.0f) + : expf(-radiusSquared * minFeatureSize * minFeatureSize * 0.5f) + - expf(-radiusSquared * maxFeatureSize * maxFeatureSize * 0.5f); + spectrum[y * spectrumWidth + x].x *= mask; + spectrum[y * spectrumWidth + x].y *= mask; + } + + __global__ void renderSpectrumKernel(const float2* spectrum, + int spectrumWidth, + int width, + int height, + float* output) + { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= width || y >= height) + { + return; + } + const int sourceX = (x + width / 2) % width; + const int sourceY = (y + height / 2) % height; + float2 value; + if (sourceX <= width / 2) + { + value = spectrum[sourceY * spectrumWidth + sourceX]; + } + else + { + const float2 conjugate = spectrum[((height - sourceY) % height) * spectrumWidth + + width - sourceX]; + value = make_float2(conjugate.x, -conjugate.y); + } + output[y * width + x] = log1pf(hypotf(value.x, value.y)); + } + + __global__ void scaleKernel(float* output, int count, float scale) + { + const int index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) + { + output[index] *= scale; + } + } + + __device__ unsigned int orderedFloat(float value) + { + const unsigned int bits = __float_as_uint(value); + return (bits & 0x80000000U) == 0 ? bits | 0x80000000U : ~bits; + } + + __device__ float floatFromOrdered(unsigned int value) + { + const unsigned int bits = (value & 0x80000000U) == 0 ? ~value : value & ~0x80000000U; + return __uint_as_float(bits); + } + + __global__ void reduceMinMaxKernel(const float* input, int count, unsigned int* limits) + { + __shared__ unsigned int blockMinimum; + __shared__ unsigned int blockMaximum; + if (threadIdx.x == 0) + { + blockMinimum = 0xFF800000U; + blockMaximum = 0x007FFFFFU; + } + __syncthreads(); + const int index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) + { + const unsigned int value = orderedFloat(input[index]); + atomicMin(&blockMinimum, value); + atomicMax(&blockMaximum, value); + } + __syncthreads(); + if (threadIdx.x == 0) + { + atomicMin(&limits[0], blockMinimum); + atomicMax(&limits[1], blockMaximum); + } + } + + __global__ void minMaxNormalizeKernel(float* output, + int count, + const unsigned int* limits, + float maxValue) + { + const int index = blockIdx.x * blockDim.x + threadIdx.x; + if (index < count) + { + const float minimum = floatFromOrdered(limits[0]); + const float maximum = floatFromOrdered(limits[1]); + output[index] = maximum == minimum + ? 0.0f + : (output[index] - minimum) * maxValue / (maximum - minimum); + } + } + + bool synchronizeKernel() + { + return cudaGetLastError() == cudaSuccess && cudaDeviceSynchronize() == cudaSuccess; + } + + bool normalizeMinMax(float* output, int count, void* scratch, float maxValue) + { + const unsigned int limits[] = {0xFF800000U, 0x007FFFFFU}; + if (cudaMemcpy(scratch, limits, sizeof(limits), cudaMemcpyHostToDevice) != cudaSuccess) + { + return false; + } + constexpr int blockSize = 256; + reduceMinMaxKernel<<<(count + blockSize - 1) / blockSize, blockSize>>>( + output, + count, + static_cast(scratch)); + minMaxNormalizeKernel<<<(count + blockSize - 1) / blockSize, blockSize>>>( + output, + count, + static_cast(scratch), + maxValue); + return synchronizeKernel(); + } +} + +namespace scopeone::cuda::detail +{ + bool convertToFloat(const void* source, + int sourceStride, + int width, + int height, + int sourceBytesPerPixel, + void* destination, + std::size_t destinationPitchBytes) + { + const dim3 block(16, 16); + const dim3 grid((width + block.x - 1) / block.x, + (height + block.y - 1) / block.y); + convertToFloatKernel<<>>( + static_cast(source), + sourceStride, + width, + height, + sourceBytesPerPixel, + static_cast(destination), + destinationPitchBytes); + return synchronizeKernel(); + } + + bool convertFromFloat(const void* source, + std::size_t sourcePitchBytes, + void* destination, + int destinationStride, + int width, + int height, + int destinationBytesPerPixel) + { + const dim3 block(16, 16); + const dim3 grid((width + block.x - 1) / block.x, + (height + block.y - 1) / block.y); + convertFromFloatKernel<<>>( + static_cast(source), + sourcePitchBytes, + static_cast(destination), + destinationStride, + width, + height, + destinationBytesPerPixel); + return synchronizeKernel(); + } + + bool launchGaussian(const void* input, + std::size_t inputPitchBytes, + void* output, + std::size_t outputPitchBytes, + int width, + int height, + int kernelSize, + float sigma) + { + kernelSize = std::max(1, kernelSize); + if ((kernelSize % 2) == 0) + { + ++kernelSize; + } + const float effectiveSigma = sigma > 0.0f + ? sigma + : 0.3f * ((kernelSize - 1) * 0.5f - 1.0f) + 0.8f; + const int radius = kernelSize / 2; + const dim3 block(16, 16); + const dim3 grid((width + block.x - 1) / block.x, + (height + block.y - 1) / block.y); + gaussianKernel<<>>( + static_cast(input), + inputPitchBytes, + static_cast(output), + outputPitchBytes, + width, + height, + radius, + effectiveSigma); + return synchronizeKernel(); + } + + bool launchFrequencyFilter(const void* input, + void* output, + void* spectrum, + int width, + int height, + float minFeatureSize, + float maxFeatureSize, + int filterKind, + int outputMode, + int forwardPlan, + int inversePlan, + void* minMaxScratch, + float maxValue) + { + if (cufftExecR2C(forwardPlan, + static_cast(const_cast(input)), + static_cast(spectrum)) != CUFFT_SUCCESS) + { + return false; + } + + const dim3 block(16, 16); + const dim3 imageGrid((width + block.x - 1) / block.x, + (height + block.y - 1) / block.y); + const int spectrumWidth = width / 2 + 1; + const dim3 grid((spectrumWidth + block.x - 1) / block.x, + (height + block.y - 1) / block.y); + if (outputMode == 0) + { + renderSpectrumKernel<<>>(static_cast(spectrum), + spectrumWidth, + width, + height, + static_cast(output)); + return synchronizeKernel() + && normalizeMinMax(static_cast(output), + width * height, + minMaxScratch, + maxValue); + } + frequencyMaskKernel<<>>( + static_cast(spectrum), + spectrumWidth, + height, + width, + minFeatureSize, + maxFeatureSize, + filterKind); + if (!synchronizeKernel()) + { + return false; + } + + if (outputMode == 1) + { + renderSpectrumKernel<<>>(static_cast(spectrum), + spectrumWidth, + width, + height, + static_cast(output)); + return synchronizeKernel() + && normalizeMinMax(static_cast(output), + width * height, + minMaxScratch, + maxValue); + } + + if (cufftExecC2R(inversePlan, + static_cast(spectrum), + static_cast(output)) != CUFFT_SUCCESS) + { + return false; + } + constexpr int blockSize = 256; + scaleKernel<<<(width * height + blockSize - 1) / blockSize, blockSize>>>( + static_cast(output), + width * height, + 1.0f / static_cast(width * height)); + return synchronizeKernel() + && normalizeMinMax(static_cast(output), + width * height, + minMaxScratch, + maxValue); + } +} diff --git a/ScopeOneCuda/src/CudaProcessingRegistration.cpp b/ScopeOneCuda/src/CudaProcessingRegistration.cpp new file mode 100644 index 0000000..1c4a242 --- /dev/null +++ b/ScopeOneCuda/src/CudaProcessingRegistration.cpp @@ -0,0 +1,78 @@ +#include "CudaFrequencyFilterModule.h" +#include "CudaGaussianBlurModule.h" + +#include "scopeone/ScopeOneCore.h" + +extern "C" SCOPEONE_CUDA_EXPORT void scopeone_register_processing_modules( + scopeone::core::ScopeOneCore* core) +{ + core->registerProcessingModule( + {QStringLiteral("cuda.gaussian_blur"), + QStringLiteral("CUDA Gaussian Blur"), + 1, + {{QStringLiteral("kernel_size"), + QStringLiteral("Kernel size"), + scopeone::core::ProcessingParameterType::Integer, + 3, + 1, + 99, + 2, + 0}, + {QStringLiteral("sigma"), + QStringLiteral("Sigma"), + scopeone::core::ProcessingParameterType::Real, + 0.0, + 0.0, + 100.0, + 0.1, + 2}}}, + []() + { + return std::make_unique(); + }); + + core->registerProcessingModule( + {QStringLiteral("cuda.frequency_filter"), + QStringLiteral("CUDA Frequency Filter"), + 1, + {{QStringLiteral("output_mode"), + QStringLiteral("Output"), + scopeone::core::ProcessingParameterType::Choice, + 2, + 0, + 2, + 1, + 0, + {{QStringLiteral("Spectrum"), 0}, + {QStringLiteral("Filtered spectrum"), 1}, + {QStringLiteral("Filtered image"), 2}}}, + {QStringLiteral("min_feature_size"), + QStringLiteral("Min feature size"), + scopeone::core::ProcessingParameterType::Real, + 2.0, + 0.0, + 1000.0, + 0.1, + 2}, + {QStringLiteral("max_feature_size"), + QStringLiteral("Max feature size"), + scopeone::core::ProcessingParameterType::Real, + 10.0, + 0.0, + 1000.0, + 0.1, + 2}, + {QStringLiteral("filter_kind"), + QStringLiteral("Filter kind"), + scopeone::core::ProcessingParameterType::Choice, + 0, + 0, + 1, + 1, + 0, + {{QStringLiteral("Smooth"), 0}, {QStringLiteral("Hard"), 1}}}}}, + []() + { + return std::make_unique(); + }); +} diff --git a/ScopeOneCuda/src/CudaRealImageModule.cpp b/ScopeOneCuda/src/CudaRealImageModule.cpp new file mode 100644 index 0000000..cb26a0d --- /dev/null +++ b/ScopeOneCuda/src/CudaRealImageModule.cpp @@ -0,0 +1,74 @@ +#include "scopeone/cuda/CudaRealImageModule.h" + +#include "scopeone/cuda/CudaDevice.h" + +#include + +namespace scopeone::cuda +{ + CudaRealImageModule::CudaRealImageModule(GpuMemoryLayout layout) + : m_layout(layout) + { + } + + CudaRealImageModule::~CudaRealImageModule() = default; + + scopeone::core::ProcessingResult CudaRealImageModule::process( + const scopeone::core::ImageFrame& frame, + int processingBitDepth) + { + if (!frame.isValid()) + { + return {scopeone::core::ImageFrame{}, QStringLiteral("Invalid input frame")}; + } + if (!isCudaDeviceAvailable()) + { + return {scopeone::core::ImageFrame{}, QStringLiteral("CUDA device unavailable")}; + } + if (!m_inputBuffer.isValid() + || m_inputBuffer.width() != frame.width + || m_inputBuffer.height() != frame.height + || m_inputBuffer.layout() != m_layout) + { + if (!m_inputBuffer.allocate(frame.width, frame.height, m_layout) + || !m_outputBuffer.allocate(frame.width, frame.height, m_layout)) + { + return {scopeone::core::ImageFrame{}, + QStringLiteral("CUDA frame allocation failed")}; + } + } + if (!m_inputBuffer.upload(frame)) + { + return {scopeone::core::ImageFrame{}, + QStringLiteral("CUDA input upload failed")}; + } + if (!processDevice(m_inputBuffer, m_outputBuffer, processingBitDepth)) + { + return {scopeone::core::ImageFrame{}, + QStringLiteral("CUDA processing failed")}; + } + + scopeone::core::ImageFrame output; + output.cameraId = frame.cameraId; + output.width = frame.width; + output.height = frame.height; + output.pixelFormat = processingBitDepth >= 16 + ? scopeone::core::ImagePixelFormat::Mono16 + : scopeone::core::ImagePixelFormat::Mono8; + output.bitsPerSample = processingBitDepth >= 16 ? 16 : 8; + output.stride = output.width * output.bytesPerPixel(); + output.frameIndex = frame.frameIndex; + output.timestampNs = frame.timestampNs; + output.sourceRoiX = frame.sourceRoiX; + output.sourceRoiY = frame.sourceRoiY; + output.sourceRoiWidth = frame.sourceRoiWidth; + output.sourceRoiHeight = frame.sourceRoiHeight; + output.bytes.resize(static_cast(output.payloadByteCount())); + if (!m_outputBuffer.download(output)) + { + return {scopeone::core::ImageFrame{}, + QStringLiteral("CUDA output download failed")}; + } + return {std::move(output), {}}; + } +} diff --git a/ScopeOneCuda/src/GpuComplexFrame.cpp b/ScopeOneCuda/src/GpuComplexFrame.cpp new file mode 100644 index 0000000..847d3d7 --- /dev/null +++ b/ScopeOneCuda/src/GpuComplexFrame.cpp @@ -0,0 +1,102 @@ +#include "scopeone/cuda/GpuComplexFrame.h" + +#include + +#include + +namespace scopeone::cuda +{ + GpuComplexFrame::~GpuComplexFrame() + { + release(); + } + + GpuComplexFrame::GpuComplexFrame(GpuComplexFrame&& other) noexcept + : m_data(std::exchange(other.m_data, nullptr)) + , m_realWidth(std::exchange(other.m_realWidth, 0)) + , m_spectrumWidth(std::exchange(other.m_spectrumWidth, 0)) + , m_height(std::exchange(other.m_height, 0)) + { + } + + GpuComplexFrame& GpuComplexFrame::operator=(GpuComplexFrame&& other) noexcept + { + if (this != &other) + { + release(); + m_data = std::exchange(other.m_data, nullptr); + m_realWidth = std::exchange(other.m_realWidth, 0); + m_spectrumWidth = std::exchange(other.m_spectrumWidth, 0); + m_height = std::exchange(other.m_height, 0); + } + return *this; + } + + bool GpuComplexFrame::allocateForRealImage(int width, int height) + { + if (isValid() && m_realWidth == width && m_height == height) + { + return true; + } + + release(); + m_realWidth = width; + m_spectrumWidth = width / 2 + 1; + m_height = height; + const std::size_t bytes = static_cast(m_spectrumWidth) + * static_cast(m_height) + * sizeof(float2); + if (cudaMalloc(&m_data, bytes) != cudaSuccess) + { + release(); + return false; + } + return true; + } + + void GpuComplexFrame::release() + { + if (m_data) + { + cudaFree(m_data); + } + m_data = nullptr; + m_realWidth = 0; + m_spectrumWidth = 0; + m_height = 0; + } + + bool GpuComplexFrame::isValid() const + { + return m_data != nullptr + && m_realWidth > 0 + && m_spectrumWidth > 0 + && m_height > 0; + } + + void* GpuComplexFrame::data() const + { + return m_data; + } + + int GpuComplexFrame::realWidth() const + { + return m_realWidth; + } + + int GpuComplexFrame::spectrumWidth() const + { + return m_spectrumWidth; + } + + int GpuComplexFrame::height() const + { + return m_height; + } + + std::size_t GpuComplexFrame::elementCount() const + { + return static_cast(m_spectrumWidth) + * static_cast(m_height); + } +} diff --git a/ScopeOneCuda/src/GpuRealFrame.cpp b/ScopeOneCuda/src/GpuRealFrame.cpp new file mode 100644 index 0000000..6e833ec --- /dev/null +++ b/ScopeOneCuda/src/GpuRealFrame.cpp @@ -0,0 +1,251 @@ +#include "scopeone/cuda/GpuRealFrame.h" + +#include "scopeone/cuda/CudaKernelLaunch.h" + +#include + +#include + +namespace scopeone::cuda +{ + GpuRealFrame::GpuRealFrame(int width, int height, GpuMemoryLayout layout) + { + allocate(width, height, layout); + } + + GpuRealFrame::~GpuRealFrame() + { + release(); + } + + GpuRealFrame::GpuRealFrame(GpuRealFrame&& other) noexcept + : m_data(std::exchange(other.m_data, nullptr)) + , m_pitchBytes(std::exchange(other.m_pitchBytes, 0)) + , m_width(std::exchange(other.m_width, 0)) + , m_height(std::exchange(other.m_height, 0)) + , m_layout(other.m_layout) + , m_uploadBuffer(std::exchange(other.m_uploadBuffer, nullptr)) + , m_uploadCapacity(std::exchange(other.m_uploadCapacity, 0)) + , m_downloadBuffer(std::exchange(other.m_downloadBuffer, nullptr)) + , m_downloadCapacity(std::exchange(other.m_downloadCapacity, 0)) + { + } + + GpuRealFrame& GpuRealFrame::operator=(GpuRealFrame&& other) noexcept + { + if (this != &other) + { + release(); + m_data = std::exchange(other.m_data, nullptr); + m_pitchBytes = std::exchange(other.m_pitchBytes, 0); + m_width = std::exchange(other.m_width, 0); + m_height = std::exchange(other.m_height, 0); + m_layout = other.m_layout; + m_uploadBuffer = std::exchange(other.m_uploadBuffer, nullptr); + m_uploadCapacity = std::exchange(other.m_uploadCapacity, 0); + m_downloadBuffer = std::exchange(other.m_downloadBuffer, nullptr); + m_downloadCapacity = std::exchange(other.m_downloadCapacity, 0); + } + return *this; + } + + bool GpuRealFrame::allocate(int width, int height, GpuMemoryLayout layout) + { + if (isValid() && m_width == width && m_height == height && m_layout == layout) + { + return true; + } + + release(); + m_width = width; + m_height = height; + m_layout = layout; + if (layout == GpuMemoryLayout::Pitched2D) + { + if (cudaMallocPitch(reinterpret_cast(&m_data), + &m_pitchBytes, + static_cast(width) * sizeof(float), + height) != cudaSuccess) + { + release(); + return false; + } + } + else + { + m_pitchBytes = static_cast(width) * sizeof(float); + if (cudaMalloc(reinterpret_cast(&m_data), + m_pitchBytes * static_cast(height)) != cudaSuccess) + { + release(); + return false; + } + } + return true; + } + + void GpuRealFrame::release() + { + if (m_data) + { + cudaFree(m_data); + } + if (m_uploadBuffer) + { + cudaFree(m_uploadBuffer); + } + if (m_downloadBuffer) + { + cudaFree(m_downloadBuffer); + } + m_data = nullptr; + m_pitchBytes = 0; + m_width = 0; + m_height = 0; + m_uploadBuffer = nullptr; + m_uploadCapacity = 0; + m_downloadBuffer = nullptr; + m_downloadCapacity = 0; + } + + bool GpuRealFrame::isValid() const + { + return m_data != nullptr && m_width > 0 && m_height > 0 && m_pitchBytes > 0; + } + + void* GpuRealFrame::data() const + { + return m_data; + } + + std::size_t GpuRealFrame::pitchBytes() const + { + return m_pitchBytes; + } + + int GpuRealFrame::width() const + { + return m_width; + } + + int GpuRealFrame::height() const + { + return m_height; + } + + GpuMemoryLayout GpuRealFrame::layout() const + { + return m_layout; + } + + bool GpuRealFrame::ensureUploadBuffer(std::size_t bytes) + { + if (m_uploadCapacity >= bytes) + { + return true; + } + if (m_uploadBuffer) + { + cudaFree(m_uploadBuffer); + } + m_uploadBuffer = nullptr; + m_uploadCapacity = 0; + if (cudaMalloc(reinterpret_cast(&m_uploadBuffer), bytes) != cudaSuccess) + { + return false; + } + m_uploadCapacity = bytes; + return true; + } + + bool GpuRealFrame::ensureDownloadBuffer(std::size_t bytes) const + { + if (m_downloadCapacity >= bytes) + { + return true; + } + if (m_downloadBuffer) + { + cudaFree(m_downloadBuffer); + } + m_downloadBuffer = nullptr; + m_downloadCapacity = 0; + if (cudaMalloc(reinterpret_cast(&m_downloadBuffer), bytes) != cudaSuccess) + { + return false; + } + m_downloadCapacity = bytes; + return true; + } + + bool GpuRealFrame::upload(const scopeone::core::ImageFrame& frame) + { + if (!frame.isValid()) + { + return false; + } + if (!isValid() + || m_width != frame.width + || m_height != frame.height) + { + if (!allocate(frame.width, frame.height, m_layout)) + { + return false; + } + } + if (!ensureUploadBuffer(static_cast(frame.payloadByteCount()))) + { + return false; + } + if (cudaMemcpy2D(m_uploadBuffer, + static_cast(frame.stride), + frame.bytes.constData(), + static_cast(frame.stride), + static_cast(frame.stride), + static_cast(frame.height), + cudaMemcpyHostToDevice) != cudaSuccess) + { + return false; + } + return detail::convertToFloat(m_uploadBuffer, + frame.stride, + frame.width, + frame.height, + frame.bytesPerPixel(), + m_data, + m_pitchBytes); + } + + bool GpuRealFrame::download(scopeone::core::ImageFrame& frame) const + { + if (!isValid() + || frame.width != m_width + || frame.height != m_height + || (frame.pixelFormat != scopeone::core::ImagePixelFormat::Mono8 + && frame.pixelFormat != scopeone::core::ImagePixelFormat::Mono16)) + { + return false; + } + if (!ensureDownloadBuffer(static_cast(frame.payloadByteCount()))) + { + return false; + } + if (!detail::convertFromFloat(m_data, + m_pitchBytes, + m_downloadBuffer, + frame.stride, + frame.width, + frame.height, + frame.bytesPerPixel())) + { + return false; + } + return cudaMemcpy2D(frame.bytes.data(), + static_cast(frame.stride), + m_downloadBuffer, + static_cast(frame.stride), + static_cast(frame.stride), + static_cast(frame.height), + cudaMemcpyDeviceToHost) == cudaSuccess; + } +} diff --git a/VERSION b/VERSION index 0f52923..5487dbb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.803 +1.5.915 diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt new file mode 100644 index 0000000..60f9e7e --- /dev/null +++ b/plugins/CMakeLists.txt @@ -0,0 +1,37 @@ +cmake_minimum_required(VERSION 3.23) +project(ScopeOnePlugins LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_AUTOMOC ON) + +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) +find_package(ScopeOneCore CONFIG REQUIRED) +find_package(OpenCV REQUIRED COMPONENTS core imgcodecs imgproc) +find_program(SCOPEONE_CUDA_NVCC_EXECUTABLE nvcc) + +function(scopeone_add_plugin target kind) + set_target_properties(${target} PROPERTIES + PREFIX "" + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins/${kind}" + LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/plugins/${kind}" + ) + install(TARGETS ${target} + RUNTIME DESTINATION "plugins/${kind}" + LIBRARY DESTINATION "plugins/${kind}" + ) +endfunction() + +add_subdirectory(hardware) +add_subdirectory(tools) +if (SCOPEONE_CUDA_NVCC_EXECUTABLE) + set(CMAKE_CUDA_COMPILER "${SCOPEONE_CUDA_NVCC_EXECUTABLE}" CACHE FILEPATH + "CUDA compiler selected by the environment" FORCE) + get_filename_component(_cuda_toolkit_root "${CMAKE_CUDA_COMPILER}" DIRECTORY) + get_filename_component(_cuda_toolkit_root "${_cuda_toolkit_root}" DIRECTORY) + set(CUDAToolkit_ROOT "${_cuda_toolkit_root}" CACHE PATH + "CUDA toolkit selected by the compiler" FORCE) + enable_language(CUDA) + find_package(CUDAToolkit REQUIRED) + add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/../ScopeOneCuda ScopeOneCuda) +endif () +add_subdirectory(processing) diff --git a/plugins/hardware/CMakeLists.txt b/plugins/hardware/CMakeLists.txt new file mode 100644 index 0000000..b050fd7 --- /dev/null +++ b/plugins/hardware/CMakeLists.txt @@ -0,0 +1,4 @@ +add_subdirectory(NIDaqmx) +add_subdirectory(PtuFile) +add_subdirectory(ExampleHardware) +add_subdirectory(SimulatedPmt) diff --git a/plugins/hardware/ExampleHardware/CMakeLists.txt b/plugins/hardware/ExampleHardware/CMakeLists.txt new file mode 100644 index 0000000..7b56deb --- /dev/null +++ b/plugins/hardware/ExampleHardware/CMakeLists.txt @@ -0,0 +1,6 @@ +add_library(ScopeOneExampleHardware MODULE + ExampleHardwarePlugin.cpp + plugin.json +) +target_link_libraries(ScopeOneExampleHardware PRIVATE scopeone::PluginSDK Qt6::Core) +scopeone_add_plugin(ScopeOneExampleHardware hardware) diff --git a/plugins/hardware/ExampleHardware/ExampleHardwarePlugin.cpp b/plugins/hardware/ExampleHardware/ExampleHardwarePlugin.cpp new file mode 100644 index 0000000..a18ce79 --- /dev/null +++ b/plugins/hardware/ExampleHardware/ExampleHardwarePlugin.cpp @@ -0,0 +1,44 @@ +#include "scopeone/DriverHostProviderPlugin.h" +#include "scopeone/SimulatorProvider.h" + +#include + +namespace +{ + class ExampleHardwarePlugin final : public QObject, + public scopeone::core::DriverHostProviderPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID ScopeOneDriverHostProviderPlugin_iid FILE "plugin.json") + Q_INTERFACES(scopeone::core::DriverHostProviderPlugin) + + public: + QString providerId() const override + { + return QStringLiteral("example.hardware"); + } + + scopeone::core::HardwareProviderPtr createProvider(const QJsonObject& options, + QString* errorMessage) override + { + if (errorMessage) + { + errorMessage->clear(); + } + const int width = options.value(QStringLiteral("width")).toInt(512); + const int height = options.value(QStringLiteral("height")).toInt(512); + if (width <= 0 || height <= 0) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("width and height must be positive"); + } + return {}; + } + return std::make_shared( + QStringLiteral("camera.example"), width, height, providerId()); + } + }; +} + +#include "ExampleHardwarePlugin.moc" diff --git a/plugins/hardware/ExampleHardware/plugin.json b/plugins/hardware/ExampleHardware/plugin.json new file mode 100644 index 0000000..b4b0867 --- /dev/null +++ b/plugins/hardware/ExampleHardware/plugin.json @@ -0,0 +1,9 @@ +{ + "id": "example.hardware", + "name": "Example Hardware", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "hardware", + "providerId": "example.hardware", + "autoLoad": false +} diff --git a/plugins/hardware/NIDaqmx/CMakeLists.txt b/plugins/hardware/NIDaqmx/CMakeLists.txt new file mode 100644 index 0000000..92c4a2f --- /dev/null +++ b/plugins/hardware/NIDaqmx/CMakeLists.txt @@ -0,0 +1,11 @@ +add_library(ScopeOneNIDaqmx MODULE + NIDaqmxPlugin.cpp + NIDaqmxPlugin.h + plugin.json +) +set_target_properties(ScopeOneNIDaqmx PROPERTIES OUTPUT_NAME "NIDaqmxPlugin") +target_link_libraries(ScopeOneNIDaqmx PRIVATE + scopeone::PluginSDK + Qt6::Core +) +scopeone_add_plugin(ScopeOneNIDaqmx hardware) diff --git a/plugins/hardware/NIDaqmx/NIDaqmxPlugin.cpp b/plugins/hardware/NIDaqmx/NIDaqmxPlugin.cpp new file mode 100644 index 0000000..bbc0f49 --- /dev/null +++ b/plugins/hardware/NIDaqmx/NIDaqmxPlugin.cpp @@ -0,0 +1,873 @@ +#include "NIDaqmxPlugin.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::plugins +{ + using namespace scopeone::core; + + namespace + { +#ifdef Q_OS_WIN +#define SCOPEONE_DAQMX_CALL __stdcall + constexpr auto kLibraryName = "nicaiu.dll"; +#else +#define SCOPEONE_DAQMX_CALL + constexpr auto kLibraryName = "libnidaqmx.so"; +#endif + + using Int32 = qint32; + using UInt32 = quint32; + using UInt64 = quint64; + using Bool32 = quint32; + using TaskHandle = void*; + + constexpr Int32 kRising = 10280; + constexpr Int32 kFalling = 10171; + constexpr Int32 kHertz = 10373; + constexpr Int32 kLow = 10214; + constexpr Int32 kContinuousSamples = 10123; + constexpr Int32 kFiniteSamples = 10178; + constexpr Int32 kDefaultTerminalConfiguration = -1; + constexpr Int32 kVolts = 10348; + constexpr Int32 kChannelForAllLines = 1; + constexpr Int32 kGroupByScanNumber = 1; + constexpr Int32 kDoNotInvert = 0; + constexpr Int32 kInvertPolarity = 1; + + struct DaqmxApi + { + using GetString = Int32 (SCOPEONE_DAQMX_CALL*)(char*, UInt32); + using GetDeviceString = Int32 (SCOPEONE_DAQMX_CALL*)(const char*, char*, UInt32); + using CreateTask = Int32 (SCOPEONE_DAQMX_CALL*)(const char*, TaskHandle*); + using ClearTask = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle); + using StartTask = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle); + using StopTask = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle); + using CreatePulse = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + const char*, + const char*, + Int32, + Int32, + double, + double, + double); + using CreatePulseTicks = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + const char*, + const char*, + const char*, + Int32, + UInt32, + UInt32, + UInt32); + using ConfigureImplicit = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, Int32, UInt64); + using ConfigureTrigger = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, const char*, Int32); + using SetChannelString = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, const char*, const char*); + using RouteTerminal = Int32 (SCOPEONE_DAQMX_CALL*)(const char*, const char*, Int32); + using DisconnectTerminal = Int32 (SCOPEONE_DAQMX_CALL*)(const char*, const char*); + using CreateAiVoltage = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + const char*, + const char*, + Int32, + double, + double, + Int32, + const char*); + using CreateAoVoltage = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + const char*, + const char*, + double, + double, + Int32, + const char*); + using CreateDigital = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + const char*, + const char*, + Int32); + using ConfigureSampleClock = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + const char*, + double, + Int32, + Int32, + UInt64); + using WriteAnalog = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + Int32, + Bool32, + double, + Int32, + const double*, + Int32*, + void*); + using WriteDigital = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + Int32, + Bool32, + double, + Int32, + const UInt32*, + Int32*, + void*); + using AvailableSamples = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, UInt32*); + using ReadAnalog = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + Int32, + double, + Int32, + double*, + UInt32, + Int32*, + void*); + using ReadDigital = Int32 (SCOPEONE_DAQMX_CALL*)(TaskHandle, + Int32, + double, + Int32, + UInt32*, + UInt32, + Int32*, + void*); + + QLibrary library{QString::fromLatin1(kLibraryName)}; + GetString getSystemDeviceNames{nullptr}; + GetString getExtendedErrorInfo{nullptr}; + GetDeviceString getProductType{nullptr}; + GetDeviceString getAiChannels{nullptr}; + GetDeviceString getAoChannels{nullptr}; + GetDeviceString getDiLines{nullptr}; + GetDeviceString getDoLines{nullptr}; + GetDeviceString getCiChannels{nullptr}; + GetDeviceString getCoChannels{nullptr}; + GetDeviceString getTerminals{nullptr}; + CreateTask createTask{nullptr}; + ClearTask clearTask{nullptr}; + StartTask startTask{nullptr}; + StopTask stopTask{nullptr}; + CreatePulse createPulse{nullptr}; + CreatePulseTicks createPulseTicks{nullptr}; + ConfigureImplicit configureImplicit{nullptr}; + ConfigureTrigger configureTrigger{nullptr}; + SetChannelString setChannelString{nullptr}; + RouteTerminal routeTerminal{nullptr}; + DisconnectTerminal disconnectTerminal{nullptr}; + CreateAiVoltage createAiVoltage{nullptr}; + CreateAoVoltage createAoVoltage{nullptr}; + CreateDigital createDi{nullptr}; + CreateDigital createDo{nullptr}; + ConfigureSampleClock configureSampleClock{nullptr}; + WriteAnalog writeAnalog{nullptr}; + WriteDigital writeDigital{nullptr}; + AvailableSamples availableSamples{nullptr}; + ReadAnalog readAnalog{nullptr}; + ReadDigital readDigital{nullptr}; + + template + bool resolve(Function& function, const char* name) + { + function = reinterpret_cast(library.resolve(name)); + return function != nullptr; + } + + bool load(bool controllerFunctions, QString* errorMessage = nullptr) + { + if (!library.isLoaded() && !library.load()) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("NI-DAQmx runtime is unavailable: %1") + .arg(library.errorString()); + } + return false; + } + bool ok = resolve(getSystemDeviceNames, "DAQmxGetSysDevNames") + && resolve(getExtendedErrorInfo, "DAQmxGetExtendedErrorInfo") + && resolve(getProductType, "DAQmxGetDevProductType") + && resolve(getAiChannels, "DAQmxGetDevAIPhysicalChans") + && resolve(getAoChannels, "DAQmxGetDevAOPhysicalChans") + && resolve(getDiLines, "DAQmxGetDevDILines") + && resolve(getDoLines, "DAQmxGetDevDOLines") + && resolve(getCiChannels, "DAQmxGetDevCIPhysicalChans") + && resolve(getCoChannels, "DAQmxGetDevCOPhysicalChans") + && resolve(getTerminals, "DAQmxGetDevTerminals"); + if (controllerFunctions) + { + ok = ok + && resolve(createTask, "DAQmxCreateTask") + && resolve(clearTask, "DAQmxClearTask") + && resolve(startTask, "DAQmxStartTask") + && resolve(stopTask, "DAQmxStopTask") + && resolve(createPulse, "DAQmxCreateCOPulseChanFreq") + && resolve(createPulseTicks, "DAQmxCreateCOPulseChanTicks") + && resolve(configureImplicit, "DAQmxCfgImplicitTiming") + && resolve(configureTrigger, "DAQmxCfgDigEdgeStartTrig") + && resolve(setChannelString, "DAQmxSetCOPulseTerm") + && resolve(routeTerminal, "DAQmxConnectTerms") + && resolve(disconnectTerminal, "DAQmxDisconnectTerms") + && resolve(createAiVoltage, "DAQmxCreateAIVoltageChan") + && resolve(createAoVoltage, "DAQmxCreateAOVoltageChan") + && resolve(createDi, "DAQmxCreateDIChan") + && resolve(createDo, "DAQmxCreateDOChan") + && resolve(configureSampleClock, "DAQmxCfgSampClkTiming") + && resolve(writeAnalog, "DAQmxWriteAnalogF64") + && resolve(writeDigital, "DAQmxWriteDigitalU32") + && resolve(availableSamples, "DAQmxGetReadAvailSampPerChan") + && resolve(readAnalog, "DAQmxReadAnalogF64") + && resolve(readDigital, "DAQmxReadDigitalU32"); + } + if (!ok && errorMessage) + { + *errorMessage = QStringLiteral("NI-DAQmx runtime is missing required functions"); + } + return ok; + } + + QString error(Int32 code) const + { + std::array buffer{}; + if (getExtendedErrorInfo) + { + getExtendedErrorInfo(buffer.data(), static_cast(buffer.size())); + } + const QString detail = QString::fromLocal8Bit(buffer.data()).trimmed(); + return detail.isEmpty() + ? QStringLiteral("NI-DAQmx error %1").arg(code) + : detail; + } + }; + + QStringList splitNames(const QByteArray& value) + { + QStringList names; + for (const QString& name : QString::fromLocal8Bit(value).split( + QLatin1Char(','), Qt::SkipEmptyParts)) + { + names.append(name.trimmed()); + } + return names; + } + + QString readSystemString(DaqmxApi::GetString function) + { + const Int32 size = function(nullptr, 0); + if (size <= 0) + { + return {}; + } + QByteArray buffer(size, '\0'); + return function(buffer.data(), static_cast(buffer.size())) < 0 + ? QString() + : QString::fromLocal8Bit(buffer.constData()); + } + + QString readDeviceString(DaqmxApi::GetDeviceString function, + const QString& device) + { + const QByteArray encoded = device.toLocal8Bit(); + const Int32 size = function(encoded.constData(), nullptr, 0); + if (size <= 0) + { + return {}; + } + QByteArray buffer(size, '\0'); + return function(encoded.constData(), buffer.data(), + static_cast(buffer.size())) < 0 + ? QString() + : QString::fromLocal8Bit(buffer.constData()); + } + + QString nativeDeviceId(const QString& id) + { + return id.startsWith(QStringLiteral("ni:")) ? id.mid(3) : id; + } + + QString absoluteTerminal(const QString& device, const QString& terminal) + { + const QString value = terminal.trimmed(); + if (value.isEmpty() || value.startsWith(QLatin1Char('/'))) + { + return value; + } + return value.startsWith(device + QLatin1Char('/'), Qt::CaseInsensitive) + ? QLatin1Char('/') + value + : QStringLiteral("/%1/%2").arg(device, value); + } + + class NIDaqmxController final : public DaqController + { + public: + explicit NIDaqmxController(QString device, QObject* parent) + : DaqController(parent) + , m_device(std::move(device)) + { + } + + ~NIDaqmxController() override + { + stop(); + } + + bool start(const DaqSessionConfig& config, + QString* errorMessage) override + { + if (m_state == DaqState::Armed || m_state == DaqState::Running) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("DAQ device is already active"); + } + return false; + } + + QString loadError; + if (!m_api.load(true, &loadError)) + { + return fail(loadError, errorMessage); + } + emitState(DaqState::Armed, QStringLiteral("Configuring hardware tasks")); + + for (const DaqTerminalRoute& route : config.routes) + { + const QByteArray source = absoluteTerminal(m_device, route.source).toLocal8Bit(); + const QByteArray destination = absoluteTerminal(m_device, route.destination).toLocal8Bit(); + const Int32 result = m_api.routeTerminal( + source.constData(), + destination.constData(), + route.inverted ? kInvertPolarity : kDoNotInvert); + if (result < 0) + { + return fail(m_api.error(result), errorMessage); + } + m_routes.push_back({source, destination}); + } + + QString taskError; + for (int index = 0; index < config.pulseTasks.size(); ++index) + { + if (!createPulseTask(config.pulseTasks[index], index, taskError)) + { + return fail(taskError, errorMessage); + } + } + for (int index = 0; index < config.analogTasks.size(); ++index) + { + if (!createAnalogTask(config.analogTasks[index], index, taskError)) + { + return fail(taskError, errorMessage); + } + } + for (int index = 0; index < config.digitalTasks.size(); ++index) + { + if (!createDigitalTask(config.digitalTasks[index], index, taskError)) + { + return fail(taskError, errorMessage); + } + } + if (m_tasks.empty()) + { + return fail(QStringLiteral("DAQ session has no tasks"), errorMessage); + } + + for (ActiveTask& task : m_tasks) + { + const Int32 result = m_api.startTask(task.handle); + if (result < 0) + { + return fail(m_api.error(result), errorMessage); + } + } + if (std::any_of(m_tasks.cbegin(), m_tasks.cend(), + [](const ActiveTask& task) + { + return task.inputKind != InputKind::None; + })) + { + m_reading.store(true); + m_reader = std::thread([this]() { readInputs(); }); + } + emitState(DaqState::Running, QStringLiteral("DAQ session is active")); + return true; + } + + void stop() override + { + releaseHardware(); + if (m_state != DaqState::Idle) + { + emitState(DaqState::Idle, QStringLiteral("DAQ device is idle")); + } + } + + DaqState state() const override + { + return m_state.load(); + } + + QString stateMessage() const override + { + return m_message; + } + + private: + enum class InputKind + { + None, + Analog, + Digital + }; + + struct ActiveTask + { + TaskHandle handle{nullptr}; + InputKind inputKind{InputKind::None}; + QString name; + QStringList channels; + quint64 nextSample{0}; + double sampleRateHz{0.0}; + }; + + bool configureTiming(TaskHandle handle, + const DaqTaskTiming& timing, + QString& errorMessage) + { + const QByteArray clock = absoluteTerminal(m_device, timing.sampleClock).toLocal8Bit(); + Int32 result = m_api.configureSampleClock( + handle, + clock.constData(), + timing.sampleRateHz, + timing.sampleEdge == DaqEdge::Rising ? kRising : kFalling, + timing.sampleMode == DaqSampleMode::Continuous + ? kContinuousSamples + : kFiniteSamples, + timing.samplesPerChannel); + if (result >= 0 && !timing.startTrigger.trimmed().isEmpty()) + { + const QByteArray trigger = absoluteTerminal(m_device, + timing.startTrigger).toLocal8Bit(); + result = m_api.configureTrigger( + handle, + trigger.constData(), + timing.startEdge == DaqEdge::Rising ? kRising : kFalling); + } + if (result < 0) + { + errorMessage = m_api.error(result); + return false; + } + return true; + } + + bool createPulseTask(const DaqPulseTaskConfig& config, + int index, + QString& errorMessage) + { + TaskHandle handle = nullptr; + const QString name = config.name.trimmed().isEmpty() + ? QStringLiteral("Pulse Task %1").arg(index + 1) + : config.name.trimmed(); + Int32 result = m_api.createTask(name.toLocal8Bit().constData(), &handle); + const QByteArray counter = absoluteTerminal(m_device, config.counter).toLocal8Bit(); + const QByteArray output = absoluteTerminal(m_device, config.outputTerminal).toLocal8Bit(); + if (result >= 0 && !config.timebaseSource.trimmed().isEmpty()) + { + const QByteArray timebase = absoluteTerminal(m_device, + config.timebaseSource).toLocal8Bit(); + result = m_api.createPulseTicks( + handle, + counter.constData(), + nullptr, + timebase.constData(), + kLow, + config.initialDelayTicks, + config.lowTicks, + config.highTicks); + } + else if (result >= 0) + { + result = m_api.createPulse( + handle, + counter.constData(), + nullptr, + kHertz, + kLow, + config.initialDelaySeconds, + config.frequencyHz, + config.dutyCycle); + } + if (result >= 0 && !config.outputTerminal.trimmed().isEmpty()) + { + result = m_api.setChannelString(handle, + counter.constData(), + output.constData()); + } + if (result >= 0) + { + result = m_api.configureImplicit( + handle, + kContinuousSamples, + 1000); + } + if (result >= 0 && !config.startTrigger.trimmed().isEmpty()) + { + const QByteArray trigger = absoluteTerminal(m_device, + config.startTrigger).toLocal8Bit(); + result = m_api.configureTrigger( + handle, + trigger.constData(), + config.startEdge == DaqEdge::Rising ? kRising : kFalling); + } + if (result < 0) + { + errorMessage = m_api.error(result); + if (handle) + { + m_api.clearTask(handle); + } + return false; + } + m_tasks.push_back({handle, + InputKind::None, + name, + {}, + 0, + 0.0}); + return true; + } + + bool createAnalogTask(const DaqAnalogTaskConfig& config, + int index, + QString& errorMessage) + { + TaskHandle handle = nullptr; + const QString name = config.name.trimmed().isEmpty() + ? QStringLiteral("Analog Task %1").arg(index + 1) + : config.name.trimmed(); + Int32 result = m_api.createTask(name.toLocal8Bit().constData(), &handle); + const QByteArray channels = config.channels.join(QLatin1Char(',')).toLocal8Bit(); + if (result >= 0 && config.direction == DaqTaskDirection::Input) + { + result = m_api.createAiVoltage( + handle, + channels.constData(), + "", + kDefaultTerminalConfiguration, + config.minimumVolts, + config.maximumVolts, + kVolts, + nullptr); + } + else if (result >= 0) + { + result = m_api.createAoVoltage( + handle, + channels.constData(), + "", + config.minimumVolts, + config.maximumVolts, + kVolts, + nullptr); + } + if (result >= 0 && !configureTiming(handle, config.timing, errorMessage)) + { + result = -1; + } + if (result >= 0 && config.direction == DaqTaskDirection::Output) + { + Int32 written = 0; + result = m_api.writeAnalog( + handle, + static_cast(config.timing.samplesPerChannel), + 0, + 10.0, + kGroupByScanNumber, + config.outputSamplesByScan.constData(), + &written, + nullptr); + } + if (result < 0) + { + if (errorMessage.isEmpty()) + { + errorMessage = m_api.error(result); + } + if (handle) + { + m_api.clearTask(handle); + } + return false; + } + m_tasks.push_back({handle, + config.direction == DaqTaskDirection::Input + ? InputKind::Analog + : InputKind::None, + name, + config.channels, + 0, + config.timing.sampleRateHz}); + return true; + } + + bool createDigitalTask(const DaqDigitalTaskConfig& config, + int index, + QString& errorMessage) + { + TaskHandle handle = nullptr; + const QString name = config.name.trimmed().isEmpty() + ? QStringLiteral("Digital Task %1").arg(index + 1) + : config.name.trimmed(); + Int32 result = m_api.createTask(name.toLocal8Bit().constData(), &handle); + const QByteArray lines = config.lines.join(QLatin1Char(',')).toLocal8Bit(); + if (result >= 0) + { + result = config.direction == DaqTaskDirection::Input + ? m_api.createDi( + handle, + lines.constData(), + "", + kChannelForAllLines) + : m_api.createDo( + handle, + lines.constData(), + "", + kChannelForAllLines); + } + if (result >= 0 && !configureTiming(handle, config.timing, errorMessage)) + { + result = -1; + } + if (result >= 0 && config.direction == DaqTaskDirection::Output) + { + Int32 written = 0; + result = m_api.writeDigital( + handle, + static_cast(config.timing.samplesPerChannel), + 0, + 10.0, + kGroupByScanNumber, + config.outputSamplesByScan.constData(), + &written, + nullptr); + } + if (result < 0) + { + if (errorMessage.isEmpty()) + { + errorMessage = m_api.error(result); + } + if (handle) + { + m_api.clearTask(handle); + } + return false; + } + m_tasks.push_back({handle, + config.direction == DaqTaskDirection::Input + ? InputKind::Digital + : InputKind::None, + name, + config.lines, + 0, + config.timing.sampleRateHz}); + return true; + } + + void readInputs() + { + constexpr UInt32 kSamplesPerRead = 65536; + while (m_reading.load()) + { + for (ActiveTask& task : m_tasks) + { + if (task.inputKind == InputKind::None) + { + continue; + } + UInt32 available = 0; + Int32 result = m_api.availableSamples(task.handle, &available); + if (result < 0) + { + reportReadError(m_api.error(result)); + return; + } + if (available == 0) + { + continue; + } + const UInt32 samples = std::min(available, kSamplesPerRead); + DaqInputChunk chunk; + chunk.deviceId = QStringLiteral("ni:%1").arg(m_device); + chunk.taskName = task.name; + chunk.channels = task.channels; + chunk.firstSample = task.nextSample; + chunk.nominalSampleRateHz = task.sampleRateHz; + Int32 samplesRead = 0; + if (task.inputKind == InputKind::Analog) + { + chunk.analogSamplesByScan.resize( + static_cast(samples * task.channels.size())); + result = m_api.readAnalog( + task.handle, + static_cast(samples), + 0.0, + kGroupByScanNumber, + chunk.analogSamplesByScan.data(), + static_cast(chunk.analogSamplesByScan.size()), + &samplesRead, + nullptr); + chunk.analogSamplesByScan.resize( + static_cast(samplesRead * task.channels.size())); + } + else + { + chunk.digitalSamplesByScan.resize(static_cast(samples)); + result = m_api.readDigital( + task.handle, + static_cast(samples), + 0.0, + kGroupByScanNumber, + chunk.digitalSamplesByScan.data(), + static_cast(chunk.digitalSamplesByScan.size()), + &samplesRead, + nullptr); + chunk.digitalSamplesByScan.resize(samplesRead); + } + if (result < 0) + { + reportReadError(m_api.error(result)); + return; + } + if (samplesRead > 0) + { + task.nextSample += static_cast(samplesRead); + emit inputDataReady(chunk); + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + } + + void releaseHardware() + { + m_reading.store(false); + if (m_reader.joinable()) + { + m_reader.join(); + } + for (auto it = m_tasks.rbegin(); it != m_tasks.rend(); ++it) + { + m_api.stopTask(it->handle); + m_api.clearTask(it->handle); + } + m_tasks.clear(); + for (auto it = m_routes.rbegin(); it != m_routes.rend(); ++it) + { + m_api.disconnectTerminal(it->first.constData(), it->second.constData()); + } + m_routes.clear(); + } + + void reportReadError(const QString& message) + { + QMetaObject::invokeMethod(this, [this, message]() + { + if (m_state == DaqState::Armed || m_state == DaqState::Running) + { + fail(message, nullptr); + } + }, Qt::QueuedConnection); + } + + bool fail(const QString& message, QString* errorMessage) + { + releaseHardware(); + if (errorMessage) + { + *errorMessage = message; + } + emitState(DaqState::Error, message); + emit controllerError(message); + return false; + } + + void emitState(DaqState state, const QString& message) + { + m_state = state; + m_message = message; + emit stateChanged(state, message); + } + + QString m_device; + DaqmxApi m_api; + std::vector m_tasks; + std::vector> m_routes; + std::atomic_bool m_reading{false}; + std::thread m_reader; + std::atomic m_state{DaqState::Idle}; + QString m_message{QStringLiteral("DAQ device is idle")}; + }; + + void appendChannels(DaqDeviceDescriptor& descriptor, + const QString& names, + DaqChannelType type) + { + for (const QString& name : splitNames(names.toLocal8Bit())) + { + descriptor.channels.append({name, type}); + } + } + } + + QList NIDaqmxPlugin::devices() const + { + DaqmxApi api; + if (!api.load(false)) + { + return {}; + } + + QList result; + for (const QString& device : splitNames( + readSystemString(api.getSystemDeviceNames).toLocal8Bit())) + { + DaqDeviceDescriptor descriptor; + descriptor.id = QStringLiteral("ni:%1").arg(device); + descriptor.name = device; + descriptor.provider = QStringLiteral("National Instruments"); + descriptor.product = readDeviceString(api.getProductType, device); + appendChannels(descriptor, + readDeviceString(api.getAiChannels, device), + DaqChannelType::AnalogInput); + appendChannels(descriptor, + readDeviceString(api.getAoChannels, device), + DaqChannelType::AnalogOutput); + appendChannels(descriptor, + readDeviceString(api.getDiLines, device), + DaqChannelType::DigitalInput); + appendChannels(descriptor, + readDeviceString(api.getDoLines, device), + DaqChannelType::DigitalOutput); + appendChannels(descriptor, + readDeviceString(api.getCiChannels, device), + DaqChannelType::CounterInput); + appendChannels(descriptor, + readDeviceString(api.getCoChannels, device), + DaqChannelType::CounterOutput); + descriptor.terminals = splitNames( + readDeviceString(api.getTerminals, device).toLocal8Bit()); + result.append(std::move(descriptor)); + } + return result; + } + + DaqController* NIDaqmxPlugin::createController(const QString& deviceId, + QObject* parent) + { + const QString device = nativeDeviceId(deviceId.trimmed()); + return new NIDaqmxController(device, parent); + } +} diff --git a/plugins/hardware/NIDaqmx/NIDaqmxPlugin.h b/plugins/hardware/NIDaqmx/NIDaqmxPlugin.h new file mode 100644 index 0000000..de9a5b5 --- /dev/null +++ b/plugins/hardware/NIDaqmx/NIDaqmxPlugin.h @@ -0,0 +1,22 @@ +#pragma once + +#include "scopeone/DaqDevice.h" + +#include + +namespace scopeone::plugins +{ + class NIDaqmxPlugin final : public QObject, + public scopeone::core::DaqDevicePlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID SCOPEONE_DAQ_DEVICE_PLUGIN_IID FILE "plugin.json") + Q_INTERFACES(scopeone::core::DaqDevicePlugin) + + public: + QList devices() const override; + scopeone::core::DaqController* createController( + const QString& deviceId, + QObject* parent = nullptr) override; + }; +} diff --git a/plugins/hardware/NIDaqmx/plugin.json b/plugins/hardware/NIDaqmx/plugin.json new file mode 100644 index 0000000..35e6102 --- /dev/null +++ b/plugins/hardware/NIDaqmx/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "scopeone.nidaqmx", + "name": "NI-DAQmx Device Plugin", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "hardware" +} diff --git a/plugins/hardware/PtuFile/CMakeLists.txt b/plugins/hardware/PtuFile/CMakeLists.txt new file mode 100644 index 0000000..eb7394a --- /dev/null +++ b/plugins/hardware/PtuFile/CMakeLists.txt @@ -0,0 +1,11 @@ +add_library(ScopeOnePtuFile MODULE + PtuFilePlugin.cpp + PtuFilePlugin.h + plugin.json +) +set_target_properties(ScopeOnePtuFile PROPERTIES OUTPUT_NAME "PtuFilePlugin") +target_link_libraries(ScopeOnePtuFile PRIVATE + scopeone::PluginSDK + Qt6::Core +) +scopeone_add_plugin(ScopeOnePtuFile hardware) diff --git a/plugins/hardware/PtuFile/PtuFilePlugin.cpp b/plugins/hardware/PtuFile/PtuFilePlugin.cpp new file mode 100644 index 0000000..dc8fbbe --- /dev/null +++ b/plugins/hardware/PtuFile/PtuFilePlugin.cpp @@ -0,0 +1,593 @@ +#include "PtuFilePlugin.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::plugins +{ + using namespace scopeone::core; + + namespace + { + constexpr auto kSourceId = "ptu:file"; + constexpr qsizetype kTagSize = 48; + constexpr qsizetype kRecordsPerChunk = 65536; + + constexpr quint32 kTagEmpty = 0xffff0008U; + constexpr quint32 kTagBool = 0x00000008U; + constexpr quint32 kTagInt = 0x10000008U; + constexpr quint32 kTagBitSet = 0x11000008U; + constexpr quint32 kTagColor = 0x12000008U; + constexpr quint32 kTagFloat = 0x20000008U; + constexpr quint32 kTagDateTime = 0x21000008U; + constexpr quint32 kTagFloatArray = 0x2001ffffU; + constexpr quint32 kTagAnsiString = 0x4001ffffU; + constexpr quint32 kTagWideString = 0x4002ffffU; + constexpr quint32 kTagBinaryBlob = 0xffffffffU; + + constexpr quint32 kPicoHarpT3 = 0x00010303U; + constexpr quint32 kPicoHarpT2 = 0x00010203U; + constexpr quint32 kHydraHarpT3 = 0x00010304U; + constexpr quint32 kHydraHarpT2 = 0x00010204U; + constexpr quint32 kHydraHarp2T3 = 0x01010304U; + constexpr quint32 kHydraHarp2T2 = 0x01010204U; + constexpr quint32 kTimeHarp260NT3 = 0x00010305U; + constexpr quint32 kTimeHarp260NT2 = 0x00010205U; + constexpr quint32 kTimeHarp260PT3 = 0x00010306U; + constexpr quint32 kTimeHarp260PT2 = 0x00010206U; + constexpr quint32 kGenericT3 = 0x00010307U; + constexpr quint32 kGenericT2 = 0x00010207U; + + enum class Decoder + { + PicoT2, + PicoT3, + HydraT2V1, + HydraT3V1, + HydraT2V2, + HydraT3V2 + }; + + struct PtuHeader + { + quint64 recordCount{0}; + quint32 recordType{0}; + double globalResolutionSeconds{0.0}; + Decoder decoder{Decoder::PicoT2}; + }; + + struct PtuSettings + { + QString filePath; + int detectorChannel{1}; + double sampleIntervalSeconds{0.01}; + bool publishEvents{false}; + }; + + QString tagIdentifier(const QByteArray& bytes) + { + const int terminator = bytes.indexOf('\0'); + return QString::fromLatin1(terminator >= 0 ? bytes.first(terminator) : bytes); + } + + double tagDouble(qint64 value) + { + const quint64 bits = static_cast(value); + double result = 0.0; + std::memcpy(&result, &bits, sizeof(result)); + return result; + } + + bool decoderForRecordType(quint32 recordType, Decoder& decoder) + { + switch (recordType) + { + case kPicoHarpT2: + decoder = Decoder::PicoT2; + return true; + case kPicoHarpT3: + decoder = Decoder::PicoT3; + return true; + case kHydraHarpT2: + decoder = Decoder::HydraT2V1; + return true; + case kHydraHarpT3: + decoder = Decoder::HydraT3V1; + return true; + case kHydraHarp2T2: + case kTimeHarp260NT2: + case kTimeHarp260PT2: + case kGenericT2: + decoder = Decoder::HydraT2V2; + return true; + case kHydraHarp2T3: + case kTimeHarp260NT3: + case kTimeHarp260PT3: + case kGenericT3: + decoder = Decoder::HydraT3V2; + return true; + default: + return false; + } + } + + bool skipTagPayload(QFile& file, qint64 byteCount, QString& errorMessage) + { + if (byteCount < 0 || byteCount > file.size() - file.pos() + || !file.seek(file.pos() + byteCount)) + { + errorMessage = QStringLiteral("Invalid PTU tag payload length"); + return false; + } + return true; + } + + bool readHeader(QFile& file, PtuHeader& header, QString& errorMessage) + { + const QByteArray magic = file.read(8); + const QByteArray version = file.read(8); + if (magic.size() != 8 || version.size() != 8 + || !magic.startsWith("PQTTTR")) + { + errorMessage = QStringLiteral("The selected file is not a PTU file"); + return false; + } + + qint64 recordCount = -1; + qint64 recordType = -1; + double globalResolution = 0.0; + while (true) + { + const QByteArray rawTag = file.read(kTagSize); + if (rawTag.size() != kTagSize) + { + errorMessage = QStringLiteral("Incomplete PTU header"); + return false; + } + const auto* bytes = reinterpret_cast(rawTag.constData()); + const QString identifier = tagIdentifier(rawTag.first(32)); + const quint32 type = qFromLittleEndian(bytes + 36); + const qint64 value = qFromLittleEndian(bytes + 40); + if (identifier == QStringLiteral("TTResult_NumberOfRecords")) + { + recordCount = value; + } + else if (identifier == QStringLiteral("TTResultFormat_TTTRRecType")) + { + recordType = value; + } + else if (identifier == QStringLiteral("MeasDesc_GlobalResolution")) + { + globalResolution = tagDouble(value); + } + + if (type == kTagFloatArray || type == kTagAnsiString + || type == kTagWideString || type == kTagBinaryBlob) + { + if (!skipTagPayload(file, value, errorMessage)) + { + return false; + } + } + else if (type != kTagEmpty && type != kTagBool && type != kTagInt + && type != kTagBitSet && type != kTagColor + && type != kTagFloat && type != kTagDateTime) + { + errorMessage = QStringLiteral("Unsupported PTU tag type 0x%1") + .arg(type, 8, 16, QLatin1Char('0')); + return false; + } + if (identifier == QStringLiteral("Header_End")) + { + break; + } + } + + Decoder decoder; + if (recordCount < 0 || recordType < 0 + || !std::isfinite(globalResolution) + || globalResolution <= 0.0 + || !decoderForRecordType(static_cast(recordType), decoder)) + { + errorMessage = QStringLiteral("Unsupported or incomplete PTU measurement header"); + return false; + } + header.recordCount = static_cast(recordCount); + header.recordType = static_cast(recordType); + header.globalResolutionSeconds = globalResolution; + header.decoder = decoder; + return true; + } + + SignalSourceDescriptor sourceDescriptor() + { + SignalSourceDescriptor descriptor; + descriptor.id = QString::fromLatin1(kSourceId); + descriptor.name = QStringLiteral("PTU File"); + descriptor.provider = QStringLiteral("PicoQuant"); + descriptor.quantity = QStringLiteral("Photon count"); + descriptor.unit = QStringLiteral("photons"); + descriptor.streamType = SignalStreamType::TimestampedEvents; + + SignalParameterDescriptor file; + file.key = QStringLiteral("filePath"); + file.name = QStringLiteral("PTU file"); + file.type = SignalParameterType::File; + file.fileFilter = QStringLiteral("PicoQuant PTU files (*.ptu);;All files (*)"); + descriptor.parameters.append(file); + + SignalParameterDescriptor channel; + channel.key = QStringLiteral("detectorChannel"); + channel.name = QStringLiteral("Detector channel"); + channel.type = SignalParameterType::Integer; + channel.defaultValue = 1; + channel.hasRange = true; + channel.minimum = 0; + channel.maximum = 64; + descriptor.parameters.append(channel); + return descriptor; + } + + class PtuFileSource final : public SignalSource + { + public: + explicit PtuFileSource(QObject* parent = nullptr) + : SignalSource(parent) + { + } + + ~PtuFileSource() override + { + std::lock_guard lock(m_threadMutex); + if (m_worker.joinable()) + { + m_worker.request_stop(); + m_worker.join(); + } + } + + bool start(const SignalAcquisitionConfig& config, + QString* errorMessage) override + { + const QString filePath = config.sourceSettings + .value(QStringLiteral("filePath")).toString().trimmed(); + if (config.sourceId.trimmed() != QString::fromLatin1(kSourceId) + || !QFileInfo::exists(filePath) + || !QFileInfo(filePath).isFile()) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Select an existing PTU file"); + } + return false; + } + + std::lock_guard lock(m_threadMutex); + if (m_state == SignalSourceState::Starting + || m_state == SignalSourceState::Running + || m_state == SignalSourceState::Stopping) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Signal source is already active"); + } + return false; + } + if (m_worker.joinable()) + { + m_worker.join(); + } + + PtuSettings settings; + settings.filePath = filePath; + settings.detectorChannel = config.sourceSettings + .value(QStringLiteral("detectorChannel"), 1).toInt(); + settings.sampleIntervalSeconds = config.sampleIntervalSeconds; + settings.publishEvents = config.publishTimestampedEvents + || config.scanImage.enabled; + setState(SignalSourceState::Starting, QStringLiteral("Opening PTU file")); + m_worker = std::jthread([this, settings](std::stop_token stopToken) + { + run(stopToken, settings); + }); + return true; + } + + void stop() override + { + std::lock_guard lock(m_threadMutex); + if (m_state == SignalSourceState::Starting + || m_state == SignalSourceState::Running) + { + setState(SignalSourceState::Stopping, QStringLiteral("Stopping PTU read")); + m_worker.request_stop(); + } + } + + SignalSourceState state() const override + { + return m_state.load(); + } + + QString stateMessage() const override + { + std::lock_guard lock(m_stateMutex); + return m_stateMessage; + } + + private: + void setState(SignalSourceState state, const QString& message) + { + { + std::lock_guard lock(m_stateMutex); + m_stateMessage = message; + m_state.store(state); + } + emit stateChanged(state, message); + } + + void fail(const QString& message) + { + setState(SignalSourceState::Error, message); + emit sourceError(message); + } + + void run(std::stop_token stopToken, const PtuSettings& settings) + { + QFile file(settings.filePath); + if (!file.open(QIODevice::ReadOnly)) + { + fail(QStringLiteral("Failed to open PTU file: %1").arg(file.errorString())); + return; + } + + PtuHeader header; + QString errorMessage; + if (!readHeader(file, header, errorMessage)) + { + fail(errorMessage); + return; + } + setState(SignalSourceState::Running, + QStringLiteral("Reading %1 PTU records").arg(header.recordCount)); + + EventCountBinner binner(QString::fromLatin1(kSourceId), + QStringLiteral("Photon count"), + QStringLiteral("photons"), + header.globalResolutionSeconds, + settings.sampleIntervalSeconds); + quint64 overflowCorrection = 0; + quint64 processedRecords = 0; + quint64 photonCount = 0; + quint64 markerCount = 0; + quint64 lastTick = 0; + bool hasTick = false; + + while (processedRecords < header.recordCount + && !stopToken.stop_requested()) + { + const qsizetype count = static_cast(std::min( + kRecordsPerChunk, header.recordCount - processedRecords)); + const QByteArray rawRecords = file.read(count * 4); + if (rawRecords.size() != count * 4) + { + fail(QStringLiteral("Unexpected end of PTU record data")); + return; + } + + TimestampedEventChunk events; + if (settings.publishEvents) + { + events.sourceId = QString::fromLatin1(kSourceId); + events.tickPeriodSeconds = header.globalResolutionSeconds; + events.eventTicks.reserve(count); + events.eventCodes.reserve(count); + } + + const auto addPhoton = [&](quint64 tick, quint32 channel) + { + lastTick = tick; + hasTick = true; + if (static_cast(channel) == settings.detectorChannel) + { + binner.addEvent(tick); + ++photonCount; + if (settings.publishEvents) + { + events.eventTicks.append(tick); + events.eventCodes.append(channel); + } + } + else + { + binner.advanceToTick(tick); + } + }; + const auto addMarker = [&](quint64 tick, quint32 code) + { + lastTick = tick; + hasTick = true; + binner.addMarker(tick, code); + ++markerCount; + if (settings.publishEvents) + { + events.markerTicks.append(tick); + events.markerCodes.append(code); + } + }; + const auto advanceOverflow = [&](quint64 tick) + { + lastTick = tick; + hasTick = true; + binner.advanceToTick(tick); + }; + + const auto* bytes = reinterpret_cast(rawRecords.constData()); + for (qsizetype index = 0; index < count; ++index) + { + const quint32 record = qFromLittleEndian(bytes + index * 4); + switch (header.decoder) + { + case Decoder::PicoT2: + { + constexpr quint64 wraparound = 210698240ULL; + const quint32 channel = record >> 28; + const quint32 time = record & 0x0fffffffU; + if (channel != 0x0fU) + { + addPhoton(overflowCorrection + time, channel); + } + else if ((time & 0x0fU) == 0) + { + overflowCorrection += wraparound; + advanceOverflow(overflowCorrection); + } + else + { + addMarker(overflowCorrection + (time & 0x0ffffff0U), + time & 0x0fU); + } + break; + } + case Decoder::PicoT3: + { + constexpr quint64 wraparound = 65536ULL; + const quint32 channel = record >> 28; + const quint32 nsync = record & 0x0000ffffU; + const quint32 dtime = (record >> 16) & 0x00000fffU; + if (channel != 0x0fU) + { + addPhoton(overflowCorrection + nsync, channel); + } + else if (dtime == 0) + { + overflowCorrection += wraparound; + advanceOverflow(overflowCorrection); + } + else + { + addMarker(overflowCorrection + nsync, dtime); + } + break; + } + case Decoder::HydraT2V1: + case Decoder::HydraT2V2: + { + constexpr quint64 wraparoundV1 = 33552000ULL; + constexpr quint64 wraparoundV2 = 33554432ULL; + const bool version1 = header.decoder == Decoder::HydraT2V1; + const bool special = (record & 0x80000000U) != 0; + const quint32 channel = (record >> 25) & 0x3fU; + const quint32 time = record & 0x01ffffffU; + if (!special) + { + addPhoton(overflowCorrection + time, channel + 1); + } + else if (channel == 0x3fU) + { + const quint64 overflows = version1 || time == 0 ? 1 : time; + overflowCorrection += overflows + * (version1 ? wraparoundV1 : wraparoundV2); + advanceOverflow(overflowCorrection); + } + else if (channel >= 1 && channel <= 15) + { + addMarker(overflowCorrection + time, channel); + } + else if (channel == 0) + { + addPhoton(overflowCorrection + time, 0); + } + break; + } + case Decoder::HydraT3V1: + case Decoder::HydraT3V2: + { + constexpr quint64 wraparound = 1024ULL; + const bool version1 = header.decoder == Decoder::HydraT3V1; + const bool special = (record & 0x80000000U) != 0; + const quint32 channel = (record >> 25) & 0x3fU; + const quint32 nsync = record & 0x000003ffU; + if (!special) + { + addPhoton(overflowCorrection + nsync, channel); + } + else if (channel == 0x3fU) + { + const quint64 overflows = version1 || nsync == 0 ? 1 : nsync; + overflowCorrection += overflows * wraparound; + advanceOverflow(overflowCorrection); + } + else if (channel >= 1 && channel <= 15) + { + addMarker(overflowCorrection + nsync, channel); + } + break; + } + } + if (binner.hasReadyChunks()) + { + for (const TimeSeriesChunk& chunk : binner.takeReadyChunks()) + { + emit timeSeriesReady(chunk); + } + } + } + processedRecords += static_cast(count); + if (events.isValid()) + { + emit timestampedEventsReady(events); + } + } + + if (stopToken.stop_requested()) + { + setState(SignalSourceState::Idle, QStringLiteral("PTU read stopped")); + return; + } + if (hasTick) + { + binner.advanceToElapsedSeconds( + static_cast(lastTick) * header.globalResolutionSeconds + + settings.sampleIntervalSeconds); + } + for (const TimeSeriesChunk& chunk : binner.takeCompletedChunks()) + { + emit timeSeriesReady(chunk); + } + setState(SignalSourceState::Idle, + QStringLiteral("PTU read complete: %1 photons, %2 markers") + .arg(photonCount) + .arg(markerCount)); + } + + mutable std::mutex m_stateMutex; + std::mutex m_threadMutex; + std::jthread m_worker; + std::atomic m_state{SignalSourceState::Idle}; + QString m_stateMessage{QStringLiteral("PTU file source is idle")}; + }; + } + + QList PtuFilePlugin::signalSources() const + { + return {sourceDescriptor()}; + } + + SignalSource* PtuFilePlugin::createSignalSource(const QString& sourceId, + QObject* parent) + { + return sourceId.trimmed() == QString::fromLatin1(kSourceId) + ? new PtuFileSource(parent) + : nullptr; + } +} diff --git a/plugins/hardware/PtuFile/PtuFilePlugin.h b/plugins/hardware/PtuFile/PtuFilePlugin.h new file mode 100644 index 0000000..21aa99b --- /dev/null +++ b/plugins/hardware/PtuFile/PtuFilePlugin.h @@ -0,0 +1,22 @@ +#pragma once + +#include "scopeone/SignalSource.h" + +#include + +namespace scopeone::plugins +{ + class PtuFilePlugin final : public QObject, + public scopeone::core::SignalSourcePlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID SCOPEONE_SIGNAL_SOURCE_PLUGIN_IID FILE "plugin.json") + Q_INTERFACES(scopeone::core::SignalSourcePlugin) + + public: + QList signalSources() const override; + scopeone::core::SignalSource* createSignalSource( + const QString& sourceId, + QObject* parent = nullptr) override; + }; +} diff --git a/plugins/hardware/PtuFile/plugin.json b/plugins/hardware/PtuFile/plugin.json new file mode 100644 index 0000000..bf13605 --- /dev/null +++ b/plugins/hardware/PtuFile/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "scopeone.ptu-file", + "name": "PicoQuant PTU Source", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "hardware" +} diff --git a/plugins/hardware/SimulatedPmt/CMakeLists.txt b/plugins/hardware/SimulatedPmt/CMakeLists.txt new file mode 100644 index 0000000..850510a --- /dev/null +++ b/plugins/hardware/SimulatedPmt/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(ScopeOneSimulatedPmt MODULE + SimulatedPmtPlugin.cpp + SimulatedPmtPlugin.h + SimulatedPmtSource.cpp + SimulatedPmtSource.h + plugin.json +) + +target_link_libraries(ScopeOneSimulatedPmt PRIVATE + scopeone::PluginSDK + Qt6::Core +) + +scopeone_add_plugin(ScopeOneSimulatedPmt hardware) diff --git a/plugins/hardware/SimulatedPmt/SimulatedPmtPlugin.cpp b/plugins/hardware/SimulatedPmt/SimulatedPmtPlugin.cpp new file mode 100644 index 0000000..83d2423 --- /dev/null +++ b/plugins/hardware/SimulatedPmt/SimulatedPmtPlugin.cpp @@ -0,0 +1,141 @@ +#include "SimulatedPmtPlugin.h" + +#include "SimulatedPmtSource.h" + +namespace scopeone::plugins +{ + namespace + { + constexpr auto kSourceId = "pmt:simulator"; + + scopeone::core::SignalParameterDescriptor realParameter( + const QString& key, + const QString& name, + double value, + double minimum, + double maximum, + const QString& suffix) + { + scopeone::core::SignalParameterDescriptor parameter; + parameter.key = key; + parameter.name = name; + parameter.type = scopeone::core::SignalParameterType::Real; + parameter.defaultValue = value; + parameter.hasRange = true; + parameter.minimum = minimum; + parameter.maximum = maximum; + parameter.suffix = suffix; + return parameter; + } + + scopeone::core::SignalParameterDescriptor choiceParameter( + const QString& key, + const QString& name, + const QStringList& values, + const QStringList& names, + const QString& value) + { + scopeone::core::SignalParameterDescriptor parameter; + parameter.key = key; + parameter.name = name; + parameter.type = scopeone::core::SignalParameterType::Choice; + parameter.defaultValue = value; + for (int index = 0; index < values.size(); ++index) + { + parameter.choices.append(values[index]); + parameter.choiceNames.append(names[index]); + } + return parameter; + } + + scopeone::core::SignalSourceDescriptor sourceDescriptor() + { + using namespace scopeone::core; + + SignalSourceDescriptor descriptor; + descriptor.id = QString::fromLatin1(kSourceId); + descriptor.name = QStringLiteral("Simulated PMT"); + descriptor.provider = QStringLiteral("ScopeOne"); + descriptor.quantity = QStringLiteral("Photon count rate"); + descriptor.unit = QStringLiteral("counts/s"); + descriptor.streamType = SignalStreamType::TimestampedEvents; + descriptor.parameters.append(realParameter( + QStringLiteral("voltage"), + QStringLiteral("Control voltage"), + 1.0, + 0.0, + 1.25, + QStringLiteral(" V"))); + descriptor.parameters.append(realParameter( + QStringLiteral("gain"), + QStringLiteral("Gain"), + 1.0, + 1.0, + 100.0, + QString())); + descriptor.parameters.append(realParameter( + QStringLiteral("baseRate"), + QStringLiteral("Base rate"), + 1000000.0, + 10000.0, + 1000000.0, + QStringLiteral(" counts/s"))); + descriptor.parameters.append(realParameter( + QStringLiteral("darkCountRate"), + QStringLiteral("Dark count rate"), + 500.0, + 0.0, + 5000.0, + QStringLiteral(" counts/s"))); + descriptor.parameters.append(realParameter( + QStringLiteral("modulationFrequency"), + QStringLiteral("Modulation frequency"), + 2.0, + 0.0, + 1000.0, + QStringLiteral(" Hz"))); + descriptor.parameters.append(realParameter( + QStringLiteral("scanFrameRate"), + QStringLiteral("Scan frame rate"), + 2.0, + 0.5, + 10.0, + QStringLiteral(" Hz"))); + descriptor.parameters.append(choiceParameter( + QStringLiteral("waveform"), + QStringLiteral("Waveform"), + {QStringLiteral("constant"), QStringLiteral("sine"), QStringLiteral("square")}, + {QStringLiteral("Constant"), QStringLiteral("Sine"), QStringLiteral("Square")}, + QStringLiteral("constant"))); + descriptor.parameters.append(choiceParameter( + QStringLiteral("noiseMode"), + QStringLiteral("Noise"), + {QStringLiteral("poisson"), QStringLiteral("gaussian"), QStringLiteral("none")}, + {QStringLiteral("Poisson"), QStringLiteral("Gaussian"), QStringLiteral("None")}, + QStringLiteral("poisson"))); + descriptor.parameters.append(choiceParameter( + QStringLiteral("pattern"), + QStringLiteral("Pattern"), + {QStringLiteral("beads"), QStringLiteral("usaf"), + QStringLiteral("grid"), QStringLiteral("cells")}, + {QStringLiteral("Fluorescent Beads"), QStringLiteral("USAF Target"), + QStringLiteral("Concentric Grid"), QStringLiteral("Cell Structure")}, + QStringLiteral("beads"))); + return descriptor; + } + } + + QList SimulatedPmtPlugin::signalSources() const + { + return {sourceDescriptor()}; + } + + scopeone::core::SignalSource* SimulatedPmtPlugin::createSignalSource( + const QString& sourceId, + QObject* parent) + { + return sourceId.trimmed() == QString::fromLatin1(kSourceId) + ? new SimulatedPmtSource(parent) + : nullptr; + } +} diff --git a/plugins/hardware/SimulatedPmt/SimulatedPmtPlugin.h b/plugins/hardware/SimulatedPmt/SimulatedPmtPlugin.h new file mode 100644 index 0000000..f4c8954 --- /dev/null +++ b/plugins/hardware/SimulatedPmt/SimulatedPmtPlugin.h @@ -0,0 +1,22 @@ +#pragma once + +#include "scopeone/SignalSource.h" + +#include + +namespace scopeone::plugins +{ + class SimulatedPmtPlugin final : public QObject, + public scopeone::core::SignalSourcePlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID SCOPEONE_SIGNAL_SOURCE_PLUGIN_IID FILE "plugin.json") + Q_INTERFACES(scopeone::core::SignalSourcePlugin) + + public: + QList signalSources() const override; + scopeone::core::SignalSource* createSignalSource( + const QString& sourceId, + QObject* parent = nullptr) override; + }; +} diff --git a/plugins/hardware/SimulatedPmt/SimulatedPmtSource.cpp b/plugins/hardware/SimulatedPmt/SimulatedPmtSource.cpp new file mode 100644 index 0000000..a3c8dec --- /dev/null +++ b/plugins/hardware/SimulatedPmt/SimulatedPmtSource.cpp @@ -0,0 +1,467 @@ +#include "SimulatedPmtSource.h" + +#include +#include +#include +#include +#include +#include + +namespace scopeone::plugins +{ + using namespace scopeone::core; + + namespace + { + constexpr auto kSourceId = "pmt:simulator"; + constexpr double kTickPeriodSeconds = 1.0e-9; + constexpr double Pi = 3.14159265358979323846; + constexpr quint32 kPhotonCode = 1; + + int photonCount(double mean, + const QString& noiseMode, + std::mt19937_64& random) + { + if (noiseMode == QStringLiteral("none")) + { + return std::max(0, static_cast(std::llround(mean))); + } + if (noiseMode == QStringLiteral("gaussian")) + { + const double sigma = std::sqrt(std::max(0.0, mean)); + return std::max(0, static_cast(std::llround( + std::normal_distribution(mean, sigma)(random)))); + } + return std::poisson_distribution(mean)(random); + } + + double gaussianSpot(double x, + double y, + double centerX, + double centerY, + double sigma) + { + const double dx = x - centerX; + const double dy = y - centerY; + return std::exp(-(dx * dx + dy * dy) / (2.0 * sigma * sigma)); + } + + struct Bead + { + double x; + double y; + double brightness; + }; + + constexpr std::array Beads{{ + {0.08, 0.10, 0.91}, {0.19, 0.08, 0.84}, {0.32, 0.12, 0.98}, + {0.46, 0.07, 0.88}, {0.62, 0.11, 0.94}, {0.77, 0.08, 0.82}, + {0.91, 0.13, 0.96}, {0.13, 0.25, 0.87}, {0.29, 0.29, 1.00}, + {0.48, 0.23, 0.83}, {0.69, 0.27, 0.92}, {0.87, 0.31, 0.86}, + {0.08, 0.43, 0.95}, {0.24, 0.47, 0.81}, {0.42, 0.39, 0.90}, + {0.61, 0.45, 0.97}, {0.80, 0.42, 0.85}, {0.94, 0.50, 0.93}, + {0.14, 0.63, 0.89}, {0.34, 0.58, 0.99}, {0.53, 0.66, 0.84}, + {0.72, 0.61, 0.96}, {0.89, 0.68, 0.80}, {0.07, 0.82, 0.92}, + {0.22, 0.88, 0.86}, {0.39, 0.79, 0.95}, {0.58, 0.86, 0.82}, + {0.76, 0.81, 0.98}, {0.92, 0.89, 0.88}, {0.47, 0.94, 0.91}, + {0.30, 0.70, 0.85}, {0.67, 0.75, 0.94} + }}; + + struct BeadDoublet + { + double x; + double y; + double firstBrightness; + double secondBrightness; + }; + + constexpr std::array BeadDoublets{{ + {0.183, 0.176, 1.00, 0.92}, + {0.543, 0.335, 0.88, 0.96}, + {0.373, 0.515, 0.98, 0.86}, + {0.713, 0.925, 0.91, 1.00} + }}; + + double patternIntensity(const QString& pattern, + int column, + int line, + int width, + int height) + { + const double x = (column + 0.5) / width; + const double y = (line + 0.5) / height; + if (pattern == QStringLiteral("beads")) + { + const double resolutionScale = std::min(width, height) / 256.0; + const double fwhmPixels = 4.0 * resolutionScale; + const double sigmaPixels = fwhmPixels / 2.354820045; + double value = 0.002; + for (const Bead& bead : Beads) + { + value += bead.brightness + * gaussianSpot(column + 0.5, + line + 0.5, + bead.x * width, + bead.y * height, + sigmaPixels); + } + const double doubletSpacingPixels = 4.0 * resolutionScale; + for (const BeadDoublet& doublet : BeadDoublets) + { + const double centerX = doublet.x * width; + const double centerY = doublet.y * height; + value += doublet.firstBrightness + * gaussianSpot(column + 0.5, + line + 0.5, + centerX - doubletSpacingPixels * 0.5, + centerY, + sigmaPixels); + value += doublet.secondBrightness + * gaussianSpot(column + 0.5, + line + 0.5, + centerX + doubletSpacingPixels * 0.5, + centerY, + sigmaPixels); + } + return std::min(value, 1.0); + } + if (pattern == QStringLiteral("usaf")) + { + const double barX = std::abs(std::sin(2.0 * Pi * 18.0 * x)); + const double barY = std::abs(std::sin(2.0 * Pi * 12.0 * y)); + return (x > 0.15 && x < 0.85 && y > 0.2 && y < 0.8) + ? std::max(barX, barY) + : 0.02; + } + if (pattern == QStringLiteral("grid")) + { + const double dx = x - 0.5; + const double dy = y - 0.5; + const double radius = std::sqrt(dx * dx + dy * dy); + const double rings = 0.5 + 0.5 * std::cos(2.0 * Pi * 18.0 * radius); + const double lines = 0.5 + 0.5 * std::cos(2.0 * Pi * 8.0 * x) + * std::cos(2.0 * Pi * 8.0 * y); + return std::max(rings, lines); + } + + double value = 0.02; + value = std::max(value, gaussianSpot(x, y, 0.28, 0.35, 0.07)); + value = std::max(value, gaussianSpot(x, y, 0.62, 0.32, 0.09)); + value = std::max(value, gaussianSpot(x, y, 0.45, 0.65, 0.10)); + value = std::max(value, + 0.5 + 0.5 * std::sin(2.0 * Pi * (4.0 * x + 2.0 * y))); + return value; + } + } + + SimulatedPmtSource::SimulatedPmtSource(QObject* parent) + : SignalSource(parent) + { + } + + SimulatedPmtSource::~SimulatedPmtSource() + { + std::lock_guard lock(m_threadMutex); + if (m_worker.joinable()) + { + m_worker.request_stop(); + m_worker.join(); + } + } + + bool SimulatedPmtSource::start(const SignalAcquisitionConfig& config, + QString* errorMessage) + { + if (config.sourceId.trimmed() != QString::fromLatin1(kSourceId)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Unknown simulated PMT source"); + } + return false; + } + + std::lock_guard lock(m_threadMutex); + if (m_state == SignalSourceState::Starting + || m_state == SignalSourceState::Running + || m_state == SignalSourceState::Stopping) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Simulated PMT is already active"); + } + return false; + } + if (m_worker.joinable()) + { + m_worker.join(); + } + + Settings settings; + settings.voltage = config.sourceSettings + .value(QStringLiteral("voltage"), settings.voltage).toDouble(); + settings.gain = config.sourceSettings + .value(QStringLiteral("gain"), settings.gain).toDouble(); + settings.baseRate = config.sourceSettings + .value(QStringLiteral("baseRate"), settings.baseRate).toDouble(); + settings.darkCountRate = config.sourceSettings + .value(QStringLiteral("darkCountRate"), settings.darkCountRate).toDouble(); + settings.modulationFrequency = config.sourceSettings + .value(QStringLiteral("modulationFrequency"), settings.modulationFrequency).toDouble(); + settings.scanFrameRate = config.sourceSettings + .value(QStringLiteral("scanFrameRate"), settings.scanFrameRate).toDouble(); + settings.waveform = config.sourceSettings + .value(QStringLiteral("waveform"), settings.waveform).toString(); + settings.noiseMode = config.sourceSettings + .value(QStringLiteral("noiseMode"), settings.noiseMode).toString(); + settings.pattern = config.sourceSettings + .value(QStringLiteral("pattern"), settings.pattern).toString(); + + setState(SignalSourceState::Starting, QStringLiteral("Starting simulated PMT")); + m_worker = std::jthread([this, config, settings](std::stop_token stopToken) + { + run(stopToken, config, settings); + }); + return true; + } + + void SimulatedPmtSource::stop() + { + std::lock_guard lock(m_threadMutex); + if (m_state == SignalSourceState::Starting + || m_state == SignalSourceState::Running) + { + setState(SignalSourceState::Stopping, QStringLiteral("Stopping simulated PMT")); + m_worker.request_stop(); + } + } + + SignalSourceState SimulatedPmtSource::state() const + { + return m_state.load(); + } + + QString SimulatedPmtSource::stateMessage() const + { + std::lock_guard lock(m_stateMutex); + return m_stateMessage; + } + + void SimulatedPmtSource::setState(SignalSourceState state, const QString& message) + { + { + std::lock_guard lock(m_stateMutex); + m_stateMessage = message; + m_state.store(state); + } + emit stateChanged(state, message); + } + + double SimulatedPmtSource::rateAt(double timeSeconds, const Settings& settings) const + { + const double gain = settings.gain * std::pow(settings.voltage, 3.0); + const double signalRate = settings.baseRate * gain + settings.darkCountRate; + const double phase = 2.0 * Pi * settings.modulationFrequency * timeSeconds; + + if (settings.waveform == QStringLiteral("sine")) + { + return signalRate * (0.15 + 0.85 * (0.5 + 0.5 * std::sin(phase))); + } + if (settings.waveform == QStringLiteral("square")) + { + return signalRate * (std::sin(phase) >= 0.0 ? 1.0 : 0.15); + } + return signalRate; + } + + void SimulatedPmtSource::run(std::stop_token stopToken, + const SignalAcquisitionConfig& config, + const Settings& settings) + { + setState(SignalSourceState::Running, QStringLiteral("Simulated PMT is running")); + if (config.scanImage.enabled) + { + runScan(stopToken, config, settings); + } + else + { + runStream(stopToken, config, settings); + } + + if (stopToken.stop_requested()) + { + setState(SignalSourceState::Idle, QStringLiteral("Simulated PMT stopped")); + } + else + { + setState(SignalSourceState::Idle, QStringLiteral("Simulated PMT finished")); + } + } + + void SimulatedPmtSource::runStream(std::stop_token stopToken, + const SignalAcquisitionConfig& config, + const Settings& settings) + { + constexpr int chunkSize = 64; + const double sampleInterval = config.sampleIntervalSeconds; + const auto startTime = std::chrono::steady_clock::now(); + std::mt19937_64 random(std::random_device{}()); + quint64 sampleIndex = 0; + quint64 totalEvents = 0; + + while (!stopToken.stop_requested() + && sampleIndex * sampleInterval * 1000.0 < config.durationMs) + { + TimeSeriesChunk chunk; + chunk.sourceId = QString::fromLatin1(kSourceId); + chunk.quantity = QStringLiteral("Photon count rate"); + chunk.unit = QStringLiteral("counts/s"); + chunk.startTimeSeconds = sampleIndex * sampleInterval; + chunk.sampleIntervalSeconds = sampleInterval; + chunk.values.reserve(chunkSize); + + TimestampedEventChunk events; + if (config.publishTimestampedEvents) + { + events.sourceId = QString::fromLatin1(kSourceId); + events.tickPeriodSeconds = sampleInterval; + } + + for (int index = 0; index < chunkSize; ++index) + { + const double timeSeconds = sampleIndex * sampleInterval; + const double rate = rateAt(timeSeconds, settings); + const int count = photonCount(rate * sampleInterval, + settings.noiseMode, + random); + const double value = settings.noiseMode == QStringLiteral("none") + ? rate + : count / sampleInterval; + totalEvents += static_cast(count); + if (config.publishTimestampedEvents) + { + for (int event = 0; event < count; ++event) + { + events.eventTicks.append(sampleIndex); + events.eventCodes.append(kPhotonCode); + } + } + chunk.values.append(value); + ++sampleIndex; + } + chunk.totalInputEvents = totalEvents; + chunk.totalMarkers = 0; + emit timeSeriesReady(chunk); + if (events.isValid()) + { + emit timestampedEventsReady(events); + } + std::this_thread::sleep_until( + startTime + std::chrono::duration_cast( + std::chrono::duration(sampleIndex * sampleInterval))); + } + } + + void SimulatedPmtSource::runScan(std::stop_token stopToken, + const SignalAcquisitionConfig& config, + const Settings& settings) + { + const ScanImageConfig& scan = config.scanImage; + const double frameDuration = 1.0 / settings.scanFrameRate; + const double lineDuration = frameDuration / scan.height; + const int samplesPerLine = scan.width * 2; + const double sampleDuration = lineDuration / samplesPerLine; + const quint64 lineTicks = std::max( + 1, + static_cast(std::llround(lineDuration / kTickPeriodSeconds))); + const quint64 frameTicks = lineTicks * static_cast(scan.height); + const double actualLineDuration = lineTicks * kTickPeriodSeconds; + const double actualFrameDuration = frameTicks * kTickPeriodSeconds; + const auto startTime = std::chrono::steady_clock::now(); + std::mt19937_64 random(std::random_device{}()); + EventCountBinner binner(QString::fromLatin1(kSourceId), + QStringLiteral("Photon count rate"), + QStringLiteral("counts/s"), + kTickPeriodSeconds, + config.sampleIntervalSeconds); + quint64 frameIndex = 0; + + while (!stopToken.stop_requested() + && frameIndex * actualFrameDuration * 1000.0 < config.durationMs) + { + const quint64 frameStart = frameIndex * frameTicks; + TimestampedEventChunk events; + events.sourceId = QString::fromLatin1(kSourceId); + events.tickPeriodSeconds = kTickPeriodSeconds; + events.markerTicks.append(frameStart); + events.markerCodes.append(scan.frameStartMarker); + binner.addMarker(frameStart, scan.frameStartMarker); + + const double gain = settings.gain * std::pow(settings.voltage, 3.0); + for (int line = 0; line < scan.height; ++line) + { + const quint64 lineStart = frameStart + lineTicks * static_cast(line); + for (int sample = 0; sample < samplesPerLine; ++sample) + { + const int column = sample < scan.width + ? sample + : samplesPerLine - 1 - sample; + const double rate = patternIntensity(settings.pattern, + column, + line, + scan.width, + scan.height) + * settings.baseRate * gain + + settings.darkCountRate; + const int count = photonCount(rate * sampleDuration, + settings.noiseMode, + random); + const quint64 pixelStart = lineStart + + static_cast( + static_cast(sample) * lineTicks / samplesPerLine); + const quint64 pixelEnd = lineStart + + static_cast( + static_cast(sample + 1) * lineTicks / samplesPerLine); + const quint64 pixelWidth = std::max( + 1, + pixelEnd > pixelStart ? pixelEnd - pixelStart : 1); + std::uniform_int_distribution offset(0, pixelWidth - 1); + for (int event = 0; event < count; ++event) + { + const quint64 tick = pixelStart + offset(random); + events.eventTicks.append(tick); + events.eventCodes.append(kPhotonCode); + binner.addEvent(tick); + } + } + + const quint64 lineEnd = lineStart + lineTicks; + events.markerTicks.append(lineEnd); + events.markerCodes.append(scan.lineMarker); + binner.addMarker(lineEnd, scan.lineMarker); + for (const TimeSeriesChunk& chunk : binner.takeReadyChunks()) + { + emit timeSeriesReady(chunk); + } + } + + if (scan.frameEndMarker != 0) + { + events.markerTicks.append(frameStart + frameTicks); + events.markerCodes.append(scan.frameEndMarker); + binner.addMarker(frameStart + frameTicks, scan.frameEndMarker); + } + binner.advanceToElapsedSeconds((frameIndex + 1) * actualFrameDuration); + for (const TimeSeriesChunk& chunk : binner.takeCompletedChunks()) + { + emit timeSeriesReady(chunk); + } + emit timestampedEventsReady(events); + ++frameIndex; + + std::this_thread::sleep_until( + startTime + std::chrono::duration_cast( + std::chrono::duration(frameIndex * actualFrameDuration))); + } + } +} diff --git a/plugins/hardware/SimulatedPmt/SimulatedPmtSource.h b/plugins/hardware/SimulatedPmt/SimulatedPmtSource.h new file mode 100644 index 0000000..9971363 --- /dev/null +++ b/plugins/hardware/SimulatedPmt/SimulatedPmtSource.h @@ -0,0 +1,56 @@ +#pragma once + +#include "scopeone/SignalSource.h" + +#include +#include +#include + +namespace scopeone::plugins +{ + class SimulatedPmtSource final : public scopeone::core::SignalSource + { + public: + explicit SimulatedPmtSource(QObject* parent = nullptr); + ~SimulatedPmtSource() override; + + bool start(const scopeone::core::SignalAcquisitionConfig& config, + QString* errorMessage = nullptr) override; + void stop() override; + scopeone::core::SignalSourceState state() const override; + QString stateMessage() const override; + + private: + struct Settings + { + double voltage{1.0}; + double gain{1.0}; + double baseRate{1000000.0}; + double darkCountRate{500.0}; + double modulationFrequency{2.0}; + double scanFrameRate{2.0}; + QString waveform{QStringLiteral("constant")}; + QString noiseMode{QStringLiteral("poisson")}; + QString pattern{QStringLiteral("beads")}; + }; + + void run(std::stop_token stopToken, + const scopeone::core::SignalAcquisitionConfig& config, + const Settings& settings); + void runStream(std::stop_token stopToken, + const scopeone::core::SignalAcquisitionConfig& config, + const Settings& settings); + void runScan(std::stop_token stopToken, + const scopeone::core::SignalAcquisitionConfig& config, + const Settings& settings); + double rateAt(double timeSeconds, const Settings& settings) const; + void setState(scopeone::core::SignalSourceState state, const QString& message); + + mutable std::mutex m_stateMutex; + std::mutex m_threadMutex; + std::jthread m_worker; + std::atomic m_state{ + scopeone::core::SignalSourceState::Idle}; + QString m_stateMessage{QStringLiteral("Simulated PMT is idle")}; + }; +} diff --git a/plugins/hardware/SimulatedPmt/plugin.json b/plugins/hardware/SimulatedPmt/plugin.json new file mode 100644 index 0000000..546f876 --- /dev/null +++ b/plugins/hardware/SimulatedPmt/plugin.json @@ -0,0 +1,8 @@ +{ + "id": "pmt.simulator", + "name": "Simulated PMT", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "hardware", + "providerId": "pmt.simulator" +} diff --git a/plugins/processing/CMakeLists.txt b/plugins/processing/CMakeLists.txt new file mode 100644 index 0000000..5767d50 --- /dev/null +++ b/plugins/processing/CMakeLists.txt @@ -0,0 +1,5 @@ +add_subdirectory(ExampleProcessing) +add_subdirectory(IscatProcessing) +if (TARGET ScopeOneCuda) + add_subdirectory(ExampleCudaProcessing) +endif () diff --git a/plugins/processing/ExampleCudaProcessing/CMakeLists.txt b/plugins/processing/ExampleCudaProcessing/CMakeLists.txt new file mode 100644 index 0000000..6b8a356 --- /dev/null +++ b/plugins/processing/ExampleCudaProcessing/CMakeLists.txt @@ -0,0 +1,16 @@ +add_library(ScopeOneExampleCudaProcessing MODULE + ExampleCudaProcessingPlugin.cpp + ExampleCudaKernel.cu + plugin.json +) +target_link_libraries(ScopeOneExampleCudaProcessing PRIVATE + scopeone::Cuda + scopeone::PluginSDK + Qt6::Core + CUDA::cudart +) +set_target_properties(ScopeOneExampleCudaProcessing PROPERTIES + CUDA_ARCHITECTURES "${SCOPEONE_CUDA_ARCHITECTURES}" +) +set_target_properties(ScopeOneExampleCudaProcessing PROPERTIES OUTPUT_NAME "ExampleCudaProcessing") +scopeone_add_plugin(ScopeOneExampleCudaProcessing processing) diff --git a/plugins/processing/ExampleCudaProcessing/ExampleCudaKernel.cu b/plugins/processing/ExampleCudaProcessing/ExampleCudaKernel.cu new file mode 100644 index 0000000..873da03 --- /dev/null +++ b/plugins/processing/ExampleCudaProcessing/ExampleCudaKernel.cu @@ -0,0 +1,52 @@ +#include + +#include + +namespace +{ + __global__ void invertKernel(const float* input, + std::size_t inputPitchBytes, + float* output, + std::size_t outputPitchBytes, + int width, + int height) + { + const int x = blockIdx.x * blockDim.x + threadIdx.x; + const int y = blockIdx.y * blockDim.y + threadIdx.y; + if (x >= width || y >= height) + { + return; + } + + const float* inputRow = reinterpret_cast( + reinterpret_cast(input) + + static_cast(y) * inputPitchBytes); + float* outputRow = reinterpret_cast( + reinterpret_cast(output) + + static_cast(y) * outputPitchBytes); + outputRow[x] = 65535.0f - inputRow[x]; + } +} + +namespace example_cuda +{ + bool launchInvert(const void* input, + std::size_t inputPitchBytes, + void* output, + std::size_t outputPitchBytes, + int width, + int height) + { + const dim3 block(16, 16); + const dim3 grid((width + block.x - 1) / block.x, + (height + block.y - 1) / block.y); + invertKernel<<>>( + static_cast(input), + inputPitchBytes, + static_cast(output), + outputPitchBytes, + width, + height); + return cudaGetLastError() == cudaSuccess && cudaDeviceSynchronize() == cudaSuccess; + } +} diff --git a/plugins/processing/ExampleCudaProcessing/ExampleCudaProcessingPlugin.cpp b/plugins/processing/ExampleCudaProcessing/ExampleCudaProcessingPlugin.cpp new file mode 100644 index 0000000..acada43 --- /dev/null +++ b/plugins/processing/ExampleCudaProcessing/ExampleCudaProcessingPlugin.cpp @@ -0,0 +1,74 @@ +#include "scopeone/ProcessingPlugin.h" +#include "scopeone/cuda/CudaRealImageModule.h" + +#include + +namespace example_cuda +{ + bool launchInvert(const void* input, + std::size_t inputPitchBytes, + void* output, + std::size_t outputPitchBytes, + int width, + int height); +} + +namespace +{ + class InvertModule final : public scopeone::cuda::CudaRealImageModule + { + public: + InvertModule() + : CudaRealImageModule(scopeone::cuda::GpuMemoryLayout::Pitched2D) + { + } + + QString id() const override { return QStringLiteral("example.cuda_invert"); } + QString name() const override { return QStringLiteral("CUDA Example Invert"); } + QVariantMap parameters() const override { return {}; } + void setParameters(const QVariantMap&) override {} + + std::unique_ptr createRuntime() const override + { + return std::make_unique(); + } + + protected: + bool processDevice(const scopeone::cuda::GpuRealFrame& input, + scopeone::cuda::GpuRealFrame& output, + int) override + { + return example_cuda::launchInvert(input.data(), + input.pitchBytes(), + output.data(), + output.pitchBytes(), + input.width(), + input.height()); + } + }; + + class ExampleCudaProcessingPlugin final : public QObject, + public scopeone::core::ProcessingPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID ScopeOneProcessingPlugin_iid FILE "plugin.json") + Q_INTERFACES(scopeone::core::ProcessingPlugin) + + public: + QList processingModules() const override + { + return {{QStringLiteral("example.cuda_invert"), + QStringLiteral("CUDA Example Invert")}}; + } + + std::unique_ptr createProcessingModule( + const QString& moduleId) override + { + return moduleId == QStringLiteral("example.cuda_invert") + ? std::make_unique() + : nullptr; + } + }; +} + +#include "ExampleCudaProcessingPlugin.moc" diff --git a/plugins/processing/ExampleCudaProcessing/plugin.json b/plugins/processing/ExampleCudaProcessing/plugin.json new file mode 100644 index 0000000..8322427 --- /dev/null +++ b/plugins/processing/ExampleCudaProcessing/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "example.cuda_processing", + "name": "Example CUDA Processing", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "processing" +} diff --git a/plugins/processing/ExampleProcessing/CMakeLists.txt b/plugins/processing/ExampleProcessing/CMakeLists.txt new file mode 100644 index 0000000..9d48301 --- /dev/null +++ b/plugins/processing/ExampleProcessing/CMakeLists.txt @@ -0,0 +1,6 @@ +add_library(ScopeOneExampleProcessing MODULE + ExampleProcessingPlugin.cpp + plugin.json +) +target_link_libraries(ScopeOneExampleProcessing PRIVATE scopeone::PluginSDK Qt6::Core) +scopeone_add_plugin(ScopeOneExampleProcessing processing) diff --git a/plugins/processing/ExampleProcessing/ExampleProcessingPlugin.cpp b/plugins/processing/ExampleProcessing/ExampleProcessingPlugin.cpp new file mode 100644 index 0000000..4d031b8 --- /dev/null +++ b/plugins/processing/ExampleProcessing/ExampleProcessingPlugin.cpp @@ -0,0 +1,49 @@ +#include "scopeone/ProcessingPlugin.h" + +#include + +namespace +{ + class PassthroughModule final : public scopeone::core::ProcessingModule + { + public: + QString id() const override { return QStringLiteral("example.passthrough"); } + QString name() const override { return QStringLiteral("Example Passthrough"); } + QVariantMap parameters() const override { return {}; } + void setParameters(const QVariantMap&) override {} + std::unique_ptr createRuntime() const override + { + return std::make_unique(); + } + scopeone::core::ProcessingResult process(const scopeone::core::ImageFrame& frame, + int) override + { + return {frame, {}}; + } + }; + + class ExampleProcessingPlugin final : public QObject, + public scopeone::core::ProcessingPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID ScopeOneProcessingPlugin_iid FILE "plugin.json") + Q_INTERFACES(scopeone::core::ProcessingPlugin) + + public: + QList processingModules() const override + { + return {{QStringLiteral("example.passthrough"), + QStringLiteral("Example Passthrough")}}; + } + + std::unique_ptr createProcessingModule( + const QString& moduleId) override + { + return moduleId == QStringLiteral("example.passthrough") + ? std::make_unique() + : nullptr; + } + }; +} + +#include "ExampleProcessingPlugin.moc" diff --git a/plugins/processing/ExampleProcessing/plugin.json b/plugins/processing/ExampleProcessing/plugin.json new file mode 100644 index 0000000..5be3b4f --- /dev/null +++ b/plugins/processing/ExampleProcessing/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "example.processing", + "name": "Example Processing", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "processing" +} diff --git a/plugins/processing/IscatProcessing/CMakeLists.txt b/plugins/processing/IscatProcessing/CMakeLists.txt new file mode 100644 index 0000000..1a38a9a --- /dev/null +++ b/plugins/processing/IscatProcessing/CMakeLists.txt @@ -0,0 +1,18 @@ +add_library(ScopeOneIscatProcessing MODULE + IscatProcessingPlugin.cpp + IscatProcessingModule.cpp + IscatProcessingModule.h + plugin.json +) + +target_compile_features(ScopeOneIscatProcessing PRIVATE cxx_std_20) +target_link_libraries(ScopeOneIscatProcessing PRIVATE + scopeone::PluginSDK + opencv_core + opencv_imgproc + Qt6::Core +) +set_target_properties(ScopeOneIscatProcessing PROPERTIES + OUTPUT_NAME "IscatProcessingPlugin" +) +scopeone_add_plugin(ScopeOneIscatProcessing processing) diff --git a/plugins/processing/IscatProcessing/IscatProcessingModule.cpp b/plugins/processing/IscatProcessing/IscatProcessingModule.cpp new file mode 100644 index 0000000..ac709a1 --- /dev/null +++ b/plugins/processing/IscatProcessing/IscatProcessingModule.cpp @@ -0,0 +1,406 @@ +#include "IscatProcessingModule.h" + +#include +#include +#include +#include +#include +#include + +namespace scopeone::iscat +{ + namespace + { + scopeone::core::ImageFrame makeMono16Output( + const scopeone::core::ImageFrame& reference, + const cv::Mat& values) + { + QByteArray bytes(static_cast(values.cols) + * values.rows + * static_cast(sizeof(quint16)), + Qt::Uninitialized); + cv::Mat output(values.rows, + values.cols, + CV_16UC1, + bytes.data(), + static_cast(values.cols * sizeof(quint16))); + values.convertTo(output, CV_16U); + + scopeone::core::ImageFrame frame = reference; + frame.width = values.cols; + frame.height = values.rows; + frame.stride = values.cols * static_cast(sizeof(quint16)); + frame.bitsPerSample = 16; + frame.pixelFormat = scopeone::core::ImagePixelFormat::Mono16; + frame.bytes = std::move(bytes); + return frame; + } + } + + QString IscatProcessingModule::id() const + { + return QStringLiteral("iscat.processing"); + } + + QString IscatProcessingModule::name() const + { + return QStringLiteral("iSCAT Processing"); + } + + QVariantMap IscatProcessingModule::parameters() const + { + return {{QStringLiteral("mode"), m_mode}, + {QStringLiteral("blur_sigma"), m_blurSigma}, + {QStringLiteral("median_window"), m_medianWindow}, + {QStringLiteral("ema_alpha"), m_emaAlpha}, + {QStringLiteral("capture_reference"), m_captureReference}, + {QStringLiteral("batch_size"), m_batchSize}, + {QStringLiteral("contrast_gain"), m_contrastGain}, + {QStringLiteral("enable_high_pass"), m_enableHighPass}, + {QStringLiteral("high_pass_sigma"), m_highPassSigma}, + {QStringLiteral("output_mode"), m_outputMode}}; + } + + void IscatProcessingModule::setParameters(const QVariantMap& parameters) + { + const int previousMode = m_mode; + const double previousBlurSigma = m_blurSigma; + const int previousMedianWindow = m_medianWindow; + const int previousBatchSize = m_batchSize; + + if (parameters.contains(QStringLiteral("mode"))) + { + m_mode = qBound(0, parameters.value(QStringLiteral("mode")).toInt(), 4); + } + if (parameters.contains(QStringLiteral("blur_sigma"))) + { + m_blurSigma = qBound(1.0, + parameters.value(QStringLiteral("blur_sigma")).toDouble(), + 100.0); + } + if (parameters.contains(QStringLiteral("median_window"))) + { + m_medianWindow = qBound(3, + parameters.value(QStringLiteral("median_window")).toInt(), + 101); + if ((m_medianWindow % 2) == 0) + { + ++m_medianWindow; + } + } + if (parameters.contains(QStringLiteral("ema_alpha"))) + { + m_emaAlpha = qBound(0.001, + parameters.value(QStringLiteral("ema_alpha")).toDouble(), + 0.5); + } + if (parameters.contains(QStringLiteral("capture_reference"))) + { + m_captureReference = parameters.value(QStringLiteral("capture_reference")).toBool(); + } + if (parameters.contains(QStringLiteral("batch_size"))) + { + m_batchSize = qBound(1, + parameters.value(QStringLiteral("batch_size")).toInt(), + 500); + } + if (parameters.contains(QStringLiteral("contrast_gain"))) + { + m_contrastGain = qBound(1.0, + parameters.value(QStringLiteral("contrast_gain")).toDouble(), + 500.0); + } + if (parameters.contains(QStringLiteral("enable_high_pass"))) + { + m_enableHighPass = parameters.value(QStringLiteral("enable_high_pass")).toBool(); + } + if (parameters.contains(QStringLiteral("high_pass_sigma"))) + { + m_highPassSigma = qBound(2.0, + parameters.value(QStringLiteral("high_pass_sigma")).toDouble(), + 200.0); + } + if (parameters.contains(QStringLiteral("output_mode"))) + { + m_outputMode = qBound(0, + parameters.value(QStringLiteral("output_mode")).toInt(), + 2); + } + + if (previousMode != m_mode + || previousBlurSigma != m_blurSigma + || previousMedianWindow != m_medianWindow + || previousBatchSize != m_batchSize) + { + clearState(); + } + } + + std::unique_ptr IscatProcessingModule::createRuntime() const + { + auto module = std::make_unique(); + module->setParameters(parameters()); + return module; + } + + bool IscatProcessingModule::resetState() + { + clearState(); + return true; + } + + void IscatProcessingModule::clearState() + { + m_medianBuffer.clear(); + m_lockedMedianBackground.release(); + m_emaAccumulator.release(); + m_snapshotBackground.release(); + m_draBatchA.clear(); + m_draBatchB.clear(); + m_draSumA.release(); + m_draSumB.release(); + } + + cv::Mat IscatProcessingModule::computePixelMedian(const std::deque& frames) const + { + cv::Mat median(frames.front().size(), CV_32F); + cv::parallel_for_(cv::Range(0, median.rows), + [&](const cv::Range& range) + { + std::vector values; + values.reserve(frames.size()); + for (int y = range.start; y < range.end; ++y) + { + float* outputRow = median.ptr(y); + for (int x = 0; x < median.cols; ++x) + { + values.clear(); + for (const cv::Mat& frame : frames) + { + values.push_back(frame.at(y, x)); + } + const auto middle = values.begin() + + static_cast(values.size() / 2); + std::nth_element(values.begin(), middle, values.end()); + outputRow[x] = *middle; + } + } + }); + return median; + } + + scopeone::core::ProcessingResult IscatProcessingModule::finalizeContrastOutput( + const cv::Mat& input, + const cv::Mat& background, + const scopeone::core::ImageFrame& frame) + { + if (m_outputMode == 2) + { + cv::Mat outputValues; + background.convertTo(outputValues, CV_32F); + cv::max(outputValues, 0.0f, outputValues); + cv::min(outputValues, 65535.0f, outputValues); + return {makeMono16Output(frame, outputValues), {}}; + } + + cv::Mat safeBackground; + cv::max(background, 1.0f, safeBackground); + cv::Mat contrast; + cv::subtract(input, background, contrast); + cv::divide(contrast, safeBackground, contrast); + if (m_enableHighPass) + { + cv::Mat lowFrequency; + cv::GaussianBlur(contrast, + lowFrequency, + cv::Size(0, 0), + m_highPassSigma); + contrast -= lowFrequency; + } + + cv::Mat outputValues; + if (m_outputMode == 0) + { + outputValues = contrast * (m_contrastGain * 32768.0) + 32768.0; + } + else + { + cv::absdiff(contrast, cv::Scalar::all(0.0), contrast); + outputValues = contrast * (m_contrastGain * 65535.0); + } + cv::max(outputValues, 0.0f, outputValues); + cv::min(outputValues, 65535.0f, outputValues); + return {makeMono16Output(frame, outputValues), {}}; + } + + scopeone::core::ProcessingResult IscatProcessingModule::processFlatField( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame) + { + cv::Mat background; + cv::GaussianBlur(input, + background, + cv::Size(0, 0), + m_blurSigma); + return finalizeContrastOutput(input, background, frame); + } + + scopeone::core::ProcessingResult IscatProcessingModule::processTemporalMedian( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame) + { + if (m_captureReference) + { + m_lockedMedianBackground.release(); + m_medianBuffer.clear(); + m_captureReference = false; + } + if (!m_lockedMedianBackground.empty()) + { + return finalizeContrastOutput(input, m_lockedMedianBackground, frame); + } + + m_medianBuffer.push_back(input.clone()); + if (m_medianBuffer.size() < static_cast(m_medianWindow)) + { + return {frame, {}}; + } + + cv::Mat median = computePixelMedian(m_medianBuffer); + cv::GaussianBlur(median, + m_lockedMedianBackground, + cv::Size(0, 0), + m_blurSigma); + m_medianBuffer.clear(); + return finalizeContrastOutput(input, m_lockedMedianBackground, frame); + } + + scopeone::core::ProcessingResult IscatProcessingModule::processEma( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame) + { + if (m_emaAccumulator.empty()) + { + input.copyTo(m_emaAccumulator); + } + else + { + m_emaAccumulator = (1.0 - m_emaAlpha) * m_emaAccumulator + + m_emaAlpha * input; + } + + cv::Mat background; + cv::GaussianBlur(m_emaAccumulator, + background, + cv::Size(0, 0), + m_blurSigma); + return finalizeContrastOutput(input, background, frame); + } + + scopeone::core::ProcessingResult IscatProcessingModule::processSnapshot( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame) + { + if (m_snapshotBackground.empty() || m_captureReference) + { + cv::GaussianBlur(input, + m_snapshotBackground, + cv::Size(0, 0), + m_blurSigma); + m_captureReference = false; + } + return finalizeContrastOutput(input, m_snapshotBackground, frame); + } + + scopeone::core::ProcessingResult IscatProcessingModule::processDra( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame) + { + if (m_draBatchA.size() < static_cast(m_batchSize)) + { + m_draBatchA.push_back(input.clone()); + if (m_draSumA.empty()) + { + input.copyTo(m_draSumA); + } + else + { + m_draSumA += input; + } + return {frame, {}}; + } + + if (m_draBatchB.size() < static_cast(m_batchSize)) + { + m_draBatchB.push_back(input.clone()); + if (m_draSumB.empty()) + { + input.copyTo(m_draSumB); + } + else + { + m_draSumB += input; + } + if (m_draBatchB.size() < static_cast(m_batchSize)) + { + return {frame, {}}; + } + + return finalizeContrastOutput(m_draSumB / m_batchSize, + m_draSumA / m_batchSize, + frame); + } + + const cv::Mat& oldestA = m_draBatchA.front(); + const cv::Mat& bridge = m_draBatchB.front(); + m_draSumA += bridge - oldestA; + m_draSumB += input - bridge; + m_draBatchA.pop_front(); + m_draBatchA.push_back(bridge); + m_draBatchB.pop_front(); + m_draBatchB.push_back(input.clone()); + + return finalizeContrastOutput(m_draSumB / m_batchSize, + m_draSumA / m_batchSize, + frame); + } + + scopeone::core::ProcessingResult IscatProcessingModule::process( + const scopeone::core::ImageFrame& frame, + int) + { + if (!frame.isValid() || (!frame.isMono8() && !frame.isMono16())) + { + return {scopeone::core::ImageFrame{}, QStringLiteral("Unsupported iSCAT input frame")}; + } + + const int sourceType = frame.isMono16() ? CV_16UC1 : CV_8UC1; + const cv::Mat source(frame.height, + frame.width, + sourceType, + const_cast(frame.bytes.constData()), + frame.stride); + cv::Mat input; + source.convertTo(input, CV_32F, frame.isMono8() ? 257.0 : 1.0); + if (m_stateSize != input.size()) + { + clearState(); + m_stateSize = input.size(); + } + + switch (m_mode) + { + case 0: + return processFlatField(input, frame); + case 1: + return processTemporalMedian(input, frame); + case 2: + return processEma(input, frame); + case 3: + return processSnapshot(input, frame); + case 4: + return processDra(input, frame); + } + return {}; + } +} diff --git a/plugins/processing/IscatProcessing/IscatProcessingModule.h b/plugins/processing/IscatProcessing/IscatProcessingModule.h new file mode 100644 index 0000000..9e18573 --- /dev/null +++ b/plugins/processing/IscatProcessing/IscatProcessingModule.h @@ -0,0 +1,65 @@ +#pragma once + +#include "scopeone/ProcessingPlugin.h" + +#include +#include + +namespace scopeone::iscat +{ + class IscatProcessingModule final : public scopeone::core::ProcessingModule + { + public: + QString id() const override; + QString name() const override; + QVariantMap parameters() const override; + void setParameters(const QVariantMap& parameters) override; + std::unique_ptr createRuntime() const override; + bool resetState() override; + scopeone::core::ProcessingResult process(const scopeone::core::ImageFrame& frame, + int processingBitDepth) override; + + private: + void clearState(); + cv::Mat computePixelMedian(const std::deque& frames) const; + scopeone::core::ProcessingResult processFlatField( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame); + scopeone::core::ProcessingResult processTemporalMedian( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame); + scopeone::core::ProcessingResult processEma( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame); + scopeone::core::ProcessingResult processSnapshot( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame); + scopeone::core::ProcessingResult processDra( + const cv::Mat& input, + const scopeone::core::ImageFrame& frame); + scopeone::core::ProcessingResult finalizeContrastOutput( + const cv::Mat& input, + const cv::Mat& background, + const scopeone::core::ImageFrame& frame); + + int m_mode{0}; + double m_blurSigma{15.0}; + int m_medianWindow{31}; + double m_emaAlpha{0.05}; + bool m_captureReference{false}; + int m_batchSize{16}; + double m_contrastGain{20.0}; + bool m_enableHighPass{false}; + double m_highPassSigma{30.0}; + int m_outputMode{0}; + std::deque m_medianBuffer; + cv::Mat m_lockedMedianBackground; + cv::Mat m_emaAccumulator; + cv::Mat m_snapshotBackground; + std::deque m_draBatchA; + std::deque m_draBatchB; + cv::Mat m_draSumA; + cv::Mat m_draSumB; + cv::Size m_stateSize; + }; +} diff --git a/plugins/processing/IscatProcessing/IscatProcessingPlugin.cpp b/plugins/processing/IscatProcessing/IscatProcessingPlugin.cpp new file mode 100644 index 0000000..ff75248 --- /dev/null +++ b/plugins/processing/IscatProcessing/IscatProcessingPlugin.cpp @@ -0,0 +1,124 @@ +#include "IscatProcessingModule.h" + +#include "scopeone/ProcessingPlugin.h" + +#include +#include + +namespace +{ + scopeone::core::ProcessingParameterDescriptor makeRealParameter( + const char* key, + const char* name, + double value, + double minimum, + double maximum, + double step, + int decimals) + { + scopeone::core::ProcessingParameterDescriptor descriptor{ + QString::fromLatin1(key), + QString::fromLatin1(name), + scopeone::core::ProcessingParameterType::Real, + value, + minimum, + maximum, + step}; + descriptor.decimals = decimals; + return descriptor; + } + + scopeone::core::ProcessingParameterDescriptor makeBooleanParameter( + const char* key, + const char* name, + bool value) + { + return {QString::fromLatin1(key), + QString::fromLatin1(name), + scopeone::core::ProcessingParameterType::Boolean, + value}; + } + + scopeone::core::ProcessingParameterDescriptor makeIntegerParameter( + const char* key, + const char* name, + int value, + int minimum, + int maximum, + int step) + { + return {QString::fromLatin1(key), + QString::fromLatin1(name), + scopeone::core::ProcessingParameterType::Integer, + value, + minimum, + maximum, + step}; + } + + scopeone::core::ProcessingParameterDescriptor makeChoiceParameter( + const char* key, + const char* name, + int value, + std::initializer_list choices) + { + scopeone::core::ProcessingParameterDescriptor descriptor{ + QString::fromLatin1(key), + QString::fromLatin1(name), + scopeone::core::ProcessingParameterType::Choice, + value}; + int index = 0; + for (const char* choice : choices) + { + descriptor.choices.append({QString::fromLatin1(choice), index++}); + } + return descriptor; + } + + class IscatProcessingPlugin final : public QObject, + public scopeone::core::ProcessingPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID ScopeOneProcessingPlugin_iid FILE "plugin.json") + Q_INTERFACES(scopeone::core::ProcessingPlugin) + + public: + QList processingModules() const override + { + return {{QStringLiteral("iscat.processing"), + QStringLiteral("iSCAT Processing"), + 1, + {makeChoiceParameter("mode", + "Background mode", + 0, + {"Flat Field", + "Temporal Median", + "Dynamic EMA", + "Snapshot Reference", + "Differential Rolling (DRA)"}), + makeRealParameter("blur_sigma", "Blur sigma", 15.0, 1.0, 100.0, 0.5, 1), + makeIntegerParameter("median_window", "Median window", 31, 3, 101, 2), + makeRealParameter("ema_alpha", "Smoothing factor", 0.05, 0.001, 0.5, 0.005, 3), + makeBooleanParameter("capture_reference", "Capture Reference", false), + makeIntegerParameter("batch_size", "DRA batch size", 16, 1, 500, 1), + makeRealParameter("contrast_gain", "Contrast gain", 20.0, 1.0, 500.0, 1.0, 1), + makeBooleanParameter("enable_high_pass", "Enable High-Pass", false), + makeRealParameter("high_pass_sigma", "High-Pass sigma", 30.0, 2.0, 200.0, 1.0, 1), + makeChoiceParameter("output_mode", + "Output mode", + 0, + {"Contrast Centered", "Absolute Contrast", "Estimated Background"})}, + true}}; + } + + std::unique_ptr createProcessingModule( + const QString& moduleId) override + { + return moduleId == QStringLiteral("iscat.processing") + ? std::make_unique() + : nullptr; + } + }; +} + +#include "IscatProcessingPlugin.moc" diff --git a/plugins/processing/IscatProcessing/plugin.json b/plugins/processing/IscatProcessing/plugin.json new file mode 100644 index 0000000..6aa1c76 --- /dev/null +++ b/plugins/processing/IscatProcessing/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "iscat.processing", + "name": "iSCAT Processing", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "processing" +} diff --git a/plugins/tools/CMakeLists.txt b/plugins/tools/CMakeLists.txt new file mode 100644 index 0000000..17a7bc4 --- /dev/null +++ b/plugins/tools/CMakeLists.txt @@ -0,0 +1,3 @@ +add_subdirectory(DhmTool) +add_subdirectory(ScanningDaqTool) +add_subdirectory(ExampleTool) diff --git a/plugins/tools/DhmTool/CMakeLists.txt b/plugins/tools/DhmTool/CMakeLists.txt new file mode 100644 index 0000000..bce1ad7 --- /dev/null +++ b/plugins/tools/DhmTool/CMakeLists.txt @@ -0,0 +1,15 @@ +add_library(ScopeOneDhmTool MODULE + DhmToolPlugin.cpp + DhmReconstruction.cpp + DhmReconstruction.h + plugin.json +) +target_compile_features(ScopeOneDhmTool PRIVATE cxx_std_20) +target_link_libraries(ScopeOneDhmTool PRIVATE + scopeone::PluginSDK + opencv_core + opencv_imgcodecs + opencv_imgproc + Qt6::Widgets +) +scopeone_add_plugin(ScopeOneDhmTool tools) diff --git a/plugins/tools/DhmTool/DhmReconstruction.cpp b/plugins/tools/DhmTool/DhmReconstruction.cpp new file mode 100644 index 0000000..7c0854a --- /dev/null +++ b/plugins/tools/DhmTool/DhmReconstruction.cpp @@ -0,0 +1,522 @@ +#include "DhmReconstruction.h" + +#include + +#include +#include +#include +#include + +namespace +{ + using scopeone::core::ImageFrame; + using scopeone::dhm::DhmParameters; + + constexpr float Pi = 3.14159265358979323846f; + constexpr float TwoPi = 2.0f * Pi; + + struct SpectrumData + { + cv::Mat shiftedSpectrum; + cv::Mat logMagnitude; + int roiWidth{0}; + int roiHeight{0}; + }; + + cv::Mat inputAsFloat(const ImageFrame& input) + { + const int depth = input.isMono16() ? CV_16UC1 : CV_8UC1; + const cv::Mat view(input.height, + input.width, + depth, + const_cast(input.bytes.constData()), + static_cast(input.stride)); + cv::Mat result; + view.convertTo(result, CV_32F); + return result; + } + + cv::Mat applyRoi(const cv::Mat& input, const DhmParameters& params) + { + if (params.roiMode == scopeone::dhm::DhmRoiMode::FullFrame) + { + return input.clone(); + } + + const int size = std::min(params.roiSize, std::min(input.cols, input.rows)); + const int x = (input.cols - size) / 2; + const int y = (input.rows - size) / 2; + return input(cv::Rect(x, y, size, size)).clone(); + } + + void fftShift(cv::Mat& input) + { + const int halfWidth = input.cols / 2; + const int halfHeight = input.rows / 2; + cv::Mat topLeft(input, cv::Rect(0, 0, halfWidth, halfHeight)); + cv::Mat topRight(input, cv::Rect(input.cols - halfWidth, + 0, + halfWidth, + halfHeight)); + cv::Mat bottomLeft(input, cv::Rect(0, + input.rows - halfHeight, + halfWidth, + halfHeight)); + cv::Mat bottomRight(input, cv::Rect(input.cols - halfWidth, + input.rows - halfHeight, + halfWidth, + halfHeight)); + + cv::Mat temporary; + topLeft.copyTo(temporary); + bottomRight.copyTo(topLeft); + temporary.copyTo(bottomRight); + topRight.copyTo(temporary); + bottomLeft.copyTo(topRight); + temporary.copyTo(bottomLeft); + } + + SpectrumData buildSpectrum(const ImageFrame& input, const DhmParameters& params) + { + SpectrumData result; + const cv::Mat roi = applyRoi(inputAsFloat(input), params); + result.roiWidth = roi.cols; + result.roiHeight = roi.rows; + + const int fftWidth = cv::getOptimalDFTSize(roi.cols); + const int fftHeight = cv::getOptimalDFTSize(roi.rows); + cv::Mat padded; + cv::copyMakeBorder(roi, + padded, + 0, + fftHeight - roi.rows, + 0, + fftWidth - roi.cols, + cv::BORDER_CONSTANT, + cv::Scalar::all(0)); + + cv::dft(padded, result.shiftedSpectrum, cv::DFT_COMPLEX_OUTPUT); + fftShift(result.shiftedSpectrum); + + cv::Mat planes[2]; + cv::split(result.shiftedSpectrum, planes); + cv::magnitude(planes[0], planes[1], result.logMagnitude); + result.logMagnitude += 1.0f; + cv::log(result.logMagnitude, result.logMagnitude); + return result; + } + + ImageFrame normalizedFrame(const cv::Mat& values, + const ImageFrame& source, + const QString& cameraId) + { + cv::Mat normalized; + cv::normalize(values, normalized, 0.0, 65535.0, cv::NORM_MINMAX, CV_16U); + normalized = normalized.clone(); + + ImageFrame frame; + frame.cameraId = cameraId; + frame.width = normalized.cols; + frame.height = normalized.rows; + frame.stride = static_cast(normalized.step); + frame.bitsPerSample = 16; + frame.pixelFormat = scopeone::core::ImagePixelFormat::Mono16; + frame.frameIndex = source.frameIndex; + frame.timestampNs = source.timestampNs; + frame.bytes = QByteArray(reinterpret_cast(normalized.data), + static_cast(normalized.total() + * normalized.elemSize())); + return frame; + } + + QPoint detectedOffset(const cv::Mat& logMagnitude) + { + const cv::Point point = scopeone::dhm::autoDetectSideband(logMagnitude); + return {point.x - logMagnitude.cols / 2, + point.y - logMagnitude.rows / 2}; + } + + cv::Mat circularMask(int width, + int height, + const cv::Point& center, + int radius, + bool softEdge, + double sigma) + { + cv::Mat mask = cv::Mat::zeros(height, width, CV_32F); + cv::circle(mask, center, radius, cv::Scalar(1.0f), cv::FILLED, cv::LINE_AA); + if (softEdge) + { + const int kernelSize = std::max(3, (static_cast(std::ceil(sigma * 6.0)) | 1)); + cv::GaussianBlur(mask, + mask, + cv::Size(kernelSize, kernelSize), + sigma, + sigma, + cv::BORDER_REPLICATE); + double maximum = 0.0; + cv::minMaxLoc(mask, nullptr, &maximum); + mask /= static_cast(maximum); + } + return mask; + } + + void circularShift(const cv::Mat& source, cv::Mat& destination, int shiftX, int shiftY) + { + destination.create(source.size(), source.type()); + for (int y = 0; y < source.rows; ++y) + { + const int sourceY = (y - shiftY + source.rows) % source.rows; + for (int x = 0; x < source.cols; ++x) + { + const int sourceX = (x - shiftX + source.cols) % source.cols; + destination.at(y, x) = source.at(sourceY, sourceX); + } + } + } + + void applyMask(cv::Mat& spectrum, const cv::Mat& mask) + { + for (int y = 0; y < spectrum.rows; ++y) + { + auto* row = spectrum.ptr(y); + const auto* maskRow = mask.ptr(y); + for (int x = 0; x < spectrum.cols; ++x) + { + row[x] *= maskRow[x]; + } + } + } + + void applyPropagation(cv::Mat& shiftedSpectrum, + double wavelength, + double pixelSize, + double z) + { + const double waveNumber = 2.0 * CV_PI / wavelength; + const int centerX = shiftedSpectrum.cols / 2; + const int centerY = shiftedSpectrum.rows / 2; + for (int y = 0; y < shiftedSpectrum.rows; ++y) + { + const double fy = static_cast(y - centerY) + / (pixelSize * shiftedSpectrum.rows); + const double ky = 2.0 * CV_PI * fy; + auto* row = shiftedSpectrum.ptr(y); + for (int x = 0; x < shiftedSpectrum.cols; ++x) + { + const double fx = static_cast(x - centerX) + / (pixelSize * shiftedSpectrum.cols); + const double kx = 2.0 * CV_PI * fx; + const double kz = std::sqrt(std::max(0.0, + waveNumber * waveNumber + - kx * kx + - ky * ky)); + const float real = static_cast(std::cos(kz * z)); + const float imaginary = static_cast(std::sin(kz * z)); + const cv::Vec2f value = row[x]; + row[x][0] = value[0] * real - value[1] * imaginary; + row[x][1] = value[0] * imaginary + value[1] * real; + } + } + } + + float wrappedDifference(float value, float reference) + { + float difference = value - reference; + while (difference > Pi) + { + difference -= TwoPi; + } + while (difference < -Pi) + { + difference += TwoPi; + } + return difference; + } + + cv::Mat unwrapPhase2D(const cv::Mat& wrapped, + const std::atomic_bool& cancel) + { + struct Node + { + float quality; + int x; + int y; + + bool operator<(const Node& other) const + { + return quality < other.quality; + } + }; + + cv::Mat reliability = cv::Mat::zeros(wrapped.size(), CV_32F); + for (int y = 0; y < wrapped.rows; ++y) + { + for (int x = 0; x < wrapped.cols; ++x) + { + const float horizontal = x + 1 < wrapped.cols + ? std::abs(wrappedDifference( + wrapped.at(y, x + 1), + wrapped.at(y, x))) + : 0.0f; + const float vertical = y + 1 < wrapped.rows + ? std::abs(wrappedDifference( + wrapped.at(y + 1, x), + wrapped.at(y, x))) + : 0.0f; + reliability.at(y, x) = 1.0f / (horizontal + vertical + 1.0e-6f); + } + } + + double maximum = 0.0; + cv::Point seed; + cv::minMaxLoc(reliability, nullptr, &maximum, nullptr, &seed); + + cv::Mat unwrapped = cv::Mat::zeros(wrapped.size(), CV_32F); + cv::Mat visited = cv::Mat::zeros(wrapped.size(), CV_8U); + std::priority_queue queue; + queue.push({reliability.at(seed.y, seed.x), seed.x, seed.y}); + unwrapped.at(seed.y, seed.x) = wrapped.at(seed.y, seed.x); + visited.at(seed.y, seed.x) = 1; + + constexpr int dx[] = {1, -1, 0, 0}; + constexpr int dy[] = {0, 0, 1, -1}; + while (!queue.empty()) + { + if (cancel) + { + return {}; + } + + const Node node = queue.top(); + queue.pop(); + + for (int direction = 0; direction < 4; ++direction) + { + const int nx = node.x + dx[direction]; + const int ny = node.y + dy[direction]; + if (nx < 0 || nx >= wrapped.cols || ny < 0 || ny >= wrapped.rows + || visited.at(ny, nx)) + { + continue; + } + + const float reference = unwrapped.at(node.y, node.x); + const float value = wrapped.at(ny, nx); + unwrapped.at(ny, nx) + = value + TwoPi * std::round((reference - value) / TwoPi); + visited.at(ny, nx) = 1; + queue.push({reliability.at(ny, nx), nx, ny}); + } + } + return unwrapped; + } + + void removeTilt(cv::Mat& phase) + { + const double centerX = (phase.cols - 1) / 2.0; + const double centerY = (phase.rows - 1) / 2.0; + const double scaleX = std::max(1.0, centerX); + const double scaleY = std::max(1.0, centerY); + + double sums[3][3]{}; + double vector[3]{}; + for (int y = 0; y < phase.rows; ++y) + { + const double normalizedY = (y - centerY) / scaleY; + for (int x = 0; x < phase.cols; ++x) + { + const double normalizedX = (x - centerX) / scaleX; + const double terms[3] = {normalizedX, normalizedY, 1.0}; + const double value = phase.at(y, x); + for (int row = 0; row < 3; ++row) + { + vector[row] += terms[row] * value; + for (int column = 0; column < 3; ++column) + { + sums[row][column] += terms[row] * terms[column]; + } + } + } + } + + cv::Mat matrix(3, 3, CV_64F, sums); + cv::Mat rightHandSide(3, 1, CV_64F, vector); + cv::Mat coefficients; + cv::solve(matrix, rightHandSide, coefficients, cv::DECOMP_CHOLESKY); + + for (int y = 0; y < phase.rows; ++y) + { + const double normalizedY = (y - centerY) / scaleY; + for (int x = 0; x < phase.cols; ++x) + { + const double normalizedX = (x - centerX) / scaleX; + const double plane = coefficients.at(0) * normalizedX + + coefficients.at(1) * normalizedY + + coefficients.at(2); + phase.at(y, x) -= static_cast(plane); + } + } + } +} + +namespace scopeone::dhm +{ + cv::Point autoDetectSideband(const cv::Mat& logMagSpectrum) + { + cv::Mat blurred; + cv::GaussianBlur(logMagSpectrum, blurred, cv::Size(0, 0), 2.0, 2.0); + + const cv::Point center(logMagSpectrum.cols / 2, logMagSpectrum.rows / 2); + const int dcRadius = static_cast( + 0.06 * std::min(logMagSpectrum.cols, logMagSpectrum.rows)); + cv::Mat mask = cv::Mat::zeros(logMagSpectrum.size(), CV_8U); + cv::rectangle(mask, + cv::Rect(0, 0, logMagSpectrum.cols, std::max(1, center.y)), + cv::Scalar(255), + cv::FILLED); + cv::circle(mask, center, dcRadius, cv::Scalar(0), cv::FILLED); + + cv::Point peak; + cv::minMaxLoc(blurred, nullptr, nullptr, nullptr, &peak, mask); + return peak; + } + + std::pair computeSpectrum(const ImageFrame& input, + const DhmParameters& params) + { + const SpectrumData spectrum = buildSpectrum(input, params); + const ImageFrame frame = normalizedFrame( + spectrum.logMagnitude, input, QStringLiteral("dhm.spectrum")); + return {frame, detectedOffset(spectrum.logMagnitude)}; + } + + DhmResult reconstruct(const ImageFrame& input, + const DhmParameters& params, + const std::atomic_bool& cancel, + const std::function& progress) + { + const SpectrumData spectrum = buildSpectrum(input, params); + const QPoint detected = detectedOffset(spectrum.logMagnitude); + const QPoint selected = params.autoDetectSideband + ? detected + : QPoint(params.sidebandX, params.sidebandY); + const int maximumX = spectrum.shiftedSpectrum.cols / 2 - 1; + const int maximumY = spectrum.shiftedSpectrum.rows / 2 - 1; + const int sidebandX = std::clamp(selected.x(), -maximumX, maximumX); + const int sidebandY = std::clamp(selected.y(), -maximumY, maximumY); + const int radius = std::clamp( + params.radius, + 1, + std::min(spectrum.shiftedSpectrum.cols, spectrum.shiftedSpectrum.rows) / 2); + + DhmResult result; + result.detectedSidebandX = detected.x(); + result.detectedSidebandY = detected.y(); + result.spectrumFrame = normalizedFrame( + spectrum.logMagnitude, input, QStringLiteral("dhm.spectrum")); + progress(10); + + if (params.outputMode == DhmOutputMode::Spectrum) + { + result.outputFrame = result.spectrumFrame; + progress(100); + return result; + } + + cv::Mat filtered = spectrum.shiftedSpectrum.clone(); + const cv::Point sideband(spectrum.shiftedSpectrum.cols / 2 + sidebandX, + spectrum.shiftedSpectrum.rows / 2 + sidebandY); + const cv::Mat mask = circularMask(spectrum.shiftedSpectrum.cols, + spectrum.shiftedSpectrum.rows, + sideband, + radius, + params.softEdge, + params.softEdgeSigma); + applyMask(filtered, mask); + progress(30); + + cv::Mat demodulated; + circularShift(filtered, demodulated, -sidebandX, -sidebandY); + if (params.z != 0.0) + { + applyPropagation(demodulated, + params.wavelength, + params.pixelSize, + params.z); + } + progress(48); + + fftShift(demodulated); + cv::Mat complexField; + cv::idft(demodulated, + complexField, + cv::DFT_SCALE | cv::DFT_COMPLEX_OUTPUT); + cv::Mat fieldRoi(complexField, + cv::Rect(0, 0, spectrum.roiWidth, spectrum.roiHeight)); + + cv::Mat planes[2]; + cv::split(fieldRoi, planes); + cv::Mat amplitude; + cv::Mat phase; + cv::magnitude(planes[0], planes[1], amplitude); + cv::phase(planes[0], planes[1], phase, false); + const cv::Mat wrappedPhase = phase.clone(); + progress(62); + + if (params.unwrapPhase) + { + phase = unwrapPhase2D(phase, cancel); + if (phase.empty()) + { + return {}; + } + } + if (cancel) + { + return {}; + } + progress(78); + + if (params.removeTilt) + { + removeTilt(phase); + } + progress(88); + + switch (params.outputMode) + { + case DhmOutputMode::QuantitativePhase: + { + const cv::Mat quantitativePhase = phase * (params.wavelength / TwoPi); + result.outputFrame = normalizedFrame( + quantitativePhase, input, QStringLiteral("dhm.phase")); + break; + } + case DhmOutputMode::WrappedPhase: + { + cv::Mat displayPhase = wrappedPhase.clone(); + for (int y = 0; y < displayPhase.rows; ++y) + { + for (int x = 0; x < displayPhase.cols; ++x) + { + displayPhase.at(y, x) + = (displayPhase.at(y, x) + Pi) / TwoPi; + } + } + result.outputFrame = normalizedFrame( + displayPhase, input, QStringLiteral("dhm.wrapped_phase")); + break; + } + case DhmOutputMode::Amplitude: + result.outputFrame = normalizedFrame( + amplitude, input, QStringLiteral("dhm.amplitude")); + break; + case DhmOutputMode::Spectrum: + break; + } + + progress(100); + return result; + } +} diff --git a/plugins/tools/DhmTool/DhmReconstruction.h b/plugins/tools/DhmTool/DhmReconstruction.h new file mode 100644 index 0000000..cf129f4 --- /dev/null +++ b/plugins/tools/DhmTool/DhmReconstruction.h @@ -0,0 +1,65 @@ +#pragma once + +#include "scopeone/ImageFrame.h" + +#include +#include + +#include +#include +#include + +namespace scopeone::dhm +{ + enum class DhmRoiMode + { + FullFrame, + CenterCrop + }; + + enum class DhmOutputMode + { + QuantitativePhase, + WrappedPhase, + Amplitude, + Spectrum + }; + + struct DhmParameters + { + int sidebandX{48}; + int sidebandY{-32}; + int radius{24}; + bool autoDetectSideband{true}; + bool softEdge{true}; + double softEdgeSigma{2.0}; + double wavelength{632.8e-9}; + double pixelSize{5.5e-6}; + double z{0.0}; + bool unwrapPhase{false}; + bool removeTilt{false}; + DhmRoiMode roiMode{DhmRoiMode::FullFrame}; + int roiSize{512}; + DhmOutputMode outputMode{DhmOutputMode::QuantitativePhase}; + }; + + struct DhmResult + { + scopeone::core::ImageFrame outputFrame; + scopeone::core::ImageFrame spectrumFrame; + int detectedSidebandX{0}; + int detectedSidebandY{0}; + }; + + DhmResult reconstruct( + const scopeone::core::ImageFrame& input, + const DhmParameters& params, + const std::atomic_bool& cancel, + const std::function& progress); + + std::pair computeSpectrum( + const scopeone::core::ImageFrame& input, + const DhmParameters& params); + + cv::Point autoDetectSideband(const cv::Mat& logMagSpectrum); +} diff --git a/plugins/tools/DhmTool/DhmToolPlugin.cpp b/plugins/tools/DhmTool/DhmToolPlugin.cpp new file mode 100644 index 0000000..c3b0052 --- /dev/null +++ b/plugins/tools/DhmTool/DhmToolPlugin.cpp @@ -0,0 +1,706 @@ +#include "DhmReconstruction.h" + +#include "scopeone/ToolFrameStream.h" +#include "scopeone/ToolPlugin.h" +#include "scopeone/ToolTask.h" +#include "scopeone/ScopeOneCore.h" +#include "scopeone/ImageSceneModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include + +namespace +{ + using scopeone::core::ImageFrame; + using scopeone::dhm::DhmOutputMode; + using scopeone::dhm::DhmParameters; + using scopeone::dhm::DhmRoiMode; + + class SpectrumView final : public QWidget + { + public: + explicit SpectrumView(QWidget* parent = nullptr) + : QWidget(parent) + { + setMinimumSize(320, 320); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMouseTracking(true); + } + + void setFrame(const ImageFrame& frame) + { + m_frame = frame; + if (!frame.isValid()) + { + m_image = {}; + update(); + return; + } + + const cv::Mat source(frame.height, + frame.width, + CV_16UC1, + const_cast(reinterpret_cast( + frame.bytes.constData())), + static_cast(frame.stride)); + cv::Mat eightBit; + source.convertTo(eightBit, CV_8U, 1.0 / 256.0); + cv::Mat colored; + cv::applyColorMap(eightBit, colored, cv::COLORMAP_JET); + cv::cvtColor(colored, colored, cv::COLOR_BGR2RGB); + m_image = QImage(colored.data, + colored.cols, + colored.rows, + static_cast(colored.step), + QImage::Format_RGB888) + .copy(); + update(); + } + + void setSelection(const QPoint& offset, int radius) + { + m_selection = offset; + m_radius = radius; + update(); + } + + void setSelectionChanged(std::function callback) + { + m_selectionChanged = std::move(callback); + } + + protected: + void paintEvent(QPaintEvent*) override + { + QPainter painter(this); + painter.fillRect(rect(), QColor(QStringLiteral("#171b20"))); + if (m_image.isNull()) + { + painter.setPen(QColor(QStringLiteral("#8d98a4"))); + painter.drawText(rect(), Qt::AlignCenter, tr("Spectrum unavailable")); + return; + } + + const QRectF imageRect = fittedImageRect(); + painter.drawImage(imageRect, m_image); + + const QPoint center(m_frame.width / 2, m_frame.height / 2); + const QPoint selected(center.x() + m_selection.x(), + center.y() + m_selection.y()); + const QPointF selectedPoint = imagePoint(selected, imageRect); + const QPointF centerPoint = imagePoint(center, imageRect); + const double scaleX = imageRect.width() / m_frame.width; + const double scaleY = imageRect.height() / m_frame.height; + const double radius = m_radius * (scaleX + scaleY) / 2.0; + + painter.setRenderHint(QPainter::Antialiasing); + painter.setPen(QPen(QColor(QStringLiteral("#ffffff")), 1.5)); + painter.drawLine(selectedPoint.x() - 8, + selectedPoint.y(), + selectedPoint.x() + 8, + selectedPoint.y()); + painter.drawLine(selectedPoint.x(), + selectedPoint.y() - 8, + selectedPoint.x(), + selectedPoint.y() + 8); + painter.setPen(QPen(QColor(QStringLiteral("#ffd166")), 2.0)); + painter.drawEllipse(selectedPoint, radius, radius); + painter.setPen(QPen(QColor(QStringLiteral("#ffffff")), 1.0, Qt::DashLine)); + painter.drawEllipse(centerPoint, 4.0, 4.0); + } + + void mousePressEvent(QMouseEvent* event) override + { + if (event->button() != Qt::LeftButton || m_image.isNull()) + { + return; + } + + const QRectF imageRect = fittedImageRect(); + if (!imageRect.contains(event->position())) + { + return; + } + const int x = std::clamp( + static_cast((event->position().x() - imageRect.left()) + * m_frame.width / imageRect.width()), + 0, + m_frame.width - 1); + const int y = std::clamp( + static_cast((event->position().y() - imageRect.top()) + * m_frame.height / imageRect.height()), + 0, + m_frame.height - 1); + const QPoint center(m_frame.width / 2, m_frame.height / 2); + m_selection = QPoint(x - center.x(), y - center.y()); + update(); + m_selectionChanged(m_selection); + } + + private: + QRectF fittedImageRect() const + { + const QSizeF imageSize = m_image.size(); + const QSizeF available = size(); + const double scale = std::min(available.width() / imageSize.width(), + available.height() / imageSize.height()); + const QSizeF scaled = imageSize * scale; + return QRectF((available.width() - scaled.width()) / 2.0, + (available.height() - scaled.height()) / 2.0, + scaled.width(), + scaled.height()); + } + + QPointF imagePoint(const QPoint& point, const QRectF& imageRect) const + { + return {imageRect.left() + imageRect.width() * point.x() / m_frame.width, + imageRect.top() + imageRect.height() * point.y() / m_frame.height}; + } + + ImageFrame m_frame; + QImage m_image; + QPoint m_selection; + int m_radius{24}; + std::function m_selectionChanged; + }; + + class DhmTool final : public QWidget + { + public: + DhmTool(scopeone::ui::ScopeOneToolContext& context, QWidget* parent) + : QWidget(parent) + , m_context(context) + { + setWindowFlag(Qt::Window, true); + setWindowTitle(QStringLiteral("DHM Reconstruction")); + resize(1180, 820); + + auto* layout = new QVBoxLayout(this); + auto* previews = new QHBoxLayout(); + m_inputPreview = createPreview(QStringLiteral("Input hologram")); + m_spectrumView = new SpectrumView(this); + m_resultPreview = createPreview(QStringLiteral("Reconstructed result")); + previews->addWidget(m_inputPreview, 1); + previews->addWidget(m_spectrumView, 1); + previews->addWidget(m_resultPreview, 1); + layout->addLayout(previews, 1); + + auto* controls = new QHBoxLayout(); + controls->addLayout(createSidebandForm(), 1); + controls->addLayout(createOpticsForm(), 1); + controls->addLayout(createOutputForm(), 1); + layout->addLayout(controls); + + auto* actions = new QHBoxLayout(); + auto* layerLabel = new QLabel(QStringLiteral("Input:"), this); + m_layerComboBox = new QComboBox(this); + m_layerComboBox->setMinimumWidth(140); + m_autoDetectButton = new QPushButton(QStringLiteral("Auto detect +1"), this); + m_reconstructButton = new QPushButton(QStringLiteral("Reconstruct once"), this); + m_cancelButton = new QPushButton(QStringLiteral("Cancel"), this); + m_cancelButton->setEnabled(false); + actions->addWidget(layerLabel); + actions->addWidget(m_layerComboBox); + actions->addWidget(m_autoDetectButton); + actions->addWidget(m_reconstructButton); + actions->addWidget(m_cancelButton); + actions->addStretch(); + m_liveCheckBox = new QCheckBox(QStringLiteral("Live reconstruction"), this); + m_liveCheckBox->setChecked(true); + actions->addWidget(m_liveCheckBox); + layout->addLayout(actions); + + auto* statusLayout = new QHBoxLayout(); + m_progress = new QProgressBar(this); + m_progress->setRange(0, 100); + m_progress->setValue(0); + m_status = new QLabel(QStringLiteral("Ready"), this); + statusLayout->addWidget(m_progress, 1); + statusLayout->addWidget(m_status); + layout->addLayout(statusLayout); + + m_stream = new scopeone::ui::ScopeOneToolFrameStream(m_context.core(), this); + + refreshLayerComboBox(); + connect(m_context.core().imageSceneModel(), + &scopeone::core::ImageSceneModel::layersChanged, + this, + [this]() + { + const QString previousSelection = m_layerComboBox->currentData().toString(); + refreshLayerComboBox(); + if (previousSelection != m_layerComboBox->currentData().toString()) + { + startReconstruction(); + } + }); + connect(m_layerComboBox, &QComboBox::currentIndexChanged, this, + [this]() + { + const QString layerKey = m_layerComboBox->currentData().toString(); + m_sourceId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); + m_stream->setSourceId(m_sourceId); + startReconstruction(); + }); + + m_spectrumView->setSelectionChanged([this](const QPoint& offset) + { + setManualSideband(offset); + }); + connect(m_autoDetectButton, &QPushButton::clicked, this, + [this]() + { + m_autoDetectCheckBox->setChecked(true); + startReconstruction(); + }); + connect(m_reconstructButton, &QPushButton::clicked, this, + [this]() { startReconstruction(); }); + connect(m_cancelButton, &QPushButton::clicked, this, + [this]() + { + m_liveCheckBox->setChecked(false); + m_task->cancel(); + }); + connect(m_liveCheckBox, &QCheckBox::toggled, this, + [this](bool enabled) + { + m_stream->setEnabled(enabled); + if (!enabled) + { + m_status->setText(m_task ? QStringLiteral("Finishing current frame") + : QStringLiteral("Ready")); + return; + } + const QString layerKey = m_layerComboBox->currentData().toString(); + const ImageFrame frame = !layerKey.isEmpty() + ? m_context.core().graphFrame(layerKey) + : m_context.currentFrame(); + if (frame.isValid()) + { + m_sourceId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); + m_stream->setSourceId(m_sourceId); + queueFrame(frame); + } + }); + connect(m_stream, &scopeone::ui::ScopeOneToolFrameStream::frameReady, this, + [this](const ImageFrame& frame) + { + if (m_liveCheckBox->isChecked()) + { + m_sourceId = frame.cameraId; + m_stream->setSourceId(m_sourceId); + queueFrame(frame); + } + }); + + const ImageFrame frame = m_context.currentFrame(); + if (frame.isValid()) + { + m_sourceId = frame.cameraId; + m_stream->setSourceId(m_sourceId); + queueFrame(frame); + } + } + + private: + QFormLayout* createSidebandForm() + { + auto* form = new QFormLayout(); + m_autoDetectCheckBox = new QCheckBox(QStringLiteral("Auto detect sideband"), this); + m_autoDetectCheckBox->setChecked(true); + m_sidebandX = createSpinBox(-4096, 4096, 48); + m_sidebandY = createSpinBox(-4096, 4096, -32); + m_radius = createSpinBox(1, 4096, 24); + m_softEdgeCheckBox = new QCheckBox(QStringLiteral("Soft circular edge"), this); + m_softEdgeCheckBox->setChecked(true); + m_softEdgeSigma = createDoubleSpinBox(0.1, 30.0, 2.0, 1); + form->addRow(m_autoDetectCheckBox); + form->addRow(QStringLiteral("Sideband X"), m_sidebandX); + form->addRow(QStringLiteral("Sideband Y"), m_sidebandY); + form->addRow(QStringLiteral("Filter radius"), m_radius); + form->addRow(m_softEdgeCheckBox); + form->addRow(QStringLiteral("Soft edge sigma"), m_softEdgeSigma); + connect(m_sidebandX, &QSpinBox::valueChanged, this, + [this]() { m_autoDetectCheckBox->setChecked(false); }); + connect(m_sidebandY, &QSpinBox::valueChanged, this, + [this]() { m_autoDetectCheckBox->setChecked(false); }); + connect(m_radius, &QSpinBox::valueChanged, this, + [this](int radius) { m_spectrumView->setSelection(currentOffset(), radius); }); + return form; + } + + QFormLayout* createOpticsForm() + { + auto* form = new QFormLayout(); + m_roiMode = new QComboBox(this); + m_roiMode->addItem(QStringLiteral("Full frame"), + static_cast(DhmRoiMode::FullFrame)); + m_roiMode->addItem(QStringLiteral("Center crop 512"), + static_cast(DhmRoiMode::CenterCrop)); + m_roiMode->addItem(QStringLiteral("Center crop 1024"), + static_cast(DhmRoiMode::CenterCrop)); + m_roiSize = createSpinBox(32, 4096, 512); + m_wavelength = createDoubleSpinBox(100.0, 2000.0, 632.8, 1); + m_pixelSize = createDoubleSpinBox(0.01, 100.0, 5.5, 3); + m_z = createDoubleSpinBox(-100.0, 100.0, 0.0, 4); + m_wavelength->setSuffix(QStringLiteral(" nm")); + m_pixelSize->setSuffix(QStringLiteral(" um")); + m_z->setSuffix(QStringLiteral(" mm")); + form->addRow(QStringLiteral("ROI"), m_roiMode); + form->addRow(QStringLiteral("Crop size"), m_roiSize); + form->addRow(QStringLiteral("Wavelength"), m_wavelength); + form->addRow(QStringLiteral("Pixel size"), m_pixelSize); + form->addRow(QStringLiteral("Propagation z"), m_z); + connect(m_roiMode, qOverload(&QComboBox::currentIndexChanged), this, + [this](int index) + { + m_roiSize->setEnabled(index != 0); + if (index == 1) + { + m_roiSize->setValue(512); + } + if (index == 2) + { + m_roiSize->setValue(1024); + } + }); + return form; + } + + QFormLayout* createOutputForm() + { + auto* form = new QFormLayout(); + m_unwrapCheckBox = new QCheckBox(QStringLiteral("Quality-guided unwrap"), this); + m_tiltCheckBox = new QCheckBox(QStringLiteral("Remove tilt plane"), this); + m_outputMode = new QComboBox(this); + m_outputMode->addItem(QStringLiteral("Quantitative phase"), + static_cast(DhmOutputMode::QuantitativePhase)); + m_outputMode->addItem(QStringLiteral("Wrapped phase"), + static_cast(DhmOutputMode::WrappedPhase)); + m_outputMode->addItem(QStringLiteral("Amplitude"), + static_cast(DhmOutputMode::Amplitude)); + m_outputMode->addItem(QStringLiteral("Spectrum"), + static_cast(DhmOutputMode::Spectrum)); + form->addRow(m_unwrapCheckBox); + form->addRow(m_tiltCheckBox); + form->addRow(QStringLiteral("Output"), m_outputMode); + return form; + } + + static QSpinBox* createSpinBox(int minimum, int maximum, int value) + { + auto* spinBox = new QSpinBox(); + spinBox->setRange(minimum, maximum); + spinBox->setValue(value); + return spinBox; + } + + static QDoubleSpinBox* createDoubleSpinBox(double minimum, + double maximum, + double value, + int decimals) + { + auto* spinBox = new QDoubleSpinBox(); + spinBox->setRange(minimum, maximum); + spinBox->setDecimals(decimals); + spinBox->setValue(value); + return spinBox; + } + + static QLabel* createPreview(const QString& title) + { + auto* preview = new QLabel(title); + preview->setAlignment(Qt::AlignCenter); + preview->setMinimumSize(300, 300); + preview->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + preview->setFrameShape(QFrame::Box); + preview->setStyleSheet(QStringLiteral("background: #202020; color: #aaaaaa;")); + return preview; + } + + static void showPreview(QLabel* preview, const ImageFrame& frame) + { + const QImage image(reinterpret_cast(frame.bytes.constData()), + frame.width, + frame.height, + frame.stride, + frame.isMono16() ? QImage::Format_Grayscale16 + : QImage::Format_Grayscale8); + preview->setPixmap(QPixmap::fromImage(image).scaled( + preview->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); + } + + QPoint currentOffset() const + { + return {m_sidebandX->value(), m_sidebandY->value()}; + } + + void setManualSideband(const QPoint& offset) + { + QSignalBlocker blockX(m_sidebandX); + QSignalBlocker blockY(m_sidebandY); + m_sidebandX->setValue(offset.x()); + m_sidebandY->setValue(offset.y()); + m_autoDetectCheckBox->setChecked(false); + m_spectrumView->setSelection(offset, m_radius->value()); + } + + void refreshLayerComboBox() + { + const QString currentSelection = m_layerComboBox->currentData().toString(); + const QString activeKey = m_context.currentLayerKey(); + const QSignalBlocker blocker(m_layerComboBox); + m_layerComboBox->clear(); + const QStringList layerIds = m_context.core().imageSceneModel()->layerIds(); + for (const QString& layerKey : layerIds) + { + QString name = layerKey; + scopeone::core::DocumentLayer layer; + if (m_context.core().imageSceneModel()->findLayer(layerKey, layer) && !layer.name.isEmpty()) + { + name = layer.name; + } + m_layerComboBox->addItem(name, layerKey); + } + int index = m_layerComboBox->findData(currentSelection); + if (index < 0 && !activeKey.isEmpty()) + { + index = m_layerComboBox->findData(activeKey); + } + if (index >= 0) + { + m_layerComboBox->setCurrentIndex(index); + } + } + + void startReconstruction() + { + const QString layerKey = m_layerComboBox->currentData().toString(); + const ImageFrame input = !layerKey.isEmpty() + ? m_context.core().graphFrame(layerKey) + : m_context.currentFrame(); + if (!input.isValid()) + { + m_status->setText(QStringLiteral("No hologram layer available")); + return; + } + m_sourceId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); + m_stream->setSourceId(m_sourceId); + queueFrame(input); + } + + DhmParameters parameters() const + { + DhmParameters params; + params.autoDetectSideband = m_autoDetectCheckBox->isChecked(); + params.sidebandX = m_sidebandX->value(); + params.sidebandY = m_sidebandY->value(); + params.radius = m_radius->value(); + params.softEdge = m_softEdgeCheckBox->isChecked(); + params.softEdgeSigma = m_softEdgeSigma->value(); + params.wavelength = m_wavelength->value() * 1.0e-9; + params.pixelSize = m_pixelSize->value() * 1.0e-6; + params.z = m_z->value() * 1.0e-3; + params.unwrapPhase = m_unwrapCheckBox->isChecked(); + params.removeTilt = m_tiltCheckBox->isChecked(); + params.roiMode = static_cast(m_roiMode->currentData().toInt()); + params.roiSize = m_roiSize->value(); + params.outputMode = static_cast(m_outputMode->currentData().toInt()); + return params; + } + + void queueFrame(const ImageFrame& input) + { + showPreview(m_inputPreview, input); + m_stream->setProcessing(true); + const DhmParameters params = parameters(); + auto result = std::make_shared(); + m_task = new scopeone::ui::ScopeOneToolTask( + [input, params, result](const std::atomic_bool& cancel, + const std::function& progress) + { + *result = scopeone::dhm::reconstruct(input, params, cancel, progress); + }, + this); + connect(m_task, &scopeone::ui::ScopeOneToolTask::progressChanged, this, + [this](int percent) + { + m_progress->setValue(percent); + m_status->setText(QStringLiteral("Reconstructing %1%%").arg(percent)); + }); + connect(m_task, &scopeone::ui::ScopeOneToolTask::finished, this, + [this, result]() { publishResult(*result); }); + connect(m_task, &scopeone::ui::ScopeOneToolTask::canceled, this, + [this]() { finishTask(QStringLiteral("Canceled")); }); + connect(m_task, &scopeone::ui::ScopeOneToolTask::failed, this, + [this](const QString& message) { finishTask(message); }); + + m_reconstructButton->setEnabled(false); + m_autoDetectButton->setEnabled(false); + m_layerComboBox->setEnabled(false); + m_cancelButton->setEnabled(true); + m_progress->setValue(0); + m_status->setText(QStringLiteral("Reconstructing 0%")); + m_task->start(); + } + + void publishResult(const scopeone::dhm::DhmResult& result) + { + if (!result.outputFrame.isValid()) + { + finishTask(QStringLiteral("Reconstruction produced no output")); + return; + } + + m_spectrumView->setFrame(result.spectrumFrame); + if (m_autoDetectCheckBox->isChecked()) + { + QSignalBlocker blockX(m_sidebandX); + QSignalBlocker blockY(m_sidebandY); + m_sidebandX->setValue(result.detectedSidebandX); + m_sidebandY->setValue(result.detectedSidebandY); + } + m_spectrumView->setSelection(currentOffset(), m_radius->value()); + showPreview(m_resultPreview, result.outputFrame); + + const QString sourceId = result.outputFrame.cameraId; + const QString displayName = outputName(); + const ImageFrame stored = m_context.publishToolStreamFrame( + sourceId, + result.outputFrame, + displayName); + if (!stored.isValid()) + { + finishTask(QStringLiteral("Failed to publish DHM output")); + return; + } + + QStringList layers; + const QString rawLayer = scopeone::core::ScopeOneCore::rawLayerKey(m_sourceId); + if (m_context.core().graphFrame(rawLayer).isValid()) + { + layers.append(rawLayer); + } + layers.append(scopeone::core::ScopeOneCore::toolLayerKey(stored.cameraId)); + m_context.showLayers(layers, layers.size() > 1); + finishTask(QStringLiteral("DHM output ready")); + } + + QString outputName() const + { + switch (static_cast(m_outputMode->currentData().toInt())) + { + case DhmOutputMode::QuantitativePhase: + return QStringLiteral("DHM Quantitative Phase"); + case DhmOutputMode::WrappedPhase: + return QStringLiteral("DHM Wrapped Phase"); + case DhmOutputMode::Amplitude: + return QStringLiteral("DHM Amplitude"); + case DhmOutputMode::Spectrum: + return QStringLiteral("DHM Spectrum"); + } + return QStringLiteral("DHM Output"); + } + + void finishTask(const QString& status) + { + m_progress->setValue(status == QStringLiteral("DHM output ready") ? 100 : m_progress->value()); + m_status->setText(status); + m_reconstructButton->setEnabled(true); + m_autoDetectButton->setEnabled(true); + m_layerComboBox->setEnabled(true); + m_cancelButton->setEnabled(false); + if (m_task) + { + m_task->deleteLater(); + m_task = nullptr; + } + if (!m_liveCheckBox->isChecked()) + { + m_stream->clearPendingFrame(); + } + m_stream->setProcessing(false); + } + + scopeone::ui::ScopeOneToolContext& m_context; + QLabel* m_inputPreview{nullptr}; + SpectrumView* m_spectrumView{nullptr}; + QLabel* m_resultPreview{nullptr}; + QCheckBox* m_autoDetectCheckBox{nullptr}; + QSpinBox* m_sidebandX{nullptr}; + QSpinBox* m_sidebandY{nullptr}; + QSpinBox* m_radius{nullptr}; + QCheckBox* m_softEdgeCheckBox{nullptr}; + QDoubleSpinBox* m_softEdgeSigma{nullptr}; + QComboBox* m_roiMode{nullptr}; + QSpinBox* m_roiSize{nullptr}; + QDoubleSpinBox* m_wavelength{nullptr}; + QDoubleSpinBox* m_pixelSize{nullptr}; + QDoubleSpinBox* m_z{nullptr}; + QCheckBox* m_unwrapCheckBox{nullptr}; + QCheckBox* m_tiltCheckBox{nullptr}; + QComboBox* m_outputMode{nullptr}; + QComboBox* m_layerComboBox{nullptr}; + QPushButton* m_autoDetectButton{nullptr}; + QPushButton* m_reconstructButton{nullptr}; + QPushButton* m_cancelButton{nullptr}; + QCheckBox* m_liveCheckBox{nullptr}; + QProgressBar* m_progress{nullptr}; + QLabel* m_status{nullptr}; + scopeone::ui::ScopeOneToolTask* m_task{nullptr}; + scopeone::ui::ScopeOneToolFrameStream* m_stream{nullptr}; + QString m_sourceId; + }; + + class DhmToolPlugin final : public QObject, + public scopeone::ui::ScopeOneToolPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID ScopeOneToolPlugin_iid FILE "plugin.json") + Q_INTERFACES(scopeone::ui::ScopeOneToolPlugin) + + public: + QList tools() const override + { + return {{QStringLiteral("scopeone.dhm_reconstruction"), + QStringLiteral("DHM Reconstruction"), + QStringLiteral("Reconstruction"), + scopeone::ui::ToolWindowMode::ModelessSingleton, + false}}; + } + + QWidget* createTool(const QString& toolId, + scopeone::ui::ScopeOneToolContext& context, + QWidget* parent) override + { + return toolId == QStringLiteral("scopeone.dhm_reconstruction") + ? new DhmTool(context, parent) + : nullptr; + } + }; +} + +#include "DhmToolPlugin.moc" diff --git a/plugins/tools/DhmTool/plugin.json b/plugins/tools/DhmTool/plugin.json new file mode 100644 index 0000000..966d58e --- /dev/null +++ b/plugins/tools/DhmTool/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "scopeone.dhm.reconstruction", + "name": "DHM Reconstruction", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "tool" +} diff --git a/plugins/tools/ExampleTool/CMakeLists.txt b/plugins/tools/ExampleTool/CMakeLists.txt new file mode 100644 index 0000000..78abc6b --- /dev/null +++ b/plugins/tools/ExampleTool/CMakeLists.txt @@ -0,0 +1,6 @@ +add_library(ScopeOneExampleTool MODULE + ExampleToolPlugin.cpp + plugin.json +) +target_link_libraries(ScopeOneExampleTool PRIVATE scopeone::PluginSDK Qt6::Widgets) +scopeone_add_plugin(ScopeOneExampleTool tools) diff --git a/plugins/tools/ExampleTool/ExampleToolPlugin.cpp b/plugins/tools/ExampleTool/ExampleToolPlugin.cpp new file mode 100644 index 0000000..571bd86 --- /dev/null +++ b/plugins/tools/ExampleTool/ExampleToolPlugin.cpp @@ -0,0 +1,43 @@ +#include "scopeone/ToolPlugin.h" + +#include +#include +#include +#include + +namespace +{ + class ExampleToolPlugin final : public QObject, + public scopeone::ui::ScopeOneToolPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID ScopeOneToolPlugin_iid FILE "plugin.json") + Q_INTERFACES(scopeone::ui::ScopeOneToolPlugin) + + public: + QList tools() const override + { + return {{QStringLiteral("example.tool.window"), + QStringLiteral("Example Tool"), + QStringLiteral("Examples")}}; + } + + QWidget* createTool(const QString& toolId, + scopeone::ui::ScopeOneToolContext& context, + QWidget* parent) override + { + if (toolId != QStringLiteral("example.tool.window")) + { + return nullptr; + } + auto* window = new QWidget(parent, Qt::Window); + window->setWindowTitle(QStringLiteral("Example Tool")); + auto* layout = new QVBoxLayout(window); + layout->addWidget(new QLabel( + QStringLiteral("Active layer: %1").arg(context.currentLayerKey()), window)); + return window; + } + }; +} + +#include "ExampleToolPlugin.moc" diff --git a/plugins/tools/ExampleTool/plugin.json b/plugins/tools/ExampleTool/plugin.json new file mode 100644 index 0000000..b9e207b --- /dev/null +++ b/plugins/tools/ExampleTool/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "example.tool", + "name": "Example Tool", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "tool" +} diff --git a/plugins/tools/ScanningDaqTool/CMakeLists.txt b/plugins/tools/ScanningDaqTool/CMakeLists.txt new file mode 100644 index 0000000..54ecbb4 --- /dev/null +++ b/plugins/tools/ScanningDaqTool/CMakeLists.txt @@ -0,0 +1,14 @@ +add_library(ScopeOneScanningDaqTool MODULE + DaqControlWidget.cpp + DaqControlWidget.h + SignalMonitorWidget.cpp + SignalMonitorWidget.h + ScanningDaqToolPlugin.cpp + plugin.json +) +set_target_properties(ScopeOneScanningDaqTool PROPERTIES OUTPUT_NAME "ScanningDaqTool") +target_link_libraries(ScopeOneScanningDaqTool PRIVATE + scopeone::PluginSDK + Qt6::Widgets +) +scopeone_add_plugin(ScopeOneScanningDaqTool tools) diff --git a/plugins/tools/ScanningDaqTool/DaqControlWidget.cpp b/plugins/tools/ScanningDaqTool/DaqControlWidget.cpp new file mode 100644 index 0000000..7addc78 --- /dev/null +++ b/plugins/tools/ScanningDaqTool/DaqControlWidget.cpp @@ -0,0 +1,834 @@ +#include "DaqControlWidget.h" + +#include "scopeone/ScopeOneCore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace scopeone::plugins +{ + namespace + { + QComboBox* editableCombo(const QStringList& values, QWidget* parent) + { + auto* combo = new QComboBox(parent); + combo->setEditable(true); + combo->addItem(QString()); + combo->addItems(values); + return combo; + } + + QDoubleSpinBox* realSpin(double minimum, + double maximum, + double value, + int decimals, + const QString& suffix, + QWidget* parent) + { + auto* spin = new QDoubleSpinBox(parent); + spin->setRange(minimum, maximum); + spin->setDecimals(decimals); + spin->setValue(value); + spin->setSuffix(suffix); + return spin; + } + + QStringList commaSeparated(const QString& text) + { + QStringList result; + for (const QString& item : text.split(QLatin1Char(','), Qt::SkipEmptyParts)) + { + const QString trimmed = item.trimmed(); + if (!trimmed.isEmpty()) + { + result.append(trimmed); + } + } + return result; + } + } + + DaqControlWidget::DaqControlWidget(scopeone::core::ScopeOneCore* core, + QWidget* parent) + : QWidget(parent) + , m_core(core) + { + auto* layout = new QVBoxLayout(this); + auto* deviceForm = new QFormLayout(); + m_deviceCombo = new QComboBox(this); + deviceForm->addRow(tr("Device"), m_deviceCombo); + m_productLabel = new QLabel(this); + m_productLabel->setWordWrap(true); + deviceForm->addRow(tr("Hardware"), m_productLabel); + m_resourcesLabel = new QLabel(this); + m_resourcesLabel->setWordWrap(true); + deviceForm->addRow(tr("Resources"), m_resourcesLabel); + layout->addLayout(deviceForm); + + m_rasterGroup = new QGroupBox(tr("Raster scan timing"), this); + m_rasterGroup->setCheckable(true); + m_rasterGroup->setChecked(false); + m_rasterContents = new QWidget(m_rasterGroup); + auto* rasterLayout = new QVBoxLayout(m_rasterGroup); + rasterLayout->setContentsMargins(0, 0, 0, 0); + rasterLayout->addWidget(m_rasterContents); + auto* rasterForm = new QFormLayout(m_rasterContents); + m_lineClockCombo = editableCombo({}, m_rasterContents); + rasterForm->addRow(tr("Line clock input"), m_lineClockCombo); + m_lineRateSpin = realSpin(0.001, 100000000.0, 1000.0, 3, + tr(" Hz"), m_rasterContents); + rasterForm->addRow(tr("Nominal line rate"), m_lineRateSpin); + m_activeLinesSpin = new QSpinBox(m_rasterContents); + m_activeLinesSpin->setRange(2, 1000000); + m_activeLinesSpin->setValue(512); + rasterForm->addRow(tr("Active lines"), m_activeLinesSpin); + m_flybackLinesSpin = new QSpinBox(m_rasterContents); + m_flybackLinesSpin->setRange(2, 1000000); + m_flybackLinesSpin->setValue(16); + rasterForm->addRow(tr("Flyback lines"), m_flybackLinesSpin); + m_yChannelCombo = new QComboBox(m_rasterContents); + rasterForm->addRow(tr("Y analog output"), m_yChannelCombo); + m_yStartSpin = realSpin(-1000.0, 1000.0, -1.0, 6, + tr(" V"), m_rasterContents); + rasterForm->addRow(tr("Y start"), m_yStartSpin); + m_yEndSpin = realSpin(-1000.0, 1000.0, 1.0, 6, + tr(" V"), m_rasterContents); + rasterForm->addRow(tr("Y end"), m_yEndSpin); + m_frameCounterCombo = new QComboBox(m_rasterContents); + rasterForm->addRow(tr("Frame counter"), m_frameCounterCombo); + m_lineOutputCombo = editableCombo({}, m_rasterContents); + rasterForm->addRow(tr("Line output"), m_lineOutputCombo); + m_frameOutputCombo = editableCombo({}, m_rasterContents); + rasterForm->addRow(tr("Frame output"), m_frameOutputCombo); + m_rasterContents->setVisible(false); + layout->addWidget(m_rasterGroup); + + layout->addWidget(new QLabel(tr("Counter pulse tasks"), this)); + m_pulseTable = new QTableWidget(0, 7, this); + m_pulseTable->setHorizontalHeaderLabels( + {tr("Counter"), tr("Output terminal"), tr("Frequency"), tr("Duty"), tr("Delay"), + tr("Start trigger"), tr("Edge")}); + m_pulseTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_pulseTable->horizontalHeader()->setStretchLastSection(true); + m_pulseTable->verticalHeader()->hide(); + m_pulseTable->setSelectionBehavior(QAbstractItemView::SelectRows); + layout->addWidget(m_pulseTable); + + auto* pulseButtons = new QHBoxLayout(); + m_addPulseButton = new QPushButton(tr("Add pulse"), this); + m_removePulseButton = new QPushButton(tr("Remove"), this); + pulseButtons->addWidget(m_addPulseButton); + pulseButtons->addWidget(m_removePulseButton); + pulseButtons->addStretch(); + layout->addLayout(pulseButtons); + + layout->addWidget(new QLabel(tr("Buffered hardware tasks"), this)); + m_bufferedTable = new QTableWidget(0, 12, this); + m_bufferedTable->setHorizontalHeaderLabels( + {tr("Type"), tr("Channels"), tr("Minimum"), tr("Maximum"), + tr("Sample clock"), tr("Clock edge"), tr("Rate"), tr("Samples"), + tr("Mode"), tr("Start trigger"), tr("Trigger edge"), + tr("Output data")}); + m_bufferedTable->horizontalHeader()->setSectionResizeMode( + QHeaderView::ResizeToContents); + m_bufferedTable->horizontalHeader()->setStretchLastSection(true); + m_bufferedTable->verticalHeader()->hide(); + m_bufferedTable->setSelectionBehavior(QAbstractItemView::SelectRows); + layout->addWidget(m_bufferedTable); + + auto* bufferedButtons = new QHBoxLayout(); + m_addBufferedButton = new QPushButton(tr("Add task"), this); + m_removeBufferedButton = new QPushButton(tr("Remove"), this); + bufferedButtons->addWidget(m_addBufferedButton); + bufferedButtons->addWidget(m_removeBufferedButton); + bufferedButtons->addStretch(); + layout->addLayout(bufferedButtons); + + layout->addWidget(new QLabel(tr("Terminal routes"), this)); + m_routeTable = new QTableWidget(0, 3, this); + m_routeTable->setHorizontalHeaderLabels( + {tr("Source"), tr("Destination"), tr("Polarity")}); + m_routeTable->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); + m_routeTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); + m_routeTable->verticalHeader()->hide(); + m_routeTable->setSelectionBehavior(QAbstractItemView::SelectRows); + layout->addWidget(m_routeTable); + + auto* routeButtons = new QHBoxLayout(); + m_addRouteButton = new QPushButton(tr("Add route"), this); + m_removeRouteButton = new QPushButton(tr("Remove"), this); + routeButtons->addWidget(m_addRouteButton); + routeButtons->addWidget(m_removeRouteButton); + routeButtons->addStretch(); + layout->addLayout(routeButtons); + + auto* runButtons = new QHBoxLayout(); + m_startButton = new QPushButton(tr("Arm and start"), this); + m_startButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_stopButton = new QPushButton(tr("Stop"), this); + m_stopButton->setIcon(style()->standardIcon(QStyle::SP_MediaStop)); + m_stopButton->setEnabled(false); + runButtons->addWidget(m_startButton); + runButtons->addWidget(m_stopButton); + layout->addLayout(runButtons); + + m_statusLabel = new QLabel(tr("No DAQ devices available"), this); + m_statusLabel->setWordWrap(true); + layout->addWidget(m_statusLabel); + + for (const auto& device : m_core->daqDevices()) + { + m_deviceCombo->addItem(device.name, device.id); + m_deviceCombo->setItemData( + m_deviceCombo->count() - 1, + QStringLiteral("%1, %2").arg(device.provider, device.product), + Qt::ToolTipRole); + } + + connect(m_deviceCombo, &QComboBox::currentIndexChanged, + this, &DaqControlWidget::refreshDevice); + connect(m_rasterGroup, &QGroupBox::toggled, + m_rasterContents, &QWidget::setVisible); + connect(m_addPulseButton, &QPushButton::clicked, + this, &DaqControlWidget::addPulseRow); + connect(m_removePulseButton, &QPushButton::clicked, this, [this]() + { + removeSelectedRow(m_pulseTable); + }); + connect(m_addBufferedButton, &QPushButton::clicked, + this, &DaqControlWidget::addBufferedRow); + connect(m_removeBufferedButton, &QPushButton::clicked, this, [this]() + { + removeSelectedRow(m_bufferedTable); + }); + connect(m_addRouteButton, &QPushButton::clicked, + this, &DaqControlWidget::addRouteRow); + connect(m_removeRouteButton, &QPushButton::clicked, this, [this]() + { + removeSelectedRow(m_routeTable); + }); + connect(m_startButton, &QPushButton::clicked, + this, &DaqControlWidget::startSession); + connect(m_stopButton, &QPushButton::clicked, this, [this]() + { + m_core->stopDaqSession(m_activeDeviceId); + }); + connect(m_core, &scopeone::core::ScopeOneCore::daqStateChanged, + this, &DaqControlWidget::handleStateChanged); + connect(m_core, &scopeone::core::ScopeOneCore::daqError, + this, [](const QString&, const QString& message) + { + qCritical().noquote() << message; + }); + connect(m_core, &scopeone::core::ScopeOneCore::daqInputDataReady, + this, [this](const scopeone::core::DaqInputChunk& chunk) + { + if (chunk.deviceId != m_activeDeviceId) + { + return; + } + if (m_inputStatusTimer.isValid() + && m_inputStatusTimer.elapsed() < 100) + { + return; + } + m_inputStatusTimer.restart(); + const qsizetype values = !chunk.analogSamplesByScan.isEmpty() + ? chunk.analogSamplesByScan.size() + : chunk.digitalSamplesByScan.size(); + m_statusLabel->setText( + tr("%1: received %2 values").arg(chunk.taskName).arg(values)); + }); + + refreshDevice(); + } + + scopeone::core::DaqDeviceDescriptor DaqControlWidget::currentDevice() const + { + const QString id = m_deviceCombo->currentData().toString(); + for (const auto& device : m_core->daqDevices()) + { + if (device.id == id) + { + return device; + } + } + return {}; + } + + QStringList DaqControlWidget::terminalChoices( + const scopeone::core::DaqDeviceDescriptor& device) const + { + QStringList choices = device.terminals; + choices.removeDuplicates(); + std::sort(choices.begin(), choices.end(), + [](const QString& left, const QString& right) + { + return left.compare(right, Qt::CaseInsensitive) < 0; + }); + return choices; + } + + void DaqControlWidget::refreshDevice() + { + m_pulseTable->setRowCount(0); + m_bufferedTable->setRowCount(0); + m_routeTable->setRowCount(0); + const auto device = currentDevice(); + const bool available = !device.id.isEmpty(); + const QStringList terminals = terminalChoices(device); + for (QComboBox* combo : {m_lineClockCombo, m_lineOutputCombo, + m_frameOutputCombo}) + { + combo->clear(); + combo->addItem(QString()); + combo->addItems(terminals); + } + m_yChannelCombo->clear(); + m_frameCounterCombo->clear(); + for (const auto& channel : device.channels) + { + if (channel.type == scopeone::core::DaqChannelType::AnalogOutput) + { + m_yChannelCombo->addItem(channel.physicalName); + } + else if (channel.type == scopeone::core::DaqChannelType::CounterOutput) + { + m_frameCounterCombo->addItem(channel.physicalName); + } + } + m_productLabel->setText(available + ? QStringLiteral("%1, %2") + .arg(device.provider, device.product) + : tr("DAQ device not found")); + if (available) + { + int counts[6]{}; + QStringList channelNames; + for (const auto& channel : device.channels) + { + ++counts[static_cast(channel.type)]; + channelNames.append(channel.physicalName); + } + m_resourcesLabel->setText( + tr("AI %1 AO %2 DI %3 DO %4 CI %5 CO %6 Terminals %7") + .arg(counts[static_cast(scopeone::core::DaqChannelType::AnalogInput)]) + .arg(counts[static_cast(scopeone::core::DaqChannelType::AnalogOutput)]) + .arg(counts[static_cast(scopeone::core::DaqChannelType::DigitalInput)]) + .arg(counts[static_cast(scopeone::core::DaqChannelType::DigitalOutput)]) + .arg(counts[static_cast(scopeone::core::DaqChannelType::CounterInput)]) + .arg(counts[static_cast(scopeone::core::DaqChannelType::CounterOutput)]) + .arg(device.terminals.size())); + m_resourcesLabel->setToolTip(channelNames.join(QLatin1Char('\n'))); + } + else + { + m_resourcesLabel->clear(); + m_resourcesLabel->setToolTip(QString()); + } + m_statusLabel->setText(available + ? m_core->daqStateMessage(device.id) + : tr("Install a DAQ device plugin and driver")); + setControlsEnabled(available); + m_stopButton->setEnabled(false); + } + + void DaqControlWidget::addBufferedRow() + { + const auto device = currentDevice(); + const int row = m_bufferedTable->rowCount(); + m_bufferedTable->insertRow(row); + + auto* type = new QComboBox(m_bufferedTable); + type->addItem(tr("Analog input"), QStringLiteral("AI")); + type->addItem(tr("Analog output"), QStringLiteral("AO")); + type->addItem(tr("Digital input"), QStringLiteral("DI")); + type->addItem(tr("Digital output"), QStringLiteral("DO")); + m_bufferedTable->setCellWidget(row, 0, type); + + auto* channels = new QComboBox(m_bufferedTable); + channels->setEditable(true); + m_bufferedTable->setCellWidget(row, 1, channels); + m_bufferedTable->setCellWidget( + row, 2, realSpin(-1000.0, 1000.0, -10.0, 3, + tr(" V"), m_bufferedTable)); + m_bufferedTable->setCellWidget( + row, 3, realSpin(-1000.0, 1000.0, 10.0, 3, + tr(" V"), m_bufferedTable)); + m_bufferedTable->setCellWidget( + row, 4, editableCombo(terminalChoices(device), m_bufferedTable)); + auto* sampleEdge = new QComboBox(m_bufferedTable); + sampleEdge->addItem(tr("Rising"), static_cast(scopeone::core::DaqEdge::Rising)); + sampleEdge->addItem(tr("Falling"), static_cast(scopeone::core::DaqEdge::Falling)); + m_bufferedTable->setCellWidget(row, 5, sampleEdge); + m_bufferedTable->setCellWidget( + row, 6, realSpin(0.001, 1000000000.0, 1000.0, 3, + tr(" Hz"), m_bufferedTable)); + auto* samples = new QSpinBox(m_bufferedTable); + samples->setRange(1, std::numeric_limits::max()); + samples->setValue(1); + m_bufferedTable->setCellWidget(row, 7, samples); + auto* mode = new QComboBox(m_bufferedTable); + mode->addItem(tr("Finite"), static_cast(scopeone::core::DaqSampleMode::Finite)); + mode->addItem(tr("Continuous"), + static_cast(scopeone::core::DaqSampleMode::Continuous)); + m_bufferedTable->setCellWidget(row, 8, mode); + m_bufferedTable->setCellWidget( + row, 9, editableCombo(terminalChoices(device), m_bufferedTable)); + auto* triggerEdge = new QComboBox(m_bufferedTable); + triggerEdge->addItem(tr("Rising"), static_cast(scopeone::core::DaqEdge::Rising)); + triggerEdge->addItem(tr("Falling"), static_cast(scopeone::core::DaqEdge::Falling)); + m_bufferedTable->setCellWidget(row, 10, triggerEdge); + auto* outputData = new QLineEdit(m_bufferedTable); + auto* waveformAction = outputData->addAction( + QIcon::fromTheme(QStringLiteral("office-chart-line"), + style()->standardIcon(QStyle::SP_FileDialogDetailedView)), + QLineEdit::TrailingPosition); + waveformAction->setToolTip(tr("Generate analog waveform")); + outputData->setPlaceholderText(tr("Comma-separated samples")); + connect(outputData, &QLineEdit::textEdited, outputData, + [outputData]() + { + outputData->setProperty("generatedAnalogSamples", QVariant()); + }); + connect(waveformAction, &QAction::triggered, this, + [this, outputData]() + { + for (int currentRow = 0; + currentRow < m_bufferedTable->rowCount(); ++currentRow) + { + if (m_bufferedTable->cellWidget(currentRow, 11) == outputData) + { + configureAnalogWaveform(currentRow, outputData); + return; + } + } + }); + m_bufferedTable->setCellWidget(row, 11, outputData); + + const auto updateChannels = [device, type, channels, outputData, + waveformAction, this]() + { + int row = -1; + for (int currentRow = 0; + currentRow < m_bufferedTable->rowCount(); ++currentRow) + { + if (m_bufferedTable->cellWidget(currentRow, 11) == outputData) + { + row = currentRow; + break; + } + } + if (row < 0) + { + return; + } + const QString taskType = type->currentData().toString(); + const bool analog = taskType == QStringLiteral("AI") + || taskType == QStringLiteral("AO"); + const bool output = taskType == QStringLiteral("AO") + || taskType == QStringLiteral("DO"); + const auto channelType = taskType == QStringLiteral("AI") + ? scopeone::core::DaqChannelType::AnalogInput + : taskType == QStringLiteral("AO") + ? scopeone::core::DaqChannelType::AnalogOutput + : taskType == QStringLiteral("DI") + ? scopeone::core::DaqChannelType::DigitalInput + : scopeone::core::DaqChannelType::DigitalOutput; + const QString current = channels->currentText(); + channels->clear(); + for (const auto& channel : device.channels) + { + if (channel.type == channelType) + { + channels->addItem(channel.physicalName); + } + } + if (!current.isEmpty()) + { + channels->setCurrentText(current); + } + m_bufferedTable->cellWidget(row, 2)->setEnabled(analog); + m_bufferedTable->cellWidget(row, 3)->setEnabled(analog); + m_bufferedTable->cellWidget(row, 11)->setEnabled(output); + waveformAction->setVisible(analog && output); + }; + connect(type, &QComboBox::currentIndexChanged, this, updateChannels); + updateChannels(); + } + + void DaqControlWidget::configureAnalogWaveform(int row, QLineEdit* outputData) + { + QDialog dialog(this); + dialog.setWindowTitle(tr("Analog Waveform")); + auto* layout = new QFormLayout(&dialog); + + auto* shape = new QComboBox(&dialog); + shape->addItems({tr("Constant"), tr("Square"), tr("Sine"), + tr("Triangle"), tr("Ramp")}); + shape->setCurrentIndex(1); + layout->addRow(tr("Shape"), shape); + + auto* low = realSpin(-1000.0, 1000.0, 0.0, 6, tr(" V"), &dialog); + auto* high = realSpin(-1000.0, 1000.0, 0.1, 6, tr(" V"), &dialog); + layout->addRow(tr("Low"), low); + layout->addRow(tr("High"), high); + + auto* frequency = realSpin(0.001, 100000000.0, 10.0, 3, + tr(" Hz"), &dialog); + layout->addRow(tr("Frequency"), frequency); + auto* duty = realSpin(0.001, 99.999, 50.0, 3, tr(" %"), &dialog); + layout->addRow(tr("Duty cycle"), duty); + auto* points = new QSpinBox(&dialog); + points->setRange(2, 100000); + points->setValue(100); + layout->addRow(tr("Samples per cycle"), points); + + connect(shape, &QComboBox::currentIndexChanged, &dialog, + [shape, low, frequency, duty]() + { + const int index = shape->currentIndex(); + low->setEnabled(index != 0); + frequency->setEnabled(index != 0); + duty->setEnabled(index == 1); + }); + + auto* buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dialog); + connect(buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + layout->addRow(buttons); + if (dialog.exec() != QDialog::Accepted) + { + return; + } + + const int count = points->value(); + const double lower = low->value(); + const double upper = high->value(); + const double range = upper - lower; + const double dutyFraction = duty->value() / 100.0; + QVariantList samples; + samples.reserve(count); + const double twoPi = 2.0 * std::acos(-1.0); + for (int index = 0; index < count; ++index) + { + const double phase = static_cast(index) / count; + double value = upper; + switch (shape->currentIndex()) + { + case 1: + value = phase < 1.0 - dutyFraction ? lower : upper; + break; + case 2: + value = lower + range * (0.5 - 0.5 * std::cos(twoPi * phase)); + break; + case 3: + value = lower + range * (1.0 - std::abs(2.0 * phase - 1.0)); + break; + case 4: + value = lower + range * index / (count - 1); + break; + default: + break; + } + samples.append(value); + } + outputData->setProperty("generatedAnalogSamples", samples); + outputData->setText(tr("%1, %2 to %3 V, %4 Hz") + .arg(shape->currentText()) + .arg(lower, 0, 'g', 6) + .arg(upper, 0, 'g', 6) + .arg(frequency->value(), 0, 'g', 6)); + + qobject_cast(m_bufferedTable->cellWidget(row, 6)) + ->setValue(frequency->value() * count); + qobject_cast(m_bufferedTable->cellWidget(row, 7))->setValue(count); + auto* mode = qobject_cast(m_bufferedTable->cellWidget(row, 8)); + mode->setCurrentIndex(mode->findData( + static_cast(scopeone::core::DaqSampleMode::Continuous))); + } + + void DaqControlWidget::addPulseRow() + { + const auto device = currentDevice(); + QStringList counters; + for (const auto& channel : device.channels) + { + if (channel.type == scopeone::core::DaqChannelType::CounterOutput) + { + counters.append(channel.physicalName); + } + } + const int row = m_pulseTable->rowCount(); + m_pulseTable->insertRow(row); + auto* counter = new QComboBox(m_pulseTable); + counter->addItems(counters); + m_pulseTable->setCellWidget(row, 0, counter); + m_pulseTable->setCellWidget( + row, 1, editableCombo(terminalChoices(device), m_pulseTable)); + m_pulseTable->setCellWidget( + row, 2, realSpin(0.001, 100000000.0, 1000.0, 3, + tr(" Hz"), m_pulseTable)); + m_pulseTable->setCellWidget( + row, 3, realSpin(0.001, 99.999, 50.0, 3, + tr(" %"), m_pulseTable)); + m_pulseTable->setCellWidget( + row, 4, realSpin(0.0, 1000000.0, 0.0, 3, + tr(" ms"), m_pulseTable)); + m_pulseTable->setCellWidget( + row, 5, editableCombo(terminalChoices(device), m_pulseTable)); + auto* edge = new QComboBox(m_pulseTable); + edge->addItem(tr("Rising"), static_cast(scopeone::core::DaqEdge::Rising)); + edge->addItem(tr("Falling"), static_cast(scopeone::core::DaqEdge::Falling)); + m_pulseTable->setCellWidget(row, 6, edge); + } + + void DaqControlWidget::addRouteRow() + { + const QStringList terminals = terminalChoices(currentDevice()); + const int row = m_routeTable->rowCount(); + m_routeTable->insertRow(row); + m_routeTable->setCellWidget(row, 0, editableCombo(terminals, m_routeTable)); + m_routeTable->setCellWidget(row, 1, editableCombo(terminals, m_routeTable)); + auto* polarity = new QComboBox(m_routeTable); + polarity->addItem(tr("Normal"), false); + polarity->addItem(tr("Inverted"), true); + m_routeTable->setCellWidget(row, 2, polarity); + } + + void DaqControlWidget::removeSelectedRow(QTableWidget* table) + { + const int row = table->currentRow(); + if (row >= 0) + { + table->removeRow(row); + } + } + + void DaqControlWidget::startSession() + { + scopeone::core::DaqSessionConfig config; + config.deviceId = m_deviceCombo->currentData().toString(); + if (m_rasterGroup->isChecked()) + { + scopeone::core::DaqRasterScanConfig scan; + scan.name = QStringLiteral("ScopeOne raster scan"); + scan.lineClock = m_lineClockCombo->currentText(); + scan.nominalLineRateHz = m_lineRateSpin->value(); + scan.activeLines = static_cast(m_activeLinesSpin->value()); + scan.flybackLines = static_cast(m_flybackLinesSpin->value()); + scan.yChannel = m_yChannelCombo->currentText(); + scan.yStartVolts = m_yStartSpin->value(); + scan.yEndVolts = m_yEndSpin->value(); + scan.frameCounter = m_frameCounterCombo->currentText(); + scan.lineOutputTerminal = m_lineOutputCombo->currentText(); + scan.frameOutputTerminal = m_frameOutputCombo->currentText(); + config.rasterScans.append(scan); + } + for (int row = 0; row < m_pulseTable->rowCount(); ++row) + { + scopeone::core::DaqPulseTaskConfig pulse; + pulse.name = QStringLiteral("ScopeOne Pulse %1").arg(row + 1); + pulse.counter = qobject_cast( + m_pulseTable->cellWidget(row, 0))->currentText(); + pulse.outputTerminal = qobject_cast( + m_pulseTable->cellWidget(row, 1))->currentText(); + pulse.frequencyHz = qobject_cast( + m_pulseTable->cellWidget(row, 2))->value(); + pulse.dutyCycle = qobject_cast( + m_pulseTable->cellWidget(row, 3))->value() / 100.0; + pulse.initialDelaySeconds = qobject_cast( + m_pulseTable->cellWidget(row, 4))->value() + / 1000.0; + pulse.startTrigger = qobject_cast( + m_pulseTable->cellWidget(row, 5))->currentText(); + pulse.startEdge = static_cast( + qobject_cast(m_pulseTable->cellWidget(row, 6)) + ->currentData().toInt()); + config.pulseTasks.append(pulse); + } + for (int row = 0; row < m_routeTable->rowCount(); ++row) + { + scopeone::core::DaqTerminalRoute route; + route.source = qobject_cast( + m_routeTable->cellWidget(row, 0))->currentText(); + route.destination = qobject_cast( + m_routeTable->cellWidget(row, 1))->currentText(); + route.inverted = qobject_cast( + m_routeTable->cellWidget(row, 2))->currentData().toBool(); + config.routes.append(route); + } + for (int row = 0; row < m_bufferedTable->rowCount(); ++row) + { + const QString type = qobject_cast( + m_bufferedTable->cellWidget(row, 0)) + ->currentData().toString(); + const QStringList channels = commaSeparated( + qobject_cast(m_bufferedTable->cellWidget(row, 1)) + ->currentText()); + scopeone::core::DaqTaskTiming timing; + timing.sampleClock = qobject_cast( + m_bufferedTable->cellWidget(row, 4))->currentText(); + timing.sampleEdge = static_cast( + qobject_cast(m_bufferedTable->cellWidget(row, 5)) + ->currentData().toInt()); + timing.sampleRateHz = qobject_cast( + m_bufferedTable->cellWidget(row, 6))->value(); + timing.samplesPerChannel = static_cast( + qobject_cast(m_bufferedTable->cellWidget(row, 7))->value()); + timing.sampleMode = static_cast( + qobject_cast(m_bufferedTable->cellWidget(row, 8)) + ->currentData().toInt()); + timing.startTrigger = qobject_cast( + m_bufferedTable->cellWidget(row, 9))->currentText(); + timing.startEdge = static_cast( + qobject_cast(m_bufferedTable->cellWidget(row, 10)) + ->currentData().toInt()); + auto* outputData = qobject_cast( + m_bufferedTable->cellWidget(row, 11)); + const QStringList sampleText = commaSeparated(outputData->text()); + + if (type == QStringLiteral("AI") || type == QStringLiteral("AO")) + { + scopeone::core::DaqAnalogTaskConfig task; + task.name = QStringLiteral("ScopeOne %1 %2").arg(type).arg(row + 1); + task.direction = type == QStringLiteral("AI") + ? scopeone::core::DaqTaskDirection::Input + : scopeone::core::DaqTaskDirection::Output; + task.channels = channels; + task.minimumVolts = qobject_cast( + m_bufferedTable->cellWidget(row, 2))->value(); + task.maximumVolts = qobject_cast( + m_bufferedTable->cellWidget(row, 3))->value(); + task.timing = timing; + if (task.direction == scopeone::core::DaqTaskDirection::Output) + { + const QVariantList generatedSamples = outputData + ->property("generatedAnalogSamples") + .toList(); + if (!generatedSamples.isEmpty()) + { + for (const QVariant& value : generatedSamples) + { + task.outputSamplesByScan.append(value.toDouble()); + } + } + else + { + for (const QString& value : sampleText) + { + bool valid = false; + const double sample = value.toDouble(&valid); + if (!valid) + { + m_statusLabel->setText( + tr("Invalid analog output sample: %1").arg(value)); + return; + } + task.outputSamplesByScan.append(sample); + } + } + } + config.analogTasks.append(task); + } + else + { + scopeone::core::DaqDigitalTaskConfig task; + task.name = QStringLiteral("ScopeOne %1 %2").arg(type).arg(row + 1); + task.direction = type == QStringLiteral("DI") + ? scopeone::core::DaqTaskDirection::Input + : scopeone::core::DaqTaskDirection::Output; + task.lines = channels; + task.timing = timing; + if (task.direction == scopeone::core::DaqTaskDirection::Output) + { + for (const QString& value : sampleText) + { + bool valid = false; + const quint32 sample = value.toUInt(&valid, 0); + if (!valid) + { + m_statusLabel->setText( + tr("Invalid digital output sample: %1").arg(value)); + return; + } + task.outputSamplesByScan.append(sample); + } + } + config.digitalTasks.append(task); + } + } + + QString errorMessage; + if (!m_core->startDaqSession(config, &errorMessage)) + { + m_statusLabel->setText(errorMessage); + qWarning().noquote() << errorMessage; + return; + } + m_activeDeviceId = config.deviceId; + } + + void DaqControlWidget::setControlsEnabled(bool enabled) + { + m_deviceCombo->setEnabled(enabled); + m_rasterGroup->setEnabled(enabled); + m_pulseTable->setEnabled(enabled); + m_bufferedTable->setEnabled(enabled); + m_routeTable->setEnabled(enabled); + m_addPulseButton->setEnabled(enabled); + m_removePulseButton->setEnabled(enabled); + m_addBufferedButton->setEnabled(enabled); + m_removeBufferedButton->setEnabled(enabled); + m_addRouteButton->setEnabled(enabled); + m_removeRouteButton->setEnabled(enabled); + m_startButton->setEnabled(enabled && m_deviceCombo->count() > 0); + } + + void DaqControlWidget::handleStateChanged(const QString& deviceId, + scopeone::core::DaqState state, + const QString& message) + { + if (deviceId != m_activeDeviceId + && deviceId != m_deviceCombo->currentData().toString()) + { + return; + } + const bool active = state == scopeone::core::DaqState::Armed + || state == scopeone::core::DaqState::Running; + m_statusLabel->setText(message); + setControlsEnabled(!active); + m_stopButton->setEnabled(active); + if (active) + { + m_activeDeviceId = deviceId; + } + else + { + m_activeDeviceId.clear(); + } + } +} diff --git a/plugins/tools/ScanningDaqTool/DaqControlWidget.h b/plugins/tools/ScanningDaqTool/DaqControlWidget.h new file mode 100644 index 0000000..246f879 --- /dev/null +++ b/plugins/tools/ScanningDaqTool/DaqControlWidget.h @@ -0,0 +1,79 @@ +#pragma once + +#include "scopeone/DaqDevice.h" + +#include +#include + +class QComboBox; +class QDoubleSpinBox; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QWidget; + +namespace scopeone::core +{ + class ScopeOneCore; +} + +namespace scopeone::plugins +{ + class DaqControlWidget final : public QWidget + { + Q_OBJECT + + public: + explicit DaqControlWidget(scopeone::core::ScopeOneCore* core, + QWidget* parent = nullptr); + + private: + scopeone::core::DaqDeviceDescriptor currentDevice() const; + QStringList terminalChoices(const scopeone::core::DaqDeviceDescriptor& device) const; + void refreshDevice(); + void addPulseRow(); + void addBufferedRow(); + void configureAnalogWaveform(int row, QLineEdit* outputData); + void addRouteRow(); + void removeSelectedRow(QTableWidget* table); + void startSession(); + void setControlsEnabled(bool enabled); + void handleStateChanged(const QString& deviceId, + scopeone::core::DaqState state, + const QString& message); + + scopeone::core::ScopeOneCore* m_core{nullptr}; + QComboBox* m_deviceCombo{nullptr}; + QLabel* m_productLabel{nullptr}; + QLabel* m_resourcesLabel{nullptr}; + QGroupBox* m_rasterGroup{nullptr}; + QWidget* m_rasterContents{nullptr}; + QComboBox* m_lineClockCombo{nullptr}; + QDoubleSpinBox* m_lineRateSpin{nullptr}; + QSpinBox* m_activeLinesSpin{nullptr}; + QSpinBox* m_flybackLinesSpin{nullptr}; + QComboBox* m_yChannelCombo{nullptr}; + QDoubleSpinBox* m_yStartSpin{nullptr}; + QDoubleSpinBox* m_yEndSpin{nullptr}; + QComboBox* m_frameCounterCombo{nullptr}; + QComboBox* m_lineOutputCombo{nullptr}; + QComboBox* m_frameOutputCombo{nullptr}; + QTableWidget* m_pulseTable{nullptr}; + QTableWidget* m_bufferedTable{nullptr}; + QTableWidget* m_routeTable{nullptr}; + QPushButton* m_addPulseButton{nullptr}; + QPushButton* m_removePulseButton{nullptr}; + QPushButton* m_addBufferedButton{nullptr}; + QPushButton* m_removeBufferedButton{nullptr}; + QPushButton* m_addRouteButton{nullptr}; + QPushButton* m_removeRouteButton{nullptr}; + QPushButton* m_startButton{nullptr}; + QPushButton* m_stopButton{nullptr}; + QLabel* m_statusLabel{nullptr}; + QString m_activeDeviceId; + QElapsedTimer m_inputStatusTimer; + }; +} diff --git a/plugins/tools/ScanningDaqTool/ScanningDaqToolPlugin.cpp b/plugins/tools/ScanningDaqTool/ScanningDaqToolPlugin.cpp new file mode 100644 index 0000000..597c4a7 --- /dev/null +++ b/plugins/tools/ScanningDaqTool/ScanningDaqToolPlugin.cpp @@ -0,0 +1,49 @@ +#include "DaqControlWidget.h" +#include "SignalMonitorWidget.h" + +#include "scopeone/ToolPlugin.h" + +#include + +namespace scopeone::plugins +{ + class ScanningDaqToolPlugin final : public QObject, + public scopeone::ui::ScopeOneToolPlugin + { + Q_OBJECT + Q_PLUGIN_METADATA(IID ScopeOneToolPlugin_iid FILE "plugin.json") + Q_INTERFACES(scopeone::ui::ScopeOneToolPlugin) + + public: + QList tools() const override + { + return {{QStringLiteral("scopeone.daq_control"), + QStringLiteral("Scanning and DAQ Control"), + QStringLiteral("Acquisition"), + scopeone::ui::ToolWindowMode::ModelessSingleton, + false}, + {QStringLiteral("scopeone.signal_monitor"), + QStringLiteral("Signal Monitor"), + QStringLiteral("Acquisition"), + scopeone::ui::ToolWindowMode::ModelessSingleton, + false}}; + } + + QWidget* createTool(const QString& toolId, + scopeone::ui::ScopeOneToolContext& context, + QWidget* parent) override + { + if (toolId == QStringLiteral("scopeone.daq_control")) + { + return new DaqControlWidget(&context.core(), parent); + } + if (toolId == QStringLiteral("scopeone.signal_monitor")) + { + return new SignalMonitorWidget(&context.core(), context, parent); + } + return nullptr; + } + }; +} + +#include "ScanningDaqToolPlugin.moc" diff --git a/plugins/tools/ScanningDaqTool/SignalMonitorWidget.cpp b/plugins/tools/ScanningDaqTool/SignalMonitorWidget.cpp new file mode 100644 index 0000000..dbea648 --- /dev/null +++ b/plugins/tools/ScanningDaqTool/SignalMonitorWidget.cpp @@ -0,0 +1,700 @@ +#include "SignalMonitorWidget.h" + +#include "scopeone/ScopeOneCore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace scopeone::plugins +{ + namespace + { + QString formattedValue(double value, const QString& unit) + { + return QStringLiteral("%1 %2").arg(value, 0, 'g', 5).arg(unit); + } + + QString settingKey(const QString& sourceId, const QString& parameterKey) + { + return QStringLiteral("signalSources/%1/%2").arg(sourceId, parameterKey); + } + } + + class SignalTracePlot final : public QWidget + { + public: + explicit SignalTracePlot(QWidget* parent = nullptr) + : QWidget(parent) + { + setMinimumHeight(220); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + } + + double appendChunk(const scopeone::core::TimeSeriesChunk& chunk, + double windowSeconds) + { + m_windowSeconds = windowSeconds; + for (int index = 0; index < chunk.values.size(); ++index) + { + m_times.append(chunk.startTimeSeconds + + (static_cast(index) + 0.5) + * chunk.sampleIntervalSeconds); + m_values.append(chunk.values[index]); + } + trim(); + update(); + return m_values.constLast(); + } + + void setWindowSeconds(double windowSeconds) + { + m_windowSeconds = windowSeconds; + trim(); + update(); + } + + void clear() + { + m_times.clear(); + m_values.clear(); + update(); + } + + protected: + void paintEvent(QPaintEvent*) override + { + QPainter painter(this); + painter.fillRect(rect(), palette().color(QPalette::Base)); + + const QRectF plotRect = QRectF(rect()).adjusted(62.0, 12.0, -12.0, -30.0); + if (plotRect.width() <= 1.0 || plotRect.height() <= 1.0) + { + return; + } + painter.setPen(palette().color(QPalette::Mid)); + painter.drawLine(plotRect.bottomLeft(), plotRect.bottomRight()); + painter.drawLine(plotRect.bottomLeft(), plotRect.topLeft()); + + const QColor textColor = palette().color(QPalette::Text); + if (m_times.isEmpty() || m_values.isEmpty()) + { + painter.setPen(textColor); + painter.drawText(plotRect, Qt::AlignCenter, tr("No signal data")); + return; + } + + const double endTime = m_times.constLast(); + const double startTime = std::max(0.0, endTime - m_windowSeconds); + const double displayedDuration = std::max( + endTime - startTime, (std::numeric_limits::epsilon)()); + const auto first = std::lower_bound(m_times.cbegin(), m_times.cend(), startTime); + const int firstIndex = static_cast(std::distance(m_times.cbegin(), first)); + const int lastIndex = std::min(m_times.size(), m_values.size()); + if (firstIndex >= lastIndex) + { + return; + } + + double minimum = (std::numeric_limits::max)(); + double maximum = (std::numeric_limits::lowest)(); + for (int index = firstIndex; index < lastIndex; ++index) + { + minimum = std::min(minimum, m_values[index]); + maximum = std::max(maximum, m_values[index]); + } + const bool nonnegative = minimum >= 0.0; + const bool nonpositive = maximum <= 0.0; + if (nonnegative) + { + minimum = 0.0; + } + if (nonpositive) + { + maximum = 0.0; + } + if (minimum == maximum) + { + maximum = minimum + 1.0; + } + const double padding = (maximum - minimum) * 0.05; + if (!nonnegative) + { + minimum -= padding; + } + if (!nonpositive) + { + maximum += padding; + } + + const auto mapX = [&](double time) + { + return plotRect.left() + + (time - startTime) / displayedDuration * plotRect.width(); + }; + const auto mapY = [&](double value) + { + return plotRect.bottom() + - (value - minimum) / (maximum - minimum) * plotRect.height(); + }; + + painter.setPen(textColor); + painter.drawText(QRectF(0.0, plotRect.top() - 6.0, + plotRect.left() - 6.0, 20.0), + Qt::AlignRight | Qt::AlignVCenter, + QString::number(maximum, 'g', 5)); + painter.drawText(QRectF(0.0, plotRect.bottom() - 10.0, + plotRect.left() - 6.0, 20.0), + Qt::AlignRight | Qt::AlignVCenter, + QString::number(minimum, 'g', 5)); + painter.drawText(QRectF(plotRect.left(), plotRect.bottom() + 4.0, + plotRect.width(), 20.0), + Qt::AlignLeft | Qt::AlignVCenter, + QStringLiteral("%1 s").arg(startTime, 0, 'g', 4)); + painter.drawText(QRectF(plotRect.left(), plotRect.bottom() + 4.0, + plotRect.width(), 20.0), + Qt::AlignRight | Qt::AlignVCenter, + QStringLiteral("%1 s").arg(endTime, 0, 'g', 4)); + + painter.setPen(QPen(palette().color(QPalette::Highlight), 1.5)); + const int visiblePoints = lastIndex - firstIndex; + const int pixelColumns = std::max(1, static_cast(plotRect.width())); + if (visiblePoints <= pixelColumns * 2) + { + painter.setRenderHint(QPainter::Antialiasing, true); + QPainterPath path; + path.moveTo(mapX(m_times[firstIndex]), mapY(m_values[firstIndex])); + for (int index = firstIndex + 1; index < lastIndex; ++index) + { + path.lineTo(mapX(m_times[index]), mapY(m_values[index])); + } + painter.drawPath(path); + return; + } + + QVector minima(pixelColumns, maximum); + QVector maxima(pixelColumns, minimum); + QVector populated(pixelColumns, false); + // Preserve extrema when samples outnumber horizontal pixels + for (int index = firstIndex; index < lastIndex; ++index) + { + const int column = std::clamp( + static_cast((m_times[index] - startTime) + / displayedDuration * pixelColumns), + 0, + pixelColumns - 1); + minima[column] = std::min(minima[column], m_values[index]); + maxima[column] = std::max(maxima[column], m_values[index]); + populated[column] = true; + } + for (int column = 0; column < pixelColumns; ++column) + { + if (populated[column]) + { + const double x = plotRect.left() + column; + painter.drawLine(QPointF(x, mapY(minima[column])), + QPointF(x, mapY(maxima[column]))); + } + } + } + + private: + void trim() + { + if (m_times.isEmpty()) + { + return; + } + const double cutoff = m_times.constLast() - m_windowSeconds; + const auto first = std::lower_bound(m_times.cbegin(), m_times.cend(), cutoff); + const int removeCount = static_cast(std::distance(m_times.cbegin(), first)); + if (removeCount > 0) + { + m_times.remove(0, removeCount); + m_values.remove(0, removeCount); + } + } + + QVector m_times; + QVector m_values; + double m_windowSeconds{10.0}; + }; + + SignalMonitorWidget::SignalMonitorWidget(scopeone::core::ScopeOneCore* core, + scopeone::ui::ScopeOneToolContext& context, + QWidget* parent) + : QWidget(parent) + , m_core(core) + , m_context(context) + { + auto* layout = new QVBoxLayout(this); + auto* sourceForm = new QFormLayout(); + m_sourceCombo = new QComboBox(this); + sourceForm->addRow(tr("Source"), m_sourceCombo); + layout->addLayout(sourceForm); + + m_sourceForm = new QFormLayout(); + layout->addLayout(m_sourceForm); + + auto* traceForm = new QFormLayout(); + m_sampleIntervalSpin = new QDoubleSpinBox(this); + m_sampleIntervalSpin->setRange(0.001, 10000.0); + m_sampleIntervalSpin->setDecimals(3); + m_sampleIntervalSpin->setSuffix(tr(" ms")); + m_sampleIntervalSpin->setValue(10.0); + traceForm->addRow(tr("Interval"), m_sampleIntervalSpin); + + m_windowDurationSpin = new QDoubleSpinBox(this); + m_windowDurationSpin->setRange(0.1, 60.0); + m_windowDurationSpin->setDecimals(1); + m_windowDurationSpin->setSuffix(tr(" s")); + m_windowDurationSpin->setValue(10.0); + traceForm->addRow(tr("Window"), m_windowDurationSpin); + + m_scanImageCheck = new QCheckBox(tr("Build scan image"), this); + traceForm->addRow(tr("Scan"), m_scanImageCheck); + m_scanWidthSpin = new QSpinBox(this); + m_scanWidthSpin->setRange(1, 8192); + m_scanWidthSpin->setValue(256); + traceForm->addRow(tr("Width"), m_scanWidthSpin); + m_scanHeightSpin = new QSpinBox(this); + m_scanHeightSpin->setRange(1, 8192); + m_scanHeightSpin->setValue(256); + traceForm->addRow(tr("Height"), m_scanHeightSpin); + m_scanGainSpin = new QSpinBox(this); + m_scanGainSpin->setRange(1, 65535); + m_scanGainSpin->setValue(1000); + m_scanGainSpin->setSuffix(tr(" x")); + traceForm->addRow(tr("Gain"), m_scanGainSpin); + + m_scanAverageFramesSpin = new QSpinBox(this); + m_scanAverageFramesSpin->setRange(1, 256); + m_scanAverageFramesSpin->setValue(1); + m_scanAverageFramesSpin->setToolTip( + tr("Average this many complete scan images before publishing one result")); + traceForm->addRow(tr("Average frames"), m_scanAverageFramesSpin); + const auto markerCombo = [this](quint32 value) + { + auto* combo = new QComboBox(this); + combo->addItem(tr("Off"), 0U); + combo->addItem(tr("Marker 1"), 1U); + combo->addItem(tr("Marker 2"), 2U); + combo->addItem(tr("Marker 3"), 4U); + combo->addItem(tr("Marker 4"), 8U); + combo->setCurrentIndex(combo->findData(value)); + return combo; + }; + m_frameStartMarkerCombo = markerCombo(1); + traceForm->addRow(tr("Frame start"), m_frameStartMarkerCombo); + m_lineMarkerCombo = markerCombo(2); + m_lineMarkerCombo->setToolTip( + tr("One marker completes one full back-and-forth scan line")); + traceForm->addRow(tr("Line marker"), m_lineMarkerCombo); + m_frameEndMarkerCombo = markerCombo(0); + traceForm->addRow(tr("Frame end"), m_frameEndMarkerCombo); + m_serpentineCheck = new QCheckBox(tr("Reverse alternate lines"), this); + traceForm->addRow(tr("Scan order"), m_serpentineCheck); + m_mirrorHorizontalCheck = new QCheckBox(tr("Mirror left/right"), this); + traceForm->addRow(tr("Image orientation"), m_mirrorHorizontalCheck); + layout->addLayout(traceForm); + + auto* buttonLayout = new QHBoxLayout(); + m_startButton = new QPushButton(tr("Start"), this); + m_startButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); + m_stopButton = new QPushButton(tr("Stop"), this); + m_stopButton->setIcon(style()->standardIcon(QStyle::SP_MediaStop)); + m_stopButton->setEnabled(false); + buttonLayout->addWidget(m_startButton); + buttonLayout->addWidget(m_stopButton); + layout->addLayout(buttonLayout); + + m_valueLabel = new QLabel(tr("Value: -"), this); + m_statusLabel = new QLabel(tr("No signal sources available"), this); + m_statusLabel->setWordWrap(true); + layout->addWidget(m_valueLabel); + layout->addWidget(m_statusLabel); + m_plot = new SignalTracePlot(this); + layout->addWidget(m_plot, 1); + + connect(m_sourceCombo, &QComboBox::currentIndexChanged, + this, &SignalMonitorWidget::rebuildSourceParameters); + connect(m_startButton, &QPushButton::clicked, + this, &SignalMonitorWidget::startAcquisition); + connect(m_stopButton, &QPushButton::clicked, this, [this]() + { + m_core->stopSignalTrace(m_activeSourceId); + }); + connect(m_windowDurationSpin, &QDoubleSpinBox::valueChanged, + m_plot, &SignalTracePlot::setWindowSeconds); + connect(m_scanImageCheck, &QCheckBox::toggled, this, [this](bool enabled) + { + const bool editable = enabled && m_scanImageCheck->isEnabled(); + m_scanWidthSpin->setEnabled(editable); + m_scanHeightSpin->setEnabled(editable); + m_scanGainSpin->setEnabled(editable); + m_scanAverageFramesSpin->setEnabled(editable); + m_frameStartMarkerCombo->setEnabled(editable); + m_lineMarkerCombo->setEnabled(editable); + m_frameEndMarkerCombo->setEnabled(editable); + m_serpentineCheck->setEnabled(editable); + m_mirrorHorizontalCheck->setEnabled(editable); + }); + connect(m_core, &scopeone::core::ScopeOneCore::signalTimeSeriesReady, + this, &SignalMonitorWidget::handleTimeSeries); + connect(m_core, &scopeone::core::ScopeOneCore::signalSourceStateChanged, + this, &SignalMonitorWidget::handleStateChanged); + connect(m_core, &scopeone::core::ScopeOneCore::signalSourceError, + this, [](const QString&, const QString& message) + { + qCritical().noquote() << message; + }); + connect(m_core, &scopeone::core::ScopeOneCore::staticFramePublished, + this, + [this](const QString& sourceId, + const QString&, + const scopeone::core::ImageFrame&) + { + if (!m_scanImageCheck->isChecked() + || m_scanLayerShown + || sourceId != QStringLiteral("scan:%1").arg(m_activeSourceId)) + { + return; + } + m_scanLayerShown = true; + m_context.showLayers({ + scopeone::core::ScopeOneCore::staticLayerKey(sourceId)}); + }); + connect(m_core, &scopeone::core::ScopeOneCore::scanImageSessionReady, + this, [this](const std::shared_ptr& session) + { + m_statusLabel->setText( + tr("Scan session ready: %1 frames") + .arg(session->recordedFrameCount())); + m_context.presentSession(session, tr("Reconstructed Scan")); + }); + + m_scanImageCheck->setChecked(false); + m_scanWidthSpin->setEnabled(false); + m_scanHeightSpin->setEnabled(false); + m_scanGainSpin->setEnabled(false); + m_scanAverageFramesSpin->setEnabled(false); + m_frameStartMarkerCombo->setEnabled(false); + m_lineMarkerCombo->setEnabled(false); + m_frameEndMarkerCombo->setEnabled(false); + m_serpentineCheck->setEnabled(false); + m_mirrorHorizontalCheck->setEnabled(false); + + refreshSources(); + } + + void SignalMonitorWidget::refreshSources() + { + const QString selectedId = m_sourceCombo->currentData().toString(); + const QList sources = m_core->signalSources(); + { + const QSignalBlocker blocker(m_sourceCombo); + m_sourceCombo->clear(); + for (const auto& source : sources) + { + m_sourceCombo->addItem(source.name, source.id); + m_sourceCombo->setItemData( + m_sourceCombo->count() - 1, + QStringLiteral("%1, %2 [%3]") + .arg(source.provider, source.quantity, source.unit), + Qt::ToolTipRole); + } + const int selectedIndex = m_sourceCombo->findData(selectedId); + if (selectedIndex >= 0) + { + m_sourceCombo->setCurrentIndex(selectedIndex); + } + } + const bool available = !sources.isEmpty(); + if (available) + { + const QString sourceId = m_sourceCombo->currentData().toString(); + const auto state = m_core->signalSourceState(sourceId); + const bool active = state == scopeone::core::SignalSourceState::Starting + || state == scopeone::core::SignalSourceState::Running + || state == scopeone::core::SignalSourceState::Stopping; + m_startButton->setEnabled(!active); + m_stopButton->setEnabled(state == scopeone::core::SignalSourceState::Starting + || state == scopeone::core::SignalSourceState::Running); + m_statusLabel->setText(m_core->signalSourceStateMessage(sourceId)); + } + else + { + m_startButton->setEnabled(false); + m_stopButton->setEnabled(false); + m_statusLabel->setText(tr("No signal source plugins found")); + } + rebuildSourceParameters(); + } + + void SignalMonitorWidget::rebuildSourceParameters() + { + while (m_sourceForm->rowCount() > 0) + { + m_sourceForm->removeRow(0); + } + m_parameterEditors.clear(); + + const auto descriptor = currentDescriptor(); + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + for (const auto& parameter : descriptor.parameters) + { + const QVariant storedValue = settings.value( + settingKey(descriptor.id, parameter.key), parameter.defaultValue); + QWidget* editor = nullptr; + if (parameter.type == scopeone::core::SignalParameterType::Integer) + { + auto* spin = new QSpinBox(this); + if (parameter.hasRange) + { + spin->setRange(static_cast(parameter.minimum), + static_cast(parameter.maximum)); + } + spin->setSuffix(parameter.suffix); + spin->setValue(storedValue.toInt()); + editor = spin; + } + else if (parameter.type == scopeone::core::SignalParameterType::Real) + { + auto* spin = new QDoubleSpinBox(this); + if (parameter.hasRange) + { + spin->setRange(parameter.minimum, parameter.maximum); + } + spin->setSuffix(parameter.suffix); + spin->setValue(storedValue.toDouble()); + editor = spin; + } + else if (parameter.type == scopeone::core::SignalParameterType::Choice) + { + auto* combo = new QComboBox(this); + for (int index = 0; index < parameter.choices.size(); ++index) + { + const QString name = index < parameter.choiceNames.size() + ? parameter.choiceNames[index] + : parameter.choices[index].toString(); + combo->addItem(name, parameter.choices[index]); + } + combo->setCurrentIndex(std::max(0, combo->findData(storedValue))); + editor = combo; + } + else + { + auto* edit = new QLineEdit(storedValue.toString(), this); + editor = edit; + if (parameter.type == scopeone::core::SignalParameterType::File) + { + auto* row = new QWidget(this); + auto* rowLayout = new QHBoxLayout(row); + rowLayout->setContentsMargins(0, 0, 0, 0); + rowLayout->setSpacing(4); + auto* browse = new QToolButton(row); + browse->setIcon(style()->standardIcon(QStyle::SP_DirOpenIcon)); + browse->setToolTip(tr("Select file")); + rowLayout->addWidget(edit, 1); + rowLayout->addWidget(browse); + connect(browse, &QToolButton::clicked, this, + [this, edit, parameter]() + { + const QString path = QFileDialog::getOpenFileName( + this, + parameter.name, + QFileInfo(edit->text()).absolutePath(), + parameter.fileFilter); + if (!path.isEmpty()) + { + edit->setText(QDir::toNativeSeparators(path)); + } + }); + m_sourceForm->addRow(parameter.name, row); + m_parameterEditors.insert(parameter.key, edit); + continue; + } + } + m_sourceForm->addRow(parameter.name, editor); + m_parameterEditors.insert(parameter.key, editor); + } + m_sampleIntervalSpin->setEnabled( + descriptor.streamType == scopeone::core::SignalStreamType::TimestampedEvents); + } + + void SignalMonitorWidget::startAcquisition() + { + const auto descriptor = currentDescriptor(); + if (descriptor.id.isEmpty()) + { + return; + } + + scopeone::core::SignalAcquisitionConfig config; + config.sourceId = descriptor.id; + config.sampleIntervalSeconds = m_sampleIntervalSpin->value() / 1000.0; + config.scanImage.enabled = m_scanImageCheck->isChecked(); + config.scanImage.width = m_scanWidthSpin->value(); + config.scanImage.height = m_scanHeightSpin->value(); + config.scanImage.gain = static_cast(m_scanGainSpin->value()); + config.scanImage.averageFrames = m_scanAverageFramesSpin->value(); + config.scanImage.frameStartMarker = + m_frameStartMarkerCombo->currentData().toUInt(); + config.scanImage.lineMarker = + m_lineMarkerCombo->currentData().toUInt(); + config.scanImage.frameEndMarker = + m_frameEndMarkerCombo->currentData().toUInt(); + config.scanImage.serpentine = m_serpentineCheck->isChecked(); + config.scanImage.mirrorHorizontal = m_mirrorHorizontalCheck->isChecked(); + config.sourceSettings = sourceSettings(); + + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + for (auto it = config.sourceSettings.constBegin(); + it != config.sourceSettings.constEnd(); ++it) + { + settings.setValue(settingKey(descriptor.id, it.key()), it.value()); + } + + m_plot->clear(); + m_valueLabel->setText(QStringLiteral("%1: -").arg(descriptor.quantity)); + QString errorMessage; + if (!m_core->startSignalTrace(config, &errorMessage)) + { + m_statusLabel->setText(errorMessage); + qWarning().noquote() << errorMessage; + return; + } + m_activeSourceId = descriptor.id; + m_scanLayerShown = false; + } + + void SignalMonitorWidget::handleTimeSeries( + const scopeone::core::TimeSeriesChunk& chunk) + { + if (!chunk.isValid() || chunk.sourceId != m_activeSourceId) + { + return; + } + const double latestValue = m_plot->appendChunk( + chunk, m_windowDurationSpin->value()); + m_valueLabel->setText(QStringLiteral("%1: %2") + .arg(chunk.quantity, formattedValue(latestValue, chunk.unit))); + m_valueLabel->setToolTip(tr("Input events: %1\nMarkers: %2") + .arg(chunk.totalInputEvents) + .arg(chunk.totalMarkers)); + } + + void SignalMonitorWidget::handleStateChanged( + const QString& sourceId, + scopeone::core::SignalSourceState state, + const QString& message) + { + if (sourceId != m_activeSourceId + && sourceId != m_sourceCombo->currentData().toString()) + { + return; + } + m_statusLabel->setText(message); + const bool active = state == scopeone::core::SignalSourceState::Starting + || state == scopeone::core::SignalSourceState::Running + || state == scopeone::core::SignalSourceState::Stopping; + setSourceControlsEnabled(!active); + m_startButton->setEnabled(!active); + m_stopButton->setEnabled(state == scopeone::core::SignalSourceState::Starting + || state == scopeone::core::SignalSourceState::Running); + if (active) + { + m_activeSourceId = sourceId; + } + else if (m_activeSourceId == sourceId) + { + m_activeSourceId.clear(); + } + } + + void SignalMonitorWidget::setSourceControlsEnabled(bool enabled) + { + m_sourceCombo->setEnabled(enabled); + for (QWidget* editor : m_parameterEditors) + { + editor->setEnabled(enabled); + if (QWidget* row = editor->parentWidget(); row && row != this) + { + row->setEnabled(enabled); + } + } + m_sampleIntervalSpin->setEnabled( + enabled && currentDescriptor().streamType + == scopeone::core::SignalStreamType::TimestampedEvents); + m_scanImageCheck->setEnabled(enabled); + const bool scanEditable = enabled && m_scanImageCheck->isChecked(); + m_scanWidthSpin->setEnabled(scanEditable); + m_scanHeightSpin->setEnabled(scanEditable); + m_scanGainSpin->setEnabled(scanEditable); + m_scanAverageFramesSpin->setEnabled(scanEditable); + m_frameStartMarkerCombo->setEnabled(scanEditable); + m_lineMarkerCombo->setEnabled(scanEditable); + m_frameEndMarkerCombo->setEnabled(scanEditable); + m_serpentineCheck->setEnabled(scanEditable); + m_mirrorHorizontalCheck->setEnabled(scanEditable); + } + + scopeone::core::SignalSourceDescriptor SignalMonitorWidget::currentDescriptor() const + { + const QString sourceId = m_sourceCombo->currentData().toString(); + for (const auto& descriptor : m_core->signalSources()) + { + if (descriptor.id == sourceId) + { + return descriptor; + } + } + return {}; + } + + QVariantMap SignalMonitorWidget::sourceSettings() const + { + QVariantMap values; + for (auto it = m_parameterEditors.constBegin(); + it != m_parameterEditors.constEnd(); ++it) + { + if (const auto* spin = qobject_cast(it.value())) + { + values.insert(it.key(), spin->value()); + } + else if (const auto* spin = qobject_cast(it.value())) + { + values.insert(it.key(), spin->value()); + } + else if (const auto* combo = qobject_cast(it.value())) + { + values.insert(it.key(), combo->currentData()); + } + else if (const auto* edit = qobject_cast(it.value())) + { + values.insert(it.key(), edit->text().trimmed()); + } + } + return values; + } +} diff --git a/plugins/tools/ScanningDaqTool/SignalMonitorWidget.h b/plugins/tools/ScanningDaqTool/SignalMonitorWidget.h new file mode 100644 index 0000000..a2d26d1 --- /dev/null +++ b/plugins/tools/ScanningDaqTool/SignalMonitorWidget.h @@ -0,0 +1,72 @@ +#pragma once + +#include "scopeone/SignalSource.h" +#include "scopeone/ToolPlugin.h" + +#include +#include + +class QComboBox; +class QCheckBox; +class QDoubleSpinBox; +class QFormLayout; +class QLabel; +class QPushButton; +class QSpinBox; + +namespace scopeone::core +{ + class ScopeOneCore; +} + +namespace scopeone::plugins +{ + class SignalTracePlot; + + class SignalMonitorWidget final : public QWidget + { + Q_OBJECT + + public: + explicit SignalMonitorWidget(scopeone::core::ScopeOneCore* core, + scopeone::ui::ScopeOneToolContext& context, + QWidget* parent = nullptr); + + private: + void refreshSources(); + void rebuildSourceParameters(); + void startAcquisition(); + void handleTimeSeries(const scopeone::core::TimeSeriesChunk& chunk); + void handleStateChanged(const QString& sourceId, + scopeone::core::SignalSourceState state, + const QString& message); + void setSourceControlsEnabled(bool enabled); + scopeone::core::SignalSourceDescriptor currentDescriptor() const; + QVariantMap sourceSettings() const; + + scopeone::core::ScopeOneCore* m_core{nullptr}; + scopeone::ui::ScopeOneToolContext& m_context; + QComboBox* m_sourceCombo{nullptr}; + QFormLayout* m_sourceForm{nullptr}; + QHash m_parameterEditors; + QDoubleSpinBox* m_sampleIntervalSpin{nullptr}; + QDoubleSpinBox* m_windowDurationSpin{nullptr}; + QCheckBox* m_scanImageCheck{nullptr}; + QSpinBox* m_scanWidthSpin{nullptr}; + QSpinBox* m_scanHeightSpin{nullptr}; + QSpinBox* m_scanGainSpin{nullptr}; + QSpinBox* m_scanAverageFramesSpin{nullptr}; + QComboBox* m_frameStartMarkerCombo{nullptr}; + QComboBox* m_lineMarkerCombo{nullptr}; + QComboBox* m_frameEndMarkerCombo{nullptr}; + QCheckBox* m_serpentineCheck{nullptr}; + QCheckBox* m_mirrorHorizontalCheck{nullptr}; + QPushButton* m_startButton{nullptr}; + QPushButton* m_stopButton{nullptr}; + QLabel* m_valueLabel{nullptr}; + QLabel* m_statusLabel{nullptr}; + SignalTracePlot* m_plot{nullptr}; + QString m_activeSourceId; + bool m_scanLayerShown{false}; + }; +} diff --git a/plugins/tools/ScanningDaqTool/plugin.json b/plugins/tools/ScanningDaqTool/plugin.json new file mode 100644 index 0000000..3a17f0f --- /dev/null +++ b/plugins/tools/ScanningDaqTool/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "scopeone.scanning-daq-tool", + "name": "Scanning and DAQ Tools", + "version": "1.0.0", + "scopeOneApi": 1, + "kind": "tool" +} diff --git a/resources/Screenshot 2026-09-01 140040.png b/resources/Screenshot 2026-09-01 140040.png new file mode 100644 index 0000000..66e733b Binary files /dev/null and b/resources/Screenshot 2026-09-01 140040.png differ diff --git a/resources/luts/Jet.lut b/resources/luts/Jet.lut new file mode 100644 index 0000000..1f3eda1 Binary files /dev/null and b/resources/luts/Jet.lut differ diff --git a/resources/luts/README.md b/resources/luts/README.md index 89d3f62..c8208b9 100644 --- a/resources/luts/README.md +++ b/resources/luts/README.md @@ -7,3 +7,6 @@ Gray and the single-channel or dual-channel tables are linear maps. `Fire.lut` follows the ImageJ Fire map used by Micro-Manager, and `Ice.lut` follows the ImageJ Ice map. The Viridis, Inferno, Magma, and Cividis tables use the canonical listed colormaps published by Matplotlib. + +`Jet.lut` is the classic MATLAB/ImageJ jet map, `Rainbow.lut` is a continuous +rainbow map, and `Turbo.lut` is Google's perceptually improved jet replacement. diff --git a/resources/luts/Rainbow.lut b/resources/luts/Rainbow.lut new file mode 100644 index 0000000..7791910 Binary files /dev/null and b/resources/luts/Rainbow.lut differ diff --git a/resources/luts/Turbo.lut b/resources/luts/Turbo.lut new file mode 100644 index 0000000..3fab497 --- /dev/null +++ b/resources/luts/Turbo.lut @@ -0,0 +1,4 @@ +023456789:;<=>??@AABBCDDDEEEFFFFFGGGGGGFFFFFEEDCBA@>=;:87531/.,*('%#"  "%'*,/258@CFIKNQTVY\^adfiknqsvx{}€‚…‡ŠŒ‘”–™›ž £¥¨«­¯²´·¹¼¾ÀÃÅÇÉËÍÐÒÔÕ×ÙÛÝÞàâãäæçéêëìîïðñòóôõö÷øøùúúûüüýýþþþþÿÿÿÿÿÿÿþþþýýüüûûúùø÷öõôóñðïíìêéçåäâàßÝÛÙ×ÕÓÑÏÍËÉÇÅÃÁ¾¼º¸¶³±®¬©§¤¡ž›™–“Ї„~{xurolifc`][XUSPNKIGECA?=;97531/-+*(&%#!  + ;CJQX_fmsy€†‹‘—œ¢§¬±µº¿ÃÇËÏÓÖÚÝàãæéëîðòôöøúûüýþþÿÿÿþþýüûúø÷õôòðîëéçäâßÝÚØÕÒÐÍÊÈÅÂÀ½»¹¶´²¯¬ª§¤¡ž›˜”‘ŽŠ‡„€}zvsolifb_\YVSQNKIGDB@?=<:98766554444444445556677788999:::::::::9998766543210/-,+*)'&%#"! + +  \ No newline at end of file diff --git a/resources/resources.qrc b/resources/resources.qrc index 33b072f..148a9dd 100644 --- a/resources/resources.qrc +++ b/resources/resources.qrc @@ -16,5 +16,8 @@ luts/Inferno.lut luts/Magma.lut luts/Cividis.lut + luts/Jet.lut + luts/Rainbow.lut + luts/Turbo.lut diff --git a/scripts/build.ps1 b/scripts/build.ps1 index f2ed4cd..18dabe9 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -76,10 +76,10 @@ for ($i = 0; $i -lt $args.Count; $i++) { } } -if ($target -notin @("all", "core", "gui", "scopewriter")) { - throw "Invalid target '$target'. Expected one of: all, core, gui, scopewriter." +if ($target -notin @("all", "core", "gui", "plugins", "scopewriter")) { + throw "Invalid target '$target'. Expected one of: all, core, gui, plugins, scopewriter." } -if ($package -and $target -in @("core", "scopewriter")) { +if ($package -and $target -in @("core", "plugins", "scopewriter")) { throw "--package requires --target gui or --target all." } if ($run -and $target -eq "scopewriter") { @@ -236,9 +236,13 @@ $writerBuildDir = Join-Path $writerBuildRoot "standalone" $writerInstallDir = Join-Path $writerSourceDir "install" $writerConsumerSourceDir = Join-Path $writerSourceDir "tests\consumer" $writerConsumerBuildDir = Join-Path $writerBuildRoot "consumer" +$pluginSourceDir = Join-Path $repoRoot "plugins" +$pluginBuildDir = Join-Path $repoRoot "build\plugins" +$pluginInstallDir = Join-Path $coreInstallDir "bin" $config = "Release" $coreCachePath = Join-Path $coreBuildDir "CMakeCache.txt" $guiCachePath = Join-Path $guiBuildDir "CMakeCache.txt" +$pluginCachePath = Join-Path $pluginBuildDir "CMakeCache.txt" if ($clean) { if ($target -eq "scopewriter") { @@ -255,6 +259,10 @@ if ($clean) { Write-Step "Removing GUI build directory" Remove-Item -LiteralPath $guiBuildDir -Recurse -Force } + if ($target -in @("all", "plugins") -and (Test-Path $pluginBuildDir)) { + Write-Step "Removing plugin build directory" + Remove-Item -LiteralPath $pluginBuildDir -Recurse -Force + } if ($target -in @("all", "core") -and (Test-Path $coreBuildDir)) { Write-Step "Removing ScopeOneCore build directory" Remove-Item -LiteralPath $coreBuildDir -Recurse -Force @@ -268,6 +276,27 @@ $guiConfigureOptionOverride = $guiConfigureOption.Count -gt 0 $needCoreConfigure = $configure -or $coreConfigureOptionOverride -or -not (Test-Path $coreCachePath) $needGuiConfigure = $configure -or $guiConfigureOptionOverride -or -not (Test-Path $guiCachePath) +$pluginBuildFilesExist = (Test-Path (Join-Path $pluginBuildDir "ALL_BUILD.vcxproj")) -or + (Test-Path (Join-Path $pluginBuildDir "build.ninja")) -or + (Test-Path (Join-Path $pluginBuildDir "Makefile")) +$needPluginConfigure = $configure -or -not (Test-Path $pluginCachePath) -or + -not $pluginBuildFilesExist +$pluginCachedInstallPrefix = Normalize-CMakePath ( + Get-CMakeCacheValue -CachePath $pluginCachePath -Key "CMAKE_INSTALL_PREFIX") +$pluginCachedSourceDir = Normalize-CMakePath ( + Get-CMakeCacheValue -CachePath $pluginCachePath -Key "CMAKE_HOME_DIRECTORY") +if ($pluginCachedSourceDir -and + $pluginCachedSourceDir -ne (Normalize-CMakePath $pluginSourceDir)) { + $needPluginConfigure = $true + if (Test-Path $pluginBuildDir) { + Write-Step "Removing stale plugin build directory" + Remove-Item -LiteralPath $pluginBuildDir -Recurse -Force + } +} +if ($pluginCachedInstallPrefix -and + $pluginCachedInstallPrefix -ne (Normalize-CMakePath $pluginInstallDir)) { + $needPluginConfigure = $true +} $installPrefixOverride = Find-ConfigureOverride -Options $coreConfigureOption -Prefix "-DCMAKE_INSTALL_PREFIX=" @@ -401,6 +430,65 @@ if ($target -in @("all", "core")) { } } +if ($target -in @("all", "plugins")) { + $coreConfigFile = Join-Path $coreInstallDir "lib\cmake\ScopeOneCore\ScopeOneCoreConfig.cmake" + if (-not (Test-Path $coreConfigFile)) { + throw "ScopeOneCore is not installed at $coreInstallDir. Build the core first." + } + + if ($needPluginConfigure) { + $pluginConfigureArgs = @( + "-S", $pluginSourceDir, + "-B", $pluginBuildDir, + "-DScopeOneCore_ROOT=$coreInstallDir", + "-DCMAKE_PREFIX_PATH=$coreInstallDir", + "-DCMAKE_INSTALL_PREFIX=$pluginInstallDir" + ) + $coreQt6Dir = Get-CMakeCacheValue -CachePath $coreCachePath -Key "Qt6_DIR" + if ($coreQt6Dir) { + $pluginConfigureArgs += "-DQt6_DIR=$coreQt6Dir" + } + $coreOpenCvDir = Get-CMakeCacheValue -CachePath $coreCachePath -Key "OpenCV_DIR" + if (-not $coreOpenCvDir) { + $coreOpenCvDir = Join-Path $coreSourceDir "external\opencv-4.12.0\build" + } + if ($coreOpenCvDir) { + $pluginConfigureArgs += "-DOpenCV_DIR=$coreOpenCvDir" + } + if ($env:CUDA_PATH) { + $pluginConfigureArgs += @("-T", "cuda=$env:CUDA_PATH") + } + Invoke-Step ` + -Label "Configuring ScopeOne plugins" ` + -FilePath $cmake ` + -Arguments $pluginConfigureArgs ` + -WorkingDirectory $repoRoot + } + + Invoke-Step ` + -Label "Building ScopeOne plugins ($config)" ` + -FilePath $cmake ` + -Arguments @( + "--build", $pluginBuildDir, + "--config", $config, + "--parallel" + ) ` + -WorkingDirectory $repoRoot + + Invoke-Step ` + -Label "Installing ScopeOne plugins" ` + -FilePath $cmake ` + -Arguments @( + "--install", $pluginBuildDir, + "--config", $config + ) ` + -WorkingDirectory $repoRoot + + if ($target -eq "all") { + $needGuiConfigure = $true + } +} + if ($target -in @("all", "gui")) { if ($env:OS -eq "Windows_NT") { $guiBuildPrefix = [System.IO.Path]::GetFullPath($guiBuildDir).TrimEnd( @@ -472,6 +560,9 @@ if ($target -eq "scopewriter") { } else { Write-Host "ScopeOneCore install: $coreInstallDir" + if ($target -in @("all", "plugins")) { + Write-Host "Plugins: $pluginBuildDir" + } } if ($target -ne "scopewriter") { if (Test-Path $guiExe) { diff --git a/src/AboutDialog.cpp b/src/AboutDialog.cpp index f4d25c2..c9012c2 100644 --- a/src/AboutDialog.cpp +++ b/src/AboutDialog.cpp @@ -37,10 +37,10 @@ namespace scopeone::ui } mainLayout->addWidget(logoLabel, 0, Qt::AlignHCenter); - m_contentBrowser = new QTextBrowser(this); - m_contentBrowser->setOpenExternalLinks(true); - setContent(); - mainLayout->addWidget(m_contentBrowser, 1); + auto* contentBrowser = new QTextBrowser(this); + contentBrowser->setOpenExternalLinks(true); + setContent(contentBrowser); + mainLayout->addWidget(contentBrowser, 1); auto* okButton = new QPushButton("Close", this); connect(okButton, &QPushButton::clicked, this, &QDialog::accept); @@ -55,7 +55,7 @@ namespace scopeone::ui } // Keep app and core version text in one place - void AboutDialog::setContent() + void AboutDialog::setContent(QTextBrowser* browser) { const QString title = QStringLiteral(SCOPEONE_APP_NAME " " SCOPEONE_APP_VERSION_STRING); const QString coreVersion = scopeone::core::ScopeOneCore::getVersion(); @@ -67,7 +67,7 @@ namespace scopeone::ui const QString platformInfo = QString("%1, %2") .arg(QSysInfo::prettyProductName(), QSysInfo::currentCpuArchitecture()); - m_contentBrowser->setHtml(QString(R"( + browser->setHtml(QString(R"(

%1

diff --git a/src/AboutDialog.h b/src/AboutDialog.h index b25c95e..df82d2c 100644 --- a/src/AboutDialog.h +++ b/src/AboutDialog.h @@ -18,8 +18,6 @@ namespace scopeone::ui private: void setupUI(); - void setContent(); - - QTextBrowser* m_contentBrowser{nullptr}; + void setContent(QTextBrowser* browser); }; } diff --git a/src/ConfigPresetWidget.cpp b/src/ConfigPresetWidget.cpp index 969c404..3f69956 100644 --- a/src/ConfigPresetWidget.cpp +++ b/src/ConfigPresetWidget.cpp @@ -57,7 +57,7 @@ namespace scopeone::ui auto* controlLayout = new QHBoxLayout(); auto* refreshButton = new QPushButton("Refresh", this); refreshButton->setMaximumWidth(60); - connect(refreshButton, &QPushButton::clicked, this, &ConfigPresetWidget::onRefreshClicked); + connect(refreshButton, &QPushButton::clicked, this, &ConfigPresetWidget::refresh); auto* autoRefreshCheckBox = new QCheckBox("Auto Refresh", this); autoRefreshCheckBox->setChecked(m_autoRefresh); @@ -162,11 +162,6 @@ namespace scopeone::ui } } - // Refresh config presets on demand - void ConfigPresetWidget::onRefreshClicked() - { - refresh(); - } // Toggle periodic config preset refresh void ConfigPresetWidget::onAutoRefreshToggled(bool enabled) diff --git a/src/ConfigPresetWidget.h b/src/ConfigPresetWidget.h index 689586b..b30a80d 100644 --- a/src/ConfigPresetWidget.h +++ b/src/ConfigPresetWidget.h @@ -27,7 +27,6 @@ namespace scopeone::ui void errorOccurred(const QString& message); private: - void onRefreshClicked(); void onAutoRefreshToggled(bool enabled); void onAutoRefreshTimer(); diff --git a/src/ConsoleWidget.cpp b/src/ConsoleWidget.cpp index 5373b13..26f6b03 100644 --- a/src/ConsoleWidget.cpp +++ b/src/ConsoleWidget.cpp @@ -7,14 +7,28 @@ #include #include #include +#include +#include +#include +#include #include #include +#include +#include +#include +#include #include +#include +#include #include #include +#include +#include #include +#include #include #include +#include namespace scopeone::ui { @@ -97,11 +111,24 @@ namespace scopeone::ui , m_autoScroll(true) { setupUI(); - connect(m_clearButton, &QPushButton::clicked, this, &ConsoleWidget::onClearClicked); - connect(m_showTimestampsCheckBox, &QCheckBox::toggled, this, &ConsoleWidget::onShowTimestampsToggled); - connect(m_autoScrollCheckBox, &QCheckBox::toggled, this, &ConsoleWidget::onAutoScrollToggled); + connect(m_showTimestampsCheckBox, &QCheckBox::toggled, this, &ConsoleWidget::setShowTimestamps); + connect(m_autoScrollCheckBox, &QCheckBox::toggled, this, &ConsoleWidget::setAutoScroll); connect(m_filterComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &ConsoleWidget::onFilterChanged); + connect(m_searchInput, &QLineEdit::textChanged, this, + [this](const QString& text) + { + m_searchKeyword = text.trimmed(); + updateDisplay(); + }); + m_consoleTextEdit->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_consoleTextEdit, &QTextEdit::customContextMenuRequested, + this, &ConsoleWidget::showContextMenu); + m_commandInput->installEventFilter(this); + + auto* clearShortcut = new QShortcut(QKeySequence(QStringLiteral("Ctrl+L")), this); + clearShortcut->setContext(Qt::WidgetWithChildrenShortcut); + connect(clearShortcut, &QShortcut::activated, this, &ConsoleWidget::clearMessages); updateDisplay(); } @@ -115,7 +142,7 @@ namespace scopeone::ui } } - // Build the console text view and filter controls + // Build the console text view and interactive command controls void ConsoleWidget::setupUI() { auto* mainLayout = new QVBoxLayout(this); @@ -139,38 +166,78 @@ namespace scopeone::ui "}" ); - auto* controlLayout = new QHBoxLayout(); + auto* topBarLayout = new QVBoxLayout(); + topBarLayout->setSpacing(4); + topBarLayout->setContentsMargins(0, 0, 0, 0); + + auto* row1Layout = new QHBoxLayout(); + row1Layout->setSpacing(4); + + m_searchInput = new QLineEdit(this); + m_searchInput->setPlaceholderText(tr("Search logs...")); + m_searchInput->setClearButtonEnabled(true); + m_searchInput->setMinimumWidth(80); + + m_filterComboBox = new QComboBox(this); + m_filterComboBox->addItems({tr("All"), + QStringLiteral("INFO"), + QStringLiteral("DEBUG"), + QStringLiteral("WARNING"), + QStringLiteral("ERROR")}); + m_filterComboBox->setFixedWidth(80); + + auto* clearButton = new QPushButton(tr("Clear"), this); + clearButton->setFixedWidth(50); + connect(clearButton, &QPushButton::clicked, this, &ConsoleWidget::clearMessages); - m_clearButton = new QPushButton("Clear", this); - m_clearButton->setMaximumWidth(60); + row1Layout->addWidget(m_searchInput, 1); + row1Layout->addWidget(m_filterComboBox); + row1Layout->addWidget(clearButton); - m_showTimestampsCheckBox = new QCheckBox("Timestamps", this); + auto* row2Layout = new QHBoxLayout(); + row2Layout->setSpacing(8); + + m_showTimestampsCheckBox = new QCheckBox(tr("Timestamps"), this); m_showTimestampsCheckBox->setChecked(m_showTimestamps); - m_autoScrollCheckBox = new QCheckBox("Auto-scroll", this); + m_autoScrollCheckBox = new QCheckBox(tr("Auto-scroll"), this); m_autoScrollCheckBox->setChecked(m_autoScroll); - auto* filterLabel = new QLabel("Filter:", this); - m_filterComboBox = new QComboBox(this); - m_filterComboBox->addItem("All"); - m_filterComboBox->addItem("INFO"); - m_filterComboBox->addItem("DEBUG"); - m_filterComboBox->addItem("WARNING"); - m_filterComboBox->addItem("ERROR"); - m_filterComboBox->setMaximumWidth(120); - - m_messageCountLabel = new QLabel("Messages: 0", this); - - controlLayout->addWidget(m_clearButton); - controlLayout->addWidget(m_showTimestampsCheckBox); - controlLayout->addWidget(m_autoScrollCheckBox); - controlLayout->addWidget(filterLabel); - controlLayout->addWidget(m_filterComboBox); - controlLayout->addStretch(); - controlLayout->addWidget(m_messageCountLabel); + m_messageCountLabel = new QLabel(tr("Messages: 0"), this); + m_messageCountLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + + row2Layout->addWidget(m_showTimestampsCheckBox); + row2Layout->addWidget(m_autoScrollCheckBox); + row2Layout->addStretch(1); + row2Layout->addWidget(m_messageCountLabel); + topBarLayout->addLayout(row1Layout); + topBarLayout->addLayout(row2Layout); + + mainLayout->addLayout(topBarLayout, 0); mainLayout->addWidget(m_consoleTextEdit, 1); - mainLayout->addLayout(controlLayout, 0); + + auto* commandLayout = new QHBoxLayout(); + auto* promptLabel = new QLabel(QStringLiteral(">>> "), this); + promptLabel->setStyleSheet(QStringLiteral("color: #51cf66;")); + promptLabel->setFont(consoleFont); + + m_commandInput = new QLineEdit(this); + m_commandInput->setPlaceholderText(tr("Enter a command, then press Enter")); + m_commandInput->setFont(consoleFont); + m_commandInput->setStyleSheet( + QStringLiteral("QLineEdit { background: #1e1e1e; color: #f8f9fa; " + "border: 1px solid #495057; padding: 3px 6px; }")); + + auto* runButton = new QPushButton(tr("Run"), this); + runButton->setFixedWidth(64); + connect(runButton, &QPushButton::clicked, this, + [this]() { executeCommand(m_commandInput->text()); }); + + commandLayout->addWidget(promptLabel); + commandLayout->addWidget(m_commandInput, 1); + commandLayout->addWidget(runButton); + mainLayout->addLayout(commandLayout, 0); } // Append one message and trim old history @@ -189,9 +256,7 @@ namespace scopeone::ui m_messages.append(msg); - m_messageCountLabel->setText(QString("Messages: %1").arg(m_messages.size())); - - if (m_messageFilter.isEmpty() || m_messageFilter.contains(msg.type)) + if (messageMatchesCurrentFilter(msg)) { QString formattedMessage = formatMessage(msg); m_consoleTextEdit->append(formattedMessage); @@ -201,6 +266,14 @@ namespace scopeone::ui scrollToBottom(); } } + if (m_messageFilter.isEmpty() && m_searchKeyword.isEmpty()) + { + m_messageCountLabel->setText(tr("Messages: %1").arg(m_messages.size())); + } + else + { + updateDisplay(); + } } // Clear all stored and visible messages @@ -208,7 +281,7 @@ namespace scopeone::ui { m_messages.clear(); m_consoleTextEdit->clear(); - m_messageCountLabel->setText("Messages: 0"); + m_messageCountLabel->setText(tr("Messages: 0")); } // Toggle timestamp rendering for stored log entries @@ -263,15 +336,22 @@ namespace scopeone::ui QString htmlContent; htmlContent.reserve(m_messages.size() * 100); + int visibleCount = 0; for (const auto& msg : m_messages) { - if (m_messageFilter.isEmpty() || m_messageFilter.contains(msg.type)) + if (messageMatchesCurrentFilter(msg)) { + ++visibleCount; htmlContent += formatMessage(msg); htmlContent += "
"; } } + m_messageCountLabel->setText( + visibleCount == m_messages.size() + ? tr("Messages: %1").arg(m_messages.size()) + : tr("Messages: %1/%2").arg(visibleCount).arg(m_messages.size())); + if (!htmlContent.isEmpty()) { m_consoleTextEdit->setHtml(htmlContent); @@ -312,9 +392,25 @@ namespace scopeone::ui if (type == "ERROR") return "#ff6b6b"; if (type == "WARNING") return "#f59f00"; if (type == "DEBUG") return "#1581ed"; + if (type == "COMMAND") return "#51cf66"; + if (type == "API") return "#66d9ef"; return "#dee2e6"; } + bool ConsoleWidget::messageMatchesCurrentFilter(const ConsoleMessage& msg) const + { + if (!m_messageFilter.isEmpty() && !m_messageFilter.contains(msg.type)) + { + return false; + } + if (m_searchKeyword.isEmpty()) + { + return true; + } + return msg.message.contains(m_searchKeyword, Qt::CaseInsensitive) + || msg.type.contains(m_searchKeyword, Qt::CaseInsensitive); + } + // Scroll the console view to the newest message void ConsoleWidget::scrollToBottom() { @@ -322,27 +418,13 @@ namespace scopeone::ui scrollBar->setValue(scrollBar->maximum()); } - void ConsoleWidget::onClearClicked() - { - clearMessages(); - } - - void ConsoleWidget::onShowTimestampsToggled(bool show) - { - setShowTimestamps(show); - } - - void ConsoleWidget::onAutoScrollToggled(bool autoScroll) - { - setAutoScroll(autoScroll); - } // Apply the selected message type filter void ConsoleWidget::onFilterChanged() { QString selectedFilter = m_filterComboBox->currentText(); - if (selectedFilter == "All") + if (selectedFilter == tr("All")) { m_messageFilter.clear(); } @@ -355,6 +437,363 @@ namespace scopeone::ui updateDisplay(); } + void ConsoleWidget::setApiDispatcher(ApiDispatcher dispatcher) + { + m_apiDispatcher = std::move(dispatcher); + } + + // Execute one shorthand command or raw Local API request + void ConsoleWidget::executeCommand(const QString& commandText) + { + const QString command = commandText.trimmed(); + if (command.isEmpty()) + { + return; + } + + m_commandHistory.append(command); + if (m_commandHistory.size() > 100) + { + m_commandHistory.removeFirst(); + } + m_historyIndex = m_commandHistory.size(); + m_commandInput->clear(); + addMessage(QStringLiteral(">>> ") + command, QStringLiteral("COMMAND")); + + const QStringList tokens = command.split( + QRegularExpression(QStringLiteral("\\s+")), Qt::SkipEmptyParts); + const QString verb = tokens.at(0).toLower(); + + if (verb == QStringLiteral("clear") || verb == QStringLiteral("cls")) + { + clearMessages(); + return; + } + if (verb == QStringLiteral("help") || verb == QStringLiteral("?")) + { + showHelp(); + return; + } + + QJsonObject request; + if (verb == QStringLiteral("snap")) + { + request.insert(QStringLiteral("type"), QStringLiteral("record")); + request.insert(QStringLiteral("frames"), 1); + if (tokens.size() > 1) + { + request.insert(QStringLiteral("camera"), tokens.at(1)); + } + } + else if (verb == QStringLiteral("preview") || verb == QStringLiteral("live")) + { + const QString action = tokens.value(1).toLower(); + if (action != QStringLiteral("start") + && action != QStringLiteral("on") + && action != QStringLiteral("stop") + && action != QStringLiteral("off")) + { + addMessage(tr("Usage: preview start|stop [camera]"), QStringLiteral("ERROR")); + return; + } + request.insert(QStringLiteral("type"), + action == QStringLiteral("stop") || action == QStringLiteral("off") + ? QStringLiteral("stop_preview") + : QStringLiteral("start_preview")); + if (tokens.size() > 2) + { + request.insert(QStringLiteral("camera"), tokens.at(2)); + } + } + else if (verb == QStringLiteral("exp") || verb == QStringLiteral("exposure")) + { + bool ok = false; + const double exposureMs = tokens.value(1).toDouble(&ok); + if (!ok || exposureMs <= 0.0) + { + addMessage(tr("Usage: exp [camera]"), QStringLiteral("ERROR")); + return; + } + request.insert(QStringLiteral("type"), QStringLiteral("set_exposure")); + request.insert(QStringLiteral("exposureMs"), exposureMs); + if (tokens.size() > 2) + { + request.insert(QStringLiteral("camera"), tokens.at(2)); + } + } + else if (verb == QStringLiteral("stage")) + { + const QString axis = tokens.value(1).toLower(); + if (axis == QStringLiteral("z")) + { + bool ok = false; + const double dz = tokens.value(2).toDouble(&ok); + if (!ok) + { + addMessage(tr("Usage: stage z "), QStringLiteral("ERROR")); + return; + } + request.insert(QStringLiteral("type"), QStringLiteral("move_z_relative")); + request.insert(QStringLiteral("dz"), dz); + } + else if (axis == QStringLiteral("xy")) + { + bool okX = false; + bool okY = false; + const double dx = tokens.value(2).toDouble(&okX); + const double dy = tokens.value(3).toDouble(&okY); + if (!okX || !okY) + { + addMessage(tr("Usage: stage xy "), QStringLiteral("ERROR")); + return; + } + request.insert(QStringLiteral("type"), QStringLiteral("move_xy_relative")); + request.insert(QStringLiteral("dx"), dx); + request.insert(QStringLiteral("dy"), dy); + } + else + { + addMessage(tr("Usage: stage z or stage xy "), + QStringLiteral("ERROR")); + return; + } + } + else if (verb == QStringLiteral("roi")) + { + const QString action = tokens.value(1).toLower(); + if (action == QStringLiteral("draw")) + { + request.insert(QStringLiteral("type"), QStringLiteral("draw_roi")); + } + else if (action == QStringLiteral("half")) + { + request.insert(QStringLiteral("type"), QStringLiteral("set_half_roi")); + } + else if (action == QStringLiteral("clear")) + { + request.insert(QStringLiteral("type"), QStringLiteral("clear_roi")); + } + else + { + addMessage(tr("Usage: roi draw|half|clear [camera]"), QStringLiteral("ERROR")); + return; + } + if (tokens.size() > 2) + { + request.insert(QStringLiteral("camera"), tokens.at(2)); + } + } + else if (verb == QStringLiteral("fit")) + { + request.insert(QStringLiteral("type"), QStringLiteral("set_fit_to_window")); + request.insert(QStringLiteral("enabled"), true); + } + else if (verb == QStringLiteral("zoom")) + { + bool ok = false; + const int zoomPercent = tokens.value(1).toInt(&ok); + if (!ok || zoomPercent <= 0) + { + addMessage(tr("Usage: zoom "), QStringLiteral("ERROR")); + return; + } + request.insert(QStringLiteral("type"), QStringLiteral("set_zoom")); + request.insert(QStringLiteral("zoomPercent"), zoomPercent); + } + else if (verb == QStringLiteral("auto")) + { + request.insert(QStringLiteral("type"), QStringLiteral("auto_layer_levels")); + } + else if (verb == QStringLiteral("status")) + { + request.insert(QStringLiteral("type"), QStringLiteral("status")); + } + else if (verb == QStringLiteral("api")) + { + const QString operationType = tokens.value(1); + if (operationType.isEmpty()) + { + addMessage(tr("Usage: api [json_payload]"), + QStringLiteral("ERROR")); + return; + } + const int payloadStart = command.indexOf(operationType) + operationType.size(); + const QString payload = command.mid(payloadStart).trimmed(); + if (!payload.isEmpty()) + { + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson( + payload.toUtf8(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) + { + addMessage(tr("Invalid JSON: %1").arg(parseError.errorString()), + QStringLiteral("ERROR")); + return; + } + request = document.object(); + } + request.insert(QStringLiteral("type"), operationType); + } + else + { + addMessage(tr("Unknown command '%1'. Type 'help' for available commands.") + .arg(verb), + QStringLiteral("WARNING")); + return; + } + + m_apiDispatcher(request, + [this](const QJsonObject& response) + { + showCommandResponse(response); + }); + } + + // Print the command grammar in the console + void ConsoleWidget::showHelp() + { + addMessage(QStringLiteral( + "Commands:\n" + " help, ? Show this help\n" + " clear, cls Clear the console\n" + " snap [camera] Capture one frame\n" + " preview start|stop [camera] Control live preview\n" + " exp [camera] Set exposure\n" + " stage z Move focus stage\n" + " stage xy Move XY stage\n" + " roi draw|half|clear [camera] Control ROI\n" + " fit Fit preview to window\n" + " zoom Set preview zoom\n" + " auto Auto-stretch active layer\n" + " status Show application status\n" + " api [json] Send a raw Local API request"), + QStringLiteral("INFO")); + } + + // Show one Local API response in a readable form + void ConsoleWidget::showCommandResponse(const QJsonObject& response) + { + if (!response.value(QStringLiteral("ok")).toBool()) + { + addMessage(response.value(QStringLiteral("error")) + .toString(), + QStringLiteral("ERROR")); + return; + } + + const QString type = response.value(QStringLiteral("type")).toString(); + if (type == QStringLiteral("record")) + { + addMessage(tr("Snapshot captured"), QStringLiteral("INFO")); + return; + } + if (type == QStringLiteral("set_exposure") + && response.contains(QStringLiteral("exposureMs"))) + { + addMessage(tr("Exposure set to %1 ms") + .arg(response.value(QStringLiteral("exposureMs")).toDouble()), + QStringLiteral("INFO")); + return; + } + if (type == QStringLiteral("start_preview")) + { + addMessage(tr("Live preview started"), QStringLiteral("INFO")); + return; + } + if (type == QStringLiteral("stop_preview")) + { + addMessage(tr("Live preview stopped"), QStringLiteral("INFO")); + return; + } + + addMessage(QString::fromUtf8(QJsonDocument(response).toJson(QJsonDocument::Indented)), + QStringLiteral("API")); + } + + // Export the current visible console text + void ConsoleWidget::exportLogsToFile() + { + const QString filePath = QFileDialog::getSaveFileName( + this, tr("Export Console Logs"), QString(), tr("Log files (*.log);;All files (*.*)")); + if (filePath.isEmpty()) + { + return; + } + + QFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) + { + addMessage(tr("Failed to export logs to %1").arg(filePath), QStringLiteral("ERROR")); + return; + } + QTextStream stream(&file); + stream << m_consoleTextEdit->toPlainText(); + addMessage(tr("Logs exported to %1").arg(filePath), QStringLiteral("INFO")); + } + + // Build the console context menu for copy, export, and clear actions + void ConsoleWidget::showContextMenu(const QPoint& position) + { + QMenu menu(this); + QAction* copySelection = menu.addAction(tr("Copy Selection")); + copySelection->setEnabled(m_consoleTextEdit->textCursor().hasSelection()); + connect(copySelection, &QAction::triggered, + m_consoleTextEdit, &QTextEdit::copy); + + QAction* copyAll = menu.addAction(tr("Copy All")); + connect(copyAll, &QAction::triggered, this, + [this]() + { + QGuiApplication::clipboard()->setText(m_consoleTextEdit->toPlainText()); + }); + + menu.addSeparator(); + QAction* exportAction = menu.addAction(tr("Export Logs to File...")); + connect(exportAction, &QAction::triggered, this, &ConsoleWidget::exportLogsToFile); + + menu.addSeparator(); + QAction* clearAction = menu.addAction(tr("Clear Console (Ctrl+L)")); + connect(clearAction, &QAction::triggered, this, &ConsoleWidget::clearMessages); + + menu.exec(m_consoleTextEdit->viewport()->mapToGlobal(position)); + } + + // Provide terminal-style history navigation in the command input + bool ConsoleWidget::eventFilter(QObject* object, QEvent* event) + { + if (object == m_commandInput && event->type() == QEvent::KeyPress) + { + auto* keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Up) + { + if (!m_commandHistory.isEmpty()) + { + m_historyIndex = qMax(0, m_historyIndex - 1); + m_commandInput->setText(m_commandHistory.at(m_historyIndex)); + } + return true; + } + if (keyEvent->key() == Qt::Key_Down) + { + if (!m_commandHistory.isEmpty()) + { + m_historyIndex = qMin(m_commandHistory.size(), m_historyIndex + 1); + m_commandInput->setText( + m_historyIndex == m_commandHistory.size() + ? QString() + : m_commandHistory.at(m_historyIndex)); + } + return true; + } + if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) + { + executeCommand(m_commandInput->text()); + return true; + } + } + return QWidget::eventFilter(object, event); + } + // Forward Qt log output into this widget void ConsoleWidget::installAsQtMessageSink(ConsoleWidget* sink) { diff --git a/src/ConsoleWidget.h b/src/ConsoleWidget.h index 9a1bac1..cdb7f15 100644 --- a/src/ConsoleWidget.h +++ b/src/ConsoleWidget.h @@ -1,13 +1,17 @@ #pragma once #include +#include #include #include #include #include +#include class QTextEdit; -class QPushButton; +class QEvent; +class QPoint; +class QLineEdit; class QCheckBox; class QComboBox; class QLabel; @@ -19,6 +23,9 @@ namespace scopeone::ui Q_OBJECT public: + using ApiDispatcher = std::function)>; + explicit ConsoleWidget(QWidget* parent = nullptr); ~ConsoleWidget() override; @@ -43,10 +50,9 @@ namespace scopeone::ui QStringList getMessageFilter() const; + void setApiDispatcher(ApiDispatcher dispatcher); + private: - void onClearClicked(); - void onShowTimestampsToggled(bool show); - void onAutoScrollToggled(bool autoScroll); void onFilterChanged(); struct ConsoleMessage @@ -60,19 +66,30 @@ namespace scopeone::ui void updateDisplay(); QString formatMessage(const ConsoleMessage& msg) const; QString getTypeColor(const QString& type) const; + bool messageMatchesCurrentFilter(const ConsoleMessage& msg) const; void scrollToBottom(); - - QTextEdit* m_consoleTextEdit; - - QPushButton* m_clearButton; - QCheckBox* m_showTimestampsCheckBox; - QCheckBox* m_autoScrollCheckBox; - QComboBox* m_filterComboBox; - QLabel* m_messageCountLabel; - - bool m_showTimestamps; - bool m_autoScroll; + void executeCommand(const QString& commandText); + void showHelp(); + void exportLogsToFile(); + void showContextMenu(const QPoint& position); + void showCommandResponse(const QJsonObject& response); + bool eventFilter(QObject* object, QEvent* event) override; + + QTextEdit* m_consoleTextEdit{nullptr}; + QCheckBox* m_showTimestampsCheckBox{nullptr}; + QCheckBox* m_autoScrollCheckBox{nullptr}; + QComboBox* m_filterComboBox{nullptr}; + QLabel* m_messageCountLabel{nullptr}; + QLineEdit* m_searchInput{nullptr}; + QLineEdit* m_commandInput{nullptr}; + + bool m_showTimestamps{true}; + bool m_autoScroll{true}; QStringList m_messageFilter; QList m_messages; + QString m_searchKeyword; + QStringList m_commandHistory; + int m_historyIndex{-1}; + ApiDispatcher m_apiDispatcher; }; } diff --git a/src/DeviceControlWidget.cpp b/src/DeviceControlWidget.cpp index 40e9db3..a7ec9f7 100644 --- a/src/DeviceControlWidget.cpp +++ b/src/DeviceControlWidget.cpp @@ -1,14 +1,18 @@ #include "DeviceControlWidget.h" +#include "ImageWorkspace.h" #include "scopeone/ImageSceneModel.h" #include "scopeone/ScopeOneCore.h" #include "PreviewWidget.h" #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -16,6 +20,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -24,28 +32,16 @@ #include #include #include +#include #include #include +#include namespace scopeone::ui { namespace { - // Converts a preview layout mode to combo index - int layerLayoutComboIndex(PreviewWidget::LayerLayoutMode mode) - { - return mode == PreviewWidget::LayerLayoutMode::Overlay ? 1 : 0; - } - - // Converts a combo index to preview layout mode - PreviewWidget::LayerLayoutMode layerLayoutModeFromComboIndex(int index) - { - return index == 1 - ? PreviewWidget::LayerLayoutMode::Overlay - : PreviewWidget::LayerLayoutMode::SideBySide; - } - // Formats exposure with compact decimal precision QString formatExposureMs(double exposureMs) { @@ -62,6 +58,313 @@ namespace scopeone::ui } } // namespace + class LayerHistogramWidget : public QWidget + { + public: + using LevelsCallback = std::function; + using AutoLevelsCallback = std::function; + + explicit LayerHistogramWidget(QWidget* parent = nullptr) + : QWidget(parent) + { + setMinimumHeight(150); + setMouseTracking(true); + } + + void setStats(const scopeone::core::ScopeOneCore::HistogramStats& stats) + { + m_stats = stats; + update(); + } + + void setLevels(int minLevel, int maxLevel, int domainMax) + { + m_minLevel = minLevel; + m_maxLevel = maxLevel; + m_domainMax = qMax(1, domainMax); + update(); + } + + void clear() + { + m_stats = {}; + update(); + } + + void setLogScale(bool enabled) + { + m_logScale = enabled; + update(); + } + + void setOnLevelsChanged(LevelsCallback callback) + { + m_levelsCallback = std::move(callback); + } + + void setOnAutoLevelsRequested(AutoLevelsCallback callback) + { + m_autoLevelsCallback = std::move(callback); + } + + QRect plotRect() const + { + const QFontMetrics metrics = fontMetrics(); + const int labelHeight = metrics.height() + 4; + const int xLabelWidth = qMax(50, metrics.horizontalAdvance(QStringLiteral("65535")) + 12); + const int yLabelWidth = qMax(40, metrics.horizontalAdvance(QStringLiteral("999.9M")) + 8); + return rect().adjusted( + yLabelWidth + 6, + labelHeight + 2, + -(xLabelWidth / 2 + 4), + -(2 * labelHeight + 8)); + } + + protected: + void paintEvent(QPaintEvent*) override + { + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + const QPalette& colors = palette(); + const QFontMetrics metrics = painter.fontMetrics(); + const int labelHeight = metrics.height() + 4; + const int xLabelWidth = qMax(50, metrics.horizontalAdvance(QStringLiteral("65535")) + 12); + const QRect plot = plotRect(); + + painter.fillRect(plot, colors.brush(QPalette::Base)); + painter.setPen(QPen(colors.color(QPalette::Mid), 1)); + painter.drawRect(plot); + + if (!m_stats.hasData() || m_stats.histogram.empty()) + { + painter.setPen(colors.color(QPalette::PlaceholderText)); + painter.drawText(plot, Qt::AlignCenter, QStringLiteral("No Histogram Data")); + return; + } + + int maxCount = 0; + for (const int count : m_stats.histogram) + { + maxCount = qMax(maxCount, count); + } + if (maxCount == 0) + { + painter.setPen(colors.color(QPalette::PlaceholderText)); + painter.drawText(plot, Qt::AlignCenter, QStringLiteral("No Histogram Data")); + return; + } + + painter.setPen(QPen(colors.color(QPalette::Highlight), 1)); + const int histogramSize = static_cast(m_stats.histogram.size()); + for (int i = 0; i < histogramSize; ++i) + { + const int count = m_stats.histogram[static_cast(i)]; + const double normalized = m_logScale && count > 0 + ? log10(count + 1.0) / log10(maxCount + 1.0) + : static_cast(count) / maxCount; + const int x = plot.left() + (i * plot.width()) / histogramSize; + const int height = static_cast(normalized * plot.height()); + painter.drawLine(x, plot.bottom(), x, plot.bottom() - height); + } + + const int domain = qMax(1, m_domainMax); + const int xMin = qBound(plot.left(), plot.left() + static_cast(static_cast(m_minLevel) * plot.width() / domain), plot.right()); + const int xMax = qBound(plot.left(), plot.left() + static_cast(static_cast(m_maxLevel) * plot.width() / domain), plot.right()); + + if (xMin > plot.left()) + { + painter.fillRect(QRect(plot.left(), plot.top(), xMin - plot.left(), plot.height()), QColor(0, 0, 0, 70)); + } + if (xMax < plot.right()) + { + painter.fillRect(QRect(xMax, plot.top(), plot.right() - xMax, plot.height()), QColor(0, 0, 0, 70)); + } + + painter.setPen(QPen(QColor(0, 200, 255), 2)); + painter.drawLine(xMin, plot.top(), xMin, plot.bottom()); + QPolygon minHandle; + minHandle << QPoint(xMin - 4, plot.top()) << QPoint(xMin + 4, plot.top()) << QPoint(xMin, plot.top() + 6); + painter.setBrush(QColor(0, 200, 255)); + painter.drawPolygon(minHandle); + + painter.setPen(QPen(QColor(255, 180, 0), 2)); + painter.drawLine(xMax, plot.top(), xMax, plot.bottom()); + QPolygon maxHandle; + maxHandle << QPoint(xMax - 4, plot.top()) << QPoint(xMax + 4, plot.top()) << QPoint(xMax, plot.top() + 6); + painter.setBrush(QColor(255, 180, 0)); + painter.drawPolygon(maxHandle); + + painter.setPen(QPen(colors.color(QPalette::Mid), 1)); + painter.drawLine(plot.left(), plot.top(), plot.left(), plot.bottom()); + painter.drawLine(plot.left(), plot.bottom(), plot.right(), plot.bottom()); + + const int maxValue = qMax(1, m_stats.maxValue); + for (int i = 0; i <= 4; ++i) + { + const int x = plot.left() + (i * plot.width()) / 4; + const int value = (i * maxValue) / 4; + painter.drawLine(x, plot.bottom(), x, plot.bottom() + 5); + painter.setPen(colors.color(QPalette::Text)); + painter.drawText(QRect(x - xLabelWidth / 2, + plot.bottom() + 5, + xLabelWidth, + labelHeight), + Qt::AlignCenter, + QString::number(value)); + painter.setPen(QPen(colors.color(QPalette::Mid), 1)); + } + + painter.setPen(colors.color(QPalette::Text)); + painter.drawText(QRect(plot.left(), + plot.bottom() + labelHeight + 5, + plot.width(), + labelHeight), + Qt::AlignCenter, + QStringLiteral("Intensity")); + painter.drawText(QRect(0, + plot.top() - labelHeight, + plot.left() - 8, + labelHeight), + Qt::AlignRight | Qt::AlignVCenter, + QStringLiteral("Count")); + + const QString readout = QStringLiteral("Min: %1 Max: %2").arg(m_minLevel).arg(m_maxLevel); + painter.drawText(QRect(plot.left(), 0, plot.width(), labelHeight), Qt::AlignRight | Qt::AlignVCenter, readout); + } + + void mousePressEvent(QMouseEvent* event) override + { + if (event->button() != Qt::LeftButton) + { + QWidget::mousePressEvent(event); + return; + } + const QRect plot = plotRect(); + if (plot.width() <= 0) return; + const int domain = qMax(1, m_domainMax); + const int xMin = plot.left() + static_cast(static_cast(m_minLevel) * plot.width() / domain); + const int xMax = plot.left() + static_cast(static_cast(m_maxLevel) * plot.width() / domain); + const int mx = event->pos().x(); + if (std::abs(mx - xMin) <= 8) + { + m_dragMode = DragMode::MinLevel; + } + else if (std::abs(mx - xMax) <= 8) + { + m_dragMode = DragMode::MaxLevel; + } + else if (std::abs(mx - xMin) < std::abs(mx - xMax)) + { + m_dragMode = DragMode::MinLevel; + updateLevelFromMouse(mx); + } + else + { + m_dragMode = DragMode::MaxLevel; + updateLevelFromMouse(mx); + } + event->accept(); + } + + void mouseMoveEvent(QMouseEvent* event) override + { + const QRect plot = plotRect(); + if (m_dragMode != DragMode::None) + { + updateLevelFromMouse(event->pos().x()); + event->accept(); + return; + } + if (plot.width() > 0) + { + const int domain = qMax(1, m_domainMax); + const int xMin = plot.left() + static_cast(static_cast(m_minLevel) * plot.width() / domain); + const int xMax = plot.left() + static_cast(static_cast(m_maxLevel) * plot.width() / domain); + const int mx = event->pos().x(); + if (std::abs(mx - xMin) <= 8 || std::abs(mx - xMax) <= 8) + { + setCursor(Qt::SizeHorCursor); + } + else + { + setCursor(Qt::ArrowCursor); + } + } + QWidget::mouseMoveEvent(event); + } + + void mouseReleaseEvent(QMouseEvent* event) override + { + if (m_dragMode != DragMode::None) + { + m_dragMode = DragMode::None; + event->accept(); + return; + } + QWidget::mouseReleaseEvent(event); + } + + void mouseDoubleClickEvent(QMouseEvent* event) override + { + if (event->button() == Qt::LeftButton) + { + if (m_autoLevelsCallback) + { + m_autoLevelsCallback(); + } + event->accept(); + return; + } + QWidget::mouseDoubleClickEvent(event); + } + + private: + enum class DragMode { None, MinLevel, MaxLevel }; + + void updateLevelFromMouse(int mouseX) + { + const QRect plot = plotRect(); + if (plot.width() <= 0) return; + const int domain = qMax(1, m_domainMax); + const int rawVal = static_cast(static_cast(mouseX - plot.left()) * domain / plot.width()); + if (m_dragMode == DragMode::MinLevel) + { + const int newMin = qBound(0, rawVal, m_maxLevel - 1); + if (newMin != m_minLevel) + { + m_minLevel = newMin; + if (m_levelsCallback) + { + m_levelsCallback(m_minLevel, m_maxLevel); + } + update(); + } + } + else if (m_dragMode == DragMode::MaxLevel) + { + const int newMax = qBound(m_minLevel + 1, rawVal, domain); + if (newMax != m_maxLevel) + { + m_maxLevel = newMax; + if (m_levelsCallback) + { + m_levelsCallback(m_minLevel, m_maxLevel); + } + update(); + } + } + } + + scopeone::core::ScopeOneCore::HistogramStats m_stats; + bool m_logScale{false}; + int m_minLevel{0}; + int m_maxLevel{255}; + int m_domainMax{255}; + DragMode m_dragMode{DragMode::None}; + LevelsCallback m_levelsCallback; + AutoLevelsCallback m_autoLevelsCallback; + }; + // Creates the device control widget and initializes controls DeviceControlWidget::DeviceControlWidget(scopeone::core::ScopeOneCore* core, QWidget* parent) : QWidget(parent) @@ -87,6 +390,16 @@ namespace scopeone::ui } }, Qt::QueuedConnection); + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::layerHistogramReady, + this, &DeviceControlWidget::onLayerHistogramReady); + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::layerAnalysisCleared, + this, [this](const QString& layerKey) + { + if (layerKey == currentLayerKey()) + { + m_layerHistogramWidget->clear(); + } + }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::stagePositionChanged, this, [this]() { @@ -120,40 +433,89 @@ namespace scopeone::ui // Builds the device control layout void DeviceControlWidget::setupUI() { - QVBoxLayout* mainLayout = new QVBoxLayout(this); - mainLayout->setSpacing(0); - mainLayout->setContentsMargins(0, 0, 0, 0); - - QScrollArea* scrollArea = new QScrollArea(); - scrollArea->setWidgetResizable(true); - scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - scrollArea->setFrameShape(QFrame::NoFrame); - - QWidget* contentContainer = new QWidget(); - QVBoxLayout* contentLayout = new QVBoxLayout(contentContainer); - contentLayout->setSpacing(5); - contentLayout->setContentsMargins(5, 5, 5, 5); + m_imageControlsWidget = new QScrollArea(this); + m_imageControlsWidget->setWidgetResizable(true); + m_imageControlsWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_imageControlsWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_imageControlsWidget->setFrameShape(QFrame::NoFrame); + auto* imageContainer = new QWidget(m_imageControlsWidget); + auto* imageLayout = new QVBoxLayout(imageContainer); + imageLayout->setSpacing(5); + imageLayout->setContentsMargins(5, 5, 5, 5); + imageLayout->addWidget(createPreviewControlsGroup()); + imageLayout->addStretch(); + m_imageControlsWidget->setWidget(imageContainer); + + m_hardwareControlsWidget = new QScrollArea(this); + m_hardwareControlsWidget->setWidgetResizable(true); + m_hardwareControlsWidget->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + m_hardwareControlsWidget->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + m_hardwareControlsWidget->setFrameShape(QFrame::NoFrame); + auto* hardwareContainer = new QWidget(m_hardwareControlsWidget); + auto* hardwareLayout = new QVBoxLayout(hardwareContainer); + hardwareLayout->setSpacing(5); + hardwareLayout->setContentsMargins(5, 5, 5, 5); + hardwareLayout->addWidget(createControlGroup()); + auto* stageGroup = createStageGroup(); + hardwareLayout->addWidget(stageGroup); + hardwareLayout->addStretch(); + m_hardwareControlsWidget->setWidget(hardwareContainer); + } - contentLayout->addWidget(createPreviewControlsGroup()); + void DeviceControlWidget::setImageWorkspace(ImageWorkspace* workspace) + { + m_workspace = workspace; + connect(m_workspace, &ImageWorkspace::activeLayerChanged, + this, [this](const QString&) + { + syncLayerSelection(); + refreshPreviewLayerSettings(); + refreshLayerHistogram(); + updateControlsState(); + if (m_liveViewerContext) + { + syncControlTargetToSelectedRawLayer(); + } + }); + connect(m_workspace, &ImageWorkspace::activeFrameChanged, + this, &DeviceControlWidget::refreshLayerHistogram); + connect(m_workspace, &ImageWorkspace::activeViewerChanged, + this, &DeviceControlWidget::refreshLayerHistogram); + connect(m_workspace, &ImageWorkspace::histogramReady, + this, &DeviceControlWidget::onLayerHistogramReady); + syncLayerSelection(); + refreshLayerHistogram(); + } - contentLayout->addWidget(createControlGroup()); - contentLayout->addWidget(createStageGroup()); + QWidget* DeviceControlWidget::imageControlsWidget() const + { + return m_imageControlsWidget; + } - contentLayout->addStretch(); + QWidget* DeviceControlWidget::hardwareControlsWidget() const + { + return m_hardwareControlsWidget; + } - scrollArea->setWidget(contentContainer); - mainLayout->addWidget(scrollArea); + void DeviceControlWidget::setControlsEnabled(bool enabled) + { + m_imageControlsWidget->setEnabled(enabled); + m_hardwareControlsWidget->setEnabled(enabled); } // Connects the preview widget to control panel state void DeviceControlWidget::setPreviewWidget(PreviewWidget* preview) { - if (!preview) + if (m_previewWidget) { - qFatal("DeviceControlWidget requires PreviewWidget"); + disconnect(m_previewWidget, nullptr, this, nullptr); + } + if (m_sceneModel) + { + disconnect(m_sceneModel, nullptr, this, nullptr); } m_previewWidget = preview; + m_sceneModel = preview->sceneModel(); { QSignalBlocker blocker(m_layerColormapComboBox); @@ -175,35 +537,25 @@ namespace scopeone::ui { applyPreviewVisibility(layerKeys, false); }); - connect(m_previewWidget, &PreviewWidget::layerLayoutModeChanged, - this, [this](PreviewWidget::LayerLayoutMode mode) - { - syncPreviewLayerLayoutCombo(layerLayoutComboIndex(mode)); - }); connect(m_previewWidget, &PreviewWidget::layerInfoTextChanged, this, &DeviceControlWidget::onPreviewLayerInfoTextChanged); - connect(m_previewWidget, &PreviewWidget::zoomLevelChanged, this, - [this](int value) - { - const QSignalBlocker blocker(m_zoomSpinBox); - m_zoomSpinBox->setValue(value); - }); - connect(m_previewWidget, &PreviewWidget::fitToWindowChanged, this, - [this](bool enabled) + connect(m_sceneModel, &scopeone::core::ImageSceneModel::layerDisplayChanged, + this, [this](const QString& layerKey) { - const QSignalBlocker blocker(m_fitToWindowCheckBox); - m_fitToWindowCheckBox->setChecked(enabled); - updatePreviewZoomControls(); + if (layerKey == currentLayerKey()) + { + refreshPreviewLayerSettings(); + } }); - connect(m_scopeonecore->imageSceneModel(), &scopeone::core::ImageSceneModel::layerDisplayChanged, - this, [this](const QString& layerKey) + connect(m_sceneModel, &scopeone::core::ImageSceneModel::layerAutoStretchChanged, + this, [this](const QString& layerKey, bool) { - if (layerKey == m_selectedLayerKey) + if (layerKey == currentLayerKey()) { refreshPreviewLayerSettings(); } }); - connect(m_scopeonecore->imageSceneModel(), + connect(m_sceneModel, &scopeone::core::ImageSceneModel::sourceDisplayTransformChanged, this, [this](const QString& sourceId) { @@ -216,40 +568,81 @@ namespace scopeone::ui onPreviewAvailableCameraIdsChanged(m_previewWidget->availableCameraIds()); onPreviewAvailableLayerKeysChanged(m_previewWidget->availableLayerKeys()); applyPreviewVisibility(m_previewWidget->visibleLayerKeys(), false); - syncPreviewLayerLayoutCombo(layerLayoutComboIndex(m_previewWidget->layerLayoutMode())); onPreviewLayerInfoTextChanged(m_previewWidget->layerInfoSummaryText()); - QSignalBlocker zoomBlocker(m_zoomSpinBox); - m_zoomSpinBox->setValue(m_previewWidget->zoomPercent()); - QSignalBlocker fitBlocker(m_fitToWindowCheckBox); - m_fitToWindowCheckBox->setChecked(m_previewWidget->isFitToWindow()); - updatePreviewZoomControls(); + m_clippingCheckBox->setChecked(m_previewWidget->isClippingWarningEnabled()); + connect(m_clippingCheckBox, &QCheckBox::toggled, + m_previewWidget, &PreviewWidget::setClippingWarningEnabled); + connect(m_previewWidget, &PreviewWidget::clippingWarningChanged, + m_clippingCheckBox, &QCheckBox::setChecked); + + m_scaleBarCheckBox->setChecked(m_previewWidget->isScaleBarVisible()); + connect(m_scaleBarCheckBox, &QCheckBox::toggled, + m_previewWidget, &PreviewWidget::setScaleBarVisible); + connect(m_previewWidget, &PreviewWidget::scaleBarVisibilityChanged, + m_scaleBarCheckBox, &QCheckBox::setChecked); + { + const QSignalBlocker blocker(m_viewDimensionCombo); + m_viewDimensionCombo->setCurrentIndex( + m_previewWidget->viewDimensionMode() == PreviewWidget::ViewDimensionMode::ThreeDimensional + ? 1 + : 0); + } + { + const QSignalBlocker blocker(m_3dZScaleSlider); + m_3dZScaleSlider->setValue(qRound(m_previewWidget->get3dZScale() * 10.0f)); + } + { + const QSignalBlocker blocker(m_3dZScaleSpinBox); + m_3dZScaleSpinBox->setValue(m_previewWidget->get3dZScale()); + } + { + const QSignalBlocker blocker(m_3dWireframeCheckBox); + m_3dWireframeCheckBox->setChecked(m_previewWidget->is3dWireframeEnabled()); + } + connect(m_previewWidget, &PreviewWidget::viewDimensionModeChanged, + this, [this](PreviewWidget::ViewDimensionMode mode) + { + const QSignalBlocker blocker(m_viewDimensionCombo); + m_viewDimensionCombo->setCurrentIndex( + mode == PreviewWidget::ViewDimensionMode::ThreeDimensional ? 1 : 0); + }); + connect(m_previewWidget, &PreviewWidget::threeDimensionalZScaleChanged, + this, [this](float scale) + { + { + const QSignalBlocker sliderBlocker(m_3dZScaleSlider); + m_3dZScaleSlider->setValue(qRound(scale * 10.0f)); + } + const QSignalBlocker spinBlocker(m_3dZScaleSpinBox); + m_3dZScaleSpinBox->setValue(scale); + }); + connect(m_previewWidget, &PreviewWidget::threeDimensionalWireframeChanged, + this, [this](bool enabled) + { + const QSignalBlocker blocker(m_3dWireframeCheckBox); + m_3dWireframeCheckBox->setChecked(enabled); + }); + m_3dColorbarCheckBox->setChecked(m_previewWidget->isThreeDimensionalColorbarVisible()); + connect(m_3dColorbarCheckBox, &QCheckBox::toggled, + m_previewWidget, &PreviewWidget::setThreeDimensionalColorbarVisible); + connect(m_previewWidget, &PreviewWidget::threeDimensionalColorbarVisibilityChanged, + this, [this](bool visible) + { + const QSignalBlocker blocker(m_3dColorbarCheckBox); + m_3dColorbarCheckBox->setChecked(visible); + }); } - // Builds preview zoom layer and alignment controls + // Builds layer and alignment controls QWidget* DeviceControlWidget::createPreviewControlsGroup() { - m_previewControlsGroup = new QGroupBox("Preview Controls", this); - QGridLayout* controlLayout = new QGridLayout(m_previewControlsGroup); + auto* previewControlsGroup = new QGroupBox("Layers", this); + QGridLayout* controlLayout = new QGridLayout(previewControlsGroup); controlLayout->setHorizontalSpacing(6); controlLayout->setVerticalSpacing(4); controlLayout->setContentsMargins(6, 6, 6, 6); - m_zoomLabel = new QLabel("View Zoom:", this); - m_zoomSpinBox = new QSpinBox(this); - m_zoomSpinBox->setRange(10, 500); - m_zoomSpinBox->setValue(100); - m_zoomSpinBox->setSuffix("%"); - m_zoomSpinBox->setFixedWidth(58); - m_zoomSpinBox->setKeyboardTracking(false); - - m_fitToWindowCheckBox = new QCheckBox("Fit to Window", this); - m_fitToWindowCheckBox->setChecked(true); - - m_layerLayoutCombo = new QComboBox(this); - m_layerLayoutCombo->addItem("Side-by-side"); - m_layerLayoutCombo->addItem("Overlay"); - m_layerTable = new QTableWidget(this); m_layerTable->setColumnCount(3); m_layerTable->setHorizontalHeaderLabels(QStringList{ @@ -263,11 +656,37 @@ namespace scopeone::ui m_layerTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::ResizeToContents); m_layerTable->setSelectionMode(QAbstractItemView::SingleSelection); m_layerTable->setSelectionBehavior(QAbstractItemView::SelectRows); - m_layerTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_layerTable->setEditTriggers(QAbstractItemView::DoubleClicked + | QAbstractItemView::EditKeyPressed); + m_layerTable->setContextMenuPolicy(Qt::CustomContextMenu); m_layerTable->setShowGrid(false); m_layerTable->setMinimumHeight(94); m_layerTable->setMaximumHeight(150); + auto* layerHistogramGroup = new QGroupBox(QStringLiteral("Histogram"), previewControlsGroup); + auto* histogramLayout = new QVBoxLayout(layerHistogramGroup); + histogramLayout->setContentsMargins(6, 6, 6, 6); + m_layerHistogramWidget = new LayerHistogramWidget(layerHistogramGroup); + histogramLayout->addWidget(m_layerHistogramWidget); + auto* histogramLevelsLayout = new QHBoxLayout; + m_layerAutoButton = new QPushButton(QStringLiteral("Auto"), layerHistogramGroup); + m_layerAutoButton->setToolTip(QStringLiteral("Set display levels from the current image")); + m_layerFullRangeButton = new QPushButton(QStringLiteral("Full Range"), layerHistogramGroup); + m_layerFullRangeButton->setToolTip(QStringLiteral("Show the complete intensity range")); + histogramLevelsLayout->addWidget(m_layerAutoButton); + histogramLevelsLayout->addWidget(m_layerFullRangeButton); + histogramLayout->addLayout(histogramLevelsLayout); + + m_layerAutoStretchCheckBox = new QCheckBox(QStringLiteral("Continuous Auto"), layerHistogramGroup); + m_layerAutoStretchCheckBox->setToolTip( + QStringLiteral("Update display levels continuously as images arrive")); + + auto* layerHistogramLogCheckBox = new QCheckBox(QStringLiteral("Log scale"), layerHistogramGroup); + histogramLayout->addWidget(m_layerAutoStretchCheckBox); + histogramLayout->addWidget(layerHistogramLogCheckBox); + connect(layerHistogramLogCheckBox, &QCheckBox::toggled, + m_layerHistogramWidget, &LayerHistogramWidget::setLogScale); + m_layerSettingsGroup = new QGroupBox("Layer Settings", this); QGridLayout* layerSettingsLayout = new QGridLayout(m_layerSettingsGroup); layerSettingsLayout->setContentsMargins(6, 6, 6, 6); @@ -281,6 +700,8 @@ namespace scopeone::ui m_layerMoveDownButton = new QPushButton(QStringLiteral("Down"), m_layerSettingsGroup); m_layerRemoveButton = new QPushButton(QStringLiteral("Remove"), m_layerSettingsGroup); m_layerRemoveButton->setMaximumWidth(68); + auto* layerImportButton = new QPushButton(QStringLiteral("Import..."), m_layerSettingsGroup); + layerImportButton->setMaximumWidth(68); m_layerOpacitySpinBox = new QSpinBox(m_layerSettingsGroup); m_layerOpacitySpinBox->setRange(0, 100); @@ -297,107 +718,172 @@ namespace scopeone::ui m_layerColormapComboBox = new QComboBox(m_layerSettingsGroup); m_layerBlendingComboBox = new QComboBox(m_layerSettingsGroup); - - m_layerFrameLabel = new QLabel(QStringLiteral("Frame:"), m_layerSettingsGroup); - m_layerFrameSlider = new QSlider(Qt::Horizontal, m_layerSettingsGroup); - m_layerFrameSlider->setRange(0, 0); - m_layerFrameValueLabel = new QLabel(QStringLiteral("1 / 1"), m_layerSettingsGroup); - m_layerFrameValueLabel->setMinimumWidth(46); - m_layerFrameValueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + m_clippingCheckBox = new QCheckBox(QStringLiteral("Hi-Lo Warn"), m_layerSettingsGroup); + m_clippingCheckBox->setToolTip(QStringLiteral("Highlight saturated pixels in red and zero pixels in blue (Hotkey: C)")); + m_scaleBarCheckBox = new QCheckBox(QStringLiteral("Scale Bar"), m_layerSettingsGroup); + m_scaleBarCheckBox->setToolTip(QStringLiteral("Display calibrated scale bar in viewport")); + m_scaleBarCheckBox->setChecked(true); layerSettingsLayout->addWidget(m_selectedLayerLabel, 0, 0, 1, 6); - layerSettingsLayout->addWidget(m_layerFrameLabel, 1, 0); - layerSettingsLayout->addWidget(m_layerFrameSlider, 1, 1, 1, 4); - layerSettingsLayout->addWidget(m_layerFrameValueLabel, 1, 5); - layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Order:"), m_layerSettingsGroup), 2, 0); - layerSettingsLayout->addWidget(m_layerMoveUpButton, 2, 1, Qt::AlignLeft); - layerSettingsLayout->addWidget(m_layerMoveDownButton, 2, 2, Qt::AlignLeft); - layerSettingsLayout->addWidget(m_layerRemoveButton, 2, 3, Qt::AlignLeft); - layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Opacity:"), m_layerSettingsGroup), 3, 0); - layerSettingsLayout->addWidget(m_layerOpacitySpinBox, 3, 1, Qt::AlignLeft); - layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Gamma:"), m_layerSettingsGroup), 3, 2); - layerSettingsLayout->addWidget(m_layerGammaSpinBox, 3, 3, Qt::AlignLeft); - layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Color:"), m_layerSettingsGroup), 4, 0); - layerSettingsLayout->addWidget(m_layerColormapComboBox, 4, 1); - layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Blend:"), m_layerSettingsGroup), 4, 2); - layerSettingsLayout->addWidget(m_layerBlendingComboBox, 4, 3, 1, 3); - - m_alignXLabel = new QLabel("X offset:", m_layerSettingsGroup); - m_alignXSpinBox = new QSpinBox(m_layerSettingsGroup); + layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Order:"), m_layerSettingsGroup), 1, 0); + layerSettingsLayout->addWidget(m_layerMoveUpButton, 1, 1, Qt::AlignLeft); + layerSettingsLayout->addWidget(m_layerMoveDownButton, 1, 2, Qt::AlignLeft); + layerSettingsLayout->addWidget(m_layerRemoveButton, 1, 3, Qt::AlignLeft); + layerSettingsLayout->addWidget(layerImportButton, 1, 4, Qt::AlignLeft); + layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Opacity:"), m_layerSettingsGroup), 2, 0); + layerSettingsLayout->addWidget(m_layerOpacitySpinBox, 2, 1, Qt::AlignLeft); + layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Gamma:"), m_layerSettingsGroup), 2, 2); + layerSettingsLayout->addWidget(m_layerGammaSpinBox, 2, 3, Qt::AlignLeft); + layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Color:"), m_layerSettingsGroup), 3, 0); + layerSettingsLayout->addWidget(m_layerColormapComboBox, 3, 1); + layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Blend:"), m_layerSettingsGroup), 3, 2); + layerSettingsLayout->addWidget(m_layerBlendingComboBox, 3, 3, 1, 3); + layerSettingsLayout->addWidget(m_clippingCheckBox, 4, 0, 1, 3); + layerSettingsLayout->addWidget(m_scaleBarCheckBox, 4, 3, 1, 3); + + auto* transformGroup = new QGroupBox(QStringLiteral("Transform"), this); + auto* transformLayout = new QGridLayout(transformGroup); + transformLayout->setContentsMargins(6, 6, 6, 6); + auto* alignXLabel = new QLabel("X offset:", transformGroup); + m_alignXSpinBox = new QSpinBox(transformGroup); m_alignXSpinBox->setRange(-1000, 1000); m_alignXSpinBox->setValue(0); m_alignXSpinBox->setFixedWidth(64); m_alignXSpinBox->setKeyboardTracking(false); - m_alignYLabel = new QLabel("Y offset:", m_layerSettingsGroup); - m_alignYSpinBox = new QSpinBox(m_layerSettingsGroup); + auto* alignYLabel = new QLabel("Y offset:", transformGroup); + m_alignYSpinBox = new QSpinBox(transformGroup); m_alignYSpinBox->setRange(-1000, 1000); m_alignYSpinBox->setValue(0); m_alignYSpinBox->setFixedWidth(64); m_alignYSpinBox->setKeyboardTracking(false); - m_alignZoomLabel = new QLabel("Scale:", m_layerSettingsGroup); - m_alignZoomSpinBox = new QSpinBox(m_layerSettingsGroup); + auto* alignZoomLabel = new QLabel("Scale:", transformGroup); + m_alignZoomSpinBox = new QSpinBox(transformGroup); m_alignZoomSpinBox->setRange(10, 500); m_alignZoomSpinBox->setValue(100); m_alignZoomSpinBox->setFixedWidth(58); m_alignZoomSpinBox->setToolTip("Source camera display scale percent"); m_alignZoomSpinBox->setKeyboardTracking(false); - m_alignFlipXCheckBox = new QCheckBox("Flip X", m_layerSettingsGroup); - m_alignFlipYCheckBox = new QCheckBox("Flip Y", m_layerSettingsGroup); - m_alignResetButton = new QPushButton("Reset", m_layerSettingsGroup); - m_alignResetButton->setMaximumWidth(50); - m_alignResetButton->setToolTip("Reset offset and flip"); - - // Display transforms are edited from the selected layer but stored per source camera for now - layerSettingsLayout->addWidget(new QLabel(QStringLiteral("Transform:"), m_layerSettingsGroup), 5, 0, 1, 6); - layerSettingsLayout->addWidget(m_alignXLabel, 6, 0); - layerSettingsLayout->addWidget(m_alignXSpinBox, 6, 1, Qt::AlignLeft); - layerSettingsLayout->addWidget(m_alignYLabel, 6, 2); - layerSettingsLayout->addWidget(m_alignYSpinBox, 6, 3, Qt::AlignLeft); - layerSettingsLayout->addWidget(m_alignZoomLabel, 7, 0); - layerSettingsLayout->addWidget(m_alignZoomSpinBox, 7, 1, Qt::AlignLeft); - layerSettingsLayout->addWidget(m_alignFlipXCheckBox, 7, 2, Qt::AlignLeft); - layerSettingsLayout->addWidget(m_alignFlipYCheckBox, 7, 3, Qt::AlignLeft); - layerSettingsLayout->addWidget(m_alignResetButton, 7, 4, Qt::AlignLeft); - layerSettingsLayout->setColumnStretch(5, 1); - - m_zoomLabel->setMinimumWidth(60); - m_alignXLabel->setMinimumWidth(20); - m_alignYLabel->setMinimumWidth(20); - m_alignZoomLabel->setMinimumWidth(60); - - QLabel* layoutLabel = new QLabel("Layout:", this); - layoutLabel->setMinimumWidth(60); - - controlLayout->addWidget(m_zoomLabel, 0, 0); - controlLayout->addWidget(m_zoomSpinBox, 0, 1, Qt::AlignLeft); - controlLayout->addWidget(m_fitToWindowCheckBox, 0, 2, 1, 2); - controlLayout->addWidget(layoutLabel, 0, 4); - controlLayout->addWidget(m_layerLayoutCombo, 0, 5); - - controlLayout->addWidget(m_layerTable, 1, 0, 1, 6); + m_alignFlipXCheckBox = new QCheckBox("Flip X", transformGroup); + m_alignFlipYCheckBox = new QCheckBox("Flip Y", transformGroup); + auto* alignResetButton = new QPushButton("Reset", transformGroup); + alignResetButton->setMaximumWidth(50); + alignResetButton->setToolTip("Reset offset and flip"); + + transformLayout->addWidget(alignXLabel, 0, 0); + transformLayout->addWidget(m_alignXSpinBox, 0, 1, Qt::AlignLeft); + transformLayout->addWidget(alignYLabel, 0, 2); + transformLayout->addWidget(m_alignYSpinBox, 0, 3, Qt::AlignLeft); + transformLayout->addWidget(alignZoomLabel, 1, 0); + transformLayout->addWidget(m_alignZoomSpinBox, 1, 1, Qt::AlignLeft); + transformLayout->addWidget(m_alignFlipXCheckBox, 1, 2, Qt::AlignLeft); + transformLayout->addWidget(m_alignFlipYCheckBox, 1, 3, Qt::AlignLeft); + transformLayout->addWidget(alignResetButton, 1, 4, Qt::AlignLeft); + transformLayout->setColumnStretch(5, 1); + + alignXLabel->setMinimumWidth(20); + alignYLabel->setMinimumWidth(20); + alignZoomLabel->setMinimumWidth(60); + + auto* surfaceViewGroup = new QGroupBox(QStringLiteral("Surface View"), this); + auto* surfaceViewLayout = new QGridLayout(surfaceViewGroup); + surfaceViewLayout->setContentsMargins(6, 6, 6, 6); + surfaceViewLayout->setHorizontalSpacing(6); + surfaceViewLayout->setVerticalSpacing(4); + + m_viewDimensionCombo = new QComboBox(surfaceViewGroup); + m_viewDimensionCombo->addItem(QStringLiteral("2D Flat Map")); + m_viewDimensionCombo->addItem(QStringLiteral("3D Surface")); + m_viewDimensionCombo->setToolTip(QStringLiteral("Switch between the flat image and a displaced surface")); + + m_3dZScaleSlider = new QSlider(Qt::Horizontal, surfaceViewGroup); + m_3dZScaleSlider->setRange(1, 100); + m_3dZScaleSlider->setValue(10); + m_3dZScaleSlider->setToolTip(QStringLiteral("Height exaggeration from 0.1x to 10.0x")); + + m_3dZScaleSpinBox = new QDoubleSpinBox(surfaceViewGroup); + m_3dZScaleSpinBox->setRange(0.1, 10.0); + m_3dZScaleSpinBox->setSingleStep(0.1); + m_3dZScaleSpinBox->setDecimals(1); + m_3dZScaleSpinBox->setSuffix(QStringLiteral("x")); + m_3dZScaleSpinBox->setValue(1.0); + m_3dZScaleSpinBox->setFixedWidth(64); + m_3dZScaleSpinBox->setKeyboardTracking(false); + + m_3dWireframeCheckBox = new QCheckBox(QStringLiteral("Wireframe"), surfaceViewGroup); + m_3dColorbarCheckBox = new QCheckBox(QStringLiteral("Colorbar"), surfaceViewGroup); + m_3dColorbarCheckBox->setChecked(true); + auto* reset3dButton = new QPushButton(QStringLiteral("Reset View"), surfaceViewGroup); + reset3dButton->setMaximumWidth(84); + + surfaceViewLayout->addWidget(new QLabel(QStringLiteral("Mode:"), surfaceViewGroup), 0, 0); + surfaceViewLayout->addWidget(m_viewDimensionCombo, 0, 1, 1, 3); + surfaceViewLayout->addWidget(new QLabel(QStringLiteral("Z-Scale:"), surfaceViewGroup), 1, 0); + surfaceViewLayout->addWidget(m_3dZScaleSlider, 1, 1); + surfaceViewLayout->addWidget(m_3dZScaleSpinBox, 1, 2); + surfaceViewLayout->addWidget(m_3dWireframeCheckBox, 2, 0, 1, 2); + surfaceViewLayout->addWidget(m_3dColorbarCheckBox, 2, 2); + surfaceViewLayout->addWidget(reset3dButton, 2, 3, 1, 1, Qt::AlignLeft); + + connect(m_viewDimensionCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, [this](int index) + { + m_previewWidget->setViewDimensionMode( + index == 1 ? PreviewWidget::ViewDimensionMode::ThreeDimensional + : PreviewWidget::ViewDimensionMode::TwoDimensional); + }); + connect(m_3dZScaleSlider, &QSlider::valueChanged, this, + [this](int value) + { + const float scale = static_cast(value) / 10.0f; + { + const QSignalBlocker blocker(m_3dZScaleSpinBox); + m_3dZScaleSpinBox->setValue(scale); + } + m_previewWidget->set3dZScale(scale); + }); + connect(m_3dZScaleSpinBox, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double value) + { + { + const QSignalBlocker blocker(m_3dZScaleSlider); + m_3dZScaleSlider->setValue(qRound(value * 10.0)); + } + m_previewWidget->set3dZScale(static_cast(value)); + }); + connect(m_3dWireframeCheckBox, &QCheckBox::toggled, + this, [this](bool enabled) { m_previewWidget->set3dWireframeEnabled(enabled); }); + connect(reset3dButton, &QPushButton::clicked, + this, [this]() { m_previewWidget->reset3dCamera(); }); + + controlLayout->addWidget(m_layerTable, 0, 0, 1, 6); + controlLayout->addWidget(layerHistogramGroup, 1, 0, 1, 6); controlLayout->addWidget(m_layerSettingsGroup, 2, 0, 1, 6); + controlLayout->addWidget(transformGroup, 3, 0, 1, 6); + controlLayout->addWidget(surfaceViewGroup, 4, 0, 1, 6); - controlLayout->setColumnStretch(1, 1); - controlLayout->setColumnStretch(3, 1); controlLayout->setColumnStretch(5, 1); - connect(m_zoomSpinBox, QOverload::of(&QSpinBox::valueChanged), - this, &DeviceControlWidget::onPreviewZoomSpinBoxChanged); - connect(m_fitToWindowCheckBox, &QCheckBox::toggled, - this, &DeviceControlWidget::onPreviewFitToWindowToggled); - connect(m_layerLayoutCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, &DeviceControlWidget::onPreviewLayerLayoutComboChanged); connect(m_layerTable, &QTableWidget::currentCellChanged, this, &DeviceControlWidget::onPreviewLayerSelectionChanged); + connect(m_layerTable, &QTableWidget::itemChanged, + this, &DeviceControlWidget::onPreviewLayerTableItemChanged); + connect(m_layerTable, &QTableWidget::customContextMenuRequested, + this, &DeviceControlWidget::showLayerContextMenu); connect(m_layerMoveUpButton, &QPushButton::clicked, - this, &DeviceControlWidget::onPreviewLayerMoveUpClicked); + this, [this]() { m_sceneModel->moveLayer(currentLayerKey(), -1); }); connect(m_layerMoveDownButton, &QPushButton::clicked, - this, &DeviceControlWidget::onPreviewLayerMoveDownClicked); + this, [this]() { m_sceneModel->moveLayer(currentLayerKey(), 1); }); connect(m_layerRemoveButton, &QPushButton::clicked, - this, &DeviceControlWidget::onPreviewLayerRemoveClicked); + this, [this]() + { + m_scopeonecore->removeStaticFrame( + scopeone::core::ScopeOneCore::sourceIdFromLayerKey(currentLayerKey())); + }); + connect(layerImportButton, &QPushButton::clicked, + this, &DeviceControlWidget::onPreviewLayerImportClicked); connect(m_layerOpacitySpinBox, QOverload::of(&QSpinBox::valueChanged), this, &DeviceControlWidget::onPreviewLayerOpacityChanged); connect(m_layerGammaSpinBox, QOverload::of(&QDoubleSpinBox::valueChanged), @@ -406,9 +892,29 @@ namespace scopeone::ui this, &DeviceControlWidget::onPreviewLayerColormapChanged); connect(m_layerBlendingComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &DeviceControlWidget::onPreviewLayerBlendingChanged); - connect(m_layerFrameSlider, &QSlider::valueChanged, - this, &DeviceControlWidget::onPreviewLayerFrameSliderChanged); - + connect(m_layerAutoButton, &QPushButton::clicked, + this, [this]() { m_workspace->autoLayerLevels(currentLayerKey()); }); + connect(m_layerFullRangeButton, &QPushButton::clicked, + this, [this]() { m_workspace->fullLayerLevels(currentLayerKey()); }); + connect(m_layerAutoStretchCheckBox, &QCheckBox::toggled, + this, &DeviceControlWidget::onPreviewLayerAutoStretchToggled); + m_layerHistogramWidget->setOnLevelsChanged([this](int minLevel, int maxLevel) + { + const QString layerKey = currentLayerKey(); + if (layerKey.isEmpty() || !m_sceneModel) return; + scopeone::core::DocumentLayer layer; + if (m_sceneModel->findLayer(layerKey, layer)) + { + const int domainMax = layer.display.levelDomainMax > 0 ? layer.display.levelDomainMax : 255; + m_workspace->setLayerAutoStretchEnabled(layerKey, false); + m_layerAutoStretchCheckBox->setChecked(false); + m_sceneModel->setLayerDisplayLevels(layerKey, minLevel, maxLevel, domainMax); + } + }); + m_layerHistogramWidget->setOnAutoLevelsRequested([this]() + { + m_workspace->autoLayerLevels(currentLayerKey()); + }); const auto applySourceTransform = [this]() { const QString sourceId = selectedLayerSourceId(); @@ -422,7 +928,7 @@ namespace scopeone::ui transform.zoomPercent = m_alignZoomSpinBox->value(); transform.flipX = m_alignFlipXCheckBox->isChecked(); transform.flipY = m_alignFlipYCheckBox->isChecked(); - m_scopeonecore->imageSceneModel()->setSourceDisplayTransform(sourceId, transform); + m_sceneModel->setSourceDisplayTransform(sourceId, transform); }; connect(m_alignXSpinBox, QOverload::of(&QSpinBox::valueChanged), this, [applySourceTransform](int) { applySourceTransform(); }); @@ -434,39 +940,23 @@ namespace scopeone::ui this, [applySourceTransform](bool) { applySourceTransform(); }); connect(m_alignFlipYCheckBox, &QCheckBox::toggled, this, [applySourceTransform](bool) { applySourceTransform(); }); - connect(m_alignResetButton, &QPushButton::clicked, + connect(alignResetButton, &QPushButton::clicked, this, [this]() { resetSelectedLayerTransform(); }); - updatePreviewZoomControls(); refreshPreviewLayerSettings(); - return m_previewControlsGroup; - } - - // Updates zoom controls from fit to window state - void DeviceControlWidget::updatePreviewZoomControls() - { - m_zoomSpinBox->setEnabled(!m_fitToWindowCheckBox->isChecked()); + return previewControlsGroup; } // Rebuilds the preview layer table void DeviceControlWidget::rebuildPreviewLayerTable(const QStringList& layerKeys) { - const QString previousLayerKey = m_selectedLayerKey; + const QString previousLayerKey = currentLayerKey(); QSignalBlocker tableBlocker(m_layerTable); m_layerRows.clear(); m_layerTable->setRowCount(layerKeys.size()); - for (const QString& layerKey : m_layerFrameCounts.keys()) - { - if (!layerKeys.contains(layerKey)) - { - m_layerFrameCounts.remove(layerKey); - m_layerFrameIndices.remove(layerKey); - } - } - int row = 0; const auto addLayerRow = [&](const QString& layerKey) { @@ -477,7 +967,7 @@ namespace scopeone::ui m_layerTable->setCellWidget(row, 0, visibleCheckBox); QTableWidgetItem* nameItem = new QTableWidgetItem(m_previewWidget->layerName(layerKey)); - nameItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + nameItem->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable | Qt::ItemIsEditable); nameItem->setData(Qt::UserRole, layerKey); m_layerTable->setItem(row, 1, nameItem); @@ -505,19 +995,17 @@ namespace scopeone::ui if (selectedRow >= 0) { m_layerTable->setCurrentCell(selectedRow, 1); - m_selectedLayerKey = layerKeys.at(selectedRow); - } - else - { - m_selectedLayerKey.clear(); } - refreshPreviewLayerSettings(); - if (m_selectedLayerKey != previousLayerKey) + const QString selectedLayerKey = selectedRow >= 0 + ? layerKeys.at(selectedRow) + : QString{}; + if (m_workspace && selectedLayerKey != previousLayerKey) { - emit currentLayerChanged(m_selectedLayerKey); - syncControlTargetToSelectedRawLayer(); + m_workspace->setActiveLayerKey(selectedLayerKey); } + refreshPreviewLayerSettings(); + syncControlTargetToSelectedRawLayer(); } // Refreshes per layer size and frame rate text in the layer table @@ -548,11 +1036,11 @@ namespace scopeone::ui if (notifyPreview) { - m_scopeonecore->imageSceneModel()->setVisibleLayers(layerKeys); + m_sceneModel->setVisibleLayers(layerKeys); } - const QString previousLayerKey = m_selectedLayerKey; - if (!layerKeys.isEmpty() && !visibleLayerKeySet.contains(m_selectedLayerKey)) + const QString selectedLayerKey = currentLayerKey(); + if (!layerKeys.isEmpty() && !visibleLayerKeySet.contains(selectedLayerKey)) { const QString nextLayerKey = layerKeys.first(); for (int row = 0; row < m_layerTable->rowCount(); ++row) @@ -562,37 +1050,34 @@ namespace scopeone::ui { QSignalBlocker tableBlocker(m_layerTable); m_layerTable->setCurrentCell(row, 1); - m_selectedLayerKey = nextLayerKey; + m_workspace->setActiveLayerKey(nextLayerKey); break; } } } refreshPreviewLayerSettings(); - if (m_selectedLayerKey != previousLayerKey) - { - emit currentLayerChanged(m_selectedLayerKey); - } } // Updates the settings editor for the selected preview layer void DeviceControlWidget::refreshPreviewLayerSettings() { - const bool hasLayer = !m_selectedLayerKey.isEmpty() - && m_layerRows.contains(m_selectedLayerKey); + const QString layerKey = currentLayerKey(); + const bool hasLayer = !layerKey.isEmpty() && m_layerRows.contains(layerKey); m_layerSettingsGroup->setEnabled(hasLayer); + m_layerAutoButton->setEnabled(hasLayer); + m_layerFullRangeButton->setEnabled(hasLayer); + m_layerAutoStretchCheckBox->setEnabled(hasLayer); if (!hasLayer) { m_selectedLayerLabel->setText(QStringLiteral("No layer selected")); - refreshLayerFrameControl(); return; } - m_selectedLayerLabel->setText(m_previewWidget->layerName(m_selectedLayerKey)); - refreshLayerFrameControl(); + m_selectedLayerLabel->setText(m_previewWidget->layerName(layerKey)); scopeone::core::DocumentLayer layer; - m_scopeonecore->imageSceneModel()->findLayer(m_selectedLayerKey, layer); + m_sceneModel->findLayer(layerKey, layer); { QSignalBlocker blocker(m_layerOpacitySpinBox); m_layerOpacitySpinBox->setValue(layer.display.opacityPercent); @@ -611,16 +1096,26 @@ namespace scopeone::ui const int index = m_layerBlendingComboBox->findText(layer.display.blending); m_layerBlendingComboBox->setCurrentIndex(index); } + { + QSignalBlocker blocker(m_layerAutoStretchCheckBox); + m_layerAutoStretchCheckBox->setChecked( + m_workspace->layerAutoStretchEnabled(layerKey)); + } + + m_layerHistogramWidget->setLevels( + layer.display.levelMin, layer.display.levelMax, layer.display.levelDomainMax); const int row = m_layerTable->currentRow(); m_layerMoveUpButton->setEnabled(row > 0); m_layerMoveDownButton->setEnabled(row < m_layerTable->rowCount() - 1); - m_layerRemoveButton->setEnabled(scopeone::core::ScopeOneCore::isStaticLayerKey(m_selectedLayerKey)); + m_layerRemoveButton->setEnabled( + m_sceneModel == m_scopeonecore->imageSceneModel() + && scopeone::core::ScopeOneCore::isStaticLayerKey(layerKey)); m_layerOpacitySpinBox->setEnabled(m_layerBlendingComboBox->currentText() != QStringLiteral("Opaque")); const QString sourceId = selectedLayerSourceId(); const scopeone::core::ImageSceneModel::SourceDisplayTransform transform = - m_scopeonecore->imageSceneModel()->sourceDisplayTransform(sourceId); + m_sceneModel->sourceDisplayTransform(sourceId); { QSignalBlocker blocker(m_alignXSpinBox); m_alignXSpinBox->setValue(transform.offsetX); @@ -643,27 +1138,33 @@ namespace scopeone::ui } } - // Updates the frame slider for stack backed gallery layers - void DeviceControlWidget::refreshLayerFrameControl() + QString DeviceControlWidget::selectedLayerSourceId() const { - const int frameCount = m_layerFrameCounts.value(m_selectedLayerKey, 1); - const int frameIndex = qBound(0, m_layerFrameIndices.value(m_selectedLayerKey, 0), qMax(0, frameCount - 1)); - const bool visible = frameCount > 1; - - m_layerFrameLabel->setVisible(visible); - m_layerFrameSlider->setVisible(visible); - m_layerFrameValueLabel->setVisible(visible); - m_layerFrameSlider->setEnabled(visible); - - QSignalBlocker blocker(m_layerFrameSlider); - m_layerFrameSlider->setRange(0, qMax(0, frameCount - 1)); - m_layerFrameSlider->setValue(frameIndex); - m_layerFrameValueLabel->setText(QStringLiteral("%1 / %2").arg(frameIndex + 1).arg(qMax(1, frameCount))); + return scopeone::core::ScopeOneCore::sourceIdFromLayerKey(currentLayerKey()); } - QString DeviceControlWidget::selectedLayerSourceId() const + QString DeviceControlWidget::currentLayerKey() const { - return scopeone::core::ScopeOneCore::sourceIdFromLayerKey(m_selectedLayerKey); + return m_workspace ? m_workspace->activeLayerKey() : QString{}; + } + + void DeviceControlWidget::syncLayerSelection() + { + const QString layerKey = currentLayerKey(); + for (int row = 0; row < m_layerTable->rowCount(); ++row) + { + QTableWidgetItem* item = m_layerTable->item(row, 1); + if (item && item->data(Qt::UserRole).toString() == layerKey) + { + const QSignalBlocker blocker(m_layerTable); + m_layerTable->setCurrentCell(row, 1); + refreshPreviewLayerSettings(); + return; + } + } + const QSignalBlocker blocker(m_layerTable); + m_layerTable->clearSelection(); + refreshPreviewLayerSettings(); } // Refreshes selected layer transform values when live cameras change @@ -676,36 +1177,15 @@ namespace scopeone::ui { rebuildPreviewLayerTable(layerKeys); applyPreviewVisibility(m_previewWidget->visibleLayerKeys(), false); + refreshLayerHistogram(); updateControlsState(); } - void DeviceControlWidget::syncPreviewLayerLayoutCombo(int index) - { - QSignalBlocker blocker(m_layerLayoutCombo); - m_layerLayoutCombo->setCurrentIndex(index); - } - void DeviceControlWidget::onPreviewLayerInfoTextChanged(const QString&) { refreshPreviewLayerInfoText(); } - void DeviceControlWidget::onPreviewZoomSpinBoxChanged(int value) - { - m_previewWidget->setZoomPercent(value); - } - - void DeviceControlWidget::onPreviewFitToWindowToggled(bool enabled) - { - m_previewWidget->setFitToWindow(enabled); - updatePreviewZoomControls(); - } - - void DeviceControlWidget::onPreviewLayerLayoutComboChanged(int index) - { - m_previewWidget->setLayerLayoutMode(layerLayoutModeFromComboIndex(index)); - } - void DeviceControlWidget::onPreviewLayerVisibleToggled(bool) { auto* checkBox = qobject_cast(sender()); @@ -719,84 +1199,175 @@ namespace scopeone::ui break; } } - m_scopeonecore->imageSceneModel()->setLayerVisible(layerKey, checkBox->isChecked()); + m_sceneModel->setLayerVisible(layerKey, checkBox->isChecked()); } void DeviceControlWidget::onPreviewLayerOpacityChanged(int value) { - m_scopeonecore->imageSceneModel()->setLayerOpacityPercent(m_selectedLayerKey, value); + m_sceneModel->setLayerOpacityPercent(currentLayerKey(), value); } void DeviceControlWidget::onPreviewLayerGammaChanged(double value) { - m_scopeonecore->imageSceneModel()->setLayerGamma(m_selectedLayerKey, value); + m_sceneModel->setLayerGamma(currentLayerKey(), value); } void DeviceControlWidget::onPreviewLayerColormapChanged(int) { - m_scopeonecore->imageSceneModel()->setLayerColormap( - m_selectedLayerKey, m_layerColormapComboBox->currentText()); + m_sceneModel->setLayerColormap( + currentLayerKey(), m_layerColormapComboBox->currentText()); } void DeviceControlWidget::onPreviewLayerBlendingChanged(int) { - m_scopeonecore->imageSceneModel()->setLayerBlending( - m_selectedLayerKey, m_layerBlendingComboBox->currentText()); + m_sceneModel->setLayerBlending( + currentLayerKey(), m_layerBlendingComboBox->currentText()); m_layerOpacitySpinBox->setEnabled(m_layerBlendingComboBox->currentText() != QStringLiteral("Opaque")); } - void DeviceControlWidget::onPreviewLayerFrameSliderChanged(int value) + void DeviceControlWidget::onPreviewLayerAutoStretchToggled(bool enabled) + { + m_workspace->setLayerAutoStretchEnabled(currentLayerKey(), enabled); + } + + void DeviceControlWidget::onPreviewLayerSelectionChanged(int currentRow, int, int, int) + { + QTableWidgetItem* item = m_layerTable->item(currentRow, 1); + const QString layerKey = item ? item->data(Qt::UserRole).toString() : QString(); + m_workspace->setActiveLayerKey(layerKey); + refreshPreviewLayerSettings(); + refreshLayerHistogram(); + updateControlsState(); + } + + void DeviceControlWidget::onPreviewLayerDuplicateClicked() + { + const QString sourceLayerKey = currentLayerKey(); + const scopeone::core::ImageFrame sourceFrame = m_scopeonecore->graphFrame(sourceLayerKey); + scopeone::core::DocumentLayer sourceLayer; + m_sceneModel->findLayer(sourceLayerKey, sourceLayer); + + const QString sourceId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(sourceLayerKey); + int copyIndex = 1; + QString duplicateSourceId; + do + { + duplicateSourceId = QStringLiteral("copy:%1_%2").arg(sourceId).arg(copyIndex++); + } + while (m_sceneModel->hasSource(duplicateSourceId)); + + scopeone::core::ImageFrame duplicateFrame = sourceFrame; + duplicateFrame.cameraId = duplicateSourceId; + const QString duplicateName = QStringLiteral("%1 (Copy)") + .arg(sourceLayer.name.isEmpty() + ? m_previewWidget->layerName(sourceLayerKey) + : sourceLayer.name); + const scopeone::core::ImageFrame published = m_scopeonecore->publishStaticFrame( + duplicateSourceId, duplicateFrame, duplicateName); + const QString duplicateLayerKey = + scopeone::core::ScopeOneCore::staticLayerKey(published.cameraId); + + m_sceneModel->setLayerVisible(duplicateLayerKey, sourceLayer.display.visible); + m_sceneModel->setLayerOpacityPercent( + duplicateLayerKey, sourceLayer.display.opacityPercent); + m_sceneModel->setLayerGamma(duplicateLayerKey, sourceLayer.display.gamma); + m_sceneModel->setLayerColormap(duplicateLayerKey, sourceLayer.display.colormap); + m_sceneModel->setLayerBlending(duplicateLayerKey, sourceLayer.display.blending); + m_sceneModel->setLayerDisplayLevels(duplicateLayerKey, + sourceLayer.display.levelMin, + sourceLayer.display.levelMax, + sourceLayer.display.levelDomainMax); + m_workspace->setActiveLayerKey(duplicateLayerKey); + } + + void DeviceControlWidget::onPreviewLayerTableItemChanged(QTableWidgetItem* item) { - const int frameCount = m_layerFrameCounts.value(m_selectedLayerKey, 1); - if (m_selectedLayerKey.isEmpty() || frameCount <= 1) + if (item->column() == 1) { - return; + m_sceneModel->setLayerName(item->data(Qt::UserRole).toString(), item->text()); } + } - const int frameIndex = qBound(0, value, frameCount - 1); - if (m_layerFrameIndices.value(m_selectedLayerKey, 0) == frameIndex) + void DeviceControlWidget::showLayerContextMenu(const QPoint& pos) + { + const int row = m_layerTable->rowAt(pos.y()); + if (row < 0) { return; } - m_layerFrameIndices.insert(m_selectedLayerKey, frameIndex); - m_layerFrameValueLabel->setText(QStringLiteral("%1 / %2").arg(frameIndex + 1).arg(frameCount)); - emit previewLayerFrameRequested(m_selectedLayerKey, frameIndex); + m_layerTable->setCurrentCell(row, 1); + QMenu menu(this); + QAction* renameAction = menu.addAction(QStringLiteral("Rename")); + QAction* duplicateAction = menu.addAction(QStringLiteral("Duplicate Layer")); + QAction* selectedAction = menu.exec(m_layerTable->viewport()->mapToGlobal(pos)); + if (selectedAction == renameAction) + { + m_layerTable->editItem(m_layerTable->item(row, 1)); + } + else if (selectedAction == duplicateAction) + { + onPreviewLayerDuplicateClicked(); + } } - void DeviceControlWidget::onPreviewLayerSelectionChanged(int currentRow, int, int, int) + // Open file dialog and import image files as static layers + void DeviceControlWidget::onPreviewLayerImportClicked() { - QTableWidgetItem* item = m_layerTable->item(currentRow, 1); - const QString previousLayerKey = m_selectedLayerKey; - m_selectedLayerKey = item ? item->data(Qt::UserRole).toString() : QString(); - refreshPreviewLayerSettings(); - updateControlsState(); - if (m_selectedLayerKey != previousLayerKey) + const QStringList filePaths = QFileDialog::getOpenFileNames( + this, + tr("Import Image as Layer"), + QString(), + tr("Images (*.tif *.tiff *.png *.jpg *.jpeg *.bmp)")); + for (const QString& filePath : filePaths) { - emit currentLayerChanged(m_selectedLayerKey); + m_scopeonecore->importImageAsStaticLayerAsync(filePath); } } - void DeviceControlWidget::onPreviewLayerMoveUpClicked() + void DeviceControlWidget::onLayerHistogramReady( + const QString& layerKey, + const scopeone::core::ScopeOneCore::HistogramStats& stats) { - m_scopeonecore->imageSceneModel()->moveLayer(m_selectedLayerKey, -1); + if (layerKey == currentLayerKey()) + { + m_layerHistogramWidget->setStats(stats); + } } - void DeviceControlWidget::onPreviewLayerMoveDownClicked() + void DeviceControlWidget::refreshLayerHistogram() { - m_scopeonecore->imageSceneModel()->moveLayer(m_selectedLayerKey, 1); - } + const QString layerKey = currentLayerKey(); + if (layerKey.isEmpty()) + { + m_layerHistogramWidget->clear(); + return; + } - void DeviceControlWidget::onPreviewLayerRemoveClicked() - { - m_scopeonecore->removeStaticFrame( - scopeone::core::ScopeOneCore::sourceIdFromLayerKey(m_selectedLayerKey)); + if (m_workspace->isLiveViewerActive()) + { + scopeone::core::ScopeOneCore::HistogramStats stats; + if (m_scopeonecore->getLayerHistogram(layerKey, stats)) + { + m_layerHistogramWidget->setStats(stats); + } + else + { + m_layerHistogramWidget->clear(); + } + m_scopeonecore->setActiveHistogramLayer(layerKey); + return; + } + + m_layerHistogramWidget->clear(); + m_workspace->requestHistogram(layerKey); } // Use the selected raw camera layer as the hardware control target void DeviceControlWidget::syncControlTargetToSelectedRawLayer() { - if (!scopeone::core::ScopeOneCore::isRawLayerKey(m_selectedLayerKey)) + if (isAllTarget(m_currentTarget) + || !scopeone::core::ScopeOneCore::isRawLayerKey(currentLayerKey())) { return; } @@ -829,13 +1400,14 @@ namespace scopeone::ui const QString sourceId = selectedLayerSourceId(); if (!sourceId.isEmpty()) { - m_scopeonecore->imageSceneModel()->resetSourceDisplayTransform(sourceId); + m_sceneModel->resetSourceDisplayTransform(sourceId); } } QWidget* DeviceControlWidget::createControlGroup() { QGroupBox* group = new QGroupBox("Camera Controls"); + m_cameraControlsGroup = group; QGridLayout* layout = new QGridLayout(group); int row = 0; @@ -848,7 +1420,8 @@ namespace scopeone::ui layout->addWidget(m_cameraSelectCombo, row, 1); row++; - layout->addWidget(new QLabel("Exposure (ms):"), row, 0); + m_exposureLabel = new QLabel("Exposure (ms):", group); + layout->addWidget(m_exposureLabel, row, 0); m_exposureLineEdit = new QLineEdit(); auto* exposureValidator = new QDoubleValidator(0.1, 10000.0, 16, m_exposureLineEdit); exposureValidator->setLocale(QLocale::c()); @@ -861,11 +1434,17 @@ namespace scopeone::ui row++; m_previewToggleButton = new QPushButton("Start Preview"); - m_previewToggleButton->setMinimumWidth(140); m_previewToggleButton->setMinimumHeight(30); connect(m_previewToggleButton, &QPushButton::clicked, this, &DeviceControlWidget::onPreviewToggleClicked); - - layout->addWidget(m_previewToggleButton, row, 0, 1, 2); + m_snapButton = new QPushButton("Snap", group); + m_snapButton->setMinimumHeight(30); + m_snapButton->setToolTip(tr("Capture the latest frame from the selected target")); + connect(m_snapButton, &QPushButton::clicked, this, [this]() + { + emit snapRequested(m_currentTarget); + }); + layout->addWidget(m_previewToggleButton, row, 0); + layout->addWidget(m_snapButton, row, 1); QHBoxLayout* roiLayout = new QHBoxLayout(); m_drawROIButton = new QPushButton("Draw ROI", group); @@ -885,6 +1464,7 @@ namespace scopeone::ui QWidget* DeviceControlWidget::createStageGroup() { QGroupBox* group = new QGroupBox("Stage Controls"); + m_stageControlsGroup = group; QHBoxLayout* mainLayout = new QHBoxLayout(group); QVBoxLayout* xyColumn = new QVBoxLayout(); QVBoxLayout* zColumn = new QVBoxLayout(); @@ -1162,11 +1742,11 @@ namespace scopeone::ui // Updates enabled state for stage controls void DeviceControlWidget::updateStageControlsEnabled() { - const bool hasXY = !selectedXYStageLabel().isEmpty(); - const bool hasZ = !selectedZStageLabel().isEmpty(); + const bool hasXY = m_liveViewerContext && !selectedXYStageLabel().isEmpty(); + const bool hasZ = m_liveViewerContext && !selectedZStageLabel().isEmpty(); - m_xyStageCombo->setEnabled(m_xyStageCombo->count() > 0); - m_zStageCombo->setEnabled(m_zStageCombo->count() > 0); + m_xyStageCombo->setEnabled(m_liveViewerContext && m_xyStageCombo->count() > 0); + m_zStageCombo->setEnabled(m_liveViewerContext && m_zStageCombo->count() > 0); m_xyStepLineEdit->setEnabled(hasXY); m_xyBigStepLineEdit->setEnabled(hasXY); m_zStepLineEdit->setEnabled(hasZ); @@ -1273,6 +1853,26 @@ namespace scopeone::ui emit stageMoveFailed(tr("Failed to queue Z stage move: %1").arg(zLabel)); } + // Moves XY stage with step factor + void DeviceControlWidget::moveXYStep(double dxScale, double dyScale, bool big) + { + const double stepValue = (big ? m_xyBigStepLineEdit : m_xyStepLineEdit)->text().toDouble(); + if (stepValue > 0.0) + { + moveXYStage(dxScale * stepValue, dyScale * stepValue); + } + } + + // Moves Z stage with step factor + void DeviceControlWidget::moveZStep(double dzScale, bool big) + { + const double stepValue = (big ? m_zBigStepLineEdit : m_zStepLineEdit)->text().toDouble(); + if (stepValue > 0.0) + { + moveZStage(dzScale * stepValue); + } + } + // Updates control state when cameras initialize void DeviceControlWidget::onCameraInitialized(bool initialized) { @@ -1312,15 +1912,22 @@ namespace scopeone::ui // Keeps device control buttons in sync void DeviceControlWidget::updateControlsState() { - m_exposureLineEdit->setEnabled(m_cameraInitialized); - m_previewToggleButton->setEnabled(m_cameraInitialized); + const bool canControlHardware = m_cameraInitialized && m_liveViewerContext; + m_cameraControlsGroup->setVisible(true); + m_stageControlsGroup->setVisible(true); + m_cameraControlsGroup->setEnabled(canControlHardware); + m_stageControlsGroup->setEnabled(m_liveViewerContext); + m_cameraSelectCombo->setEnabled(canControlHardware && m_controlTargetEnabled); + m_exposureLineEdit->setEnabled(canControlHardware); + m_previewToggleButton->setEnabled(canControlHardware); + m_snapButton->setEnabled(canControlHardware); m_previewToggleButton->setText(m_previewRunning ? QStringLiteral("Stop Preview") : QStringLiteral("Start Preview")); const bool hasRoiTarget = !roiCameraTarget().isEmpty(); - m_drawROIButton->setEnabled(m_cameraInitialized && hasRoiTarget); - m_halfROIButton->setEnabled(m_cameraInitialized && hasRoiTarget); - m_clearROIButton->setEnabled(m_cameraInitialized); + m_drawROIButton->setEnabled(canControlHardware && hasRoiTarget); + m_halfROIButton->setEnabled(canControlHardware && hasRoiTarget); + m_clearROIButton->setEnabled(canControlHardware && hasRoiTarget); updateStageControlsEnabled(); } @@ -1358,6 +1965,7 @@ namespace scopeone::ui m_minExposureMs = lower; m_maxExposureMs = upper; } + m_exposureLabel->setText(tr("Exposure (ms):")); return; } @@ -1394,6 +2002,7 @@ namespace scopeone::ui m_minExposureMs = commonLower; m_maxExposureMs = commonUpper; } + m_exposureLabel->setText(tr("Exposure (ms):")); } // Updates preview running state for the control button @@ -1403,43 +2012,7 @@ namespace scopeone::ui updateControlsState(); } - // Returns the preview layer currently selected in the layer table - QString DeviceControlWidget::currentLayerKey() const - { - return m_selectedLayerKey; - } - - // Sets stack frame metadata for one preview layer - void DeviceControlWidget::setLayerFrameControl(const QString& layerKey, int frameCount, int frameIndex) - { - const QString trimmedLayerKey = layerKey.trimmed(); - if (trimmedLayerKey.isEmpty() || frameCount <= 1) - { - removeLayerFrameControl(trimmedLayerKey); - return; - } - - const int clampedCount = qMax(1, frameCount); - m_layerFrameCounts.insert(trimmedLayerKey, clampedCount); - m_layerFrameIndices.insert(trimmedLayerKey, qBound(0, frameIndex, clampedCount - 1)); - if (trimmedLayerKey == m_selectedLayerKey) - { - refreshLayerFrameControl(); - } - } - - // Removes stack frame metadata for one preview layer - void DeviceControlWidget::removeLayerFrameControl(const QString& layerKey) - { - const QString trimmedLayerKey = layerKey.trimmed(); - m_layerFrameCounts.remove(trimmedLayerKey); - m_layerFrameIndices.remove(trimmedLayerKey); - if (trimmedLayerKey.isEmpty() || trimmedLayerKey == m_selectedLayerKey) - { - refreshLayerFrameControl(); - } - } - + // Checks whether a control operation targets every camera bool DeviceControlWidget::isAllTarget(const QString& target) const { return target.compare("All", Qt::CaseInsensitive) == 0; @@ -1459,8 +2032,6 @@ namespace scopeone::ui // Rebuilds available camera control targets void DeviceControlWidget::setControlTargets(const QStringList& cameraIds) { - QString current = m_cameraSelectCombo->currentText(); - { QSignalBlocker blocker(m_cameraSelectCombo); m_cameraSelectCombo->clear(); @@ -1470,26 +2041,13 @@ namespace scopeone::ui m_cameraSelectCombo->addItem(id); } - int idx = m_cameraSelectCombo->findText(current); - const bool currentIsAll = isAllTarget(current); - if (!cameraIds.isEmpty()) + if (cameraIds.size() > 1) { - if (currentIsAll && cameraIds.size() > 1) - { - m_cameraSelectCombo->setCurrentIndex(0); - } - else if (currentIsAll) - { - m_cameraSelectCombo->setCurrentIndex(1); - } - else if (idx >= 0) - { - m_cameraSelectCombo->setCurrentIndex(idx); - } - else - { - m_cameraSelectCombo->setCurrentIndex(1); - } + m_cameraSelectCombo->setCurrentIndex(0); + } + else if (cameraIds.size() == 1) + { + m_cameraSelectCombo->setCurrentIndex(1); } else { @@ -1516,7 +2074,14 @@ namespace scopeone::ui // Enables or disables the camera target selector void DeviceControlWidget::setControlTargetEnabled(bool enabled) { - m_cameraSelectCombo->setEnabled(enabled); + m_controlTargetEnabled = enabled; + updateControlsState(); + } + + void DeviceControlWidget::setViewerContext(bool liveViewer) + { + m_liveViewerContext = liveViewer; + updateControlsState(); } // Applies a new camera control target @@ -1536,12 +2101,6 @@ namespace scopeone::ui // Starts ROI drawing for the selected camera void DeviceControlWidget::onDrawROIClicked() { - if (isAllTarget(m_currentTarget)) - { - emit requestDrawROI(QString()); - return; - } - const QString cameraId = roiCameraTarget(); if (cameraId.isEmpty()) { @@ -1566,6 +2125,10 @@ namespace scopeone::ui // Requests ROI clearing for the selected target void DeviceControlWidget::onClearROIClicked() { - emit requestClearROI(m_currentTarget); + const QString cameraId = roiCameraTarget(); + if (!cameraId.isEmpty()) + { + emit requestClearROI(cameraId); + } } } // namespace scopeone::ui diff --git a/src/DeviceControlWidget.h b/src/DeviceControlWidget.h index dff84b7..bf8ee16 100644 --- a/src/DeviceControlWidget.h +++ b/src/DeviceControlWidget.h @@ -1,5 +1,7 @@ #pragma once +#include "scopeone/ScopeOneCore.h" + #include #include #include @@ -8,6 +10,7 @@ namespace scopeone::core { + class ImageSceneModel; class ScopeOneCore; } @@ -18,12 +21,17 @@ class QGroupBox; class QLabel; class QLineEdit; class QPushButton; +class QPoint; +class QScrollArea; class QSlider; class QSpinBox; class QTableWidget; +class QTableWidgetItem; namespace scopeone::ui { + class ImageWorkspace; + class LayerHistogramWidget; class PreviewWidget; class DeviceControlWidget : public QWidget @@ -36,19 +44,22 @@ namespace scopeone::ui void setControlTargets(const QStringList& cameraIds); + void setImageWorkspace(ImageWorkspace* workspace); void setPreviewWidget(PreviewWidget* previewWidget); + void setViewerContext(bool liveViewer); + QWidget* imageControlsWidget() const; + QWidget* hardwareControlsWidget() const; void setControlTargetEnabled(bool enabled); + void setControlsEnabled(bool enabled); void refreshStageDevices(); void refreshCameraParameters(); - void onCameraInitialized(bool initialized); - void setPreviewRunning(bool running); - QString currentLayerKey() const; - void setLayerFrameControl(const QString& layerKey, int frameCount, int frameIndex); - void removeLayerFrameControl(const QString& layerKey); + void onCameraInitialized(bool initialized); + void moveXYStep(double dxScale, double dyScale, bool big = false); + void moveZStep(double dzScale, bool big = false); signals : void startPreviewRequested(); @@ -57,8 +68,7 @@ namespace scopeone::ui void exposureValueChanged(double exposureMs); void controlTargetChanged(const QString& target); - void currentLayerChanged(const QString& layerKey); - void previewLayerFrameRequested(const QString& layerKey, int frameIndex); + void snapRequested(const QString& target); void stageMoveFailed(const QString& message); void requestDrawROI(const QString& cameraId); @@ -78,33 +88,35 @@ namespace scopeone::ui void onClearROIClicked(); QWidget* createPreviewControlsGroup(); - void updatePreviewZoomControls(); void rebuildPreviewLayerTable(const QStringList& layerKeys); void applyPreviewVisibility(const QStringList& layerKeys, bool notifyPreview); void refreshPreviewLayerSettings(); - void refreshLayerFrameControl(); QString selectedLayerSourceId() const; void onPreviewAvailableCameraIdsChanged(const QStringList& cameraIds); void onPreviewAvailableLayerKeysChanged(const QStringList& layerKeys); - void syncPreviewLayerLayoutCombo(int index); void onPreviewLayerInfoTextChanged(const QString& text); void refreshPreviewLayerInfoText(); - - void onPreviewZoomSpinBoxChanged(int value); - void onPreviewFitToWindowToggled(bool enabled); - void onPreviewLayerLayoutComboChanged(int index); void onPreviewLayerVisibleToggled(bool checked); void onPreviewLayerOpacityChanged(int value); void onPreviewLayerGammaChanged(double value); void onPreviewLayerColormapChanged(int index); void onPreviewLayerBlendingChanged(int index); - void onPreviewLayerFrameSliderChanged(int value); - void onPreviewLayerSelectionChanged(int currentRow, int currentColumn, int previousRow, int previousColumn); - void onPreviewLayerMoveUpClicked(); - void onPreviewLayerMoveDownClicked(); - void onPreviewLayerRemoveClicked(); + void onPreviewLayerAutoStretchToggled(bool enabled); + void onPreviewLayerSelectionChanged(int currentRow, + int currentColumn, + int previousRow, + int previousColumn); + void onPreviewLayerImportClicked(); + void onPreviewLayerDuplicateClicked(); + void onPreviewLayerTableItemChanged(QTableWidgetItem* item); + void showLayerContextMenu(const QPoint& pos); + void onLayerHistogramReady(const QString& layerKey, + const scopeone::core::ScopeOneCore::HistogramStats& stats); + void refreshLayerHistogram(); void syncControlTargetToSelectedRawLayer(); void resetSelectedLayerTransform(); + void syncLayerSelection(); + QString currentLayerKey() const; void setupUI(); @@ -116,11 +128,12 @@ namespace scopeone::ui bool isAllTarget(const QString& target) const; QString roiCameraTarget() const; scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; - QGroupBox* m_previewControlsGroup{nullptr}; - QLabel* m_zoomLabel{nullptr}; - QSpinBox* m_zoomSpinBox{nullptr}; - QCheckBox* m_fitToWindowCheckBox{nullptr}; - QComboBox* m_layerLayoutCombo{nullptr}; + ImageWorkspace* m_workspace{nullptr}; + scopeone::core::ImageSceneModel* m_sceneModel{nullptr}; + QScrollArea* m_imageControlsWidget{nullptr}; + QScrollArea* m_hardwareControlsWidget{nullptr}; + QGroupBox* m_cameraControlsGroup{nullptr}; + QGroupBox* m_stageControlsGroup{nullptr}; QTableWidget* m_layerTable{nullptr}; QMap m_layerRows; QGroupBox* m_layerSettingsGroup{nullptr}; @@ -132,26 +145,29 @@ namespace scopeone::ui QDoubleSpinBox* m_layerGammaSpinBox{nullptr}; QComboBox* m_layerColormapComboBox{nullptr}; QComboBox* m_layerBlendingComboBox{nullptr}; - QLabel* m_layerFrameLabel{nullptr}; - QSlider* m_layerFrameSlider{nullptr}; - QLabel* m_layerFrameValueLabel{nullptr}; - QMap m_layerFrameCounts; - QMap m_layerFrameIndices; - QString m_selectedLayerKey; - QLabel* m_alignXLabel{nullptr}; + QPushButton* m_layerAutoButton{nullptr}; + QPushButton* m_layerFullRangeButton{nullptr}; + QCheckBox* m_layerAutoStretchCheckBox{nullptr}; + QCheckBox* m_clippingCheckBox{nullptr}; + QCheckBox* m_scaleBarCheckBox{nullptr}; + LayerHistogramWidget* m_layerHistogramWidget{nullptr}; + QComboBox* m_viewDimensionCombo{nullptr}; + QSlider* m_3dZScaleSlider{nullptr}; + QDoubleSpinBox* m_3dZScaleSpinBox{nullptr}; + QCheckBox* m_3dWireframeCheckBox{nullptr}; + QCheckBox* m_3dColorbarCheckBox{nullptr}; QSpinBox* m_alignXSpinBox{nullptr}; - QLabel* m_alignYLabel{nullptr}; QSpinBox* m_alignYSpinBox{nullptr}; - QLabel* m_alignZoomLabel{nullptr}; QSpinBox* m_alignZoomSpinBox{nullptr}; QCheckBox* m_alignFlipXCheckBox{nullptr}; QCheckBox* m_alignFlipYCheckBox{nullptr}; - QPushButton* m_alignResetButton{nullptr}; PreviewWidget* m_previewWidget{nullptr}; QLineEdit* m_exposureLineEdit{nullptr}; + QLabel* m_exposureLabel{nullptr}; QPushButton* m_previewToggleButton{nullptr}; + QPushButton* m_snapButton{nullptr}; QComboBox* m_cameraSelectCombo{nullptr}; QPushButton* m_drawROIButton{nullptr}; QPushButton* m_halfROIButton{nullptr}; @@ -191,6 +207,8 @@ namespace scopeone::ui bool m_cameraInitialized; bool m_previewRunning; + bool m_liveViewerContext{true}; + bool m_controlTargetEnabled{true}; QString m_currentTarget; double m_minExposureMs{0.1}; double m_maxExposureMs{10000.0}; diff --git a/src/DevicePropertyWidget.cpp b/src/DevicePropertyWidget.cpp index dd20726..e2d40c0 100644 --- a/src/DevicePropertyWidget.cpp +++ b/src/DevicePropertyWidget.cpp @@ -106,7 +106,7 @@ namespace scopeone::ui auto* refreshButton = new QPushButton("Refresh", this); refreshButton->setMaximumWidth(60); - connect(refreshButton, &QPushButton::clicked, this, &DevicePropertyWidget::onRefreshClicked); + connect(refreshButton, &QPushButton::clicked, this, [this]() { refresh(false); }); auto* optionsButton = new QToolButton(this); optionsButton->setText("Options"); @@ -600,11 +600,6 @@ namespace scopeone::ui } } - // Refresh all property values from hardware - void DevicePropertyWidget::onRefreshClicked() - { - refresh(false); - } // Toggle read only property visibility void DevicePropertyWidget::onShowReadOnlyToggled(bool show) diff --git a/src/DevicePropertyWidget.h b/src/DevicePropertyWidget.h index cb4c5b4..bac4634 100644 --- a/src/DevicePropertyWidget.h +++ b/src/DevicePropertyWidget.h @@ -25,7 +25,6 @@ namespace scopeone::ui void errorOccurred(const QString& message); private: - void onRefreshClicked(); void onShowReadOnlyToggled(bool show); void onShowPreInitToggled(bool show); void onAutoRefreshToggled(bool enabled); diff --git a/src/ImageGalleryWidget.cpp b/src/ImageGalleryWidget.cpp index e52af37..9312182 100644 --- a/src/ImageGalleryWidget.cpp +++ b/src/ImageGalleryWidget.cpp @@ -1,13 +1,25 @@ #include "ImageGalleryWidget.h" #include +#include #include +#include +#include +#include #include #include #include +#include +#include +#include #include +#include +#include #include #include +#include +#include +#include namespace scopeone::ui { @@ -78,10 +90,62 @@ namespace scopeone::ui return session.capturePlan().cameraIds.size(); } - // Count buffered frames or streamed frames written to disk - qint64 sessionFrameCount(const RecordingSessionData& session) + // Build a square grayscale thumbnail from a stored camera frame + QIcon frameThumbnail(const scopeone::core::ImageFrame& frame) { - return session.recordedFrameCount(); + if (!frame.isValid()) + { + return {}; + } + + QImage image(frame.width, frame.height, QImage::Format_Grayscale8); + if (frame.isMono8()) + { + for (int y = 0; y < frame.height; ++y) + { + const char* source = frame.bytes.constData() + + static_cast(y) * frame.stride; + std::memcpy(image.scanLine(y), source, static_cast(frame.width)); + } + } + else + { + quint16 minimum = (std::numeric_limits::max)(); + quint16 maximum = 0; + for (int y = 0; y < frame.height; ++y) + { + const auto* source = reinterpret_cast( + frame.bytes.constData() + static_cast(y) * frame.stride); + for (int x = 0; x < frame.width; ++x) + { + minimum = (std::min)(minimum, source[x]); + maximum = (std::max)(maximum, source[x]); + } + } + + const int range = static_cast(maximum) - static_cast(minimum); + for (int y = 0; y < frame.height; ++y) + { + const auto* source = reinterpret_cast( + frame.bytes.constData() + static_cast(y) * frame.stride); + uchar* target = image.scanLine(y); + for (int x = 0; x < frame.width; ++x) + { + target[x] = range > 0 + ? static_cast( + (static_cast(source[x]) - minimum) * 255 / range) + : static_cast(source[x] > 0 ? 255 : 0); + } + } + } + + const QImage scaled = image.scaled(QSize(48, 48), Qt::KeepAspectRatio, + Qt::SmoothTransformation); + QPixmap pixmap(48, 48); + pixmap.fill(QColor(QStringLiteral("#1c2229"))); + QPainter painter(&pixmap); + painter.drawImage((48 - scaled.width()) / 2, (48 - scaled.height()) / 2, scaled); + return QIcon(pixmap); } } @@ -111,6 +175,19 @@ namespace scopeone::ui updateButtons(); updateEmptyState(); }); + connect(m_core, &scopeone::core::ScopeOneCore::recordingSessionFrameReady, + this, + [this](quint64, + const std::shared_ptr& session, + const QString&, + int, + const scopeone::core::ImageFrame& frame) + { + if (session && frame.isValid()) + { + updateSessionThumbnail(session->capturePlan().experimentId, frame); + } + }); } // Add one acquired image session to the gallery @@ -139,12 +216,22 @@ namespace scopeone::ui auto* item = new QListWidgetItem(m_sessionList); item->setText(displayTitle(*session, title) + QLatin1Char('\n') + itemSubtitle(*session)); + item->setSizeHint(QSize(0, 64)); item->setData(kSessionIdRole, id); if (!session->isSaved()) { item->setFlags(item->flags() | Qt::ItemIsUserCheckable); item->setCheckState(Qt::Unchecked); } + const QStringList cameraIds = session->recordedCameraIds(); + for (const QString& cameraId : cameraIds) + { + if (session->recordedFrameCount(cameraId) > 0) + { + m_core->requestRecordingSessionFrame(session, cameraId, 0); + break; + } + } m_sessionList->setCurrentItem(item); updateButtons(); @@ -202,22 +289,32 @@ namespace scopeone::ui m_emptyLabel = new QLabel(QStringLiteral("No captured images"), this); m_emptyLabel->setAlignment(Qt::AlignCenter); - layout->addWidget(m_emptyLabel); + layout->addWidget(m_emptyLabel, 1); m_sessionList = new QListWidget(this); m_sessionList->setSelectionMode(QAbstractItemView::SingleSelection); + m_sessionList->setIconSize(QSize(48, 48)); + m_sessionList->setSpacing(4); + m_sessionList->setUniformItemSizes(true); + m_sessionList->setTextElideMode(Qt::ElideRight); + m_sessionList->setContextMenuPolicy(Qt::CustomContextMenu); + m_sessionList->setStyleSheet(QStringLiteral( + "QListWidget { border: 1px solid #3a424b; border-radius: 4px; padding: 2px; }" + "QListWidget::item { padding: 6px; border-radius: 4px; }" + "QListWidget::item:selected { background: #31485d; }")); layout->addWidget(m_sessionList, 1); auto* buttonLayout = new QHBoxLayout(); - m_liveButton = new QPushButton(QStringLiteral("Live"), this); - m_openButton = new QPushButton(QStringLiteral("Preview"), this); + buttonLayout->setSpacing(6); + m_deleteButton = new QPushButton(QStringLiteral("Delete"), this); m_saveCheckedButton = new QPushButton(QStringLiteral("Save Checked"), this); - m_removeButton = new QPushButton(QStringLiteral("Remove"), this); - buttonLayout->addWidget(m_liveButton); - buttonLayout->addWidget(m_openButton); - buttonLayout->addWidget(m_saveCheckedButton); - buttonLayout->addWidget(m_removeButton); - layout->addLayout(buttonLayout); + m_saveCheckedButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + m_deleteButton->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + m_deleteButton->setToolTip(QStringLiteral("Delete the selected gallery session")); + m_saveCheckedButton->setToolTip(QStringLiteral("Save all checked unsaved sessions")); + buttonLayout->addWidget(m_saveCheckedButton, 1); + buttonLayout->addWidget(m_deleteButton); + layout->addLayout(buttonLayout, 0); connect(m_sessionList, &QListWidget::currentItemChanged, this, [this](QListWidgetItem*, QListWidgetItem*) @@ -225,51 +322,22 @@ namespace scopeone::ui updateButtons(); }); connect(m_sessionList, &QListWidget::itemDoubleClicked, this, - [this](QListWidgetItem*) - { - auto session = currentSession(); - if (canPreviewSession(session)) - { - emit sessionOpenRequested(session); - } - }); + [this](QListWidgetItem*) { openCurrentSession(); }); connect(m_sessionList, &QListWidget::itemChanged, this, [this](QListWidgetItem*) { updateButtons(); }); - connect(m_liveButton, &QPushButton::clicked, this, &ImageGalleryWidget::livePreviewRequested); - connect(m_openButton, &QPushButton::clicked, this, - [this]() - { - auto session = currentSession(); - if (canPreviewSession(session)) - { - emit sessionOpenRequested(session); - } - }); - connect(m_saveCheckedButton, &QPushButton::clicked, this, - [this]() - { - const auto sessions = checkedSessions(); - if (!sessions.isEmpty()) - { - emit saveSessionsRequested(sessions); - } - }); - connect(m_removeButton, &QPushButton::clicked, this, - [this]() - { - QListWidgetItem* item = m_sessionList->currentItem(); - if (!item) - { - return; - } - auto session = m_core->recordingSession(item->data(kSessionIdRole).toString()); - delete m_sessionList->takeItem(m_sessionList->row(item)); - if (session) - { - emit sessionRemoved(session); - } - updateButtons(); - updateEmptyState(); - }); + connect(m_sessionList, &QListWidget::customContextMenuRequested, + this, &ImageGalleryWidget::showContextMenu); + connect(m_saveCheckedButton, &QPushButton::clicked, + this, &ImageGalleryWidget::saveCheckedSessions); + connect(m_deleteButton, &QPushButton::clicked, this, &ImageGalleryWidget::deleteCurrentSession); + + auto* returnShortcut = new QShortcut(QKeySequence(Qt::Key_Return), m_sessionList); + connect(returnShortcut, &QShortcut::activated, this, &ImageGalleryWidget::openCurrentSession); + auto* enterShortcut = new QShortcut(QKeySequence(Qt::Key_Enter), m_sessionList); + connect(enterShortcut, &QShortcut::activated, this, &ImageGalleryWidget::openCurrentSession); + auto* deleteShortcut = new QShortcut(QKeySequence(Qt::Key_Delete), m_sessionList); + connect(deleteShortcut, &QShortcut::activated, this, &ImageGalleryWidget::deleteCurrentSession); + auto* backspaceShortcut = new QShortcut(QKeySequence(Qt::Key_Backspace), m_sessionList); + connect(backspaceShortcut, &QShortcut::activated, this, &ImageGalleryWidget::deleteCurrentSession); } // Enable actions only when they have valid targets @@ -277,11 +345,125 @@ namespace scopeone::ui { const auto session = currentSession(); const bool hasCurrent = session != nullptr; - m_openButton->setEnabled(canPreviewSession(session)); - m_removeButton->setEnabled(hasCurrent); + m_deleteButton->setEnabled(hasCurrent); m_saveCheckedButton->setEnabled(!checkedSessions().isEmpty()); } + // Open the currently selected session + void ImageGalleryWidget::openCurrentSession() + { + const auto session = currentSession(); + if (canPreviewSession(session)) + { + emit sessionOpenRequested(session); + } + } + + // Remove the currently selected session from the gallery + void ImageGalleryWidget::deleteCurrentSession() + { + QListWidgetItem* item = m_sessionList->currentItem(); + if (!item) + { + return; + } + const auto session = m_core->recordingSession(item->data(kSessionIdRole).toString()); + delete m_sessionList->takeItem(m_sessionList->row(item)); + if (session) + { + emit sessionRemoved(session); + } + updateButtons(); + updateEmptyState(); + } + + // Save all checked unsaved sessions + void ImageGalleryWidget::saveCheckedSessions() + { + const auto sessions = checkedSessions(); + if (!sessions.isEmpty()) + { + emit saveSessionsRequested(sessions); + } + } + + // Check or uncheck every session that supports gallery selection + void ImageGalleryWidget::setAllSessionsChecked(bool checked) + { + for (int row = 0; row < m_sessionList->count(); ++row) + { + QListWidgetItem* item = m_sessionList->item(row); + if (item->flags() & Qt::ItemIsUserCheckable) + { + item->setCheckState(checked ? Qt::Checked : Qt::Unchecked); + } + } + } + + // Show gallery actions for the item under the pointer + void ImageGalleryWidget::showContextMenu(const QPoint& position) + { + if (QListWidgetItem* item = m_sessionList->itemAt(position)) + { + m_sessionList->setCurrentItem(item); + } + else + { + m_sessionList->clearSelection(); + m_sessionList->setCurrentItem(nullptr); + } + + const auto session = currentSession(); + QMenu menu(this); + QAction* openAction = menu.addAction(QStringLiteral("Open Preview (Enter)")); + QAction* saveAsAction = menu.addAction(QStringLiteral("Save Selected As...")); + QAction* deleteAction = menu.addAction(QStringLiteral("Delete (Del)")); + menu.addSeparator(); + QAction* selectAllAction = menu.addAction(QStringLiteral("Select All")); + QAction* unselectAllAction = menu.addAction(QStringLiteral("Unselect All")); + QAction* saveCheckedAction = menu.addAction(QStringLiteral("Save Checked")); + + openAction->setEnabled(canPreviewSession(session)); + saveAsAction->setEnabled(session != nullptr); + deleteAction->setEnabled(session != nullptr); + saveCheckedAction->setEnabled(!checkedSessions().isEmpty()); + + connect(openAction, &QAction::triggered, this, &ImageGalleryWidget::openCurrentSession); + connect(saveAsAction, &QAction::triggered, this, + [this]() + { + const auto selected = currentSession(); + if (selected) + { + emit saveSessionAsRequested(selected); + } + }); + connect(deleteAction, &QAction::triggered, this, &ImageGalleryWidget::deleteCurrentSession); + connect(selectAllAction, &QAction::triggered, this, + [this]() { setAllSessionsChecked(true); }); + connect(unselectAllAction, &QAction::triggered, this, + [this]() { setAllSessionsChecked(false); }); + connect(saveCheckedAction, &QAction::triggered, + this, &ImageGalleryWidget::saveCheckedSessions); + menu.exec(m_sessionList->viewport()->mapToGlobal(position)); + } + + // Apply an asynchronously loaded frame to its gallery item + void ImageGalleryWidget::updateSessionThumbnail( + const QString& sessionId, + const scopeone::core::ImageFrame& frame) + { + for (int row = 0; row < m_sessionList->count(); ++row) + { + QListWidgetItem* item = m_sessionList->item(row); + if (item->data(kSessionIdRole).toString() == sessionId.trimmed()) + { + item->setIcon(frameThumbnail(frame)); + return; + } + } + } + // Show a simple empty state when no sessions exist void ImageGalleryWidget::updateEmptyState() { @@ -330,7 +512,7 @@ namespace scopeone::ui { details.append(sourceName); } - details.append(QStringLiteral("%1 frame(s)").arg(sessionFrameCount(session))); + details.append(QStringLiteral("%1 frame(s)").arg(session.recordedFrameCount())); details.append(QStringLiteral("%1 camera(s)").arg(sessionCameraCount(session))); details.append(saveState); return details.join(QStringLiteral(", ")); diff --git a/src/ImageGalleryWidget.h b/src/ImageGalleryWidget.h index 82c1c4e..7d4c0c7 100644 --- a/src/ImageGalleryWidget.h +++ b/src/ImageGalleryWidget.h @@ -9,6 +9,7 @@ class QLabel; class QListWidget; class QPushButton; +class QPoint; namespace scopeone::ui { @@ -26,9 +27,10 @@ namespace scopeone::ui QList> unsavedSessions() const; signals: - void livePreviewRequested(); void sessionOpenRequested(const std::shared_ptr& session); void sessionRemoved(const std::shared_ptr& session); + void saveSessionAsRequested( + const std::shared_ptr& session); void saveSessionsRequested( const QList>& sessions); @@ -40,14 +42,19 @@ namespace scopeone::ui QString itemSubtitle(const scopeone::core::ScopeOneCore::RecordingSessionData& session) const; std::shared_ptr currentSession() const; QList> checkedSessions() const; + void openCurrentSession(); + void deleteCurrentSession(); + void saveCheckedSessions(); + void setAllSessionsChecked(bool checked); + void showContextMenu(const QPoint& position); + void updateSessionThumbnail(const QString& sessionId, + const scopeone::core::ImageFrame& frame); scopeone::core::ScopeOneCore* m_core{nullptr}; QListWidget* m_sessionList{nullptr}; QLabel* m_emptyLabel{nullptr}; - QPushButton* m_liveButton{nullptr}; - QPushButton* m_openButton{nullptr}; QPushButton* m_saveCheckedButton{nullptr}; - QPushButton* m_removeButton{nullptr}; + QPushButton* m_deleteButton{nullptr}; int m_nextSnapshotTitleIndex{1}; int m_nextRecordingTitleIndex{1}; }; diff --git a/src/ImageProcessingWidget.cpp b/src/ImageProcessingWidget.cpp index 9ed2d6f..8ba657b 100644 --- a/src/ImageProcessingWidget.cpp +++ b/src/ImageProcessingWidget.cpp @@ -1,647 +1,758 @@ #include "ImageProcessingWidget.h" +#include "ImageWorkspace.h" + #include "scopeone/ScopeOneCore.h" +#include "scopeone/ImageSceneModel.h" #include #include #include -#include #include +#include +#include #include #include #include #include +#include #include +#include #include +#include +#include #include +#include +#include +#include #include +#include #include -#include #include #include -#include +#include +#include + +#include +#include namespace scopeone::ui { namespace { + using ProcessingModuleDescriptor = scopeone::core::ProcessingModuleDescriptor; using ProcessingModuleInfo = scopeone::core::ScopeOneCore::ProcessingModuleInfo; - using ProcessingModuleKind = scopeone::core::ScopeOneCore::ProcessingModuleKind; - - void configureParameterSpinBox(QAbstractSpinBox* spinBox) - { - spinBox->setKeyboardTracking(false); - spinBox->setCorrectionMode(QAbstractSpinBox::CorrectToNearestValue); - } + using ProcessingParameterDescriptor = scopeone::core::ProcessingParameterDescriptor; + using ProcessingParameterType = scopeone::core::ProcessingParameterType; - class ProcessingModuleConfigWidgetBase : public QWidget + class MaskPreviewWidget final : public QWidget { public: - ProcessingModuleConfigWidgetBase(scopeone::core::ScopeOneCore* core, - int moduleIndex, - QWidget* parent = nullptr) + explicit MaskPreviewWidget(QWidget* parent) : QWidget(parent) - , m_scopeonecore(core) - , m_moduleIndex(moduleIndex) { - if (!core) - { - qFatal("ProcessingModuleConfigWidgetBase requires ScopeOneCore"); - } + setMinimumSize(220, 220); + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + setMouseTracking(true); } - protected: - bool applyParameters(const QVariantMap& parameters) + void setParameters(const QVariantMap& parameters) { - return m_scopeonecore->setProcessingModuleParameters(m_moduleIndex, parameters); + m_parameters = parameters; + update(); } - bool resetModule() + void setParametersChanged(std::function callback) { - return m_scopeonecore->resetProcessingModuleState(m_moduleIndex); + m_parametersChanged = std::move(callback); } - scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; - int m_moduleIndex{-1}; - }; - - class FFTModuleConfigWidget : public ProcessingModuleConfigWidgetBase - { - public: - FFTModuleConfigWidget(scopeone::core::ScopeOneCore* core, - int moduleIndex, - const ProcessingModuleInfo& info, - QWidget* parent = nullptr) - : ProcessingModuleConfigWidgetBase(core, moduleIndex, parent) + protected: + void paintEvent(QPaintEvent*) override { - auto* layout = new QVBoxLayout(this); - auto* group = new QGroupBox("FFT Settings", this); - auto* groupLayout = new QGridLayout(group); - - groupLayout->addWidget(new QLabel("Output:", group), 0, 0); - m_outputModeCombo = new QComboBox(group); - m_outputModeCombo->addItem("FFT Spectrum", 0); - m_outputModeCombo->addItem("Bandpass FFT Spectrum", 1); - m_outputModeCombo->addItem("Bandpass IFFT Image", 2); - groupLayout->addWidget(m_outputModeCombo, 0, 1); - - groupLayout->addWidget(new QLabel("Min feature size:", group), 1, 0); - m_minFeatureSizeSpin = new QDoubleSpinBox(group); - m_minFeatureSizeSpin->setRange(0.0, 1000.0); - m_minFeatureSizeSpin->setDecimals(2); - configureParameterSpinBox(m_minFeatureSizeSpin); - groupLayout->addWidget(m_minFeatureSizeSpin, 1, 1); - - groupLayout->addWidget(new QLabel("Max feature size:", group), 2, 0); - m_maxFeatureSizeSpin = new QDoubleSpinBox(group); - m_maxFeatureSizeSpin->setRange(0.0, 1000.0); - m_maxFeatureSizeSpin->setDecimals(2); - configureParameterSpinBox(m_maxFeatureSizeSpin); - groupLayout->addWidget(m_maxFeatureSizeSpin, 2, 1); - - groupLayout->addWidget(new QLabel("Filter kind:", group), 3, 0); - m_filterKindCombo = new QComboBox(group); - m_filterKindCombo->addItem("Smooth", 0); - m_filterKindCombo->addItem("Hard", 1); - groupLayout->addWidget(m_filterKindCombo, 3, 1); - - layout->addWidget(group); - layout->addStretch(); - - const QVariantMap params = info.parameters(); - const int outputModeIndex = m_outputModeCombo->findData(params.value("output_mode").toInt()); - m_outputModeCombo->setCurrentIndex(outputModeIndex); - m_minFeatureSizeSpin->setValue(params.value("min_feature_size").toDouble()); - m_maxFeatureSizeSpin->setValue(params.value("max_feature_size").toDouble()); - const int filterIndex = m_filterKindCombo->findData(params.value("filter_kind").toInt()); - m_filterKindCombo->setCurrentIndex(filterIndex); - - connect(m_outputModeCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this]() { apply(); }); - connect(m_minFeatureSizeSpin, QOverload::of(&QDoubleSpinBox::valueChanged), - this, [this]() { apply(); }); - connect(m_maxFeatureSizeSpin, QOverload::of(&QDoubleSpinBox::valueChanged), - this, [this]() { apply(); }); - connect(m_filterKindCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this]() { apply(); }); - updateFilterControls(); + QPainter painter(this); + painter.fillRect(rect(), QColor(24, 27, 31)); + painter.setRenderHint(QPainter::Antialiasing); + const QRectF area = plotArea(); + painter.setPen(QColor(82, 88, 96)); + painter.drawLine(area.center().x(), area.top(), area.center().x(), area.bottom()); + painter.drawLine(area.left(), area.center().y(), area.right(), area.center().y()); + + const QPointF center = toWidget(m_parameters.value(QStringLiteral("center_x")).toDouble(), + m_parameters.value(QStringLiteral("center_y")).toDouble()); + const double sizeX = m_parameters.value(QStringLiteral("size_x"), 0.1).toDouble() * area.width(); + const double sizeY = m_parameters.value(QStringLiteral("size_y"), 0.1).toDouble() * area.height(); + const double rotation = m_parameters.value(QStringLiteral("rotation")).toDouble(); + const int shape = m_parameters.value(QStringLiteral("shape")).toInt(); + painter.save(); + painter.translate(center); + painter.rotate(-rotation); + painter.setPen(QPen(QColor(90, 210, 255), 2)); + painter.setBrush(QColor(90, 210, 255, 70)); + if (shape == 1) + { + painter.drawRect(QRectF(-sizeX / 2.0, -sizeY / 2.0, sizeX, sizeY)); + } + else + { + painter.drawEllipse(QRectF(-sizeX / 2.0, -sizeY / 2.0, sizeX, sizeY)); + if (shape == 2) + { + const double inner = m_parameters.value(QStringLiteral("inner_size")).toDouble() + * area.width(); + painter.setBrush(QColor(24, 27, 31)); + painter.drawEllipse(QRectF(-inner / 2.0, -inner / 2.0, inner, inner)); + } + } + painter.restore(); + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(255, 120, 90)); + painter.drawEllipse(center, 4, 4); + painter.setBrush(QColor(90, 210, 255)); + painter.drawEllipse(toWidget(m_parameters.value(QStringLiteral("center_x")).toDouble() + + m_parameters.value(QStringLiteral("size_x"), 0.1).toDouble() / 2.0, + m_parameters.value(QStringLiteral("center_y")).toDouble()), + 5, 5); } - private: - void apply() + void mousePressEvent(QMouseEvent* event) override { - QVariantMap params; - params["output_mode"] = m_outputModeCombo->currentData().toInt(); - params["min_feature_size"] = m_minFeatureSizeSpin->value(); - params["max_feature_size"] = m_maxFeatureSizeSpin->value(); - params["filter_kind"] = m_filterKindCombo->currentData().toInt(); - updateFilterControls(); - applyParameters(params); + const QPointF center = toWidget(m_parameters.value(QStringLiteral("center_x")).toDouble(), + m_parameters.value(QStringLiteral("center_y")).toDouble()); + const QPointF handle = toWidget(m_parameters.value(QStringLiteral("center_x")).toDouble() + + m_parameters.value(QStringLiteral("size_x"), 0.1).toDouble() / 2.0, + m_parameters.value(QStringLiteral("center_y")).toDouble()); + if (QLineF(event->position(), center).length() < 14.0) + { + m_dragMode = DragMode::Move; + } + else if (QLineF(event->position(), handle).length() < 14.0) + { + m_dragMode = DragMode::Resize; + } + else + { + m_dragMode = DragMode::None; + } + m_lastPosition = event->position(); } - void updateFilterControls() + void mouseMoveEvent(QMouseEvent* event) override { - const bool usesBandpass = m_outputModeCombo->currentData().toInt() != 0; - m_minFeatureSizeSpin->setEnabled(usesBandpass); - m_maxFeatureSizeSpin->setEnabled(usesBandpass); - m_filterKindCombo->setEnabled(usesBandpass); + if (m_dragMode == DragMode::None) + { + return; + } + QVariantMap parameters = m_parameters; + if (m_dragMode == DragMode::Move) + { + const QPointF delta = event->position() - m_lastPosition; + parameters[QStringLiteral("center_x")] = qBound(-0.5, + parameters.value(QStringLiteral("center_x")).toDouble() + + delta.x() / plotArea().width(), + 0.5); + parameters[QStringLiteral("center_y")] = qBound(-0.5, + parameters.value(QStringLiteral("center_y")).toDouble() + + delta.y() / plotArea().height(), + 0.5); + } + else + { + const QPointF center = toWidget(parameters.value(QStringLiteral("center_x")).toDouble(), + parameters.value(QStringLiteral("center_y")).toDouble()); + parameters[QStringLiteral("size_x")] = qBound(0.001, + 2.0 * std::abs(event->position().x() - center.x()) + / plotArea().width(), + 1.0); + parameters[QStringLiteral("size_y")] = qBound(0.001, + 2.0 * std::abs(event->position().y() - center.y()) + / plotArea().height(), + 1.0); + } + m_lastPosition = event->position(); + if (m_parametersChanged) + { + m_parametersChanged(parameters); + } } - QComboBox* m_outputModeCombo{nullptr}; - QDoubleSpinBox* m_minFeatureSizeSpin{nullptr}; - QDoubleSpinBox* m_maxFeatureSizeSpin{nullptr}; - QComboBox* m_filterKindCombo{nullptr}; - }; - - class SpatiotemporalBinningModuleConfigWidget : public ProcessingModuleConfigWidgetBase - { - public: - SpatiotemporalBinningModuleConfigWidget(scopeone::core::ScopeOneCore* core, - int moduleIndex, - const ProcessingModuleInfo& info, - QWidget* parent = nullptr) - : ProcessingModuleConfigWidgetBase(core, moduleIndex, parent) + void mouseReleaseEvent(QMouseEvent*) override { - auto* layout = new QVBoxLayout(this); - auto* group = new QGroupBox("Spatiotemporal Binning Settings", this); - auto* groupLayout = new QGridLayout(group); - - groupLayout->addWidget(new QLabel("Spatial X:", group), 0, 0); - m_spatialBinXSpin = new QSpinBox(group); - m_spatialBinXSpin->setRange(1, 64); - configureParameterSpinBox(m_spatialBinXSpin); - groupLayout->addWidget(m_spatialBinXSpin, 0, 1); - - groupLayout->addWidget(new QLabel("Spatial Y:", group), 1, 0); - m_spatialBinYSpin = new QSpinBox(group); - m_spatialBinYSpin->setRange(1, 64); - configureParameterSpinBox(m_spatialBinYSpin); - groupLayout->addWidget(m_spatialBinYSpin, 1, 1); - - groupLayout->addWidget(new QLabel("Temporal:", group), 2, 0); - m_temporalBinSpin = new QSpinBox(group); - m_temporalBinSpin->setRange(1, 256); - configureParameterSpinBox(m_temporalBinSpin); - groupLayout->addWidget(m_temporalBinSpin, 2, 1); - - groupLayout->addWidget(new QLabel("Spatial mode:", group), 3, 0); - m_spatialModeCombo = new QComboBox(group); - m_spatialModeCombo->addItem("Mean", 0); - m_spatialModeCombo->addItem("Sum", 1); - m_spatialModeCombo->addItem("Minimum", 2); - m_spatialModeCombo->addItem("Maximum", 3); - m_spatialModeCombo->addItem("Skip", 4); - groupLayout->addWidget(m_spatialModeCombo, 3, 1); - - groupLayout->addWidget(new QLabel("Temporal mode:", group), 4, 0); - m_temporalModeCombo = new QComboBox(group); - for (int i = 0; i < m_spatialModeCombo->count(); ++i) - { - m_temporalModeCombo->addItem(m_spatialModeCombo->itemText(i), - m_spatialModeCombo->itemData(i)); - } - groupLayout->addWidget(m_temporalModeCombo, 4, 1); - - layout->addWidget(group); - layout->addStretch(); - - const QVariantMap params = info.parameters(); - m_spatialBinXSpin->setValue(params.value("spatial_bin_x").toInt()); - m_spatialBinYSpin->setValue(params.value("spatial_bin_y").toInt()); - m_temporalBinSpin->setValue(params.value("temporal_bin").toInt()); - - const int spatialModeIndex = m_spatialModeCombo->findData(params.value("spatial_mode").toInt()); - m_spatialModeCombo->setCurrentIndex(spatialModeIndex); - const int temporalModeIndex = m_temporalModeCombo->findData(params.value("temporal_mode").toInt()); - m_temporalModeCombo->setCurrentIndex(temporalModeIndex); - - connect(m_spatialBinXSpin, QOverload::of(&QSpinBox::valueChanged), - this, [this]() { apply(); }); - connect(m_spatialBinYSpin, QOverload::of(&QSpinBox::valueChanged), - this, [this]() { apply(); }); - connect(m_temporalBinSpin, QOverload::of(&QSpinBox::valueChanged), - this, [this]() { apply(); }); - connect(m_spatialModeCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this]() { apply(); }); - connect(m_temporalModeCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this]() { apply(); }); + m_dragMode = DragMode::None; } private: - void apply() + enum class DragMode { - QVariantMap params; - params["spatial_bin_x"] = m_spatialBinXSpin->value(); - params["spatial_bin_y"] = m_spatialBinYSpin->value(); - params["temporal_bin"] = m_temporalBinSpin->value(); - params["spatial_mode"] = m_spatialModeCombo->currentData().toInt(); - params["temporal_mode"] = m_temporalModeCombo->currentData().toInt(); - applyParameters(params); - } - - QSpinBox* m_spatialBinXSpin{nullptr}; - QSpinBox* m_spatialBinYSpin{nullptr}; - QSpinBox* m_temporalBinSpin{nullptr}; - QComboBox* m_spatialModeCombo{nullptr}; - QComboBox* m_temporalModeCombo{nullptr}; - }; + None, + Move, + Resize + }; - class GaussianBlurModuleConfigWidget : public ProcessingModuleConfigWidgetBase - { - public: - GaussianBlurModuleConfigWidget(scopeone::core::ScopeOneCore* core, - int moduleIndex, - const ProcessingModuleInfo& info, - QWidget* parent = nullptr) - : ProcessingModuleConfigWidgetBase(core, moduleIndex, parent) + QPointF toWidget(double x, double y) const { - auto* layout = new QVBoxLayout(this); - auto* group = new QGroupBox("Gaussian Blur Settings", this); - auto* groupLayout = new QGridLayout(group); - - groupLayout->addWidget(new QLabel("Kernel size:", group), 0, 0); - m_kernelSizeSpin = new QSpinBox(group); - m_kernelSizeSpin->setRange(1, 99); - m_kernelSizeSpin->setSingleStep(2); - configureParameterSpinBox(m_kernelSizeSpin); - groupLayout->addWidget(m_kernelSizeSpin, 0, 1); - - groupLayout->addWidget(new QLabel("Sigma:", group), 1, 0); - m_sigmaSpin = new QDoubleSpinBox(group); - m_sigmaSpin->setRange(0.0, 100.0); - m_sigmaSpin->setDecimals(2); - configureParameterSpinBox(m_sigmaSpin); - groupLayout->addWidget(m_sigmaSpin, 1, 1); - - layout->addWidget(group); - layout->addStretch(); - - const QVariantMap params = info.parameters(); - m_kernelSizeSpin->setValue(params.value("kernel_size").toInt()); - m_sigmaSpin->setValue(params.value("sigma").toDouble()); - - connect(m_kernelSizeSpin, QOverload::of(&QSpinBox::valueChanged), - this, [this]() { apply(); }); - connect(m_sigmaSpin, QOverload::of(&QDoubleSpinBox::valueChanged), - this, [this]() { apply(); }); + const QRectF area = plotArea(); + return {area.left() + (x + 0.5) * area.width(), + area.top() + (y + 0.5) * area.height()}; } - private: - void apply() + QRectF plotArea() const { - QVariantMap params; - params["kernel_size"] = m_kernelSizeSpin->value(); - params["sigma"] = m_sigmaSpin->value(); - applyParameters(params); + const qreal side = qMin(width(), height()); + return QRectF((width() - side) / 2.0, + (height() - side) / 2.0, + side, + side); } - QSpinBox* m_kernelSizeSpin{nullptr}; - QDoubleSpinBox* m_sigmaSpin{nullptr}; + QVariantMap m_parameters; + std::function m_parametersChanged; + QPointF m_lastPosition; + DragMode m_dragMode{DragMode::None}; }; - class DifferentialRollingModuleConfigWidget : public ProcessingModuleConfigWidgetBase + void configureSpinBox(QAbstractSpinBox* spinBox) + { + spinBox->setKeyboardTracking(false); + spinBox->setCorrectionMode(QAbstractSpinBox::CorrectToNearestValue); + } + + class ModuleConfigWidget final : public QWidget { public: - DifferentialRollingModuleConfigWidget(scopeone::core::ScopeOneCore* core, - int moduleIndex, - const ProcessingModuleInfo& info, - QWidget* parent = nullptr) - : ProcessingModuleConfigWidgetBase(core, moduleIndex, parent) + ModuleConfigWidget(scopeone::core::ScopeOneCore* core, + int moduleIndex, + const ProcessingModuleInfo& info, + QWidget* parent) + : QWidget(parent), m_core(core), m_moduleIndex(moduleIndex) { auto* layout = new QVBoxLayout(this); - auto* group = new QGroupBox("Differential Rolling Settings", this); - auto* groupLayout = new QGridLayout(group); - groupLayout->addWidget(new QLabel("Batch size:", group), 0, 0); - - m_batchSizeSpin = new QSpinBox(group); - m_batchSizeSpin->setRange(1, 256); - configureParameterSpinBox(m_batchSizeSpin); - const QVariantMap params = info.parameters(); - m_batchSizeSpin->setValue(params.value("batch_size").toInt()); - groupLayout->addWidget(m_batchSizeSpin, 0, 1); - - m_normalizeCheck = new QCheckBox("Normalize by batch_1", group); - m_normalizeCheck->setChecked(params.value("normalize").toBool()); - groupLayout->addWidget(m_normalizeCheck, 1, 0, 1, 2); - - layout->addWidget(group); - layout->addWidget(new QLabel("Preview is zero-centered grayscale around mid-gray.", this)); - - m_resetButton = new QPushButton("Reset Buffer", this); - connect(m_batchSizeSpin, QOverload::of(&QSpinBox::valueChanged), this, [this]() + auto* group = new QGroupBox(info.name(), this); + auto* form = new QFormLayout(group); + if (info.descriptor().id == QStringLiteral("mask")) { - apply(); - }); - connect(m_normalizeCheck, &QCheckBox::toggled, this, [this]() + auto* preview = new MaskPreviewWidget(group); + preview->setParameters(info.parameters()); + preview->setParametersChanged([this, preview](const QVariantMap& parameters) + { + preview->setParameters(parameters); + m_core->setProcessingModuleParameters(m_moduleIndex, parameters); + }); + form->addRow(preview); + m_maskPreview = preview; + } + for (const ProcessingParameterDescriptor& parameter : info.descriptor().parameters) { - apply(); - }); - connect(m_resetButton, &QPushButton::clicked, this, [this]() + QWidget* editor = createEditor(parameter, + info.parameters().value(parameter.key, + parameter.defaultValue), + group); + m_editors.insert(parameter.key, editor); + m_descriptors.insert(parameter.key, parameter); + form->addRow(parameter.name + QLatin1Char(':'), editor); + } + layout->addWidget(group); + + if (info.descriptor().resettable) { - resetModule(); - }); - layout->addWidget(m_resetButton); + auto* resetButton = new QPushButton(tr("Reset State"), this); + connect(resetButton, &QPushButton::clicked, this, [this]() + { + m_core->resetProcessingModuleState(m_moduleIndex); + }); + layout->addWidget(resetButton); + } layout->addStretch(); } - private: - void apply() + void setParameters(const QVariantMap& parameters) { - QVariantMap params; - params["batch_size"] = m_batchSizeSpin->value(); - params["normalize"] = m_normalizeCheck->isChecked(); - applyParameters(params); + for (auto it = m_descriptors.constBegin(); it != m_descriptors.constEnd(); ++it) + { + QWidget* editor = m_editors.value(it.key()); + const QVariant value = parameters.value(it.key(), it->defaultValue); + const QSignalBlocker blocker(editor); + switch (it->type) + { + case ProcessingParameterType::Integer: + qobject_cast(editor)->setValue(value.toInt()); + break; + case ProcessingParameterType::Real: + qobject_cast(editor)->setValue(value.toDouble()); + break; + case ProcessingParameterType::Boolean: + qobject_cast(editor)->setChecked(value.toBool()); + break; + case ProcessingParameterType::Choice: + { + auto* combo = qobject_cast(editor); + combo->setCurrentIndex(combo->findData(value)); + break; + } + } + } + if (m_maskPreview) + { + m_maskPreview->setParameters(parameters); + } } - QSpinBox* m_batchSizeSpin{nullptr}; - QCheckBox* m_normalizeCheck{nullptr}; - QPushButton* m_resetButton{nullptr}; - }; - - class BackgroundCalibrationModuleConfigWidget : public ProcessingModuleConfigWidgetBase - { - public: - BackgroundCalibrationModuleConfigWidget(scopeone::core::ScopeOneCore* core, - int moduleIndex, - const ProcessingModuleInfo& info, - QWidget* parent = nullptr) - : ProcessingModuleConfigWidgetBase(core, moduleIndex, parent) + private: + QWidget* createEditor(const ProcessingParameterDescriptor& descriptor, + const QVariant& value, + QWidget* parent) { - auto* layout = new QVBoxLayout(this); - auto* group = new QGroupBox("Background Calibration Settings", this); - auto* groupLayout = new QGridLayout(group); - - groupLayout->addWidget(new QLabel("Frames:", group), 0, 0); - m_calibrationFramesSpin = new QSpinBox(group); - m_calibrationFramesSpin->setRange(3, 1001); - m_calibrationFramesSpin->setSingleStep(2); - configureParameterSpinBox(m_calibrationFramesSpin); - groupLayout->addWidget(m_calibrationFramesSpin, 0, 1); - - groupLayout->addWidget(new QLabel("Mode:", group), 1, 0); - m_modeCombo = new QComboBox(group); - m_modeCombo->addItem("Snapshot", 0); - m_modeCombo->addItem("Running", 1); - groupLayout->addWidget(m_modeCombo, 1, 1); - - groupLayout->addWidget(new QLabel("Method:", group), 2, 0); - m_methodCombo = new QComboBox(group); - m_methodCombo->addItem("Median", 0); - m_methodCombo->addItem("Mean", 1); - m_methodCombo->addItem("Maximum", 2); - m_methodCombo->addItem("Minimum", 3); - groupLayout->addWidget(m_methodCombo, 2, 1); - - groupLayout->addWidget(new QLabel("Operation:", group), 3, 0); - m_operationCombo = new QComboBox(group); - m_operationCombo->addItem("Subtract", 0); - m_operationCombo->addItem("Add", 1); - m_operationCombo->addItem("Multiply", 2); - m_operationCombo->addItem("Divide", 3); - groupLayout->addWidget(m_operationCombo, 3, 1); - - layout->addWidget(group); - - m_resetButton = new QPushButton("Reset Background", this); - layout->addWidget(m_resetButton); - layout->addStretch(); - - const QVariantMap params = info.parameters(); - int frames = params.value("calibration_frames").toInt(); - if (frames < 3) + switch (descriptor.type) + { + case ProcessingParameterType::Integer: { - frames = 3; + auto* editor = new QSpinBox(parent); + editor->setRange(descriptor.minimum.toInt(), descriptor.maximum.toInt()); + editor->setSingleStep(qMax(1, descriptor.step.toInt())); + editor->setValue(value.toInt()); + configureSpinBox(editor); + connect(editor, QOverload::of(&QSpinBox::valueChanged), + this, [this]() { apply(); }); + return editor; } - if ((frames % 2) == 0) + case ProcessingParameterType::Real: { - ++frames; + auto* editor = new QDoubleSpinBox(parent); + editor->setRange(descriptor.minimum.toDouble(), descriptor.maximum.toDouble()); + editor->setSingleStep(descriptor.step.toDouble()); + editor->setDecimals(descriptor.decimals); + editor->setValue(value.toDouble()); + configureSpinBox(editor); + connect(editor, QOverload::of(&QDoubleSpinBox::valueChanged), + this, [this]() { apply(); }); + return editor; } - m_calibrationFramesSpin->setValue(frames); - const int modeIndex = m_modeCombo->findData(params.value("mode").toInt()); - m_modeCombo->setCurrentIndex(modeIndex); - const int methodIndex = m_methodCombo->findData(params.value("method").toInt()); - m_methodCombo->setCurrentIndex(methodIndex); - const int operationIndex = m_operationCombo->findData(params.value("operation").toInt()); - m_operationCombo->setCurrentIndex(operationIndex); - - connect(m_calibrationFramesSpin, QOverload::of(&QSpinBox::valueChanged), - this, [this]() { apply(); }); - connect(m_modeCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this]() { apply(); }); - connect(m_methodCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this]() { apply(); }); - connect(m_operationCombo, QOverload::of(&QComboBox::currentIndexChanged), - this, [this]() { apply(); }); - connect(m_resetButton, &QPushButton::clicked, this, [this]() - { - resetModule(); - }); + case ProcessingParameterType::Boolean: + { + auto* editor = new QCheckBox(parent); + editor->setChecked(value.toBool()); + connect(editor, &QCheckBox::toggled, this, [this]() { apply(); }); + return editor; + } + case ProcessingParameterType::Choice: + { + auto* editor = new QComboBox(parent); + for (const auto& choice : descriptor.choices) + { + editor->addItem(choice.name, choice.value); + } + editor->setCurrentIndex(editor->findData(value)); + connect(editor, QOverload::of(&QComboBox::currentIndexChanged), + this, [this]() { apply(); }); + return editor; + } + } + return new QWidget(parent); } - private: - void apply() + QVariant editorValue(const QString& key) const { - QVariantMap params; - params["calibration_frames"] = m_calibrationFramesSpin->value(); - params["mode"] = m_modeCombo->currentData().toInt(); - params["method"] = m_methodCombo->currentData().toInt(); - params["operation"] = m_operationCombo->currentData().toInt(); - applyParameters(params); + QWidget* editor = m_editors.value(key); + switch (m_descriptors.value(key).type) + { + case ProcessingParameterType::Integer: + return qobject_cast(editor)->value(); + case ProcessingParameterType::Real: + return qobject_cast(editor)->value(); + case ProcessingParameterType::Boolean: + return qobject_cast(editor)->isChecked(); + case ProcessingParameterType::Choice: + return qobject_cast(editor)->currentData(); + } + return {}; } - QSpinBox* m_calibrationFramesSpin{nullptr}; - QComboBox* m_modeCombo{nullptr}; - QComboBox* m_methodCombo{nullptr}; - QComboBox* m_operationCombo{nullptr}; - QPushButton* m_resetButton{nullptr}; - }; - - QWidget* createConfigWidget(scopeone::core::ScopeOneCore* core, - int moduleIndex, - const ProcessingModuleInfo& info, - QWidget* parent) - { - switch (info.kind()) + void apply() { - case ProcessingModuleKind::FFT: - return new FFTModuleConfigWidget(core, moduleIndex, info, parent); - case ProcessingModuleKind::SpatiotemporalBinning: - return new SpatiotemporalBinningModuleConfigWidget(core, moduleIndex, info, parent); - case ProcessingModuleKind::GaussianBlur: - return new GaussianBlurModuleConfigWidget(core, moduleIndex, info, parent); - case ProcessingModuleKind::DifferentialRolling: - return new DifferentialRollingModuleConfigWidget(core, moduleIndex, info, parent); - case ProcessingModuleKind::BackgroundCalibration: - return new BackgroundCalibrationModuleConfigWidget(core, moduleIndex, info, parent); - case ProcessingModuleKind::Unknown: - break; + QVariantMap parameters; + for (auto it = m_descriptors.constBegin(); it != m_descriptors.constEnd(); ++it) + { + parameters.insert(it.key(), editorValue(it.key())); + } + m_core->setProcessingModuleParameters(m_moduleIndex, parameters); } - qFatal("ImageProcessingWidget received an unsupported processing module kind"); - return nullptr; - } - } // namespace - ImageProcessingWidget::ImageProcessingWidget(scopeone::core::ScopeOneCore* core, QWidget* parent) - : QWidget(parent) - , m_scopeonecore(core) + scopeone::core::ScopeOneCore* m_core; + int m_moduleIndex; + QHash m_editors; + QHash m_descriptors; + MaskPreviewWidget* m_maskPreview{nullptr}; + }; + } + + ImageProcessingWidget::ImageProcessingWidget(scopeone::core::ScopeOneCore* core, + ImageWorkspace* workspace, + QWidget* parent) + : QWidget(parent), m_scopeonecore(core), m_workspace(workspace) { - if (!core) - { - qFatal("ImageProcessingWidget requires ScopeOneCore"); - } - m_processingRunning = m_scopeonecore->isRealTimeProcessingEnabled(); + setAutoFillBackground(true); + setBackgroundRole(QPalette::Window); + m_processingRunning = core->isRealTimeProcessingEnabled(); + setupUI(); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::processingModulesChanged, + connect(core, &scopeone::core::ScopeOneCore::processingModulesChanged, this, [this]() { updateModuleList(); updateConfigWidget(); updateRunButtons(); }); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::processingModuleParametersChanged, + connect(core, &scopeone::core::ScopeOneCore::processingModuleParametersChanged, this, [this](int moduleIndex) { if (moduleIndex == m_moduleList->currentRow()) { - updateConfigWidget(); + const auto modules = m_scopeonecore->processingModules(); + if (moduleIndex >= 0 && moduleIndex < modules.size()) + { + static_cast(m_configStack->currentWidget()) + ->setParameters(modules.at(moduleIndex).parameters()); + } } }); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::processingSettingsChanged, + connect(core, &scopeone::core::ScopeOneCore::processingSettingsChanged, this, [this]() { updateProcessingSettings(); updateRunButtons(); syncProcessingState(); }); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::processingError, + connect(core, &scopeone::core::ScopeOneCore::processingError, this, [](const QString& error) { - qWarning().noquote() << QString("Processing error: %1").arg(error); + qWarning().noquote() << QStringLiteral("Processing error: %1").arg(error); + }); + connect(core, &scopeone::core::ScopeOneCore::hardwareDevicesChanged, + this, &ImageProcessingWidget::refreshSources); + connect(core, &scopeone::core::ScopeOneCore::recordingSessionsChanged, + this, &ImageProcessingWidget::refreshSources); + connect(core->imageSceneModel(), &scopeone::core::ImageSceneModel::layersChanged, + this, &ImageProcessingWidget::refreshSources); + connect(core, &scopeone::core::ScopeOneCore::imageProcessingFinished, + this, [this](quint64 requestId, + const QString& inputSourceId, + const scopeone::core::ImageFrame& frame, + const QString& error) + { + if (requestId != m_offlineProcessingRequestId + || !m_directProcessingRequest) + { + return; + } + finishOfflineProcessing(); + if (!error.isEmpty() || !frame.isValid()) + { + QMessageBox::warning(this, tr("Processing Failed"), error); + return; + } + const QString outputSourceId = QStringLiteral("processed:%1") + .arg(scopeone::core::ScopeOneCore::sourceIdFromLayerKey( + inputSourceId)); + const auto output = m_scopeonecore->publishStaticFrame( + outputSourceId, frame, tr("Processed Image")); + if (output.isValid()) + { + emit processedLayerReady( + scopeone::core::ScopeOneCore::staticLayerKey(outputSourceId)); + } + }); + connect(core, &scopeone::core::ScopeOneCore::stackProcessingProgress, + this, [this](quint64 requestId, qint64 completed, qint64 total) + { + if (requestId == m_offlineProcessingRequestId) + { + m_processingProgress->setMaximum(static_cast(total)); + m_processingProgress->setValue(static_cast(completed)); + } + }); + connect(core, &scopeone::core::ScopeOneCore::stackProcessingFinished, + this, [this](quint64 requestId, + const std::shared_ptr& session, + const QString& error) + { + if (requestId != m_offlineProcessingRequestId + || !m_directProcessingRequest) + { + return; + } + finishOfflineProcessing(); + if (!error.isEmpty() || !session) + { + QMessageBox::warning(this, tr("Processing Failed"), error); + return; + } + emit processedStackReady(session); + refreshSources(); + }); + connect(core, &scopeone::core::ScopeOneCore::layerStackProcessingFinished, + this, [this](quint64 requestId, + const QString& outputLayerKey, + const QString& error) + { + if (requestId != m_offlineProcessingRequestId + || !m_directProcessingRequest) + { + return; + } + finishOfflineProcessing(); + if (!error.isEmpty() || outputLayerKey.isEmpty()) + { + QMessageBox::warning(this, tr("Processing Failed"), error); + return; + } + emit processedLayerReady(outputLayerKey); + refreshSources(); + }); + connect(workspace, &ImageWorkspace::activeDocumentChanged, + this, [this]() + { + refreshSources(); + const int index = m_sourceCombo->findData( + m_workspace->activeDocumentId(), Qt::UserRole + 1); + if (index >= 0) + { + m_sourceCombo->setCurrentIndex(index); + } + }); + connect(workspace, &ImageWorkspace::documentsChanged, + this, &ImageProcessingWidget::refreshSources); + connect(workspace, &ImageWorkspace::documentProcessingProgress, + this, [this](quint64 requestId, qint64 completed, qint64 total) + { + if (!m_directProcessingRequest + && requestId == m_offlineProcessingRequestId) + { + m_processingProgress->setMaximum(static_cast(total)); + m_processingProgress->setValue(static_cast(completed)); + } + }); + connect(workspace, &ImageWorkspace::documentProcessingFinished, + this, [this](quint64 requestId, const QString&, const QString& error) + { + if (m_directProcessingRequest + || requestId != m_offlineProcessingRequestId) + { + return; + } + finishOfflineProcessing(); + if (!error.isEmpty()) + { + QMessageBox::warning(this, tr("Processing Failed"), error); + } }); - setupUI(); updateProcessingSettings(); + refreshSources(); updateModuleList(); updateConfigWidget(); updateRunButtons(); } - // Builds the image processing widget layout void ImageProcessingWidget::setupUI() { auto* mainLayout = new QVBoxLayout(this); - auto* splitter = new QSplitter(Qt::Vertical, this); - - setupRunControls(); - setupModuleList(); - setupModuleConfig(); - - auto* topWidget = new QWidget(this); - auto* topLayout = new QVBoxLayout(topWidget); - topLayout->addWidget(m_runControlsWidget); - topLayout->addWidget(m_moduleList->parentWidget()); - - splitter->addWidget(topWidget); - splitter->addWidget(m_configStack->parentWidget()); - splitter->setStretchFactor(0, 2); - splitter->setStretchFactor(1, 3); + mainLayout->setContentsMargins(0, 0, 0, 0); + auto* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto* content = new QWidget(scrollArea); + content->setAutoFillBackground(true); + content->setBackgroundRole(QPalette::Window); + auto* contentLayout = new QVBoxLayout(content); + contentLayout->setContentsMargins(5, 5, 5, 5); + QWidget* pipelineGroup = setupModuleList(); + QWidget* parametersGroup = setupModuleConfig(); + QWidget* executionGroup = setupExecutionControls(); + QWidget* inputGroup = setupInputControls(); + contentLayout->addWidget(inputGroup); + contentLayout->addWidget(pipelineGroup); + contentLayout->addWidget(parametersGroup, 1); + contentLayout->addWidget(executionGroup); + scrollArea->setWidget(content); + mainLayout->addWidget(scrollArea); + } - mainLayout->addWidget(splitter); + QWidget* ImageProcessingWidget::setupInputControls() + { + auto* inputControlsGroup = new QGroupBox(tr("Pipeline Input"), this); + auto* layout = new QGridLayout(inputControlsGroup); + m_liveModeRadio = new QRadioButton(tr("Live Stream"), inputControlsGroup); + m_staticModeRadio = new QRadioButton(tr("Image / Stack"), inputControlsGroup); + m_liveModeRadio->setChecked(true); + layout->addWidget(m_liveModeRadio, 0, 0); + layout->addWidget(m_staticModeRadio, 0, 1); + m_sourceLabel = new QLabel(inputControlsGroup); + layout->addWidget(m_sourceLabel, 1, 0); + m_liveSourceCombo = new QComboBox(inputControlsGroup); + m_liveSourceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + layout->addWidget(m_liveSourceCombo, 1, 1, 1, 2); + m_sourceCombo = new QComboBox(inputControlsGroup); + m_sourceCombo->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + layout->addWidget(m_sourceCombo, 1, 1, 1, 2); + m_rangeLabel = new QLabel(tr("Range"), inputControlsGroup); + layout->addWidget(m_rangeLabel, 2, 0); + m_offlineScopeCombo = new QComboBox(inputControlsGroup); + m_offlineScopeCombo->addItem(tr("Current frame"), false); + m_offlineScopeCombo->addItem(tr("Entire stack"), true); + layout->addWidget(m_offlineScopeCombo, 2, 1, 1, 2); + layout->setColumnStretch(2, 1); + connect(m_liveModeRadio, &QRadioButton::toggled, + this, &ImageProcessingWidget::onInputModeChanged); + connect(m_staticModeRadio, &QRadioButton::toggled, + this, &ImageProcessingWidget::onInputModeChanged); + connect(m_liveSourceCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, [this]() + { + m_scopeonecore->setRealTimeProcessingSource( + m_liveSourceCombo->currentData().toString()); + updateRunButtons(); + }); + connect(m_sourceCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, [this]() { updateRunButtons(); }); + connect(m_offlineScopeCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, [this]() { updateRunButtons(); }); + onInputModeChanged(); + return inputControlsGroup; } - // Builds processing start stop controls - void ImageProcessingWidget::setupRunControls() + QWidget* ImageProcessingWidget::setupExecutionControls() { - m_runControlsWidget = new QWidget(this); - auto* layout = new QHBoxLayout(m_runControlsWidget); - m_startButton = new QPushButton("Start Processing", m_runControlsWidget); - m_stopButton = new QPushButton("Stop Processing", m_runControlsWidget); - m_processingBitDepthCombo = new QComboBox(m_runControlsWidget); + auto* executionControlsGroup = new QGroupBox(tr("Pipeline Output & Execution"), this); + auto* layout = new QGridLayout(executionControlsGroup); + layout->addWidget(new QLabel(tr("Bit depth"), executionControlsGroup), 0, 0); + m_processingBitDepthCombo = new QComboBox(executionControlsGroup); m_processingBitDepthCombo->addItem( - "8-bit", static_cast(scopeone::core::ScopeOneCore::ProcessingBitDepth::Bit8)); + tr("8-bit"), static_cast(scopeone::core::ProcessingBitDepth::Bit8)); m_processingBitDepthCombo->addItem( - "16-bit", static_cast(scopeone::core::ScopeOneCore::ProcessingBitDepth::Bit16)); - connect(m_startButton, &QPushButton::clicked, this, &ImageProcessingWidget::onStartProcessing); - connect(m_stopButton, &QPushButton::clicked, this, &ImageProcessingWidget::onStopProcessing); + tr("16-bit"), static_cast(scopeone::core::ProcessingBitDepth::Bit16)); connect(m_processingBitDepthCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ImageProcessingWidget::onProcessingBitDepthChanged); + layout->addWidget(m_processingBitDepthCombo, 0, 1); + m_executeButton = new QPushButton(executionControlsGroup); + m_cancelProcessingButton = new QPushButton(tr("Cancel"), executionControlsGroup); + m_cancelProcessingButton->setEnabled(false); + connect(m_executeButton, &QPushButton::clicked, + this, [this]() + { + if (m_liveModeRadio->isChecked()) + { + onLiveProcessingToggled(!m_scopeonecore->isRealTimeProcessingEnabled()); + } + else + { + onRunOfflineProcessing(); + } + }); + connect(m_cancelProcessingButton, &QPushButton::clicked, + this, &ImageProcessingWidget::onCancelProcessing); + layout->addWidget(m_executeButton, 1, 0, 1, 2); + layout->addWidget(m_cancelProcessingButton, 1, 2); + m_processingProgress = new QProgressBar(executionControlsGroup); + m_processingProgress->setVisible(false); + layout->addWidget(m_processingProgress, 2, 0, 1, 3); + return executionControlsGroup; + } - layout->addWidget(m_startButton); - layout->addWidget(m_stopButton); - layout->addWidget(m_processingBitDepthCombo); - layout->addStretch(); + void ImageProcessingWidget::onInputModeChanged() + { + const bool liveMode = m_liveModeRadio->isChecked(); + m_sourceLabel->setText(liveMode ? tr("Target") : tr("Source")); + m_liveSourceCombo->setVisible(liveMode); + m_sourceCombo->setVisible(!liveMode); + m_rangeLabel->setVisible(!liveMode); + m_offlineScopeCombo->setVisible(!liveMode); + updateRunButtons(); } - // Builds the module list and add remove controls - void ImageProcessingWidget::setupModuleList() + QWidget* ImageProcessingWidget::setupModuleList() { - auto* group = new QGroupBox("Processing Modules", this); + auto* group = new QGroupBox(tr("Pipeline"), this); auto* layout = new QVBoxLayout(group); - m_moduleList = new QListWidget(group); connect(m_moduleList, &QListWidget::currentRowChanged, this, &ImageProcessingWidget::onModuleSelectionChanged); + connect(m_moduleList, &QListWidget::itemChanged, + this, &ImageProcessingWidget::onModuleItemChanged); layout->addWidget(m_moduleList); - - auto* controlsLayout = new QHBoxLayout(); + auto* controls = new QHBoxLayout; m_moduleTypeCombo = new QComboBox(group); - m_moduleTypeCombo->addItem("Spatiotemporal Binning", - static_cast(ProcessingModuleKind::SpatiotemporalBinning)); - m_moduleTypeCombo->addItem("Gaussian Blur", static_cast(ProcessingModuleKind::GaussianBlur)); - m_moduleTypeCombo->addItem("FFT", static_cast(ProcessingModuleKind::FFT)); - m_moduleTypeCombo->addItem("Differential Rolling", - static_cast(ProcessingModuleKind::DifferentialRolling)); - m_moduleTypeCombo->addItem("Background Calibration", - static_cast(ProcessingModuleKind::BackgroundCalibration)); - controlsLayout->addWidget(m_moduleTypeCombo); - - m_addModuleButton = new QPushButton("Add", group); + for (const ProcessingModuleDescriptor& descriptor : m_scopeonecore->availableProcessingModules()) + { + m_moduleTypeCombo->addItem(descriptor.name, descriptor.id); + } + controls->addWidget(m_moduleTypeCombo); + m_addModuleButton = new QPushButton(tr("Add"), group); + m_removeModuleButton = new QPushButton(tr("Remove"), group); + m_moveModuleUpButton = new QPushButton(tr("Up"), group); + m_moveModuleDownButton = new QPushButton(tr("Down"), group); + m_addModuleButton->setFixedWidth(52); + m_moveModuleUpButton->setFixedWidth(42); + m_moveModuleDownButton->setFixedWidth(52); + m_removeModuleButton->setFixedWidth(64); connect(m_addModuleButton, &QPushButton::clicked, this, &ImageProcessingWidget::onAddModuleClicked); - controlsLayout->addWidget(m_addModuleButton); - - m_removeModuleButton = new QPushButton("Remove", group); connect(m_removeModuleButton, &QPushButton::clicked, this, &ImageProcessingWidget::onRemoveModuleClicked); - controlsLayout->addWidget(m_removeModuleButton); - - layout->addLayout(controlsLayout); + connect(m_moveModuleUpButton, &QPushButton::clicked, + this, &ImageProcessingWidget::onMoveModuleUpClicked); + connect(m_moveModuleDownButton, &QPushButton::clicked, + this, &ImageProcessingWidget::onMoveModuleDownClicked); + controls->addWidget(m_addModuleButton); + controls->addWidget(m_moveModuleUpButton); + controls->addWidget(m_moveModuleDownButton); + controls->addWidget(m_removeModuleButton); + layout->addLayout(controls); + return group; } - // Builds the module configuration stack - void ImageProcessingWidget::setupModuleConfig() + QWidget* ImageProcessingWidget::setupModuleConfig() { - auto* group = new QGroupBox("Module Configuration", this); + auto* group = new QGroupBox(tr("Step Parameters"), this); auto* layout = new QVBoxLayout(group); - m_configStack = new QStackedWidget(group); m_emptyConfigWidget = new QWidget(m_configStack); auto* emptyLayout = new QVBoxLayout(m_emptyConfigWidget); - emptyLayout->addWidget(new QLabel("Select a module to configure", m_emptyConfigWidget)); + emptyLayout->addWidget(new QLabel(tr("Select a module to configure"), m_emptyConfigWidget)); emptyLayout->addStretch(); - m_configStack->addWidget(m_emptyConfigWidget); layout->addWidget(m_configStack); + return group; } - // Rebuilds the visible module list from core state void ImageProcessingWidget::updateModuleList() { const int currentRow = m_moduleList->currentRow(); - const QList modules = m_scopeonecore->processingModules(); - - const QSignalBlocker moduleListBlocker(m_moduleList); + const auto modules = m_scopeonecore->processingModules(); + const QSignalBlocker blocker(m_moduleList); m_moduleList->clear(); - for (const ProcessingModuleInfo& info : modules) + for (int index = 0; index < modules.size(); ++index) { - m_moduleList->addItem(info.name()); + const ProcessingModuleInfo& module = modules.at(index); + auto* item = new QListWidgetItem( + tr("%1. %2").arg(index + 1).arg(module.name()), m_moduleList); + item->setFlags(item->flags() | Qt::ItemIsUserCheckable); + item->setCheckState(module.enabled() ? Qt::Checked : Qt::Unchecked); + item->setToolTip(module.enabled() + ? tr("Enabled") + : tr("Bypassed")); } if (!modules.isEmpty()) { - const int nextRow = qBound(0, currentRow, m_moduleList->count() - 1); - m_moduleList->setCurrentRow(nextRow); + m_moduleList->setCurrentRow(qBound(0, currentRow, modules.size() - 1)); + } + else + { + m_moduleList->clearSelection(); } } - // Rebuilds the editor for the selected module void ImageProcessingWidget::updateConfigWidget() { while (m_configStack->count() > 1) @@ -651,32 +762,102 @@ namespace scopeone::ui widget->deleteLater(); } m_configStack->setCurrentWidget(m_emptyConfigWidget); - - const int currentRow = m_moduleList->currentRow(); - const QList modules = m_scopeonecore->processingModules(); - if (currentRow < 0 || currentRow >= modules.size()) + const int row = m_moduleList->currentRow(); + const auto modules = m_scopeonecore->processingModules(); + if (row < 0 || row >= modules.size()) { return; } - - QWidget* configWidget = createConfigWidget(m_scopeonecore, currentRow, modules.at(currentRow), m_configStack); - m_configStack->addWidget(configWidget); - m_configStack->setCurrentWidget(configWidget); + auto* editor = new ModuleConfigWidget(m_scopeonecore, row, modules.at(row), m_configStack); + m_configStack->addWidget(editor); + m_configStack->setCurrentWidget(editor); } - // Updates processing controls from running state void ImageProcessingWidget::updateRunButtons() { const bool running = m_scopeonecore->isRealTimeProcessingEnabled(); const bool hasModules = !m_scopeonecore->processingModules().isEmpty(); - m_startButton->setEnabled(!running && hasModules); - m_stopButton->setEnabled(running); - m_processingBitDepthCombo->setEnabled(!running); - m_moduleList->parentWidget()->setEnabled(!running); - m_configStack->parentWidget()->setEnabled(!running); + const bool idle = m_offlineProcessingRequestId == 0; + m_processingBitDepthCombo->setEnabled(!running && idle); + m_moduleList->setEnabled(!running && idle); + m_moduleTypeCombo->setEnabled(!running && idle); + m_addModuleButton->setEnabled(!running && idle); + m_removeModuleButton->setEnabled(!running && idle && m_moduleList->currentRow() >= 0); + m_moveModuleUpButton->setEnabled(!running && idle && m_moduleList->currentRow() > 0); + m_moveModuleDownButton->setEnabled(!running && idle + && m_moduleList->currentRow() >= 0 + && m_moduleList->currentRow() < m_moduleList->count() - 1); + m_configStack->setEnabled(!running && idle); + + const bool liveMode = m_liveModeRadio->isChecked(); + const QString sourceType = m_sourceCombo->currentData(Qt::UserRole).toString(); + const bool hasPipeline = hasModules; + const QString sourceId = m_sourceCombo->currentData(Qt::UserRole + 1).toString(); + const bool hasSource = !sourceType.isEmpty(); + const bool sourceAvailable = hasSource + && (sourceType == QStringLiteral("layer") + || sourceType == QStringLiteral("stack") + || m_workspace->document(sourceId).ready); + const bool canProcessImage = sourceType == QStringLiteral("layer") + || sourceType == QStringLiteral("document") + || sourceType == QStringLiteral("document_stack"); + const int sliceCount = (sourceType == QStringLiteral("layer")) + ? m_scopeonecore->layerSliceCount(sourceId) + : 0; + const bool canProcessStack = sourceType == QStringLiteral("stack") + || sourceType == QStringLiteral("document_stack") + || sliceCount > 1; + if (!canProcessStack && m_offlineScopeCombo->currentData().toBool()) + { + const QSignalBlocker blocker(m_offlineScopeCombo); + m_offlineScopeCombo->setCurrentIndex(0); + } + const bool entireStack = m_offlineScopeCombo->currentData().toBool(); + const bool validScope = entireStack ? canProcessStack : canProcessImage; + const bool canRun = !running && idle && hasPipeline && sourceAvailable && validScope; + m_liveSourceCombo->setEnabled(!running && idle && liveMode); + m_sourceCombo->setEnabled(!running && idle && !liveMode); + m_offlineScopeCombo->setEnabled(!running && idle && !liveMode && canProcessStack); + m_executeButton->setText(liveMode + ? running ? tr("Stop Live Pipeline") + : tr("Start Live Pipeline") + : tr("Run Processing")); + m_executeButton->setEnabled(liveMode + ? idle && (running || hasModules) + : canRun); + QString runDisabledReason; + if (!idle) + { + runDisabledReason = tr("A processing run is already in progress"); + } + else if (liveMode && !running && !hasModules) + { + runDisabledReason = tr("Add at least one pipeline step first"); + } + else if (!liveMode && running) + { + runDisabledReason = tr("Stop live processing before running a static image"); + } + else if (!liveMode && !hasPipeline) + { + runDisabledReason = tr("Add at least one pipeline step first"); + } + else if (!liveMode && !hasSource) + { + runDisabledReason = tr("Select a source to process"); + } + else if (!liveMode && !sourceAvailable) + { + runDisabledReason = tr("The selected source is not available"); + } + else if (!liveMode && !validScope) + { + runDisabledReason = tr("The selected source does not support this range"); + } + m_executeButton->setToolTip(runDisabledReason); + m_cancelProcessingButton->setEnabled(!idle); } - // Emits user facing processing state changes from the shared core state void ImageProcessingWidget::syncProcessingState() { const bool running = m_scopeonecore->isRealTimeProcessingEnabled(); @@ -695,94 +876,241 @@ namespace scopeone::ui } } - // Syncs processing settings from core state void ImageProcessingWidget::updateProcessingSettings() { - const auto currentBitDepth = static_cast(m_scopeonecore->processingBitDepth()); - const int index = m_processingBitDepthCombo->findData(currentBitDepth); - if (index != m_processingBitDepthCombo->currentIndex()) + const int value = static_cast(m_scopeonecore->processingBitDepth()); + const QSignalBlocker blocker(m_processingBitDepthCombo); + m_processingBitDepthCombo->setCurrentIndex(m_processingBitDepthCombo->findData(value)); + const QSignalBlocker sourceBlocker(m_liveSourceCombo); + const int sourceIndex = m_liveSourceCombo->findData( + m_scopeonecore->realTimeProcessingSource()); + m_liveSourceCombo->setCurrentIndex(qMax(0, sourceIndex)); + } + + void ImageProcessingWidget::refreshSources() + { + const QString currentType = m_sourceCombo->currentData(Qt::UserRole).toString(); + const QString currentFirst = m_sourceCombo->currentData(Qt::UserRole + 1).toString(); + const QString currentSecond = m_sourceCombo->currentData(Qt::UserRole + 2).toString(); + const QSignalBlocker blocker(m_sourceCombo); + const QString liveSource = m_scopeonecore->realTimeProcessingSource(); + { + const QSignalBlocker liveBlocker(m_liveSourceCombo); + m_liveSourceCombo->clear(); + m_liveSourceCombo->addItem(tr("All preview cameras"), QString{}); + for (const QString& cameraId : m_scopeonecore->cameraIds()) + { + m_liveSourceCombo->addItem(tr("Preview camera: %1").arg(cameraId), cameraId); + } + const int liveIndex = m_liveSourceCombo->findData(liveSource); + m_liveSourceCombo->setCurrentIndex(qMax(0, liveIndex)); + } + m_sourceCombo->clear(); + + const QString activeDocumentId = m_workspace->activeDocumentId(); + for (const ImageDocumentInfo& document : m_workspace->documents()) + { + const QString sourceType = document.frameCount > 1 + ? QStringLiteral("document_stack") + : QStringLiteral("document"); + m_sourceCombo->addItem( + document.active ? tr("Active document: %1").arg(document.title) + : tr("Document: %1").arg(document.title), + sourceType); + const int index = m_sourceCombo->count() - 1; + m_sourceCombo->setItemData(index, document.id, Qt::UserRole + 1); + if (document.id == activeDocumentId) + { + m_sourceCombo->setCurrentIndex(index); + } + } + + for (const QString& layerKey : m_scopeonecore->imageSceneModel()->layerIds()) + { + scopeone::core::DocumentLayer layer; + if (m_scopeonecore->imageSceneModel()->findLayer(layerKey, layer)) + { + const int slices = m_scopeonecore->layerSliceCount(layerKey); + const QString name = layer.name.isEmpty() ? layerKey : layer.name; + const QString title = slices > 1 ? tr("%1 (%2 slices)").arg(name).arg(slices) : name; + m_sourceCombo->addItem(title, QStringLiteral("layer")); + const int index = m_sourceCombo->count() - 1; + m_sourceCombo->setItemData(index, layerKey, Qt::UserRole + 1); + } + } + for (const QString& sessionId : m_scopeonecore->recordingSessionIds()) { - const QSignalBlocker bitDepthBlocker(m_processingBitDepthCombo); - m_processingBitDepthCombo->setCurrentIndex(index); + const auto session = m_scopeonecore->recordingSession(sessionId); + if (!session) + { + continue; + } + for (const QString& cameraId : session->recordedCameraIds()) + { + if (session->recordedFrameCount(cameraId) <= 0) + { + continue; + } + m_sourceCombo->addItem( + tr("Stack: %1 / %2 (%3 frames)") + .arg(sessionId, cameraId) + .arg(session->recordedFrameCount(cameraId)), + QStringLiteral("stack")); + const int index = m_sourceCombo->count() - 1; + m_sourceCombo->setItemData(index, sessionId, Qt::UserRole + 1); + m_sourceCombo->setItemData(index, cameraId, Qt::UserRole + 2); + } } + for (int index = 0; index < m_sourceCombo->count(); ++index) + { + if (m_sourceCombo->itemData(index, Qt::UserRole).toString() == currentType + && m_sourceCombo->itemData(index, Qt::UserRole + 1).toString() == currentFirst + && m_sourceCombo->itemData(index, Qt::UserRole + 2).toString() == currentSecond) + { + m_sourceCombo->setCurrentIndex(index); + break; + } + } + updateRunButtons(); } - // Adds the selected processing module - void ImageProcessingWidget::onAddModuleClicked() + void ImageProcessingWidget::onRunOfflineProcessing() { - const auto kind = static_cast(m_moduleTypeCombo->currentData().toInt()); - if (!m_scopeonecore->addProcessingModule(kind)) + const QString sourceType = m_sourceCombo->currentData(Qt::UserRole).toString(); + const QString sourceId = m_sourceCombo->currentData(Qt::UserRole + 1).toString(); + const bool entireStack = m_offlineScopeCombo->currentData().toBool(); + m_directProcessingRequest = sourceType == QStringLiteral("layer") + || sourceType == QStringLiteral("stack"); + if (entireStack) { - QMessageBox::warning(this, "Warning", "Failed to add processing module"); + if (sourceType == QStringLiteral("layer")) + { + m_offlineProcessingRequestId = m_scopeonecore->requestLayerStackProcessing(sourceId); + } + else if (m_directProcessingRequest) + { + m_offlineProcessingRequestId = m_scopeonecore->requestRecordingSessionStackProcessing( + m_sourceCombo->currentData(Qt::UserRole + 1).toString(), + m_sourceCombo->currentData(Qt::UserRole + 2).toString()); + } + else + { + m_offlineProcessingRequestId = m_workspace->processDocument(sourceId, true); + } + } + else + { + m_offlineProcessingRequestId = m_directProcessingRequest + ? m_scopeonecore->requestImageProcessing( + m_scopeonecore->graphFrame(sourceId), sourceId) + : m_workspace->processDocument(sourceId, false); + } + if (m_offlineProcessingRequestId == 0) + { + finishOfflineProcessing(); + QMessageBox::warning(this, + tr("Processing Failed"), + entireStack ? tr("No stack is available") + : tr("No image is available")); return; } + m_processingProgress->setRange(0, 0); + m_processingProgress->setVisible(true); + updateRunButtons(); + } - updateModuleList(); - m_moduleList->setCurrentRow(m_moduleList->count() - 1); + void ImageProcessingWidget::onCancelProcessing() + { + m_scopeonecore->cancelProcessingRequest(m_offlineProcessingRequestId); + } + + void ImageProcessingWidget::finishOfflineProcessing() + { + m_offlineProcessingRequestId = 0; + m_directProcessingRequest = false; + m_processingProgress->setVisible(false); updateRunButtons(); } - // Removes the selected processing module - void ImageProcessingWidget::onRemoveModuleClicked() + void ImageProcessingWidget::onAddModuleClicked() { - const int currentRow = m_moduleList->currentRow(); - if (currentRow < 0) + if (!m_scopeonecore->addProcessingModule(m_moduleTypeCombo->currentData().toString())) { - QMessageBox::information(this, "Information", "Please select a module to remove"); + QMessageBox::warning(this, tr("Warning"), tr("Failed to add processing module")); return; } + updateModuleList(); + m_moduleList->setCurrentRow(m_moduleList->count() - 1); + } - if (!m_scopeonecore->removeProcessingModule(currentRow)) + void ImageProcessingWidget::onRemoveModuleClicked() + { + const int row = m_moduleList->currentRow(); + if (row < 0 || !m_scopeonecore->removeProcessingModule(row)) { - QMessageBox::warning(this, "Warning", "Failed to remove processing module"); + QMessageBox::warning(this, tr("Warning"), tr("Failed to remove processing module")); return; } + } - updateModuleList(); + void ImageProcessingWidget::onModuleSelectionChanged() + { updateConfigWidget(); updateRunButtons(); } - // Updates configuration when module selection changes - void ImageProcessingWidget::onModuleSelectionChanged() + void ImageProcessingWidget::onModuleItemChanged(QListWidgetItem* item) { - updateConfigWidget(); + const int row = m_moduleList->row(item); + m_scopeonecore->setProcessingModuleEnabled(row, item->checkState() == Qt::Checked); } - // Applies the selected processing bit depth - void ImageProcessingWidget::onProcessingBitDepthChanged() + void ImageProcessingWidget::onMoveModuleUpClicked() { - const auto bitDepth = static_cast( - m_processingBitDepthCombo->currentData().toInt()); - if (!m_scopeonecore->setProcessingBitDepth(bitDepth)) + const int row = m_moduleList->currentRow(); + if (row <= 0) { - QMessageBox::warning(this, "Warning", "Failed to update processing bit depth"); - updateProcessingSettings(); + return; + } + if (m_scopeonecore->moveProcessingModule(row, row - 1)) + { + m_moduleList->setCurrentRow(row - 1); } } - // Starts real time image processing - void ImageProcessingWidget::onStartProcessing() + void ImageProcessingWidget::onMoveModuleDownClicked() { - if (!m_scopeonecore->setRealTimeProcessingEnabled(true)) + const int row = m_moduleList->currentRow(); + if (row < 0 || row >= m_moduleList->count() - 1) { - QMessageBox::information(this, "Information", "Please add a processing module first"); - updateRunButtons(); return; } - updateRunButtons(); - qInfo().noquote() << "Processing started"; + if (m_scopeonecore->moveProcessingModule(row, row + 1)) + { + m_moduleList->setCurrentRow(row + 1); + } } - // Stops real time image processing - void ImageProcessingWidget::onStopProcessing() + void ImageProcessingWidget::onProcessingBitDepthChanged() { - if (!m_scopeonecore->setRealTimeProcessingEnabled(false)) + const auto depth = static_cast( + m_processingBitDepthCombo->currentData().toInt()); + if (!m_scopeonecore->setProcessingBitDepth(depth)) { - QMessageBox::warning(this, "Warning", "Failed to stop image processing"); + updateProcessingSettings(); + } + } + + void ImageProcessingWidget::onLiveProcessingToggled(bool enabled) + { + if (!m_scopeonecore->setRealTimeProcessingEnabled(enabled)) + { + updateRunButtons(); return; } updateRunButtons(); - qInfo().noquote() << "Processing stopped"; + qInfo().noquote() << (m_scopeonecore->isRealTimeProcessingEnabled() + ? "Processing started" + : "Processing stopped"); } -} // namespace scopeone::ui +} diff --git a/src/ImageProcessingWidget.h b/src/ImageProcessingWidget.h index 3b391ca..fc696f2 100644 --- a/src/ImageProcessingWidget.h +++ b/src/ImageProcessingWidget.h @@ -1,60 +1,90 @@ #pragma once +#include "scopeone/ScopeOneCore.h" + #include +#include class QListWidget; +class QListWidgetItem; +class QCheckBox; class QComboBox; +class QLabel; class QPushButton; +class QProgressBar; +class QRadioButton; class QStackedWidget; -namespace scopeone::core -{ - class ScopeOneCore; -} - namespace scopeone::ui { + class ImageWorkspace; + class ImageProcessingWidget : public QWidget { Q_OBJECT public: - explicit ImageProcessingWidget(scopeone::core::ScopeOneCore* core, QWidget* parent = nullptr); + explicit ImageProcessingWidget(scopeone::core::ScopeOneCore* core, + ImageWorkspace* workspace, + QWidget* parent = nullptr); ~ImageProcessingWidget() override = default; signals: void processingStarted(); void processingStopped(); + void processedLayerReady(const QString& layerKey); + void processedStackReady( + const std::shared_ptr& session); private: void onAddModuleClicked(); void onRemoveModuleClicked(); + void onMoveModuleUpClicked(); + void onMoveModuleDownClicked(); + void onModuleItemChanged(QListWidgetItem* item); void onModuleSelectionChanged(); void onProcessingBitDepthChanged(); - void onStartProcessing(); - void onStopProcessing(); + void onInputModeChanged(); + void onLiveProcessingToggled(bool enabled); + void onRunOfflineProcessing(); + void onCancelProcessing(); void setupUI(); - void setupRunControls(); - void setupModuleList(); - void setupModuleConfig(); + QWidget* setupInputControls(); + QWidget* setupExecutionControls(); + QWidget* setupModuleList(); + QWidget* setupModuleConfig(); void updateProcessingSettings(); void updateModuleList(); void updateConfigWidget(); void updateRunButtons(); void syncProcessingState(); + void refreshSources(); + void finishOfflineProcessing(); scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; + ImageWorkspace* m_workspace{nullptr}; bool m_processingRunning{false}; - QWidget* m_runControlsWidget{nullptr}; - QPushButton* m_startButton{nullptr}; - QPushButton* m_stopButton{nullptr}; + QComboBox* m_sourceCombo{nullptr}; + QRadioButton* m_liveModeRadio{nullptr}; + QRadioButton* m_staticModeRadio{nullptr}; + QLabel* m_sourceLabel{nullptr}; + QLabel* m_rangeLabel{nullptr}; + QPushButton* m_executeButton{nullptr}; + QPushButton* m_cancelProcessingButton{nullptr}; + QProgressBar* m_processingProgress{nullptr}; QComboBox* m_processingBitDepthCombo{nullptr}; + QComboBox* m_liveSourceCombo{nullptr}; + QComboBox* m_offlineScopeCombo{nullptr}; QListWidget* m_moduleList{nullptr}; QPushButton* m_addModuleButton{nullptr}; QPushButton* m_removeModuleButton{nullptr}; + QPushButton* m_moveModuleUpButton{nullptr}; + QPushButton* m_moveModuleDownButton{nullptr}; QComboBox* m_moduleTypeCombo{nullptr}; QStackedWidget* m_configStack{nullptr}; QWidget* m_emptyConfigWidget{nullptr}; + quint64 m_offlineProcessingRequestId{0}; + bool m_directProcessingRequest{false}; }; } diff --git a/src/ImageToolsDialog.cpp b/src/ImageToolsDialog.cpp index 244259b..57b85d0 100644 --- a/src/ImageToolsDialog.cpp +++ b/src/ImageToolsDialog.cpp @@ -1,6 +1,6 @@ #include "ImageToolsDialog.h" -#include "PreviewWidget.h" +#include "ScopeOneToolPlugin.h" #include "scopeone/ImageSceneModel.h" #include "scopeone/ScopeOneCore.h" @@ -114,18 +114,12 @@ namespace scopeone::ui } // Create a stage driven mosaic tool - StageMosaicDialog::StageMosaicDialog(scopeone::core::ScopeOneCore* core, - PreviewWidget* previewWidget, + StageMosaicDialog::StageMosaicDialog(ScopeOneToolContext& context, QWidget* parent) : QDialog(parent) - , m_core(core) - , m_previewWidget(previewWidget) + , m_core(&context.core()) + , m_context(context) { - if (!core || !previewWidget) - { - qFatal("StageMosaicDialog requires ScopeOneCore and PreviewWidget"); - } - setWindowTitle(tr("Stage Mosaic")); setupUI(); refreshDevices(); @@ -135,11 +129,28 @@ namespace scopeone::ui m_statusLabel->setText(message); }); connect(m_core, &ScopeOneCore::stageMosaicFinished, - this, [this](const std::shared_ptr&, - const QString&, + this, [this](const std::shared_ptr& session, + const QString& message, bool) { setMosaicRunning(false); + if (!session) + { + m_context.showToolStatus(message, 8000); + return; + } + m_context.presentSession( + session, tr("Stage Mosaic %1").arg(session->cameraIds().value(0))); + m_core->removeStaticFrame(QStringLiteral("stage_mosaic")); + }); + connect(m_core, &ScopeOneCore::stageMosaicFrameUpdated, + this, [this](const scopeone::core::ImageFrame&) + { + const QString layerKey = ScopeOneCore::staticLayerKey( + QStringLiteral("stage_mosaic")); + m_core->imageSceneModel()->setLayerColormap(layerKey, QStringLiteral("Gray")); + m_core->imageSceneModel()->setLayerBlending(layerKey, QStringLiteral("Opaque")); + m_context.showLayers({layerKey}); }); const ScopeOneCore::StageMosaicStatus status = m_core->stageMosaicStatus(); if (status.state == ScopeOneCore::StageMosaicState::Running) @@ -262,13 +273,13 @@ namespace scopeone::ui // Start a grid mosaic from the current stage position void StageMosaicDialog::startMosaic() { - m_activeCameraId = selectedCameraId(); + const QString activeCameraId = selectedCameraId(); ScopeOneCore::StageMosaicPlan plan; - plan.cameraId = m_activeCameraId; + plan.cameraId = activeCameraId; plan.xyStageId = selectedStageId(); plan.rows = m_rowsSpinBox->value(); plan.columns = m_columnsSpinBox->value(); - plan.pixelSizeUm = m_core->cameraPixelSizeUm(m_activeCameraId); + plan.pixelSizeUm = m_core->cameraPixelSizeUm(activeCameraId); plan.stepXUm = m_stepXSpinBox->value(); plan.stepYUm = m_stepYSpinBox->value(); plan.settleMs = m_settleMsSpinBox->value(); @@ -293,9 +304,7 @@ namespace scopeone::ui } setMosaicRunning(true); m_statusLabel->setText(tr("Starting mosaic capture")); - m_core->imageSceneModel()->setVisibleLayers( - {scopeone::core::ScopeOneCore::rawLayerKey(m_activeCameraId)}); - m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); + m_context.showLayers({scopeone::core::ScopeOneCore::rawLayerKey(activeCameraId)}); } // Request cancellation after the current stage move @@ -319,18 +328,12 @@ namespace scopeone::ui } // Create a particle detection tool - ParticleDetectionDialog::ParticleDetectionDialog(scopeone::core::ScopeOneCore* core, - PreviewWidget* previewWidget, + ParticleDetectionDialog::ParticleDetectionDialog(ScopeOneToolContext& context, QWidget* parent) : QDialog(parent) - , m_core(core) - , m_previewWidget(previewWidget) + , m_core(&context.core()) + , m_context(context) { - if (!core || !previewWidget) - { - qFatal("ParticleDetectionDialog requires ScopeOneCore and PreviewWidget"); - } - setWindowTitle(tr("Particle Detection")); setupUI(); connect(m_core, &ScopeOneCore::particleDetectionFinished, @@ -534,9 +537,8 @@ namespace scopeone::ui m_core->imageSceneModel()->setLayerBlending(maskLayer, QStringLiteral("Additive")); m_core->imageSceneModel()->setLayerVisible( scopeone::core::ScopeOneCore::rawLayerKey(cameraId), true); - m_core->imageSceneModel()->setVisibleLayers( + m_context.showLayers( {scopeone::core::ScopeOneCore::rawLayerKey(cameraId), maskLayer}); - m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); m_statusLabel->setText( result.truncated ? tr("Detected at least %1 particle(s)").arg(result.particles.size()) diff --git a/src/ImageToolsDialog.h b/src/ImageToolsDialog.h index f4cefb7..adae22e 100644 --- a/src/ImageToolsDialog.h +++ b/src/ImageToolsDialog.h @@ -15,7 +15,7 @@ class QTimer; namespace scopeone::ui { - class PreviewWidget; + class ScopeOneToolContext; class CameraScaleDialog : public QDialog { @@ -40,8 +40,7 @@ namespace scopeone::ui Q_OBJECT public: - StageMosaicDialog(scopeone::core::ScopeOneCore* core, - PreviewWidget* previewWidget, + StageMosaicDialog(ScopeOneToolContext& context, QWidget* parent = nullptr); void reject() override; @@ -56,7 +55,7 @@ namespace scopeone::ui QString selectedStageId() const; scopeone::core::ScopeOneCore* m_core{nullptr}; - PreviewWidget* m_previewWidget{nullptr}; + ScopeOneToolContext& m_context; QComboBox* m_cameraCombo{nullptr}; QComboBox* m_stageCombo{nullptr}; QSpinBox* m_rowsSpinBox{nullptr}; @@ -68,7 +67,6 @@ namespace scopeone::ui QPushButton* m_startButton{nullptr}; QPushButton* m_stopButton{nullptr}; QLabel* m_statusLabel{nullptr}; - QString m_activeCameraId; }; class ParticleDetectionDialog : public QDialog @@ -76,8 +74,7 @@ namespace scopeone::ui Q_OBJECT public: - ParticleDetectionDialog(scopeone::core::ScopeOneCore* core, - PreviewWidget* previewWidget, + ParticleDetectionDialog(ScopeOneToolContext& context, QWidget* parent = nullptr); void reject() override; @@ -95,7 +92,7 @@ namespace scopeone::ui QString selectedCameraId() const; scopeone::core::ScopeOneCore* m_core{nullptr}; - PreviewWidget* m_previewWidget{nullptr}; + ScopeOneToolContext& m_context; QComboBox* m_cameraCombo{nullptr}; QSpinBox* m_thresholdSpinBox{nullptr}; QSpinBox* m_minAreaSpinBox{nullptr}; diff --git a/src/ImageWorkspace.cpp b/src/ImageWorkspace.cpp new file mode 100644 index 0000000..5c4e3e6 --- /dev/null +++ b/src/ImageWorkspace.cpp @@ -0,0 +1,1734 @@ +#include "ImageWorkspace.h" + +#include "PreviewWidget.h" +#include "scopeone/ImageSceneModel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::ui +{ + namespace + { + using RecordingSessionData = scopeone::core::ScopeOneCore::RecordingSessionData; + + class ImageDocumentPage final : public QWidget + { + Q_OBJECT + + public: + ImageDocumentPage(const QString& documentId, + const QString& title, + const QString& cameraId, + const std::shared_ptr& session, + const scopeone::core::ExperimentDocument& presentation, + int frameCount, + QWidget* parent) + : QWidget(parent), m_documentId(documentId), m_sourceId(cameraId) + { + auto* scene = new scopeone::core::ImageSceneModel(this); + scopeone::core::DocumentLayer layer; + auto sourceLayer = std::find_if( + presentation.layers.cbegin(), presentation.layers.cend(), + [&cameraId](const scopeone::core::DocumentLayer& candidate) + { + return candidate.sourceId == cameraId + && candidate.kind == scopeone::core::DocumentLayerKind::Raw; + }); + if (sourceLayer == presentation.layers.cend()) + { + sourceLayer = std::find_if( + presentation.layers.cbegin(), presentation.layers.cend(), + [&cameraId](const scopeone::core::DocumentLayer& candidate) + { + return candidate.sourceId == cameraId; + }); + } + if (sourceLayer != presentation.layers.cend()) + { + layer = *sourceLayer; + } + layer.id = scopeone::core::ScopeOneCore::staticLayerKey(cameraId); + layer.sourceId = cameraId; + layer.name = title; + layer.kind = scopeone::core::DocumentLayerKind::Gallery; + layer.display.visible = true; + scene->ensureLayer(layer); + scene->setVisibleLayers({layer.id}); + if (sourceLayer != presentation.layers.cend()) + { + for (const auto& savedMarkup : presentation.markups) + { + if (savedMarkup.layerId != sourceLayer->id) + { + continue; + } + const QString markupId = + savedMarkup.type == scopeone::core::DocumentMarkupType::Line + ? scene->createLine(layer.id, + savedMarkup.start.toPoint(), + savedMarkup.end.toPoint(), + savedMarkup.label, + savedMarkup.role) + : scene->createRect(layer.id, + savedMarkup.rect.toRect(), + savedMarkup.label, + savedMarkup.role); + scene->setVisible(markupId, savedMarkup.visible); + scene->setSelected(markupId, savedMarkup.selected); + } + } + + auto* layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + m_preview = new PreviewWidget(scene, this); + m_preview->setPixelSizeCallback([session, cameraId](const QString&) + { + return session ? session->cameraPixelSizeUm(cameraId) : 0.0; + }); + layout->addWidget(m_preview, 1); + + auto* navigation = new QWidget(this); + auto* navigationLayout = new QHBoxLayout(navigation); + navigationLayout->setContentsMargins(8, 4, 8, 4); + m_slider = new QSlider(Qt::Horizontal, navigation); + m_slider->setRange(0, qMax(0, frameCount - 1)); + m_frameLabel = new QLabel(navigation); + m_frameLabel->setMinimumWidth(90); + m_frameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + navigationLayout->addWidget(m_slider, 1); + navigationLayout->addWidget(m_frameLabel); + navigation->setVisible(frameCount > 1); + layout->addWidget(navigation); + updateFrameLabel(0, frameCount); + + connect(m_slider, &QSlider::valueChanged, this, + [this, frameCount](int index) + { + updateFrameLabel(index, frameCount); + emit frameIndexRequested(m_documentId, index); + }); + } + + void showFrame(const scopeone::core::ImageFrame& frame) + { + scopeone::core::ImageFrame displayFrame(frame); + displayFrame.cameraId = m_sourceId; + sceneModel()->updateLayerFrame( + scopeone::core::ScopeOneCore::staticLayerKey(m_sourceId), displayFrame); + m_preview->setGraphStaticLayerFrame(m_sourceId, displayFrame); + } + + scopeone::core::ImageSceneModel* sceneModel() const + { + return m_preview->sceneModel(); + } + + PreviewWidget* previewWidget() const + { + return m_preview; + } + + void setFrameIndex(int index) + { + const QSignalBlocker blocker(m_slider); + m_slider->setValue(index); + updateFrameLabel(index, m_slider->maximum() + 1); + } + + signals: + void frameIndexRequested(const QString& documentId, int frameIndex); + + private: + void updateFrameLabel(int index, int count) + { + m_frameLabel->setText(QStringLiteral("%1 / %2").arg(index + 1).arg(count)); + } + + QString m_documentId; + QString m_sourceId; + PreviewWidget* m_preview{nullptr}; + QSlider* m_slider{nullptr}; + QLabel* m_frameLabel{nullptr}; + }; + + QString defaultTitle(const RecordingSessionData& session, const QString& cameraId) + { + const QString baseName = session.capturePlan().baseName.trimmed(); + return baseName.isEmpty() ? cameraId : baseName + QStringLiteral(" - ") + cameraId; + } + + QString compactViewerTitle(const QString& title) + { + static const QRegularExpression timestampPattern( + QStringLiteral("(\\d{8})_(\\d{6})")); + const QRegularExpressionMatch firstMatch = timestampPattern.match(title); + if (!firstMatch.hasMatch()) + { + return title; + } + const QDateTime timestamp = QDateTime::fromString( + firstMatch.captured(1) + QStringLiteral("_") + firstMatch.captured(2), + QStringLiteral("yyyyMMdd_HHmmss")); + if (!timestamp.isValid()) + { + return title; + } + QString compact = title.left(firstMatch.capturedStart()) + + timestamp.toString(QStringLiteral("MM-dd HH:mm:ss")) + + title.mid(firstMatch.capturedEnd()); + + QRegularExpressionMatchIterator iterator = timestampPattern.globalMatch(compact); + QList> duplicateRanges; + while (iterator.hasNext()) + { + const QRegularExpressionMatch duplicate = iterator.next(); + duplicateRanges.append({duplicate.capturedStart(), duplicate.capturedLength()}); + } + for (auto it = duplicateRanges.crbegin(); it != duplicateRanges.crend(); ++it) + { + compact.remove(it->first, it->second); + } + compact.remove(QRegularExpression(QStringLiteral("\\s*[-|_]\\s*$"))); + return compact.trimmed(); + } + } + + struct ImageWorkspace::Document + { + QString id; + QString title; + std::shared_ptr session; + QString cameraId; + int frameIndex{0}; + int frameCount{0}; + int requestedFrameIndex{0}; + quint64 frameRequestId{0}; + scopeone::core::ImageFrame currentFrame; + QString activeLayerKey; + QPointer page; + }; + + ImageWorkspace::ImageWorkspace(scopeone::core::ScopeOneCore* core, + QWidget* windowParent, + QObject* parent) + : QObject(parent), m_core(core) + { + m_viewerHost = new QWidget(windowParent); + auto* hostLayout = new QVBoxLayout(m_viewerHost); + hostLayout->setContentsMargins(0, 0, 0, 0); + hostLayout->setSpacing(0); + + m_viewerToolbar = new QToolBar(tr("Viewer"), m_viewerHost); + m_viewerToolbar->setMovable(false); + m_viewerToolbar->setFloatable(false); + m_viewerToolbar->setToolButtonStyle(Qt::ToolButtonTextOnly); + hostLayout->addWidget(m_viewerToolbar); + + m_viewerStack = new QStackedWidget(m_viewerHost); + m_viewerTabs = new QTabWidget(m_viewerStack); + m_viewerTabs->setTabsClosable(true); + m_viewerTabs->setDocumentMode(true); + m_viewerStack->addWidget(m_viewerTabs); + + m_compareWidget = new QWidget(m_viewerStack); + auto* compareLayout = new QHBoxLayout(m_compareWidget); + compareLayout->setContentsMargins(0, 0, 0, 0); + auto* compareSplitter = new QSplitter(Qt::Horizontal, m_compareWidget); + m_compareLeftHost = new QGroupBox(m_compareWidget); + m_compareLeftHost->setLayout(new QVBoxLayout); + m_compareLeftHost->layout()->setContentsMargins(4, 4, 4, 4); + m_compareRightHost = new QGroupBox(m_compareWidget); + m_compareRightHost->setLayout(new QVBoxLayout); + m_compareRightHost->layout()->setContentsMargins(4, 4, 4, 4); + compareSplitter->addWidget(m_compareLeftHost); + compareSplitter->addWidget(m_compareRightHost); + compareSplitter->setSizes({1, 1}); + compareLayout->addWidget(compareSplitter); + m_viewerStack->addWidget(m_compareWidget); + hostLayout->addWidget(m_viewerStack, 1); + + setupViewerToolbar(); + const int viewerToolbarHeight = m_viewerToolbar->sizeHint().height(); + m_viewerToolbar->setMinimumHeight(viewerToolbarHeight); + m_viewerToolbar->setMaximumHeight(viewerToolbarHeight); + connect(m_viewerTabs, &QTabWidget::currentChanged, + this, [this](int index) + { + if (index == m_liveTabIndex) + { + activateLiveViewer(); + return; + } + if (Document* document = findDocumentByPage(m_viewerTabs->widget(index))) + { + if (m_activeDocumentId != document->id) + { + setActiveDocument(document->id); + } + updateViewerToolbar(); + } + }); + connect(m_viewerTabs, &QTabWidget::tabCloseRequested, + this, [this](int index) + { + if (index == m_liveTabIndex) + { + return; + } + if (Document* document = findDocumentByPage(m_viewerTabs->widget(index))) + { + closeDocument(document->id); + } + }); + + connect(core, &scopeone::core::ScopeOneCore::recordingSessionFrameReady, + this, [this](quint64 requestId, + const std::shared_ptr&, + const QString&, + int frameIndex, + const scopeone::core::ImageFrame& frame) + { + const QString documentId = m_frameRequests.take(requestId); + Document* document = findDocument(documentId); + if (!document || document->frameRequestId != requestId) + { + return; + } + document->frameRequestId = 0; + if (!frame.isValid() && !document->currentFrame.isValid()) + { + closeDocument(documentId); + return; + } + if (frame.isValid() && frameIndex == document->requestedFrameIndex) + { + document->frameIndex = frameIndex; + document->currentFrame = frame; + document->page->setFrameIndex(frameIndex); + document->page->showFrame(frame); + if (document->id == m_activeDocumentId) + { + emit activeFrameChanged(); + updateLineProfile(*document); + } + emit documentsChanged(); + } + if (document->frameIndex != document->requestedFrameIndex) + { + if (!requestFrame(*document, document->requestedFrameIndex)) + { + document->page->setFrameIndex(document->frameIndex); + } + } + }); + connect(core, &scopeone::core::ScopeOneCore::recordingSessionClosed, + this, [this](const QString& sessionId) + { + QStringList documentIds; + for (const auto& document : m_documents) + { + if (document->session->capturePlan().experimentId == sessionId) + { + documentIds.append(document->id); + } + } + for (const QString& documentId : documentIds) + { + closeDocument(documentId); + } + }); + connect(core, &scopeone::core::ScopeOneCore::imageProcessingFinished, + this, [this](quint64 requestId, + const QString&, + const scopeone::core::ImageFrame& frame, + const QString& errorMessage) + { + const QString sourceId = m_processingRequests.take(requestId); + if (sourceId.isEmpty()) + { + return; + } + const Document* source = findDocument(sourceId); + const QString resultError = !source && errorMessage.isEmpty() + ? tr("Source image viewer was closed") + : errorMessage; + QString outputId; + if (resultError.isEmpty() && frame.isValid()) + { + outputId = openFrame(frame, + tr("Processed %1").arg(source->title)); + } + emit documentProcessingFinished(requestId, outputId, resultError); + }); + connect(core, &scopeone::core::ScopeOneCore::stackProcessingProgress, + this, [this](quint64 requestId, qint64 completed, qint64 total) + { + if (m_processingRequests.contains(requestId)) + { + emit documentProcessingProgress(requestId, completed, total); + } + }); + connect(core, &scopeone::core::ScopeOneCore::stackProcessingFinished, + this, [this](quint64 requestId, + const std::shared_ptr& session, + const QString& errorMessage) + { + const QString sourceId = m_processingRequests.take(requestId); + if (sourceId.isEmpty()) + { + return; + } + const QString resultError = !findDocument(sourceId) && errorMessage.isEmpty() + ? tr("Source image viewer was closed") + : errorMessage; + QString outputId; + if (resultError.isEmpty() && session) + { + const QStringList ids = openSession(session, tr("Processed Stack")); + outputId = ids.value(0); + emit sessionAvailable(session, tr("Processed Stack")); + } + emit documentProcessingFinished(requestId, outputId, resultError); + }); + connect(core, &scopeone::core::ScopeOneCore::recordingSessionCameraSaveFinished, + this, [this](const std::shared_ptr& session, + const QString& cameraId, + bool success, + const QString& message) + { + const QString key = session + ? session->capturePlan().experimentId + + QLatin1Char('\n') + cameraId + : QString{}; + const QString documentId = m_saveRequests.take(key); + if (!documentId.isEmpty()) + { + emit documentSaveFinished(documentId, success, message); + } + }); + } + + ImageWorkspace::~ImageWorkspace() = default; + + QWidget* ImageWorkspace::viewerHost() const + { + return m_viewerHost; + } + + // Builds the compact controls used by the active image viewer + void ImageWorkspace::setupViewerToolbar() + { + m_fitToWindowAction = m_viewerToolbar->addAction(tr("Fit")); + m_fitToWindowAction->setCheckable(true); + m_fitToWindowAction->setToolTip(tr("Fit the image to the viewer")); + connect(m_fitToWindowAction, &QAction::toggled, this, + [this](bool enabled) + { + if (PreviewWidget* preview = activePreviewWidget()) + { + preview->setFitToWindow(enabled); + } + updateViewerToolbar(); + }); + + m_oneToOneAction = m_viewerToolbar->addAction(tr("1:1")); + m_oneToOneAction->setToolTip(tr("Show the image at native pixel size")); + connect(m_oneToOneAction, &QAction::triggered, this, + [this]() + { + if (PreviewWidget* preview = activePreviewWidget()) + { + preview->setFitToWindow(false); + preview->setZoomPercent(100); + } + }); + + m_zoomCombo = new QComboBox(m_viewerToolbar); + m_zoomCombo->setEditable(true); + m_zoomCombo->setMinimumWidth(76); + m_zoomCombo->setInsertPolicy(QComboBox::NoInsert); + m_zoomCombo->addItem(tr("Fit")); + m_zoomCombo->addItems({QStringLiteral("25%"), + QStringLiteral("50%"), + QStringLiteral("75%"), + QStringLiteral("100%"), + QStringLiteral("150%"), + QStringLiteral("200%"), + QStringLiteral("300%"), + QStringLiteral("400%"), + QStringLiteral("800%")}); + m_zoomCombo->setToolTip(tr("Choose the viewport zoom percentage")); + m_viewerToolbar->addWidget(m_zoomCombo); + auto applyZoomText = [this]() + { + PreviewWidget* preview = activePreviewWidget(); + if (!preview) + { + return; + } + const QString text = m_zoomCombo->currentText().trimmed(); + if (text.compare(QStringLiteral("Fit"), Qt::CaseInsensitive) == 0) + { + preview->setFitToWindow(true); + return; + } + + QString numericText = text; + if (numericText.endsWith(QLatin1Char('%'))) + { + numericText.chop(1); + } + bool ok = false; + const int percent = numericText.toInt(&ok); + if (ok) + { + preview->setFitToWindow(false); + preview->setZoomPercent(percent); + } + updateViewerToolbar(); + }; + connect(m_zoomCombo, qOverload(&QComboBox::activated), this, + [applyZoomText](int) { applyZoomText(); }); + connect(m_zoomCombo->lineEdit(), &QLineEdit::editingFinished, + this, applyZoomText); + + m_viewerToolbar->addSeparator(); + m_layoutCombo = new QComboBox(m_viewerToolbar); + m_layoutCombo->addItem(tr("Grid View (G)")); + m_layoutCombo->addItem(tr("Overlay (G)")); + m_layoutCombo->setToolTip(tr("Choose how multiple image layers are arranged")); + m_viewerToolbar->addWidget(m_layoutCombo); + connect(m_layoutCombo, qOverload(&QComboBox::currentIndexChanged), this, + [this](int index) + { + if (PreviewWidget* preview = activePreviewWidget()) + { + preview->setLayerLayoutMode( + index == 1 ? PreviewWidget::LayerLayoutMode::Overlay + : PreviewWidget::LayerLayoutMode::SideBySide); + } + }); + + m_viewerToolbar->addSeparator(); + m_dimensionAction = m_viewerToolbar->addAction(tr("3D Surface")); + m_dimensionAction->setCheckable(true); + m_dimensionAction->setToolTip(tr("Switch between flat 2D and 3D surface view")); + connect(m_dimensionAction, &QAction::toggled, this, + [this](bool enabled) + { + if (PreviewWidget* preview = activePreviewWidget()) + { + preview->setViewDimensionMode( + enabled ? PreviewWidget::ViewDimensionMode::ThreeDimensional + : PreviewWidget::ViewDimensionMode::TwoDimensional); + } + updateViewerToolbar(); + }); + + m_reset3dAction = m_viewerToolbar->addAction(tr("Reset 3D")); + m_reset3dAction->setToolTip(tr("Reset the 3D camera view")); + connect(m_reset3dAction, &QAction::triggered, this, + [this]() + { + if (PreviewWidget* preview = activePreviewWidget()) + { + preview->reset3dCamera(); + } + }); + + m_compareSeparator = m_viewerToolbar->addSeparator(); + m_compareAction = m_viewerToolbar->addAction(tr("Compare")); + m_compareAction->setCheckable(true); + m_compareAction->setToolTip(tr("Show two image documents side by side")); + connect(m_compareAction, &QAction::toggled, this, + [this](bool enabled) + { + if (enabled) + { + if (!beginComparison()) + { + const QSignalBlocker blocker(m_compareAction); + m_compareAction->setChecked(false); + } + } + else + { + endComparison(); + } + }); + + m_compareDocumentCombo = new QComboBox(m_viewerToolbar); + m_compareDocumentCombo->setMinimumWidth(180); + m_compareDocumentCombo->setToolTip(tr("Choose the document shown on the right")); + m_compareDocumentCombo->setVisible(false); + m_viewerToolbar->addWidget(m_compareDocumentCombo); + connect(m_compareDocumentCombo, qOverload(&QComboBox::currentIndexChanged), this, + [this](int index) + { + if (index < 0) + { + return; + } + QString rightId = m_compareDocumentCombo->itemData(index).toString(); + const QString leftId = comparisonActive() + ? m_compareLeftDocumentId + : m_activeDocumentId; + if (rightId == QStringLiteral("duplicate")) + { + rightId = duplicateDocument(leftId); + } + if (rightId.isEmpty() + || rightId == leftId + || (comparisonActive() && rightId == m_compareRightDocumentId)) + { + return; + } + if (comparisonActive()) + { + endComparison(); + } + setActiveDocument(leftId); + { + const QSignalBlocker blocker(m_compareAction); + m_compareAction->setChecked(true); + } + if (!beginComparison(rightId)) + { + const QSignalBlocker blocker(m_compareAction); + m_compareAction->setChecked(false); + } + }); + + m_linkFramesAction = m_viewerToolbar->addAction(tr("Link Frames")); + m_linkFramesAction->setCheckable(true); + m_linkFramesAction->setToolTip(tr("Keep both compared sequences on the same frame number")); + m_linkFramesAction->setVisible(false); + } + + // Refreshes viewer controls when the active document or display state changes + void ImageWorkspace::updateViewerToolbar() + { + PreviewWidget* preview = activePreviewWidget(); + const ImageDocumentInfo activeDocument = document(); + m_fitToWindowAction->setEnabled(preview != nullptr); + m_oneToOneAction->setEnabled(preview != nullptr); + m_zoomCombo->setEnabled(preview != nullptr); + m_dimensionAction->setEnabled(preview != nullptr); + const bool staticDocument = activeDocument.isValid(); + m_compareSeparator->setVisible(staticDocument); + m_compareAction->setVisible(staticDocument); + m_compareAction->setEnabled(staticDocument); + m_compareDocumentCombo->setVisible(staticDocument); + m_compareDocumentCombo->setEnabled(staticDocument); + m_linkFramesAction->setVisible(staticDocument && comparisonActive()); + m_reset3dAction->setEnabled( + preview && preview->viewDimensionMode() == PreviewWidget::ViewDimensionMode::ThreeDimensional); + if (preview) + { + { + const QSignalBlocker blocker(m_fitToWindowAction); + m_fitToWindowAction->setChecked(preview->isFitToWindow()); + } + { + const QSignalBlocker blocker(m_zoomCombo); + m_zoomCombo->setEditText( + preview->isFitToWindow() + ? tr("Fit") + : QStringLiteral("%1%").arg(preview->zoomPercent())); + } + { + const QSignalBlocker blocker(m_layoutCombo); + m_layoutCombo->setCurrentIndex( + preview->layerLayoutMode() == PreviewWidget::LayerLayoutMode::Overlay ? 1 : 0); + } + { + const QSignalBlocker blocker(m_dimensionAction); + m_dimensionAction->setChecked( + preview->viewDimensionMode() == PreviewWidget::ViewDimensionMode::ThreeDimensional); + } + } + + { + const QSignalBlocker blocker(m_compareDocumentCombo); + m_compareDocumentCombo->clear(); + const QString leftDocumentId = comparisonActive() + ? m_compareLeftDocumentId + : m_activeDocumentId; + if (!leftDocumentId.isEmpty()) + { + const Document* left = findDocument(leftDocumentId); + m_compareDocumentCombo->addItem( + left ? tr("Same sequence, independent frame") : tr("Duplicate current document"), + QStringLiteral("duplicate")); + for (const auto& candidate : m_documents) + { + if (candidate->id != leftDocumentId) + { + m_compareDocumentCombo->addItem(candidate->title, candidate->id); + } + } + const int rightIndex = m_compareDocumentCombo->findData( + comparisonActive() ? m_compareRightDocumentId : QStringLiteral("duplicate")); + m_compareDocumentCombo->setCurrentIndex(rightIndex >= 0 ? rightIndex : 0); + } + } + } + + void ImageWorkspace::setLiveViewer(PreviewWidget* previewWidget) + { + m_livePreviewWidget = previewWidget; + connectViewer(previewWidget, {}); + m_liveLayerKey = previewWidget->visibleLayerKeys().value(0); + if (m_liveTabIndex < 0) + { + m_liveTabIndex = m_viewerTabs->addTab(previewWidget, tr("Live Preview")); + m_viewerTabs->tabBar()->setTabButton( + m_liveTabIndex, QTabBar::RightSide, nullptr); + m_viewerTabs->setCurrentIndex(m_liveTabIndex); + } + updateViewerToolbar(); + } + + void ImageWorkspace::activateLiveViewer() + { + if (!m_livePreviewWidget) + { + return; + } + if (comparisonActive()) + { + endComparison(); + } + m_viewerTabs->setCurrentIndex(m_liveTabIndex); + if (m_activeDocumentId.isEmpty()) + { + return; + } + m_activeDocumentId.clear(); + emit activeDocumentChanged({}); + emit activeViewerChanged(); + emit activeLayerChanged(activeLayerKey()); + emit documentsChanged(); + updateViewerToolbar(); + } + + void ImageWorkspace::setVisibleLayers(const QStringList& layerKeys, bool sideBySide) + { + scopeone::core::ImageSceneModel* scene = activeSceneModel(); + PreviewWidget* preview = activePreviewWidget(); + if (!scene || !preview) + { + return; + } + scene->setVisibleLayers(layerKeys); + preview->setLayerLayoutMode(sideBySide + ? PreviewWidget::LayerLayoutMode::SideBySide + : PreviewWidget::LayerLayoutMode::Overlay); + } + + QStringList ImageWorkspace::openSession(const std::shared_ptr& session, + const QString& title, + const QString& cameraId) + { + if (!session) + { + return {}; + } + const QStringList cameras = cameraId.trimmed().isEmpty() + ? session->recordedCameraIds() + : QStringList{cameraId.trimmed()}; + QStringList openedIds; + for (const QString& camera : cameras) + { + const qint64 count = session->recordedFrameCount(camera); + if (count <= 0 || count > (std::numeric_limits::max)()) + { + continue; + } + const QString sessionId = session->capturePlan().experimentId; + auto existing = std::find_if(m_documents.begin(), m_documents.end(), + [&sessionId, &camera](const auto& document) + { + return document->session->capturePlan().experimentId == sessionId + && document->cameraId == camera; + }); + if (existing != m_documents.end()) + { + activateDocument((*existing)->id); + openedIds.append((*existing)->id); + continue; + } + + auto document = std::make_unique(); + document->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + document->title = title.trimmed().isEmpty() ? defaultTitle(*session, camera) + : title.trimmed(); + if (cameras.size() > 1 && !title.trimmed().isEmpty()) + { + document->title += QStringLiteral(" - ") + camera; + } + document->session = session; + document->cameraId = camera; + document->frameCount = static_cast(count); + document->activeLayerKey = scopeone::core::ScopeOneCore::staticLayerKey(camera); + document->page = new ImageDocumentPage(document->id, + document->title, + camera, + session, + session->experimentDocument(), + document->frameCount, + m_viewerTabs); + connectViewer(document->page->previewWidget(), document->id); + const QString id = document->id; + connect(document->page, &ImageDocumentPage::frameIndexRequested, + this, &ImageWorkspace::requestDocumentFrame); + m_documents.push_back(std::move(document)); + const int tabIndex = m_viewerTabs->addTab( + m_documents.back()->page, compactViewerTitle(m_documents.back()->title)); + m_viewerTabs->setTabToolTip(tabIndex, m_documents.back()->title); + if (!requestFrame(*m_documents.back(), 0)) + { + closeDocument(id); + continue; + } + openedIds.append(id); + } + if (!openedIds.isEmpty()) + { + activateDocument(openedIds.constLast()); + } + return openedIds; + } + + QString ImageWorkspace::openFrame(const scopeone::core::ImageFrame& frame, + const QString& title) + { + if (!frame.isValid()) + { + return {}; + } + scopeone::core::ExperimentPlan plan; + plan.experimentId = QUuid::createUuid().toString(QUuid::WithoutBraces); + plan.cameraIds = {frame.cameraId}; + plan.streamToDisk = false; + plan.baseName = title.trimmed().isEmpty() + ? QStringLiteral("image_%1").arg( + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_hhmmss_zzz"))) + : title.trimmed(); + const auto session = m_core->createFrameSession({frame}, plan); + if (!session) + { + return {}; + } + emit sessionAvailable(session, plan.baseName); + return openSession(session, plan.baseName, frame.cameraId).value(0); + } + + QList ImageWorkspace::documents() const + { + QList result; + result.reserve(static_cast(m_documents.size())); + for (const auto& document : m_documents) + { + result.append({document->id, + document->title, + document->session->capturePlan().experimentId, + document->cameraId, + document->frameIndex, + document->frameCount, + document->currentFrame.isValid(), + document->id == m_activeDocumentId}); + } + return result; + } + + ImageDocumentInfo ImageWorkspace::document(const QString& documentId) const + { + const Document* document = findDocument(documentId); + return document + ? ImageDocumentInfo{document->id, + document->title, + document->session->capturePlan().experimentId, + document->cameraId, + document->frameIndex, + document->frameCount, + document->currentFrame.isValid(), + document->id == m_activeDocumentId} + : ImageDocumentInfo{}; + } + + QString ImageWorkspace::activeDocumentId() const + { + return m_activeDocumentId; + } + + bool ImageWorkspace::isLiveViewerActive() const + { + return m_activeDocumentId.isEmpty(); + } + + scopeone::core::ImageSceneModel* ImageWorkspace::activeSceneModel() const + { + return sceneModel(m_activeDocumentId); + } + + PreviewWidget* ImageWorkspace::activePreviewWidget() const + { + return previewWidget(m_activeDocumentId); + } + + scopeone::core::ImageSceneModel* ImageWorkspace::sceneModel(const QString& documentId) const + { + if (documentId.trimmed().isEmpty()) + { + return m_core->imageSceneModel(); + } + const Document* document = findDocument(documentId); + return document && document->page ? document->page->sceneModel() : nullptr; + } + + PreviewWidget* ImageWorkspace::previewWidget(const QString& documentId) const + { + if (documentId.trimmed().isEmpty()) + { + return m_livePreviewWidget; + } + const Document* document = findDocument(documentId); + return document && document->page ? document->page->previewWidget() : nullptr; + } + + scopeone::core::ImageFrame ImageWorkspace::frameForLayer(const QString& layerKey) const + { + if (isLiveViewerActive()) + { + return m_core->graphFrame(layerKey); + } + const Document* document = findDocument(m_activeDocumentId); + if (!document || !document->page + || !document->page->sceneModel()->layerIds().contains(layerKey)) + { + return {}; + } + return document->currentFrame; + } + + bool ImageWorkspace::histogram( + const QString& layerKey, + scopeone::core::ScopeOneCore::HistogramStats& stats) const + { + return isLiveViewerActive() + ? m_core->getLayerHistogram(layerKey, stats) + : scopeone::core::ScopeOneCore::computeHistogramStats(frameForLayer(layerKey), stats); + } + + void ImageWorkspace::requestHistogram(const QString& layerKey) + { + queueHistogramRequest(layerKey, false); + } + + // Queues a static frame histogram without blocking the viewer + void ImageWorkspace::queueHistogramRequest(const QString& layerKey, bool applyAutoLevels) + { + if (isLiveViewerActive()) + { + return; + } + const Document* document = findDocument(m_activeDocumentId); + if (!document || !document->currentFrame.isValid()) + { + return; + } + m_histogramDocumentId = document->id; + m_histogramLayerKey = layerKey; + m_histogramFrameIndex = document->frameIndex; + m_histogramFrame = document->currentFrame; + m_histogramApplyAutoLevels = applyAutoLevels; + ++m_histogramGeneration; + startHistogramRequest(); + } + + void ImageWorkspace::startHistogramRequest() + { + if (m_histogramRunning || !m_histogramFrame.isValid()) + { + return; + } + m_histogramRunning = true; + const QString documentId = m_histogramDocumentId; + const QString layerKey = m_histogramLayerKey; + const int frameIndex = m_histogramFrameIndex; + const bool applyAutoLevels = m_histogramApplyAutoLevels; + const quint64 generation = m_histogramGeneration; + const scopeone::core::ImageFrame frame = std::exchange( + m_histogramFrame, scopeone::core::ImageFrame{}); + m_histogramApplyAutoLevels = false; + auto* watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcherBase::finished, this, + [this, watcher, documentId, layerKey, frameIndex, applyAutoLevels, generation]() + { + m_histogramRunning = false; + const Document* document = findDocument(documentId); + if (generation == m_histogramGeneration + && document && documentId == m_activeDocumentId + && document->frameIndex == frameIndex) + { + const auto stats = watcher->result(); + if (applyAutoLevels) + { + activeSceneModel()->setLayerAutoStretchEnabled(layerKey, false); + activeSceneModel()->setLayerDisplayLevels( + layerKey, + stats.autoMinLevel, + stats.autoMaxLevel, + stats.maxValue); + } + emit histogramReady(layerKey, stats); + } + watcher->deleteLater(); + startHistogramRequest(); + }); + watcher->setFuture(QtConcurrent::run([frame]() + { + scopeone::core::ScopeOneCore::HistogramStats stats; + scopeone::core::ScopeOneCore::computeHistogramStats(frame, stats); + return stats; + })); + } + + bool ImageWorkspace::autoLayerLevels(const QString& layerKey) + { + if (isLiveViewerActive()) + { + return m_core->autoLayerLevels(layerKey); + } + if (!frameForLayer(layerKey).isValid()) + { + return false; + } + queueHistogramRequest(layerKey, true); + return true; + } + + bool ImageWorkspace::fullLayerLevels(const QString& layerKey) + { + if (isLiveViewerActive()) + { + return m_core->fullLayerLevels(layerKey); + } + const scopeone::core::ImageFrame frame = frameForLayer(layerKey); + if (!frame.isValid()) + { + return false; + } + activeSceneModel()->setLayerAutoStretchEnabled(layerKey, false); + return activeSceneModel()->setLayerDisplayLevels(layerKey, 0, frame.maxValue(), frame.maxValue()); + } + + bool ImageWorkspace::setLayerAutoStretchEnabled(const QString& layerKey, bool enabled) + { + if (isLiveViewerActive()) + { + return m_core->setLayerAutoStretchEnabled(layerKey, enabled); + } + if (!activeSceneModel()->setLayerAutoStretchEnabled(layerKey, enabled)) + { + return false; + } + return !enabled || autoLayerLevels(layerKey); + } + + bool ImageWorkspace::layerAutoStretchEnabled(const QString& layerKey) const + { + scopeone::core::ImageSceneModel* scene = activeSceneModel(); + return scene && scene->layerAutoStretchEnabled(layerKey); + } + + bool ImageWorkspace::lineProfile(const QString& layerKey, + const QPoint& start, + const QPoint& end, + QVector& values) const + { + if (isLiveViewerActive()) + { + return m_core->getLineProfile(layerKey, start, end, values); + } + const scopeone::core::ImageFrame frame = frameForLayer(layerKey); + if (!frame.isValid()) + { + return false; + } + const int count = qMax(qAbs(end.x() - start.x()), qAbs(end.y() - start.y())) + 1; + values.clear(); + values.reserve(count); + for (int i = 0; i < count; ++i) + { + const double t = count == 1 ? 0.0 : static_cast(i) / (count - 1); + const int x = qRound(start.x() + t * (end.x() - start.x())); + const int y = qRound(start.y() + t * (end.y() - start.y())); + if (x < 0 || y < 0 || x >= frame.width || y >= frame.height) + { + return false; + } + const uchar* row = reinterpret_cast(frame.bytes.constData()) + + y * frame.stride; + values.append(frame.bytesPerPixel() == 1 + ? row[x] + : reinterpret_cast(row)[x]); + } + return !values.isEmpty(); + } + + bool ImageWorkspace::pixelValue(const QString& layerKey, const QPoint& point, int& value) const + { + if (isLiveViewerActive()) + { + return m_core->graphPixelValue(layerKey, point, value); + } + const scopeone::core::ImageFrame frame = frameForLayer(layerKey); + if (!frame.isValid() || point.x() < 0 || point.y() < 0 + || point.x() >= frame.width || point.y() >= frame.height) + { + return false; + } + const uchar* row = reinterpret_cast(frame.bytes.constData()) + + point.y() * frame.stride; + value = frame.bytesPerPixel() == 1 + ? row[point.x()] + : reinterpret_cast(row)[point.x()]; + return true; + } + + double ImageWorkspace::pixelSizeUm(const QString& layerKey) const + { + if (isLiveViewerActive()) + { + return m_core->cameraPixelSizeUm( + scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey)); + } + const Document* document = findDocument(m_activeDocumentId); + return document ? document->session->cameraPixelSizeUm(document->cameraId) : 0.0; + } + + QString ImageWorkspace::activeLayerKey() const + { + if (isLiveViewerActive()) + { + const scopeone::core::ImageSceneModel* scene = activeSceneModel(); + return scene && scene->visibleLayerIds().contains(m_liveLayerKey) + ? m_liveLayerKey + : scene ? scene->visibleLayerIds().value(0) : QString{}; + } + const Document* document = findDocument(m_activeDocumentId); + scopeone::core::ImageSceneModel* scene = activeSceneModel(); + if (document && scene && scene->layerIds().contains(document->activeLayerKey)) + { + return document->activeLayerKey; + } + return scene ? scene->visibleLayerIds().value(0) : QString{}; + } + + void ImageWorkspace::setActiveLayerKey(const QString& layerKey) + { + const QString normalizedLayerKey = layerKey.trimmed(); + const QString previousLayerKey = activeLayerKey(); + if (isLiveViewerActive()) + { + if (!activeSceneModel()->layerIds().contains(normalizedLayerKey)) + { + return; + } + m_liveLayerKey = normalizedLayerKey; + } + else if (Document* document = findDocument(m_activeDocumentId)) + { + if (!activeSceneModel()->layerIds().contains(normalizedLayerKey)) + { + return; + } + document->activeLayerKey = normalizedLayerKey; + } + if (PreviewWidget* preview = activePreviewWidget()) + { + preview->setActiveLayerKey(normalizedLayerKey); + } + if (previousLayerKey != normalizedLayerKey) + { + emit activeLayerChanged(normalizedLayerKey); + } + updateViewerToolbar(); + } + + // Keeps the active layer aligned with the visible layers in one viewer + void ImageWorkspace::syncActiveLayer(const QString& documentId) + { + scopeone::core::ImageSceneModel* scene = sceneModel(documentId); + if (!scene) + { + return; + } + + const QString previousLayerKey = documentId == m_activeDocumentId + ? activeLayerKey() + : QString{}; + const QStringList visibleLayerKeys = scene->visibleLayerIds(); + if (documentId.isEmpty()) + { + if (!visibleLayerKeys.contains(m_liveLayerKey)) + { + m_liveLayerKey = visibleLayerKeys.value(0); + } + } + else if (Document* document = findDocument(documentId)) + { + if (!visibleLayerKeys.contains(document->activeLayerKey)) + { + document->activeLayerKey = visibleLayerKeys.value(0); + } + } + + updateViewerToolbar(); + if (documentId == m_activeDocumentId && previousLayerKey != activeLayerKey()) + { + emit activeLayerChanged(activeLayerKey()); + } + } + + scopeone::core::ImageFrame ImageWorkspace::currentFrame(const QString& documentId) const + { + const Document* document = findDocument(documentId); + return document ? document->currentFrame : scopeone::core::ImageFrame{}; + } + + bool ImageWorkspace::activateDocument(const QString& documentId) + { + Document* document = findDocument(documentId); + if (!document || !document->page) + { + return false; + } + if (comparisonActive()) + { + if (documentId == m_compareLeftDocumentId + || documentId == m_compareRightDocumentId) + { + setActiveDocument(documentId); + return true; + } + endComparison(); + } + m_viewerTabs->setCurrentWidget(document->page); + return true; + } + + void ImageWorkspace::setActiveDocument(const QString& documentId) + { + if (m_activeDocumentId == documentId || !findDocument(documentId)) + { + return; + } + m_activeDocumentId = documentId; + if (comparisonActive()) + { + const Document* left = findDocument(m_compareLeftDocumentId); + const Document* right = findDocument(m_compareRightDocumentId); + const QString leftTitle = left ? left->title : QString{}; + const QString rightTitle = right ? right->title : QString{}; + m_compareLeftHost->setTitle(documentId == m_compareLeftDocumentId + ? tr("Active: %1").arg(leftTitle) + : leftTitle); + m_compareRightHost->setTitle(documentId == m_compareRightDocumentId + ? tr("Active: %1").arg(rightTitle) + : rightTitle); + } + emit activeDocumentChanged(documentId); + emit activeViewerChanged(); + emit activeLayerChanged(activeLayerKey()); + emit documentsChanged(); + updateViewerToolbar(); + } + + bool ImageWorkspace::comparisonActive() const + { + return !m_compareLeftDocumentId.isEmpty() + && !m_compareRightDocumentId.isEmpty(); + } + + bool ImageWorkspace::beginComparison(const QString& rightDocumentId) + { + Document* left = findDocument(m_activeDocumentId); + if (!left || comparisonActive()) + { + return false; + } + + QString rightId = rightDocumentId; + if (rightId.isEmpty()) + { + for (const auto& candidate : m_documents) + { + if (candidate->id != left->id) + { + rightId = candidate->id; + break; + } + } + } + if (rightId.isEmpty()) + { + rightId = duplicateDocument(left->id); + } + Document* right = findDocument(rightId); + if (!right || right == left) + { + return false; + } + + m_compareLeftDocumentId = left->id; + m_compareRightDocumentId = right->id; + { + const QSignalBlocker blocker(m_viewerTabs); + const int leftIndex = m_viewerTabs->indexOf(left->page); + if (leftIndex >= 0) + { + m_viewerTabs->removeTab(leftIndex); + } + const int rightIndex = m_viewerTabs->indexOf(right->page); + if (rightIndex >= 0) + { + m_viewerTabs->removeTab(rightIndex); + } + } + m_compareLeftHost->layout()->addWidget(left->page); + m_compareRightHost->layout()->addWidget(right->page); + m_viewerStack->setCurrentWidget(m_compareWidget); + m_compareLeftHost->setTitle(tr("Active: %1").arg(left->title)); + m_compareRightHost->setTitle(right->title); + updateViewerToolbar(); + return true; + } + + void ImageWorkspace::endComparison() + { + if (!comparisonActive()) + { + return; + } + Document* left = findDocument(m_compareLeftDocumentId); + Document* right = findDocument(m_compareRightDocumentId); + if (left && left->page) + { + m_compareLeftHost->layout()->removeWidget(left->page); + } + if (right && right->page) + { + m_compareRightHost->layout()->removeWidget(right->page); + } + m_compareLeftDocumentId.clear(); + m_compareRightDocumentId.clear(); + rebuildViewerTabs(); + m_viewerStack->setCurrentWidget(m_viewerTabs); + { + const QSignalBlocker blocker(m_compareAction); + m_compareAction->setChecked(false); + } + updateViewerToolbar(); + } + + void ImageWorkspace::rebuildViewerTabs() + { + const QSignalBlocker blocker(m_viewerTabs); + while (m_viewerTabs->count() > 0) + { + m_viewerTabs->removeTab(0); + } + m_liveTabIndex = m_livePreviewWidget + ? m_viewerTabs->addTab(m_livePreviewWidget, tr("Live Preview")) + : -1; + if (m_liveTabIndex >= 0) + { + m_viewerTabs->tabBar()->setTabButton(m_liveTabIndex, QTabBar::RightSide, nullptr); + } + for (const auto& document : m_documents) + { + const int tabIndex = m_viewerTabs->addTab( + document->page, compactViewerTitle(document->title)); + m_viewerTabs->setTabToolTip(tabIndex, document->title); + } + if (Document* active = findDocument(m_activeDocumentId)) + { + m_viewerTabs->setCurrentWidget(active->page); + } + else if (m_liveTabIndex >= 0) + { + m_viewerTabs->setCurrentIndex(m_liveTabIndex); + } + } + + bool ImageWorkspace::closeDocument(const QString& documentId) + { + Document* document = findDocument(documentId); + if (!document || !document->page) + { + return false; + } + removeDocument(documentId); + return true; + } + + quint64 ImageWorkspace::processDocument(const QString& documentId, bool completeStack) + { + Document* document = findDocument(documentId); + if (!document) + { + return 0; + } + const quint64 requestId = completeStack + ? m_core->requestRecordingSessionStackProcessing( + document->session->capturePlan().experimentId, + document->cameraId) + : m_core->requestImageProcessing(document->currentFrame, + document->id); + if (requestId != 0) + { + m_processingRequests.insert(requestId, document->id); + } + return requestId; + } + + bool ImageWorkspace::saveDocument( + const QString& documentId, + const scopeone::core::ScopeOneCore::RecordingSaveOptions& options) + { + Document* document = findDocument(documentId); + if (!document + || !m_core->saveRecordingSessionCamera( + document->session, + document->cameraId, + options, + &document->page->sceneModel()->document())) + { + return false; + } + m_saveRequests.insert(document->session->capturePlan().experimentId + + QLatin1Char('\n') + document->cameraId, + document->id); + return true; + } + + void ImageWorkspace::saveDocumentAs(const QString& documentId) + { + Document* document = findDocument(documentId); + if (!document) + { + return; + } + const QString saveDir = QFileDialog::getExistingDirectory( + document->page, + tr("Select Dataset Folder"), + QDir::homePath()); + if (saveDir.isEmpty()) + { + return; + } + bool accepted = false; + QString baseName = QInputDialog::getText( + document->page, + tr("Save Image Dataset As"), + tr("Dataset name and optional format suffix"), + QLineEdit::Normal, + document->title + QStringLiteral(".ome.tiff"), + &accepted) + .trimmed(); + if (!accepted || baseName.isEmpty()) + { + return; + } + scopeone::core::ScopeOneCore::RecordingSaveOptions options; + if (baseName.endsWith(QStringLiteral(".ome.tiff"), Qt::CaseInsensitive)) + { + baseName.chop(9); + options.format = scopeone::core::RecordingFormat::OmeTiff; + } + else if (baseName.endsWith(QStringLiteral(".ome.zarr"), Qt::CaseInsensitive)) + { + baseName.chop(9); + options.format = scopeone::core::RecordingFormat::OmeZarr; + } + else if (baseName.endsWith(QStringLiteral(".tiff"), Qt::CaseInsensitive)) + { + baseName.chop(5); + options.format = scopeone::core::RecordingFormat::Tiff; + } + else if (baseName.endsWith(QStringLiteral(".bin"), Qt::CaseInsensitive)) + { + baseName.chop(4); + options.format = scopeone::core::RecordingFormat::Binary; + } + options.saveDir = saveDir; + options.baseName = baseName; + options.enableCompression = options.format != scopeone::core::RecordingFormat::Binary; + saveDocument(document->id, options); + } + + ImageWorkspace::Document* ImageWorkspace::findDocument(const QString& documentId) const + { + const QString id = documentId.trimmed().isEmpty() ? m_activeDocumentId + : documentId.trimmed(); + const auto it = std::find_if(m_documents.begin(), m_documents.end(), + [&id](const auto& document) + { + return document->id == id; + }); + return it == m_documents.end() ? nullptr : it->get(); + } + + ImageWorkspace::Document* ImageWorkspace::findDocumentByPage(QWidget* page) const + { + const auto it = std::find_if(m_documents.cbegin(), m_documents.cend(), + [page](const auto& document) + { + return document->page == page; + }); + return it == m_documents.cend() ? nullptr : it->get(); + } + + QString ImageWorkspace::duplicateDocument(const QString& documentId) + { + const Document* source = findDocument(documentId); + if (!source || !source->session) + { + return {}; + } + auto document = std::make_unique(); + document->id = QUuid::createUuid().toString(QUuid::WithoutBraces); + document->title = tr("%1 Copy").arg(source->title); + document->session = source->session; + document->cameraId = source->cameraId; + document->frameCount = source->frameCount; + document->frameIndex = source->frameIndex; + document->requestedFrameIndex = source->frameIndex; + document->activeLayerKey = scopeone::core::ScopeOneCore::staticLayerKey( + document->cameraId); + document->page = new ImageDocumentPage(document->id, + document->title, + document->cameraId, + document->session, + document->session->experimentDocument(), + document->frameCount, + m_viewerTabs); + connectViewer(document->page->previewWidget(), document->id); + connect(document->page, &ImageDocumentPage::frameIndexRequested, + this, &ImageWorkspace::requestDocumentFrame); + const QString id = document->id; + m_documents.push_back(std::move(document)); + const int tabIndex = m_viewerTabs->addTab( + m_documents.back()->page, compactViewerTitle(m_documents.back()->title)); + m_viewerTabs->setTabToolTip(tabIndex, m_documents.back()->title); + if (!requestFrame(*m_documents.back(), source->frameIndex)) + { + removeDocument(id); + return {}; + } + emit documentsChanged(); + return id; + } + + void ImageWorkspace::requestDocumentFrame(const QString& documentId, int frameIndex) + { + Document* document = findDocument(documentId); + if (!document) + { + return; + } + requestFrame(*document, frameIndex); + if (!comparisonActive() || !m_linkFramesAction->isChecked()) + { + return; + } + const QString peerId = documentId == m_compareLeftDocumentId + ? m_compareRightDocumentId + : documentId == m_compareRightDocumentId + ? m_compareLeftDocumentId + : QString{}; + if (Document* peer = findDocument(peerId)) + { + requestFrame(*peer, frameIndex); + } + } + + bool ImageWorkspace::requestFrame(Document& document, int frameIndex) + { + document.requestedFrameIndex = qBound(0, frameIndex, document.frameCount - 1); + if (document.frameRequestId != 0) + { + return true; + } + document.frameRequestId = m_core->requestRecordingSessionFrame( + document.session, document.cameraId, document.requestedFrameIndex); + if (document.frameRequestId != 0) + { + m_frameRequests.insert(document.frameRequestId, document.id); + return true; + } + return false; + } + + void ImageWorkspace::removeDocument(const QString& documentId) + { + if (comparisonActive() + && (documentId == m_compareLeftDocumentId + || documentId == m_compareRightDocumentId)) + { + endComparison(); + } + const auto it = std::find_if(m_documents.begin(), m_documents.end(), + [&documentId](const auto& document) + { + return document->id == documentId; + }); + if (it == m_documents.end()) + { + return; + } + if ((*it)->frameRequestId != 0) + { + m_frameRequests.remove((*it)->frameRequestId); + } + for (auto request = m_processingRequests.constBegin(); + request != m_processingRequests.constEnd(); ++request) + { + if (request.value() == documentId) + { + m_core->cancelProcessingRequest(request.key()); + } + } + const int tabIndex = m_viewerTabs->indexOf((*it)->page); + if (tabIndex >= 0) + { + m_viewerTabs->removeTab(tabIndex); + } + delete (*it)->page; + m_documents.erase(it); + if (m_activeDocumentId == documentId) + { + if (Document* active = findDocumentByPage(m_viewerTabs->currentWidget())) + { + m_activeDocumentId = active->id; + } + else + { + m_activeDocumentId.clear(); + } + emit activeDocumentChanged(m_activeDocumentId); + emit activeViewerChanged(); + emit activeLayerChanged(activeLayerKey()); + updateViewerToolbar(); + } + emit documentsChanged(); + } + + void ImageWorkspace::connectViewer(PreviewWidget* preview, const QString& documentId) + { + connect(preview, &PreviewWidget::activated, this, [this, documentId]() + { + if (documentId.isEmpty()) + { + activateLiveViewer(); + } + else + { + setActiveDocument(documentId); + } + }); + connect(preview, &PreviewWidget::measurementLineDrawn, + this, &ImageWorkspace::measurementLineDrawn); + connect(preview, &PreviewWidget::measurementLineInspected, + this, &ImageWorkspace::measurementLineInspected); + connect(preview, &PreviewWidget::measurementLineCleared, + this, &ImageWorkspace::measurementLineCleared); + connect(preview, &PreviewWidget::layerClicked, + this, [this](const QString& layerKey) { setActiveLayerKey(layerKey); }); + connect(preview, &PreviewWidget::mousePositionChanged, + this, [this, documentId](const QPoint& position) + { + if (documentId == m_activeDocumentId) + { + emit mousePositionChanged(position); + } + }); + auto* scene = preview->sceneModel(); + connect(preview, &PreviewWidget::availableLayerKeysChanged, + this, [this](const QStringList&) { updateViewerToolbar(); }); + connect(preview, &PreviewWidget::zoomLevelChanged, + this, [this](int) { updateViewerToolbar(); }); + connect(preview, &PreviewWidget::fitToWindowChanged, + this, [this](bool) { updateViewerToolbar(); }); + connect(preview, &PreviewWidget::layerLayoutModeChanged, + this, [this](PreviewWidget::LayerLayoutMode) { updateViewerToolbar(); }); + connect(preview, &PreviewWidget::viewDimensionModeChanged, + this, [this](PreviewWidget::ViewDimensionMode mode) + { + Q_UNUSED(mode); + emit viewDimensionModeChanged(); + updateViewerToolbar(); + }); + connect(scene, &scopeone::core::ImageSceneModel::layersChanged, + this, [this, documentId]() { syncActiveLayer(documentId); }); + if (documentId.isEmpty()) + { + return; + } + connect(scene, &scopeone::core::ImageSceneModel::markupsChanged, + this, [this, documentId]() + { + Document* document = findDocument(documentId); + if (document && documentId == m_activeDocumentId) + { + updateLineProfile(*document); + } + }); + } + + void ImageWorkspace::updateLineProfile(Document& document) + { + for (const auto& markup : document.page->sceneModel()->markups()) + { + if (markup.role == scopeone::core::ImageSceneModel::MarkupRole::CrossSection) + { + QVector values; + if (lineProfile(markup.layerKey, markup.start, markup.end, values)) + { + emit lineProfileUpdated(markup.layerKey, values); + } + return; + } + } + } +} + +#include "ImageWorkspace.moc" diff --git a/src/ImageWorkspace.h b/src/ImageWorkspace.h new file mode 100644 index 0000000..49f5e6e --- /dev/null +++ b/src/ImageWorkspace.h @@ -0,0 +1,183 @@ +#pragma once + +#include "scopeone/ScopeOneCore.h" + +#include +#include +#include +#include +#include +#include + +class QAction; +class QComboBox; +class QGroupBox; +class QLabel; +class QStackedWidget; +class QWidget; +class QTabWidget; +class QToolBar; + +namespace scopeone::core +{ + class ImageSceneModel; +} + +namespace scopeone::ui +{ + class PreviewWidget; + + struct ImageDocumentInfo + { + QString id; + QString title; + QString sessionId; + QString cameraId; + int frameIndex{0}; + int frameCount{0}; + bool ready{false}; + bool active{false}; + + bool isValid() const { return !id.isEmpty(); } + }; + + class ImageWorkspace : public QObject + { + Q_OBJECT + + public: + explicit ImageWorkspace(scopeone::core::ScopeOneCore* core, + QWidget* windowParent, + QObject* parent = nullptr); + ~ImageWorkspace() override; + + QWidget* viewerHost() const; + void setLiveViewer(PreviewWidget* previewWidget); + void activateLiveViewer(); + void setVisibleLayers(const QStringList& layerKeys, bool sideBySide = false); + + QStringList openSession( + const std::shared_ptr& session, + const QString& title = QString(), + const QString& cameraId = QString()); + QString openFrame(const scopeone::core::ImageFrame& frame, + const QString& title); + QList documents() const; + ImageDocumentInfo document(const QString& documentId = QString()) const; + QString activeDocumentId() const; + bool isLiveViewerActive() const; + scopeone::core::ImageSceneModel* activeSceneModel() const; + PreviewWidget* activePreviewWidget() const; + scopeone::core::ImageFrame frameForLayer(const QString& layerKey) const; + bool histogram(const QString& layerKey, + scopeone::core::ScopeOneCore::HistogramStats& stats) const; + void requestHistogram(const QString& layerKey); + bool autoLayerLevels(const QString& layerKey); + bool fullLayerLevels(const QString& layerKey); + bool setLayerAutoStretchEnabled(const QString& layerKey, bool enabled); + bool layerAutoStretchEnabled(const QString& layerKey) const; + bool lineProfile(const QString& layerKey, + const QPoint& start, + const QPoint& end, + QVector& values) const; + bool pixelValue(const QString& layerKey, const QPoint& point, int& value) const; + double pixelSizeUm(const QString& layerKey) const; + QString activeLayerKey() const; + void setActiveLayerKey(const QString& layerKey); + scopeone::core::ImageSceneModel* sceneModel(const QString& documentId) const; + PreviewWidget* previewWidget(const QString& documentId) const; + scopeone::core::ImageFrame currentFrame(const QString& documentId = QString()) const; + bool activateDocument(const QString& documentId); + bool closeDocument(const QString& documentId); + quint64 processDocument(const QString& documentId, bool completeStack); + bool saveDocument(const QString& documentId, + const scopeone::core::ScopeOneCore::RecordingSaveOptions& options); + void saveDocumentAs(const QString& documentId = QString()); + + signals: + void documentsChanged(); + void activeDocumentChanged(const QString& documentId); + void activeViewerChanged(); + void viewDimensionModeChanged(); + void activeLayerChanged(const QString& layerKey); + void activeFrameChanged(); + void histogramReady(const QString& layerKey, + const scopeone::core::ScopeOneCore::HistogramStats& stats); + void mousePositionChanged(const QPoint& widgetPos); + void documentProcessingProgress(quint64 requestId, qint64 completed, qint64 total); + void documentProcessingFinished(quint64 requestId, + const QString& outputDocumentId, + const QString& errorMessage); + void documentSaveFinished(const QString& documentId, + bool success, + const QString& message); + void sessionAvailable( + const std::shared_ptr& session, + const QString& title); + void measurementLineDrawn(const QString& layerKey, + const QPoint& start, + const QPoint& end); + void measurementLineInspected(const QString& layerKey, + const QPoint& start, + const QPoint& end); + void measurementLineCleared(); + void lineProfileUpdated(const QString& layerKey, const QVector& values); + + private: + struct Document; + Document* findDocument(const QString& documentId) const; + Document* findDocumentByPage(QWidget* page) const; + QString duplicateDocument(const QString& documentId); + bool requestFrame(Document& document, int frameIndex); + void requestDocumentFrame(const QString& documentId, int frameIndex); + void removeDocument(const QString& documentId); + void connectViewer(PreviewWidget* previewWidget, const QString& documentId); + void setupViewerToolbar(); + void updateViewerToolbar(); + void setActiveDocument(const QString& documentId); + bool beginComparison(const QString& rightDocumentId = QString()); + void endComparison(); + void rebuildViewerTabs(); + bool comparisonActive() const; + void syncActiveLayer(const QString& documentId); + void updateLineProfile(Document& document); + void queueHistogramRequest(const QString& layerKey, bool applyAutoLevels); + void startHistogramRequest(); + + scopeone::core::ScopeOneCore* m_core{nullptr}; + QWidget* m_viewerHost{nullptr}; + QToolBar* m_viewerToolbar{nullptr}; + QStackedWidget* m_viewerStack{nullptr}; + QTabWidget* m_viewerTabs{nullptr}; + QWidget* m_compareWidget{nullptr}; + QGroupBox* m_compareLeftHost{nullptr}; + QGroupBox* m_compareRightHost{nullptr}; + QAction* m_fitToWindowAction{nullptr}; + QAction* m_oneToOneAction{nullptr}; + QAction* m_compareSeparator{nullptr}; + QAction* m_compareAction{nullptr}; + QAction* m_linkFramesAction{nullptr}; + QAction* m_dimensionAction{nullptr}; + QAction* m_reset3dAction{nullptr}; + QComboBox* m_layoutCombo{nullptr}; + QComboBox* m_zoomCombo{nullptr}; + QComboBox* m_compareDocumentCombo{nullptr}; + int m_liveTabIndex{-1}; + PreviewWidget* m_livePreviewWidget{nullptr}; + std::vector> m_documents; + QString m_activeDocumentId; + QString m_compareLeftDocumentId; + QString m_compareRightDocumentId; + QString m_liveLayerKey; + QHash m_frameRequests; + QHash m_processingRequests; + QHash m_saveRequests; + QString m_histogramDocumentId; + QString m_histogramLayerKey; + int m_histogramFrameIndex{-1}; + scopeone::core::ImageFrame m_histogramFrame; + bool m_histogramApplyAutoLevels{false}; + bool m_histogramRunning{false}; + quint64 m_histogramGeneration{0}; + }; +} diff --git a/src/InspectWidget.cpp b/src/InspectWidget.cpp index e70cfd0..7a9aff0 100644 --- a/src/InspectWidget.cpp +++ b/src/InspectWidget.cpp @@ -1,11 +1,8 @@ #include "InspectWidget.h" +#include "ImageWorkspace.h" #include "scopeone/ImageSceneModel.h" -#include -#include #include -#include -#include #include #include #include @@ -33,11 +30,24 @@ namespace scopeone::ui { return QStringLiteral("static"); } + if (scopeone::core::ScopeOneCore::isToolLayerKey(layerKey)) + { + return QStringLiteral("tool"); + } return scopeone::core::ScopeOneCore::isProcessedLayerKey(layerKey) ? QStringLiteral("proc") : QStringLiteral("raw"); } + QString inspectLayerTitle(const QString& layerKey, bool active) + { + const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); + return QStringLiteral("%1 - %2 [%3]") + .arg(active ? QStringLiteral("Active Layer") : QStringLiteral("Layer"), + cameraId, + inspectLayerSourceLabel(layerKey)); + } + // Check whether a preview layer can use live core inspection bool isLiveLayerKey(const QString& layerKey) { @@ -71,6 +81,11 @@ namespace scopeone::ui update(); } + QVector values() const + { + return m_values; + } + protected: // Paint the cross section curve and its summary labels void paintEvent(QPaintEvent*) override @@ -164,274 +179,14 @@ namespace scopeone::ui QVector m_values; }; - struct LayerHistogramData - { - QString layerKey; - scopeone::core::ScopeOneCore::HistogramStats stats; - QColor color{Qt::blue}; - }; - - class InspectHistogramWidget : public QWidget - { - public: - explicit InspectHistogramWidget(QWidget* parent = nullptr) - : QWidget(parent) - { - setMinimumHeight(150); - } - - // Store histogram data for one layer and repaint - void updateLayerHistogram(const QString& layerKey, - const scopeone::core::ScopeOneCore::HistogramStats& stats, - const QColor& color) - { - LayerHistogramData data; - data.layerKey = layerKey; - data.stats = stats; - data.color = color; - m_layerData[layerKey] = data; - update(); - } - - // Toggle logarithmic histogram display - void setLogScale(bool logScale) - { - m_logScale = logScale; - update(); - } - - protected: - // Paint all tracked camera histograms in one chart - void paintEvent(QPaintEvent*) override - { - QPainter painter(this); - painter.setRenderHint(QPainter::Antialiasing); - const QPalette& colors = palette(); - const QFontMetrics metrics = painter.fontMetrics(); - const int labelHeight = metrics.height() + 4; - const int xLabelWidth = qMax(50, metrics.horizontalAdvance(QStringLiteral("65535")) + 12); - const int yLabelWidth = qMax(40, metrics.horizontalAdvance(QStringLiteral("999.9M")) + 8); - - const QRect rect = this->rect().adjusted( - yLabelWidth + 6, - labelHeight / 2 + 2, - -(xLabelWidth / 2 + 4), - -(labelHeight + 8)); - - painter.fillRect(rect, colors.brush(QPalette::Base)); - painter.setPen(QPen(colors.color(QPalette::Mid), 1)); - painter.drawRect(rect); - - if (m_layerData.isEmpty()) - { - painter.setPen(colors.color(QPalette::PlaceholderText)); - painter.drawText(rect, Qt::AlignCenter, QStringLiteral("No Layer Data")); - return; - } - - int globalMaxValue = 255; - int globalMaxCount = 0; - for (const LayerHistogramData& layerData : m_layerData) - { - if (!layerData.stats.hasData() || layerData.stats.histogram.empty()) - { - continue; - } - globalMaxValue = qMax(globalMaxValue, layerData.stats.maxValue); - for (int count : layerData.stats.histogram) - { - globalMaxCount = qMax(globalMaxCount, count); - } - } - - if (globalMaxCount == 0) - { - painter.setPen(colors.color(QPalette::PlaceholderText)); - painter.drawText(rect, Qt::AlignCenter, QStringLiteral("No Histogram Data")); - return; - } - - for (const LayerHistogramData& layerData : m_layerData) - { - if (!layerData.stats.hasData() || layerData.stats.histogram.empty()) - { - continue; - } - - const int histSize = static_cast(layerData.stats.histogram.size()); - QColor histColor = layerData.color; - histColor.setAlpha(180); - painter.setPen(QPen(histColor, 1)); - - for (int i = 0; i < histSize; ++i) - { - const int x = rect.left() + (i * rect.width()) / histSize; - const int count = layerData.stats.histogram[static_cast(i)]; - - double normalizedCount = 0.0; - if (m_logScale && count > 0) - { - normalizedCount = log10(count + 1.0) / log10(globalMaxCount + 1.0); - } - else - { - normalizedCount = static_cast(count) / globalMaxCount; - } - - const int height = static_cast(normalizedCount * rect.height()); - if (height > 0) - { - painter.drawLine(x, rect.bottom(), x, rect.bottom() - height); - } - } - } - - drawAxes(painter, rect, globalMaxValue, xLabelWidth, labelHeight); - } - - private: - // Draw intensity and count axes for the histogram plot - void drawAxes(QPainter& painter, - const QRect& rect, - int maxValue, - int xLabelWidth, - int labelHeight) - { - const QColor axisColor = palette().color(QPalette::Mid); - const QColor textColor = palette().color(QPalette::Text); - painter.setPen(QPen(axisColor, 1)); - - QList xTicks; - xTicks << 0 << maxValue / 4 << maxValue / 2 << (maxValue * 3) / 4 << maxValue; - - for (int i = 0; i < xTicks.size(); ++i) - { - const int x = rect.left() + (i * rect.width()) / (xTicks.size() - 1); - painter.drawLine(x, rect.bottom(), x, rect.bottom() + 5); - - const QString label = QString::number(xTicks[i]); - const QRect textRect( - x - xLabelWidth / 2, - rect.bottom() + 5, - xLabelWidth, - labelHeight); - painter.setPen(textColor); - painter.drawText(textRect, Qt::AlignCenter, label); - painter.setPen(QPen(axisColor, 1)); - } - - painter.drawLine(rect.left(), rect.top(), rect.left(), rect.bottom()); - - int maxCount = 0; - for (const LayerHistogramData& layerData : m_layerData) - { - if (!layerData.stats.hasData()) - { - continue; - } - for (int count : layerData.stats.histogram) - { - maxCount = qMax(maxCount, count); - } - } - - if (maxCount <= 0) - { - return; - } - - QList yTicks; - if (m_logScale) - { - yTicks = {1, 10, 100, 1000, 10000}; - } - else - { - int step = maxCount / 4; - if (step == 0) - { - step = 1; - } - - int magnitude = 1; - while (step > magnitude * 10) - { - magnitude *= 10; - } - step = ((step / magnitude) + 1) * magnitude; - - for (int i = 0; i <= 4; ++i) - { - const int value = i * step; - if (value <= maxCount) - { - yTicks.append(value); - } - } - } - - for (int count : yTicks) - { - if (count > maxCount) - { - continue; - } - - double normalizedCount = 0.0; - if (m_logScale && count > 0) - { - normalizedCount = log10(count + 1.0) / log10(maxCount + 1.0); - } - else - { - normalizedCount = static_cast(count) / maxCount; - } - - const int y = rect.bottom() - static_cast(normalizedCount * rect.height()); - painter.drawLine(rect.left() - 5, y, rect.left(), y); - - QString label; - if (count >= 1000000000) - { - label = QStringLiteral("%1G").arg(count / 1000000000.0, 0, 'f', 1); - } - else if (count >= 1000000) - { - label = QStringLiteral("%1M").arg(count / 1000000.0, 0, 'f', 1); - } - else if (count >= 1000) - { - label = QStringLiteral("%1k").arg(count / 1000.0, 0, 'f', 1); - } - else - { - label = QString::number(count); - } - const QRect textRect( - 0, - y - labelHeight / 2, - rect.left() - 8, - labelHeight); - painter.setPen(textColor); - painter.drawText(textRect, Qt::AlignRight | Qt::AlignVCenter, label); - painter.setPen(QPen(axisColor, 1)); - } - } - - QHash m_layerData; - bool m_logScale{false}; - }; - // Create the inspection panel and subscribe to core analysis signals - InspectWidget::InspectWidget(scopeone::core::ScopeOneCore* core, QWidget* parent) + InspectWidget::InspectWidget(scopeone::core::ScopeOneCore* core, + ImageWorkspace* workspace, + QWidget* parent) : QWidget(parent) , m_scopeonecore(core) + , m_workspace(workspace) { - if (!core) - { - qFatal("InspectWidget requires ScopeOneCore"); - } - setWindowTitle(QStringLiteral("Inspect")); setupUI(); updateControlsState(); @@ -444,6 +199,76 @@ namespace scopeone::ui this, &InspectWidget::setLayerCrossSectionProfile); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::lineProfileCleared, this, &InspectWidget::clearCrossSectionProfile); + connect(m_workspace, &ImageWorkspace::activeViewerChanged, + this, &InspectWidget::refreshActiveViewer); + connect(m_workspace, &ImageWorkspace::activeFrameChanged, + this, [this]() + { + if (m_workspace->isLiveViewerActive() || currentLayerKey().isEmpty()) + { + return; + } + m_workspace->requestHistogram(currentLayerKey()); + }); + connect(m_workspace, &ImageWorkspace::activeLayerChanged, + this, [this](const QString&) + { + const QString layerKey = currentLayerKey(); + if (m_workspace->isLiveViewerActive()) + { + m_scopeonecore->setActiveHistogramLayer(layerKey); + } + else if (!layerKey.isEmpty()) + { + m_workspace->requestHistogram(layerKey); + } + updateLayerVisibility(); + updateControlsState(); + }); + connect(m_workspace, &ImageWorkspace::histogramReady, + this, [this](const QString& layerKey, + const scopeone::core::ScopeOneCore::HistogramStats& stats) + { + if (layerKey == currentLayerKey() && !m_workspace->isLiveViewerActive()) + { + setLayerInspect(layerKey, stats); + if (m_workspace->layerAutoStretchEnabled(layerKey)) + { + m_sceneModel->setLayerDisplayLevels( + layerKey, + stats.autoMinLevel, + stats.autoMaxLevel, + stats.maxValue); + } + } + }); + connect(m_workspace, &ImageWorkspace::lineProfileUpdated, + this, &InspectWidget::setLayerCrossSectionProfile); + refreshActiveViewer(); + } + + void InspectWidget::refreshActiveViewer() + { + saveViewerState(); + const bool inspectLive = m_workspace->isLiveViewerActive(); + const QString viewerStateId = m_workspace->activeDocumentId(); + if (m_inspectingLive && !inspectLive) + { + m_scopeonecore->setActiveHistogramLayer({}); + } + m_inspectingLive = inspectLive; + m_activeViewerStateId = viewerStateId; + restoreViewerState(); + if (m_sceneModel) + { + disconnect(m_sceneModel, nullptr, this, nullptr); + } + m_sceneModel = m_workspace->activeSceneModel(); + setAvailableLayers(m_sceneModel ? m_sceneModel->layerIds() : QStringList{}); + if (!m_sceneModel) + { + return; + } const auto refreshLayerDisplay = [this](const QString& layerKey) { const auto state = m_layerStates.constFind(layerKey); @@ -452,50 +277,102 @@ namespace scopeone::ui updateLayerInspect(layerKey, state->stats); } }; - connect(m_scopeonecore->imageSceneModel(), - &scopeone::core::ImageSceneModel::layerDisplayChanged, - this, refreshLayerDisplay); - connect(m_scopeonecore->imageSceneModel(), - &scopeone::core::ImageSceneModel::layerAutoStretchChanged, + connect(m_sceneModel, &scopeone::core::ImageSceneModel::layerDisplayChanged, + this, [this, refreshLayerDisplay](const QString& layerKey) + { + refreshLayerDisplay(layerKey); + updateLayerVisibility(); + updateControlsState(); + }); + connect(m_sceneModel, &scopeone::core::ImageSceneModel::layerAutoStretchChanged, this, [refreshLayerDisplay](const QString& layerKey, bool) { refreshLayerDisplay(layerKey); }); + connect(m_sceneModel, &scopeone::core::ImageSceneModel::layersChanged, + this, [this]() + { + setAvailableLayers(m_sceneModel->layerIds()); + }); + const QString layerKey = currentLayerKey(); + if (m_workspace->isLiveViewerActive()) + { + m_scopeonecore->setActiveHistogramLayer(layerKey); + } + else if (!layerKey.isEmpty()) + { + m_workspace->requestHistogram(layerKey); + } + updateLayerVisibility(); + for (auto it = m_layerStates.cbegin(); it != m_layerStates.cend(); ++it) + { + if (it->hasStats) + { + updateLayerInspect(it.key(), it->stats); + } + } + updateControlsState(); } - InspectWidget::~InspectWidget() + void InspectWidget::saveViewerState() { - m_scopeonecore->setActiveHistogramLayer({}); + ViewerInspectState& state = m_viewerStates[m_activeViewerStateId]; + state.layerStates = m_layerStates; + state.crossSectionLayerKey = m_crossSectionLayerKey; + state.crossSectionValues = m_crossSectionWidget->values(); + state.measurementLayerKey = m_measurementLayerKey; + state.measurementInfo = m_measurementInfoLabel->text(); } - // Enable inspect controls when camera state changes - void InspectWidget::onCameraInitialized(bool initialized) + void InspectWidget::restoreViewerState() { - m_cameraInitialized = initialized; - updateControlsState(); + const auto it = m_viewerStates.constFind(m_activeViewerStateId); + if (it == m_viewerStates.constEnd()) + { + m_layerStates.clear(); + m_crossSectionLayerKey.clear(); + m_crossSectionWidget->clear(); + m_measurementLayerKey.clear(); + m_measurementInfoLabel->clear(); + m_measurementInfoLabel->hide(); + return; + } - if (!initialized) + const ViewerInspectState& state = it.value(); + m_layerStates = state.layerStates; + m_crossSectionLayerKey = state.crossSectionLayerKey; + if (state.crossSectionLayerKey.isEmpty() || state.crossSectionValues.isEmpty()) { - clearCrossSectionProfile(); + m_crossSectionWidget->clear(); } + else + { + m_crossSectionWidget->setProfile( + state.crossSectionLayerKey, state.crossSectionValues); + } + m_measurementLayerKey = state.measurementLayerKey; + m_measurementInfoLabel->setText(state.measurementInfo); + m_measurementInfoLabel->setVisible(!state.measurementInfo.isEmpty()); } - // Track the currently selected preview layer - void InspectWidget::setCurrentLayer(const QString& layerKey) + InspectWidget::~InspectWidget() { - m_currentLayerKey = layerKey.trimmed(); - m_scopeonecore->setActiveHistogramLayer(m_currentLayerKey); - if (!m_crossSectionLayerKey.isEmpty() && m_crossSectionLayerKey != m_currentLayerKey) + if (m_workspace->isLiveViewerActive()) { - clearCrossSectionProfile(); - emit requestClearCrossSection(); + m_scopeonecore->setActiveHistogramLayer({}); } - if (!m_measurementLayerKey.isEmpty() && m_measurementLayerKey != m_currentLayerKey) + } + + // Enable inspect controls when camera state changes + void InspectWidget::onCameraInitialized(bool initialized) + { + m_cameraInitialized = initialized; + updateControlsState(); + + if (!initialized) { - clearMeasurementLine(); + clearCrossSectionProfile(); } - updateLayerVisibility(); - updateControlsState(); } // Remove inspect state for layers that are no longer available @@ -528,6 +405,11 @@ namespace scopeone::ui removeLayerInfo(key); } + for (const QString& key : m_availableLayerKeys) + { + addLayerInfo(key); + } + if (!m_crossSectionLayerKey.isEmpty() && !m_availableLayerKeys.contains(m_crossSectionLayerKey)) { clearCrossSectionProfile(); @@ -538,14 +420,21 @@ namespace scopeone::ui clearMeasurementLine(); } - if (!m_currentLayerKey.isEmpty() && !m_availableLayerKeys.contains(m_currentLayerKey)) + if (!currentLayerKey().isEmpty() && !m_availableLayerKeys.contains(currentLayerKey())) { - m_currentLayerKey.clear(); - m_scopeonecore->setActiveHistogramLayer({}); + if (m_workspace->isLiveViewerActive()) + { + m_scopeonecore->setActiveHistogramLayer({}); + } clearCrossSectionProfile(); } updateLayerVisibility(); updateControlsState(); + + if (m_workspace->isLiveViewerActive()) + { + m_scopeonecore->setActiveHistogramLayer(currentLayerKey()); + } } // Store live camera availability for core backed tools @@ -562,23 +451,20 @@ namespace scopeone::ui emit requestClearCrossSection(); } - if (!m_currentLayerKey.isEmpty() - && isLiveLayerKey(m_currentLayerKey) + if (!currentLayerKey().isEmpty() + && isLiveLayerKey(currentLayerKey()) && !m_availableCameraIds.contains(currentLayerCameraId())) { - m_currentLayerKey.clear(); - m_scopeonecore->setActiveHistogramLayer({}); + if (m_workspace->isLiveViewerActive()) + { + m_scopeonecore->setActiveHistogramLayer({}); + } clearCrossSectionProfile(); } updateLayerVisibility(); updateControlsState(); } - void InspectWidget::setCrossSectionVisible(bool visible) - { - m_crossSectionGroup->setVisible(visible); - } - // Show inspect data for an explicit preview layer void InspectWidget::setLayerInspect( const QString& layerKey, @@ -597,14 +483,6 @@ namespace scopeone::ui updateLayerInspect(trimmedLayerKey, stats); } - // Clear all layer inspect groups - void InspectWidget::clearInspect() - { - clearMeasurementLine(); - setAvailableLayers({}); - setAvailableCameras({}); - } - // Remove cached inspect data for one graph layer void InspectWidget::clearLayerInspect(const QString& layerKey) { @@ -616,7 +494,7 @@ namespace scopeone::ui m_layerStates.remove(trimmedLayerKey); removeLayerInfo(trimmedLayerKey); - if (m_currentLayerKey == trimmedLayerKey) + if (currentLayerKey() == trimmedLayerKey) { clearCrossSectionProfile(); } @@ -652,6 +530,7 @@ namespace scopeone::ui } QStringList lines{ + QStringLiteral("Layer: %1").arg(m_measurementLayerKey), QStringLiteral("Start: (%1, %2)").arg(start.x()).arg(start.y()), QStringLiteral("Angle: %1°").arg(angleDegrees, 0, 'f', 1), QStringLiteral("Length: %1 px").arg(lengthPixels, 0, 'f', 2) @@ -661,6 +540,10 @@ namespace scopeone::ui lines.append(QStringLiteral("Actual: %1 µm") .arg(actualLengthUm, 0, 'f', 3)); } + else + { + lines.append(QStringLiteral("Scale: not calibrated")); + } m_measurementInfoLabel->setText(lines.join('\n')); m_measurementInfoLabel->show(); } @@ -677,7 +560,7 @@ namespace scopeone::ui void InspectWidget::setLayerCrossSectionProfile(const QString& layerKey, const QVector& values) { const QString trimmedLayerKey = layerKey.trimmed(); - if (trimmedLayerKey.isEmpty() || trimmedLayerKey != m_currentLayerKey) + if (trimmedLayerKey.isEmpty() || trimmedLayerKey != currentLayerKey()) { return; } @@ -700,14 +583,16 @@ namespace scopeone::ui scrollArea->setFrameShape(QFrame::NoFrame); auto* contentContainer = new QWidget(scrollArea); + m_contentContainer = contentContainer; auto* contentLayout = new QVBoxLayout(contentContainer); + m_contentLayout = contentLayout; contentLayout->setSpacing(8); contentLayout->setContentsMargins(5, 5, 5, 5); auto* annotationGroup = new QGroupBox(QStringLiteral("Annotation"), contentContainer); auto* annotationLayout = new QVBoxLayout(annotationGroup); auto* annotationButtons = new QHBoxLayout(); - m_drawMeasurementLineButton = new QPushButton(QStringLiteral("Line"), annotationGroup); + m_drawMeasurementLineButton = new QPushButton(QStringLiteral("Measure Line"), annotationGroup); m_clearMeasurementLinesButton = new QPushButton(QStringLiteral("Clear"), annotationGroup); annotationButtons->addWidget(m_drawMeasurementLineButton); annotationButtons->addWidget(m_clearMeasurementLinesButton); @@ -717,32 +602,29 @@ namespace scopeone::ui annotationLayout->addWidget(m_measurementInfoLabel); contentLayout->addWidget(annotationGroup); - m_crossSectionGroup = new QGroupBox(QStringLiteral("Cross Section"), contentContainer); - auto* crossSectionLayout = new QVBoxLayout(m_crossSectionGroup); + auto* crossSectionGroup = new QGroupBox(QStringLiteral("Cross Section"), contentContainer); + auto* crossSectionLayout = new QVBoxLayout(crossSectionGroup); auto* crossSectionButtons = new QHBoxLayout(); - m_drawCrossSectionButton = new QPushButton(QStringLiteral("Draw Cross Section"), m_crossSectionGroup); - m_clearCrossSectionButton = new QPushButton(QStringLiteral("Clear Cross Section"), m_crossSectionGroup); + m_drawCrossSectionButton = new QPushButton(QStringLiteral("Intensity Profile"), crossSectionGroup); + m_clearCrossSectionButton = new QPushButton(QStringLiteral("Clear Profile"), crossSectionGroup); crossSectionButtons->addWidget(m_drawCrossSectionButton); crossSectionButtons->addWidget(m_clearCrossSectionButton); crossSectionButtons->addStretch(); crossSectionLayout->addLayout(crossSectionButtons); - m_crossSectionWidget = new InspectCrossSectionWidget(m_crossSectionGroup); + m_crossSectionWidget = new InspectCrossSectionWidget(crossSectionGroup); crossSectionLayout->addWidget(m_crossSectionWidget); - contentLayout->addWidget(m_crossSectionGroup); + contentLayout->addWidget(crossSectionGroup); - m_histogramContainerLayout = new QVBoxLayout(); - m_histogramContainerLayout->setSpacing(10); - contentLayout->addLayout(m_histogramContainerLayout); contentLayout->addStretch(); connect(m_drawCrossSectionButton, &QPushButton::clicked, this, [this]() { - if (m_currentLayerKey.isEmpty()) + if (currentLayerKey().isEmpty()) { return; } - m_crossSectionLayerKey = m_currentLayerKey; - emit requestDrawCrossSectionLayer(m_currentLayerKey); + m_crossSectionLayerKey = currentLayerKey(); + emit requestDrawCrossSectionLayer(currentLayerKey()); }); connect(m_clearCrossSectionButton, &QPushButton::clicked, this, [this]() { @@ -751,40 +633,28 @@ namespace scopeone::ui }); connect(m_drawMeasurementLineButton, &QPushButton::clicked, this, [this]() { - emit requestDrawMeasurementLine(m_currentLayerKey); + emit requestDrawMeasurementLine(currentLayerKey()); }); connect(m_clearMeasurementLinesButton, &QPushButton::clicked, this, [this]() { - emit requestClearMeasurementLines(m_currentLayerKey); + emit requestClearMeasurementLines( + m_measurementLayerKey.isEmpty() ? currentLayerKey() : m_measurementLayerKey); }); scrollArea->setWidget(contentContainer); mainLayout->addWidget(scrollArea); } - // Create histogram controls for one layer + // Create statistics controls for one layer QWidget* InspectWidget::createLayerInfoGroup(const QString& layerKey) { const QString normalizedLayerKey = layerKey.trimmed(); - const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(normalizedLayerKey); - auto* group = new QGroupBox( - QStringLiteral("Layer - %1 [%2]").arg(cameraId, inspectLayerSourceLabel(normalizedLayerKey)), - this); + auto* group = new QGroupBox(inspectLayerTitle(normalizedLayerKey, false), m_contentContainer); auto* layout = new QVBoxLayout(group); LayerInfoGroup infoGroup; infoGroup.layerKey = normalizedLayerKey; infoGroup.groupBox = group; - auto* histLabel = new QLabel(QStringLiteral("Histogram"), group); - QFont boldFont = histLabel->font(); - boldFont.setBold(true); - histLabel->setFont(boldFont); - layout->addWidget(histLabel); - - auto* histogramWidget = new InspectHistogramWidget(group); - layout->addWidget(histogramWidget); - infoGroup.histogramWidget = histogramWidget; - auto* slidersLayout = new QHBoxLayout(); auto* minLabel = new QLabel(QStringLiteral("Min:"), group); @@ -813,45 +683,14 @@ namespace scopeone::ui slidersLayout->addWidget(maxSliderValueLabel); layout->addLayout(slidersLayout); - auto* histControlLayout = new QHBoxLayout(); - auto* autoButton = new QPushButton(QStringLiteral("Auto"), group); - auto* fullButton = new QPushButton(QStringLiteral("Full"), group); - auto* autoStretchCheckBox = new QCheckBox(QStringLiteral("Auto-stretch"), group); - auto* logScaleCheckBox = new QCheckBox(QStringLiteral("Log hist"), group); - histControlLayout->addWidget(autoButton); - histControlLayout->addWidget(fullButton); - histControlLayout->addWidget(autoStretchCheckBox); - histControlLayout->addWidget(logScaleCheckBox); - histControlLayout->addStretch(); - layout->addLayout(histControlLayout); - - infoGroup.autoButton = autoButton; - infoGroup.fullButton = fullButton; - infoGroup.autoStretchCheckBox = autoStretchCheckBox; - infoGroup.logScaleCheckBox = logScaleCheckBox; infoGroup.minSlider = minSlider; infoGroup.maxSlider = maxSlider; infoGroup.minSliderValueLabel = minSliderValueLabel; infoGroup.maxSliderValueLabel = maxSliderValueLabel; layout->addWidget(createStatisticsGroup(infoGroup)); m_layerInfoGroups.insert(normalizedLayerKey, infoGroup); + m_contentLayout->insertWidget(m_contentLayout->count() - 1, group); - connect(autoButton, &QPushButton::clicked, this, [this, normalizedLayerKey]() - { - onAutoButtonClicked(normalizedLayerKey); - }); - connect(fullButton, &QPushButton::clicked, this, [this, normalizedLayerKey]() - { - onFullButtonClicked(normalizedLayerKey); - }); - connect(autoStretchCheckBox, &QCheckBox::toggled, this, [this, normalizedLayerKey](bool checked) - { - onAutoStretchChanged(normalizedLayerKey, checked); - }); - connect(logScaleCheckBox, &QCheckBox::toggled, this, [this, normalizedLayerKey](bool checked) - { - onLogScaleChanged(normalizedLayerKey, checked); - }); connect(minSlider, &QSlider::valueChanged, this, [this, normalizedLayerKey, minSlider, maxSlider, minSliderValueLabel](int value) { @@ -924,8 +763,7 @@ namespace scopeone::ui return; } - QWidget* histogramGroup = createLayerInfoGroup(normalizedLayerKey); - m_histogramContainerLayout->addWidget(histogramGroup); + createLayerInfoGroup(normalizedLayerKey); updateLayerVisibility(); updateControlsState(); } @@ -940,7 +778,7 @@ namespace scopeone::ui } LayerInfoGroup& infoGroup = it.value(); - m_histogramContainerLayout->removeWidget(infoGroup.groupBox); + m_contentLayout->removeWidget(infoGroup.groupBox); infoGroup.groupBox->deleteLater(); m_layerInfoGroups.erase(it); } @@ -967,14 +805,11 @@ namespace scopeone::ui } scopeone::core::DocumentLayer layer; - if (!m_scopeonecore->imageSceneModel()->findLayer(normalizedLayerKey, layer)) + if (!m_sceneModel || !m_sceneModel->findLayer(normalizedLayerKey, layer)) { return; } - const QColor layerColor = getLayerColor(normalizedLayerKey); - infoGroup.histogramWidget->updateLayerHistogram(normalizedLayerKey, stats, layerColor); - const int maxValue = qMax(1, layer.display.levelDomainMax); const int displayMin = qBound(0, layer.display.levelMin, maxValue - 1); const int displayMax = qBound(displayMin + 1, layer.display.levelMax, maxValue); @@ -988,45 +823,10 @@ namespace scopeone::ui } infoGroup.minSliderValueLabel->setText(QString::number(displayMin)); infoGroup.maxSliderValueLabel->setText(QString::number(displayMax)); - { - QSignalBlocker blocker(infoGroup.autoStretchCheckBox); - infoGroup.autoStretchCheckBox->setChecked( - m_scopeonecore->layerAutoStretchEnabled(normalizedLayerKey)); - } - updateStatisticsDisplay(normalizedLayerKey, stats); updateControlsState(); } - // Apply the computed auto display range once - void InspectWidget::onAutoButtonClicked(const QString& layerKey) - { - m_scopeonecore->autoLayerLevels(layerKey); - } - - // Expand the display range to the full pixel range - void InspectWidget::onFullButtonClicked(const QString& layerKey) - { - m_scopeonecore->fullLayerLevels(layerKey); - } - - // Toggle continuous auto stretch for one layer - void InspectWidget::onAutoStretchChanged(const QString& layerKey, bool checked) - { - m_scopeonecore->setLayerAutoStretchEnabled(layerKey, checked); - } - - // Toggle logarithmic histogram scaling for one layer - void InspectWidget::onLogScaleChanged(const QString& layerKey, bool checked) - { - auto it = m_layerInfoGroups.find(layerKey); - if (it == m_layerInfoGroups.end()) - { - return; - } - it.value().histogramWidget->setLogScale(checked); - } - // Update numeric statistics labels for one layer void InspectWidget::updateStatisticsDisplay( const QString& layerKey, @@ -1060,19 +860,24 @@ namespace scopeone::ui // Enable controls according to live camera and selected layer state void InspectWidget::updateControlsState() { - const auto currentState = m_layerStates.constFind(m_currentLayerKey); + const QString layerKey = currentLayerKey(); + const auto currentState = m_layerStates.constFind(layerKey); const bool currentLayerHasStats = currentState != m_layerStates.constEnd() && currentState.value().hasStats; const bool liveCrossSectionEnabled = m_cameraInitialized - && isLiveLayerKey(m_currentLayerKey) + && isLiveLayerKey(layerKey) && m_availableCameraIds.contains(currentLayerCameraId()); - const bool staticCrossSectionEnabled = scopeone::core::ScopeOneCore::isStaticLayerKey(m_currentLayerKey) + const bool toolCrossSectionEnabled = scopeone::core::ScopeOneCore::isToolLayerKey(layerKey) + && currentLayerHasStats; + const bool staticCrossSectionEnabled = scopeone::core::ScopeOneCore::isStaticLayerKey(layerKey) && currentLayerHasStats; - const bool crossSectionEnabled = !m_currentLayerKey.isEmpty() - && (liveCrossSectionEnabled || staticCrossSectionEnabled); + const bool crossSectionEnabled = !layerKey.isEmpty() + && (liveCrossSectionEnabled + || toolCrossSectionEnabled + || staticCrossSectionEnabled); m_drawCrossSectionButton->setEnabled(crossSectionEnabled); - m_clearCrossSectionButton->setEnabled(m_cameraInitialized || !m_currentLayerKey.isEmpty()); - const bool annotationEnabled = !m_currentLayerKey.isEmpty() - && m_availableLayerKeys.contains(m_currentLayerKey); + m_clearCrossSectionButton->setEnabled(m_cameraInitialized || !layerKey.isEmpty()); + const bool annotationEnabled = !layerKey.isEmpty() + && m_availableLayerKeys.contains(layerKey); m_drawMeasurementLineButton->setEnabled(annotationEnabled); m_clearMeasurementLinesButton->setEnabled(annotationEnabled); @@ -1081,21 +886,27 @@ namespace scopeone::ui LayerInfoGroup& infoGroup = it.value(); const auto stateIt = m_layerStates.constFind(infoGroup.layerKey); const bool hasStats = stateIt != m_layerStates.constEnd() && stateIt.value().hasStats; - infoGroup.autoButton->setEnabled(hasStats); - infoGroup.fullButton->setEnabled(hasStats); - infoGroup.autoStretchCheckBox->setEnabled(hasStats); - infoGroup.logScaleCheckBox->setEnabled(hasStats); + const bool isActiveLayer = infoGroup.layerKey == layerKey; + infoGroup.minSlider->setEnabled(hasStats && isActiveLayer); + infoGroup.maxSlider->setEnabled(hasStats && isActiveLayer); } } // Shows inspect controls for the selected preview layer void InspectWidget::updateLayerVisibility() { + const QString layerKey = currentLayerKey(); + const QStringList visibleLayerKeys = m_sceneModel + ? m_sceneModel->visibleLayerIds() + : QStringList{}; for (auto it = m_layerInfoGroups.begin(); it != m_layerInfoGroups.end(); ++it) { LayerInfoGroup& infoGroup = it.value(); - infoGroup.groupBox->setVisible(!m_currentLayerKey.isEmpty() - && infoGroup.layerKey == m_currentLayerKey); + const bool showLayer = visibleLayerKeys.contains(infoGroup.layerKey) + && infoGroup.layerKey == layerKey; + infoGroup.groupBox->setVisible(showLayer); + infoGroup.groupBox->setTitle( + inspectLayerTitle(infoGroup.layerKey, infoGroup.layerKey == layerKey)); } } @@ -1112,20 +923,6 @@ namespace scopeone::ui return it.value(); } - // Pick a stable display color from the layer key - QColor InspectWidget::getLayerColor(const QString& layerKey) const - { - static const QList layerColors = { - QColor(0, 120, 215), - QColor(232, 17, 35), - QColor(16, 124, 16), - QColor(247, 99, 12) - }; - - const int index = qHash(layerKey) % layerColors.size(); - return layerColors[index]; - } - // Apply manual display range changes from layer sliders void InspectWidget::onLayerSliderChanged(const QString& layerKey, int minValue, int maxValue) { @@ -1139,13 +936,18 @@ namespace scopeone::ui { return; } - m_scopeonecore->setLayerAutoStretchEnabled(layerKey, false); - m_scopeonecore->imageSceneModel()->setLayerDisplayLevels( + m_workspace->setLayerAutoStretchEnabled(layerKey, false); + m_sceneModel->setLayerDisplayLevels( layerKey, minValue, maxValue, qMax(1, state.stats.maxValue)); } QString InspectWidget::currentLayerCameraId() const { - return scopeone::core::ScopeOneCore::sourceIdFromLayerKey(m_currentLayerKey); + return scopeone::core::ScopeOneCore::sourceIdFromLayerKey(currentLayerKey()); + } + + QString InspectWidget::currentLayerKey() const + { + return m_workspace ? m_workspace->activeLayerKey() : QString{}; } } // namespace scopeone::ui diff --git a/src/InspectWidget.h b/src/InspectWidget.h index 38ca46a..33fe1df 100644 --- a/src/InspectWidget.h +++ b/src/InspectWidget.h @@ -9,8 +9,6 @@ #include #include -class QCheckBox; -class QColor; class QGroupBox; class QLabel; class QPushButton; @@ -19,8 +17,8 @@ class QVBoxLayout; namespace scopeone::ui { + class ImageWorkspace; class InspectCrossSectionWidget; - class InspectHistogramWidget; class InspectWidget : public QWidget { @@ -34,17 +32,16 @@ namespace scopeone::ui bool hasStats{false}; }; - explicit InspectWidget(scopeone::core::ScopeOneCore* core, QWidget* parent = nullptr); + explicit InspectWidget(scopeone::core::ScopeOneCore* core, + ImageWorkspace* workspace, + QWidget* parent = nullptr); ~InspectWidget() override; void onCameraInitialized(bool initialized); - void setCurrentLayer(const QString& layerKey); void setAvailableLayers(const QStringList& layerKeys); void setAvailableCameras(const QStringList& cameraIds); - void setCrossSectionVisible(bool visible); void setLayerInspect(const QString& layerKey, const scopeone::core::ScopeOneCore::HistogramStats& stats); - void clearInspect(); void clearLayerInspect(const QString& layerKey); void clearCrossSectionProfile(); void setLayerCrossSectionProfile(const QString& layerKey, const QVector& values); @@ -53,6 +50,7 @@ namespace scopeone::ui const QPoint& end, double actualLengthUm); void clearMeasurementLine(); + void refreshActiveViewer(); signals: void requestDrawCrossSectionLayer(const QString& layerKey); @@ -65,11 +63,6 @@ namespace scopeone::ui { QString layerKey; QGroupBox* groupBox{nullptr}; - InspectHistogramWidget* histogramWidget{nullptr}; - QPushButton* autoButton{nullptr}; - QPushButton* fullButton{nullptr}; - QCheckBox* autoStretchCheckBox{nullptr}; - QCheckBox* logScaleCheckBox{nullptr}; QSlider* minSlider{nullptr}; QSlider* maxSlider{nullptr}; QLabel* minSliderValueLabel{nullptr}; @@ -81,6 +74,15 @@ namespace scopeone::ui QLabel* pixelCountLabel{nullptr}; }; + struct ViewerInspectState + { + QHash layerStates; + QString crossSectionLayerKey; + QVector crossSectionValues; + QString measurementLayerKey; + QString measurementInfo; + }; + void setupUI(); QWidget* createLayerInfoGroup(const QString& layerKey); QWidget* createStatisticsGroup(LayerInfoGroup& infoGroup); @@ -88,35 +90,37 @@ namespace scopeone::ui void removeLayerInfo(const QString& layerKey); void updateLayerInspect(const QString& layerKey, const scopeone::core::ScopeOneCore::HistogramStats& stats); - void onAutoButtonClicked(const QString& layerKey); - void onFullButtonClicked(const QString& layerKey); - void onAutoStretchChanged(const QString& layerKey, bool checked); - void onLogScaleChanged(const QString& layerKey, bool checked); void updateStatisticsDisplay(const QString& layerKey, const scopeone::core::ScopeOneCore::HistogramStats& stats); void updateControlsState(); void updateLayerVisibility(); + void saveViewerState(); + void restoreViewerState(); + QString currentLayerKey() const; LayerInspectState& getOrCreateLayerState(const QString& layerKey); - QColor getLayerColor(const QString& layerKey) const; void onLayerSliderChanged(const QString& layerKey, int minValue, int maxValue); QString currentLayerCameraId() const; scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; + ImageWorkspace* m_workspace{nullptr}; + scopeone::core::ImageSceneModel* m_sceneModel{nullptr}; + QWidget* m_contentContainer{nullptr}; + QVBoxLayout* m_contentLayout{nullptr}; QHash m_layerInfoGroups; QHash m_layerStates; QStringList m_availableLayerKeys; QStringList m_availableCameraIds; - QVBoxLayout* m_histogramContainerLayout{nullptr}; QPushButton* m_drawMeasurementLineButton{nullptr}; QPushButton* m_clearMeasurementLinesButton{nullptr}; QLabel* m_measurementInfoLabel{nullptr}; InspectCrossSectionWidget* m_crossSectionWidget{nullptr}; - QGroupBox* m_crossSectionGroup{nullptr}; QPushButton* m_drawCrossSectionButton{nullptr}; QPushButton* m_clearCrossSectionButton{nullptr}; bool m_cameraInitialized{false}; - QString m_currentLayerKey; QString m_measurementLayerKey; QString m_crossSectionLayerKey; + QHash m_viewerStates; + QString m_activeViewerStateId; + bool m_inspectingLive{true}; }; } diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 2477015..ed9f131 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -10,10 +10,12 @@ #include "PreviewWidget.h" #include "scopeone/ImageSceneModel.h" #include "ImageGalleryWidget.h" +#include "ImageWorkspace.h" #include "ImageToolsDialog.h" #include "ImageProcessingWidget.h" #include "RecordingWidget.h" #include "SettingsDialog.h" +#include "PluginManagerDialog.h" #include "ScopeOneLocalApiServer.h" #include @@ -28,19 +30,28 @@ #include #include #include +#include +#include +#include +#include #include #include #include #include +#include +#include #include +#include #include +#include #include -#include +#include #include +#include #include #include #include -#include +#include #include #include @@ -64,21 +75,6 @@ namespace scopeone::ui return layerKeys; } - // Keep only raw layers from the current preview selection - QStringList rawOnlyLayerKeys(const QStringList& layerKeys) - { - QStringList rawKeys; - rawKeys.reserve(layerKeys.size()); - for (const QString& layerKey : layerKeys) - { - if (scopeone::core::ScopeOneCore::isRawLayerKey(layerKey)) - { - rawKeys.append(layerKey); - } - } - return rawKeys; - } - // Apply common status label presentation void configureStatusLabel(QLabel* label, int minWidth, int maxWidth, const QString& tooltip) { @@ -105,27 +101,6 @@ namespace scopeone::ui } } - // Build a stable key for one gallery session - QString gallerySessionKey( - const std::shared_ptr& session) - { - return session ? session->capturePlan().experimentId : QString(); - } - - // Build the static layer id for one recorded camera in a gallery session - QString gallerySessionLayerId( - const std::shared_ptr& session, - const QString& cameraId) - { - return QStringLiteral("gallery:%1:%2").arg(gallerySessionKey(session), cameraId); - } - - int uiFrameCount(qint64 frameCount) - { - return static_cast( - qBound(1, frameCount, static_cast((std::numeric_limits::max)()))); - } - // Detect the standard 64 bit Micro-Manager installation QString detectedMicroManagerDirectory() { @@ -146,6 +121,15 @@ namespace scopeone::ui return {}; } + // Apply the requested Qt widget style + void applyWidgetStyle(const QString& widgetStyle) + { + if (widgetStyle != QStringLiteral("default") && !widgetStyle.isEmpty()) + { + QApplication::setStyle(QStyleFactory::create(widgetStyle)); + } + } + // Apply the requested Qt color scheme void applyColorScheme(const QString& colorScheme) { @@ -164,17 +148,6 @@ namespace scopeone::ui } } - // Remove graph layers that belong to one gallery session - void removeGallerySessionPreview( - scopeone::core::ScopeOneCore& core, - const std::shared_ptr& session) - { - for (const QString& cameraId : session->recordedCameraIds()) - { - core.removeStaticFrame(gallerySessionLayerId(session, cameraId)); - } - } - void updateSessionPresentation( scopeone::core::ScopeOneCore& core, const scopeone::core::ImageSceneModel& sceneModel, @@ -206,9 +179,119 @@ namespace scopeone::ui } applyStoredApplicationSettings(); + m_imageWorkspace = new ImageWorkspace(core, this, this); setupUI(); + m_imageWorkspace->setLiveViewer(m_previewWidget); setupSignalWiring(); - new ScopeOneLocalApiServer(m_scopeonecore, m_previewWidget, this); + m_localApiServer = new ScopeOneLocalApiServer( + m_scopeonecore, m_previewWidget, m_imageWorkspace, this); + m_consoleWidget->setApiDispatcher( + [this](const QJsonObject& request, + std::function callback) + { + QJsonObject routedRequest = request; + const QString type = routedRequest.value(QStringLiteral("type")).toString(); + + if (type == QStringLiteral("set_fit_to_window")) + { + PreviewWidget* preview = m_imageWorkspace->activePreviewWidget(); + if (!preview) + { + QJsonObject response; + response.insert(QStringLiteral("type"), type); + response.insert(QStringLiteral("ok"), false); + response.insert(QStringLiteral("error"), QStringLiteral("No active preview")); + callback(response); + return; + } + preview->setFitToWindow( + routedRequest.value(QStringLiteral("enabled")).toBool()); + QJsonObject response; + response.insert(QStringLiteral("type"), type); + response.insert(QStringLiteral("ok"), true); + response.insert(QStringLiteral("enabled"), + preview->isFitToWindow()); + callback(response); + return; + } + + if (type == QStringLiteral("set_zoom")) + { + PreviewWidget* preview = m_imageWorkspace->activePreviewWidget(); + if (!preview) + { + QJsonObject response; + response.insert(QStringLiteral("type"), type); + response.insert(QStringLiteral("ok"), false); + response.insert(QStringLiteral("error"), QStringLiteral("No active preview")); + callback(response); + return; + } + preview->setFitToWindow(false); + preview->setZoomPercent( + routedRequest.value(QStringLiteral("zoomPercent")).toInt()); + QJsonObject response; + response.insert(QStringLiteral("type"), type); + response.insert(QStringLiteral("ok"), true); + response.insert(QStringLiteral("zoomPercent"), + preview->zoomPercent()); + callback(response); + return; + } + + if (type == QStringLiteral("draw_roi") + || type == QStringLiteral("set_half_roi") + || type == QStringLiteral("clear_roi")) + { + QString camera = routedRequest.value(QStringLiteral("camera")).toString(); + if (camera.isEmpty() + || camera.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + { + camera = m_currentControlTarget; + if (camera.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + { + camera = scopeone::core::ScopeOneCore::sourceIdFromLayerKey( + m_imageWorkspace->activeLayerKey()); + } + routedRequest.insert(QStringLiteral("camera"), camera); + } + } + + if (type == QStringLiteral("draw_roi")) + { + PreviewWidget* preview = m_imageWorkspace->activePreviewWidget(); + if (!preview) + { + QJsonObject response; + response.insert(QStringLiteral("type"), type); + response.insert(QStringLiteral("ok"), false); + response.insert(QStringLiteral("error"), QStringLiteral("No active preview")); + callback(response); + return; + } + preview->startROIDrawing( + routedRequest.value(QStringLiteral("camera")).toString()); + QJsonObject response; + response.insert(QStringLiteral("type"), type); + response.insert(QStringLiteral("ok"), true); + callback(response); + return; + } + + if (type == QStringLiteral("auto_layer_levels") + && !routedRequest.contains(QStringLiteral("layerKey"))) + { + routedRequest.insert(QStringLiteral("layerKey"), + m_imageWorkspace->activeLayerKey()); + } + + m_localApiServer->dispatchRequest( + routedRequest, + [callback = std::move(callback)](QJsonObject response) + { + callback(response); + }); + }); logStartupSummary(); setWindowTitle("ScopeOne"); @@ -252,14 +335,20 @@ namespace scopeone::ui m_closePendingSessions = unsavedSessions; m_closeSaveTotal = unsavedSessions.size(); m_closeSaveInProgress = true; + m_closeAfterSave = true; m_closeSaveProgress = new QProgressDialog( - tr("Saving gallery images..."), QString(), 0, m_closeSaveTotal, this); + tr("Saving gallery images..."), tr("Keep Open"), 0, m_closeSaveTotal, this); m_closeSaveProgress->setWindowTitle(tr("Saving")); - m_closeSaveProgress->setWindowModality(Qt::ApplicationModal); - m_closeSaveProgress->setCancelButton(nullptr); + m_closeSaveProgress->setWindowModality(Qt::WindowModal); m_closeSaveProgress->setMinimumDuration(0); m_closeSaveProgress->setAutoClose(false); m_closeSaveProgress->setValue(0); + connect(m_closeSaveProgress, &QProgressDialog::canceled, this, [this]() + { + m_closeAfterSave = false; + m_closeSaveProgress->deleteLater(); + m_closeSaveProgress = nullptr; + }); m_closeSaveProgress->show(); for (const auto& session : unsavedSessions) { @@ -286,31 +375,14 @@ namespace scopeone::ui !configurationRunning && !m_recentConfigurationsMenu->isEmpty()); m_propertyBrowser->setEnabled(!configurationRunning); m_configPresetWidget->setEnabled(!configurationRunning); - m_deviceControlWidget->setEnabled(!configurationRunning); - m_scaleAction->setEnabled(!configurationRunning && !m_scopeonecore->cameraIds().isEmpty()); - m_stageMosaicAction->setEnabled(!configurationRunning); - m_particleDetectionAction->setEnabled(!configurationRunning); - if (m_stageMosaicDialog) - { - m_stageMosaicDialog->setEnabled(!configurationRunning); - } - if (m_particleDetectionDialog) - { - m_particleDetectionDialog->setEnabled(!configurationRunning); - } + m_deviceControlWidget->setControlsEnabled(!configurationRunning); + m_toolRegistry->setEnabled(!configurationRunning); - const QStringList cameraIds = m_scopeonecore->cameraIds(); - if (cameraIds.isEmpty()) - { - applyNoCameraState(); - } - else - { - applyLoadedCameraState(cameraIds); - } + syncCameraState(); if (!configurationRunning) { refreshDevicePanels(false); + const QStringList cameraIds = m_scopeonecore->cameraIds(); if (m_scopeonecore->loadedConfigurationPath().isEmpty()) { showStatusMessage(tr("Configuration unloaded"), 3000); @@ -325,6 +397,13 @@ namespace scopeone::ui } } }); + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::hardwareDevicesChanged, + this, [this]() + { + if (m_scopeonecore->configurationOperationRunning()) return; + syncCameraState(); + refreshDevicePanels(false); + }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::configurationLoadFinished, this, [this](bool success, @@ -342,8 +421,6 @@ namespace scopeone::ui }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::configurationUnloadFinished, this, &MainWindow::handleConfigurationUnloadFinished); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::recordingSessionFrameReady, - this, &MainWindow::handleGalleryFrameReady); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::deviceStateChanged, this, [this]() { @@ -357,6 +434,7 @@ namespace scopeone::ui connect(m_scopeonecore, &scopeone::core::ScopeOneCore::previewStateChanged, this, [this](bool running) { + m_previewRunning = running; m_previewWidget->resetLiveFrameRates(); m_deviceControlWidget->setPreviewRunning(running); m_deviceControlWidget->setControlTargetEnabled(!running); @@ -365,15 +443,55 @@ namespace scopeone::ui running ? tr("Preview is running") : tr("Preview is idle")); showStatusMessage(running ? tr("Live preview started") : tr("Live preview stopped"), 3000); }); + connect(m_scopeonecore, + &scopeone::core::ScopeOneCore::staticImageImportProgress, + this, + [this](const QString&, int percent, const QString& statusText) + { + if (percent == 0) + { + m_staticImportProgress->setRange(0, 0); + } + else + { + m_staticImportProgress->setRange(0, 100); + m_staticImportProgress->setValue(percent); + } + m_staticImportProgress->show(); + showStatusMessage(statusText); + }); + connect(m_scopeonecore, + &scopeone::core::ScopeOneCore::staticImageImportFinished, + this, + [this](const QString& filePath, + const QString& layerKey, + bool success, + const QString& errorMessage) + { + m_staticImportProgress->hide(); + if (!success) + { + showStatusMessage(errorMessage, 5000); + return; + } + + m_imageWorkspace->setActiveLayerKey(layerKey); + m_saveImageAsAction->setEnabled(true); + showStatusMessage( + tr("Imported %1 (%2 slices)") + .arg(QFileInfo(filePath).fileName()) + .arg(m_scopeonecore->layerSliceCount(layerKey)), + 5000); + }); - connect(m_previewWidget, &PreviewWidget::mousePositionChanged, + connect(m_imageWorkspace, &ImageWorkspace::mousePositionChanged, this, &MainWindow::handlePreviewMousePosition); connect(m_previewWidget, &PreviewWidget::roiDrawn, this, &MainWindow::handleRoiDrawn); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::rawFramesAcquired, - m_previewWidget, &PreviewWidget::trackRawFrameRate); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::processedFramesCompleted, - m_previewWidget, &PreviewWidget::trackProcessedFrameRate); + connect(m_previewWidget, &PreviewWidget::imageFilesDropped, + this, &MainWindow::importImages); + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::layerFrameRatesUpdated, + m_previewWidget, &PreviewWidget::setLayerFrameRates); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::previewRawFrameReady, this, [this](const scopeone::core::ImageFrame& frame) { @@ -396,27 +514,34 @@ namespace scopeone::ui const QString&, const scopeone::core::ImageFrame& frame) { - m_previewWidget->setGraphStaticLayerFrame(sourceId, frame); + const QString layerKey = m_previewWidget->setGraphStaticLayerFrame(sourceId, frame); + m_previewWidget->setLayerSliceCount( + layerKey, + m_scopeonecore->layerSliceCount(layerKey)); schedulePreviewCursorStatusRefresh(); }); + connect(m_previewWidget, &PreviewWidget::layerSliceIndexRequested, + this, [this](const QString& layerKey, int sliceIndex) + { + m_scopeonecore->setLayerSliceIndex(layerKey, sliceIndex); + }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::staticFrameRemoved, this, [this](const QString& sourceId) { const QString layerKey = scopeone::core::ScopeOneCore::staticLayerKey(sourceId); - m_galleryLayerFrameControls.remove(layerKey); - m_galleryFrameRequests.remove(layerKey); - m_deviceControlWidget->removeLayerFrameControl(layerKey); m_previewWidget->removeStaticLayer(layerKey); }); + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::toolStreamFramePublished, + this, [this](const QString& sourceId, + const QString&, + const scopeone::core::ImageFrame& frame) + { + m_previewWidget->setGraphToolLayerFrame(sourceId, frame); + schedulePreviewCursorStatusRefresh(); + }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::staticFramesCleared, this, [this]() { - m_galleryFrameRequests.clear(); - for (const QString& layerKey : m_galleryLayerFrameControls.keys()) - { - m_deviceControlWidget->removeLayerFrameControl(layerKey); - } - m_galleryLayerFrameControls.clear(); m_previewWidget->clearStaticLayers(); }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::liveFramesCleared, @@ -497,14 +622,13 @@ namespace scopeone::ui } }); + connect(m_previewWidget, &PreviewWidget::stageStepRequested, + m_deviceControlWidget, &DeviceControlWidget::moveXYStep); + connect(m_previewWidget, &PreviewWidget::stageZStepRequested, + m_deviceControlWidget, &DeviceControlWidget::moveZStep); + connect(m_deviceControlWidget, &DeviceControlWidget::controlTargetChanged, this, &MainWindow::updateControlTarget); - connect(m_deviceControlWidget, &DeviceControlWidget::currentLayerChanged, - m_inspectWidget, &InspectWidget::setCurrentLayer); - connect(m_deviceControlWidget, &DeviceControlWidget::previewLayerFrameRequested, - this, &MainWindow::updateGalleryLayerFrame); - connect(m_previewWidget, &PreviewWidget::availableLayerKeysChanged, - m_inspectWidget, &InspectWidget::setAvailableLayers); connect(m_deviceControlWidget, &DeviceControlWidget::exposureValueChanged, this, [this](double ms) { @@ -519,106 +643,83 @@ namespace scopeone::ui connect(m_inspectWidget, &InspectWidget::requestDrawCrossSectionLayer, this, [this](const QString& layerKey) { - m_previewWidget->startCrossSectionDrawingForLayer(layerKey); - showStatusMessage(tr("Drag a line on the preview"), 5000); + if (auto* preview = m_imageWorkspace->activePreviewWidget()) + { + preview->startCrossSectionDrawingForLayer(layerKey); + showStatusMessage(tr("Drag a line on the preview"), 5000); + } }); connect(m_inspectWidget, &InspectWidget::requestClearCrossSection, this, [this]() { - m_previewWidget->clearCrossSection(); + if (auto* preview = m_imageWorkspace->activePreviewWidget()) + { + preview->clearCrossSection(); + } }); connect(m_inspectWidget, &InspectWidget::requestDrawMeasurementLine, this, [this](const QString& layerKey) { - m_previewWidget->startMeasurementLineDrawingForLayer(layerKey); - showStatusMessage(tr("Drag a line on the preview"), 5000); + if (auto* preview = m_imageWorkspace->activePreviewWidget()) + { + preview->startMeasurementLineDrawingForLayer(layerKey); + showStatusMessage(tr("Drag a line on the preview"), 5000); + } }); connect(m_inspectWidget, &InspectWidget::requestClearMeasurementLines, this, [this](const QString& layerKey) { - m_imageSceneModel->clearRole(ImageSceneModel::MarkupRole::Measurement, layerKey); + if (auto* sceneModel = m_imageWorkspace->activeSceneModel()) + { + sceneModel->clearRole( + ImageSceneModel::MarkupRole::Measurement, layerKey); + } m_inspectWidget->clearMeasurementLine(); }); - connect(m_previewWidget, &PreviewWidget::measurementLineDrawn, + connect(m_imageWorkspace, &ImageWorkspace::measurementLineDrawn, this, [this](const QString& layerKey, const QPoint& start, const QPoint& end) { - const QString markupId = m_imageSceneModel->createLine( - layerKey, - start, - end, - QString(), - ImageSceneModel::MarkupRole::Measurement); - m_imageSceneModel->selectOnly(markupId); - showMeasurementLine(layerKey, start, end); + if (auto* sceneModel = m_imageWorkspace->activeSceneModel()) + { + const QString markupId = sceneModel->createLine( + layerKey, + start, + end, + QString(), + ImageSceneModel::MarkupRole::Measurement); + sceneModel->selectOnly(markupId); + showMeasurementLine(layerKey, start, end); + } }); - connect(m_previewWidget, &PreviewWidget::measurementLineInspected, + connect(m_imageWorkspace, &ImageWorkspace::measurementLineInspected, this, [this](const QString& layerKey, const QPoint& start, const QPoint& end) { showMeasurementLine(layerKey, start, end); }); - connect(m_previewWidget, &PreviewWidget::measurementLineCleared, + connect(m_imageWorkspace, &ImageWorkspace::measurementLineCleared, m_inspectWidget, &InspectWidget::clearMeasurementLine); - connect(m_imageSceneModel, &ImageSceneModel::markupsChanged, - this, [this]() - { - for (const ImageSceneModel::Markup& markup : m_imageSceneModel->markups()) - { - if (markup.selected - && markup.type == ImageSceneModel::MarkupType::Line - && markup.role == ImageSceneModel::MarkupRole::Measurement) - { - showMeasurementLine(markup.layerKey, markup.start, markup.end); - return; - } - } - m_inspectWidget->clearMeasurementLine(); - }); - - m_inspectWidget->setAvailableLayers(m_previewWidget->availableLayerKeys()); - m_inspectWidget->setCurrentLayer(m_deviceControlWidget->currentLayerKey()); connect(m_imageProcessingWidget, &ImageProcessingWidget::processingStarted, this, [this]() { + m_imageWorkspace->activateLiveViewer(); setStatusLabelText(m_statusProcessingLabel, tr("Processing: Live"), tr("Processing is running")); showStatusMessage(tr("Image processing started"), 3000); - QStringList visibleLayerKeys = m_previewWidget->visibleLayerKeys(); const QStringList availableCameraIds = m_previewWidget->availableCameraIds(); - - for (const QString& layerKey : std::as_const(visibleLayerKeys)) + QStringList visibleLayerKeys; + for (const QString& cameraId : availableCameraIds) { - if (!scopeone::core::ScopeOneCore::isRawLayerKey(layerKey)) - { - continue; - } - const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); - if (cameraId.isEmpty() || !availableCameraIds.contains(cameraId)) - { - continue; - } - const QString processedLayerKey = scopeone::core::ScopeOneCore::processedLayerKey(cameraId); - if (!visibleLayerKeys.contains(processedLayerKey)) - { - visibleLayerKeys.append(processedLayerKey); - } - } - - if (visibleLayerKeys.isEmpty()) - { - for (const QString& cameraId : availableCameraIds) - { - visibleLayerKeys.append(scopeone::core::ScopeOneCore::processedLayerKey(cameraId)); - } + visibleLayerKeys.append(scopeone::core::ScopeOneCore::rawLayerKey(cameraId)); + visibleLayerKeys.append(scopeone::core::ScopeOneCore::processedLayerKey(cameraId)); } - m_imageSceneModel->setVisibleLayers(visibleLayerKeys); - m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::SideBySide); + m_imageWorkspace->setVisibleLayers(visibleLayerKeys, true); }); connect(m_imageProcessingWidget, &ImageProcessingWidget::processingStopped, this, [this]() @@ -627,69 +728,46 @@ namespace scopeone::ui tr("Processing: Off"), tr("Processing is off")); showStatusMessage(tr("Image processing stopped"), 3000); - QStringList visibleLayerKeys = rawOnlyLayerKeys(m_previewWidget->visibleLayerKeys()); - if (visibleLayerKeys.isEmpty()) - { - visibleLayerKeys = rawLayerKeys(m_previewWidget->availableCameraIds()); - } - m_imageSceneModel->setVisibleLayers(visibleLayerKeys); + const QStringList visibleLayerKeys = + rawLayerKeys(m_previewWidget->availableCameraIds()); + m_imageWorkspace->setVisibleLayers(visibleLayerKeys); + }); + connect(m_imageProcessingWidget, &ImageProcessingWidget::processedLayerReady, + this, [this](const QString& layerKey) + { + m_imageWorkspace->setActiveLayerKey(layerKey); + showLayers({layerKey}); + showStatusMessage(tr("Processed image added to preview"), 5000); + }); + connect(m_imageProcessingWidget, &ImageProcessingWidget::processedStackReady, + this, [this](const std::shared_ptr& session) + { + m_imageGalleryWidget->addSession(session, tr("Processed Stack")); + m_imageWorkspace->openSession(session, tr("Processed Stack")); }); - connect(m_exitAction, &QAction::triggered, this, &QWidget::close); - connect(m_fullScreenAction, &QAction::toggled, - this, &MainWindow::setFullScreenEnabled); - connect(m_aboutAction, &QAction::triggered, - this, [this]() { AboutDialog::showAbout(this); }); - connect(m_aboutQtAction, &QAction::triggered, qApp, &QApplication::aboutQt); - connect(m_loadConfigurationAction, &QAction::triggered, - this, &MainWindow::loadConfigurationFromDialog); - connect(m_unloadConfigurationAction, &QAction::triggered, - this, &MainWindow::unloadConfigurationWithConfirmation); - connect(m_stageMosaicAction, &QAction::triggered, - this, &MainWindow::openStageMosaicTool); - connect(m_particleDetectionAction, &QAction::triggered, - this, &MainWindow::openParticleDetectionTool); - connect(m_scaleAction, &QAction::triggered, - this, &MainWindow::openScaleDialog); - connect(m_settingsAction, &QAction::triggered, - this, &MainWindow::openSettingsDialog); - - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::stageMosaicFrameUpdated, - this, [this](const scopeone::core::ImageFrame&) + connect(m_imageWorkspace, &ImageWorkspace::sessionAvailable, + m_imageGalleryWidget, &ImageGalleryWidget::addSession); + connect(m_imageWorkspace, &ImageWorkspace::activeViewerChanged, + this, [this]() { - 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); + const bool liveViewer = m_imageWorkspace->isLiveViewerActive(); + m_deviceControlWidget->setPreviewWidget( + m_imageWorkspace->activePreviewWidget()); + m_deviceControlWidget->setViewerContext(liveViewer); + updateDimensionViewActions(); }); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::stageMosaicFinished, - this, - [this](const std::shared_ptr& session, - const QString& message, - bool) + connect(m_imageWorkspace, &ImageWorkspace::viewDimensionModeChanged, + this, &MainWindow::updateDimensionViewActions); + connect(m_imageWorkspace, &ImageWorkspace::activeDocumentChanged, + this, [this](const QString& documentId) { - 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")); - registerGallerySessionFrameControls(session, 0); - for (const QString& cameraId : session->recordedCameraIds()) - { - if (session->recordedFrameCount(cameraId) > 0) - { - updateGalleryLayerFrame( - scopeone::core::ScopeOneCore::staticLayerKey( - gallerySessionLayerId(session, cameraId)), - 0); - } - } - showStatusMessage(tr("Mosaic added to Gallery"), 5000); + m_saveImageAsAction->setEnabled(!documentId.isEmpty()); + }); + connect(m_imageWorkspace, &ImageWorkspace::documentSaveFinished, + this, [this](const QString&, bool success, const QString& message) + { + showStatusMessage(message, success ? 5000 : 8000); }); connect(m_recordingWidget, &RecordingWidget::gallerySessionCaptured, @@ -698,12 +776,28 @@ namespace scopeone::ui { updateSessionPresentation(*m_scopeonecore, *m_imageSceneModel, session); m_imageGalleryWidget->addSession(session); + m_imageGalleryDockWidget->show(); + m_imageGalleryDockWidget->raise(); + }); + connect(m_deviceControlWidget, &DeviceControlWidget::snapRequested, + this, [this](const QString& target) + { + if (!m_recordingWidget->snapToGallery(target)) + { + showStatusMessage(tr("No current frame is available to capture"), 5000); + } + else + { + showStatusMessage(tr("Snapshot captured"), 3000); + } }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::recordingStopped, this, [this](const std::shared_ptr& session) { m_imageGalleryWidget->addSession(session); + m_imageGalleryDockWidget->show(); + m_imageGalleryDockWidget->raise(); QTimer::singleShot(0, m_deviceControlWidget, [this]() { m_deviceControlWidget->refreshCameraParameters(); }); const QString result = session @@ -748,29 +842,30 @@ namespace scopeone::ui showStatusMessage(tr("No gallery image available for preview"), 5000); return; } - registerGallerySessionFrameControls(session, 0); - for (const QString& cameraId : session->recordedCameraIds()) + m_imageWorkspace->activateLiveViewer(); + const QString layerKey = m_scopeonecore->importSessionAsStaticLayer(session); + if (!layerKey.isEmpty()) { - if (session->recordedFrameCount(cameraId) > 0) - { - updateGalleryLayerFrame( - scopeone::core::ScopeOneCore::staticLayerKey( - gallerySessionLayerId(session, cameraId)), - 0); - } + m_imageWorkspace->setActiveLayerKey(layerKey); + showStatusMessage( + tr("Opened gallery layer: %1").arg(m_previewWidget->layerName(layerKey)), + 3000); } - showStatusMessage(tr("Loading gallery preview...")); }); - connect(m_imageGalleryWidget, &ImageGalleryWidget::livePreviewRequested, - this, &MainWindow::showLivePreview); connect(m_imageGalleryWidget, &ImageGalleryWidget::sessionRemoved, this, [this](const std::shared_ptr& session) { - removeGallerySessionFrameControls(session); - removeGallerySessionPreview(*m_scopeonecore, session); - m_scopeonecore->closeRecordingSession( - session->capturePlan().experimentId); + const QString expId = session->capturePlan().experimentId.trimmed(); + if (!expId.isEmpty()) + { + for (const QString& camera : session->recordedCameraIds()) + { + const QString sourceId = QStringLiteral("gallery:%1_%2").arg(expId, camera); + m_scopeonecore->removeStaticFrame(sourceId); + } + m_scopeonecore->closeRecordingSession(expId); + } }); connect(m_imageGalleryWidget, &ImageGalleryWidget::saveSessionsRequested, this, @@ -785,6 +880,61 @@ namespace scopeone::ui } } }); + connect(m_imageGalleryWidget, &ImageGalleryWidget::saveSessionAsRequested, + this, + [this](const std::shared_ptr& session) + { + const QString saveDir = QFileDialog::getExistingDirectory( + this, tr("Select Dataset Folder"), QDir::homePath()); + if (saveDir.isEmpty()) + { + return; + } + + bool accepted = false; + QString baseName = QInputDialog::getText( + this, + tr("Save Image Dataset As"), + tr("Dataset name and optional format suffix"), + QLineEdit::Normal, + session->capturePlan().baseName + QStringLiteral(".ome.tiff"), + &accepted) + .trimmed(); + if (!accepted || baseName.isEmpty()) + { + return; + } + + scopeone::core::ScopeOneCore::RecordingSaveOptions options; + if (baseName.endsWith(QStringLiteral(".ome.tiff"), Qt::CaseInsensitive)) + { + baseName.chop(9); + options.format = scopeone::core::RecordingFormat::OmeTiff; + } + else if (baseName.endsWith(QStringLiteral(".ome.zarr"), Qt::CaseInsensitive)) + { + baseName.chop(9); + options.format = scopeone::core::RecordingFormat::OmeZarr; + } + else if (baseName.endsWith(QStringLiteral(".tiff"), Qt::CaseInsensitive)) + { + baseName.chop(5); + options.format = scopeone::core::RecordingFormat::Tiff; + } + else if (baseName.endsWith(QStringLiteral(".bin"), Qt::CaseInsensitive)) + { + baseName.chop(4); + options.format = scopeone::core::RecordingFormat::Binary; + } + options.saveDir = saveDir; + options.baseName = baseName; + options.enableCompression = options.format != scopeone::core::RecordingFormat::Binary; + updateSessionPresentation(*m_scopeonecore, *m_imageSceneModel, session); + if (!m_scopeonecore->saveRecordingSession(session, options)) + { + showStatusMessage(tr("Could not start saving the selected session"), 5000); + } + }); connectPropertyPanels(); } @@ -823,7 +973,12 @@ namespace scopeone::ui void MainWindow::setupUI() { m_previewWidget = new PreviewWidget(m_imageSceneModel, this); - setCentralWidget(m_previewWidget); + m_previewWidget->setPixelSizeCallback([this](const QString& layerKey) + { + return m_imageWorkspace->pixelSizeUm(layerKey); + }); + setCentralWidget(m_imageWorkspace->viewerHost()); + setupTools(); setupStatusBar(); setupDeviceControl(); @@ -834,9 +989,116 @@ namespace scopeone::ui setupPropertyBrowser(); setupRecording(); setupImageGallery(); + setTabPosition(Qt::LeftDockWidgetArea, QTabWidget::North); updateDockWidgetMenu(); } + // Register built in tools and discover external tool plugins + void MainWindow::setupTools() + { + m_toolRegistry = std::make_unique(*this); + m_toolRegistry->registerTool( + {QStringLiteral("scopeone.scale"), tr("&Scale..."), {}, ToolWindowMode::Modal, true}, + [](ScopeOneToolContext& context, QWidget* parent) + { + return new CameraScaleDialog(&context.core(), parent); + }); + m_toolRegistry->registerTool( + {QStringLiteral("scopeone.stage_mosaic"), tr("Stage &Mosaic..."), {}, + ToolWindowMode::ModelessSingleton}, + [](ScopeOneToolContext& context, QWidget* parent) + { + return new StageMosaicDialog(context, parent); + }); + m_toolRegistry->registerTool( + {QStringLiteral("scopeone.particle_detection"), tr("&Particle Detection..."), {}, + ToolWindowMode::ModelessSingleton}, + [](ScopeOneToolContext& context, QWidget* parent) + { + return new ParticleDetectionDialog(context, parent); + }); + + const QString pluginDirectory = QDir(QCoreApplication::applicationDirPath()) + .filePath(QStringLiteral("plugins/tools")); + for (const QString& error : m_toolRegistry->loadPlugins(pluginDirectory)) + { + qWarning().noquote() << QStringLiteral("Failed to load tool plugin %1").arg(error); + } + const QString userPluginDirectory = + QDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)) + .filePath(QStringLiteral("plugins/tools")); + for (const QString& error : m_toolRegistry->loadPlugins(userPluginDirectory)) + { + qWarning().noquote() << QStringLiteral("Failed to load tool plugin %1").arg(error); + } + } + + scopeone::core::ScopeOneCore& MainWindow::core() const + { + return *m_scopeonecore; + } + + QString MainWindow::currentLayerKey() const + { + return m_imageWorkspace->activeLayerKey(); + } + + scopeone::core::ImageFrame MainWindow::currentFrame() const + { + return m_scopeonecore->graphFrame(currentLayerKey()); + } + + double MainWindow::layerFrameRate(const QString& layerKey) const + { + return m_scopeonecore->layerFrameRate(layerKey); + } + + QMap MainWindow::layerFrameRates() const + { + return m_scopeonecore->layerFrameRates(); + } + + scopeone::core::ImageFrame MainWindow::publishToolStreamFrame( + const QString& sourceId, + const scopeone::core::ImageFrame& frame, + const QString& displayName) + { + return m_scopeonecore->publishToolStreamFrame(sourceId, frame, displayName); + } + + void MainWindow::showLayers(const QStringList& layerKeys, bool sideBySide) + { + auto* activeScene = m_imageWorkspace->activeSceneModel(); + const bool belongsToLiveScene = std::all_of( + layerKeys.cbegin(), layerKeys.cend(), [this](const QString& layerKey) + { + return m_imageSceneModel->layerIds().contains(layerKey); + }); + if (belongsToLiveScene && activeScene != m_imageSceneModel) + { + m_imageWorkspace->activateLiveViewer(); + } + m_imageWorkspace->setVisibleLayers(layerKeys, sideBySide); + } + + void MainWindow::showToolStatus(const QString& message, int timeoutMs) + { + showStatusMessage(message, timeoutMs); + } + + void MainWindow::presentSession( + const std::shared_ptr& session, + const QString& title) + { + if (!session) + { + return; + } + m_imageGalleryWidget->addSession(session, title); + m_imageWorkspace->openSession(session, title); + showStatusMessage(tr("Images added to Gallery"), 5000); + } + // Create the shared status strip for transient and persistent state void MainWindow::setupStatusBar() { @@ -858,7 +1120,16 @@ namespace scopeone::ui m_statusRecordingLabel = new QLabel(tr("Recording: Idle"), this); configureStatusLabel(m_statusRecordingLabel, 120, 150, tr("Recording state")); + m_staticImportProgress = new QProgressBar(this); + m_staticImportProgress->setRange(0, 100); + m_staticImportProgress->setValue(0); + m_staticImportProgress->setTextVisible(false); + m_staticImportProgress->setFixedSize(120, 14); + m_staticImportProgress->setToolTip(tr("Image import progress")); + m_staticImportProgress->hide(); + bar->addWidget(m_statusMessageLabel, 1); + bar->addPermanentWidget(m_staticImportProgress); bar->addPermanentWidget(m_statusCursorLabel); bar->addPermanentWidget(m_statusPreviewLabel); bar->addPermanentWidget(m_statusProcessingLabel); @@ -881,46 +1152,227 @@ namespace scopeone::ui // Create application menus and persistent actions void MainWindow::setupMenuBar() { - m_fileMenu = menuBar()->addMenu(tr("&File")); - m_loadConfigurationAction = m_fileMenu->addAction(tr("&Load Configuration...")); - m_recentConfigurationsMenu = m_fileMenu->addMenu(tr("&Recent Configurations")); + auto* fileMenu = menuBar()->addMenu(tr("&File")); + m_loadConfigurationAction = fileMenu->addAction(tr("&Load Configuration...")); + connect(m_loadConfigurationAction, &QAction::triggered, + this, &MainWindow::loadConfigurationFromDialog); + m_recentConfigurationsMenu = fileMenu->addMenu(tr("&Recent Configurations")); connect(m_recentConfigurationsMenu, &QMenu::aboutToShow, this, &MainWindow::refreshRecentConfigurationsMenu); refreshRecentConfigurationsMenu(); - m_unloadConfigurationAction = m_fileMenu->addAction(tr("&Unload Configuration")); - m_fileMenu->addSeparator(); - m_exitAction = m_fileMenu->addAction(tr("E&xit")); - - m_viewMenu = menuBar()->addMenu(tr("&View")); - m_fullScreenAction = m_viewMenu->addAction(tr("&Full Screen")); - m_fullScreenAction->setCheckable(true); - m_viewMenu->addSeparator(); - m_dockWidgetsMenu = m_viewMenu->addMenu(tr("&Dock Widgets")); - - m_toolsMenu = menuBar()->addMenu(tr("&Tools")); - m_scaleAction = m_toolsMenu->addAction(tr("&Scale...")); - m_scaleAction->setEnabled(!m_scopeonecore->cameraIds().isEmpty()); - m_stageMosaicAction = m_toolsMenu->addAction(tr("Stage &Mosaic...")); - m_particleDetectionAction = m_toolsMenu->addAction(tr("&Particle Detection...")); - m_toolsMenu->addSeparator(); - m_settingsAction = m_toolsMenu->addAction(tr("&Settings...")); - - m_helpMenu = menuBar()->addMenu(tr("&Help")); - auto* reportProblemAction = m_helpMenu->addAction(tr("Report a &Problem...")); + m_unloadConfigurationAction = fileMenu->addAction(tr("&Unload Configuration")); + connect(m_unloadConfigurationAction, &QAction::triggered, + this, &MainWindow::unloadConfigurationWithConfirmation); + fileMenu->addSeparator(); + auto* importImageAction = fileMenu->addAction(tr("&Import Image as Layer...")); + importImageAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+I"))); + connect(importImageAction, &QAction::triggered, this, &MainWindow::openImportImageDialog); + m_saveImageAsAction = fileMenu->addAction(tr("Save Image &As...")); + m_saveImageAsAction->setEnabled(false); + connect(m_saveImageAsAction, &QAction::triggered, + m_imageWorkspace, [this]() { m_imageWorkspace->saveDocumentAs(); }); + fileMenu->addSeparator(); + auto* exitAction = fileMenu->addAction(tr("E&xit")); + connect(exitAction, &QAction::triggered, this, &QWidget::close); + + auto* viewMenu = menuBar()->addMenu(tr("&View")); + auto* fullScreenAction = viewMenu->addAction(tr("&Full Screen")); + fullScreenAction->setCheckable(true); + fullScreenAction->setShortcut(QKeySequence::FullScreen); + connect(fullScreenAction, &QAction::toggled, + this, &MainWindow::setFullScreenEnabled); + + auto* fitToWindowAction = viewMenu->addAction(tr("Fit to &Window")); + fitToWindowAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+0"))); + connect(fitToWindowAction, &QAction::triggered, this, [this]() + { + m_previewWidget->setFitToWindow(true); + }); + + auto* actualSizeAction = viewMenu->addAction(tr("&Actual Size (100%)")); + actualSizeAction->setShortcut(QKeySequence(QStringLiteral("Ctrl+1"))); + connect(actualSizeAction, &QAction::triggered, this, [this]() + { + m_previewWidget->setFitToWindow(false); + m_previewWidget->setZoomPercent(100); + }); + + auto* zoomInAction = viewMenu->addAction(tr("Zoom &In")); + zoomInAction->setShortcuts({QKeySequence::ZoomIn, QKeySequence(QStringLiteral("Ctrl+=")), QKeySequence(QStringLiteral("Ctrl++"))}); + connect(zoomInAction, &QAction::triggered, this, [this]() + { + m_previewWidget->setFitToWindow(false); + m_previewWidget->setZoomPercent(m_previewWidget->zoomPercent() + 20); + }); + + auto* zoomOutAction = viewMenu->addAction(tr("Zoom &Out")); + zoomOutAction->setShortcut(QKeySequence::ZoomOut); + connect(zoomOutAction, &QAction::triggered, this, [this]() + { + m_previewWidget->setFitToWindow(false); + m_previewWidget->setZoomPercent(m_previewWidget->zoomPercent() - 20); + }); + + viewMenu->addSeparator(); + + auto* scaleBarAction = viewMenu->addAction(tr("Show &Scale Bar")); + scaleBarAction->setCheckable(true); + scaleBarAction->setChecked(m_previewWidget->isScaleBarVisible()); + connect(scaleBarAction, &QAction::toggled, m_previewWidget, &PreviewWidget::setScaleBarVisible); + connect(m_previewWidget, &PreviewWidget::scaleBarVisibilityChanged, scaleBarAction, &QAction::setChecked); + + auto* clippingAction = viewMenu->addAction(tr("Show &Saturation Warning (Hi-Lo)")); + clippingAction->setCheckable(true); + clippingAction->setShortcut(QKeySequence(Qt::Key_C)); + clippingAction->setChecked(m_previewWidget->isClippingWarningEnabled()); + connect(clippingAction, &QAction::toggled, m_previewWidget, &PreviewWidget::setClippingWarningEnabled); + connect(m_previewWidget, &PreviewWidget::clippingWarningChanged, clippingAction, &QAction::setChecked); + + auto* toggleLayoutAction = viewMenu->addAction(tr("Toggle &Grid / Overlay Layout")); + toggleLayoutAction->setShortcut(QKeySequence(Qt::Key_G)); + toggleLayoutAction->setShortcutContext(Qt::ApplicationShortcut); + connect(toggleLayoutAction, &QAction::triggered, this, [this]() + { + QWidget* focus = focusWidget(); + if (focus && (qobject_cast(focus) || qobject_cast(focus) || qobject_cast(focus))) + { + return; + } + if (m_previewWidget->layerLayoutMode() == PreviewWidget::LayerLayoutMode::SideBySide) + { + m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); + showStatusMessage(tr("Layout: Overlay Blended"), 2000); + } + else + { + m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::SideBySide); + showStatusMessage(tr("Layout: Grid Split View"), 2000); + } + }); + addAction(toggleLayoutAction); + + m_toggleDimensionAction = viewMenu->addAction(tr("3D Surface View")); + m_toggleDimensionAction->setCheckable(true); + m_toggleDimensionAction->setShortcuts({QKeySequence(QStringLiteral("Ctrl+3")), + QKeySequence(Qt::Key_D)}); + m_toggleDimensionAction->setShortcutContext(Qt::ApplicationShortcut); + connect(m_toggleDimensionAction, &QAction::toggled, this, [this](bool enabled) + { + if (auto* preview = m_imageWorkspace->activePreviewWidget()) + { + preview->setViewDimensionMode( + enabled ? PreviewWidget::ViewDimensionMode::ThreeDimensional + : PreviewWidget::ViewDimensionMode::TwoDimensional); + } + }); + addAction(m_toggleDimensionAction); + + m_reset3dAction = viewMenu->addAction(tr("Reset 3D Camera")); + connect(m_reset3dAction, &QAction::triggered, this, + [this]() { + if (auto* preview = m_imageWorkspace->activePreviewWidget()) + { + preview->reset3dCamera(); + } + }); + m_reset3dAction->setEnabled(false); + + m_toggle3dColorbarAction = viewMenu->addAction(tr("3D Colorbar")); + m_toggle3dColorbarAction->setCheckable(true); + m_toggle3dColorbarAction->setChecked(m_previewWidget->isThreeDimensionalColorbarVisible()); + connect(m_toggle3dColorbarAction, &QAction::toggled, + this, [this](bool visible) + { + if (auto* preview = m_imageWorkspace->activePreviewWidget()) + { + preview->setThreeDimensionalColorbarVisible(visible); + } + }); + connect(m_previewWidget, &PreviewWidget::threeDimensionalColorbarVisibilityChanged, + m_toggle3dColorbarAction, &QAction::setChecked); + + viewMenu->addSeparator(); + m_dockWidgetsMenu = viewMenu->addMenu(tr("&Dock Widgets")); + + auto* togglePreviewAction = new QAction(tr("Toggle Live Preview"), this); + togglePreviewAction->setShortcut(QKeySequence(Qt::Key_Space)); + togglePreviewAction->setShortcutContext(Qt::ApplicationShortcut); + connect(togglePreviewAction, &QAction::triggered, this, [this]() + { + QWidget* focus = focusWidget(); + if (focus && (qobject_cast(focus) || qobject_cast(focus) || qobject_cast(focus))) + { + return; + } + if (m_previewRunning) + { + m_scopeonecore->stopPreview(m_currentControlTarget); + } + else + { + m_scopeonecore->startPreview(m_currentControlTarget); + } + }); + addAction(togglePreviewAction); + + auto* snapAction = new QAction(tr("Snap"), this); + snapAction->setShortcuts({QKeySequence(Qt::CTRL | Qt::Key_Return), QKeySequence(Qt::CTRL | Qt::Key_Enter)}); + snapAction->setShortcutContext(Qt::ApplicationShortcut); + connect(snapAction, &QAction::triggered, this, [this]() + { + m_recordingWidget->snapToGallery(m_currentControlTarget); + }); + addAction(snapAction); + + auto* autoContrastAction = new QAction(tr("Auto Contrast"), this); + autoContrastAction->setShortcut(QKeySequence(Qt::Key_A)); + autoContrastAction->setShortcutContext(Qt::ApplicationShortcut); + connect(autoContrastAction, &QAction::triggered, this, [this]() + { + QWidget* focus = focusWidget(); + if (focus && (qobject_cast(focus) || qobject_cast(focus) || qobject_cast(focus))) + { + return; + } + const QString activeLayer = m_imageWorkspace->activeLayerKey(); + if (!activeLayer.isEmpty()) + { + m_imageWorkspace->autoLayerLevels(activeLayer); + } + }); + addAction(autoContrastAction); + + auto* toolsMenu = menuBar()->addMenu(tr("&Tools")); + m_toolRegistry->populateMenu(toolsMenu, this); + toolsMenu->addSeparator(); + auto* pluginManagerAction = toolsMenu->addAction(tr("Plugin &Manager...")); + connect(pluginManagerAction, &QAction::triggered, this, [this]() + { + PluginManagerDialog(this).exec(); + }); + auto* settingsAction = toolsMenu->addAction(tr("&Settings...")); + connect(settingsAction, &QAction::triggered, + this, &MainWindow::openSettingsDialog); + + auto* helpMenu = menuBar()->addMenu(tr("&Help")); + auto* reportProblemAction = helpMenu->addAction(tr("Report a &Problem...")); connect(reportProblemAction, &QAction::triggered, this, []() { QDesktopServices::openUrl(QUrl(QStringLiteral( "https://github.com/Experimental-Microscopy-Lab/ScopeOne/issues"))); }); - m_helpMenu->addSeparator(); - m_aboutQtAction = m_helpMenu->addAction(tr("About &Qt")); - m_aboutAction = m_helpMenu->addAction(tr("&About ScopeOne")); + helpMenu->addSeparator(); + auto* aboutQtAction = helpMenu->addAction(tr("About &Qt")); + connect(aboutQtAction, &QAction::triggered, qApp, &QApplication::aboutQt); + auto* aboutAction = helpMenu->addAction(tr("&About ScopeOne")); + connect(aboutAction, &QAction::triggered, + this, [this]() { AboutDialog::showAbout(this); }); } // Create the camera control dock void MainWindow::setupDeviceControl() { - m_deviceControlDockWidget = new QDockWidget(tr("Control"), this); m_deviceControlWidget = new DeviceControlWidget(m_scopeonecore, this); + m_deviceControlWidget->setImageWorkspace(m_imageWorkspace); m_deviceControlWidget->setPreviewWidget(m_previewWidget); connect(m_deviceControlWidget, &DeviceControlWidget::stageMoveFailed, this, [this](const QString& message) @@ -928,77 +1380,75 @@ namespace scopeone::ui showStatusMessage(message, 5000); qWarning().noquote() << message; }); - m_deviceControlDockWidget->setWidget(m_deviceControlWidget); - - addDockWidget(Qt::RightDockWidgetArea, m_deviceControlDockWidget); } // Create the image inspection dock void MainWindow::setupInspect() { - m_inspectDockWidget = new QDockWidget(tr("Inspect"), this); - m_inspectDockWidget->setFeatures(QDockWidget::DockWidgetMovable | - QDockWidget::DockWidgetFloatable | - QDockWidget::DockWidgetClosable); - - m_inspectWidget = new InspectWidget(m_scopeonecore, this); - m_inspectDockWidget->setWidget(m_inspectWidget); - - addDockWidget(Qt::RightDockWidgetArea, m_inspectDockWidget); - tabifyDockWidget(m_deviceControlDockWidget, m_inspectDockWidget); + m_inspectWidget = new InspectWidget(m_scopeonecore, m_imageWorkspace, this); } // Create the processing module dock void MainWindow::setupImageProcessing() { - m_imageProcessingDockWidget = new QDockWidget(tr("Image Processing"), this); - m_imageProcessingDockWidget->setFeatures(QDockWidget::DockWidgetMovable | - QDockWidget::DockWidgetFloatable | - QDockWidget::DockWidgetClosable); + m_imageProcessingWidget = new ImageProcessingWidget(m_scopeonecore, m_imageWorkspace, this); - m_imageProcessingWidget = new ImageProcessingWidget(m_scopeonecore, this); - m_imageProcessingDockWidget->setWidget(m_imageProcessingWidget); + m_controlDockWidget = new QDockWidget(tr("Control"), this); + m_controlDockWidget->setWidget(m_deviceControlWidget->hardwareControlsWidget()); + m_controlDockWidget->setAllowedAreas(Qt::RightDockWidgetArea); + addDockWidget(Qt::RightDockWidgetArea, m_controlDockWidget); - addDockWidget(Qt::RightDockWidgetArea, m_imageProcessingDockWidget); - tabifyDockWidget(m_inspectDockWidget, m_imageProcessingDockWidget); - m_deviceControlDockWidget->raise(); - } + m_viewDockWidget = new QDockWidget(tr("View"), this); + m_viewDockWidget->setWidget(m_deviceControlWidget->imageControlsWidget()); + m_viewDockWidget->setAllowedAreas(Qt::RightDockWidgetArea); + tabifyDockWidget(m_controlDockWidget, m_viewDockWidget); + + m_analyzeDockWidget = new QDockWidget(tr("Analyze"), this); + m_analyzeDockWidget->setWidget(m_inspectWidget); + m_analyzeDockWidget->setAllowedAreas(Qt::RightDockWidgetArea); + tabifyDockWidget(m_controlDockWidget, m_analyzeDockWidget); + + m_processDockWidget = new QDockWidget(tr("Process"), this); + m_processDockWidget->setWidget(m_imageProcessingWidget); + m_processDockWidget->setAllowedAreas(Qt::RightDockWidgetArea); + tabifyDockWidget(m_controlDockWidget, m_processDockWidget); - // Create the log console dock and install the Qt message sink - void MainWindow::setupConsole() - { m_consoleDockWidget = new QDockWidget(tr("Console"), this); - m_consoleWidget = new ConsoleWidget(this); + m_consoleWidget = new ConsoleWidget(m_consoleDockWidget); m_consoleDockWidget->setWidget(m_consoleWidget); + m_consoleDockWidget->setAllowedAreas(Qt::RightDockWidgetArea); + tabifyDockWidget(m_controlDockWidget, m_consoleDockWidget); + m_controlDockWidget->raise(); + } + // Install the Qt message sink for the embedded console + void MainWindow::setupConsole() + { ConsoleWidget::installAsQtMessageSink(m_consoleWidget); - - addDockWidget(Qt::RightDockWidgetArea, m_consoleDockWidget); - splitDockWidget(m_deviceControlDockWidget, m_consoleDockWidget, Qt::Vertical); } // Create the device property and config preset dock void MainWindow::setupPropertyBrowser() { - m_propertyDockWidget = new QDockWidget(tr("Device Properties"), this); - m_propertyDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + m_propertyDockWidget = new QDockWidget(tr("Properties"), this); + m_propertyDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea); m_propertyBrowser = new DevicePropertyWidget(m_scopeonecore, this); m_configPresetWidget = new ConfigPresetWidget(m_scopeonecore, this); - auto* tabWidget = new QTabWidget(m_propertyDockWidget); - tabWidget->addTab(m_propertyBrowser, tr("Properties")); - tabWidget->addTab(m_configPresetWidget, tr("Configs")); - m_propertyDockWidget->setWidget(tabWidget); - + m_propertyDockWidget->setWidget(m_propertyBrowser); addDockWidget(Qt::LeftDockWidgetArea, m_propertyDockWidget); + + m_configPresetDockWidget = new QDockWidget(tr("Configs"), this); + m_configPresetDockWidget->setWidget(m_configPresetWidget); + m_configPresetDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea); } // Create the recording control dock void MainWindow::setupRecording() { m_recordingDockWidget = new QDockWidget(tr("Recording"), this); - m_recordingDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + m_recordingDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea); m_recordingWidget = new RecordingWidget(m_scopeonecore, this); m_recordingDockWidget->setWidget(m_recordingWidget); @@ -1011,7 +1461,7 @@ namespace scopeone::ui void MainWindow::setupImageGallery() { m_imageGalleryDockWidget = new QDockWidget(tr("Image Gallery"), this); - m_imageGalleryDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + m_imageGalleryDockWidget->setAllowedAreas(Qt::LeftDockWidgetArea); m_imageGalleryDockWidget->setFeatures(QDockWidget::DockWidgetMovable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetClosable); @@ -1019,9 +1469,11 @@ namespace scopeone::ui m_imageGalleryWidget = new ImageGalleryWidget(m_scopeonecore, this); m_imageGalleryDockWidget->setWidget(m_imageGalleryWidget); + addDockWidget(Qt::LeftDockWidgetArea, m_configPresetDockWidget); + tabifyDockWidget(m_propertyDockWidget, m_configPresetDockWidget); addDockWidget(Qt::LeftDockWidgetArea, m_imageGalleryDockWidget); - tabifyDockWidget(m_recordingDockWidget, m_imageGalleryDockWidget); - m_recordingDockWidget->raise(); + tabifyDockWidget(m_propertyDockWidget, m_imageGalleryDockWidget); + m_propertyDockWidget->raise(); } // Close the modal configuration progress dialog if present @@ -1047,29 +1499,24 @@ namespace scopeone::ui return; } + m_imageWorkspace->activateLiveViewer(); + m_scopeonecore->clearStaticFrames(); if (m_currentControlTarget.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) { - m_imageSceneModel->setVisibleLayers(rawLayerKeys(cameraIds)); - m_previewWidget->setLayerLayoutMode(cameraIds.size() > 1 - ? PreviewWidget::LayerLayoutMode::SideBySide - : PreviewWidget::LayerLayoutMode::Overlay); + m_imageWorkspace->setVisibleLayers(rawLayerKeys(cameraIds), cameraIds.size() > 1); return; } if (cameraIds.contains(m_currentControlTarget)) { - m_imageSceneModel->setVisibleLayers( + m_imageWorkspace->setVisibleLayers( {scopeone::core::ScopeOneCore::rawLayerKey(m_currentControlTarget)}); - m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); return; } - m_imageSceneModel->setVisibleLayers(rawLayerKeys(cameraIds)); - m_previewWidget->setLayerLayoutMode(cameraIds.size() > 1 - ? PreviewWidget::LayerLayoutMode::SideBySide - : PreviewWidget::LayerLayoutMode::Overlay); + m_imageWorkspace->setVisibleLayers(rawLayerKeys(cameraIds), cameraIds.size() > 1); } // Switch the active camera target and preview selection @@ -1115,13 +1562,15 @@ namespace scopeone::ui m_dockWidgetsMenu->addAction(action); }; - addDock(m_propertyDockWidget, QStringLiteral("Device Properties")); + addDock(m_propertyDockWidget, QStringLiteral("Properties")); + addDock(m_configPresetDockWidget, QStringLiteral("Configs")); addDock(m_recordingDockWidget, QStringLiteral("Recording")); addDock(m_imageGalleryDockWidget, QStringLiteral("Image Gallery")); + addDock(m_controlDockWidget, QStringLiteral("Control")); + addDock(m_viewDockWidget, QStringLiteral("View")); + addDock(m_analyzeDockWidget, QStringLiteral("Analyze")); + addDock(m_processDockWidget, QStringLiteral("Process")); addDock(m_consoleDockWidget, QStringLiteral("Console")); - addDock(m_deviceControlDockWidget, QStringLiteral("Control")); - addDock(m_inspectDockWidget, QStringLiteral("Inspect")); - addDock(m_imageProcessingDockWidget, QStringLiteral("Image Processing")); } // Push loaded camera ids into every dependent panel @@ -1135,15 +1584,30 @@ namespace scopeone::ui if (cameraIds.size() > 1) { - m_imageSceneModel->setVisibleLayers(rawLayerKeys(cameraIds)); + m_imageWorkspace->setVisibleLayers(rawLayerKeys(cameraIds), true); } else if (!cameraIds.isEmpty()) { - m_imageSceneModel->setVisibleLayers( + m_imageWorkspace->setVisibleLayers( {scopeone::core::ScopeOneCore::rawLayerKey(cameraIds.first())}); } m_recordingWidget->setAvailableCameras(cameraIds); + m_toolRegistry->updateActions(); + } + + // Synchronizes camera lists and UI state based on available devices + void MainWindow::syncCameraState() + { + const QStringList cameraIds = m_scopeonecore->cameraIds(); + if (cameraIds.isEmpty()) + { + applyNoCameraState(); + } + else + { + applyLoadedCameraState(cameraIds); + } } // Clear preview and panel state when no camera is available @@ -1162,6 +1626,7 @@ namespace scopeone::ui m_inspectWidget->clearCrossSectionProfile(); m_recordingWidget->setAvailableCameras({}); + m_toolRegistry->updateActions(); } // Refresh panels that mirror device state @@ -1177,6 +1642,9 @@ namespace scopeone::ui { constexpr qint64 kDefaultRecordedMaxBytes = 16ll * 1024 * 1024 * 1024; QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + applyWidgetStyle(settings.value( + QStringLiteral("Appearance/Style"), + QStringLiteral("default")).toString()); applyColorScheme(settings.value( QStringLiteral("Appearance/ColorScheme"), QStringLiteral("system")).toString()); @@ -1216,9 +1684,24 @@ namespace scopeone::ui m_consoleWidget->addMessage( QStringLiteral("External device adapters: %1").arg(adapterPaths.join(QStringLiteral("; ")))); } + updateDimensionViewActions(); showStatusMessage(tr("ScopeOne ready"), 3000); } + void MainWindow::updateDimensionViewActions() + { + PreviewWidget* preview = m_imageWorkspace->activePreviewWidget(); + const bool threeDimensional = + preview->viewDimensionMode() == PreviewWidget::ViewDimensionMode::ThreeDimensional; + { + const QSignalBlocker blocker(m_toggleDimensionAction); + m_toggleDimensionAction->setChecked(threeDimensional); + } + m_toggleDimensionAction->setEnabled(true); + m_reset3dAction->setEnabled(threeDimensional); + m_toggle3dColorbarAction->setEnabled(threeDimensional); + } + // Show one transient status message without disturbing persistent fields void MainWindow::showStatusMessage(const QString& message, int timeoutMs) { @@ -1253,21 +1736,54 @@ namespace scopeone::ui return; } - PreviewWidget::PreviewInteractionTarget target; - if (!m_previewWidget->interactionTargetAt(m_lastPreviewMousePos, target)) + PreviewWidget* preview = m_imageWorkspace->activePreviewWidget(); + if (!preview) { clearCursorStatus(); return; } - int value = 0; - const bool valueOk = m_scopeonecore->graphPixelValue(target.layerKey, target.imagePos, value); - const QString msg = QStringLiteral("x=%1 y=%2 value=%3") - .arg(target.imagePos.x(), 5, 10, QLatin1Char(' ')) - .arg(target.imagePos.y(), 5, 10, QLatin1Char(' ')) - .arg(valueOk ? QString::number(value) : QStringLiteral("-"), - 6, - QLatin1Char(' ')); + const QVector targets = + preview->interactionTargetsAt(m_lastPreviewMousePos); + if (targets.isEmpty()) + { + clearCursorStatus(); + return; + } + + const PreviewWidget::PreviewInteractionTarget& activeTarget = targets.constLast(); + const auto valueText = [this](const PreviewWidget::PreviewInteractionTarget& target) + { + int value = 0; + return m_imageWorkspace->pixelValue(target.layerKey, target.imagePos, value) + ? QString::number(value) + : QStringLiteral("-"); + }; + + QString msg; + if (preview->layerLayoutMode() == PreviewWidget::LayerLayoutMode::SideBySide) + { + msg = QStringLiteral("[%1] X: %2 Y: %3 | Val: %4") + .arg(preview->layerName(activeTarget.layerKey)) + .arg(activeTarget.imagePos.x()) + .arg(activeTarget.imagePos.y()) + .arg(valueText(activeTarget)); + } + else + { + QStringList values; + values.reserve(targets.size()); + for (const auto& target : targets) + { + values.append(QStringLiteral("[%1]: %2") + .arg(preview->layerName(target.layerKey)) + .arg(valueText(target))); + } + msg = QStringLiteral("X: %1 Y: %2 | %3") + .arg(activeTarget.imagePos.x()) + .arg(activeTarget.imagePos.y()) + .arg(values.join(QStringLiteral(" "))); + } setCursorStatus(msg); } @@ -1288,20 +1804,10 @@ namespace scopeone::ui const QPoint& end) { double actualLengthUm = 0.0; - double pixelSizeUm = 0.0; - const auto galleryControl = m_galleryLayerFrameControls.constFind(layerKey); - if (galleryControl != m_galleryLayerFrameControls.constEnd() - && galleryControl->session) - { - pixelSizeUm = galleryControl->session->cameraPixelSizeUm(galleryControl->cameraId); - } - else - { - const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); - pixelSizeUm = m_scopeonecore->cameraPixelSizeUm(cameraId); - } + const double pixelSizeUm = m_imageWorkspace->pixelSizeUm(layerKey); scopeone::core::DocumentLayer layer; - if (pixelSizeUm > 0.0 && m_imageSceneModel->findLayer(layerKey, layer)) + if (pixelSizeUm > 0.0 + && m_imageWorkspace->activeSceneModel()->findLayer(layerKey, layer)) { const QPointF sensorStart = layer.pixelToSensor.map(QPointF(start)); const QPointF sensorEnd = layer.pixelToSensor.map(QPointF(end)); @@ -1312,170 +1818,15 @@ namespace scopeone::ui m_inspectWidget->setMeasurementLine(layerKey, start, end, actualLengthUm); } - // Registers right panel frame sliders for stack backed gallery layers - void MainWindow::registerGallerySessionFrameControls( - const std::shared_ptr& session, - int frameIndex) - { - if (!session) - { - return; - } - - for (const QString& cameraId : session->recordedCameraIds()) - { - const QString layerKey = scopeone::core::ScopeOneCore::staticLayerKey( - gallerySessionLayerId(session, cameraId)); - const int frameCount = uiFrameCount(session->recordedFrameCount(cameraId)); - m_galleryLayerFrameControls.insert(layerKey, {session, cameraId}); - if (frameCount <= 1) - { - m_deviceControlWidget->removeLayerFrameControl(layerKey); - continue; - } - - m_deviceControlWidget->setLayerFrameControl( - layerKey, - frameCount, - qBound(0, frameIndex, frameCount - 1)); - } - } - - // Removes right panel frame sliders for one gallery session - void MainWindow::removeGallerySessionFrameControls( - const std::shared_ptr& session) - { - if (!session) - { - return; - } - - for (const QString& cameraId : session->recordedCameraIds()) - { - const QString layerKey = scopeone::core::ScopeOneCore::staticLayerKey( - gallerySessionLayerId(session, cameraId)); - m_galleryLayerFrameControls.remove(layerKey); - m_galleryFrameRequests.remove(layerKey); - m_deviceControlWidget->removeLayerFrameControl(layerKey); - } - } - - // Updates gallery static layers when a layer frame slider moves - void MainWindow::updateGalleryLayerFrame(const QString& layerKey, int frameIndex) - { - const auto it = m_galleryLayerFrameControls.constFind(layerKey); - if (it == m_galleryLayerFrameControls.constEnd()) - { - return; - } - - const auto session = it.value().session; - if (!session) - { - return; - } - - const QString cameraId = it.value().cameraId; - const qint64 cameraFrameCount = session->recordedFrameCount(cameraId); - if (cameraFrameCount <= 0) - { - return; - } - - const int cameraFrameIndex = static_cast( - qBound(0, static_cast(frameIndex), cameraFrameCount - 1)); - GalleryFrameRequestState& state = m_galleryFrameRequests[layerKey]; - state.latestFrameIndex = cameraFrameIndex; - if (state.requestId == 0) - { - requestLatestGalleryFrame(layerKey); - } - } - - // Starts the newest pending frame read for one gallery layer - void MainWindow::requestLatestGalleryFrame(const QString& layerKey) - { - const auto controlIt = m_galleryLayerFrameControls.constFind(layerKey); - auto stateIt = m_galleryFrameRequests.find(layerKey); - if (controlIt == m_galleryLayerFrameControls.constEnd() - || stateIt == m_galleryFrameRequests.end() - || stateIt->requestId != 0) - { - return; - } - - stateIt->requestId = m_scopeonecore->requestRecordingSessionFrame( - controlIt->session, - controlIt->cameraId, - stateIt->latestFrameIndex); - } - - // Displays a decoded frame only if it is still the latest slider request - void MainWindow::handleGalleryFrameReady( - quint64 requestId, - const std::shared_ptr& session, - const QString& cameraId, - int frameIndex, - const scopeone::core::ImageFrame& frame) - { - const QString layerKey = scopeone::core::ScopeOneCore::staticLayerKey( - gallerySessionLayerId(session, cameraId)); - auto stateIt = m_galleryFrameRequests.find(layerKey); - if (stateIt == m_galleryFrameRequests.end() || stateIt->requestId != requestId) - { - return; - } - - stateIt->requestId = 0; - if (stateIt->latestFrameIndex != frameIndex) - { - requestLatestGalleryFrame(layerKey); - return; - } - if (!frame.isValid()) - { - showStatusMessage(tr("Failed to load gallery frame"), 5000); - return; - } - - const QString layerId = gallerySessionLayerId(session, cameraId); - const qint64 cameraFrameCount = session->recordedFrameCount(cameraId); - const QString displayName = cameraFrameCount > 1 - ? tr("Gallery %1 Frame %2").arg(cameraId).arg(frameIndex + 1) - : tr("Gallery %1").arg(cameraId); - const scopeone::core::ImageFrame graphFrame = m_scopeonecore->publishStaticFrame( - layerId, - frame, - displayName); - if (!graphFrame.isValid()) - { - return; - } - - QStringList visibleLayers = m_imageSceneModel->visibleLayerIds(); - if (!visibleLayers.contains(layerKey)) - { - visibleLayers.append(layerKey); - m_imageSceneModel->setVisibleLayers(visibleLayers); - } - m_previewWidget->setLayerLayoutMode( - visibleLayers.size() > 1 - ? PreviewWidget::LayerLayoutMode::SideBySide - : PreviewWidget::LayerLayoutMode::Overlay); - if (cameraFrameCount > 1) - { - m_deviceControlWidget->setLayerFrameControl( - layerKey, uiFrameCount(cameraFrameCount), frameIndex); - } - showStatusMessage(tr("Gallery %1 frame %2").arg(cameraId).arg(frameIndex + 1), 1500); - } - // Edit persistent application settings void MainWindow::openSettingsDialog() { constexpr qint64 kDefaultRecordedMaxBytes = 16ll * 1024 * 1024 * 1024; const qint64 currentValue = m_scopeonecore->recordingMaxPendingWriteBytes(); QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + const QString widgetStyle = settings.value( + QStringLiteral("Appearance/Style"), + QStringLiteral("default")).toString(); const QString colorScheme = settings.value( QStringLiteral("Appearance/ColorScheme"), QStringLiteral("system")).toString(); @@ -1483,6 +1834,7 @@ namespace scopeone::ui const QStringList adapterPaths = m_scopeonecore->additionalDeviceAdapterSearchPaths(); SettingsDialog dialog(currentValue > 0 ? currentValue : kDefaultRecordedMaxBytes, adapterPaths.value(0), + widgetStyle, colorScheme, this); if (dialog.exec() != QDialog::Accepted) @@ -1500,51 +1852,38 @@ namespace scopeone::ui tr("The device adapter directory could not be updated.")); return; } + const QString selectedWidgetStyle = dialog.widgetStyle(); const QString selectedColorScheme = dialog.colorScheme(); settings.setValue(QStringLiteral("Recording/MaxPendingWriteBytes"), recordedMaxBytes); settings.setValue(QStringLiteral("Hardware/MicroManagerDirectory"), microManagerDirectory); + settings.setValue(QStringLiteral("Appearance/Style"), selectedWidgetStyle); settings.setValue(QStringLiteral("Appearance/ColorScheme"), selectedColorScheme); m_scopeonecore->setRecordingMaxPendingWriteBytes(recordedMaxBytes); + applyWidgetStyle(selectedWidgetStyle); applyColorScheme(selectedColorScheme); showStatusMessage( tr("Settings updated"), 5000); } - // Edit the global per camera image scale - void MainWindow::openScaleDialog() - { - CameraScaleDialog dialog(m_scopeonecore, this); - dialog.exec(); - } - - // Open the stage driven image mosaic tool - void MainWindow::openStageMosaicTool() + // Open file dialog and import image files as static layers + void MainWindow::openImportImageDialog() { - if (!m_stageMosaicDialog) - { - auto* dialog = new StageMosaicDialog(m_scopeonecore, m_previewWidget, this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->setModal(false); - m_stageMosaicDialog = dialog; - } - m_stageMosaicDialog->show(); - m_stageMosaicDialog->raise(); - m_stageMosaicDialog->activateWindow(); + const QStringList filePaths = QFileDialog::getOpenFileNames( + this, + tr("Import Image as Layer"), + QString(), + tr("Images (*.tif *.tiff *.png *.jpg *.jpeg *.bmp)")); + importImages(filePaths); } - // Open the OpenCV particle detection tool - void MainWindow::openParticleDetectionTool() + // Import multiple image files as static layers into the workspace + void MainWindow::importImages(const QStringList& filePaths) { - if (!m_particleDetectionDialog) + for (const QString& filePath : filePaths) { - m_particleDetectionDialog = new ParticleDetectionDialog(m_scopeonecore, m_previewWidget, this); - m_particleDetectionDialog->setAttribute(Qt::WA_DeleteOnClose); - m_particleDetectionDialog->setModal(false); + m_scopeonecore->importImageAsStaticLayerAsync(filePath); } - m_particleDetectionDialog->show(); - m_particleDetectionDialog->raise(); - m_particleDetectionDialog->activateWindow(); } // Display image coordinates and pixel value under the cursor @@ -1712,6 +2051,7 @@ namespace scopeone::ui if (!session || !session->isSaved()) { m_closeSaveInProgress = false; + m_closeAfterSave = false; m_closePendingSessions.clear(); if (m_closeSaveProgress) { @@ -1733,6 +2073,8 @@ namespace scopeone::ui } m_closeSaveInProgress = false; + const bool closeAfterSave = m_closeAfterSave; + m_closeAfterSave = false; if (m_closeSaveProgress) { m_closeSaveProgress->setValue(m_closeSaveTotal); @@ -1740,7 +2082,10 @@ namespace scopeone::ui m_closeSaveProgress->deleteLater(); m_closeSaveProgress = nullptr; } - QTimer::singleShot(0, this, &QWidget::close); + if (closeAfterSave) + { + QTimer::singleShot(0, this, &QWidget::close); + } } // Load a Micro Manager config selected by the user diff --git a/src/MainWindow.h b/src/MainWindow.h index 17020d3..f7086fa 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -1,8 +1,8 @@ #pragma once #include "scopeone/ScopeOneCore.h" +#include "ScopeOneToolPlugin.h" -#include #include #include #include @@ -15,6 +15,7 @@ class QCloseEvent; class QDockWidget; class QLabel; class QMenu; +class QProgressBar; class QProgressDialog; class QTimer; @@ -29,16 +30,15 @@ namespace scopeone::ui class DevicePropertyWidget; class ConfigPresetWidget; class ImageGalleryWidget; + class ImageWorkspace; class ImageProcessingWidget; class InspectWidget; class PreviewWidget; class ConsoleWidget; class DeviceControlWidget; class RecordingWidget; - class StageMosaicDialog; - class ParticleDetectionDialog; - - class MainWindow : public QMainWindow + class ScopeOneLocalApiServer; + class MainWindow : public QMainWindow, public ScopeOneToolContext { Q_OBJECT @@ -46,6 +46,21 @@ namespace scopeone::ui explicit MainWindow(scopeone::core::ScopeOneCore* core, QWidget* parent = nullptr); ~MainWindow() override = default; + scopeone::core::ScopeOneCore& core() const override; + QString currentLayerKey() const override; + scopeone::core::ImageFrame currentFrame() const override; + double layerFrameRate(const QString& layerKey) const override; + QMap layerFrameRates() const override; + scopeone::core::ImageFrame publishToolStreamFrame( + const QString& sourceId, + const scopeone::core::ImageFrame& frame, + const QString& displayName = QString()) override; + void showLayers(const QStringList& layerKeys, bool sideBySide = false) override; + void showToolStatus(const QString& message, int timeoutMs = 5000) override; + void presentSession( + const std::shared_ptr& session, + const QString& title) override; + protected: void closeEvent(QCloseEvent* event) override; @@ -53,6 +68,7 @@ namespace scopeone::ui void setupUI(); void setupSignalWiring(); void setupStatusBar(); + void setupTools(); void setupMenuBar(); void setupDeviceControl(); @@ -67,38 +83,25 @@ namespace scopeone::ui void showLivePreview(); void updateControlTarget(const QString& target); void updateDockWidgetMenu(); + void syncCameraState(); void applyLoadedCameraState(const QStringList& cameraIds); void applyNoCameraState(); void refreshDevicePanels(bool fromCache = false); void applyStoredApplicationSettings(); void logStartupSummary(); void openSettingsDialog(); - void openScaleDialog(); - void openStageMosaicTool(); - void openParticleDetectionTool(); + void openImportImageDialog(); + void importImages(const QStringList& filePaths); void connectPropertyPanels(); void showStatusMessage(const QString& message, int timeoutMs = 0); void setCursorStatus(const QString& text); void clearCursorStatus(); void refreshPreviewCursorStatus(); void schedulePreviewCursorStatusRefresh(); + void updateDimensionViewActions(); void showMeasurementLine(const QString& layerKey, const QPoint& start, const QPoint& end); - void registerGallerySessionFrameControls( - const std::shared_ptr& session, - int frameIndex); - void removeGallerySessionFrameControls( - const std::shared_ptr& session); - void updateGalleryLayerFrame(const QString& layerKey, int frameIndex); - void requestLatestGalleryFrame(const QString& layerKey); - void handleGalleryFrameReady( - quint64 requestId, - const std::shared_ptr& session, - const QString& cameraId, - int frameIndex, - const scopeone::core::ImageFrame& frame); - void handlePreviewMousePosition(const QPoint& pos); void handleRoiDrawn(const QString& cameraId, int x, @@ -127,10 +130,11 @@ namespace scopeone::ui scopeone::core::ImageSceneModel* m_imageSceneModel{nullptr}; PreviewWidget* m_previewWidget{nullptr}; - QDockWidget* m_consoleDockWidget{nullptr}; ConsoleWidget* m_consoleWidget{nullptr}; + ScopeOneLocalApiServer* m_localApiServer{nullptr}; QDockWidget* m_propertyDockWidget{nullptr}; + QDockWidget* m_configPresetDockWidget{nullptr}; DevicePropertyWidget* m_propertyBrowser{nullptr}; ConfigPresetWidget* m_configPresetWidget{nullptr}; @@ -138,56 +142,38 @@ namespace scopeone::ui RecordingWidget* m_recordingWidget{nullptr}; QDockWidget* m_imageGalleryDockWidget{nullptr}; ImageGalleryWidget* m_imageGalleryWidget{nullptr}; + ImageWorkspace* m_imageWorkspace{nullptr}; - struct GalleryLayerFrameControl - { - std::shared_ptr session; - QString cameraId; - }; - QHash m_galleryLayerFrameControls; - struct GalleryFrameRequestState - { - quint64 requestId{0}; - int latestFrameIndex{0}; - }; - QHash m_galleryFrameRequests; - - QDockWidget* m_deviceControlDockWidget{nullptr}; DeviceControlWidget* m_deviceControlWidget{nullptr}; - QDockWidget* m_imageProcessingDockWidget{nullptr}; ImageProcessingWidget* m_imageProcessingWidget{nullptr}; - QDockWidget* m_inspectDockWidget{nullptr}; + QDockWidget* m_controlDockWidget{nullptr}; + QDockWidget* m_viewDockWidget{nullptr}; + QDockWidget* m_analyzeDockWidget{nullptr}; + QDockWidget* m_processDockWidget{nullptr}; + QDockWidget* m_consoleDockWidget{nullptr}; InspectWidget* m_inspectWidget{nullptr}; - QMenu* m_fileMenu{nullptr}; QMenu* m_recentConfigurationsMenu{nullptr}; - QMenu* m_viewMenu{nullptr}; - QMenu* m_toolsMenu{nullptr}; - QMenu* m_helpMenu{nullptr}; QMenu* m_dockWidgetsMenu{nullptr}; - QAction* m_exitAction{nullptr}; - QAction* m_fullScreenAction{nullptr}; + QAction* m_toggleDimensionAction{nullptr}; + QAction* m_toggle3dColorbarAction{nullptr}; + QAction* m_reset3dAction{nullptr}; QAction* m_loadConfigurationAction{nullptr}; QAction* m_unloadConfigurationAction{nullptr}; - QAction* m_settingsAction{nullptr}; - QAction* m_scaleAction{nullptr}; - QAction* m_stageMosaicAction{nullptr}; - QAction* m_particleDetectionAction{nullptr}; - QAction* m_aboutAction{nullptr}; - QAction* m_aboutQtAction{nullptr}; + QAction* m_saveImageAsAction{nullptr}; QPointer m_loadConfigProgress; QPointer m_closeSaveProgress; - QPointer m_stageMosaicDialog; - QPointer m_particleDetectionDialog; + std::unique_ptr m_toolRegistry; QLabel* m_statusMessageLabel{nullptr}; QLabel* m_statusCursorLabel{nullptr}; QLabel* m_statusPreviewLabel{nullptr}; QLabel* m_statusProcessingLabel{nullptr}; QLabel* m_statusRecordingLabel{nullptr}; + QProgressBar* m_staticImportProgress{nullptr}; QTimer* m_statusMessageTimer{nullptr}; QTimer* m_cursorRefreshTimer{nullptr}; scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; @@ -196,5 +182,7 @@ namespace scopeone::ui QList> m_closePendingSessions; int m_closeSaveTotal{0}; bool m_closeSaveInProgress{false}; + bool m_closeAfterSave{false}; + bool m_previewRunning{false}; }; } diff --git a/src/PluginManagerDialog.cpp b/src/PluginManagerDialog.cpp new file mode 100644 index 0000000..5525a3d --- /dev/null +++ b/src/PluginManagerDialog.cpp @@ -0,0 +1,428 @@ +#include "PluginManagerDialog.h" + +#include "scopeone/PluginManifest.h" +#include "scopeone/DaqDevice.h" +#include "scopeone/DriverHostProviderPlugin.h" +#include "scopeone/ProcessingPlugin.h" +#include "scopeone/SignalSource.h" +#include "scopeone/ScopeOneCore.h" +#include "scopeone/ToolPlugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::ui +{ + namespace + { + constexpr int kIdRole = Qt::UserRole; + constexpr int kKindRole = Qt::UserRole + 1; + constexpr int kStatusRole = Qt::UserRole + 2; + constexpr int kMetadataRole = Qt::UserRole + 3; + + struct DiscoveredPlugin + { + scopeone::core::PluginManifest manifest; + scopeone::core::PluginKind expectedKind{scopeone::core::PluginKind::Processing}; + QString path; + QString interfaceId; + QJsonObject metadata; + QString error; + }; + + QStringList pluginInterfaceIds(scopeone::core::PluginKind kind); + + QList discoverPlugins() + { + using scopeone::core::PluginKind; + const QList> directories{ + {QStringLiteral("processing"), PluginKind::Processing}, + {QStringLiteral("tools"), PluginKind::Tool}, + {QStringLiteral("hardware"), PluginKind::Hardware} + }; + QList plugins; + const QStringList roots{ + QDir(QCoreApplication::applicationDirPath()).filePath(QStringLiteral("plugins")), + QDir(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)) + .filePath(QStringLiteral("plugins")) + }; + for (const QString& rootPath : roots) + { + const QDir root(rootPath); + for (const auto& [directoryName, kind] : directories) + { + const QDir directory(root.filePath(directoryName)); + for (const QFileInfo& file : directory.entryInfoList(QDir::Files, QDir::Name)) + { + if (!QLibrary::isLibrary(file.absoluteFilePath())) + { + continue; + } + QPluginLoader loader(file.absoluteFilePath()); + DiscoveredPlugin plugin; + plugin.expectedKind = kind; + plugin.path = file.absoluteFilePath(); + const QJsonObject loaderMetadata = loader.metaData(); + plugin.interfaceId = loaderMetadata.value(QStringLiteral("IID")).toString(); + if (plugin.interfaceId.isEmpty()) + { + continue; + } + plugin.metadata = loaderMetadata.value(QStringLiteral("MetaData")).toObject(); + scopeone::core::parsePluginManifest( + plugin.metadata, + kind, + plugin.manifest, + &plugin.error); + if (plugin.error.isEmpty() + && !pluginInterfaceIds(kind).contains(plugin.interfaceId)) + { + plugin.error = QStringLiteral("plugin interface does not match its kind"); + } + plugins.append(std::move(plugin)); + } + } + } + return plugins; + } + + QString settingsKey(const QString& pluginId, const QString& name) + { + return QStringLiteral("Plugins/%1/%2").arg(pluginId, name); + } + + QString pluginDirectoryName(scopeone::core::PluginKind kind) + { + return kind == scopeone::core::PluginKind::Tool + ? QStringLiteral("tools") + : scopeone::core::pluginKindName(kind); + } + + QStringList pluginInterfaceIds(scopeone::core::PluginKind kind) + { + switch (kind) + { + case scopeone::core::PluginKind::Processing: + return {QStringLiteral(ScopeOneProcessingPlugin_iid)}; + case scopeone::core::PluginKind::Tool: + return {QStringLiteral(ScopeOneToolPlugin_iid)}; + case scopeone::core::PluginKind::Hardware: + return { + QStringLiteral(ScopeOneDriverHostProviderPlugin_iid), + QStringLiteral(SCOPEONE_DAQ_DEVICE_PLUGIN_IID), + QStringLiteral(SCOPEONE_SIGNAL_SOURCE_PLUGIN_IID)}; + } + return {}; + } + } + + QStringList loadConfiguredHardwarePlugins(scopeone::core::ScopeOneCore& core) + { + QStringList errors; + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + for (const DiscoveredPlugin& plugin : discoverPlugins()) + { + if (plugin.expectedKind != scopeone::core::PluginKind::Hardware) + { + continue; + } + if (plugin.interfaceId != QStringLiteral(ScopeOneDriverHostProviderPlugin_iid)) + { + continue; + } + if (!plugin.error.isEmpty()) + { + errors.append(QStringLiteral("%1: %2") + .arg(QFileInfo(plugin.path).fileName(), plugin.error)); + continue; + } + + const QString id = plugin.manifest.id; + const QString enabledKey = settingsKey(id, QStringLiteral("enabled")); + const bool enabled = settings.contains(enabledKey) + ? settings.value(enabledKey).toBool() + : plugin.manifest.autoLoad; + if (!enabled) + { + continue; + } + + const QString providerId = plugin.manifest.metadata + .value(QStringLiteral("providerId")) + .toString().trimmed(); + if (providerId != id) + { + errors.append(QStringLiteral("%1: providerId must match plugin id") + .arg(QFileInfo(plugin.path).fileName())); + continue; + } + const QVariantMap options = settings + .value(settingsKey(id, QStringLiteral("options"))) + .toMap(); + QString error; + if (!core.registerDriverHostProvider(providerId, plugin.path, options, &error)) + { + errors.append(QStringLiteral("%1: %2") + .arg(QFileInfo(plugin.path).fileName(), error)); + } + } + return errors; + } + + PluginManagerDialog::PluginManagerDialog(QWidget* parent) + : QDialog(parent) + { + setWindowTitle(tr("Plugin Manager")); + resize(900, 600); + + auto* layout = new QVBoxLayout(this); + m_table = new QTableWidget(this); + m_table->setColumnCount(6); + m_table->setHorizontalHeaderLabels( + {tr("Enabled"), tr("Name"), tr("Type"), tr("Version"), tr("Status"), tr("Location")}); + m_table->setSelectionBehavior(QAbstractItemView::SelectRows); + m_table->setSelectionMode(QAbstractItemView::SingleSelection); + m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); + m_table->verticalHeader()->setVisible(false); + m_table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_table->horizontalHeader()->setSectionResizeMode(5, QHeaderView::Stretch); + layout->addWidget(m_table, 1); + + layout->addWidget(new QLabel(tr("Plugin metadata and diagnostics"), this)); + m_detailsEdit = new QPlainTextEdit(this); + m_detailsEdit->setReadOnly(true); + m_detailsEdit->setMaximumHeight(160); + layout->addWidget(m_detailsEdit); + + layout->addWidget(new QLabel(tr("Hardware options (JSON)"), this)); + m_optionsEdit = new QPlainTextEdit(this); + m_optionsEdit->setMaximumHeight(100); + m_optionsEdit->setPlaceholderText(QStringLiteral("{}")); + layout->addWidget(m_optionsEdit); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Save | QDialogButtonBox::Close, + this); + auto* installButton = buttons->addButton(tr("Install..."), QDialogButtonBox::ActionRole); + layout->addWidget(buttons); + connect(buttons->button(QDialogButtonBox::Save), &QPushButton::clicked, + this, &PluginManagerDialog::saveHardwareSettings); + connect(installButton, &QPushButton::clicked, + this, &PluginManagerDialog::installPlugin); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(m_table, &QTableWidget::itemSelectionChanged, + this, &PluginManagerDialog::showSelectedOptions); + refreshPlugins(); + } + + void PluginManagerDialog::refreshPlugins() + { + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + const QList plugins = discoverPlugins(); + m_table->setRowCount(plugins.size()); + for (int row = 0; row < plugins.size(); ++row) + { + const DiscoveredPlugin& plugin = plugins.at(row); + const bool hardware = plugin.expectedKind == scopeone::core::PluginKind::Hardware; + auto* enabled = new QTableWidgetItem(); + enabled->setData(kIdRole, plugin.manifest.id); + enabled->setData(kKindRole, static_cast(plugin.expectedKind)); + const QString status = plugin.error.isEmpty() ? tr("Ready") : plugin.error; + enabled->setData(kStatusRole, status); + enabled->setData( + kMetadataRole, + QString::fromUtf8(QJsonDocument(plugin.metadata).toJson(QJsonDocument::Indented))); + if (hardware && plugin.error.isEmpty()) + { + enabled->setFlags(enabled->flags() | Qt::ItemIsUserCheckable); + const QString key = settingsKey(plugin.manifest.id, QStringLiteral("enabled")); + const bool checked = settings.contains(key) + ? settings.value(key).toBool() + : plugin.manifest.autoLoad; + enabled->setCheckState(checked ? Qt::Checked : Qt::Unchecked); + } + m_table->setItem(row, 0, enabled); + m_table->setItem(row, 1, new QTableWidgetItem( + plugin.manifest.name.isEmpty() + ? QFileInfo(plugin.path).completeBaseName() + : plugin.manifest.name)); + m_table->setItem(row, 2, new QTableWidgetItem( + scopeone::core::pluginKindName(plugin.expectedKind))); + m_table->setItem(row, 3, new QTableWidgetItem(plugin.manifest.version)); + m_table->setItem(row, 4, new QTableWidgetItem( + status)); + m_table->setItem(row, 5, new QTableWidgetItem(plugin.path)); + } + if (m_table->rowCount() > 0) + { + m_table->selectRow(0); + } + } + + void PluginManagerDialog::showSelectedOptions() + { + const int row = m_table->currentRow(); + if (row < 0) + { + m_optionsEdit->clear(); + m_optionsEdit->setEnabled(false); + return; + } + const QTableWidgetItem* item = m_table->item(row, 0); + const auto kind = static_cast(item->data(kKindRole).toInt()); + const QString id = item->data(kIdRole).toString(); + const QString status = item->data(kStatusRole).toString(); + m_detailsEdit->setPlainText( + item->data(kMetadataRole).toString() + + QStringLiteral("\n\n") + + tr("Status: %1").arg(status)); + const bool hardware = kind == scopeone::core::PluginKind::Hardware + && !id.isEmpty() + && status == tr("Ready"); + m_optionsEdit->setEnabled(hardware); + if (!hardware) + { + m_optionsEdit->clear(); + return; + } + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + const QJsonObject options = QJsonObject::fromVariantMap( + settings.value(settingsKey(id, QStringLiteral("options"))).toMap()); + m_optionsEdit->setPlainText(QString::fromUtf8( + QJsonDocument(options).toJson(QJsonDocument::Indented))); + } + + void PluginManagerDialog::installPlugin() + { + const QString sourcePath = QFileDialog::getOpenFileName( + this, tr("Install Plugin"), {}, tr("Plugin libraries (*.dll *.so *.dylib)")); + if (sourcePath.isEmpty()) + { + return; + } + + QPluginLoader loader(sourcePath); + const QJsonObject loaderMetadata = loader.metaData(); + const QJsonObject metadata = loaderMetadata.value(QStringLiteral("MetaData")).toObject(); + scopeone::core::PluginManifest manifest; + QString error; + bool valid = false; + scopeone::core::PluginKind kind = scopeone::core::PluginKind::Processing; + for (const auto candidate : {scopeone::core::PluginKind::Processing, + scopeone::core::PluginKind::Tool, + scopeone::core::PluginKind::Hardware}) + { + if (scopeone::core::parsePluginManifest(metadata, candidate, manifest, &error)) + { + kind = candidate; + valid = true; + break; + } + } + if (!valid) + { + QMessageBox::warning(this, tr("Plugin Manager"), error); + return; + } + if (!pluginInterfaceIds(kind).contains(loaderMetadata.value(QStringLiteral("IID")).toString())) + { + QMessageBox::warning(this, tr("Plugin Manager"), + tr("The plugin interface does not match its declared type.")); + return; + } + + QDir userRoot(QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation)); + const QString pluginDirectory = userRoot.filePath( + QStringLiteral("plugins/%1").arg(pluginDirectoryName(kind))); + if (!QDir().mkpath(pluginDirectory)) + { + QMessageBox::warning(this, tr("Plugin Manager"), tr("The plugin directory could not be created.")); + return; + } + const QString destination = QDir(pluginDirectory).filePath(QFileInfo(sourcePath).fileName()); + if (QFileInfo(sourcePath).canonicalFilePath() == QFileInfo(destination).canonicalFilePath()) + { + QMessageBox::information(this, tr("Plugin Manager"), tr("This plugin is already installed.")); + return; + } + if (QFileInfo::exists(destination) && !QFile::remove(destination)) + { + QMessageBox::warning(this, tr("Plugin Manager"), tr("The existing plugin could not be replaced.")); + return; + } + if (!QFile::copy(sourcePath, destination)) + { + QMessageBox::warning(this, tr("Plugin Manager"), tr("The plugin could not be installed.")); + return; + } + refreshPlugins(); + QMessageBox::information(this, tr("Plugin Manager"), + tr("The plugin will be available after ScopeOne restarts.")); + } + + void PluginManagerDialog::saveHardwareSettings() + { + const int row = m_table->currentRow(); + if (row < 0) + { + return; + } + QTableWidgetItem* item = m_table->item(row, 0); + const auto kind = static_cast(item->data(kKindRole).toInt()); + const QString id = item->data(kIdRole).toString(); + QVariantMap selectedOptions; + if (kind == scopeone::core::PluginKind::Hardware && !id.isEmpty()) + { + QJsonParseError parseError; + QByteArray optionJson = m_optionsEdit->toPlainText().trimmed().toUtf8(); + if (optionJson.isEmpty()) + { + optionJson = QByteArrayLiteral("{}"); + } + const QJsonDocument options = QJsonDocument::fromJson(optionJson, &parseError); + if (parseError.error != QJsonParseError::NoError || !options.isObject()) + { + QMessageBox::warning(this, tr("Plugin Manager"), tr("Hardware options must be a JSON object.")); + return; + } + selectedOptions = options.object().toVariantMap(); + } + + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + for (int pluginRow = 0; pluginRow < m_table->rowCount(); ++pluginRow) + { + QTableWidgetItem* pluginItem = m_table->item(pluginRow, 0); + const auto pluginKind = static_cast( + pluginItem->data(kKindRole).toInt()); + const QString pluginId = pluginItem->data(kIdRole).toString(); + if (pluginKind == scopeone::core::PluginKind::Hardware && !pluginId.isEmpty()) + { + settings.setValue(settingsKey(pluginId, QStringLiteral("enabled")), + pluginItem->checkState() == Qt::Checked); + } + } + if (kind == scopeone::core::PluginKind::Hardware && !id.isEmpty()) + { + settings.setValue(settingsKey(id, QStringLiteral("options")), selectedOptions); + } + QMessageBox::information(this, tr("Plugin Manager"), + tr("Plugin settings will take effect after ScopeOne restarts.")); + } +} diff --git a/src/PluginManagerDialog.h b/src/PluginManagerDialog.h new file mode 100644 index 0000000..04acec6 --- /dev/null +++ b/src/PluginManagerDialog.h @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +class QPlainTextEdit; +class QTableWidget; + +namespace scopeone::core +{ + class ScopeOneCore; +} + +namespace scopeone::ui +{ + QStringList loadConfiguredHardwarePlugins(scopeone::core::ScopeOneCore& core); + + class PluginManagerDialog final : public QDialog + { + Q_OBJECT + + public: + explicit PluginManagerDialog(QWidget* parent = nullptr); + + private: + void refreshPlugins(); + void installPlugin(); + void showSelectedOptions(); + void saveHardwareSettings(); + + QTableWidget* m_table{nullptr}; + QPlainTextEdit* m_detailsEdit{nullptr}; + QPlainTextEdit* m_optionsEdit{nullptr}; + }; +} diff --git a/src/PreviewWidget.cpp b/src/PreviewWidget.cpp index bc0d228..c47cee7 100644 --- a/src/PreviewWidget.cpp +++ b/src/PreviewWidget.cpp @@ -3,18 +3,30 @@ #include "scopeone/ImageSceneModel.h" #include "scopeone/ScopeOneCore.h" #include +#include +#include #include +#include +#include #include +#include #include +#include +#include +#include #include #include #include #include +#include #include #include +#include +#include #include #include #include +#include #include namespace scopeone::ui @@ -243,6 +255,7 @@ namespace scopeone::ui setMinimumSize(256, 256); setMouseTracking(true); setFocusPolicy(Qt::StrongFocus); + setAcceptDrops(true); m_placeholderLabel = new QLabel(m_placeholderText, this); m_placeholderLabel->setAlignment(Qt::AlignCenter); @@ -258,9 +271,56 @@ namespace scopeone::ui m_placeholderLabel->setFont(placeholderFont); m_placeholderLabel->setGeometry(rect()); - m_fpsUpdateTimer.setInterval(3000); - connect(&m_fpsUpdateTimer, &QTimer::timeout, this, &PreviewWidget::updateFrameRates); - m_fpsUpdateTimer.start(); + m_sliceBar = new QWidget(this); + m_sliceBar->setStyleSheet(QStringLiteral( + "QWidget { background: rgba(20, 24, 28, 220); border-radius: 5px; }" + "QLabel { color: white; }" + "QPushButton { color: white; padding: 2px 6px; }")); + auto* sliceLayout = new QHBoxLayout(m_sliceBar); + sliceLayout->setContentsMargins(6, 2, 6, 2); + sliceLayout->setSpacing(4); + m_sliceLabel = new QLabel(m_sliceBar); + m_sliceSlider = new QSlider(Qt::Horizontal, m_sliceBar); + m_sliceSlider->setMinimumWidth(30); + m_slicePlayButton = new QPushButton(QStringLiteral("Play"), m_sliceBar); + m_slicePlayButton->setCheckable(true); + sliceLayout->addWidget(m_sliceLabel); + sliceLayout->addWidget(m_sliceSlider, 1); + sliceLayout->addWidget(m_slicePlayButton); + connect(m_sliceSlider, &QSlider::valueChanged, + this, [this](int index) + { + m_layerSliceIndices[m_activeLayerKey] = index; + const int sliceCount = m_layerSliceCounts.value(m_activeLayerKey, 1); + const bool compact = m_sliceBar->width() < 260; + m_sliceLabel->setText( + compact ? tr("%1/%2").arg(index + 1).arg(sliceCount) + : tr("Slice %1 / %2").arg(index + 1).arg(sliceCount)); + emit layerSliceIndexRequested(m_activeLayerKey, index); + }); + connect(m_slicePlayButton, &QPushButton::toggled, + this, [this](bool enabled) + { + m_slicePlayButton->setText(enabled ? QStringLiteral("Stop") + : QStringLiteral("Play")); + if (enabled) + { + m_sliceTimer.start(); + } + else + { + m_sliceTimer.stop(); + } + }); + m_sliceTimer.setInterval(120); + connect(&m_sliceTimer, &QTimer::timeout, + this, [this]() + { + m_sliceSlider->setValue( + (m_sliceSlider->value() + 1) % (m_sliceSlider->maximum() + 1)); + }); + m_sliceBar->hide(); + } // Releases cached OpenGL textures @@ -328,17 +388,6 @@ namespace scopeone::ui } } - // Counts one frame in a live layer throughput window - void PreviewWidget::updateLayerFps(const QString& layerKey, quint64 frameCount) - { - FpsState& state = m_fpsStates[layerKey]; - if (!state.intervalTimer.isValid()) - { - state.intervalTimer.start(); - } - state.framesSinceUpdate += frameCount; - } - // Stores one graph processed frame and updates layer statistics void PreviewWidget::setGraphProcessedFrame(const ImageFrame& frame) { @@ -384,44 +433,23 @@ namespace scopeone::ui update(); } - // Tracks completed processing throughput from aggregated frame counts - void PreviewWidget::trackProcessedFrameRate(const QString& cameraId, quint64 frameCount) - { - const QString sourceId = normalizedSourceId(cameraId); - if (!sourceId.isEmpty() && frameCount > 0) - { - updateLayerFps(previewLayerKey(sourceId, true), frameCount); - } - } - - // Tracks acquired raw throughput from backend frame counts - void PreviewWidget::trackRawFrameRate(const QString& cameraId, quint64 frameCount) - { - const QString sourceId = normalizedSourceId(cameraId); - if (!sourceId.isEmpty() && frameCount > 0) - { - updateLayerFps(previewLayerKey(sourceId, false), frameCount); - } - } - // Clears live throughput windows when preview stops void PreviewWidget::resetLiveFrameRates() { - bool changed = false; for (auto it = m_layerFps.begin(); it != m_layerFps.end(); ++it) { - if ((ScopeOneCore::isRawLayerKey(it.key()) || ScopeOneCore::isProcessedLayerKey(it.key())) - && !qFuzzyIsNull(it.value())) + if (ScopeOneCore::isRawLayerKey(it.key()) || ScopeOneCore::isProcessedLayerKey(it.key())) { it.value() = 0.0; - changed = true; } } - m_fpsStates.clear(); - if (changed) - { - updateLayerInfoDisplay(); - } + updateLayerInfoDisplay(); + } + + void PreviewWidget::setLayerFrameRates(const QMap& frameRates) + { + m_layerFps = frameRates; + updateLayerInfoDisplay(); } // Changes how multiple preview layers are laid out @@ -432,6 +460,7 @@ namespace scopeone::ui return; } m_layerLayoutMode = mode; + updateSliceBar(); updateImageDisplay(); emit layerLayoutModeChanged(m_layerLayoutMode); } @@ -486,6 +515,48 @@ namespace scopeone::ui return layerKey; } + void PreviewWidget::setLayerSliceCount(const QString& layerKey, int sliceCount) + { + m_layerSliceCounts.insert(layerKey, sliceCount); + m_layerSliceIndices.insert(layerKey, + qBound(0, + m_layerSliceIndices.value(layerKey), + sliceCount - 1)); + updateSliceBar(); + } + + // Adds or updates one realtime tool layer + QString PreviewWidget::setGraphToolLayerFrame(const QString& layerId, + const ImageFrame& frame) + { + const QString normalizedId = layerId.trimmed(); + if (normalizedId.isEmpty() || !frame.isValid()) + { + return {}; + } + const QString layerKey = ScopeOneCore::toolLayerKey(normalizedId); + const QString sourceId = ScopeOneCore::sourceIdFromLayerKey(layerKey); + if (normalizedSourceId(frame.cameraId) != sourceId) + { + return {}; + } + const bool newLayer = !m_toolSourceIds.contains(sourceId); + m_toolSourceIds.insert(sourceId); + if (!storeSourceFrame(sourceId, FrameRole::Raw, frame)) + { + return {}; + } + initializeLayerInfo(layerKey); + updateLayerInfoDisplay(); + updateImageDisplay(); + if (newLayer) + { + emit availableLayerKeysChanged(availableLayerKeys()); + emit visibleLayerKeysChanged(visibleLayerKeys()); + } + return layerKey; + } + // Removes one static image layer from the preview bool PreviewWidget::removeStaticLayer(const QString& layerKey) { @@ -499,6 +570,7 @@ namespace scopeone::ui updateLayerInfoDisplay(); emit visibleLayerKeysChanged(visibleLayerKeys()); emit availableLayerKeysChanged(availableLayerKeys()); + updateSliceBar(); updateImageDisplay(); return true; } @@ -525,6 +597,7 @@ namespace scopeone::ui updateLayerInfoDisplay(); emit visibleLayerKeysChanged(visibleLayerKeys()); emit availableLayerKeysChanged(availableLayerKeys()); + updateSliceBar(); updateImageDisplay(); } @@ -564,6 +637,17 @@ namespace scopeone::ui availableKeys.append(layerKey); availableSet.insert(layerKey); } + for (const QString& sourceId : m_toolSourceIds) + { + const QString layerKey = ScopeOneCore::toolLayerKey(sourceId); + scopeone::core::DocumentLayer layer; + if (!m_sceneModel->findLayer(layerKey, layer)) + { + continue; + } + availableKeys.append(layerKey); + availableSet.insert(layerKey); + } QStringList layerKeys; layerKeys.reserve(availableKeys.size()); @@ -663,8 +747,6 @@ namespace scopeone::ui const QString normalizedId = normalizedSourceId(sourceId); m_layerFps.remove(previewLayerKey(normalizedId, false)); m_layerFps.remove(previewLayerKey(normalizedId, true)); - m_fpsStates.remove(previewLayerKey(normalizedId, false)); - m_fpsStates.remove(previewLayerKey(normalizedId, true)); updateLayerInfoDisplay(); QMutexLocker lock(&m_mutex); @@ -698,7 +780,6 @@ namespace scopeone::ui for (const QString& cameraId : m_availableCameraIds) { m_layerFps.remove(previewLayerKey(cameraId, true)); - m_fpsStates.remove(previewLayerKey(cameraId, true)); } updateLayerInfoDisplay(); @@ -787,31 +868,14 @@ namespace scopeone::ui return Blending::Translucent; } - QString PreviewWidget::blendingName(Blending blending) const - { - switch (blending) - { - case Blending::Additive: - return QStringLiteral("Additive"); - case Blending::Minimum: - return QStringLiteral("Minimum"); - case Blending::Opaque: - return QStringLiteral("Opaque"); - case Blending::Multiplicative: - return QStringLiteral("Multiplicative"); - case Blending::Translucent: - return QStringLiteral("Translucent"); - } - return QStringLiteral("Translucent"); - } - // Removes stored state for one static image source void PreviewWidget::removeStaticLayerData(const QString& sourceId) { const QString layerKey = ScopeOneCore::staticLayerKey(sourceId); m_staticSourceIds.remove(sourceId); + m_layerSliceCounts.remove(layerKey); + m_layerSliceIndices.remove(layerKey); m_layerFps.remove(layerKey); - m_fpsStates.remove(layerKey); { QMutexLocker lock(&m_mutex); @@ -836,46 +900,226 @@ namespace scopeone::ui { keys.insert(ScopeOneCore::staticLayerKey(sourceId)); } + for (const QString& sourceId : m_toolSourceIds) + { + keys.insert(ScopeOneCore::toolLayerKey(sourceId)); + } return keys; } - // Sets the global preview zoom percentage + // Returns the viewport state used by one layer or by the overlay + PreviewWidget::ViewportState& PreviewWidget::viewportStateForLayer(const QString& layerKey) + { + if (m_layerLayoutMode == LayerLayoutMode::Overlay) + { + return m_overlayViewportState; + } + return m_viewportStates[layerKey]; + } + + PreviewWidget::ViewportState PreviewWidget::viewportStateForLayer(const QString& layerKey) const + { + if (m_layerLayoutMode == LayerLayoutMode::Overlay) + { + return m_overlayViewportState; + } + return m_viewportStates.value(layerKey); + } + + QString PreviewWidget::viewportControlLayerKey() const + { + if (!m_activeLayerKey.isEmpty()) + { + return m_activeLayerKey; + } + return visibleLayerKeys().value(0); + } + + // Sets the preview zoom for the active layer or the overlay void PreviewWidget::setZoomPercent(int percent) { - const int nextPercent = qBound(10, percent, 500); - if (m_zoomPercent == nextPercent) + const int nextPercent = qBound(10, percent, 800); + ViewportState& state = viewportStateForLayer(viewportControlLayerKey()); + if (state.zoomPercent == nextPercent) { return; } - m_zoomPercent = nextPercent; - emit zoomLevelChanged(m_zoomPercent); + state.zoomPercent = nextPercent; + emit zoomLevelChanged(state.zoomPercent); update(); } int PreviewWidget::zoomPercent() const { - return m_zoomPercent; + return viewportStateForLayer(viewportControlLayerKey()).zoomPercent; } - // Enables or disables fit to window display mode + // Enables or disables fit to window for the active layer or the overlay void PreviewWidget::setFitToWindow(bool enabled) { - if (m_fitToWindow == enabled) + ViewportState& state = viewportStateForLayer(viewportControlLayerKey()); + if (state.fitToWindow == enabled) { return; } - m_fitToWindow = enabled; - if (m_fitToWindow) + state.fitToWindow = enabled; + if (state.fitToWindow) { - m_viewOffset = QPoint(); + state.offset = QPoint(); } - emit fitToWindowChanged(m_fitToWindow); + emit fitToWindowChanged(state.fitToWindow); update(); } bool PreviewWidget::isFitToWindow() const { - return m_fitToWindow; + return viewportStateForLayer(viewportControlLayerKey()).fitToWindow; + } + + // Sets whether the calibrated scale bar is drawn on preview + void PreviewWidget::setScaleBarVisible(bool visible) + { + if (m_scaleBarVisible == visible) + { + return; + } + m_scaleBarVisible = visible; + emit scaleBarVisibilityChanged(m_scaleBarVisible); + update(); + } + + bool PreviewWidget::isScaleBarVisible() const + { + return m_scaleBarVisible; + } + + // Sets whether overexposure and underexposure clipping warning is active + void PreviewWidget::setClippingWarningEnabled(bool enabled) + { + if (m_clippingWarning == enabled) + { + return; + } + m_clippingWarning = enabled; + emit clippingWarningChanged(m_clippingWarning); + update(); + } + + bool PreviewWidget::isClippingWarningEnabled() const + { + return m_clippingWarning; + } + + void PreviewWidget::setViewDimensionMode(ViewDimensionMode mode) + { + if (m_viewDimensionMode == mode) + { + return; + } + m_viewDimensionMode = mode; + m_surfaceOrbiting = false; + m_surfacePanning = false; + m_surfaceLayerKey.clear(); + unsetCursor(); + emit viewDimensionModeChanged(m_viewDimensionMode); + update(); + } + + void PreviewWidget::set3dZScale(float scale) + { + const float nextScale = qBound(0.1f, scale, 10.0f); + if (qFuzzyCompare(m_zScale, nextScale)) + { + return; + } + m_zScale = nextScale; + emit threeDimensionalZScaleChanged(m_zScale); + update(); + } + + void PreviewWidget::reset3dCamera() + { + if (m_activeLayerKey.isEmpty()) + { + return; + } + Camera3dState& camera = cameraForLayer(m_activeLayerKey); + camera = Camera3dState{}; + update(); + } + + PreviewWidget::Camera3dState& PreviewWidget::cameraForLayer(const QString& layerKey) + { + return m_layerCameras3d[layerKey]; + } + + const PreviewWidget::Camera3dState& PreviewWidget::cameraForLayer( + const QString& layerKey) const + { + return m_layerCameras3d.constFind(layerKey).value(); + } + + QString PreviewWidget::layerKeyAt3dPosition(const QPoint& widgetPos) const + { + QMap frameSources; + std::vector frameSourceRenderInfos; + std::vector renderItems; + buildRenderSnapshot(frameSources, frameSourceRenderInfos, renderItems); + for (const RenderItem& item : renderItems) + { + if (item.layerKey == m_activeLayerKey && item.area.contains(widgetPos)) + { + return item.layerKey; + } + } + for (const RenderItem& item : renderItems) + { + if (item.area.contains(widgetPos)) + { + return item.layerKey; + } + } + return {}; + } + + void PreviewWidget::set3dWireframeEnabled(bool enabled) + { + if (m_wireframe3d == enabled) + { + return; + } + m_wireframe3d = enabled; + emit threeDimensionalWireframeChanged(m_wireframe3d); + update(); + } + + void PreviewWidget::setThreeDimensionalColorbarVisible(bool visible) + { + if (m_threeDimensionalColorbarVisible == visible) + { + return; + } + m_threeDimensionalColorbarVisible = visible; + emit threeDimensionalColorbarVisibilityChanged(visible); + update(); + } + + void PreviewWidget::setActiveLayerKey(const QString& key) + { + if (m_activeLayerKey == key) + { + return; + } + m_activeLayerKey = key; + updateSliceBar(); + update(); + } + + // Sets the callback providing pixel size in micrometers per layer + void PreviewWidget::setPixelSizeCallback(std::function callback) + { + m_pixelSizeCallback = std::move(callback); + update(); } // Refreshes placeholder state and schedules repaint @@ -900,41 +1144,10 @@ namespace scopeone::ui { m_placeholderText = QStringLiteral("No layer visible"); } + updateSliceBarGeometry(); update(); } - // Publishes raw and processed throughput over one shared time window - void PreviewWidget::updateFrameRates() - { - if (m_fpsStates.isEmpty()) - { - return; - } - - bool changed = false; - for (auto it = m_fpsStates.begin(); it != m_fpsStates.end(); ++it) - { - FpsState& state = it.value(); - const qint64 elapsedNs = state.intervalTimer.nsecsElapsed(); - const double fps = elapsedNs > 0 - ? (static_cast(state.framesSinceUpdate) * 1000000000.0) - / static_cast(elapsedNs) - : 0.0; - state.framesSinceUpdate = 0; - state.intervalTimer.restart(); - double& currentFps = m_layerFps[it.key()]; - if (!qFuzzyCompare(currentFps + 1.0, fps + 1.0)) - { - currentFps = fps; - changed = true; - } - } - if (changed) - { - updateLayerInfoDisplay(); - } - } - // Checks whether a frame source has raw data bool PreviewWidget::hasRawFrame(const FrameSourceState& frameState) const { @@ -994,6 +1207,7 @@ namespace scopeone::ui // Resolves the displayed image rectangle for one layer bool PreviewWidget::resolveDisplayGeometry(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, QRect& displayRect, QSize& imageSize) const @@ -1005,7 +1219,7 @@ namespace scopeone::ui return false; } imageSize = frameState.processedFrame.size(); - displayRect = targetRectForImageSize(imageSize, frameState, area); + displayRect = targetRectForImageSize(imageSize, frameState, layerKey, area); } else { @@ -1014,7 +1228,7 @@ namespace scopeone::ui return false; } imageSize = frameState.rawFrame.size(); - displayRect = targetRectForImageSize(imageSize, frameState, area); + displayRect = targetRectForImageSize(imageSize, frameState, layerKey, area); } return imageSize.width() > 0 @@ -1046,7 +1260,7 @@ namespace scopeone::ui frameState = *item.info->frameState; processed = item.processed; itemArea = item.area; - return resolveDisplayGeometry(frameState, processed, itemArea, displayRect, imageSize); + return resolveDisplayGeometry(frameState, processed, layerKey, itemArea, displayRect, imageSize); } return false; @@ -1055,13 +1269,14 @@ namespace scopeone::ui // Maps one widget position into image coordinates bool PreviewWidget::mapWidgetPositionToImage(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, const QPoint& widgetPos, QPoint& imagePos) const { QRect displayRect; QSize imageSize; - if (!resolveDisplayGeometry(frameState, processed, area, displayRect, imageSize) + if (!resolveDisplayGeometry(frameState, processed, layerKey, area, displayRect, imageSize) || !displayRect.contains(widgetPos)) { return false; @@ -1090,13 +1305,14 @@ namespace scopeone::ui // Maps one widget rectangle into image rectangle bounds bool PreviewWidget::mapWidgetRectToImage(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, const QRect& widgetRect, QRect& imageRect) const { QRect displayRect; QSize imageSize; - if (!resolveDisplayGeometry(frameState, processed, area, displayRect, imageSize)) + if (!resolveDisplayGeometry(frameState, processed, layerKey, area, displayRect, imageSize)) { return false; } @@ -1150,13 +1366,14 @@ namespace scopeone::ui // Maps one image coordinate into widget coordinates bool PreviewWidget::mapImagePositionToWidget(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, const QPoint& imagePos, QPoint& widgetPos) const { QRect displayRect; QSize imageSize; - if (!resolveDisplayGeometry(frameState, processed, area, displayRect, imageSize)) + if (!resolveDisplayGeometry(frameState, processed, layerKey, area, displayRect, imageSize)) { return false; } @@ -1212,6 +1429,7 @@ namespace scopeone::ui QSize imageSize; if (!resolveDisplayGeometry(*item.info->frameState, item.processed, + item.layerKey, item.area, displayRect, imageSize)) @@ -1238,11 +1456,13 @@ namespace scopeone::ui QPoint endWidget; if (!mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, startImage, startWidget) || !mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, endImage, endWidget)) @@ -1275,11 +1495,13 @@ namespace scopeone::ui } if (!mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, imageRect.topLeft(), topLeft) || !mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, imageRect.bottomRight(), bottomRight)) @@ -1351,6 +1573,7 @@ namespace scopeone::ui QPoint clippedEnd; if (!resolveDisplayGeometry(*item.info->frameState, item.processed, + item.layerKey, item.area, displayRect, imageSize) @@ -1361,11 +1584,13 @@ namespace scopeone::ui clippedEnd) || !mapWidgetPositionToImage(*item.info->frameState, item.processed, + item.layerKey, item.area, clippedStart, markup.start) || !mapWidgetPositionToImage(*item.info->frameState, item.processed, + item.layerKey, item.area, clippedEnd, markup.end)) @@ -1394,17 +1619,20 @@ namespace scopeone::ui QPoint imageEnd; if (!resolveDisplayGeometry(*item.info->frameState, item.processed, + item.layerKey, item.area, displayRect, imageSize) || !clipLineToRect(m_crossSectionStart, m_crossSectionEnd, displayRect, clippedStart, clippedEnd) || !mapWidgetPositionToImage(*item.info->frameState, item.processed, + item.layerKey, item.area, clippedStart, imageStart) || !mapWidgetPositionToImage(*item.info->frameState, item.processed, + item.layerKey, item.area, clippedEnd, imageEnd)) @@ -1435,6 +1663,7 @@ namespace scopeone::ui QRect imageRect; if (!mapWidgetRectToImage(*item.info->frameState, item.processed, + item.layerKey, item.area, QRect(m_roiStart, m_roiEnd), imageRect)) @@ -1453,52 +1682,241 @@ namespace scopeone::ui } } - bool PreviewWidget::markupAtWidgetPosition(const QPoint& widgetPos, - ImageSceneModel::Markup& outMarkup, - PreviewInteractionTarget& outTarget, - MarkupEditMode& outEditMode) const + // Draws a calibrated scale bar overlay in the corner of visible image areas + void PreviewWidget::drawScaleBar(QPainter& painter, const std::vector& renderItems) const { - if (!m_sceneModel->hasMarkups()) + if (renderItems.empty()) { - return false; + return; } - QMap frameSources; - std::vector frameSourceRenderInfos; - std::vector renderItems; - buildRenderSnapshot(frameSources, frameSourceRenderInfos, renderItems); - - const QList markups = m_sceneModel->markups(); - for (int markupIndex = markups.size() - 1; markupIndex >= 0; --markupIndex) + QSet drawnAreas; + for (const RenderItem& item : renderItems) { - const ImageSceneModel::Markup& markup = markups.at(markupIndex); - if (!markup.visible) + if (!item.info || !item.info->frameState || drawnAreas.contains(item.area)) { continue; } - for (const RenderItem& item : renderItems) + QRect displayRect; + QSize imageSize; + if (!resolveDisplayGeometry(*item.info->frameState, + item.processed, + item.layerKey, + item.area, + displayRect, + imageSize)) { - if (item.layerKey != markup.layerKey || !item.info || !item.info->frameState) - { - continue; - } + continue; + } - QRect displayRect; - QSize imageSize; - if (!resolveDisplayGeometry(*item.info->frameState, - item.processed, - item.area, - displayRect, - imageSize) - || !displayRect.contains(widgetPos)) + if (imageSize.width() <= 0 || displayRect.width() <= 0) + { + continue; + } + + const double pixelSize = m_pixelSizeCallback ? m_pixelSizeCallback(item.layerKey) : 0.0; + if (pixelSize <= 0.0) + { + continue; + } + + const double pixelsPerImagePixel = static_cast(displayRect.width()) / static_cast(imageSize.width()); + const double screenPixelsPerUm = pixelsPerImagePixel / pixelSize; + if (screenPixelsPerUm <= 1e-6) + { + continue; + } + + const double targetUm = 80.0 / screenPixelsPerUm; + static const double niceSteps[] = { + 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 25.0, 50.0, + 100.0, 200.0, 250.0, 500.0, 1000.0, 2000.0, 5000.0, 10000.0, 25000.0, 50000.0 + }; + + double bestUm = niceSteps[0]; + double minDiff = std::abs(targetUm - bestUm); + for (double step : niceSteps) + { + const double diff = std::abs(targetUm - step); + if (diff < minDiff) { - continue; + minDiff = diff; + bestUm = step; } + } - QPoint imagePos; - if (!mapWidgetPositionToImage(*item.info->frameState, - item.processed, + const int barWidthPx = static_cast(std::round(bestUm * screenPixelsPerUm)); + if (barWidthPx < 10 || barWidthPx > displayRect.width() - 20) + { + continue; + } + + QString labelText; + if (bestUm >= 1000.0) + { + labelText = QString::number(bestUm / 1000.0, 'g', 3) + QStringLiteral(" mm"); + } + else if (bestUm >= 1.0) + { + labelText = QString::number(bestUm, 'g', 3) + QStringLiteral(" um"); + } + else + { + labelText = QString::number(bestUm * 1000.0, 'g', 3) + QStringLiteral(" nm"); + } + + painter.save(); + painter.setRenderHint(QPainter::Antialiasing, true); + + QFont font = painter.font(); + font.setPointSize(9); + font.setBold(true); + painter.setFont(font); + + const QFontMetrics fm(font); + const int textWidth = fm.horizontalAdvance(labelText); + const int boxWidth = std::max(barWidthPx, textWidth) + 16; + const int boxHeight = fm.height() + 14; + + const int margin = 12; + const int boxX = displayRect.right() - boxWidth - margin; + const int boxY = displayRect.bottom() - boxHeight - margin; + const QRect boxRect(boxX, boxY, boxWidth, boxHeight); + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 160)); + painter.drawRoundedRect(boxRect, 4, 4); + + painter.setPen(Qt::white); + const QRect textRect(boxX, boxY + 2, boxWidth, fm.height()); + painter.drawText(textRect, Qt::AlignCenter, labelText); + + const int barX = boxX + (boxWidth - barWidthPx) / 2; + const int barY = boxY + fm.height() + 6; + QPen linePen(Qt::white, 3, Qt::SolidLine, Qt::RoundCap); + painter.setPen(linePen); + painter.drawLine(barX, barY, barX + barWidthPx, barY); + + painter.restore(); + drawnAreas.insert(item.area); + } + } + + // Draws tile names and active border outlines in Grid View + void PreviewWidget::drawTileLabelsAndBadges(QPainter& painter, const std::vector& renderItems) const + { + if (renderItems.empty()) + { + return; + } + painter.save(); + painter.setRenderHint(QPainter::Antialiasing); + + const QFont font(QStringLiteral("Segoe UI"), 9); + painter.setFont(font); + const QFontMetrics fm(font); + const bool overlay = m_layerLayoutMode == LayerLayoutMode::Overlay + && renderItems.size() > 1; + bool overlayBadgeDrawn = false; + + for (const auto& item : renderItems) + { + if (!item.info || !item.info->frameState) + { + continue; + } + + if (overlay) + { + if (!m_activeLayerKey.isEmpty() && item.layerKey != m_activeLayerKey) + { + continue; + } + if (overlayBadgeDrawn) + { + continue; + } + } + + if (!m_activeLayerKey.isEmpty() && item.layerKey == m_activeLayerKey && renderItems.size() > 1) + { + painter.setPen(QPen(QColor(0, 200, 255, 200), 2)); + painter.setBrush(Qt::NoBrush); + painter.drawRect(item.area.adjusted(1, 1, -1, -1)); + } + + if (m_layerLayoutMode == LayerLayoutMode::SideBySide || renderItems.size() > 1) + { + const QString name = layerName(item.layerKey); + const int frameW = item.processed ? item.info->frameState->processedFrame.width : item.info->frameState->rawFrame.width; + const int frameH = item.processed ? item.info->frameState->processedFrame.height : item.info->frameState->rawFrame.height; + const QString labelText = (frameW > 0 && frameH > 0) + ? QString("%1 (%2x%3)").arg(name).arg(frameW).arg(frameH) + : name; + const int textWidth = fm.horizontalAdvance(labelText); + const QRect badgeRect(item.area.left() + 8, item.area.top() + 8, textWidth + 14, fm.height() + 6); + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(15, 18, 22, 180)); + painter.drawRoundedRect(badgeRect, 4, 4); + + painter.setPen(QColor(230, 235, 240)); + painter.drawText(badgeRect, Qt::AlignCenter, labelText); + overlayBadgeDrawn = true; + } + } + painter.restore(); + } + + bool PreviewWidget::markupAtWidgetPosition(const QPoint& widgetPos, + ImageSceneModel::Markup& outMarkup, + PreviewInteractionTarget& outTarget, + MarkupEditMode& outEditMode) const + { + if (!m_sceneModel->hasMarkups()) + { + return false; + } + + QMap frameSources; + std::vector frameSourceRenderInfos; + std::vector renderItems; + buildRenderSnapshot(frameSources, frameSourceRenderInfos, renderItems); + + const QList markups = m_sceneModel->markups(); + for (int markupIndex = markups.size() - 1; markupIndex >= 0; --markupIndex) + { + const ImageSceneModel::Markup& markup = markups.at(markupIndex); + if (!markup.visible) + { + continue; + } + + for (const RenderItem& item : renderItems) + { + if (item.layerKey != markup.layerKey || !item.info || !item.info->frameState) + { + continue; + } + + QRect displayRect; + QSize imageSize; + if (!resolveDisplayGeometry(*item.info->frameState, + item.processed, + item.layerKey, + item.area, + displayRect, + imageSize) + || !displayRect.contains(widgetPos)) + { + continue; + } + + QPoint imagePos; + if (!mapWidgetPositionToImage(*item.info->frameState, + item.processed, + item.layerKey, item.area, widgetPos, imagePos)) @@ -1512,11 +1930,13 @@ namespace scopeone::ui QPoint endWidget; if (!mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, markup.start, startWidget) || !mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, markup.end, endWidget)) @@ -1546,11 +1966,13 @@ namespace scopeone::ui QPoint bottomRight; if (!mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, markup.rect.normalized().topLeft(), topLeft) || !mapImagePositionToWidget(*item.info->frameState, item.processed, + item.layerKey, item.area, markup.rect.normalized().bottomRight(), bottomRight)) @@ -1634,7 +2056,12 @@ namespace scopeone::ui const FrameSourceState& frameState = *item.info->frameState; QRect displayRect; QSize imageSize; - if (!resolveDisplayGeometry(frameState, item.processed, item.area, displayRect, imageSize)) + if (!resolveDisplayGeometry(frameState, + item.processed, + item.layerKey, + item.area, + displayRect, + imageSize)) { return; } @@ -1645,6 +2072,7 @@ namespace scopeone::ui frameState.processedFrame, frameState.processedRevision, displayRect, + item.area, frameState.flipX, frameState.flipY, item.display, @@ -1658,6 +2086,7 @@ namespace scopeone::ui frameState.rawFrame, frameState.rawRevision, displayRect, + item.area, frameState.flipX, frameState.flipY, item.display, @@ -1666,6 +2095,144 @@ namespace scopeone::ui } } + // Draws one image layer as a GPU-displaced surface + void PreviewWidget::draw3dSurface(const RenderItem& item, + const Camera3dState& camera, + const QRect& targetArea) + { + const FrameSourceState& frameState = *item.info->frameState; + const ImageFrame& frame = item.processed ? frameState.processedFrame : frameState.rawFrame; + const quint64 revision = item.processed ? frameState.processedRevision : frameState.rawRevision; + const GLuint texture = ensureFrameTexture(item.layerKey, frame, revision); + const GLint internalFormat = frame.isMono16() ? GL_R16 : GL_R8; + const float sampleMax = internalFormat == GL_R16 ? 65535.0f : 255.0f; + const float bitMax = static_cast(qMax(1, frame.maxValue())); + const float levelDomain = static_cast(qMax(1, item.display.levelDomainMax)); + + QMatrix4x4 projection; + projection.perspective(45.0f, + static_cast(targetArea.width()) + / static_cast(targetArea.height()), + 0.1f, + 100.0f); + QMatrix4x4 view; + view.translate(camera.pan.x(), camera.pan.y(), -camera.distance); + view.rotate(camera.pitch, 1.0f, 0.0f, 0.0f); + view.rotate(camera.yaw, 0.0f, 0.0f, 1.0f); + + m_prog3d.bind(); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + m_prog3d.setUniformValue(m_u3dTex, 0); + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, m_colormapTexture); + m_prog3d.setUniformValue(m_u3dColormapLut, 1); + glActiveTexture(GL_TEXTURE0); + + m_prog3d.setUniformValue(m_u3dMvp, projection * view); + m_prog3d.setUniformValue(m_u3dMinNorm, + static_cast(item.display.levelMin) / levelDomain); + m_prog3d.setUniformValue(m_u3dMaxNorm, + static_cast(item.display.levelMax) / levelDomain); + m_prog3d.setUniformValue(m_u3dTexNormScale, sampleMax / bitMax); + m_prog3d.setUniformValue(m_u3dZScale, m_zScale); + m_prog3d.setUniformValue(m_u3dGamma, static_cast(item.display.gamma)); + m_prog3d.setUniformValue(m_u3dColormap, item.display.colormapIndex); + m_prog3d.setUniformValue(m_u3dShowClipping, m_clippingWarning ? 1 : 0); + m_prog3d.setUniformValue(m_u3dUvScale, frameState.flipX ? -1.0f : 1.0f, + frameState.flipY ? 1.0f : -1.0f); + m_prog3d.setUniformValue(m_u3dUvOffset, frameState.flipX ? 1.0f : 0.0f, + frameState.flipY ? 0.0f : 1.0f); + m_prog3d.setUniformValue(m_u3dLightDirection, QVector3D(-0.4f, 0.5f, 1.0f)); + + glDisable(GL_BLEND); + glEnable(GL_DEPTH_TEST); + glDepthMask(GL_TRUE); + glDepthFunc(GL_LESS); + m_gridVao.bind(); + if (m_wireframe3d) + { + m_gl3dFunctions.glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); + } + glDrawElements(GL_TRIANGLES, + m_gridElementCount, + GL_UNSIGNED_INT, + nullptr); + if (m_wireframe3d) + { + m_gl3dFunctions.glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + } + m_gridVao.release(); + glDisable(GL_DEPTH_TEST); + m_prog3d.release(); + } + + QImage PreviewWidget::colormapStripImage(int colormapIndex, int height) const + { + const QStringList names = ImageSceneModel::supportedColormaps(); + const QByteArray lut = loadImageJLut(names[colormapIndex]); + QImage strip(16, height, QImage::Format_RGB888); + for (int y = 0; y < height; ++y) + { + const int lutIndex = 255 - (y * 255 / (height - 1)); + uchar* line = strip.scanLine(y); + for (int x = 0; x < strip.width(); ++x) + { + line[3 * x] = static_cast(lut[3 * lutIndex]); + line[3 * x + 1] = static_cast(lut[3 * lutIndex + 1]); + line[3 * x + 2] = static_cast(lut[3 * lutIndex + 2]); + } + } + return strip; + } + + void PreviewWidget::draw3dColorbar(QPainter& painter, + const RenderItem& item, + const QRect& viewportRect) const + { + const int panelWidth = 100; + const int panelHeight = 220; + const QRect panel(viewportRect.right() - panelWidth - 16, + viewportRect.center().y() - panelHeight / 2, + panelWidth, + panelHeight); + const QRect strip(panel.x() + 10, panel.y() + 32, 16, 174); + const QStringList names = ImageSceneModel::supportedColormaps(); + + painter.save(); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 160)); + painter.drawRoundedRect(panel, 5, 5); + painter.drawImage(strip, colormapStripImage(item.display.colormapIndex, strip.height())); + + QFont font = painter.font(); + font.setPointSize(8); + painter.setFont(font); + painter.setPen(Qt::white); + painter.drawText(QRect(panel.x() + 4, panel.y() + 7, panel.width() - 8, 18), + Qt::AlignCenter, + names[item.display.colormapIndex]); + + const QFontMetrics metrics(font); + for (int tick = 0; tick < 5; ++tick) + { + const double fraction = static_cast(tick) / 4.0; + const int y = strip.top() + qRound(fraction * (strip.height() - 1)); + const double value = item.display.levelMax + + (item.display.levelMin - item.display.levelMax) * fraction; + painter.setPen(QPen(Qt::white, 1)); + painter.drawLine(strip.right() + 2, y, strip.right() + 7, y); + painter.drawText(QRect(strip.right() + 10, + y - metrics.height() / 2, + panel.right() - strip.right() - 14, + metrics.height()), + Qt::AlignLeft | Qt::AlignVCenter, + QString::number(value, 'f', 0)); + } + painter.restore(); + } + // Updates the layer info summary text void PreviewWidget::updateLayerInfoDisplay() { @@ -1774,14 +2341,24 @@ namespace scopeone::ui const FrameSourceState& frameState = *item.info->frameState; QRect displayRect; QSize imageSize; - if (!resolveDisplayGeometry(frameState, item.processed, item.area, displayRect, imageSize) + if (!resolveDisplayGeometry(frameState, + item.processed, + item.layerKey, + item.area, + displayRect, + imageSize) || !displayRect.contains(widgetPos)) { continue; } QPoint imagePos; - if (!mapWidgetPositionToImage(frameState, item.processed, item.area, widgetPos, imagePos)) + if (!mapWidgetPositionToImage(frameState, + item.processed, + item.layerKey, + item.area, + widgetPos, + imagePos)) { continue; } @@ -1807,10 +2384,67 @@ namespace scopeone::ui return resolveInteractionTarget(widgetPos, outTarget, sourceId, rawOnly, QString()); } + // Resolves all visible layers under one widget point + QVector PreviewWidget::interactionTargetsAt( + const QPoint& widgetPos) const + { + QVector targets; + QMap frameSources; + std::vector frameSourceRenderInfos; + std::vector renderItems; + buildRenderSnapshot(frameSources, frameSourceRenderInfos, renderItems); + + for (const RenderItem& item : renderItems) + { + if (!item.info || !item.info->frameState || !item.area.contains(widgetPos)) + { + continue; + } + if (item.display.blending != Blending::Opaque && item.display.opacityPercent <= 0) + { + continue; + } + + const FrameSourceState& frameState = *item.info->frameState; + QRect displayRect; + QSize imageSize; + if (!resolveDisplayGeometry(frameState, + item.processed, + item.layerKey, + item.area, + displayRect, + imageSize) + || !displayRect.contains(widgetPos)) + { + continue; + } + + QPoint imagePos; + if (!mapWidgetPositionToImage(frameState, + item.processed, + item.layerKey, + item.area, + widgetPos, + imagePos)) + { + continue; + } + + targets.append({item.layerKey, + item.info->sourceId, + imagePos, + item.area, + displayRect, + item.processed}); + } + return targets; + } + // Initializes OpenGL state for preview rendering void PreviewWidget::initializeGL() { initializeOpenGLFunctions(); + m_gl3dFunctions.initializeOpenGLFunctions(); const QSurfaceFormat format = context()->format(); QString profile = QStringLiteral("No profile"); @@ -1850,6 +2484,77 @@ namespace scopeone::ui { applyViewportForRect(rect()); m_placeholderLabel->setGeometry(rect()); + updateSliceBarGeometry(); + } + + void PreviewWidget::updateSliceBar() + { + const int sliceCount = m_layerSliceCounts.value(m_activeLayerKey, 1); + if (sliceCount <= 1) + { + m_sliceBar->hide(); + m_sliceTimer.stop(); + m_slicePlayButton->setChecked(false); + return; + } + + m_sliceSlider->setRange(0, sliceCount - 1); + { + const QSignalBlocker blocker(m_sliceSlider); + m_sliceSlider->setValue(m_layerSliceIndices.value(m_activeLayerKey)); + } + updateSliceBarGeometry(); + } + + void PreviewWidget::updateSliceBarGeometry() + { + const int sliceCount = m_layerSliceCounts.value(m_activeLayerKey, 1); + if (sliceCount <= 1) + { + m_sliceBar->hide(); + return; + } + + QMap frameSources; + std::vector frameSourceRenderInfos; + std::vector renderItems; + buildRenderSnapshot(frameSources, frameSourceRenderInfos, renderItems); + + QRect cell; + for (const RenderItem& item : renderItems) + { + if (item.layerKey == m_activeLayerKey) + { + cell = item.area; + break; + } + } + + if (cell.isEmpty() || cell.width() < 80) + { + m_sliceBar->hide(); + return; + } + + const int margin = 8; + const int barHeight = 28; + const int maxBarWidth = cell.width() - 2 * margin; + + const bool compact = maxBarWidth < 260; + const bool ultraCompact = maxBarWidth < 160; + + m_slicePlayButton->setVisible(!ultraCompact); + const int currentSlice = m_sliceSlider->value() + 1; + m_sliceLabel->setText(compact ? tr("%1/%2").arg(currentSlice).arg(sliceCount) + : tr("Slice %1 / %2").arg(currentSlice).arg(sliceCount)); + + m_sliceBar->setMaximumSize(maxBarWidth, barHeight); + m_sliceBar->setGeometry(cell.x() + margin, + cell.y() + cell.height() - barHeight - margin, + maxBarWidth, + barHeight); + m_sliceBar->show(); + m_sliceBar->raise(); } // Computes tiled preview rectangles for visible layers @@ -2001,8 +2706,10 @@ namespace scopeone::ui return; } glClearColor(0.1f, 0.1f, 0.1f, 1.0f); + glDisable(GL_SCISSOR_TEST); applyViewportForRect(rect()); - glClear(GL_COLOR_BUFFER_BIT); + glDepthMask(GL_TRUE); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); bool canGpu = m_glInited && m_prog.isLinked(); QMap frameSources; @@ -2015,7 +2722,6 @@ namespace scopeone::ui showPlaceholder(m_placeholderText); return; } - if (canGpu) { if (renderItems.empty()) @@ -2025,19 +2731,140 @@ namespace scopeone::ui } m_placeholderLabel->hide(); + if (m_viewDimensionMode == ViewDimensionMode::ThreeDimensional) + { + if (m_layerLayoutMode == LayerLayoutMode::SideBySide) + { + glEnable(GL_SCISSOR_TEST); + for (const RenderItem& item : renderItems) + { + applyScissorForRect(item.area); + applyViewportForRect(item.area); + glClear(GL_DEPTH_BUFFER_BIT); + draw3dSurface(item, + cameraForLayer(item.layerKey), + item.area); + } + glDisable(GL_SCISSOR_TEST); + applyViewportForRect(rect()); + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + for (const RenderItem& item : renderItems) + { + const QRect panelRect = item.area.adjusted(8, 8, -8, -8) + .intersected(QRect(item.area.x() + 8, + item.area.y() + 8, + qMax(0, item.area.width() - 16), + 42)); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 0, 0, 170)); + p.drawRoundedRect(panelRect, 5, 5); + p.setPen(Qt::white); + QFont font = p.font(); + font.setBold(true); + p.setFont(font); + p.drawText(panelRect.adjusted(8, 4, -8, -4), + Qt::AlignLeft | Qt::AlignVCenter, + layerName(item.layerKey)); + p.setPen(item.layerKey == m_activeLayerKey + ? QColor(255, 220, 80) + : QColor(220, 225, 230, 190)); + p.setBrush(Qt::NoBrush); + p.setPen(QPen(p.pen().color(), item.layerKey == m_activeLayerKey ? 2 : 1)); + p.drawRect(item.area.adjusted(1, 1, -2, -2)); + } + if (m_threeDimensionalColorbarVisible) + { + const RenderItem* activeItem = &renderItems.front(); + for (const RenderItem& item : renderItems) + { + if (item.layerKey == m_activeLayerKey) + { + activeItem = &item; + break; + } + } + draw3dColorbar(p, *activeItem, activeItem->area); + } + p.setPen(QColor(220, 225, 230, 190)); + p.drawText(QRect(12, height() - 28, width() - 24, 18), + Qt::AlignRight | Qt::AlignVCenter, + tr("Left drag: orbit Right drag: pan Wheel: zoom")); + return; + } + + const RenderItem* surfaceItem = &renderItems.back(); + for (const RenderItem& item : renderItems) + { + if (item.layerKey == m_activeLayerKey) + { + surfaceItem = &item; + break; + } + } + + glDisable(GL_SCISSOR_TEST); + applyViewportForRect(rect()); + draw3dSurface(*surfaceItem, + cameraForLayer(surfaceItem->layerKey), + rect()); + + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + QFont font = p.font(); + font.setBold(true); + p.setFont(font); + const QString title = tr("3D Surface: %1").arg(layerName(surfaceItem->layerKey)); + const QString detail = tr("Z-Scale %1x%2").arg(m_zScale, 0, 'f', 1) + .arg(m_wireframe3d ? tr(" | Wireframe") : QString()); + const QRect panelRect(12, 12, 230, 52); + p.setPen(Qt::NoPen); + p.setBrush(QColor(0, 0, 0, 170)); + p.drawRoundedRect(panelRect, 5, 5); + p.setPen(Qt::white); + p.drawText(panelRect.adjusted(10, 7, -10, -26), + Qt::AlignLeft | Qt::AlignVCenter, + title); + font.setBold(false); + p.setFont(font); + p.drawText(panelRect.adjusted(10, 25, -10, -7), + Qt::AlignLeft | Qt::AlignVCenter, + detail); + if (m_threeDimensionalColorbarVisible) + { + draw3dColorbar(p, *surfaceItem, rect()); + } + p.setPen(QColor(220, 225, 230, 190)); + p.drawText(QRect(12, height() - 28, width() - 24, 18), + Qt::AlignRight | Qt::AlignVCenter, + tr("Left drag: orbit Right drag: pan Wheel: zoom")); + return; + } + + glDisable(GL_DEPTH_TEST); for (const auto& item : renderItems) { drawRenderItem(item); } + glDisable(GL_SCISSOR_TEST); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + applyViewportForRect(rect()); + QPainter p(this); if (m_sceneModel->hasMarkups() || (m_roiDrawingMode && m_roiDragging) || (m_crossSectionDrawingMode && m_crossSectionDragging)) { - QPainter p(this); drawMarkups(p, renderItems); drawActiveInteractionMarkup(p, renderItems); } + if (m_scaleBarVisible) + { + drawScaleBar(p, renderItems); + } + drawTileLabelsAndBadges(p, renderItems); return; } @@ -2093,6 +2920,7 @@ namespace scopeone::ui uniform float uAlpha; uniform float uGamma; uniform int uColormap; + uniform int uShowClipping; uniform sampler2D uColormapLut; vec3 applyColormap(float t, int map) { vec2 lutSize = vec2(textureSize(uColormapLut, 0)); @@ -2103,6 +2931,16 @@ namespace scopeone::ui void main(){ vec4 s = texture(uTex, vUV); float t0 = s.r * uTexNormScale; + if (uShowClipping == 1) { + if (t0 >= 0.999) { + FragColor = vec4(1.0, 0.0, 0.0, uAlpha); + return; + } + if (t0 <= 0.0001) { + FragColor = vec4(0.0, 0.2, 1.0, uAlpha); + return; + } + } float t = clamp((t0 - uMinNorm) / max(uMaxNorm - uMinNorm, 1e-6), 0.0, 1.0); t = pow(t, 1.0 / max(uGamma, 1e-3)); FragColor = vec4(applyColormap(t, uColormap), uAlpha); @@ -2131,16 +2969,163 @@ namespace scopeone::ui glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); glEnableVertexAttribArray(1); glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); - m_uTex = m_prog.uniformLocation("uTex"); - m_uMinNorm = m_prog.uniformLocation("uMinNorm"); - m_uMaxNorm = m_prog.uniformLocation("uMaxNorm"); - m_uTexNormScale = m_prog.uniformLocation("uTexNormScale"); - m_uAlpha = m_prog.uniformLocation("uAlpha"); - m_uGamma = m_prog.uniformLocation("uGamma"); - m_uColormap = m_prog.uniformLocation("uColormap"); - m_uColormapLut = m_prog.uniformLocation("uColormapLut"); - m_uUvScale = m_prog.uniformLocation("uUvScale"); - m_uUvOffset = m_prog.uniformLocation("uUvOffset"); + m_uTex = m_prog.uniformLocation("uTex"); + m_uMinNorm = m_prog.uniformLocation("uMinNorm"); + m_uMaxNorm = m_prog.uniformLocation("uMaxNorm"); + m_uTexNormScale = m_prog.uniformLocation("uTexNormScale"); + m_uAlpha = m_prog.uniformLocation("uAlpha"); + m_uGamma = m_prog.uniformLocation("uGamma"); + m_uColormap = m_prog.uniformLocation("uColormap"); + m_uColormapLut = m_prog.uniformLocation("uColormapLut"); + m_uUvScale = m_prog.uniformLocation("uUvScale"); + m_uUvOffset = m_prog.uniformLocation("uUvOffset"); + m_uShowClipping = m_prog.uniformLocation("uShowClipping"); + + const char* vs3d = R"( + #version 330 core + layout (location = 0) in vec2 aPos; + layout (location = 1) in vec2 aUV; + out vec2 vUV; + out float vHeight; + uniform sampler2D uTex; + uniform mat4 uMvp; + uniform float uMinNorm; + uniform float uMaxNorm; + uniform float uTexNormScale; + uniform float uGamma; + uniform float uZScale; + uniform vec2 uUvScale; + uniform vec2 uUvOffset; + float heightAt(vec2 uv) { + float value = texture(uTex, uv * uUvScale + uUvOffset).r * uTexNormScale; + float normalized = clamp((value - uMinNorm) / max(uMaxNorm - uMinNorm, 1e-6), 0.0, 1.0); + return pow(normalized, 1.0 / max(uGamma, 1e-3)); + } + void main() { + vUV = aUV * uUvScale + uUvOffset; + vHeight = heightAt(aUV); + gl_Position = uMvp * vec4(aPos, vHeight * uZScale, 1.0); + } + )"; + const char* fs3d = R"( + #version 330 core + in vec2 vUV; + in float vHeight; + out vec4 FragColor; + uniform sampler2D uTex; + uniform float uMinNorm; + uniform float uMaxNorm; + uniform float uTexNormScale; + uniform float uGamma; + uniform int uColormap; + uniform int uShowClipping; + uniform sampler2D uColormapLut; + uniform vec3 uLightDirection; + vec3 applyColormap(float t, int map) { + vec2 lutSize = vec2(textureSize(uColormapLut, 0)); + float column = (t * (lutSize.x - 1.0) + 0.5) / lutSize.x; + float row = (float(map) + 0.5) / lutSize.y; + return texture(uColormapLut, vec2(column, row)).rgb; + } + void main() { + float value = texture(uTex, vUV).r * uTexNormScale; + if (uShowClipping == 1 && value >= 0.999) { + FragColor = vec4(1.0, 0.0, 0.0, 1.0); + return; + } + if (uShowClipping == 1 && value <= 0.0001) { + FragColor = vec4(0.0, 0.2, 1.0, 1.0); + return; + } + vec2 texel = 1.0 / vec2(textureSize(uTex, 0)); + float dx = texture(uTex, clamp(vUV + vec2(texel.x, 0.0), 0.0, 1.0)).r + - texture(uTex, clamp(vUV - vec2(texel.x, 0.0), 0.0, 1.0)).r; + float dy = texture(uTex, clamp(vUV + vec2(0.0, texel.y), 0.0, 1.0)).r + - texture(uTex, clamp(vUV - vec2(0.0, texel.y), 0.0, 1.0)).r; + vec3 normal = normalize(vec3(-dx * 4.0, -dy * 4.0, 1.0)); + float diffuse = max(dot(normal, normalize(uLightDirection)), 0.0); + float lighting = 0.28 + 0.72 * diffuse; + float normalized = clamp((value - uMinNorm) / max(uMaxNorm - uMinNorm, 1e-6), 0.0, 1.0); + normalized = pow(normalized, 1.0 / max(uGamma, 1e-3)); + FragColor = vec4(applyColormap(normalized, uColormap) * lighting, 1.0); + } + )"; + if (!m_prog3d.addShaderFromSourceCode(QOpenGLShader::Vertex, vs3d) + || !m_prog3d.addShaderFromSourceCode(QOpenGLShader::Fragment, fs3d) + || !m_prog3d.link()) + { + qCritical() << "PreviewWidget: 3D shader setup FAILED" << m_prog3d.log(); + return; + } + + constexpr int gridSize = 256; + std::vector gridVertices; + gridVertices.reserve(gridSize * gridSize * 4); + for (int y = 0; y < gridSize; ++y) + { + const float v = static_cast(y) / static_cast(gridSize - 1); + for (int x = 0; x < gridSize; ++x) + { + const float u = static_cast(x) / static_cast(gridSize - 1); + gridVertices.push_back(u * 2.0f - 1.0f); + gridVertices.push_back(v * 2.0f - 1.0f); + gridVertices.push_back(u); + gridVertices.push_back(1.0f - v); + } + } + + std::vector gridIndices; + gridIndices.reserve((gridSize - 1) * (gridSize - 1) * 6); + for (int y = 0; y < gridSize - 1; ++y) + { + for (int x = 0; x < gridSize - 1; ++x) + { + const GLuint topLeft = static_cast(y * gridSize + x); + const GLuint topRight = topLeft + 1; + const GLuint bottomLeft = static_cast((y + 1) * gridSize + x); + const GLuint bottomRight = bottomLeft + 1; + gridIndices.insert(gridIndices.end(), + {topLeft, bottomLeft, topRight, + topRight, bottomLeft, bottomRight}); + } + } + + m_gridVao.create(); + glGenBuffers(1, &m_gridVbo); + glGenBuffers(1, &m_gridIbo); + m_gridVao.bind(); + glBindBuffer(GL_ARRAY_BUFFER, m_gridVbo); + glBufferData(GL_ARRAY_BUFFER, + static_cast(gridVertices.size() * sizeof(float)), + gridVertices.data(), + GL_STATIC_DRAW); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_gridIbo); + glBufferData(GL_ELEMENT_ARRAY_BUFFER, + static_cast(gridIndices.size() * sizeof(GLuint)), + gridIndices.data(), + GL_STATIC_DRAW); + m_prog3d.bind(); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)0); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void*)(2 * sizeof(float))); + m_prog3d.release(); + m_gridVao.release(); + glBindBuffer(GL_ARRAY_BUFFER, 0); + m_gridElementCount = static_cast(gridIndices.size()); + m_u3dTex = m_prog3d.uniformLocation("uTex"); + m_u3dMvp = m_prog3d.uniformLocation("uMvp"); + m_u3dMinNorm = m_prog3d.uniformLocation("uMinNorm"); + m_u3dMaxNorm = m_prog3d.uniformLocation("uMaxNorm"); + m_u3dTexNormScale = m_prog3d.uniformLocation("uTexNormScale"); + m_u3dZScale = m_prog3d.uniformLocation("uZScale"); + m_u3dGamma = m_prog3d.uniformLocation("uGamma"); + m_u3dColormap = m_prog3d.uniformLocation("uColormap"); + m_u3dColormapLut = m_prog3d.uniformLocation("uColormapLut"); + m_u3dShowClipping = m_prog3d.uniformLocation("uShowClipping"); + m_u3dUvScale = m_prog3d.uniformLocation("uUvScale"); + m_u3dUvOffset = m_prog3d.uniformLocation("uUvOffset"); + m_u3dLightDirection = m_prog3d.uniformLocation("uLightDirection"); // Uploads all colormaps once for shader lookup glGenTextures(1, &m_colormapTexture); @@ -2154,6 +3139,7 @@ namespace scopeone::ui glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, kColormapSize, colormaps.size(), 0, GL_RGB, GL_UNSIGNED_BYTE, atlas.constData()); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); glActiveTexture(GL_TEXTURE0); m_prog.release(); m_vao.release(); @@ -2173,58 +3159,72 @@ namespace scopeone::ui if (m_uUvOffset >= 0) m_prog.setUniformValue(m_uUvOffset, ox, oy); } - // Uploads and draws one image frame into a target rectangle - void PreviewWidget::drawFrameInRect(const QString& textureKey, - const ImageFrame& frame, - quint64 frameRevision, - const QRect& r, - bool flipX, - bool flipY, - const LayerDisplaySettings& display, - bool firstVisibleInArea) + GLuint PreviewWidget::ensureFrameTexture(const QString& textureKey, + const ImageFrame& frame, + quint64 frameRevision) { - if (!frame.isValid() || r.width() <= 0 || r.height() <= 0) return; - - ensureGlPipeline(); - GLenum uploadType = GL_UNSIGNED_BYTE; GLint internalFormat = GL_R8; int unpackAlign = 1; - if (frame.isMono16()) { uploadType = GL_UNSIGNED_SHORT; internalFormat = GL_R16; unpackAlign = 2; } - else if (!frame.isMono8()) - { - return; - } - GLuint texId = getOrCreateTexture(textureKey, frame.width, frame.height, internalFormat); + const GLuint texId = getOrCreateTexture(textureKey, + frame.width, + frame.height, + internalFormat); CachedTexture& cachedTexture = m_textureCache[textureKey]; - glBindTexture(GL_TEXTURE_2D, texId); + if (cachedTexture.uploadedRevision == frameRevision) + { + return texId; + } - if (cachedTexture.uploadedRevision != frameRevision) + glPixelStorei(GL_UNPACK_ALIGNMENT, unpackAlign); + const int bytesPerPixel = (uploadType == GL_UNSIGNED_SHORT) ? 2 : 1; + if (frame.stride > 0) { - glPixelStorei(GL_UNPACK_ALIGNMENT, unpackAlign); - const int bytesPerPixel = (uploadType == GL_UNSIGNED_SHORT) ? 2 : 1; - if (frame.stride > 0) + const int rowPixels = frame.stride / bytesPerPixel; + if (rowPixels != frame.width) { - const int rowPixels = frame.stride / bytesPerPixel; - if (rowPixels != frame.width) - { - glPixelStorei(GL_UNPACK_ROW_LENGTH, rowPixels); - } + glPixelStorei(GL_UNPACK_ROW_LENGTH, rowPixels); } - glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, - frame.width, frame.height, - GL_RED, uploadType, frame.bytes.constData()); - glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); - cachedTexture.uploadedRevision = frameRevision; } + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, + frame.width, frame.height, + GL_RED, uploadType, frame.bytes.constData()); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 4); + cachedTexture.uploadedRevision = frameRevision; + return texId; + } + + // Uploads and draws one image frame into a target rectangle + void PreviewWidget::drawFrameInRect(const QString& textureKey, + const ImageFrame& frame, + quint64 frameRevision, + const QRect& displayRect, + const QRect& clipRect, + bool flipX, + bool flipY, + const LayerDisplaySettings& display, + bool firstVisibleInArea) + { + if (!frame.isValid() || displayRect.width() <= 0 || displayRect.height() <= 0) return; + + ensureGlPipeline(); + + if (!frame.isMono8() && !frame.isMono16()) + { + return; + } + + const GLint internalFormat = frame.isMono16() ? GL_R16 : GL_R8; + const GLuint texId = ensureFrameTexture(textureKey, frame, frameRevision); m_prog.bind(); glActiveTexture(GL_TEXTURE0); @@ -2247,6 +3247,7 @@ namespace scopeone::ui m_prog.setUniformValue(m_uAlpha, opacity); m_prog.setUniformValue(m_uGamma, static_cast(std::clamp(display.gamma, 0.2, 2.0))); m_prog.setUniformValue(m_uColormap, display.colormapIndex); + m_prog.setUniformValue(m_uShowClipping, m_clippingWarning ? 1 : 0); setUvTransform(flipX, flipY); if (display.blending == Blending::Opaque) @@ -2285,13 +3286,16 @@ namespace scopeone::ui } } - applyViewportForRect(r); + glEnable(GL_SCISSOR_TEST); + applyScissorForRect(clipRect); + applyViewportForRect(displayRect); m_vao.bind(); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); m_vao.release(); glBlendEquation(GL_FUNC_ADD); glDisable(GL_BLEND); + glDisable(GL_SCISSOR_TEST); m_prog.release(); } @@ -2299,29 +3303,31 @@ namespace scopeone::ui // Computes the target rectangle for an image inside an area QRect PreviewWidget::targetRectForImageSize(const QSize& imageSize, const FrameSourceState& frameState, + const QString& layerKey, const QRect& avail) const { if (imageSize.width() <= 0 || imageSize.height() <= 0 || avail.width() <= 0 || avail.height() <= 0) return avail; + const ViewportState viewport = viewportStateForLayer(layerKey); QSize s = imageSize; - if (m_fitToWindow) + if (viewport.fitToWindow) { s.scale(avail.size(), Qt::KeepAspectRatio); s = s * (frameState.zoomPercent / 100.0); } else { - const double z = (m_zoomPercent / 100.0) * (frameState.zoomPercent / 100.0); + const double z = (viewport.zoomPercent / 100.0) * (frameState.zoomPercent / 100.0); s = s * z; } int x = avail.x() + (avail.width() - s.width()) / 2 + frameState.offsetX; int y = avail.y() + (avail.height() - s.height()) / 2 + frameState.offsetY; - if (!m_fitToWindow) + if (!viewport.fitToWindow) { - x += m_viewOffset.x(); - y += m_viewOffset.y(); + x += viewport.offset.x(); + y += viewport.offset.y(); } return QRect(QPoint(x, y), s); } @@ -2344,6 +3350,26 @@ namespace scopeone::ui glViewport(xPx, glY, wPx, hPx); } + // Applies an OpenGL scissor rectangle in logical widget coordinates + void PreviewWidget::applyScissorForRect(const QRect& logicalRect) + { + const QRect clippedRect = logicalRect.intersected(rect()); + if (clippedRect.width() <= 0 || clippedRect.height() <= 0) + { + glScissor(0, 0, 0, 0); + return; + } + + const qreal dpr = devicePixelRatioF(); + const int totalHeightPx = qMax(1, qRound(height() * dpr)); + const int xPx = qRound(clippedRect.x() * dpr); + const int yPx = qRound(clippedRect.y() * dpr); + const int wPx = qMax(1, qRound(clippedRect.width() * dpr)); + const int hPx = qMax(1, qRound(clippedRect.height() * dpr)); + const int glY = totalHeightPx - hPx - yPx; + glScissor(xPx, glY, wPx, hPx); + } + // Returns an existing texture or creates one with matching shape GLuint PreviewWidget::getOrCreateTexture(const QString& key, int width, int height, GLenum internalFormat) { @@ -2393,6 +3419,23 @@ namespace scopeone::ui glDeleteTextures(1, &m_colormapTexture); m_colormapTexture = 0; } + if (m_gridVbo != 0) + { + glDeleteBuffers(1, &m_gridVbo); + m_gridVbo = 0; + } + if (m_gridIbo != 0) + { + glDeleteBuffers(1, &m_gridIbo); + m_gridIbo = 0; + } + if (m_vbo != 0) + { + glDeleteBuffers(1, &m_vbo); + m_vbo = 0; + } + m_gridVao.destroy(); + m_vao.destroy(); doneCurrent(); } @@ -2502,7 +3545,89 @@ namespace scopeone::ui // Starts active drawing interactions from a mouse press void PreviewWidget::mousePressEvent(QMouseEvent* event) { + emit activated(); emit mousePositionChanged(event->pos()); + if (m_viewDimensionMode == ViewDimensionMode::ThreeDimensional) + { + const QString layerKey = layerKeyAt3dPosition(event->pos()); + if (layerKey.isEmpty()) + { + event->accept(); + return; + } + if (m_activeLayerKey != layerKey) + { + setActiveLayerKey(layerKey); + emit layerClicked(layerKey); + } + m_surfaceLayerKey = layerKey; + Camera3dState& camera = cameraForLayer(layerKey); + if (event->button() == Qt::LeftButton) + { + m_surfaceOrbiting = true; + m_surfacePanning = false; + m_surfaceDragStart = event->pos(); + m_surfaceStartPitch = camera.pitch; + m_surfaceStartYaw = camera.yaw; + setCursor(Qt::ClosedHandCursor); + event->accept(); + return; + } + if (event->button() == Qt::RightButton) + { + m_surfacePanning = true; + m_surfaceOrbiting = false; + m_surfaceDragStart = event->pos(); + m_surfaceStartPan = camera.pan; + setCursor(Qt::SizeAllCursor); + event->accept(); + return; + } + } + if (event->button() == Qt::MiddleButton) + { + PreviewInteractionTarget target; + if (!interactionTargetAt(event->pos(), target)) + { + return; + } + + setActiveLayerKey(target.layerKey); + emit layerClicked(target.layerKey); + const FrameSourceState frameState = snapshotFrameSources().value(target.sourceId); + ViewportState& viewport = viewportStateForLayer(target.layerKey); + if (viewport.fitToWindow) + { + const QPointF relativePos( + static_cast(event->pos().x() - target.displayRect.x()) + / static_cast(target.displayRect.width()), + static_cast(event->pos().y() - target.displayRect.y()) + / static_cast(target.displayRect.height())); + viewport.fitToWindow = false; + QRect newRect; + QSize imageSize; + if (resolveDisplayGeometry(frameState, + target.processed, + target.layerKey, + target.itemArea, + newRect, + imageSize)) + { + const int desiredX = qRound(event->pos().x() - relativePos.x() * newRect.width()); + const int desiredY = qRound(event->pos().y() - relativePos.y() * newRect.height()); + viewport.offset += QPoint(desiredX - newRect.x(), desiredY - newRect.y()); + } + emit fitToWindowChanged(false); + } + m_viewPanning = true; + m_panLayerKey = target.layerKey; + m_panStartWidgetPos = event->pos(); + m_panStartOffset = viewport.offset; + setCursor(Qt::ClosedHandCursor); + update(); + event->accept(); + return; + } if (m_measurementLineDrawingMode && event->button() == Qt::LeftButton) { PreviewInteractionTarget target; @@ -2588,15 +3713,123 @@ namespace scopeone::ui return; } m_sceneModel->selectOnly(QString()); + + if (interactionTargetAt(event->pos(), target) && !target.layerKey.isEmpty()) + { + setActiveLayerKey(target.layerKey); + emit layerClicked(target.layerKey); + } + else if (m_layerLayoutMode == LayerLayoutMode::SideBySide) + { + QMap frameSources; + std::vector frameSourceRenderInfos; + std::vector renderItems; + buildRenderSnapshot(frameSources, frameSourceRenderInfos, renderItems); + for (const RenderItem& item : renderItems) + { + if (item.area.contains(event->pos())) + { + setActiveLayerKey(item.layerKey); + emit layerClicked(item.layerKey); + break; + } + } + } } QOpenGLWidget::mousePressEvent(event); } + // Toggles visibility of the layer under the double click + void PreviewWidget::mouseDoubleClickEvent(QMouseEvent* event) + { + if (m_viewDimensionMode == ViewDimensionMode::ThreeDimensional + && event->button() == Qt::LeftButton) + { + const QString layerKey = layerKeyAt3dPosition(event->pos()); + if (!layerKey.isEmpty()) + { + if (m_activeLayerKey != layerKey) + { + setActiveLayerKey(layerKey); + emit layerClicked(layerKey); + } + reset3dCamera(); + } + event->accept(); + return; + } + if (event->button() == Qt::LeftButton) + { + PreviewInteractionTarget target; + if (interactionTargetAt(event->pos(), target) && !target.layerKey.isEmpty()) + { + if (m_savedVisibleLayerKeys.isEmpty()) + { + m_savedVisibleLayerKeys = m_sceneModel->visibleLayerIds(); + m_sceneModel->setVisibleLayers({target.layerKey}); + } + else + { + m_sceneModel->setVisibleLayers(m_savedVisibleLayerKeys); + m_savedVisibleLayerKeys.clear(); + } + update(); + return; + } + if (!m_savedVisibleLayerKeys.isEmpty()) + { + m_sceneModel->setVisibleLayers(m_savedVisibleLayerKeys); + m_savedVisibleLayerKeys.clear(); + update(); + return; + } + } + QOpenGLWidget::mouseDoubleClickEvent(event); + } + // Updates active drawing interactions during mouse move void PreviewWidget::mouseMoveEvent(QMouseEvent* event) { emit mousePositionChanged(event->pos()); + update(); + if (m_viewDimensionMode == ViewDimensionMode::ThreeDimensional) + { + if (m_surfaceOrbiting) + { + Camera3dState& camera = cameraForLayer(m_surfaceLayerKey); + const QPoint delta = event->pos() - m_surfaceDragStart; + camera.yaw = m_surfaceStartYaw + static_cast(delta.x()) * 0.5f; + camera.pitch = qBound(-85.0f, + m_surfaceStartPitch + static_cast(delta.y()) * 0.5f, + 85.0f); + update(); + return; + } + if (m_surfacePanning) + { + Camera3dState& camera = cameraForLayer(m_surfaceLayerKey); + const QPoint delta = event->pos() - m_surfaceDragStart; + QMap frameSources; + std::vector frameSourceRenderInfos; + std::vector renderItems; + buildRenderSnapshot(frameSources, frameSourceRenderInfos, renderItems); + QRect area = rect(); + for (const RenderItem& item : renderItems) + { + if (item.layerKey == m_surfaceLayerKey) + { + area = item.area; + break; + } + } + camera.pan = m_surfaceStartPan + + QVector2D(static_cast(delta.x()) / static_cast(area.width()), + -static_cast(delta.y()) / static_cast(area.height())); + update(); + return; + } + } if (m_measurementLineDrawingMode && m_measurementLineDragging) { m_measurementLineEnd = event->pos(); @@ -2618,6 +3851,15 @@ namespace scopeone::ui return; } + if (m_viewPanning) + { + ViewportState& viewport = viewportStateForLayer(m_panLayerKey); + viewport.offset = m_panStartOffset + event->pos() - m_panStartWidgetPos; + emit mousePositionChanged(event->pos()); + update(); + return; + } + if (m_markupDragging) { FrameSourceState frameState; @@ -2632,7 +3874,12 @@ namespace scopeone::ui itemArea, displayRect, imageSize) - && mapWidgetPositionToImage(frameState, processed, itemArea, event->pos(), imagePos)) + && mapWidgetPositionToImage(frameState, + processed, + m_dragMarkupOriginal.layerKey, + itemArea, + event->pos(), + imagePos)) { if (m_dragMarkupOriginal.type == ImageSceneModel::MarkupType::Line) { @@ -2702,6 +3949,34 @@ namespace scopeone::ui void PreviewWidget::mouseReleaseEvent(QMouseEvent* event) { emit mousePositionChanged(event->pos()); + if (m_viewDimensionMode == ViewDimensionMode::ThreeDimensional) + { + if (m_surfaceOrbiting && event->button() == Qt::LeftButton) + { + m_surfaceOrbiting = false; + m_surfaceLayerKey.clear(); + unsetCursor(); + event->accept(); + return; + } + if (m_surfacePanning && event->button() == Qt::RightButton) + { + m_surfacePanning = false; + m_surfaceLayerKey.clear(); + unsetCursor(); + event->accept(); + return; + } + } + if (m_viewPanning && event->button() == Qt::MiddleButton) + { + m_viewPanning = false; + m_panLayerKey.clear(); + unsetCursor(); + update(); + event->accept(); + return; + } if (m_measurementLineDrawingMode && event->button() == Qt::LeftButton && m_measurementLineDragging) @@ -2743,11 +4018,13 @@ namespace scopeone::ui clippedEnd) || !mapWidgetPositionToImage(frameState, processed, + m_measurementLineTargetLayerKey, itemArea, clippedStart, imageStart) || !mapWidgetPositionToImage(frameState, processed, + m_measurementLineTargetLayerKey, itemArea, clippedEnd, imageEnd) @@ -2791,8 +4068,18 @@ namespace scopeone::ui displayRect, imageSize) || !clipLineToRect(m_crossSectionStart, m_crossSectionEnd, displayRect, clippedStart, clippedEnd) - || !mapWidgetPositionToImage(frameState, processed, itemArea, clippedStart, imgStart) - || !mapWidgetPositionToImage(frameState, processed, itemArea, clippedEnd, imgEnd)) + || !mapWidgetPositionToImage(frameState, + processed, + startTarget.layerKey, + itemArea, + clippedStart, + imgStart) + || !mapWidgetPositionToImage(frameState, + processed, + startTarget.layerKey, + itemArea, + clippedEnd, + imgEnd)) { cancelCrossSectionDrawing(); return; @@ -2856,7 +4143,12 @@ namespace scopeone::ui } QRect imageRect; - if (!mapWidgetRectToImage(frameState, false, itemArea, clippedRect, imageRect)) + if (!mapWidgetRectToImage(frameState, + false, + startTarget.layerKey, + itemArea, + clippedRect, + imageRect)) { cancelROIDrawing(); return; @@ -2900,12 +4192,43 @@ namespace scopeone::ui void PreviewWidget::leaveEvent(QEvent* event) { emit mousePositionChanged(QPoint(-1, -1)); + update(); QOpenGLWidget::leaveEvent(event); } // Handles control wheel zoom around the cursor anchor void PreviewWidget::wheelEvent(QWheelEvent* event) { + if (m_viewDimensionMode == ViewDimensionMode::ThreeDimensional) + { + const QPoint widgetPos = event->position().toPoint(); + const QString layerKey = layerKeyAt3dPosition(widgetPos); + if (layerKey.isEmpty()) + { + event->accept(); + return; + } + if (m_activeLayerKey != layerKey) + { + setActiveLayerKey(layerKey); + emit layerClicked(layerKey); + } + Camera3dState& camera = cameraForLayer(layerKey); + const int deltaY = event->angleDelta().y(); + if (deltaY != 0) + { + const int steps = (deltaY / 120 != 0) + ? (deltaY / 120) + : ((deltaY > 0) ? 1 : -1); + camera.distance = qBound(0.8f, + camera.distance * std::pow(0.88f, + static_cast(steps)), + 20.0f); + } + update(); + event->accept(); + return; + } if (!(event->modifiers() & Qt::ControlModifier)) { QOpenGLWidget::wheelEvent(event); @@ -2919,59 +4242,65 @@ namespace scopeone::ui return; } - const int steps = (deltaY / 120 != 0) ? (deltaY / 120) : ((deltaY > 0) ? 1 : -1); PreviewInteractionTarget target; - QPointF relativePos; - const bool hasAnchor = interactionTargetAt(event->position().toPoint(), target) - && !target.sourceId.isEmpty() - && target.displayRect.width() > 0 - && target.displayRect.height() > 0; - if (hasAnchor) + const QPoint widgetPos = event->position().toPoint(); + if (!interactionTargetAt(widgetPos, target)) { - relativePos = QPointF( - static_cast(event->position().x() - target.displayRect.x()) - / static_cast(target.displayRect.width()), - static_cast(event->position().y() - target.displayRect.y()) - / static_cast(target.displayRect.height())); + event->accept(); + return; } - if (m_fitToWindow) + + if (m_activeLayerKey != target.layerKey) { - setFitToWindow(false); + setActiveLayerKey(target.layerKey); + emit layerClicked(target.layerKey); } - setZoomPercent(m_zoomPercent + steps * 10); - if (hasAnchor) + const int steps = (deltaY / 120 != 0) ? (deltaY / 120) : ((deltaY > 0) ? 1 : -1); + const QPointF relativePos( + static_cast(widgetPos.x() - target.displayRect.x()) + / static_cast(target.displayRect.width()), + static_cast(widgetPos.y() - target.displayRect.y()) + / static_cast(target.displayRect.height())); + ViewportState& viewport = viewportStateForLayer(target.layerKey); + const bool wasFitToWindow = viewport.fitToWindow; + viewport.fitToWindow = false; + viewport.zoomPercent = qBound(10, viewport.zoomPercent + steps * 10, 800); + + if (wasFitToWindow) { - FrameSourceState frameState; - const QMap frameSources = snapshotFrameSources(); - const auto frameStateIt = frameSources.constFind(target.sourceId); - const bool hasFrameState = frameStateIt != frameSources.constEnd(); - if (hasFrameState) - { - frameState = frameStateIt.value(); - } + emit fitToWindowChanged(false); + } + emit zoomLevelChanged(viewport.zoomPercent); - if (hasFrameState) - { - QRect newRect; - QSize imageSize; - if (!resolveDisplayGeometry(frameState, target.processed, target.itemArea, newRect, imageSize)) - { - event->accept(); - return; - } - const int desiredX = qRound(event->position().x() - relativePos.x() * newRect.width()); - const int desiredY = qRound(event->position().y() - relativePos.y() * newRect.height()); - m_viewOffset += QPoint(desiredX - newRect.x(), desiredY - newRect.y()); - update(); - } + const FrameSourceState frameState = snapshotFrameSources().value(target.sourceId); + QRect newRect; + QSize imageSize; + if (resolveDisplayGeometry(frameState, + target.processed, + target.layerKey, + target.itemArea, + newRect, + imageSize)) + { + const int desiredX = qRound(widgetPos.x() - relativePos.x() * newRect.width()); + const int desiredY = qRound(widgetPos.y() - relativePos.y() * newRect.height()); + viewport.offset += QPoint(desiredX - newRect.x(), desiredY - newRect.y()); } + update(); event->accept(); } // Cancels active drawing modes from keyboard input void PreviewWidget::keyPressEvent(QKeyEvent* event) { + if (m_viewDimensionMode == ViewDimensionMode::ThreeDimensional + && event->key() == Qt::Key_R) + { + reset3dCamera(); + event->accept(); + return; + } if (m_measurementLineDrawingMode && event->key() == Qt::Key_Escape) { cancelMeasurementLineDrawing(); @@ -3000,6 +4329,8 @@ namespace scopeone::ui return; } + + if (event->key() == Qt::Key_Escape) { m_sceneModel->selectOnly(QString()); @@ -3007,6 +4338,72 @@ namespace scopeone::ui return; } + const bool bigStep = (event->modifiers() & Qt::ShiftModifier); + if (event->key() == Qt::Key_Up) + { + emit stageStepRequested(0.0, 1.0, bigStep); + event->accept(); + return; + } + if (event->key() == Qt::Key_Down) + { + emit stageStepRequested(0.0, -1.0, bigStep); + event->accept(); + return; + } + if (event->key() == Qt::Key_Left) + { + emit stageStepRequested(-1.0, 0.0, bigStep); + event->accept(); + return; + } + if (event->key() == Qt::Key_Right) + { + emit stageStepRequested(1.0, 0.0, bigStep); + event->accept(); + return; + } + if (event->key() == Qt::Key_PageUp) + { + emit stageZStepRequested(1.0, bigStep); + event->accept(); + return; + } + if (event->key() == Qt::Key_PageDown) + { + emit stageZStepRequested(-1.0, bigStep); + event->accept(); + return; + } + QOpenGLWidget::keyPressEvent(event); } + + // Accept file drag operations containing image files + void PreviewWidget::dragEnterEvent(QDragEnterEvent* event) + { + if (event->mimeData()->hasUrls()) + { + event->acceptProposedAction(); + } + } + + // Process dropped files and emit image paths + void PreviewWidget::dropEvent(QDropEvent* event) + { + const QList urls = event->mimeData()->urls(); + QStringList filePaths; + for (const QUrl& url : urls) + { + if (url.isLocalFile()) + { + filePaths.append(url.toLocalFile()); + } + } + if (!filePaths.isEmpty()) + { + emit imageFilesDropped(filePaths); + event->acceptProposedAction(); + } + } } // namespace scopeone::ui diff --git a/src/PreviewWidget.h b/src/PreviewWidget.h index 060d1e8..088cdb4 100644 --- a/src/PreviewWidget.h +++ b/src/PreviewWidget.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -15,17 +16,25 @@ #include #include #include +#include +#include #include #include "scopeone/ImageSceneModel.h" #include "scopeone/ImageFrame.h" +class QDragEnterEvent; +class QDropEvent; class QEvent; +class QImage; class QKeyEvent; class QLabel; class QMouseEvent; class QPainter; class QPointF; +class QPushButton; +class QSlider; class QWheelEvent; +class QWidget; namespace scopeone::ui { @@ -37,6 +46,7 @@ namespace scopeone::ui public: enum class LayerLayoutMode { SideBySide, Overlay }; + enum class ViewDimensionMode { TwoDimensional, ThreeDimensional }; struct PreviewInteractionTarget { @@ -50,17 +60,20 @@ namespace scopeone::ui PreviewWidget(ImageSceneModel* sceneModel, QWidget* parent); ~PreviewWidget() override; + ImageSceneModel* sceneModel() const { return m_sceneModel; } void setGraphProcessedFrame(const scopeone::core::ImageFrame& frame); void setGraphRawFrame(const scopeone::core::ImageFrame& frame); - void trackProcessedFrameRate(const QString& cameraId, quint64 frameCount); - void trackRawFrameRate(const QString& cameraId, quint64 frameCount); void resetLiveFrameRates(); + void setLayerFrameRates(const QMap& frameRates); void setLayerLayoutMode(LayerLayoutMode mode); LayerLayoutMode layerLayoutMode() const; void setAvailableCameraIds(const QStringList& cameraIds); QString setGraphStaticLayerFrame(const QString& layerId, const scopeone::core::ImageFrame& frame); + void setLayerSliceCount(const QString& layerKey, int sliceCount); + QString setGraphToolLayerFrame(const QString& layerId, + const scopeone::core::ImageFrame& frame); bool removeStaticLayer(const QString& layerKey); void clearStaticLayers(); QStringList availableCameraIds() const; @@ -77,6 +90,22 @@ namespace scopeone::ui int zoomPercent() const; void setFitToWindow(bool enabled); bool isFitToWindow() const; + void setScaleBarVisible(bool visible); + bool isScaleBarVisible() const; + void setClippingWarningEnabled(bool enabled); + bool isClippingWarningEnabled() const; + void setViewDimensionMode(ViewDimensionMode mode); + ViewDimensionMode viewDimensionMode() const { return m_viewDimensionMode; } + void set3dZScale(float scale); + float get3dZScale() const { return m_zScale; } + void reset3dCamera(); + void set3dWireframeEnabled(bool enabled); + bool is3dWireframeEnabled() const { return m_wireframe3d; } + void setThreeDimensionalColorbarVisible(bool visible); + bool isThreeDimensionalColorbarVisible() const { return m_threeDimensionalColorbarVisible; } + void setActiveLayerKey(const QString& key); + QString activeLayerKey() const { return m_activeLayerKey; } + void setPixelSizeCallback(std::function callback); void startROIDrawing(const QString& cameraId); void startMeasurementLineDrawingForLayer(const QString& layerKey); void startCrossSectionDrawingForLayer(const QString& layerKey); @@ -86,6 +115,7 @@ namespace scopeone::ui PreviewInteractionTarget& outTarget, const QString& sourceId = QString(), bool rawOnly = false) const; + QVector interactionTargetsAt(const QPoint& widgetPos) const; signals: void availableCameraIdsChanged(const QStringList& cameraIds); void availableLayerKeysChanged(const QStringList& layerKeys); @@ -94,6 +124,17 @@ namespace scopeone::ui void layerInfoTextChanged(const QString& text); void zoomLevelChanged(int zoomPercent); void fitToWindowChanged(bool enabled); + void scaleBarVisibilityChanged(bool visible); + void clippingWarningChanged(bool enabled); + void viewDimensionModeChanged(ViewDimensionMode mode); + void threeDimensionalZScaleChanged(float scale); + void threeDimensionalWireframeChanged(bool enabled); + void threeDimensionalColorbarVisibilityChanged(bool visible); + void stageStepRequested(double dxScale, double dyScale, bool big); + void stageZStepRequested(double dzScale, bool big); + void layerClicked(const QString& layerKey); + void layerSliceIndexRequested(const QString& layerKey, int sliceIndex); + void activated(); void mousePositionChanged(const QPoint& widgetPos); void roiDrawn(const QString& cameraId, int x, @@ -107,25 +148,23 @@ namespace scopeone::ui const QPoint& start, const QPoint& end); void measurementLineCleared(); + void imageFilesDropped(const QStringList& filePaths); protected: void initializeGL() override; void resizeGL(int, int) override; void paintGL() override; void mousePressEvent(QMouseEvent* event) override; + void mouseDoubleClickEvent(QMouseEvent* event) override; void mouseMoveEvent(QMouseEvent* event) override; void mouseReleaseEvent(QMouseEvent* event) override; void leaveEvent(QEvent* event) override; void wheelEvent(QWheelEvent* event) override; void keyPressEvent(QKeyEvent* event) override; + void dragEnterEvent(QDragEnterEvent* event) override; + void dropEvent(QDropEvent* event) override; private: - struct FpsState - { - QElapsedTimer intervalTimer; - quint64 framesSinceUpdate{0}; - }; - enum class Blending { Translucent = 0, Additive, Minimum, Opaque, Multiplicative }; enum class FrameRole { Raw, Processed }; enum class MarkupEditMode @@ -173,6 +212,13 @@ namespace scopeone::ui bool hasRawFrame{false}; }; + struct ViewportState + { + int zoomPercent{100}; + QPoint offset; + bool fitToWindow{true}; + }; + struct LayerRenderItem { const FrameSourceRenderInfo* info{nullptr}; @@ -190,33 +236,66 @@ namespace scopeone::ui bool firstVisibleInArea{false}; }; + struct Camera3dState + { + float pitch{35.0f}; + float yaw{45.0f}; + float distance{2.8f}; + QVector2D pan{0.0f, 0.0f}; + }; + private: QStringList m_availableCameraIds; QSet m_staticSourceIds; + QSet m_toolSourceIds; LayerLayoutMode m_layerLayoutMode{LayerLayoutMode::SideBySide}; QMap m_layerFps; QString m_layerInfoText{QStringLiteral("No image loaded")}; - QMap m_fpsStates; - QTimer m_fpsUpdateTimer; + QTimer m_sliceTimer; ImageSceneModel* m_sceneModel{nullptr}; QLabel* m_placeholderLabel{nullptr}; + QWidget* m_sliceBar{nullptr}; + QSlider* m_sliceSlider{nullptr}; + QLabel* m_sliceLabel{nullptr}; + QPushButton* m_slicePlayButton{nullptr}; mutable QMutex m_mutex; QMap m_frameSources; quint64 m_nextFrameRevision{0}; - int m_zoomPercent{100}; - bool m_fitToWindow{true}; - QPoint m_viewOffset; + QMap m_viewportStates; + ViewportState m_overlayViewportState; QString m_placeholderText{QStringLiteral("No image loaded")}; bool m_glInited{false}; + QOpenGLFunctions_3_3_Core m_gl3dFunctions; QOpenGLVertexArrayObject m_vao; GLuint m_vbo{0}; GLuint m_colormapTexture{0}; QOpenGLShaderProgram m_prog; + QOpenGLShaderProgram m_prog3d; + QOpenGLVertexArrayObject m_gridVao; + GLuint m_gridVbo{0}; + GLuint m_gridIbo{0}; + int m_gridElementCount{0}; GLint m_uTex{-1}, m_uMinNorm{-1}, m_uMaxNorm{-1}, m_uTexNormScale{-1}, m_uAlpha{-1}; GLint m_uGamma{-1}, m_uColormap{-1}, m_uColormapLut{-1}; - GLint m_uUvScale{-1}, m_uUvOffset{-1}; + GLint m_uUvScale{-1}, m_uUvOffset{-1}, m_uShowClipping{-1}; + GLint m_u3dTex{-1}, m_u3dMvp{-1}, m_u3dMinNorm{-1}, m_u3dMaxNorm{-1}; + GLint m_u3dTexNormScale{-1}, m_u3dZScale{-1}, m_u3dGamma{-1}; + GLint m_u3dColormap{-1}, m_u3dColormapLut{-1}, m_u3dShowClipping{-1}; + GLint m_u3dUvScale{-1}, m_u3dUvOffset{-1}, m_u3dLightDirection{-1}; + ViewDimensionMode m_viewDimensionMode{ViewDimensionMode::TwoDimensional}; + QMap m_layerCameras3d; + QMap m_layerSliceCounts; + QMap m_layerSliceIndices; + float m_zScale{1.0f}; + bool m_wireframe3d{false}; + bool m_threeDimensionalColorbarVisible{true}; + bool m_scaleBarVisible{true}; + bool m_clippingWarning{false}; + QString m_activeLayerKey; + QStringList m_savedVisibleLayerKeys; + std::function m_pixelSizeCallback; struct CachedTexture { @@ -252,9 +331,18 @@ namespace scopeone::ui QPoint m_dragMarkupStartImagePos; MarkupEditMode m_dragMarkupEditMode{MarkupEditMode::None}; bool m_markupDragging{false}; + bool m_viewPanning{false}; + QPoint m_panStartWidgetPos; + QPoint m_panStartOffset; + QString m_panLayerKey; + bool m_surfaceOrbiting{false}; + bool m_surfacePanning{false}; + QPoint m_surfaceDragStart; + QString m_surfaceLayerKey; + float m_surfaceStartPitch{35.0f}; + float m_surfaceStartYaw{45.0f}; + QVector2D m_surfaceStartPan{0.0f, 0.0f}; void updateImageDisplay(); - void updateLayerFps(const QString& layerKey, quint64 frameCount = 1); - void updateFrameRates(); bool storeSourceFrame(const QString& sourceId, FrameRole role, const scopeone::core::ImageFrame& frame, @@ -265,7 +353,6 @@ namespace scopeone::ui LayerDisplaySettings defaultLayerDisplaySettings(bool processed) const; LayerDisplaySettings layerDisplaySettings(const QString& layerKey) const; Blending blendingFromName(const QString& name) const; - QString blendingName(Blending blending) const; void removeStaticLayerData(const QString& sourceId); QSet validLayerKeys() const; bool hasRawFrame(const FrameSourceState& frameState) const; @@ -276,6 +363,7 @@ namespace scopeone::ui std::vector& renderItems) const; bool resolveDisplayGeometry(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, QRect& displayRect, QSize& imageSize) const; @@ -292,16 +380,19 @@ namespace scopeone::ui const QString& layerKey) const; bool mapWidgetPositionToImage(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, const QPoint& widgetPos, QPoint& imagePos) const; bool mapWidgetRectToImage(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, const QRect& widgetRect, QRect& imageRect) const; bool mapImagePositionToWidget(const FrameSourceState& frameState, bool processed, + const QString& layerKey, const QRect& area, const QPoint& imagePos, QPoint& widgetPos) const; @@ -311,26 +402,44 @@ namespace scopeone::ui const RenderItem& item) const; void drawMarkups(QPainter& painter, const std::vector& renderItems) const; void drawActiveInteractionMarkup(QPainter& painter, const std::vector& renderItems) const; + void drawScaleBar(QPainter& painter, const std::vector& renderItems) const; + void drawTileLabelsAndBadges(QPainter& painter, const std::vector& renderItems) const; bool markupAtWidgetPosition(const QPoint& widgetPos, ImageSceneModel::Markup& outMarkup, PreviewInteractionTarget& outTarget, MarkupEditMode& outEditMode) const; void clearSelectedMarkups(); void drawRenderItem(const RenderItem& item); + void draw3dSurface(const RenderItem& item, + const Camera3dState& camera, + const QRect& targetArea); + void draw3dColorbar(QPainter& painter, + const RenderItem& item, + const QRect& viewportRect) const; + QImage colormapStripImage(int colormapIndex, int height) const; void ensureGlPipeline(); + GLuint ensureFrameTexture(const QString& textureKey, + const scopeone::core::ImageFrame& frame, + quint64 frameRevision); void drawFrameInRect(const QString& textureKey, const scopeone::core::ImageFrame& frame, quint64 frameRevision, - const QRect& r, + const QRect& displayRect, + const QRect& clipRect, bool flipX, bool flipY, const LayerDisplaySettings& display, bool firstVisibleInArea); QRect targetRectForImageSize(const QSize& imageSize, const FrameSourceState& frameState, + const QString& layerKey, const QRect& avail) const; void setUvTransform(bool flipX, bool flipY); void applyViewportForRect(const QRect& logicalRect); + void applyScissorForRect(const QRect& logicalRect); + ViewportState& viewportStateForLayer(const QString& layerKey); + ViewportState viewportStateForLayer(const QString& layerKey) const; + QString viewportControlLayerKey() const; std::vector computeLayout(int count) const; std::vector buildRenderItems(const std::vector& frameSourceRenderInfos) const; @@ -339,5 +448,10 @@ namespace scopeone::ui void cancelROIDrawing(); void cancelMeasurementLineDrawing(); void cancelCrossSectionDrawing(); + void updateSliceBar(); + void updateSliceBarGeometry(); + Camera3dState& cameraForLayer(const QString& layerKey); + const Camera3dState& cameraForLayer(const QString& layerKey) const; + QString layerKeyAt3dPosition(const QPoint& widgetPos) const; }; } diff --git a/src/RecordingWidget.cpp b/src/RecordingWidget.cpp index ce0d3da..092542f 100644 --- a/src/RecordingWidget.cpp +++ b/src/RecordingWidget.cpp @@ -225,8 +225,6 @@ namespace scopeone::ui connect(m_startStopButton, &QPushButton::clicked, this, &RecordingWidget::onStartStopClicked); connect(m_burstModeCheck, &QCheckBox::toggled, this, [this]() { updateUiState(); }); connect(m_detectorCombo, &QComboBox::currentTextChanged, this, [this]() { updateUiState(); }); - connect(m_snapToGalleryButton, &QPushButton::clicked, this, - [this]() { appendSelectedFramesToGallery(); }); connect(m_saveDirLineEdit, &QLineEdit::textChanged, this, [this]() { updateUiState(); }); connect(m_fileNameLineEdit, &QLineEdit::textChanged, this, [this]() { updateUiState(); }); connect(m_formatCombo, QOverload::of(&QComboBox::currentIndexChanged), this, @@ -438,13 +436,6 @@ namespace scopeone::ui captureLayout->addWidget(m_fileNameLineEdit, 2, 1); captureLayout->addWidget(m_autoNameButton, 2, 2); - auto* galleryRow = new QHBoxLayout(); - galleryRow->setSpacing(6); - m_snapToGalleryButton = new QPushButton("Snap to Gallery", this); - galleryRow->addWidget(m_snapToGalleryButton); - captureLayout->addWidget(new QLabel("Gallery:", this), 3, 0); - captureLayout->addLayout(galleryRow, 3, 1, 1, 2); - contentLayout->addWidget(captureGroup); auto* formatGroup = new QGroupBox("Format", this); @@ -720,7 +711,6 @@ namespace scopeone::ui m_browseButton->setEnabled(editingEnabled); m_fileNameLineEdit->setEnabled(editingEnabled); m_autoNameButton->setEnabled(editingEnabled); - m_snapToGalleryButton->setEnabled(hasSelectedCameras); m_formatCombo->setEnabled(editingEnabled); const bool binaryFormat = m_formatCombo->currentData().toInt() == static_cast(scopeone::core::RecordingFormat::Binary); @@ -976,10 +966,19 @@ namespace scopeone::ui return true; } - // Captures latest selected frames into the gallery - bool RecordingWidget::appendSelectedFramesToGallery() + // Captures the Acquire target into the gallery + bool RecordingWidget::snapToGallery(const QString& target) { - const QStringList cameraIds = selectedCameraIds(); + const QString normalizedTarget = target.trimmed(); + QStringList cameraIds; + if (normalizedTarget.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + { + cameraIds = m_availableCameraIds; + } + else if (m_availableCameraIds.contains(normalizedTarget)) + { + cameraIds = {normalizedTarget}; + } if (cameraIds.isEmpty()) { qWarning().noquote() << "No camera available for gallery capture"; diff --git a/src/RecordingWidget.h b/src/RecordingWidget.h index 2f0923a..f8d4597 100644 --- a/src/RecordingWidget.h +++ b/src/RecordingWidget.h @@ -28,6 +28,7 @@ namespace scopeone::ui ~RecordingWidget() override; void setAvailableCameras(const QStringList& cameraIds); + bool snapToGallery(const QString& target); signals: void gallerySessionCaptured( @@ -44,8 +45,6 @@ namespace scopeone::ui void updateStorageStatusText(qint64 availableBytes); void moveOrderItem(int delta); void syncOrderList(); - bool appendSelectedFramesToGallery(); - QString getLastSaveDirectory() const; void setLastSaveDirectory(const QString& path); QString buildTimestampBaseName() const; @@ -60,8 +59,6 @@ namespace scopeone::ui QPushButton* m_browseButton{nullptr}; QLineEdit* m_fileNameLineEdit{nullptr}; QPushButton* m_autoNameButton{nullptr}; - QPushButton* m_snapToGalleryButton{nullptr}; - QCheckBox* m_compressionCheck{nullptr}; QSpinBox* m_compressionLevelSpin{nullptr}; QComboBox* m_formatCombo{nullptr}; diff --git a/src/ScopeOneLocalApiServer.cpp b/src/ScopeOneLocalApiServer.cpp index cd1068d..65262a7 100644 --- a/src/ScopeOneLocalApiServer.cpp +++ b/src/ScopeOneLocalApiServer.cpp @@ -2,6 +2,7 @@ #include "scopeone/ImageSceneModel.h" #include "PreviewWidget.h" +#include "ImageWorkspace.h" #include "scopeone/ScopeOneCore.h" #include @@ -118,6 +119,30 @@ namespace scopeone::ui return response; } + QJsonObject imageDocumentToJson(const ImageDocumentInfo& document) + { + QJsonObject object; + object.insert(QStringLiteral("documentId"), document.id); + object.insert(QStringLiteral("title"), document.title); + object.insert(QStringLiteral("sessionId"), document.sessionId); + object.insert(QStringLiteral("cameraId"), document.cameraId); + object.insert(QStringLiteral("frameIndex"), document.frameIndex); + object.insert(QStringLiteral("frameCount"), document.frameCount); + object.insert(QStringLiteral("ready"), document.ready); + object.insert(QStringLiteral("active"), document.active); + return object; + } + + QJsonArray imageDocumentsToJson(const QList& documents) + { + QJsonArray array; + for (const ImageDocumentInfo& document : documents) + { + array.append(imageDocumentToJson(document)); + } + return array; + } + // Converts one markup to local API JSON QJsonObject markupToJson(const ImageSceneModel::Markup& markup) { @@ -189,71 +214,9 @@ namespace scopeone::ui return object; } - QString processingModuleApiKindName(scopeone::core::ScopeOneCore::ProcessingModuleKind kind) + QString processingModuleIdFromJson(const QJsonValue& value) { - using ProcessingModuleKind = scopeone::core::ScopeOneCore::ProcessingModuleKind; - switch (kind) - { - case ProcessingModuleKind::FFT: - return QStringLiteral("fft"); - case ProcessingModuleKind::BackgroundCalibration: - return QStringLiteral("background_calibration"); - case ProcessingModuleKind::SpatiotemporalBinning: - return QStringLiteral("spatiotemporal_binning"); - case ProcessingModuleKind::GaussianBlur: - return QStringLiteral("gaussian_blur"); - case ProcessingModuleKind::DifferentialRolling: - return QStringLiteral("differential_rolling"); - case ProcessingModuleKind::Unknown: - return QStringLiteral("unknown"); - } - return QStringLiteral("unknown"); - } - - scopeone::core::ScopeOneCore::ProcessingModuleKind processingModuleKindFromJson(const QJsonValue& value) - { - using ProcessingModuleKind = scopeone::core::ScopeOneCore::ProcessingModuleKind; - if (value.isDouble()) - { - const int kind = value.toInt(static_cast(ProcessingModuleKind::Unknown)); - switch (static_cast(kind)) - { - case ProcessingModuleKind::FFT: - case ProcessingModuleKind::BackgroundCalibration: - case ProcessingModuleKind::SpatiotemporalBinning: - case ProcessingModuleKind::GaussianBlur: - case ProcessingModuleKind::DifferentialRolling: - return static_cast(kind); - case ProcessingModuleKind::Unknown: - return ProcessingModuleKind::Unknown; - } - } - - QString name = value.toString().trimmed().toLower(); - name.remove(QLatin1Char('_')); - name.remove(QLatin1Char('-')); - name.remove(QLatin1Char(' ')); - if (name == QStringLiteral("fft")) - { - return ProcessingModuleKind::FFT; - } - if (name == QStringLiteral("backgroundcalibration") || name == QStringLiteral("background")) - { - return ProcessingModuleKind::BackgroundCalibration; - } - if (name == QStringLiteral("spatiotemporalbinning") || name == QStringLiteral("binning")) - { - return ProcessingModuleKind::SpatiotemporalBinning; - } - if (name == QStringLiteral("gaussianblur") || name == QStringLiteral("blur")) - { - return ProcessingModuleKind::GaussianBlur; - } - if (name == QStringLiteral("differentialrolling") || name == QStringLiteral("rolling")) - { - return ProcessingModuleKind::DifferentialRolling; - } - return ProcessingModuleKind::Unknown; + return value.isString() ? value.toString().trimmed() : QString{}; } QJsonObject processingModuleToJson( @@ -262,13 +225,36 @@ namespace scopeone::ui { QJsonObject object; object.insert(QStringLiteral("index"), index); - object.insert(QStringLiteral("kind"), processingModuleApiKindName(info.kind())); - object.insert(QStringLiteral("kindValue"), static_cast(info.kind())); + object.insert(QStringLiteral("kind"), info.id()); object.insert(QStringLiteral("name"), info.name()); + object.insert(QStringLiteral("enabled"), info.enabled()); object.insert(QStringLiteral("parameters"), QJsonObject::fromVariantMap(info.parameters())); return object; } + QJsonObject processingModuleDescriptorToJson( + const scopeone::core::ProcessingModuleDescriptor& descriptor) + { + QJsonObject object; + object.insert(QStringLiteral("id"), descriptor.id); + object.insert(QStringLiteral("name"), descriptor.name); + object.insert(QStringLiteral("schemaVersion"), descriptor.schemaVersion); + QJsonArray parameters; + for (const auto& parameter : descriptor.parameters) + { + QJsonObject item; + item.insert(QStringLiteral("key"), parameter.key); + item.insert(QStringLiteral("name"), parameter.name); + item.insert(QStringLiteral("type"), static_cast(parameter.type)); + item.insert(QStringLiteral("default"), QJsonValue::fromVariant(parameter.defaultValue)); + item.insert(QStringLiteral("minimum"), QJsonValue::fromVariant(parameter.minimum)); + item.insert(QStringLiteral("maximum"), QJsonValue::fromVariant(parameter.maximum)); + parameters.append(item); + } + object.insert(QStringLiteral("parameters"), parameters); + return object; + } + QJsonArray processingModulesToJson( const QList& modules) { @@ -750,6 +736,11 @@ namespace scopeone::ui QStringLiteral("show_frame_mapping_as_layer"), QStringLiteral("save_frame_mapping") })); + groups.insert(QStringLiteral("imageWorkspace"), QJsonArray::fromStringList(QStringList{ + QStringLiteral("image_windows"), QStringLiteral("open_image_window"), + QStringLiteral("activate_image_window"), QStringLiteral("close_image_window"), + QStringLiteral("process_image_window"), QStringLiteral("save_image_window") + })); QJsonObject object; object.insert(QStringLiteral("localOnly"), true); @@ -762,7 +753,8 @@ namespace scopeone::ui object.insert(QStringLiteral("operationGroups"), groups); object.insert(QStringLiteral("longRunningOperations"), QJsonArray::fromStringList(QStringList{ QStringLiteral("start_experiment"), QStringLiteral("record"), - QStringLiteral("start_stage_mosaic") + QStringLiteral("start_stage_mosaic"), QStringLiteral("process_image_window"), + QStringLiteral("save_image_window") })); object.insert(QStringLiteral("hardwareMutationOperations"), QJsonArray::fromStringList(QStringList{ QStringLiteral("load_config"), QStringLiteral("unload_config"), @@ -777,14 +769,16 @@ namespace scopeone::ui })); object.insert(QStringLiteral("filesystemMutationOperations"), QJsonArray::fromStringList(QStringList{ QStringLiteral("save_experiment"), QStringLiteral("start_experiment"), - QStringLiteral("save_frame_mapping"), QStringLiteral("session_save") + QStringLiteral("save_frame_mapping"), QStringLiteral("session_save"), + QStringLiteral("save_image_window") })); object.insert(QStringLiteral("destructiveOperations"), QJsonArray::fromStringList(QStringList{ QStringLiteral("unload_config"), QStringLiteral("remove_static_layer"), QStringLiteral("clear_static_layers"), QStringLiteral("remove_markup"), QStringLiteral("clear_markups"), QStringLiteral("remove_processing_module"), QStringLiteral("load_experiment"), QStringLiteral("cancel_experiment"), - QStringLiteral("session_close"), QStringLiteral("cancel_stage_mosaic") + QStringLiteral("session_close"), QStringLiteral("cancel_stage_mosaic"), + QStringLiteral("close_image_window") })); object.insert(QStringLiteral("frameTransport"), QStringLiteral("shared_memory")); return object; @@ -1125,18 +1119,22 @@ namespace scopeone::ui // Starts the local API pipe server and frame mapping ScopeOneLocalApiServer::ScopeOneLocalApiServer(scopeone::core::ScopeOneCore* core, PreviewWidget* previewWidget, + ImageWorkspace* imageWorkspace, QObject* parent) : QObject(parent) , m_scopeonecore(core) , m_previewWidget(previewWidget) - , m_sceneModel(core ? core->imageSceneModel() : nullptr) + , m_imageWorkspace(imageWorkspace) + , m_sceneModel(core->imageSceneModel()) , m_server(new QLocalServer(this)) { - if (!core) - { - qFatal("ScopeOneLocalApiServer requires ScopeOneCore"); - } m_taskPool.setMaxThreadCount(1); + connect(m_imageWorkspace, &ImageWorkspace::activeViewerChanged, + this, [this]() + { + m_sceneModel = m_imageWorkspace->activeSceneModel(); + m_previewWidget = m_imageWorkspace->activePreviewWidget(); + }); QLocalServer::removeServer(kServerName); connect(m_server, &QLocalServer::newConnection, @@ -1351,17 +1349,203 @@ namespace scopeone::ui // Starts Local API operations that must not run in the socket callback bool ScopeOneLocalApiServer::processAsyncRequest(QLocalSocket* socket, const QJsonObject& request, - const QJsonValue& requestId) + const QJsonValue& requestId, + const ResponseCallback& callback) { const QString type = request.value(QStringLiteral("type")).toString().trimmed(); const QPointer guardedSocket(socket); - const auto finish = [this, guardedSocket, requestId](QJsonObject response) + const auto finish = callback + ? callback + : ResponseCallback([this, guardedSocket, requestId](QJsonObject response) + { + if (guardedSocket) + { + sendRequestResponse(guardedSocket, std::move(response), requestId); + } + }); + + if (type == QStringLiteral("open_image_window")) { - if (guardedSocket) + const QString sessionId = request.value(QStringLiteral("sessionId")).toString().trimmed(); + const auto session = m_scopeonecore->recordingSession(sessionId); + if (!session) { - sendRequestResponse(guardedSocket, std::move(response), requestId); + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), QStringLiteral("Unknown session")); + finish(std::move(response)); + return true; + } + + auto* context = new QObject(this); + auto documentIds = std::make_shared(); + auto completed = std::make_shared(false); + const auto respondWhenReady = [this, context, finish, type, documentIds, completed]() + { + if (*completed || documentIds->isEmpty()) + { + return; + } + for (const QString& documentId : *documentIds) + { + const ImageDocumentInfo document = m_imageWorkspace->document(documentId); + if (!document.isValid()) + { + *completed = true; + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + QStringLiteral("Image window closed before its first frame loaded")); + finish(std::move(response)); + context->deleteLater(); + return; + } + if (!document.ready) + { + return; + } + } + *completed = true; + QJsonObject response = makeResponse(type, true); + response.insert(QStringLiteral("documentIds"), + QJsonArray::fromStringList(*documentIds)); + response.insert(QStringLiteral("activeDocumentId"), + m_imageWorkspace->activeDocumentId()); + finish(std::move(response)); + context->deleteLater(); + }; + connect(m_imageWorkspace, &ImageWorkspace::documentsChanged, + context, respondWhenReady); + *documentIds = m_imageWorkspace->openSession( + session, + request.value(QStringLiteral("title")).toString(), + request.value(QStringLiteral("cameraId")).toString()); + if (documentIds->isEmpty()) + { + *completed = true; + delete context; + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + QStringLiteral("Session has no matching image data")); + finish(std::move(response)); + } + else + { + respondWhenReady(); + } + return true; + } + + if (type == QStringLiteral("process_image_window")) + { + const QJsonValue completeStackValue = request.value(QStringLiteral("completeStack")); + const ImageDocumentInfo source = m_imageWorkspace->document( + request.value(QStringLiteral("documentId")).toString()); + if (!source.isValid() + || (!completeStackValue.isUndefined() && !completeStackValue.isBool())) + { + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + source.isValid() + ? QStringLiteral("completeStack must be a boolean") + : QStringLiteral("Unknown image window")); + finish(std::move(response)); + return true; } - }; + + auto* context = new QObject(this); + auto operationId = std::make_shared(0); + connect(m_imageWorkspace, &ImageWorkspace::documentProcessingFinished, + context, + [this, context, finish, type, operationId]( + quint64 completedId, + const QString& outputDocumentId, + const QString& errorMessage) + { + if (completedId != *operationId) + { + return; + } + const bool success = errorMessage.isEmpty() && !outputDocumentId.isEmpty(); + QJsonObject response = makeResponse(type, success); + if (success) + { + response.insert(QStringLiteral("document"), + imageDocumentToJson( + m_imageWorkspace->document(outputDocumentId))); + } + else + { + response.insert(QStringLiteral("error"), + errorMessage.isEmpty() + ? QStringLiteral("Image processing failed") + : errorMessage); + } + finish(std::move(response)); + context->deleteLater(); + }); + *operationId = m_imageWorkspace->processDocument( + source.id, completeStackValue.toBool(false)); + if (*operationId == 0) + { + delete context; + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + QStringLiteral("No image data or processing modules are available")); + finish(std::move(response)); + } + return true; + } + + if (type == QStringLiteral("save_image_window")) + { + const ImageDocumentInfo document = m_imageWorkspace->document( + request.value(QStringLiteral("documentId")).toString()); + const QString saveError = saveRequestError(request); + if (!document.isValid() || !saveError.isEmpty()) + { + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + document.isValid() ? saveError + : QStringLiteral("Unknown image window")); + finish(std::move(response)); + return true; + } + + scopeone::core::ScopeOneCore::RecordingSaveOptions options; + applySaveRequest(request, options); + auto* context = new QObject(this); + connect(m_imageWorkspace, &ImageWorkspace::documentSaveFinished, + context, + [context, finish, type, document](const QString& completedDocumentId, + bool success, + const QString& message) + { + if (completedDocumentId != document.id) + { + return; + } + QJsonObject response = makeResponse(type, success); + response.insert(QStringLiteral("documentId"), document.id); + if (success) + { + response.insert(QStringLiteral("message"), message); + } + else + { + response.insert(QStringLiteral("error"), message); + } + finish(std::move(response)); + context->deleteLater(); + }); + if (!m_imageWorkspace->saveDocument(document.id, options)) + { + delete context; + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + QStringLiteral("Failed to start image window save")); + finish(std::move(response)); + } + return true; + } if (type == QStringLiteral("load_config")) { @@ -1525,7 +1709,7 @@ namespace scopeone::ui int maxParticles = 1000; const QJsonValue exportMaskValue = request.value(QStringLiteral("exportMask")); const QJsonValue publishMaskValue = request.value(QStringLiteral("publishMask")); - const scopeone::core::ImageFrame frame = m_scopeonecore->graphFrame(layerKey); + const scopeone::core::ImageFrame frame = m_imageWorkspace->frameForLayer(layerKey); if (layerKey.isEmpty() || !intField(request, QStringLiteral("threshold"), threshold) || !intField(request, QStringLiteral("minArea"), minArea) @@ -1549,8 +1733,11 @@ namespace scopeone::ui return true; } threshold = qMin(threshold, frame.maxValue()); + const bool sourceWasLive = m_imageWorkspace->isLiveViewerActive(); + const QPointer sourceScene = m_sceneModel; + const QPointer sourcePreview = m_previewWidget; const quint64 analysisId = m_scopeonecore->detectParticles( - layerKey, threshold, minArea, maxArea, maxParticles); + frame, layerKey, threshold, minArea, maxArea, maxParticles); if (analysisId == 0) { QJsonObject response = makeResponse(type, false); @@ -1563,6 +1750,7 @@ namespace scopeone::ui connect(m_scopeonecore, &scopeone::core::ScopeOneCore::particleDetectionFinished, context, [this, context, finish, type, analysisId, threshold, minArea, maxArea, + sourceWasLive, sourceScene, sourcePreview, publishMask = publishMaskValue.toBool(false), exportMask = exportMaskValue.toBool(false)]( quint64 completedId, @@ -1599,28 +1787,52 @@ namespace scopeone::ui if (publishMask) { - const QString maskLayerId = QStringLiteral("particle_mask"); - const auto publishedMask = m_scopeonecore->publishStaticFrame( - maskLayerId, result.mask, QStringLiteral("Particle Mask")); - if (!publishedMask.isValid()) + if (!sourceWasLive) + { + const QString documentId = m_imageWorkspace->openFrame( + result.mask, QStringLiteral("Particle Mask")); + if (documentId.isEmpty()) + { + response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + QStringLiteral("Failed to open particle mask")); + } + else + { + response.insert(QStringLiteral("maskDocumentId"), documentId); + } + } + else if (!sourceScene || !sourcePreview) { response = makeResponse(type, false); response.insert(QStringLiteral("error"), - QStringLiteral("Failed to publish particle mask")); + QStringLiteral("Source viewer was closed")); } else { - 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(resultLayerKey)) visibleLayers.append(resultLayerKey); - if (!visibleLayers.contains(maskLayerKey)) visibleLayers.append(maskLayerKey); - m_sceneModel->setVisibleLayers(visibleLayers); - m_previewWidget->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); - response.insert(QStringLiteral("maskLayerKey"), maskLayerKey); + const QString maskLayerId = QStringLiteral("particle_mask"); + const auto 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")); + } + else + { + const QString maskLayerKey = + scopeone::core::ScopeOneCore::staticLayerKey(maskLayerId); + sourceScene->setLayerColormap(maskLayerKey, QStringLiteral("Magenta")); + sourceScene->setLayerOpacityPercent(maskLayerKey, 70); + sourceScene->setLayerBlending(maskLayerKey, QStringLiteral("Additive")); + QStringList visibleLayers = sourceScene->visibleLayerIds(); + if (!visibleLayers.contains(resultLayerKey)) visibleLayers.append(resultLayerKey); + if (!visibleLayers.contains(maskLayerKey)) visibleLayers.append(maskLayerKey); + sourceScene->setVisibleLayers(visibleLayers); + sourcePreview->setLayerLayoutMode(PreviewWidget::LayerLayoutMode::Overlay); + response.insert(QStringLiteral("maskLayerKey"), maskLayerKey); + } } } if (response.value(QStringLiteral("ok")).toBool() && exportMask) @@ -2071,6 +2283,17 @@ namespace scopeone::ui return false; } + // Dispatch one request through the same synchronous or asynchronous path as the local socket + void ScopeOneLocalApiServer::dispatchRequest(const QJsonObject& request, + ResponseCallback callback) + { + if (processAsyncRequest(nullptr, request, QJsonValue(), callback)) + { + return; + } + callback(processRequest(request)); + } + // Dispatches one local API request object QJsonObject ScopeOneLocalApiServer::processRequest(const QJsonObject& request) { @@ -2090,6 +2313,45 @@ namespace scopeone::ui return response; } + if (type == QStringLiteral("image_windows")) + { + QJsonObject response = makeResponse(type, true); + response.insert(QStringLiteral("activeDocumentId"), + m_imageWorkspace->activeDocumentId()); + response.insert(QStringLiteral("documents"), + imageDocumentsToJson(m_imageWorkspace->documents())); + return response; + } + + if (type == QStringLiteral("activate_image_window")) + { + const QString documentId = request.value(QStringLiteral("documentId")).toString(); + const bool ok = m_imageWorkspace->activateDocument(documentId); + QJsonObject response = makeResponse(type, ok); + if (ok) + { + response.insert(QStringLiteral("document"), + imageDocumentToJson(m_imageWorkspace->document(documentId))); + } + else + { + response.insert(QStringLiteral("error"), QStringLiteral("Unknown image window")); + } + return response; + } + + if (type == QStringLiteral("close_image_window")) + { + const bool ok = m_imageWorkspace->closeDocument( + request.value(QStringLiteral("documentId")).toString()); + QJsonObject response = makeResponse(type, ok); + if (!ok) + { + response.insert(QStringLiteral("error"), QStringLiteral("Unknown image window")); + } + return response; + } + if (type == QStringLiteral("version")) { QJsonObject response = makeResponse(type, true); @@ -2329,6 +2591,8 @@ namespace scopeone::ui if (type == QStringLiteral("list_layers")) { QJsonObject response = makeResponse(type, true); + response.insert(QStringLiteral("activeDocumentId"), + m_imageWorkspace->activeDocumentId()); response.insert(QStringLiteral("layers"), layersToJson(m_sceneModel, m_previewWidget)); return response; } @@ -2339,7 +2603,7 @@ namespace scopeone::ui scopeone::core::ScopeOneCore::HistogramStats stats; QJsonObject response = makeResponse( type, - !layerKey.isEmpty() && m_scopeonecore->getLayerHistogram(layerKey, stats)); + !layerKey.isEmpty() && m_imageWorkspace->histogram(layerKey, stats)); if (!response.value(QStringLiteral("ok")).toBool()) { response.insert(QStringLiteral("error"), QStringLiteral("Layer has no current frame")); @@ -2360,7 +2624,7 @@ namespace scopeone::ui if (layerKey.isEmpty() || !intField(request, QStringLiteral("x"), x) || !intField(request, QStringLiteral("y"), y) - || !m_scopeonecore->graphPixelValue(layerKey, QPoint(x, y), value)) + || !m_imageWorkspace->pixelValue(layerKey, QPoint(x, y), value)) { response.insert(QStringLiteral("error"), QStringLiteral("Layer has no current frame or position is outside the image")); @@ -2386,8 +2650,8 @@ namespace scopeone::ui return response; } const bool ok = type == QStringLiteral("auto_layer_levels") - ? m_scopeonecore->autoLayerLevels(layerKey) - : m_scopeonecore->fullLayerLevels(layerKey); + ? m_imageWorkspace->autoLayerLevels(layerKey) + : m_imageWorkspace->fullLayerLevels(layerKey); if (!ok) { response.insert(QStringLiteral("error"), QStringLiteral("Layer has no current frame")); @@ -2397,7 +2661,7 @@ namespace scopeone::ui response = makeResponse(type, true); response.insert(QStringLiteral("layerKey"), layerKey); insertLayerDisplayFields( - response, layer, m_scopeonecore->layerAutoStretchEnabled(layerKey)); + response, layer, m_imageWorkspace->layerAutoStretchEnabled(layerKey)); return response; } @@ -2417,12 +2681,12 @@ namespace scopeone::ui response.insert(QStringLiteral("error"), QStringLiteral("Missing enabled value")); return response; } - m_scopeonecore->setLayerAutoStretchEnabled(layerKey, enabledValue.toBool()); + m_imageWorkspace->setLayerAutoStretchEnabled(layerKey, enabledValue.toBool()); m_sceneModel->findLayer(layerKey, layer); response = makeResponse(type, true); response.insert(QStringLiteral("layerKey"), layerKey); insertLayerDisplayFields( - response, layer, m_scopeonecore->layerAutoStretchEnabled(layerKey)); + response, layer, m_imageWorkspace->layerAutoStretchEnabled(layerKey)); return response; } @@ -2446,10 +2710,10 @@ namespace scopeone::ui } QVector values; - if (!m_scopeonecore->getLineProfile(layerKey, - QPoint(x1, y1), - QPoint(x2, y2), - values)) + if (!m_imageWorkspace->lineProfile(layerKey, + QPoint(x1, y1), + QPoint(x2, y2), + values)) { response.insert(QStringLiteral("error"), QStringLiteral("Layer has no current frame or line is outside the image")); @@ -2696,7 +2960,7 @@ namespace scopeone::ui } if (hasLevels) { - m_scopeonecore->setLayerAutoStretchEnabled(layerKey, false); + m_imageWorkspace->setLayerAutoStretchEnabled(layerKey, false); m_sceneModel->setLayerDisplayLevels(layerKey, minLevel, maxLevel, maxPossible); } @@ -2704,7 +2968,7 @@ namespace scopeone::ui response = makeResponse(type, true); response.insert(QStringLiteral("layerKey"), layerKey); insertLayerDisplayFields( - response, layer, m_scopeonecore->layerAutoStretchEnabled(layerKey)); + response, layer, m_imageWorkspace->layerAutoStretchEnabled(layerKey)); return response; } @@ -2789,7 +3053,8 @@ namespace scopeone::ui const QString layerKey = request.value(QStringLiteral("layerKey")).toString().trimmed(); const QString sourceId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey).trimmed(); QJsonObject response = makeResponse(type, false); - if (!scopeone::core::ScopeOneCore::isStaticLayerKey(layerKey) + if (!m_imageWorkspace->isLiveViewerActive() + || !scopeone::core::ScopeOneCore::isStaticLayerKey(layerKey) || sourceId.isEmpty() || !m_previewWidget->availableLayerKeys().contains(layerKey)) { @@ -2803,6 +3068,13 @@ namespace scopeone::ui if (type == QStringLiteral("clear_static_layers")) { + if (!m_imageWorkspace->isLiveViewerActive()) + { + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + QStringLiteral("Static preview layers belong to the live viewer")); + return response; + } m_scopeonecore->clearStaticFrames(); return makeResponse(type, true); } @@ -3458,7 +3730,15 @@ namespace scopeone::ui static_cast(m_scopeonecore->processingBitDepth())); response.insert(QStringLiteral("realTime"), m_scopeonecore->isRealTimeProcessingEnabled()); + response.insert(QStringLiteral("realTimeSource"), + m_scopeonecore->realTimeProcessingSource()); response.insert(QStringLiteral("modules"), processingModulesToJson(modules)); + QJsonArray availableModules; + for (const auto& descriptor : m_scopeonecore->availableProcessingModules()) + { + availableModules.append(processingModuleDescriptorToJson(descriptor)); + } + response.insert(QStringLiteral("availableModules"), availableModules); return response; } @@ -3491,6 +3771,15 @@ namespace scopeone::ui if (type == QStringLiteral("set_realtime_processing")) { const bool enabled = request.value(QStringLiteral("enabled")).toBool(false); + if (enabled && request.contains(QStringLiteral("cameraId")) + && !m_scopeonecore->setRealTimeProcessingSource( + request.value(QStringLiteral("cameraId")).toString())) + { + QJsonObject response = makeResponse(type, false); + response.insert(QStringLiteral("error"), + QStringLiteral("Unknown processing source or processing is already running")); + return response; + } if (!m_scopeonecore->setRealTimeProcessingEnabled(enabled)) { QJsonObject response = makeResponse(type, false); @@ -3502,19 +3791,21 @@ namespace scopeone::ui } QJsonObject response = makeResponse(type, true); response.insert(QStringLiteral("realTime"), m_scopeonecore->isRealTimeProcessingEnabled()); + response.insert(QStringLiteral("realTimeSource"), + m_scopeonecore->realTimeProcessingSource()); return response; } if (type == QStringLiteral("add_processing_module")) { - const auto kind = processingModuleKindFromJson(request.value(QStringLiteral("kind"))); + const QString moduleId = processingModuleIdFromJson(request.value(QStringLiteral("kind"))); QJsonObject response = makeResponse(type, false); - if (kind == scopeone::core::ScopeOneCore::ProcessingModuleKind::Unknown) + if (moduleId.isEmpty()) { response.insert(QStringLiteral("error"), QStringLiteral("Missing or unknown processing module kind")); return response; } - if (!m_scopeonecore->addProcessingModule(kind)) + if (!m_scopeonecore->addProcessingModule(moduleId)) { response.insert(QStringLiteral("error"), m_scopeonecore->isRealTimeProcessingEnabled() @@ -3777,7 +4068,9 @@ namespace scopeone::ui return response; } - const scopeone::core::ImageFrame frame = m_scopeonecore->graphFrame(layerKey); + const scopeone::core::ImageFrame frame = rawFrameRequest + ? m_scopeonecore->graphFrame(layerKey) + : m_imageWorkspace->frameForLayer(layerKey); if (!frame.isValid()) { response.insert(QStringLiteral("error"), diff --git a/src/ScopeOneLocalApiServer.h b/src/ScopeOneLocalApiServer.h index 3b20642..48a600d 100644 --- a/src/ScopeOneLocalApiServer.h +++ b/src/ScopeOneLocalApiServer.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include "scopeone/ScopeOneCore.h" @@ -17,17 +18,24 @@ class QLocalSocket; namespace scopeone::ui { class PreviewWidget; + class ImageWorkspace; class ScopeOneLocalApiServer : public QObject { Q_OBJECT public: + using ResponseCallback = std::function; + explicit ScopeOneLocalApiServer(scopeone::core::ScopeOneCore* core, PreviewWidget* previewWidget, + ImageWorkspace* imageWorkspace, QObject* parent = nullptr); ~ScopeOneLocalApiServer() override; + QJsonObject processRequest(const QJsonObject& request); + void dispatchRequest(const QJsonObject& request, ResponseCallback callback); + private: void handleNewConnection(); void handleSocketReadyRead(QLocalSocket* socket); @@ -38,8 +46,8 @@ namespace scopeone::ui const QJsonValue& requestId); bool processAsyncRequest(QLocalSocket* socket, const QJsonObject& request, - const QJsonValue& requestId); - QJsonObject processRequest(const QJsonObject& request); + const QJsonValue& requestId, + const ResponseCallback& callback = {}); scopeone::core::ExperimentDocument createExperimentDocument(); QJsonObject experimentStatusResponse(const QString& type, const QString& experimentId) const; @@ -52,6 +60,7 @@ namespace scopeone::ui scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; PreviewWidget* m_previewWidget{nullptr}; + ImageWorkspace* m_imageWorkspace{nullptr}; scopeone::core::ImageSceneModel* m_sceneModel{nullptr}; QLocalServer* m_server{nullptr}; QHash m_readBuffers; diff --git a/src/ScopeOneMcpServer.cpp b/src/ScopeOneMcpServer.cpp index 4364686..7aef6e7 100644 --- a/src/ScopeOneMcpServer.cpp +++ b/src/ScopeOneMcpServer.cpp @@ -227,6 +227,77 @@ namespace }), {}, ToolAccess::Confirmed), + makeTool( + QStringLiteral("image_windows"), + QStringLiteral("List independent image windows and the active window")), + makeTool( + QStringLiteral("open_image_window"), + QStringLiteral("Open retained session images in independent image windows"), + inputProperties({ + {QStringLiteral("sessionId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Recording session ID"))}, + {QStringLiteral("title"), + inputProperty(QStringLiteral("string"), QStringLiteral("Optional window title"))}, + {QStringLiteral("cameraId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Optional camera ID filter"))} + }), + {QStringLiteral("sessionId")}, + ToolAccess::StateChanging), + makeTool( + QStringLiteral("activate_image_window"), + QStringLiteral("Activate an independent image window"), + inputProperties({ + {QStringLiteral("documentId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Image window document ID"))} + }), + {QStringLiteral("documentId")}, + ToolAccess::StateChanging), + makeTool( + QStringLiteral("close_image_window"), + QStringLiteral("Close an image window, or the active window when omitted"), + inputProperties({ + {QStringLiteral("documentId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Image window document ID"))} + }), + {}, + ToolAccess::Destructive), + makeTool( + QStringLiteral("process_image_window"), + QStringLiteral("Process one frame or stack from an image window, using the active window when omitted"), + inputProperties({ + {QStringLiteral("documentId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Image window document ID"))}, + {QStringLiteral("completeStack"), + inputProperty(QStringLiteral("boolean"), QStringLiteral("Process the complete stack"), false)} + }), + {}, + ToolAccess::StateChanging), + makeTool( + QStringLiteral("save_image_window"), + QStringLiteral("Save an image window to disk, using the active window when omitted"), + inputProperties({ + {QStringLiteral("documentId"), + inputProperty(QStringLiteral("string"), QStringLiteral("Image window document ID"))}, + {QStringLiteral("saveDir"), + inputProperty(QStringLiteral("string"), QStringLiteral("Destination directory"))}, + {QStringLiteral("baseName"), + inputProperty(QStringLiteral("string"), QStringLiteral("Output base name"))}, + {QStringLiteral("format"), + withEnum( + inputProperty(QStringLiteral("string"), QStringLiteral("Output format"), + QStringLiteral("ome-tiff")), + {QStringLiteral("ome-tiff"), QStringLiteral("ome-zarr"), QStringLiteral("tiff"), QStringLiteral("binary")})}, + {QStringLiteral("compression"), + inputProperty(QStringLiteral("boolean"), QStringLiteral("Enable output compression"), false)}, + {QStringLiteral("compressionLevel"), + withMaximum( + withMinimum( + inputProperty(QStringLiteral("integer"), QStringLiteral("Compression level"), 6), + 0.0), + 9.0)} + }), + {QStringLiteral("saveDir"), QStringLiteral("baseName")}, + ToolAccess::Confirmed), makeTool( QStringLiteral("list_layers"), QStringLiteral("List image layers and their current display state")), @@ -452,7 +523,7 @@ namespace 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("Display the particle mask in ScopeOne"), false)} }), {QStringLiteral("layerKey"), QStringLiteral("threshold"), QStringLiteral("minArea"), QStringLiteral("maxArea")}, @@ -845,7 +916,10 @@ namespace QStringLiteral("Enable or disable real-time image processing"), inputProperties({ {QStringLiteral("enabled"), - inputProperty(QStringLiteral("boolean"), QStringLiteral("Real-time processing state"))} + inputProperty(QStringLiteral("boolean"), QStringLiteral("Real-time processing state"))}, + {QStringLiteral("cameraId"), + inputProperty(QStringLiteral("string"), + QStringLiteral("Optional camera source, empty selects all cameras"))} }), {QStringLiteral("enabled")}, ToolAccess::StateChanging), @@ -854,11 +928,8 @@ namespace QStringLiteral("Append a module to the image-processing pipeline"), inputProperties({ {QStringLiteral("kind"), - withEnum( - inputProperty(QStringLiteral("string"), QStringLiteral("Processing module kind")), - {QStringLiteral("fft"), QStringLiteral("background_calibration"), - QStringLiteral("spatiotemporal_binning"), QStringLiteral("gaussian_blur"), - QStringLiteral("differential_rolling")})}, + inputProperty(QStringLiteral("string"), + QStringLiteral("Processing module ID from processing_modules"))}, {QStringLiteral("parameters"), inputProperty(QStringLiteral("object"), QStringLiteral("Initial module parameters"))} }), diff --git a/src/ScopeOneToolPlugin.cpp b/src/ScopeOneToolPlugin.cpp new file mode 100644 index 0000000..e78940c --- /dev/null +++ b/src/ScopeOneToolPlugin.cpp @@ -0,0 +1,240 @@ +#include "ScopeOneToolPlugin.h" +#include "scopeone/PluginManifest.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::ui +{ + struct ToolRegistry::Entry + { + ToolDescriptor descriptor; + Factory factory; + QAction* action{nullptr}; + QPointer instance; + }; + + ToolRegistry::ToolRegistry(ScopeOneToolContext& context) + : m_context(context) + { + } + + ToolRegistry::~ToolRegistry() + { + for (const auto& entry : m_entries) + { + delete entry->instance; + } + } + + bool ToolRegistry::registerTool(const ToolDescriptor& descriptor, Factory factory) + { + const QString id = descriptor.id.trimmed(); + for (const auto& entry : m_entries) + { + if (entry->descriptor.id == id) + { + return false; + } + } + if (id.isEmpty() || descriptor.name.trimmed().isEmpty() || !factory) + { + return false; + } + + auto entry = std::make_unique(); + entry->descriptor = descriptor; + entry->descriptor.id = id; + entry->factory = std::move(factory); + m_entries.push_back(std::move(entry)); + return true; + } + + QStringList ToolRegistry::loadPlugins(const QString& directoryPath) + { + QStringList errors; + const QDir directory(directoryPath); + for (const QFileInfo& file : directory.entryInfoList(QDir::Files, QDir::Name)) + { + if (!QLibrary::isLibrary(file.absoluteFilePath())) + { + continue; + } + auto loader = std::make_unique(file.absoluteFilePath()); + scopeone::core::PluginManifest manifest; + QString manifestError; + if (!scopeone::core::parsePluginManifest( + loader->metaData().value(QStringLiteral("MetaData")).toObject(), + scopeone::core::PluginKind::Tool, + manifest, + &manifestError)) + { + errors.append(QStringLiteral("%1: %2").arg(file.fileName(), manifestError)); + continue; + } + auto* plugin = qobject_cast(loader->instance()); + if (!plugin) + { + errors.append(QStringLiteral("%1: %2").arg(file.fileName(), loader->errorString())); + continue; + } + + const QList descriptors = plugin->tools(); + QSet ids; + bool valid = !descriptors.isEmpty(); + for (const ToolDescriptor& descriptor : descriptors) + { + const QString id = descriptor.id.trimmed(); + if (id.isEmpty() + || descriptor.name.trimmed().isEmpty() + || ids.contains(id)) + { + valid = false; + break; + } + ids.insert(id); + } + if (!valid) + { + errors.append(QStringLiteral("%1: invalid or duplicate tool id").arg(file.fileName())); + loader->unload(); + continue; + } + + const size_t entryCount = m_entries.size(); + for (const ToolDescriptor& descriptor : descriptors) + { + const QString id = descriptor.id.trimmed(); + if (!registerTool(descriptor, [plugin, id](ScopeOneToolContext& context, + QWidget* parent) + { + return plugin->createTool(id, context, parent); + })) + { + m_entries.resize(entryCount); + errors.append(QStringLiteral("%1: tool id conflicts with an existing tool") + .arg(file.fileName())); + loader->unload(); + break; + } + } + if (m_entries.size() == entryCount) + { + continue; + } + m_pluginLoaders.push_back(std::move(loader)); + } + return errors; + } + + void ToolRegistry::populateMenu(QMenu* menu, QWidget* parent) + { + QHash categories; + for (const auto& entry : m_entries) + { + QMenu* target = menu; + const QString category = entry->descriptor.category.trimmed(); + if (!category.isEmpty()) + { + target = categories.value(category); + if (!target) + { + target = menu->addMenu(category); + categories.insert(category, target); + } + } + entry->action = target->addAction(entry->descriptor.name); + const QString id = entry->descriptor.id; + QObject::connect(entry->action, &QAction::triggered, parent, + [this, id, parent]() { openTool(id, parent); }); + } + updateActions(); + } + + void ToolRegistry::updateActions() + { + const bool hasCamera = !m_context.core().cameraIds().isEmpty(); + for (const auto& entry : m_entries) + { + if (entry->action) + { + entry->action->setEnabled( + m_enabled && (!entry->descriptor.requiresCamera || hasCamera)); + } + } + } + + void ToolRegistry::setEnabled(bool enabled) + { + m_enabled = enabled; + for (const auto& entry : m_entries) + { + if (entry->instance) + { + entry->instance->setEnabled(enabled); + } + } + updateActions(); + } + + void ToolRegistry::openTool(const QString& toolId, QWidget* parent) + { + const auto centerTool = [parent](QWidget* tool) + { + QWidget* host = parent->window(); + const QPoint position = host->frameGeometry().center() + - tool->frameGeometry().center(); + tool->move(position); + }; + + for (const auto& entry : m_entries) + { + if (entry->descriptor.id != toolId) + { + continue; + } + if (entry->descriptor.windowMode == ToolWindowMode::ModelessSingleton && entry->instance) + { + entry->instance->show(); + entry->instance->raise(); + entry->instance->activateWindow(); + return; + } + + QWidget* tool = entry->factory(m_context, parent); + if (!tool) + { + return; + } + if (entry->descriptor.windowMode == ToolWindowMode::Modal) + { + if (auto* dialog = qobject_cast(tool)) + { + dialog->exec(); + } + delete tool; + return; + } + tool->setParent(nullptr, Qt::Window); + tool->setAttribute(Qt::WA_DeleteOnClose); + entry->instance = tool; + tool->adjustSize(); + tool->show(); + tool->raise(); + tool->activateWindow(); + QTimer::singleShot(0, tool, [tool, centerTool]() + { + tool->adjustSize(); + centerTool(tool); + }); + } + } +} diff --git a/src/ScopeOneToolPlugin.h b/src/ScopeOneToolPlugin.h new file mode 100644 index 0000000..3cc019a --- /dev/null +++ b/src/ScopeOneToolPlugin.h @@ -0,0 +1,40 @@ +#pragma once + +#include "scopeone/ToolPlugin.h" + +#include +#include +#include +#include + +class QAction; +class QMenu; +class QPluginLoader; +class QWidget; + +namespace scopeone::ui +{ + class ToolRegistry + { + public: + using Factory = std::function; + + explicit ToolRegistry(ScopeOneToolContext& context); + ~ToolRegistry(); + + bool registerTool(const ToolDescriptor& descriptor, Factory factory); + QStringList loadPlugins(const QString& directoryPath); + void populateMenu(QMenu* menu, QWidget* parent); + void setEnabled(bool enabled); + void updateActions(); + + private: + struct Entry; + void openTool(const QString& toolId, QWidget* parent); + + ScopeOneToolContext& m_context; + std::vector> m_entries; + std::vector> m_pluginLoaders; + bool m_enabled{true}; + }; +} diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp index eea71d9..59b60dc 100644 --- a/src/SettingsDialog.cpp +++ b/src/SettingsDialog.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include @@ -24,6 +25,7 @@ namespace scopeone::ui // Create the application settings dialog SettingsDialog::SettingsDialog(qint64 maxPendingWriteBytes, const QString& microManagerDirectory, + const QString& widgetStyle, const QString& colorScheme, QWidget* parent) : QDialog(parent) @@ -34,13 +36,24 @@ namespace scopeone::ui auto* layout = new QVBoxLayout(this); auto* formLayout = new QFormLayout(); + + m_widgetStyleComboBox = new QComboBox(this); + m_widgetStyleComboBox->addItem(QStringLiteral("Default"), QStringLiteral("default")); + for (const QString& styleKey : QStyleFactory::keys()) + { + m_widgetStyleComboBox->addItem(styleKey, styleKey); + } + const int styleIndex = m_widgetStyleComboBox->findData(widgetStyle); + m_widgetStyleComboBox->setCurrentIndex(styleIndex >= 0 ? styleIndex : 0); + formLayout->addRow(QStringLiteral("Widget style"), m_widgetStyleComboBox); + m_colorSchemeComboBox = new QComboBox(this); m_colorSchemeComboBox->addItem(QStringLiteral("System"), QStringLiteral("system")); m_colorSchemeComboBox->addItem(QStringLiteral("Light"), QStringLiteral("light")); m_colorSchemeComboBox->addItem(QStringLiteral("Dark"), QStringLiteral("dark")); const int colorSchemeIndex = m_colorSchemeComboBox->findData(colorScheme); m_colorSchemeComboBox->setCurrentIndex(colorSchemeIndex >= 0 ? colorSchemeIndex : 0); - formLayout->addRow(QStringLiteral("Theme"), m_colorSchemeComboBox); + formLayout->addRow(QStringLiteral("Color scheme"), m_colorSchemeComboBox); m_recordingBufferLimitEdit = new QLineEdit(this); auto* validator = new QDoubleValidator(m_recordingBufferLimitEdit); @@ -130,6 +143,12 @@ namespace scopeone::ui return directory.isEmpty() ? QString() : QDir::cleanPath(directory); } + // Return the selected application widget style + QString SettingsDialog::widgetStyle() const + { + return m_widgetStyleComboBox->currentData().toString(); + } + // Return the selected application color scheme QString SettingsDialog::colorScheme() const { diff --git a/src/SettingsDialog.h b/src/SettingsDialog.h index d36bdf1..3b52970 100644 --- a/src/SettingsDialog.h +++ b/src/SettingsDialog.h @@ -15,16 +15,19 @@ namespace scopeone::ui public: explicit SettingsDialog(qint64 maxPendingWriteBytes, const QString& microManagerDirectory, + const QString& widgetStyle, const QString& colorScheme, QWidget* parent = nullptr); qint64 maxPendingWriteBytes() const; QString microManagerDirectory() const; + QString widgetStyle() const; QString colorScheme() const; private: QLineEdit* m_recordingBufferLimitEdit{nullptr}; QLineEdit* m_microManagerDirectoryEdit{nullptr}; + QComboBox* m_widgetStyleComboBox{nullptr}; QComboBox* m_colorSchemeComboBox{nullptr}; }; } diff --git a/src/main.cpp b/src/main.cpp index cbeb0ab..2cc896d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -6,6 +6,7 @@ #include "scopeone/ScopeOneCore.h" #include "ConsoleWidget.h" #include "MainWindow.h" +#include "PluginManagerDialog.h" int main(int argc, char *argv[]) { @@ -14,7 +15,7 @@ int main(int argc, char *argv[]) format.setVersion(4, 1); format.setProfile(QSurfaceFormat::CoreProfile); format.setSwapBehavior(QSurfaceFormat::DoubleBuffer); - format.setDepthBufferSize(0); + format.setDepthBufferSize(24); QSurfaceFormat::setDefaultFormat(format); QApplication app(argc, argv); @@ -25,6 +26,10 @@ int main(int argc, char *argv[]) scopeone::ui::ConsoleWidget::installQtMessageHandler(); auto scopeOneCore = std::make_unique(); + for (const QString& error : scopeone::ui::loadConfiguredHardwarePlugins(*scopeOneCore)) + { + qWarning().noquote() << QStringLiteral("Failed to load hardware plugin %1").arg(error); + } scopeone::ui::MainWindow window(scopeOneCore.get()); window.show(); return app.exec();