diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..0e22473 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,16 @@ +{ + "name": "scopeone", + "owner": { + "name": "ScopeOne Project" + }, + "description": "Claude Code plugins for ScopeOne microscopy automation", + "plugins": [ + { + "name": "scopeone", + "displayName": "ScopeOne", + "source": "./scopeone-skill", + "description": "Control ScopeOne through its bundled MCP server", + "homepage": "https://github.com/Experimental-Microscopy-Lab/ScopeOne" + } + ] +} diff --git a/.github/workflows/compile-check.yml b/.github/workflows/compile-check.yml index 4fecd1d..b00bdeb 100644 --- a/.github/workflows/compile-check.yml +++ b/.github/workflows/compile-check.yml @@ -44,6 +44,6 @@ jobs: libtiff-dev \ zlib1g-dev - - name: Compile MMCore and ScopeOne + - name: Compile mmCoreAndDevices and ScopeOne shell: bash run: ./scripts/build-linux.sh diff --git a/.gitignore b/.gitignore index 6f20162..979af84 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,12 @@ ScopeOneCore/python/** !.github/ !.github/** +!.claude-plugin/ +!.claude-plugin/** + +!scopeone-skill/ +!scopeone-skill/** + !resources/ !resources/** diff --git a/README.md b/README.md index addfc9b..e9e5061 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Preprint DOI

-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 multi-process architecture where each camera runs in its own [MMCore](https://github.com/micro-manager/mmCoreAndDevices) instance, enabling simultaneous preview and acquisition across multiple cameras. +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.

@@ -154,6 +154,20 @@ The API reports which operations mutate hardware, write files, remove state, or `ScopeOneMcpServer` is a standalone C++ MCP server included with ScopeOne. It uses MCP protocol version `2025-06-18` over standard input and output and forwards validated tool calls to the running desktop app through the Local API. It does not embed a model, open a network port, or depend on Python. +#### Claude Code plugin + +Install ScopeOne first, then run these commands inside Claude Code: + +```text +/plugin marketplace add Experimental-Microscopy-Lab/ScopeOne +/plugin install scopeone@scopeone +/reload-plugins +``` + +When prompted, select `ScopeOneMcpServer.exe` from the same directory as `ScopeOne.exe`. Start ScopeOne before asking Claude to inspect or control it. The plugin supplies the MCP configuration and agent operating guidance, so no Python installation or manual MCP server launch is required. + +#### Other MCP hosts + To use it: 1. Start ScopeOne. Load the device configuration manually or ask the agent to load it after confirmation. @@ -173,7 +187,8 @@ The MCP tool set mirrors the Local API operation catalog, including system state The current validation list is still short, but the codebase has been cleaned to remove early hard-coded device assumptions. In principle, ScopeOne should follow Micro-Manager device compatibility. ## 🖥️ Tested System Configurations -- Windows 10, Dual Intel(R) Xeon(R) E5-2637 v3, 64 GB RAM, NVIDIA Quadro K620 -- Windows 11, Intel(R) Core(TM) Ultra 5 125U, 64 GB RAM +- Windows 10 Version 21H2, Dual Intel(R) Xeon(R) E5-2637 v3, 64 GB RAM, NVIDIA Quadro K620 +- Windows 11 Version 25H2, Intel(R) Core(TM) Ultra 5 125U, 64 GB RAM +- Fedora Linux 44 (Workstation Edition), Intel(R) Core(TM) i7-7700, 32 GB RAM -Although these machines are older and weaker than many typical lab computers, ScopeOne still provides smooth real-time preview and processing on them. +We build and test ScopeOne on the above machines, which are comparatively older and weaker than many typical optical lab computers. However, ScopeOne still provides smooth real-time preview and processing on them. diff --git a/ScopeOneCore/CMakeLists.txt b/ScopeOneCore/CMakeLists.txt index b2a23c2..6ee40b5 100644 --- a/ScopeOneCore/CMakeLists.txt +++ b/ScopeOneCore/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(ScopeOneCore VERSION 2.0.719 LANGUAGES CXX) +project(ScopeOneCore VERSION 2.0.722 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) set(CMAKE_AUTOMOC ON) @@ -108,7 +108,10 @@ set(CORE_SOURCES src/DifferentialRollingModule.cpp src/MDAManager.cpp src/RecordingManager.cpp - src/MultiProcessCameraManager.cpp + src/CameraBackend.cpp + src/CameraManager.cpp + src/NativeCameraBackend.cpp + src/AgentCameraBackend.cpp src/ScopeOneCore.cpp ) @@ -129,7 +132,8 @@ set(CORE_HEADERS internal/DifferentialRollingModule.h internal/MDAManager.h internal/RecordingManager.h - internal/MultiProcessCameraManager.h + internal/CameraBackend.h + internal/CameraManager.h include/scopeone/ImageFrame.h include/scopeone/SharedFrame.h include/scopeone/scopeone_core_export.h diff --git a/ScopeOneCore/README.md b/ScopeOneCore/README.md index 360529f..234b8da 100644 --- a/ScopeOneCore/README.md +++ b/ScopeOneCore/README.md @@ -43,7 +43,7 @@ Use this placement rule: | Namespace | Purpose | Examples | |---|---|---| | `scopeone::core` | Stable Core-facing types and public facades | `ScopeOneCore`, `ImageFrame`, `ExperimentDocument`, `ImageSceneModel` | -| `scopeone::core::internal` | Core-only managers and processing implementations | `MMCoreManager`, `RecordingManager`, processing modules | +| `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::ui` | Desktop application widgets and UI coordination outside this library | `MainWindow`, `PreviewWidget`, `InspectWidget` | diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index 70ae169..c0260ab 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -21,6 +22,8 @@ #include "scopeone/scopeone_core_export.h" class CMMCore; +class QThreadPool; +class QTimer; namespace scopeone::core { @@ -620,6 +623,7 @@ namespace scopeone::core void clearProcessedFrames(); bool getRawImageStatistics(const QString& cameraId, HistogramStats& stats) const; bool getLayerHistogram(const QString& layerKey, HistogramStats& stats) const; + void setActiveHistogramLayer(const QString& layerKey); bool autoLayerLevels(const QString& layerKey); bool fullLayerLevels(const QString& layerKey); bool setLayerAutoStretchEnabled(const QString& layerKey, bool enabled); @@ -726,10 +730,12 @@ namespace scopeone::core void deviceStateChanged(); void stagePositionChanged(); void newRawFrameReady(const ImageFrame& frame); + 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 processedFrameReady(const ImageFrame& frame); + void processedFramesCompleted(const QString& cameraId, quint64 frameCount); void previewProcessedFrameReady(const ImageFrame& frame); void staticFramePublished(const QString& sourceId, const QString& displayName, @@ -825,6 +831,7 @@ namespace scopeone::core struct HistogramJobState { bool inFlight{false}; + bool retryScheduled{false}; qint64 lastScheduledMs{0}; quint64 activeSequence{0}; ImageFrame queuedFrame; @@ -834,7 +841,7 @@ namespace scopeone::core LoadConfigResult* result, QString* errorMessage); std::shared_ptr core() const; - bool isAgentCamera(const QString& deviceLabel) 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, @@ -845,13 +852,12 @@ namespace scopeone::core void processGraphRawFrameAsync(const ImageFrame& frame); void queuePreviewRawFrame(const ImageFrame& frame); void queuePreviewProcessedFrame(const ImageFrame& frame); - void flushPreviewRawFrames(); - void flushPreviewProcessedFrames(); + void schedulePreviewFlush(); + void flushPreviewFrames(); QString sessionFrameSourceId(const std::shared_ptr& session) const; bool publishSessionFrameSource(const std::shared_ptr& session); - void scheduleHistogramStats(const QString& cameraId, - bool processed, - const ImageFrame& frame); + bool histogramUpdatesEnabled(const QString& layerKey) const; + void scheduleHistogramStats(const QString& layerKey, const ImageFrame& frame); void clearLayerAnalysis(const QString& layerKey); void clearLayerAnalysisByPrefix(const QString& prefix); void updateLineProfile(const QString& cameraId, @@ -891,9 +897,12 @@ namespace scopeone::core QHash m_pendingPreviewProcessedFrames; QHash m_histogramJobStates; QHash m_latestHistogramStats; + std::unique_ptr m_histogramThreadPool; + QString m_activeHistogramLayerKey; quint64 m_nextHistogramSequence{0}; - bool m_previewRawFlushQueued{false}; - bool m_previewProcessedFlushQueued{false}; + QElapsedTimer m_lineProfileUpdateTimer; + QElapsedTimer m_previewPublishTimer; + QTimer* m_previewFlushTimer{nullptr}; QSet m_sessionsSaving; }; } diff --git a/ScopeOneCore/include/scopeone/SharedFrame.h b/ScopeOneCore/include/scopeone/SharedFrame.h index 770acef..eeadebb 100644 --- a/ScopeOneCore/include/scopeone/SharedFrame.h +++ b/ScopeOneCore/include/scopeone/SharedFrame.h @@ -76,8 +76,8 @@ namespace scopeone::core } } - inline constexpr int kSharedFrameNumSlots = 12; - inline constexpr int kSharedFrameMaxBytes = 16 * 2048 * 2048; + inline constexpr int kSharedFrameNumSlots = 32; + inline constexpr int kSharedFrameMaxBytes = 4 * 2048 * 2048; inline constexpr int kSharedMemoryControlSize = 64; inline constexpr int kSharedFrameHeaderSize = static_cast(sizeof(SharedFrameHeader)); inline constexpr int kSharedFrameSlotStride = kSharedFrameHeaderSize + kSharedFrameMaxBytes; diff --git a/ScopeOneCore/internal/AgentProtocol.h b/ScopeOneCore/internal/AgentProtocol.h index 81d8d55..ae1d07c 100644 --- a/ScopeOneCore/internal/AgentProtocol.h +++ b/ScopeOneCore/internal/AgentProtocol.h @@ -8,7 +8,7 @@ namespace scopeone::core::internal::agent { - inline constexpr quint32 kProtocolVersion = 1; + inline constexpr quint32 kProtocolVersion = 3; inline constexpr quint32 kMaxControlMessageBytes = 256 * 1024; inline const QString kEnvelopeKindField = QStringLiteral("kind"); @@ -23,6 +23,7 @@ namespace scopeone::core::internal::agent 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 kCommandSetExposure = QStringLiteral("SetExposure"); inline const QString kCommandListProperties = QStringLiteral("ListProperties"); inline const QString kCommandGetProperty = QStringLiteral("GetProperty"); @@ -32,11 +33,14 @@ namespace scopeone::core::internal::agent inline const QString kCommandClearRoi = QStringLiteral("ClearROI"); inline const QString kCommandGetRoi = QStringLiteral("GetROI"); + inline const QString kFrameDeliveryModePreviewLatest = QStringLiteral("PreviewLatest"); + inline const QString kFrameDeliveryModeLatestOnly = QStringLiteral("LatestOnly"); + inline const QString kFrameDeliveryModeAllFrames = QStringLiteral("AllFrames"); + 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 kEventBufferOverflow = QStringLiteral("BufferOverflow"); inline const QString kExecutableFileName = QStringLiteral("ScopeOne_Agent.exe"); diff --git a/ScopeOneCore/internal/BackgroundCalibrationModule.h b/ScopeOneCore/internal/BackgroundCalibrationModule.h index 15b1a02..dd9285d 100644 --- a/ScopeOneCore/internal/BackgroundCalibrationModule.h +++ b/ScopeOneCore/internal/BackgroundCalibrationModule.h @@ -2,6 +2,7 @@ #include "internal/ProcessingModule.h" #include +#include namespace scopeone::core::internal { @@ -41,9 +42,12 @@ namespace scopeone::core::internal private: void resetCalibration(); void computeBackground(); + ProcessingResult processRunningMean(const ImageFrame& sourceFrame, + const ImageFrame& workingFrame); int m_calibrationFrames{101}; std::deque m_buffer; + std::vector m_runningSum; ImageFrame m_background; bool m_calibrated{false}; BackgroundOperation m_operation{BackgroundOperation::Subtract}; diff --git a/ScopeOneCore/internal/CameraBackend.h b/ScopeOneCore/internal/CameraBackend.h new file mode 100644 index 0000000..5f71fea --- /dev/null +++ b/ScopeOneCore/internal/CameraBackend.h @@ -0,0 +1,140 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "scopeone/ImageFrame.h" + +class CMMCore; + +namespace scopeone::core::internal +{ + struct CameraPropertyReadback + { + QString value; + QString type{QStringLiteral("Unknown")}; + bool readOnly{true}; + bool preInit{false}; + QStringList allowedValues; + bool hasLimits{false}; + double lowerLimit{0.0}; + double upperLimit{0.0}; + }; + + class CameraBackend : public QObject + { + Q_OBJECT + + public: + enum class Kind + { + Native, + Agent + }; + + explicit CameraBackend(QObject* parent = nullptr); + ~CameraBackend() override; + + virtual Kind kind() const = 0; + + virtual bool configureNativeCamera(const std::shared_ptr& core, + const QString& cameraId, + double exposureMs); + virtual bool addAgentCamera(const QString& cameraId, + const QString& adapter, + const QString& device, + const QStringList& preInitProperties, + const QStringList& properties, + double exposureMs); + + 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 void setFrameDeliveryPaused(bool paused) = 0; + virtual bool captureEventFrame(const QString& cameraId, + scopeone::core::ImageFrame& frame, + int timeoutMs); + + virtual bool setRecordingFrameDeliveryEnabled(bool enabled); + virtual bool setHighRateFrameDeliveryEnabled(bool enabled); + + bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const; + bool setExposure(const QString& cameraIdOrAll, double exposureMs); + QStringList listProperties(const QString& cameraId); + bool readPropertyDetails(const QString& cameraId, + const QString& name, + bool fromCache, + CameraPropertyReadback& readback); + bool setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage); + 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); + + signals: + void rawFrameReady(const scopeone::core::ImageFrame& frame); + void rawFramesAcquired(const QString& cameraId, quint64 frameCount); + void recordingFramesReady(const QList& frames); + void frameDeliveryFailed(const QString& errorMessage); + void previewStateChanged(bool running); + void agentControlServerListening(const QString& cameraId, const QString& serverName); + + protected: + bool applyWithPreviewRestart(const QString& cameraId, const std::function& operation); + void notifyPreviewStarted(const QString& cameraId); + void notifyPreviewStopped(); + void submitFrames(const QList& frames, quint64 acquiredFrameCount); + void discardPendingPreviewFrames(); + bool recordingFrameDeliveryEnabled() const; + bool highRateFrameDeliveryEnabled() const; + + virtual bool hasRunningCamera() const = 0; + virtual bool resolvePrimaryCameraId(const QString& cameraIdOrAll, QString& cameraId) const = 0; + virtual QStringList resolveTargetCameraIds(const QString& cameraIdOrAll) const = 0; + virtual bool readExposureFor(const QString& cameraId, double& exposureMs) const = 0; + virtual bool writeExposureFor(const QString& cameraId, double exposureMs) = 0; + virtual QStringList listPropertiesFor(const QString& cameraId) = 0; + virtual bool readPropertyDetailsFor(const QString& cameraId, + const QString& name, + bool fromCache, + CameraPropertyReadback& readback) = 0; + virtual bool setPropertyFor(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) = 0; + virtual bool setROIFor(const QString& cameraId, int x, int y, int width, int height) = 0; + virtual bool clearROIFor(const QString& cameraId) = 0; + virtual bool getROIFor(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) = 0; + + private: + void flushPendingFrames(); + + mutable QMutex m_frameDeliveryMutex; + QHash m_pendingLatestFrames; + QHash m_pendingAcquiredFrameCounts; + QList m_pendingRecordingFrames; + qint64 m_pendingRecordingBytes{0}; + QString m_pendingDeliveryError; + std::atomic_bool m_recordingFrameDeliveryEnabled{false}; + std::atomic_bool m_highRateFrameDeliveryEnabled{false}; + bool m_frameFlushQueued{false}; + }; + + std::unique_ptr createNativeCameraBackend(); + std::unique_ptr createAgentCameraBackend(); +} diff --git a/ScopeOneCore/internal/CameraManager.h b/ScopeOneCore/internal/CameraManager.h new file mode 100644 index 0000000..f04f6c0 --- /dev/null +++ b/ScopeOneCore/internal/CameraManager.h @@ -0,0 +1,85 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "internal/CameraBackend.h" + +class CMMCore; + +namespace scopeone::core::internal +{ + class CameraManager : public QObject + { + Q_OBJECT + + public: + explicit CameraManager(QObject* parent = nullptr); + ~CameraManager() override; + + bool configureNativeCamera(const std::shared_ptr& core, + const QString& cameraId, + double exposureMs = 0.0); + bool addAgentCamera(const QString& cameraId, + const QString& adapter, + const QString& device, + const QStringList& preInitProperties = QStringList(), + const QStringList& properties = QStringList(), + double exposureMs = 0.0); + void shutdown(); + + bool startPreview(); + bool stopPreview(); + 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 getExposure(const QString& cameraIdOrAll, double& exposureMs) const; + bool setExposure(const QString& cameraIdOrAll, double exposureMs); + QStringList listProperties(const QString& cameraId); + QString getProperty(const QString& cameraId, + const QString& name, + bool fromCache = false); + 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); + + 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 captureEventFrame(const QString& cameraId, + scopeone::core::ImageFrame& frame, + int timeoutMs = 1500); + + signals: + void newRawFrameReady(const scopeone::core::ImageFrame& frame); + void rawFramesAcquired(const QString& cameraId, quint64 frameCount); + void recordingFramesReady(const QList& frames); + void frameDeliveryFailed(const QString& errorMessage); + void previewStateChanged(bool running); + void agentControlServerListening(const QString& cameraId, const QString& serverName); + + private: + bool activateBackend(CameraBackend::Kind kind); + + std::unique_ptr m_backend; + bool m_recordingFrameDeliveryEnabled{false}; + bool m_highRateFrameDeliveryEnabled{false}; + QMap m_propertyDetailsCache; + }; +} diff --git a/ScopeOneCore/internal/FrameBufferUtils.h b/ScopeOneCore/internal/FrameBufferUtils.h index e43cf84..233944e 100644 --- a/ScopeOneCore/internal/FrameBufferUtils.h +++ b/ScopeOneCore/internal/FrameBufferUtils.h @@ -3,6 +3,7 @@ #include "scopeone/ImageFrame.h" #include +#include #include namespace cv @@ -65,16 +66,34 @@ namespace scopeone::core::internal } template - inline Pixel* mutableRowData(QByteArray& bytes, int width, int y) + inline Pixel clampPixelValue(int value, int maxValue) { - return reinterpret_cast( - bytes.data() + static_cast(y) * static_cast(width) * static_cast(sizeof(Pixel))); + return static_cast(qBound(0, value, maxValue)); } - template - inline Pixel clampPixelValue(int value, int maxValue) + template + inline void parallelForRows(qint64 workItemCount, int rowCount, Handler&& handler) { - return static_cast(qBound(0, value, maxValue)); + constexpr qint64 kMinimumParallelWorkItems = 1024ll * 1024ll; + if (rowCount <= 0 || workItemCount < kMinimumParallelWorkItems) + { + handler(0, qMax(0, rowCount)); + return; + } + + cv::parallel_for_(cv::Range(0, rowCount), + [&handler](const cv::Range& range) + { + handler(range.start, range.end); + }); + } + + template + inline void parallelForImageRows(int width, int height, Handler&& handler) + { + parallelForRows(static_cast(width) * height, + height, + std::forward(handler)); } template diff --git a/ScopeOneCore/internal/ImageProcessingFramework.h b/ScopeOneCore/internal/ImageProcessingFramework.h index bb0244c..fded134 100644 --- a/ScopeOneCore/internal/ImageProcessingFramework.h +++ b/ScopeOneCore/internal/ImageProcessingFramework.h @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -67,15 +68,18 @@ namespace scopeone::core::internal ImageFrame processFrameThrough(int endModuleIndex, const ImageFrame& frame); signals: - void imageProcessed(const ImageFrame& frame); + void imageProcessed(const ImageFrame& frame, quint64 completedFrameCount); void processingError(const QString& error); private: struct CameraSlot { ImageFrame latestFrame; + quint64 latestFrameGeneration{0}; bool hasFrame{false}; bool processing{false}; + ImageFrame latestProcessedFrame; + quint64 completedFrameCount{0}; }; QString getCameraKey(const ImageFrame& frame) const; @@ -85,6 +89,7 @@ namespace scopeone::core::internal QHash>& pipelines, const QString& cameraKey); void processCameraQueue(const QString& cameraKey); + void flushProcessedOutputs(); ImageFrame frameFromResult(ProcessingResult result); ProcessingPipelineDefinition m_definition; @@ -92,10 +97,11 @@ namespace scopeone::core::internal QHash> m_offlinePipelines; std::atomic m_realTimeEnabled; std::atomic m_processingBitDepth{16}; - void submitFrame(const ImageFrame& frame); mutable QMutex m_frameMutex; QHash m_cameraSlots; + quint64 m_liveGeneration{1}; QThreadPool m_threadPool; + QTimer m_outputTimer; }; } diff --git a/ScopeOneCore/internal/MDAManager.h b/ScopeOneCore/internal/MDAManager.h index ff821c7..2978e5c 100644 --- a/ScopeOneCore/internal/MDAManager.h +++ b/ScopeOneCore/internal/MDAManager.h @@ -15,7 +15,7 @@ class CMMCore; namespace scopeone::core::internal { - class MultiProcessCameraManager; + class CameraManager; struct MDAOutput { @@ -37,7 +37,7 @@ namespace scopeone::core::internal bool isRunning() const { return m_running.load(); } - void setMultiProcessCameraManager(MultiProcessCameraManager* mpcm); + void setCameraManager(CameraManager* cameraManager); bool start(const QList& events, bool block = false); void requestCancel(); @@ -58,7 +58,7 @@ namespace scopeone::core::internal void runSequence(QList events); std::shared_ptr m_mmcore; - MultiProcessCameraManager* m_mpcm{nullptr}; + CameraManager* m_cameraManager{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 6ffce9b..8a4d932 100644 --- a/ScopeOneCore/internal/MMCoreManager.h +++ b/ScopeOneCore/internal/MMCoreManager.h @@ -7,7 +7,7 @@ namespace scopeone::core::internal { - class MultiProcessCameraManager; + class CameraManager; enum class DeviceType { @@ -52,7 +52,7 @@ namespace scopeone::core::internal QString getDeviceTypeString(DeviceType type) const; bool loadConfigurationAndStartCameras(const QString& configPath, - MultiProcessCameraManager* mpcm, + CameraManager* cameraManager, LoadConfigResult* result, QString* errorMessage); diff --git a/ScopeOneCore/internal/MultiProcessCameraManager.h b/ScopeOneCore/internal/MultiProcessCameraManager.h deleted file mode 100644 index bdb2b98..0000000 --- a/ScopeOneCore/internal/MultiProcessCameraManager.h +++ /dev/null @@ -1,123 +0,0 @@ -#pragma once - -#include -#include -#include -#include -#include -#include -#include -#include - -#include "scopeone/ImageFrame.h" - -class QJsonObject; -class CMMCore; - -namespace scopeone::core::internal -{ - class MultiProcessCameraManager : public QObject - { - Q_OBJECT - - public: - explicit MultiProcessCameraManager(QObject* parent = nullptr); - ~MultiProcessCameraManager(); - - void setNativeCore(const std::shared_ptr& core); - bool startSingleCamera(const QString& cameraId, double exposureMs = 0.0); - - void stopAgents(); - - bool startAgentFor(const QString& cameraId, const QString& adapter, const QString& device, - const QStringList& preInitProperties = QStringList(), - const QStringList& properties = QStringList(), - double exposureMs = 0.0); - bool stopAgentFor(const QString& cameraId); - - bool startPreview(); - bool stopPreview(); - void setPollingPaused(bool paused); - - bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const; - bool setExposure(const QString& cameraIdOrAll, double exposureMs); - bool startPreviewFor(const QString& cameraId); - bool stopPreviewFor(const QString& cameraId); - bool isPreviewRunning(const QString& cameraId) const; - QStringList listProperties(const QString& cameraId); - QString getProperty(const QString& cameraId, const QString& name); - 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); - 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); - - 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 captureEventFrame(const QString& cameraId, - scopeone::core::ImageFrame& frame, - int timeoutMs = 1500); - - signals: - void newRawFrameReady(const scopeone::core::ImageFrame& frame); - void previewStateChanged(bool running); - void agentControlServerListening(const QString& cameraId, const QString& serverName); - - private: - struct CameraBackend; - struct DeviceControlBackend; - struct NativeSingleCameraBackend; - struct AgentCameraBackend; - struct ControlSession; - struct CameraSlot; - - void pollSharedMemory(); - bool consumeAgentFrames(CameraSlot& slot, bool emitFrames); - bool hasRunningCamera() const; - void clearPropertyCaches(); - bool waitForControlReady(CameraSlot& slot, int timeoutMs); - - struct CameraSlot - { - QString cameraId; - QString shmKey; - std::shared_ptr shm; - std::shared_ptr process; - std::shared_ptr control; - quint64 lastFrameIndex = 0; - bool isRunning = false; - double exposureMs = 10.0; - scopeone::core::ImageFrame latestFrame; - }; - - bool ensureSharedMemory(CameraSlot& slot); - bool readLatestFrame(CameraSlot& slot); - void updatePollingInterval(); - bool isSingleCamera(const QString& cameraId) const; - void pollSingleCamera(CameraSlot& slot); - bool sendControlCommand(const QString& cameraId, - const QJsonObject& request, - QJsonObject* response, - int timeoutMs = 1200); - - QMap> m_cameras; - QTimer m_pollTimer; - bool m_pollingPaused{false}; - std::shared_ptr m_nativeCore; - QString m_singleCameraId; - std::unique_ptr m_runtime; - - QMap m_propertyTypeCache; - QMap m_propertyReadOnlyCache; - QMap m_propertyAllowedValuesCache; - QMap m_propertyHasLimitsCache; - QMap m_propertyLowerLimitCache; - QMap m_propertyUpperLimitCache; - }; -} diff --git a/ScopeOneCore/internal/RecordingManager.h b/ScopeOneCore/internal/RecordingManager.h index 471ecf7..8c9483b 100644 --- a/ScopeOneCore/internal/RecordingManager.h +++ b/ScopeOneCore/internal/RecordingManager.h @@ -6,6 +6,8 @@ #include #include #include +#include +#include #include #include #include @@ -27,7 +29,7 @@ namespace scopeone::core::internal using RecordingWriterPhase = scopeone::core::ScopeOneCore::RecordingWriterPhase; using RecordingWriterStatus = scopeone::core::ScopeOneCore::RecordingWriterStatus; - class MultiProcessCameraManager; + class CameraManager; class RecordingManager : public QObject { @@ -37,7 +39,7 @@ namespace scopeone::core::internal explicit RecordingManager(QObject* parent = nullptr); ~RecordingManager() override; - void setMultiProcessCameraManager(MultiProcessCameraManager* mpcm) { m_mpcm = mpcm; } + void setCameraManager(CameraManager* cameraManager) { m_cameraManager = cameraManager; } void setMMCore(const std::shared_ptr& core) { m_mmcore = core; } void setLatestFrameFetcher(std::function fetcher) @@ -54,7 +56,8 @@ namespace scopeone::core::internal bool isRecording() const { return m_captureState.isRecording; } - void onNewRawFrameReady(const ImageFrame& frame); + void onRawFramesReady(const QList& frames); + void onFrameDeliveryFailed(const QString& errorMessage); static QString saveSessionToDisk(const std::shared_ptr& session); @@ -77,7 +80,6 @@ namespace scopeone::core::internal double y, bool hasZ, double z); - void bufferUsageChanged(qint64 pendingWriteBytes); void writerStatusChanged(const RecordingWriterStatus& status); void recordingStateChanged(bool isRecording); void recordingStopped(const std::shared_ptr& session); @@ -187,14 +189,14 @@ namespace scopeone::core::internal QString& errorMessage); void appendPreviewEventRecord(const ImageFrame& frame); void primeLastFrameIndices(); - void emitProgress(); + void emitProgress(bool force = false); bool startStreamingOutputs(const ExperimentPlan& plan); void stopStreamingOutputs(bool applyOutputManifest = false); void requestWriterStop(); void writerLoop(const std::shared_ptr& output, quint64 generation); bool writeTask(CameraOutput& output, const WriteTask& task, QString& errorMessage); QString formatName(RecordingFormat format) const; - void emitBufferUsageChanged(qint64 pendingWriteBytes); + void markWriterStatusDirty(); void emitWriterStatus(); void setWriterStatus(RecordingWriterPhase phase, const QString& errorMessage = QString()); QString writerErrorSnapshot() const; @@ -214,7 +216,7 @@ namespace scopeone::core::internal bool allCamerasReachedTarget() const; bool advanceBurstStateIfNeeded(); - MultiProcessCameraManager* m_mpcm{nullptr}; + CameraManager* m_cameraManager{nullptr}; std::shared_ptr m_mmcore; std::function m_latestFrameFetcher; @@ -222,5 +224,8 @@ namespace scopeone::core::internal WriterState m_writerState; CaptureState m_captureState; MdaState m_mdaState; + QElapsedTimer m_progressPublishTimer; + QTimer m_writerStatusTimer; + std::atomic_bool m_writerStatusDirty{false}; }; } diff --git a/ScopeOneCore/internal/SpatiotemporalBinningModule.h b/ScopeOneCore/internal/SpatiotemporalBinningModule.h index 4363924..c1b21d9 100644 --- a/ScopeOneCore/internal/SpatiotemporalBinningModule.h +++ b/ScopeOneCore/internal/SpatiotemporalBinningModule.h @@ -3,6 +3,7 @@ #include "internal/ProcessingModule.h" #include +#include namespace scopeone::core::internal { @@ -33,5 +34,6 @@ namespace scopeone::core::internal BinningMode m_spatialMode{BinningMode::Mean}; BinningMode m_temporalMode{BinningMode::Mean}; std::deque m_frameBuffer; + std::vector m_temporalSum; }; } diff --git a/ScopeOneCore/src/AgentCameraBackend.cpp b/ScopeOneCore/src/AgentCameraBackend.cpp new file mode 100644 index 0000000..58d298e --- /dev/null +++ b/ScopeOneCore/src/AgentCameraBackend.cpp @@ -0,0 +1,1851 @@ +#include "internal/CameraBackend.h" +#include "internal/AgentProtocol.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 + +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; + using scopeone::core::SharedPixelFormat; + using scopeone::core::kSharedFrameHeaderSize; + using scopeone::core::kSharedFrameMaxBytes; + using scopeone::core::kSharedFrameNumSlots; + using scopeone::core::kSharedFrameSlotStride; + using scopeone::core::kSharedMemoryControlSize; + + namespace + { + constexpr int kAgentControlReadyTimeoutMs = 15000; + + enum class AgentFrameDeliveryMode + { + PreviewLatest, + LatestOnly, + AllFrames + }; + + // Normalizes camera ids before they become backend keys + QString normalizedCameraId(const QString& cameraId) + { + return cameraId.trimmed(); + } + + const QString& frameDeliveryModeName(AgentFrameDeliveryMode mode) + { + switch (mode) + { + case AgentFrameDeliveryMode::PreviewLatest: + return agent::kFrameDeliveryModePreviewLatest; + case AgentFrameDeliveryMode::LatestOnly: + return agent::kFrameDeliveryModeLatestOnly; + case AgentFrameDeliveryMode::AllFrames: + return agent::kFrameDeliveryModeAllFrames; + } + return agent::kFrameDeliveryModePreviewLatest; + } + + AgentFrameDeliveryMode nonRecordingDeliveryMode(bool highRate) + { + return highRate + ? AgentFrameDeliveryMode::LatestOnly + : AgentFrameDeliveryMode::PreviewLatest; + } + + } // namespace + + struct AgentControlSession final : QObject + { + struct PendingRequest + { + QByteArray encoded; + QTimer* timer{nullptr}; + std::function completion; + }; + + explicit AgentControlSession(const QString& cameraId, + const QString& serverName, + QObject* parent = nullptr) + : QObject(parent) + , m_cameraId(cameraId) + , m_serverName(serverName) + { + m_reconnectTimer.setSingleShot(true); + connect(&m_reconnectTimer, &QTimer::timeout, this, [this]() { ensureConnected(); }); + + connect(&m_socket, &QLocalSocket::connected, this, [this]() { flushPendingWrites(); }); + connect(&m_socket, &QLocalSocket::disconnected, this, [this]() + { + m_readBuffer.clear(); + setReady(false); + failAll(QStringLiteral("Control session disconnected")); + if (!m_closing) + { + m_reconnectTimer.start(100); + } + }); + connect(&m_socket, &QLocalSocket::readyRead, this, [this]() { handleReadyRead(); }); + connect(&m_socket, &QLocalSocket::errorOccurred, this, + [this](QLocalSocket::LocalSocketError) + { + if (m_closing) + { + return; + } + setReady(false); + failAll(QStringLiteral("Control socket error for '%1'").arg(m_cameraId)); + if (m_socket.state() == QLocalSocket::UnconnectedState) + { + m_reconnectTimer.start(100); + } + }); + } + + void start() + { + ensureConnected(); + } + + void stop() + { + m_closing = true; + m_reconnectTimer.stop(); + m_readBuffer.clear(); + setReady(false); + failAll(QStringLiteral("Control session closed")); + if (m_socket.state() != QLocalSocket::UnconnectedState) + { + m_socket.abort(); + } + } + + bool isReady() const + { + return m_ready && m_socket.state() == QLocalSocket::ConnectedState; + } + + bool waitForReady(int timeoutMs) + { + if (isReady()) + { + return true; + } + + ensureConnected(); + + QEventLoop loop; + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval((std::max)(1, timeoutMs)); + + const auto quitIfReady = [this, &loop]() + { + if (isReady()) + { + loop.quit(); + } + }; + + connect(&m_socket, &QLocalSocket::connected, &loop, quitIfReady); + connect(&m_socket, &QLocalSocket::readyRead, &loop, quitIfReady); + connect(&m_socket, &QLocalSocket::disconnected, &loop, quitIfReady); + connect(&m_socket, &QLocalSocket::errorOccurred, &loop, + [quitIfReady](QLocalSocket::LocalSocketError) + { + quitIfReady(); + }); + connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); + + watchdog.start(); + if (!isReady()) + { + loop.exec(); + } + return isReady(); + } + + void addReadyHandler(std::function handler) + { + m_readyHandlers.push_back(std::move(handler)); + } + + void addEventHandler(std::function handler) + { + m_eventHandlers.push_back(std::move(handler)); + } + + bool sendRequest(const QJsonObject& request, + int timeoutMs, + std::function completion) + { + const QString type = request.value(agent::kMessageTypeField).toString(); + if (type.isEmpty()) + { + return false; + } + + 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)); + + PendingRequest pending; + pending.encoded = agent::encodeMessage(envelope); + pending.completion = std::move(completion); + pending.timer = new QTimer(this); + pending.timer->setSingleShot(true); + connect(pending.timer, &QTimer::timeout, this, [this, requestId]() + { + completeRequest(requestId, false, QJsonObject{}, QStringLiteral("Control request timed out")); + }); + pending.timer->start((std::max)(1, timeoutMs)); + + m_pending.insert(requestId, pending); + m_sendQueue.push_back(requestId); + ensureConnected(); + flushPendingWrites(); + return true; + } + + private: + void ensureConnected() + { + if (m_closing) + { + return; + } + if (m_socket.state() == QLocalSocket::ConnectedState + || m_socket.state() == QLocalSocket::ConnectingState) + { + return; + } + m_socket.connectToServer(m_serverName); + } + + void setReady(bool ready) + { + if (m_ready == ready) + { + return; + } + m_ready = ready; + for (const auto& handler : m_readyHandlers) + { + handler(m_ready); + } + } + + void flushPendingWrites() + { + if (m_socket.state() != QLocalSocket::ConnectedState) + { + return; + } + + while (!m_sendQueue.isEmpty()) + { + const quint64 requestId = m_sendQueue.front(); + m_sendQueue.pop_front(); + + auto it = m_pending.find(requestId); + if (it == m_pending.end()) + { + continue; + } + m_socket.write(it->encoded); + } + m_socket.flush(); + } + + void handleReadyRead() + { + 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) + { + resetWithError(error); + return; + } + + if (message.value(agent::kEnvelopeVersionField).toInt(0) + != static_cast(agent::kProtocolVersion)) + { + resetWithError(QStringLiteral("Control protocol version mismatch")); + return; + } + + const QString kind = message.value(agent::kEnvelopeKindField).toString(); + if (kind == agent::kMessageKindResponse) + { + const quint64 requestId = + agent::decodeUInt64(message.value(agent::kEnvelopeRequestIdField)); + if (requestId == 0) + { + continue; + } + completeRequest(requestId, true, message, QString{}); + continue; + } + + if (kind == agent::kMessageKindEvent) + { + const QString type = message.value(agent::kMessageTypeField).toString(); + if (type == agent::kEventHello) + { + setReady(true); + } + for (const auto& handler : m_eventHandlers) + { + handler(message); + } + } + } + } + + void completeRequest(quint64 requestId, + bool ok, + const QJsonObject& response, + const QString& error) + { + auto it = m_pending.find(requestId); + if (it == m_pending.end()) + { + return; + } + + PendingRequest pending = it.value(); + m_pending.erase(it); + + if (pending.timer) + { + pending.timer->stop(); + pending.timer->deleteLater(); + } + if (pending.completion) + { + pending.completion(ok, response, error); + } + } + + void failAll(const QString& error) + { + const QList requestIds = m_pending.keys(); + for (quint64 requestId : requestIds) + { + completeRequest(requestId, false, QJsonObject{}, error); + } + m_sendQueue.clear(); + } + + void resetWithError(const QString& error) + { + m_readBuffer.clear(); + setReady(false); + failAll(error); + if (m_socket.state() != QLocalSocket::UnconnectedState) + { + m_socket.abort(); + } + if (!m_closing) + { + m_reconnectTimer.start(100); + } + } + + QString m_cameraId; + QString m_serverName; + QLocalSocket m_socket; + QTimer m_reconnectTimer; + QByteArray m_readBuffer; + QMap m_pending; + QList m_sendQueue; + QList> m_readyHandlers; + QList> m_eventHandlers; + quint64 m_nextRequestId{1}; + bool m_ready{false}; + bool m_closing{false}; + }; + + class AgentCameraBackend; + + class AgentFrameWorker final : public QObject + { + public: + explicit AgentFrameWorker(AgentCameraBackend* owner) + : m_owner(owner) + { + } + + void addCamera(const QString& cameraId, const QString& shmKey); + void removeCamera(const QString& cameraId); + void clear(); + bool ensureSharedMemory(const QString& cameraId); + ImageFrame consumeFrames(const QString& cameraId); + void consumeFramesAsync(const QString& cameraId); + + private: + struct ReaderSlot + { + QString cameraId; + QString shmKey; + std::unique_ptr shm; + quint64 lastFrameIndex{0}; + ImageFrame latestFrame; + }; + + bool ensureSharedMemory(ReaderSlot& slot); + bool copyFrame(ReaderSlot& slot, + const SharedFrameHeader& header, + const uchar* pixelData, + QList& frames); + bool readLatestFrame(ReaderSlot& slot, + QList& frames, + quint64& acquiredFrameCount); + bool readAllFrames(ReaderSlot& slot, + QList& frames, + quint64& acquiredFrameCount); + + AgentCameraBackend* const m_owner; + QMap> m_readers; + }; + + struct AgentCameraSlot + { + QString cameraId; + QString shmKey; + std::shared_ptr process; + std::shared_ptr control; + bool isRunning{false}; + double exposureMs{10.0}; + bool frameReadQueued{false}; + bool frameReadRequested{false}; + }; + + class AgentCameraBackend final : public CameraBackend + { + public: + AgentCameraBackend(); + ~AgentCameraBackend() override; + + Kind kind() const override { return Kind::Agent; } + bool addAgentCamera(const QString& cameraId, + const QString& adapter, + const QString& device, + const QStringList& preInitProperties, + const QStringList& properties, + double exposureMs) override; + void removeAgentCamera(const QString& cameraId); + bool isPreviewRunning(const QString& cameraId) const override + { + const auto it = m_cameras.constFind(cameraId); + return it != m_cameras.constEnd() && it.value() && it.value()->isRunning; + } + void setFrameDeliveryPaused(bool paused) override; + bool setRecordingFrameDeliveryEnabled(bool enabled) override; + bool setHighRateFrameDeliveryEnabled(bool enabled) override; + bool captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs) override; + + void shutdown(); + + bool startPreview() override + { + if (m_cameras.isEmpty()) + { + return false; + } + + QStringList startedCameraIds; + for (auto it = m_cameras.begin(); it != m_cameras.end(); ++it) + { + const QString cameraId = it.key(); + if (it.value() && it.value()->isRunning) + { + continue; + } + if (!startPreviewFor(cameraId)) + { + for (const QString& startedCameraId : startedCameraIds) + { + stopPreviewFor(startedCameraId); + } + return false; + } + startedCameraIds.append(cameraId); + } + qInfo().noquote() << "Agent preview started"; + return true; + } + + bool stopPreview() override + { + bool ok = true; + for (auto it = m_cameras.begin(); it != m_cameras.end(); ++it) + { + ok = stopPreviewFor(it.key()) && ok; + } + if (ok) + { + qInfo().noquote() << "Agent preview stopped"; + } + return ok; + } + + bool startPreviewFor(const QString& cameraId) override + { + if (!m_cameras.contains(cameraId)) + { + qWarning().noquote() << QString("Camera '%1' not found").arg(cameraId); + return false; + } + + AgentCameraSlot& slot = *m_cameras[cameraId]; + if (slot.isRunning) + { + return true; + } + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandStartPreview); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 1200)) + { + qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); + return false; + } + if (!resp.value(QStringLiteral("ok")).toBool(false)) + { + qWarning().noquote() << QString("Agent refused to start preview for '%1'").arg(cameraId); + return false; + } + + bool sharedMemoryReady = false; + for (int attempt = 0; attempt < 20 && !sharedMemoryReady; ++attempt) + { + sharedMemoryReady = prepareFrameReader(cameraId); + if (!sharedMemoryReady) + { + QThread::msleep(50); + } + } + if (!sharedMemoryReady) + { + qWarning().noquote() << QString("Shared memory unavailable for '%1'").arg(cameraId); + QJsonObject stopRequest; + stopRequest.insert(agent::kMessageTypeField, agent::kCommandStopPreview); + sendControlCommand(cameraId, stopRequest, nullptr, 1200); + return false; + } + + slot.isRunning = true; + notifyPreviewStarted(cameraId); + return true; + } + + bool stopPreviewFor(const QString& cameraId) override + { + const auto it = m_cameras.find(cameraId); + if (it == m_cameras.end() || !it.value()) + { + return false; + } + if (!it.value()->isRunning) + { + return true; + } + + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandStopPreview); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 1200)) + { + return false; + } + if (!resp.value(QStringLiteral("ok")).toBool(false)) + { + return false; + } + + const bool wasRunning = it.value()->isRunning; + it.value()->isRunning = false; + if (wasRunning) + { + notifyPreviewStopped(); + } + return true; + } + + protected: + bool hasRunningCamera() const override + { + for (auto it = m_cameras.constBegin(); it != m_cameras.constEnd(); ++it) + { + if (it.value() && it.value()->isRunning) + { + return true; + } + } + return false; + } + + bool resolvePrimaryCameraId(const QString& cameraIdOrAll, QString& cameraId) const override + { + if (m_cameras.isEmpty()) + { + return false; + } + + const QString target = + cameraIdOrAll.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0 + ? m_cameras.firstKey() + : cameraIdOrAll; + if (!m_cameras.contains(target)) + { + return false; + } + cameraId = target; + return true; + } + + QStringList resolveTargetCameraIds(const QString& cameraIdOrAll) const override + { + if (cameraIdOrAll.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) + { + return m_cameras.keys(); + } + return m_cameras.contains(cameraIdOrAll) ? QStringList{cameraIdOrAll} : QStringList{}; + } + + bool readExposureFor(const QString& cameraId, double& exposureMs) const override + { + const auto it = m_cameras.constFind(cameraId); + if (it == m_cameras.constEnd() || !it.value()) + { + return false; + } + exposureMs = it.value()->exposureMs; + return exposureMs > 0.0; + } + + bool writeExposureFor(const QString& cameraId, double exposureMs) override + { + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandSetExposure); + req.insert(QStringLiteral("value"), exposureMs); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 1200) + || !resp.value(QStringLiteral("ok")).toBool(false)) + { + return false; + } + const double actualExposureMs = resp.value(QStringLiteral("exposureMs")).toDouble(exposureMs); + m_cameras[cameraId]->exposureMs = actualExposureMs; + return true; + } + + QStringList listPropertiesFor(const QString& cameraId) override + { + QStringList out; + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandListProperties); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 4000)) + { + return out; + } + if (!resp.value(QStringLiteral("ok")).toBool(false)) + { + return out; + } + const QJsonArray properties = resp.value(QStringLiteral("properties")).toArray(); + for (const auto& value : properties) + { + out << value.toString(); + } + return out; + } + + bool readPropertyDetailsFor(const QString& cameraId, + const QString& name, + bool fromCache, + CameraPropertyReadback& readback) override + { + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandGetProperty); + req.insert(QStringLiteral("name"), name); + req.insert(QStringLiteral("fromCache"), fromCache); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 4000)) + { + return false; + } + if (!resp.value(QStringLiteral("ok")).toBool(false)) + { + return false; + } + + readback.value = resp.value(QStringLiteral("value")).toString(); + readback.type = resp.value(QStringLiteral("propertyType")).toString(QStringLiteral("Unknown")); + readback.readOnly = resp.value(QStringLiteral("readOnly")).toBool(true); + readback.preInit = resp.value(QStringLiteral("preInit")).toBool(false); + readback.hasLimits = resp.value(QStringLiteral("hasLimits")).toBool(false); + readback.lowerLimit = resp.value(QStringLiteral("lowerLimit")).toDouble(0.0); + readback.upperLimit = resp.value(QStringLiteral("upperLimit")).toDouble(0.0); + + const QJsonArray allowedValues = resp.value(QStringLiteral("allowedValues")).toArray(); + for (const auto& value : allowedValues) + { + readback.allowedValues << value.toString(); + } + return true; + } + + bool setPropertyFor(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) override + { + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandSetProperty); + req.insert(QStringLiteral("name"), name); + req.insert(QStringLiteral("value"), value); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 4000)) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Agent control request failed"); + } + return false; + } + if (!resp.value(QStringLiteral("ok")).toBool(false)) + { + if (errorMessage) + { + *errorMessage = resp.value(QStringLiteral("error")).toString(); + } + return false; + } + return true; + } + + bool setROIFor(const QString& cameraId, int x, int y, int width, int height) override + { + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandSetRoi); + req.insert(QStringLiteral("x"), x); + req.insert(QStringLiteral("y"), y); + req.insert(QStringLiteral("width"), width); + req.insert(QStringLiteral("height"), height); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 1200)) + { + qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); + return false; + } + const bool ok = resp.value(QStringLiteral("ok")).toBool(false); + if (!ok) + { + qWarning().noquote() << QString("Failed to set ROI for '%1': %2") + .arg(cameraId, resp.value(QStringLiteral("error")).toString(QStringLiteral("Unknown error"))); + } + return ok; + } + + bool clearROIFor(const QString& cameraId) override + { + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandClearRoi); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 1200)) + { + qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); + return false; + } + const bool ok = resp.value(QStringLiteral("ok")).toBool(false); + if (!ok) + { + qWarning().noquote() << QString("Failed to clear ROI for '%1': %2") + .arg(cameraId, resp.value(QStringLiteral("error")).toString(QStringLiteral("Unknown error"))); + } + return ok; + } + + bool getROIFor(const QString& cameraId, int& x, int& y, int& width, int& height) override + { + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandGetRoi); + QJsonObject resp; + if (!sendControlCommand(cameraId, req, &resp, 1200)) + { + qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); + return false; + } + const bool ok = resp.value(QStringLiteral("ok")).toBool(false); + if (!ok) + { + qWarning().noquote() << QString("Failed to get ROI for '%1': %2") + .arg(cameraId, resp.value(QStringLiteral("error")).toString(QStringLiteral("Unknown error"))); + return false; + } + + x = resp.value(QStringLiteral("x")).toInt(0); + y = resp.value(QStringLiteral("y")).toInt(0); + width = resp.value(QStringLiteral("width")).toInt(0); + height = resp.value(QStringLiteral("height")).toInt(0); + return true; + } + private: + friend class AgentFrameWorker; + + bool waitForControlReady(AgentCameraSlot& slot, int timeoutMs); + bool addFrameReader(const QString& cameraId, const QString& shmKey); + void removeFrameReader(const QString& cameraId); + bool prepareFrameReader(const QString& cameraId); + ImageFrame consumeFrameNow(const QString& cameraId); + void scheduleFrameRead(const QString& cameraId); + void completeFrameRead(const QString& cameraId); + bool sendControlCommand(const QString& cameraId, + const QJsonObject& request, + QJsonObject* response, + int timeoutMs); + bool sendFrameDeliveryMode(const QString& cameraId, AgentFrameDeliveryMode mode); + + QMap> m_cameras; + std::atomic_bool m_frameDeliveryPaused{false}; + bool m_shuttingDown{false}; + QThread m_frameThread; + AgentFrameWorker* 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() + { + m_frameThread.setObjectName(QStringLiteral("ScopeOneAgentFrameReader")); + m_frameWorker = new AgentFrameWorker(this); + m_frameWorker->moveToThread(&m_frameThread); + QObject::connect(&m_frameThread, &QThread::finished, + m_frameWorker, &QObject::deleteLater); + m_frameThread.start(); + } + + AgentCameraBackend::~AgentCameraBackend() + { + shutdown(); + m_frameThread.quit(); + m_frameThread.wait(); + m_frameWorker = nullptr; + } + + void AgentCameraBackend::shutdown() + { + if (m_shuttingDown) + { + return; + } + m_shuttingDown = true; + CameraBackend::setRecordingFrameDeliveryEnabled(false); + + const bool hadRunningCamera = hasRunningCamera(); + for (auto& slot : m_cameras) + { + slot->isRunning = false; + } + if (m_frameWorker && m_frameThread.isRunning()) + { + AgentFrameWorker* const worker = m_frameWorker; + QMetaObject::invokeMethod(worker, + [worker]() { worker->clear(); }, + Qt::BlockingQueuedConnection); + } + for (auto& slot : m_cameras) + { + if (slot->control) + { + QJsonObject request; + request.insert(agent::kMessageTypeField, agent::kCommandShutdown); + sendControlCommand(slot->cameraId, request, nullptr, 800); + slot->control->stop(); + } + if (slot->process) + { + slot->process->terminate(); + if (!slot->process->waitForFinished(1500)) + { + slot->process->kill(); + slot->process->waitForFinished(1000); + } + } + } + m_cameras.clear(); + discardPendingPreviewFrames(); + if (hadRunningCamera) + { + notifyPreviewStopped(); + } + } + + void AgentFrameWorker::addCamera(const QString& cameraId, const QString& shmKey) + { + removeCamera(cameraId); + auto slot = std::make_shared(); + slot->cameraId = cameraId; + slot->shmKey = shmKey; + slot->shm = std::make_unique(); + slot->shm->setNativeKey(shmKey); + m_readers.insert(cameraId, std::move(slot)); + } + + void AgentFrameWorker::removeCamera(const QString& cameraId) + { + m_readers.remove(cameraId); + } + + void AgentFrameWorker::clear() + { + m_readers.clear(); + } + + bool AgentFrameWorker::ensureSharedMemory(const QString& cameraId) + { + const auto it = m_readers.find(cameraId); + return it != m_readers.end() && ensureSharedMemory(*it.value()); + } + + bool AgentFrameWorker::ensureSharedMemory(ReaderSlot& slot) + { + const int expectedSize = + kSharedMemoryControlSize + kSharedFrameNumSlots * kSharedFrameSlotStride; + if (slot.shm->isAttached()) + { + if (slot.shm->size() >= expectedSize) + { + return true; + } + qWarning().noquote() + << QString("SHM size mismatch for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); + slot.shm->detach(); + return false; + } + if (!slot.shm->attach(QSharedMemory::ReadWrite)) + { + qWarning().noquote() + << QString("SHM attach failed for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); + return false; + } + if (slot.shm->size() < expectedSize) + { + qWarning().noquote() + << QString("SHM layout mismatch for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); + slot.shm->detach(); + return false; + } + qInfo().noquote() + << QString("SHM attached for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); + return true; + } + + // Waits for one agent control channel to become ready + bool AgentCameraBackend::waitForControlReady(AgentCameraSlot& slot, int timeoutMs) + { + return slot.control && slot.control->waitForReady(timeoutMs); + } + + bool AgentCameraBackend::addFrameReader(const QString& cameraId, const QString& shmKey) + { + if (!m_frameWorker || !m_frameThread.isRunning()) + { + return false; + } + AgentFrameWorker* const worker = m_frameWorker; + return QMetaObject::invokeMethod( + worker, + [worker, cameraId, shmKey]() { worker->addCamera(cameraId, shmKey); }, + Qt::BlockingQueuedConnection); + } + + void AgentCameraBackend::removeFrameReader(const QString& cameraId) + { + if (m_frameWorker && m_frameThread.isRunning()) + { + AgentFrameWorker* const worker = m_frameWorker; + QMetaObject::invokeMethod( + worker, + [worker, cameraId]() { worker->removeCamera(cameraId); }, + Qt::BlockingQueuedConnection); + } + } + + bool AgentCameraBackend::prepareFrameReader(const QString& cameraId) + { + if (!m_frameWorker || !m_frameThread.isRunning()) + { + return false; + } + bool attached = false; + AgentFrameWorker* const worker = m_frameWorker; + const bool invoked = QMetaObject::invokeMethod( + worker, + [worker, cameraId, &attached]() { attached = worker->ensureSharedMemory(cameraId); }, + Qt::BlockingQueuedConnection); + return invoked && attached; + } + + // Sends one JSON command to an agent control channel + bool AgentCameraBackend::sendControlCommand(const QString& cameraId, + const QJsonObject& request, + QJsonObject* response, + int timeoutMs) + { + const QString normalizedId = normalizedCameraId(cameraId); + const auto it = m_cameras.constFind(normalizedId); + if (it == m_cameras.constEnd() || !it.value() || !it.value()->control) + { + return false; + } + + bool completed = false; + bool ok = false; + QJsonObject capturedResponse; + QEventLoop loop; + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval((std::max)(1, timeoutMs + 250)); + connect(&watchdog, &QTimer::timeout, &loop, [&]() + { + if (!completed) + { + completed = true; + ok = false; + } + loop.quit(); + }); + + if (!it.value()->control->sendRequest( + request, + (std::max)(1, timeoutMs), + [&](bool requestOk, const QJsonObject& requestResponse, const QString&) + { + if (completed) + { + return; + } + completed = true; + ok = requestOk; + capturedResponse = requestResponse; + loop.quit(); + })) + { + return false; + } + + watchdog.start(); + if (!completed) + { + loop.exec(); + } + + if (!ok) + { + return false; + } + if (response) + { + *response = capturedResponse; + } + return true; + } + + // Selects the requested producer delivery policy in one agent + bool AgentCameraBackend::sendFrameDeliveryMode(const QString& cameraId, + AgentFrameDeliveryMode mode) + { + QJsonObject request; + request.insert(agent::kMessageTypeField, agent::kCommandSetFrameDeliveryMode); + request.insert(QStringLiteral("mode"), frameDeliveryModeName(mode)); + QJsonObject response; + return sendControlCommand(cameraId, request, &response, 1200) + && response.value(QStringLiteral("ok")).toBool(false); + } + + bool AgentFrameWorker::copyFrame(ReaderSlot& slot, + const SharedFrameHeader& header, + const uchar* pixelData, + QList& frames) + { + const quint64 rawSize = sharedFramePayloadSize(header); + QByteArray payload; + payload.resize(static_cast(rawSize)); + memcpy(payload.data(), pixelData, static_cast(rawSize)); + + ImageFrame frame = ImageFrame::fromSharedFrame(slot.cameraId, header, payload); + if (!frame.isValid()) + { + return false; + } + slot.latestFrame = frame; + frames.append(std::move(frame)); + return true; + } + + // Reads only the newest ready frame for responsive preview delivery + bool AgentFrameWorker::readLatestFrame(ReaderSlot& slot, + QList& frames, + quint64& acquiredFrameCount) + { + auto* base = static_cast(slot.shm->data()); + if (!base) + { + return false; + } + + const int slotStride = kSharedFrameSlotStride; + const int baseOffset = kSharedMemoryControlSize; + auto* control = reinterpret_cast(base); + + const quint64 previousFrameIndex = slot.lastFrameIndex; + SharedFrameHeader capturedHeader{}; + uchar* capturedSlot = nullptr; + + auto claimCandidate = [&](quint32 idx) -> bool + { + if (idx >= kSharedFrameNumSlots) return false; + uchar* ptr = base + baseOffset + idx * slotStride; + SharedFrameHeader header{}; + if (!claimFrameSlot(ptr, header)) return false; + if (header.frameIndex <= slot.lastFrameIndex) + { + releaseFrameSlot(ptr); + return false; + } + capturedHeader = header; + capturedSlot = ptr; + return true; + }; + + const quint32 latestIdx = std::atomic_ref(control->latestSlotIndex) + .load(std::memory_order_acquire); + claimCandidate(latestIdx); + + if (!capturedSlot) + { + quint64 bestIndex = slot.lastFrameIndex; + for (int i = 0; i < kSharedFrameNumSlots; ++i) + { + uchar* ptr = base + baseOffset + i * slotStride; + SharedFrameHeader header{}; + if (!claimFrameSlot(ptr, header)) continue; + if (header.frameIndex > bestIndex) + { + if (capturedSlot) + { + releaseFrameSlot(capturedSlot); + } + bestIndex = header.frameIndex; + capturedHeader = header; + capturedSlot = ptr; + } + else + { + releaseFrameSlot(ptr); + } + } + } + + const bool ok = capturedSlot + && copyFrame(slot, + capturedHeader, + capturedSlot + kSharedFrameHeaderSize, + frames); + if (capturedSlot) + { + releaseFrameSlot(capturedSlot); + } + if (ok) + { + slot.lastFrameIndex = capturedHeader.frameIndex; + acquiredFrameCount = capturedHeader.frameIndex > previousFrameIndex + ? capturedHeader.frameIndex - previousFrameIndex + : 1; + } + return ok; + } + + // Reads every retained shared memory frame in order for recording delivery + bool AgentFrameWorker::readAllFrames(ReaderSlot& slot, + QList& frames, + quint64& acquiredFrameCount) + { + auto* base = static_cast(slot.shm->data()); + if (!base) + { + return false; + } + + const int slotStride = kSharedFrameSlotStride; + const int baseOffset = kSharedMemoryControlSize; + const quint64 lastIndex = slot.lastFrameIndex; + + struct ClaimedSlot + { + uchar* ptr{nullptr}; + SharedFrameHeader header{}; + }; + std::vector claimedSlots; + claimedSlots.reserve(kSharedFrameNumSlots); + + for (int i = 0; i < kSharedFrameNumSlots; ++i) + { + uchar* ptr = base + baseOffset + i * slotStride; + SharedFrameHeader header{}; + if (!claimFrameSlot(ptr, header)) continue; + if (header.frameIndex > lastIndex) + { + claimedSlots.push_back({ptr, header}); + } + else + { + releaseFrameSlot(ptr); + } + } + + if (claimedSlots.empty()) + { + return false; + } + + std::sort(claimedSlots.begin(), claimedSlots.end(), [](const ClaimedSlot& a, const ClaimedSlot& b) + { + return a.header.frameIndex < b.header.frameIndex; + }); + + frames.reserve(static_cast(claimedSlots.size())); + quint64 maxIndex = lastIndex; + for (const ClaimedSlot& claimed : claimedSlots) + { + if (copyFrame(slot, + claimed.header, + claimed.ptr + kSharedFrameHeaderSize, + frames)) + { + maxIndex = claimed.header.frameIndex; + } + releaseFrameSlot(claimed.ptr); + } + if (frames.isEmpty()) + { + return false; + } + + slot.latestFrame = frames.constLast(); + slot.lastFrameIndex = maxIndex; + acquiredFrameCount = maxIndex > lastIndex + ? maxIndex - lastIndex + : static_cast(frames.size()); + return true; + } + + // Copies frames on the reader thread and forwards only completed images + ImageFrame AgentFrameWorker::consumeFrames(const QString& cameraId) + { + const auto it = m_readers.find(cameraId); + if (it == m_readers.end()) + { + return {}; + } + + ReaderSlot& slot = *it.value(); + QList frames; + quint64 acquiredFrameCount = 0; + if (ensureSharedMemory(slot)) + { + if (m_owner->recordingFrameDeliveryEnabled() + || m_owner->highRateFrameDeliveryEnabled()) + { + readAllFrames(slot, frames, acquiredFrameCount); + } + else + { + readLatestFrame(slot, frames, acquiredFrameCount); + } + } + + if (!frames.isEmpty()) + { + m_owner->submitFrames(frames, acquiredFrameCount); + } + return slot.latestFrame; + } + + void AgentFrameWorker::consumeFramesAsync(const QString& cameraId) + { + if (!m_owner->m_frameDeliveryPaused.load(std::memory_order_relaxed)) + { + consumeFrames(cameraId); + } + AgentCameraBackend* const owner = m_owner; + QMetaObject::invokeMethod(owner, + [owner, cameraId]() { owner->completeFrameRead(cameraId); }, + Qt::QueuedConnection); + } + + ImageFrame AgentCameraBackend::consumeFrameNow(const QString& cameraId) + { + if (!m_frameWorker || !m_frameThread.isRunning()) + { + return {}; + } + + ImageFrame frame; + AgentFrameWorker* const worker = m_frameWorker; + const bool invoked = QMetaObject::invokeMethod( + worker, + [worker, cameraId, &frame]() { frame = worker->consumeFrames(cameraId); }, + Qt::BlockingQueuedConnection); + return invoked ? frame : ImageFrame{}; + } + + void AgentCameraBackend::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(); + if (!slot.isRunning || m_frameDeliveryPaused.load(std::memory_order_relaxed)) + { + return; + } + if (slot.frameReadQueued) + { + slot.frameReadRequested = true; + return; + } + + slot.frameReadQueued = true; + AgentFrameWorker* const worker = m_frameWorker; + if (!QMetaObject::invokeMethod( + worker, + [worker, cameraId]() { worker->consumeFramesAsync(cameraId); }, + Qt::QueuedConnection)) + { + slot.frameReadQueued = false; + } + } + + void AgentCameraBackend::completeFrameRead(const QString& cameraId) + { + const auto it = m_cameras.find(cameraId); + if (it == m_cameras.end() || !it.value()) + { + return; + } + AgentCameraSlot& slot = *it.value(); + slot.frameReadQueued = false; + if (!slot.frameReadRequested) + { + return; + } + + slot.frameReadRequested = false; + scheduleFrameRead(cameraId); + } + + // Triggers one camera and waits for its event frame + bool AgentCameraBackend::captureEventFrame(const QString& cameraId, + ImageFrame& frame, + int timeoutMs) + { + const QString normalizedId = normalizedCameraId(cameraId); + const auto cameraIt = m_cameras.constFind(normalizedId); + if (cameraIt == m_cameras.constEnd() || !cameraIt.value() + || !prepareFrameReader(normalizedId)) + { + return false; + } + + const ImageFrame previousFrame = consumeFrameNow(normalizedId); + const quint64 previousFrameIndex = previousFrame.isValid() + ? previousFrame.frameIndex + : 0; + + QJsonObject req; + req.insert(agent::kMessageTypeField, agent::kCommandCaptureEvent); + QJsonObject resp; + const int waitMs = (timeoutMs > 0) ? timeoutMs : 1500; + if (!sendControlCommand(normalizedId, req, &resp, waitMs + 1000)) + { + return false; + } + if (!resp.value("ok").toBool(false)) + { + return false; + } + + const quint64 targetFrameIndex = + agent::decodeUInt64(resp.value(QStringLiteral("frameIndex"))); + QElapsedTimer timer; + timer.start(); + + while (timer.elapsed() <= waitMs) + { + const ImageFrame candidate = consumeFrameNow(normalizedId); + if (candidate.isValid() + && candidate.frameIndex > previousFrameIndex + && (targetFrameIndex == 0 || candidate.frameIndex >= targetFrameIndex)) + { + frame = candidate; + return true; + } + QCoreApplication::processEvents(QEventLoop::AllEvents, 5); + QThread::msleep(1); + } + + return false; + } + + // Pauses or resumes polling while another workflow controls capture + void AgentCameraBackend::setFrameDeliveryPaused(bool paused) + { + if (m_frameDeliveryPaused.exchange(paused, std::memory_order_relaxed) == paused) + { + return; + } + if (!paused) + { + for (auto it = m_cameras.begin(); it != m_cameras.end(); ++it) + { + if (it.value() && it.value()->isRunning) + { + scheduleFrameRead(it.key()); + } + } + } + } + + // Switches agent producers before changing the local recording consumer mode + bool AgentCameraBackend::setRecordingFrameDeliveryEnabled(bool enabled) + { + if (enabled && recordingFrameDeliveryEnabled()) + { + return true; + } + + const AgentFrameDeliveryMode previewMode = + nonRecordingDeliveryMode(highRateFrameDeliveryEnabled()); + if (!enabled) + { + CameraBackend::setRecordingFrameDeliveryEnabled(false); + bool ok = true; + for (auto it = m_cameras.constBegin(); it != m_cameras.constEnd(); ++it) + { + ok = it.value() && sendFrameDeliveryMode(it.key(), previewMode) && ok; + } + return ok; + } + + QStringList updatedCameraIds; + for (auto it = m_cameras.constBegin(); it != m_cameras.constEnd(); ++it) + { + if (!it.value() + || !sendFrameDeliveryMode(it.key(), AgentFrameDeliveryMode::AllFrames)) + { + for (const QString& cameraId : updatedCameraIds) + { + sendFrameDeliveryMode(cameraId, previewMode); + } + return false; + } + updatedCameraIds.append(it.key()); + } + + for (const QString& cameraId : updatedCameraIds) + { + if (!prepareFrameReader(cameraId)) + { + for (const QString& updatedCameraId : updatedCameraIds) + { + sendFrameDeliveryMode(updatedCameraId, previewMode); + } + return false; + } + consumeFrameNow(cameraId); + } + return CameraBackend::setRecordingFrameDeliveryEnabled(true); + } + + // Switches preview producers between display-rate and processing-rate delivery + bool AgentCameraBackend::setHighRateFrameDeliveryEnabled(bool enabled) + { + if (highRateFrameDeliveryEnabled() == enabled) + { + return true; + } + + if (recordingFrameDeliveryEnabled()) + { + return CameraBackend::setHighRateFrameDeliveryEnabled(enabled); + } + + const AgentFrameDeliveryMode targetMode = nonRecordingDeliveryMode(enabled); + const AgentFrameDeliveryMode rollbackMode = + nonRecordingDeliveryMode(highRateFrameDeliveryEnabled()); + QStringList updatedCameraIds; + for (auto it = m_cameras.constBegin(); it != m_cameras.constEnd(); ++it) + { + if (!it.value() || !sendFrameDeliveryMode(it.key(), targetMode)) + { + for (const QString& cameraId : updatedCameraIds) + { + sendFrameDeliveryMode(cameraId, rollbackMode); + } + return false; + } + updatedCameraIds.append(it.key()); + } + return CameraBackend::setHighRateFrameDeliveryEnabled(enabled); + } + + // Starts one camera agent process and connects its control channel + bool AgentCameraBackend::addAgentCamera(const QString& cameraId, + const QString& adapter, + const QString& device, + const QStringList& preInitProperties, + const QStringList& properties, + double exposureMs) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString adapterName = adapter.trimmed(); + const QString deviceName = device.trimmed(); + if (normalizedId.isEmpty() || adapterName.isEmpty() || deviceName.isEmpty()) + { + return false; + } + if (m_cameras.contains(normalizedId)) + { + return true; + } + auto slot = std::make_shared(); + slot->cameraId = normalizedId; + slot->shmKey = agent::sharedMemoryKey(normalizedId); + slot->process = std::make_shared(); + slot->control = std::make_shared(normalizedId, + agent::controlServerName(normalizedId)); + slot->exposureMs = exposureMs; + const QString agentPath = + QDir(QCoreApplication::applicationDirPath()).filePath(agent::kExecutableFileName); + if (!QFileInfo::exists(agentPath)) + { + qWarning().noquote() << QString("Agent executable not found: %1").arg(agentPath); + return false; + } + QStringList args; + args << "--cameraId" << normalizedId + << "--adapter" << adapterName + << "--device" << deviceName + << "--shm" << slot->shmKey; + if (exposureMs > 0.0) + { + args << "--exposure" << QString::number(exposureMs, 'f', 4); + } + for (const QString& encodedProperty : preInitProperties) + { + if (!encodedProperty.isEmpty()) + { + args << "--preinit" << encodedProperty; + } + } + for (const QString& encodedProperty : properties) + { + if (!encodedProperty.isEmpty()) + { + args << "--property" << encodedProperty; + } + } + slot->process->setProgram(agentPath); + slot->process->setArguments(args); + slot->process->setProcessChannelMode(QProcess::MergedChannels); + + QProcess* const process = slot->process.get(); + connect(process, &QProcess::readyReadStandardOutput, this, [normalizedId, process]() + { + const QByteArray output = process->readAllStandardOutput(); + if (!output.isEmpty()) + { + QStringList lines = QString::fromUtf8(output).split('\n', Qt::SkipEmptyParts); + for (const QString& line : lines) + { + const QString trimmed = line.trimmed(); + qInfo().noquote() << QString("[Agent %1] %2").arg(normalizedId, trimmed); + } + } + }); + connect(process, &QProcess::finished, this, + [this, normalizedId](int, QProcess::ExitStatus) + { + const auto it = m_cameras.find(normalizedId); + if (it == m_cameras.end() || !it.value()) + { + return; + } + const bool wasRunning = it.value()->isRunning; + it.value()->isRunning = false; + const bool wasRecording = recordingFrameDeliveryEnabled(); + if (wasRecording) + { + CameraBackend::setRecordingFrameDeliveryEnabled(false); + } + if (wasRunning) + { + notifyPreviewStopped(); + } + if (wasRecording) + { + emit frameDeliveryFailed( + QStringLiteral("Camera agent exited for '%1'").arg(normalizedId)); + } + }); + + if (slot->control) + { + slot->control->addReadyHandler([this, normalizedId](bool ready) + { + if (ready) + { + emit agentControlServerListening(normalizedId, agent::controlServerName(normalizedId)); + } + }); + slot->control->addEventHandler([this, normalizedId](const QJsonObject& event) + { + const auto it = m_cameras.find(normalizedId); + if (it == m_cameras.end() || !it.value()) + { + return; + } + AgentCameraSlot& slot = *it.value(); + const QString type = event.value(agent::kMessageTypeField).toString(); + if (type == agent::kEventFrameAvailable) + { + scheduleFrameRead(normalizedId); + return; + } + if (type == agent::kEventPreviewState) + { + const bool wasRunning = slot.isRunning; + slot.isRunning = event.value(QStringLiteral("running")).toBool(slot.isRunning); + if (wasRunning && !slot.isRunning) + { + notifyPreviewStopped(); + } + return; + } + if (type == agent::kEventAgentError) + { + const QString error = QStringLiteral("Agent '%1' error: %2") + .arg(slot.cameraId, + event.value(QStringLiteral("error")).toString()); + qWarning().noquote() << error; + if (recordingFrameDeliveryEnabled()) + { + CameraBackend::setRecordingFrameDeliveryEnabled(false); + emit frameDeliveryFailed(error); + } + return; + } + }); + } + + slot->process->start(); + if (!slot->process->waitForStarted(3000)) + { + qWarning().noquote() << QString("Failed to start agent for %1").arg(normalizedId); + return false; + } + m_cameras.insert(normalizedId, slot); + if (slot->control) + { + slot->control->start(); + } + if (!waitForControlReady(*slot, kAgentControlReadyTimeoutMs)) + { + qWarning().noquote() + << QString("Agent control session did not become ready for %1 within %2 ms") + .arg(normalizedId) + .arg(kAgentControlReadyTimeoutMs); + removeAgentCamera(normalizedId); + return false; + } + if (!addFrameReader(normalizedId, slot->shmKey)) + { + removeAgentCamera(normalizedId); + return false; + } + const AgentFrameDeliveryMode deliveryMode = recordingFrameDeliveryEnabled() + ? AgentFrameDeliveryMode::AllFrames + : nonRecordingDeliveryMode( + highRateFrameDeliveryEnabled()); + if (!sendFrameDeliveryMode(normalizedId, deliveryMode)) + { + removeAgentCamera(normalizedId); + return false; + } + return true; + } + + // Stops one camera agent process and releases its resources + void AgentCameraBackend::removeAgentCamera(const QString& cameraId) + { + const QString normalizedId = normalizedCameraId(cameraId); + if (!m_cameras.contains(normalizedId)) + { + return; + } + const auto slot = m_cameras.value(normalizedId); + const bool wasRunning = slot->isRunning; + slot->isRunning = false; + removeFrameReader(normalizedId); + if (slot->control) + { + QJsonObject request; + request.insert(agent::kMessageTypeField, agent::kCommandShutdown); + sendControlCommand(normalizedId, request, nullptr, 800); + } + m_cameras.remove(normalizedId); + if (slot->control) + { + slot->control->stop(); + } + if (slot->process) + { + slot->process->terminate(); + if (!slot->process->waitForFinished(1500)) + { + slot->process->kill(); + slot->process->waitForFinished(1000); + } + } + if (wasRunning) + { + notifyPreviewStopped(); + } + } + + std::unique_ptr createAgentCameraBackend() + { + return std::make_unique(); + } +} diff --git a/ScopeOneCore/src/AgentMain.cpp b/ScopeOneCore/src/AgentMain.cpp index 6d89ce3..feeeb6a 100644 --- a/ScopeOneCore/src/AgentMain.cpp +++ b/ScopeOneCore/src/AgentMain.cpp @@ -14,9 +14,14 @@ #include #include +#include +#include +#include #include #include #include +#include +#include #include "MMCore.h" #include "internal/AgentProtocol.h" @@ -24,6 +29,9 @@ 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; @@ -34,6 +42,21 @@ namespace scopeone::core::internal 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) { @@ -89,7 +112,6 @@ namespace scopeone::core::internal return; } m_socket->write(agent::encodeMessage(message)); - m_socket->flush(); } signals: @@ -212,7 +234,7 @@ namespace scopeone::core::internal if (!m_timer) { m_timer = new QTimer(this); - m_timer->setInterval(1); + m_timer->setTimerType(Qt::PreciseTimer); connect(m_timer, &QTimer::timeout, this, &AgentRuntime::pollAndWrite); } @@ -297,7 +319,19 @@ namespace scopeone::core::internal if (m_shm->lock()) { - memset(m_shm->data(), 0, totalBytes); + 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(); } @@ -343,6 +377,13 @@ namespace scopeone::core::internal ShuttingDown }; + enum class FrameDeliveryMode + { + PreviewLatest, + LatestOnly, + AllFrames + }; + // Frame layout describes one shared memory payload shape struct FrameLayout { @@ -369,13 +410,15 @@ namespace scopeone::core::internal QString* errorMessage); void emitPreviewStateEvent(); void emitAgentErrorEvent(const QString& error); - void emitBufferOverflowEvent(long bufferCapacity); void emitFrameAvailableEvent(quint64 frameIndex); bool startPreviewInternal(QString* errorMessage); bool stopPreviewInternal(QString* errorMessage); bool captureEventFrameInternal(quint64& frameIndex, QString* errorMessage); - bool writeFrameToSharedMemory(const void* pixels, quint64* frameIndexOut = nullptr); + 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(); @@ -394,6 +437,9 @@ namespace scopeone::core::internal 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}; @@ -405,7 +451,9 @@ namespace scopeone::core::internal int m_sourceRoiHeight{0}; quint64 m_frameIndex{0}; - int m_overflowCheckCounter{0}; + quint64 m_pendingFrameAdvance{0}; + int m_nextWriteSlot{0}; + FrameDeliveryMode m_frameDeliveryMode{FrameDeliveryMode::PreviewLatest}; }; // Publish the initial hello event to connected clients @@ -514,6 +562,39 @@ namespace scopeone::core::internal 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); @@ -586,9 +667,13 @@ namespace scopeone::core::internal 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; @@ -599,13 +684,13 @@ namespace scopeone::core::internal try { value = QString::fromStdString( - m_mmcore->getProperty(m_cameraId.toStdString().c_str(), - name.toStdString().c_str())); + fromCache + ? m_mmcore->getPropertyFromCache(camera.c_str(), property.c_str()) + : m_mmcore->getProperty(camera.c_str(), property.c_str())); try { - switch (m_mmcore->getPropertyType(m_cameraId.toStdString().c_str(), - name.toStdString().c_str())) + switch (m_mmcore->getPropertyType(camera.c_str(), property.c_str())) { case MM::String: propertyType = QStringLiteral("String"); @@ -627,9 +712,15 @@ namespace scopeone::core::internal try { - readOnly = m_mmcore->isPropertyReadOnly( - m_cameraId.toStdString().c_str(), - name.toStdString().c_str()); + 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&) { @@ -638,8 +729,7 @@ namespace scopeone::core::internal try { const auto values = - m_mmcore->getAllowedPropertyValues(m_cameraId.toStdString().c_str(), - name.toStdString().c_str()); + m_mmcore->getAllowedPropertyValues(camera.c_str(), property.c_str()); for (const auto& allowedValue : values) { allowedValues.append(QString::fromStdString(allowedValue)); @@ -651,16 +741,11 @@ namespace scopeone::core::internal try { - hasLimits = m_mmcore->hasPropertyLimits(m_cameraId.toStdString().c_str(), - name.toStdString().c_str()); + hasLimits = m_mmcore->hasPropertyLimits(camera.c_str(), property.c_str()); if (hasLimits) { - lowerLimit = m_mmcore->getPropertyLowerLimit( - m_cameraId.toStdString().c_str(), - name.toStdString().c_str()); - upperLimit = m_mmcore->getPropertyUpperLimit( - m_cameraId.toStdString().c_str(), - name.toStdString().c_str()); + lowerLimit = m_mmcore->getPropertyLowerLimit(camera.c_str(), property.c_str()); + upperLimit = m_mmcore->getPropertyUpperLimit(camera.c_str(), property.c_str()); } } catch (const CMMError&) @@ -680,6 +765,7 @@ namespace scopeone::core::internal 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); @@ -912,14 +998,6 @@ namespace scopeone::core::internal emit eventReady(event); } - // Publish a camera buffer overflow event - void AgentRuntime::emitBufferOverflowEvent(long bufferCapacity) - { - QJsonObject event = makeEvent(agent::kEventBufferOverflow); - event.insert(QStringLiteral("capacityFrames"), static_cast(bufferCapacity)); - emit eventReady(event); - } - // Publish the newest shared memory frame index void AgentRuntime::emitFrameAvailableEvent(quint64 frameIndex) { @@ -959,7 +1037,22 @@ namespace scopeone::core::internal 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(); @@ -999,6 +1092,10 @@ namespace scopeone::core::internal { 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); @@ -1066,7 +1163,9 @@ namespace scopeone::core::internal return false; } - if (!writeFrameToSharedMemory(pixels, &frameIndex)) + const quint64 frameAdvance = m_pendingFrameAdvance + 1; + m_pendingFrameAdvance = 0; + if (!writeFrameToSharedMemory(pixels, frameAdvance, &frameIndex)) { if (errorMessage) { @@ -1075,6 +1174,11 @@ namespace scopeone::core::internal return false; } + if (m_frameDeliveryMode == FrameDeliveryMode::PreviewLatest) + { + m_deliveryTimer.restart(); + } + emitFrameAvailableEvent(frameIndex); return true; } @@ -1089,27 +1193,62 @@ namespace scopeone::core::internal } // Copy one camera frame into the shared memory ring buffer - bool AgentRuntime::writeFrameToSharedMemory(const void* pixels, quint64* frameIndexOut) + bool AgentRuntime::writeFrameToSharedMemory(const void* pixels, + quint64 frameAdvance, + quint64* frameIndexOut) { if (!m_shm || !m_shm->isAttached()) { return false; } - if (!m_shm->lock()) + uchar* base = static_cast(m_shm->data()); + if (!base) { return false; } - uchar* base = static_cast(m_shm->data()); - if (!base) + 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_shm->unlock(); + m_frameIndex = nextFrameIndex; + if (frameIndexOut) + { + *frameIndexOut = m_frameIndex; + } return false; } - const int slotIndex = static_cast(m_frameIndex % kSharedFrameNumSlots); auto* control = reinterpret_cast(base); - uchar* ptr = base + kSharedMemoryControlSize + slotIndex * kSharedFrameSlotStride; + m_nextWriteSlot = (slotIndex + 1) % kSharedFrameNumSlots; SharedFrameHeader header{}; header.state = 1; header.width = m_frameLayout.width; @@ -1118,7 +1257,7 @@ namespace scopeone::core::internal header.pixelFormat = static_cast(m_frameLayout.format); header.bitsPerSample = m_frameLayout.bitDepth; header.channels = m_frameLayout.channels; - header.frameIndex = ++m_frameIndex; + header.frameIndex = nextFrameIndex; header.timestampNs = static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; setSharedFrameSourceRoi(header, @@ -1126,20 +1265,23 @@ namespace scopeone::core::internal m_sourceRoiY, m_sourceRoiWidth, m_sourceRoiHeight); - memcpy(ptr, &header, sizeof(header)); + 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)); - header.state = 2; - memcpy(ptr, &header, sizeof(header)); - control->latestSlotIndex = static_cast(slotIndex); + 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; } - m_shm->unlock(); return true; } @@ -1155,49 +1297,82 @@ namespace scopeone::core::internal { long remaining = m_mmcore->getRemainingImageCount(); - if (++m_overflowCheckCounter >= 100 || remaining > 10) - { - m_overflowCheckCounter = 0; - if (m_mmcore->isBufferOverflowed()) - { - emitBufferOverflowEvent(m_mmcore->getBufferTotalCapacity()); - } - } - if (remaining <= 0) { return; } quint64 newestFrameIndex = 0; - while (remaining-- > 0) - { - const void* pixels = m_mmcore->popNextImage(); - if (!pixels) + 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) { - break; + 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; + } + } } - - const unsigned width = m_mmcore->getImageWidth(); - const unsigned height = m_mmcore->getImageHeight(); - const unsigned bytesPerPixel = m_mmcore->getBytesPerPixel(); - if (!ensureFrameLayout(width, height, bytesPerPixel)) + } + else + { + while (remaining-- > 0) { - break; - } + const void* pixels = m_mmcore->popNextImage(); + if (!pixels) + { + break; + } + ++acquiredFrameCount; - quint64 writtenFrameIndex = 0; - if (!writeFrameToSharedMemory(pixels, &writtenFrameIndex)) - { - break; + if (!m_frameLayoutValid) + { + break; + } + + quint64 writtenFrameIndex = 0; + if (!writeFrameToSharedMemory(pixels, 1, &writtenFrameIndex)) + { + break; + } + newestFrameIndex = writtenFrameIndex; } - newestFrameIndex = writtenFrameIndex; } if (newestFrameIndex != 0) { emitFrameAvailableEvent(newestFrameIndex); } + updatePollingInterval(acquiredFrameCount); } catch (const CMMError& error) { @@ -1208,6 +1383,33 @@ namespace scopeone::core::internal } } + // 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) { diff --git a/ScopeOneCore/src/BackgroundCalibrationModule.cpp b/ScopeOneCore/src/BackgroundCalibrationModule.cpp index 25b1de6..c88a54d 100644 --- a/ScopeOneCore/src/BackgroundCalibrationModule.cpp +++ b/ScopeOneCore/src/BackgroundCalibrationModule.cpp @@ -1,9 +1,58 @@ #include "internal/BackgroundCalibrationModule.h" #include "internal/FrameBufferUtils.h" #include +#include namespace scopeone::core::internal { + namespace + { + // Applies one calibrated background sample to one source sample + int applyBackgroundOperation(int source, + int background, + int maxValue, + BackgroundOperation operation) + { + switch (operation) + { + case BackgroundOperation::Subtract: + return source - background; + case BackgroundOperation::Add: + return source + background; + case BackgroundOperation::Multiply: + return static_cast((static_cast(source) * background) / maxValue); + case BackgroundOperation::Divide: + return background > 0 + ? static_cast((static_cast(source) * maxValue) / background) + : maxValue; + } + return source; + } + + // Adds one frame to a full precision running pixel sum + void addFrameToRunningSum(const ImageFrame& frame, std::vector& sum) + { + dispatchFrameType(frame, [&]() + { + const char* sourceData = frame.bytes.constData(); + qint64* sumData = sum.data(); + parallelForImageRows(frame.width, frame.height, [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + const Pixel* row = reinterpret_cast( + sourceData + static_cast(y) * frame.stride); + qint64* sumRow = sumData + static_cast(y) * frame.width; + for (int x = 0; x < frame.width; ++x) + { + sumRow[x] += static_cast(row[x]); + } + } + }); + }); + } + } // namespace + // Creates an independent background calibration runtime std::unique_ptr BackgroundCalibrationModule::createRuntime() const { @@ -23,6 +72,7 @@ namespace scopeone::core::internal void BackgroundCalibrationModule::resetCalibration() { m_buffer.clear(); + m_runningSum.clear(); m_background = ImageFrame{}; m_calibrated = false; } @@ -33,6 +83,8 @@ namespace scopeone::core::internal const int w = m_buffer.front().width; const int h = m_buffer.front().height; const int maxValue = m_buffer.front().maxValue(); + const qint64 workItemCount = static_cast(w) + * h * static_cast(m_buffer.size()); QByteArray bgBytes = dispatchFrameType(m_buffer.front(), [&]() { QByteArray outBytes = allocatePixelBytes(w, h); @@ -40,77 +92,110 @@ namespace scopeone::core::internal { return outBytes; } + Pixel* outputData = reinterpret_cast(outBytes.data()); switch (m_method) { case BackgroundMethod::Median: { - std::vector vals(m_buffer.size()); - for (int y = 0; y < h; ++y) + parallelForRows(workItemCount, h, [&](int firstRow, int lastRow) { - Pixel* dst = mutableRowData(outBytes, w, y); - for (int x = 0; x < w; ++x) + std::vector vals(m_buffer.size()); + std::vector rows(m_buffer.size()); + for (int y = firstRow; y < lastRow; ++y) { for (size_t k = 0; k < m_buffer.size(); ++k) { - vals[k] = frameRowData(m_buffer[k], y)[x]; + rows[k] = frameRowData(m_buffer[k], y); + } + Pixel* dst = outputData + static_cast(y) * w; + for (int x = 0; x < w; ++x) + { + for (size_t k = 0; k < m_buffer.size(); ++k) + { + vals[k] = rows[k][x]; + } + std::nth_element(vals.begin(), vals.begin() + vals.size() / 2, vals.end()); + dst[x] = vals[vals.size() / 2]; } - std::nth_element(vals.begin(), vals.begin() + vals.size() / 2, vals.end()); - dst[x] = vals[vals.size() / 2]; } - } + }); break; } case BackgroundMethod::Mean: { - for (int y = 0; y < h; ++y) + parallelForRows(workItemCount, h, [&](int firstRow, int lastRow) { - Pixel* dst = mutableRowData(outBytes, w, y); - for (int x = 0; x < w; ++x) + std::vector rows(m_buffer.size()); + for (int y = firstRow; y < lastRow; ++y) { - qint64 sum = 0; for (size_t k = 0; k < m_buffer.size(); ++k) { - sum += static_cast(frameRowData(m_buffer[k], y)[x]); + rows[k] = frameRowData(m_buffer[k], y); + } + Pixel* dst = outputData + static_cast(y) * w; + for (int x = 0; x < w; ++x) + { + qint64 sum = 0; + for (size_t k = 0; k < m_buffer.size(); ++k) + { + sum += static_cast(rows[k][x]); + } + dst[x] = clampPixelValue( + static_cast(sum / static_cast(m_buffer.size())), + maxValue); } - dst[x] = clampPixelValue( - static_cast(sum / static_cast(m_buffer.size())), - maxValue); } - } + }); break; } case BackgroundMethod::Maximum: { - for (int y = 0; y < h; ++y) + parallelForRows(workItemCount, h, [&](int firstRow, int lastRow) { - Pixel* dst = mutableRowData(outBytes, w, y); - for (int x = 0; x < w; ++x) + std::vector rows(m_buffer.size()); + for (int y = firstRow; y < lastRow; ++y) { - int best = 0; for (size_t k = 0; k < m_buffer.size(); ++k) { - best = qMax(best, static_cast(frameRowData(m_buffer[k], y)[x])); + rows[k] = frameRowData(m_buffer[k], y); + } + Pixel* dst = outputData + static_cast(y) * w; + for (int x = 0; x < w; ++x) + { + int best = 0; + for (size_t k = 0; k < m_buffer.size(); ++k) + { + best = qMax(best, static_cast(rows[k][x])); + } + dst[x] = clampPixelValue(best, maxValue); } - dst[x] = clampPixelValue(best, maxValue); } - } + }); break; } case BackgroundMethod::Minimum: { - for (int y = 0; y < h; ++y) + parallelForRows(workItemCount, h, [&](int firstRow, int lastRow) { - Pixel* dst = mutableRowData(outBytes, w, y); - for (int x = 0; x < w; ++x) + std::vector rows(m_buffer.size()); + for (int y = firstRow; y < lastRow; ++y) { - int best = maxValue; for (size_t k = 0; k < m_buffer.size(); ++k) { - best = qMin(best, static_cast(frameRowData(m_buffer[k], y)[x])); + rows[k] = frameRowData(m_buffer[k], y); + } + Pixel* dst = outputData + static_cast(y) * w; + for (int x = 0; x < w; ++x) + { + int best = maxValue; + for (size_t k = 0; k < m_buffer.size(); ++k) + { + best = qMin(best, static_cast(rows[k][x])); + } + dst[x] = clampPixelValue(best, maxValue); } - dst[x] = clampPixelValue(best, maxValue); } - } + }); break; } } @@ -120,6 +205,89 @@ namespace scopeone::core::internal m_background = makeFrameLike(m_buffer.front(), w, h, std::move(bgBytes)); } + // Updates a running mean background and applies it in one image pass + ProcessingResult BackgroundCalibrationModule::processRunningMean( + const ImageFrame& sourceFrame, + const ImageFrame& workingFrame) + { + const qint64 pixelCount = static_cast(workingFrame.width) * workingFrame.height; + if (pixelCount <= 0) + { + return {{}, QStringLiteral("Invalid running background dimensions")}; + } + if (m_runningSum.size() != static_cast(pixelCount)) + { + m_buffer.clear(); + m_runningSum.assign(static_cast(pixelCount), 0); + m_background = ImageFrame{}; + } + + if (static_cast(m_buffer.size()) < m_calibrationFrames) + { + addFrameToRunningSum(workingFrame, m_runningSum); + m_buffer.push_back(workingFrame); + return {sourceFrame, {}}; + } + + const ImageFrame& oldestFrame = m_buffer.front(); + const int maxValue = workingFrame.maxValue(); + QByteArray outputBytes; + dispatchFrameType(workingFrame, [&]() + { + outputBytes = allocatePixelBytes(workingFrame.width, workingFrame.height); + if (outputBytes.isEmpty()) + { + return; + } + + const char* sourceData = workingFrame.bytes.constData(); + const char* oldestData = oldestFrame.bytes.constData(); + Pixel* outputData = reinterpret_cast(outputBytes.data()); + qint64* sumData = m_runningSum.data(); + parallelForImageRows(workingFrame.width, + workingFrame.height, + [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + const Pixel* sourceRow = reinterpret_cast( + sourceData + static_cast(y) * workingFrame.stride); + const Pixel* oldestRow = reinterpret_cast( + oldestData + static_cast(y) * oldestFrame.stride); + const size_t rowOffset = static_cast(y) * workingFrame.width; + Pixel* outputRow = outputData + rowOffset; + qint64* sumRow = sumData + rowOffset; + for (int x = 0; x < workingFrame.width; ++x) + { + const int source = static_cast(sourceRow[x]); + const int background = static_cast( + sumRow[x] / static_cast(m_calibrationFrames)); + outputRow[x] = clampPixelValue( + applyBackgroundOperation(source, + background, + maxValue, + m_operation), + maxValue); + sumRow[x] += source - static_cast(oldestRow[x]); + } + } + }); + }); + + if (outputBytes.isEmpty()) + { + return {{}, QStringLiteral("Failed to allocate running background output")}; + } + + ImageFrame output = makeFrameLike(workingFrame, + workingFrame.width, + workingFrame.height, + std::move(outputBytes)); + m_buffer.pop_front(); + m_buffer.push_back(workingFrame); + return {std::move(output), {}}; + } + // Applies background calibration to one mono frame ProcessingResult BackgroundCalibrationModule::process(const ImageFrame& frame, int processingBitDepth) { @@ -142,6 +310,11 @@ namespace scopeone::core::internal resetCalibration(); } + if (m_mode == BackgroundMode::Running && m_method == BackgroundMethod::Mean) + { + return processRunningMean(frame, workingFrame); + } + if (m_mode == BackgroundMode::Running) { if ((int)m_buffer.size() == m_calibrationFrames) @@ -185,34 +358,31 @@ namespace scopeone::core::internal { return bytes; } - for (int y = 0; y < workingFrame.height; ++y) + const char* sourceData = workingFrame.bytes.constData(); + const char* backgroundData = m_background.bytes.constData(); + Pixel* outputData = reinterpret_cast(bytes.data()); + parallelForImageRows(workingFrame.width, + workingFrame.height, + [&](int firstRow, int lastRow) { - const Pixel* s = frameRowData(workingFrame, y); - const Pixel* b = frameRowData(m_background, y); - Pixel* d = mutableRowData(bytes, workingFrame.width, y); - for (int x = 0; x < workingFrame.width; ++x) + for (int y = firstRow; y < lastRow; ++y) { - int result = 0; - switch (m_operation) + const Pixel* s = reinterpret_cast( + sourceData + static_cast(y) * workingFrame.stride); + const Pixel* b = reinterpret_cast( + backgroundData + static_cast(y) * m_background.stride); + Pixel* d = outputData + static_cast(y) * workingFrame.width; + for (int x = 0; x < workingFrame.width; ++x) { - case BackgroundOperation::Subtract: - result = int(s[x]) - int(b[x]); - break; - case BackgroundOperation::Add: - result = int(s[x]) + int(b[x]); - break; - case BackgroundOperation::Multiply: - result = static_cast((static_cast(s[x]) * b[x]) / maxValue); - break; - case BackgroundOperation::Divide: - result = (b[x] > 0) - ? static_cast((static_cast(s[x]) * maxValue) / b[x]) - : maxValue; - break; + d[x] = clampPixelValue( + applyBackgroundOperation(static_cast(s[x]), + static_cast(b[x]), + maxValue, + m_operation), + maxValue); } - d[x] = clampPixelValue(result, maxValue); } - } + }); return bytes; }); diff --git a/ScopeOneCore/src/CameraBackend.cpp b/ScopeOneCore/src/CameraBackend.cpp new file mode 100644 index 0000000..61b9d7f --- /dev/null +++ b/ScopeOneCore/src/CameraBackend.cpp @@ -0,0 +1,336 @@ +#include "internal/CameraBackend.h" + +#include +#include +#include + +namespace scopeone::core::internal +{ + namespace + { + constexpr qint64 kMaximumPendingRecordingBytes = 256ll * 1024 * 1024; + } + + CameraBackend::CameraBackend(QObject* parent) + : QObject(parent) + { + } + + CameraBackend::~CameraBackend() = default; + + bool CameraBackend::configureNativeCamera(const std::shared_ptr&, + const QString&, + double) + { + return false; + } + + bool CameraBackend::addAgentCamera(const QString&, + const QString&, + const QString&, + const QStringList&, + const QStringList&, + double) + { + return false; + } + + bool CameraBackend::captureEventFrame(const QString&, ImageFrame&, int) + { + return false; + } + + bool CameraBackend::setRecordingFrameDeliveryEnabled(bool enabled) + { + QMutexLocker lock(&m_frameDeliveryMutex); + if (enabled && m_recordingFrameDeliveryEnabled.load(std::memory_order_relaxed)) + { + return true; + } + + m_recordingFrameDeliveryEnabled.store(enabled, std::memory_order_relaxed); + m_pendingRecordingFrames.clear(); + m_pendingRecordingBytes = 0; + m_pendingDeliveryError.clear(); + return true; + } + + // Selects full speed latest frame delivery for live processing consumers + bool CameraBackend::setHighRateFrameDeliveryEnabled(bool enabled) + { + m_highRateFrameDeliveryEnabled.store(enabled, std::memory_order_relaxed); + return true; + } + + bool CameraBackend::getExposure(const QString& cameraIdOrAll, double& exposureMs) const + { + QString cameraId; + if (!resolvePrimaryCameraId(cameraIdOrAll, cameraId)) + { + return false; + } + return readExposureFor(cameraId, exposureMs); + } + + bool CameraBackend::setExposure(const QString& cameraIdOrAll, double exposureMs) + { + const QStringList cameraIds = resolveTargetCameraIds(cameraIdOrAll); + if (cameraIds.isEmpty()) + { + return false; + } + + for (const QString& cameraId : cameraIds) + { + if (!applyWithPreviewRestart(cameraId, + [this, cameraId, exposureMs]() + { + return writeExposureFor(cameraId, exposureMs); + })) + { + return false; + } + } + return true; + } + + QStringList CameraBackend::listProperties(const QString& cameraId) + { + QString resolvedCameraId; + if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) + { + return {}; + } + return listPropertiesFor(resolvedCameraId); + } + + bool CameraBackend::readPropertyDetails(const QString& cameraId, + const QString& name, + bool fromCache, + CameraPropertyReadback& readback) + { + QString resolvedCameraId; + if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) + { + return false; + } + return readPropertyDetailsFor(resolvedCameraId, name, fromCache, readback); + } + + bool CameraBackend::setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) + { + QString resolvedCameraId; + if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) + { + return false; + } + return applyWithPreviewRestart( + resolvedCameraId, + [this, resolvedCameraId, name, value, errorMessage]() + { + return setPropertyFor(resolvedCameraId, name, value, errorMessage); + }); + } + + bool CameraBackend::setROI(const QString& cameraId, int x, int y, int width, int height) + { + QString resolvedCameraId; + if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) + { + return false; + } + return applyWithPreviewRestart( + resolvedCameraId, + [this, resolvedCameraId, x, y, width, height]() + { + return setROIFor(resolvedCameraId, x, y, width, height); + }); + } + + bool CameraBackend::clearROI(const QString& cameraId) + { + QString resolvedCameraId; + if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) + { + return false; + } + return applyWithPreviewRestart( + resolvedCameraId, + [this, resolvedCameraId]() { return clearROIFor(resolvedCameraId); }); + } + + bool CameraBackend::getROI(const QString& cameraId, int& x, int& y, int& width, int& height) + { + QString resolvedCameraId; + if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) + { + return false; + } + return getROIFor(resolvedCameraId, x, y, width, height); + } + + bool CameraBackend::applyWithPreviewRestart(const QString& cameraId, + const std::function& operation) + { + const bool wasRunning = isPreviewRunning(cameraId); + if (wasRunning && !stopPreviewFor(cameraId)) + { + return false; + } + + const bool ok = operation(); + if (wasRunning && !startPreviewFor(cameraId)) + { + return false; + } + return ok; + } + + void CameraBackend::notifyPreviewStarted(const QString& cameraId) + { + emit previewStateChanged(true); + qInfo().noquote() << QString("Preview started for camera '%1'").arg(cameraId); + } + + void CameraBackend::notifyPreviewStopped() + { + if (!hasRunningCamera()) + { + emit previewStateChanged(false); + qInfo().noquote() << "All cameras stopped"; + } + } + + void CameraBackend::submitFrames(const QList& frames, quint64 acquiredFrameCount) + { + bool queueFlush = false; + { + QMutexLocker lock(&m_frameDeliveryMutex); + bool recordingDeliveryEnabled = + m_recordingFrameDeliveryEnabled.load(std::memory_order_relaxed); + QString acquiredCameraId; + quint64 validFrameCount = 0; + for (const ImageFrame& frame : frames) + { + const QString cameraId = frame.cameraId.trimmed(); + if (!frame.isValid() || cameraId.isEmpty()) + { + continue; + } + + ImageFrame normalizedFrame(frame); + normalizedFrame.cameraId = cameraId; + acquiredCameraId = cameraId; + ++validFrameCount; + m_pendingLatestFrames.insert(cameraId, normalizedFrame); + + if (!recordingDeliveryEnabled) + { + continue; + } + + const qint64 frameBytes = normalizedFrame.payloadByteCount(); + if (frameBytes > kMaximumPendingRecordingBytes - m_pendingRecordingBytes) + { + if (m_pendingDeliveryError.isEmpty()) + { + m_pendingDeliveryError = QStringLiteral( + "Recording frame delivery exceeded the 256 MiB pending limit"); + } + recordingDeliveryEnabled = false; + m_recordingFrameDeliveryEnabled.store(false, std::memory_order_relaxed); + continue; + } + + m_pendingRecordingFrames.append(normalizedFrame); + m_pendingRecordingBytes += frameBytes; + } + + if (!acquiredCameraId.isEmpty() && acquiredFrameCount > 0) + { + m_pendingAcquiredFrameCounts[acquiredCameraId] += acquiredFrameCount; + } + if (recordingDeliveryEnabled + && acquiredFrameCount > validFrameCount + && m_pendingDeliveryError.isEmpty()) + { + m_pendingDeliveryError = QStringLiteral( + "Recording frame delivery detected dropped camera frames"); + m_recordingFrameDeliveryEnabled.store(false, std::memory_order_relaxed); + } + + if ((!m_pendingLatestFrames.isEmpty() + || !m_pendingAcquiredFrameCounts.isEmpty() + || !m_pendingRecordingFrames.isEmpty() + || !m_pendingDeliveryError.isEmpty()) + && !m_frameFlushQueued) + { + m_frameFlushQueued = true; + queueFlush = true; + } + } + + if (queueFlush) + { + QMetaObject::invokeMethod( + this, + [this]() { flushPendingFrames(); }, + Qt::QueuedConnection); + } + } + + void CameraBackend::discardPendingPreviewFrames() + { + QMutexLocker lock(&m_frameDeliveryMutex); + m_pendingLatestFrames.clear(); + m_pendingAcquiredFrameCounts.clear(); + } + + bool CameraBackend::recordingFrameDeliveryEnabled() const + { + return m_recordingFrameDeliveryEnabled.load(std::memory_order_relaxed); + } + + bool CameraBackend::highRateFrameDeliveryEnabled() const + { + return m_highRateFrameDeliveryEnabled.load(std::memory_order_relaxed); + } + + void CameraBackend::flushPendingFrames() + { + QHash latestFrames; + QHash acquiredFrameCounts; + QList recordingFrames; + QString deliveryError; + { + QMutexLocker lock(&m_frameDeliveryMutex); + latestFrames.swap(m_pendingLatestFrames); + acquiredFrameCounts.swap(m_pendingAcquiredFrameCounts); + recordingFrames.swap(m_pendingRecordingFrames); + deliveryError.swap(m_pendingDeliveryError); + m_pendingRecordingBytes = 0; + m_frameFlushQueued = false; + } + + for (auto it = acquiredFrameCounts.constBegin(); it != acquiredFrameCounts.constEnd(); ++it) + { + emit rawFramesAcquired(it.key(), it.value()); + } + for (auto it = latestFrames.constBegin(); it != latestFrames.constEnd(); ++it) + { + emit rawFrameReady(it.value()); + } + if (!deliveryError.isEmpty()) + { + recordingFrames.clear(); + emit frameDeliveryFailed(deliveryError); + } + else if (!recordingFrames.isEmpty()) + { + emit recordingFramesReady(recordingFrames); + } + } +} diff --git a/ScopeOneCore/src/CameraManager.cpp b/ScopeOneCore/src/CameraManager.cpp new file mode 100644 index 0000000..38ac728 --- /dev/null +++ b/ScopeOneCore/src/CameraManager.cpp @@ -0,0 +1,321 @@ +#include "internal/CameraManager.h" + +namespace scopeone::core::internal +{ + namespace + { + QString normalizedCameraId(const QString& cameraId) + { + return cameraId.trimmed(); + } + + QString propertyCacheKey(const QString& cameraId, const QString& name) + { + return QStringLiteral("%1:%2").arg(cameraId, name); + } + } + + CameraManager::CameraManager(QObject* parent) + : QObject(parent) + { + qRegisterMetaType("scopeone::core::ImageFrame"); + } + + CameraManager::~CameraManager() + { + shutdown(); + } + + bool CameraManager::activateBackend(CameraBackend::Kind kind) + { + if (m_backend && m_backend->kind() == kind) + { + return true; + } + + shutdown(); + m_backend = kind == CameraBackend::Kind::Native + ? createNativeCameraBackend() + : createAgentCameraBackend(); + if (!m_backend) + { + return false; + } + + connect(m_backend.get(), &CameraBackend::rawFrameReady, + this, &CameraManager::newRawFrameReady); + connect(m_backend.get(), &CameraBackend::rawFramesAcquired, + this, &CameraManager::rawFramesAcquired); + connect(m_backend.get(), &CameraBackend::recordingFramesReady, + this, &CameraManager::recordingFramesReady); + 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); + if (!m_backend->setHighRateFrameDeliveryEnabled(m_highRateFrameDeliveryEnabled) + || !m_backend->setRecordingFrameDeliveryEnabled(m_recordingFrameDeliveryEnabled)) + { + m_backend.reset(); + return false; + } + return true; + } + + bool CameraManager::configureNativeCamera(const std::shared_ptr& core, + const QString& cameraId, + double exposureMs) + { + const QString normalizedId = normalizedCameraId(cameraId); + return core + && !normalizedId.isEmpty() + && activateBackend(CameraBackend::Kind::Native) + && m_backend->configureNativeCamera(core, normalizedId, exposureMs); + } + + bool CameraManager::addAgentCamera(const QString& cameraId, + const QString& adapter, + const QString& device, + const QStringList& preInitProperties, + const QStringList& properties, + double exposureMs) + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() + && activateBackend(CameraBackend::Kind::Agent) + && m_backend->addAgentCamera(normalizedId, + adapter, + device, + preInitProperties, + properties, + exposureMs); + } + + void CameraManager::shutdown() + { + m_backend.reset(); + m_recordingFrameDeliveryEnabled = false; + m_propertyDetailsCache.clear(); + } + + bool CameraManager::startPreview() + { + return m_backend && m_backend->startPreview(); + } + + bool CameraManager::stopPreview() + { + return m_backend && m_backend->stopPreview(); + } + + bool CameraManager::startPreviewFor(const QString& cameraId) + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() && m_backend && m_backend->startPreviewFor(normalizedId); + } + + bool CameraManager::stopPreviewFor(const QString& cameraId) + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() && m_backend && m_backend->stopPreviewFor(normalizedId); + } + + bool CameraManager::isPreviewRunning(const QString& cameraId) const + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() && m_backend && m_backend->isPreviewRunning(normalizedId); + } + + void CameraManager::setFrameDeliveryPaused(bool paused) + { + if (m_backend) + { + m_backend->setFrameDeliveryPaused(paused); + } + } + + bool CameraManager::setRecordingFrameDeliveryEnabled(bool enabled) + { + const bool ok = !m_backend || m_backend->setRecordingFrameDeliveryEnabled(enabled); + if (ok) + { + m_recordingFrameDeliveryEnabled = enabled; + } + return ok; + } + + bool CameraManager::setHighRateFrameDeliveryEnabled(bool enabled) + { + const bool ok = !m_backend || m_backend->setHighRateFrameDeliveryEnabled(enabled); + if (ok) + { + m_highRateFrameDeliveryEnabled = enabled; + } + return ok; + } + + bool CameraManager::getExposure(const QString& cameraIdOrAll, double& exposureMs) const + { + exposureMs = 0.0; + const QString target = cameraIdOrAll.trimmed(); + return !target.isEmpty() && m_backend && m_backend->getExposure(target, exposureMs); + } + + bool CameraManager::setExposure(const QString& cameraIdOrAll, double exposureMs) + { + const QString target = cameraIdOrAll.trimmed(); + return !target.isEmpty() && m_backend && m_backend->setExposure(target, exposureMs); + } + + QStringList CameraManager::listProperties(const QString& cameraId) + { + const QString normalizedId = normalizedCameraId(cameraId); + return normalizedId.isEmpty() || !m_backend + ? QStringList{} + : m_backend->listProperties(normalizedId); + } + + QString CameraManager::getProperty(const QString& cameraId, + const QString& name, + bool fromCache) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + CameraPropertyReadback readback; + if (normalizedId.isEmpty() + || !m_backend + || !m_backend->readPropertyDetails(normalizedId, name, fromCache, readback)) + { + m_propertyDetailsCache.remove(cacheKey); + return {}; + } + + m_propertyDetailsCache.insert(cacheKey, readback); + return readback.value; + } + + bool CameraManager::setProperty(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) + { + const QString normalizedId = normalizedCameraId(cameraId); + if (normalizedId.isEmpty() || !m_backend) + { + return false; + } + const bool ok = m_backend->setProperty(normalizedId, name, value, errorMessage); + if (ok) + { + m_propertyDetailsCache.remove(propertyCacheKey(normalizedId, name)); + } + return ok; + } + + QString CameraManager::getPropertyType(const QString& cameraId, const QString& name) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + if (!m_propertyDetailsCache.contains(cacheKey)) + { + getProperty(normalizedId, name); + } + return m_propertyDetailsCache.value(cacheKey).type; + } + + bool CameraManager::isPropertyReadOnly(const QString& cameraId, const QString& name) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + if (!m_propertyDetailsCache.contains(cacheKey)) + { + getProperty(normalizedId, name); + } + return m_propertyDetailsCache.value(cacheKey).readOnly; + } + + bool CameraManager::isPropertyPreInit(const QString& cameraId, const QString& name) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + if (!m_propertyDetailsCache.contains(cacheKey)) + { + getProperty(normalizedId, name); + } + return m_propertyDetailsCache.value(cacheKey).preInit; + } + + QStringList CameraManager::getAllowedPropertyValues(const QString& cameraId, const QString& name) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + if (!m_propertyDetailsCache.contains(cacheKey)) + { + getProperty(normalizedId, name); + } + return m_propertyDetailsCache.value(cacheKey).allowedValues; + } + + bool CameraManager::hasPropertyLimits(const QString& cameraId, const QString& name) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + if (!m_propertyDetailsCache.contains(cacheKey)) + { + getProperty(normalizedId, name); + } + return m_propertyDetailsCache.value(cacheKey).hasLimits; + } + + double CameraManager::getPropertyLowerLimit(const QString& cameraId, const QString& name) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + if (!m_propertyDetailsCache.contains(cacheKey)) + { + getProperty(normalizedId, name); + } + return m_propertyDetailsCache.value(cacheKey).lowerLimit; + } + + double CameraManager::getPropertyUpperLimit(const QString& cameraId, const QString& name) + { + const QString normalizedId = normalizedCameraId(cameraId); + const QString cacheKey = propertyCacheKey(normalizedId, name); + if (!m_propertyDetailsCache.contains(cacheKey)) + { + getProperty(normalizedId, name); + } + return m_propertyDetailsCache.value(cacheKey).upperLimit; + } + + bool CameraManager::setROI(const QString& cameraId, int x, int y, int width, int height) + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() + && m_backend + && m_backend->setROI(normalizedId, x, y, width, height); + } + + bool CameraManager::clearROI(const QString& cameraId) + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() && m_backend && m_backend->clearROI(normalizedId); + } + + bool CameraManager::getROI(const QString& cameraId, int& x, int& y, int& width, int& height) + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() && m_backend && m_backend->getROI(normalizedId, x, y, width, height); + } + + bool CameraManager::captureEventFrame(const QString& cameraId, ImageFrame& frame, int timeoutMs) + { + const QString normalizedId = normalizedCameraId(cameraId); + return !normalizedId.isEmpty() + && m_backend + && m_backend->captureEventFrame(normalizedId, frame, timeoutMs); + } + +} diff --git a/ScopeOneCore/src/DifferentialRollingModule.cpp b/ScopeOneCore/src/DifferentialRollingModule.cpp index c337299..4170c4c 100644 --- a/ScopeOneCore/src/DifferentialRollingModule.cpp +++ b/ScopeOneCore/src/DifferentialRollingModule.cpp @@ -2,12 +2,13 @@ #include "internal/FrameBufferUtils.h" +#include + namespace scopeone::core::internal { namespace { - constexpr double kNormalizedDisplayScale = 4096.0; - constexpr double kNormalizationEpsilon = 1.0; + constexpr qint64 kNormalizedDisplayScale = 4096; qint64 pixelCountForSize(int width, int height) { @@ -18,33 +19,57 @@ namespace scopeone::core::internal return static_cast(width) * static_cast(height); } - // Adds or removes one frame from a rolling pixel sum - void accumulateFrameSum(const ImageFrame& frame, std::vector& sum, int sign) + // Adds one frame to a rolling pixel sum + void addFrameToSum(const ImageFrame& frame, std::vector& sum) { dispatchFrameType(frame, [&]() { - for (int y = 0; y < frame.height; ++y) + const char* sourceData = frame.bytes.constData(); + int* sumData = sum.data(); + parallelForImageRows(frame.width, frame.height, [&](int firstRow, int lastRow) { - const Pixel* row = frameRowData(frame, y); - const qint64 rowOffset = static_cast(y) * static_cast(frame.width); - for (int x = 0; x < frame.width; ++x) + for (int y = firstRow; y < lastRow; ++y) { - sum[static_cast(rowOffset + x)] += sign * static_cast(row[x]); + const Pixel* row = reinterpret_cast( + sourceData + static_cast(y) * frame.stride); + int* sumRow = sumData + static_cast(y) * frame.width; + for (int x = 0; x < frame.width; ++x) + { + sumRow[x] += static_cast(row[x]); + } } - } + }); }); } - // Adds one frame to a rolling pixel sum - void addFrameToSum(const ImageFrame& frame, std::vector& sum) + qint64 roundedDivide(qint64 numerator, qint64 denominator) { - accumulateFrameSum(frame, sum, 1); + return numerator >= 0 + ? (numerator + denominator / 2) / denominator + : (numerator - denominator / 2) / denominator; } - // Removes one frame from a rolling pixel sum - void subtractFrameFromSum(const ImageFrame& frame, std::vector& sum) + // Maps rolling sums to one display pixel + int differentialDisplayValue(int sumA, + int sumB, + int batchSize, + bool normalize, + bool mono16) { - accumulateFrameSum(frame, sum, -1); + const qint64 centerValue = mono16 ? 32768 : 128; + const qint64 difference = static_cast(sumB) - sumA; + qint64 displayValue = centerValue + roundedDivide(difference, batchSize); + if (normalize) + { + const qint64 normalizationScale = mono16 ? 32767 : kNormalizedDisplayScale; + const qint64 denominator = sumA > batchSize ? sumA : batchSize; + displayValue = centerValue + + roundedDivide(difference * normalizationScale, denominator); + } + return static_cast(qBound( + static_cast((std::numeric_limits::min)()), + displayValue, + static_cast((std::numeric_limits::max)()))); } // Resets rolling state for a new frame size @@ -68,8 +93,7 @@ namespace scopeone::core::internal bool normalize) { const int maxValue = reference.maxValue(); - const double centerValue = reference.isMono16() ? 32768.0 : 128.0; - const double normalizationScale = reference.isMono16() ? 32767.0 : kNormalizedDisplayScale; + const bool mono16 = reference.isMono16(); const qint64 pixelCount = pixelCountForSize(width, height); if (pixelCount <= 0) { @@ -83,27 +107,96 @@ namespace scopeone::core::internal return outBytes; } auto* outData = reinterpret_cast(outBytes.data()); - for (qint64 i = 0; i < pixelCount; ++i) + parallelForImageRows(width, height, [&](int firstRow, int lastRow) { - const double sum1 = static_cast(sumA[static_cast(i)]); - const double sum2 = static_cast(sumB[static_cast(i)]); - const double averageDiff = (sum2 - sum1) / static_cast(batchSize); - double displayValue = averageDiff + centerValue; - if (normalize) + const qint64 firstPixel = static_cast(firstRow) * width; + const qint64 lastPixel = static_cast(lastRow) * width; + for (qint64 i = firstPixel; i < lastPixel; ++i) { - const double normalized = (sum2 - sum1) - / (sum1 > static_cast(batchSize) - ? sum1 - : static_cast(batchSize) * kNormalizationEpsilon); - displayValue = centerValue + normalized * normalizationScale; + outData[i] = clampPixelValue( + differentialDisplayValue(sumA[static_cast(i)], + sumB[static_cast(i)], + batchSize, + normalize, + mono16), + maxValue); } - outData[i] = clampPixelValue(qRound(displayValue), maxValue); - } + }); return outBytes; }); return makeFrameLike(reference, width, height, std::move(bytes)); } + + // Advances both rolling sums and builds the output in one image pass + ImageFrame advanceRollingAndBuildOutput(const ImageFrame& oldestA, + const ImageFrame& bridge, + const ImageFrame& current, + std::vector& sumA, + std::vector& sumB, + int batchSize, + bool normalize) + { + const qint64 pixelCount = pixelCountForSize(current.width, current.height); + if (pixelCount <= 0) + { + return {}; + } + + const int maxValue = current.maxValue(); + const bool mono16 = current.isMono16(); + QByteArray bytes = dispatchFrameType(current, [&]() + { + QByteArray outBytes = allocatePixelBytes(current.width, current.height); + if (outBytes.isEmpty()) + { + return outBytes; + } + + const char* oldestData = oldestA.bytes.constData(); + const char* bridgeData = bridge.bytes.constData(); + const char* currentData = current.bytes.constData(); + Pixel* outputData = reinterpret_cast(outBytes.data()); + int* sumAData = sumA.data(); + int* sumBData = sumB.data(); + parallelForImageRows(current.width, current.height, [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + const Pixel* oldestRow = reinterpret_cast( + oldestData + static_cast(y) * oldestA.stride); + const Pixel* bridgeRow = reinterpret_cast( + bridgeData + static_cast(y) * bridge.stride); + const Pixel* currentRow = reinterpret_cast( + currentData + static_cast(y) * current.stride); + const size_t rowOffset = static_cast(y) * current.width; + Pixel* outputRow = outputData + rowOffset; + int* sumARow = sumAData + rowOffset; + int* sumBRow = sumBData + rowOffset; + for (int x = 0; x < current.width; ++x) + { + sumARow[x] += static_cast(bridgeRow[x]) + - static_cast(oldestRow[x]); + sumBRow[x] += static_cast(currentRow[x]) + - static_cast(bridgeRow[x]); + outputRow[x] = clampPixelValue( + differentialDisplayValue(sumARow[x], + sumBRow[x], + batchSize, + normalize, + mono16), + maxValue); + } + } + }); + return outBytes; + }); + + return makeFrameLike(current, + current.width, + current.height, + std::move(bytes)); + } } // namespace // Creates an independent differential rolling runtime @@ -170,25 +263,19 @@ namespace scopeone::core::internal const ImageFrame oldestA = m_state.batchA.front(); const ImageFrame bridgeFrame = m_state.batchB.front(); - - subtractFrameFromSum(oldestA, m_state.sumA); + ImageFrame output = advanceRollingAndBuildOutput(oldestA, + bridgeFrame, + workingFrame, + m_state.sumA, + m_state.sumB, + m_batchSize, + m_normalize); m_state.batchA.pop_front(); m_state.batchA.push_back(bridgeFrame); - addFrameToSum(bridgeFrame, m_state.sumA); - - subtractFrameFromSum(bridgeFrame, m_state.sumB); m_state.batchB.pop_front(); m_state.batchB.push_back(workingFrame); - addFrameToSum(workingFrame, m_state.sumB); - - return {makeDifferentialOutput(workingFrame.width, - workingFrame.height, - workingFrame, - m_state.sumA, - m_state.sumB, - m_batchSize, - m_normalize), - {}}; + + return {std::move(output), {}}; } catch (const std::exception& e) { diff --git a/ScopeOneCore/src/FFTModule.cpp b/ScopeOneCore/src/FFTModule.cpp index 74178ca..2b44438 100644 --- a/ScopeOneCore/src/FFTModule.cpp +++ b/ScopeOneCore/src/FFTModule.cpp @@ -2,6 +2,7 @@ #include "internal/FrameBufferUtils.h" #include +#include #include #include #include @@ -33,11 +34,21 @@ namespace scopeone::core::internal ImageFrame matToOutputFrame(const cv::Mat& input, const ImageFrame& reference) { - cv::Mat normalized; 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); - QByteArray bytes = copyMatBytes(normalized); return makeFrameLike(reference, normalized.cols, normalized.rows, std::move(bytes)); } @@ -47,15 +58,19 @@ namespace scopeone::core::internal shifted.create(image.size(), image.type()); const int xOffset = image.cols / 2; const int yOffset = image.rows / 2; - for (int y = 0; y < image.rows; ++y) + 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) { - const float* srcRow = image.ptr((y + yOffset) % image.rows); - float* dstRow = shifted.ptr(y); - for (int x = 0; x < image.cols; ++x) + for (int y = firstRow; y < lastRow; ++y) { - dstRow[x] = srcRow[(x + xOffset) % image.cols]; + 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); } - } + }); } // Computes a log magnitude spectrum from complex DFT planes @@ -107,16 +122,8 @@ namespace scopeone::core::internal } } - cv::Mat mask(size, CV_32F); - const int cx = size.width / 2; - const int cy = size.height / 2; - centered(cv::Rect(cx, cy, size.width - cx, size.height - cy)).copyTo( - mask(cv::Rect(0, 0, size.width - cx, size.height - cy))); - centered(cv::Rect(0, cy, cx, size.height - cy)).copyTo( - mask(cv::Rect(size.width - cx, 0, cx, size.height - cy))); - centered(cv::Rect(cx, 0, size.width - cx, cy)).copyTo( - mask(cv::Rect(0, size.height - cy, size.width - cx, cy))); - centered(cv::Rect(0, 0, cx, cy)).copyTo(mask(cv::Rect(size.width - cx, size.height - cy, cx, cy))); + cv::Mat mask; + fftShift(centered, mask); return mask; } } diff --git a/ScopeOneCore/src/FrameBufferUtils.cpp b/ScopeOneCore/src/FrameBufferUtils.cpp index f490be0..17f7d3e 100644 --- a/ScopeOneCore/src/FrameBufferUtils.cpp +++ b/ScopeOneCore/src/FrameBufferUtils.cpp @@ -67,16 +67,20 @@ namespace scopeone::core::internal uchar* dstData = reinterpret_cast(bytes.data()); const char* srcData = src.bytes.constData(); - for (int y = 0; y < src.height; ++y) + parallelForImageRows(src.width, src.height, [&](int firstRow, int lastRow) { - const quint16* srcRow = reinterpret_cast(srcData + static_cast(y) * src.stride); - uchar* dstRow = dstData + static_cast(y) * src.width; - for (int x = 0; x < src.width; ++x) + for (int y = firstRow; y < lastRow; ++y) { - dstRow[x] = static_cast(mono8ValueFrom16(static_cast(srcRow[x]), - src.bitsPerSample)); + const quint16* srcRow = reinterpret_cast( + srcData + static_cast(y) * src.stride); + uchar* dstRow = dstData + static_cast(y) * src.width; + for (int x = 0; x < src.width; ++x) + { + dstRow[x] = static_cast( + mono8ValueFrom16(static_cast(srcRow[x]), src.bitsPerSample)); + } } - } + }); dst = makeMono8Frame(src.cameraId, src.width, src.height, std::move(bytes)); copyFrameMetadata(src, dst); diff --git a/ScopeOneCore/src/GaussianBlurModule.cpp b/ScopeOneCore/src/GaussianBlurModule.cpp index eb8a2a8..123157b 100644 --- a/ScopeOneCore/src/GaussianBlurModule.cpp +++ b/ScopeOneCore/src/GaussianBlurModule.cpp @@ -32,12 +32,23 @@ namespace scopeone::core::internal } const int cvType = workingFrame.isMono16() ? CV_16UC1 : CV_8UC1; - cv::Mat src(workingFrame.height, workingFrame.width, cvType, - workingFrame.bytes.data(), workingFrame.stride); - cv::Mat blurred; + const cv::Mat src(workingFrame.height, workingFrame.width, cvType, + const_cast(workingFrame.bytes.constData()), + workingFrame.stride); + QByteArray bytes = workingFrame.isMono16() + ? allocatePixelBytes(workingFrame.width, workingFrame.height) + : allocatePixelBytes(workingFrame.width, workingFrame.height); + if (bytes.isEmpty()) + { + return {{}, QStringLiteral("Failed to allocate Gaussian blur output")}; + } + cv::Mat blurred(workingFrame.height, + workingFrame.width, + cvType, + bytes.data(), + workingFrame.width * workingFrame.bytesPerPixel()); cv::GaussianBlur(src, blurred, cv::Size(m_kernelSize, m_kernelSize), m_sigma, m_sigma); - QByteArray bytes = copyMatBytes(blurred); return {makeFrameLike(workingFrame, blurred.cols, blurred.rows, std::move(bytes)), {}}; } catch (const std::exception& e) diff --git a/ScopeOneCore/src/ImageProcessingFramework.cpp b/ScopeOneCore/src/ImageProcessingFramework.cpp index bdbbfed..023b30e 100644 --- a/ScopeOneCore/src/ImageProcessingFramework.cpp +++ b/ScopeOneCore/src/ImageProcessingFramework.cpp @@ -11,6 +11,8 @@ namespace scopeone::core::internal { namespace { + constexpr int kProcessedOutputIntervalMs = 16; + // Normalizes frame identity before it enters a runtime pipeline ImageFrame normalizedFrameSource(const ImageFrame& frame) { @@ -169,6 +171,10 @@ namespace scopeone::core::internal { const int idealThreadCount = QThread::idealThreadCount(); m_threadPool.setMaxThreadCount(qMax(2, idealThreadCount - 1)); + m_outputTimer.setInterval(kProcessedOutputIntervalMs); + m_outputTimer.setTimerType(Qt::PreciseTimer); + connect(&m_outputTimer, &QTimer::timeout, + this, &ImageProcessingManager::flushProcessedOutputs); } // Waits for owned processing workers to finish @@ -186,7 +192,29 @@ namespace scopeone::core::internal // Enables or disables real time processing for incoming frames void ImageProcessingManager::enableRealTimeProcessing(bool enabled) { + QMutexLocker locker(&m_frameMutex); + if (m_realTimeEnabled.load() == enabled) + { + return; + } + m_realTimeEnabled = enabled; + if (enabled) + { + m_outputTimer.start(); + } + else + { + m_outputTimer.stop(); + } + ++m_liveGeneration; + for (CameraSlot& slot : m_cameraSlots) + { + slot.latestFrame = {}; + slot.hasFrame = false; + slot.latestProcessedFrame = {}; + slot.completedFrameCount = 0; + } } // Sets the processing bit depth and rebuilds runtime pipelines @@ -202,16 +230,52 @@ namespace scopeone::core::internal QMutexLocker locker(&m_frameMutex); m_livePipelines.clear(); m_offlinePipelines.clear(); + ++m_liveGeneration; + for (CameraSlot& slot : m_cameraSlots) + { + slot.latestFrame = {}; + slot.hasFrame = false; + slot.latestProcessedFrame = {}; + slot.completedFrameCount = 0; + } } - // Queues one frame for asynchronous processing + // Stores the newest frame and starts a worker when needed void ImageProcessingManager::processFrameAsync(const ImageFrame& frame) { if (!m_realTimeEnabled || !frame.isValid()) { return; } - submitFrame(frame); + + const ImageFrame normalizedFrame = normalizedFrameSource(frame); + const QString cameraKey = getCameraKey(normalizedFrame); + bool shouldStartWorker = false; + + { + QMutexLocker locker(&m_frameMutex); + if (!m_realTimeEnabled.load()) + { + return; + } + CameraSlot& slot = m_cameraSlots[cameraKey]; + slot.latestFrame = normalizedFrame; + slot.latestFrameGeneration = m_liveGeneration; + slot.hasFrame = true; + if (!slot.processing) + { + slot.processing = true; + shouldStartWorker = true; + } + } + + if (shouldStartWorker) + { + m_threadPool.start([this, cameraKey]() + { + processCameraQueue(cameraKey); + }); + } } // Runs one frame through the runtime pipeline for its source @@ -278,39 +342,6 @@ namespace scopeone::core::internal return key.isEmpty() ? QStringLiteral("__default__") : key; } - // Stores the newest frame and starts a worker when needed - void ImageProcessingManager::submitFrame(const ImageFrame& frame) - { - if (!m_realTimeEnabled || !frame.isValid()) - { - return; - } - - const ImageFrame normalizedFrame = normalizedFrameSource(frame); - const QString cameraKey = getCameraKey(normalizedFrame); - bool shouldStartWorker = false; - - { - QMutexLocker locker(&m_frameMutex); - CameraSlot& slot = m_cameraSlots[cameraKey]; - slot.latestFrame = normalizedFrame; - slot.hasFrame = true; - if (!slot.processing) - { - slot.processing = true; - shouldStartWorker = true; - } - } - - if (shouldStartWorker) - { - m_threadPool.start([this, cameraKey]() - { - processCameraQueue(cameraKey); - }); - } - } - // Returns the live runtime pipeline owned by one camera preview stream std::shared_ptr ImageProcessingManager::livePipelineForCamera( const QString& cameraKey) @@ -348,6 +379,7 @@ namespace scopeone::core::internal while (true) { ImageFrame frame; + quint64 frameGeneration = 0; { QMutexLocker locker(&m_frameMutex); @@ -361,6 +393,7 @@ namespace scopeone::core::internal return; } frame = it->latestFrame; + frameGeneration = it->latestFrameGeneration; it->hasFrame = false; } @@ -371,7 +404,15 @@ namespace scopeone::core::internal ProcessingResult result = pipeline->process(frame, m_processingBitDepth.load()); if (result.succeeded()) { - emit imageProcessed(result.frame); + QMutexLocker locker(&m_frameMutex); + auto it = m_cameraSlots.find(cameraKey); + if (it != m_cameraSlots.end() + && m_realTimeEnabled.load() + && frameGeneration == m_liveGeneration) + { + it->latestProcessedFrame = std::move(result.frame); + ++it->completedFrameCount; + } } else { @@ -386,4 +427,29 @@ namespace scopeone::core::internal } } } + + // Publishes the newest completed result per camera at a bounded rate + void ImageProcessingManager::flushProcessedOutputs() + { + std::vector> outputs; + + { + QMutexLocker locker(&m_frameMutex); + outputs.reserve(static_cast(m_cameraSlots.size())); + for (CameraSlot& slot : m_cameraSlots) + { + if (slot.completedFrameCount == 0 || !slot.latestProcessedFrame.isValid()) + { + continue; + } + outputs.emplace_back(std::move(slot.latestProcessedFrame), + std::exchange(slot.completedFrameCount, quint64{0})); + } + } + + for (const auto& [frame, completedFrameCount] : outputs) + { + emit imageProcessed(frame, completedFrameCount); + } + } } // namespace scopeone::core::internal diff --git a/ScopeOneCore/src/MDAManager.cpp b/ScopeOneCore/src/MDAManager.cpp index edac6e0..6f6e266 100644 --- a/ScopeOneCore/src/MDAManager.cpp +++ b/ScopeOneCore/src/MDAManager.cpp @@ -1,6 +1,6 @@ #include "internal/MDAManager.h" -#include "internal/MultiProcessCameraManager.h" +#include "internal/CameraManager.h" #include "MMCore.h" #include @@ -39,9 +39,9 @@ namespace scopeone::core::internal } // Connects MDA capture to the active camera backend - void MDAManager::setMultiProcessCameraManager(MultiProcessCameraManager* mpcm) + void MDAManager::setCameraManager(CameraManager* cameraManager) { - m_mpcm = mpcm; + m_cameraManager = cameraManager; } // Starts one immutable acquisition event sequence @@ -180,9 +180,9 @@ namespace scopeone::core::internal { if (event.cameraIds.size() > 1) { - if (!m_mpcm) + if (!m_cameraManager) { - if (errorMessage) *errorMessage = QStringLiteral("MultiProcessCameraManager not available"); + if (errorMessage) *errorMessage = QStringLiteral("CameraManager not available"); return false; } return execEventMultiCamera(event, output, errorMessage); @@ -297,7 +297,7 @@ namespace scopeone::core::internal { CaptureResult result; result.cameraId = cameraId; - if (!m_mpcm->captureEventFrame(cameraId, result.frame, captureTimeoutMs)) + if (!m_cameraManager->captureEventFrame(cameraId, result.frame, captureTimeoutMs)) { result.error = QStringLiteral("Failed to capture frame from camera: %1").arg(cameraId); return result; diff --git a/ScopeOneCore/src/MMCoreManager.cpp b/ScopeOneCore/src/MMCoreManager.cpp index 503e840..e934024 100644 --- a/ScopeOneCore/src/MMCoreManager.cpp +++ b/ScopeOneCore/src/MMCoreManager.cpp @@ -1,5 +1,5 @@ #include "internal/MMCoreManager.h" -#include "internal/MultiProcessCameraManager.h" +#include "internal/CameraManager.h" #include #include #include @@ -330,7 +330,7 @@ namespace scopeone::core::internal // Initializes devices and starts camera backends bool MMCoreManager::loadConfigurationAndStartCameras(const QString& configPath, - MultiProcessCameraManager* mpcm, + CameraManager* cameraManager, LoadConfigResult* result, QString* errorMessage) { @@ -441,14 +441,9 @@ namespace scopeone::core::internal QStringList agentsStarted; const bool foundCamera = !cameraInfos.empty(); - if (useSingleCamera && mpcm) - { - mpcm->setNativeCore(m_mmcore); - } - for (const auto& ci : cameraInfos) { - if (!mpcm) + if (!cameraManager) { cameraIds.append(ci.label); continue; @@ -456,7 +451,7 @@ namespace scopeone::core::internal if (useSingleCamera) { - if (mpcm->startSingleCamera(ci.label, ci.exposureMs)) + if (cameraManager->configureNativeCamera(m_mmcore, ci.label, ci.exposureMs)) { cameraIds.append(ci.label); } @@ -467,12 +462,12 @@ namespace scopeone::core::internal } else { - if (mpcm->startAgentFor(ci.label, - ci.adapter, - ci.device, - ci.preInitProperties, - ci.properties, - ci.exposureMs)) + if (cameraManager->addAgentCamera(ci.label, + ci.adapter, + ci.device, + ci.preInitProperties, + ci.properties, + ci.exposureMs)) { cameraIds.append(ci.label); agentsStarted.append(ci.label); diff --git a/ScopeOneCore/src/MultiProcessCameraManager.cpp b/ScopeOneCore/src/MultiProcessCameraManager.cpp deleted file mode 100644 index ab1cfe0..0000000 --- a/ScopeOneCore/src/MultiProcessCameraManager.cpp +++ /dev/null @@ -1,2435 +0,0 @@ -#include "internal/MultiProcessCameraManager.h" -#include "internal/AgentProtocol.h" -#include "scopeone/SharedFrame.h" -#include "MMCore.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace scopeone::core::internal -{ - using scopeone::core::ImageFrame; - using scopeone::core::SharedFrameHeader; - using scopeone::core::SharedMemoryControl; - using scopeone::core::SharedPixelFormat; - using scopeone::core::kSharedFrameHeaderSize; - using scopeone::core::kSharedFrameMaxBytes; - using scopeone::core::kSharedFrameNumSlots; - using scopeone::core::kSharedFrameSlotStride; - using scopeone::core::kSharedMemoryControlSize; - - namespace - { - constexpr int kAgentControlReadyTimeoutMs = 15000; - - // Normalizes camera ids before they become manager keys - QString normalizedCameraId(const QString& cameraId) - { - return cameraId.trimmed(); - } - - struct PropertyReadback - { - QString value; - QString type{QStringLiteral("Unknown")}; - bool readOnly{true}; - QStringList allowedValues; - bool hasLimits{false}; - double lowerLimit{0.0}; - double upperLimit{0.0}; - }; - } // namespace - - struct MultiProcessCameraManager::ControlSession : QObject - { - struct PendingRequest - { - QByteArray encoded; - QTimer* timer{nullptr}; - std::function completion; - }; - - explicit ControlSession(const QString& cameraId, - const QString& serverName, - QObject* parent = nullptr) - : QObject(parent) - , m_cameraId(cameraId) - , m_serverName(serverName) - { - m_reconnectTimer.setSingleShot(true); - connect(&m_reconnectTimer, &QTimer::timeout, this, [this]() { ensureConnected(); }); - - connect(&m_socket, &QLocalSocket::connected, this, [this]() { flushPendingWrites(); }); - connect(&m_socket, &QLocalSocket::disconnected, this, [this]() - { - setReady(false); - failAll(QStringLiteral("Control session disconnected")); - if (!m_closing) - { - m_reconnectTimer.start(100); - } - }); - connect(&m_socket, &QLocalSocket::readyRead, this, [this]() { handleReadyRead(); }); - connect(&m_socket, &QLocalSocket::errorOccurred, this, - [this](QLocalSocket::LocalSocketError) - { - if (m_closing) - { - return; - } - setReady(false); - failAll(QStringLiteral("Control socket error for '%1'").arg(m_cameraId)); - if (m_socket.state() == QLocalSocket::UnconnectedState) - { - m_reconnectTimer.start(100); - } - }); - } - - void start() - { - ensureConnected(); - } - - void stop() - { - m_closing = true; - m_reconnectTimer.stop(); - setReady(false); - failAll(QStringLiteral("Control session closed")); - if (m_socket.state() != QLocalSocket::UnconnectedState) - { - m_socket.abort(); - } - } - - bool isReady() const - { - return m_ready && m_socket.state() == QLocalSocket::ConnectedState; - } - - bool waitForReady(int timeoutMs) - { - if (isReady()) - { - return true; - } - - ensureConnected(); - - QEventLoop loop; - QTimer watchdog; - watchdog.setSingleShot(true); - watchdog.setInterval((std::max)(1, timeoutMs)); - - const auto quitIfReady = [this, &loop]() - { - if (isReady()) - { - loop.quit(); - } - }; - - connect(&m_socket, &QLocalSocket::connected, &loop, quitIfReady); - connect(&m_socket, &QLocalSocket::readyRead, &loop, quitIfReady); - connect(&m_socket, &QLocalSocket::disconnected, &loop, quitIfReady); - connect(&m_socket, &QLocalSocket::errorOccurred, &loop, - [quitIfReady](QLocalSocket::LocalSocketError) - { - quitIfReady(); - }); - connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); - - watchdog.start(); - if (!isReady()) - { - loop.exec(); - } - return isReady(); - } - - void addReadyHandler(std::function handler) - { - m_readyHandlers.push_back(std::move(handler)); - } - - void addEventHandler(std::function handler) - { - m_eventHandlers.push_back(std::move(handler)); - } - - bool sendRequest(const QJsonObject& request, - int timeoutMs, - std::function completion) - { - const QString type = request.value(agent::kMessageTypeField).toString(); - if (type.isEmpty()) - { - return false; - } - - 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)); - - PendingRequest pending; - pending.encoded = agent::encodeMessage(envelope); - pending.completion = std::move(completion); - pending.timer = new QTimer(this); - pending.timer->setSingleShot(true); - connect(pending.timer, &QTimer::timeout, this, [this, requestId]() - { - completeRequest(requestId, false, QJsonObject{}, QStringLiteral("Control request timed out")); - }); - pending.timer->start((std::max)(1, timeoutMs)); - - m_pending.insert(requestId, pending); - m_sendQueue.push_back(requestId); - ensureConnected(); - flushPendingWrites(); - return true; - } - - private: - void ensureConnected() - { - if (m_closing) - { - return; - } - if (m_socket.state() == QLocalSocket::ConnectedState - || m_socket.state() == QLocalSocket::ConnectingState) - { - return; - } - m_socket.connectToServer(m_serverName); - } - - void setReady(bool ready) - { - if (m_ready == ready) - { - return; - } - m_ready = ready; - for (const auto& handler : m_readyHandlers) - { - handler(m_ready); - } - } - - void flushPendingWrites() - { - if (m_socket.state() != QLocalSocket::ConnectedState) - { - return; - } - - while (!m_sendQueue.isEmpty()) - { - const quint64 requestId = m_sendQueue.front(); - m_sendQueue.pop_front(); - - auto it = m_pending.find(requestId); - if (it == m_pending.end()) - { - continue; - } - m_socket.write(it->encoded); - } - m_socket.flush(); - } - - void handleReadyRead() - { - 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) - { - resetWithError(error); - return; - } - - if (message.value(agent::kEnvelopeVersionField).toInt(0) - != static_cast(agent::kProtocolVersion)) - { - resetWithError(QStringLiteral("Control protocol version mismatch")); - return; - } - - const QString kind = message.value(agent::kEnvelopeKindField).toString(); - if (kind == agent::kMessageKindResponse) - { - const quint64 requestId = - agent::decodeUInt64(message.value(agent::kEnvelopeRequestIdField)); - if (requestId == 0) - { - continue; - } - completeRequest(requestId, true, message, QString{}); - continue; - } - - if (kind == agent::kMessageKindEvent) - { - const QString type = message.value(agent::kMessageTypeField).toString(); - if (type == agent::kEventHello) - { - setReady(true); - } - for (const auto& handler : m_eventHandlers) - { - handler(message); - } - } - } - } - - void completeRequest(quint64 requestId, - bool ok, - const QJsonObject& response, - const QString& error) - { - auto it = m_pending.find(requestId); - if (it == m_pending.end()) - { - return; - } - - PendingRequest pending = it.value(); - m_pending.erase(it); - - if (pending.timer) - { - pending.timer->stop(); - pending.timer->deleteLater(); - } - if (pending.completion) - { - pending.completion(ok, response, error); - } - } - - void failAll(const QString& error) - { - const QList requestIds = m_pending.keys(); - for (quint64 requestId : requestIds) - { - completeRequest(requestId, false, QJsonObject{}, error); - } - m_sendQueue.clear(); - } - - void resetWithError(const QString& error) - { - setReady(false); - failAll(error); - if (m_socket.state() != QLocalSocket::UnconnectedState) - { - m_socket.abort(); - } - if (!m_closing) - { - m_reconnectTimer.start(100); - } - } - - QString m_cameraId; - QString m_serverName; - QLocalSocket m_socket; - QTimer m_reconnectTimer; - QByteArray m_readBuffer; - QMap m_pending; - QList m_sendQueue; - QList> m_readyHandlers; - QList> m_eventHandlers; - quint64 m_nextRequestId{1}; - bool m_ready{false}; - bool m_closing{false}; - }; - - // Route camera work through one backend - struct MultiProcessCameraManager::CameraBackend - { - explicit CameraBackend(MultiProcessCameraManager& ownerRef) - : owner(ownerRef) - { - } - - virtual ~CameraBackend() = default; - - virtual bool isNative() const = 0; - virtual void shutdown() = 0; - virtual bool startPreview() = 0; - virtual bool stopPreview() = 0; - virtual bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const = 0; - virtual bool startPreviewFor(const QString& cameraId) = 0; - virtual bool stopPreviewFor(const QString& cameraId) = 0; - virtual bool setExposure(const QString& cameraIdOrAll, double exposureMs) = 0; - virtual QStringList listProperties(const QString& cameraId) = 0; - virtual bool readPropertyDetails(const QString& cameraId, const QString& name, PropertyReadback& readback) = 0; - virtual bool setProperty(const QString& cameraId, - const QString& name, - const QString& value, - QString* errorMessage) = 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; - - protected: - void scheduleInitialPolls() - { - QTimer::singleShot(10, &owner, [&owner = owner]() { owner.pollSharedMemory(); }); - QTimer::singleShot(40, &owner, [&owner = owner]() { owner.pollSharedMemory(); }); - QTimer::singleShot(100, &owner, [&owner = owner]() { owner.pollSharedMemory(); }); - } - - void onPreviewStarted(const QString& cameraId) - { - if (isNative() && !owner.m_pollingPaused && !owner.m_pollTimer.isActive()) - { - owner.m_pollTimer.start(); - } - if (isNative() && !owner.m_pollingPaused) - { - owner.updatePollingInterval(); - } - emit owner.previewStateChanged(true); - qInfo().noquote() << QString("Preview started for camera '%1'").arg(cameraId); - if (isNative()) - { - scheduleInitialPolls(); - } - } - - void onPreviewStopped() - { - const bool allStopped = !owner.hasRunningCamera(); - - if (allStopped && owner.m_pollTimer.isActive()) - { - owner.m_pollTimer.stop(); - } - if (allStopped) - { - emit owner.previewStateChanged(false); - qInfo().noquote() << "All cameras stopped"; - } - else if (isNative() && !owner.m_pollingPaused) - { - owner.updatePollingInterval(); - } - } - - MultiProcessCameraManager& owner; - }; - - struct MultiProcessCameraManager::DeviceControlBackend : CameraBackend - { - using CameraBackend::CameraBackend; - - bool getExposure(const QString& cameraIdOrAll, double& exposureMs) const override - { - QString cameraId; - if (!resolvePrimaryCameraId(cameraIdOrAll, cameraId)) - { - return false; - } - return readExposureFor(cameraId, exposureMs); - } - - bool setExposure(const QString& cameraIdOrAll, double exposureMs) override - { - const QStringList cameraIds = resolveTargetCameraIds(cameraIdOrAll); - if (cameraIds.isEmpty()) - { - return false; - } - - for (const QString& cameraId : cameraIds) - { - if (!applyWithPreviewRestart(cameraId, [&]() - { - if (!writeExposureFor(cameraId, exposureMs)) - { - return false; - } - double actualExposureMs = exposureMs; - readExposureFor(cameraId, actualExposureMs); - owner.m_cameras[cameraId]->exposureMs = actualExposureMs; - return true; - })) - { - return false; - } - } - - owner.updatePollingInterval(); - return true; - } - - QStringList listProperties(const QString& cameraId) override - { - QString resolvedCameraId; - if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) - { - return {}; - } - return listPropertiesFor(resolvedCameraId); - } - - bool readPropertyDetails(const QString& cameraId, const QString& name, PropertyReadback& readback) override - { - QString resolvedCameraId; - if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) - { - return false; - } - return readPropertyDetailsFor(resolvedCameraId, name, readback); - } - - bool setProperty(const QString& cameraId, - const QString& name, - const QString& value, - QString* errorMessage) override - { - QString resolvedCameraId; - if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) - { - return false; - } - return applyWithPreviewRestart( - resolvedCameraId, [&]() { return setPropertyFor(resolvedCameraId, name, value, errorMessage); }); - } - - bool setROI(const QString& cameraId, int x, int y, int width, int height) override - { - QString resolvedCameraId; - if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) - { - return false; - } - return applyWithPreviewRestart( - resolvedCameraId, [&]() { return setROIFor(resolvedCameraId, x, y, width, height); }); - } - - bool clearROI(const QString& cameraId) override - { - QString resolvedCameraId; - if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) - { - return false; - } - return applyWithPreviewRestart(resolvedCameraId, [&]() { return clearROIFor(resolvedCameraId); }); - } - - bool getROI(const QString& cameraId, int& x, int& y, int& width, int& height) override - { - QString resolvedCameraId; - if (!resolvePrimaryCameraId(cameraId, resolvedCameraId)) - { - return false; - } - return getROIFor(resolvedCameraId, x, y, width, height); - } - - protected: - template - bool applyWithPreviewRestart(const QString& cameraId, Operation&& operation) - { - // Restart preview around camera changes - const bool wasRunning = owner.m_cameras.contains(cameraId) && owner.m_cameras.value(cameraId)->isRunning; - if (wasRunning && !stopPreviewFor(cameraId)) - { - return false; - } - - const bool ok = operation(); - - if (wasRunning && !startPreviewFor(cameraId)) - { - return false; - } - - return ok; - } - - virtual bool resolvePrimaryCameraId(const QString& cameraIdOrAll, QString& cameraId) const = 0; - virtual QStringList resolveTargetCameraIds(const QString& cameraIdOrAll) const = 0; - virtual bool readExposureFor(const QString& cameraId, double& exposureMs) const = 0; - virtual bool writeExposureFor(const QString& cameraId, double exposureMs) = 0; - virtual QStringList listPropertiesFor(const QString& cameraId) = 0; - virtual bool readPropertyDetailsFor(const QString& cameraId, - const QString& name, - PropertyReadback& readback) = 0; - virtual bool setPropertyFor(const QString& cameraId, - const QString& name, - const QString& value, - QString* errorMessage) = 0; - virtual bool setROIFor(const QString& cameraId, int x, int y, int width, int height) = 0; - virtual bool clearROIFor(const QString& cameraId) = 0; - virtual bool getROIFor(const QString& cameraId, int& x, int& y, int& width, int& height) = 0; - }; - - struct MultiProcessCameraManager::NativeSingleCameraBackend final : DeviceControlBackend - { - public: - explicit NativeSingleCameraBackend(MultiProcessCameraManager& owner) - : DeviceControlBackend(owner) - { - } - - bool isNative() const override { return true; } - - void shutdown() override - { - stopPreview(); - owner.m_cameras.clear(); - owner.m_singleCameraId.clear(); - } - - bool startPreview() override - { - return startPreviewFor(owner.m_singleCameraId); - } - - bool stopPreview() override - { - return stopPreviewFor(owner.m_singleCameraId); - } - - bool startPreviewFor(const QString& cameraId) override - { - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return false; - } - if (!owner.m_cameras.contains(cameraId)) - { - return false; - } - - auto slotPtr = owner.m_cameras.value(cameraId); - MultiProcessCameraManager::CameraSlot& slot = *slotPtr; - try - { - owner.m_nativeCore->setCameraDevice(cameraId.toStdString().c_str()); - while (owner.m_nativeCore->getRemainingImageCount() > 0) - { - owner.m_nativeCore->popNextImage(); - } - if (!owner.m_nativeCore->isSequenceRunning()) - { - owner.m_nativeCore->startContinuousSequenceAcquisition(0.0); - } - try - { - const double exposureMs = owner.m_nativeCore->getExposure(); - if (exposureMs > 0.0) - { - slot.exposureMs = exposureMs; - } - } - catch (const CMMError&) - { - } - } - catch (const CMMError&) - { - qWarning().noquote() << QString("Failed to start local preview for camera '%1'").arg(cameraId); - return false; - } - - slot.isRunning = true; - onPreviewStarted(cameraId); - return true; - } - - bool stopPreviewFor(const QString& cameraId) override - { - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return false; - } - - try - { - if (owner.m_nativeCore->isSequenceRunning()) - { - owner.m_nativeCore->stopSequenceAcquisition(); - } - } - catch (const CMMError&) - { - return false; - } - - if (owner.m_cameras.contains(cameraId)) - { - owner.m_cameras[cameraId]->isRunning = false; - } - onPreviewStopped(); - return true; - } - - protected: - bool resolvePrimaryCameraId(const QString& cameraIdOrAll, QString& cameraId) const override - { - const QString target = - cameraIdOrAll.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0 - ? owner.m_singleCameraId - : cameraIdOrAll; - if (!owner.isSingleCamera(target)) - { - return false; - } - cameraId = target; - return true; - } - - QStringList resolveTargetCameraIds(const QString& cameraIdOrAll) const override - { - QString cameraId; - return resolvePrimaryCameraId(cameraIdOrAll, cameraId) ? QStringList{cameraId} : QStringList{}; - } - - bool readExposureFor(const QString& cameraId, double& exposureMs) const override - { - if (!owner.m_nativeCore) - { - return false; - } - try - { - exposureMs = owner.m_nativeCore->getExposure(cameraId.toStdString().c_str()); - return true; - } - catch (const CMMError&) - { - if (owner.m_cameras.contains(cameraId)) - { - exposureMs = owner.m_cameras.value(cameraId)->exposureMs; - return exposureMs > 0.0; - } - return false; - } - } - - bool writeExposureFor(const QString& cameraId, double exposureMs) override - { - if (!owner.m_nativeCore) - { - return false; - } - try - { - owner.m_nativeCore->setCameraDevice(cameraId.toStdString().c_str()); - owner.m_nativeCore->setExposure(exposureMs); - owner.m_nativeCore->waitForDevice(cameraId.toStdString().c_str()); - while (owner.m_nativeCore->getRemainingImageCount() > 0) - { - owner.m_nativeCore->popNextImage(); - } - return true; - } - catch (const CMMError&) - { - return false; - } - } - - QStringList listPropertiesFor(const QString& cameraId) override - { - QStringList out; - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return out; - } - try - { - const auto names = owner.m_nativeCore->getDevicePropertyNames(cameraId.toStdString().c_str()); - for (const auto& name : names) - { - out << QString::fromStdString(name); - } - } - catch (const CMMError&) - { - return {}; - } - return out; - } - - bool readPropertyDetailsFor(const QString& cameraId, const QString& name, PropertyReadback& readback) override - { - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return false; - } - - try - { - readback.value = QString::fromStdString( - owner.m_nativeCore->getProperty(cameraId.toStdString().c_str(), name.toStdString().c_str())); - try - { - const MM::PropertyType type = - owner.m_nativeCore->getPropertyType(cameraId.toStdString().c_str(), name.toStdString().c_str()); - switch (type) - { - case MM::String: readback.type = QStringLiteral("String"); - break; - case MM::Float: readback.type = QStringLiteral("Float"); - break; - case MM::Integer: readback.type = QStringLiteral("Integer"); - break; - default: readback.type = QStringLiteral("Unknown"); - break; - } - } - catch (const CMMError&) - { - } - try - { - readback.readOnly = - owner.m_nativeCore->isPropertyReadOnly(cameraId.toStdString().c_str(), - name.toStdString().c_str()); - } - catch (const CMMError&) - { - } - try - { - const auto allowed = owner.m_nativeCore->getAllowedPropertyValues(cameraId.toStdString().c_str(), - name.toStdString().c_str()); - for (const auto& value : allowed) - { - readback.allowedValues.append(QString::fromStdString(value)); - } - } - catch (const CMMError&) - { - } - try - { - readback.hasLimits = - owner.m_nativeCore->hasPropertyLimits(cameraId.toStdString().c_str(), - name.toStdString().c_str()); - if (readback.hasLimits) - { - readback.lowerLimit = owner.m_nativeCore->getPropertyLowerLimit(cameraId.toStdString().c_str(), - name.toStdString().c_str()); - readback.upperLimit = owner.m_nativeCore->getPropertyUpperLimit(cameraId.toStdString().c_str(), - name.toStdString().c_str()); - } - } - catch (const CMMError&) - { - } - return true; - } - catch (const CMMError&) - { - return false; - } - } - - bool setPropertyFor(const QString& cameraId, - const QString& name, - const QString& value, - QString* errorMessage) override - { - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return false; - } - try - { - owner.m_nativeCore->setCameraDevice(cameraId.toStdString().c_str()); - owner.m_nativeCore->setProperty(cameraId.toStdString().c_str(), - name.toStdString().c_str(), - value.toStdString().c_str()); - owner.m_nativeCore->waitForDevice(cameraId.toStdString().c_str()); - return true; - } - catch (const CMMError& e) - { - if (errorMessage) - { - *errorMessage = QString::fromStdString(e.getMsg()); - } - return false; - } - } - - bool setROIFor(const QString& cameraId, int x, int y, int width, int height) override - { - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return false; - } - try - { - owner.m_nativeCore->setROI(cameraId.toStdString().c_str(), x, y, width, height); - owner.m_nativeCore->waitForDevice(cameraId.toStdString().c_str()); - return true; - } - catch (const CMMError& e) - { - qWarning().noquote() << QString("Failed to set ROI for '%1': %2").arg(cameraId, e.getMsg().c_str()); - return false; - } - } - - bool clearROIFor(const QString& cameraId) override - { - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return false; - } - try - { - owner.m_nativeCore->setCameraDevice(cameraId.toStdString().c_str()); - owner.m_nativeCore->clearROI(); - owner.m_nativeCore->waitForDevice(cameraId.toStdString().c_str()); - return true; - } - catch (const CMMError& e) - { - qWarning().noquote() << QString("Failed to clear ROI for '%1': %2").arg(cameraId, e.getMsg().c_str()); - return false; - } - } - - bool getROIFor(const QString& cameraId, int& x, int& y, int& width, int& height) override - { - if (!owner.isSingleCamera(cameraId) || !owner.m_nativeCore) - { - return false; - } - try - { - owner.m_nativeCore->getROI(cameraId.toStdString().c_str(), x, y, width, height); - return true; - } - catch (const CMMError& e) - { - qWarning().noquote() << QString("Failed to get ROI for '%1': %2").arg(cameraId, e.getMsg().c_str()); - return false; - } - } - }; - - struct MultiProcessCameraManager::AgentCameraBackend final : DeviceControlBackend - { - public: - explicit AgentCameraBackend(MultiProcessCameraManager& owner) - : DeviceControlBackend(owner) - { - } - - bool isNative() const override { return false; } - - void shutdown() override - { - const bool hadRunningCamera = owner.hasRunningCamera(); - owner.m_pollTimer.stop(); - for (auto& ptr : owner.m_cameras) - { - if (ptr->control) - { - QJsonObject request; - request.insert(agent::kMessageTypeField, agent::kCommandShutdown); - owner.sendControlCommand(ptr->cameraId, request, nullptr, 800); - ptr->control->stop(); - } - if (ptr->process) - { - ptr->process->terminate(); - if (!ptr->process->waitForFinished(1500)) - { - ptr->process->kill(); - ptr->process->waitForFinished(1000); - } - } - if (ptr->shm && ptr->shm->isAttached()) - { - ptr->shm->detach(); - } - } - owner.m_cameras.clear(); - if (hadRunningCamera) - { - emit owner.previewStateChanged(false); - } - } - - bool startPreview() override - { - if (owner.m_cameras.isEmpty()) - { - return false; - } - - for (auto it = owner.m_cameras.begin(); it != owner.m_cameras.end(); ++it) - { - const QString cameraId = it.key(); - if (!startPreviewFor(cameraId)) - { - return false; - } - } - qInfo().noquote() << "Multi-process preview started"; - return true; - } - - bool stopPreview() override - { - bool ok = true; - for (auto it = owner.m_cameras.begin(); it != owner.m_cameras.end(); ++it) - { - ok = stopPreviewFor(it.key()) && ok; - } - if (ok) - { - qInfo().noquote() << "Multi-process preview stopped"; - } - return ok; - } - - bool startPreviewFor(const QString& cameraId) override - { - if (!owner.m_cameras.contains(cameraId)) - { - qWarning().noquote() << QString("Camera '%1' not found").arg(cameraId); - return false; - } - - MultiProcessCameraManager::CameraSlot& slot = *owner.m_cameras[cameraId]; - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandStartPreview); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 1200)) - { - qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); - return false; - } - if (!resp.value(QStringLiteral("ok")).toBool(false)) - { - qWarning().noquote() << QString("Agent refused to start preview for '%1'").arg(cameraId); - return false; - } - - int tries = 0; - while (!owner.ensureSharedMemory(slot) && tries++ < 20) - { - QThread::msleep(50); - } - if (!slot.shm || !slot.shm->isAttached()) - { - qWarning().noquote() << QString("Shared memory unavailable for '%1'").arg(cameraId); - return false; - } - - slot.isRunning = true; - onPreviewStarted(cameraId); - return true; - } - - bool stopPreviewFor(const QString& cameraId) override - { - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandStopPreview); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 1200)) - { - return false; - } - if (!resp.value(QStringLiteral("ok")).toBool(false)) - { - return false; - } - - if (owner.m_cameras.contains(cameraId)) - { - owner.m_cameras[cameraId]->isRunning = false; - } - onPreviewStopped(); - return true; - } - - protected: - bool resolvePrimaryCameraId(const QString& cameraIdOrAll, QString& cameraId) const override - { - if (owner.m_cameras.isEmpty()) - { - return false; - } - - const QString target = - cameraIdOrAll.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0 - ? owner.m_cameras.firstKey() - : cameraIdOrAll; - if (!owner.m_cameras.contains(target)) - { - return false; - } - cameraId = target; - return true; - } - - QStringList resolveTargetCameraIds(const QString& cameraIdOrAll) const override - { - if (cameraIdOrAll.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) - { - return owner.m_cameras.keys(); - } - return owner.m_cameras.contains(cameraIdOrAll) ? QStringList{cameraIdOrAll} : QStringList{}; - } - - bool readExposureFor(const QString& cameraId, double& exposureMs) const override - { - const auto it = owner.m_cameras.constFind(cameraId); - if (it == owner.m_cameras.constEnd() || !it.value()) - { - return false; - } - exposureMs = it.value()->exposureMs; - return exposureMs > 0.0; - } - - bool writeExposureFor(const QString& cameraId, double exposureMs) override - { - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandSetExposure); - req.insert(QStringLiteral("value"), exposureMs); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 1200) - || !resp.value(QStringLiteral("ok")).toBool(false)) - { - return false; - } - const double actualExposureMs = resp.value(QStringLiteral("exposureMs")).toDouble(exposureMs); - owner.m_cameras[cameraId]->exposureMs = actualExposureMs; - return true; - } - - QStringList listPropertiesFor(const QString& cameraId) override - { - QStringList out; - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandListProperties); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 4000)) - { - return out; - } - if (!resp.value(QStringLiteral("ok")).toBool(false)) - { - return out; - } - const QJsonArray properties = resp.value(QStringLiteral("properties")).toArray(); - for (const auto& value : properties) - { - out << value.toString(); - } - return out; - } - - bool readPropertyDetailsFor(const QString& cameraId, const QString& name, PropertyReadback& readback) override - { - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandGetProperty); - req.insert(QStringLiteral("name"), name); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 4000)) - { - return false; - } - if (!resp.value(QStringLiteral("ok")).toBool(false)) - { - return false; - } - - readback.value = resp.value(QStringLiteral("value")).toString(); - readback.type = resp.value(QStringLiteral("propertyType")).toString(QStringLiteral("Unknown")); - readback.readOnly = resp.value(QStringLiteral("readOnly")).toBool(true); - readback.hasLimits = resp.value(QStringLiteral("hasLimits")).toBool(false); - readback.lowerLimit = resp.value(QStringLiteral("lowerLimit")).toDouble(0.0); - readback.upperLimit = resp.value(QStringLiteral("upperLimit")).toDouble(0.0); - - const QJsonArray allowedValues = resp.value(QStringLiteral("allowedValues")).toArray(); - for (const auto& value : allowedValues) - { - readback.allowedValues << value.toString(); - } - return true; - } - - bool setPropertyFor(const QString& cameraId, - const QString& name, - const QString& value, - QString* errorMessage) override - { - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandSetProperty); - req.insert(QStringLiteral("name"), name); - req.insert(QStringLiteral("value"), value); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 4000)) - { - if (errorMessage) - { - *errorMessage = QStringLiteral("Agent control request failed"); - } - return false; - } - if (!resp.value(QStringLiteral("ok")).toBool(false)) - { - if (errorMessage) - { - *errorMessage = resp.value(QStringLiteral("error")).toString(); - } - return false; - } - return true; - } - - bool setROIFor(const QString& cameraId, int x, int y, int width, int height) override - { - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandSetRoi); - req.insert(QStringLiteral("x"), x); - req.insert(QStringLiteral("y"), y); - req.insert(QStringLiteral("width"), width); - req.insert(QStringLiteral("height"), height); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 1200)) - { - qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); - return false; - } - const bool ok = resp.value(QStringLiteral("ok")).toBool(false); - if (!ok) - { - qWarning().noquote() << QString("Failed to set ROI for '%1': %2") - .arg(cameraId, resp.value(QStringLiteral("error")).toString(QStringLiteral("Unknown error"))); - } - return ok; - } - - bool clearROIFor(const QString& cameraId) override - { - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandClearRoi); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 1200)) - { - qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); - return false; - } - const bool ok = resp.value(QStringLiteral("ok")).toBool(false); - if (!ok) - { - qWarning().noquote() << QString("Failed to clear ROI for '%1': %2") - .arg(cameraId, resp.value(QStringLiteral("error")).toString(QStringLiteral("Unknown error"))); - } - return ok; - } - - bool getROIFor(const QString& cameraId, int& x, int& y, int& width, int& height) override - { - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandGetRoi); - QJsonObject resp; - if (!owner.sendControlCommand(cameraId, req, &resp, 1200)) - { - qWarning().noquote() << QString("Failed to connect to camera '%1'").arg(cameraId); - return false; - } - const bool ok = resp.value(QStringLiteral("ok")).toBool(false); - if (!ok) - { - qWarning().noquote() << QString("Failed to get ROI for '%1': %2") - .arg(cameraId, resp.value(QStringLiteral("error")).toString(QStringLiteral("Unknown error"))); - return false; - } - - x = resp.value(QStringLiteral("x")).toInt(0); - y = resp.value(QStringLiteral("y")).toInt(0); - width = resp.value(QStringLiteral("width")).toInt(0); - height = resp.value(QStringLiteral("height")).toInt(0); - return true; - } - }; - - // Creates the camera manager and shared memory polling timer - MultiProcessCameraManager::MultiProcessCameraManager(QObject* parent) - : QObject(parent) - { - m_pollTimer.setInterval(1); - connect(&m_pollTimer, &QTimer::timeout, this, &MultiProcessCameraManager::pollSharedMemory); - } - - // Sets the native MMCore instance for single camera mode - void MultiProcessCameraManager::setNativeCore(const std::shared_ptr& core) - { - m_nativeCore = core; - } - - // Prepares native single camera preview without launching an agent - bool MultiProcessCameraManager::startSingleCamera(const QString& cameraId, double exposureMs) - { - const QString normalizedId = normalizedCameraId(cameraId); - if (!m_nativeCore) - { - return false; - } - if (normalizedId.isEmpty()) - { - return false; - } - if (m_runtime && !m_runtime->isNative()) - { - stopAgents(); - } - else if (m_runtime) - { - m_runtime->shutdown(); - } - if (!m_runtime) - { - m_runtime = std::make_unique(*this); - } - - m_singleCameraId = normalizedId; - clearPropertyCaches(); - m_cameras.clear(); - auto slot = std::make_shared(); - slot->cameraId = normalizedId; - slot->exposureMs = exposureMs; - slot->isRunning = false; - m_cameras.insert(normalizedId, slot); - - return true; - } - - // 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.state > 2) return false; - 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; - } - - // Updates native polling interval from active exposure times - void MultiProcessCameraManager::updatePollingInterval() - { - if (m_runtime && !m_runtime->isNative()) - { - return; - } - - double minExposureMs = 1000.0; - bool hasRunningCamera = false; - - for (auto it = m_cameras.begin(); it != m_cameras.end(); ++it) - { - const CameraSlot& slot = *it.value(); - if (slot.isRunning && slot.exposureMs > 0) - { - minExposureMs = (std::min)(minExposureMs, slot.exposureMs); - hasRunningCamera = true; - } - } - - if (!hasRunningCamera) - { - return; - } - - const double targetIntervalMs = minExposureMs / 2.0; - int newInterval = static_cast((std::max)(1.0, (std::min)(500.0, targetIntervalMs))); - - if (m_pollTimer.interval() != newInterval) - { - m_pollTimer.setInterval(newInterval); - } - } - - // Stops all camera backends during teardown - MultiProcessCameraManager::~MultiProcessCameraManager() - { - stopAgents(); - } - - // Checks whether any configured camera is currently running - bool MultiProcessCameraManager::hasRunningCamera() const - { - for (auto it = m_cameras.begin(); it != m_cameras.end(); ++it) - { - if (it.value() && it.value()->isRunning) - { - return true; - } - } - return false; - } - - // Clears cached property metadata across camera backends - void MultiProcessCameraManager::clearPropertyCaches() - { - m_propertyTypeCache.clear(); - m_propertyReadOnlyCache.clear(); - m_propertyAllowedValuesCache.clear(); - m_propertyHasLimitsCache.clear(); - m_propertyLowerLimitCache.clear(); - m_propertyUpperLimitCache.clear(); - } - - // Waits for one agent control channel to become ready - bool MultiProcessCameraManager::waitForControlReady(CameraSlot& slot, int timeoutMs) - { - return slot.control && slot.control->waitForReady(timeoutMs); - } - - // Stops all agent or native camera backends - void MultiProcessCameraManager::stopAgents() - { - if (m_runtime) - { - m_runtime->shutdown(); - } - m_pollTimer.stop(); - m_runtime.reset(); - m_singleCameraId.clear(); - clearPropertyCaches(); - } - - // Ensures the shared memory block for one camera is attached - bool MultiProcessCameraManager::ensureSharedMemory(CameraSlot& slot) - { - if (!slot.shm) - { - return false; - } - const int expectedSize = kSharedMemoryControlSize + kSharedFrameNumSlots * kSharedFrameSlotStride; - if (slot.shm->isAttached()) - { - if (slot.shm->size() < expectedSize) - { - qWarning().noquote() << QString("SHM size mismatch for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); - slot.shm->detach(); - return false; - } - return true; - } - if (!slot.shm->attach(QSharedMemory::ReadOnly)) - { - qWarning().noquote() << QString("SHM attach failed for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); - return false; - } - if (slot.shm->size() < expectedSize) - { - qWarning().noquote() << QString("SHM layout mismatch for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); - slot.shm->detach(); - return false; - } - qInfo().noquote() << QString("SHM attached for %1 (key=%2)").arg(slot.cameraId, slot.shmKey); - return true; - } - - // Checks whether a camera is handled by the native backend - bool MultiProcessCameraManager::isSingleCamera(const QString& cameraId) const - { - return m_runtime && m_runtime->isNative() && !m_singleCameraId.isEmpty() && cameraId == m_singleCameraId; - } - - // Pulls queued frames from the native MMCore circular buffer - void MultiProcessCameraManager::pollSingleCamera(CameraSlot& slot) - { - if (!m_nativeCore || !slot.isRunning) - { - return; - } - - long remaining = 0; - try - { - remaining = m_nativeCore->getRemainingImageCount(); - } - catch (const CMMError&) - { - return; - } - if (remaining <= 0) - { - return; - } - - const unsigned w = m_nativeCore->getImageWidth(); - const unsigned h = m_nativeCore->getImageHeight(); - const unsigned bpp = m_nativeCore->getBytesPerPixel(); - if (bpp != 1 && bpp != 2) - { - return; - } - const qint64 stride = static_cast(w) * bpp; - const qint64 rawSize = stride * h; - if (w > static_cast((std::numeric_limits::max)()) - || h > static_cast((std::numeric_limits::max)()) - || stride > (std::numeric_limits::max)() - || rawSize <= 0 - || rawSize > (std::numeric_limits::max)()) - { - return; - } - - int sourceRoiX = 0; - int sourceRoiY = 0; - int sourceRoiWidth = static_cast(w); - int sourceRoiHeight = static_cast(h); - try - { - m_nativeCore->getROI(slot.cameraId.toStdString().c_str(), - sourceRoiX, - sourceRoiY, - sourceRoiWidth, - sourceRoiHeight); - } - catch (const CMMError&) - { - sourceRoiX = 0; - sourceRoiY = 0; - sourceRoiWidth = static_cast(w); - sourceRoiHeight = static_cast(h); - } - - quint16 bitDepth = (bpp == 2) ? 16 : 8; - try - { - const int bd = m_nativeCore->getImageBitDepth(); - const auto pixelFormat = bpp == 2 ? scopeone::core::ImagePixelFormat::Mono16 - : scopeone::core::ImagePixelFormat::Mono8; - bitDepth = static_cast(ImageFrame::normalizedBitsPerSample(pixelFormat, bd)); - } - catch (const CMMError&) - { - } - - ImageFrame lastFrame; - - while (remaining-- > 0) - { - const void* img = m_nativeCore->popNextImage(); - if (!img) - { - break; - } - - QByteArray payload; - payload.resize(static_cast(rawSize)); - memcpy(payload.data(), img, static_cast(rawSize)); - - ImageFrame frame; - frame.cameraId = slot.cameraId; - frame.width = static_cast(w); - frame.height = static_cast(h); - frame.stride = static_cast(stride); - frame.pixelFormat = bpp == 2 ? scopeone::core::ImagePixelFormat::Mono16 - : scopeone::core::ImagePixelFormat::Mono8; - frame.bitsPerSample = ImageFrame::normalizedBitsPerSample(frame.pixelFormat, bitDepth); - frame.frameIndex = ++slot.lastFrameIndex; - frame.timestampNs = static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; - frame.sourceRoiX = sourceRoiX; - frame.sourceRoiY = sourceRoiY; - frame.sourceRoiWidth = sourceRoiWidth; - frame.sourceRoiHeight = sourceRoiHeight; - frame.bytes = std::move(payload); - if (frame.isValid()) - { - emit newRawFrameReady(frame); - lastFrame = std::move(frame); - } - } - - if (lastFrame.isValid()) - { - slot.latestFrame = std::move(lastFrame); - } - } - - // Sends one JSON command to an agent control channel - bool MultiProcessCameraManager::sendControlCommand(const QString& cameraId, - const QJsonObject& request, - QJsonObject* response, - int timeoutMs) - { - const QString normalizedId = normalizedCameraId(cameraId); - const auto it = m_cameras.constFind(normalizedId); - if (it == m_cameras.constEnd() || !it.value() || !it.value()->control) - { - return false; - } - - bool completed = false; - bool ok = false; - QJsonObject capturedResponse; - QEventLoop loop; - QTimer watchdog; - watchdog.setSingleShot(true); - watchdog.setInterval((std::max)(1, timeoutMs + 250)); - connect(&watchdog, &QTimer::timeout, &loop, [&]() - { - if (!completed) - { - completed = true; - ok = false; - } - loop.quit(); - }); - - if (!it.value()->control->sendRequest( - request, - (std::max)(1, timeoutMs), - [&](bool requestOk, const QJsonObject& requestResponse, const QString&) - { - if (completed) - { - return; - } - completed = true; - ok = requestOk; - capturedResponse = requestResponse; - loop.quit(); - })) - { - return false; - } - - watchdog.start(); - if (!completed) - { - loop.exec(); - } - - if (!ok) - { - return false; - } - if (response) - { - *response = capturedResponse; - } - return true; - } - - // Reads the latest ready frame from one shared memory block - bool MultiProcessCameraManager::readLatestFrame(CameraSlot& slot) - { - if (!slot.shm || !slot.shm->isAttached()) - { - return false; - } - if (!slot.shm->lock()) - { - return false; - } - - uchar* base = static_cast(slot.shm->data()); - if (!base) - { - slot.shm->unlock(); - return false; - } - - const int slotStride = kSharedFrameSlotStride; - const int baseOffset = kSharedMemoryControlSize; - const SharedMemoryControl* control = reinterpret_cast(base); - - auto copySlotToImage = [&](const SharedFrameHeader& capturedHeader, const uchar* pixelData) -> bool - { - const quint64 rawSize = sharedFramePayloadSize(capturedHeader); - - QByteArray payload; - payload.resize(static_cast(rawSize)); - memcpy(payload.data(), pixelData, static_cast(rawSize)); - slot.latestFrame = ImageFrame::fromSharedFrame(slot.cameraId, capturedHeader, payload); - slot.lastFrameIndex = capturedHeader.frameIndex; - return slot.latestFrame.isValid(); - }; - - bool ok = false; - SharedFrameHeader capturedHeader{}; - - auto trySlotIndex = [&](quint32 idx) -> bool - { - if (idx >= kSharedFrameNumSlots) return false; - const uchar* ptr = base + baseOffset + idx * slotStride; - SharedFrameHeader header{}; - memcpy(&header, ptr, sizeof(SharedFrameHeader)); - if (!headerLooksSane(header)) return false; - if (header.state != 2) return false; - if (header.frameIndex <= slot.lastFrameIndex) return false; - capturedHeader = header; - return copySlotToImage(capturedHeader, ptr + kSharedFrameHeaderSize); - }; - - const quint32 latestIdx = control->latestSlotIndex; - ok = trySlotIndex(latestIdx); - - if (!ok) - { - quint64 bestIndex = slot.lastFrameIndex; - int bestOffset = -1; - SharedFrameHeader header{}; - for (int i = 0; i < kSharedFrameNumSlots; ++i) - { - const uchar* ptr = base + baseOffset + i * slotStride; - memcpy(&header, ptr, sizeof(SharedFrameHeader)); - if (!headerLooksSane(header)) continue; - if (header.state == 2 && header.frameIndex > bestIndex) - { - bestIndex = header.frameIndex; - bestOffset = i; - } - } - - if (bestOffset >= 0) - { - const uchar* ptr = base + baseOffset + bestOffset * slotStride; - memcpy(&capturedHeader, ptr, sizeof(SharedFrameHeader)); - if (headerLooksSane(capturedHeader) && capturedHeader.state == 2 && capturedHeader.frameIndex > slot. - lastFrameIndex) - { - ok = copySlotToImage(capturedHeader, ptr + kSharedFrameHeaderSize); - } - } - } - - slot.shm->unlock(); - - return ok; - } - - // Consumes all new ready frames from one agent shared memory block - bool MultiProcessCameraManager::consumeAgentFrames(CameraSlot& slot, bool emitFrames) - { - if (!slot.isRunning) - { - return false; - } - if (!ensureSharedMemory(slot)) - { - return false; - } - if (!slot.shm || !slot.shm->lock()) - { - return false; - } - - uchar* base = static_cast(slot.shm->data()); - if (!base) - { - slot.shm->unlock(); - return false; - } - - struct PendingSlot - { - SharedFrameHeader header{}; - int index{-1}; - }; - - const int slotStride = kSharedFrameSlotStride; - const int baseOffset = kSharedMemoryControlSize; - const quint64 lastIndex = slot.lastFrameIndex; - - std::vector pending; - pending.reserve(kSharedFrameNumSlots); - - for (int i = 0; i < kSharedFrameNumSlots; ++i) - { - const uchar* ptr = base + baseOffset + i * slotStride; - SharedFrameHeader header{}; - memcpy(&header, ptr, sizeof(SharedFrameHeader)); - if (!headerLooksSane(header)) continue; - if (header.state != 2) continue; - if (header.frameIndex <= lastIndex) continue; - pending.push_back(PendingSlot{header, i}); - } - - if (pending.empty()) - { - slot.shm->unlock(); - return false; - } - - std::sort(pending.begin(), pending.end(), [](const PendingSlot& a, const PendingSlot& b) - { - return a.header.frameIndex < b.header.frameIndex; - }); - - std::vector frames; - frames.reserve(pending.size()); - quint64 maxIndex = lastIndex; - - for (const PendingSlot& item : pending) - { - const SharedFrameHeader& header = item.header; - const uchar* ptr = base + baseOffset + item.index * slotStride; - const uchar* pixelData = ptr + kSharedFrameHeaderSize; - const quint64 rawSize = sharedFramePayloadSize(header); - QByteArray payload; - payload.resize(static_cast(rawSize)); - memcpy(payload.data(), pixelData, static_cast(rawSize)); - ImageFrame frame = ImageFrame::fromSharedFrame(slot.cameraId, header, payload); - if (frame.isValid()) - { - frames.push_back(frame); - } - if (header.frameIndex > maxIndex) - { - maxIndex = header.frameIndex; - } - } - - slot.lastFrameIndex = maxIndex; - slot.shm->unlock(); - - if (frames.empty()) - { - return false; - } - - if (emitFrames) - { - for (const ImageFrame& frame : frames) - { - if (!frame.isValid()) - { - continue; - } - emit newRawFrameReady(frame); - } - } - - slot.latestFrame = frames.back(); - return true; - } - - // Triggers one camera and waits for its event frame - bool MultiProcessCameraManager::captureEventFrame(const QString& cameraId, - ImageFrame& frame, - int timeoutMs) - { - const QString normalizedId = normalizedCameraId(cameraId); - if (!m_cameras.contains(normalizedId)) - { - return false; - } - auto slotPtr = m_cameras.value(normalizedId); - if (!slotPtr) - { - return false; - } - - const quint64 previousFrameIndex = slotPtr->latestFrame.isValid() - ? slotPtr->latestFrame.frameIndex - : slotPtr->lastFrameIndex; - - QJsonObject req; - req.insert(agent::kMessageTypeField, agent::kCommandCaptureEvent); - QJsonObject resp; - const int waitMs = (timeoutMs > 0) ? timeoutMs : 1500; - if (!sendControlCommand(normalizedId, req, &resp, waitMs + 1000)) - { - return false; - } - if (!resp.value("ok").toBool(false)) - { - return false; - } - - const quint64 targetFrameIndex = - agent::decodeUInt64(resp.value(QStringLiteral("frameIndex"))); - if (!ensureSharedMemory(*slotPtr)) - { - return false; - } - - const auto hasTargetFrame = [&]() -> bool - { - return slotPtr->latestFrame.isValid() - && slotPtr->latestFrame.frameIndex > previousFrameIndex - && (targetFrameIndex == 0 || slotPtr->latestFrame.frameIndex >= targetFrameIndex); - }; - - if (hasTargetFrame()) - { - frame = slotPtr->latestFrame; - return true; - } - - QElapsedTimer timer; - timer.start(); - - while (timer.elapsed() <= waitMs) - { - if (hasTargetFrame()) - { - frame = slotPtr->latestFrame; - return true; - } - if (readLatestFrame(*slotPtr) && hasTargetFrame()) - { - frame = slotPtr->latestFrame; - return true; - } - QCoreApplication::processEvents(QEventLoop::AllEvents, 5); - QThread::msleep(1); - } - - return false; - } - - // Pauses or resumes polling while another workflow controls capture - void MultiProcessCameraManager::setPollingPaused(bool paused) - { - if (m_pollingPaused == paused) - { - return; - } - m_pollingPaused = paused; - if (m_runtime && m_runtime->isNative()) - { - if (m_pollingPaused) - { - if (m_pollTimer.isActive()) - { - m_pollTimer.stop(); - } - return; - } - if (hasRunningCamera() && !m_pollTimer.isActive()) - { - m_pollTimer.start(); - updatePollingInterval(); - } - return; - } - - if (!m_pollingPaused) - { - for (auto it = m_cameras.begin(); it != m_cameras.end(); ++it) - { - if (it.value() && it.value()->isRunning) - { - consumeAgentFrames(*it.value(), true); - } - } - } - } - - // Polls the native single camera backend for new frames - void MultiProcessCameraManager::pollSharedMemory() - { - if (!m_runtime || !m_runtime->isNative() || m_pollingPaused) - { - return; - } - - if (m_cameras.contains(m_singleCameraId)) - { - pollSingleCamera(*m_cameras[m_singleCameraId]); - } - } - - // Starts one camera agent process and connects its control channel - bool MultiProcessCameraManager::startAgentFor(const QString& cameraId, - const QString& adapter, - const QString& device, - const QStringList& preInitProperties, - const QStringList& properties, - double exposureMs) - { - const QString normalizedId = normalizedCameraId(cameraId); - const QString adapterName = adapter.trimmed(); - const QString deviceName = device.trimmed(); - if (normalizedId.isEmpty() || adapterName.isEmpty() || deviceName.isEmpty()) - { - return false; - } - if (m_runtime && m_runtime->isNative()) - { - stopAgents(); - } - if (!m_runtime) - { - m_runtime = std::make_unique(*this); - m_singleCameraId.clear(); - clearPropertyCaches(); - } - if (m_cameras.contains(normalizedId)) - { - return true; - } - auto slot = std::make_shared(); - slot->cameraId = normalizedId; - slot->shmKey = agent::sharedMemoryKey(normalizedId); - slot->shm = std::make_shared(); - slot->shm->setNativeKey(slot->shmKey); - slot->process = std::make_shared(this); - slot->control = std::make_shared(normalizedId, - agent::controlServerName(normalizedId), - this); - slot->exposureMs = exposureMs; - const QString agentPath = - QDir(QCoreApplication::applicationDirPath()).filePath(agent::kExecutableFileName); - if (!QFileInfo::exists(agentPath)) - { - qWarning().noquote() << QString("Agent executable not found: %1").arg(agentPath); - return false; - } - QStringList args; - args << "--cameraId" << normalizedId - << "--adapter" << adapterName - << "--device" << deviceName - << "--shm" << slot->shmKey; - if (exposureMs > 0.0) - { - args << "--exposure" << QString::number(exposureMs, 'f', 4); - } - for (const QString& encodedProperty : preInitProperties) - { - if (!encodedProperty.isEmpty()) - { - args << "--preinit" << encodedProperty; - } - } - for (const QString& encodedProperty : properties) - { - if (!encodedProperty.isEmpty()) - { - args << "--property" << encodedProperty; - } - } - slot->process->setProgram(agentPath); - slot->process->setArguments(args); - slot->process->setProcessChannelMode(QProcess::MergedChannels); - - connect(slot->process.get(), &QProcess::readyReadStandardOutput, this, [normalizedId, slot]() - { - QByteArray output = slot->process->readAllStandardOutput(); - if (!output.isEmpty()) - { - QStringList lines = QString::fromUtf8(output).split('\n', Qt::SkipEmptyParts); - for (const QString& line : lines) - { - const QString trimmed = line.trimmed(); - qInfo().noquote() << QString("[Agent %1] %2").arg(normalizedId, trimmed); - } - } - }); - connect(slot->process.get(), &QProcess::finished, this, - [this, slot](int, QProcess::ExitStatus) - { - slot->isRunning = false; - if (!hasRunningCamera()) - { - emit previewStateChanged(false); - } - }); - - if (slot->control) - { - slot->control->addReadyHandler([this, normalizedId](bool ready) - { - if (ready) - { - emit agentControlServerListening(normalizedId, agent::controlServerName(normalizedId)); - } - }); - slot->control->addEventHandler([this, slot](const QJsonObject& event) - { - const QString type = event.value(agent::kMessageTypeField).toString(); - if (type == agent::kEventFrameAvailable) - { - consumeAgentFrames(*slot, !m_pollingPaused); - return; - } - if (type == agent::kEventPreviewState) - { - const bool running = event.value(QStringLiteral("running")).toBool(slot->isRunning); - slot->isRunning = running; - return; - } - if (type == agent::kEventAgentError) - { - qWarning().noquote() - << QString("Agent '%1' error: %2") - .arg(slot->cameraId, - event.value(QStringLiteral("error")).toString()); - return; - } - if (type == agent::kEventBufferOverflow) - { - qWarning().noquote() - << QString("Agent '%1' reported MMCore buffer overflow (capacity=%2 frames)") - .arg(slot->cameraId) - .arg(event.value(QStringLiteral("capacityFrames")).toInt(0)); - } - }); - } - - slot->process->start(); - if (!slot->process->waitForStarted(3000)) - { - qWarning().noquote() << QString("Failed to start agent for %1").arg(normalizedId); - return false; - } - m_cameras.insert(normalizedId, slot); - if (slot->control) - { - slot->control->start(); - } - if (!waitForControlReady(*slot, kAgentControlReadyTimeoutMs)) - { - qWarning().noquote() - << QString("Agent control session did not become ready for %1 within %2 ms") - .arg(normalizedId) - .arg(kAgentControlReadyTimeoutMs); - stopAgentFor(normalizedId); - return false; - } - return true; - } - - // Stops one camera agent process and releases its resources - bool MultiProcessCameraManager::stopAgentFor(const QString& cameraId) - { - const QString normalizedId = normalizedCameraId(cameraId); - if (m_runtime && m_runtime->isNative()) - { - return false; - } - if (!m_cameras.contains(normalizedId)) return true; - if (auto existing = m_cameras.value(normalizedId); existing && existing->control) - { - QJsonObject request; - request.insert(agent::kMessageTypeField, agent::kCommandShutdown); - sendControlCommand(normalizedId, request, nullptr, 800); - } - auto slot = m_cameras.take(normalizedId); - if (slot->control) - { - slot->control->stop(); - } - if (slot->process) - { - slot->process->terminate(); - if (!slot->process->waitForFinished(1500)) - { - slot->process->kill(); - slot->process->waitForFinished(1000); - } - } - if (slot->shm && slot->shm->isAttached()) slot->shm->detach(); - if (m_cameras.isEmpty()) - { - if (m_pollTimer.isActive()) - { - m_pollTimer.stop(); - } - emit previewStateChanged(false); - m_singleCameraId.clear(); - clearPropertyCaches(); - m_runtime.reset(); - } - return true; - } - - // Starts preview on the active camera backend - bool MultiProcessCameraManager::startPreview() - { - return m_runtime && m_runtime->startPreview(); - } - - // Stops preview on the active camera backend - bool MultiProcessCameraManager::stopPreview() - { - return m_runtime && m_runtime->stopPreview(); - } - - // Sets exposure through the active camera backend - bool MultiProcessCameraManager::setExposure(const QString& cameraIdOrAll, double exposureMs) - { - const QString target = cameraIdOrAll.trimmed(); - return !target.isEmpty() && m_runtime && m_runtime->setExposure(target, exposureMs); - } - - // Reads exposure through the active camera backend - bool MultiProcessCameraManager::getExposure(const QString& cameraIdOrAll, double& exposureMs) const - { - exposureMs = 0.0; - const QString target = cameraIdOrAll.trimmed(); - return !target.isEmpty() && m_runtime && m_runtime->getExposure(target, exposureMs); - } - - // Lists properties for one camera - QStringList MultiProcessCameraManager::listProperties(const QString& cameraId) - { - const QString normalizedId = normalizedCameraId(cameraId); - if (normalizedId.isEmpty() || !m_runtime) - { - return {}; - } - return m_runtime->listProperties(normalizedId); - } - - // Reads a property and updates property metadata caches - QString MultiProcessCameraManager::getProperty(const QString& cameraId, const QString& name) - { - const QString normalizedId = normalizedCameraId(cameraId); - QString propKey = QString("%1:%2").arg(normalizedId, name); - PropertyReadback readback; - if (normalizedId.isEmpty() || !m_runtime || !m_runtime->readPropertyDetails(normalizedId, name, readback)) - { - m_propertyTypeCache.remove(propKey); - m_propertyReadOnlyCache.remove(propKey); - m_propertyAllowedValuesCache.remove(propKey); - m_propertyHasLimitsCache.remove(propKey); - m_propertyLowerLimitCache.remove(propKey); - m_propertyUpperLimitCache.remove(propKey); - return {}; - } - - m_propertyTypeCache[propKey] = readback.type; - m_propertyReadOnlyCache[propKey] = readback.readOnly; - m_propertyAllowedValuesCache[propKey] = readback.allowedValues; - m_propertyHasLimitsCache[propKey] = readback.hasLimits; - if (readback.hasLimits) - { - m_propertyLowerLimitCache[propKey] = readback.lowerLimit; - m_propertyUpperLimitCache[propKey] = readback.upperLimit; - } - else - { - m_propertyLowerLimitCache.remove(propKey); - m_propertyUpperLimitCache.remove(propKey); - } - return readback.value; - } - - // Reads cached property type after refreshing if needed - QString MultiProcessCameraManager::getPropertyType(const QString& cameraId, const QString& name) - { - const QString normalizedId = normalizedCameraId(cameraId); - QString propKey = QString("%1:%2").arg(normalizedId, name); - if (m_propertyTypeCache.contains(propKey)) - { - return m_propertyTypeCache[propKey]; - } - getProperty(normalizedId, name); - return m_propertyTypeCache.value(propKey, QStringLiteral("Unknown")); - } - - // Reads cached property mutability after refreshing if needed - bool MultiProcessCameraManager::isPropertyReadOnly(const QString& cameraId, const QString& name) - { - const QString normalizedId = normalizedCameraId(cameraId); - QString propKey = QString("%1:%2").arg(normalizedId, name); - if (m_propertyReadOnlyCache.contains(propKey)) - { - return m_propertyReadOnlyCache[propKey]; - } - getProperty(normalizedId, name); - return m_propertyReadOnlyCache.value(propKey, true); - } - - // Writes a camera property and invalidates cached metadata - bool MultiProcessCameraManager::setProperty(const QString& cameraId, - const QString& name, - const QString& value, - QString* errorMessage) - { - const QString normalizedId = normalizedCameraId(cameraId); - if (normalizedId.isEmpty() || !m_runtime) - { - return false; - } - const bool ok = m_runtime->setProperty(normalizedId, name, value, errorMessage); - if (ok) - { - const QString propKey = QString("%1:%2").arg(normalizedId, name); - m_propertyTypeCache.remove(propKey); - m_propertyReadOnlyCache.remove(propKey); - m_propertyAllowedValuesCache.remove(propKey); - m_propertyHasLimitsCache.remove(propKey); - m_propertyLowerLimitCache.remove(propKey); - m_propertyUpperLimitCache.remove(propKey); - } - return ok; - } - - // Reports whether one camera preview is running - bool MultiProcessCameraManager::isPreviewRunning(const QString& cameraId) const - { - const QString normalizedId = normalizedCameraId(cameraId); - const auto it = m_cameras.constFind(normalizedId); - return it != m_cameras.constEnd() && it.value() && it.value()->isRunning; - } - - // Starts preview for one camera - bool MultiProcessCameraManager::startPreviewFor(const QString& cameraId) - { - const QString normalizedId = normalizedCameraId(cameraId); - return !normalizedId.isEmpty() && m_runtime && m_runtime->startPreviewFor(normalizedId); - } - - // Stops preview for one camera - bool MultiProcessCameraManager::stopPreviewFor(const QString& cameraId) - { - const QString normalizedId = normalizedCameraId(cameraId); - return !normalizedId.isEmpty() && m_runtime && m_runtime->stopPreviewFor(normalizedId); - } - - // Reads cached allowed property values after refreshing if needed - QStringList MultiProcessCameraManager::getAllowedPropertyValues(const QString& cameraId, const QString& name) - { - const QString normalizedId = normalizedCameraId(cameraId); - QString propKey = QString("%1:%2").arg(normalizedId, name); - if (m_propertyAllowedValuesCache.contains(propKey)) - { - return m_propertyAllowedValuesCache[propKey]; - } - getProperty(normalizedId, name); - return m_propertyAllowedValuesCache.value(propKey, QStringList()); - } - - // Reads cached property limit availability after refreshing if needed - bool MultiProcessCameraManager::hasPropertyLimits(const QString& cameraId, const QString& name) - { - const QString normalizedId = normalizedCameraId(cameraId); - QString propKey = QString("%1:%2").arg(normalizedId, name); - if (m_propertyHasLimitsCache.contains(propKey)) - { - return m_propertyHasLimitsCache[propKey]; - } - getProperty(normalizedId, name); - return m_propertyHasLimitsCache.value(propKey, false); - } - - // Reads cached lower property limit after refreshing if needed - double MultiProcessCameraManager::getPropertyLowerLimit(const QString& cameraId, const QString& name) - { - const QString normalizedId = normalizedCameraId(cameraId); - QString propKey = QString("%1:%2").arg(normalizedId, name); - if (m_propertyLowerLimitCache.contains(propKey)) - { - return m_propertyLowerLimitCache[propKey]; - } - getProperty(normalizedId, name); - return m_propertyLowerLimitCache.value(propKey, 0.0); - } - - // Reads cached upper property limit after refreshing if needed - double MultiProcessCameraManager::getPropertyUpperLimit(const QString& cameraId, const QString& name) - { - const QString normalizedId = normalizedCameraId(cameraId); - QString propKey = QString("%1:%2").arg(normalizedId, name); - if (m_propertyUpperLimitCache.contains(propKey)) - { - return m_propertyUpperLimitCache[propKey]; - } - getProperty(normalizedId, name); - return m_propertyUpperLimitCache.value(propKey, 0.0); - } - - // Sets ROI through the active camera backend - bool MultiProcessCameraManager::setROI(const QString& cameraId, int x, int y, int width, int height) - { - const QString normalizedId = normalizedCameraId(cameraId); - return !normalizedId.isEmpty() && m_runtime && m_runtime->setROI(normalizedId, x, y, width, height); - } - - // Clears ROI through the active camera backend - bool MultiProcessCameraManager::clearROI(const QString& cameraId) - { - const QString normalizedId = normalizedCameraId(cameraId); - return !normalizedId.isEmpty() && m_runtime && m_runtime->clearROI(normalizedId); - } - - // Reads ROI through the active camera backend - bool MultiProcessCameraManager::getROI(const QString& cameraId, int& x, int& y, int& width, int& height) - { - const QString normalizedId = normalizedCameraId(cameraId); - return !normalizedId.isEmpty() && m_runtime && m_runtime->getROI(normalizedId, x, y, width, height); - } -} // namespace scopeone::core::internal diff --git a/ScopeOneCore/src/NativeCameraBackend.cpp b/ScopeOneCore/src/NativeCameraBackend.cpp new file mode 100644 index 0000000..a1bf284 --- /dev/null +++ b/ScopeOneCore/src/NativeCameraBackend.cpp @@ -0,0 +1,964 @@ +#include "internal/CameraBackend.h" + +#include "MMCore.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace scopeone::core::internal +{ + namespace + { + 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; + } + const double intervalMs = std::clamp(frameIntervalMs / 4.0, + static_cast(kMinimumPollIntervalMs), + static_cast(kMaximumPollIntervalMs)); + return static_cast(intervalMs); + } + + struct NativeStreamConfiguration + { + std::shared_ptr core; + QString cameraId; + int width{0}; + int height{0}; + int stride{0}; + int bitsPerSample{0}; + ImagePixelFormat pixelFormat{ImagePixelFormat::Invalid}; + int sourceRoiX{0}; + int sourceRoiY{0}; + int sourceRoiWidth{0}; + int sourceRoiHeight{0}; + quint64 firstFrameIndex{0}; + quint64 generation{0}; + double expectedIntervalMs{0.0}; + + bool isValid() const + { + const qint64 payloadBytes = static_cast(stride) * height; + const int bytesPerPixel = pixelFormat == ImagePixelFormat::Mono16 + ? 2 + : (pixelFormat == ImagePixelFormat::Mono8 ? 1 : 0); + const qint64 minimumStride = static_cast(width) * bytesPerPixel; + return core + && !cameraId.trimmed().isEmpty() + && width > 0 + && height > 0 + && bytesPerPixel > 0 + && stride >= minimumStride + && (stride % bytesPerPixel) == 0 + && bitsPerSample == ImageFrame::normalizedBitsPerSample(pixelFormat, bitsPerSample) + && payloadBytes > 0 + && payloadBytes <= (std::numeric_limits::max)() + && generation > 0; + } + }; + + class NativeFrameWorker; + + class NativeCameraBackend final : public CameraBackend + { + public: + NativeCameraBackend(); + ~NativeCameraBackend() override; + + Kind kind() const override { return Kind::Native; } + void shutdown(); + bool configureNativeCamera(const std::shared_ptr& core, + const QString& cameraId, + double exposureMs) 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; + void setFrameDeliveryPaused(bool paused) override; + + protected: + bool hasRunningCamera() const override { return m_running; } + bool resolvePrimaryCameraId(const QString& cameraIdOrAll, QString& cameraId) const override; + QStringList resolveTargetCameraIds(const QString& cameraIdOrAll) const override; + bool readExposureFor(const QString& cameraId, double& exposureMs) const override; + bool writeExposureFor(const QString& cameraId, double exposureMs) override; + QStringList listPropertiesFor(const QString& cameraId) override; + bool readPropertyDetailsFor(const QString& cameraId, + const QString& name, + bool fromCache, + CameraPropertyReadback& readback) override; + bool setPropertyFor(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) override; + bool setROIFor(const QString& cameraId, int x, int y, int width, int height) override; + bool clearROIFor(const QString& cameraId) override; + bool getROIFor(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) override; + + private: + friend class NativeFrameWorker; + + bool startNativeStream(); + bool stopNativeStream(); + void setWorkerPaused(bool paused); + void submitWorkerFrames(const QList& frames, quint64 acquiredFrameCount); + bool requiresAllFrames() const; + bool requiresHighRateFrames() const; + void handleStreamFailure(quint64 generation, + const QString& cameraId, + const QString& error); + bool matchesCamera(const QString& cameraId) const; + + std::shared_ptr m_core; + QString m_cameraId; + double m_exposureMs{10.0}; + bool m_running{false}; + bool m_frameDeliveryPaused{false}; + std::atomic m_lastFrameIndex{0}; + quint64 m_streamGeneration{0}; + quint64 m_activeGeneration{0}; + QThread m_streamThread; + NativeFrameWorker* m_worker{nullptr}; + }; + + class NativeFrameWorker final : public QObject + { + public: + explicit NativeFrameWorker(NativeCameraBackend* owner) + : m_owner(owner) + { + } + + bool start(const NativeStreamConfiguration& configuration) + { + stop(); + if (!configuration.isValid()) + { + return false; + } + + m_configuration = configuration; + m_configuration.cameraId = m_configuration.cameraId.trimmed(); + m_frameIndex = configuration.firstFrameIndex; + m_observedIntervalMs = configuration.expectedIntervalMs; + m_pendingAcquiredFrameCount = 0; + m_paused = false; + m_frameIntervalTimer.start(); + m_deliveryTimer.invalidate(); + + if (!m_timer) + { + m_timer = new QTimer(this); + m_timer->setTimerType(Qt::PreciseTimer); + connect(m_timer, &QTimer::timeout, this, [this]() { poll(); }); + } + m_timer->setInterval(pollingIntervalFor(configuration.expectedIntervalMs)); + m_timer->start(); + return true; + } + + void stop() + { + if (m_timer) + { + m_timer->stop(); + } + m_configuration = NativeStreamConfiguration{}; + m_frameIntervalTimer.invalidate(); + m_deliveryTimer.invalidate(); + m_frameIndex = 0; + m_pendingAcquiredFrameCount = 0; + m_observedIntervalMs = 0.0; + m_paused = false; + } + + void setPaused(bool paused) + { + if (m_paused == paused || !m_configuration.isValid()) + { + return; + } + m_paused = paused; + if (m_paused) + { + m_timer->stop(); + return; + } + m_frameIntervalTimer.restart(); + m_timer->start(); + } + + private: + void poll() + { + if (m_paused || !m_configuration.isValid()) + { + return; + } + + try + { + long remaining = m_configuration.core->getRemainingImageCount(); + if (remaining <= 0) + { + return; + } + + const qint64 payloadByteCount = static_cast(m_configuration.stride) + * m_configuration.height; + const QPointer owner = m_owner; + if (!owner) + { + return; + } + + const bool requiresAllFrames = owner->requiresAllFrames(); + const bool deliverLatest = requiresAllFrames + || owner->requiresHighRateFrames() + || !m_deliveryTimer.isValid() + || m_deliveryTimer.elapsed() >= kPreviewFrameDeliveryIntervalMs; + QList frames; + int frameCount = 0; + while (remaining-- > 0) + { + const void* pixels = m_configuration.core->popNextImage(); + if (!pixels) + { + break; + } + const quint64 frameIndex = ++m_frameIndex; + ++frameCount; + if (!requiresAllFrames && (remaining > 0 || !deliverLatest)) + { + continue; + } + + ImageFrame frame; + frame.cameraId = m_configuration.cameraId; + frame.width = m_configuration.width; + frame.height = m_configuration.height; + frame.stride = m_configuration.stride; + frame.bitsPerSample = m_configuration.bitsPerSample; + frame.pixelFormat = m_configuration.pixelFormat; + frame.frameIndex = frameIndex; + frame.timestampNs = static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; + frame.sourceRoiX = m_configuration.sourceRoiX; + frame.sourceRoiY = m_configuration.sourceRoiY; + frame.sourceRoiWidth = m_configuration.sourceRoiWidth; + frame.sourceRoiHeight = m_configuration.sourceRoiHeight; + frame.bytes.resize(static_cast(payloadByteCount)); + memcpy(frame.bytes.data(), pixels, static_cast(payloadByteCount)); + frames.append(std::move(frame)); + } + if (requiresAllFrames) + { + m_pendingAcquiredFrameCount = 0; + if (!frames.isEmpty()) + { + owner->submitWorkerFrames(frames, + static_cast(frameCount)); + } + } + else + { + m_pendingAcquiredFrameCount += static_cast(frameCount); + if (!frames.isEmpty()) + { + const quint64 acquiredFrameCount = m_pendingAcquiredFrameCount; + m_pendingAcquiredFrameCount = 0; + m_deliveryTimer.restart(); + owner->submitWorkerFrames(frames, acquiredFrameCount); + } + } + updatePollingInterval(frameCount); + } + catch (const CMMError& error) + { + m_timer->stop(); + postFailure(m_configuration.generation, + m_configuration.cameraId, + QString::fromStdString(error.getMsg())); + } + } + + void postFailure(quint64 generation, const QString& cameraId, const QString& error) + { + const QPointer owner = m_owner; + if (!owner) + { + return; + } + QMetaObject::invokeMethod( + owner.data(), + [owner, generation, cameraId, error]() + { + if (owner) + { + owner->handleStreamFailure(generation, cameraId, error); + } + }, + Qt::QueuedConnection); + } + + void updatePollingInterval(int 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 / frameCount; + if (!std::isfinite(measuredIntervalMs) || measuredIntervalMs <= 0.0) + { + return; + } + + m_observedIntervalMs = m_observedIntervalMs > 0.0 + ? 0.75 * m_observedIntervalMs + 0.25 * measuredIntervalMs + : measuredIntervalMs; + const int intervalMs = pollingIntervalFor(m_observedIntervalMs); + if (m_timer->interval() != intervalMs) + { + m_timer->setInterval(intervalMs); + } + } + + QPointer m_owner; + QTimer* m_timer{nullptr}; + NativeStreamConfiguration m_configuration; + QElapsedTimer m_frameIntervalTimer; + QElapsedTimer m_deliveryTimer; + quint64 m_frameIndex{0}; + quint64 m_pendingAcquiredFrameCount{0}; + double m_observedIntervalMs{0.0}; + bool m_paused{false}; + }; + + NativeCameraBackend::NativeCameraBackend() + { + m_streamThread.setObjectName(QStringLiteral("ScopeOneNativeFrameWorker")); + m_worker = new NativeFrameWorker(this); + m_worker->moveToThread(&m_streamThread); + connect(&m_streamThread, &QThread::finished, m_worker, &QObject::deleteLater); + m_streamThread.start(); + } + + NativeCameraBackend::~NativeCameraBackend() + { + shutdown(); + m_streamThread.quit(); + m_streamThread.wait(); + m_worker = nullptr; + } + + void NativeCameraBackend::shutdown() + { + if (m_running) + { + stopNativeStream(); + } + m_core.reset(); + m_cameraId.clear(); + m_exposureMs = 10.0; + m_lastFrameIndex.store(0, std::memory_order_relaxed); + m_activeGeneration = 0; + m_frameDeliveryPaused = false; + } + + bool NativeCameraBackend::configureNativeCamera(const std::shared_ptr& core, + const QString& cameraId, + double exposureMs) + { + const QString normalizedId = cameraId.trimmed(); + if (!core || normalizedId.isEmpty()) + { + return false; + } + shutdown(); + m_core = core; + m_cameraId = normalizedId; + m_exposureMs = exposureMs > 0.0 ? exposureMs : 10.0; + return true; + } + + bool NativeCameraBackend::startPreview() + { + return startPreviewFor(m_cameraId); + } + + bool NativeCameraBackend::stopPreview() + { + return stopPreviewFor(m_cameraId); + } + + bool NativeCameraBackend::startPreviewFor(const QString& cameraId) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + if (m_running) + { + return true; + } + if (!startNativeStream()) + { + return false; + } + notifyPreviewStarted(m_cameraId); + return true; + } + + bool NativeCameraBackend::stopPreviewFor(const QString& cameraId) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + if (!m_running) + { + return true; + } + const bool stopped = stopNativeStream(); + notifyPreviewStopped(); + return stopped; + } + + bool NativeCameraBackend::isPreviewRunning(const QString& cameraId) const + { + return matchesCamera(cameraId) && m_running; + } + + void NativeCameraBackend::setFrameDeliveryPaused(bool paused) + { + if (m_frameDeliveryPaused == paused) + { + return; + } + m_frameDeliveryPaused = paused; + setWorkerPaused(paused); + } + + bool NativeCameraBackend::resolvePrimaryCameraId(const QString& cameraIdOrAll, QString& cameraId) const + { + const QString target = cameraIdOrAll.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0 + ? m_cameraId + : cameraIdOrAll; + if (!matchesCamera(target)) + { + return false; + } + cameraId = m_cameraId; + return true; + } + + QStringList NativeCameraBackend::resolveTargetCameraIds(const QString& cameraIdOrAll) const + { + QString cameraId; + return resolvePrimaryCameraId(cameraIdOrAll, cameraId) ? QStringList{cameraId} : QStringList{}; + } + + bool NativeCameraBackend::readExposureFor(const QString& cameraId, double& exposureMs) const + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + try + { + exposureMs = m_core->getExposure(cameraId.toStdString().c_str()); + return true; + } + catch (const CMMError&) + { + exposureMs = m_exposureMs; + return exposureMs > 0.0; + } + } + + bool NativeCameraBackend::writeExposureFor(const QString& cameraId, double exposureMs) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + try + { + m_core->setCameraDevice(cameraId.toStdString().c_str()); + m_core->setExposure(exposureMs); + m_core->waitForDevice(cameraId.toStdString().c_str()); + double actualExposureMs = exposureMs; + try + { + actualExposureMs = m_core->getExposure(); + } + catch (const CMMError&) + { + } + m_exposureMs = actualExposureMs; + return true; + } + catch (const CMMError&) + { + return false; + } + } + + QStringList NativeCameraBackend::listPropertiesFor(const QString& cameraId) + { + if (!matchesCamera(cameraId) || !m_core) + { + return {}; + } + try + { + QStringList properties; + for (const std::string& name : m_core->getDevicePropertyNames(cameraId.toStdString().c_str())) + { + properties << QString::fromStdString(name); + } + return properties; + } + catch (const CMMError&) + { + return {}; + } + } + + bool NativeCameraBackend::readPropertyDetailsFor(const QString& cameraId, + const QString& name, + bool fromCache, + CameraPropertyReadback& readback) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + + const std::string camera = cameraId.toStdString(); + const std::string property = name.toStdString(); + try + { + readback.value = QString::fromStdString( + fromCache + ? m_core->getPropertyFromCache(camera.c_str(), property.c_str()) + : m_core->getProperty(camera.c_str(), property.c_str())); + try + { + switch (m_core->getPropertyType(camera.c_str(), property.c_str())) + { + case MM::String: readback.type = QStringLiteral("String"); break; + case MM::Float: readback.type = QStringLiteral("Float"); break; + case MM::Integer: readback.type = QStringLiteral("Integer"); break; + default: readback.type = QStringLiteral("Unknown"); break; + } + } + catch (const CMMError&) + { + } + try + { + readback.readOnly = m_core->isPropertyReadOnly(camera.c_str(), property.c_str()); + } + catch (const CMMError&) + { + } + try + { + readback.preInit = m_core->isPropertyPreInit(camera.c_str(), property.c_str()); + } + catch (const CMMError&) + { + } + try + { + for (const std::string& value : m_core->getAllowedPropertyValues(camera.c_str(), property.c_str())) + { + readback.allowedValues << QString::fromStdString(value); + } + } + catch (const CMMError&) + { + } + try + { + readback.hasLimits = m_core->hasPropertyLimits(camera.c_str(), property.c_str()); + if (readback.hasLimits) + { + readback.lowerLimit = m_core->getPropertyLowerLimit(camera.c_str(), property.c_str()); + readback.upperLimit = m_core->getPropertyUpperLimit(camera.c_str(), property.c_str()); + } + } + catch (const CMMError&) + { + } + return true; + } + catch (const CMMError&) + { + return false; + } + } + + bool NativeCameraBackend::setPropertyFor(const QString& cameraId, + const QString& name, + const QString& value, + QString* errorMessage) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + try + { + m_core->setCameraDevice(cameraId.toStdString().c_str()); + m_core->setProperty(cameraId.toStdString().c_str(), + name.toStdString().c_str(), + value.toStdString().c_str()); + m_core->waitForDevice(cameraId.toStdString().c_str()); + return true; + } + catch (const CMMError& error) + { + if (errorMessage) + { + *errorMessage = QString::fromStdString(error.getMsg()); + } + return false; + } + } + + bool NativeCameraBackend::setROIFor(const QString& cameraId, int x, int y, int width, int height) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + try + { + m_core->setROI(cameraId.toStdString().c_str(), x, y, width, height); + m_core->waitForDevice(cameraId.toStdString().c_str()); + return true; + } + catch (const CMMError& error) + { + qWarning().noquote() + << QString("Failed to set ROI for '%1': %2") + .arg(cameraId, QString::fromStdString(error.getMsg())); + return false; + } + } + + bool NativeCameraBackend::clearROIFor(const QString& cameraId) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + try + { + m_core->setCameraDevice(cameraId.toStdString().c_str()); + m_core->clearROI(); + m_core->waitForDevice(cameraId.toStdString().c_str()); + return true; + } + catch (const CMMError& error) + { + qWarning().noquote() + << QString("Failed to clear ROI for '%1': %2") + .arg(cameraId, QString::fromStdString(error.getMsg())); + return false; + } + } + + bool NativeCameraBackend::getROIFor(const QString& cameraId, + int& x, + int& y, + int& width, + int& height) + { + if (!matchesCamera(cameraId) || !m_core) + { + return false; + } + try + { + m_core->getROI(cameraId.toStdString().c_str(), x, y, width, height); + return true; + } + catch (const CMMError& error) + { + qWarning().noquote() + << QString("Failed to get ROI for '%1': %2") + .arg(cameraId, QString::fromStdString(error.getMsg())); + return false; + } + } + + bool NativeCameraBackend::startNativeStream() + { + if (!m_core || !m_worker || !m_streamThread.isRunning()) + { + return false; + } + + try + { + m_core->setCameraDevice(m_cameraId.toStdString().c_str()); + if (m_core->isSequenceRunning()) + { + m_core->stopSequenceAcquisition(); + } + + const unsigned width = m_core->getImageWidth(); + const unsigned height = m_core->getImageHeight(); + const unsigned bytesPerPixel = m_core->getBytesPerPixel(); + const qint64 stride = static_cast(width) * bytesPerPixel; + const qint64 payloadByteCount = stride * height; + if ((bytesPerPixel != 1 && bytesPerPixel != 2) + || width > static_cast((std::numeric_limits::max)()) + || height > static_cast((std::numeric_limits::max)()) + || stride > (std::numeric_limits::max)() + || payloadByteCount <= 0 + || payloadByteCount > (std::numeric_limits::max)()) + { + return false; + } + + NativeStreamConfiguration configuration; + configuration.core = m_core; + configuration.cameraId = m_cameraId; + configuration.width = static_cast(width); + configuration.height = static_cast(height); + configuration.stride = static_cast(stride); + configuration.pixelFormat = bytesPerPixel == 2 + ? ImagePixelFormat::Mono16 + : ImagePixelFormat::Mono8; + int bitsPerSample = bytesPerPixel == 2 ? 16 : 8; + try + { + bitsPerSample = static_cast(m_core->getImageBitDepth()); + } + catch (const CMMError&) + { + } + configuration.bitsPerSample = ImageFrame::normalizedBitsPerSample( + configuration.pixelFormat, + bitsPerSample); + configuration.sourceRoiWidth = configuration.width; + configuration.sourceRoiHeight = configuration.height; + try + { + m_core->getROI(m_cameraId.toStdString().c_str(), + configuration.sourceRoiX, + configuration.sourceRoiY, + configuration.sourceRoiWidth, + configuration.sourceRoiHeight); + } + catch (const CMMError&) + { + configuration.sourceRoiX = 0; + configuration.sourceRoiY = 0; + configuration.sourceRoiWidth = configuration.width; + configuration.sourceRoiHeight = configuration.height; + } + + try + { + const double hardwareExposureMs = m_core->getExposure(); + if (hardwareExposureMs > 0.0) + { + m_exposureMs = hardwareExposureMs; + } + } + catch (const CMMError&) + { + } + configuration.expectedIntervalMs = m_exposureMs; + try + { + const std::string camera = m_cameraId.toStdString(); + if (m_core->hasProperty(camera.c_str(), MM::g_Keyword_ActualInterval_ms)) + { + const std::string value = + m_core->getProperty(camera.c_str(), MM::g_Keyword_ActualInterval_ms); + char* end = nullptr; + const double actualIntervalMs = std::strtod(value.c_str(), &end); + if (end != value.c_str() + && std::isfinite(actualIntervalMs) + && actualIntervalMs > 0.0) + { + configuration.expectedIntervalMs = + (std::max)(configuration.expectedIntervalMs, actualIntervalMs); + } + } + } + catch (const CMMError&) + { + } + + configuration.firstFrameIndex = m_lastFrameIndex.load(std::memory_order_relaxed); + configuration.generation = ++m_streamGeneration; + m_activeGeneration = configuration.generation; + + m_core->startContinuousSequenceAcquisition(0.0); + + bool workerStarted = false; + NativeFrameWorker* const worker = m_worker; + const bool paused = m_frameDeliveryPaused; + const bool invoked = QMetaObject::invokeMethod( + worker, + [worker, configuration, paused, &workerStarted]() + { + workerStarted = worker->start(configuration); + if (workerStarted && paused) + { + worker->setPaused(true); + } + }, + Qt::BlockingQueuedConnection); + if (!invoked || !workerStarted) + { + stopNativeStream(); + return false; + } + + m_running = true; + return true; + } + catch (const CMMError& error) + { + qWarning().noquote() + << QString("Failed to start native camera stream for '%1': %2") + .arg(m_cameraId, QString::fromStdString(error.getMsg())); + stopNativeStream(); + return false; + } + } + + bool NativeCameraBackend::stopNativeStream() + { + ++m_streamGeneration; + m_activeGeneration = 0; + + bool workerStopped = !m_worker || !m_streamThread.isRunning(); + if (m_worker && m_streamThread.isRunning()) + { + NativeFrameWorker* const worker = m_worker; + workerStopped = QMetaObject::invokeMethod( + worker, + [worker]() { worker->stop(); }, + Qt::BlockingQueuedConnection); + } + discardPendingPreviewFrames(); + + bool sequenceStopped = true; + try + { + if (m_core && m_core->isSequenceRunning()) + { + m_core->stopSequenceAcquisition(); + } + } + catch (const CMMError&) + { + sequenceStopped = false; + } + m_running = false; + return workerStopped && sequenceStopped; + } + + void NativeCameraBackend::setWorkerPaused(bool paused) + { + if (!m_worker || !m_streamThread.isRunning()) + { + return; + } + NativeFrameWorker* const worker = m_worker; + QMetaObject::invokeMethod( + worker, + [worker, paused]() { worker->setPaused(paused); }, + Qt::BlockingQueuedConnection); + } + + void NativeCameraBackend::submitWorkerFrames(const QList& frames, + quint64 acquiredFrameCount) + { + if (frames.isEmpty()) + { + return; + } + m_lastFrameIndex.store(frames.constLast().frameIndex, std::memory_order_relaxed); + submitFrames(frames, acquiredFrameCount); + } + + bool NativeCameraBackend::requiresAllFrames() const + { + return recordingFrameDeliveryEnabled(); + } + + bool NativeCameraBackend::requiresHighRateFrames() const + { + return highRateFrameDeliveryEnabled(); + } + + void NativeCameraBackend::handleStreamFailure(quint64 generation, + const QString& cameraId, + const QString& error) + { + if (generation != m_activeGeneration || cameraId != m_cameraId) + { + return; + } + const QString message = QStringLiteral("Native camera stream failed for '%1': %2") + .arg(cameraId, error); + const bool wasRecording = recordingFrameDeliveryEnabled(); + if (wasRecording) + { + CameraBackend::setRecordingFrameDeliveryEnabled(false); + } + qWarning().noquote() << message; + stopNativeStream(); + notifyPreviewStopped(); + if (wasRecording) + { + emit frameDeliveryFailed(message); + } + } + + bool NativeCameraBackend::matchesCamera(const QString& cameraId) const + { + return !m_cameraId.isEmpty() && cameraId.trimmed() == m_cameraId; + } + } + + std::unique_ptr createNativeCameraBackend() + { + return std::make_unique(); + } +} diff --git a/ScopeOneCore/src/RecordingManager.cpp b/ScopeOneCore/src/RecordingManager.cpp index 2ac112b..90a76e6 100644 --- a/ScopeOneCore/src/RecordingManager.cpp +++ b/ScopeOneCore/src/RecordingManager.cpp @@ -1,6 +1,6 @@ #include "internal/RecordingManager.h" -#include "internal/MultiProcessCameraManager.h" +#include "internal/CameraManager.h" #include "MMCore.h" #include @@ -635,6 +635,14 @@ namespace scopeone::core::internal RecordingManager::RecordingManager(QObject* parent) : QObject(parent) { + m_writerStatusTimer.setInterval(100); + connect(&m_writerStatusTimer, &QTimer::timeout, this, [this]() + { + if (m_writerStatusDirty.load(std::memory_order_acquire)) + { + emitWriterStatus(); + } + }); } // Stops recording and writer threads during teardown @@ -663,15 +671,16 @@ namespace scopeone::core::internal return static_cast(m_writerState.recordedMaxBytes); } - // Emits the current write buffer usage - void RecordingManager::emitBufferUsageChanged(qint64 pendingWriteBytes) + // Marks writer telemetry for the next bounded UI update + void RecordingManager::markWriterStatusDirty() { - emit bufferUsageChanged(pendingWriteBytes); + m_writerStatusDirty.store(true, std::memory_order_release); } // Emits a snapshot of writer status void RecordingManager::emitWriterStatus() { + m_writerStatusDirty.store(false, std::memory_order_release); RecordingWriterStatus status; { std::lock_guard lock(m_writerState.writeMutex); @@ -775,7 +784,7 @@ namespace scopeone::core::internal { return false; } - if (!planUsesMda(plan) && !planStreamsMda(plan) && !m_mpcm && !m_latestFrameFetcher) + if (!planUsesMda(plan) && !planStreamsMda(plan) && !m_cameraManager && !m_latestFrameFetcher) { errorMessage = QStringLiteral("Frame source is not available for recording"); return false; @@ -988,7 +997,6 @@ namespace scopeone::core::internal std::lock_guard lock(m_writerState.writeMutex); m_writerState.pendingWriteBytes = 0; } - emitBufferUsageChanged(0); const quint64 generation = m_captureState.generation; for (auto it = m_writerState.cameraOutputs.begin(); it != m_writerState.cameraOutputs.end(); ++it) { @@ -1007,6 +1015,7 @@ namespace scopeone::core::internal writerLoop(output, generation); }); } + m_writerStatusTimer.start(); setWriterStatus(RecordingWriterPhase::Writing); return true; } @@ -1096,7 +1105,7 @@ namespace scopeone::core::internal m_writerState.cameraOutputs.clear(); m_writerState.pendingWriteBytes = 0; } - emitBufferUsageChanged(0); + m_writerStatusTimer.stop(); emitWriterStatus(); } @@ -1140,7 +1149,7 @@ namespace scopeone::core::internal writerFailure = m_writerState.writerError; } requestWriterStop(); - emitWriterStatus(); + markWriterStatusDirty(); QMetaObject::invokeMethod(this, [this, writerFailure, generation]() { if (generation == m_captureState.generation && m_captureState.isRecording) @@ -1151,16 +1160,13 @@ namespace scopeone::core::internal break; } - qint64 pendingWriteBytes = 0; { std::lock_guard lock(m_writerState.writeMutex); m_writerState.pendingWriteBytes -= static_cast(task.frame.payloadByteCount()); m_writerState.status.addWrittenFrames(1); - pendingWriteBytes = static_cast(m_writerState.pendingWriteBytes); } output->framesWritten += 1; - emitBufferUsageChanged(pendingWriteBytes); - emitWriterStatus(); + markWriterStatusDirty(); } } @@ -1282,6 +1288,12 @@ namespace scopeone::core::internal if (!usesMda) { primeLastFrameIndices(); + if (m_cameraManager + && !m_cameraManager->setRecordingFrameDeliveryEnabled(true)) + { + qWarning().noquote() << "Failed to enable all-frame camera delivery"; + return false; + } } resetSessionState(plan, deviceProperties); @@ -1289,6 +1301,10 @@ namespace scopeone::core::internal { if (!startStreamingOutputs(plan)) { + if (m_cameraManager) + { + m_cameraManager->setRecordingFrameDeliveryEnabled(false); + } const QString writerError = writerErrorSnapshot(); qWarning().noquote() << (writerError.isEmpty() ? QStringLiteral("Failed to start streaming outputs") @@ -1311,7 +1327,7 @@ namespace scopeone::core::internal ? kRecordingPhaseRecordingBurst : kRecordingPhaseRecording); emit recordingStateChanged(true); - emitProgress(); + emitProgress(true); qInfo().noquote() << QString("Recording started (%1 camera(s))").arg(m_captureState.activeCameraIds.size()); @@ -1340,13 +1356,17 @@ namespace scopeone::core::internal void RecordingManager::finishRecording(ExperimentRunState state, const QString& errorMessage) { if (!m_captureState.isRecording) return; + if (m_cameraManager) + { + m_cameraManager->setRecordingFrameDeliveryEnabled(false); + } if (m_mdaState.usingMda && m_mdaState.manager && m_mdaState.manager->isRunning()) { m_mdaState.manager->requestCancel(); } - if (m_mpcm) + if (m_cameraManager) { - m_mpcm->setPollingPaused(false); + m_cameraManager->setFrameDeliveryPaused(false); } if (m_captureState.streamToDisk) @@ -1358,7 +1378,7 @@ namespace scopeone::core::internal m_captureState.isRecording = false; m_captureState.phase = kRecordingPhaseStopped; emit recordingStateChanged(false); - emitProgress(); + emitProgress(true); qInfo().noquote() << "Recording stopped"; @@ -1396,10 +1416,27 @@ namespace scopeone::core::internal emit recordingStopped(session); } - // Receives one raw preview frame for recording ingestion - void RecordingManager::onNewRawFrameReady(const ImageFrame& frame) + // Receives one complete preview frame batch for recording ingestion + void RecordingManager::onRawFramesReady(const QList& frames) + { + for (const ImageFrame& frame : frames) + { + if (!m_captureState.isRecording) + { + break; + } + ingestFrame(FramePacket{frame, FramePacket::Source::PreviewStream}); + } + } + + // Fails an active preview recording when frame delivery is incomplete + void RecordingManager::onFrameDeliveryFailed(const QString& errorMessage) { - ingestFrame(FramePacket{frame, FramePacket::Source::PreviewStream}); + if (!m_captureState.isRecording || m_mdaState.usingMda) + { + return; + } + finishRecording(ExperimentRunState::Failed, errorMessage); } // Seeds last frame indices to skip stale preview frames @@ -1422,8 +1459,17 @@ namespace scopeone::core::internal } // Emits recording progress for UI and API listeners - void RecordingManager::emitProgress() + void RecordingManager::emitProgress(bool force) { + constexpr qint64 kProgressPublishIntervalMs = 100; + if (!force + && m_progressPublishTimer.isValid() + && m_progressPublishTimer.elapsed() < kProgressPublishIntervalMs) + { + return; + } + m_progressPublishTimer.restart(); + qint64 frameCurrent = 0; const int burstCurrent = m_captureState.burstMode ? m_captureState.currentBurst : 0; const int burstTarget = m_captureState.burstMode ? m_captureState.targetBursts : 0; @@ -1502,7 +1548,6 @@ namespace scopeone::core::internal { const QString cameraId = frame.cameraId.trimmed(); const size_t frameBytes = static_cast(frame.payloadByteCount()); - qint64 pendingWriteBytes = 0; std::shared_ptr output; QString failureError; bool emitStatus = false; @@ -1533,7 +1578,6 @@ namespace scopeone::core::internal { output = it.value(); m_writerState.pendingWriteBytes += frameBytes; - pendingWriteBytes = static_cast(m_writerState.pendingWriteBytes); } } if (!output) @@ -1557,20 +1601,17 @@ namespace scopeone::core::internal std::lock_guard lock(output->queueMutex); if (output->stopRequested) { - qint64 revertedPendingBytes = 0; { std::lock_guard stateLock(m_writerState.writeMutex); m_writerState.pendingWriteBytes -= frameBytes; - revertedPendingBytes = static_cast(m_writerState.pendingWriteBytes); } - emitBufferUsageChanged(revertedPendingBytes); + markWriterStatusDirty(); return false; } output->writeQueue.push_back(WriteTask{frame}); } - emitBufferUsageChanged(pendingWriteBytes); output->writeCondition.notify_one(); - emitWriterStatus(); + markWriterStatusDirty(); return true; } @@ -1729,7 +1770,7 @@ namespace scopeone::core::internal { m_sessionState.activeSession->appendEventRecord(record); } - emitProgress(); + emitProgress(true); return; } @@ -1814,9 +1855,9 @@ namespace scopeone::core::internal qWarning().noquote() << message; return false; } - if (m_captureState.activeCameraIds.size() > 1 && !m_mpcm) + if (m_captureState.activeCameraIds.size() > 1 && !m_cameraManager) { - const QString message = QStringLiteral("Multi-camera MDA requires MultiProcessCameraManager"); + const QString message = QStringLiteral("Multi-camera MDA requires CameraManager"); if (errorMessage) *errorMessage = message; qWarning().noquote() << message; return false; @@ -1833,11 +1874,11 @@ namespace scopeone::core::internal qWarning().noquote() << message; return false; } - m_mdaState.manager->setMultiProcessCameraManager(m_mpcm); + m_mdaState.manager->setCameraManager(m_cameraManager); if (m_captureState.activeCameraIds.size() > 1) { - m_mpcm->setPollingPaused(true); + m_cameraManager->setFrameDeliveryPaused(true); } m_mdaState.cameraId = m_captureState.activeCameraIds.first(); @@ -1877,7 +1918,7 @@ namespace scopeone::core::internal m_captureState.waitingBetweenBursts = true; m_captureState.lastBurstEndMs = m_captureState.elapsedTimer.elapsed(); m_captureState.phase = kRecordingPhaseWaitingNextBurst; - emitProgress(); + emitProgress(true); const int waitMs = static_cast(m_captureState.burstIntervalMs); QTimer::singleShot(waitMs, this, [this, generation]() { @@ -1961,7 +2002,7 @@ namespace scopeone::core::internal const int burstIndex = m_captureState.currentBurst; m_captureState.currentBurst += 1; m_captureState.phase = kRecordingPhaseRecordingMda; - emitProgress(); + emitProgress(true); QString buildError; const QList events = buildAcquisitionEvents(m_mdaState.plan, @@ -2032,7 +2073,7 @@ namespace scopeone::core::internal m_captureState.currentBurst += 1; m_captureState.phase = kRecordingPhaseWaitingNextBurst; - emitProgress(); + emitProgress(true); if (m_captureState.currentBurst >= m_captureState.targetBursts) { diff --git a/ScopeOneCore/src/ScopeOneCore.cpp b/ScopeOneCore/src/ScopeOneCore.cpp index 25522f8..8dd0f79 100644 --- a/ScopeOneCore/src/ScopeOneCore.cpp +++ b/ScopeOneCore/src/ScopeOneCore.cpp @@ -7,7 +7,7 @@ #include "internal/GaussianBlurModule.h" #include "internal/ImageProcessingFramework.h" #include "internal/MMCoreManager.h" -#include "internal/MultiProcessCameraManager.h" +#include "internal/CameraManager.h" #include "internal/ParticleAnalysis.h" #include "internal/RecordingManager.h" #include "internal/SpatiotemporalBinningModule.h" @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -46,17 +47,10 @@ namespace constexpr double kHistogramAutoStretchIgnoredQuantile = 0.001; // Histogram refresh is throttled to keep preview responsive constexpr qint64 kHistogramRefreshIntervalMs = 250; - - // Map a sample value into the shared histogram bin space - int histogramBinForValue(int value, int maxValue) - { - if (maxValue <= 0) - { - return 0; - } - const qint64 scaled = static_cast(value) * (kHistogramBinCount - 1); - return qBound(0, static_cast(scaled / maxValue), kHistogramBinCount - 1); - } + // Live line profiles update fast enough for interaction without following camera rate + constexpr qint64 kLineProfileRefreshIntervalMs = 50; + // Display delivery is bounded independently from acquisition and processing throughput + constexpr qint64 kPreviewRefreshIntervalMs = 16; // Convert a histogram bin to its lower source value int histogramBinLowerValue(int binIndex, int maxValue) @@ -463,26 +457,30 @@ namespace stats.maxValue = (1 << stats.bitDepth) - 1; stats.histogram.assign(kHistogramBinCount, 0); - stats.minVal = static_cast(stats.maxValue); - stats.maxVal = 0.0; - const uchar* bytes = reinterpret_cast(frame.bytes.constData()); - double sum = 0.0; - double sumSq = 0.0; + quint64 sum = 0; + quint64 sumSq = 0; + int minimum = (std::numeric_limits::max)(); + int maximum = 0; if (mono16) { + const int histogramShift = stats.bitDepth - 8; for (int y = 0; y < frame.height; ++y) { const quint16* row = reinterpret_cast(bytes + static_cast(y) * frame.stride); for (int x = 0; x < frame.width; ++x) { const int value = static_cast(row[x]); - sum += value; - sumSq += static_cast(value) * value; - stats.minVal = (std::min)(stats.minVal, static_cast(value)); - stats.maxVal = (std::max)(stats.maxVal, static_cast(value)); - stats.histogram[static_cast(histogramBinForValue(value, stats.maxValue))] += 1; + const int bin = histogramShift >= 0 + ? value >> histogramShift + : value << -histogramShift; + sum += static_cast(value); + sumSq += static_cast(value) * static_cast(value); + minimum = (std::min)(minimum, value); + maximum = (std::max)(maximum, value); + stats.histogram[static_cast( + qBound(0, bin, kHistogramBinCount - 1))] += 1; } } } @@ -490,24 +488,26 @@ namespace { stats.bitDepth = 8; stats.maxValue = 255; - stats.histogram.assign(kHistogramBinCount, 0); for (int y = 0; y < frame.height; ++y) { const uchar* row = bytes + static_cast(y) * frame.stride; for (int x = 0; x < frame.width; ++x) { const int value = static_cast(row[x]); - sum += value; - sumSq += static_cast(value) * value; - stats.minVal = (std::min)(stats.minVal, static_cast(value)); - stats.maxVal = (std::max)(stats.maxVal, static_cast(value)); - stats.histogram[static_cast(histogramBinForValue(value, stats.maxValue))] += 1; + sum += static_cast(value); + sumSq += static_cast(value) * static_cast(value); + minimum = (std::min)(minimum, value); + maximum = (std::max)(maximum, value); + stats.histogram[static_cast(value)] += 1; } } } - stats.mean = sum / (std::max)(1, totalPixels); - const double variance = (sumSq / (std::max)(1, totalPixels)) - (stats.mean * stats.mean); + stats.minVal = static_cast(minimum); + stats.maxVal = static_cast(maximum); + stats.mean = static_cast(sum) / (std::max)(1, totalPixels); + const double variance = static_cast(sumSq) / (std::max)(1, totalPixels) + - (stats.mean * stats.mean); stats.stdDev = std::sqrt((std::max)(0.0, variance)); computeAutoLevels(stats); return true; @@ -619,7 +619,7 @@ namespace scopeone::core using scopeone::core::internal::GaussianBlurModule; using scopeone::core::internal::ImageProcessingManager; using scopeone::core::internal::MMCoreManager; - using scopeone::core::internal::MultiProcessCameraManager; + using scopeone::core::internal::CameraManager; using scopeone::core::internal::ProcessingModule; using scopeone::core::internal::ProcessingPipelineDefinition; using scopeone::core::internal::RecordingManager; @@ -1169,7 +1169,7 @@ namespace scopeone::core struct ScopeOneCore::Managers { MMCoreManager* mmcoreManager{nullptr}; - MultiProcessCameraManager* mpcm{nullptr}; + CameraManager* cameraManager{nullptr}; RecordingManager* recordingManager{nullptr}; ImageProcessingManager* imageProcessingManager{nullptr}; StageMosaicManager* stageMosaicManager{nullptr}; @@ -1281,17 +1281,24 @@ namespace scopeone::core qRegisterMetaType( "scopeone::core::ScopeOneCore::HistogramStats"); qRegisterMetaType("scopeone::core::ImageFrame"); + m_histogramThreadPool = std::make_unique(); + m_histogramThreadPool->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_imageSceneModel = new ImageSceneModel(this); connect(m_imageSceneModel, &ImageSceneModel::markupsChanged, this, &ScopeOneCore::syncLineProfileFromScene); m_managers->mmcoreManager = new MMCoreManager(this); - m_managers->mpcm = new MultiProcessCameraManager(this); + m_managers->cameraManager = new CameraManager(this); m_managers->recordingManager = new RecordingManager(this); m_managers->imageProcessingManager = new ImageProcessingManager(this); m_managers->stageMosaicManager = new StageMosaicManager(this, this); m_managers->recordingWriterStatus.reset( m_managers->recordingManager->recordedMaxBytes()); - m_managers->recordingManager->setMultiProcessCameraManager(m_managers->mpcm); + m_managers->recordingManager->setCameraManager(m_managers->cameraManager); m_managers->recordingManager->setMMCore(m_managers->mmcoreManager->getCore()); m_managers->recordingManager->setLatestFrameFetcher( [this](const QString& cameraId, ImageFrame& frame) @@ -1300,16 +1307,19 @@ namespace scopeone::core return frame.isValid(); }); - connect(m_managers->mpcm, &MultiProcessCameraManager::newRawFrameReady, + connect(m_managers->cameraManager, &CameraManager::newRawFrameReady, this, &ScopeOneCore::handleIncomingRawFrame); - connect(m_managers->mpcm, &MultiProcessCameraManager::previewStateChanged, + connect(m_managers->cameraManager, &CameraManager::rawFramesAcquired, + this, &ScopeOneCore::rawFramesAcquired); + connect(m_managers->cameraManager, &CameraManager::recordingFramesReady, + 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->mpcm, &MultiProcessCameraManager::agentControlServerListening, + connect(m_managers->cameraManager, &CameraManager::agentControlServerListening, this, &ScopeOneCore::agentControlServerListening); - connect(this, &ScopeOneCore::newRawFrameReady, - m_managers->recordingManager, &RecordingManager::onNewRawFrameReady, - Qt::QueuedConnection); connect(m_managers->recordingManager, &RecordingManager::mdaRawFrameReady, this, &ScopeOneCore::handleIncomingRawFrame, Qt::QueuedConnection); @@ -1392,22 +1402,19 @@ namespace scopeone::core this, &ScopeOneCore::stageMosaicFinished); connect(m_managers->imageProcessingManager, &ImageProcessingManager::imageProcessed, - this, [this](const ImageFrame& frame) + this, [this](const ImageFrame& frame, quint64 completedFrameCount) { - if (!frame.isValid()) + if (!frame.isValid() || completedFrameCount == 0) { return; } const QString layerKey = processedLayerKey(frame.cameraId); - ensureSceneLayer(layerKey, - frame.cameraId, - QStringLiteral("%1 Processed").arg(frame.cameraId), - DocumentLayerKind::Processed); m_imageSceneModel->updateLayerFrame(layerKey, frame); m_frameGraph.publishLatest(FrameGraphStream::Processed, frame); + emit processedFramesCompleted(frame.cameraId, completedFrameCount); emit processedFrameReady(frame); queuePreviewProcessedFrame(frame); - scheduleHistogramStats(frame.cameraId, true, frame); + scheduleHistogramStats(layerKey, frame); updateLineProfile(frame.cameraId, true, frame); }); connect(m_managers->imageProcessingManager, &ImageProcessingManager::processingError, @@ -1418,6 +1425,7 @@ namespace scopeone::core ScopeOneCore::~ScopeOneCore() { unloadConfiguration(); + m_histogramThreadPool->waitForDone(); } // Expose the native MMCore handle for low level callers @@ -1426,8 +1434,8 @@ namespace scopeone::core return m_managers->mmcoreManager->getCore(); } - // Check whether a device is owned by the agent camera path - bool ScopeOneCore::isAgentCamera(const QString& deviceLabel) const + // Check whether a device is owned by the active camera backend + bool ScopeOneCore::isConfiguredCamera(const QString& deviceLabel) const { return m_cameraIds.contains(deviceLabel); } @@ -1436,7 +1444,7 @@ namespace scopeone::core bool ScopeOneCore::isNativeCamera(const QString& deviceLabel) const { const QString device = deviceLabel.trimmed(); - if (device.isEmpty() || isAgentCamera(device)) + if (device.isEmpty() || isConfiguredCamera(device)) { return false; } @@ -1458,7 +1466,7 @@ namespace scopeone::core QStringList running; for (const QString& cameraId : m_cameraIds) { - if (m_managers->mpcm->isPreviewRunning(cameraId)) + if (m_managers->cameraManager->isPreviewRunning(cameraId)) { running.append(cameraId); } @@ -1473,7 +1481,7 @@ namespace scopeone::core { MMCoreManager::LoadConfigResult mmResult; if (!m_managers->mmcoreManager->loadConfigurationAndStartCameras( - configPath, m_managers->mpcm, &mmResult, errorMessage)) + configPath, m_managers->cameraManager, &mmResult, errorMessage)) { return false; } @@ -1535,8 +1543,8 @@ namespace scopeone::core { m_managers->stageMosaicManager->cancel(); const QStringList cameraIds = m_cameraIds; - m_managers->mpcm->stopPreview(); - m_managers->mpcm->stopAgents(); + m_managers->cameraManager->stopPreview(); + m_managers->cameraManager->shutdown(); for (const QString& cameraId : cameraIds) { @@ -1557,10 +1565,11 @@ namespace scopeone::core m_loadedConfigPath.clear(); m_loadedConfigSha256.clear(); m_frameGraph.clear(); - m_previewRawFlushQueued = false; - m_previewProcessedFlushQueued = false; + m_previewFlushTimer->stop(); + m_previewPublishTimer.invalidate(); m_histogramJobStates.clear(); m_latestHistogramStats.clear(); + m_activeHistogramLayerKey.clear(); m_imageSceneModel->reset(); emit hardwareConfigurationChanged(); } @@ -1576,9 +1585,9 @@ namespace scopeone::core } if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) { - return m_managers->mpcm->startPreview(); + return m_managers->cameraManager->startPreview(); } - return m_managers->mpcm->startPreviewFor(target); + return m_managers->cameraManager->startPreviewFor(target); } // Stop preview for one camera or the full camera set @@ -1591,9 +1600,9 @@ namespace scopeone::core } if (target.compare(QStringLiteral("All"), Qt::CaseInsensitive) == 0) { - return m_managers->mpcm->stopPreview(); + return m_managers->cameraManager->stopPreview(); } - return m_managers->mpcm->stopPreviewFor(target); + return m_managers->cameraManager->stopPreviewFor(target); } // Submit exposure changes through the active camera manager @@ -1604,7 +1613,7 @@ namespace scopeone::core { return false; } - const bool ok = m_managers->mpcm->setExposure(target, exposureMs); + const bool ok = m_managers->cameraManager->setExposure(target, exposureMs); if (ok) { emit deviceStateChanged(); @@ -1620,7 +1629,7 @@ namespace scopeone::core { return false; } - const bool ok = m_managers->mpcm->setROI(target, x, y, width, height); + const bool ok = m_managers->cameraManager->setROI(target, x, y, width, height); if (ok) { clearLiveFrames(target); @@ -1681,7 +1690,7 @@ namespace scopeone::core bool changed = false; for (const QString& cameraId : targets) { - if (!m_managers->mpcm->clearROI(cameraId)) + if (!m_managers->cameraManager->clearROI(cameraId)) { ok = false; continue; @@ -1704,7 +1713,7 @@ namespace scopeone::core { return false; } - return m_managers->mpcm->getROI(target, x, y, width, height); + return m_managers->cameraManager->getROI(target, x, y, width, height); } // Track the active line profile request for future frames @@ -1726,6 +1735,7 @@ namespace scopeone::core m_activeLineProfile.processed = processed; m_activeLineProfile.staticSource = false; m_activeLineProfile.active = true; + m_lineProfileUpdateTimer.invalidate(); if (processed) { @@ -1778,6 +1788,7 @@ namespace scopeone::core { const bool wasActive = m_activeLineProfile.active; m_activeLineProfile = ActiveLineProfile{}; + m_lineProfileUpdateTimer.invalidate(); m_imageSceneModel->clearRole(DocumentMarkupRole::CrossSection); if (wasActive) { @@ -1828,15 +1839,11 @@ namespace scopeone::core ImageFrame normalizedFrame(frame); normalizedFrame.cameraId = cameraId; const QString layerKey = rawLayerKey(cameraId); - ensureSceneLayer(layerKey, - cameraId, - QStringLiteral("%1 Raw").arg(cameraId), - DocumentLayerKind::Raw); m_imageSceneModel->updateLayerFrame(layerKey, normalizedFrame); m_frameGraph.publishLatest(FrameGraphStream::Raw, normalizedFrame); emit newRawFrameReady(normalizedFrame); queuePreviewRawFrame(normalizedFrame); - scheduleHistogramStats(cameraId, false, normalizedFrame); + scheduleHistogramStats(layerKey, normalizedFrame); updateLineProfile(cameraId, false, normalizedFrame); if (isRealTimeProcessingEnabled()) @@ -1849,47 +1856,46 @@ namespace scopeone::core void ScopeOneCore::queuePreviewRawFrame(const ImageFrame& frame) { m_pendingPreviewRawFrames.insert(frame.cameraId, frame); - if (m_previewRawFlushQueued) - { - return; - } - - m_previewRawFlushQueued = true; - QTimer::singleShot(0, this, [this]() { flushPreviewRawFrames(); }); + schedulePreviewFlush(); } // Queue the newest processed frame for the display path void ScopeOneCore::queuePreviewProcessedFrame(const ImageFrame& frame) { m_pendingPreviewProcessedFrames.insert(frame.cameraId, frame); - if (m_previewProcessedFlushQueued) + schedulePreviewFlush(); + } + + // Schedule one latest frame display update without following camera rate + void ScopeOneCore::schedulePreviewFlush() + { + if (m_previewFlushTimer->isActive()) { return; } - m_previewProcessedFlushQueued = true; - QTimer::singleShot(0, this, [this]() { flushPreviewProcessedFrames(); }); + qint64 delayMs = 0; + if (m_previewPublishTimer.isValid()) + { + delayMs = qMax(qint64{0}, + kPreviewRefreshIntervalMs - m_previewPublishTimer.elapsed()); + } + m_previewFlushTimer->start(static_cast(delayMs)); } - // Flush latest only raw frames to preview consumers - void ScopeOneCore::flushPreviewRawFrames() + // Flush the newest raw and processed frames in one display update + void ScopeOneCore::flushPreviewFrames() { - m_previewRawFlushQueued = false; - QHash frames; - frames.swap(m_pendingPreviewRawFrames); - for (auto it = frames.constBegin(); it != frames.constEnd(); ++it) + m_previewPublishTimer.restart(); + QHash rawFrames; + QHash processedFrames; + rawFrames.swap(m_pendingPreviewRawFrames); + processedFrames.swap(m_pendingPreviewProcessedFrames); + for (auto it = rawFrames.constBegin(); it != rawFrames.constEnd(); ++it) { emit previewRawFrameReady(it.value()); } - } - - // Flush latest only processed frames to preview consumers - void ScopeOneCore::flushPreviewProcessedFrames() - { - m_previewProcessedFlushQueued = false; - QHash frames; - frames.swap(m_pendingPreviewProcessedFrames); - for (auto it = frames.constBegin(); it != frames.constEnd(); ++it) + for (auto it = processedFrames.constBegin(); it != processedFrames.constEnd(); ++it) { emit previewProcessedFrameReady(it.value()); } @@ -2080,17 +2086,8 @@ namespace scopeone::core displayName.trimmed().isEmpty() ? storedFrame.cameraId : displayName.trimmed()); m_imageSceneModel->updateLayerFrame(layerKey, storedFrame); m_imageSceneModel->setLayerVisible(layerKey, true); - HistogramStats stats; - if (computeHistogramStats(storedFrame, stats)) - { - m_latestHistogramStats.insert(layerKey, stats); - if (m_imageSceneModel->layerAutoStretchEnabled(layerKey)) - { - m_imageSceneModel->setLayerDisplayLevels( - layerKey, stats.autoMinLevel, stats.autoMaxLevel, stats.maxValue); - } - emit layerHistogramReady(layerKey, stats); - } + m_latestHistogramStats.remove(layerKey); + scheduleHistogramStats(layerKey, storedFrame); if (!updateStaticLineProfile(storedFrame.cameraId, storedFrame)) { clearLineProfile(); @@ -2291,37 +2288,88 @@ namespace scopeone::core return computeHistogramStats(graphFrame(key), stats); } + // Select the layer whose live histogram is consumed by the frontend + void ScopeOneCore::setActiveHistogramLayer(const QString& layerKey) + { + const QString key = layerKey.trimmed(); + if (m_activeHistogramLayerKey == key) + { + return; + } + + const QString previousKey = m_activeHistogramLayerKey; + m_activeHistogramLayerKey = key; + if (!previousKey.isEmpty() + && !m_imageSceneModel->layerAutoStretchEnabled(previousKey)) + { + m_latestHistogramStats.remove(previousKey); + auto it = m_histogramJobStates.find(previousKey); + if (it != m_histogramJobStates.end()) + { + it->queuedFrame = ImageFrame{}; + } + } + if (!key.isEmpty() && !m_imageSceneModel->layerAutoStretchEnabled(key)) + { + m_latestHistogramStats.remove(key); + } + + const ImageFrame frame = graphFrame(key); + if (frame.isValid()) + { + scheduleHistogramStats(key, frame); + } + } + bool ScopeOneCore::autoLayerLevels(const QString& layerKey) { + const QString key = layerKey.trimmed(); HistogramStats stats; - if (!getLayerHistogram(layerKey, stats)) + if (!computeHistogramStats(graphFrame(key), stats)) { return false; } + m_latestHistogramStats.insert(key, stats); return m_imageSceneModel->setLayerDisplayLevels( - layerKey, stats.autoMinLevel, stats.autoMaxLevel, stats.maxValue); + key, stats.autoMinLevel, stats.autoMaxLevel, stats.maxValue); } bool ScopeOneCore::fullLayerLevels(const QString& layerKey) { - HistogramStats stats; - if (!getLayerHistogram(layerKey, stats)) + const QString key = layerKey.trimmed(); + const ImageFrame frame = graphFrame(key); + if (!frame.isValid()) { return false; } - m_imageSceneModel->setLayerAutoStretchEnabled(layerKey, false); - return m_imageSceneModel->setLayerDisplayLevels(layerKey, 0, stats.maxValue, stats.maxValue); + const int maxValue = frame.maxValue(); + m_imageSceneModel->setLayerAutoStretchEnabled(key, false); + return m_imageSceneModel->setLayerDisplayLevels(key, 0, maxValue, maxValue); } bool ScopeOneCore::setLayerAutoStretchEnabled(const QString& layerKey, bool enabled) { - if (!m_imageSceneModel->setLayerAutoStretchEnabled(layerKey, enabled)) + const QString key = layerKey.trimmed(); + if (!m_imageSceneModel->setLayerAutoStretchEnabled(key, enabled)) { return false; } if (enabled) { - autoLayerLevels(layerKey); + autoLayerLevels(key); + const ImageFrame frame = graphFrame(key); + if (frame.isValid()) + { + scheduleHistogramStats(key, frame); + } + } + else if (m_activeHistogramLayerKey != key) + { + auto it = m_histogramJobStates.find(key); + if (it != m_histogramJobStates.end()) + { + it->queuedFrame = ImageFrame{}; + } } return true; } @@ -2386,73 +2434,111 @@ namespace scopeone::core return m_managers->stageMosaicManager->status(); } - // Schedule throttled histogram work for a layer - void ScopeOneCore::scheduleHistogramStats(const QString& cameraId, - bool processed, - const ImageFrame& frame) + // Check whether one layer currently needs continuously refreshed statistics + bool ScopeOneCore::histogramUpdatesEnabled(const QString& layerKey) const { - // Throttle histogram work per layer - const QString trimmedCameraId = cameraId.trimmed(); - if (trimmedCameraId.isEmpty() || !frame.isValid()) + const QString key = layerKey.trimmed(); + return !key.isEmpty() + && (key == m_activeHistogramLayerKey + || m_imageSceneModel->layerAutoStretchEnabled(key)); + } + + // Schedule rate limited histogram work for one graph layer + void ScopeOneCore::scheduleHistogramStats(const QString& layerKey, const ImageFrame& frame) + { + const QString key = layerKey.trimmed(); + if (key.isEmpty() || !frame.isValid()) { return; } + if (!histogramUpdatesEnabled(key)) + { + m_latestHistogramStats.remove(key); + return; + } - const QString cacheKey = histogramLayerKey(trimmedCameraId, processed); - HistogramJobState& state = m_histogramJobStates[cacheKey]; + HistogramJobState& state = m_histogramJobStates[key]; const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); if (state.inFlight) { state.queuedFrame = frame; return; } - if (state.lastScheduledMs > 0 && (nowMs - state.lastScheduledMs) < kHistogramRefreshIntervalMs) + + const qint64 elapsedMs = nowMs - state.lastScheduledMs; + if (state.lastScheduledMs > 0 && elapsedMs < kHistogramRefreshIntervalMs) { + state.queuedFrame = frame; + if (!state.retryScheduled) + { + state.retryScheduled = true; + const int delayMs = static_cast( + (std::max)(qint64{1}, kHistogramRefreshIntervalMs - elapsedMs)); + QTimer::singleShot(delayMs, this, [this, key]() + { + auto it = m_histogramJobStates.find(key); + if (it == m_histogramJobStates.end()) + { + return; + } + it->retryScheduled = false; + const ImageFrame queuedFrame = it->queuedFrame; + it->queuedFrame = ImageFrame{}; + if (queuedFrame.isValid()) + { + scheduleHistogramStats(key, queuedFrame); + } + }); + } return; } state.inFlight = true; + state.queuedFrame = ImageFrame{}; state.lastScheduledMs = nowMs; const quint64 sequence = ++m_nextHistogramSequence; state.activeSequence = sequence; auto* watcher = new QFutureWatcher(this); connect(watcher, &QFutureWatcher::finished, this, - [this, watcher, trimmedCameraId, processed, cacheKey, sequence]() + [this, watcher, key, sequence]() { HistogramStats stats = watcher->result(); ImageFrame queuedFrame; - auto it = m_histogramJobStates.find(cacheKey); + auto it = m_histogramJobStates.find(key); if (it != m_histogramJobStates.end() && it->activeSequence == sequence) { it->inFlight = false; - if (stats.hasData()) + if (histogramUpdatesEnabled(key) && stats.hasData()) { - m_latestHistogramStats.insert(cacheKey, stats); - if (m_imageSceneModel->layerAutoStretchEnabled(cacheKey)) + m_latestHistogramStats.insert(key, stats); + if (m_imageSceneModel->layerAutoStretchEnabled(key)) { m_imageSceneModel->setLayerDisplayLevels( - cacheKey, stats.autoMinLevel, stats.autoMaxLevel, stats.maxValue); + key, stats.autoMinLevel, stats.autoMaxLevel, stats.maxValue); } - emit imageHistogramReady(trimmedCameraId, processed, stats); - emit layerHistogramReady(cacheKey, stats); + if (isRawLayerKey(key) || isProcessedLayerKey(key)) + { + emit imageHistogramReady( + sourceIdFromLayerKey(key), isProcessedLayerKey(key), stats); + } + emit layerHistogramReady(key, stats); } - if (it->queuedFrame.isValid()) + if (histogramUpdatesEnabled(key) && it->queuedFrame.isValid()) { queuedFrame = it->queuedFrame; - it->queuedFrame = ImageFrame{}; - it->lastScheduledMs = 0; } + it->queuedFrame = ImageFrame{}; } watcher->deleteLater(); if (queuedFrame.isValid()) { - scheduleHistogramStats(trimmedCameraId, processed, queuedFrame); + scheduleHistogramStats(key, queuedFrame); } }); - watcher->setFuture(QtConcurrent::run([frame]() + watcher->setFuture(QtConcurrent::run(m_histogramThreadPool.get(), [frame]() { HistogramStats stats; ScopeOneCore::computeHistogramStats(frame, stats); @@ -2477,6 +2563,12 @@ namespace scopeone::core { return; } + if (m_lineProfileUpdateTimer.isValid() + && m_lineProfileUpdateTimer.elapsed() < kLineProfileRefreshIntervalMs) + { + return; + } + m_lineProfileUpdateTimer.restart(); QVector values; if (!sampleLine(m_activeLineProfile.start, m_activeLineProfile.end, values, @@ -2792,7 +2884,8 @@ namespace scopeone::core } resolvedTarget = m_cameraIds.first(); } - if (m_cameraIds.contains(resolvedTarget) && m_managers->mpcm->getExposure(resolvedTarget, exposureMs)) + if (m_cameraIds.contains(resolvedTarget) + && m_managers->cameraManager->getExposure(resolvedTarget, exposureMs)) { return true; } @@ -2876,9 +2969,9 @@ namespace scopeone::core { return {}; } - if (isAgentCamera(device)) + if (isConfiguredCamera(device)) { - return m_managers->mpcm->listProperties(device); + return m_managers->cameraManager->listProperties(device); } auto handle = core(); try @@ -2900,9 +2993,9 @@ namespace scopeone::core { return {}; } - if (isAgentCamera(device)) + if (isConfiguredCamera(device)) { - return m_managers->mpcm->getProperty(device, property); + return m_managers->cameraManager->getProperty(device, property, fromCache); } auto handle = core(); try @@ -2931,9 +3024,9 @@ namespace scopeone::core { return QStringLiteral("Unknown"); } - if (isAgentCamera(device)) + if (isConfiguredCamera(device)) { - return m_managers->mpcm->getPropertyType(device, property); + return m_managers->cameraManager->getPropertyType(device, property); } auto handle = core(); try @@ -2963,9 +3056,9 @@ namespace scopeone::core { return true; } - if (isAgentCamera(device)) + if (isConfiguredCamera(device)) { - return m_managers->mpcm->isPropertyReadOnly(device, property); + return m_managers->cameraManager->isPropertyReadOnly(device, property); } auto handle = core(); try @@ -2978,15 +3071,19 @@ namespace scopeone::core } } - // Check whether a native property must be set before initialization + // Check whether a property must be set before initialization bool ScopeOneCore::isPropertyPreInit(const QString& deviceLabel, const QString& name) const { const QString device = deviceLabel.trimmed(); const QString property = name.trimmed(); - if (device.isEmpty() || property.isEmpty() || isAgentCamera(device)) + if (device.isEmpty() || property.isEmpty()) { return false; } + if (isConfiguredCamera(device)) + { + return m_managers->cameraManager->isPropertyPreInit(device, property); + } auto handle = core(); try { @@ -3007,9 +3104,9 @@ namespace scopeone::core { return {}; } - if (isAgentCamera(device)) + if (isConfiguredCamera(device)) { - return m_managers->mpcm->getAllowedPropertyValues(device, property); + return m_managers->cameraManager->getAllowedPropertyValues(device, property); } auto handle = core(); try @@ -3038,14 +3135,14 @@ namespace scopeone::core { return false; } - if (isAgentCamera(device)) + if (isConfiguredCamera(device)) { - if (!m_managers->mpcm->hasPropertyLimits(device, property)) + if (!m_managers->cameraManager->hasPropertyLimits(device, property)) { return false; } - lower = m_managers->mpcm->getPropertyLowerLimit(device, property); - upper = m_managers->mpcm->getPropertyUpperLimit(device, property); + lower = m_managers->cameraManager->getPropertyLowerLimit(device, property); + upper = m_managers->cameraManager->getPropertyUpperLimit(device, property); return true; } @@ -3083,16 +3180,16 @@ namespace scopeone::core return false; } - if (isAgentCamera(device)) + if (isConfiguredCamera(device)) { - QString agentError; - if (!m_managers->mpcm->setProperty(device, property, value, &agentError)) + QString cameraError; + if (!m_managers->cameraManager->setProperty(device, property, value, &cameraError)) { if (errorMessage) { - *errorMessage = agentError.isEmpty() - ? QStringLiteral("Agent setProperty failed") - : agentError; + *errorMessage = cameraError.isEmpty() + ? QStringLiteral("Camera setProperty failed") + : cameraError; } return false; } @@ -3145,6 +3242,10 @@ namespace scopeone::core { return false; } + if (!m_managers->cameraManager->setHighRateFrameDeliveryEnabled(enabled)) + { + return false; + } if (m_managers->imageProcessingManager->isRealTimeProcessingEnabled() == enabled) { if (!enabled) diff --git a/ScopeOneCore/src/SpatiotemporalBinningModule.cpp b/ScopeOneCore/src/SpatiotemporalBinningModule.cpp index 4a56063..1cbb97a 100644 --- a/ScopeOneCore/src/SpatiotemporalBinningModule.cpp +++ b/ScopeOneCore/src/SpatiotemporalBinningModule.cpp @@ -1,8 +1,9 @@ #include "internal/SpatiotemporalBinningModule.h" #include "internal/FrameBufferUtils.h" -#include #include +#include +#include namespace scopeone::core::internal { @@ -15,77 +16,278 @@ namespace scopeone::core::internal && mode <= static_cast(SpatiotemporalBinningModule::BinningMode::Skip); } - // Reduces a set of pixel values using the selected binning mode - int modeValue(SpatiotemporalBinningModule::BinningMode mode, const std::vector& values) + template + int reduceTemporalExtremaPixel(const std::vector& rows, + int x, + int maxValue) { - switch (mode) + static_assert(Mode == SpatiotemporalBinningModule::BinningMode::Minimum + || Mode == SpatiotemporalBinningModule::BinningMode::Maximum); + + if constexpr (Mode == SpatiotemporalBinningModule::BinningMode::Minimum) + { + int value = maxValue; + for (const Pixel* row : rows) + { + value = qMin(value, static_cast(row[x])); + } + return value; + } + else + { + int value = 0; + for (const Pixel* row : rows) + { + value = qMax(value, static_cast(row[x])); + } + return value; + } + } + + template + QByteArray temporalExtremaBytes(const std::deque& buffer, + int width, + int height, + int maxValue) + { + QByteArray outBytes = allocatePixelBytes(width, height); + if (outBytes.isEmpty()) + { + return outBytes; + } + + Pixel* outputData = reinterpret_cast(outBytes.data()); + std::vector sourceData(buffer.size()); + std::vector sourceStrides(buffer.size()); + for (size_t i = 0; i < buffer.size(); ++i) + { + sourceData[i] = buffer[i].bytes.constData(); + sourceStrides[i] = buffer[i].stride; + } + const qint64 workItemCount = static_cast(width) + * height * static_cast(buffer.size()); + parallelForRows(workItemCount, height, [&](int firstRow, int lastRow) + { + std::vector rows(buffer.size()); + for (int y = firstRow; y < lastRow; ++y) + { + for (size_t i = 0; i < buffer.size(); ++i) + { + rows[i] = reinterpret_cast( + sourceData[i] + static_cast(y) * sourceStrides[i]); + } + Pixel* dstRow = outputData + static_cast(y) * width; + for (int x = 0; x < width; ++x) + { + dstRow[x] = static_cast( + reduceTemporalExtremaPixel(rows, x, maxValue)); + } + } + }); + return outBytes; + } + + // Advances a rolling temporal sum and writes a ready output in the same image pass + ImageFrame advanceTemporalSum(const ImageFrame& incoming, + const ImageFrame* expired, + const ImageFrame& reference, + std::vector& sum, + int frameCount, + bool mean, + bool outputReady) + { + const int maxValue = incoming.maxValue(); + QByteArray bytes = dispatchFrameType(incoming, [&]() + { + QByteArray outBytes; + if (outputReady) + { + outBytes = allocatePixelBytes(incoming.width, incoming.height); + if (outBytes.isEmpty()) + { + return outBytes; + } + } + + const char* incomingData = incoming.bytes.constData(); + const char* expiredData = expired ? expired->bytes.constData() : nullptr; + qint64* sumData = sum.data(); + Pixel* outputData = outputReady + ? reinterpret_cast(outBytes.data()) + : nullptr; + parallelForImageRows(incoming.width, incoming.height, [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + const Pixel* incomingRow = reinterpret_cast( + incomingData + static_cast(y) * incoming.stride); + const Pixel* expiredRow = expired + ? reinterpret_cast( + expiredData + + static_cast(y) * expired->stride) + : nullptr; + qint64* sumRow = sumData + static_cast(y) * incoming.width; + Pixel* outputRow = outputData + ? outputData + static_cast(y) * incoming.width + : nullptr; + for (int x = 0; x < incoming.width; ++x) + { + sumRow[x] += static_cast(incomingRow[x]); + if (expiredRow) + { + sumRow[x] -= static_cast(expiredRow[x]); + } + if (outputRow) + { + const qint64 value = mean + ? sumRow[x] / frameCount + : sumRow[x]; + outputRow[x] = static_cast( + qBound(qint64{0}, value, static_cast(maxValue))); + } + } + } + }); + return outBytes; + }); + if (!outputReady) + { + return {}; + } + return makeFrameLike(reference, + incoming.width, + incoming.height, + std::move(bytes)); + } + + template + int reduceSpatialBlock(const char* sourceData, + int sourceStride, + int startX, + int startY, + int binX, + int binY, + int maxValue) + { + if constexpr (Mode == SpatiotemporalBinningModule::BinningMode::Skip) + { + const Pixel* row = reinterpret_cast( + sourceData + static_cast(startY) * sourceStride); + return static_cast(row[startX]); + } + + if constexpr (Mode == SpatiotemporalBinningModule::BinningMode::Minimum) { - case SpatiotemporalBinningModule::BinningMode::Mean: + int value = maxValue; + for (int yy = 0; yy < binY; ++yy) { - int sum = 0; - for (int value : values) + const Pixel* row = reinterpret_cast( + sourceData + static_cast(startY + yy) * sourceStride) + startX; + for (int xx = 0; xx < binX; ++xx) { - sum += value; + value = qMin(value, static_cast(row[xx])); } - return sum / static_cast(values.size()); } - case SpatiotemporalBinningModule::BinningMode::Sum: + return value; + } + + if constexpr (Mode == SpatiotemporalBinningModule::BinningMode::Maximum) + { + int value = 0; + for (int yy = 0; yy < binY; ++yy) { - int sum = 0; - for (int value : values) + const Pixel* row = reinterpret_cast( + sourceData + static_cast(startY + yy) * sourceStride) + startX; + for (int xx = 0; xx < binX; ++xx) { - sum += value; + value = qMax(value, static_cast(row[xx])); } - return sum; } - case SpatiotemporalBinningModule::BinningMode::Minimum: - return *std::min_element(values.begin(), values.end()); - case SpatiotemporalBinningModule::BinningMode::Maximum: - return *std::max_element(values.begin(), values.end()); - case SpatiotemporalBinningModule::BinningMode::Skip: - return values.front(); + return value; + } + + qint64 sum = 0; + for (int yy = 0; yy < binY; ++yy) + { + const Pixel* row = reinterpret_cast( + sourceData + static_cast(startY + yy) * sourceStride) + startX; + for (int xx = 0; xx < binX; ++xx) + { + sum += static_cast(row[xx]); + } } - return values.front(); + if constexpr (Mode == SpatiotemporalBinningModule::BinningMode::Mean) + { + sum /= static_cast(binX) * binY; + } + return static_cast(qBound(qint64{0}, sum, static_cast(maxValue))); } - // Combines buffered frames along the time axis - ImageFrame applyTemporalBinning(const std::deque& buffer, + template + QByteArray spatialBinningBytes(const ImageFrame& frame, + int width, + int height, + int binX, + int binY, + int maxValue) + { + QByteArray outBytes = allocatePixelBytes(width, height); + if (outBytes.isEmpty()) + { + return outBytes; + } + + const char* sourceData = frame.bytes.constData(); + Pixel* outputData = reinterpret_cast(outBytes.data()); + const qint64 workItemCount = static_cast(width) + * height * binX * binY; + parallelForRows(workItemCount, height, [&](int firstRow, int lastRow) + { + for (int y = firstRow; y < lastRow; ++y) + { + Pixel* dst = outputData + static_cast(y) * width; + for (int x = 0; x < width; ++x) + { + dst[x] = static_cast( + reduceSpatialBlock(sourceData, + frame.stride, + x * binX, + y * binY, + binX, + binY, + maxValue)); + } + } + }); + return outBytes; + } + + // Scans one buffered window for temporal minimum or maximum + ImageFrame applyTemporalExtrema(const std::deque& buffer, SpatiotemporalBinningModule::BinningMode mode) { if (buffer.empty()) { qFatal("Temporal binning requires at least one frame"); } - if (buffer.size() == 1 || mode == SpatiotemporalBinningModule::BinningMode::Skip) + if (mode != SpatiotemporalBinningModule::BinningMode::Minimum + && mode != SpatiotemporalBinningModule::BinningMode::Maximum) { - return buffer.front(); + qFatal("Temporal extrema requires minimum or maximum mode"); } const int width = buffer.front().width; const int height = buffer.front().height; const int maxValue = buffer.front().maxValue(); - std::vector samples(buffer.size()); QByteArray bytes = dispatchFrameType(buffer.front(), [&]() { - QByteArray outBytes = allocatePixelBytes(width, height); - if (outBytes.isEmpty()) + if (mode == SpatiotemporalBinningModule::BinningMode::Minimum) { - return outBytes; + return temporalExtremaBytes( + buffer, width, height, maxValue); } - for (int y = 0; y < height; ++y) - { - Pixel* dstRow = mutableRowData(outBytes, width, y); - for (int x = 0; x < width; ++x) - { - for (int i = 0; i < static_cast(buffer.size()); ++i) - { - samples[static_cast(i)] = static_cast( - frameRowData(buffer[static_cast(i)], y)[x]); - } - dstRow[x] = clampPixelValue(modeValue(mode, samples), maxValue); - } - } - return outBytes; + return temporalExtremaBytes( + buffer, width, height, maxValue); }); return makeFrameLike(buffer.front(), width, height, std::move(bytes)); @@ -109,34 +311,28 @@ namespace scopeone::core::internal return frame; } - std::vector samples; - samples.reserve(static_cast(static_cast(binX) * binY)); const int maxValue = frame.maxValue(); QByteArray bytes = dispatchFrameType(frame, [&]() { - QByteArray outBytes = allocatePixelBytes(width, height); - if (outBytes.isEmpty()) - { - return outBytes; - } - for (int y = 0; y < height; ++y) + switch (mode) { - Pixel* dst = mutableRowData(outBytes, width, y); - for (int x = 0; x < width; ++x) - { - samples.clear(); - for (int yy = 0; yy < binY; ++yy) - { - for (int xx = 0; xx < binX; ++xx) - { - samples.push_back(static_cast( - frameRowData(frame, y * binY + yy)[x * binX + xx])); - } - } - dst[x] = clampPixelValue(modeValue(mode, samples), maxValue); - } + case SpatiotemporalBinningModule::BinningMode::Mean: + return spatialBinningBytes( + frame, width, height, binX, binY, maxValue); + case SpatiotemporalBinningModule::BinningMode::Sum: + return spatialBinningBytes( + frame, width, height, binX, binY, maxValue); + case SpatiotemporalBinningModule::BinningMode::Minimum: + return spatialBinningBytes( + frame, width, height, binX, binY, maxValue); + case SpatiotemporalBinningModule::BinningMode::Maximum: + return spatialBinningBytes( + frame, width, height, binX, binY, maxValue); + case SpatiotemporalBinningModule::BinningMode::Skip: + return spatialBinningBytes( + frame, width, height, binX, binY, maxValue); } - return outBytes; + return QByteArray{}; }); return makeFrameLike(frame, width, height, std::move(bytes)); @@ -169,6 +365,38 @@ namespace scopeone::core::internal if (!m_frameBuffer.empty() && !m_frameBuffer.front().isCompatibleWith(workingFrame)) { m_frameBuffer.clear(); + m_temporalSum.clear(); + } + + ImageFrame temporal; + const bool useRollingSum = m_temporalBin > 1 + && (m_temporalMode == BinningMode::Mean + || m_temporalMode == BinningMode::Sum); + if (useRollingSum) + { + const qint64 pixelCount = static_cast(workingFrame.width) + * workingFrame.height; + if (m_temporalSum.size() != static_cast(pixelCount)) + { + m_frameBuffer.clear(); + m_temporalSum.assign(static_cast(pixelCount), qint64{0}); + } + const ImageFrame* expired = static_cast(m_frameBuffer.size()) >= m_temporalBin + ? &m_frameBuffer.front() + : nullptr; + const bool outputReady = static_cast(m_frameBuffer.size()) + 1 + >= m_temporalBin; + const ImageFrame& reference = m_frameBuffer.empty() + ? workingFrame + : m_frameBuffer.front(); + temporal = advanceTemporalSum( + workingFrame, + expired, + reference, + m_temporalSum, + m_temporalBin, + m_temporalMode == BinningMode::Mean, + outputReady); } m_frameBuffer.push_back(workingFrame); while (static_cast(m_frameBuffer.size()) > m_temporalBin) @@ -180,7 +408,12 @@ namespace scopeone::core::internal { return {frame, {}}; } - const ImageFrame temporal = applyTemporalBinning(m_frameBuffer, m_temporalMode); + if (!useRollingSum) + { + temporal = m_frameBuffer.size() == 1 || m_temporalMode == BinningMode::Skip + ? m_frameBuffer.front() + : applyTemporalExtrema(m_frameBuffer, m_temporalMode); + } return {applySpatialBinning(temporal, m_spatialBinX, m_spatialBinY, m_spatialMode), {}}; } catch (const std::exception& e) @@ -248,6 +481,7 @@ namespace scopeone::core::internal if (resetBuffer) { m_frameBuffer.clear(); + m_temporalSum.clear(); } } @@ -255,6 +489,7 @@ namespace scopeone::core::internal bool SpatiotemporalBinningModule::resetState() { m_frameBuffer.clear(); + m_temporalSum.clear(); return true; } } // namespace scopeone::core::internal diff --git a/scopeone-skill/.claude-plugin/plugin.json b/scopeone-skill/.claude-plugin/plugin.json new file mode 100644 index 0000000..a9f45c4 --- /dev/null +++ b/scopeone-skill/.claude-plugin/plugin.json @@ -0,0 +1,16 @@ +{ + "name": "scopeone", + "description": "Control ScopeOne through its bundled MCP server", + "version": "1.0.0", + "author": { + "name": "ScopeOne Project" + }, + "userConfig": { + "mcp_server": { + "type": "file", + "title": "ScopeOne MCP Server", + "description": "Select the ScopeOneMcpServer executable installed with ScopeOne", + "required": true + } + } +} diff --git a/scopeone-skill/.mcp.json b/scopeone-skill/.mcp.json new file mode 100644 index 0000000..206f2a3 --- /dev/null +++ b/scopeone-skill/.mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "scopeone": { + "command": "${user_config.mcp_server}" + } + } +} diff --git a/scopeone-skill/SKILL.md b/scopeone-skill/SKILL.md new file mode 100644 index 0000000..295edbc --- /dev/null +++ b/scopeone-skill/SKILL.md @@ -0,0 +1,112 @@ +--- +name: scopeone +description: Configure and operate a running ScopeOne microscopy application through its bundled stdio MCP server. Use when an MCP-capable AI agent needs to connect to ScopeOne, inspect microscope state, load Micro-Manager configurations, control preview, exposure, ROI, or stages, manage processing, recording, or mosaics, analyze frames, or work with image layers and annotations. Do not use for ordinary ScopeOne source editing, builds, packaging, or code review unless the task also requires controlling the running application. +--- + +# ScopeOne + +Control the running desktop application through `ScopeOneMcpServer`. Treat the +Local API as the authority and use MCP tools instead of clicking GUI controls or +calling the Python client. + +## Install the shared Skill + +Use this same folder for every compatible agent instead of maintaining separate +Codex and Claude variants: + +- Codex: copy or link it to `$CODEX_HOME/skills/scopeone`, or to + `~/.codex/skills/scopeone` when `CODEX_HOME` is unset. +- Claude Code: install or load this folder as a plugin. When prompted, select + the `ScopeOneMcpServer` executable installed with ScopeOne. + +## Select the path + +1. If the request only concerns source code, building, packaging, or review, + follow the repository instructions and do not configure MCP. +2. If ScopeOne MCP tools are already available, skip registration. +3. If the tools are unavailable and the user wants runtime control, repair the + active client's MCP configuration before attempting microscope operations. + +## Configure the MCP client + +1. Locate `ScopeOneMcpServer.exe` beside an installed `ScopeOne.exe`, in the + portable package, or under `build/Release` in a source checkout. On Linux, + use the corresponding executable without the `.exe` suffix. +2. Do not launch the MCP server manually. The MCP client starts it as a stdio + child process after registration. +3. In Claude Code, use the plugin's required `ScopeOne MCP Server` setting. The + plugin starts that executable automatically. Do not also run `claude mcp add`. + If the configured path is missing or wrong, ask the user to update the plugin + setting and reload plugins. +4. In Codex, explain that registration changes the user's configuration and + obtain explicit approval before making that change. Check the existing entry: + + ```text + codex mcp get scopeone + ``` + +5. If the Codex entry is absent, replace `ABSOLUTE_SERVER_PATH` with the + resolved executable path and run: + + ```text + codex mcp add scopeone -- "ABSOLUTE_SERVER_PATH" + ``` + +6. If an existing Codex entry points to the wrong executable, explain the + mismatch and obtain explicit approval before running `codex mcp remove + scopeone`, then add the corrected entry. +7. Ask the user to restart the agent or start a new session after registration. + Do not claim the MCP tools are available in the current session until they + actually appear. + +## Establish state + +1. Ensure the ScopeOne desktop application is running. Launch it only when the + user explicitly requests that action. +2. Call `capabilities` to discover the current operation catalog. +3. Call `state_snapshot` before changing hardware or acquisition state. +4. Use the current MCP schemas as the parameter authority. Do not reconstruct + requests from memory when a schema is available. + +## Execute operations + +- Prefer one semantic ScopeOne operation over GUI automation or a sequence of + low-level property edits. +- Read back state after each mutation. Use hardware reads for exposure, ROI, + properties, and stage positions; use status calls for acquisitions. +- Poll `experiment_status` or `stage_mosaic_status` for long-running work and + stop only at a terminal state or when the user requests cancellation. +- Use `layer_frame` for raw, processed, static, mosaic, or Gallery image data. +- Remember that all clients share one frame mapping. Finish reading one exported + frame before requesting another. +- Read [references/tool-routing.md](references/tool-routing.md) for canonical + operation sequences and troubleshooting. + +## Apply safety boundaries + +- Respect every MCP confirmation field. Never set `confirm=true` without the + user's explicit approval for that operation. +- Treat configuration changes, camera settings, ROI changes, stage motion, + acquisition, cancellation, file writes, and destructive removals as real + effects on laboratory state. +- Never infer a stage destination, recording directory, output name, or + destructive target when the user has not supplied enough context. +- Do not expose UI-only actions such as opening dialogs, changing docks, or + simulating button clicks. Use domain operations only. +- Do not fall back to Python merely because MCP setup is incomplete. Use Python + only when the user explicitly requests a script, notebook, or Python workflow. + +## Recover cleanly + +- If the client cannot start the MCP server, verify the registered absolute path + and confirm that the executable belongs to the same ScopeOne installation. +- If the MCP server starts but reports that the Local API is unavailable, ask + the user to start ScopeOne and ensure only the intended instance is running. +- If the Claude plugin is enabled but tools are absent, verify its configured + executable and reload plugins. For Codex, restart it and inspect + `codex mcp get scopeone`. +- If an operation fails, report its exact error, refresh state, and correct the + request. Do not retry physical or destructive operations blindly. + +For raw protocol or Python-client work only, consult +`ScopeOneCore/python/scopeone/README.md` in the repository. diff --git a/scopeone-skill/references/tool-routing.md b/scopeone-skill/references/tool-routing.md new file mode 100644 index 0000000..61fd658 --- /dev/null +++ b/scopeone-skill/references/tool-routing.md @@ -0,0 +1,267 @@ +# ScopeOne tool routing + +Use `capabilities` and the live MCP schemas as the complete API reference. This +file records workflow choices that are easy to get wrong; it does not duplicate +the full tool catalog. + +## Contents + +- [Start every runtime task](#start-every-runtime-task) +- [Route compound requests efficiently](#route-compound-requests-efficiently) +- [Configuration and preview](#configuration-and-preview) +- [Hardware control](#hardware-control) +- [Layers, markups, and analysis](#layers-markups-and-analysis) +- [Processing and frame transfer](#processing-and-frame-transfer) +- [Acquisition and persistence](#acquisition-and-persistence) +- [Failure routing](#failure-routing) + +## Start every runtime task + +1. Call `capabilities` once per session to discover operation groups and safety + classifications. Refresh it after an application or server version change. +2. Call `state_snapshot` and identify the exact configuration, camera, device, + layer, markup, experiment, and session IDs needed for the request. +3. Serialize calls through one control connection. Do not issue concurrent + mutations or concurrent frame-exporting operations. +4. Before a confirmed operation, state the concrete hardware, filesystem, or + destructive effect and obtain the user's approval. +5. Read back the affected hardware or application state after every mutation. + +Use `ping` only for connection health, `version` for component identity, and +`status` for a compact summary. Use `state_snapshot` for operational decisions. + +## Route compound requests efficiently + +- Treat a clear user command as approval for the exact confirmed operations it + names. Ask again only when the target, destination, destructive scope, or + physical effect is ambiguous or would be inferred. +- Discover capabilities once and resolve identifiers once for a planned + sequence. Refresh only after an operation invalidates identifiers or a call + reports stale state. +- Use an operation's returned applied value as its readback when available. Add + a targeted getter only when the response omits the resulting hardware or + application state. +- Execute dependent operations in order and stop the sequence at the first + failure. Do not continue with assumptions about an earlier result. +- For “load this cfg, use half ROI, preview, then auto levels,” use + `load_config`, resolve the camera, `set_half_roi`, `start_preview`, + `list_layers`, and `auto_layer_levels`. Do not call `get_layer_histogram` and + do not repeat `capabilities` or a full `state_snapshot` between those steps. + +## Configuration and preview + +### Load, switch, or unload configuration + +1. Resolve an absolute `.cfg` path before calling `load_config`. Do not open the + desktop file dialog. +2. After loading, use the returned camera IDs. Call `loaded_devices` only when + the next action targets a device, and call `state_snapshot` only when broader + application state is needed. +3. Before changing a preset, call `config_groups`, `configs`, and + `current_config`. Call `set_config` only with a listed group and preset, then + read `current_config` again. +4. Treat `unload_config` as destructive because it stops acquisition and + invalidates device, camera, preview, and layer identifiers. Confirm it and + verify the unloaded state before continuing. + +### Start or stop preview + +1. Select one concrete camera unless the user explicitly requests `All`. +2. Call `start_preview` or `stop_preview` after confirmation. +3. Use `state_snapshot.preview.runningCameraIds` when running-camera state is + needed, or `list_layers` when the next action targets an image layer. Do not + call both by default. +4. Do not infer that changing one camera should change every camera. + +## Hardware control + +### Change a property or exposure + +1. Resolve the device with `loaded_devices`; use `device_property_names` or + `device_properties` to inspect valid properties and metadata. +2. Read the current value from hardware with `get_property` using + `fromCache=false`, or use `read_exposure`, before deciding on a change. +3. Submit property values in the form accepted by Micro-Manager. Do not append + display units or write a read-only property. +4. After confirmation, call `set_property` or `set_exposure` and perform a fresh + hardware read. Report the applied value rather than only the requested one. +5. When targeting `All`, preserve and report per-camera results. + +### Distinguish camera ROI from an ROI markup + +- `set_roi`, `set_half_roi`, and `clear_roi` change camera acquisition hardware. + They require confirmation and affect future frames. +- `create_rect_markup(role="roi")` creates a visual image-space annotation. It + does not crop the camera or change acquired pixels. +- For a hardware ROI, identify one camera, read `get_roi`, apply the requested + operation, and report the returned rectangle when `readBack=true`. Call + `get_roi` again only when the operation did not return a hardware readback; + `clear_roi` always needs a separate read when the final rectangle matters. +- Never translate an annotation request into a hardware ROI change, or the + reverse, without explicit user intent. + +### Move an XY or Z stage + +1. Resolve the target with `xy_stage_devices`, `z_stage_devices`, + `current_xy_stage_device`, or `current_focus_device`. Ask when multiple + devices make the target ambiguous. +2. Read the current position immediately before movement. +3. Confirm the selected device, absolute target or relative offset, and device + units. Do not assume a unit that the device has not established. +4. Call exactly one of `move_xy_relative`, `move_z_relative`, `move_xy_to`, or + `move_z_to`, then verify with `read_xy_position` or `read_z_position` and + report the actual result. +5. Do not blindly retry a failed or partially completed physical movement. + +## Layers, markups, and analysis + +### Manage image layers and source alignment + +1. Call `list_layers` before using a `layerKey` or `sourceId`; use + `layer_options` before selecting layouts, colormaps, or blending modes. +2. Use `set_layer_layout`, `set_visible_layers`, and `set_layer_display` only for + presentation. Preserve unrequested display fields. +3. Use source transforms for visual alignment only. Read + `get_source_display_transform`, apply the requested values with + `set_source_display_transform` or `reset_source_display_transform`, then read + it again. A `sourceId` is not interchangeable with a `layerKey`. +4. After `move_layer`, refresh `list_layers` because order is positional. +5. Confirm `remove_static_layer` or `clear_static_layers`. Never pass a live + camera layer to static-layer removal, and refresh layers and markups after it. + +### Adjust display levels + +1. Select the intended layer with `list_layers`. +2. Call `auto_layer_levels` directly for one-shot histogram-based levels. Do not + call `get_layer_histogram` first unless statistics were requested. +3. Use `set_layer_auto_stretch` only when levels should follow new frames. +4. Use `full_layer_levels` to restore the complete intensity range. + +### Manage markups + +1. Bind every markup to an existing `layerKey` and use image-space coordinates. +2. Use `create_line_markup` with role `cross_section` for a persistent visible + profile or role `measurement` for a measurement line. Use + `create_rect_markup` with role `roi` only for an annotation ROI. +3. Capture the returned markup ID. Call `list_markups` before `update_markup` or + deletion, then verify label, visibility, selection, and geometry afterward. +4. Supply geometry fields appropriate to the existing line or rectangle type. +5. Treat `remove_markup` and `clear_markups` as destructive. When clearing one + layer, always provide its `layerKey`; omit it only when the user explicitly + requests removal of all markups. + +### Inspect or analyze an image + +- Use `get_pixel_value` for one image-space coordinate. +- Use `get_line_profile` for a one-shot profile; use a cross-section markup when + the profile should remain visible and follow scene updates. +- Use `get_layer_histogram` only when histogram data or summary statistics are + required. +- For `detect_particles`, validate the threshold and pixel-area limits against + the selected layer. Use `publishMask=true` only when the mask should appear as + a static layer. Use `exportMask=true` only when mask pixels are needed, and + then follow the shared-frame rules below. + +## Processing and frame transfer + +### Configure the processing pipeline + +1. Read `processing_modules` and remember whether real-time processing is + running. +2. Stop real-time processing before changing bit depth, adding or removing + modules, changing parameters, or resetting accumulated module state. +3. Use `set_processing_bit_depth`, `add_processing_module`, or + `set_processing_module_parameters` only from current module data and the live + schema; preserve fields the user did not request changing. +4. Treat module indexes as positional. Refresh `processing_modules` after every + add or remove before issuing another index-based operation. +5. Confirm `remove_processing_module`. Call `reset_processing_module_state` + only while that module still exists and only when its accumulated state + should be discarded. +6. Restore real-time processing with `set_realtime_processing` only if it was + previously running or the user requests it, then verify processing state and + processed layers. + +### Choose the correct frame source + +- Use `latest_raw_frame` for the newest raw frame from one camera. +- Use `layer_frame` for the current frame of a raw, processed, static, mosaic, + or Gallery layer. +- Use `session_frame` for one retained recording frame. +- Use `session_process_frame` when a retained frame must run through a selected + range of the current processing pipeline. +- Use `frame_mapping_info` only when mapping limits or supported pixel formats + are needed; it does not export a frame. +- Prefer semantic analysis tools over exporting pixels when the requested + result is already available through histogram, pixel, profile, or particle + operations. + +### Complete a shared-frame workflow + +The shared-memory mapping contains one frame at a time for all clients. +`latest_raw_frame`, `layer_frame`, `session_frame`, `session_process_frame`, +`process_frame_mapping`, and `detect_particles(exportMask=true)` can replace its +contents. + +1. Export exactly one intended source into the mapping. +2. Immediately read or copy it, or call `process_frame_mapping` with validated + module bounds. +3. Immediately consume the result with `show_frame_mapping_as_layer` or + `save_frame_mapping`, or copy its pixels in a client that supports reading. +4. Refresh `list_layers` after publishing a layer. Confirm the destination and + base name before saving, and report the resulting path. +5. If any other frame-exporting operation may have run, request the original + frame again instead of trusting the mapping metadata. + +## Acquisition and persistence + +### Run an experiment or recording + +1. Use `experiment_document` as the canonical starting document. Modify only + requested fields and preserve its schema. +2. Call `validate_experiment` and use the returned normalized document before + `start_experiment` or `save_experiment`. +3. Treat `load_experiment` as destructive because it replaces the current + document. Confirm it, then read `experiment_document` again. +4. Confirm output directories and base names before acquisition or saving. Do + not reuse a base name when overwrite was not explicitly requested. +5. Poll `experiment_status` by its returned experiment ID until a terminal + state. Report acquisition progress, writer state, errors, and output paths. +6. Call `cancel_experiment` only after an explicit request, then poll until + cancellation is terminal. +7. Use `record` for a short blocking retained capture. `record(frames=1)` is the + semantic snapshot operation; do not simulate a Gallery button. The same + control connection cannot service polling while `record` is blocked. +8. Use `session_info`, `session_frame`, `session_process_frame`, and + `session_save` before `session_close`. Confirm closing because it discards + retained session state. + +### Run a Stage Mosaic + +1. Verify the camera and XY stage IDs and read the current XY position. +2. Confirm rows, columns, pixel size, step sizes, settling time, optional save + directory, and whether to return to the initial position. +3. Call `start_stage_mosaic`, then poll `stage_mosaic_status` until completed, + canceled, or failed. +4. Call `cancel_stage_mosaic` only after explicit approval. Do not start another + mosaic while one is active. +5. Use the returned session ID for frame access and saving. Read the final stage + position when return-to-start behavior matters. + +## Failure routing + +- No Claude MCP tools: verify the plugin's configured MCP executable and reload + plugins. No Codex MCP tools: inspect its `scopeone` registration and restart. +- MCP process cannot start: correct its executable path; do not launch it as a + separate background process. +- Local API unavailable: start ScopeOne and retry after the desktop app is ready. +- Unknown or stale camera, device, layer, markup, module, experiment, or session: + refresh `state_snapshot` and the relevant list operation; do not guess IDs. +- Hardware mutation failed: read actual hardware state and report the exact + error; do not loop retries. +- Long-running call timed out: inspect status before deciding whether another + start would duplicate active work. +- Shared frame content is stale or ambiguous: export the intended source again. +- File save failed: report the destination and error; do not select a different + path or overwrite a file without approval. +- Confirmation rejected or absent: stop without attempting an alternate route. diff --git a/src/InspectWidget.cpp b/src/InspectWidget.cpp index 5453a29..d67df4c 100644 --- a/src/InspectWidget.cpp +++ b/src/InspectWidget.cpp @@ -423,6 +423,11 @@ namespace scopeone::ui }); } + InspectWidget::~InspectWidget() + { + m_scopeonecore->setActiveHistogramLayer({}); + } + // Enable inspect controls when camera state changes void InspectWidget::onCameraInitialized(bool initialized) { @@ -439,6 +444,7 @@ namespace scopeone::ui void InspectWidget::setCurrentLayer(const QString& layerKey) { m_currentLayerKey = layerKey.trimmed(); + m_scopeonecore->setActiveHistogramLayer(m_currentLayerKey); if (!m_crossSectionLayerKey.isEmpty() && m_crossSectionLayerKey != m_currentLayerKey) { clearCrossSectionProfile(); @@ -487,6 +493,7 @@ namespace scopeone::ui if (!m_currentLayerKey.isEmpty() && !m_availableLayerKeys.contains(m_currentLayerKey)) { m_currentLayerKey.clear(); + m_scopeonecore->setActiveHistogramLayer({}); clearCrossSectionProfile(); } updateLayerVisibility(); @@ -512,6 +519,7 @@ namespace scopeone::ui && !m_availableCameraIds.contains(currentLayerCameraId())) { m_currentLayerKey.clear(); + m_scopeonecore->setActiveHistogramLayer({}); clearCrossSectionProfile(); } updateLayerVisibility(); diff --git a/src/InspectWidget.h b/src/InspectWidget.h index f281e0b..f02b24f 100644 --- a/src/InspectWidget.h +++ b/src/InspectWidget.h @@ -34,7 +34,7 @@ namespace scopeone::ui }; explicit InspectWidget(scopeone::core::ScopeOneCore* core, QWidget* parent = nullptr); - ~InspectWidget() override = default; + ~InspectWidget() override; void onCameraInitialized(bool initialized); void setCurrentLayer(const QString& layerKey); diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index b01257d..b917e0a 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -339,9 +339,9 @@ namespace scopeone::ui this, &MainWindow::handlePreviewMousePosition); connect(m_previewWidget, &PreviewWidget::roiDrawn, this, &MainWindow::handleRoiDrawn); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::newRawFrameReady, + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::rawFramesAcquired, m_previewWidget, &PreviewWidget::trackRawFrameRate); - connect(m_scopeonecore, &scopeone::core::ScopeOneCore::processedFrameReady, + connect(m_scopeonecore, &scopeone::core::ScopeOneCore::processedFramesCompleted, m_previewWidget, &PreviewWidget::trackProcessedFrameRate); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::previewRawFrameReady, this, [this](const scopeone::core::ImageFrame& frame) @@ -351,14 +351,14 @@ namespace scopeone::ui return; } m_previewWidget->setGraphRawFrame(frame); - refreshPreviewCursorStatus(); + schedulePreviewCursorStatusRefresh(); }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::previewProcessedFrameReady, this, [this](const scopeone::core::ImageFrame& frame) { m_previewWidget->setGraphProcessedFrame(frame); - refreshPreviewCursorStatus(); + schedulePreviewCursorStatusRefresh(); }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::staticFramePublished, this, [this](const QString& sourceId, @@ -366,7 +366,7 @@ namespace scopeone::ui const scopeone::core::ImageFrame& frame) { m_previewWidget->setGraphStaticLayerFrame(sourceId, frame); - refreshPreviewCursorStatus(); + schedulePreviewCursorStatusRefresh(); }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::staticFrameRemoved, this, [this](const QString& sourceId) @@ -775,6 +775,12 @@ namespace scopeone::ui { setStatusLabelText(m_statusMessageLabel, tr("Ready"), tr("Latest operation message")); }); + + m_cursorRefreshTimer = new QTimer(this); + m_cursorRefreshTimer->setInterval(50); + m_cursorRefreshTimer->setSingleShot(true); + connect(m_cursorRefreshTimer, &QTimer::timeout, + this, &MainWindow::refreshPreviewCursorStatus); } // Create application menus and persistent actions @@ -1140,6 +1146,17 @@ namespace scopeone::ui setCursorStatus(msg); } + // Coalesce live cursor sampling independently of camera frame rate + void MainWindow::schedulePreviewCursorStatusRefresh() + { + if (m_lastPreviewMousePos.x() >= 0 + && m_lastPreviewMousePos.y() >= 0 + && !m_cursorRefreshTimer->isActive()) + { + m_cursorRefreshTimer->start(); + } + } + // Registers right panel frame sliders for stack backed gallery layers void MainWindow::registerGallerySessionFrameControls( const std::shared_ptr& session, @@ -1293,6 +1310,7 @@ namespace scopeone::ui void MainWindow::handlePreviewMousePosition(const QPoint& pos) { m_lastPreviewMousePos = pos; + m_cursorRefreshTimer->stop(); if (pos.x() < 0 || pos.y() < 0) { clearCursorStatus(); diff --git a/src/MainWindow.h b/src/MainWindow.h index 18d7a1c..abe290e 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -80,6 +80,7 @@ namespace scopeone::ui void setCursorStatus(const QString& text); void clearCursorStatus(); void refreshPreviewCursorStatus(); + void schedulePreviewCursorStatusRefresh(); void registerGallerySessionFrameControls( const std::shared_ptr& session, int frameIndex); @@ -165,6 +166,7 @@ namespace scopeone::ui QLabel* m_statusProcessingLabel{nullptr}; QLabel* m_statusRecordingLabel{nullptr}; QTimer* m_statusMessageTimer{nullptr}; + QTimer* m_cursorRefreshTimer{nullptr}; scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; QString m_currentControlTarget{QStringLiteral("All")}; QPoint m_lastPreviewMousePos{-1, -1}; diff --git a/src/PreviewWidget.cpp b/src/PreviewWidget.cpp index 475dcbb..eecba5c 100644 --- a/src/PreviewWidget.cpp +++ b/src/PreviewWidget.cpp @@ -228,16 +228,19 @@ namespace scopeone::ui QMutexLocker lock(&m_mutex); FrameSourceState& frameState = m_frameSources[normalizedId]; + ++m_nextFrameRevision; bool hadFrame = false; if (role == FrameRole::Processed) { hadFrame = frameState.processedFrame.isValid(); frameState.processedFrame = frame; + frameState.processedRevision = m_nextFrameRevision; } else { hadFrame = frameState.rawFrame.isValid(); frameState.rawFrame = frame; + frameState.rawRevision = m_nextFrameRevision; } if (replacedFrame) { @@ -257,14 +260,14 @@ namespace scopeone::ui } // Counts one frame in a live layer throughput window - void PreviewWidget::updateLayerFps(const QString& layerKey) + void PreviewWidget::updateLayerFps(const QString& layerKey, quint64 frameCount) { FpsState& state = m_fpsStates[layerKey]; if (!state.intervalTimer.isValid()) { state.intervalTimer.start(); } - state.framesSinceUpdate += 1; + state.framesSinceUpdate += frameCount; } // Stores one graph processed frame and updates layer statistics @@ -312,23 +315,23 @@ namespace scopeone::ui update(); } - // Counts one completed processing output before preview coalescing - void PreviewWidget::trackProcessedFrameRate(const ImageFrame& frame) + // Tracks completed processing throughput from aggregated frame counts + void PreviewWidget::trackProcessedFrameRate(const QString& cameraId, quint64 frameCount) { - const QString sourceId = normalizedSourceId(frame.cameraId); - if (!sourceId.isEmpty() && frame.isValid()) + const QString sourceId = normalizedSourceId(cameraId); + if (!sourceId.isEmpty() && frameCount > 0) { - updateLayerFps(previewLayerKey(sourceId, true)); + updateLayerFps(previewLayerKey(sourceId, true), frameCount); } } - // Counts one incoming raw frame before preview coalescing - void PreviewWidget::trackRawFrameRate(const ImageFrame& frame) + // Tracks acquired raw throughput from backend frame counts + void PreviewWidget::trackRawFrameRate(const QString& cameraId, quint64 frameCount) { - const QString sourceId = normalizedSourceId(frame.cameraId); - if (!sourceId.isEmpty() && frame.isValid()) + const QString sourceId = normalizedSourceId(cameraId); + if (!sourceId.isEmpty() && frameCount > 0) { - updateLayerFps(previewLayerKey(sourceId, false)); + updateLayerFps(previewLayerKey(sourceId, false), frameCount); } } @@ -635,6 +638,7 @@ namespace scopeone::ui for (auto it = m_frameSources.begin(); it != m_frameSources.end(); ++it) { it->processedFrame = ImageFrame(); + it->processedRevision = 0; } } @@ -1556,6 +1560,7 @@ namespace scopeone::ui { drawFrameInRect(item.layerKey, frameState.processedFrame, + frameState.processedRevision, displayRect, frameState.flipX, frameState.flipY, @@ -1568,6 +1573,7 @@ namespace scopeone::ui { drawFrameInRect(item.layerKey, frameState.rawFrame, + frameState.rawRevision, displayRect, frameState.flipX, frameState.flipY, @@ -2043,6 +2049,7 @@ namespace scopeone::ui // 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, @@ -2069,23 +2076,28 @@ namespace scopeone::ui } GLuint texId = getOrCreateTexture(textureKey, frame.width, frame.height, internalFormat); + CachedTexture& cachedTexture = m_textureCache[textureKey]; glBindTexture(GL_TEXTURE_2D, texId); - glPixelStorei(GL_UNPACK_ALIGNMENT, unpackAlign); - const int bytesPerPixel = (uploadType == GL_UNSIGNED_SHORT) ? 2 : 1; - if (frame.stride > 0) + if (cachedTexture.uploadedRevision != frameRevision) { - const int rowPixels = frame.stride / bytesPerPixel; - if (rowPixels != frame.width) + glPixelStorei(GL_UNPACK_ALIGNMENT, unpackAlign); + const int bytesPerPixel = (uploadType == GL_UNSIGNED_SHORT) ? 2 : 1; + if (frame.stride > 0) { - glPixelStorei(GL_UNPACK_ROW_LENGTH, rowPixels); + const int rowPixels = frame.stride / bytesPerPixel; + if (rowPixels != frame.width) + { + 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); m_prog.bind(); glActiveTexture(GL_TEXTURE0); diff --git a/src/PreviewWidget.h b/src/PreviewWidget.h index 797f7e3..732873e 100644 --- a/src/PreviewWidget.h +++ b/src/PreviewWidget.h @@ -52,8 +52,8 @@ namespace scopeone::ui void setGraphProcessedFrame(const scopeone::core::ImageFrame& frame); void setGraphRawFrame(const scopeone::core::ImageFrame& frame); - void trackProcessedFrameRate(const scopeone::core::ImageFrame& frame); - void trackRawFrameRate(const scopeone::core::ImageFrame& frame); + void trackProcessedFrameRate(const QString& cameraId, quint64 frameCount); + void trackRawFrameRate(const QString& cameraId, quint64 frameCount); void resetLiveFrameRates(); void setLayerLayoutMode(LayerLayoutMode mode); LayerLayoutMode layerLayoutMode() const; @@ -150,6 +150,8 @@ namespace scopeone::ui { scopeone::core::ImageFrame processedFrame; scopeone::core::ImageFrame rawFrame; + quint64 processedRevision{0}; + quint64 rawRevision{0}; int offsetX{0}; int offsetY{0}; bool flipX{false}; @@ -194,6 +196,7 @@ namespace scopeone::ui mutable QMutex m_mutex; QMap m_frameSources; + quint64 m_nextFrameRevision{0}; int m_zoomPercent{100}; bool m_fitToWindow{true}; QPoint m_viewOffset; @@ -213,6 +216,7 @@ namespace scopeone::ui int width{0}; int height{0}; GLenum internalFormat{0}; + quint64 uploadedRevision{0}; }; QMap m_textureCache; @@ -236,7 +240,7 @@ namespace scopeone::ui MarkupEditMode m_dragMarkupEditMode{MarkupEditMode::None}; bool m_markupDragging{false}; void updateImageDisplay(); - void updateLayerFps(const QString& layerKey); + void updateLayerFps(const QString& layerKey, quint64 frameCount = 1); void updateFrameRates(); bool storeSourceFrame(const QString& sourceId, FrameRole role, @@ -305,6 +309,7 @@ namespace scopeone::ui void ensureGlPipeline(); void drawFrameInRect(const QString& textureKey, const scopeone::core::ImageFrame& frame, + quint64 frameRevision, const QRect& r, bool flipX, bool flipY,