From c50eb5d1111729e1552560e0013cb6b88f234079 Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:47:36 +0200 Subject: [PATCH 1/7] Add OME recording formats --- .gitignore | 54 +- .gitmodules | 3 + README.md | 26 +- ScopeOneCore/.gitignore | 30 +- ScopeOneCore/CMakeLists.txt | 51 +- ScopeOneCore/README.md | 4 +- .../cmake/ScopeOneCoreConfig.cmake.in | 1 + ScopeOneCore/external/ScopeWriter | 1 + .../include/scopeone/ExperimentDocument.h | 9 +- ScopeOneCore/include/scopeone/ScopeOneCore.h | 2 +- ScopeOneCore/internal/OmeZarrStorage.h | 14 + ScopeOneCore/internal/RecordingManager.h | 16 +- ScopeOneCore/python/scopeone/README.md | 26 +- .../python/scopeone/src/scopeone/client.py | 8 +- .../python/scopeone/src/scopeone/core.py | 8 +- .../python/scopeone/src/scopeone/session.py | 2 +- ScopeOneCore/src/ExperimentDocument.cpp | 54 +- ScopeOneCore/src/OmeZarrStorage.cpp | 263 +++++++ ScopeOneCore/src/RecordingManager.cpp | 677 +++++++++--------- ScopeOneCore/src/ScopeOneCore.cpp | 154 +++- ScopeOneCore/src/StageMosaicManager.cpp | 3 +- scripts/build.ps1 | 174 ++++- src/RecordingWidget.cpp | 2 + src/ScopeOneLocalApiServer.cpp | 49 +- src/ScopeOneMcpServer.cpp | 18 +- 25 files changed, 1161 insertions(+), 488 deletions(-) create mode 100644 .gitmodules create mode 160000 ScopeOneCore/external/ScopeWriter create mode 100644 ScopeOneCore/internal/OmeZarrStorage.h create mode 100644 ScopeOneCore/src/OmeZarrStorage.cpp diff --git a/.gitignore b/.gitignore index beb1519..6ebdb3c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,33 +1,35 @@ -* -!src/ -!src/** -!ScopeOneCore/ -!ScopeOneCore/** -ScopeOneCore/install/** -ScopeOneCore/build/** -ScopeOneCore/external/** -ScopeOneCore/python/** -!scripts/ -!scripts/** +/* +!/src/ +!/src/** +!/ScopeOneCore/ +!/ScopeOneCore/** +/ScopeOneCore/install/ +/ScopeOneCore/build/ +/ScopeOneCore/external/* +!/ScopeOneCore/external/ScopeWriter/ +!/ScopeOneCore/external/ScopeWriter/** +/ScopeOneCore/python/* +!/scripts/ +!/scripts/** -!.github/ -!.github/** +!/.github/ +!/.github/** -!resources/ -!resources/** +!/resources/ +!/resources/** -!config/ -config/* -!config/MMConfig_demo.cfg -!config/dual_hamamatsu_897D.cfg -!config/MMConfig_demo_dual.cfg +!/config/ +/config/* +!/config/MMConfig_demo.cfg +!/config/dual_hamamatsu_897D.cfg +!/config/MMConfig_demo_dual.cfg -!CMakeLists.txt -!README.md -!VERSION +!/CMakeLists.txt +!/README.md +!/VERSION -!.gitignore +!/.gitignore -build/** -ref/** +/build/ +/ref/ **/.DS_Store diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..125e6b5 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "ScopeOneCore/external/ScopeWriter"] + path = ScopeOneCore/external/ScopeWriter + url = https://github.com/Experimental-Microscopy-Lab/ScopeWriter.git diff --git a/README.md b/README.md index 0da6777..27197cd 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,22 @@ There is an example dual-camera .cfg file in the config folder, just change the - [Visual Studio 2022](https://visualstudio.microsoft.com/vs/) (MSVC v143 toolset) - [Qt](https://www.qt.io/development/download-qt-installer-oss) 6.9.1 (msvc2022_64) - OpenCV 4.12.0 -- libtiff 4.7.1, -- zlib 1.3.1 - mmCoreAndDevices -Extract the bundled third-party dependencies into `ScopeOneCore/external` under this repository. Expected third-party dependencies path layout: +Place OpenCV and MMCore under `ScopeOneCore/external`. ScopeWriter is maintained in +its own repository and included here as a Git submodule. Clone ScopeOne with: + +```powershell +git clone --recurse-submodules https://github.com/Experimental-Microscopy-Lab/ScopeOne.git +``` + +For an existing checkout, initialize ScopeWriter with: + +```powershell +git submodule update --init --recursive +``` + +The expected layout is: ```text ScopeOne/ @@ -56,11 +67,12 @@ ScopeOne/ external/ mmCoreAndDevices/ opencv-4.12.0/ - tiff-4.7.1/ - zlib-1.3.1/ + ScopeWriter/ ``` - +ScopeWriter contains its filesystem Zarr V3 writer and carries libtiff, zlib, zstd and crc32c under its own `third_party` directory. It builds these dependencies from source without downloading packages during CMake configuration. + + **Windows Build Steps:** @@ -183,7 +195,7 @@ cp ScopeOneCore/external/mmCoreAndDevices/DeviceAdapters/DemoCamera/.libs/libmmg ## 🤖 Automation and AI Agents -The desktop app exposes a language-neutral local control API and shared-memory frame channel. An AI agent does not run inside ScopeOne or depend on Python. A tool adapter can discover supported operation groups with the `capabilities` request, read a structured observation with `state_snapshot`, and invoke the exposed camera, stage, mosaic, processing, image analysis, experiment, recording, layer, and markup operations. Requests may carry an ID that is echoed by the app for correlation. +The desktop app exposes a language-neutral local control API and shared-memory frame channel. An AI agent does not run inside ScopeOne or depend on Python. A tool adapter can discover supported operation groups with the `capabilities` request, read a structured observation with `state_snapshot`, and invoke the exposed camera, stage, mosaic, processing, image analysis, experiment, recording, layer, and markup operations. Requests may carry an ID that is echoed by the app for correlation. Recording save operations use `ome-tiff` by default and also accept `ome-zarr`, `tiff` and `binary`. The API reports which operations mutate hardware, write files, remove state, or may run for a long time. An agent adapter should request user confirmation before those operations and verify the result with the returned read-back value, experiment status, or a new state snapshot. The Python package is one optional client implementation. See the [Python client and Local API protocol guide](ScopeOneCore/python/scopeone/README.md) for protocol details and runnable examples. diff --git a/ScopeOneCore/.gitignore b/ScopeOneCore/.gitignore index 10016e8..b17a460 100644 --- a/ScopeOneCore/.gitignore +++ b/ScopeOneCore/.gitignore @@ -1,20 +1,20 @@ -!include/ -!internal/ +!/include/ +!/internal/ !/src/ -!CMakeLists.txt -!README.md +!/CMakeLists.txt +!/README.md -!python/ -!python/scopeone/ -!python/scopeone/.gitignore -!python/scopeone/README.md -!python/scopeone/pyproject.toml -!python/scopeone/examples/ -!python/scopeone/examples/** -!python/scopeone/src/ -!python/scopeone/src/scopeone/ -!python/scopeone/src/scopeone/**/*.py -!python/scopeone/src/scopeone/*.py +!/python/ +!/python/scopeone/ +!/python/scopeone/.gitignore +!/python/scopeone/README.md +!/python/scopeone/pyproject.toml +!/python/scopeone/examples/ +!/python/scopeone/examples/** +!/python/scopeone/src/ +!/python/scopeone/src/scopeone/ +!/python/scopeone/src/scopeone/**/*.py +!/python/scopeone/src/scopeone/*.py !/.gitignore diff --git a/ScopeOneCore/CMakeLists.txt b/ScopeOneCore/CMakeLists.txt index 727ab1b..453ceab 100644 --- a/ScopeOneCore/CMakeLists.txt +++ b/ScopeOneCore/CMakeLists.txt @@ -1,19 +1,16 @@ -cmake_minimum_required(VERSION 3.16) +cmake_minimum_required(VERSION 3.23) set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS "${CMAKE_CURRENT_LIST_DIR}/../VERSION") file(STRINGS "${CMAKE_CURRENT_LIST_DIR}/../VERSION" SCOPEONE_VERSION LIMIT_COUNT 1) project(ScopeOneCore VERSION "${SCOPEONE_VERSION}" LANGUAGES CXX) set(CMAKE_CXX_STANDARD 20) -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTORCC ON) -set(CMAKE_AUTOUIC ON) include(GNUInstallDirs) include(CMakePackageConfigHelpers) set(CMAKE_INSTALL_PREFIX "${CMAKE_CURRENT_SOURCE_DIR}/install") if (WIN32) - set(CMAKE_PREFIX_PATH "C:/Qt/6.11.0/msvc2022_64") + list(PREPEND CMAKE_PREFIX_PATH "C:/Qt/6.11.0/msvc2022_64") endif () set(MMCORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/external/mmCoreAndDevices") @@ -22,21 +19,19 @@ set(MMDEVICE_INCLUDE_DIR "${MMCORE_DIR}/MMDevice") find_package(Qt6 COMPONENTS Core Gui Network Concurrent REQUIRED) +set(SCOPEWRITER_BUILD_TESTS OFF) +add_subdirectory(external/ScopeWriter) + +set(CMAKE_AUTOMOC ON) +set(CMAKE_AUTORCC ON) +set(CMAKE_AUTOUIC ON) + if (WIN32) set(MMCORE_BIN_DIR "${MMCORE_DIR}/build/Release/x64") set(OPENCV_DIR "${CMAKE_CURRENT_SOURCE_DIR}/external/opencv-4.12.0") set(OpenCV_DIR "${OPENCV_DIR}/build") set(OPENCV_BIN_DIR "${OPENCV_DIR}/build/x64/vc16/bin") - set(ZLIB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/external/zlib-1.3.1") - set(ZLIB_BIN_DIR "${ZLIB_DIR}/build/Release") - set(ZLIB_INCLUDE_DIR "${ZLIB_DIR}" "${ZLIB_DIR}/build") - set(ZLIB_LIB "${ZLIB_BIN_DIR}/zlib.lib") - - set(TIFF_DIR "${CMAKE_CURRENT_SOURCE_DIR}/external/tiff-4.7.1") - set(TIFF_BIN_DIR "${TIFF_DIR}/build/libtiff/Release") - set(TIFF_INCLUDE_DIRS "${TIFF_DIR}/libtiff;${TIFF_DIR}/build/libtiff") - set(TIFF_LIB "${TIFF_BIN_DIR}/tiff.lib") find_package(OpenCV REQUIRED) @@ -45,13 +40,9 @@ if (WIN32) "${OPENCV_BIN_DIR}/opencv_videoio_ffmpeg*_64.dll" ) file(GLOB MMCORE_RUNTIME_DLLS "${MMCORE_BIN_DIR}/*.dll") - set(TIFF_RUNTIME_DLL "${TIFF_BIN_DIR}/tiff.dll") - set(ZLIB_RUNTIME_DLL "${ZLIB_BIN_DIR}/zlib.dll") set(SCOPEONE_RUNTIME_DLLS ${OPENCV_RUNTIME_DLLS} - "${TIFF_RUNTIME_DLL}" - "${ZLIB_RUNTIME_DLL}" ${MMCORE_RUNTIME_DLLS} ) @@ -62,17 +53,10 @@ if (WIN32) endif () endforeach () else () - # Linux/Unix: use system OpenCV/TIFF/ZLIB, and link the MMCore static libraries + # Linux/Unix: use system OpenCV and link the MMCore static libraries # built from the sibling micro-manager checkout (symlinked into # external/mmCoreAndDevices). No runtime DLLs to stage. find_package(OpenCV REQUIRED COMPONENTS core imgproc) - find_package(TIFF REQUIRED) - find_package(ZLIB REQUIRED) - - set(TIFF_LIB TIFF::TIFF) - set(TIFF_INCLUDE_DIRS "") - set(ZLIB_LIB ZLIB::ZLIB) - set(ZLIB_INCLUDE_DIR "") set(MMCORE_BIN_DIR "") add_library(MMCore STATIC IMPORTED GLOBAL) @@ -110,6 +94,7 @@ set(CORE_SOURCES src/DifferentialRollingModule.cpp src/MDAManager.cpp src/RecordingManager.cpp + src/OmeZarrStorage.cpp src/CameraBackend.cpp src/CameraManager.cpp src/NativeCameraBackend.cpp @@ -134,6 +119,7 @@ set(CORE_HEADERS internal/DifferentialRollingModule.h internal/MDAManager.h internal/RecordingManager.h + internal/OmeZarrStorage.h internal/CameraBackend.h internal/CameraManager.h include/scopeone/ImageFrame.h @@ -153,8 +139,10 @@ target_link_libraries(ScopeOneCore Qt::Network Qt::Concurrent ${OpenCV_LIBS} - ${TIFF_LIB} - ${ZLIB_LIB} + TIFF::tiff + ScopeWriter::ScopeWriter + Crc32c::crc32c + zstd::libzstd_static ) target_compile_definitions(ScopeOneCore PRIVATE MMDEVICE_CLIENT_BUILD @@ -171,8 +159,6 @@ target_include_directories(ScopeOneCore ${MMCORE_INCLUDE_DIR} ${MMDEVICE_INCLUDE_DIR} ${OpenCV_INCLUDE_DIRS} - ${TIFF_INCLUDE_DIRS} - ${ZLIB_INCLUDE_DIR} ) target_precompile_headers(ScopeOneCore PRIVATE @@ -205,6 +191,11 @@ target_precompile_headers(ScopeOneCore PRIVATE target_link_directories(ScopeOneCore PRIVATE ${MMCORE_BIN_DIR}) scopeone_add_runtime_copy_commands(ScopeOneCore $) +add_custom_command(TARGET ScopeOneCore POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + $ + $ +) add_executable(ScopeOne_Agent src/AgentMain.cpp diff --git a/ScopeOneCore/README.md b/ScopeOneCore/README.md index 1ba73b0..f504d66 100644 --- a/ScopeOneCore/README.md +++ b/ScopeOneCore/README.md @@ -109,8 +109,8 @@ External code should enter through these headers and `scopeone::core::ScopeOneCo ## Processing Data Flow -`ImageFrame` is the frame model used by preview, processing, recording, gallery and the local API. Use `processFrameThrough(...)` to stop at one pipeline stage and `processFrameFrom(...)` to continue from a later module after an edited frame is written back. Saved TIFF and binary recording outputs are read back asynchronously through `ScopeOneCore::requestRecordingSessionFrame(...)`. Live preview processing and synchronous API processing use separate runtime pipeline state so offline frame edits do not change live module buffers. +`ImageFrame` is the frame model used by preview, processing, recording, gallery and the local API. Use `processFrameThrough(...)` to stop at one pipeline stage and `processFrameFrom(...)` to continue from a later module after an edited frame is written back. Saved OME-TIFF, OME-Zarr, TIFF and binary recording outputs are read back asynchronously through `ScopeOneCore::requestRecordingSessionFrame(...)`. Live preview processing and synchronous API processing use separate runtime pipeline state so offline frame edits do not change live module buffers. Raw live frames, processed live frames, static tool/gallery frames, external API frames and session frame sources are routed through the core frame graph. UI preview widgets keep only a render cache, and callers should use `ScopeOneCore` frame facade methods instead of reading camera managers, recording sessions or preview cache state directly. -`ExperimentPlan` is the single recording and MDA input contract. `ExperimentDocument` adds actual event results, software and device provenance, output files, stable image layers, pixel-to-sensor transforms and markups. Schema version 1 documents are validated strictly and can be round-tripped with `experimentDocumentToJson(...)`, `saveExperimentDocument(...)` and `loadExperimentDocument(...)`; image payloads remain in TIFF, binary files or shared memory. +`ExperimentPlan` is the single recording and MDA input contract. `ExperimentDocument` adds actual event results, software and device provenance, output files, stable image layers, pixel-to-sensor transforms and markups. Schema version 2 documents include the sample-plane `pixelSizeUm` calibration, are validated strictly, and can be round-tripped with `experimentDocumentToJson(...)`, `saveExperimentDocument(...)` and `loadExperimentDocument(...)`; OME-TIFF is the default disk format. All OME-TIFF, OME-Zarr, plain TIFF and binary frame writing is delegated to the reusable ScopeWriter library. ScopeOne retains acquisition orchestration, experiment documents, output naming and reading. diff --git a/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in b/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in index 601bf9a..5e01220 100644 --- a/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in +++ b/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in @@ -3,6 +3,7 @@ include(CMakeFindDependencyMacro) find_dependency(Qt6 REQUIRED COMPONENTS Core Gui) +find_dependency(ScopeWriter CONFIG REQUIRED) include("${CMAKE_CURRENT_LIST_DIR}/ScopeOneCoreTargets.cmake") diff --git a/ScopeOneCore/external/ScopeWriter b/ScopeOneCore/external/ScopeWriter new file mode 160000 index 0000000..31227bf --- /dev/null +++ b/ScopeOneCore/external/ScopeWriter @@ -0,0 +1 @@ +Subproject commit 31227bfd8aa9652adb40b04fbefa7766970a0011 diff --git a/ScopeOneCore/include/scopeone/ExperimentDocument.h b/ScopeOneCore/include/scopeone/ExperimentDocument.h index 0d059c8..6dc7e41 100644 --- a/ScopeOneCore/include/scopeone/ExperimentDocument.h +++ b/ScopeOneCore/include/scopeone/ExperimentDocument.h @@ -17,13 +17,15 @@ namespace scopeone::core { - inline constexpr int kExperimentDocumentSchemaVersion = 1; + inline constexpr int kExperimentDocumentSchemaVersion = 2; inline constexpr int kProcessingModuleSchemaVersion = 1; enum class RecordingFormat { Tiff = 0, - Binary = 1 + Binary = 1, + OmeTiff = 2, + OmeZarr = 3 }; enum class RecordingAxis @@ -98,7 +100,7 @@ namespace scopeone::core int schemaVersion{kExperimentDocumentSchemaVersion}; QString experimentId; QStringList cameraIds; - RecordingFormat format{RecordingFormat::Tiff}; + RecordingFormat format{RecordingFormat::OmeTiff}; bool streamToDisk{true}; bool enableCompression{false}; int compressionLevel{6}; @@ -108,6 +110,7 @@ namespace scopeone::core double burstIntervalMs{0.0}; double mdaIntervalMs{0.0}; double exposureMs{0.0}; + double pixelSizeUm{0.0}; std::vector order{RecordingAxis::Time, RecordingAxis::Z, RecordingAxis::XY}; std::vector positions; std::vector zPositions; diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index 69c2891..0056904 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -56,7 +56,7 @@ namespace scopeone::core struct RecordingSaveOptions { - RecordingFormat format{RecordingFormat::Tiff}; + RecordingFormat format{RecordingFormat::OmeTiff}; bool enableCompression{false}; int compressionLevel{6}; QString saveDir; diff --git a/ScopeOneCore/internal/OmeZarrStorage.h b/ScopeOneCore/internal/OmeZarrStorage.h new file mode 100644 index 0000000..1b6b0ef --- /dev/null +++ b/ScopeOneCore/internal/OmeZarrStorage.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include "scopeone/ExperimentDocument.h" +#include "scopeone/ImageFrame.h" + +namespace scopeone::core::internal +{ + ImageFrame readOmeZarrFrame(const QString& rootPath, + const QString& cameraId, + int frameIndex, + const ExperimentDocument& document); +} diff --git a/ScopeOneCore/internal/RecordingManager.h b/ScopeOneCore/internal/RecordingManager.h index e10ecb3..5942066 100644 --- a/ScopeOneCore/internal/RecordingManager.h +++ b/ScopeOneCore/internal/RecordingManager.h @@ -3,7 +3,6 @@ #include "scopeone/ScopeOneCore.h" #include "internal/MDAManager.h" #include -#include #include #include #include @@ -96,11 +95,15 @@ namespace scopeone::core::internal ImageFrame frame; Source source{Source::PreviewStream}; + AcquisitionEvent event; + bool hasEvent{false}; }; struct WriteTask { ImageFrame frame; + AcquisitionEvent event; + bool hasEvent{false}; }; struct CameraOutput @@ -109,8 +112,9 @@ namespace scopeone::core::internal QString rawPath; QString frameInfoPath; QString metadataFileName; - QFile frameInfoFile; + QJsonObject cameraProperties; void* backend{nullptr}; + quint64 acquisitionStartTimestampNs{0}; int width{0}; int height{0}; int bits{0}; @@ -154,7 +158,7 @@ namespace scopeone::core::internal QElapsedTimer elapsedTimer; qint64 lastBurstEndMs{0}; int phase{kRecordingPhaseIdle}; - RecordingFormat format{RecordingFormat::Tiff}; + RecordingFormat format{RecordingFormat::OmeTiff}; bool streamToDisk{true}; bool enableCompression{false}; int compressionLevel{6}; @@ -188,7 +192,8 @@ namespace scopeone::core::internal void finishRecording(ExperimentRunState state, const QString& errorMessage = QString()); static bool writeSessionDocument(const std::shared_ptr& session, QString& errorMessage); - void appendPreviewEventRecord(const ImageFrame& frame); + void appendPreviewEventRecord(const ImageFrame& frame, + const AcquisitionEvent& event); void primeLastFrameIndices(); void emitProgress(bool force = false); bool startStreamingOutputs(const ExperimentPlan& plan); @@ -205,7 +210,8 @@ namespace scopeone::core::internal static QString updateSessionResult(const std::shared_ptr& session, const QString& result, bool saved); - bool enqueueFrame(const ImageFrame& frame); + bool enqueueFrame(const ImageFrame& frame, + const AcquisitionEvent* event = nullptr); bool shouldAcceptFrame(const FramePacket& packet) const; void ingestFrame(const FramePacket& packet); diff --git a/ScopeOneCore/python/scopeone/README.md b/ScopeOneCore/python/scopeone/README.md index e1aa874..042cc2b 100644 --- a/ScopeOneCore/python/scopeone/README.md +++ b/ScopeOneCore/python/scopeone/README.md @@ -77,8 +77,8 @@ stage = session.process_frame("Camera", 0, end_module_index=0) edited_stage = np.clip(stage.image * 1.2, 0, (1 << stage.bits_per_sample) - 1) result = scopeone.continue_pipeline(stage, image=edited_stage) scopeone.show_frame(result, layer_id="python_result", name="Python Result") -paths = scopeone.save_frame(result, r"C:\data", base_name="python_result", format="tiff") -session_paths = session.save(r"C:\data", base_name="raw_session", format="tiff") +paths = scopeone.save_frame(result, r"C:\data", base_name="python_result", format="ome-tiff") +session_paths = session.save(r"C:\data", base_name="raw_session", format="ome-tiff") session.close() scopeone.stop_preview("Camera") @@ -189,19 +189,19 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.latest_raw_frame(camera)` - `ScopeOne.layer_frame(layer_key)` - `ScopeOne.show_frame_mapping_as_layer(layer_id="python_result", name="Python Result", camera=None)` -- `ScopeOne.save_frame_mapping(save_dir, base_name, format="tiff", compression=False, compression_level=6, camera=None)` +- `ScopeOne.save_frame_mapping(save_dir, base_name, format="ome-tiff", compression=False, compression_level=6, camera=None)` - `ScopeOne.continue_pipeline(frame, image=None)` - `ScopeOne.show_frame(frame, image=None, layer_id="python_result", name="Python Result")` - `ScopeOne.show_image(image, layer_id="python_result", name="Python Result", camera="python", bits_per_sample=None)` -- `ScopeOne.save_frame(frame, save_dir, base_name, image=None, format="tiff", compression=False, compression_level=6)` -- `ScopeOne.save_image(image, save_dir, base_name, format="tiff", compression=False, compression_level=6, camera="python", bits_per_sample=None)` -- `ScopeOne.record(frames, camera="All", timeout_ms=120000, mda_interval_ms=0.0, z_positions=None, positions=None, order=None)` +- `ScopeOne.save_frame(frame, save_dir, base_name, image=None, format="ome-tiff", compression=False, compression_level=6)` +- `ScopeOne.save_image(image, save_dir, base_name, format="ome-tiff", compression=False, compression_level=6, camera="python", bits_per_sample=None)` +- `ScopeOne.record(frames, camera="All", timeout_ms=120000, mda_interval_ms=0.0, z_positions=None, positions=None, order=None, pixel_size_um=0.0)` - `RecordingSession.camera_ids()` - `RecordingSession.frame_count(camera=None)` - `RecordingSession.frame(camera, index)` - `RecordingSession.process_frame(camera, index, start_module_index=None, end_module_index=None)` - `RecordingSession.frames(camera)` -- `RecordingSession.save(save_dir, base_name, format="tiff", compression=False, compression_level=6)` +- `RecordingSession.save(save_dir, base_name, format="ome-tiff", compression=False, compression_level=6)` - `RecordingSession.close()` - `FrameResult.write(image=None)` @@ -293,14 +293,14 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `remove_processing_module`: fields `index`. - `set_processing_module_parameters`: fields `index`, `parameters`. - `reset_processing_module_state`: fields `index`. -- `experiment_document`: returns the shared schema version 1 document, initializing a Draft from current cameras, processing, layers, and markups when needed; response `document`. +- `experiment_document`: returns the shared schema version 2 document, initializing a Draft from current cameras, processing, layers, and markups when needed; response `document`. - `validate_experiment`: field `document`; response contains the canonical validated `document`. - `save_experiment`: fields `filePath`, `document`; validates and atomically saves the document. - `load_experiment`: field `filePath`; replaces the shared UI document when no experiment is running and responds with `document`. - `start_experiment`: field `document`; starts a validated Draft asynchronously and responds with `experimentId`, `state`, and `document`. - `experiment_status`: field `experimentId`; response `state`, `cancelRequested`, `document`, live `progress` and `writer` state while active, and completed recording session details when available. - `cancel_experiment`: field `experimentId`; requests cancellation and returns the current experiment status. -- `record`: fields `frames`, `camera`, `timeoutMs`, `mdaIntervalMs`, `zPositions`, `positions`, `order`; response `sessionId`, `cameraIds`. +- `record`: fields `frames`, `camera`, `timeoutMs`, `mdaIntervalMs`, `pixelSizeUm`, `zPositions`, `positions`, `order`; response `sessionId`, `cameraIds`. - `session_info`: fields `sessionId`; response `cameraIds`, `frameCount`, `frameCounts`. - `session_close`: fields `sessionId`; releases the recorded session held by the app. - `session_frame`: fields `sessionId`, `camera`, `index`; response `mappingName`, `mappingSize`, and frame metadata. @@ -309,8 +309,8 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `session_process_frame`: fields `sessionId`, `camera`, `index`, optional `startModuleIndex` or `endModuleIndex`; response `mappingName`, `mappingSize`, frame metadata, and optional stage metadata. - `process_frame_mapping`: optional fields `camera`, `startModuleIndex` or `endModuleIndex`; response `mappingName`, `mappingSize`, frame metadata, and optional stage metadata. - `show_frame_mapping_as_layer`: optional fields `camera`, `layerId`, `name`; imports the current shared memory frame as a preview layer and returns `layerKey`. -- `save_frame_mapping`: fields `saveDir`, `baseName`, `format`, `compression`, `compressionLevel`, optional field `camera`; imports the current shared memory frame and saves it as a one-frame output. -- `session_save`: fields `sessionId`, `saveDir`, `baseName`, `format`, `compression`, `compressionLevel`; response `paths`. +- `save_frame_mapping`: fields `saveDir`, `baseName`, `format` (`ome-tiff`, `ome-zarr`, `tiff` or `binary`), `compression`, `compressionLevel`, optional field `camera`; imports the current shared memory frame and saves it as a one-frame output. +- `session_save`: fields `sessionId`, `saveDir`, `baseName`, `format` (`ome-tiff`, `ome-zarr`, `tiff` or `binary`), `compression`, `compressionLevel`; response `paths`. ### Record request @@ -321,6 +321,7 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo "camera": "Camera", "timeoutMs": 120000, "mdaIntervalMs": 0.0, + "pixelSizeUm": 0.0, "zPositions": [0.0, 1.0], "positions": [[0.0, 0.0]], "order": ["time", "z", "xy"] @@ -328,6 +329,7 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo ``` `record` returns `sessionId` and `cameraIds`. If `zPositions` or `positions` is non-empty, recording uses the MDA snap path. If both are empty, recording uses the preview/raw-frame path. +`pixelSizeUm` overrides the active Micro-Manager calibration when positive. Zero uses the active calibration and leaves OME physical pixel size unset when no calibration is available. For timed MDA with more than one time point, `order` must begin with `time` so event start times remain monotonic. The initially created document is a complete editable Draft with in-memory recording enabled by default. Set `plan.streamToDisk`, `plan.saveDir`, and `plan.baseName` together for streamed output. Experiment documents are parsed strictly: every schema field is required, unknown fields and unsupported schema versions are rejected, and `start_experiment` accepts only Draft documents whose camera IDs are currently available. `start_experiment` is non-blocking; use the returned `ExperimentSession` or the direct status and cancel methods to control the run. Call `ExperimentSession.close()` after completion to release retained recording frames while keeping document status available. @@ -343,7 +345,7 @@ Processing module editing follows the desktop UI rules: stop real-time processin - Pixel data starts at `scopeone::core::kSharedFrameHeaderSize` - Python reads this through `scopeone.shm.frame_to_ndarray()` - Responses include `camera`, `width`, `height`, `stride`, string `payloadBytes`, `pixelFormat`, `bitsPerSample`, string `frameIndex`, string `timestampNs`, `sourceRoiX`, `sourceRoiY`, `sourceRoiWidth`, `sourceRoiHeight`, and `sourceRoiValid`. -- Session frames can come from memory, saved TIFF stacks, or saved binary streams. +- Session frames can come from memory, saved OME-TIFF or OME-Zarr outputs, TIFF stacks, or saved binary streams. - `ScopeOne.latest_raw_frame(...)`, `ScopeOne.layer_frame(...)`, `RecordingSession.frame(...)`, `RecordingSession.frames(...)`, `RecordingSession.process_frame(...)`, and `ScopeOne.process_frame_mapping(...)` return `FrameResult` objects. `latest_raw_frame` exports the current live raw frame for Python processing, while `layer_frame` accepts any key returned by `list_layers`. Particle detection can return its mask as a `FrameResult` with `export_mask=True` or publish the mask directly with `publish_mask=True`. `session_process_frame` reads a stored session frame, processes it through the current pipeline, and writes the result into the same shared memory block. Use `endModuleIndex` to stop after one stage. The response then includes `moduleIndex` and `nextModuleIndex`. Pass the edited numpy array to `ScopeOne.continue_pipeline(frame, image=edited_image)` to write it back and continue with the next pipeline stage. Pass a frame and optional edited image to `ScopeOne.show_frame(frame, image=edited_image)` to display it in the ScopeOne preview as a static layer. Use `ScopeOne.save_frame(frame, save_dir, base_name, image=edited_image)` to save the current Python/C++ result directly. `FrameResult.write()` requires the shared mapping to still contain the same frame metadata, so request the frame again after another frame export overwrites the mapping. `process_frame_mapping`, `show_frame_mapping_as_layer`, and `save_frame_mapping` reuse the last exported or explicitly imported camera id when `camera` is omitted. Provide `camera` the first time a mapping was written by an external client. diff --git a/ScopeOneCore/python/scopeone/src/scopeone/client.py b/ScopeOneCore/python/scopeone/src/scopeone/client.py index fa34c0b..4738035 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/client.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/client.py @@ -1113,6 +1113,7 @@ def record( z_positions: list[float] | None = None, positions: list[tuple[float, float]] | None = None, order: list[str] | None = None, + pixel_size_um: float = 0.0, ): request = { "type": "record", @@ -1120,6 +1121,7 @@ def record( "camera": camera, "timeoutMs": timeout_ms, "mdaIntervalMs": float(mda_interval_ms), + "pixelSizeUm": float(pixel_size_um), } if z_positions is not None: request["zPositions"] = [float(z) for z in z_positions] @@ -1249,7 +1251,7 @@ def save_frame_mapping( self, save_dir: str, base_name: str, - format: str = "tiff", + format: str = "ome-tiff", compression: bool = False, compression_level: int = 6, camera: str | None = None, @@ -1272,7 +1274,7 @@ def save_image( image: object, save_dir: str, base_name: str, - format: str = "tiff", + format: str = "ome-tiff", compression: bool = False, compression_level: int = 6, camera: str = "python", @@ -1465,7 +1467,7 @@ def save( self, save_dir: str, base_name: str, - format: str = "tiff", + format: str = "ome-tiff", compression: bool = False, compression_level: int = 6, ): diff --git a/ScopeOneCore/python/scopeone/src/scopeone/core.py b/ScopeOneCore/python/scopeone/src/scopeone/core.py index 9bfdf5b..aacb638 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/core.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/core.py @@ -406,7 +406,7 @@ def save_frame_mapping( self, save_dir: str, base_name: str, - format: str = "tiff", + format: str = "ome-tiff", compression: bool = False, compression_level: int = 6, camera: str | None = None, @@ -441,7 +441,7 @@ def save_image( image: object, save_dir: str, base_name: str, - format: str = "tiff", + format: str = "ome-tiff", compression: bool = False, compression_level: int = 6, camera: str = "python", @@ -483,7 +483,7 @@ def save_frame( save_dir: str, base_name: str, image: object | None = None, - format: str = "tiff", + format: str = "ome-tiff", compression: bool = False, compression_level: int = 6, ): @@ -527,6 +527,7 @@ def record( z_positions: list[float] | None = None, positions: list[tuple[float, float]] | None = None, order: list[str] | None = None, + pixel_size_um: float = 0.0, ) -> RecordingSession: return RecordingSession( self._client.record( @@ -537,5 +538,6 @@ def record( z_positions, positions, order, + pixel_size_um, ) ) diff --git a/ScopeOneCore/python/scopeone/src/scopeone/session.py b/ScopeOneCore/python/scopeone/src/scopeone/session.py index 6138387..2d25da1 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/session.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/session.py @@ -68,7 +68,7 @@ def save( self, save_dir: str, base_name: str, - format: str = "tiff", + format: str = "ome-tiff", compression: bool = False, compression_level: int = 6, ): diff --git a/ScopeOneCore/src/ExperimentDocument.cpp b/ScopeOneCore/src/ExperimentDocument.cpp index 1968736..9d443d8 100644 --- a/ScopeOneCore/src/ExperimentDocument.cpp +++ b/ScopeOneCore/src/ExperimentDocument.cpp @@ -627,6 +627,10 @@ namespace scopeone::core { switch (format) { + case RecordingFormat::OmeTiff: + return QStringLiteral("OmeTiff"); + case RecordingFormat::OmeZarr: + return QStringLiteral("OmeZarr"); case RecordingFormat::Tiff: return QStringLiteral("Tiff"); case RecordingFormat::Binary: @@ -651,6 +655,16 @@ namespace scopeone::core bool parseRecordingFormat(const QString& name, RecordingFormat& format) { + if (name == QStringLiteral("OmeTiff")) + { + format = RecordingFormat::OmeTiff; + return true; + } + if (name == QStringLiteral("OmeZarr")) + { + format = RecordingFormat::OmeZarr; + return true; + } if (name == QStringLiteral("Tiff")) { format = RecordingFormat::Tiff; @@ -897,7 +911,10 @@ namespace scopeone::core cameraIds.insert(cameraId); } - if (plan.format != RecordingFormat::Tiff && plan.format != RecordingFormat::Binary) + if (plan.format != RecordingFormat::OmeTiff + && plan.format != RecordingFormat::OmeZarr + && plan.format != RecordingFormat::Tiff + && plan.format != RecordingFormat::Binary) { return fail(errorMessage, QStringLiteral("%1.format is invalid").arg(path)); } @@ -906,10 +923,13 @@ namespace scopeone::core return fail(errorMessage, QStringLiteral("%1.compressionLevel must be between 0 and 9").arg(path)); } - if (plan.enableCompression && plan.format != RecordingFormat::Tiff) + if (plan.enableCompression + && plan.format != RecordingFormat::OmeTiff + && plan.format != RecordingFormat::OmeZarr + && plan.format != RecordingFormat::Tiff) { return fail(errorMessage, - QStringLiteral("%1.enableCompression is only supported for Tiff recording").arg(path)); + QStringLiteral("%1.enableCompression is only supported for OME-Zarr or TIFF-based recording").arg(path)); } if (plan.framesPerBurst < 1) { @@ -941,6 +961,11 @@ namespace scopeone::core return fail(errorMessage, QStringLiteral("%1.exposureMs must be a finite non-negative number").arg(path)); } + if (!isFinite(plan.pixelSizeUm) || plan.pixelSizeUm < 0.0) + { + return fail(errorMessage, + QStringLiteral("%1.pixelSizeUm must be a finite non-negative number").arg(path)); + } if (plan.order.empty()) { @@ -970,6 +995,20 @@ namespace scopeone::core { return fail(errorMessage, QStringLiteral("%1.order must contain Time").arg(path)); } + const auto firstNonPositionAxis = std::find_if(plan.order.cbegin(), + plan.order.cend(), + [](RecordingAxis axis) + { + return axis != RecordingAxis::XY; + }); + if (plan.format == RecordingFormat::OmeZarr + && (firstNonPositionAxis == plan.order.cend() + || *firstNonPositionAxis != RecordingAxis::Time)) + { + return fail(errorMessage, + QStringLiteral("%1.order must place Time before Z for OME-Zarr recording") + .arg(path)); + } if (plan.mdaIntervalMs > 0.0 && plan.framesPerBurst > 1 && plan.order.front() != RecordingAxis::Time) @@ -1619,6 +1658,7 @@ namespace scopeone::core object.insert(QStringLiteral("burstIntervalMs"), plan.burstIntervalMs); object.insert(QStringLiteral("mdaIntervalMs"), plan.mdaIntervalMs); object.insert(QStringLiteral("exposureMs"), plan.exposureMs); + object.insert(QStringLiteral("pixelSizeUm"), plan.pixelSizeUm); object.insert(QStringLiteral("order"), order); object.insert(QStringLiteral("positions"), positions); object.insert(QStringLiteral("zPositions"), zPositions); @@ -1786,6 +1826,7 @@ namespace scopeone::core QStringLiteral("burstIntervalMs"), QStringLiteral("mdaIntervalMs"), QStringLiteral("exposureMs"), + QStringLiteral("pixelSizeUm"), QStringLiteral("order"), QStringLiteral("positions"), QStringLiteral("zPositions"), @@ -1839,7 +1880,7 @@ namespace scopeone::core if (!parseRecordingFormat(formatName, parsed.format)) { return fail(errorMessage, - QStringLiteral("%1.format must be 'Tiff' or 'Binary'").arg(path)); + QStringLiteral("%1.format must be 'OmeTiff', 'OmeZarr', 'Tiff' or 'Binary'").arg(path)); } if (!readBool(object, @@ -1886,6 +1927,11 @@ namespace scopeone::core QStringLiteral("exposureMs"), parsed.exposureMs, path, + errorMessage) + || !readDouble(object, + QStringLiteral("pixelSizeUm"), + parsed.pixelSizeUm, + path, errorMessage)) { return false; diff --git a/ScopeOneCore/src/OmeZarrStorage.cpp b/ScopeOneCore/src/OmeZarrStorage.cpp new file mode 100644 index 0000000..67b9e4b --- /dev/null +++ b/ScopeOneCore/src/OmeZarrStorage.cpp @@ -0,0 +1,263 @@ +#include "internal/OmeZarrStorage.h" + +#include +#include +#include +#include +#include +#include + +#include "crc32c/crc32c.h" +#include "zstd.h" + +namespace scopeone::core::internal +{ + namespace + { + constexpr int kChunkEdge = 512; + constexpr quint64 kUnwrittenChunk = (std::numeric_limits::max)(); + + qint64 timeIndexForEvent(const AcquisitionEvent& event, const ExperimentPlan& plan) + { + return static_cast(event.burstIndex) * (std::max)(1, plan.framesPerBurst) + + event.timeIndex; + } + + QString datasetPath(const QString& rootPath, int positionIndex, bool multiPosition) + { + if (!multiPosition) + { + return QDir(rootPath).filePath(QStringLiteral("0")); + } + return QDir(rootPath).filePath( + QStringLiteral("Position %1/0").arg(positionIndex + 1)); + } + + quint64 readLittleEndian64(const char* data) + { + quint64 value = 0; + for (int index = 0; index < 8; ++index) + { + value |= static_cast(static_cast(data[index])) + << (index * 8); + } + return value; + } + + quint32 readLittleEndian32(const char* data) + { + quint32 value = 0; + for (int index = 0; index < 4; ++index) + { + value |= static_cast(static_cast(data[index])) + << (index * 8); + } + return value; + } + + ImageFrame readPlane(const QString& path, + const QString& cameraId, + const FrameRecord& record, + qint64 t, + int z, + bool compressed) + { + if (record.width <= 0 || record.height <= 0 || t < 0 || z < 0) + { + return {}; + } + const int bytesPerPixel = record.pixelFormat == ImagePixelFormat::Mono8 + ? 1 + : record.pixelFormat == ImagePixelFormat::Mono16 ? 2 : 0; + if (bytesPerPixel == 0) + { + return {}; + } + + const int chunkWidth = (std::min)(record.width, kChunkEdge); + const int chunkHeight = (std::min)(record.height, kChunkEdge); + const int chunksX = (record.width + chunkWidth - 1) / chunkWidth; + const int chunksY = (record.height + chunkHeight - 1) / chunkHeight; + const quint64 chunksPerShard = static_cast(chunksX) * chunksY; + if (chunksPerShard == 0 + || chunksPerShard > (std::numeric_limits::max)() / 16) + { + return {}; + } + + QFile shard(QDir(path).filePath( + QStringLiteral("c/%1/0/%2/0/0").arg(t).arg(z))); + if (!shard.open(QIODevice::ReadOnly)) + { + return {}; + } + const qint64 tableBytes = static_cast(chunksPerShard * 16); + const qint64 indexBytes = tableBytes + 4; + if (shard.size() < indexBytes || !shard.seek(shard.size() - indexBytes)) + { + return {}; + } + const QByteArray index = shard.read(indexBytes); + if (index.size() != indexBytes) + { + return {}; + } + const quint32 expectedChecksum = readLittleEndian32(index.constData() + tableBytes); + const quint32 actualChecksum = crc32c::Crc32c( + reinterpret_cast(index.constData()), + static_cast(tableBytes)); + if (expectedChecksum != actualChecksum) + { + return {}; + } + + const qint64 stride = static_cast(record.width) * bytesPerPixel; + const qint64 byteCount = stride * record.height; + const qint64 chunkBytes = static_cast(chunkWidth) + * chunkHeight * bytesPerPixel; + if (stride > (std::numeric_limits::max)() + || byteCount <= 0 + || byteCount > (std::numeric_limits::max)() + || chunkBytes <= 0 + || chunkBytes > (std::numeric_limits::max)()) + { + return {}; + } + + QByteArray bytes(static_cast(byteCount), '\0'); + QByteArray decoded(static_cast(chunkBytes), '\0'); + for (int chunkY = 0; chunkY < chunksY; ++chunkY) + { + for (int chunkX = 0; chunkX < chunksX; ++chunkX) + { + const quint64 chunkIndex = static_cast(chunkY) * chunksX + chunkX; + const char* entry = index.constData() + static_cast(chunkIndex * 16); + const quint64 offset = readLittleEndian64(entry); + const quint64 extent = readLittleEndian64(entry + 8); + if (offset == kUnwrittenChunk && extent == kUnwrittenChunk) + { + continue; + } + if (offset > static_cast(shard.size() - indexBytes) + || extent > static_cast(shard.size() - indexBytes) - offset + || extent > static_cast( + (std::numeric_limits::max)())) + { + return {}; + } + if (!shard.seek(static_cast(offset))) + { + return {}; + } + const QByteArray payload = shard.read(static_cast(extent)); + if (payload.size() != static_cast(extent)) + { + return {}; + } + if (compressed) + { + const size_t result = ZSTD_decompress(decoded.data(), + static_cast(chunkBytes), + payload.constData(), + static_cast(extent)); + if (ZSTD_isError(result) || result != static_cast(chunkBytes)) + { + return {}; + } + } + else + { + if (payload.size() != chunkBytes) + { + return {}; + } + decoded = payload; + } + + const int destinationX = chunkX * chunkWidth; + const int destinationY = chunkY * chunkHeight; + const int copyWidth = (std::min)(chunkWidth, record.width - destinationX); + const int copyHeight = (std::min)(chunkHeight, record.height - destinationY); + for (int row = 0; row < copyHeight; ++row) + { + std::memcpy(bytes.data() + + static_cast(destinationY + row) * stride + + static_cast(destinationX) * bytesPerPixel, + decoded.constData() + + static_cast(row) * chunkWidth * bytesPerPixel, + static_cast(copyWidth) * bytesPerPixel); + } + } + } + + ImageFrame frame; + frame.cameraId = cameraId; + frame.width = record.width; + frame.height = record.height; + frame.stride = static_cast(stride); + frame.pixelFormat = record.pixelFormat; + frame.bitsPerSample = ImageFrame::normalizedBitsPerSample(record.pixelFormat, + record.bitsPerSample); + frame.frameIndex = record.frameIndex; + frame.timestampNs = record.timestampNs; + frame.sourceRoiX = record.sourceRoiX; + frame.sourceRoiY = record.sourceRoiY; + frame.sourceRoiWidth = record.sourceRoiWidth; + frame.sourceRoiHeight = record.sourceRoiHeight; + frame.bytes = std::move(bytes); + return frame.isValid() ? frame : ImageFrame{}; + } + } + + ImageFrame readOmeZarrFrame(const QString& rootPath, + const QString& cameraId, + int frameIndex, + const ExperimentDocument& document) + { + if (rootPath.trimmed().isEmpty() || frameIndex < 0) + { + return {}; + } + int storedIndex = 0; + const AcquisitionEventRecord* selectedEvent = nullptr; + const FrameRecord* selectedFrame = nullptr; + for (const AcquisitionEventRecord& record : document.events) + { + const auto frameIt = record.frames.constFind(cameraId); + if (!record.succeeded || frameIt == record.frames.constEnd()) + { + continue; + } + if (storedIndex++ == frameIndex) + { + selectedEvent = &record; + selectedFrame = &frameIt.value(); + break; + } + } + if (!selectedEvent || !selectedFrame) + { + return {}; + } + + const int positionIndex = selectedEvent->event.positionIndex; + const int z = selectedEvent->event.zIndex; + const qint64 t = timeIndexForEvent(selectedEvent->event, document.plan); + const bool multiPosition = document.plan.positions.size() > 1; + if (t < 0 + || z < 0 + || positionIndex < 0 + || (multiPosition + && positionIndex >= static_cast(document.plan.positions.size())) + || (!multiPosition && positionIndex != 0)) + { + return {}; + } + return readPlane(datasetPath(rootPath, positionIndex, multiPosition), + cameraId, + *selectedFrame, + t, + z, + document.plan.enableCompression); + } +} diff --git a/ScopeOneCore/src/RecordingManager.cpp b/ScopeOneCore/src/RecordingManager.cpp index 9ce01d0..d8a6f43 100644 --- a/ScopeOneCore/src/RecordingManager.cpp +++ b/ScopeOneCore/src/RecordingManager.cpp @@ -2,12 +2,12 @@ #include "internal/CameraManager.h" #include "MMCore.h" +#include #include #include #include #include -#include #include #include #include @@ -17,10 +17,11 @@ #include #include #include +#include #include +#include #include #include -#include namespace scopeone::core::internal { @@ -40,6 +41,10 @@ namespace scopeone::core::internal { switch (format) { + case scopeone::core::RecordingFormat::OmeTiff: + return QStringLiteral("OME-TIFF"); + case scopeone::core::RecordingFormat::OmeZarr: + return QStringLiteral("OME-Zarr"); case scopeone::core::RecordingFormat::Tiff: return QStringLiteral("TIFF"); case scopeone::core::RecordingFormat::Binary: @@ -61,55 +66,36 @@ namespace scopeone::core::internal } } - quint32 pixelFormatId(ImagePixelFormat pixelFormat) - { - switch (pixelFormat) - { - case ImagePixelFormat::Mono8: - return 0; - case ImagePixelFormat::Mono16: - return 1; - default: - return 0; - } - } - - quint64 timestampNsForStorage(const ImageFrame& frame) + QString recordingExtension(scopeone::core::RecordingFormat format) { - if (frame.timestampNs != 0) + switch (format) { - return frame.timestampNs; + case scopeone::core::RecordingFormat::OmeTiff: + return QStringLiteral(".ome.tiff"); + case scopeone::core::RecordingFormat::OmeZarr: + return QStringLiteral(".ome.zarr"); + case scopeone::core::RecordingFormat::Tiff: + return QStringLiteral(".tif"); + case scopeone::core::RecordingFormat::Binary: + return QStringLiteral(".bin"); } - return static_cast(QDateTime::currentMSecsSinceEpoch()) * 1000000ull; + return QStringLiteral(".dat"); } - QByteArray buildImageDescriptionJson(const QString& metadataFileName, const ImageFrame& frame) + bool readFiniteNumber(const QJsonValue& value, double& number) { - QJsonObject imageDescription; - imageDescription["metadata_file"] = metadataFileName; - imageDescription["camera_id"] = frame.cameraId; - imageDescription["frame_index"] = QString::number(frame.frameIndex); - imageDescription["timestamp_ns"] = QString::number(timestampNsForStorage(frame)); - imageDescription["bits_per_sample"] = frame.bitsPerSample; - imageDescription["pixel_format"] = pixelFormatName(frame.pixelFormat); - imageDescription["pixel_format_id"] = static_cast(pixelFormatId(frame.pixelFormat)); - imageDescription["source_roi_x"] = frame.sourceRoiX; - imageDescription["source_roi_y"] = frame.sourceRoiY; - imageDescription["source_roi_width"] = frame.sourceRoiWidth; - imageDescription["source_roi_height"] = frame.sourceRoiHeight; - return QJsonDocument(imageDescription).toJson(QJsonDocument::Compact); + bool ok = false; + number = value.isDouble() ? value.toDouble() : value.toString().toDouble(&ok); + return (value.isDouble() || ok) && std::isfinite(number); } - QString recordingExtension(scopeone::core::RecordingFormat format) + double uniformTimeIncrementMs(const ExperimentPlan& plan) { - switch (format) + if (plan.framesPerBurst < 2 || (plan.burstMode && plan.targetBursts > 1)) { - case scopeone::core::RecordingFormat::Tiff: - return QStringLiteral(".tif"); - case scopeone::core::RecordingFormat::Binary: - return QStringLiteral(".bin"); + return 0.0; } - return QStringLiteral(".dat"); + return plan.mdaIntervalMs > 0.0 ? plan.mdaIntervalMs : 0.0; } bool requiresFrameInfo(scopeone::core::RecordingFormat format) @@ -257,14 +243,19 @@ namespace scopeone::core::internal CameraOutputPaths buildCameraOutputPaths(const QString& outputDir, const QString& baseName, const QString& cameraId, - scopeone::core::RecordingFormat format) + const ExperimentPlan& plan) { CameraOutputPaths paths; paths.rawPath = buildSessionFilePath(outputDir, baseName, cameraId, - recordingExtension(format)); - if (requiresFrameInfo(format)) + recordingExtension(plan.format)); + if (plan.format == scopeone::core::RecordingFormat::OmeTiff + && plan.positions.size() > 1) + { + paths.rawPath.chop(QStringLiteral(".ome.tiff").size()); + } + if (requiresFrameInfo(plan.format)) { paths.frameInfoPath = buildSessionFilePath(outputDir, baseName, @@ -275,49 +266,6 @@ namespace scopeone::core::internal return paths; } - QByteArray frameInfoHeaderLine() - { - return QByteArray( - "camera_id,frame_index,timestamp_ns,width,height,bits_per_sample,stride,pixel_format,pixel_format_id,payload_bytes,source_roi_x,source_roi_y,source_roi_width,source_roi_height\n"); - } - - // Escapes one frame info field for CSV storage - QString csvField(QString value) - { - if (!value.contains(QLatin1Char(',')) - && !value.contains(QLatin1Char('"')) - && !value.contains(QLatin1Char('\n')) - && !value.contains(QLatin1Char('\r'))) - { - return value; - } - - value.replace(QLatin1Char('"'), QStringLiteral("\"\"")); - return QStringLiteral("\"%1\"").arg(value); - } - - QByteArray frameInfoLine(const ImageFrame& frame) - { - const quint64 timestampNs = timestampNsForStorage(frame); - QStringList fields; - fields.reserve(14); - fields << csvField(frame.cameraId) - << QString::number(frame.frameIndex) - << QString::number(timestampNs) - << QString::number(frame.width) - << QString::number(frame.height) - << QString::number(frame.bitsPerSample) - << QString::number(frame.stride) - << csvField(pixelFormatName(frame.pixelFormat)) - << QString::number(pixelFormatId(frame.pixelFormat)) - << QString::number(frame.payloadByteCount()) - << QString::number(frame.sourceRoiX) - << QString::number(frame.sourceRoiY) - << QString::number(frame.sourceRoiWidth) - << QString::number(frame.sourceRoiHeight); - return (fields.join(QLatin1Char(',')) + QLatin1Char('\n')).toUtf8(); - } - struct FramePayloadView { const uchar* externalData{nullptr}; @@ -382,16 +330,20 @@ namespace scopeone::core::internal return payload; } - void discardIncompleteFile(QFile& file) + void discardIncompleteOutput(const QString& path) { - const QString filePath = file.fileName(); - if (file.isOpen()) + const QFileInfo info(path); + if (!info.exists()) { - file.close(); + return; } - if (!filePath.isEmpty()) + if (info.isDir()) { - QFile::remove(filePath); + QDir(info.absoluteFilePath()).removeRecursively(); + } + else + { + QFile::remove(info.absoluteFilePath()); } } @@ -457,178 +409,231 @@ namespace scopeone::core::internal TiffOptions() : useDeflate(true), zipQuality(6) {} }; - enum class Format { None, TiffStack, BinaryStream }; - SaveBackend() = default; - ~SaveBackend() { stopStack(); } + ~SaveBackend() { (void)stopStack(); } bool startStackRaw(const QString& filePath, + const QString& frameInfoPath, + const QString& metadataFileName, scopeone::core::RecordingFormat recordingFormat, int width, int height, ImagePixelFormat pixelFormat, int bitsPerSample, + const ExperimentPlan& plan, + quint64 acquisitionStartTimestampNs, + const QString& imageName, + const QJsonObject& cameraProperties, const TiffOptions& tiff = TiffOptions{}) { - stopStack(); - if (!ensureDir(filePath)) return false; + (void)stopStack(); m_lastError.clear(); - m_format = Format::None; - m_filePath = filePath; + m_omePlan = plan; - if (recordingFormat == scopeone::core::RecordingFormat::Binary) + if (recordingFormat == scopeone::core::RecordingFormat::OmeTiff + || recordingFormat == scopeone::core::RecordingFormat::OmeZarr + || recordingFormat == scopeone::core::RecordingFormat::Tiff + || recordingFormat == scopeone::core::RecordingFormat::Binary) { - auto file = std::make_unique(filePath); - if (!file->open(QIODevice::WriteOnly | QIODevice::Truncate)) + const int storageBits = tiffStorageBitsForFormat(pixelFormat); + if (storageBits == 0) { - m_lastError = QStringLiteral("Failed to open binary output"); + m_lastError = QStringLiteral("Unsupported bit depth"); return false; } - m_binaryFile = std::move(file); - m_format = Format::BinaryStream; - m_width = width; - m_height = height; - m_bits = bitsPerSample; + + scopewriter::WriterSettings settings; + switch (recordingFormat) + { + case scopeone::core::RecordingFormat::OmeTiff: + settings.format = scopewriter::Format::OmeTiff; + break; + case scopeone::core::RecordingFormat::OmeZarr: + settings.format = scopewriter::Format::OmeZarr; + break; + case scopeone::core::RecordingFormat::Tiff: + settings.format = scopewriter::Format::Tiff; + break; + case scopeone::core::RecordingFormat::Binary: + settings.format = scopewriter::Format::Binary; + break; + } +#if defined(_WIN32) + settings.outputPath = std::filesystem::path(filePath.toStdWString()); + settings.frameMetadataPath = std::filesystem::path(frameInfoPath.toStdWString()); +#else + settings.outputPath = std::filesystem::path(filePath.toStdString()); + settings.frameMetadataPath = std::filesystem::path(frameInfoPath.toStdString()); +#endif + settings.linkedMetadataFile = metadataFileName.toStdString(); + settings.width = width; + settings.height = height; + settings.pixelType = pixelFormat == ImagePixelFormat::Mono8 + ? scopewriter::PixelType::UInt8 + : scopewriter::PixelType::UInt16; + settings.significantBits = bitsPerSample; + settings.positionCount = (std::max)(1, static_cast(plan.positions.size())); + settings.timeCount = (std::max)(1, plan.framesPerBurst) + * (plan.burstMode ? (std::max)(1, plan.targetBursts) : 1); + settings.channelCount = 1; + settings.zCount = (std::max)(1, static_cast(plan.zPositions.size())); + const auto timeAxis = std::find(plan.order.begin(), + plan.order.end(), + RecordingAxis::Time); + const auto zAxis = std::find(plan.order.begin(), + plan.order.end(), + RecordingAxis::Z); + settings.acquisitionOrder = zAxis < timeAxis ? "ZTC" : "TZC"; + settings.physicalSizeXUm = plan.pixelSizeUm; + settings.physicalSizeYUm = plan.pixelSizeUm; + settings.timeIncrementMs = uniformTimeIncrementMs(plan); + if (plan.zPositions.size() > 1) + { + const double zStepUm = std::abs(plan.zPositions[1] - plan.zPositions[0]); + bool uniform = zStepUm > 0.0; + for (size_t index = 2; uniform && index < plan.zPositions.size(); ++index) + { + const double step = std::abs(plan.zPositions[index] + - plan.zPositions[index - 1]); + uniform = std::abs(step - zStepUm) + <= (std::max)(1e-9, zStepUm * 1e-9); + } + if (uniform) + { + settings.physicalSizeZUm = zStepUm; + } + } + settings.acquisitionStartTimestampNs = acquisitionStartTimestampNs; + settings.imageName = imageName.toStdString(); + settings.creator = "ScopeOne"; + settings.channels.push_back(scopewriter::ChannelMetadata{ + .name = settings.imageName + }); + settings.positions.reserve(static_cast(settings.positionCount)); + if (plan.positions.empty()) + { + settings.positions.push_back(scopewriter::PositionMetadata{ + .name = "Position 1" + }); + } + else + { + for (std::size_t index = 0; index < plan.positions.size(); ++index) + { + const auto& position = plan.positions[index]; + settings.positions.push_back(scopewriter::PositionMetadata{ + .name = "Position " + std::to_string(index + 1), + .xUm = position.x(), + .yUm = position.y() + }); + } + } + settings.detector.manufacturer = cameraProperties.value( + QStringLiteral("Name")).toString().trimmed().toStdString(); + settings.detector.model = cameraProperties.value( + QStringLiteral("CameraName")).toString().trimmed().toStdString(); + settings.detector.serialNumber = cameraProperties.value( + QStringLiteral("CameraID")).toString().trimmed().toStdString(); + double value = 0.0; + if (readFiniteNumber(cameraProperties.value(QStringLiteral("Exposure")), value) + && value > 0.0) + { + settings.defaultExposureMs = value; + } + if (readFiniteNumber(cameraProperties.value(QStringLiteral("Offset")), value)) + { + settings.detector.offset = value; + } + settings.enableCompression = tiff.useDeflate; + settings.compressionLevel = tiff.zipQuality; + + auto writer = std::make_unique(); + if (!writer->open(settings)) + { + m_lastError = QString::fromStdString(writer->lastError()); + return false; + } + m_writer = std::move(writer); return true; } - const int storageBits = tiffStorageBitsForFormat(pixelFormat); - if (storageBits == 0) - { - m_lastError = QStringLiteral("Unsupported bit depth"); - return false; - } - m_width = width; - m_height = height; - m_bits = storageBits; - m_useDeflate = tiff.useDeflate; - m_zipQuality = tiff.zipQuality; - TIFF* t = reinterpret_cast(openTiffForWrite(filePath)); - if (!t) - { - m_lastError = QStringLiteral("Failed to open TIFF output"); - return false; - } - m_tiff = t; - m_format = Format::TiffStack; - return true; + m_lastError = QStringLiteral("Unsupported recording format"); + return false; } - bool appendRaw(const uchar* data, qint64 rawBytes, const QByteArray& imageDescription = QByteArray()) + bool appendRaw(const uchar* data, + qint64 rawBytes, + const ImageFrame& frame, + const AcquisitionEvent* event = nullptr) { if (!data) return false; - if (m_format == Format::BinaryStream) + if (m_writer) { - if (!m_binaryFile) return false; if (rawBytes <= 0) { - m_lastError = QStringLiteral("Invalid binary frame size"); + m_lastError = QStringLiteral("Invalid frame size"); return false; } - if (m_binaryFile->write(reinterpret_cast(data), rawBytes) != rawBytes) + scopewriter::FrameMetadata metadata; + metadata.cameraId = frame.cameraId.toStdString(); + metadata.frameIndex = frame.frameIndex; + metadata.timestampNs = frame.timestampNs; + metadata.stride = static_cast(frame.stride); + metadata.sourceRoiX = frame.sourceRoiX; + metadata.sourceRoiY = frame.sourceRoiY; + metadata.sourceRoiWidth = frame.sourceRoiWidth; + metadata.sourceRoiHeight = frame.sourceRoiHeight; + if (event) + { + metadata.positionIndex = event->positionIndex; + metadata.t = static_cast(event->burstIndex) + * (std::max)(1, m_omePlan.framesPerBurst) + + event->timeIndex; + metadata.z = event->zIndex; + metadata.exposureMs = event->exposureMs; + if (event->hasXY) + { + metadata.positionXUm = event->x; + metadata.positionYUm = event->y; + } + if (event->hasZ) + { + metadata.positionZUm = event->z; + } + } + if (!m_writer->append(data, + static_cast(rawBytes), + metadata)) { - m_lastError = QStringLiteral("Failed to append binary frame"); + m_lastError = QString::fromStdString(m_writer->lastError()); return false; } return true; } - if (m_format != Format::TiffStack || !m_tiff) return false; - TIFF* t = reinterpret_cast(m_tiff); - TIFFCreateDirectory(t); - setCommonTags(t, m_width, m_height, m_bits); - if (m_useDeflate) - { - TIFFSetField(t, TIFFTAG_COMPRESSION, COMPRESSION_ADOBE_DEFLATE); - TIFFSetField(t, TIFFTAG_ZIPQUALITY, m_zipQuality); - TIFFSetField(t, TIFFTAG_PREDICTOR, PREDICTOR_HORIZONTAL); - } - else - { - TIFFSetField(t, TIFFTAG_COMPRESSION, COMPRESSION_NONE); - } - if (!imageDescription.isEmpty()) - { - TIFFSetField(t, TIFFTAG_IMAGEDESCRIPTION, imageDescription.constData()); - } - if (!writeStrip(t, data, m_width, m_height, m_bits)) return false; - TIFFWriteDirectory(t); - return true; + return false; } - void stopStack() + bool stopStack() { - if (m_tiff) - { - TIFFClose(reinterpret_cast(m_tiff)); - m_tiff = nullptr; - } - if (m_binaryFile) + bool success = true; + if (m_writer) { - m_binaryFile->close(); - m_binaryFile.reset(); + if (!m_writer->close()) + { + m_lastError = QString::fromStdString(m_writer->lastError()); + success = false; + } + m_writer.reset(); } - m_format = Format::None; + return success; } QString lastError() const { return m_lastError; } - bool isRecording() const { return m_format != Format::None; } private: - static bool ensureDir(const QString& filePath) - { - QFileInfo fi(filePath); - QDir dir = fi.dir(); - if (dir.exists()) return true; - return dir.mkpath("."); - } - - static void* openTiffForWrite(const QString& path) - { - const char* mode = "w8"; -#if defined(_WIN32) - std::wstring w = path.toStdWString(); -#if defined(TIFFOpenW) - return TIFFOpenW(reinterpret_cast(w.c_str()), mode); -#else - return TIFFOpen(path.toLocal8Bit().constData(), mode); -#endif -#else - return TIFFOpen(path.toLocal8Bit().constData(), mode); -#endif - } - - static void setCommonTags(void* tiff, int width, int height, int bits) - { - TIFF* t = reinterpret_cast(tiff); - TIFFSetField(t, TIFFTAG_IMAGEWIDTH, width); - TIFFSetField(t, TIFFTAG_IMAGELENGTH, height); - TIFFSetField(t, TIFFTAG_BITSPERSAMPLE, bits); - TIFFSetField(t, TIFFTAG_SAMPLESPERPIXEL, 1); - TIFFSetField(t, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_MINISBLACK); - TIFFSetField(t, TIFFTAG_ORIENTATION, ORIENTATION_TOPLEFT); - TIFFSetField(t, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG); - TIFFSetField(t, TIFFTAG_ROWSPERSTRIP, height); - } - - static bool writeStrip(void* tiff, const uchar* data, int width, int height, int bits) - { - TIFF* t = reinterpret_cast(tiff); - const tmsize_t bytesPerSample = bits / 8; - const tmsize_t stride = static_cast(width) * bytesPerSample; - const tmsize_t total = stride * height; - return TIFFWriteEncodedStrip(t, 0, (void*)data, total) != -1; - } - - void* m_tiff{nullptr}; - int m_width{0}; - int m_height{0}; - int m_bits{0}; - bool m_useDeflate{true}; - int m_zipQuality{6}; - Format m_format{Format::None}; QString m_lastError; - QString m_filePath; - std::unique_ptr m_binaryFile; + ExperimentPlan m_omePlan; + std::unique_ptr m_writer; }; } // namespace @@ -950,6 +955,10 @@ namespace scopeone::core::internal m_sessionState.activeSession->resetSaveResult(); m_sessionState.activeSession->resetWriterStatus(recordedMaxBytes()); } + const quint64 acquisitionStartTimestampNs = m_sessionState.activeSession + ? m_sessionState.activeSession->experimentDocument() + .startedTimestampNs + : 0; setWriterStatus(RecordingWriterPhase::Starting); SessionOutputInfo outputInfo; @@ -971,33 +980,18 @@ namespace scopeone::core::internal const CameraOutputPaths paths = buildCameraOutputPaths(outputInfo.outputDir, plan.baseName, cameraId, - plan.format); + plan); output->rawPath = paths.rawPath; output->metadataFileName = outputInfo.metadataFileName; + output->acquisitionStartTimestampNs = acquisitionStartTimestampNs; + if (m_sessionState.activeSession) + { + output->cameraProperties = m_sessionState.activeSession->experimentDocument() + .deviceProperties.value(cameraId).toObject(); + } if (requiresFrameInfo(plan.format)) { output->frameInfoPath = paths.frameInfoPath; - output->frameInfoFile.setFileName(output->frameInfoPath); - if (!output->frameInfoFile.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) - { - outputError = QStringLiteral("Failed to open frame info output for %1").arg(cameraId); - setWriterError(outputError); - stopStreamingOutputs(); - setWriterStatus(RecordingWriterPhase::Failed, outputError); - return false; - } - - const QByteArray header = frameInfoHeaderLine(); - if (output->frameInfoFile.write(header) != header.size() - || !output->frameInfoFile.flush()) - { - outputError = QStringLiteral("Failed to write frame info header for %1").arg(cameraId); - discardIncompleteFile(output->frameInfoFile); - setWriterError(outputError); - stopStreamingOutputs(); - setWriterStatus(RecordingWriterPhase::Failed, outputError); - return false; - } } m_writerState.cameraOutputs.insert(cameraId, output); @@ -1075,16 +1069,14 @@ namespace scopeone::core::internal if (output->backend) { auto* backend = reinterpret_cast(output->backend); - delete backend; - output->backend = nullptr; - } - if (output->frameInfoFile.isOpen()) - { - if (!output->frameInfoFile.flush() && writerErrorSnapshot().isEmpty()) + if (!backend->stopStack() && writerErrorSnapshot().isEmpty()) { - setWriterError(QStringLiteral("Failed to flush frame info for %1").arg(output->cameraId)); + setWriterError(QStringLiteral("Failed to finalize output for %1: %2") + .arg(output->cameraId) + .arg(backend->lastError())); } - output->frameInfoFile.close(); + delete backend; + output->backend = nullptr; } { std::lock_guard lock(output->queueMutex); @@ -1107,7 +1099,29 @@ namespace scopeone::core::internal output->rawPath, output->frameInfoPath); m_sessionState.activeSession->setOutputFramesWritten(output->cameraId, - output->framesWritten); + output->framesWritten); + } + } + if (!writerErrorSnapshot().isEmpty()) + { + QStringList outputDirs; + for (const auto& output : outputs) + { + if (!output) + { + continue; + } + discardIncompleteOutput(output->rawPath); + discardIncompleteOutput(output->frameInfoPath); + const QString outputDir = QFileInfo(output->rawPath).absolutePath(); + if (!outputDir.isEmpty() && !outputDirs.contains(outputDir)) + { + outputDirs.append(outputDir); + } + } + for (const QString& outputDir : outputDirs) + { + QDir().rmdir(outputDir); } } { @@ -1192,11 +1206,17 @@ namespace scopeone::core::internal auto newBackend = std::make_unique(); if (!newBackend->startStackRaw(output.rawPath, + output.frameInfoPath, + output.metadataFileName, m_captureState.format, task.frame.width, task.frame.height, task.frame.pixelFormat, task.frame.bitsPerSample, + m_mdaState.plan, + output.acquisitionStartTimestampNs, + output.cameraId, + output.cameraProperties, tiffOpts)) { errorMessage = QStringLiteral("Failed to open raw output for %1: %2") @@ -1227,12 +1247,10 @@ namespace scopeone::core::internal return false; } - const QByteArray imageDescription = m_captureState.format == RecordingFormat::Tiff - ? buildImageDescriptionJson(output.metadataFileName, task.frame) - : QByteArray(); if (!backend->appendRaw(payload.data(), payload.byteCount, - imageDescription)) + task.frame, + task.hasEvent ? &task.event : nullptr)) { errorMessage = QStringLiteral("Failed writing raw frame for %1: %2") .arg(output.cameraId) @@ -1240,15 +1258,6 @@ namespace scopeone::core::internal return false; } - if (output.frameInfoFile.isOpen()) - { - const QByteArray infoLine = frameInfoLine(task.frame); - if (output.frameInfoFile.write(infoLine) != infoLine.size()) - { - errorMessage = QStringLiteral("Failed writing frame info for %1").arg(output.cameraId); - return false; - } - } return true; } @@ -1301,6 +1310,10 @@ namespace scopeone::core::internal if (m_cameraManager && !m_cameraManager->setRecordingFrameDeliveryEnabled(true)) { + if (plan.streamToDisk) + { + QDir(sessionOutputDir(plan.saveDir, plan.baseName)).removeRecursively(); + } qWarning().noquote() << "Failed to enable all-frame camera delivery"; return false; } @@ -1554,7 +1567,8 @@ namespace scopeone::core::internal } // Adds one captured frame to the asynchronous writer queue - bool RecordingManager::enqueueFrame(const ImageFrame& frame) + bool RecordingManager::enqueueFrame(const ImageFrame& frame, + const AcquisitionEvent* event) { const QString cameraId = frame.cameraId.trimmed(); const size_t frameBytes = static_cast(frame.payloadByteCount()); @@ -1618,7 +1632,14 @@ namespace scopeone::core::internal markWriterStatusDirty(); return false; } - output->writeQueue.push_back(WriteTask{frame}); + WriteTask task; + task.frame = frame; + if (event) + { + task.event = *event; + task.hasEvent = true; + } + output->writeQueue.push_back(std::move(task)); } output->writeCondition.notify_one(); markWriterStatusDirty(); @@ -1696,9 +1717,37 @@ namespace scopeone::core::internal return; } + AcquisitionEvent previewEvent; + if (!m_mdaState.usingMda) + { + previewEvent.sequenceIndex = m_sessionState.activeSession + ? static_cast( + m_sessionState.activeSession->experimentDocument().events.size()) + : 0; + previewEvent.burstIndex = m_captureState.currentBurst; + previewEvent.timeIndex = static_cast( + m_captureState.framesCapturedThisBurst.value(frame.cameraId, 0)); + previewEvent.exposureMs = m_mdaState.plan.exposureMs; + if (previewEvent.exposureMs <= 0.0 && m_sessionState.activeSession) + { + double exposureMs = 0.0; + const QJsonObject cameraProperties = m_sessionState.activeSession->experimentDocument() + .deviceProperties.value(frame.cameraId).toObject(); + if (readFiniteNumber(cameraProperties.value(QStringLiteral("Exposure")), exposureMs) + && exposureMs > 0.0) + { + previewEvent.exposureMs = exposureMs; + } + } + previewEvent.cameraIds = QStringList{frame.cameraId}; + } + const AcquisitionEvent* writeEvent = packet.hasEvent + ? &packet.event + : (!m_mdaState.usingMda ? &previewEvent : nullptr); + if (m_captureState.streamToDisk) { - if (!enqueueFrame(frame)) + if (!enqueueFrame(frame, writeEvent)) { return; } @@ -1711,7 +1760,7 @@ namespace scopeone::core::internal if (!m_mdaState.usingMda) { - appendPreviewEventRecord(frame); + appendPreviewEventRecord(frame, previewEvent); } m_captureState.framesCapturedThisBurst[cameraId] += 1; @@ -1722,20 +1771,16 @@ namespace scopeone::core::internal } // Records one accepted preview frame as an actual acquisition event - void RecordingManager::appendPreviewEventRecord(const ImageFrame& frame) + void RecordingManager::appendPreviewEventRecord(const ImageFrame& frame, + const AcquisitionEvent& event) { if (!m_sessionState.activeSession) { return; } AcquisitionEventRecord record; - record.event.sequenceIndex = static_cast( - m_sessionState.activeSession->experimentDocument().events.size()); - record.event.burstIndex = m_captureState.currentBurst; - record.event.timeIndex = static_cast(m_captureState.framesCapturedThisBurst.value(frame.cameraId, 0)); - record.event.exposureMs = m_mdaState.plan.exposureMs; - record.event.cameraIds = QStringList{frame.cameraId}; - record.startedTimestampNs = timestampNsForStorage(frame); + record.event = event; + record.startedTimestampNs = frame.timestampNs; record.completedTimestampNs = record.startedTimestampNs; record.succeeded = true; record.frames.insert(frame.cameraId, frameRecordFromImageFrame(frame)); @@ -1801,7 +1846,7 @@ namespace scopeone::core::internal { rawFrame.frameIndex = m_captureState.lastFrameIndex.value(rawFrame.cameraId, 0) + 1; } - ingestFrame(FramePacket{rawFrame, FramePacket::Source::Mda}); + ingestFrame(FramePacket{rawFrame, FramePacket::Source::Mda, output.event, true}); if (rawFrame.isValid()) { emit mdaRawFrameReady(rawFrame); @@ -2169,8 +2214,10 @@ namespace scopeone::core::internal session->prepareForSave(session->streamedToDisk()); session->setWriterPhase(RecordingWriterPhase::Starting); - const auto failSave = [&session](const QString& errorMessage) + const auto failSave = [&session, &outputInfo](const QString& errorMessage) { + QDir(outputInfo.outputDir).removeRecursively(); + session->clearOutputFiles(); session->setWriterPhase(RecordingWriterPhase::Failed, errorMessage); return updateSessionResult(session, QStringLiteral("Error: %1").arg(errorMessage), false); }; @@ -2193,36 +2240,37 @@ namespace scopeone::core::internal { continue; } - const CameraOutputPaths paths = buildCameraOutputPaths(outputInfo.outputDir, - capturePlan.baseName, - cameraId, - capturePlan.format); - QFile frameInfoFile; - if (requiresFrameInfo(capturePlan.format)) + QList cameraEvents; + if (capturePlan.format == RecordingFormat::OmeTiff + || capturePlan.format == RecordingFormat::OmeZarr) { - frameInfoFile.setFileName(paths.frameInfoPath); - if (!frameInfoFile.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) + for (const AcquisitionEventRecord& record : session->experimentDocument().events) { - const QString errorMessage = QString("Failed to open frame info output for %1").arg(cameraId); - return failSave(errorMessage); - } - const QByteArray header = frameInfoHeaderLine(); - if (frameInfoFile.write(header) != header.size()) - { - discardIncompleteFile(frameInfoFile); - const QString errorMessage = QString("Failed to write frame info header for %1").arg(cameraId); - return failSave(errorMessage); + if (record.succeeded && record.frames.contains(cameraId)) + { + cameraEvents.append(record.event); + } } } + const CameraOutputPaths paths = buildCameraOutputPaths(outputInfo.outputDir, + capturePlan.baseName, + cameraId, + capturePlan); if (!rawSaver.startStackRaw(paths.rawPath, + paths.frameInfoPath, + outputInfo.metadataFileName, capturePlan.format, firstImageFrame.width, firstImageFrame.height, firstImageFrame.pixelFormat, firstImageFrame.bitsPerSample, + capturePlan, + session->experimentDocument().startedTimestampNs, + cameraId, + session->experimentDocument().deviceProperties + .value(cameraId).toObject(), tiffOpts)) { - discardIncompleteFile(frameInfoFile); const QString errorMessage = QString("Failed to start raw output for %1").arg(cameraId); return failSave(errorMessage); } @@ -2239,7 +2287,6 @@ namespace scopeone::core::internal || imageFrame.bitsPerSample != firstImageFrame.bitsPerSample || imageFrame.pixelFormat != firstImageFrame.pixelFormat) { - discardIncompleteFile(frameInfoFile); rawSaver.stopStack(); const QString errorMessage = QString("Frame format changed during save for %1").arg(cameraId); return failSave(errorMessage); @@ -2247,50 +2294,32 @@ namespace scopeone::core::internal const FramePayloadView payload = framePayloadForWrite(imageFrame, capturePlan.format); if (!payload.data() || payload.byteCount <= 0) { - discardIncompleteFile(frameInfoFile); rawSaver.stopStack(); const QString errorMessage = QString("Invalid frame payload for %1").arg(cameraId); return failSave(errorMessage); } - const QByteArray imageDescription = capturePlan.format == RecordingFormat::Tiff - ? buildImageDescriptionJson(outputInfo.metadataFileName, - imageFrame) - : QByteArray(); + const AcquisitionEvent* event = saved < cameraEvents.size() + ? &cameraEvents.at(saved) + : nullptr; if (!rawSaver.appendRaw(payload.data(), payload.byteCount, - imageDescription)) + imageFrame, + event)) { - discardIncompleteFile(frameInfoFile); rawSaver.stopStack(); const QString errorMessage = QString("Failed to append raw frame %1 for %2").arg(saved).arg( cameraId); return failSave(errorMessage); } - if (frameInfoFile.isOpen()) - { - const QByteArray infoLine = frameInfoLine(imageFrame); - if (frameInfoFile.write(infoLine) != infoLine.size()) - { - discardIncompleteFile(frameInfoFile); - rawSaver.stopStack(); - const QString errorMessage = QString("Failed to write frame info for %1").arg(cameraId); - return failSave(errorMessage); - } - } saved += 1; } - if (frameInfoFile.isOpen()) + if (!rawSaver.stopStack()) { - if (!frameInfoFile.flush()) - { - discardIncompleteFile(frameInfoFile); - rawSaver.stopStack(); - return failSave(QStringLiteral("Failed to flush frame info for %1").arg(cameraId)); - } - frameInfoFile.close(); + return failSave(QStringLiteral("Failed to finalize output for %1: %2") + .arg(cameraId) + .arg(rawSaver.lastError())); } - rawSaver.stopStack(); completedOutputs.insert(cameraId, RecordingFileManifest{paths.rawPath, paths.frameInfoPath, saved}); session->addWrittenFrames(saved); diff --git a/ScopeOneCore/src/ScopeOneCore.cpp b/ScopeOneCore/src/ScopeOneCore.cpp index e0ff4f6..094c4e5 100644 --- a/ScopeOneCore/src/ScopeOneCore.cpp +++ b/ScopeOneCore/src/ScopeOneCore.cpp @@ -10,12 +10,15 @@ #include "internal/CameraManager.h" #include "internal/ParticleAnalysis.h" #include "internal/RecordingManager.h" +#include "internal/OmeZarrStorage.h" #include "internal/SpatiotemporalBinningModule.h" #include "internal/StageMosaicManager.h" #include "MMCore.h" +#include #include #include #include +#include #include #include #include @@ -37,7 +40,6 @@ #include #include #include -#include namespace { @@ -252,6 +254,11 @@ namespace } const QJsonObject object = document.object(); + if (object.value(QStringLiteral("schema")).toString() + != QString::fromLatin1(scopewriter::kFrameMetadataProtocol)) + { + return; + } const QString storedCameraId = object.value(QStringLiteral("camera_id")).toString().trimmed(); if (!storedCameraId.isEmpty()) { @@ -603,6 +610,24 @@ namespace return devicePropertiesObject; } + + // Read the active Micro-Manager pixel size calibration + double currentPixelSizeUm(const std::shared_ptr& core) + { + if (!core) + { + return 0.0; + } + try + { + const double pixelSizeUm = core->getPixelSizeUm(false); + return std::isfinite(pixelSizeUm) && pixelSizeUm > 0.0 ? pixelSizeUm : 0.0; + } + catch (const CMMError&) + { + return 0.0; + } + } } namespace scopeone::core @@ -887,16 +912,78 @@ namespace scopeone::core return {}; } - if (m_manifest.plan.format == RecordingFormat::Tiff) + if (m_manifest.plan.format == RecordingFormat::OmeZarr) { + return internal::readOmeZarrFrame(fileManifest.rawPath, + cameraId, + index, + m_manifest); + } + + if (m_manifest.plan.format == RecordingFormat::OmeTiff + || m_manifest.plan.format == RecordingFormat::Tiff) + { + const AcquisitionEventRecord* selectedRecord = nullptr; + if (m_manifest.plan.format == RecordingFormat::OmeTiff) + { + int storedFrameIndex = 0; + for (const AcquisitionEventRecord& record : m_manifest.events) + { + if (!record.succeeded || !record.frames.contains(cameraId)) + { + continue; + } + if (storedFrameIndex++ == index) + { + selectedRecord = &record; + break; + } + } + } + + QString tiffPath = fileManifest.rawPath; + int tiffDirectoryIndex = index; + if (m_manifest.plan.format == RecordingFormat::OmeTiff + && m_manifest.plan.positions.size() > 1) + { + if (!selectedRecord + || selectedRecord->event.positionIndex < 0 + || selectedRecord->event.positionIndex + >= static_cast(m_manifest.plan.positions.size())) + { + return {}; + } + + const int positionIndex = selectedRecord->event.positionIndex; + const QFileInfo rootInfo(fileManifest.rawPath); + tiffPath = QDir(fileManifest.rawPath).filePath( + QStringLiteral("%1_p%2.ome.tiff") + .arg(rootInfo.fileName()) + .arg(positionIndex, 3, 10, QChar('0'))); + tiffDirectoryIndex = 0; + for (const AcquisitionEventRecord& record : m_manifest.events) + { + if (&record == selectedRecord) + { + break; + } + if (record.succeeded + && record.event.positionIndex == positionIndex + && record.frames.contains(cameraId)) + { + ++tiffDirectoryIndex; + } + } + } + std::unique_ptr tiff( - reinterpret_cast(openTiffForRead(fileManifest.rawPath))); + reinterpret_cast(openTiffForRead(tiffPath))); if (!tiff) { return {}; } - if (!TIFFSetDirectory(tiff.get(), static_cast(index))) + if (!TIFFSetDirectory(tiff.get(), static_cast(tiffDirectoryIndex))) { return {}; } @@ -977,6 +1064,19 @@ namespace scopeone::core frame.sourceRoiWidth = frame.width; frame.sourceRoiHeight = frame.height; applyTiffImageDescriptionMetadata(imageDescriptionBytes, frame); + if (selectedRecord) + { + const FrameRecord& storedFrame = selectedRecord->frames.constFind(cameraId).value(); + frame.frameIndex = storedFrame.frameIndex; + frame.timestampNs = storedFrame.timestampNs; + frame.bitsPerSample = ImageFrame::normalizedBitsPerSample( + frame.pixelFormat, + storedFrame.bitsPerSample); + frame.sourceRoiX = storedFrame.sourceRoiX; + frame.sourceRoiY = storedFrame.sourceRoiY; + frame.sourceRoiWidth = storedFrame.sourceRoiWidth; + frame.sourceRoiHeight = storedFrame.sourceRoiHeight; + } frame.bytes = std::move(bytes); return frame.isValid() ? frame : ImageFrame{}; } @@ -994,7 +1094,11 @@ namespace scopeone::core return {}; } - (void)frameInfoFile.readLine(); + const QByteArray header = frameInfoFile.readLine().trimmed(); + if (header != QByteArray(scopewriter::kBinaryFrameMetadataHeader)) + { + return {}; + } int currentIndex = 0; qint64 rawOffset = 0; while (!frameInfoFile.atEnd()) @@ -1005,7 +1109,7 @@ namespace scopeone::core continue; } const QList fields = parseFrameInfoCsvLine(line); - if (fields.size() < 10) + if (fields.size() != 14) { return {}; } @@ -1055,22 +1159,12 @@ namespace scopeone::core frame.pixelFormat = pixelFormatFromFrameInfo(fields.at(7), pixelFormatId); frame.bitsPerSample = ImageFrame::normalizedBitsPerSample(frame.pixelFormat, bitsPerSample); - if (fields.size() >= 14) + if (!readIntField(fields, 10, frame.sourceRoiX) + || !readIntField(fields, 11, frame.sourceRoiY) + || !readIntField(fields, 12, frame.sourceRoiWidth) + || !readIntField(fields, 13, frame.sourceRoiHeight)) { - if (!readIntField(fields, 10, frame.sourceRoiX) - || !readIntField(fields, 11, frame.sourceRoiY) - || !readIntField(fields, 12, frame.sourceRoiWidth) - || !readIntField(fields, 13, frame.sourceRoiHeight)) - { - return {}; - } - } - else - { - frame.sourceRoiX = 0; - frame.sourceRoiY = 0; - frame.sourceRoiWidth = frame.width; - frame.sourceRoiHeight = frame.height; + return {}; } frame.bytes = std::move(bytes); @@ -1118,18 +1212,16 @@ namespace scopeone::core .arg(CMMCore::getMMCoreVersionPatch()); } - // Return the linked libtiff version + // Return the ScopeWriter libtiff version QString ScopeOneCore::getLibTiffVersion() { - QString version = QString::fromLatin1(TIFFGetVersion()).section('\n', 0, 0).trimmed(); - version.remove(QStringLiteral("LIBTIFF, Version ")); - return version; + return QString::fromStdString(scopewriter::libTiffVersion()); } - // Return the linked zlib version + // Return the ScopeWriter zlib version QString ScopeOneCore::getZlibVersion() { - return QString::fromLatin1(zlibVersion()); + return QString::fromStdString(scopewriter::zlibVersion()); } // Build the graph layer key for one raw source @@ -2017,6 +2109,10 @@ namespace scopeone::core const ExperimentPlan& capturePlan) { ExperimentPlan plan = capturePlan; + if (plan.pixelSizeUm <= 0.0) + { + plan.pixelSizeUm = currentPixelSizeUm(core()); + } plan.configPath = m_loadedConfigPath; plan.configSha256 = m_loadedConfigSha256; plan.processing = processingRecipe(); @@ -3650,6 +3746,10 @@ namespace scopeone::core } ExperimentPlan planSnapshot = plan; + if (planSnapshot.pixelSizeUm <= 0.0) + { + planSnapshot.pixelSizeUm = currentPixelSizeUm(core()); + } if (planSnapshot.experimentId.trimmed().isEmpty()) { planSnapshot.experimentId = QUuid::createUuid().toString(QUuid::WithoutBraces); diff --git a/ScopeOneCore/src/StageMosaicManager.cpp b/ScopeOneCore/src/StageMosaicManager.cpp index 9228dab..5cc4481 100644 --- a/ScopeOneCore/src/StageMosaicManager.cpp +++ b/ScopeOneCore/src/StageMosaicManager.cpp @@ -386,7 +386,8 @@ namespace scopeone::core::internal ExperimentPlan capturePlan; capturePlan.cameraIds = {frame.cameraId}; capturePlan.streamToDisk = false; - capturePlan.format = RecordingFormat::Tiff; + capturePlan.format = RecordingFormat::OmeTiff; + capturePlan.pixelSizeUm = m_plan.pixelSizeUm; capturePlan.saveDir = m_plan.gallerySaveDir; capturePlan.baseName = QStringLiteral("stage_mosaic_") + QDateTime::currentDateTime().toString(QStringLiteral("yyyyMMdd_hhmmss_zzz")); diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 178fe74..7f5eda0 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -76,12 +76,15 @@ for ($i = 0; $i -lt $args.Count; $i++) { } } -if ($target -notin @("all", "core", "gui")) { - throw "Invalid target '$target'. Expected one of: all, core, gui." +if ($target -notin @("all", "core", "gui", "scopewriter")) { + throw "Invalid target '$target'. Expected one of: all, core, gui, scopewriter." } -if ($package -and $target -eq "core") { +if ($package -and $target -in @("core", "scopewriter")) { throw "--package requires --target gui or --target all." } +if ($run -and $target -eq "scopewriter") { + throw "--run is not available for the scopewriter target." +} function Write-Step { param([string]$Message) @@ -162,6 +165,52 @@ function Find-ConfigureOverride { return $Options | Where-Object { $_ -like "$Prefix*" } | Select-Object -First 1 } +function Import-MsvcEnvironment { + if ($env:OS -ne "Windows_NT") { + return + } + + $vsWhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vsWhere)) { + throw "Visual Studio Installer was not found." + } + $installationPath = & $vsWhere ` + -latest ` + -products * ` + -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 ` + -property installationPath + if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) { + throw "A Visual Studio installation with the C++ toolchain was not found." + } + $vsDevCmd = Join-Path $installationPath "Common7\Tools\VsDevCmd.bat" + if (-not (Test-Path $vsDevCmd)) { + throw "Visual Studio developer environment was not found." + } + $environment = & $env:ComSpec /s /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 >nul && set" + if ($LASTEXITCODE -ne 0) { + throw "Failed to initialize the Visual Studio developer environment." + } + foreach ($line in $environment) { + $separator = $line.IndexOf('=') + if ($separator -gt 0) { + $name = $line.Substring(0, $separator) + $value = $line.Substring($separator + 1) + if ($name.Equals("Path", [StringComparison]::OrdinalIgnoreCase)) { + Remove-Item Env:PATH -ErrorAction SilentlyContinue + Remove-Item Env:Path -ErrorAction SilentlyContinue + $env:Path = $value + } + else { + [Environment]::SetEnvironmentVariable($name, $value, "Process") + } + } + } + $env:CC = "cl.exe" + $env:CXX = "cl.exe" +} + +Import-MsvcEnvironment + $cmake = (Get-Command cmake -ErrorAction Stop).Source $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path @@ -173,11 +222,27 @@ $guiBuildDir = Join-Path $repoRoot "build" $coreSourceDir = Join-Path $repoRoot "ScopeOneCore" $coreBuildDir = Join-Path $coreSourceDir "build" $coreInstallDir = Join-Path $coreSourceDir "install" +$writerSourceDir = Join-Path $coreSourceDir "external\ScopeWriter" +$writerBuildRoot = Join-Path $writerSourceDir "build" +$writerBuildDir = Join-Path $writerBuildRoot "standalone" +$writerInstallDir = Join-Path $writerSourceDir "install" +$writerConsumerSourceDir = Join-Path $writerSourceDir "tests\consumer" +$writerConsumerBuildDir = Join-Path $writerBuildRoot "consumer" $config = "Release" $coreCachePath = Join-Path $coreBuildDir "CMakeCache.txt" $guiCachePath = Join-Path $guiBuildDir "CMakeCache.txt" if ($clean) { + if ($target -eq "scopewriter") { + if (Test-Path $writerBuildRoot) { + Write-Step "Removing ScopeWriter build directory" + Remove-Item -LiteralPath $writerBuildRoot -Recurse -Force + } + if (Test-Path $writerInstallDir) { + Write-Step "Removing ScopeWriter install directory" + Remove-Item -LiteralPath $writerInstallDir -Recurse -Force + } + } if ($target -in @("all", "gui") -and (Test-Path $guiBuildDir)) { Write-Step "Removing GUI build directory" Remove-Item -LiteralPath $guiBuildDir -Recurse -Force @@ -198,6 +263,83 @@ $needGuiConfigure = $configure -or $guiConfigureOptionOverride -or -not (Test-Pa $installPrefixOverride = Find-ConfigureOverride -Options $coreConfigureOption -Prefix "-DCMAKE_INSTALL_PREFIX=" +if ($target -eq "scopewriter") { + $writerGeneratorArgs = @() + if ($env:OS -eq "Windows_NT") { + $writerGeneratorArgs = @("-G", "Visual Studio 17 2022", "-A", "x64") + } + Invoke-Step ` + -Label "Configuring standalone ScopeWriter" ` + -FilePath $cmake ` + -Arguments (@( + "-S", $writerSourceDir, + "-B", $writerBuildDir, + "-DCMAKE_INSTALL_PREFIX=$writerInstallDir", + "-DSCOPEWRITER_BUILD_TESTS=ON" + ) + $writerGeneratorArgs) ` + -WorkingDirectory $repoRoot + + Invoke-Step ` + -Label "Building standalone ScopeWriter ($config)" ` + -FilePath $cmake ` + -Arguments @( + "--build", $writerBuildDir, + "--config", $config, + "--parallel" + ) ` + -WorkingDirectory $repoRoot + + $ctest = (Get-Command ctest -ErrorAction Stop).Source + Invoke-Step ` + -Label "Testing standalone ScopeWriter ($config)" ` + -FilePath $ctest ` + -Arguments @( + "--test-dir", $writerBuildDir, + "-C", $config, + "--output-on-failure" + ) ` + -WorkingDirectory $repoRoot + + Invoke-Step ` + -Label "Installing standalone ScopeWriter" ` + -FilePath $cmake ` + -Arguments @( + "--install", $writerBuildDir, + "--config", $config + ) ` + -WorkingDirectory $repoRoot + + Invoke-Step ` + -Label "Configuring installed ScopeWriter consumer" ` + -FilePath $cmake ` + -Arguments (@( + "-S", $writerConsumerSourceDir, + "-B", $writerConsumerBuildDir, + "-DCMAKE_PREFIX_PATH=$writerInstallDir" + ) + $writerGeneratorArgs) ` + -WorkingDirectory $repoRoot + + Invoke-Step ` + -Label "Building installed ScopeWriter consumer ($config)" ` + -FilePath $cmake ` + -Arguments @( + "--build", $writerConsumerBuildDir, + "--config", $config, + "--parallel" + ) ` + -WorkingDirectory $repoRoot + + Invoke-Step ` + -Label "Testing installed ScopeWriter consumer ($config)" ` + -FilePath $ctest ` + -Arguments @( + "--test-dir", $writerConsumerBuildDir, + "-C", $config, + "--output-on-failure" + ) ` + -WorkingDirectory $repoRoot +} + if (-not $needCoreConfigure -and $target -in @("all", "core")) { $cachedInstallPrefix = Normalize-CMakePath (Get-CMakeCacheValue -CachePath $coreCachePath -Key "CMAKE_INSTALL_PREFIX") $expectedInstallPrefix = Normalize-CMakePath $coreInstallDir @@ -244,9 +386,8 @@ if ($target -in @("all", "core")) { -Label "Installing ScopeOneCore into local prefix" ` -FilePath $cmake ` -Arguments @( - "--build", $coreBuildDir, - "--config", $config, - "--target", "INSTALL" + "--install", $coreBuildDir, + "--config", $config ) ` -WorkingDirectory $repoRoot } @@ -301,14 +442,21 @@ $packageCandidates = Get-ChildItem -LiteralPath $guiBuildDir -File -ErrorAction Write-Step "Summary" Write-Host "Target: $target" Write-Host "Config: $config" -Write-Host "ScopeOneCore install: $coreInstallDir" -if (Test-Path $guiExe) { - Write-Host "GUI executable: $guiExe" +if ($target -eq "scopewriter") { + Write-Host "ScopeWriter install: $writerInstallDir" +} +else { + Write-Host "ScopeOneCore install: $coreInstallDir" } -if ($packageCandidates) { - Write-Host "Packages:" - foreach ($candidate in $packageCandidates) { - Write-Host " $($candidate.FullName)" +if ($target -ne "scopewriter") { + if (Test-Path $guiExe) { + Write-Host "GUI executable: $guiExe" + } + if ($packageCandidates) { + Write-Host "Packages:" + foreach ($candidate in $packageCandidates) { + Write-Host " $($candidate.FullName)" + } } } diff --git a/src/RecordingWidget.cpp b/src/RecordingWidget.cpp index 550f820..6e656ca 100644 --- a/src/RecordingWidget.cpp +++ b/src/RecordingWidget.cpp @@ -416,6 +416,8 @@ namespace scopeone::ui formatLayout->setVerticalSpacing(4); m_formatCombo = new QComboBox(this); + m_formatCombo->addItem("OME-TIFF", static_cast(scopeone::core::RecordingFormat::OmeTiff)); + m_formatCombo->addItem("OME-Zarr", static_cast(scopeone::core::RecordingFormat::OmeZarr)); m_formatCombo->addItem("TIFF", static_cast(scopeone::core::RecordingFormat::Tiff)); m_formatCombo->addItem("Binary", static_cast(scopeone::core::RecordingFormat::Binary)); formatLayout->addWidget(new QLabel("Raw Format:", this), 0, 0); diff --git a/src/ScopeOneLocalApiServer.cpp b/src/ScopeOneLocalApiServer.cpp index 00b402d..e112dd8 100644 --- a/src/ScopeOneLocalApiServer.cpp +++ b/src/ScopeOneLocalApiServer.cpp @@ -836,6 +836,22 @@ namespace scopeone::ui { return QStringLiteral("Missing baseName"); } + const QJsonValue formatValue = request.value(QStringLiteral("format")); + if (!formatValue.isUndefined()) + { + if (!formatValue.isString()) + { + return QStringLiteral("format must be a string"); + } + const QString formatName = formatValue.toString().trimmed().toLower(); + if (formatName != QStringLiteral("ome-tiff") + && formatName != QStringLiteral("ome-zarr") + && formatName != QStringLiteral("tiff") + && formatName != QStringLiteral("binary")) + { + return QStringLiteral("Unsupported recording format: %1").arg(formatName); + } + } return {}; } @@ -845,12 +861,25 @@ namespace scopeone::ui capturePlan.saveDir = request.value(QStringLiteral("saveDir")).toString().trimmed(); capturePlan.baseName = request.value(QStringLiteral("baseName")).toString().trimmed(); const QString formatName = request.value(QStringLiteral("format")) - .toString(QStringLiteral("tiff")) + .toString(QStringLiteral("ome-tiff")) .trimmed() .toLower(); - capturePlan.format = (formatName == QStringLiteral("binary") || formatName == QStringLiteral("bin")) - ? scopeone::core::RecordingFormat::Binary - : scopeone::core::RecordingFormat::Tiff; + if (formatName == QStringLiteral("binary")) + { + capturePlan.format = scopeone::core::RecordingFormat::Binary; + } + else if (formatName == QStringLiteral("tiff")) + { + capturePlan.format = scopeone::core::RecordingFormat::Tiff; + } + else if (formatName == QStringLiteral("ome-zarr")) + { + capturePlan.format = scopeone::core::RecordingFormat::OmeZarr; + } + else + { + capturePlan.format = scopeone::core::RecordingFormat::OmeTiff; + } capturePlan.enableCompression = request.value(QStringLiteral("compression")).toBool(false); capturePlan.compressionLevel = request.value(QStringLiteral("compressionLevel")).toInt(6); } @@ -907,7 +936,7 @@ namespace scopeone::ui plan.cameraIds = resolveCameraIds( core, cameraValue.isUndefined() ? QStringLiteral("All") : cameraValue.toString()); - plan.format = scopeone::core::RecordingFormat::Tiff; + plan.format = scopeone::core::RecordingFormat::OmeTiff; plan.streamToDisk = false; plan.framesPerBurst = framesValue.toInt(); plan.burstMode = false; @@ -924,6 +953,16 @@ namespace scopeone::ui return false; } plan.mdaIntervalMs = intervalValue.isUndefined() ? 0.0 : intervalValue.toDouble(); + const QJsonValue pixelSizeValue = request.value(QStringLiteral("pixelSizeUm")); + if (!pixelSizeValue.isUndefined() + && (!pixelSizeValue.isDouble() + || !std::isfinite(pixelSizeValue.toDouble()) + || pixelSizeValue.toDouble() < 0.0)) + { + errorMessage = QStringLiteral("pixelSizeUm must be a finite non-negative number"); + return false; + } + plan.pixelSizeUm = pixelSizeValue.isUndefined() ? 0.0 : pixelSizeValue.toDouble(); if (!doubleArrayFromJson(request.value(QStringLiteral("zPositions")), QStringLiteral("zPositions"), plan.zPositions, diff --git a/src/ScopeOneMcpServer.cpp b/src/ScopeOneMcpServer.cpp index 684e721..607f1d1 100644 --- a/src/ScopeOneMcpServer.cpp +++ b/src/ScopeOneMcpServer.cpp @@ -974,6 +974,12 @@ namespace inputProperty(QStringLiteral("number"), QStringLiteral("MDA time interval in milliseconds"), 0.0), 0.0)}, + {QStringLiteral("pixelSizeUm"), + withMinimum( + inputProperty(QStringLiteral("number"), + QStringLiteral("Sample pixel size in micrometers or zero when unknown"), + 0.0), + 0.0)}, {QStringLiteral("zPositions"), arrayProperty(QStringLiteral("Optional absolute Z positions"), QStringLiteral("number"))}, {QStringLiteral("positions"), @@ -1088,10 +1094,10 @@ namespace {QStringLiteral("format"), withEnum( inputProperty(QStringLiteral("string"), QStringLiteral("Output format"), - QStringLiteral("tiff")), - {QStringLiteral("tiff"), QStringLiteral("binary")})}, + QStringLiteral("ome-tiff")), + {QStringLiteral("ome-tiff"), QStringLiteral("ome-zarr"), QStringLiteral("tiff"), QStringLiteral("binary")})}, {QStringLiteral("compression"), - inputProperty(QStringLiteral("boolean"), QStringLiteral("Enable TIFF compression"), false)}, + inputProperty(QStringLiteral("boolean"), QStringLiteral("Enable output compression"), false)}, {QStringLiteral("compressionLevel"), withMaximum( withMinimum( @@ -1116,10 +1122,10 @@ namespace {QStringLiteral("format"), withEnum( inputProperty(QStringLiteral("string"), QStringLiteral("Output format"), - QStringLiteral("tiff")), - {QStringLiteral("tiff"), QStringLiteral("binary")})}, + QStringLiteral("ome-tiff")), + {QStringLiteral("ome-tiff"), QStringLiteral("ome-zarr"), QStringLiteral("tiff"), QStringLiteral("binary")})}, {QStringLiteral("compression"), - inputProperty(QStringLiteral("boolean"), QStringLiteral("Enable TIFF compression"), false)}, + inputProperty(QStringLiteral("boolean"), QStringLiteral("Enable output compression"), false)}, {QStringLiteral("compressionLevel"), withMaximum( withMinimum( From a02f166408159d29854f82045b5813f7cd28ab9d Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Thu, 30 Jul 2026 00:41:55 +0200 Subject: [PATCH 2/7] Consolidate dataset storage in ScopeWriter --- README.md | 7 +- ScopeOneCore/CMakeLists.txt | 6 - ScopeOneCore/external/ScopeWriter | 2 +- ScopeOneCore/internal/OmeZarrStorage.h | 14 - ScopeOneCore/src/OmeZarrStorage.cpp | 263 ------------ ScopeOneCore/src/RecordingManager.cpp | 6 - ScopeOneCore/src/ScopeOneCore.cpp | 570 ++++++------------------- 7 files changed, 137 insertions(+), 731 deletions(-) delete mode 100644 ScopeOneCore/internal/OmeZarrStorage.h delete mode 100644 ScopeOneCore/src/OmeZarrStorage.cpp diff --git a/README.md b/README.md index 27197cd..2308be5 100644 --- a/README.md +++ b/README.md @@ -46,14 +46,13 @@ There is an example dual-camera .cfg file in the config folder, just change the - OpenCV 4.12.0 - mmCoreAndDevices -Place OpenCV and MMCore under `ScopeOneCore/external`. ScopeWriter is maintained in -its own repository and included here as a Git submodule. Clone ScopeOne with: +Clone ScopeOne and initialize all submodules with: ```powershell git clone --recurse-submodules https://github.com/Experimental-Microscopy-Lab/ScopeOne.git ``` -For an existing checkout, initialize ScopeWriter with: +For an existing checkout, initialize the submodules with: ```powershell git submodule update --init --recursive @@ -72,8 +71,6 @@ ScopeOne/ ScopeWriter contains its filesystem Zarr V3 writer and carries libtiff, zlib, zstd and crc32c under its own `third_party` directory. It builds these dependencies from source without downloading packages during CMake configuration. - - **Windows Build Steps:** 1. Build and install `ScopeOneCore`: diff --git a/ScopeOneCore/CMakeLists.txt b/ScopeOneCore/CMakeLists.txt index 453ceab..f5e4f30 100644 --- a/ScopeOneCore/CMakeLists.txt +++ b/ScopeOneCore/CMakeLists.txt @@ -94,7 +94,6 @@ set(CORE_SOURCES src/DifferentialRollingModule.cpp src/MDAManager.cpp src/RecordingManager.cpp - src/OmeZarrStorage.cpp src/CameraBackend.cpp src/CameraManager.cpp src/NativeCameraBackend.cpp @@ -119,7 +118,6 @@ set(CORE_HEADERS internal/DifferentialRollingModule.h internal/MDAManager.h internal/RecordingManager.h - internal/OmeZarrStorage.h internal/CameraBackend.h internal/CameraManager.h include/scopeone/ImageFrame.h @@ -139,14 +137,10 @@ target_link_libraries(ScopeOneCore Qt::Network Qt::Concurrent ${OpenCV_LIBS} - TIFF::tiff ScopeWriter::ScopeWriter - Crc32c::crc32c - zstd::libzstd_static ) target_compile_definitions(ScopeOneCore PRIVATE MMDEVICE_CLIENT_BUILD - SCOPEONE_HAVE_TIFF SCOPEONE_CORE_EXPORTS SCOPEONE_CORE_VERSION_STRING="${PROJECT_VERSION}" ) diff --git a/ScopeOneCore/external/ScopeWriter b/ScopeOneCore/external/ScopeWriter index 31227bf..3c19423 160000 --- a/ScopeOneCore/external/ScopeWriter +++ b/ScopeOneCore/external/ScopeWriter @@ -1 +1 @@ -Subproject commit 31227bfd8aa9652adb40b04fbefa7766970a0011 +Subproject commit 3c19423a9194624b9bd22d2aab07fb3b4bfd5cc0 diff --git a/ScopeOneCore/internal/OmeZarrStorage.h b/ScopeOneCore/internal/OmeZarrStorage.h deleted file mode 100644 index 1b6b0ef..0000000 --- a/ScopeOneCore/internal/OmeZarrStorage.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -#include "scopeone/ExperimentDocument.h" -#include "scopeone/ImageFrame.h" - -namespace scopeone::core::internal -{ - ImageFrame readOmeZarrFrame(const QString& rootPath, - const QString& cameraId, - int frameIndex, - const ExperimentDocument& document); -} diff --git a/ScopeOneCore/src/OmeZarrStorage.cpp b/ScopeOneCore/src/OmeZarrStorage.cpp deleted file mode 100644 index 67b9e4b..0000000 --- a/ScopeOneCore/src/OmeZarrStorage.cpp +++ /dev/null @@ -1,263 +0,0 @@ -#include "internal/OmeZarrStorage.h" - -#include -#include -#include -#include -#include -#include - -#include "crc32c/crc32c.h" -#include "zstd.h" - -namespace scopeone::core::internal -{ - namespace - { - constexpr int kChunkEdge = 512; - constexpr quint64 kUnwrittenChunk = (std::numeric_limits::max)(); - - qint64 timeIndexForEvent(const AcquisitionEvent& event, const ExperimentPlan& plan) - { - return static_cast(event.burstIndex) * (std::max)(1, plan.framesPerBurst) - + event.timeIndex; - } - - QString datasetPath(const QString& rootPath, int positionIndex, bool multiPosition) - { - if (!multiPosition) - { - return QDir(rootPath).filePath(QStringLiteral("0")); - } - return QDir(rootPath).filePath( - QStringLiteral("Position %1/0").arg(positionIndex + 1)); - } - - quint64 readLittleEndian64(const char* data) - { - quint64 value = 0; - for (int index = 0; index < 8; ++index) - { - value |= static_cast(static_cast(data[index])) - << (index * 8); - } - return value; - } - - quint32 readLittleEndian32(const char* data) - { - quint32 value = 0; - for (int index = 0; index < 4; ++index) - { - value |= static_cast(static_cast(data[index])) - << (index * 8); - } - return value; - } - - ImageFrame readPlane(const QString& path, - const QString& cameraId, - const FrameRecord& record, - qint64 t, - int z, - bool compressed) - { - if (record.width <= 0 || record.height <= 0 || t < 0 || z < 0) - { - return {}; - } - const int bytesPerPixel = record.pixelFormat == ImagePixelFormat::Mono8 - ? 1 - : record.pixelFormat == ImagePixelFormat::Mono16 ? 2 : 0; - if (bytesPerPixel == 0) - { - return {}; - } - - const int chunkWidth = (std::min)(record.width, kChunkEdge); - const int chunkHeight = (std::min)(record.height, kChunkEdge); - const int chunksX = (record.width + chunkWidth - 1) / chunkWidth; - const int chunksY = (record.height + chunkHeight - 1) / chunkHeight; - const quint64 chunksPerShard = static_cast(chunksX) * chunksY; - if (chunksPerShard == 0 - || chunksPerShard > (std::numeric_limits::max)() / 16) - { - return {}; - } - - QFile shard(QDir(path).filePath( - QStringLiteral("c/%1/0/%2/0/0").arg(t).arg(z))); - if (!shard.open(QIODevice::ReadOnly)) - { - return {}; - } - const qint64 tableBytes = static_cast(chunksPerShard * 16); - const qint64 indexBytes = tableBytes + 4; - if (shard.size() < indexBytes || !shard.seek(shard.size() - indexBytes)) - { - return {}; - } - const QByteArray index = shard.read(indexBytes); - if (index.size() != indexBytes) - { - return {}; - } - const quint32 expectedChecksum = readLittleEndian32(index.constData() + tableBytes); - const quint32 actualChecksum = crc32c::Crc32c( - reinterpret_cast(index.constData()), - static_cast(tableBytes)); - if (expectedChecksum != actualChecksum) - { - return {}; - } - - const qint64 stride = static_cast(record.width) * bytesPerPixel; - const qint64 byteCount = stride * record.height; - const qint64 chunkBytes = static_cast(chunkWidth) - * chunkHeight * bytesPerPixel; - if (stride > (std::numeric_limits::max)() - || byteCount <= 0 - || byteCount > (std::numeric_limits::max)() - || chunkBytes <= 0 - || chunkBytes > (std::numeric_limits::max)()) - { - return {}; - } - - QByteArray bytes(static_cast(byteCount), '\0'); - QByteArray decoded(static_cast(chunkBytes), '\0'); - for (int chunkY = 0; chunkY < chunksY; ++chunkY) - { - for (int chunkX = 0; chunkX < chunksX; ++chunkX) - { - const quint64 chunkIndex = static_cast(chunkY) * chunksX + chunkX; - const char* entry = index.constData() + static_cast(chunkIndex * 16); - const quint64 offset = readLittleEndian64(entry); - const quint64 extent = readLittleEndian64(entry + 8); - if (offset == kUnwrittenChunk && extent == kUnwrittenChunk) - { - continue; - } - if (offset > static_cast(shard.size() - indexBytes) - || extent > static_cast(shard.size() - indexBytes) - offset - || extent > static_cast( - (std::numeric_limits::max)())) - { - return {}; - } - if (!shard.seek(static_cast(offset))) - { - return {}; - } - const QByteArray payload = shard.read(static_cast(extent)); - if (payload.size() != static_cast(extent)) - { - return {}; - } - if (compressed) - { - const size_t result = ZSTD_decompress(decoded.data(), - static_cast(chunkBytes), - payload.constData(), - static_cast(extent)); - if (ZSTD_isError(result) || result != static_cast(chunkBytes)) - { - return {}; - } - } - else - { - if (payload.size() != chunkBytes) - { - return {}; - } - decoded = payload; - } - - const int destinationX = chunkX * chunkWidth; - const int destinationY = chunkY * chunkHeight; - const int copyWidth = (std::min)(chunkWidth, record.width - destinationX); - const int copyHeight = (std::min)(chunkHeight, record.height - destinationY); - for (int row = 0; row < copyHeight; ++row) - { - std::memcpy(bytes.data() - + static_cast(destinationY + row) * stride - + static_cast(destinationX) * bytesPerPixel, - decoded.constData() - + static_cast(row) * chunkWidth * bytesPerPixel, - static_cast(copyWidth) * bytesPerPixel); - } - } - } - - ImageFrame frame; - frame.cameraId = cameraId; - frame.width = record.width; - frame.height = record.height; - frame.stride = static_cast(stride); - frame.pixelFormat = record.pixelFormat; - frame.bitsPerSample = ImageFrame::normalizedBitsPerSample(record.pixelFormat, - record.bitsPerSample); - frame.frameIndex = record.frameIndex; - frame.timestampNs = record.timestampNs; - frame.sourceRoiX = record.sourceRoiX; - frame.sourceRoiY = record.sourceRoiY; - frame.sourceRoiWidth = record.sourceRoiWidth; - frame.sourceRoiHeight = record.sourceRoiHeight; - frame.bytes = std::move(bytes); - return frame.isValid() ? frame : ImageFrame{}; - } - } - - ImageFrame readOmeZarrFrame(const QString& rootPath, - const QString& cameraId, - int frameIndex, - const ExperimentDocument& document) - { - if (rootPath.trimmed().isEmpty() || frameIndex < 0) - { - return {}; - } - int storedIndex = 0; - const AcquisitionEventRecord* selectedEvent = nullptr; - const FrameRecord* selectedFrame = nullptr; - for (const AcquisitionEventRecord& record : document.events) - { - const auto frameIt = record.frames.constFind(cameraId); - if (!record.succeeded || frameIt == record.frames.constEnd()) - { - continue; - } - if (storedIndex++ == frameIndex) - { - selectedEvent = &record; - selectedFrame = &frameIt.value(); - break; - } - } - if (!selectedEvent || !selectedFrame) - { - return {}; - } - - const int positionIndex = selectedEvent->event.positionIndex; - const int z = selectedEvent->event.zIndex; - const qint64 t = timeIndexForEvent(selectedEvent->event, document.plan); - const bool multiPosition = document.plan.positions.size() > 1; - if (t < 0 - || z < 0 - || positionIndex < 0 - || (multiPosition - && positionIndex >= static_cast(document.plan.positions.size())) - || (!multiPosition && positionIndex != 0)) - { - return {}; - } - return readPlane(datasetPath(rootPath, positionIndex, multiPosition), - cameraId, - *selectedFrame, - t, - z, - document.plan.enableCompression); - } -} diff --git a/ScopeOneCore/src/RecordingManager.cpp b/ScopeOneCore/src/RecordingManager.cpp index d8a6f43..a5570e8 100644 --- a/ScopeOneCore/src/RecordingManager.cpp +++ b/ScopeOneCore/src/RecordingManager.cpp @@ -528,12 +528,6 @@ namespace scopeone::core::internal }); } } - settings.detector.manufacturer = cameraProperties.value( - QStringLiteral("Name")).toString().trimmed().toStdString(); - settings.detector.model = cameraProperties.value( - QStringLiteral("CameraName")).toString().trimmed().toStdString(); - settings.detector.serialNumber = cameraProperties.value( - QStringLiteral("CameraID")).toString().trimmed().toStdString(); double value = 0.0; if (readFiniteNumber(cameraProperties.value(QStringLiteral("Exposure")), value) && value > 0.0) diff --git a/ScopeOneCore/src/ScopeOneCore.cpp b/ScopeOneCore/src/ScopeOneCore.cpp index 094c4e5..a728450 100644 --- a/ScopeOneCore/src/ScopeOneCore.cpp +++ b/ScopeOneCore/src/ScopeOneCore.cpp @@ -10,7 +10,6 @@ #include "internal/CameraManager.h" #include "internal/ParticleAnalysis.h" #include "internal/RecordingManager.h" -#include "internal/OmeZarrStorage.h" #include "internal/SpatiotemporalBinningModule.h" #include "internal/StageMosaicManager.h" #include "MMCore.h" @@ -22,10 +21,7 @@ #include #include #include -#include #include -#include -#include #include #include #include @@ -35,11 +31,12 @@ #include #include #include +#include +#include #include #include #include #include -#include namespace { @@ -76,205 +73,6 @@ namespace return qBound(0, static_cast((numerator - 1) / kHistogramBinCount), maxValue); } - // Convert saved frame info metadata into an image pixel format - scopeone::core::ImagePixelFormat pixelFormatFromFrameInfo(const QByteArray& name, int id) - { - if (id == 1 || name == "Mono16") - { - return scopeone::core::ImagePixelFormat::Mono16; - } - if (id == 0 || name == "Mono8") - { - return scopeone::core::ImagePixelFormat::Mono8; - } - return scopeone::core::ImagePixelFormat::Invalid; - } - - // Opens a TIFF stack for frame readback - void* openTiffForRead(const QString& path) - { - const char* mode = "r"; -#if defined(_WIN32) - std::wstring w = path.toStdWString(); -#if defined(TIFFOpenW) - return TIFFOpenW(reinterpret_cast(w.c_str()), mode); -#else - return TIFFOpen(path.toLocal8Bit().constData(), mode); -#endif -#else - return TIFFOpen(path.toLocal8Bit().constData(), mode); -#endif - } - - // Closes a TIFF handle after frame readback - struct TiffReadCloser - { - void operator()(TIFF* tiff) const - { - if (tiff) - { - TIFFClose(tiff); - } - } - }; - - // Parses one CSV row from a binary frame info sidecar - QList parseFrameInfoCsvLine(const QByteArray& line) - { - QList fields; - QByteArray field; - bool inQuotes = false; - for (qsizetype i = 0; i < line.size(); ++i) - { - const char ch = line.at(i); - if (inQuotes) - { - if (ch == '"') - { - if (i + 1 < line.size() && line.at(i + 1) == '"') - { - field.append('"'); - ++i; - } - else - { - inQuotes = false; - } - } - else - { - field.append(ch); - } - continue; - } - - if (ch == ',') - { - fields.append(field); - field.clear(); - } - else if (ch == '"' && field.isEmpty()) - { - inQuotes = true; - } - else - { - field.append(ch); - } - } - fields.append(field); - return fields; - } - - // Read one signed integer field from a frame info row - bool readIntField(const QList& fields, int index, int& value) - { - if (index < 0 || index >= fields.size()) - { - return false; - } - bool ok = false; - value = fields.at(index).toInt(&ok); - return ok; - } - - // Read one signed long integer field from a frame info row - bool readInt64Field(const QList& fields, int index, qint64& value) - { - if (index < 0 || index >= fields.size()) - { - return false; - } - bool ok = false; - value = fields.at(index).toLongLong(&ok); - return ok; - } - - // Read one unsigned long integer field from a frame info row - bool readUInt64Field(const QList& fields, int index, quint64& value) - { - if (index < 0 || index >= fields.size()) - { - return false; - } - bool ok = false; - value = fields.at(index).toULongLong(&ok); - return ok; - } - - // Read one unsigned long integer from stored JSON metadata - quint64 readJsonUInt64(const QJsonObject& object, const QString& key, quint64 currentValue) - { - const QJsonValue value = object.value(key); - if (value.isString()) - { - bool ok = false; - const quint64 parsed = value.toString().toULongLong(&ok); - return ok ? parsed : currentValue; - } - if (value.isDouble()) - { - const double parsed = value.toDouble(-1.0); - return parsed >= 0.0 ? static_cast(parsed) : currentValue; - } - return currentValue; - } - - // Read one integer from stored JSON metadata - int readJsonInt(const QJsonObject& object, const QString& key, int currentValue) - { - const QJsonValue value = object.value(key); - if (value.isString()) - { - bool ok = false; - const int parsed = value.toString().toInt(&ok); - return ok ? parsed : currentValue; - } - if (value.isDouble()) - { - return value.toInt(currentValue); - } - return currentValue; - } - - // Apply TIFF page metadata to a recorded image frame - void applyTiffImageDescriptionMetadata(const QByteArray& imageDescription, - scopeone::core::ImageFrame& frame) - { - if (imageDescription.isEmpty()) - { - return; - } - - QJsonParseError parseError; - const QJsonDocument document = QJsonDocument::fromJson(imageDescription, &parseError); - if (parseError.error != QJsonParseError::NoError || !document.isObject()) - { - return; - } - - const QJsonObject object = document.object(); - if (object.value(QStringLiteral("schema")).toString() - != QString::fromLatin1(scopewriter::kFrameMetadataProtocol)) - { - return; - } - const QString storedCameraId = object.value(QStringLiteral("camera_id")).toString().trimmed(); - if (!storedCameraId.isEmpty()) - { - frame.cameraId = storedCameraId; - } - frame.frameIndex = readJsonUInt64(object, QStringLiteral("frame_index"), frame.frameIndex); - frame.timestampNs = readJsonUInt64(object, QStringLiteral("timestamp_ns"), frame.timestampNs); - const int storedBitsPerSample = readJsonInt(object, QStringLiteral("bits_per_sample"), frame.bitsPerSample); - frame.bitsPerSample = scopeone::core::ImageFrame::normalizedBitsPerSample(frame.pixelFormat, - storedBitsPerSample); - frame.sourceRoiX = readJsonInt(object, QStringLiteral("source_roi_x"), frame.sourceRoiX); - frame.sourceRoiY = readJsonInt(object, QStringLiteral("source_roi_y"), frame.sourceRoiY); - frame.sourceRoiWidth = readJsonInt(object, QStringLiteral("source_roi_width"), frame.sourceRoiWidth); - frame.sourceRoiHeight = readJsonInt(object, QStringLiteral("source_roi_height"), frame.sourceRoiHeight); - } - // Estimate display levels while ignoring small outlier tails void computeAutoLevels(scopeone::core::ScopeOneCore::HistogramStats& stats) { @@ -604,7 +402,6 @@ namespace } propertyValuesObject.insert(propertyName, property.value()); } - devicePropertiesObject.insert(trimmedDeviceLabel, propertyValuesObject); } @@ -912,273 +709,174 @@ namespace scopeone::core return {}; } - if (m_manifest.plan.format == RecordingFormat::OmeZarr) + const bool omeFormat = m_manifest.plan.format == RecordingFormat::OmeTiff + || m_manifest.plan.format == RecordingFormat::OmeZarr; + const AcquisitionEventRecord* selectedRecord = nullptr; + if (omeFormat) { - return internal::readOmeZarrFrame(fileManifest.rawPath, - cameraId, - index, - m_manifest); - } - - if (m_manifest.plan.format == RecordingFormat::OmeTiff - || m_manifest.plan.format == RecordingFormat::Tiff) - { - const AcquisitionEventRecord* selectedRecord = nullptr; - if (m_manifest.plan.format == RecordingFormat::OmeTiff) + int storedFrameIndex = 0; + for (const AcquisitionEventRecord& record : m_manifest.events) { - int storedFrameIndex = 0; - for (const AcquisitionEventRecord& record : m_manifest.events) + if (!record.succeeded || !record.frames.contains(cameraId)) { - if (!record.succeeded || !record.frames.contains(cameraId)) - { - continue; - } - if (storedFrameIndex++ == index) - { - selectedRecord = &record; - break; - } + continue; } - } - - QString tiffPath = fileManifest.rawPath; - int tiffDirectoryIndex = index; - if (m_manifest.plan.format == RecordingFormat::OmeTiff - && m_manifest.plan.positions.size() > 1) - { - if (!selectedRecord - || selectedRecord->event.positionIndex < 0 - || selectedRecord->event.positionIndex - >= static_cast(m_manifest.plan.positions.size())) + if (storedFrameIndex++ == index) { - return {}; - } - - const int positionIndex = selectedRecord->event.positionIndex; - const QFileInfo rootInfo(fileManifest.rawPath); - tiffPath = QDir(fileManifest.rawPath).filePath( - QStringLiteral("%1_p%2.ome.tiff") - .arg(rootInfo.fileName()) - .arg(positionIndex, 3, 10, QChar('0'))); - tiffDirectoryIndex = 0; - for (const AcquisitionEventRecord& record : m_manifest.events) - { - if (&record == selectedRecord) - { - break; - } - if (record.succeeded - && record.event.positionIndex == positionIndex - && record.frames.contains(cameraId)) - { - ++tiffDirectoryIndex; - } + selectedRecord = &record; + break; } } - - std::unique_ptr tiff( - reinterpret_cast(openTiffForRead(tiffPath))); - if (!tiff) + if (!selectedRecord) { return {}; } + } - if (!TIFFSetDirectory(tiff.get(), static_cast(tiffDirectoryIndex))) - { - return {}; - } + scopewriter::DatasetFrameLocation location; + location.frameIndex = static_cast(index); +#if defined(_WIN32) + location.dataPath = std::filesystem::path(fileManifest.rawPath.toStdWString()); + location.frameMetadataPath = + std::filesystem::path(fileManifest.frameInfoPath.toStdWString()); +#else + location.dataPath = std::filesystem::path(fileManifest.rawPath.toStdString()); + location.frameMetadataPath = + std::filesystem::path(fileManifest.frameInfoPath.toStdString()); +#endif - uint32_t width = 0; - uint32_t height = 0; - uint16_t bitsPerSample = 0; - uint16_t samplesPerPixel = 1; - uint16_t planarConfig = PLANARCONFIG_CONTIG; - char* imageDescription = nullptr; - if (!TIFFGetField(tiff.get(), TIFFTAG_IMAGEWIDTH, &width) - || !TIFFGetField(tiff.get(), TIFFTAG_IMAGELENGTH, &height) - || !TIFFGetField(tiff.get(), TIFFTAG_BITSPERSAMPLE, &bitsPerSample)) - { - return {}; - } - TIFFGetFieldDefaulted(tiff.get(), TIFFTAG_SAMPLESPERPIXEL, &samplesPerPixel); - TIFFGetFieldDefaulted(tiff.get(), TIFFTAG_PLANARCONFIG, &planarConfig); - QByteArray imageDescriptionBytes; - if (TIFFGetField(tiff.get(), TIFFTAG_IMAGEDESCRIPTION, &imageDescription) && imageDescription) - { - imageDescriptionBytes = QByteArray(imageDescription); - } - if (width == 0 || height == 0 || samplesPerPixel != 1 || planarConfig != PLANARCONFIG_CONTIG) + switch (m_manifest.plan.format) + { + case RecordingFormat::OmeZarr: + { + const int positionIndex = selectedRecord->event.positionIndex; + const bool multiPosition = m_manifest.plan.positions.size() > 1; + if (positionIndex < 0 + || (multiPosition + && positionIndex >= static_cast(m_manifest.plan.positions.size())) + || (!multiPosition && positionIndex != 0)) { return {}; } - - scopeone::core::ImagePixelFormat pixelFormat = scopeone::core::ImagePixelFormat::Invalid; - if (bitsPerSample <= 8) - { - pixelFormat = scopeone::core::ImagePixelFormat::Mono8; - bitsPerSample = 8; - } - else if (bitsPerSample <= 16) + QString arrayPath = fileManifest.rawPath; + if (multiPosition) { - pixelFormat = scopeone::core::ImagePixelFormat::Mono16; - bitsPerSample = 16; + arrayPath = QDir(arrayPath).filePath( + QStringLiteral("Position %1").arg(positionIndex + 1)); } - else + arrayPath = QDir(arrayPath).filePath(QStringLiteral("0")); +#if defined(_WIN32) + location.dataPath = std::filesystem::path(arrayPath.toStdWString()); +#else + location.dataPath = std::filesystem::path(arrayPath.toStdString()); +#endif + location.format = scopewriter::Format::OmeZarr; + location.t = static_cast(selectedRecord->event.burstIndex) + * (std::max)(1, m_manifest.plan.framesPerBurst) + + selectedRecord->event.timeIndex; + location.c = 0; + location.z = selectedRecord->event.zIndex; + break; + } + case RecordingFormat::OmeTiff: + { + location.format = scopewriter::Format::OmeTiff; + if (m_manifest.plan.positions.size() <= 1) { - return {}; + break; } - - const int bytesPerPixel = pixelFormat == scopeone::core::ImagePixelFormat::Mono16 ? 2 : 1; - const qint64 stride = static_cast(width) * bytesPerPixel; - const qint64 payloadBytes = stride * static_cast(height); - if (width > static_cast((std::numeric_limits::max)()) - || height > static_cast((std::numeric_limits::max)()) - || stride > (std::numeric_limits::max)() - || payloadBytes <= 0 - || payloadBytes > (std::numeric_limits::max)()) + const int positionIndex = selectedRecord->event.positionIndex; + if (positionIndex < 0 + || positionIndex >= static_cast(m_manifest.plan.positions.size())) { return {}; } - - QByteArray bytes; - bytes.resize(static_cast(payloadBytes)); - for (uint32_t y = 0; y < height; ++y) + const QFileInfo rootInfo(fileManifest.rawPath); + const QString tiffPath = QDir(fileManifest.rawPath).filePath( + QStringLiteral("%1_p%2.ome.tiff") + .arg(rootInfo.fileName()) + .arg(positionIndex, 3, 10, QChar('0'))); +#if defined(_WIN32) + location.dataPath = std::filesystem::path(tiffPath.toStdWString()); +#else + location.dataPath = std::filesystem::path(tiffPath.toStdString()); +#endif + location.frameIndex = 0; + for (const AcquisitionEventRecord& record : m_manifest.events) { - char* row = bytes.data() + static_cast(y) * stride; - if (TIFFReadScanline(tiff.get(), row, y, 0) < 0) + if (&record == selectedRecord) { - return {}; + break; + } + if (record.succeeded + && record.event.positionIndex == positionIndex + && record.frames.contains(cameraId)) + { + ++location.frameIndex; } } - - ImageFrame frame; - frame.cameraId = cameraId; - frame.width = static_cast(width); - frame.height = static_cast(height); - frame.stride = static_cast(stride); - frame.pixelFormat = pixelFormat; - frame.bitsPerSample = ImageFrame::normalizedBitsPerSample(frame.pixelFormat, bitsPerSample); - frame.frameIndex = static_cast(index + 1); - frame.sourceRoiX = 0; - frame.sourceRoiY = 0; - frame.sourceRoiWidth = frame.width; - frame.sourceRoiHeight = frame.height; - applyTiffImageDescriptionMetadata(imageDescriptionBytes, frame); - if (selectedRecord) + break; + } + case RecordingFormat::Tiff: + location.format = scopewriter::Format::Tiff; + break; + case RecordingFormat::Binary: + if (fileManifest.frameInfoPath.isEmpty()) { - const FrameRecord& storedFrame = selectedRecord->frames.constFind(cameraId).value(); - frame.frameIndex = storedFrame.frameIndex; - frame.timestampNs = storedFrame.timestampNs; - frame.bitsPerSample = ImageFrame::normalizedBitsPerSample( - frame.pixelFormat, - storedFrame.bitsPerSample); - frame.sourceRoiX = storedFrame.sourceRoiX; - frame.sourceRoiY = storedFrame.sourceRoiY; - frame.sourceRoiWidth = storedFrame.sourceRoiWidth; - frame.sourceRoiHeight = storedFrame.sourceRoiHeight; + return {}; } - frame.bytes = std::move(bytes); - return frame.isValid() ? frame : ImageFrame{}; + location.format = scopewriter::Format::Binary; + break; } - if (m_manifest.plan.format != RecordingFormat::Binary || fileManifest.frameInfoPath.isEmpty()) + scopewriter::DatasetFrame stored; + std::string datasetError; + if (!scopewriter::datasetFrame(location, stored, datasetError) + || stored.metadata.stride > static_cast( + (std::numeric_limits::max)()) + || stored.bytes.size() > static_cast( + (std::numeric_limits::max)())) { return {}; } - QFile frameInfoFile(fileManifest.frameInfoPath); - QFile rawFile(fileManifest.rawPath); - if (!frameInfoFile.open(QIODevice::ReadOnly | QIODevice::Text) - || !rawFile.open(QIODevice::ReadOnly)) - { - return {}; - } - - const QByteArray header = frameInfoFile.readLine().trimmed(); - if (header != QByteArray(scopewriter::kBinaryFrameMetadataHeader)) - { - return {}; - } - int currentIndex = 0; - qint64 rawOffset = 0; - while (!frameInfoFile.atEnd()) + ImageFrame frame; + frame.cameraId = QString::fromUtf8(stored.metadata.cameraId); + if (frame.cameraId.trimmed().isEmpty()) { - const QByteArray line = frameInfoFile.readLine().trimmed(); - if (line.isEmpty()) - { - continue; - } - const QList fields = parseFrameInfoCsvLine(line); - if (fields.size() != 14) - { - return {}; - } - - qint64 payloadBytes = 0; - if (!readInt64Field(fields, 9, payloadBytes) || payloadBytes <= 0) - { - return {}; - } - - if (currentIndex == index) - { - if (payloadBytes > (std::numeric_limits::max)() - || !rawFile.seek(rawOffset)) - { - return {}; - } - - QByteArray bytes = rawFile.read(payloadBytes); - if (static_cast(bytes.size()) != payloadBytes) - { - return {}; - } - - ImageFrame frame; - frame.cameraId = QString::fromUtf8(fields.at(0)).trimmed(); - if (frame.cameraId.isEmpty()) - { - frame.cameraId = cameraId; - } - if (!readUInt64Field(fields, 1, frame.frameIndex) - || !readUInt64Field(fields, 2, frame.timestampNs) - || !readIntField(fields, 3, frame.width) - || !readIntField(fields, 4, frame.height)) - { - return {}; - } - - int bitsPerSample = 0; - int pixelFormatId = 0; - if (!readIntField(fields, 5, bitsPerSample) - || !readIntField(fields, 6, frame.stride) - || !readIntField(fields, 8, pixelFormatId)) - { - return {}; - } - frame.pixelFormat = pixelFormatFromFrameInfo(fields.at(7), pixelFormatId); - frame.bitsPerSample = ImageFrame::normalizedBitsPerSample(frame.pixelFormat, bitsPerSample); - - if (!readIntField(fields, 10, frame.sourceRoiX) - || !readIntField(fields, 11, frame.sourceRoiY) - || !readIntField(fields, 12, frame.sourceRoiWidth) - || !readIntField(fields, 13, frame.sourceRoiHeight)) - { - return {}; - } - - frame.bytes = std::move(bytes); - return frame.isValid() ? frame : ImageFrame{}; - } - - if (payloadBytes > (std::numeric_limits::max)() - rawOffset) - { - return {}; - } - rawOffset += payloadBytes; - ++currentIndex; + frame.cameraId = cameraId; } - return {}; + frame.width = stored.width; + frame.height = stored.height; + frame.stride = static_cast(stored.metadata.stride); + frame.pixelFormat = stored.pixelType == scopewriter::PixelType::UInt8 + ? ImagePixelFormat::Mono8 + : ImagePixelFormat::Mono16; + frame.bitsPerSample = ImageFrame::normalizedBitsPerSample( + frame.pixelFormat, stored.significantBits); + frame.frameIndex = stored.metadata.frameIndex; + frame.timestampNs = stored.metadata.timestampNs; + frame.sourceRoiX = stored.metadata.sourceRoiX; + frame.sourceRoiY = stored.metadata.sourceRoiY; + frame.sourceRoiWidth = stored.metadata.sourceRoiWidth; + frame.sourceRoiHeight = stored.metadata.sourceRoiHeight; + frame.bytes = QByteArray(reinterpret_cast(stored.bytes.data()), + static_cast(stored.bytes.size())); + + if (selectedRecord) + { + const FrameRecord& storedMetadata = + selectedRecord->frames.constFind(cameraId).value(); + frame.frameIndex = storedMetadata.frameIndex; + frame.timestampNs = storedMetadata.timestampNs; + frame.bitsPerSample = ImageFrame::normalizedBitsPerSample( + frame.pixelFormat, storedMetadata.bitsPerSample); + frame.sourceRoiX = storedMetadata.sourceRoiX; + frame.sourceRoiY = storedMetadata.sourceRoiY; + frame.sourceRoiWidth = storedMetadata.sourceRoiWidth; + frame.sourceRoiHeight = storedMetadata.sourceRoiHeight; + } + return frame.isValid() ? frame : ImageFrame{}; } struct ScopeOneCore::Managers From b642890838d6c65ca56411926cfe9d60d2c352ca Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:04:23 +0200 Subject: [PATCH 3/7] Add calibrated OME recording and measurement tools --- ScopeOneCore/include/scopeone/ScopeOneCore.h | 42 +++- ScopeOneCore/internal/CameraBackend.h | 3 +- ScopeOneCore/internal/CameraManager.h | 2 +- ScopeOneCore/internal/RecordingManager.h | 10 +- ScopeOneCore/python/scopeone/README.md | 12 +- .../python/scopeone/src/scopeone/client.py | 4 - .../python/scopeone/src/scopeone/core.py | 4 - ScopeOneCore/src/AgentCameraBackend.cpp | 5 +- ScopeOneCore/src/CameraBackend.cpp | 20 +- ScopeOneCore/src/NativeCameraBackend.cpp | 2 +- ScopeOneCore/src/RecordingManager.cpp | 53 +++-- ScopeOneCore/src/ScopeOneCore.cpp | 105 ++++++--- scripts/build.ps1 | 44 ++-- src/ImageToolsDialog.cpp | 93 +++++++- src/ImageToolsDialog.h | 19 +- src/InspectWidget.cpp | 78 +++++++ src/InspectWidget.h | 12 ++ src/MainWindow.cpp | 55 +++++ src/MainWindow.h | 2 + src/PreviewWidget.cpp | 204 +++++++++++++++++- src/PreviewWidget.h | 12 ++ src/RecordingWidget.cpp | 103 ++++++++- src/RecordingWidget.h | 4 + src/ScopeOneLocalApiServer.cpp | 14 +- src/ScopeOneMcpServer.cpp | 11 - 25 files changed, 777 insertions(+), 136 deletions(-) diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index 0056904..fe9a39d 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -194,7 +194,10 @@ namespace scopeone::core RecordingWriterPhase phase() const { return m_phase; } qint64 pendingWriteBytes() const { return m_pendingWriteBytes; } qint64 maxPendingWriteBytes() const { return m_maxPendingWriteBytes; } + qint64 framesCaptured() const { return m_framesCaptured; } qint64 framesWritten() const { return m_framesWritten; } + qint64 droppedFrames() const { return m_droppedFrames; } + qint64 bytesWritten() const { return m_bytesWritten; } const QString& errorMessage() const { return m_errorMessage; } bool isTerminal() const @@ -208,7 +211,10 @@ namespace scopeone::core m_phase = RecordingWriterPhase::Idle; m_pendingWriteBytes = 0; m_maxPendingWriteBytes = maxPendingWriteBytes; + m_framesCaptured = 0; m_framesWritten = 0; + m_droppedFrames = 0; + m_bytesWritten = 0; m_errorMessage.clear(); } @@ -239,17 +245,35 @@ namespace scopeone::core m_maxPendingWriteBytes = maxPendingWriteBytes; } + void setFramesCaptured(qint64 framesCaptured) + { + m_framesCaptured = framesCaptured; + } + void addWrittenFrames(qint64 framesWritten) { m_framesWritten += framesWritten; } + void addDroppedFrames(qint64 droppedFrames) + { + m_droppedFrames += droppedFrames; + } + + void addWrittenBytes(qint64 bytesWritten) + { + m_bytesWritten += bytesWritten; + } + void setFrom(const RecordingWriterStatus& other) { m_phase = other.m_phase; m_pendingWriteBytes = other.m_pendingWriteBytes; m_maxPendingWriteBytes = other.m_maxPendingWriteBytes; + m_framesCaptured = other.m_framesCaptured; m_framesWritten = other.m_framesWritten; + m_droppedFrames = other.m_droppedFrames; + m_bytesWritten = other.m_bytesWritten; m_errorMessage = other.m_errorMessage; } @@ -257,7 +281,10 @@ namespace scopeone::core RecordingWriterPhase m_phase{RecordingWriterPhase::Idle}; qint64 m_pendingWriteBytes{0}; qint64 m_maxPendingWriteBytes{0}; + qint64 m_framesCaptured{0}; qint64 m_framesWritten{0}; + qint64 m_droppedFrames{0}; + qint64 m_bytesWritten{0}; QString m_errorMessage; }; @@ -270,6 +297,10 @@ namespace scopeone::core ExperimentRunState runState() const { return m_manifest.runState; } const QString& errorMessage() const { return m_manifest.errorMessage; } bool streamedToDisk() const { return m_manifest.output.streamedToDisk; } + double cameraPixelSizeUm(const QString& cameraId) const + { + return m_cameraPixelSizesUm.value(cameraId.trimmed(), 0.0); + } QStringList recordedCameraIds() const { @@ -422,6 +453,10 @@ namespace scopeone::core } void setSoftwareSnapshot(const SoftwareSnapshot& software) { m_manifest.software = software; } void setDeviceProperties(const QJsonObject& properties) { m_manifest.deviceProperties = properties; } + void setCameraPixelSizesUm(const QHash& pixelSizesUm) + { + m_cameraPixelSizesUm = pixelSizesUm; + } void setRunState(ExperimentRunState state, quint64 completedTimestampNs = 0, const QString& errorMessage = QString()) @@ -482,12 +517,14 @@ namespace scopeone::core clone->m_frames = m_frames; clone->m_saveResult = m_saveResult; clone->m_writerStatus = m_writerStatus; + clone->m_cameraPixelSizesUm = m_cameraPixelSizesUm; return clone; } void applySaveStateFrom(const RecordingSessionData& source) { m_saveResult = source.m_saveResult; m_writerStatus = source.m_writerStatus; + m_cameraPixelSizesUm = source.m_cameraPixelSizesUm; if (source.m_saveResult.saved()) { m_manifest.plan = source.m_manifest.plan; @@ -500,6 +537,7 @@ namespace scopeone::core ExperimentDocument m_manifest; QHash> m_frames; + QHash m_cameraPixelSizesUm; RecordingSaveResult m_saveResult; RecordingWriterStatus m_writerStatus; }; @@ -582,6 +620,8 @@ namespace scopeone::core QStringList cameraIds() const { return m_cameraIds; } QStringList runningPreviewCameraIds() const; + double cameraPixelSizeUm(const QString& cameraId) const; + bool setCameraPixelSizeUm(const QString& cameraId, double pixelSizeUm); bool startPreview(const QString& cameraIdOrAll); bool stopPreview(const QString& cameraIdOrAll); @@ -822,7 +862,7 @@ namespace scopeone::core { bool inFlight{false}; bool retryScheduled{false}; - qint64 lastScheduledMs{0}; + QElapsedTimer lastScheduledTimer; quint64 activeSequence{0}; ImageFrame queuedFrame; }; diff --git a/ScopeOneCore/internal/CameraBackend.h b/ScopeOneCore/internal/CameraBackend.h index e29e579..efe38a6 100644 --- a/ScopeOneCore/internal/CameraBackend.h +++ b/ScopeOneCore/internal/CameraBackend.h @@ -93,7 +93,7 @@ namespace scopeone::core::internal 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 frameDeliveryFailed(const QString& errorMessage, quint64 droppedFrames); void previewStateChanged(bool running); void agentControlServerListening(const QString& cameraId, const QString& serverName); @@ -136,6 +136,7 @@ namespace scopeone::core::internal QHash m_pendingAcquiredFrameCounts; QList m_pendingRecordingFrames; qint64 m_pendingRecordingBytes{0}; + quint64 m_pendingDroppedRecordingFrames{0}; QString m_pendingDeliveryError; std::atomic_bool m_recordingFrameDeliveryEnabled{false}; std::atomic_bool m_highRateFrameDeliveryEnabled{false}; diff --git a/ScopeOneCore/internal/CameraManager.h b/ScopeOneCore/internal/CameraManager.h index 13a9dae..3a84d79 100644 --- a/ScopeOneCore/internal/CameraManager.h +++ b/ScopeOneCore/internal/CameraManager.h @@ -72,7 +72,7 @@ namespace scopeone::core::internal 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 frameDeliveryFailed(const QString& errorMessage, quint64 droppedFrames); void previewStateChanged(bool running); void agentControlServerListening(const QString& cameraId, const QString& serverName); diff --git a/ScopeOneCore/internal/RecordingManager.h b/ScopeOneCore/internal/RecordingManager.h index 5942066..b2a855b 100644 --- a/ScopeOneCore/internal/RecordingManager.h +++ b/ScopeOneCore/internal/RecordingManager.h @@ -48,7 +48,8 @@ namespace scopeone::core::internal bool start(const ExperimentPlan& requestedPlan, const QStringList& activeCameraIds, - const QJsonObject& deviceProperties); + const QJsonObject& deviceProperties, + const QHash& cameraPixelSizesUm); void stop(); void shutdown(); void setRecordedMaxBytes(qint64 bytes); @@ -57,7 +58,7 @@ namespace scopeone::core::internal bool isRecording() const { return m_captureState.isRecording; } void onRawFramesReady(const QList& frames); - void onFrameDeliveryFailed(const QString& errorMessage); + void onFrameDeliveryFailed(const QString& errorMessage, quint64 droppedFrames); static QString saveSessionToDisk(const std::shared_ptr& session); @@ -113,6 +114,7 @@ namespace scopeone::core::internal QString frameInfoPath; QString metadataFileName; QJsonObject cameraProperties; + double pixelSizeUm{0.0}; void* backend{nullptr}; quint64 acquisitionStartTimestampNs{0}; int width{0}; @@ -187,7 +189,9 @@ namespace scopeone::core::internal bool planUsesMda(const ExperimentPlan& plan) const; bool planStreamsMda(const ExperimentPlan& plan) const; void resetCaptureState(const ExperimentPlan& plan); - void resetSessionState(const ExperimentPlan& plan, const QJsonObject& deviceProperties); + void resetSessionState(const ExperimentPlan& plan, + const QJsonObject& deviceProperties, + const QHash& cameraPixelSizesUm); void finalizeActiveSession(ExperimentRunState state, const QString& errorMessage); void finishRecording(ExperimentRunState state, const QString& errorMessage = QString()); static bool writeSessionDocument(const std::shared_ptr& session, diff --git a/ScopeOneCore/python/scopeone/README.md b/ScopeOneCore/python/scopeone/README.md index 042cc2b..56722a4 100644 --- a/ScopeOneCore/python/scopeone/README.md +++ b/ScopeOneCore/python/scopeone/README.md @@ -157,7 +157,7 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.move_z_relative(dz, device=None)` - `ScopeOne.move_xy_to(x, y, device=None)` - `ScopeOne.move_z_to(z, device=None)` -- `ScopeOne.start_stage_mosaic(camera_id, xy_stage_id, rows=1, columns=1, pixel_size_um=1.0, step_x_um=0.0, step_y_um=0.0, settle_ms=150, return_to_start=True, gallery_save_dir=None)` +- `ScopeOne.start_stage_mosaic(camera_id, xy_stage_id, rows=1, columns=1, step_x_um=0.0, step_y_um=0.0, settle_ms=150, return_to_start=True, gallery_save_dir=None)` - `ScopeOne.stage_mosaic_status()` - `ScopeOne.cancel_stage_mosaic()` - `ScopeOne.processing_state()` @@ -195,7 +195,7 @@ A control connection is synchronous and processes one request at a time. Agent a - `ScopeOne.show_image(image, layer_id="python_result", name="Python Result", camera="python", bits_per_sample=None)` - `ScopeOne.save_frame(frame, save_dir, base_name, image=None, format="ome-tiff", compression=False, compression_level=6)` - `ScopeOne.save_image(image, save_dir, base_name, format="ome-tiff", compression=False, compression_level=6, camera="python", bits_per_sample=None)` -- `ScopeOne.record(frames, camera="All", timeout_ms=120000, mda_interval_ms=0.0, z_positions=None, positions=None, order=None, pixel_size_um=0.0)` +- `ScopeOne.record(frames, camera="All", timeout_ms=120000, mda_interval_ms=0.0, z_positions=None, positions=None, order=None)` - `RecordingSession.camera_ids()` - `RecordingSession.frame_count(camera=None)` - `RecordingSession.frame(camera, index)` @@ -225,7 +225,7 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `ping`: health check. - `version`: response `version` for ScopeOne and `coreVersion` for ScopeOneCore. -- `status`: response `version`, `coreVersion`, cameras, devices, running previews, processing state, layer keys, Stage Mosaic status, recording progress, and writer status. +- `status`: response `version`, `coreVersion`, cameras, devices, running previews, processing state, layer keys, Stage Mosaic status, recording progress, and writer status. Writer status includes captured, written, and dropped frame counts, written bytes, and queued bytes. - `capabilities`: response `capabilities` with operation groups and hardware, filesystem, destructive, and long-running operation classifications. - `state_snapshot`: response `snapshot` with application and Core versions, configuration, hardware inventory, preview, processing, scene, live acquisition and writer progress, experiment, and session state. - `frame_mapping_info`: response `mappingName`, `mappingSize`, `headerBytes`, `maxPayloadBytes`, and supported `pixelFormats`. @@ -283,7 +283,7 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `move_z_relative`: fields `device`, `dz`. - `move_xy_to`: fields `device`, `x`, `y`. - `move_z_to`: fields `device`, `z`. -- `start_stage_mosaic`: fields `cameraId`, `xyStageId`, and optional `rows`, `columns`, `pixelSizeUm`, `stepXUm`, `stepYUm`, `settleMs`, `returnToStart`, and `gallerySaveDir`; starts asynchronous mosaic acquisition and returns `status`. `gallerySaveDir` becomes the default directory if the resulting Gallery session is saved later. +- `start_stage_mosaic`: fields `cameraId`, `xyStageId`, and optional `rows`, `columns`, `stepXUm`, `stepYUm`, `settleMs`, `returnToStart`, and `gallerySaveDir`; starts asynchronous mosaic acquisition and returns `status`. `gallerySaveDir` becomes the default directory if the resulting Gallery session is saved later. - `stage_mosaic_status`: response `status` with `state`, tile progress, message, and completed session ID. - `cancel_stage_mosaic`: cancels the running mosaic and returns its final `status`. - `processing_modules`: response `bitDepth`, `realTime`, and `modules`. @@ -300,7 +300,7 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo - `start_experiment`: field `document`; starts a validated Draft asynchronously and responds with `experimentId`, `state`, and `document`. - `experiment_status`: field `experimentId`; response `state`, `cancelRequested`, `document`, live `progress` and `writer` state while active, and completed recording session details when available. - `cancel_experiment`: field `experimentId`; requests cancellation and returns the current experiment status. -- `record`: fields `frames`, `camera`, `timeoutMs`, `mdaIntervalMs`, `pixelSizeUm`, `zPositions`, `positions`, `order`; response `sessionId`, `cameraIds`. +- `record`: fields `frames`, `camera`, `timeoutMs`, `mdaIntervalMs`, `zPositions`, `positions`, `order`; response `sessionId`, `cameraIds`. - `session_info`: fields `sessionId`; response `cameraIds`, `frameCount`, `frameCounts`. - `session_close`: fields `sessionId`; releases the recorded session held by the app. - `session_frame`: fields `sessionId`, `camera`, `index`; response `mappingName`, `mappingSize`, and frame metadata. @@ -321,7 +321,6 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo "camera": "Camera", "timeoutMs": 120000, "mdaIntervalMs": 0.0, - "pixelSizeUm": 0.0, "zPositions": [0.0, 1.0], "positions": [[0.0, 0.0]], "order": ["time", "z", "xy"] @@ -329,7 +328,6 @@ ScopeOne uses one local control pipe for JSON commands and one shared-memory blo ``` `record` returns `sessionId` and `cameraIds`. If `zPositions` or `positions` is non-empty, recording uses the MDA snap path. If both are empty, recording uses the preview/raw-frame path. -`pixelSizeUm` overrides the active Micro-Manager calibration when positive. Zero uses the active calibration and leaves OME physical pixel size unset when no calibration is available. For timed MDA with more than one time point, `order` must begin with `time` so event start times remain monotonic. The initially created document is a complete editable Draft with in-memory recording enabled by default. Set `plan.streamToDisk`, `plan.saveDir`, and `plan.baseName` together for streamed output. Experiment documents are parsed strictly: every schema field is required, unknown fields and unsupported schema versions are rejected, and `start_experiment` accepts only Draft documents whose camera IDs are currently available. `start_experiment` is non-blocking; use the returned `ExperimentSession` or the direct status and cancel methods to control the run. Call `ExperimentSession.close()` after completion to release retained recording frames while keeping document status available. diff --git a/ScopeOneCore/python/scopeone/src/scopeone/client.py b/ScopeOneCore/python/scopeone/src/scopeone/client.py index 4738035..4c8586e 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/client.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/client.py @@ -946,7 +946,6 @@ def start_stage_mosaic( xy_stage_id: str, rows: int = 1, columns: int = 1, - pixel_size_um: float = 1.0, step_x_um: float = 0.0, step_y_um: float = 0.0, settle_ms: int = 150, @@ -959,7 +958,6 @@ def start_stage_mosaic( "xyStageId": xy_stage_id, "rows": int(rows), "columns": int(columns), - "pixelSizeUm": float(pixel_size_um), "stepXUm": float(step_x_um), "stepYUm": float(step_y_um), "settleMs": int(settle_ms), @@ -1113,7 +1111,6 @@ def record( z_positions: list[float] | None = None, positions: list[tuple[float, float]] | None = None, order: list[str] | None = None, - pixel_size_um: float = 0.0, ): request = { "type": "record", @@ -1121,7 +1118,6 @@ def record( "camera": camera, "timeoutMs": timeout_ms, "mdaIntervalMs": float(mda_interval_ms), - "pixelSizeUm": float(pixel_size_um), } if z_positions is not None: request["zPositions"] = [float(z) for z in z_positions] diff --git a/ScopeOneCore/python/scopeone/src/scopeone/core.py b/ScopeOneCore/python/scopeone/src/scopeone/core.py index aacb638..e0c6aa8 100644 --- a/ScopeOneCore/python/scopeone/src/scopeone/core.py +++ b/ScopeOneCore/python/scopeone/src/scopeone/core.py @@ -285,7 +285,6 @@ def start_stage_mosaic( xy_stage_id: str, rows: int = 1, columns: int = 1, - pixel_size_um: float = 1.0, step_x_um: float = 0.0, step_y_um: float = 0.0, settle_ms: int = 150, @@ -297,7 +296,6 @@ def start_stage_mosaic( xy_stage_id, rows, columns, - pixel_size_um, step_x_um, step_y_um, settle_ms, @@ -527,7 +525,6 @@ def record( z_positions: list[float] | None = None, positions: list[tuple[float, float]] | None = None, order: list[str] | None = None, - pixel_size_um: float = 0.0, ) -> RecordingSession: return RecordingSession( self._client.record( @@ -538,6 +535,5 @@ def record( z_positions, positions, order, - pixel_size_um, ) ) diff --git a/ScopeOneCore/src/AgentCameraBackend.cpp b/ScopeOneCore/src/AgentCameraBackend.cpp index 5bd04df..fb2a72a 100644 --- a/ScopeOneCore/src/AgentCameraBackend.cpp +++ b/ScopeOneCore/src/AgentCameraBackend.cpp @@ -1928,7 +1928,8 @@ namespace scopeone::core::internal if (wasRecording) { emit frameDeliveryFailed( - QStringLiteral("Camera agent exited for '%1'").arg(normalizedId)); + QStringLiteral("Camera agent exited for '%1'").arg(normalizedId), + 0); } }); @@ -1974,7 +1975,7 @@ namespace scopeone::core::internal if (recordingFrameDeliveryEnabled()) { CameraBackend::setRecordingFrameDeliveryEnabled(false); - emit frameDeliveryFailed(error); + emit frameDeliveryFailed(error, 0); } return; } diff --git a/ScopeOneCore/src/CameraBackend.cpp b/ScopeOneCore/src/CameraBackend.cpp index 61b9d7f..17f2f70 100644 --- a/ScopeOneCore/src/CameraBackend.cpp +++ b/ScopeOneCore/src/CameraBackend.cpp @@ -51,6 +51,7 @@ namespace scopeone::core::internal m_recordingFrameDeliveryEnabled.store(enabled, std::memory_order_relaxed); m_pendingRecordingFrames.clear(); m_pendingRecordingBytes = 0; + m_pendingDroppedRecordingFrames = 0; m_pendingDeliveryError.clear(); return true; } @@ -211,8 +212,10 @@ namespace scopeone::core::internal QMutexLocker lock(&m_frameDeliveryMutex); bool recordingDeliveryEnabled = m_recordingFrameDeliveryEnabled.load(std::memory_order_relaxed); + const bool recordingWasEnabled = recordingDeliveryEnabled; QString acquiredCameraId; quint64 validFrameCount = 0; + quint64 queuedFrameCount = 0; for (const ImageFrame& frame : frames) { const QString cameraId = frame.cameraId.trimmed(); @@ -247,6 +250,7 @@ namespace scopeone::core::internal m_pendingRecordingFrames.append(normalizedFrame); m_pendingRecordingBytes += frameBytes; + ++queuedFrameCount; } if (!acquiredCameraId.isEmpty() && acquiredFrameCount > 0) @@ -261,6 +265,16 @@ namespace scopeone::core::internal "Recording frame delivery detected dropped camera frames"); m_recordingFrameDeliveryEnabled.store(false, std::memory_order_relaxed); } + if (recordingWasEnabled && !m_pendingDeliveryError.isEmpty()) + { + const quint64 submittedFrameCount = acquiredFrameCount > 0 + ? acquiredFrameCount + : validFrameCount; + if (submittedFrameCount > queuedFrameCount) + { + m_pendingDroppedRecordingFrames += submittedFrameCount - queuedFrameCount; + } + } if ((!m_pendingLatestFrames.isEmpty() || !m_pendingAcquiredFrameCounts.isEmpty() @@ -305,13 +319,16 @@ namespace scopeone::core::internal QHash acquiredFrameCounts; QList recordingFrames; QString deliveryError; + quint64 droppedRecordingFrames = 0; { QMutexLocker lock(&m_frameDeliveryMutex); latestFrames.swap(m_pendingLatestFrames); acquiredFrameCounts.swap(m_pendingAcquiredFrameCounts); recordingFrames.swap(m_pendingRecordingFrames); deliveryError.swap(m_pendingDeliveryError); + droppedRecordingFrames = m_pendingDroppedRecordingFrames; m_pendingRecordingBytes = 0; + m_pendingDroppedRecordingFrames = 0; m_frameFlushQueued = false; } @@ -325,8 +342,9 @@ namespace scopeone::core::internal } if (!deliveryError.isEmpty()) { + droppedRecordingFrames += static_cast(recordingFrames.size()); recordingFrames.clear(); - emit frameDeliveryFailed(deliveryError); + emit frameDeliveryFailed(deliveryError, droppedRecordingFrames); } else if (!recordingFrames.isEmpty()) { diff --git a/ScopeOneCore/src/NativeCameraBackend.cpp b/ScopeOneCore/src/NativeCameraBackend.cpp index 4823841..4a560ea 100644 --- a/ScopeOneCore/src/NativeCameraBackend.cpp +++ b/ScopeOneCore/src/NativeCameraBackend.cpp @@ -947,7 +947,7 @@ namespace scopeone::core::internal notifyPreviewStopped(); if (wasRecording) { - emit frameDeliveryFailed(message); + emit frameDeliveryFailed(message, 0); } } diff --git a/ScopeOneCore/src/RecordingManager.cpp b/ScopeOneCore/src/RecordingManager.cpp index a5570e8..dbd8fb8 100644 --- a/ScopeOneCore/src/RecordingManager.cpp +++ b/ScopeOneCore/src/RecordingManager.cpp @@ -421,6 +421,7 @@ namespace scopeone::core::internal ImagePixelFormat pixelFormat, int bitsPerSample, const ExperimentPlan& plan, + double pixelSizeUm, quint64 acquisitionStartTimestampNs, const QString& imageName, const QJsonObject& cameraProperties, @@ -484,8 +485,8 @@ namespace scopeone::core::internal plan.order.end(), RecordingAxis::Z); settings.acquisitionOrder = zAxis < timeAxis ? "ZTC" : "TZC"; - settings.physicalSizeXUm = plan.pixelSizeUm; - settings.physicalSizeYUm = plan.pixelSizeUm; + settings.physicalSizeXUm = pixelSizeUm; + settings.physicalSizeYUm = pixelSizeUm; settings.timeIncrementMs = uniformTimeIncrementMs(plan); if (plan.zPositions.size() > 1) { @@ -696,10 +697,18 @@ namespace scopeone::core::internal status = m_writerState.status; status.setPendingWriteBytes(static_cast(m_writerState.pendingWriteBytes)); status.setMaxPendingWriteBytes(static_cast(m_writerState.recordedMaxBytes)); - if (m_sessionState.activeSession) - { - m_sessionState.activeSession->setWriterStatusSnapshot(status); - } + } + qint64 capturedFrames = 0; + for (auto it = m_captureState.framesCapturedTotal.constBegin(); + it != m_captureState.framesCapturedTotal.constEnd(); + ++it) + { + capturedFrames += it.value(); + } + status.setFramesCaptured(capturedFrames); + if (m_sessionState.activeSession) + { + m_sessionState.activeSession->setWriterStatusSnapshot(status); } emit writerStatusChanged(status); } @@ -844,7 +853,8 @@ namespace scopeone::core::internal // Prepares a fresh session object for the next recording void RecordingManager::resetSessionState(const ExperimentPlan& plan, - const QJsonObject& deviceProperties) + const QJsonObject& deviceProperties, + const QHash& cameraPixelSizesUm) { m_sessionState.activeSession = std::make_shared(); m_sessionState.activeSession->setCapturePlan(plan); @@ -857,6 +867,7 @@ namespace scopeone::core::internal software.operatingSystem = QSysInfo::prettyProductName(); m_sessionState.activeSession->setSoftwareSnapshot(software); m_sessionState.activeSession->setDeviceProperties(deviceProperties); + m_sessionState.activeSession->setCameraPixelSizesUm(cameraPixelSizesUm); m_sessionState.activeSession->prepareForSave(plan.streamToDisk, recordedMaxBytes()); m_sessionState.activeSession->clearFrames(); m_sessionState.activeSession->setStartedTimestampNs(currentTimestampNs()); @@ -982,6 +993,7 @@ namespace scopeone::core::internal { output->cameraProperties = m_sessionState.activeSession->experimentDocument() .deviceProperties.value(cameraId).toObject(); + output->pixelSizeUm = m_sessionState.activeSession->cameraPixelSizeUm(cameraId); } if (requiresFrameInfo(plan.format)) { @@ -1154,6 +1166,12 @@ namespace scopeone::core::internal QString errorMessage; if (!writeTask(*output, task, errorMessage)) { + requestWriterStop(); + qint64 droppedFrames = 1; + { + std::lock_guard lock(output->queueMutex); + droppedFrames += static_cast(output->writeQueue.size()); + } QString writerFailure; { std::lock_guard lock(m_writerState.writeMutex); @@ -1164,9 +1182,9 @@ namespace scopeone::core::internal : errorMessage; } m_writerState.status.setPhase(RecordingWriterPhase::Failed, m_writerState.writerError); + m_writerState.status.addDroppedFrames(droppedFrames); writerFailure = m_writerState.writerError; } - requestWriterStop(); markWriterStatusDirty(); QMetaObject::invokeMethod(this, [this, writerFailure, generation]() { @@ -1180,8 +1198,10 @@ namespace scopeone::core::internal { std::lock_guard lock(m_writerState.writeMutex); - m_writerState.pendingWriteBytes -= static_cast(task.frame.payloadByteCount()); + const qint64 frameBytes = task.frame.payloadByteCount(); + m_writerState.pendingWriteBytes -= static_cast(frameBytes); m_writerState.status.addWrittenFrames(1); + m_writerState.status.addWrittenBytes(frameBytes); } output->framesWritten += 1; markWriterStatusDirty(); @@ -1208,6 +1228,7 @@ namespace scopeone::core::internal task.frame.pixelFormat, task.frame.bitsPerSample, m_mdaState.plan, + output.pixelSizeUm, output.acquisitionStartTimestampNs, output.cameraId, output.cameraProperties, @@ -1264,7 +1285,8 @@ namespace scopeone::core::internal // Starts a recording session using the requested plan bool RecordingManager::start(const ExperimentPlan& requestedPlan, const QStringList& activeCameraIds, - const QJsonObject& deviceProperties) + const QJsonObject& deviceProperties, + const QHash& cameraPixelSizesUm) { if (m_captureState.isRecording) { @@ -1312,7 +1334,7 @@ namespace scopeone::core::internal return false; } } - resetSessionState(plan, deviceProperties); + resetSessionState(plan, deviceProperties, cameraPixelSizesUm); if (plan.streamToDisk) { @@ -1447,12 +1469,16 @@ namespace scopeone::core::internal } // Fails an active preview recording when frame delivery is incomplete - void RecordingManager::onFrameDeliveryFailed(const QString& errorMessage) + void RecordingManager::onFrameDeliveryFailed(const QString& errorMessage, quint64 droppedFrames) { if (!m_captureState.isRecording || m_mdaState.usingMda) { return; } + { + std::lock_guard lock(m_writerState.writeMutex); + m_writerState.status.addDroppedFrames(static_cast(droppedFrames)); + } finishRecording(ExperimentRunState::Failed, errorMessage); } @@ -1579,6 +1605,7 @@ namespace scopeone::core::internal m_writerState.writerError = QStringLiteral("Missing output for %1").arg(cameraId); } m_writerState.status.setPhase(RecordingWriterPhase::Failed, m_writerState.writerError); + m_writerState.status.addDroppedFrames(1); failureError = m_writerState.writerError; emitStatus = true; } @@ -1589,6 +1616,7 @@ namespace scopeone::core::internal m_writerState.writerError = QStringLiteral("Recording write queue exceeded limit"); } m_writerState.status.setPhase(RecordingWriterPhase::Failed, m_writerState.writerError); + m_writerState.status.addDroppedFrames(1); failureError = m_writerState.writerError; emitStatus = true; } @@ -2259,6 +2287,7 @@ namespace scopeone::core::internal firstImageFrame.pixelFormat, firstImageFrame.bitsPerSample, capturePlan, + session->cameraPixelSizeUm(cameraId), session->experimentDocument().startedTimestampNs, cameraId, session->experimentDocument().deviceProperties diff --git a/ScopeOneCore/src/ScopeOneCore.cpp b/ScopeOneCore/src/ScopeOneCore.cpp index a728450..0c5164e 100644 --- a/ScopeOneCore/src/ScopeOneCore.cpp +++ b/ScopeOneCore/src/ScopeOneCore.cpp @@ -47,7 +47,7 @@ namespace // Histogram refresh is throttled to keep preview responsive constexpr qint64 kHistogramRefreshIntervalMs = 250; // Live line profiles update fast enough for interaction without following camera rate - constexpr qint64 kLineProfileRefreshIntervalMs = 50; + constexpr qint64 kLineProfileRefreshIntervalMs = 16; // Display delivery is bounded independently from acquisition and processing throughput constexpr qint64 kPreviewRefreshIntervalMs = 16; @@ -408,23 +408,6 @@ namespace return devicePropertiesObject; } - // Read the active Micro-Manager pixel size calibration - double currentPixelSizeUm(const std::shared_ptr& core) - { - if (!core) - { - return 0.0; - } - try - { - const double pixelSizeUm = core->getPixelSizeUm(false); - return std::isfinite(pixelSizeUm) && pixelSizeUm > 0.0 ? pixelSizeUm : 0.0; - } - catch (const CMMError&) - { - return 0.0; - } - } } namespace scopeone::core @@ -893,6 +876,7 @@ namespace scopeone::core bool experimentCancelRequested{false}; RecordingProgress recordingProgress; RecordingWriterStatus recordingWriterStatus; + QHash cameraPixelSizesUm; }; // Return the compiled core version string @@ -1200,6 +1184,31 @@ namespace scopeone::core return running; } + // Return the global scale assigned to one camera + double ScopeOneCore::cameraPixelSizeUm(const QString& cameraId) const + { + return m_managers->cameraPixelSizesUm.value(cameraId.trimmed(), 0.0); + } + + // Assign one global scale to a camera + bool ScopeOneCore::setCameraPixelSizeUm(const QString& cameraId, double pixelSizeUm) + { + const QString camera = cameraId.trimmed(); + if (camera.isEmpty() || !std::isfinite(pixelSizeUm) || pixelSizeUm < 0.0) + { + return false; + } + if (pixelSizeUm == 0.0) + { + m_managers->cameraPixelSizesUm.remove(camera); + } + else + { + m_managers->cameraPixelSizesUm.insert(camera, pixelSizeUm); + } + return true; + } + // Applies a completed device load to the frame graph and public state void ScopeOneCore::applyLoadedConfiguration(const QString& configPath, const LoadConfigResult& result) @@ -1807,10 +1816,16 @@ namespace scopeone::core const ExperimentPlan& capturePlan) { ExperimentPlan plan = capturePlan; - if (plan.pixelSizeUm <= 0.0) + QStringList frameCameraIds; + for (const ImageFrame& frame : frames) { - plan.pixelSizeUm = currentPixelSizeUm(core()); + const QString cameraId = frame.cameraId.trimmed(); + if (!cameraId.isEmpty() && !frameCameraIds.contains(cameraId)) + { + frameCameraIds.append(cameraId); + } } + plan.pixelSizeUm = frameCameraIds.size() == 1 ? cameraPixelSizeUm(frameCameraIds.first()) : 0.0; plan.configPath = m_loadedConfigPath; plan.configSha256 = m_loadedConfigSha256; plan.processing = processingRecipe(); @@ -1818,6 +1833,16 @@ namespace scopeone::core auto session = RecordingSessionData::fromImageFrames(frames, plan); if (session) { + QHash pixelSizesUm; + for (const QString& cameraId : session->cameraIds()) + { + const double pixelSizeUm = cameraPixelSizeUm(cameraId); + if (pixelSizeUm > 0.0) + { + pixelSizesUm.insert(cameraId, pixelSizeUm); + } + } + session->setCameraPixelSizesUm(pixelSizesUm); SoftwareSnapshot software; software.applicationVersion = QCoreApplication::applicationVersion(); software.coreVersion = getVersion(); @@ -2229,7 +2254,17 @@ namespace scopeone::core } return false; } - return m_managers->stageMosaicManager->start(plan, errorMessage); + StageMosaicPlan effectivePlan = plan; + effectivePlan.pixelSizeUm = cameraPixelSizeUm(plan.cameraId); + if (effectivePlan.pixelSizeUm <= 0.0) + { + if (errorMessage) + { + *errorMessage = QStringLiteral("Camera scale is not set"); + } + return false; + } + return m_managers->stageMosaicManager->start(effectivePlan, errorMessage); } void ScopeOneCore::cancelStageMosaic() @@ -2271,15 +2306,16 @@ namespace scopeone::core } HistogramJobState& state = m_histogramJobStates[key]; - const qint64 nowMs = QDateTime::currentMSecsSinceEpoch(); if (state.inFlight) { state.queuedFrame = frame; return; } - const qint64 elapsedMs = nowMs - state.lastScheduledMs; - if (state.lastScheduledMs > 0 && elapsedMs < kHistogramRefreshIntervalMs) + const qint64 elapsedMs = state.lastScheduledTimer.isValid() + ? state.lastScheduledTimer.elapsed() + : kHistogramRefreshIntervalMs; + if (elapsedMs < kHistogramRefreshIntervalMs) { state.queuedFrame = frame; if (!state.retryScheduled) @@ -2308,7 +2344,7 @@ namespace scopeone::core state.inFlight = true; state.queuedFrame = ImageFrame{}; - state.lastScheduledMs = nowMs; + state.lastScheduledTimer.start(); const quint64 sequence = ++m_nextHistogramSequence; state.activeSequence = sequence; @@ -3444,10 +3480,6 @@ namespace scopeone::core } ExperimentPlan planSnapshot = plan; - if (planSnapshot.pixelSizeUm <= 0.0) - { - planSnapshot.pixelSizeUm = currentPixelSizeUm(core()); - } if (planSnapshot.experimentId.trimmed().isEmpty()) { planSnapshot.experimentId = QUuid::createUuid().toString(QUuid::WithoutBraces); @@ -3471,6 +3503,9 @@ namespace scopeone::core planSnapshot.cameraIds.append(normalizedCameraId); } } + planSnapshot.pixelSizeUm = planSnapshot.cameraIds.size() == 1 + ? cameraPixelSizeUm(planSnapshot.cameraIds.first()) + : 0.0; const bool useMda = !planSnapshot.positions.empty() || !planSnapshot.zPositions.empty(); QStringList suspendedPreviewIds; @@ -3525,9 +3560,19 @@ namespace scopeone::core Qt::SingleShotConnection); } + QHash pixelSizesUm; + for (const QString& cameraId : planSnapshot.cameraIds) + { + const double pixelSizeUm = cameraPixelSizeUm(cameraId); + if (pixelSizeUm > 0.0) + { + pixelSizesUm.insert(cameraId, pixelSizeUm); + } + } const bool started = m_managers->recordingManager->start(planSnapshot, activeCameraIds, - deviceProperties); + deviceProperties, + pixelSizesUm); if (!started) { if (restorePreviewConnection) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 7f5eda0..0118209 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -170,6 +170,12 @@ function Import-MsvcEnvironment { return } + if ($env:VSCMD_VER -and (Get-Command cl.exe -ErrorAction SilentlyContinue)) { + $env:CC = "cl.exe" + $env:CXX = "cl.exe" + return + } + $vsWhere = Join-Path ${env:ProgramFiles(x86)} "Microsoft Visual Studio\Installer\vswhere.exe" if (-not (Test-Path $vsWhere)) { throw "Visual Studio Installer was not found." @@ -182,28 +188,26 @@ function Import-MsvcEnvironment { if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($installationPath)) { throw "A Visual Studio installation with the C++ toolchain was not found." } - $vsDevCmd = Join-Path $installationPath "Common7\Tools\VsDevCmd.bat" - if (-not (Test-Path $vsDevCmd)) { - throw "Visual Studio developer environment was not found." + + $devShellModule = Join-Path $installationPath "Common7\Tools\Microsoft.VisualStudio.DevShell.dll" + if (-not (Test-Path $devShellModule)) { + throw "Visual Studio developer PowerShell module was not found." } - $environment = & $env:ComSpec /s /c "`"$vsDevCmd`" -arch=x64 -host_arch=x64 >nul && set" - if ($LASTEXITCODE -ne 0) { - throw "Failed to initialize the Visual Studio developer environment." + try { + Import-Module $devShellModule -ErrorAction Stop + Enter-VsDevShell ` + -VsInstallPath $installationPath ` + -SkipAutomaticLocation ` + -Arch amd64 ` + -HostArch amd64 ` + -DevCmdArguments "-no_logo" ` + -ErrorAction Stop | Out-Null } - foreach ($line in $environment) { - $separator = $line.IndexOf('=') - if ($separator -gt 0) { - $name = $line.Substring(0, $separator) - $value = $line.Substring($separator + 1) - if ($name.Equals("Path", [StringComparison]::OrdinalIgnoreCase)) { - Remove-Item Env:PATH -ErrorAction SilentlyContinue - Remove-Item Env:Path -ErrorAction SilentlyContinue - $env:Path = $value - } - else { - [Environment]::SetEnvironmentVariable($name, $value, "Process") - } - } + catch { + throw "Failed to initialize the Visual Studio developer environment: $($_.Exception.Message)" + } + if (-not (Get-Command cl.exe -ErrorAction SilentlyContinue)) { + throw "Visual Studio developer environment did not provide cl.exe." } $env:CC = "cl.exe" $env:CXX = "cl.exe" diff --git a/src/ImageToolsDialog.cpp b/src/ImageToolsDialog.cpp index 748991d..b185cc0 100644 --- a/src/ImageToolsDialog.cpp +++ b/src/ImageToolsDialog.cpp @@ -41,6 +41,83 @@ namespace scopeone::ui } + // Create the per camera image scale editor + CameraScaleDialog::CameraScaleDialog(scopeone::core::ScopeOneCore* core, + QWidget* parent) + : QDialog(parent) + , m_core(core) + { + if (!core) + { + qFatal("CameraScaleDialog requires ScopeOneCore"); + } + + setWindowTitle(tr("Scale")); + auto* mainLayout = new QVBoxLayout(this); + auto* formLayout = new QFormLayout(); + m_cameraCombo = new QComboBox(this); + m_cameraCombo->addItems(m_core->cameraIds()); + formLayout->addRow(tr("Camera"), m_cameraCombo); + + m_pixelSizeSpinBox = new QDoubleSpinBox(this); + m_pixelSizeSpinBox->setRange(0.0, 1000000.0); + m_pixelSizeSpinBox->setDecimals(6); + m_pixelSizeSpinBox->setSingleStep(0.01); + m_pixelSizeSpinBox->setSuffix(QStringLiteral(" µm/px")); + m_pixelSizeSpinBox->setSpecialValueText(tr("Unset")); + m_pixelSizeSpinBox->setKeyboardTracking(false); + formLayout->addRow(tr("Pixel Size"), m_pixelSizeSpinBox); + mainLayout->addLayout(formLayout); + + m_statusLabel = new QLabel(tr("Scale is applied globally to the selected camera"), this); + m_statusLabel->setWordWrap(true); + mainLayout->addWidget(m_statusLabel); + + auto* buttons = new QDialogButtonBox(QDialogButtonBox::Apply | QDialogButtonBox::Close, this); + connect(buttons->button(QDialogButtonBox::Apply), &QPushButton::clicked, + this, &CameraScaleDialog::applyScale); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttons); + + connect(m_cameraCombo, &QComboBox::currentTextChanged, + this, [this]() { loadSelectedCamera(); }); + loadSelectedCamera(); + } + + // Load the global scale for the selected camera + void CameraScaleDialog::loadSelectedCamera() + { + const QString cameraId = m_cameraCombo->currentText().trimmed(); + m_pixelSizeSpinBox->setEnabled(!cameraId.isEmpty()); + m_pixelSizeSpinBox->setValue(cameraId.isEmpty() ? 0.0 : m_core->cameraPixelSizeUm(cameraId)); + } + + // Apply and persist one camera scale + void CameraScaleDialog::applyScale() + { + const QString cameraId = m_cameraCombo->currentText().trimmed(); + const double pixelSizeUm = m_pixelSizeSpinBox->value(); + if (!m_core->setCameraPixelSizeUm(cameraId, pixelSizeUm)) + { + m_statusLabel->setText(tr("Failed to update camera scale")); + return; + } + + QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); + QVariantMap pixelSizes = settings.value(QStringLiteral("Scale/CameraPixelSizesUm")).toMap(); + if (pixelSizeUm > 0.0) + { + pixelSizes.insert(cameraId, pixelSizeUm); + m_statusLabel->setText(tr("Scale updated for %1").arg(cameraId)); + } + else + { + pixelSizes.remove(cameraId); + m_statusLabel->setText(tr("Scale cleared for %1").arg(cameraId)); + } + settings.setValue(QStringLiteral("Scale/CameraPixelSizesUm"), pixelSizes); + } + // Create a stage driven mosaic tool StageMosaicDialog::StageMosaicDialog(scopeone::core::ScopeOneCore* core, PreviewWidget* previewWidget, @@ -101,13 +178,6 @@ namespace scopeone::ui m_columnsSpinBox->setRange(1, 100); m_columnsSpinBox->setValue(3); - m_pixelSizeSpinBox = new QDoubleSpinBox(captureGroup); - m_pixelSizeSpinBox->setRange(0.001, 1000000.0); - m_pixelSizeSpinBox->setDecimals(4); - m_pixelSizeSpinBox->setValue(1.0); - m_pixelSizeSpinBox->setSuffix(QStringLiteral(" um/px")); - m_pixelSizeSpinBox->setKeyboardTracking(false); - m_stepXSpinBox = new QDoubleSpinBox(captureGroup); m_stepXSpinBox->setRange(-1000000.0, 1000000.0); m_stepXSpinBox->setDecimals(3); @@ -135,7 +205,6 @@ namespace scopeone::ui captureLayout->addRow(tr("XY Stage"), m_stageCombo); captureLayout->addRow(tr("Rows"), m_rowsSpinBox); captureLayout->addRow(tr("Columns"), m_columnsSpinBox); - captureLayout->addRow(tr("Pixel Size"), m_pixelSizeSpinBox); captureLayout->addRow(tr("Step X"), m_stepXSpinBox); captureLayout->addRow(tr("Step Y"), m_stepYSpinBox); captureLayout->addRow(tr("Settle"), m_settleMsSpinBox); @@ -204,7 +273,7 @@ namespace scopeone::ui plan.xyStageId = selectedStageId(); plan.rows = m_rowsSpinBox->value(); plan.columns = m_columnsSpinBox->value(); - plan.pixelSizeUm = m_pixelSizeSpinBox->value(); + plan.pixelSizeUm = m_core->cameraPixelSizeUm(m_activeCameraId); plan.stepXUm = m_stepXSpinBox->value(); plan.stepYUm = m_stepYSpinBox->value(); plan.settleMs = m_settleMsSpinBox->value(); @@ -215,6 +284,11 @@ namespace scopeone::ui m_statusLabel->setText(tr("Select a camera and XY stage")); return; } + if (plan.pixelSizeUm <= 0.0) + { + m_statusLabel->setText(tr("Set the camera scale in Tools > Scale")); + return; + } QString errorMessage; if (!m_core->startStageMosaic(plan, &errorMessage)) @@ -243,7 +317,6 @@ namespace scopeone::ui m_stageCombo->setEnabled(!running); m_rowsSpinBox->setEnabled(!running); m_columnsSpinBox->setEnabled(!running); - m_pixelSizeSpinBox->setEnabled(!running); m_stepXSpinBox->setEnabled(!running); m_stepYSpinBox->setEnabled(!running); m_settleMsSpinBox->setEnabled(!running); diff --git a/src/ImageToolsDialog.h b/src/ImageToolsDialog.h index 8f50cce..f4cefb7 100644 --- a/src/ImageToolsDialog.h +++ b/src/ImageToolsDialog.h @@ -17,6 +17,24 @@ namespace scopeone::ui { class PreviewWidget; + class CameraScaleDialog : public QDialog + { + Q_OBJECT + + public: + explicit CameraScaleDialog(scopeone::core::ScopeOneCore* core, + QWidget* parent = nullptr); + + private: + void loadSelectedCamera(); + void applyScale(); + + scopeone::core::ScopeOneCore* m_core{nullptr}; + QComboBox* m_cameraCombo{nullptr}; + QDoubleSpinBox* m_pixelSizeSpinBox{nullptr}; + QLabel* m_statusLabel{nullptr}; + }; + class StageMosaicDialog : public QDialog { Q_OBJECT @@ -43,7 +61,6 @@ namespace scopeone::ui QComboBox* m_stageCombo{nullptr}; QSpinBox* m_rowsSpinBox{nullptr}; QSpinBox* m_columnsSpinBox{nullptr}; - QDoubleSpinBox* m_pixelSizeSpinBox{nullptr}; QDoubleSpinBox* m_stepXSpinBox{nullptr}; QDoubleSpinBox* m_stepYSpinBox{nullptr}; QSpinBox* m_settleMsSpinBox{nullptr}; diff --git a/src/InspectWidget.cpp b/src/InspectWidget.cpp index d67df4c..8cee7f1 100644 --- a/src/InspectWidget.cpp +++ b/src/InspectWidget.cpp @@ -18,6 +18,7 @@ #include #include #include +#include namespace scopeone::ui { @@ -450,6 +451,10 @@ namespace scopeone::ui clearCrossSectionProfile(); emit requestClearCrossSection(); } + if (!m_measurementLayerKey.isEmpty() && m_measurementLayerKey != m_currentLayerKey) + { + clearMeasurementLine(); + } updateLayerVisibility(); updateControlsState(); } @@ -489,6 +494,10 @@ namespace scopeone::ui clearCrossSectionProfile(); emit requestClearCrossSection(); } + if (!m_measurementLayerKey.isEmpty() && !m_availableLayerKeys.contains(m_measurementLayerKey)) + { + clearMeasurementLine(); + } if (!m_currentLayerKey.isEmpty() && !m_availableLayerKeys.contains(m_currentLayerKey)) { @@ -552,6 +561,7 @@ namespace scopeone::ui // Clear all layer inspect groups void InspectWidget::clearInspect() { + clearMeasurementLine(); setAvailableLayers({}); setAvailableCameras({}); } @@ -571,6 +581,10 @@ namespace scopeone::ui { clearCrossSectionProfile(); } + if (m_measurementLayerKey == trimmedLayerKey) + { + clearMeasurementLine(); + } updateLayerVisibility(); updateControlsState(); } @@ -582,6 +596,45 @@ namespace scopeone::ui m_crossSectionWidget->clear(); } + // Display one line measurement for the inspected layer + void InspectWidget::setMeasurementLine(const QString& layerKey, + const QPoint& start, + const QPoint& end, + double pixelSizeUm) + { + m_measurementLayerKey = layerKey.trimmed(); + const double dx = static_cast(end.x() - start.x()); + const double dy = static_cast(end.y() - start.y()); + const double lengthPixels = std::hypot(dx, dy); + double angleDegrees = std::atan2(-dy, dx) * 180.0 / 3.14159265358979323846; + if (angleDegrees < 0.0) + { + angleDegrees += 360.0; + } + + QStringList lines{ + QStringLiteral("Start: (%1, %2)").arg(start.x()).arg(start.y()), + QStringLiteral("Angle: %1°").arg(angleDegrees, 0, 'f', 1), + QStringLiteral("Length: %1 px").arg(lengthPixels, 0, 'f', 2) + }; + const bool calibrated = pixelSizeUm > 0.0; + if (calibrated) + { + lines.append(QStringLiteral("Actual: %1 µm") + .arg(lengthPixels * pixelSizeUm, 0, 'f', 3)); + } + m_measurementInfoLabel->setText(lines.join('\n')); + m_measurementInfoLabel->show(); + } + + // Clear the current line measurement display + void InspectWidget::clearMeasurementLine() + { + m_measurementLayerKey.clear(); + m_measurementInfoLabel->clear(); + m_measurementInfoLabel->hide(); + } + // Display a freshly computed cross section profile for one layer void InspectWidget::setLayerCrossSectionProfile(const QString& layerKey, const QVector& values) { @@ -613,6 +666,19 @@ namespace scopeone::ui contentLayout->setSpacing(8); contentLayout->setContentsMargins(5, 5, 5, 5); + auto* annotationGroup = new QGroupBox(QStringLiteral("Annotation"), contentContainer); + auto* annotationLayout = new QVBoxLayout(annotationGroup); + auto* annotationButtons = new QHBoxLayout(); + m_drawMeasurementLineButton = new QPushButton(QStringLiteral("Line"), annotationGroup); + m_clearMeasurementLinesButton = new QPushButton(QStringLiteral("Clear"), annotationGroup); + annotationButtons->addWidget(m_drawMeasurementLineButton); + annotationButtons->addWidget(m_clearMeasurementLinesButton); + annotationLayout->addLayout(annotationButtons); + m_measurementInfoLabel = new QLabel(annotationGroup); + m_measurementInfoLabel->hide(); + annotationLayout->addWidget(m_measurementInfoLabel); + contentLayout->addWidget(annotationGroup); + m_crossSectionGroup = new QGroupBox(QStringLiteral("Cross Section"), contentContainer); auto* crossSectionLayout = new QVBoxLayout(m_crossSectionGroup); auto* crossSectionButtons = new QHBoxLayout(); @@ -645,6 +711,14 @@ namespace scopeone::ui clearCrossSectionProfile(); emit requestClearCrossSection(); }); + connect(m_drawMeasurementLineButton, &QPushButton::clicked, this, [this]() + { + emit requestDrawMeasurementLine(m_currentLayerKey); + }); + connect(m_clearMeasurementLinesButton, &QPushButton::clicked, this, [this]() + { + emit requestClearMeasurementLines(m_currentLayerKey); + }); scrollArea->setWidget(contentContainer); mainLayout->addWidget(scrollArea); @@ -959,6 +1033,10 @@ namespace scopeone::ui && (liveCrossSectionEnabled || staticCrossSectionEnabled); m_drawCrossSectionButton->setEnabled(crossSectionEnabled); m_clearCrossSectionButton->setEnabled(m_cameraInitialized || !m_currentLayerKey.isEmpty()); + const bool annotationEnabled = !m_currentLayerKey.isEmpty() + && m_availableLayerKeys.contains(m_currentLayerKey); + m_drawMeasurementLineButton->setEnabled(annotationEnabled); + m_clearMeasurementLinesButton->setEnabled(annotationEnabled); for (auto it = m_layerInfoGroups.begin(); it != m_layerInfoGroups.end(); ++it) { diff --git a/src/InspectWidget.h b/src/InspectWidget.h index f02b24f..15585e0 100644 --- a/src/InspectWidget.h +++ b/src/InspectWidget.h @@ -3,6 +3,7 @@ #include "scopeone/ScopeOneCore.h" #include +#include #include #include #include @@ -47,10 +48,17 @@ namespace scopeone::ui void clearLayerInspect(const QString& layerKey); void clearCrossSectionProfile(); void setLayerCrossSectionProfile(const QString& layerKey, const QVector& values); + void setMeasurementLine(const QString& layerKey, + const QPoint& start, + const QPoint& end, + double pixelSizeUm); + void clearMeasurementLine(); signals: void requestDrawCrossSectionLayer(const QString& layerKey); void requestClearCrossSection(); + void requestDrawMeasurementLine(const QString& layerKey); + void requestClearMeasurementLines(const QString& layerKey); private: struct LayerInfoGroup @@ -99,12 +107,16 @@ namespace scopeone::ui QStringList m_availableLayerKeys; QStringList m_availableCameraIds; QVBoxLayout* m_histogramContainerLayout{nullptr}; + QPushButton* m_drawMeasurementLineButton{nullptr}; + QPushButton* m_clearMeasurementLinesButton{nullptr}; + QLabel* m_measurementInfoLabel{nullptr}; InspectCrossSectionWidget* m_crossSectionWidget{nullptr}; QGroupBox* m_crossSectionGroup{nullptr}; QPushButton* m_drawCrossSectionButton{nullptr}; QPushButton* m_clearCrossSectionButton{nullptr}; bool m_cameraInitialized{false}; QString m_currentLayerKey; + QString m_measurementLayerKey; QString m_crossSectionLayerKey; }; } diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 0f69cff..ec6926f 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -248,6 +248,7 @@ namespace scopeone::ui m_propertyBrowser->setEnabled(!configurationRunning); m_configPresetWidget->setEnabled(!configurationRunning); m_deviceControlWidget->setEnabled(!configurationRunning); + m_scaleAction->setEnabled(!configurationRunning && !m_scopeonecore->cameraIds().isEmpty()); m_stageMosaicAction->setEnabled(!configurationRunning); m_particleDetectionAction->setEnabled(!configurationRunning); if (m_stageMosaicDialog) @@ -489,6 +490,44 @@ namespace scopeone::ui m_previewWidget->clearCrossSection(); }); + connect(m_inspectWidget, &InspectWidget::requestDrawMeasurementLine, + this, [this](const QString& layerKey) + { + m_previewWidget->startMeasurementLineDrawingForLayer(layerKey); + showStatusMessage(tr("Drag a line on the preview"), 5000); + }); + connect(m_inspectWidget, &InspectWidget::requestClearMeasurementLines, + this, [this](const QString& layerKey) + { + m_imageSceneModel->clearRole(ImageSceneModel::MarkupRole::Measurement, layerKey); + m_inspectWidget->clearMeasurementLine(); + }); + connect(m_previewWidget, &PreviewWidget::measurementLineDrawn, + this, [this](const QString& layerKey, const QPoint& start, const QPoint& end) + { + const QString markupId = m_imageSceneModel->createLine( + layerKey, + start, + end, + QString(), + ImageSceneModel::MarkupRole::Measurement); + m_imageSceneModel->selectOnly(markupId); + const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); + m_inspectWidget->setMeasurementLine( + layerKey, start, end, m_scopeonecore->cameraPixelSizeUm(cameraId)); + }); + connect(m_previewWidget, &PreviewWidget::measurementLineInspected, + this, [this](const QString& layerKey, + const QPoint& start, + const QPoint& end) + { + const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); + m_inspectWidget->setMeasurementLine( + layerKey, start, end, m_scopeonecore->cameraPixelSizeUm(cameraId)); + }); + connect(m_previewWidget, &PreviewWidget::measurementLineCleared, + m_inspectWidget, &InspectWidget::clearMeasurementLine); + m_inspectWidget->setAvailableLayers(m_previewWidget->availableLayerKeys()); m_inspectWidget->setCurrentLayer(m_deviceControlWidget->currentLayerKey()); @@ -560,6 +599,8 @@ namespace scopeone::ui this, &MainWindow::openStageMosaicTool); connect(m_particleDetectionAction, &QAction::triggered, this, &MainWindow::openParticleDetectionTool); + connect(m_scaleAction, &QAction::triggered, + this, &MainWindow::openScaleDialog); connect(m_settingsAction, &QAction::triggered, this, &MainWindow::openSettingsDialog); @@ -804,6 +845,8 @@ namespace scopeone::ui m_dockWidgetsMenu = m_viewMenu->addMenu(tr("&Dock Widgets")); m_toolsMenu = menuBar()->addMenu(tr("&Tools")); + m_scaleAction = m_toolsMenu->addAction(tr("&Scale...")); + m_scaleAction->setEnabled(!m_scopeonecore->cameraIds().isEmpty()); m_stageMosaicAction = m_toolsMenu->addAction(tr("Stage &Mosaic...")); m_particleDetectionAction = m_toolsMenu->addAction(tr("&Particle Detection...")); m_toolsMenu->addSeparator(); @@ -1083,6 +1126,11 @@ namespace scopeone::ui QStringLiteral("Recording/MaxPendingWriteBytes"), kDefaultRecordedMaxBytes) .toLongLong(); m_scopeonecore->setRecordingMaxPendingWriteBytes(recordedMaxBytes); + const QVariantMap pixelSizesUm = settings.value(QStringLiteral("Scale/CameraPixelSizesUm")).toMap(); + for (auto it = pixelSizesUm.constBegin(); it != pixelSizesUm.constEnd(); ++it) + { + m_scopeonecore->setCameraPixelSizeUm(it.key(), it.value().toDouble()); + } } // Record the application startup state in one place @@ -1351,6 +1399,13 @@ namespace scopeone::ui 5000); } + // Edit the global per camera image scale + void MainWindow::openScaleDialog() + { + CameraScaleDialog dialog(m_scopeonecore, this); + dialog.exec(); + } + // Open the stage driven image mosaic tool void MainWindow::openStageMosaicTool() { diff --git a/src/MainWindow.h b/src/MainWindow.h index 50ba1f4..a496934 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -73,6 +73,7 @@ namespace scopeone::ui void applyStoredApplicationSettings(); void logStartupSummary(); void openSettingsDialog(); + void openScaleDialog(); void openStageMosaicTool(); void openParticleDetectionTool(); void connectPropertyPanels(); @@ -166,6 +167,7 @@ namespace scopeone::ui QAction* m_loadConfigurationAction{nullptr}; QAction* m_unloadConfigurationAction{nullptr}; QAction* m_settingsAction{nullptr}; + QAction* m_scaleAction{nullptr}; QAction* m_stageMosaicAction{nullptr}; QAction* m_particleDetectionAction{nullptr}; QAction* m_aboutAction{nullptr}; diff --git a/src/PreviewWidget.cpp b/src/PreviewWidget.cpp index f86d393..1159db6 100644 --- a/src/PreviewWidget.cpp +++ b/src/PreviewWidget.cpp @@ -1296,6 +1296,53 @@ namespace scopeone::ui void PreviewWidget::drawActiveInteractionMarkup(QPainter& painter, const std::vector& renderItems) const { + if (m_measurementLineDrawingMode + && m_measurementLineDragging + && !m_measurementLineTargetLayerKey.isEmpty()) + { + for (const RenderItem& item : renderItems) + { + if (item.layerKey != m_measurementLineTargetLayerKey || !item.info || !item.info->frameState) + { + continue; + } + + ImageSceneModel::Markup markup; + markup.type = ImageSceneModel::MarkupType::Line; + markup.role = ImageSceneModel::MarkupRole::Measurement; + markup.layerKey = m_measurementLineTargetLayerKey; + QRect displayRect; + QSize imageSize; + QPoint clippedStart; + QPoint clippedEnd; + if (!resolveDisplayGeometry(*item.info->frameState, + item.processed, + item.area, + displayRect, + imageSize) + || !clipLineToRect(m_measurementLineStart, + m_measurementLineEnd, + displayRect, + clippedStart, + clippedEnd) + || !mapWidgetPositionToImage(*item.info->frameState, + item.processed, + item.area, + clippedStart, + markup.start) + || !mapWidgetPositionToImage(*item.info->frameState, + item.processed, + item.area, + clippedEnd, + markup.end)) + { + break; + } + drawMarkup(painter, markup, item); + break; + } + } + if (m_crossSectionDrawingMode && m_crossSectionDragging && !m_crossSectionTargetLayerKey.isEmpty()) { for (const RenderItem& item : renderItems) @@ -1525,14 +1572,21 @@ namespace scopeone::ui } const QList markups = m_sceneModel->markups(); + bool measurementRemoved = false; for (const ImageSceneModel::Markup& markup : markups) { if (!markup.selected) { continue; } + measurementRemoved = measurementRemoved + || markup.role == ImageSceneModel::MarkupRole::Measurement; m_sceneModel->remove(markup.id); } + if (measurementRemoved) + { + emit measurementLineCleared(); + } } // Draws one render item into its assigned area @@ -2287,6 +2341,7 @@ namespace scopeone::ui // Starts ROI drawing for one camera void PreviewWidget::startROIDrawing(const QString& cameraId) { + cancelMeasurementLineDrawing(); if (m_crossSectionDrawingMode) { cancelCrossSectionDrawing(); @@ -2300,6 +2355,33 @@ namespace scopeone::ui update(); } + // Starts a measurement line for one exact preview layer + void PreviewWidget::startMeasurementLineDrawingForLayer(const QString& layerKey) + { + cancelROIDrawing(); + cancelCrossSectionDrawing(); + m_measurementLineDrawingMode = true; + m_measurementLineTargetLayerKey = layerKey.trimmed(); + m_measurementLineDragging = false; + setFocus(); + setCursor(Qt::CrossCursor); + update(); + } + + // Cancels active measurement line drawing + void PreviewWidget::cancelMeasurementLineDrawing() + { + if (!m_measurementLineDrawingMode) + { + return; + } + m_measurementLineDrawingMode = false; + m_measurementLineDragging = false; + m_measurementLineTargetLayerKey.clear(); + unsetCursor(); + update(); + } + // Cancels active ROI drawing void PreviewWidget::cancelROIDrawing() { @@ -2318,6 +2400,7 @@ namespace scopeone::ui // Starts cross section drawing for one exact preview layer void PreviewWidget::startCrossSectionDrawingForLayer(const QString& layerKey) { + cancelMeasurementLineDrawing(); if (m_roiDrawingMode) { cancelROIDrawing(); @@ -2358,10 +2441,29 @@ namespace scopeone::ui update(); } - // Starts ROI or cross section interaction from a mouse press + // Starts active drawing interactions from a mouse press void PreviewWidget::mousePressEvent(QMouseEvent* event) { emit mousePositionChanged(event->pos()); + if (m_measurementLineDrawingMode && event->button() == Qt::LeftButton) + { + PreviewInteractionTarget target; + const QString sourceId = ScopeOneCore::sourceIdFromLayerKey(m_measurementLineTargetLayerKey); + if (!resolveInteractionTarget(event->pos(), + target, + sourceId, + false, + m_measurementLineTargetLayerKey)) + { + return; + } + m_measurementLineStart = event->pos(); + m_measurementLineEnd = event->pos(); + m_measurementLineDragging = true; + update(); + return; + } + if (m_crossSectionDrawingMode && event->button() == Qt::LeftButton) { QString sourceId = m_crossSectionTargetSourceId; @@ -2414,6 +2516,11 @@ namespace scopeone::ui if (markupAtWidgetPosition(event->pos(), markup, target, editMode)) { m_sceneModel->selectOnly(markup.id); + if (markup.type == ImageSceneModel::MarkupType::Line + && markup.role == ImageSceneModel::MarkupRole::Measurement) + { + emit measurementLineInspected(markup.layerKey, markup.start, markup.end); + } m_dragMarkupId = markup.id; m_dragMarkupOriginal = markup; m_dragMarkupStartImagePos = target.imagePos; @@ -2428,10 +2535,17 @@ namespace scopeone::ui QOpenGLWidget::mousePressEvent(event); } - // Updates active ROI or cross section interaction during mouse move + // Updates active drawing interactions during mouse move void PreviewWidget::mouseMoveEvent(QMouseEvent* event) { emit mousePositionChanged(event->pos()); + if (m_measurementLineDrawingMode && m_measurementLineDragging) + { + m_measurementLineEnd = event->pos(); + update(); + return; + } + if (m_crossSectionDrawingMode && m_crossSectionDragging) { m_crossSectionEnd = event->pos(); @@ -2464,20 +2578,26 @@ namespace scopeone::ui { if (m_dragMarkupOriginal.type == ImageSceneModel::MarkupType::Line) { + QPoint start = m_dragMarkupOriginal.start; + QPoint end = m_dragMarkupOriginal.end; if (m_dragMarkupEditMode == MarkupEditMode::LineStart) { - m_sceneModel->updateLine(m_dragMarkupId, imagePos, m_dragMarkupOriginal.end); + start = imagePos; } else if (m_dragMarkupEditMode == MarkupEditMode::LineEnd) { - m_sceneModel->updateLine(m_dragMarkupId, m_dragMarkupOriginal.start, imagePos); + end = imagePos; } else { const QPoint delta = imagePos - m_dragMarkupStartImagePos; - m_sceneModel->updateLine(m_dragMarkupId, - m_dragMarkupOriginal.start + delta, - m_dragMarkupOriginal.end + delta); + start += delta; + end += delta; + } + if (m_sceneModel->updateLine(m_dragMarkupId, start, end) + && m_dragMarkupOriginal.role == ImageSceneModel::MarkupRole::Measurement) + { + emit measurementLineInspected(m_dragMarkupOriginal.layerKey, start, end); } } else if (m_dragMarkupOriginal.type == ImageSceneModel::MarkupType::Rect) @@ -2520,10 +2640,71 @@ namespace scopeone::ui QOpenGLWidget::mouseMoveEvent(event); } - // Finishes ROI or cross section drawing in image coordinates + // Finishes active drawing interactions in image coordinates void PreviewWidget::mouseReleaseEvent(QMouseEvent* event) { emit mousePositionChanged(event->pos()); + if (m_measurementLineDrawingMode + && event->button() == Qt::LeftButton + && m_measurementLineDragging) + { + m_measurementLineDragging = false; + m_measurementLineEnd = event->pos(); + + PreviewInteractionTarget startTarget; + const QString sourceId = ScopeOneCore::sourceIdFromLayerKey(m_measurementLineTargetLayerKey); + FrameSourceState frameState; + bool processed = false; + QRect itemArea; + QRect displayRect; + QSize imageSize; + if (!resolveInteractionTarget(m_measurementLineStart, + startTarget, + sourceId, + false, + m_measurementLineTargetLayerKey) + || !resolveLayerDisplayGeometry(m_measurementLineTargetLayerKey, + frameState, + processed, + itemArea, + displayRect, + imageSize)) + { + cancelMeasurementLineDrawing(); + return; + } + + QPoint clippedStart; + QPoint clippedEnd; + QPoint imageStart; + QPoint imageEnd; + if (!clipLineToRect(m_measurementLineStart, + m_measurementLineEnd, + displayRect, + clippedStart, + clippedEnd) + || !mapWidgetPositionToImage(frameState, + processed, + itemArea, + clippedStart, + imageStart) + || !mapWidgetPositionToImage(frameState, + processed, + itemArea, + clippedEnd, + imageEnd) + || imageStart == imageEnd) + { + cancelMeasurementLineDrawing(); + return; + } + const QString layerKey = m_measurementLineTargetLayerKey; + cancelMeasurementLineDrawing(); + emit measurementLineDrawn(layerKey, imageStart, imageEnd); + update(); + return; + } + if (m_crossSectionDrawingMode && event->button() == Qt::LeftButton && m_crossSectionDragging) { m_crossSectionDragging = false; @@ -2733,6 +2914,13 @@ namespace scopeone::ui // Cancels active drawing modes from keyboard input void PreviewWidget::keyPressEvent(QKeyEvent* event) { + if (m_measurementLineDrawingMode && event->key() == Qt::Key_Escape) + { + cancelMeasurementLineDrawing(); + event->accept(); + return; + } + if (m_crossSectionDrawingMode && event->key() == Qt::Key_Escape) { cancelCrossSectionDrawing(); diff --git a/src/PreviewWidget.h b/src/PreviewWidget.h index 732873e..36d165d 100644 --- a/src/PreviewWidget.h +++ b/src/PreviewWidget.h @@ -77,6 +77,7 @@ namespace scopeone::ui void setFitToWindow(bool enabled); bool isFitToWindow() const; void startROIDrawing(const QString& cameraId); + void startMeasurementLineDrawingForLayer(const QString& layerKey); void startCrossSectionDrawingForLayer(const QString& layerKey); void clearCrossSection(); @@ -100,6 +101,11 @@ namespace scopeone::ui int height, int sourceRoiX, int sourceRoiY); + void measurementLineDrawn(const QString& layerKey, const QPoint& start, const QPoint& end); + void measurementLineInspected(const QString& layerKey, + const QPoint& start, + const QPoint& end); + void measurementLineCleared(); protected: void initializeGL() override; @@ -234,6 +240,11 @@ namespace scopeone::ui QPoint m_crossSectionStart; QPoint m_crossSectionEnd; bool m_crossSectionDragging{false}; + bool m_measurementLineDrawingMode{false}; + QString m_measurementLineTargetLayerKey; + QPoint m_measurementLineStart; + QPoint m_measurementLineEnd; + bool m_measurementLineDragging{false}; QString m_dragMarkupId; ImageSceneModel::Markup m_dragMarkupOriginal; QPoint m_dragMarkupStartImagePos; @@ -326,6 +337,7 @@ namespace scopeone::ui GLuint getOrCreateTexture(const QString& key, int width, int height, GLenum internalFormat); void cleanupTextureCache(); void cancelROIDrawing(); + void cancelMeasurementLineDrawing(); void cancelCrossSectionDrawing(); }; } diff --git a/src/RecordingWidget.cpp b/src/RecordingWidget.cpp index 6e656ca..cb0c33b 100644 --- a/src/RecordingWidget.cpp +++ b/src/RecordingWidget.cpp @@ -22,8 +22,11 @@ #include #include #include +#include +#include #include #include +#include #include namespace @@ -180,17 +183,21 @@ namespace QString formatWriterStatusText(const scopeone::core::ScopeOneCore::RecordingWriterStatus& status) { QString text = QStringLiteral("Writer: %1").arg(writerPhaseText(status.phase())); - if (status.phase() == scopeone::core::ScopeOneCore::RecordingWriterPhase::Failed && !status.errorMessage(). - isEmpty()) - { - return QStringLiteral("%1 - %2").arg(text, status.errorMessage()); - } - QStringList details; - if (status.framesWritten() > 0 || status.phase() == + if (status.framesCaptured() > 0 || status.framesWritten() > 0 || status.phase() == scopeone::core::ScopeOneCore::RecordingWriterPhase::Completed) { - details.append(QStringLiteral("%1 frame(s)").arg(status.framesWritten())); + details.append(QStringLiteral("%1 captured, %2 written") + .arg(status.framesCaptured()) + .arg(status.framesWritten())); + } + if (status.droppedFrames() > 0) + { + details.append(QStringLiteral("%1 dropped").arg(status.droppedFrames())); + } + if (status.bytesWritten() > 0) + { + details.append(QStringLiteral("%1 written").arg(formatByteCount(status.bytesWritten()))); } if (status.maxPendingWriteBytes() > 0) { @@ -234,6 +241,19 @@ namespace scopeone::ui connect(m_formatCombo, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { updateUiState(); }); connect(m_compressionCheck, &QCheckBox::toggled, this, [this]() { updateUiState(); }); + connect(m_framesSpin, QOverload::of(&QSpinBox::valueChanged), this, + [this]() { updateStorageStatus(); }); + connect(m_burstCountSpin, QOverload::of(&QSpinBox::valueChanged), this, + [this]() { updateStorageStatus(); }); + connect(m_mdaZCountSpin, QOverload::of(&QSpinBox::valueChanged), this, + [this]() { updateStorageStatus(); }); + connect(m_mdaXCountSpin, QOverload::of(&QSpinBox::valueChanged), this, + [this]() { updateStorageStatus(); }); + connect(m_mdaYCountSpin, QOverload::of(&QSpinBox::valueChanged), this, + [this]() { updateStorageStatus(); }); + m_storageStatusTimer = new QTimer(this); + m_storageStatusTimer->setInterval(1000); + connect(m_storageStatusTimer, &QTimer::timeout, this, &RecordingWidget::updateStorageStatus); connect(m_mdaEnableZCheck, &QCheckBox::toggled, this, [this]() { syncOrderList(); @@ -287,6 +307,14 @@ namespace scopeone::ui { m_isRecording = recording; m_startStopButton->setText(recording ? "Stop" : "Start"); + if (recording) + { + m_storageStatusTimer->start(); + } + else + { + m_storageStatusTimer->stop(); + } updateUiState(); }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::recordingWriterStatusChanged, this, @@ -572,12 +600,15 @@ namespace scopeone::ui auto* statusLayout = new QVBoxLayout(statusGroup); m_statusLabel = new QLabel("Idle", this); m_writerStatusLabel = new QLabel("Writer: Idle", this); + m_writerStatusLabel->setWordWrap(true); m_frameCountLabel = new QLabel("Frames: 0", this); m_burstCountLabel = new QLabel("Bursts: 0", this); + m_storageStatusLabel = new QLabel("Storage: unavailable", this); statusLayout->addWidget(m_statusLabel); statusLayout->addWidget(m_writerStatusLabel); statusLayout->addWidget(m_frameCountLabel); statusLayout->addWidget(m_burstCountLabel); + statusLayout->addWidget(m_storageStatusLabel); contentLayout->addWidget(statusGroup); m_startStopButton = new QPushButton("Start", this); @@ -706,6 +737,62 @@ namespace scopeone::ui const bool hasName = !normalizedBaseName().isEmpty(); const bool canStart = !m_isRecording && hasCameras && hasDir && hasName; m_startStopButton->setEnabled(m_isRecording || canStart); + updateStorageStatus(); + } + + // Updates available space and the estimated uncompressed recording size + void RecordingWidget::updateStorageStatus() + { + QStringList details; + const QString saveDir = m_saveDirLineEdit->text().trimmed(); + QStorageInfo storage(saveDir); + storage.refresh(); + if (storage.isValid() && storage.isReady()) + { + details.append(QStringLiteral("%1 free").arg(formatByteCount(storage.bytesAvailable()))); + } + + const QStringList cameraIds = selectedCameraIds(); + QStringList layerKeys; + layerKeys.reserve(cameraIds.size()); + for (const QString& cameraId : cameraIds) + { + layerKeys.append(scopeone::core::ScopeOneCore::rawLayerKey(cameraId)); + } + const QList frames = m_scopeonecore->graphFrames(layerKeys); + qint64 bytesPerPlane = 0; + int validFrameCount = 0; + for (const auto& frame : frames) + { + if (frame.isValid()) + { + bytesPerPlane += frame.payloadByteCount(); + ++validFrameCount; + } + } + if (!cameraIds.isEmpty() && validFrameCount == cameraIds.size() && bytesPerPlane > 0) + { + const qint64 burstCount = m_burstModeCheck->isChecked() ? m_burstCountSpin->value() : 1; + const qint64 zCount = m_mdaEnableZCheck->isChecked() ? m_mdaZCountSpin->value() : 1; + const qint64 positionCount = m_mdaEnableXYCheck->isChecked() + ? static_cast(m_mdaXCountSpin->value()) + * m_mdaYCountSpin->value() + : 1; + const long double estimatedBytes = static_cast(bytesPerPlane) + * m_framesSpin->value() * burstCount * zCount * positionCount; + const qint64 estimate = estimatedBytes > static_cast((std::numeric_limits::max)()) + ? (std::numeric_limits::max)() + : static_cast(estimatedBytes); + details.append(QStringLiteral("%1 estimated raw").arg(formatByteCount(estimate))); + if (storage.isValid() && storage.isReady() && estimate > storage.bytesAvailable()) + { + details.append(QStringLiteral("insufficient free space")); + } + } + + m_storageStatusLabel->setText(details.isEmpty() + ? QStringLiteral("Storage: unavailable") + : QStringLiteral("Storage: %1").arg(details.join(QStringLiteral(", ")))); } // Reads the last save directory from settings diff --git a/src/RecordingWidget.h b/src/RecordingWidget.h index 6758e50..833324b 100644 --- a/src/RecordingWidget.h +++ b/src/RecordingWidget.h @@ -15,6 +15,7 @@ class QLabel; class QLineEdit; class QPushButton; class QListWidget; +class QTimer; namespace scopeone::ui { @@ -42,6 +43,7 @@ namespace scopeone::ui void setupUI(); void updateUiState(); + void updateStorageStatus(); void moveOrderItem(int delta); void syncOrderList(); bool appendSelectedFramesToGallery(); @@ -94,6 +96,8 @@ namespace scopeone::ui QLabel* m_writerStatusLabel{nullptr}; QLabel* m_frameCountLabel{nullptr}; QLabel* m_burstCountLabel{nullptr}; + QLabel* m_storageStatusLabel{nullptr}; + QTimer* m_storageStatusTimer{nullptr}; scopeone::core::ScopeOneCore* m_scopeonecore{nullptr}; QStringList m_availableCameraIds; diff --git a/src/ScopeOneLocalApiServer.cpp b/src/ScopeOneLocalApiServer.cpp index e112dd8..96ecab4 100644 --- a/src/ScopeOneLocalApiServer.cpp +++ b/src/ScopeOneLocalApiServer.cpp @@ -393,7 +393,10 @@ namespace scopeone::ui object.insert(QStringLiteral("phase"), recordingWriterPhaseName(status.phase())); object.insert(QStringLiteral("pendingWriteBytes"), status.pendingWriteBytes()); object.insert(QStringLiteral("maxPendingWriteBytes"), status.maxPendingWriteBytes()); + object.insert(QStringLiteral("framesCaptured"), status.framesCaptured()); object.insert(QStringLiteral("framesWritten"), status.framesWritten()); + object.insert(QStringLiteral("droppedFrames"), status.droppedFrames()); + object.insert(QStringLiteral("bytesWritten"), status.bytesWritten()); object.insert(QStringLiteral("error"), status.errorMessage()); return object; } @@ -953,16 +956,6 @@ namespace scopeone::ui return false; } plan.mdaIntervalMs = intervalValue.isUndefined() ? 0.0 : intervalValue.toDouble(); - const QJsonValue pixelSizeValue = request.value(QStringLiteral("pixelSizeUm")); - if (!pixelSizeValue.isUndefined() - && (!pixelSizeValue.isDouble() - || !std::isfinite(pixelSizeValue.toDouble()) - || pixelSizeValue.toDouble() < 0.0)) - { - errorMessage = QStringLiteral("pixelSizeUm must be a finite non-negative number"); - return false; - } - plan.pixelSizeUm = pixelSizeValue.isUndefined() ? 0.0 : pixelSizeValue.toDouble(); if (!doubleArrayFromJson(request.value(QStringLiteral("zPositions")), QStringLiteral("zPositions"), plan.zPositions, @@ -3364,7 +3357,6 @@ namespace scopeone::ui || plan.xyStageId.isEmpty() || !readOptionalInt(QStringLiteral("rows"), plan.rows) || !readOptionalInt(QStringLiteral("columns"), plan.columns) - || !readOptionalDouble(QStringLiteral("pixelSizeUm"), plan.pixelSizeUm) || !readOptionalDouble(QStringLiteral("stepXUm"), plan.stepXUm) || !readOptionalDouble(QStringLiteral("stepYUm"), plan.stepYUm) || !readOptionalInt(QStringLiteral("settleMs"), plan.settleMs) diff --git a/src/ScopeOneMcpServer.cpp b/src/ScopeOneMcpServer.cpp index 607f1d1..6b34533 100644 --- a/src/ScopeOneMcpServer.cpp +++ b/src/ScopeOneMcpServer.cpp @@ -797,11 +797,6 @@ namespace inputProperty(QStringLiteral("integer"), QStringLiteral("Mosaic column count"), 1), 1.0), 10000.0)}, - {QStringLiteral("pixelSizeUm"), - withMinimum( - inputProperty(QStringLiteral("number"), QStringLiteral("Image pixel size in micrometers"), - 1.0), - 1e-12)}, {QStringLiteral("stepXUm"), inputProperty(QStringLiteral("number"), QStringLiteral("Horizontal tile step in micrometers"), 0.0)}, @@ -974,12 +969,6 @@ namespace inputProperty(QStringLiteral("number"), QStringLiteral("MDA time interval in milliseconds"), 0.0), 0.0)}, - {QStringLiteral("pixelSizeUm"), - withMinimum( - inputProperty(QStringLiteral("number"), - QStringLiteral("Sample pixel size in micrometers or zero when unknown"), - 0.0), - 0.0)}, {QStringLiteral("zPositions"), arrayProperty(QStringLiteral("Optional absolute Z positions"), QStringLiteral("number"))}, {QStringLiteral("positions"), From 3224ab65368e7df8dd4719bf636c0b95d2b1d568 Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:12:46 +0200 Subject: [PATCH 4/7] Finalize OME support and UI refinements --- .../cmake/ScopeOneCoreConfig.cmake.in | 1 - ScopeOneCore/src/ExperimentDocument.cpp | 14 - ScopeOneCore/src/RecordingManager.cpp | 37 ++- ScopeOneCore/src/StageMosaicManager.cpp | 5 +- VERSION | 2 +- scripts/build.ps1 | 4 + src/DevicePropertyWidget.cpp | 259 ++++++++++++++++-- src/DevicePropertyWidget.h | 1 + src/ImageToolsDialog.cpp | 9 +- src/InspectWidget.cpp | 7 +- src/InspectWidget.h | 2 +- src/MainWindow.cpp | 59 +++- src/MainWindow.h | 3 + src/RecordingWidget.cpp | 174 +++++++----- src/RecordingWidget.h | 1 + 15 files changed, 431 insertions(+), 147 deletions(-) diff --git a/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in b/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in index 5e01220..601bf9a 100644 --- a/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in +++ b/ScopeOneCore/cmake/ScopeOneCoreConfig.cmake.in @@ -3,7 +3,6 @@ include(CMakeFindDependencyMacro) find_dependency(Qt6 REQUIRED COMPONENTS Core Gui) -find_dependency(ScopeWriter CONFIG REQUIRED) include("${CMAKE_CURRENT_LIST_DIR}/ScopeOneCoreTargets.cmake") diff --git a/ScopeOneCore/src/ExperimentDocument.cpp b/ScopeOneCore/src/ExperimentDocument.cpp index 9d443d8..8fbc0ca 100644 --- a/ScopeOneCore/src/ExperimentDocument.cpp +++ b/ScopeOneCore/src/ExperimentDocument.cpp @@ -995,20 +995,6 @@ namespace scopeone::core { return fail(errorMessage, QStringLiteral("%1.order must contain Time").arg(path)); } - const auto firstNonPositionAxis = std::find_if(plan.order.cbegin(), - plan.order.cend(), - [](RecordingAxis axis) - { - return axis != RecordingAxis::XY; - }); - if (plan.format == RecordingFormat::OmeZarr - && (firstNonPositionAxis == plan.order.cend() - || *firstNonPositionAxis != RecordingAxis::Time)) - { - return fail(errorMessage, - QStringLiteral("%1.order must place Time before Z for OME-Zarr recording") - .arg(path)); - } if (plan.mdaIntervalMs > 0.0 && plan.framesPerBurst > 1 && plan.order.front() != RecordingAxis::Time) diff --git a/ScopeOneCore/src/RecordingManager.cpp b/ScopeOneCore/src/RecordingManager.cpp index dbd8fb8..9382769 100644 --- a/ScopeOneCore/src/RecordingManager.cpp +++ b/ScopeOneCore/src/RecordingManager.cpp @@ -288,6 +288,18 @@ namespace scopeone::core::internal return static_cast(frame.width) * static_cast(frame.height) * bytesPerPixel; } + double physicalPixelSizeUm(int imageExtent, + int sourceExtent, + double cameraPixelSizeUm) + { + if (cameraPixelSizeUm <= 0.0 || imageExtent <= 0 || sourceExtent <= 0) + { + return cameraPixelSizeUm; + } + return cameraPixelSizeUm * static_cast(sourceExtent) + / static_cast(imageExtent); + } + FramePayloadView framePayloadForWrite(const ImageFrame& frame, RecordingFormat format) { FramePayloadView payload; @@ -421,7 +433,8 @@ namespace scopeone::core::internal ImagePixelFormat pixelFormat, int bitsPerSample, const ExperimentPlan& plan, - double pixelSizeUm, + double physicalSizeXUm, + double physicalSizeYUm, quint64 acquisitionStartTimestampNs, const QString& imageName, const QJsonObject& cameraProperties, @@ -485,8 +498,8 @@ namespace scopeone::core::internal plan.order.end(), RecordingAxis::Z); settings.acquisitionOrder = zAxis < timeAxis ? "ZTC" : "TZC"; - settings.physicalSizeXUm = pixelSizeUm; - settings.physicalSizeYUm = pixelSizeUm; + settings.physicalSizeXUm = physicalSizeXUm; + settings.physicalSizeYUm = physicalSizeYUm; settings.timeIncrementMs = uniformTimeIncrementMs(plan); if (plan.zPositions.size() > 1) { @@ -573,7 +586,9 @@ namespace scopeone::core::internal metadata.cameraId = frame.cameraId.toStdString(); metadata.frameIndex = frame.frameIndex; metadata.timestampNs = frame.timestampNs; - metadata.stride = static_cast(frame.stride); + metadata.stride = m_omePlan.format == RecordingFormat::Binary + ? static_cast(frame.stride) + : static_cast(frame.width * frame.bytesPerPixel()); metadata.sourceRoiX = frame.sourceRoiX; metadata.sourceRoiY = frame.sourceRoiY; metadata.sourceRoiWidth = frame.sourceRoiWidth; @@ -1228,7 +1243,12 @@ namespace scopeone::core::internal task.frame.pixelFormat, task.frame.bitsPerSample, m_mdaState.plan, - output.pixelSizeUm, + physicalPixelSizeUm(task.frame.width, + task.frame.sourceRoiWidth, + output.pixelSizeUm), + physicalPixelSizeUm(task.frame.height, + task.frame.sourceRoiHeight, + output.pixelSizeUm), output.acquisitionStartTimestampNs, output.cameraId, output.cameraProperties, @@ -2287,7 +2307,12 @@ namespace scopeone::core::internal firstImageFrame.pixelFormat, firstImageFrame.bitsPerSample, capturePlan, - session->cameraPixelSizeUm(cameraId), + physicalPixelSizeUm(firstImageFrame.width, + firstImageFrame.sourceRoiWidth, + session->cameraPixelSizeUm(cameraId)), + physicalPixelSizeUm(firstImageFrame.height, + firstImageFrame.sourceRoiHeight, + session->cameraPixelSizeUm(cameraId)), session->experimentDocument().startedTimestampNs, cameraId, session->experimentDocument().deviceProperties diff --git a/ScopeOneCore/src/StageMosaicManager.cpp b/ScopeOneCore/src/StageMosaicManager.cpp index 5cc4481..9ce9d15 100644 --- a/ScopeOneCore/src/StageMosaicManager.cpp +++ b/ScopeOneCore/src/StageMosaicManager.cpp @@ -382,9 +382,10 @@ namespace scopeone::core::internal QString finalMessage = message; if (success) { - const ImageFrame frame = m_core->graphFrame(ScopeOneCore::staticLayerKey(kMosaicLayerId)); + ImageFrame frame = m_core->graphFrame(ScopeOneCore::staticLayerKey(kMosaicLayerId)); + frame.cameraId = m_plan.cameraId; ExperimentPlan capturePlan; - capturePlan.cameraIds = {frame.cameraId}; + capturePlan.cameraIds = {m_plan.cameraId}; capturePlan.streamToDisk = false; capturePlan.format = RecordingFormat::OmeTiff; capturePlan.pixelSizeUm = m_plan.pixelSizeUm; diff --git a/VERSION b/VERSION index f9c2112..da38df8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.724 +1.3.730 diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 0118209..e47e5dd 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -202,6 +202,10 @@ function Import-MsvcEnvironment { -HostArch amd64 ` -DevCmdArguments "-no_logo" ` -ErrorAction Stop | Out-Null + $msvcPath = $env:Path + Remove-Item Env:PATH -ErrorAction SilentlyContinue + Remove-Item Env:Path -ErrorAction SilentlyContinue + $env:Path = $msvcPath } catch { throw "Failed to initialize the Visual Studio developer environment: $($_.Exception.Message)" diff --git a/src/DevicePropertyWidget.cpp b/src/DevicePropertyWidget.cpp index 690cfd8..180d4dc 100644 --- a/src/DevicePropertyWidget.cpp +++ b/src/DevicePropertyWidget.cpp @@ -2,7 +2,7 @@ #include "scopeone/ScopeOneCore.h" -#include +#include #include #include #include @@ -11,11 +11,14 @@ #include #include #include +#include #include #include +#include #include #include #include +#include #include #include #include @@ -105,22 +108,45 @@ namespace scopeone::ui refreshButton->setMaximumWidth(60); connect(refreshButton, &QPushButton::clicked, this, &DevicePropertyWidget::onRefreshClicked); - auto* showReadOnlyCheckBox = new QCheckBox("Show Read-Only", this); - showReadOnlyCheckBox->setChecked(m_showReadOnly); - connect(showReadOnlyCheckBox, &QCheckBox::toggled, this, &DevicePropertyWidget::onShowReadOnlyToggled); - - auto* showPreInitCheckBox = new QCheckBox("Show Pre-Init", this); - showPreInitCheckBox->setChecked(m_showPreInit); - connect(showPreInitCheckBox, &QCheckBox::toggled, this, &DevicePropertyWidget::onShowPreInitToggled); - - auto* autoRefreshCheckBox = new QCheckBox("Auto Refresh", this); - autoRefreshCheckBox->setChecked(m_autoRefresh); - connect(autoRefreshCheckBox, &QCheckBox::toggled, this, &DevicePropertyWidget::onAutoRefreshToggled); + auto* optionsButton = new QToolButton(this); + optionsButton->setText("Options"); + optionsButton->setPopupMode(QToolButton::InstantPopup); + + auto* optionsMenu = new QMenu(optionsButton); + auto* showReadOnlyAction = optionsMenu->addAction("Show Read-Only Properties"); + showReadOnlyAction->setCheckable(true); + showReadOnlyAction->setChecked(m_showReadOnly); + connect(showReadOnlyAction, &QAction::toggled, this, &DevicePropertyWidget::onShowReadOnlyToggled); + + auto* showPreInitAction = optionsMenu->addAction("Show Pre-Init Properties"); + showPreInitAction->setCheckable(true); + showPreInitAction->setChecked(m_showPreInit); + connect(showPreInitAction, &QAction::toggled, this, &DevicePropertyWidget::onShowPreInitToggled); + + auto* autoRefreshAction = optionsMenu->addAction("Auto Refresh"); + autoRefreshAction->setCheckable(true); + autoRefreshAction->setChecked(m_autoRefresh); + connect(autoRefreshAction, &QAction::toggled, this, &DevicePropertyWidget::onAutoRefreshToggled); + + optionsMenu->addSeparator(); + auto* columnsMenu = optionsMenu->addMenu("Columns"); + const auto addColumnAction = [this, columnsMenu](const QString& text, int column, bool visible) + { + QAction* action = columnsMenu->addAction(text); + action->setCheckable(true); + action->setChecked(visible); + connect(action, &QAction::toggled, this, [this, column](bool visible) + { + m_propertyTree->setColumnHidden(column, !visible); + }); + }; + addColumnAction("Value", ValueColumn, true); + addColumnAction("Type", TypeColumn, false); + addColumnAction("Read-Only", ReadOnlyColumn, false); + optionsButton->setMenu(optionsMenu); controlLayout->addWidget(refreshButton); - controlLayout->addWidget(showReadOnlyCheckBox); - controlLayout->addWidget(showPreInitCheckBox); - controlLayout->addWidget(autoRefreshCheckBox); + controlLayout->addWidget(optionsButton); controlLayout->addStretch(); m_propertyTree = new QTreeWidget(this); @@ -131,10 +157,13 @@ namespace scopeone::ui m_propertyTree->sortByColumn(0, Qt::AscendingOrder); m_propertyTree->setEditTriggers(QAbstractItemView::NoEditTriggers); - m_propertyTree->header()->resizeSection(NameColumn, 200); - m_propertyTree->header()->resizeSection(ValueColumn, 150); + m_propertyTree->header()->setSectionResizeMode(QHeaderView::Interactive); + m_propertyTree->header()->resizeSection(NameColumn, 160); + m_propertyTree->header()->resizeSection(ValueColumn, 130); m_propertyTree->header()->resizeSection(TypeColumn, 70); m_propertyTree->header()->resizeSection(ReadOnlyColumn, 70); + m_propertyTree->setColumnHidden(TypeColumn, true); + m_propertyTree->setColumnHidden(ReadOnlyColumn, true); { QComboBox comboProbe; @@ -161,35 +190,87 @@ namespace scopeone::ui mainLayout->addWidget(m_propertyTree); } - // Rebuild visible properties while preserving scroll position + // Refresh values in place and rebuild only when the visible structure changes void DevicePropertyWidget::refresh(bool fromCache) { - // Rebuild the tree and keep the scroll position if (m_updating || m_scopeonecore->configurationOperationRunning()) { return; } - const int oldScrollValue = m_propertyTree->verticalScrollBar()->value(); - m_updating = true; - m_propertyTree->clear(); - try { + if (updateExistingValues(fromCache)) + { + m_updating = false; + return; + } + + const bool hadItems = m_propertyTree->topLevelItemCount() > 0; + const int oldScrollValue = m_propertyTree->verticalScrollBar()->value(); + QSet expandedDevices; + for (int i = 0; i < m_propertyTree->topLevelItemCount(); ++i) + { + QTreeWidgetItem* deviceItem = m_propertyTree->topLevelItem(i); + if (deviceItem->isExpanded()) + { + expandedDevices.insert(deviceItem->text(NameColumn)); + } + } + + QString selectedDevice; + QString selectedProperty; + if (QTreeWidgetItem* selectedItem = m_propertyTree->currentItem()) + { + selectedDevice = selectedItem->data(NameColumn, Qt::UserRole).toString(); + selectedProperty = selectedItem->data(NameColumn, Qt::UserRole + 1).toString(); + if (selectedDevice.isEmpty()) + { + selectedDevice = selectedItem->text(NameColumn); + } + } + + m_propertyTree->setSortingEnabled(false); + m_propertyTree->clear(); populateDeviceTree(fromCache); + m_propertyTree->setSortingEnabled(true); + m_propertyTree->sortByColumn(NameColumn, Qt::AscendingOrder); + + for (int i = 0; i < m_propertyTree->topLevelItemCount(); ++i) + { + QTreeWidgetItem* deviceItem = m_propertyTree->topLevelItem(i); + deviceItem->setExpanded(!hadItems || expandedDevices.contains(deviceItem->text(NameColumn))); + if (deviceItem->text(NameColumn) != selectedDevice) + { + continue; + } + if (selectedProperty.isEmpty()) + { + m_propertyTree->setCurrentItem(deviceItem); + continue; + } + for (int childIndex = 0; childIndex < deviceItem->childCount(); ++childIndex) + { + QTreeWidgetItem* propertyItem = deviceItem->child(childIndex); + if (propertyItem->data(NameColumn, Qt::UserRole + 1).toString() == selectedProperty) + { + m_propertyTree->setCurrentItem(propertyItem); + break; + } + } + } + + QTimer::singleShot(0, this, [this, oldScrollValue]() + { + m_propertyTree->verticalScrollBar()->setValue(oldScrollValue); + }); } catch (const std::exception& e) { emit errorOccurred(QString("Error refreshing properties: %1").arg(e.what())); qWarning() << "Error refreshing properties:" << e.what(); } - - QTimer::singleShot(0, this, [this, oldScrollValue]() - { - m_propertyTree->verticalScrollBar()->setValue(oldScrollValue); - }); - m_updating = false; } @@ -202,7 +283,124 @@ namespace scopeone::ui addDeviceToTree(deviceLabel, fromCache); } - m_propertyTree->expandAll(); + } + + // Update existing property editors without rebuilding the tree + bool DevicePropertyWidget::updateExistingValues(bool fromCache) + { + const QStringList devices = m_scopeonecore->loadedDevices(); + if (m_propertyTree->topLevelItemCount() != devices.size()) + { + return false; + } + + for (const QString& deviceLabel : devices) + { + QTreeWidgetItem* deviceItem = nullptr; + for (int i = 0; i < m_propertyTree->topLevelItemCount(); ++i) + { + QTreeWidgetItem* candidate = m_propertyTree->topLevelItem(i); + if (candidate->text(NameColumn) == deviceLabel) + { + deviceItem = candidate; + break; + } + } + if (!deviceItem) + { + return false; + } + + const auto properties = m_scopeonecore->deviceProperties(deviceLabel, fromCache); + int visiblePropertyCount = 0; + for (const auto& propertyInfo : properties) + { + if ((!m_showReadOnly && propertyInfo.isReadOnly()) + || (!m_showPreInit && propertyInfo.isPreInit())) + { + continue; + } + ++visiblePropertyCount; + + QTreeWidgetItem* propertyItem = nullptr; + for (int i = 0; i < deviceItem->childCount(); ++i) + { + QTreeWidgetItem* candidate = deviceItem->child(i); + if (candidate->data(NameColumn, Qt::UserRole + 1).toString() == propertyInfo.name()) + { + propertyItem = candidate; + break; + } + } + if (!propertyItem) + { + return false; + } + + const QString type = propertyInfo.type(); + const QString displayType = type.isEmpty() ? QStringLiteral("Unknown") : type; + const QString readOnlyText = propertyInfo.isReadOnly() ? QStringLiteral("Yes") : QStringLiteral("No"); + if (propertyItem->text(TypeColumn) != displayType + || propertyItem->text(ReadOnlyColumn) != readOnlyText) + { + return false; + } + + const bool isInteger = type == QStringLiteral("Integer"); + const bool isFloat = type == QStringLiteral("Float"); + const QStringList allowedValues = propertyInfo.allowedValues(); + const QString value = allowedValues.isEmpty() && (isInteger || isFloat) + ? formatPropertyDisplayValue(propertyInfo.value(), isInteger, isFloat) + : propertyInfo.value(); + QWidget* editor = m_propertyTree->itemWidget(propertyItem, ValueColumn); + if (auto* combo = qobject_cast(editor)) + { + if (combo->count() != allowedValues.size()) + { + return false; + } + for (int i = 0; i < allowedValues.size(); ++i) + { + if (combo->itemText(i) != allowedValues.at(i)) + { + return false; + } + } + if (!combo->hasFocus()) + { + const QSignalBlocker blocker(combo); + combo->setCurrentText(value); + } + } + else if (auto* lineEdit = qobject_cast(editor)) + { + if (!allowedValues.isEmpty() || (!isInteger && !isFloat)) + { + return false; + } + if (!lineEdit->hasFocus()) + { + const QSignalBlocker blocker(lineEdit); + lineEdit->setText(value); + } + } + else + { + const bool needsEditor = !propertyInfo.isReadOnly() + && (!allowedValues.isEmpty() || isInteger || isFloat); + if (needsEditor) + { + return false; + } + propertyItem->setText(ValueColumn, value); + } + } + if (deviceItem->childCount() != visiblePropertyCount) + { + return false; + } + } + return true; } // Submit a property value and read back the actual accepted value @@ -233,6 +431,7 @@ namespace scopeone::ui deviceItem->setText(ValueColumn, ""); deviceItem->setText(TypeColumn, "Device"); deviceItem->setText(ReadOnlyColumn, ""); + deviceItem->setData(NameColumn, Qt::UserRole, deviceLabel); QFont font = deviceItem->font(NameColumn); font.setBold(true); diff --git a/src/DevicePropertyWidget.h b/src/DevicePropertyWidget.h index 5c7845d..cb4c5b4 100644 --- a/src/DevicePropertyWidget.h +++ b/src/DevicePropertyWidget.h @@ -33,6 +33,7 @@ namespace scopeone::ui void setupUI(); void populateDeviceTree(bool fromCache); + bool updateExistingValues(bool fromCache); bool applyPropertyValue(const QString& deviceLabel, const QString& propertyName, const QString& requestedValue, diff --git a/src/ImageToolsDialog.cpp b/src/ImageToolsDialog.cpp index b185cc0..244259b 100644 --- a/src/ImageToolsDialog.cpp +++ b/src/ImageToolsDialog.cpp @@ -69,7 +69,7 @@ namespace scopeone::ui formLayout->addRow(tr("Pixel Size"), m_pixelSizeSpinBox); mainLayout->addLayout(formLayout); - m_statusLabel = new QLabel(tr("Scale is applied globally to the selected camera"), this); + m_statusLabel = new QLabel(tr("Scale applies to the selected camera for this session"), this); m_statusLabel->setWordWrap(true); mainLayout->addWidget(m_statusLabel); @@ -92,7 +92,7 @@ namespace scopeone::ui m_pixelSizeSpinBox->setValue(cameraId.isEmpty() ? 0.0 : m_core->cameraPixelSizeUm(cameraId)); } - // Apply and persist one camera scale + // Apply one camera scale for the current application session void CameraScaleDialog::applyScale() { const QString cameraId = m_cameraCombo->currentText().trimmed(); @@ -103,19 +103,14 @@ namespace scopeone::ui return; } - QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); - QVariantMap pixelSizes = settings.value(QStringLiteral("Scale/CameraPixelSizesUm")).toMap(); if (pixelSizeUm > 0.0) { - pixelSizes.insert(cameraId, pixelSizeUm); m_statusLabel->setText(tr("Scale updated for %1").arg(cameraId)); } else { - pixelSizes.remove(cameraId); m_statusLabel->setText(tr("Scale cleared for %1").arg(cameraId)); } - settings.setValue(QStringLiteral("Scale/CameraPixelSizesUm"), pixelSizes); } // Create a stage driven mosaic tool diff --git a/src/InspectWidget.cpp b/src/InspectWidget.cpp index 8cee7f1..0b5843c 100644 --- a/src/InspectWidget.cpp +++ b/src/InspectWidget.cpp @@ -600,7 +600,7 @@ namespace scopeone::ui void InspectWidget::setMeasurementLine(const QString& layerKey, const QPoint& start, const QPoint& end, - double pixelSizeUm) + double actualLengthUm) { m_measurementLayerKey = layerKey.trimmed(); const double dx = static_cast(end.x() - start.x()); @@ -617,11 +617,10 @@ namespace scopeone::ui QStringLiteral("Angle: %1°").arg(angleDegrees, 0, 'f', 1), QStringLiteral("Length: %1 px").arg(lengthPixels, 0, 'f', 2) }; - const bool calibrated = pixelSizeUm > 0.0; - if (calibrated) + if (actualLengthUm > 0.0) { lines.append(QStringLiteral("Actual: %1 µm") - .arg(lengthPixels * pixelSizeUm, 0, 'f', 3)); + .arg(actualLengthUm, 0, 'f', 3)); } m_measurementInfoLabel->setText(lines.join('\n')); m_measurementInfoLabel->show(); diff --git a/src/InspectWidget.h b/src/InspectWidget.h index 15585e0..38ca46a 100644 --- a/src/InspectWidget.h +++ b/src/InspectWidget.h @@ -51,7 +51,7 @@ namespace scopeone::ui void setMeasurementLine(const QString& layerKey, const QPoint& start, const QPoint& end, - double pixelSizeUm); + double actualLengthUm); void clearMeasurementLine(); signals: diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index ec6926f..ac28e40 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -36,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -512,21 +513,32 @@ namespace scopeone::ui QString(), ImageSceneModel::MarkupRole::Measurement); m_imageSceneModel->selectOnly(markupId); - const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); - m_inspectWidget->setMeasurementLine( - layerKey, start, end, m_scopeonecore->cameraPixelSizeUm(cameraId)); + showMeasurementLine(layerKey, start, end); }); connect(m_previewWidget, &PreviewWidget::measurementLineInspected, this, [this](const QString& layerKey, const QPoint& start, const QPoint& end) { - const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); - m_inspectWidget->setMeasurementLine( - layerKey, start, end, m_scopeonecore->cameraPixelSizeUm(cameraId)); + showMeasurementLine(layerKey, start, end); }); connect(m_previewWidget, &PreviewWidget::measurementLineCleared, m_inspectWidget, &InspectWidget::clearMeasurementLine); + connect(m_imageSceneModel, &ImageSceneModel::markupsChanged, + this, [this]() + { + for (const ImageSceneModel::Markup& markup : m_imageSceneModel->markups()) + { + if (markup.selected + && markup.type == ImageSceneModel::MarkupType::Line + && markup.role == ImageSceneModel::MarkupRole::Measurement) + { + showMeasurementLine(markup.layerKey, markup.start, markup.end); + return; + } + } + m_inspectWidget->clearMeasurementLine(); + }); m_inspectWidget->setAvailableLayers(m_previewWidget->availableLayerKeys()); m_inspectWidget->setCurrentLayer(m_deviceControlWidget->currentLayerKey()); @@ -1126,11 +1138,6 @@ namespace scopeone::ui QStringLiteral("Recording/MaxPendingWriteBytes"), kDefaultRecordedMaxBytes) .toLongLong(); m_scopeonecore->setRecordingMaxPendingWriteBytes(recordedMaxBytes); - const QVariantMap pixelSizesUm = settings.value(QStringLiteral("Scale/CameraPixelSizesUm")).toMap(); - for (auto it = pixelSizesUm.constBegin(); it != pixelSizesUm.constEnd(); ++it) - { - m_scopeonecore->setCameraPixelSizeUm(it.key(), it.value().toDouble()); - } } // Record the application startup state in one place @@ -1220,6 +1227,36 @@ namespace scopeone::ui } } + // Display a line measurement using the layer to sensor transform + void MainWindow::showMeasurementLine(const QString& layerKey, + const QPoint& start, + const QPoint& end) + { + double actualLengthUm = 0.0; + double pixelSizeUm = 0.0; + const auto galleryControl = m_galleryLayerFrameControls.constFind(layerKey); + if (galleryControl != m_galleryLayerFrameControls.constEnd() + && galleryControl->session) + { + pixelSizeUm = galleryControl->session->cameraPixelSizeUm(galleryControl->cameraId); + } + else + { + const QString cameraId = scopeone::core::ScopeOneCore::sourceIdFromLayerKey(layerKey); + pixelSizeUm = m_scopeonecore->cameraPixelSizeUm(cameraId); + } + scopeone::core::DocumentLayer layer; + if (pixelSizeUm > 0.0 && m_imageSceneModel->findLayer(layerKey, layer)) + { + const QPointF sensorStart = layer.pixelToSensor.map(QPointF(start)); + const QPointF sensorEnd = layer.pixelToSensor.map(QPointF(end)); + actualLengthUm = std::hypot(sensorEnd.x() - sensorStart.x(), + sensorEnd.y() - sensorStart.y()) + * pixelSizeUm; + } + m_inspectWidget->setMeasurementLine(layerKey, start, end, actualLengthUm); + } + // Registers right panel frame sliders for stack backed gallery layers void MainWindow::registerGallerySessionFrameControls( const std::shared_ptr& session, diff --git a/src/MainWindow.h b/src/MainWindow.h index a496934..5c3c5f0 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -82,6 +82,9 @@ namespace scopeone::ui void clearCursorStatus(); void refreshPreviewCursorStatus(); void schedulePreviewCursorStatusRefresh(); + void showMeasurementLine(const QString& layerKey, + const QPoint& start, + const QPoint& end); void registerGallerySessionFrameControls( const std::shared_ptr& session, int frameIndex); diff --git a/src/RecordingWidget.cpp b/src/RecordingWidget.cpp index cb0c33b..3fe2d63 100644 --- a/src/RecordingWidget.cpp +++ b/src/RecordingWidget.cpp @@ -70,19 +70,7 @@ namespace return QStringLiteral("Idle"); } - QString formatStatusText(int phase, - qint64 waitRemainingMs, - int mdaTimeIndex, - int mdaTimeCount, - int mdaZIndex, - int mdaZCount, - int mdaPositionIndex, - int mdaPositionCount, - bool hasXY, - double x, - double y, - bool hasZ, - double z) + QString formatStatusText(int phase, qint64 waitRemainingMs) { QString status = phaseText(phase); if (phase == scopeone::core::kRecordingPhaseWaitingNextBurst && waitRemainingMs > 0) @@ -90,56 +78,67 @@ namespace status += QString(" (%1 ms)").arg(waitRemainingMs); } + return status; + } + + QString formatMdaStatusText(int phase, + int mdaTimeIndex, + int mdaTimeCount, + int mdaZIndex, + int mdaZCount, + int mdaPositionIndex, + int mdaPositionCount, + bool hasXY, + double x, + double y, + bool hasZ, + double z) + { if (phase != scopeone::core::kRecordingPhaseRecordingMda || mdaTimeIndex <= 0) { - return status; + return {}; } - QString axis = QString("T %1/%2").arg(mdaTimeIndex).arg((std::max)(1, mdaTimeCount)); + QStringList axes; + axes.append(QString("T %1/%2").arg(mdaTimeIndex).arg((std::max)(1, mdaTimeCount))); if (mdaZCount > 1 && mdaZIndex > 0) { - axis += QString(" Z %1/%2").arg(mdaZIndex).arg(mdaZCount); + axes.append(QString("Z %1/%2").arg(mdaZIndex).arg(mdaZCount)); } if (mdaPositionCount > 1 && mdaPositionIndex > 0) { - axis += QString(" XY %1/%2").arg(mdaPositionIndex).arg(mdaPositionCount); + axes.append(QString("XY %1/%2").arg(mdaPositionIndex).arg(mdaPositionCount)); } - QString pos; + QStringList position; if (hasXY) { - pos = QString("X=%1 Y=%2").arg(x, 0, 'f', 3).arg(y, 0, 'f', 3); + position.append(QString("X %1 Y %2").arg(x, 0, 'f', 3).arg(y, 0, 'f', 3)); } if (hasZ) { - const QString zText = QString("Current Z=%1").arg(z, 0, 'f', 3); - pos = pos.isEmpty() ? zText : (pos + " " + zText); + position.append(QString("Z %1").arg(z, 0, 'f', 3)); } - if (!axis.isEmpty() && !pos.isEmpty()) - { - return QString("%1 [%2 | %3]").arg(status, axis, pos); - } - if (!axis.isEmpty()) - { - return QString("%1 [%2]").arg(status, axis); - } - return status; + const QString axisText = axes.join(QStringLiteral(" ")); + return position.isEmpty() + ? axisText + : QStringLiteral("%1 | %2").arg(axisText, position.join(QStringLiteral(" "))); } QString formatFramesText(qint64 frameCurrent, qint64 frameTarget) { const qint64 target = (std::max)(0ll, frameTarget); - return QString("Frames: %1 / %2").arg(frameCurrent).arg(target); + return QString("%1 / %2 frames").arg(frameCurrent).arg(target); } QString formatBurstsText(int burstCurrent, int burstTarget) { if (burstTarget <= 0) { - return QStringLiteral("Bursts: 0"); + return {}; } - return QString("Bursts: %1/%2").arg(burstCurrent).arg(burstTarget); + return QString("Burst %1 / %2").arg(burstCurrent).arg(burstTarget); } QString formatByteCount(qint64 bytes) @@ -182,14 +181,12 @@ namespace QString formatWriterStatusText(const scopeone::core::ScopeOneCore::RecordingWriterStatus& status) { - QString text = QStringLiteral("Writer: %1").arg(writerPhaseText(status.phase())); + QString text = QStringLiteral("Disk: %1").arg(writerPhaseText(status.phase())); QStringList details; - if (status.framesCaptured() > 0 || status.framesWritten() > 0 || status.phase() == + if (status.framesWritten() > 0 || status.phase() == scopeone::core::ScopeOneCore::RecordingWriterPhase::Completed) { - details.append(QStringLiteral("%1 captured, %2 written") - .arg(status.framesCaptured()) - .arg(status.framesWritten())); + details.append(QStringLiteral("%1 frames written").arg(status.framesWritten())); } if (status.droppedFrames() > 0) { @@ -197,17 +194,17 @@ namespace } if (status.bytesWritten() > 0) { - details.append(QStringLiteral("%1 written").arg(formatByteCount(status.bytesWritten()))); + details.append(QStringLiteral("%1 data").arg(formatByteCount(status.bytesWritten()))); } if (status.maxPendingWriteBytes() > 0) { - details.append(QStringLiteral("%1 / %2 queued") + details.append(QStringLiteral("Queue %1 / %2") .arg(formatByteCount(status.pendingWriteBytes())) .arg(formatByteCount(status.maxPendingWriteBytes()))); } else if (status.pendingWriteBytes() > 0) { - details.append(QStringLiteral("%1 queued").arg(formatByteCount(status.pendingWriteBytes()))); + details.append(QStringLiteral("Queue %1").arg(formatByteCount(status.pendingWriteBytes()))); } if (!status.errorMessage().isEmpty()) { @@ -286,21 +283,28 @@ namespace scopeone::ui bool hasZ, double z) { - m_statusLabel->setText(formatStatusText(phase, - waitRemainingMs, - mdaTimeIndex, - mdaTimeCount, - mdaZIndex, - mdaZCount, - mdaPositionIndex, - mdaPositionCount, - hasXY, - x, - y, - hasZ, - z)); + m_statusLabel->setText(formatStatusText(phase, waitRemainingMs)); + const QString mdaStatus = formatMdaStatusText(phase, + mdaTimeIndex, + mdaTimeCount, + mdaZIndex, + mdaZCount, + mdaPositionIndex, + mdaPositionCount, + hasXY, + x, + y, + hasZ, + z); + m_mdaStatusLabel->setText(mdaStatus); + m_mdaStatusLabel->setVisible(!mdaStatus.isEmpty()); m_frameCountLabel->setText(formatFramesText(frameCurrent, frameTarget)); - m_burstCountLabel->setText(formatBurstsText(burstCurrent, burstTarget)); + const bool progressVisible = phase != scopeone::core::kRecordingPhaseIdle + && phase != scopeone::core::kRecordingPhaseStopped; + m_frameCountLabel->setVisible(progressVisible); + const QString burstStatus = formatBurstsText(burstCurrent, burstTarget); + m_burstCountLabel->setText(burstStatus); + m_burstCountLabel->setVisible(progressVisible && !burstStatus.isEmpty()); }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::recordingStateChanged, this, [this](bool recording) @@ -321,6 +325,8 @@ namespace scopeone::ui [this](const scopeone::core::ScopeOneCore::RecordingWriterStatus& status) { m_writerStatusLabel->setText(formatWriterStatusText(status)); + m_writerStatusLabel->setVisible( + status.phase() != scopeone::core::ScopeOneCore::RecordingWriterPhase::Idle); }); connect(m_scopeonecore, &scopeone::core::ScopeOneCore::recordingStopped, this, [this](const std::shared_ptr& session) @@ -347,16 +353,18 @@ namespace scopeone::ui const bool saved = recordingResultSuccess(session); if (saved) { - m_writerStatusLabel->setText(QStringLiteral("Writer: Completed")); + m_writerStatusLabel->setText( + formatWriterStatusText(m_scopeonecore->recordingWriterStatus())); } else if (!result.isEmpty()) { - m_writerStatusLabel->setText(QStringLiteral("Writer: Failed - %1").arg(result)); + m_writerStatusLabel->setText(QStringLiteral("Disk: Failed - %1").arg(result)); } else { - m_writerStatusLabel->setText(QStringLiteral("Writer: Failed - Error: no session data")); + m_writerStatusLabel->setText(QStringLiteral("Disk: Failed - Error: no session data")); } + m_writerStatusLabel->show(); if (result.isEmpty()) { qWarning().noquote() << "Error: no session data"; @@ -486,20 +494,24 @@ namespace scopeone::ui m_mdaZStartSpin->setRange(-1000000.0, 1000000.0); m_mdaZStartSpin->setDecimals(3); m_mdaZStartSpin->setValue(0.0); + m_mdaZStartSpin->setFixedWidth(60); m_mdaZStepSpin = new QDoubleSpinBox(this); m_mdaZStepSpin->setRange(-1000000.0, 1000000.0); m_mdaZStepSpin->setDecimals(3); m_mdaZStepSpin->setValue(1.0); + m_mdaZStepSpin->setFixedWidth(60); m_mdaZCountSpin = new QSpinBox(this); m_mdaZCountSpin->setRange(1, 10000); m_mdaZCountSpin->setValue(1); + m_mdaZCountSpin->setFixedWidth(60); zRowLayout->addWidget(new QLabel("Start", this)); zRowLayout->addWidget(m_mdaZStartSpin); + zRowLayout->addStretch(); zRowLayout->addWidget(new QLabel("Step", this)); zRowLayout->addWidget(m_mdaZStepSpin); + zRowLayout->addStretch(); zRowLayout->addWidget(new QLabel("Count", this)); zRowLayout->addWidget(m_mdaZCountSpin); - zRowLayout->addStretch(); mdaLayout->addRow("Z:", zRowLayout); m_mdaEnableXYCheck = new QCheckBox("Enable XY Grid", this); @@ -511,20 +523,24 @@ namespace scopeone::ui m_mdaXStartSpin->setRange(-1000000.0, 1000000.0); m_mdaXStartSpin->setDecimals(3); m_mdaXStartSpin->setValue(0.0); + m_mdaXStartSpin->setFixedWidth(60); m_mdaXStepSpin = new QDoubleSpinBox(this); m_mdaXStepSpin->setRange(-1000000.0, 1000000.0); m_mdaXStepSpin->setDecimals(3); m_mdaXStepSpin->setValue(1.0); + m_mdaXStepSpin->setFixedWidth(60); m_mdaXCountSpin = new QSpinBox(this); m_mdaXCountSpin->setRange(1, 10000); m_mdaXCountSpin->setValue(1); + m_mdaXCountSpin->setFixedWidth(60); xRowLayout->addWidget(new QLabel("Start", this)); xRowLayout->addWidget(m_mdaXStartSpin); + xRowLayout->addStretch(); xRowLayout->addWidget(new QLabel("Step", this)); xRowLayout->addWidget(m_mdaXStepSpin); + xRowLayout->addStretch(); xRowLayout->addWidget(new QLabel("Count", this)); xRowLayout->addWidget(m_mdaXCountSpin); - xRowLayout->addStretch(); mdaLayout->addRow("X:", xRowLayout); auto* yRowLayout = new QHBoxLayout(); @@ -533,24 +549,29 @@ namespace scopeone::ui m_mdaYStartSpin->setRange(-1000000.0, 1000000.0); m_mdaYStartSpin->setDecimals(3); m_mdaYStartSpin->setValue(0.0); + m_mdaYStartSpin->setFixedWidth(60); m_mdaYStepSpin = new QDoubleSpinBox(this); m_mdaYStepSpin->setRange(-1000000.0, 1000000.0); m_mdaYStepSpin->setDecimals(3); m_mdaYStepSpin->setValue(1.0); + m_mdaYStepSpin->setFixedWidth(60); m_mdaYCountSpin = new QSpinBox(this); m_mdaYCountSpin->setRange(1, 10000); m_mdaYCountSpin->setValue(1); + m_mdaYCountSpin->setFixedWidth(60); yRowLayout->addWidget(new QLabel("Start", this)); yRowLayout->addWidget(m_mdaYStartSpin); + yRowLayout->addStretch(); yRowLayout->addWidget(new QLabel("Step", this)); yRowLayout->addWidget(m_mdaYStepSpin); + yRowLayout->addStretch(); yRowLayout->addWidget(new QLabel("Count", this)); yRowLayout->addWidget(m_mdaYCountSpin); - yRowLayout->addStretch(); mdaLayout->addRow("Y:", yRowLayout); m_mdaOrderList = new QListWidget(this); m_mdaOrderList->setSelectionMode(QAbstractItemView::SingleSelection); + m_mdaOrderList->setFixedHeight(72); m_orderPreference = { static_cast(scopeone::core::ScopeOneCore::RecordingAxis::Time), static_cast(scopeone::core::ScopeOneCore::RecordingAxis::Z), @@ -597,18 +618,31 @@ namespace scopeone::ui contentLayout->addWidget(mdaGroup); auto* statusGroup = new QGroupBox("Status", this); - auto* statusLayout = new QVBoxLayout(statusGroup); + auto* statusLayout = new QGridLayout(statusGroup); + statusLayout->setHorizontalSpacing(8); + statusLayout->setVerticalSpacing(2); + statusLayout->setColumnStretch(0, 1); m_statusLabel = new QLabel("Idle", this); - m_writerStatusLabel = new QLabel("Writer: Idle", this); + m_mdaStatusLabel = new QLabel(this); + m_mdaStatusLabel->setWordWrap(true); + m_mdaStatusLabel->hide(); + m_writerStatusLabel = new QLabel(this); m_writerStatusLabel->setWordWrap(true); - m_frameCountLabel = new QLabel("Frames: 0", this); - m_burstCountLabel = new QLabel("Bursts: 0", this); + m_writerStatusLabel->hide(); + m_frameCountLabel = new QLabel(this); + m_frameCountLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + m_frameCountLabel->hide(); + m_burstCountLabel = new QLabel(this); + m_burstCountLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + m_burstCountLabel->hide(); m_storageStatusLabel = new QLabel("Storage: unavailable", this); - statusLayout->addWidget(m_statusLabel); - statusLayout->addWidget(m_writerStatusLabel); - statusLayout->addWidget(m_frameCountLabel); - statusLayout->addWidget(m_burstCountLabel); - statusLayout->addWidget(m_storageStatusLabel); + m_storageStatusLabel->setWordWrap(true); + statusLayout->addWidget(m_statusLabel, 0, 0); + statusLayout->addWidget(m_frameCountLabel, 0, 1); + statusLayout->addWidget(m_mdaStatusLabel, 1, 0); + statusLayout->addWidget(m_burstCountLabel, 1, 1); + statusLayout->addWidget(m_writerStatusLabel, 2, 0, 1, 2); + statusLayout->addWidget(m_storageStatusLabel, 3, 0, 1, 2); contentLayout->addWidget(statusGroup); m_startStopButton = new QPushButton("Start", this); diff --git a/src/RecordingWidget.h b/src/RecordingWidget.h index 833324b..dc04862 100644 --- a/src/RecordingWidget.h +++ b/src/RecordingWidget.h @@ -93,6 +93,7 @@ namespace scopeone::ui QPushButton* m_startStopButton{nullptr}; QLabel* m_statusLabel{nullptr}; + QLabel* m_mdaStatusLabel{nullptr}; QLabel* m_writerStatusLabel{nullptr}; QLabel* m_frameCountLabel{nullptr}; QLabel* m_burstCountLabel{nullptr}; From 604473092dd54cdcf17a249f1f51be7def569337 Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:44:49 +0200 Subject: [PATCH 5/7] Finalize OME readback and adapter discovery --- README.md | 2 +- ScopeOneCore/external/ScopeWriter | 2 +- ScopeOneCore/include/scopeone/ScopeOneCore.h | 2 + ScopeOneCore/internal/MMCoreManager.h | 9 ++ ScopeOneCore/src/MMCoreManager.cpp | 29 +++++- ScopeOneCore/src/ScopeOneCore.cpp | 101 +++++++++++++------ scripts/build-unix.sh | 3 + src/MainWindow.cpp | 55 +++++++++- src/SettingsDialog.cpp | 68 ++++++++++++- src/SettingsDialog.h | 7 +- 10 files changed, 236 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 2308be5..a6fd765 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Download the latest release package from the [Releases](https://github.com/Exper **Device Adapter Setup:** -ScopeOne loads Micro-Manager configuration files (.cfg) directly. We recommend installing [Micro-Manager 2.0](https://download.micro-manager.org/nightly/2.0/Windows/) to access the full device adapter library. The release package includes only a minimal set of device adapter DLLs. To add support for additional devices, simply copy the required DLLs(`mmgr_dal_xxx.dll`) from your Micro-Manager installation directory (typically `C:\Program Files\Micro-Manager-2.0`) to the root folder containing `ScopeOne.exe`. Besides, we kindly remind you first ensure your devices are working properly in Micro-Manager before using them in ScopeOne, as device compatibility issues are often related to the device adapter itself. +ScopeOne loads Micro-Manager configuration files (.cfg) directly and includes the basic adapters required for its demo configurations. To access additional hardware adapters, install [Micro-Manager 2.0](https://download.micro-manager.org/nightly/2.0/Windows/). ScopeOne automatically detects the standard `C:\Program Files\Micro-Manager-2.0` installation; custom locations can be selected under Settings. Bundled adapters take precedence over external adapters. We recommend confirming that hardware works in the selected Micro-Manager installation before using it in ScopeOne. **Dual-camera Setup:** diff --git a/ScopeOneCore/external/ScopeWriter b/ScopeOneCore/external/ScopeWriter index 3c19423..e7c455c 160000 --- a/ScopeOneCore/external/ScopeWriter +++ b/ScopeOneCore/external/ScopeWriter @@ -1 +1 @@ -Subproject commit 3c19423a9194624b9bd22d2aab07fb3b4bfd5cc0 +Subproject commit e7c455c9abd6d106eef135e7692987d5fbe7b206 diff --git a/ScopeOneCore/include/scopeone/ScopeOneCore.h b/ScopeOneCore/include/scopeone/ScopeOneCore.h index fe9a39d..054ce6a 100644 --- a/ScopeOneCore/include/scopeone/ScopeOneCore.h +++ b/ScopeOneCore/include/scopeone/ScopeOneCore.h @@ -617,6 +617,8 @@ namespace scopeone::core bool configurationOperationRunning() const { return m_configurationOperationRunning; } QString loadedConfigurationPath() const { return m_loadedConfigPath; } QString loadedConfigurationSha256() const { return m_loadedConfigSha256; } + QStringList additionalDeviceAdapterSearchPaths() const; + bool setAdditionalDeviceAdapterSearchPaths(const QStringList& paths); QStringList cameraIds() const { return m_cameraIds; } QStringList runningPreviewCameraIds() const; diff --git a/ScopeOneCore/internal/MMCoreManager.h b/ScopeOneCore/internal/MMCoreManager.h index 5a70adf..9af7ee9 100644 --- a/ScopeOneCore/internal/MMCoreManager.h +++ b/ScopeOneCore/internal/MMCoreManager.h @@ -40,6 +40,14 @@ namespace scopeone::core::internal ~MMCoreManager() override = default; std::shared_ptr getCore() const { return m_mmcore; } + const QStringList& additionalDeviceAdapterSearchPaths() const + { + return m_additionalDeviceAdapterSearchPaths; + } + void setAdditionalDeviceAdapterSearchPaths(const QStringList& paths) + { + m_additionalDeviceAdapterSearchPaths = paths; + } bool loadConfigurationDevices(const QString& configPath, LoadConfigResult& result, @@ -48,5 +56,6 @@ namespace scopeone::core::internal LoadConfigResult& result); private: std::shared_ptr m_mmcore; + QStringList m_additionalDeviceAdapterSearchPaths; }; } diff --git a/ScopeOneCore/src/MMCoreManager.cpp b/ScopeOneCore/src/MMCoreManager.cpp index 6d6c017..564f317 100644 --- a/ScopeOneCore/src/MMCoreManager.cpp +++ b/ScopeOneCore/src/MMCoreManager.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -28,11 +29,25 @@ namespace scopeone::core::internal }; // Sets adapter search paths relative to the application directory - void configureAdapterSearchPaths(CMMCore& core) + void configureAdapterSearchPaths(CMMCore& core, const QStringList& additionalPaths) { const QString appDir = QCoreApplication::applicationDirPath(); + QStringList paths{appDir}; + for (const QString& path : additionalPaths) + { + const QString normalizedPath = QDir::cleanPath(path.trimmed()); + if (!normalizedPath.isEmpty() && !paths.contains(normalizedPath, Qt::CaseInsensitive)) + { + paths.append(normalizedPath); + } + } + std::vector searchPaths; - searchPaths.push_back(appDir.toStdString()); + searchPaths.reserve(static_cast(paths.size())); + for (const QString& path : paths) + { + searchPaths.push_back(path.toStdString()); + } core.setDeviceAdapterSearchPaths(searchPaths); } @@ -172,7 +187,10 @@ namespace scopeone::core::internal } // namespace // Loads one config file into MMCore - bool loadConfigurationFile(CMMCore& core, const QString& configPath, QString* errorMessage) + bool loadConfigurationFile(CMMCore& core, + const QString& configPath, + const QStringList& additionalPaths, + QString* errorMessage) { if (configPath.trimmed().isEmpty()) { @@ -185,7 +203,7 @@ namespace scopeone::core::internal try { - configureAdapterSearchPaths(core); + configureAdapterSearchPaths(core, additionalPaths); core.loadSystemConfiguration(configPath.toStdString().c_str()); return true; } @@ -297,7 +315,8 @@ namespace scopeone::core::internal QString& errorMessage) { result = LoadConfigResult{}; - if (!loadConfigurationFile(*m_mmcore, configPath, &errorMessage)) + if (!loadConfigurationFile( + *m_mmcore, configPath, m_additionalDeviceAdapterSearchPaths, &errorMessage)) { return false; } diff --git a/ScopeOneCore/src/ScopeOneCore.cpp b/ScopeOneCore/src/ScopeOneCore.cpp index 0c5164e..e5ae0de 100644 --- a/ScopeOneCore/src/ScopeOneCore.cpp +++ b/ScopeOneCore/src/ScopeOneCore.cpp @@ -764,39 +764,48 @@ namespace scopeone::core case RecordingFormat::OmeTiff: { location.format = scopewriter::Format::OmeTiff; - if (m_manifest.plan.positions.size() <= 1) - { - break; - } - const int positionIndex = selectedRecord->event.positionIndex; - if (positionIndex < 0 - || positionIndex >= static_cast(m_manifest.plan.positions.size())) - { - return {}; - } - const QFileInfo rootInfo(fileManifest.rawPath); - const QString tiffPath = QDir(fileManifest.rawPath).filePath( - QStringLiteral("%1_p%2.ome.tiff") - .arg(rootInfo.fileName()) - .arg(positionIndex, 3, 10, QChar('0'))); + const AcquisitionEvent& event = selectedRecord->event; + const std::uint64_t framesPerBurst = static_cast( + (std::max)(1, m_manifest.plan.framesPerBurst)); + const std::uint64_t time = static_cast(event.burstIndex) + * framesPerBurst + + static_cast(event.timeIndex); + const std::uint64_t timeCount = framesPerBurst + * static_cast(m_manifest.plan.burstMode + ? (std::max)(1, m_manifest.plan.targetBursts) + : 1); + const std::uint64_t z = static_cast(event.zIndex); + const std::uint64_t zCount = (std::max)( + std::uint64_t{1}, static_cast(m_manifest.plan.zPositions.size())); + + const auto timeAxis = std::find(m_manifest.plan.order.begin(), + m_manifest.plan.order.end(), + RecordingAxis::Time); + const auto zAxis = std::find(m_manifest.plan.order.begin(), + m_manifest.plan.order.end(), + RecordingAxis::Z); + location.frameIndex = zAxis < timeAxis + ? z * timeCount + time + : time * zCount + z; + + if (m_manifest.plan.positions.size() > 1) + { + const int positionIndex = event.positionIndex; + if (positionIndex < 0 + || positionIndex >= static_cast(m_manifest.plan.positions.size())) + { + return {}; + } + const QFileInfo rootInfo(fileManifest.rawPath); + const QString tiffPath = QDir(fileManifest.rawPath).filePath( + QStringLiteral("%1_p%2.ome.tiff") + .arg(rootInfo.fileName()) + .arg(positionIndex, 3, 10, QChar('0'))); #if defined(_WIN32) - location.dataPath = std::filesystem::path(tiffPath.toStdWString()); + location.dataPath = std::filesystem::path(tiffPath.toStdWString()); #else - location.dataPath = std::filesystem::path(tiffPath.toStdString()); + location.dataPath = std::filesystem::path(tiffPath.toStdString()); #endif - location.frameIndex = 0; - for (const AcquisitionEventRecord& record : m_manifest.events) - { - if (&record == selectedRecord) - { - break; - } - if (record.succeeded - && record.event.positionIndex == positionIndex - && record.frames.contains(cameraId)) - { - ++location.frameIndex; - } } break; } @@ -1209,6 +1218,38 @@ namespace scopeone::core return true; } + // Return external device adapter directories searched after the application directory + QStringList ScopeOneCore::additionalDeviceAdapterSearchPaths() const + { + return m_managers->mmcoreManager->additionalDeviceAdapterSearchPaths(); + } + + // Set external device adapter directories for subsequent configuration loads + bool ScopeOneCore::setAdditionalDeviceAdapterSearchPaths(const QStringList& paths) + { + if (m_configurationOperationRunning) + { + return false; + } + + QStringList normalizedPaths; + for (const QString& path : paths) + { + const QFileInfo directory(path.trimmed()); + if (!directory.isDir()) + { + return false; + } + const QString normalizedPath = QDir::cleanPath(directory.absoluteFilePath()); + if (!normalizedPaths.contains(normalizedPath, Qt::CaseInsensitive)) + { + normalizedPaths.append(normalizedPath); + } + } + m_managers->mmcoreManager->setAdditionalDeviceAdapterSearchPaths(normalizedPaths); + return true; + } + // Applies a completed device load to the frame graph and public state void ScopeOneCore::applyLoadedConfiguration(const QString& configPath, const LoadConfigResult& result) diff --git a/scripts/build-unix.sh b/scripts/build-unix.sh index a7d62af..7d06db8 100755 --- a/scripts/build-unix.sh +++ b/scripts/build-unix.sh @@ -50,6 +50,9 @@ for command_name in git cmake make; do require_command "$command_name" done +step "Initializing ScopeOne submodules" +run git -C "$ROOT_DIR" submodule update --init --recursive + step "Preparing Micro-Manager checkout for $PLATFORM_NAME" run mkdir -p "$EXTERNAL_DIR" diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index ac28e40..965b421 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -121,6 +121,26 @@ namespace scopeone::ui qBound(1, frameCount, static_cast((std::numeric_limits::max)()))); } + // Detect the standard 64 bit Micro-Manager installation + QString detectedMicroManagerDirectory() + { +#ifdef Q_OS_WIN + const QString programFiles = qEnvironmentVariable("ProgramFiles"); + if (programFiles.isEmpty()) + { + return {}; + } + const QString directoryPath = QDir(programFiles).filePath(QStringLiteral("Micro-Manager-2.0")); + const QDir directory(directoryPath); + if (directory.exists() + && !directory.entryList({QStringLiteral("mmgr_dal_*.dll")}, QDir::Files).isEmpty()) + { + return QDir::cleanPath(directory.absolutePath()); + } +#endif + return {}; + } + // Remove graph layers that belong to one gallery session void removeGallerySessionPreview( scopeone::core::ScopeOneCore& core, @@ -1138,6 +1158,17 @@ namespace scopeone::ui QStringLiteral("Recording/MaxPendingWriteBytes"), kDefaultRecordedMaxBytes) .toLongLong(); m_scopeonecore->setRecordingMaxPendingWriteBytes(recordedMaxBytes); + + const QString adapterDirectoryKey = QStringLiteral("Hardware/MicroManagerDirectory"); + const QString adapterDirectory = settings.contains(adapterDirectoryKey) + ? settings.value(adapterDirectoryKey).toString().trimmed() + : detectedMicroManagerDirectory(); + if (!m_scopeonecore->setAdditionalDeviceAdapterSearchPaths( + adapterDirectory.isEmpty() ? QStringList{} : QStringList{adapterDirectory})) + { + qWarning().noquote() << QStringLiteral("Ignoring invalid Micro-Manager directory: %1") + .arg(adapterDirectory); + } } // Record the application startup state in one place @@ -1154,6 +1185,13 @@ namespace scopeone::ui m_consoleWidget->addMessage( QStringLiteral("Application directory: %1").arg(QCoreApplication::applicationDirPath()), QStringLiteral("SYSTEM")); + const QStringList adapterPaths = m_scopeonecore->additionalDeviceAdapterSearchPaths(); + if (!adapterPaths.isEmpty()) + { + m_consoleWidget->addMessage( + QStringLiteral("External device adapters: %1").arg(adapterPaths.join(QStringLiteral("; "))), + QStringLiteral("SYSTEM")); + } showStatusMessage(tr("ScopeOne ready"), 3000); } @@ -1421,18 +1459,31 @@ namespace scopeone::ui constexpr qint64 kDefaultRecordedMaxBytes = 16ll * 1024 * 1024 * 1024; const qint64 currentValue = m_scopeonecore->recordingMaxPendingWriteBytes(); - SettingsDialog dialog(currentValue > 0 ? currentValue : kDefaultRecordedMaxBytes, this); + const QStringList adapterPaths = m_scopeonecore->additionalDeviceAdapterSearchPaths(); + SettingsDialog dialog(currentValue > 0 ? currentValue : kDefaultRecordedMaxBytes, + adapterPaths.value(0), + this); if (dialog.exec() != QDialog::Accepted) { return; } const qint64 recordedMaxBytes = dialog.maxPendingWriteBytes(); + const QString microManagerDirectory = dialog.microManagerDirectory(); + if (!m_scopeonecore->setAdditionalDeviceAdapterSearchPaths( + microManagerDirectory.isEmpty() ? QStringList{} : QStringList{microManagerDirectory})) + { + QMessageBox::warning(this, + tr("Settings"), + tr("The device adapter directory could not be updated.")); + return; + } QSettings settings(QStringLiteral("ScopeOne"), QStringLiteral("ScopeOne")); settings.setValue(QStringLiteral("Recording/MaxPendingWriteBytes"), recordedMaxBytes); + settings.setValue(QStringLiteral("Hardware/MicroManagerDirectory"), microManagerDirectory); m_scopeonecore->setRecordingMaxPendingWriteBytes(recordedMaxBytes); showStatusMessage( - tr("Recording buffer limit updated to %1 bytes").arg(recordedMaxBytes), + tr("Settings updated"), 5000); } diff --git a/src/SettingsDialog.cpp b/src/SettingsDialog.cpp index aa92196..d3d9c6d 100644 --- a/src/SettingsDialog.cpp +++ b/src/SettingsDialog.cpp @@ -2,10 +2,15 @@ #include #include +#include +#include #include #include #include #include +#include +#include +#include #include namespace @@ -16,7 +21,9 @@ namespace namespace scopeone::ui { // Create the settings dialog for recording limits - SettingsDialog::SettingsDialog(qint64 maxPendingWriteBytes, QWidget* parent) + SettingsDialog::SettingsDialog(qint64 maxPendingWriteBytes, + const QString& microManagerDirectory, + QWidget* parent) : QDialog(parent) { setWindowTitle(QStringLiteral("Settings")); @@ -42,10 +49,60 @@ namespace scopeone::ui bufferLimitLayout->addWidget(new QLabel(QStringLiteral("GiB"), bufferLimitRow)); bufferLimitLayout->addStretch(); formLayout->addRow(QStringLiteral("Recording Buffer Limit"), bufferLimitRow); + + auto* microManagerRow = new QWidget(this); + auto* microManagerLayout = new QHBoxLayout(microManagerRow); + microManagerLayout->setContentsMargins(0, 0, 0, 0); + microManagerLayout->setSpacing(6); + m_microManagerDirectoryEdit = new QLineEdit(microManagerDirectory, microManagerRow); + m_microManagerDirectoryEdit->setPlaceholderText(QStringLiteral("Bundled adapters only")); + microManagerLayout->addWidget(m_microManagerDirectoryEdit, 1); + auto* browseButton = new QToolButton(microManagerRow); + browseButton->setIcon(style()->standardIcon(QStyle::SP_DirOpenIcon)); + browseButton->setToolTip(QStringLiteral("Select Micro-Manager directory")); + browseButton->setAutoRaise(true); + microManagerLayout->addWidget(browseButton); + formLayout->addRow(QStringLiteral("Micro-Manager Directory"), microManagerRow); + + connect(browseButton, &QToolButton::clicked, + this, [this]() + { + const QString directory = QFileDialog::getExistingDirectory( + this, + QStringLiteral("Select Micro-Manager Directory"), + m_microManagerDirectoryEdit->text().trimmed()); + if (!directory.isEmpty()) + { + m_microManagerDirectoryEdit->setText(QDir::toNativeSeparators(directory)); + } + }); layout->addLayout(formLayout); auto* buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); - connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::accepted, + this, [this]() + { + const QString directoryPath = this->microManagerDirectory(); + if (!directoryPath.isEmpty()) + { + QDir directory(directoryPath); +#ifdef Q_OS_WIN + const QStringList adapterFilters{QStringLiteral("mmgr_dal_*.dll")}; +#else + const QStringList adapterFilters{QStringLiteral("libmmgr_dal_*")}; +#endif + if (!directory.exists() + || directory.entryList(adapterFilters, QDir::Files).isEmpty()) + { + QMessageBox::warning( + this, + QStringLiteral("Invalid Micro-Manager Directory"), + QStringLiteral("Select a directory containing Micro-Manager device adapters.")); + return; + } + } + accept(); + }); connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); layout->addWidget(buttonBox); } @@ -55,4 +112,11 @@ namespace scopeone::ui { return static_cast(m_recordingBufferLimitEdit->text().toDouble() * kBytesPerGiB); } + + // Return the optional external adapter directory + QString SettingsDialog::microManagerDirectory() const + { + const QString directory = m_microManagerDirectoryEdit->text().trimmed(); + return directory.isEmpty() ? QString() : QDir::cleanPath(directory); + } } // namespace scopeone::ui diff --git a/src/SettingsDialog.h b/src/SettingsDialog.h index f6d3618..31b6519 100644 --- a/src/SettingsDialog.h +++ b/src/SettingsDialog.h @@ -1,6 +1,7 @@ #pragma once #include +#include class QLineEdit; @@ -11,11 +12,15 @@ namespace scopeone::ui Q_OBJECT public: - explicit SettingsDialog(qint64 maxPendingWriteBytes, QWidget* parent = nullptr); + explicit SettingsDialog(qint64 maxPendingWriteBytes, + const QString& microManagerDirectory, + QWidget* parent = nullptr); qint64 maxPendingWriteBytes() const; + QString microManagerDirectory() const; private: QLineEdit* m_recordingBufferLimitEdit{nullptr}; + QLineEdit* m_microManagerDirectoryEdit{nullptr}; }; } From 4a3116f2596fd47f52b84bd2117be6d3be0ae16d Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Thu, 30 Jul 2026 23:34:02 +0200 Subject: [PATCH 6/7] Update ScopeWriter submodule --- ScopeOneCore/external/ScopeWriter | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ScopeOneCore/external/ScopeWriter b/ScopeOneCore/external/ScopeWriter index e7c455c..e62610e 160000 --- a/ScopeOneCore/external/ScopeWriter +++ b/ScopeOneCore/external/ScopeWriter @@ -1 +1 @@ -Subproject commit e7c455c9abd6d106eef135e7692987d5fbe7b206 +Subproject commit e62610e409fce962a24b2cea222b57bf68516c88 From 6077c92289bdf001bc495f6b60c3dda56d57ca10 Mon Sep 17 00:00:00 2001 From: tz <185176969+tzhaoo@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:06:10 +0200 Subject: [PATCH 7/7] Update ScopeWriter submodule --- ScopeOneCore/external/ScopeWriter | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ScopeOneCore/external/ScopeWriter b/ScopeOneCore/external/ScopeWriter index e62610e..4f9b013 160000 --- a/ScopeOneCore/external/ScopeWriter +++ b/ScopeOneCore/external/ScopeWriter @@ -1 +1 @@ -Subproject commit e62610e409fce962a24b2cea222b57bf68516c88 +Subproject commit 4f9b013699d97619d11650b9e596a8acc532a2c2