diff --git a/CMakeLists.txt b/CMakeLists.txt index 8841931bfe..c2696a9753 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -189,6 +189,12 @@ find_package(boost_mp11 CONFIG REQUIRED) find_package(nod CONFIG REQUIRED) find_package(spdlog CONFIG REQUIRED) +#------------------------------------------------------------------------------ +# Find the Image IO libraries +#------------------------------------------------------------------------------ +find_package(Stb REQUIRED) +find_package(TIFF REQUIRED) + # ----------------------------------------------------------------------- # Find HDF5 and get the path to the DLL libraries and put that into a # global property for later install, debugging and packaging @@ -266,6 +272,7 @@ target_link_libraries(simplnx Boost::mp11 nod::nod spdlog::spdlog + TIFF::TIFF ) if(UNIX) @@ -573,7 +580,13 @@ set(SIMPLNX_HDRS ${SIMPLNX_SOURCE_DIR}/Utilities/MontageUtilities.hpp ${SIMPLNX_SOURCE_DIR}/Utilities/SIMPLConversion.hpp ${SIMPLNX_SOURCE_DIR}/Utilities/IntersectionUtilities.hpp - ${SIMPLNX_SOURCE_DIR}/Utilities/NeighborUtilities.hpp + ${SIMPLNX_SOURCE_DIR}/Utilities/NeighborUtilities.hpp + + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/IImageIO.hpp + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/ImageMetadata.hpp + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/ImageIOFactory.hpp + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/StbImageIO.hpp + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/TiffImageIO.hpp ${SIMPLNX_SOURCE_DIR}/Utilities/Math/GeometryMath.hpp # ${SIMPLNX_SOURCE_DIR}/Utilities/Math/MatrixMath.hpp @@ -791,6 +804,10 @@ set(SIMPLNX_SRCS ${SIMPLNX_SOURCE_DIR}/Utilities/Parsing/Text/CsvParser.cpp ${SIMPLNX_SOURCE_DIR}/Utilities/MD5.cpp + + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/ImageIOFactory.cpp + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/StbImageIO.cpp + ${SIMPLNX_SOURCE_DIR}/Utilities/ImageIO/TiffImageIO.cpp ) # Add Core FilterParameters diff --git a/cmake/Summary.cmake b/cmake/Summary.cmake index 47eb8ba4cc..1bae8198a4 100644 --- a/cmake/Summary.cmake +++ b/cmake/Summary.cmake @@ -58,6 +58,7 @@ message(STATUS "* span-lite (${span-lite_VERSION}) ${span-lite_DIR}") message(STATUS "* boost_mp11 (${boost_mp11_VERSION}) ${boost_mp11_DIR}") message(STATUS "* nod (${nod_VERSION}) ${nod_DIR}") message(STATUS "* reproc++ (${reproc_VERSION}) ${reproc++_DIR}") +message(STATUS "* stb (${stb_VERSION}) ${stb_DIR}") if(SIMPLNX_USE_LOCAL_EBSD_LIB) message(STATUS "* EbsdLib: Local repository being used") else() diff --git a/docs/superpowers/plans/2026-04-07-arraycalculator-memory-optimization.md b/docs/superpowers/plans/2026-04-07-arraycalculator-memory-optimization.md deleted file mode 100644 index a8db154391..0000000000 --- a/docs/superpowers/plans/2026-04-07-arraycalculator-memory-optimization.md +++ /dev/null @@ -1,1250 +0,0 @@ -# ArrayCalculator Memory Optimization Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Eliminate unnecessary memory allocations in the ArrayCalculator evaluator by introducing a CalcBuffer RAII sentinel class, making the parser data-free, and enabling direct-write to the output array. - -**Architecture:** CalcBuffer wraps Float64Array references (borrowed or owned) with automatic cleanup via DataStructure::removeData(). The parser produces data-free RPN items (DataPath + metadata, no DataObject allocation). The evaluator creates a local DataStructure for temp arrays, uses a CalcBuffer stack where intermediates are freed via RAII when consumed, and the last operator writes directly into the output DataArray when the output type is float64. - -**Tech Stack:** C++20, simplnx DataStructure/DataArray/DataStore, Catch2 tests - -**Design spec:** `docs/superpowers/specs/2026-04-07-arraycalculator-memory-optimization-design.md` - ---- - -### Task 1: Establish Baseline — Verify All Existing Tests Pass - -**Files:** -- Read: `src/Plugins/SimplnxCore/test/ArrayCalculatorTest.cpp` - -This task ensures we have a clean starting point before making changes. - -- [ ] **Step 1: Build the project** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && cmake --build . --target SimplnxCore -``` - -Expected: Build succeeds with no errors. - -- [ ] **Step 2: Run existing ArrayCalculator tests** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && ctest -R "SimplnxCore::ArrayCalculatorFilter" --verbose -``` - -Expected: All test cases pass (Filter Execution, Tokenizer, Array Resolution, Built-in Constants, Modulo Operator, Tuple Component Indexing, Sub-expression Component Access, Multi-word Array Names, Sub-expression Tuple Component Extraction). - ---- - -### Task 2: Add CalcBuffer Class Declaration to Header - -**Files:** -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp` - -Add the CalcBuffer class declaration. This task adds new code only — nothing is removed or changed yet. - -- [ ] **Step 1: Add CalcBuffer class declaration after the OperatorDef struct (after line 133)** - -Insert the following class declaration between the `OperatorDef` struct and the `CalcValue` struct (before line 138): - -```cpp -// --------------------------------------------------------------------------- -// RAII sentinel for temporary Float64Arrays in the evaluator. -// Move-only. When an Owned CalcBuffer is destroyed, it removes its -// DataArray from the scratch DataStructure via removeData(). -// --------------------------------------------------------------------------- -class SIMPLNXCORE_EXPORT CalcBuffer -{ -public: - // --- Factory methods --- - - /** - * @brief Zero-copy reference to an existing Float64Array in the real DataStructure. - * Read-only. Destructor: no-op. - */ - static CalcBuffer borrow(const Float64Array& source); - - /** - * @brief Allocate a temp Float64Array in tempDS and convert source data from any numeric type. - * Owned. Destructor: removes the temp array from tempDS. - */ - static CalcBuffer convertFrom(DataStructure& tempDS, const IDataArray& source, const std::string& name); - - /** - * @brief Allocate a 1-element temp Float64Array with the given scalar value. - * Owned. Destructor: removes the temp array from tempDS. - */ - static CalcBuffer scalar(DataStructure& tempDS, float64 value, const std::string& name); - - /** - * @brief Allocate an empty temp Float64Array with the given shape. - * Owned. Destructor: removes the temp array from tempDS. - */ - static CalcBuffer allocate(DataStructure& tempDS, const std::string& name, std::vector tupleShape, std::vector compShape); - - /** - * @brief Wrap the output DataArray for direct writing. - * Not owned. Destructor: no-op. - */ - static CalcBuffer wrapOutput(DataArray& outputArray); - - // --- Move-only, non-copyable --- - CalcBuffer(CalcBuffer&& other) noexcept; - CalcBuffer& operator=(CalcBuffer&& other) noexcept; - ~CalcBuffer(); - - CalcBuffer(const CalcBuffer&) = delete; - CalcBuffer& operator=(const CalcBuffer&) = delete; - - // --- Element access --- - float64 read(usize index) const; - void write(usize index, float64 value); - void fill(float64 value); - - // --- Metadata --- - usize size() const; - usize numTuples() const; - usize numComponents() const; - std::vector tupleShape() const; - std::vector compShape() const; - bool isScalar() const; - bool isOwned() const; - bool isOutputDirect() const; - void markAsScalar(); - - // --- Access underlying array (for final copy to non-float64 output) --- - const Float64Array& array() const; - -private: - CalcBuffer() = default; - - enum class Storage - { - Borrowed, - Owned, - OutputDirect - }; - - Storage m_Storage = Storage::Owned; - - // Borrowed: const pointer to source Float64Array in real DataStructure - const Float64Array* m_BorrowedArray = nullptr; - - // Owned: pointer to temp Float64Array + reference to its DataStructure for cleanup - DataStructure* m_TempDS = nullptr; - DataObject::IdType m_ArrayId = 0; - Float64Array* m_OwnedArray = nullptr; - - // OutputDirect: writable pointer to output DataArray - DataArray* m_OutputArray = nullptr; - - bool m_IsScalar = false; -}; -``` - -- [ ] **Step 2: Build to verify the header compiles** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && cmake --build . --target SimplnxCore -``` - -Expected: Build succeeds. CalcBuffer is declared but not yet defined — the linker won't complain because nothing references it yet. - -- [ ] **Step 3: Commit** - -```bash -git add src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp -git commit -m "ENH: Add CalcBuffer RAII sentinel class declaration to ArrayCalculator.hpp" -``` - ---- - -### Task 3: Implement CalcBuffer in ArrayCalculator.cpp - -**Files:** -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp` - -Add the full CalcBuffer implementation. Place it after the anonymous namespace closing brace (after line 250) and before the `getOperatorRegistry()` function (line 255). This keeps it near the top of the file with other utility code. - -- [ ] **Step 1: Add CalcBuffer implementation** - -Insert the following after the anonymous namespace (after line 250, before `getOperatorRegistry()`): - -```cpp -// =========================================================================== -// CalcBuffer implementation -// =========================================================================== - -CalcBuffer::CalcBuffer(CalcBuffer&& other) noexcept -: m_Storage(other.m_Storage) -, m_BorrowedArray(other.m_BorrowedArray) -, m_TempDS(other.m_TempDS) -, m_ArrayId(other.m_ArrayId) -, m_OwnedArray(other.m_OwnedArray) -, m_OutputArray(other.m_OutputArray) -, m_IsScalar(other.m_IsScalar) -{ - other.m_TempDS = nullptr; - other.m_BorrowedArray = nullptr; - other.m_OwnedArray = nullptr; - other.m_OutputArray = nullptr; -} - -CalcBuffer& CalcBuffer::operator=(CalcBuffer&& other) noexcept -{ - if(this != &other) - { - // Clean up current state - if(m_Storage == Storage::Owned && m_TempDS != nullptr) - { - m_TempDS->removeData(m_ArrayId); - } - - m_Storage = other.m_Storage; - m_BorrowedArray = other.m_BorrowedArray; - m_TempDS = other.m_TempDS; - m_ArrayId = other.m_ArrayId; - m_OwnedArray = other.m_OwnedArray; - m_OutputArray = other.m_OutputArray; - m_IsScalar = other.m_IsScalar; - - other.m_TempDS = nullptr; - other.m_BorrowedArray = nullptr; - other.m_OwnedArray = nullptr; - other.m_OutputArray = nullptr; - } - return *this; -} - -CalcBuffer::~CalcBuffer() -{ - if(m_Storage == Storage::Owned && m_TempDS != nullptr) - { - m_TempDS->removeData(m_ArrayId); - } -} - -CalcBuffer CalcBuffer::borrow(const Float64Array& source) -{ - CalcBuffer buf; - buf.m_Storage = Storage::Borrowed; - buf.m_BorrowedArray = &source; - buf.m_IsScalar = false; - return buf; -} - -CalcBuffer CalcBuffer::convertFrom(DataStructure& tempDS, const IDataArray& source, const std::string& name) -{ - std::vector tupleShape = source.getTupleShape(); - std::vector compShape = source.getComponentShape(); - Float64Array* destArr = Float64Array::CreateWithStore(tempDS, name, tupleShape, compShape); - - usize totalElements = source.getSize(); - ExecuteDataFunction(CopyToFloat64Functor{}, source.getDataType(), source, *destArr); - - CalcBuffer buf; - buf.m_Storage = Storage::Owned; - buf.m_TempDS = &tempDS; - buf.m_ArrayId = destArr->getId(); - buf.m_OwnedArray = destArr; - buf.m_IsScalar = false; - return buf; -} - -CalcBuffer CalcBuffer::scalar(DataStructure& tempDS, float64 value, const std::string& name) -{ - Float64Array* arr = Float64Array::CreateWithStore(tempDS, name, std::vector{1}, std::vector{1}); - (*arr)[0] = value; - - CalcBuffer buf; - buf.m_Storage = Storage::Owned; - buf.m_TempDS = &tempDS; - buf.m_ArrayId = arr->getId(); - buf.m_OwnedArray = arr; - buf.m_IsScalar = true; - return buf; -} - -CalcBuffer CalcBuffer::allocate(DataStructure& tempDS, const std::string& name, std::vector tupleShape, std::vector compShape) -{ - Float64Array* arr = Float64Array::CreateWithStore(tempDS, name, tupleShape, compShape); - - CalcBuffer buf; - buf.m_Storage = Storage::Owned; - buf.m_TempDS = &tempDS; - buf.m_ArrayId = arr->getId(); - buf.m_OwnedArray = arr; - buf.m_IsScalar = false; - return buf; -} - -CalcBuffer CalcBuffer::wrapOutput(DataArray& outputArray) -{ - CalcBuffer buf; - buf.m_Storage = Storage::OutputDirect; - buf.m_OutputArray = &outputArray; - buf.m_IsScalar = false; - return buf; -} - -float64 CalcBuffer::read(usize index) const -{ - switch(m_Storage) - { - case Storage::Borrowed: - return m_BorrowedArray->at(index); - case Storage::Owned: - return m_OwnedArray->at(index); - case Storage::OutputDirect: - return m_OutputArray->at(index); - } - return 0.0; -} - -void CalcBuffer::write(usize index, float64 value) -{ - switch(m_Storage) - { - case Storage::Owned: - (*m_OwnedArray)[index] = value; - return; - case Storage::OutputDirect: - (*m_OutputArray)[index] = value; - return; - case Storage::Borrowed: - return; // read-only — should not be called - } -} - -void CalcBuffer::fill(float64 value) -{ - switch(m_Storage) - { - case Storage::Owned: - m_OwnedArray->fill(value); - return; - case Storage::OutputDirect: - m_OutputArray->fill(value); - return; - case Storage::Borrowed: - return; // read-only - } -} - -usize CalcBuffer::size() const -{ - switch(m_Storage) - { - case Storage::Borrowed: - return m_BorrowedArray->getSize(); - case Storage::Owned: - return m_OwnedArray->getSize(); - case Storage::OutputDirect: - return m_OutputArray->getSize(); - } - return 0; -} - -usize CalcBuffer::numTuples() const -{ - switch(m_Storage) - { - case Storage::Borrowed: - return m_BorrowedArray->getNumberOfTuples(); - case Storage::Owned: - return m_OwnedArray->getNumberOfTuples(); - case Storage::OutputDirect: - return m_OutputArray->getNumberOfTuples(); - } - return 0; -} - -usize CalcBuffer::numComponents() const -{ - switch(m_Storage) - { - case Storage::Borrowed: - return m_BorrowedArray->getNumberOfComponents(); - case Storage::Owned: - return m_OwnedArray->getNumberOfComponents(); - case Storage::OutputDirect: - return m_OutputArray->getNumberOfComponents(); - } - return 0; -} - -std::vector CalcBuffer::tupleShape() const -{ - switch(m_Storage) - { - case Storage::Borrowed: - return m_BorrowedArray->getTupleShape(); - case Storage::Owned: - return m_OwnedArray->getTupleShape(); - case Storage::OutputDirect: - return m_OutputArray->getTupleShape(); - } - return {}; -} - -std::vector CalcBuffer::compShape() const -{ - switch(m_Storage) - { - case Storage::Borrowed: - return m_BorrowedArray->getComponentShape(); - case Storage::Owned: - return m_OwnedArray->getComponentShape(); - case Storage::OutputDirect: - return m_OutputArray->getComponentShape(); - } - return {}; -} - -bool CalcBuffer::isScalar() const -{ - return m_IsScalar; -} - -bool CalcBuffer::isOwned() const -{ - return m_Storage == Storage::Owned; -} - -bool CalcBuffer::isOutputDirect() const -{ - return m_Storage == Storage::OutputDirect; -} - -void CalcBuffer::markAsScalar() -{ - m_IsScalar = true; -} - -const Float64Array& CalcBuffer::array() const -{ - switch(m_Storage) - { - case Storage::Borrowed: - return *m_BorrowedArray; - case Storage::Owned: - return *m_OwnedArray; - case Storage::OutputDirect: - return *m_OutputArray; - } - // Should never reach here; return owned as fallback - return *m_OwnedArray; -} -``` - -- [ ] **Step 2: Build to verify CalcBuffer compiles** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && cmake --build . --target SimplnxCore -``` - -Expected: Build succeeds. CalcBuffer is implemented but not yet used. - -- [ ] **Step 3: Run existing tests to verify no regressions** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && ctest -R "SimplnxCore::ArrayCalculatorFilter" --verbose -``` - -Expected: All tests pass (no change in behavior). - -- [ ] **Step 4: Commit** - -```bash -git add src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp -git commit -m "ENH: Implement CalcBuffer RAII sentinel for ArrayCalculator temp arrays" -``` - ---- - -### Task 4: Make ParsedItem and RpnItem Data-Free - -**Files:** -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp` (RpnItem) -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp` (ParsedItem) - -Replace CalcValue-based data in ParsedItem and RpnItem with metadata-only fields. This task changes the data structures but does not yet change the parser or evaluator logic — those come next. - -- [ ] **Step 1: Update RpnItem in ArrayCalculator.hpp** - -Replace the current `RpnItem` struct (lines 152-166) with the data-free version. Also delete the `CalcValue` struct (lines 138-147): - -Delete `CalcValue`: -```cpp -// DELETE the entire CalcValue struct (lines 138-147): -// struct SIMPLNXCORE_EXPORT CalcValue { ... }; -``` - -Replace `RpnItem`: -```cpp -// --------------------------------------------------------------------------- -// A single item in the RPN (reverse-polish notation) evaluation sequence. -// Data-free: stores DataPath references and scalar values, not DataObject IDs. -// --------------------------------------------------------------------------- -struct SIMPLNXCORE_EXPORT RpnItem -{ - enum class Type - { - Scalar, - ArrayRef, - Operator, - ComponentExtract, - TupleComponentExtract - } type; - - // Scalar - float64 scalarValue = 0.0; - - // ArrayRef - DataPath arrayPath; - DataType sourceDataType = DataType::float64; - - // Operator - const OperatorDef* op = nullptr; - - // ComponentExtract / TupleComponentExtract - usize componentIndex = std::numeric_limits::max(); - usize tupleIndex = std::numeric_limits::max(); -}; -``` - -- [ ] **Step 2: Update ParsedItem in the anonymous namespace of ArrayCalculator.cpp** - -Replace the current `ParsedItem` struct (lines 26-44) with the data-free version: - -```cpp -struct ParsedItem -{ - enum class Kind - { - Scalar, - ArrayRef, - Operator, - LParen, - RParen, - Comma, - ComponentExtract, - TupleComponentExtract - } kind; - - // Scalar - float64 scalarValue = 0.0; - - // ArrayRef: metadata for validation (no data allocated) - DataPath arrayPath; - DataType sourceDataType = DataType::float64; - std::vector arrayTupleShape; - std::vector arrayCompShape; - - // Operator - const OperatorDef* op = nullptr; - bool isNegativePrefix = false; - - // ComponentExtract / TupleComponentExtract - usize componentIndex = std::numeric_limits::max(); - usize tupleIndex = std::numeric_limits::max(); -}; -``` - -- [ ] **Step 3: Update isBinaryOp helper function** - -The `isBinaryOp` function (around line 139-142) references `ParsedItem::Kind::Operator` which stays the same. No change needed to this function. - -- [ ] **Step 4: Note — do NOT build yet** - -The parser and evaluator still reference the old CalcValue and old ParsedItem/RpnItem fields. They must be updated in Tasks 5 and 6 before the code compiles. Proceed directly to Task 5. - ---- - -### Task 5: Rewrite Parser to Be Data-Free - -**Files:** -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp` (remove parser members) -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp` (rewrite parse()) - -This is the largest task. The parser's `parse()` method is rewritten so it allocates zero data — it only produces RPN items with DataPath/scalar metadata. The helper methods `createScalarInTemp()`, `copyArrayToTemp()`, and `nextScratchName()` are removed from the parser class. The `m_TempDataStructure`, `m_IsPreflight`, and `m_ScratchCounter` members are removed. The `getTempDataStructure()` method is removed. - -- [ ] **Step 1: Remove parser-only members from ArrayCalculatorParser in the header** - -In `ArrayCalculator.hpp`, remove the following from the `ArrayCalculatorParser` class: - -Remove these private method declarations: -- `std::string nextScratchName();` (line 234) -- `DataObject::IdType copyArrayToTemp(const IDataArray& sourceArray);` (line 242) -- `DataObject::IdType createScalarInTemp(double value);` (line 249) - -Remove these private member variables: -- `DataStructure m_TempDataStructure;` (line 252) -- `bool m_IsPreflight;` (line 255) -- `usize m_ScratchCounter = 0;` (line 256) - -Remove this public method: -- `DataStructure& getTempDataStructure() { ... }` (lines 217-221) - -Update the constructor signature — remove `bool isPreflight` parameter: -```cpp -ArrayCalculatorParser(const DataStructure& dataStructure, const DataPath& selectedGroupPath, const std::string& infixEquation, const std::atomic_bool& shouldCancel); -``` - -- [ ] **Step 2: Update ArrayCalculatorParser constructor implementation in .cpp** - -Replace the constructor (lines 305-312) with: - -```cpp -ArrayCalculatorParser::ArrayCalculatorParser(const DataStructure& dataStructure, const DataPath& selectedGroupPath, const std::string& infixEquation, const std::atomic_bool& shouldCancel) -: m_DataStructure(dataStructure) -, m_SelectedGroupPath(selectedGroupPath) -, m_InfixEquation(infixEquation) -, m_ShouldCancel(shouldCancel) -{ -} -``` - -- [ ] **Step 3: Delete the old helper method implementations from .cpp** - -Delete the following function bodies from ArrayCalculator.cpp: -- `ArrayCalculatorParser::nextScratchName()` (lines 443-446) -- `ArrayCalculatorParser::createScalarInTemp()` (lines 449-454) -- `ArrayCalculatorParser::copyArrayToTemp()` (lines 457-476) - -- [ ] **Step 4: Rewrite parse() — token resolution (steps 3+4)** - -This is the core change. In the token resolution loop (starting around line 595), every place that previously called `createScalarInTemp()` or `copyArrayToTemp()` must instead store metadata in the ParsedItem. - -**For numeric literals** (the `TokenType::Number` case, around line 773): -Replace: -```cpp -DataObject::IdType id = createScalarInTemp(numValue); -ParsedItem pi; -pi.kind = ParsedItem::Kind::Value; -pi.value = CalcValue{CalcValue::Kind::Number, id}; -``` -With: -```cpp -ParsedItem pi; -pi.kind = ParsedItem::Kind::Scalar; -pi.scalarValue = numValue; -``` - -**For constants `pi` and `e`** (around line 841): -Replace: -```cpp -double constValue = (tok.text == "pi") ? std::numbers::pi : std::numbers::e; -DataObject::IdType id = createScalarInTemp(constValue); -ParsedItem pi; -pi.kind = ParsedItem::Kind::Value; -pi.value = CalcValue{CalcValue::Kind::Number, id}; -``` -With: -```cpp -float64 constValue = (tok.text == "pi") ? std::numbers::pi : std::numbers::e; -ParsedItem pi; -pi.kind = ParsedItem::Kind::Scalar; -pi.scalarValue = constValue; -``` - -**For array references found via selected group** (around line 876): -Replace: -```cpp -DataObject::IdType id = copyArrayToTemp(*dataArray); -ParsedItem pi; -pi.kind = ParsedItem::Kind::Value; -pi.value = CalcValue{CalcValue::Kind::Array, id}; -``` -With: -```cpp -ParsedItem pi; -pi.kind = ParsedItem::Kind::ArrayRef; -pi.arrayPath = arrayPath; -pi.sourceDataType = dataArray->getDataType(); -pi.arrayTupleShape = dataArray->getTupleShape(); -pi.arrayCompShape = dataArray->getComponentShape(); -``` - -Apply the same pattern to **all other array resolution sites**: -- Array found via `findArraysByName()` (around line 916): same change — store DataPath, DataType, shapes -- Quoted string path resolution (around line 969): same change - -**For bracket indexing `Array[C]`** (the block starting around line 635): - -Currently this block accesses `m_TempDataStructure` to get the temp array and extract component data. Replace the entire bracket handling block. When `prevItem.kind == ParsedItem::Kind::ArrayRef`: - -For `[C]` (single bracket number): -```cpp -usize numComponents = 1; -for(usize d : prevItem.arrayCompShape) -{ - numComponents *= d; -} - -// Validate component index -if(compIdx >= numComponents) -{ - return MakeErrorResult(static_cast(CalculatorErrorCode::ComponentOutOfRange), - fmt::format("Component index {} is out of range for array with {} components.", compIdx, numComponents)); -} - -// Emit a ComponentExtract after the ArrayRef -ParsedItem ce; -ce.kind = ParsedItem::Kind::ComponentExtract; -ce.componentIndex = compIdx; -items.push_back(ce); -``` - -For `[T, C]` (two bracket numbers): -```cpp -usize numTuples = 1; -for(usize d : prevItem.arrayTupleShape) -{ - numTuples *= d; -} -usize numComponents = 1; -for(usize d : prevItem.arrayCompShape) -{ - numComponents *= d; -} - -if(tupleIdx >= numTuples) -{ - return MakeErrorResult(static_cast(CalculatorErrorCode::TupleOutOfRange), - fmt::format("Tuple index {} is out of range for array with {} tuples.", tupleIdx, numTuples)); -} -if(compIdx >= numComponents) -{ - return MakeErrorResult(static_cast(CalculatorErrorCode::ComponentOutOfRange), - fmt::format("Component index {} is out of range for array with {} components.", compIdx, numComponents)); -} - -ParsedItem tce; -tce.kind = ParsedItem::Kind::TupleComponentExtract; -tce.tupleIndex = tupleIdx; -tce.componentIndex = compIdx; -items.push_back(tce); -``` - -The `(expr)[C]` and `(expr)[T,C]` paths (when `prevItem.kind == ParsedItem::Kind::RParen`) remain unchanged — they already emit ComponentExtract/TupleComponentExtract items. - -- [ ] **Step 5: Rewrite parse() — validation step 7b** - -The validation step 7b (starting around line 1316) currently queries `m_TempDataStructure.getDataAs()` for each array value. Replace it to query `ParsedItem::arrayTupleShape` and `ParsedItem::arrayCompShape` directly: - -```cpp -// 7b: Collect array-type values and verify consistent tuple/component info -std::vector arrayTupleShape; -std::vector arrayCompShape; -usize arrayNumTuples = 0; -bool hasArray = false; -bool hasNumericValue = false; -bool tupleShapesMatch = true; - -for(const auto& item : items) -{ - if(item.kind == ParsedItem::Kind::Scalar || item.kind == ParsedItem::Kind::ArrayRef) - { - hasNumericValue = true; - } - if(item.kind == ParsedItem::Kind::ArrayRef) - { - std::vector ts = item.arrayTupleShape; - std::vector cs = item.arrayCompShape; - usize nt = 1; - for(usize d : ts) - { - nt *= d; - } - - if(hasArray) - { - if(!arrayCompShape.empty() && arrayCompShape != cs) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::InconsistentCompDims), - "Attribute Array symbols in the infix expression have mismatching component dimensions."); - } - if(arrayNumTuples != 0 && nt != arrayNumTuples) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::InconsistentTuples), - "Attribute Array symbols in the infix expression have mismatching number of tuples."); - } - if(!arrayTupleShape.empty() && arrayTupleShape != ts) - { - tupleShapesMatch = false; - } - } - - hasArray = true; - arrayTupleShape = ts; - arrayCompShape = cs; - arrayNumTuples = nt; - } -} -``` - -- [ ] **Step 6: Rewrite parse() — shunting-yard conversion to RPN** - -The shunting-yard loop (starting around line 1416) currently converts ParsedItems to RpnItems. Update the `Value` case to handle the new `Scalar` and `ArrayRef` kinds: - -Replace the `ParsedItem::Kind::Value` case with two cases: - -```cpp -case ParsedItem::Kind::Scalar: { - RpnItem rpn; - rpn.type = RpnItem::Type::Scalar; - rpn.scalarValue = item.scalarValue; - m_RpnItems.push_back(rpn); - break; -} - -case ParsedItem::Kind::ArrayRef: { - RpnItem rpn; - rpn.type = RpnItem::Type::ArrayRef; - rpn.arrayPath = item.arrayPath; - rpn.sourceDataType = item.sourceDataType; - m_RpnItems.push_back(rpn); - break; -} -``` - -Update the `ComponentExtract` and `TupleComponentExtract` cases similarly — they already match the new RpnItem fields. Just ensure the RpnItem type assignment uses `RpnItem::Type::ComponentExtract` / `RpnItem::Type::TupleComponentExtract` and sets `componentIndex`/`tupleIndex`. - -- [ ] **Step 7: Update constructor calls in ArrayCalculatorFilter.cpp** - -In `ArrayCalculatorFilter.cpp`, update the two places where `ArrayCalculatorParser` is constructed: - -In `preflightImpl()` (around line 88), remove the `true` (isPreflight) argument: -```cpp -ArrayCalculatorParser parser(dataStructure, pInfixEquationValue.m_SelectedGroup, pInfixEquationValue.m_Equation, m_ShouldCancel); -``` - -In `ArrayCalculator::operator()()` in ArrayCalculator.cpp (around line 1789), remove the `false` (isPreflight) argument: -```cpp -ArrayCalculatorParser parser(m_DataStructure, m_InputValues->SelectedGroup, m_InputValues->InfixEquation, m_ShouldCancel); -``` - -- [ ] **Step 8: Note — do NOT build yet** - -The evaluator (`evaluateInto()`) still references the old CalcValue-based eval stack. It must be updated in Task 6 before the code compiles. - ---- - -### Task 6: Rewrite Evaluator to Use CalcBuffer Stack - -**Files:** -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp` - -Rewrite `evaluateInto()` to use a `std::stack` with a local `DataStructure` for temps. Add the last-operator OutputDirect optimization and the final result copy logic. - -- [ ] **Step 1: Rewrite evaluateInto()** - -Replace the entire `evaluateInto()` method (starting at line 1534) with the following implementation. This is the complete new evaluator: - -```cpp -Result<> ArrayCalculatorParser::evaluateInto(DataStructure& dataStructure, const DataPath& outputPath, NumericType scalarType, CalculatorParameter::AngleUnits units) -{ - // 1. Parse (populates m_RpnItems via shunting-yard) - Result<> parseResult = parse(); - if(parseResult.invalid()) - { - return parseResult; - } - - // 2. Create local temp DataStructure for intermediate arrays - DataStructure tempDS; - usize scratchCounter = 0; - auto nextScratchName = [&scratchCounter]() -> std::string { - return "_calc_" + std::to_string(scratchCounter++); - }; - - // 3. Pre-scan RPN to find the index of the last operator/extract item - // for the OutputDirect optimization - DataType outputDataType = ConvertNumericTypeToDataType(scalarType); - bool outputIsFloat64 = (outputDataType == DataType::float64); - int64 lastOpIndex = -1; - for(int64 idx = static_cast(m_RpnItems.size()) - 1; idx >= 0; --idx) - { - RpnItem::Type t = m_RpnItems[static_cast(idx)].type; - if(t == RpnItem::Type::Operator || t == RpnItem::Type::ComponentExtract || t == RpnItem::Type::TupleComponentExtract) - { - lastOpIndex = idx; - break; - } - } - - // 4. Walk the RPN items using a CalcBuffer evaluation stack - std::stack evalStack; - - for(usize rpnIdx = 0; rpnIdx < m_RpnItems.size(); ++rpnIdx) - { - if(m_ShouldCancel) - { - return {}; - } - - const RpnItem& rpnItem = m_RpnItems[rpnIdx]; - bool isLastOp = (static_cast(rpnIdx) == lastOpIndex); - - switch(rpnItem.type) - { - case RpnItem::Type::Scalar: { - evalStack.push(CalcBuffer::scalar(tempDS, rpnItem.scalarValue, nextScratchName())); - break; - } - - case RpnItem::Type::ArrayRef: { - if(rpnItem.sourceDataType == DataType::float64) - { - const auto& sourceArray = m_DataStructure.getDataRefAs(rpnItem.arrayPath); - evalStack.push(CalcBuffer::borrow(sourceArray)); - } - else - { - const auto& sourceArray = m_DataStructure.getDataRefAs(rpnItem.arrayPath); - evalStack.push(CalcBuffer::convertFrom(tempDS, sourceArray, nextScratchName())); - } - break; - } - - case RpnItem::Type::Operator: { - const OperatorDef* op = rpnItem.op; - if(op == nullptr) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::InvalidEquation), "Internal error: null operator in RPN evaluation."); - } - - if(op->numArgs == 1) - { - if(evalStack.empty()) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::NotEnoughArguments), "Not enough arguments for unary operator."); - } - CalcBuffer operand = std::move(evalStack.top()); - evalStack.pop(); - - std::vector resultTupleShape = operand.tupleShape(); - std::vector resultCompShape = operand.compShape(); - usize totalSize = operand.size(); - - CalcBuffer result = (isLastOp && outputIsFloat64) - ? CalcBuffer::wrapOutput(dataStructure.getDataRefAs>(outputPath)) - : CalcBuffer::allocate(tempDS, nextScratchName(), resultTupleShape, resultCompShape); - - for(usize i = 0; i < totalSize; i++) - { - float64 val = operand.read(i); - - if(op->trigMode == OperatorDef::ForwardTrig && units == CalculatorParameter::AngleUnits::Degrees) - { - val = val * (std::numbers::pi / 180.0); - } - - float64 res = op->unaryOp(val); - - if(op->trigMode == OperatorDef::InverseTrig && units == CalculatorParameter::AngleUnits::Degrees) - { - res = res * (180.0 / std::numbers::pi); - } - - result.write(i, res); - } - - bool wasScalar = operand.isScalar(); - if(wasScalar) - { - result.markAsScalar(); - } - // operand destroyed here, RAII cleans up - evalStack.push(std::move(result)); - } - else if(op->numArgs == 2) - { - if(evalStack.size() < 2) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::NotEnoughArguments), "Not enough arguments for binary operator."); - } - CalcBuffer right = std::move(evalStack.top()); - evalStack.pop(); - CalcBuffer left = std::move(evalStack.top()); - evalStack.pop(); - - // Determine output shape: use the array operand's shape (broadcast scalars) - std::vector outTupleShape; - std::vector outCompShape; - if(!left.isScalar()) - { - outTupleShape = left.tupleShape(); - outCompShape = left.compShape(); - } - else - { - outTupleShape = right.tupleShape(); - outCompShape = right.compShape(); - } - - usize totalSize = 1; - for(usize d : outTupleShape) - { - totalSize *= d; - } - for(usize d : outCompShape) - { - totalSize *= d; - } - - CalcBuffer result = (isLastOp && outputIsFloat64) - ? CalcBuffer::wrapOutput(dataStructure.getDataRefAs>(outputPath)) - : CalcBuffer::allocate(tempDS, nextScratchName(), outTupleShape, outCompShape); - - bool leftIsScalar = left.isScalar(); - bool rightIsScalar = right.isScalar(); - - for(usize i = 0; i < totalSize; i++) - { - float64 lv = left.read(leftIsScalar ? 0 : i); - float64 rv = right.read(rightIsScalar ? 0 : i); - result.write(i, op->binaryOp(lv, rv)); - } - - if(leftIsScalar && rightIsScalar) - { - result.markAsScalar(); - } - // left and right destroyed here, RAII cleans up owned temps - evalStack.push(std::move(result)); - } - else - { - return MakeErrorResult(static_cast(CalculatorErrorCode::InvalidEquation), - fmt::format("Internal error: operator '{}' has unsupported numArgs={}.", op->token, op->numArgs)); - } - break; - } - - case RpnItem::Type::ComponentExtract: { - if(evalStack.empty()) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::NotEnoughArguments), "Not enough arguments for component extraction."); - } - CalcBuffer operand = std::move(evalStack.top()); - evalStack.pop(); - - usize numComps = operand.numComponents(); - usize numTuples = operand.numTuples(); - usize compIdx = rpnItem.componentIndex; - - if(compIdx >= numComps) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::ComponentOutOfRange), - fmt::format("Component index {} is out of range for array with {} components.", compIdx, numComps)); - } - - CalcBuffer result = (isLastOp && outputIsFloat64) - ? CalcBuffer::wrapOutput(dataStructure.getDataRefAs>(outputPath)) - : CalcBuffer::allocate(tempDS, nextScratchName(), operand.tupleShape(), std::vector{1}); - - for(usize t = 0; t < numTuples; ++t) - { - result.write(t, operand.read(t * numComps + compIdx)); - } - - evalStack.push(std::move(result)); - break; - } - - case RpnItem::Type::TupleComponentExtract: { - if(evalStack.empty()) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::NotEnoughArguments), "Not enough arguments for tuple+component extraction."); - } - CalcBuffer operand = std::move(evalStack.top()); - evalStack.pop(); - - usize numComps = operand.numComponents(); - usize numTuples = operand.numTuples(); - usize tupleIdx = rpnItem.tupleIndex; - usize compIdx = rpnItem.componentIndex; - - if(tupleIdx >= numTuples) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::TupleOutOfRange), - fmt::format("Tuple index {} is out of range for array with {} tuples.", tupleIdx, numTuples)); - } - if(compIdx >= numComps) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::ComponentOutOfRange), - fmt::format("Component index {} is out of range for array with {} components.", compIdx, numComps)); - } - - float64 value = operand.read(tupleIdx * numComps + compIdx); - // operand destroyed, RAII cleans up - evalStack.push(CalcBuffer::scalar(tempDS, value, nextScratchName())); - break; - } - - } // end switch - } - - // 5. Final result - if(evalStack.size() != 1) - { - return MakeErrorResult(static_cast(CalculatorErrorCode::InvalidEquation), - fmt::format("Internal error: evaluation stack has {} items remaining; expected exactly 1.", evalStack.size())); - } - - CalcBuffer finalResult = std::move(evalStack.top()); - evalStack.pop(); - - // 6. Copy/cast result into the output array (checked in order, first match wins) - if(finalResult.isScalar()) - { - // Fill entire output with the scalar value - float64 scalarVal = finalResult.read(0); - ExecuteDataFunction(CopyResultFunctor{}, outputDataType, dataStructure, outputPath, scalarVal); - } - else if(finalResult.isOutputDirect()) - { - // Data is already in the output array — nothing to do - } - else if(outputIsFloat64) - { - // Direct float64-to-float64 copy via operator[] (no type cast) - auto& outputArray = dataStructure.getDataRefAs>(outputPath); - usize totalSize = finalResult.size(); - for(usize i = 0; i < totalSize; i++) - { - outputArray[i] = finalResult.read(i); - } - } - else - { - // Type-casting copy via CopyResultFunctor - const Float64Array& resultArray = finalResult.array(); - ExecuteDataFunction(CopyResultFunctor{}, outputDataType, dataStructure, outputPath, &resultArray, false); - } - - return parseResult; -} -``` - -- [ ] **Step 2: Update CopyResultFunctor to support scalar fill** - -The scalar fill path now passes a `float64` value directly. Add an overload or update `CopyResultFunctor` in the anonymous namespace. Replace the existing `CopyResultFunctor` (lines 230-248) with: - -```cpp -struct CopyResultFunctor -{ - // Full array copy (non-float64 output) - template - void operator()(DataStructure& ds, const DataPath& outputPath, const Float64Array* resultArray, bool /*unused*/) - { - auto& output = ds.getDataRefAs>(outputPath).getDataStoreRef(); - for(usize i = 0; i < output.getSize(); i++) - { - output[i] = static_cast(resultArray->at(i)); - } - } - - // Scalar fill - template - void operator()(DataStructure& ds, const DataPath& outputPath, float64 scalarValue) - { - auto& output = ds.getDataRefAs>(outputPath); - output.fill(static_cast(scalarValue)); - } -}; -``` - -- [ ] **Step 3: Remove old CopyToFloat64Functor** - -The `CopyToFloat64Functor` (lines 122-134) is no longer needed at the top level since its logic is now inside `CalcBuffer::convertFrom()`. However, `CalcBuffer::convertFrom()` still calls it via `ExecuteDataFunction`. Keep `CopyToFloat64Functor` in the anonymous namespace — it is still referenced by `CalcBuffer::convertFrom()`. - -- [ ] **Step 4: Build the project** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && cmake --build . --target SimplnxCore -``` - -Expected: Build succeeds. Fix any compilation errors arising from ParsedItem/RpnItem field name mismatches — common issues: -- `item.kind == ParsedItem::Kind::Value` → split into `ParsedItem::Kind::Scalar` and `ParsedItem::Kind::ArrayRef` -- `item.value.kind == CalcValue::Kind::Array` → `item.kind == ParsedItem::Kind::ArrayRef` -- References to `item.value` → `item.scalarValue` or `item.arrayPath` - -- [ ] **Step 5: Run all ArrayCalculator tests** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && ctest -R "SimplnxCore::ArrayCalculatorFilter" --verbose -``` - -Expected: All test cases pass — identical behavior to baseline. If any fail, debug by comparing the error code or output value against the expected. Common issues: -- Bracket indexing `Array[C]` now emits ArrayRef + ComponentExtract, so the evaluator must handle ComponentExtract on borrowed arrays correctly -- Scalar detection: CalcBuffer created via `CalcBuffer::scalar()` has `m_IsScalar = true`, but CalcBuffers from binary operations where both operands are scalar should also be scalar. Check that the scalar fill path triggers correctly for all-scalar expressions. - -- [ ] **Step 6: Commit** - -```bash -git add src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp \ - src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp \ - src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ArrayCalculatorFilter.cpp -git commit -m "MEM: Rewrite ArrayCalculator parser and evaluator with CalcBuffer RAII - -Parser is now data-free: produces RPN items with DataPath/scalar metadata -instead of allocating temporary Float64Arrays. Evaluator uses a CalcBuffer -stack with RAII cleanup — intermediates are freed when consumed. Float64 -input arrays are borrowed (zero-copy). The last RPN operator writes -directly into the output DataArray when output type is float64." -``` - ---- - -### Task 7: Final Verification and Cleanup - -**Files:** -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp` (cleanup) -- Modify: `src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp` (cleanup) - -- [ ] **Step 1: Run clang-format on modified files** - -```bash -cd /Users/mjackson/Workspace7/simplnx && clang-format -i \ - src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp \ - src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp \ - src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ArrayCalculatorFilter.cpp -``` - -- [ ] **Step 2: Build the full project (not just SimplnxCore)** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && cmake --build . --target all -``` - -Expected: Full build succeeds — no other files reference CalcValue or the removed parser members. - -- [ ] **Step 3: Run the full SimplnxCore test suite** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && ctest -R "SimplnxCore::" --verbose -``` - -Expected: All SimplnxCore tests pass. This catches any accidental regressions in other filters. - -- [ ] **Step 4: Run ArrayCalculator tests specifically and verify all assertions pass** - -```bash -cd /Users/mjackson/Workspace2/DREAM3D-Build/simplnx-Rel && ctest -R "SimplnxCore::ArrayCalculatorFilter" --verbose -``` - -Expected: All 9 test cases pass with all assertions (the test output should show the same assertion count as the baseline from Task 1). - -- [ ] **Step 5: Commit formatting changes (if any)** - -```bash -git add src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.hpp \ - src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp \ - src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ArrayCalculatorFilter.cpp -git commit -m "STY: Run clang-format on ArrayCalculator files after memory optimization" -``` - diff --git a/docs/superpowers/specs/2026-04-07-arraycalculator-memory-optimization-design.md b/docs/superpowers/specs/2026-04-07-arraycalculator-memory-optimization-design.md deleted file mode 100644 index 177fadb63f..0000000000 --- a/docs/superpowers/specs/2026-04-07-arraycalculator-memory-optimization-design.md +++ /dev/null @@ -1,289 +0,0 @@ -# ArrayCalculator Memory Optimization Design - -## Problem - -The ArrayCalculator evaluator allocates temporary `Float64Array` objects in a scratch `DataStructure` (`m_TempDataStructure`) for every input array, every intermediate result, and the final result. None of these temporaries are freed until the parser is destroyed. For a simple expression like `a + b` with float64 inputs and float64 output, this produces 4 array-sized allocations when 0 would suffice. - -For large datasets with complex expressions, this causes memory usage to explode: an expression with N operations on an array of M elements allocates O(N * M * 8) bytes of temporaries that all stay alive simultaneously. - -### Specific waste patterns - -1. **Input float64 arrays are copied into temp Float64Arrays** even though the data is already float64 — a full O(M) allocation + O(M) copy per input reference. -2. **Every intermediate result allocates a new Float64Array** in `m_TempDataStructure`. None are freed until the parser destructs. -3. **The final result is copied element-by-element** from the temp Float64Array into the output DataArray, even when the output type is float64 — an unnecessary O(M) allocation + O(M) copy. - -## Constraints - -- **`m_TempDataStructure` must be retained.** Temporary arrays must live inside a `DataStructure` because the `DataArray`/`DataStore` abstraction is load-bearing: the project is moving to an out-of-core `DataStore` implementation where data may not be resident in memory. Raw `std::vector` or `float64*` buffers cannot replace `DataArray`. -- **No raw pointer access to DataArray data.** All element reads/writes must go through `DataArray::at()` or `operator[]`. Never use `T*` pointers obtained from `DataStore::data()` or similar. -- **Memory optimization is the first priority.** CPU computation performance is the second priority. -- **Backward compatibility.** All existing tests must continue to pass. Error codes, parameter keys, and pipeline JSON formats are unchanged by this work. - -## Solution: CalcBuffer — RAII Sentinel for Temp DataArrays - -### Overview - -Introduce a `CalcBuffer` class: a move-only RAII handle that wraps a `Float64Array` (either a temp array in `m_TempDataStructure` or a borrowed reference to an input array in the real `DataStructure`). When an owned `CalcBuffer` is destroyed, it removes its `DataArray` from the `DataStructure` via `DataStructure::removeData(DataObject::IdType)`. - -The evaluation stack changes from `std::stack` (with manual ID lookups) to `std::stack` (with automatic RAII cleanup). Intermediates are freed the instant they are consumed by an operator. - -### CalcBuffer class - -```cpp -class SIMPLNXCORE_EXPORT CalcBuffer -{ -public: - // --- Factory methods --- - - // Zero-copy reference to an existing Float64Array. Read-only. Destructor: no-op. - static CalcBuffer borrow(const Float64Array& source); - - // Allocate a temp Float64Array in tempDS, convert source data from any numeric type. - // Owned. Destructor: removes from tempDS. - static CalcBuffer convertFrom(DataStructure& tempDS, const IDataArray& source, - const std::string& name); - - // Allocate a 1-element temp Float64Array with the given scalar value. - static CalcBuffer scalar(DataStructure& tempDS, double value, const std::string& name); - - // Allocate an empty temp Float64Array with the given shape. - static CalcBuffer allocate(DataStructure& tempDS, const std::string& name, - std::vector tupleShape, std::vector compShape); - - // Wrap the output DataArray for direct writing. Destructor: no-op. - static CalcBuffer wrapOutput(DataArray& outputArray); - - // --- Move-only, non-copyable --- - CalcBuffer(CalcBuffer&& other) noexcept; - CalcBuffer& operator=(CalcBuffer&& other) noexcept; - ~CalcBuffer(); - - CalcBuffer(const CalcBuffer&) = delete; - CalcBuffer& operator=(const CalcBuffer&) = delete; - - // --- Element access --- - float64 read(usize index) const; - void write(usize index, float64 value); - void fill(float64 value); - - // --- Metadata --- - usize size() const; - usize numTuples() const; - usize numComponents() const; - std::vector tupleShape() const; - std::vector compShape() const; - bool isScalar() const; - bool isOwned() const; - bool isOutputDirect() const; - - // --- Access underlying array (for final copy to non-float64 output) --- - const Float64Array& array() const; - -private: - CalcBuffer() = default; - - enum class Storage - { - Borrowed, // const Float64Array* in real DataStructure, read-only - Owned, // Float64Array in m_TempDataStructure, read-write, cleaned up on destroy - OutputDirect // DataArray* output array, read-write, NOT cleaned up - }; - - Storage m_Storage = Storage::Owned; - - // Borrowed - const Float64Array* m_BorrowedArray = nullptr; - - // Owned - DataStructure* m_TempDS = nullptr; - DataObject::IdType m_ArrayId = 0; - Float64Array* m_OwnedArray = nullptr; - - // OutputDirect - DataArray* m_OutputArray = nullptr; - - bool m_IsScalar = false; -}; -``` - -#### Storage mode behavior - -| Mode | `read(i)` | `write(i, v)` | Destructor | -|------|-----------|---------------|------------| -| Borrowed | `m_BorrowedArray->at(i)` | assert/error | no-op | -| Owned | `m_OwnedArray->at(i)` | `(*m_OwnedArray)[i] = v` | `m_TempDS->removeData(m_ArrayId)` | -| OutputDirect | `m_OutputArray->at(i)` | `(*m_OutputArray)[i] = v` | no-op | - -#### Move semantics - -The move constructor transfers all fields and nulls out the source so the moved-from object's destructor is a no-op: - -```cpp -CalcBuffer::CalcBuffer(CalcBuffer&& other) noexcept -: m_Storage(other.m_Storage) -, m_BorrowedArray(other.m_BorrowedArray) -, m_TempDS(other.m_TempDS) -, m_ArrayId(other.m_ArrayId) -, m_OwnedArray(other.m_OwnedArray) -, m_OutputArray(other.m_OutputArray) -, m_IsScalar(other.m_IsScalar) -{ - other.m_TempDS = nullptr; // prevents other's destructor from removing the array - other.m_BorrowedArray = nullptr; - other.m_OwnedArray = nullptr; - other.m_OutputArray = nullptr; -} -``` - -### RPN item changes (data-free parser) - -`CalcValue` is deleted. `RpnItem` stores metadata only — no `DataObject::IdType`, no data allocation during parsing. - -```cpp -struct SIMPLNXCORE_EXPORT RpnItem -{ - enum class Type - { - Scalar, // Numeric literal or constant (pi, e) - ArrayRef, // Reference to a source array in the real DataStructure - Operator, // Math operator or function - ComponentExtract, // [C] on a sub-expression result - TupleComponentExtract // [T, C] on a sub-expression result - } type; - - // Scalar - float64 scalarValue = 0.0; - - // ArrayRef - DataPath arrayPath; - DataType sourceDataType = DataType::float64; - - // Operator - const OperatorDef* op = nullptr; - - // ComponentExtract / TupleComponentExtract - usize componentIndex = std::numeric_limits::max(); - usize tupleIndex = std::numeric_limits::max(); -}; -``` - -### Parser changes - -The parser becomes a pure validation + RPN construction pass with no data allocation: - -1. **`createScalarInTemp()` eliminated** — parser stores `float64 scalarValue` in RpnItem. -2. **`copyArrayToTemp()` eliminated** — parser stores `DataPath` + `DataType` from the source. -3. **`Array[C]` bracket indexing unified with `(expr)[C]`** — parser emits `ArrayRef` + `ComponentExtract` RPN items instead of extracting component data during parsing. -4. **`Array[T,C]` bracket indexing unified with `(expr)[T,C]`** — parser emits `ArrayRef` + `TupleComponentExtract`. -5. **Validation step 7b** queries source array shapes directly via `dataStructure.getDataRefAs(path)` instead of looking at temp arrays. -6. **`m_IsPreflight` flag eliminated** — parser is identical for preflight and execution. -7. **`m_TempDataStructure` removed from the parser** — it is created in the evaluator only. - -### Evaluator changes - -`m_TempDataStructure` becomes a local variable inside `evaluateInto()` (currently it is a member of `ArrayCalculatorParser`). Since the parser no longer needs it, the evaluator creates it on the stack and it is destroyed — along with any remaining temp arrays — when `evaluateInto()` returns. The eval stack is `std::stack`. - -#### Buffer creation by RPN type - -| RPN Type | CalcBuffer creation | -|----------|-------------------| -| `Scalar` | `CalcBuffer::scalar(tempDS, value, name)` | -| `ArrayRef` where `sourceDataType == float64` | `CalcBuffer::borrow(sourceFloat64Array)` | -| `ArrayRef` where `sourceDataType != float64` | `CalcBuffer::convertFrom(tempDS, source, name)` | - -#### RAII cleanup during operator evaluation - -When processing a binary operator, operands are moved out of the stack into locals. After the result is computed and pushed, the locals are destroyed at the closing brace, triggering `removeData()` for any owned temps: - -```cpp -{ - CalcBuffer right = std::move(evalStack.top()); - evalStack.pop(); - CalcBuffer left = std::move(evalStack.top()); - evalStack.pop(); - - CalcBuffer result = CalcBuffer::allocate(tempDS, name, outTupleShape, outCompShape); - - bool leftIsScalar = left.isScalar(); - bool rightIsScalar = right.isScalar(); - - for(usize i = 0; i < totalSize; i++) - { - float64 lv = left.read(leftIsScalar ? 0 : i); - float64 rv = right.read(rightIsScalar ? 0 : i); - result.write(i, op->binaryOp(lv, rv)); - } - - evalStack.push(std::move(result)); -} -// left and right destroyed here -> owned temp arrays removed from tempDS -``` - -#### Last-operator direct-write optimization - -For the last RPN operator, when the output type is `float64`: - -1. Pre-scan `m_RpnItems` to find the index of the last Operator/ComponentExtract/TupleComponentExtract. -2. When processing that item, use `CalcBuffer::wrapOutput(outputFloat64Array)` instead of `CalcBuffer::allocate()`. -3. Writes go directly into the output array via `operator[]`. -4. At the end, detect `isOutputDirect()` on the final CalcBuffer and skip the copy step. - -This eliminates the last temp array allocation entirely. - -#### Final result copy to output - -After the RPN loop, the stack has exactly one CalcBuffer: - -Checked in order (first match wins): - -| Final CalcBuffer | Output type | Action | -|-----------------|-------------|--------| -| isScalar() | any | Fill the output array with the scalar value via `DataArray::fill()` or equivalent loop | -| OutputDirect | float64 | No copy needed — data is already in the output | -| Owned or Borrowed | float64 | Element-by-element copy into output `DataArray` via `operator[]` (no type cast) | -| Owned or Borrowed | non-float64 | `CopyResultFunctor` cast-copy via `ExecuteDataFunction` | - -### CPU performance considerations - -- **`CalcBuffer::read()` branch on storage mode**: predictable per-CalcBuffer (same branch every call). Negligible cost compared to `std::function` dispatch through `op->binaryOp`/`op->unaryOp`. -- **Scalar broadcast check**: hoisted outside inner loops (`leftIsScalar`/`rightIsScalar` evaluated once before the loop). -- **OutputDirect adds a third branch to `write()`**: only applies to the single final-result CalcBuffer, so the branch predictor handles it trivially. -- **Borrowed reads via `Float64Array::at()` vs current temp array reads via `Float64Array::at()`**: identical per-element cost. Zero CPU regression for reads. - -### Memory impact analysis - -For expression `a + b + c + d + e + f` with float64 inputs and float64 output (M elements each): - -| Metric | Current | New | -|--------|---------|-----| -| Input copies | 6 arrays (6 * M * 8 bytes) | 0 (all borrowed) | -| Peak intermediate arrays | 5 (all alive simultaneously) | 1 (RAII frees consumed) | -| Final result temp | 1 array (M * 8 bytes) | 0 (OutputDirect writes to output) | -| **Total temp memory** | **12 * M * 8 bytes** | **1 * M * 8 bytes** (one intermediate) | - -For the same expression with non-float64 inputs (e.g., int32): - -| Metric | Current | New | -|--------|---------|-----| -| Input copies | 6 arrays | 6 arrays (conversion required) | -| Peak intermediate arrays | 5 | 1 | -| Final result temp | 1 | 0 (if output is float64) or 1 (if not) | -| **Total temp memory** | **12 * M * 8 bytes** | **7 * M * 8 bytes** (6 conversions + 1 intermediate) | - -### Files modified - -- `ArrayCalculator.hpp` — add `CalcBuffer` class, update `RpnItem`, remove `CalcValue`, remove `m_TempDataStructure`/`m_IsPreflight`/`createScalarInTemp`/`copyArrayToTemp` from parser, add `m_TempDataStructure` to evaluator or create locally -- `ArrayCalculator.cpp` — rewrite `parse()` to be data-free, rewrite `evaluateInto()` with CalcBuffer stack + RAII + OutputDirect, remove `CopyToFloat64Functor` (moved into `CalcBuffer::convertFrom`), update `CopyResultFunctor` for the non-float64 output path -- `ArrayCalculatorFilter.cpp` — no changes expected (parser/evaluator API stays the same) -- `ArrayCalculatorTest.cpp` — no changes expected (tests exercise the filter, not internal classes) - -### What is NOT changing - -- `OperatorDef` struct and operator registry — unchanged -- `Token` struct and `tokenize()` — unchanged -- `CalculatorErrorCode` / `CalculatorWarningCode` enums — unchanged -- `ArrayCalculatorInputValues` struct — unchanged -- `ArrayCalculator` algorithm class public API — unchanged -- `ArrayCalculatorParser::parseAndValidate()` signature — unchanged -- `ArrayCalculatorParser::evaluateInto()` signature — unchanged -- Filter parameter keys, UUID, pipeline JSON format — unchanged diff --git a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Common/ReadImageUtils.hpp b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Common/ReadImageUtils.hpp index c06946e9f0..2a2718e43e 100644 --- a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Common/ReadImageUtils.hpp +++ b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Common/ReadImageUtils.hpp @@ -30,9 +30,16 @@ void ConvertImageToDataStoreAsType(itk::Image& image, DataSto const auto* rawBufferPtr = reinterpret_cast(pixelContainer->GetBufferPointer()); + // pixelContainer->Size() returns the number of pixels, not the number of scalar + // elements. For multi-component pixel types (e.g. itk::Vector), the + // underlying scalar buffer contains pixelCount * componentsPerPixel elements. + // Multiply by the component count so std::transform iterates over every scalar. + const std::size_t componentsPerPixel = itk::NumericTraits::GetLength(); + const std::size_t totalElements = pixelContainer->Size() * componentsPerPixel; + constexpr auto destMaxV = static_cast(std::numeric_limits::max()); constexpr auto originMaxV = std::numeric_limits::max(); - std::transform(rawBufferPtr, rawBufferPtr + pixelContainer->Size(), dataStore.data(), [](auto value) { + std::transform(rawBufferPtr, rawBufferPtr + totalElements, dataStore.data(), [](auto value) { float64 ratio = static_cast(value) / static_cast(originMaxV); return static_cast(ratio * destMaxV); }); diff --git a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageReaderFilter.cpp b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageReaderFilter.cpp index 5cf1a4dd67..00fbcc908a 100644 --- a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageReaderFilter.cpp +++ b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageReaderFilter.cpp @@ -61,7 +61,7 @@ std::string ITKImageReaderFilter::humanName() const //------------------------------------------------------------------------------ std::vector ITKImageReaderFilter::defaultTags() const { - return {className(), "io", "input", "read", "import"}; + return {className(), "io", "input", "read", "import", "image", "jpg", "tiff", "bmp", "png"}; } //------------------------------------------------------------------------------ diff --git a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp index d3ab7198de..8bc48f5877 100644 --- a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp +++ b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp @@ -28,11 +28,10 @@ #include #include #include +#include #include "simplnx/Utilities/SIMPLConversion.hpp" -#include - namespace fs = std::filesystem; using namespace nx::core; diff --git a/src/Plugins/ITKImageProcessing/test/CMakeLists.txt b/src/Plugins/ITKImageProcessing/test/CMakeLists.txt index 5bbf6b9d2c..ae6eb225fc 100644 --- a/src/Plugins/ITKImageProcessing/test/CMakeLists.txt +++ b/src/Plugins/ITKImageProcessing/test/CMakeLists.txt @@ -132,10 +132,6 @@ if(EXISTS "${DREAM3D_DATA_DIR}" AND SIMPLNX_DOWNLOAD_TEST_FILES) file(MAKE_DIRECTORY "${DREAM3D_DATA_DIR}/TestFiles/") endif() download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME fiji_montage.tar.gz SHA512 70139babc838ce3ab1f5adddfddc86dcc51996e614c6c2d757bcb2e59e8ebdc744dac269233494b1ef8d09397aecb4ccca3384f0a91bb017f2cf6309c4ac40fa) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME image_flip_test_images.tar.gz SHA512 4e282a270251133004bf4b979d0d064631b618fc82f503184c602c40d388b725f81faf8e77654d285852acc3217d51534c9a71240be4a87a91dc46da7871e7d2) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME import_image_stack_test.tar.gz SHA512 bec92d655d0d96928f616612d46b52cd51ffa5dbc1d16a64bb79040bbdd3c50f8193350771b177bb64aa68fc0ff7e459745265818c1ea34eb27431426ff15083) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME import_image_stack_test_v2.tar.gz SHA512 b3600c072ecbdb27ed3ed7298dac88708aa94d1eed21e6b0581b772311d1e3c2c6693713026ae512c86729079b41c629067fb1a7df75697d08dbdf2dfad0f553) - download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME itk_image_reader_test.tar.gz SHA512 15c42c913df8e0aebfc4196ea96ed5fdc2fab71daacbaf27287d6aee08483acb37f2f38d88af6c549084bae892b07c7aca8537b2511920d18248c8bc18aab92f) endif() create_pipeline_tests(PLUGIN_NAME ${PLUGIN_NAME} PIPELINE_LIST ${PREBUILT_PIPELINE_NAMES}) diff --git a/src/Plugins/ITKImageProcessing/test/ITKImageReaderTest.cpp b/src/Plugins/ITKImageProcessing/test/ITKImageReaderTest.cpp index 5024be291b..d036fe4a36 100644 --- a/src/Plugins/ITKImageProcessing/test/ITKImageReaderTest.cpp +++ b/src/Plugins/ITKImageProcessing/test/ITKImageReaderTest.cpp @@ -14,16 +14,16 @@ using namespace nx::core; namespace { -const std::string k_TestDataDirName = "itk_image_reader_test"; +const std::string k_TestDataDirName = "itk_image_reader_test_v3"; const fs::path k_TestDataDir = fs::path(unit_test::k_TestFilesDir.view()) / k_TestDataDirName; -const fs::path k_ExemplarFile = k_TestDataDir / "itk_image_reader_test.dream3d"; +const fs::path k_ExemplarFile = k_TestDataDir / "itk_image_reader_test_v3.dream3d"; const fs::path k_InputImageFile = k_TestDataDir / "200x200_0.tif"; const std::string k_ImageDataName = "ImageData"; } // namespace TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Read_Basic", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -66,7 +66,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Read_Basic", "[ITKImageProc TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Override_Origin", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -112,7 +112,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Override_Origin", "[ITKImag TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Centering_Origin", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -156,7 +156,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Centering_Origin", "[ITKIma TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Cropping", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); // This block generates every combination of croppingOptions, changeOrigin, and changeSpacing and then the entire test executes for each combination std::vector spacing = {2.0, 2.0, 1.0}; @@ -233,7 +233,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Cropping", "[ITKImageProces TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Override_Spacing", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -279,7 +279,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Override_Spacing", "[ITKIma TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: OriginSpacing_Preprocessed", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -337,7 +337,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: OriginSpacing_Preprocessed" TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: OriginSpacing_Postprocessed", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -396,7 +396,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: OriginSpacing_Postprocessed TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: DataType_Conversion", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -443,7 +443,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: DataType_Conversion", "[ITK TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Interaction_Crop_DataType", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; @@ -499,7 +499,7 @@ TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Interaction_Crop_DataType", TEST_CASE("ITKImageProcessing::ITKImageReaderFilter: Interaction_All", "[ITKImageProcessing][ITKImageReaderFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); ITKImageReaderFilter filter; diff --git a/src/Plugins/ITKImageProcessing/test/ITKImportImageStackTest.cpp b/src/Plugins/ITKImageProcessing/test/ITKImportImageStackTest.cpp index 412009b7b3..c40aca7c7a 100644 --- a/src/Plugins/ITKImageProcessing/test/ITKImportImageStackTest.cpp +++ b/src/Plugins/ITKImageProcessing/test/ITKImportImageStackTest.cpp @@ -20,7 +20,10 @@ namespace const std::string k_ImageStackDir = unit_test::k_DataDir.str() + "/ImageStack"; const DataPath k_ImageGeomPath = {{"ImageGeometry"}}; const DataPath k_ImageDataPath = k_ImageGeomPath.createChildPath(ImageGeom::k_CellAttributeMatrixName).createChildPath("ImageData"); -const std::string k_FlippedImageStackDirName = "image_flip_test_images"; +// The image_flip_test_images directory lives inside the import_image_stack_test_v3 archive +// alongside the main exemplar file. k_ImageFlipStackDir below resolves to +// .../TestFiles/import_image_stack_test_v3/image_flip_test_images. +const std::string k_FlippedImageStackSubDirName = "image_flip_test_images"; const DataPath k_XGeneratedImageGeomPath = DataPath({"xGeneratedImageGeom"}); const DataPath k_YGeneratedImageGeomPath = DataPath({"yGeneratedImageGeom"}); const DataPath k_XFlipImageGeomPath = DataPath({"xFlipImageGeom"}); @@ -29,7 +32,7 @@ const std::string k_ImageDataName = "ImageData"; const ChoicesParameter::ValueType k_NoImageTransform = 0; const ChoicesParameter::ValueType k_FlipAboutXAxis = 1; const ChoicesParameter::ValueType k_FlipAboutYAxis = 2; -const fs::path k_ImageFlipStackDir = fs::path(fmt::format("{}/{}", unit_test::k_TestFilesDir, k_FlippedImageStackDirName)); +const fs::path k_ImageFlipStackDir = fs::path(fmt::format("{}/import_image_stack_test_v3/{}", unit_test::k_TestFilesDir, k_FlippedImageStackSubDirName)); // Exemplar Array Paths const DataPath k_XFlippedImageDataPath = k_XFlipImageGeomPath.createChildPath(Constants::k_Cell_Data).createChildPath(::k_ImageDataName); @@ -160,10 +163,10 @@ void CompareXYFlippedGeometries(DataStructure& dataStructure) } // Test data paths -const std::string k_TestDataDirName = "import_image_stack_test"; +const std::string k_TestDataDirName = "import_image_stack_test_v3"; const fs::path k_TestDataDir = fs::path(unit_test::k_TestFilesDir.view()) / k_TestDataDirName; const fs::path k_InputImagesDir = k_TestDataDir / "input_images"; -const fs::path k_ExemplarFile = k_TestDataDir / "import_image_stack_test.dream3d"; +const fs::path k_ExemplarFile = k_TestDataDir / "import_image_stack_test_v3.dream3d"; // Standard test parameters const std::string k_FilePrefix = "200x200_"; @@ -466,7 +469,7 @@ TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: CompareImage", "[ITKIm TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Even-Even X/Y", "[ITKImageProcessing][ITKImportImageStackFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "image_flip_test_images.tar.gz", k_FlippedImageStackDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); const std::string filePrefix = "image_flip_even_even_"; @@ -490,7 +493,7 @@ TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Even-Eve TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Even-Odd X/Y", "[ITKImageProcessing][ITKImportImageStackFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "image_flip_test_images.tar.gz", k_FlippedImageStackDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); const std::string filePrefix = "image_flip_even_odd_"; @@ -514,7 +517,7 @@ TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Even-Odd TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Odd-Even X/Y", "[ITKImageProcessing][ITKImportImageStackFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "image_flip_test_images.tar.gz", k_FlippedImageStackDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); const std::string filePrefix = "image_flip_odd_even_"; @@ -538,7 +541,7 @@ TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Odd-Even TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Odd-Odd X/Y", "[ITKImageProcessing][ITKImportImageStackFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "image_flip_test_images.tar.gz", k_FlippedImageStackDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); const std::string filePrefix = "image_flip_odd_odd_"; @@ -562,7 +565,7 @@ TEST_CASE("ITKImageProcessing::ITKImportImageStackFilter: Flipped Image Odd-Odd TEST_CASE("ITKImportImageStack::Baseline_NoProcessing", "[ITKImageProcessing][ITKImportImageStackFilter][Baseline]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -594,7 +597,7 @@ TEST_CASE("ITKImportImageStack::Baseline_NoProcessing", "[ITKImageProcessing][IT TEST_CASE("ITKImportImageStack::Crop_Voxel_XOnly", "[ITKImageProcessing][ITKImportImageStackFilter][Cropping]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -623,7 +626,7 @@ TEST_CASE("ITKImportImageStack::Crop_Voxel_XOnly", "[ITKImageProcessing][ITKImpo TEST_CASE("ITKImportImageStack::Crop_Voxel_YOnly", "[ITKImageProcessing][ITKImportImageStackFilter][Cropping]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -652,7 +655,7 @@ TEST_CASE("ITKImportImageStack::Crop_Voxel_YOnly", "[ITKImageProcessing][ITKImpo TEST_CASE("ITKImportImageStack::Crop_Voxel_ZOnly", "[ITKImageProcessing][ITKImportImageStackFilter][Cropping]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -681,7 +684,7 @@ TEST_CASE("ITKImportImageStack::Crop_Voxel_ZOnly", "[ITKImageProcessing][ITKImpo TEST_CASE("ITKImportImageStack::Crop_Voxel_XY", "[ITKImageProcessing][ITKImportImageStackFilter][Cropping]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -710,7 +713,7 @@ TEST_CASE("ITKImportImageStack::Crop_Voxel_XY", "[ITKImageProcessing][ITKImportI TEST_CASE("ITKImportImageStack::Crop_Voxel_XYZ", "[ITKImageProcessing][ITKImportImageStackFilter][Cropping]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -739,7 +742,7 @@ TEST_CASE("ITKImportImageStack::Crop_Voxel_XYZ", "[ITKImageProcessing][ITKImport TEST_CASE("ITKImportImageStack::Crop_Physical_XY", "[ITKImageProcessing][ITKImportImageStackFilter][Cropping]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -768,7 +771,7 @@ TEST_CASE("ITKImportImageStack::Crop_Physical_XY", "[ITKImageProcessing][ITKImpo TEST_CASE("ITKImportImageStack::Crop_Physical_Z", "[ITKImageProcessing][ITKImportImageStackFilter][Cropping]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -801,7 +804,7 @@ TEST_CASE("ITKImportImageStack::Crop_Physical_Z", "[ITKImageProcessing][ITKImpor TEST_CASE("ITKImportImageStack::Resample_ScalingFactor", "[ITKImageProcessing][ITKImportImageStackFilter][Resampling]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -830,7 +833,7 @@ TEST_CASE("ITKImportImageStack::Resample_ScalingFactor", "[ITKImageProcessing][I TEST_CASE("ITKImportImageStack::Resample_ExactDimensions", "[ITKImageProcessing][ITKImportImageStackFilter][Resampling]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -863,7 +866,7 @@ TEST_CASE("ITKImportImageStack::Resample_ExactDimensions", "[ITKImageProcessing] TEST_CASE("ITKImportImageStack::Grayscale_Conversion", "[ITKImageProcessing][ITKImportImageStackFilter][Grayscale]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -900,7 +903,7 @@ TEST_CASE("ITKImportImageStack::Grayscale_Conversion", "[ITKImageProcessing][ITK TEST_CASE("ITKImportImageStack::FlipY", "[ITKImageProcessing][ITKImportImageStackFilter][Flip]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -932,7 +935,7 @@ TEST_CASE("ITKImportImageStack::FlipY", "[ITKImageProcessing][ITKImportImageStac TEST_CASE("ITKImportImageStack::OriginSpacing_Preprocessed", "[ITKImageProcessing][ITKImportImageStackFilter][OriginSpacing]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -962,7 +965,7 @@ TEST_CASE("ITKImportImageStack::OriginSpacing_Preprocessed", "[ITKImageProcessin TEST_CASE("ITKImportImageStack::OriginSpacing_Postprocessed", "[ITKImageProcessing][ITKImportImageStackFilter][OriginSpacing]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -991,7 +994,7 @@ TEST_CASE("ITKImportImageStack::OriginSpacing_Postprocessed", "[ITKImageProcessi TEST_CASE("ITKImportImageStack::OriginSpacing_Preprocessed_WithZCrop", "[ITKImageProcessing][ITKImportImageStackFilter][OriginSpacing]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1020,7 +1023,7 @@ TEST_CASE("ITKImportImageStack::OriginSpacing_Preprocessed_WithZCrop", "[ITKImag TEST_CASE("ITKImportImageStack::OriginSpacing_Postprocessed_WithZCrop", "[ITKImageProcessing][ITKImportImageStackFilter][OriginSpacing]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1053,7 +1056,7 @@ TEST_CASE("ITKImportImageStack::OriginSpacing_Postprocessed_WithZCrop", "[ITKIma TEST_CASE("ITKImportImageStack::Interaction_Crop_Resample", "[ITKImageProcessing][ITKImportImageStackFilter][Interaction]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1082,7 +1085,7 @@ TEST_CASE("ITKImportImageStack::Interaction_Crop_Resample", "[ITKImageProcessing TEST_CASE("ITKImportImageStack::Interaction_Crop_Flip", "[ITKImageProcessing][ITKImportImageStackFilter][Interaction]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1111,7 +1114,7 @@ TEST_CASE("ITKImportImageStack::Interaction_Crop_Flip", "[ITKImageProcessing][IT TEST_CASE("ITKImportImageStack::Interaction_Resample_Flip", "[ITKImageProcessing][ITKImportImageStackFilter][Interaction]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1140,7 +1143,7 @@ TEST_CASE("ITKImportImageStack::Interaction_Resample_Flip", "[ITKImageProcessing TEST_CASE("ITKImportImageStack::Interaction_Crop_Grayscale", "[ITKImageProcessing][ITKImportImageStackFilter][Interaction]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1172,7 +1175,7 @@ TEST_CASE("ITKImportImageStack::Interaction_Crop_Grayscale", "[ITKImageProcessin TEST_CASE("ITKImportImageStack::Interaction_Resample_Grayscale", "[ITKImageProcessing][ITKImportImageStackFilter][Interaction]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1205,7 +1208,7 @@ TEST_CASE("ITKImportImageStack::Interaction_Resample_Grayscale", "[ITKImageProce TEST_CASE("ITKImportImageStack::Interaction_Grayscale_Flip", "[ITKImageProcessing][ITKImportImageStackFilter][Interaction]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; @@ -1237,7 +1240,7 @@ TEST_CASE("ITKImportImageStack::Interaction_Grayscale_Flip", "[ITKImageProcessin TEST_CASE("ITKImportImageStack::Interaction_FullPipeline", "[ITKImageProcessing][ITKImportImageStackFilter][Interaction]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v2.tar.gz", k_TestDataDirName); + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); UnitTest::LoadPlugins(); DataStructure ds; diff --git a/src/Plugins/SimplnxCore/CMakeLists.txt b/src/Plugins/SimplnxCore/CMakeLists.txt index a19eff9ce3..917e3688b7 100644 --- a/src/Plugins/SimplnxCore/CMakeLists.txt +++ b/src/Plugins/SimplnxCore/CMakeLists.txt @@ -112,6 +112,8 @@ set(FilterList ReadDeformKeyFileV12Filter ReadDREAM3DFilter ReadHDF5DatasetFilter + ReadImageFilter + ReadImageStackFilter ReadRawBinaryFilter ReadStlFileFilter ReadStringDataArrayFilter @@ -161,6 +163,7 @@ set(FilterList WriteStlFileFilter WriteVtkRectilinearGridFilter WriteVtkStructuredPointsFilter + WriteImageFilter ) set(ActionList @@ -274,6 +277,8 @@ set(AlgorithmList ReadDeformKeyFileV12 # ReadDREAM3D ReadHDF5Dataset + ReadImage + ReadImageStack ReadRawBinary ReadStlFile ReadStringDataArray @@ -324,6 +329,7 @@ set(AlgorithmList WriteStlFile WriteVtkRectilinearGrid WriteVtkStructuredPoints + WriteImage ) create_simplnx_plugin(NAME ${PLUGIN_NAME} @@ -357,7 +363,6 @@ file(TO_CMAKE_PATH "${reproc_dll_path}" reproc_dll_path) get_property(SIMPLNX_EXTRA_LIBRARY_DIRS GLOBAL PROPERTY SIMPLNX_EXTRA_LIBRARY_DIRS) set_property(GLOBAL PROPERTY SIMPLNX_EXTRA_LIBRARY_DIRS ${SIMPLNX_EXTRA_LIBRARY_DIRS} ${reproc_dll_path}) - #------------------------------------------------------------------------------ # If there are additional libraries that this plugin needs to link against you # can use the target_link_libraries() cmake call @@ -568,18 +573,18 @@ install(FILES COMPONENT Applications ) -add_executable(txm_reader ${OLESS_SOURCES} ${${PLUGIN_NAME}_SOURCE_DIR}/src/${PLUGIN_NAME}/oless/txm_reader.cpp) -target_include_directories(txm_reader PRIVATE ${${PLUGIN_NAME}_SOURCE_DIR}/src/${PLUGIN_NAME}/oless) +# add_executable(txm_reader ${OLESS_SOURCES} ${${PLUGIN_NAME}_SOURCE_DIR}/src/${PLUGIN_NAME}/oless/txm_reader.cpp) +# target_include_directories(txm_reader PRIVATE ${${PLUGIN_NAME}_SOURCE_DIR}/src/${PLUGIN_NAME}/oless) -target_compile_features(txm_reader - PUBLIC - cxx_std_17 -) +# target_compile_features(txm_reader +# PUBLIC +# cxx_std_17 +# ) -set_target_properties(txm_reader - PROPERTIES - DEBUG_POSTFIX "_d" -) +# set_target_properties(txm_reader +# PROPERTIES +# DEBUG_POSTFIX "_d" +# ) # target_include_directories(txm_reader # PUBLIC # ${${PLUGIN_NAME}_SOURCE_DIR}/src/${PLUGIN_NAME}/oless diff --git a/src/Plugins/SimplnxCore/docs/ReadImageFilter.md b/src/Plugins/SimplnxCore/docs/ReadImageFilter.md new file mode 100644 index 0000000000..d347681217 --- /dev/null +++ b/src/Plugins/SimplnxCore/docs/ReadImageFilter.md @@ -0,0 +1,35 @@ +# INSERT_HUMAN_NAME + +## Group (Subgroup) + +What group (and possibly subgroup) does the filter belong to + +## Description + +This **Filter** ..... + +Images can be used with this: + +![](Images/ReadImage_1.png) + +## Warning + +## Notes + +## Caveats + +% Auto generated parameter table will be inserted here + +## Reference + + +## Example Pipelines + + +## License & Copyright + +Please see the description file distributed with this plugin. + +## DREAM3D Mailing Lists + +If you need help, need to file a bug report or want to request a new feature, please head over to the [DREAM3DNX-Issues](https://github.com/BlueQuartzSoftware/DREAM3DNX-Issues/discussions) GitHub site where the community of DREAM3D-NX users can help answer your questions. diff --git a/src/Plugins/SimplnxCore/docs/ReadImageStackFilter.md b/src/Plugins/SimplnxCore/docs/ReadImageStackFilter.md new file mode 100644 index 0000000000..189227e036 --- /dev/null +++ b/src/Plugins/SimplnxCore/docs/ReadImageStackFilter.md @@ -0,0 +1,35 @@ +# INSERT_HUMAN_NAME + +## Group (Subgroup) + +What group (and possibly subgroup) does the filter belong to + +## Description + +This **Filter** ..... + +Images can be used with this: + +![](Images/ReadImageStack_1.png) + +## Warning + +## Notes + +## Caveats + +% Auto generated parameter table will be inserted here + +## Reference + + +## Example Pipelines + + +## License & Copyright + +Please see the description file distributed with this plugin. + +## DREAM3D Mailing Lists + +If you need help, need to file a bug report or want to request a new feature, please head over to the [DREAM3DNX-Issues](https://github.com/BlueQuartzSoftware/DREAM3DNX-Issues/discussions) GitHub site where the community of DREAM3D-NX users can help answer your questions. diff --git a/src/Plugins/SimplnxCore/docs/WriteImageFilter.md b/src/Plugins/SimplnxCore/docs/WriteImageFilter.md new file mode 100644 index 0000000000..79a6b18424 --- /dev/null +++ b/src/Plugins/SimplnxCore/docs/WriteImageFilter.md @@ -0,0 +1,35 @@ +# INSERT_HUMAN_NAME + +## Group (Subgroup) + +What group (and possibly subgroup) does the filter belong to + +## Description + +This **Filter** ..... + +Images can be used with this: + +![](Images/WriteImage_1.png) + +## Warning + +## Notes + +## Caveats + +% Auto generated parameter table will be inserted here + +## Reference + + +## Example Pipelines + + +## License & Copyright + +Please see the description file distributed with this plugin. + +## DREAM3D Mailing Lists + +If you need help, need to file a bug report or want to request a new feature, please head over to the [DREAM3DNX-Issues](https://github.com/BlueQuartzSoftware/DREAM3DNX-Issues/discussions) GitHub site where the community of DREAM3D-NX users can help answer your questions. diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImage.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImage.cpp new file mode 100644 index 0000000000..cb6d4a903c --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImage.cpp @@ -0,0 +1,285 @@ +#include "ReadImage.hpp" + +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/DataGroup.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Utilities/FilterUtilities.hpp" +#include "simplnx/Utilities/ImageIO/IImageIO.hpp" +#include "simplnx/Utilities/ImageIO/ImageIOFactory.hpp" + +#include + +#include +#include + +using namespace nx::core; + +namespace +{ +usize BytesPerComponent(DataType dt) +{ + switch(dt) + { + case DataType::uint8: + return 1; + case DataType::uint16: + return 2; + case DataType::uint32: + return 4; + case DataType::float32: + return 4; + default: + return 0; + } +} + +// Cropping window inside the source image. If no cropping is used, the window covers the full image. +struct CropWindow +{ + usize srcWidth = 0; + usize srcHeight = 0; + usize dstWidth = 0; + usize dstHeight = 0; + usize xStart = 0; + usize yStart = 0; + usize numComponents = 1; +}; + +struct CopyPixelDataFunctor +{ + template + Result<> operator()(IDataArray& dataArray, const std::vector& buffer, const CropWindow& window) + { + auto& dataStore = dataArray.template getIDataStoreRefAs>(); + const T* typedBuffer = reinterpret_cast(buffer.data()); + + const usize nComps = window.numComponents; + const usize srcWidth = window.srcWidth; + const usize dstWidth = window.dstWidth; + const usize xStart = window.xStart; + const usize yStart = window.yStart; + + for(usize y = 0; y < window.dstHeight; y++) + { + const usize srcY = y + yStart; + for(usize x = 0; x < window.dstWidth; x++) + { + const usize srcX = x + xStart; + const usize srcIndex = (srcY * srcWidth + srcX) * nComps; + const usize dstIndex = (y * dstWidth + x) * nComps; + for(usize c = 0; c < nComps; c++) + { + dataStore[dstIndex + c] = typedBuffer[srcIndex + c]; + } + } + } + return {}; + } +}; + +template +struct ConvertPixelDataFunctor +{ + template + Result<> operator()(IDataArray& dataArray, const std::vector& buffer, const CropWindow& window) + { + auto& dataStore = dataArray.template getIDataStoreRefAs>(); + const SrcT* typedBuffer = reinterpret_cast(buffer.data()); + + constexpr double srcMax = static_cast(std::numeric_limits::max()); + constexpr double destMax = static_cast(std::numeric_limits::max()); + + const usize nComps = window.numComponents; + const usize srcWidth = window.srcWidth; + const usize dstWidth = window.dstWidth; + const usize xStart = window.xStart; + const usize yStart = window.yStart; + + for(usize y = 0; y < window.dstHeight; y++) + { + const usize srcY = y + yStart; + for(usize x = 0; x < window.dstWidth; x++) + { + const usize srcX = x + xStart; + const usize srcIndex = (srcY * srcWidth + srcX) * nComps; + const usize dstIndex = (y * dstWidth + x) * nComps; + for(usize c = 0; c < nComps; c++) + { + const double normalized = static_cast(typedBuffer[srcIndex + c]) / srcMax; + dataStore[dstIndex + c] = static_cast(normalized * destMax); + } + } + } + return {}; + } +}; + +struct DispatchConversionFunctor +{ + template + Result<> operator()(DataType destType, IDataArray& dataArray, const std::vector& buffer, const CropWindow& window) + { + return ExecuteDataFunction(ConvertPixelDataFunctor{}, destType, dataArray, buffer, window); + } +}; +} // namespace + +// ----------------------------------------------------------------------------- +ReadImage::ReadImage(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, ReadImageInputValues* inputValues) +: m_DataStructure(dataStructure) +, m_InputValues(inputValues) +, m_ShouldCancel(shouldCancel) +, m_MessageHandler(mesgHandler) +{ +} + +// ----------------------------------------------------------------------------- +ReadImage::~ReadImage() noexcept = default; + +// ----------------------------------------------------------------------------- +Result<> ReadImage::operator()() +{ + const auto& inputFilePath = m_InputValues->inputFilePath; + + m_MessageHandler(IFilter::Message::Type::Info, fmt::format("Reading image file: {}", inputFilePath.string())); + + // Create the appropriate image IO backend + auto backendResult = CreateImageIO(inputFilePath); + if(backendResult.invalid()) + { + return ConvertResult(std::move(backendResult)); + } + auto& backend = backendResult.value(); + + // Read metadata + auto metadataResult = backend->readMetadata(inputFilePath); + if(metadataResult.invalid()) + { + return ConvertResult(std::move(metadataResult)); + } + const auto& metadata = metadataResult.value(); + + // Compute buffer size + usize bytesPerComp = BytesPerComponent(metadata.dataType); + if(bytesPerComp == 0) + { + return MakeErrorResult(-2000, fmt::format("Unsupported source data type in image file: {}", inputFilePath.string())); + } + usize bufferSize = metadata.width * metadata.height * metadata.numComponents * bytesPerComp; + + // Read pixel data + std::vector tempBuffer(bufferSize); + auto readResult = backend->readPixelData(inputFilePath, tempBuffer); + if(readResult.invalid()) + { + return readResult; + } + + if(m_ShouldCancel) + { + return {}; + } + + // Get the target DataArray + auto& dataArray = m_DataStructure.getDataRefAs(m_InputValues->imageDataArrayPath); + const auto& imageGeom = m_DataStructure.getDataRefAs(m_InputValues->imageGeometryPath); + const SizeVec3 geomDims = imageGeom.getDimensions(); + + // Build a cropping window describing which source pixels go into the (already-sized) DataStore. + CropWindow window; + window.srcWidth = metadata.width; + window.srcHeight = metadata.height; + window.numComponents = metadata.numComponents; + window.dstWidth = geomDims[0]; + window.dstHeight = geomDims[1]; + window.xStart = 0; + window.yStart = 0; + + const auto& croppingOptions = m_InputValues->croppingOptions; + const bool cropImage = croppingOptions.type != CropGeometryParameter::CropValues::TypeEnum::NoCropping; + const bool crop2dImage = cropImage && (croppingOptions.cropX || croppingOptions.cropY); + if(crop2dImage) + { + if(croppingOptions.type == CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume) + { + if(croppingOptions.cropX) + { + window.xStart = static_cast(croppingOptions.xBoundVoxels[0]); + } + if(croppingOptions.cropY) + { + window.yStart = static_cast(croppingOptions.yBoundVoxels[0]); + } + } + else // PhysicalSubvolume + { + // Convert physical coordinates to source voxel indices using the file's native origin/spacing. + // The ImageGeom's origin/spacing may have been overridden in preflight, but cropping bounds are + // interpreted against whatever origin/spacing was active when the crop filter ran. In the + // Preprocessed case, overrides were applied before cropping, so we mirror them here. + FloatVec3 srcOrigin = metadata.origin.value_or(FloatVec3{0.0f, 0.0f, 0.0f}); + FloatVec3 srcSpacing = metadata.spacing.value_or(FloatVec3{1.0f, 1.0f, 1.0f}); + if(m_InputValues->originSpacingProcessing == 0) // Preprocessed + { + if(m_InputValues->changeSpacing) + { + srcSpacing = m_InputValues->spacing; + } + if(m_InputValues->changeOrigin) + { + srcOrigin = m_InputValues->origin; + if(m_InputValues->centerOrigin) + { + srcOrigin[0] = -0.5f * srcSpacing[0] * static_cast(metadata.width); + srcOrigin[1] = -0.5f * srcSpacing[1] * static_cast(metadata.height); + srcOrigin[2] = 0.0f; + } + } + } + if(croppingOptions.cropX) + { + const float64 xMin = static_cast(croppingOptions.xBoundPhysical[0]); + const int64 voxelX = static_cast((xMin - static_cast(srcOrigin[0])) / static_cast(srcSpacing[0])); + window.xStart = voxelX < 0 ? 0 : static_cast(voxelX); + } + if(croppingOptions.cropY) + { + const float64 yMin = static_cast(croppingOptions.yBoundPhysical[0]); + const int64 voxelY = static_cast((yMin - static_cast(srcOrigin[1])) / static_cast(srcSpacing[1])); + window.yStart = voxelY < 0 ? 0 : static_cast(voxelY); + } + } + } + + // Safety clamp: ensure the crop window is entirely within the source image bounds. + if(window.xStart + window.dstWidth > window.srcWidth || window.yStart + window.dstHeight > window.srcHeight) + { + return MakeErrorResult(-2001, fmt::format("Crop window (start=[{},{}], size=[{},{}]) does not fit within the source image (size=[{},{}])", window.xStart, window.yStart, window.dstWidth, + window.dstHeight, window.srcWidth, window.srcHeight)); + } + + // Determine if type conversion is needed + DataType destType = dataArray.getDataType(); + DataType srcType = metadata.dataType; + + if(m_InputValues->changeDataType && srcType != destType) + { + m_MessageHandler(IFilter::Message::Type::Info, fmt::format("Converting pixel data from {} to {}", DataTypeToString(srcType), DataTypeToString(destType))); + auto convResult = ExecuteDataFunction(DispatchConversionFunctor{}, srcType, destType, dataArray, tempBuffer, window); + if(convResult.invalid()) + { + return convResult; + } + } + else + { + // Direct copy - source and dest types match + auto copyResult = ExecuteDataFunction(CopyPixelDataFunctor{}, srcType, dataArray, tempBuffer, window); + if(copyResult.invalid()) + { + return copyResult; + } + } + + return {}; +} diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImage.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImage.hpp new file mode 100644 index 0000000000..c025bde89e --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImage.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "SimplnxCore/SimplnxCore_export.hpp" + +#include "simplnx/Common/Array.hpp" +#include "simplnx/Common/Types.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/DataStructure.hpp" +#include "simplnx/Filter/IFilter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/CropGeometryParameter.hpp" + +#include + +namespace nx::core +{ + +struct SIMPLNXCORE_EXPORT ReadImageInputValues +{ + std::filesystem::path inputFilePath; + DataPath imageGeometryPath; + DataPath imageDataArrayPath; + std::string cellDataName; + bool changeOrigin = false; + bool centerOrigin = false; + FloatVec3 origin; + bool changeSpacing = false; + FloatVec3 spacing; + usize originSpacingProcessing = 1; // 0=Preprocessed, 1=Postprocessed + bool changeDataType = false; + DataType imageDataType = DataType::uint8; + CropGeometryParameter::ValueType croppingOptions; +}; + +/** + * @class ReadImage + * @brief This algorithm reads a single 2D image file into a pre-allocated DataArray + * using the IImageIO abstraction layer. + */ +class SIMPLNXCORE_EXPORT ReadImage +{ +public: + ReadImage(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, ReadImageInputValues* inputValues); + ~ReadImage() noexcept; + + ReadImage(const ReadImage&) = delete; + ReadImage(ReadImage&&) noexcept = delete; + ReadImage& operator=(const ReadImage&) = delete; + ReadImage& operator=(ReadImage&&) noexcept = delete; + + Result<> operator()(); + +private: + DataStructure& m_DataStructure; + const ReadImageInputValues* m_InputValues = nullptr; + const std::atomic_bool& m_ShouldCancel; + const IFilter::MessageHandler& m_MessageHandler; +}; + +} // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImageStack.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImageStack.cpp new file mode 100644 index 0000000000..47a98e6915 --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImageStack.cpp @@ -0,0 +1,391 @@ +#include "ReadImageStack.hpp" + +#include "SimplnxCore/Filters/ConvertColorToGrayScaleFilter.hpp" +#include "SimplnxCore/Filters/ReadImageFilter.hpp" +#include "SimplnxCore/Filters/ResampleImageGeomFilter.hpp" + +#include "simplnx/Common/TypesUtility.hpp" +#include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/DataGroup.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Filter/FilterHandle.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/CropGeometryParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" +#include "simplnx/Utilities/FilterUtilities.hpp" + +#include + +#include + +namespace fs = std::filesystem; + +using namespace nx::core; + +namespace +{ +const ChoicesParameter::ValueType k_NoImageTransform = 0; +const ChoicesParameter::ValueType k_FlipAboutXAxis = 1; +const ChoicesParameter::ValueType k_FlipAboutYAxis = 2; + +const ChoicesParameter::ValueType k_NoResampleModeIndex = 0; +const ChoicesParameter::ValueType k_ScalingModeIndex = 1; +const ChoicesParameter::ValueType k_ExactDimensionsModeIndex = 2; + +const Uuid k_SimplnxCorePluginId = *Uuid::FromString("05cc618b-781f-4ac0-b9ac-43f26ce1854f"); +const Uuid k_ReadImageFilterId = *Uuid::FromString("7391288d-3d0b-492e-93c9-e128e05c9737"); +const FilterHandle k_ReadImageFilterHandle(k_ReadImageFilterId, k_SimplnxCorePluginId); +const Uuid k_ColorToGrayScaleFilterId = *Uuid::FromString("d938a2aa-fee2-4db9-aa2f-2c34a9736580"); +const FilterHandle k_ColorToGrayScaleFilterHandle(k_ColorToGrayScaleFilterId, k_SimplnxCorePluginId); +const Uuid k_ResampleImageGeomFilterId = *Uuid::FromString("9783ea2c-4cf7-46de-ab21-b40d91a48c5b"); +const FilterHandle k_ResampleImageGeomFilterHandle(k_ResampleImageGeomFilterId, k_SimplnxCorePluginId); + +template +void FlipAboutYAxis(DataArray& dataArray, Vec3& dims) +{ + AbstractDataStore& tempDataStore = dataArray.getDataStoreRef(); + + usize numComp = tempDataStore.getNumberOfComponents(); + std::vector currentRowBuffer(dims[0] * dataArray.getNumberOfComponents()); + + for(usize row = 0; row < dims[1]; row++) + { + // Copy the current row into a temp buffer + typename AbstractDataStore::Iterator startIter = tempDataStore.begin() + (dims[0] * numComp * row); + typename AbstractDataStore::Iterator endIter = startIter + dims[0] * numComp; + std::copy(startIter, endIter, currentRowBuffer.begin()); + + // Starting at the last tuple in the buffer + usize bufferIndex = (dims[0] - 1) * numComp; + usize dataStoreIndex = row * dims[0] * numComp; + + for(usize tupleIdx = 0; tupleIdx < dims[0]; tupleIdx++) + { + for(usize cIdx = 0; cIdx < numComp; cIdx++) + { + tempDataStore.setValue(dataStoreIndex, currentRowBuffer[bufferIndex + cIdx]); + dataStoreIndex++; + } + bufferIndex = bufferIndex - numComp; + } + } +} + +template +void FlipAboutXAxis(DataArray& dataArray, Vec3& dims) +{ + AbstractDataStore& tempDataStore = dataArray.getDataStoreRef(); + usize numComp = tempDataStore.getNumberOfComponents(); + size_t rowLCV = (dims[1] % 2 == 1) ? ((dims[1] - 1) / 2) : dims[1] / 2; + usize bottomRow = dims[1] - 1; + + for(usize row = 0; row < rowLCV; row++) + { + // Copy the "top" row into a temp buffer + usize topStartIter = 0 + (dims[0] * numComp * row); + usize topEndIter = topStartIter + dims[0] * numComp; + usize bottomStartIter = 0 + (dims[0] * numComp * bottomRow); + + // Copy from bottom to top and then temp to bottom + for(usize eleIndex = topStartIter; eleIndex < topEndIter; eleIndex++) + { + T value = tempDataStore.getValue(eleIndex); + tempDataStore[eleIndex] = tempDataStore[bottomStartIter]; + tempDataStore[bottomStartIter] = value; + bottomStartIter++; + } + bottomRow--; + } +} + +template +Result<> ReadImageStackImpl(DataStructure& dataStructure, const ReadImageStackInputValues* inputValues, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel) +{ + const DataPath& imageGeomPath = inputValues->imageGeometryPath; + const std::string& cellDataName = inputValues->cellDataName; + const std::string& imageArrayName = inputValues->imageDataArrayName; + const std::vector& files = inputValues->fileList; + const ChoicesParameter::ValueType transformType = inputValues->imageTransformChoice; + const bool convertToGrayscale = inputValues->convertToGrayScale; + const VectorFloat32Parameter::ValueType& luminosityValues = inputValues->colorWeights; + const ChoicesParameter::ValueType resample = inputValues->resampleImagesChoice; + const float32 scalingFactor = inputValues->scaling; + const VectorUInt64Parameter::ValueType& exactDims = inputValues->exactXYDimensions; + const bool changeDataType = inputValues->changeDataType; + const ChoicesParameter::ValueType destType = inputValues->imageDataTypeChoice; + const CropGeometryParameter::ValueType& croppingOptions = inputValues->croppingOptions; + const bool shouldChangeOrigin = inputValues->changeOrigin; + const VectorFloat64Parameter::ValueType& origin = inputValues->origin; + const bool shouldChangeSpacing = inputValues->changeSpacing; + const VectorFloat64Parameter::ValueType& spacing = inputValues->spacing; + const usize originSpacingProcessing = inputValues->originSpacingProcessing; + + DataPath destImageGeomPath = imageGeomPath; + auto& destImageGeomInitial = dataStructure.getDataRefAs(destImageGeomPath); + + FilterList* filterListPtr = Application::Instance()->getFilterList(); + + if((convertToGrayscale || resample != k_NoResampleModeIndex) && !filterListPtr->containsPlugin(k_SimplnxCorePluginId)) + { + return MakeErrorResult(-18542, "SimplnxCore was not instantiated in this instance, so color to grayscale / resample is not a valid option."); + } + std::unique_ptr grayScaleFilter = filterListPtr->createFilter(k_ColorToGrayScaleFilterHandle); + Result<> outputResult = {}; + + // Determine start/end slice based on Z cropping + usize startSlice = 0; + usize endSlice = files.size() - 1; + if(croppingOptions.cropZ && croppingOptions.type == CropGeometryParameter::ValueType::TypeEnum::VoxelSubvolume) + { + startSlice = static_cast(croppingOptions.zBoundVoxels[0]); + endSlice = static_cast(croppingOptions.zBoundVoxels[1]); + } + else if(croppingOptions.cropZ && croppingOptions.type == CropGeometryParameter::ValueType::TypeEnum::PhysicalSubvolume) + { + SizeVec3 destDims = destImageGeomInitial.getDimensions(); + FloatVec3 destOrigin = destImageGeomInitial.getOrigin(); + + std::optional result = destImageGeomInitial.getIndex(destOrigin[0], destOrigin[1], croppingOptions.zBoundPhysical[0]); + if(result.has_value()) + { + startSlice = result.value() / (destDims[0] * destDims[1]); + } + result = destImageGeomInitial.getIndex(destOrigin[0], destOrigin[1], croppingOptions.zBoundPhysical[1]); + if(result.has_value()) + { + endSlice = result.value() / (destDims[0] * destDims[1]); + } + } + + // Loop over all the files, importing them one by one and copying the data into the destination data array + usize slice = 0; + for(usize i = startSlice; i <= endSlice; i++) + { + const std::string& filePath = files[i]; + messageHandler(IFilter::Message::Type::Info, fmt::format("Importing: {}", filePath)); + + DataStructure importedDataStructure; + { + // Create a sub-filter instance to read each image + std::unique_ptr imageReader = filterListPtr->createFilter(k_ReadImageFilterHandle); + if(nullptr == imageReader.get()) + { + return MakeErrorResult(-18543, "Unable to create an instance of ReadImageFilter, so image stack reading is not available."); + } + + Arguments args; + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, std::make_any(imageGeomPath)); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, std::make_any(cellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, std::make_any(imageArrayName)); + args.insertOrAssign(ReadImageFilter::k_FileName_Key, std::make_any(filePath)); + args.insertOrAssign(ReadImageFilter::k_ChangeDataType_Key, std::make_any(changeDataType)); + args.insertOrAssign(ReadImageFilter::k_ImageDataType_Key, std::make_any(destType)); + args.insertOrAssign(ReadImageFilter::k_LengthUnit_Key, std::make_any(to_underlying(IGeometry::LengthUnit::Micrometer))); + // Do not set the origin/spacing if processing timing is postprocessed; the main filter will set it at the end + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, std::make_any(shouldChangeOrigin && originSpacingProcessing == 0)); + args.insertOrAssign(ReadImageFilter::k_CenterOrigin_Key, std::make_any(false)); + args.insertOrAssign(ReadImageFilter::k_Origin_Key, std::make_any(origin)); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, std::make_any(shouldChangeSpacing && originSpacingProcessing == 0)); + args.insertOrAssign(ReadImageFilter::k_Spacing_Key, std::make_any(spacing)); + args.insertOrAssign(ReadImageFilter::k_OriginSpacingProcessing_Key, std::make_any(originSpacingProcessing)); + args.insertOrAssign(ReadImageFilter::k_CroppingOptions_Key, std::make_any(croppingOptions)); + + IFilter::ExecuteResult executeResult = imageReader->execute(importedDataStructure, args); + if(executeResult.result.invalid()) + { + return executeResult.result; + } + } + + // ======================= Resample Image Geometry Section =================== + if(resample != k_NoResampleModeIndex) + { + std::unique_ptr resampleImageGeomFilter = filterListPtr->createFilter(k_ResampleImageGeomFilterHandle); + if(nullptr == resampleImageGeomFilter.get()) + { + return MakeErrorResult(-18545, "Unable to create an instance of ResampleImageGeomFilter."); + } + if(resample == k_ScalingModeIndex) + { + if(scalingFactor == 100.0f) + { + // No-op + } + else + { + Arguments resampleImageGeomArgs; + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_SelectedImageGeometryPath_Key, std::make_any(imageGeomPath)); + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_RemoveOriginalGeometry_Key, std::make_any(true)); + + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_ResamplingMode_Key, std::make_any(1)); + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_Scaling_Key, std::make_any(std::vector{scalingFactor, scalingFactor, 100.0f})); + + // Run resample image geometry filter and process results and messages + Result<> result = resampleImageGeomFilter->execute(importedDataStructure, resampleImageGeomArgs).result; + if(result.invalid()) + { + return result; + } + } + } + else + { + Arguments resampleImageGeomArgs; + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_SelectedImageGeometryPath_Key, std::make_any(imageGeomPath)); + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_RemoveOriginalGeometry_Key, std::make_any(true)); + + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_ResamplingMode_Key, std::make_any(2)); + resampleImageGeomArgs.insertOrAssign(ResampleImageGeomFilter::k_ExactDimensions_Key, std::make_any(std::vector{exactDims[0], exactDims[1], 1})); + + // Run resample image geometry filter and process results and messages + Result<> result = resampleImageGeomFilter->execute(importedDataStructure, resampleImageGeomArgs).result; + if(result.invalid()) + { + return result; + } + } + + // The preflight creates the resampled geometry at a "_resampled" path in the main DataStructure. + // The final rename back to the original path happens via a deferred action AFTER execute completes, + // so during algorithm execution we must write to the "_resampled" path. + destImageGeomPath = DataPath({imageGeomPath.getTargetName() + "_resampled"}); + } + + // ======================= Convert to GrayScale Section =================== + DataPath srcImageDataPath = imageGeomPath.createChildPath(cellDataName).createChildPath(imageArrayName); + + bool validInputForGrayScaleConversion = importedDataStructure.getDataRefAs(srcImageDataPath).getDataType() == DataType::uint8; + if(convertToGrayscale && validInputForGrayScaleConversion && nullptr != grayScaleFilter.get()) + { + Arguments colorToGrayscaleArgs; + colorToGrayscaleArgs.insertOrAssign(ConvertColorToGrayScaleFilter::k_ConversionAlgorithm_Key, std::make_any(0)); + colorToGrayscaleArgs.insertOrAssign(ConvertColorToGrayScaleFilter::k_ColorWeights_Key, std::make_any(luminosityValues)); + colorToGrayscaleArgs.insertOrAssign(ConvertColorToGrayScaleFilter::k_InputDataArrayPath_Key, std::make_any>(std::vector{srcImageDataPath})); + colorToGrayscaleArgs.insertOrAssign(ConvertColorToGrayScaleFilter::k_OutputArrayPrefix_Key, std::make_any("gray")); + + // Run grayscale filter and process results and messages + Result<> result = grayScaleFilter->execute(importedDataStructure, colorToGrayscaleArgs).result; + if(result.invalid()) + { + return result; + } + + // deletion of non-grayscale array + DataObject::IdType id; + { // scoped for safety since this reference will be nonexistent in a moment + auto& oldArray = importedDataStructure.getDataRefAs(srcImageDataPath); + id = oldArray.getId(); + } + importedDataStructure.removeData(id); + + // rename grayscale array to reflect original + { + auto& gray = importedDataStructure.getDataRefAs(srcImageDataPath.replaceName("gray" + srcImageDataPath.getTargetName())); + if(!gray.canRename(srcImageDataPath.getTargetName())) + { + return MakeErrorResult(-64543, fmt::format("Unable to rename the internal grayscale array to {}", srcImageDataPath.getTargetName())); + } + gray.rename(srcImageDataPath.getTargetName()); + } + } + else if(convertToGrayscale && !validInputForGrayScaleConversion) + { + outputResult.warnings().emplace_back( + Warning{-74320, fmt::format("The array ({}) resulting from reading the input image file is not a UInt8Array. The input image will not be converted to grayscale.", + srcImageDataPath.getTargetName())}); + } + + auto& destImageGeom = dataStructure.getDataRefAs(destImageGeomPath); + SizeVec3 destDims = destImageGeom.getDimensions(); + const usize destTuplesPerSlice = destDims[0] * destDims[1]; + + // Check the ImageGeometry of the imported Image matches the destination + const auto& importedImageGeom = importedDataStructure.getDataRefAs(imageGeomPath); + SizeVec3 importedDims = importedImageGeom.getDimensions(); + if(destDims[0] != importedDims[0] || destDims[1] != importedDims[1]) + { + return MakeErrorResult(-64510, fmt::format("Slice {} image dimensions are different than expected dimensions.\n Expected Slice Dims are: {} x {}\n Received Slice Dims are: {} x {}\n", slice, + destDims[0], destDims[1], importedDims[0], importedDims[1])); + } + + // Compute the Tuple Index we are at: + const usize destTupleIndex = (slice * destDims[0] * destDims[1]); + + // get the current Slice data... + auto& srcData = importedDataStructure.getDataRefAs>(srcImageDataPath); + AbstractDataStore& srcDataStore = srcData.getDataStoreRef(); + + if(transformType == k_FlipAboutYAxis) + { + FlipAboutYAxis(srcData, destDims); + } + else if(transformType == k_FlipAboutXAxis) + { + FlipAboutXAxis(srcData, destDims); + } + + // Copy that into the output array... + // When grayscale conversion is requested, the preflight creates the destination array with a + // "grayscale_" prefix and renames it back to the original name via a deferred action after + // execute completes. During algorithm execution we must write to the prefixed path. + DataPath destImageDataPath = convertToGrayscale ? destImageGeomPath.createChildPath(cellDataName).createChildPath("grayscale_" + imageArrayName) : + destImageGeomPath.createChildPath(cellDataName).createChildPath(imageArrayName); + auto& outputData = dataStructure.getDataRefAs>(destImageDataPath); + AbstractDataStore& outputDataStore = outputData.getDataStoreRef(); + Result<> result = outputDataStore.copyFrom(destTupleIndex, srcDataStore, 0, destTuplesPerSlice); + if(result.invalid()) + { + return result; + } + + slice++; + + // Check to see if the filter got canceled. + if(shouldCancel) + { + return outputResult; + } + } + + return outputResult; +} + +struct ReadImageStackDispatchFunctor +{ + template + Result<> operator()(DataStructure& dataStructure, const ReadImageStackInputValues* inputValues, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel) + { + return ReadImageStackImpl(dataStructure, inputValues, messageHandler, shouldCancel); + } +}; +} // namespace + +// ----------------------------------------------------------------------------- +ReadImageStack::ReadImageStack(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, ReadImageStackInputValues* inputValues) +: m_DataStructure(dataStructure) +, m_InputValues(inputValues) +, m_ShouldCancel(shouldCancel) +, m_MessageHandler(mesgHandler) +{ +} + +// ----------------------------------------------------------------------------- +ReadImageStack::~ReadImageStack() noexcept = default; + +// ----------------------------------------------------------------------------- +Result<> ReadImageStack::operator()() +{ + // Determine the DataType of the final destination DataArray from the DataStructure + const std::string& imageArrayName = m_InputValues->imageDataArrayName; + const std::string& cellDataName = m_InputValues->cellDataName; + DataPath destImageGeomPath = m_InputValues->imageGeometryPath; + DataPath destImageDataPath = destImageGeomPath.createChildPath(cellDataName).createChildPath(imageArrayName); + + auto& destArray = m_DataStructure.getDataRefAs(destImageDataPath); + DataType destDataType = destArray.getDataType(); + + return ExecuteDataFunction(ReadImageStackDispatchFunctor{}, destDataType, m_DataStructure, m_InputValues, m_MessageHandler, m_ShouldCancel); +} diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImageStack.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImageStack.hpp new file mode 100644 index 0000000000..664583ac78 --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadImageStack.hpp @@ -0,0 +1,69 @@ +#pragma once + +#include "SimplnxCore/SimplnxCore_export.hpp" + +#include "simplnx/Common/Array.hpp" +#include "simplnx/Common/Types.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/DataStructure.hpp" +#include "simplnx/Filter/IFilter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/CropGeometryParameter.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" + +#include +#include +#include + +namespace nx::core +{ + +struct SIMPLNXCORE_EXPORT ReadImageStackInputValues +{ + std::vector fileList; + DataPath imageGeometryPath; + std::string imageDataArrayName; + std::string cellDataName; + bool changeOrigin = false; + VectorFloat64Parameter::ValueType origin; + bool changeSpacing = false; + VectorFloat64Parameter::ValueType spacing; + usize originSpacingProcessing = 1; // 0=Preprocessed, 1=Postprocessed + usize imageTransformChoice = 0; // 0=None, 1=FlipAboutXAxis, 2=FlipAboutYAxis + bool convertToGrayScale = false; + VectorFloat32Parameter::ValueType colorWeights; + usize resampleImagesChoice = 0; // 0=None, 1=Scaling, 2=ExactDims + float32 scaling = 100.0f; + VectorUInt64Parameter::ValueType exactXYDimensions; + bool changeDataType = false; + usize imageDataTypeChoice = 0; + CropGeometryParameter::ValueType croppingOptions; +}; + +/** + * @class ReadImageStack + * @brief This algorithm reads a numbered sequence of 2D image files into a 3D DataArray + * using ReadImageFilter as a sub-filter for per-slice reading. Supports resampling, + * grayscale conversion, flip transforms, origin/spacing overrides, and Z-slice cropping. + */ +class SIMPLNXCORE_EXPORT ReadImageStack +{ +public: + ReadImageStack(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, ReadImageStackInputValues* inputValues); + ~ReadImageStack() noexcept; + + ReadImageStack(const ReadImageStack&) = delete; + ReadImageStack(ReadImageStack&&) noexcept = delete; + ReadImageStack& operator=(const ReadImageStack&) = delete; + ReadImageStack& operator=(ReadImageStack&&) noexcept = delete; + + Result<> operator()(); + +private: + DataStructure& m_DataStructure; + const ReadImageStackInputValues* m_InputValues = nullptr; + const std::atomic_bool& m_ShouldCancel; + const IFilter::MessageHandler& m_MessageHandler; +}; + +} // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteImage.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteImage.cpp new file mode 100644 index 0000000000..92752af972 --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteImage.cpp @@ -0,0 +1,228 @@ +#include "WriteImage.hpp" + +#include "simplnx/Common/AtomicFile.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Utilities/FilterUtilities.hpp" +#include "simplnx/Utilities/ImageIO/IImageIO.hpp" +#include "simplnx/Utilities/ImageIO/ImageIOFactory.hpp" +#include "simplnx/Utilities/ImageIO/ImageMetadata.hpp" + +#include + +#include +#include + +namespace fs = std::filesystem; + +using namespace nx::core; + +namespace +{ +/** + * @brief Functor that extracts a single 2D slice from a typed DataStore + * into a raw byte buffer suitable for IImageIO::writePixelData(). + */ +struct ExtractSliceFunctor +{ + template + Result<> operator()(const IDataArray& dataArray, std::vector& buffer, usize sliceIndex, usize planeIndex, usize dimX, usize dimY, usize dimZ, usize nComp) + { + const auto& dataStore = dataArray.template getIDataStoreRefAs>(); + T* typedBuffer = reinterpret_cast(buffer.data()); + + if(planeIndex == 0) // XY plane — iterate over Z, slice width=X, slice height=Y + { + usize z = sliceIndex; + for(usize y = 0; y < dimY; ++y) + { + for(usize x = 0; x < dimX; ++x) + { + usize srcIndex = (z * dimY * dimX + y * dimX + x) * nComp; + usize dstIndex = (y * dimX + x) * nComp; + for(usize c = 0; c < nComp; ++c) + { + typedBuffer[dstIndex + c] = dataStore.getValue(srcIndex + c); + } + } + } + } + else if(planeIndex == 1) // XZ plane — iterate over Y, slice width=X, slice height=Z + { + usize y = sliceIndex; + for(usize z = 0; z < dimZ; ++z) + { + for(usize x = 0; x < dimX; ++x) + { + usize srcIndex = (z * dimY * dimX + y * dimX + x) * nComp; + usize dstIndex = (z * dimX + x) * nComp; + for(usize c = 0; c < nComp; ++c) + { + typedBuffer[dstIndex + c] = dataStore.getValue(srcIndex + c); + } + } + } + } + else if(planeIndex == 2) // YZ plane — iterate over X, slice width=Y, slice height=Z + { + usize x = sliceIndex; + for(usize z = 0; z < dimZ; ++z) + { + for(usize y = 0; y < dimY; ++y) + { + usize srcIndex = (z * dimY * dimX + y * dimX + x) * nComp; + usize dstIndex = (z * dimY + y) * nComp; + for(usize c = 0; c < nComp; ++c) + { + typedBuffer[dstIndex + c] = dataStore.getValue(srcIndex + c); + } + } + } + } + + return {}; + } +}; +} // namespace + +// ----------------------------------------------------------------------------- +WriteImage::WriteImage(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, WriteImageInputValues* inputValues) +: m_DataStructure(dataStructure) +, m_InputValues(inputValues) +, m_ShouldCancel(shouldCancel) +, m_MessageHandler(mesgHandler) +{ +} + +// ----------------------------------------------------------------------------- +WriteImage::~WriteImage() noexcept = default; + +// ----------------------------------------------------------------------------- +Result<> WriteImage::operator()() +{ + const auto& imageGeom = m_DataStructure.getDataRefAs(m_InputValues->imageGeometryPath); + const auto& imageArray = m_DataStructure.getDataRefAs(m_InputValues->imageDataArrayPath); + + // Stored fastest to slowest: X, Y, Z + SizeVec3 dims = imageGeom.getDimensions(); + usize dimX = dims[0]; + usize dimY = dims[1]; + usize dimZ = dims[2]; + + usize nComp = imageArray.getNumberOfComponents(); + DataType dataType = imageArray.getDataType(); + usize bytesPerComponent = imageArray.getIDataStoreRef().getTypeSize(); + + // Determine slice parameters based on plane + usize sliceCount = 0; + usize sliceW = 0; + usize sliceH = 0; + + switch(m_InputValues->planeIndex) + { + case 0: // XY + sliceCount = dimZ; + sliceW = dimX; + sliceH = dimY; + break; + case 1: // XZ + sliceCount = dimY; + sliceW = dimX; + sliceH = dimZ; + break; + case 2: // YZ + sliceCount = dimX; + sliceW = dimY; + sliceH = dimZ; + break; + default: + return MakeErrorResult(-27000, fmt::format("Invalid plane index: {}", m_InputValues->planeIndex)); + } + + // Create the IImageIO backend + auto imageIOResult = CreateImageIO(m_InputValues->outputFilePath); + if(imageIOResult.invalid()) + { + return ConvertResult(std::move(imageIOResult)); + } + const auto& imageIO = imageIOResult.value(); + + // Create output directory if needed + fs::path parentDir = fs::absolute(m_InputValues->outputFilePath).parent_path(); + if(!fs::exists(parentDir)) + { + if(!fs::create_directories(parentDir)) + { + return MakeErrorResult(-27001, fmt::format("Error creating output directory '{}'", parentDir.string())); + } + } + + // Pre-compute filename parts + fs::path stem = m_InputValues->outputFilePath.stem(); + fs::path ext = m_InputValues->outputFilePath.extension(); + fs::path parent = fs::absolute(m_InputValues->outputFilePath).parent_path(); + + // Allocate temp buffer for one slice + usize sliceBufferSize = sliceW * sliceH * nComp * bytesPerComponent; + std::vector sliceBuffer(sliceBufferSize); + + for(usize slice = 0; slice < sliceCount; ++slice) + { + if(m_ShouldCancel) + { + return {}; + } + + m_MessageHandler(IFilter::Message::Type::Info, fmt::format("Writing slice {}/{}", slice + 1, sliceCount)); + + // Zero out the buffer + std::fill(sliceBuffer.begin(), sliceBuffer.end(), static_cast(0)); + + // Extract slice data using type-dispatched functor + auto extractResult = ExecuteDataFunction(ExtractSliceFunctor{}, dataType, imageArray, sliceBuffer, slice, m_InputValues->planeIndex, dimX, dimY, dimZ, nComp); + if(extractResult.invalid()) + { + return extractResult; + } + + // Build ImageMetadata for this 2D slice + ImageMetadata metadata; + metadata.width = sliceW; + metadata.height = sliceH; + metadata.numComponents = nComp; + metadata.dataType = dataType; + metadata.numPages = 1; + + // Generate filename: stem_NNN.ext + std::string indexStr = fmt::format("{}", slice + m_InputValues->indexOffset); + while(indexStr.size() < static_cast(m_InputValues->totalIndexDigits)) + { + indexStr = m_InputValues->leadingDigitCharacter + indexStr; + } + fs::path slicePath = parent / fmt::format("{}_{}{}", stem.string(), indexStr, ext.string()); + + // Use AtomicFile for safe writes + auto atomicFileResult = AtomicFile::Create(slicePath); + if(atomicFileResult.invalid()) + { + return ConvertResult(std::move(atomicFileResult)); + } + AtomicFile atomicFile = std::move(atomicFileResult.value()); + + // Write via IImageIO + auto writeResult = imageIO->writePixelData(atomicFile.tempFilePath(), sliceBuffer, metadata); + if(writeResult.invalid()) + { + return writeResult; + } + + // Commit the atomic file + auto commitResult = atomicFile.commit(); + if(commitResult.invalid()) + { + return commitResult; + } + } + + return {}; +} diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteImage.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteImage.hpp new file mode 100644 index 0000000000..92536566ed --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteImage.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "SimplnxCore/SimplnxCore_export.hpp" + +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/DataStructure.hpp" +#include "simplnx/Filter/IFilter.hpp" + +#include +#include + +namespace nx::core +{ + +struct SIMPLNXCORE_EXPORT WriteImageInputValues +{ + std::filesystem::path outputFilePath; + usize planeIndex = 0; ///< 0=XY, 1=XZ, 2=YZ + uint64 indexOffset = 0; + int32 totalIndexDigits = 3; + std::string leadingDigitCharacter = "0"; + DataPath imageGeometryPath; + DataPath imageDataArrayPath; +}; + +/** + * @class WriteImage + * @brief Extracts 2D slices from a 3D ImageGeometry and writes them as + * individual image files via the IImageIO layer. + */ +class SIMPLNXCORE_EXPORT WriteImage +{ +public: + WriteImage(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, WriteImageInputValues* inputValues); + ~WriteImage() noexcept; + + WriteImage(const WriteImage&) = delete; + WriteImage(WriteImage&&) noexcept = delete; + WriteImage& operator=(const WriteImage&) = delete; + WriteImage& operator=(WriteImage&&) noexcept = delete; + + Result<> operator()(); + +private: + DataStructure& m_DataStructure; + const WriteImageInputValues* m_InputValues = nullptr; + const std::atomic_bool& m_ShouldCancel; + const IFilter::MessageHandler& m_MessageHandler; +}; + +} // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageFilter.cpp new file mode 100644 index 0000000000..4771a91dc2 --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageFilter.cpp @@ -0,0 +1,398 @@ +#include "ReadImageFilter.hpp" + +#include "SimplnxCore/Filters/Algorithms/ReadImage.hpp" + +#include "simplnx/Common/TypesUtility.hpp" +#include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/DataStore.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Filter/Actions/CreateArrayAction.hpp" +#include "simplnx/Filter/Actions/CreateImageGeometryAction.hpp" +#include "simplnx/Filter/Actions/UpdateImageGeomAction.hpp" +#include "simplnx/Filter/FilterHandle.hpp" +#include "simplnx/Filter/FilterList.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/CropGeometryParameter.hpp" +#include "simplnx/Parameters/DataGroupCreationParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/FileSystemPathParameter.hpp" +#include "simplnx/Parameters/GeometrySelectionParameter.hpp" +#include "simplnx/Parameters/StringParameter.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" +#include "simplnx/Utilities/SIMPLConversion.hpp" + +#include "simplnx/Utilities/ImageIO/IImageIO.hpp" +#include "simplnx/Utilities/ImageIO/ImageIOFactory.hpp" + +#include + +#include +#include + +namespace fs = std::filesystem; + +using namespace nx::core; + +namespace +{ +const Uuid k_SimplnxCorePluginId = *Uuid::FromString("05cc618b-781f-4ac0-b9ac-43f26ce1854f"); +const Uuid k_CropImageGeomFilterId = *Uuid::FromString("e6476737-4aa7-48ba-a702-3dfab82c96e2"); +const FilterHandle k_CropImageGeomFilterHandle(k_CropImageGeomFilterId, k_SimplnxCorePluginId); + +DataType ConvertChoiceToDataType(usize choice) +{ + switch(choice) + { + case 0: + return DataType::uint8; + case 1: + return DataType::uint16; + case 2: + return DataType::uint32; + } + return DataType::uint8; +} +} // namespace + +namespace nx::core +{ +//------------------------------------------------------------------------------ +std::string ReadImageFilter::name() const +{ + return FilterTraits::name.str(); +} + +//------------------------------------------------------------------------------ +std::string ReadImageFilter::className() const +{ + return FilterTraits::className; +} + +//------------------------------------------------------------------------------ +Uuid ReadImageFilter::uuid() const +{ + return FilterTraits::uuid; +} + +//------------------------------------------------------------------------------ +std::string ReadImageFilter::humanName() const +{ + return "Read Image"; +} + +//------------------------------------------------------------------------------ +std::vector ReadImageFilter::defaultTags() const +{ + return {className(), "io", "input", "read", "import", "image", "jpg", "tiff", "bmp", "png"}; +} + +//------------------------------------------------------------------------------ +Parameters ReadImageFilter::parameters() const +{ + Parameters params; + + params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); + + params.insert(std::make_unique(k_FileName_Key, "File", "Input image file", fs::path(""), + FileSystemPathParameter::ExtensionsType{{".png"}, {".tiff"}, {".tif"}, {".bmp"}, {".jpeg"}, {".jpg"}}, + FileSystemPathParameter::PathType::InputFile, false)); + + params.insert(std::make_unique(k_LengthUnit_Key, "Length Unit", "The length unit that will be set into the created image geometry", + to_underlying(IGeometry::LengthUnit::Micrometer), IGeometry::GetAllLengthUnitStrings())); + + params.insertLinkableParameter(std::make_unique(k_ChangeDataType_Key, "Set Image Data Type", "Set the final created image data type.", false)); + params.insert(std::make_unique(k_ImageDataType_Key, "Output Data Type", "Numeric Type of data to create", 0ULL, + ChoicesParameter::Choices{"uint8", "uint16", "uint32"})); // Sequence Dependent DO NOT REORDER + + params.insertSeparator(Parameters::Separator{"Origin & Spacing Options"}); + params.insertLinkableParameter(std::make_unique(k_ChangeOrigin_Key, "Set Origin", "Specifies if the origin should be changed", false)); + params.insert( + std::make_unique(k_CenterOrigin_Key, "Put Input Origin at the Center of Geometry", "Specifies if the origin should be aligned with the corner (false) or center (true)", false)); + params.insert(std::make_unique(k_Origin_Key, "Origin (Physical Units)", "Specifies the new origin values in physical units.", std::vector{0.0, 0.0, 0.0}, + std::vector{"X", "Y", "Z"})); + + params.insertLinkableParameter(std::make_unique(k_ChangeSpacing_Key, "Set Spacing", "Specifies if the spacing should be changed", false)); + params.insert(std::make_unique(k_Spacing_Key, "Spacing (Physical Units)", "Specifies the new spacing values in physical units.", std::vector{1, 1, 1}, + std::vector{"X", "Y", "Z"})); + params.insert(std::make_unique(k_OriginSpacingProcessing_Key, "Origin & Spacing Processing", "Whether the origin & spacing should be preprocessed or postprocessed.", 1, + ChoicesParameter::Choices{"Preprocessed", "Postprocessed"})); + + params.linkParameters(k_ChangeDataType_Key, k_ImageDataType_Key, true); + + params.linkParameters(k_ChangeOrigin_Key, k_Origin_Key, std::make_any(true)); + params.linkParameters(k_ChangeOrigin_Key, k_CenterOrigin_Key, std::make_any(true)); + params.linkParameters(k_ChangeSpacing_Key, k_Spacing_Key, std::make_any(true)); + params.linkParameters(k_ChangeOrigin_Key, k_OriginSpacingProcessing_Key, true); + params.linkParameters(k_ChangeSpacing_Key, k_OriginSpacingProcessing_Key, true); + + params.insertSeparator(Parameters::Separator{"Cropping Options"}); + auto croppingOptions = CropGeometryParameter::ValueType{}; + croppingOptions.is2D = true; + params.insert(std::make_unique( + k_CroppingOptions_Key, "Cropping Options", + "The cropping options used to crop images. These include picking the cropping type, the cropping dimensions, and the cropping ranges for each chosen dimension.", croppingOptions)); + + params.insertSeparator(Parameters::Separator{"Output Data Object(s)"}); + params.insert(std::make_unique(k_ImageGeometryPath_Key, "Created Image Geometry", "The path to the created Image Geometry", DataPath({"ImageDataContainer"}))); + params.insert(std::make_unique(k_CellDataName_Key, "Created Cell Attribute Matrix", "The name of the created cell attribute matrix", ImageGeom::k_CellAttributeMatrixName)); + params.insert(std::make_unique(k_ImageDataArrayPath_Key, "Created Cell Data", + "The name of the created image data array. Will be stored in the created Cell Attribute Matrix", "ImageData")); + + return params; +} + +//------------------------------------------------------------------------------ +IFilter::VersionType ReadImageFilter::parametersVersion() const +{ + return 1; +} + +//------------------------------------------------------------------------------ +IFilter::UniquePointer ReadImageFilter::clone() const +{ + return std::make_unique(); +} + +//------------------------------------------------------------------------------ +IFilter::PreflightResult ReadImageFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const +{ + auto fileName = filterArgs.value(k_FileName_Key); + auto imageGeomPath = filterArgs.value(k_ImageGeometryPath_Key); + auto cellDataName = filterArgs.value(k_CellDataName_Key); + auto imageDataArrayName = filterArgs.value(k_ImageDataArrayPath_Key); + auto shouldChangeOrigin = filterArgs.value(k_ChangeOrigin_Key); + auto shouldCenterOrigin = filterArgs.value(k_CenterOrigin_Key); + auto shouldChangeSpacing = filterArgs.value(k_ChangeSpacing_Key); + auto originValues = filterArgs.value(k_Origin_Key); + auto spacingValues = filterArgs.value(k_Spacing_Key); + auto originSpacingProcessing = filterArgs.value(k_OriginSpacingProcessing_Key); + auto pChangeDataType = filterArgs.value(k_ChangeDataType_Key); + auto pChoiceType = filterArgs.value(k_ImageDataType_Key); + auto lengthUnitIndex = filterArgs.value(k_LengthUnit_Key); + auto croppingOptions = filterArgs.value(k_CroppingOptions_Key); + + nx::core::Result resultOutputActions; + std::vector preflightUpdatedValues; + + // Validate the file extension by creating an IO backend + auto backendResult = CreateImageIO(fileName); + if(backendResult.invalid()) + { + return {ConvertResultTo(ConvertResult(std::move(backendResult)), {})}; + } + auto& backend = backendResult.value(); + + // Read metadata from the image file + auto metadataResult = backend->readMetadata(fileName); + if(metadataResult.invalid()) + { + return {ConvertResultTo(ConvertResult(std::move(metadataResult)), {})}; + } + const auto& metadata = metadataResult.value(); + + // Set up dims, origin, spacing from metadata + std::vector dims = {metadata.width, metadata.height, 1}; + FloatVec3 origin = metadata.origin.value_or(FloatVec3{0.0f, 0.0f, 0.0f}); + FloatVec3 spacing = metadata.spacing.value_or(FloatVec3{1.0f, 1.0f, 1.0f}); + + // Lambda to apply origin/spacing overrides consistently at either pre- or post-processing stage. + auto applyOriginSpacingOverrides = [&]() { + if(shouldChangeSpacing) + { + spacing = FloatVec3{static_cast(spacingValues[0]), static_cast(spacingValues[1]), static_cast(spacingValues[2])}; + } + + if(shouldChangeOrigin) + { + origin = FloatVec3{static_cast(originValues[0]), static_cast(originValues[1]), static_cast(originValues[2])}; + if(shouldCenterOrigin) + { + for(usize i = 0; i < 3; i++) + { + origin[i] = -0.5f * spacing[i] * static_cast(dims[i]); + } + } + } + }; + + // Apply origin/spacing overrides (Preprocessed = before cropping) + if(originSpacingProcessing == 0) // Preprocessed + { + applyOriginSpacingOverrides(); + } + + // Apply cropping if requested by building a temporary ImageGeom and running CropImageGeomFilter preflight. + bool cropImage = croppingOptions.type != CropGeometryParameter::CropValues::TypeEnum::NoCropping; + bool crop2dImage = cropImage && (croppingOptions.cropX || croppingOptions.cropY); + if(crop2dImage) + { + FilterList* filterListPtr = Application::Instance()->getFilterList(); + if(filterListPtr == nullptr || !filterListPtr->containsPlugin(k_SimplnxCorePluginId)) + { + return IFilter::MakePreflightErrorResult(-18542, "The plugin SimplnxCore was not instantiated in this instance, so image cropping is not available."); + } + + std::unique_ptr cropImageGeomFilter = filterListPtr->createFilter(k_CropImageGeomFilterHandle); + if(cropImageGeomFilter == nullptr) + { + return IFilter::MakePreflightErrorResult(-18543, "Unable to create an instance of the crop image geometry filter, so image cropping is not available."); + } + + DataStructure tmpDs; + DataPath tmpGeomPath = DataPath({"tmpGeom"}); + ImageGeom* tmpGeomPtr = ImageGeom::Create(tmpDs, tmpGeomPath.getTargetName()); + AttributeMatrix* amPtr = AttributeMatrix::Create(tmpDs, "CellData", std::vector(dims.crbegin(), dims.crend()), tmpGeomPtr->getId()); + tmpGeomPtr->setCellData(*amPtr); + tmpGeomPtr->setDimensions(dims); + tmpGeomPtr->setOrigin(origin); + tmpGeomPtr->setSpacing(spacing); + + Arguments cropImageGeomArgs; + cropImageGeomArgs.insertOrAssign("input_image_geometry_path", std::make_any(tmpGeomPath)); + cropImageGeomArgs.insertOrAssign("use_physical_bounds", std::make_any(croppingOptions.type == CropGeometryParameter::CropValues::TypeEnum::PhysicalSubvolume)); + cropImageGeomArgs.insertOrAssign("crop_x_dim", std::make_any(croppingOptions.cropX)); + cropImageGeomArgs.insertOrAssign("crop_y_dim", std::make_any(croppingOptions.cropY)); + cropImageGeomArgs.insertOrAssign("crop_z_dim", std::make_any(false)); // 2D image => no Z crop + if(croppingOptions.type == CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume) + { + cropImageGeomArgs.insertOrAssign("min_voxel", + std::make_any({static_cast(croppingOptions.xBoundVoxels[0]), static_cast(croppingOptions.yBoundVoxels[0]), + static_cast(croppingOptions.zBoundVoxels[0])})); + cropImageGeomArgs.insertOrAssign("max_voxel", + std::make_any({static_cast(croppingOptions.xBoundVoxels[1]), static_cast(croppingOptions.yBoundVoxels[1]), + static_cast(croppingOptions.zBoundVoxels[1])})); + } + else + { + cropImageGeomArgs.insertOrAssign( + "min_coord", std::make_any({static_cast(croppingOptions.xBoundPhysical[0]), static_cast(croppingOptions.yBoundPhysical[0]), + static_cast(croppingOptions.zBoundPhysical[0])})); + cropImageGeomArgs.insertOrAssign( + "max_coord", std::make_any({static_cast(croppingOptions.xBoundPhysical[1]), static_cast(croppingOptions.yBoundPhysical[1]), + static_cast(croppingOptions.zBoundPhysical[1])})); + } + cropImageGeomArgs.insertOrAssign("remove_original_geometry", std::make_any(true)); + + IFilter::PreflightResult cropImageResult = cropImageGeomFilter->preflight(tmpDs, cropImageGeomArgs); + if(cropImageResult.outputActions.invalid()) + { + return cropImageResult; + } + + Result<> actionsResult = cropImageResult.outputActions.value().applyAll(tmpDs, IDataAction::Mode::Preflight); + if(actionsResult.invalid()) + { + return {ConvertResultTo(std::move(actionsResult), {})}; + } + // Apply any updated values (e.g. UpdateImageGeomAction) to the temporary image geom so we can read + // the cropped origin/spacing from the geometry after execution semantics of applyAll. + + const auto& croppedGeom = tmpDs.getDataRefAs(tmpGeomPath); + dims = croppedGeom.getDimensions().toContainer>(); + spacing = croppedGeom.getSpacing().toContainer>(); + origin = croppedGeom.getOrigin().toContainer>(); + } + + // Apply origin/spacing overrides (Postprocessed = after cropping) + if(originSpacingProcessing == 1) // Postprocessed + { + applyOriginSpacingOverrides(); + } + + // Determine the data type for the created array + DataType dataType = metadata.dataType; + if(pChangeDataType) + { + dataType = ConvertChoiceToDataType(pChoiceType); + } + + auto lengthUnit = static_cast(lengthUnitIndex); + + // DataArray dimensions are stored slowest to fastest (Z, Y, X), the opposite of ImageGeometry (X, Y, Z) + std::vector arrayDims(dims.crbegin(), dims.crend()); + + std::vector componentDims = {metadata.numComponents}; + + // Create the ImageGeometry + { + auto originVec = std::vector{origin[0], origin[1], origin[2]}; + auto spacingVec = std::vector{spacing[0], spacing[1], spacing[2]}; + resultOutputActions.value().appendAction(std::make_unique(imageGeomPath, dims, originVec, spacingVec, cellDataName, lengthUnit)); + } + + // Create the data array + { + DataPath imageDataArrayPath = imageGeomPath.createChildPath(cellDataName).createChildPath(imageDataArrayName); + resultOutputActions.value().appendAction(std::make_unique(dataType, arrayDims, componentDims, imageDataArrayPath)); + } + + // Build a summary string for preflight updated values + std::string summary = fmt::format("Image Dimensions: {} x {}\n" + "Pixel Components: {}\n" + "Source Data Type: {}\n" + "Output Data Type: {}\n" + "Origin: [{}, {}, {}]\n" + "Spacing: [{}, {}, {}]", + metadata.width, metadata.height, metadata.numComponents, DataTypeToString(metadata.dataType), DataTypeToString(dataType), origin[0], origin[1], origin[2], + spacing[0], spacing[1], spacing[2]); + + preflightUpdatedValues.push_back({"Image Information", summary}); + + return {std::move(resultOutputActions), std::move(preflightUpdatedValues)}; +} + +//------------------------------------------------------------------------------ +Result<> ReadImageFilter::executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + ReadImageInputValues inputValues; + + auto imageGeomPath = filterArgs.value(k_ImageGeometryPath_Key); + auto cellDataName = filterArgs.value(k_CellDataName_Key); + auto imageDataArrayName = filterArgs.value(k_ImageDataArrayPath_Key); + + inputValues.inputFilePath = filterArgs.value(k_FileName_Key); + inputValues.imageGeometryPath = imageGeomPath; + inputValues.imageDataArrayPath = imageGeomPath.createChildPath(cellDataName).createChildPath(imageDataArrayName); + inputValues.cellDataName = cellDataName; + inputValues.changeOrigin = filterArgs.value(k_ChangeOrigin_Key); + inputValues.centerOrigin = filterArgs.value(k_CenterOrigin_Key); + auto originValues = filterArgs.value(k_Origin_Key); + inputValues.origin = FloatVec3{static_cast(originValues[0]), static_cast(originValues[1]), static_cast(originValues[2])}; + inputValues.changeSpacing = filterArgs.value(k_ChangeSpacing_Key); + auto spacingValues = filterArgs.value(k_Spacing_Key); + inputValues.spacing = FloatVec3{static_cast(spacingValues[0]), static_cast(spacingValues[1]), static_cast(spacingValues[2])}; + inputValues.originSpacingProcessing = filterArgs.value(k_OriginSpacingProcessing_Key); + inputValues.changeDataType = filterArgs.value(k_ChangeDataType_Key); + inputValues.imageDataType = ConvertChoiceToDataType(filterArgs.value(k_ImageDataType_Key)); + inputValues.croppingOptions = filterArgs.value(k_CroppingOptions_Key); + + return ReadImage(dataStructure, messageHandler, shouldCancel, &inputValues)(); +} + +namespace +{ +namespace SIMPL +{ +} // namespace SIMPL +} // namespace + +//------------------------------------------------------------------------------ +Result ReadImageFilter::FromSIMPLJson(const nlohmann::json& json) +{ + Arguments args = ReadImageFilter().getDefaultArguments(); + + std::vector> results; + + /* This is a NEW filter and not ported so this section does not matter */ + + Result<> conversionResult = MergeResults(std::move(results)); + + return ConvertResultTo(std::move(conversionResult), std::move(args)); +} + +} // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageFilter.hpp new file mode 100644 index 0000000000..b301e1351a --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageFilter.hpp @@ -0,0 +1,136 @@ +#pragma once + +#include "SimplnxCore/SimplnxCore_export.hpp" + +#include "simplnx/Filter/FilterTraits.hpp" +#include "simplnx/Filter/IFilter.hpp" + +namespace nx::core +{ +/** + * @class ReadImageFilter + * @brief This filter will .... + */ +class SIMPLNXCORE_EXPORT ReadImageFilter : public IFilter +{ +public: + ReadImageFilter() = default; + ~ReadImageFilter() noexcept override = default; + + ReadImageFilter(const ReadImageFilter&) = delete; + ReadImageFilter(ReadImageFilter&&) noexcept = delete; + + ReadImageFilter& operator=(const ReadImageFilter&) = delete; + ReadImageFilter& operator=(ReadImageFilter&&) noexcept = delete; + + // Parameter Keys + static constexpr StringLiteral k_FileName_Key = "file_name"; + static constexpr StringLiteral k_ImageGeometryPath_Key = "output_geometry_path"; + static constexpr StringLiteral k_ImageDataArrayPath_Key = "image_data_array_name"; + static constexpr StringLiteral k_CellDataName_Key = "cell_attribute_matrix_name"; + + static constexpr StringLiteral k_LengthUnit_Key = "length_unit_index"; + + static constexpr StringLiteral k_ChangeOrigin_Key = "change_origin"; + static constexpr StringLiteral k_CenterOrigin_Key = "center_origin"; + static constexpr StringLiteral k_Origin_Key = "origin"; + + static constexpr StringLiteral k_ChangeSpacing_Key = "change_spacing"; + static constexpr StringLiteral k_Spacing_Key = "spacing"; + static constexpr StringLiteral k_OriginSpacingProcessing_Key = "origin_spacing_processing_index"; + + static constexpr StringLiteral k_ChangeDataType_Key = "change_image_data_type"; + static constexpr StringLiteral k_ImageDataType_Key = "image_data_type_index"; + + static constexpr StringLiteral k_CroppingOptions_Key = "cropping_options"; + + /** + * @brief Reads SIMPL json and converts it simplnx Arguments. + * @param json + * @return Result + */ + static Result FromSIMPLJson(const nlohmann::json& json); + + /** + * @brief Returns the name of the filter. + * @return + */ + std::string name() const override; + + /** + * @brief Returns the C++ classname of this filter. + * @return + */ + std::string className() const override; + + /** + * @brief Returns the uuid of the filter. + * @return + */ + Uuid uuid() const override; + + /** + * @brief Returns the human readable name of the filter. + * @return + */ + std::string humanName() const override; + + /** + * @brief Returns the default tags for this filter. + * @return + */ + std::vector defaultTags() const override; + + /** + * @brief Returns the parameters of the filter (i.e. its inputs) + * @return + */ + Parameters parameters() const override; + + /** + * @brief Returns parameters version integer. + * Initial version should always be 1. + * Should be incremented everytime the parameters change. + * @return VersionType + */ + VersionType parametersVersion() const override; + + /** + * @brief Returns a copy of the filter. + * @return + */ + UniquePointer clone() const override; + +protected: + /** + * @brief Takes in a DataStructure and checks that the filter can be run on it with the given arguments. + * Returns any warnings/errors. Also returns the changes that would be applied to the DataStructure. + * Some parts of the actions may not be completely filled out if all the required information is not available at preflight time. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + PreflightResult preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; + + /** + * @brief Applies the filter's algorithm to the DataStructure with the given arguments. Returns any warnings/errors. + * On failure, there is no guarantee that the DataStructure is in a correct state. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param pipelineNode The node in the pipeline that is being executed + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + Result<> executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; +}; +} // namespace nx::core + +SIMPLNX_DEF_FILTER_TRAITS(nx::core, ReadImageFilter, "7391288d-3d0b-492e-93c9-e128e05c9737"); +/* LEGACY UUID FOR THIS FILTER @OLD_UUID@ */ diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageStackFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageStackFilter.cpp new file mode 100644 index 0000000000..3acc4c57ac --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageStackFilter.cpp @@ -0,0 +1,540 @@ +#include "ReadImageStackFilter.hpp" + +#include "SimplnxCore/Filters/Algorithms/ReadImageStack.hpp" +#include "SimplnxCore/Filters/ReadImageFilter.hpp" + +#include "simplnx/Common/TypesUtility.hpp" +#include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Filter/Actions/CreateArrayAction.hpp" +#include "simplnx/Filter/Actions/CreateImageGeometryAction.hpp" +#include "simplnx/Filter/Actions/DeleteDataAction.hpp" +#include "simplnx/Filter/Actions/RenameDataAction.hpp" +#include "simplnx/Filter/Actions/UpdateImageGeomAction.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/CropGeometryParameter.hpp" +#include "simplnx/Parameters/DataGroupCreationParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/GeneratedFileListParameter.hpp" +#include "simplnx/Parameters/NumberParameter.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" +#include "simplnx/Utilities/FilterUtilities.hpp" +#include "simplnx/Utilities/GeometryHelpers.hpp" + +#include + +namespace fs = std::filesystem; + +using namespace nx::core; + +namespace +{ +const ChoicesParameter::Choices k_SliceOperationChoices = {"None", "Flip about X axis", "Flip about Y axis"}; +const ChoicesParameter::ValueType k_NoImageTransform = 0; +const ChoicesParameter::ValueType k_FlipAboutXAxis = 1; +const ChoicesParameter::ValueType k_FlipAboutYAxis = 2; + +constexpr nx::core::StringLiteral k_NoResamplingMode = "Do Not Resample (0)"; +constexpr nx::core::StringLiteral k_ScalingMode = "Scaling (1)"; +constexpr nx::core::StringLiteral k_ExactDimensions = "Exact X/Y Dimensions (2)"; +const nx::core::ChoicesParameter::Choices k_ResamplingChoices = {k_NoResamplingMode, k_ScalingMode, k_ExactDimensions}; +const nx::core::ChoicesParameter::ValueType k_NoResampleModeIndex = 0; +const nx::core::ChoicesParameter::ValueType k_ScalingModeIndex = 1; +const nx::core::ChoicesParameter::ValueType k_ExactDimensionsModeIndex = 2; + +const Uuid k_SimplnxCorePluginId = *Uuid::FromString("05cc618b-781f-4ac0-b9ac-43f26ce1854f"); +const Uuid k_RotateSampleRefFrameFilterId = *Uuid::FromString("d2451dc1-a5a1-4ac2-a64d-7991669dcffc"); +const FilterHandle k_RotateSampleRefFrameFilterHandle(k_RotateSampleRefFrameFilterId, k_SimplnxCorePluginId); +const Uuid k_ColorToGrayScaleFilterId = *Uuid::FromString("d938a2aa-fee2-4db9-aa2f-2c34a9736580"); +const FilterHandle k_ColorToGrayScaleFilterHandle(k_ColorToGrayScaleFilterId, k_SimplnxCorePluginId); +const Uuid k_ResampleImageGeomFilterId = *Uuid::FromString("9783ea2c-4cf7-46de-ab21-b40d91a48c5b"); +const FilterHandle k_ResampleImageGeomFilterHandle(k_ResampleImageGeomFilterId, k_SimplnxCorePluginId); +const Uuid k_CropImageGeomFilterId = *Uuid::FromString("e6476737-4aa7-48ba-a702-3dfab82c96e2"); +const FilterHandle k_CropImageGeomFilterHandle(k_CropImageGeomFilterId, k_SimplnxCorePluginId); +} // namespace + +namespace nx::core +{ +//------------------------------------------------------------------------------ +std::string ReadImageStackFilter::name() const +{ + return FilterTraits::name.str(); +} + +//------------------------------------------------------------------------------ +std::string ReadImageStackFilter::className() const +{ + return FilterTraits::className; +} + +//------------------------------------------------------------------------------ +Uuid ReadImageStackFilter::uuid() const +{ + return FilterTraits::uuid; +} + +//------------------------------------------------------------------------------ +std::string ReadImageStackFilter::humanName() const +{ + return "Read Image Stack"; +} + +//------------------------------------------------------------------------------ +std::vector ReadImageStackFilter::defaultTags() const +{ + return {className(), "IO", "Input", "Read", "Import", "Image", "Tif", "JPEG", "PNG"}; +} + +//------------------------------------------------------------------------------ +Parameters ReadImageStackFilter::parameters() const +{ + Parameters params; + + params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); + params.insert( + std::make_unique(k_InputFileListInfo_Key, "Input File List", "The list of 2D image files to be read in to a 3D volume", GeneratedFileListParameter::ValueType{})); + + params.insertSeparator(Parameters::Separator{"Cropping Options"}); + params.insert(std::make_unique( + k_CroppingOptions_Key, "Cropping Options", + "The cropping options used to crop images. These include picking the cropping type, the cropping dimensions, and the cropping ranges for each chosen dimension.", + CropGeometryParameter::ValueType{})); + + params.insertSeparator(Parameters::Separator{"Origin & Spacing Options"}); + params.insertLinkableParameter(std::make_unique(k_ChangeOrigin_Key, "Set Origin", "Specifies if the origin should be changed", false)); + params.insert(std::make_unique(k_Origin_Key, "Origin", "The origin of the 3D volume", std::vector{0.0F, 0.0F, 0.0F}, std::vector{"X", "y", "Z"})); + params.insertLinkableParameter(std::make_unique(k_ChangeSpacing_Key, "Set Spacing", "Specifies if the spacing should be changed", false)); + params.insert(std::make_unique(k_Spacing_Key, "Spacing", "The spacing of the 3D volume", std::vector{1.0F, 1.0F, 1.0F}, std::vector{"X", "y", "Z"})); + params.insert(std::make_unique(k_OriginSpacingProcessing_Key, "Origin & Spacing Processing", "Whether the origin & spacing should be preprocessed or postprocessed.", 1, + ChoicesParameter::Choices{"Preprocessed", "Postprocessed"})); + + params.insertSeparator(Parameters::Separator{"Resampling Options"}); + params.insertLinkableParameter(std::make_unique(k_ResampleImagesChoice_Key, "Resample Images", + "Mode can be [0] Do Not Rescale, [1] Scaling as Percent, [2] Exact X/Y Dimensions For Resampling Along Z Axis", + ::k_NoResampleModeIndex, ::k_ResamplingChoices)); + params.insert(std::make_unique( + k_Scaling_Key, "Scaling (%)", + "The scaling of the 3D volume, in percentages. Percentage must be greater than or equal to 1.0f. Larger percentages will cause more voxels, smaller percentages " + "will cause less voxels. For example, 10.0 is one-tenth the original number of pixels. 200.0 is double the number of pixels.", + 100.0f)); + params.insert(std::make_unique(k_ExactXYDimensions_Key, "Exact 2D Dimensions (Pixels)", + "The supplied dimensions will be used to determine the resampled output geometry size. See associated Filter documentation for further detail.", + std::vector{100, 100}, std::vector({"X", "Y"}))); + + params.insertSeparator(Parameters::Separator{"Other Slice Options"}); + params.insertLinkableParameter( + std::make_unique(k_ConvertToGrayScale_Key, "Convert To GrayScale", "The filter will show an error if the images are already in grayscale format", false)); + params.insert(std::make_unique(k_ColorWeights_Key, "Color Weighting", "RGB weights for the grayscale conversion using the luminosity algorithm.", + std::vector{0.2125f, 0.7154f, 0.0721f}, std::vector({"Red", "Green", "Blue"}))); + params.insertLinkableParameter( + std::make_unique(k_ImageTransformChoice_Key, "Flip Slice", "Operation that is performed on each slice. 0=None, 1=Flip about X, 2=Flip about Y", 0, k_SliceOperationChoices)); + + params.insertLinkableParameter(std::make_unique(k_ChangeDataType_Key, "Set Image Data Type", "Set the final created image data type.", false)); + params.insert(std::make_unique(k_ImageDataType_Key, "Output Data Type", "Numeric Type of data to create", 0ULL, + ChoicesParameter::Choices{"uint8", "uint16", "uint32"})); // Sequence Dependent DO NOT REORDER + + params.insertSeparator(Parameters::Separator{"Output Data"}); + params.insert(std::make_unique(k_ImageGeometryPath_Key, "Created Image Geometry", "The path to the created Image Geometry", DataPath({"ImageDataContainer"}))); + params.insert(std::make_unique(k_CellDataName_Key, "Cell Data Name", "The name of the created cell attribute matrix", ImageGeom::k_CellAttributeMatrixName)); + params.insert(std::make_unique(k_ImageDataArrayPath_Key, "Created Image Data", "The path to the created image data array", "ImageData")); + + params.linkParameters(k_ConvertToGrayScale_Key, k_ColorWeights_Key, true); + params.linkParameters(k_ResampleImagesChoice_Key, k_Scaling_Key, ::k_ScalingModeIndex); + params.linkParameters(k_ResampleImagesChoice_Key, k_ExactXYDimensions_Key, ::k_ExactDimensionsModeIndex); + params.linkParameters(k_ChangeDataType_Key, k_ImageDataType_Key, true); + params.linkParameters(k_ChangeOrigin_Key, k_Origin_Key, true); + params.linkParameters(k_ChangeSpacing_Key, k_Spacing_Key, true); + params.linkParameters(k_ChangeOrigin_Key, k_OriginSpacingProcessing_Key, true); + params.linkParameters(k_ChangeSpacing_Key, k_OriginSpacingProcessing_Key, true); + + return params; +} + +//------------------------------------------------------------------------------ +IFilter::VersionType ReadImageStackFilter::parametersVersion() const +{ + return 1; +} + +//------------------------------------------------------------------------------ +IFilter::UniquePointer ReadImageStackFilter::clone() const +{ + return std::make_unique(); +} + +//------------------------------------------------------------------------------ +IFilter::PreflightResult ReadImageStackFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + auto inputFileListInfo = filterArgs.value(k_InputFileListInfo_Key); + auto shouldChangeOrigin = filterArgs.value(k_ChangeOrigin_Key); + auto shouldChangeSpacing = filterArgs.value(k_ChangeSpacing_Key); + auto origin = filterArgs.value(k_Origin_Key); + auto spacing = filterArgs.value(k_Spacing_Key); + auto originSpacingProcessing = filterArgs.value(k_OriginSpacingProcessing_Key); + auto imageGeomPath = filterArgs.value(k_ImageGeometryPath_Key); + auto pImageDataArrayNameValue = filterArgs.value(k_ImageDataArrayPath_Key); + auto cellDataName = filterArgs.value(k_CellDataName_Key); + auto imageTransformValue = filterArgs.value(k_ImageTransformChoice_Key); + auto pConvertToGrayScaleValue = filterArgs.value(k_ConvertToGrayScale_Key); + auto pColorWeightsValue = filterArgs.value(k_ColorWeights_Key); + auto pResampleImagesChoiceValue = filterArgs.value(k_ResampleImagesChoice_Key); + auto pScalingValue = filterArgs.value(k_Scaling_Key); + auto pExactXYDimsValue = filterArgs.value(k_ExactXYDimensions_Key); + + auto pChangeDataType = filterArgs.value(k_ChangeDataType_Key); + auto numericType = filterArgs.value(k_ImageDataType_Key); + auto croppingOptions = filterArgs.value(k_CroppingOptions_Key); + + nx::core::Result resultOutputActions; + std::vector preflightUpdatedValues; + + std::vector files = inputFileListInfo.generate(); + + if(files.empty()) + { + return {MakeErrorResult(-1, "GeneratedFileList must not be empty")}; + } + + DataStructure tmpDs; + std::vector outputDims; + std::vector outputSpacing; + std::vector outputOrigin; + IGeometry::LengthUnit outputUnits = IGeometry::LengthUnit::Micrometer; + + // Create a sub-filter to read each image, although for preflight we are going to read the first image in the + // list and hope the rest are correct. + Arguments imageReaderArgs; + imageReaderArgs.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, std::make_any(imageGeomPath)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_CellDataName_Key, std::make_any(cellDataName)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, std::make_any(pImageDataArrayNameValue)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_FileName_Key, std::make_any(files.at(0))); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_ChangeDataType_Key, std::make_any(pChangeDataType)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_ImageDataType_Key, std::make_any(numericType)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_LengthUnit_Key, std::make_any(to_underlying(IGeometry::LengthUnit::Micrometer))); + // Do not set the origin if processing timing is postprocessed, we will set the final origin & spacing at the end + imageReaderArgs.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, std::make_any(shouldChangeOrigin && originSpacingProcessing == 0)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_CenterOrigin_Key, std::make_any(false)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_Origin_Key, std::make_any(origin)); + // Do not set the spacing if processing timing is postprocessed, we will set the final origin & spacing at the end + imageReaderArgs.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, std::make_any(shouldChangeSpacing && originSpacingProcessing == 0)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_Spacing_Key, std::make_any(spacing)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_OriginSpacingProcessing_Key, std::make_any(originSpacingProcessing)); + imageReaderArgs.insertOrAssign(ReadImageFilter::k_CroppingOptions_Key, std::make_any(croppingOptions)); + + const ReadImageFilter imageReader; + PreflightResult imageReaderResult = imageReader.preflight(tmpDs, imageReaderArgs, messageHandler, shouldCancel); + if(imageReaderResult.outputActions.invalid()) + { + return imageReaderResult; + } + + // The first output actions should be the geometry creation + const IDataAction* action0Ptr = imageReaderResult.outputActions.value().actions.at(0).get(); + const auto* createImageGeomActionPtr = dynamic_cast(action0Ptr); + if(createImageGeomActionPtr != nullptr) + { + outputDims = createImageGeomActionPtr->dims(); + outputSpacing = createImageGeomActionPtr->spacing(); + outputOrigin = createImageGeomActionPtr->origin(); + outputUnits = createImageGeomActionPtr->units(); + + // Compute Z dimension, taking into account possible Z cropping + usize totalSlices = files.size(); + usize zDim = totalSlices; + + if(croppingOptions.cropZ) + { + if(croppingOptions.type == CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume) + { + // Voxel-based Z cropping: zBoundVoxels are inclusive indices + const auto zMin = static_cast(croppingOptions.zBoundVoxels[0]); + const auto zMax = static_cast(croppingOptions.zBoundVoxels[1]); + if(zMax >= zMin) + { + zDim = zMax - zMin + 1; + } + } + else if(croppingOptions.type == CropGeometryParameter::CropValues::TypeEnum::PhysicalSubvolume) + { + const float64 zMinPhys = croppingOptions.zBoundPhysical[0]; + const float64 zMaxPhys = croppingOptions.zBoundPhysical[1]; + + const float64 originZ = (shouldChangeOrigin && originSpacingProcessing == 0) ? origin[2] : 0; + const float64 spacingZ = (shouldChangeSpacing && originSpacingProcessing == 0) ? spacing[2] : 1; + + if(zMaxPhys < zMinPhys) + { + return MakePreflightErrorResult( + -23520, fmt::format("Invalid Z cropping range: the maximum physical Z value is smaller than the minimum. Please ensure the start Z is less than or equal to the end Z.")); + } + + if(spacingZ <= 0) + { + return MakePreflightErrorResult(-23521, fmt::format("Invalid Z spacing ({}). The Z spacing must be greater than zero to apply physical cropping.", spacingZ)); + } + + if(zMinPhys < originZ || zMinPhys > (static_cast(zDim) * spacingZ + originZ)) + { + return MakePreflightErrorResult(-23522, fmt::format("The minimum Z cropping value ({}) is outside the image bounds. Valid Z range is [{} to {}] in physical units.", zMinPhys, originZ, + (static_cast(zDim) * spacingZ + originZ))); + } + + if(zMaxPhys < originZ || zMaxPhys > (static_cast(zDim) * spacingZ + originZ)) + { + return MakePreflightErrorResult(-23523, fmt::format("The maximum Z cropping value ({}) is outside the image bounds. Valid Z range is [{} to {}] in physical units.", zMaxPhys, originZ, + (static_cast(zDim) * spacingZ + originZ))); + } + + const auto zMinIndex = static_cast(std::floor((zMinPhys - originZ) / spacingZ)); + if(zMinIndex >= zDim) + { + return MakePreflightErrorResult(-23524, fmt::format("The minimum Z cropping value ({}) converts to slice index {} which is outside the valid slice index range [0 to {}].", zMinPhys, + zMinIndex, (zDim > 0 ? zDim - 1 : 0))); + } + + const auto zMaxIndex = static_cast(std::floor((zMaxPhys - originZ) / spacingZ)); + if(zMaxIndex >= zDim) + { + return MakePreflightErrorResult(-23525, fmt::format("The maximum Z cropping value ({}) converts to slice index {} which is outside the valid slice index range [0 to {}].", zMaxPhys, + zMaxIndex, (zDim > 0 ? zDim - 1 : 0))); + } + + zDim = zMaxIndex - zMinIndex + 1; + } + } + + outputDims.back() = zDim; + + resultOutputActions.value().appendAction(std::make_unique(createImageGeomActionPtr->path(), outputDims, createImageGeomActionPtr->origin(), + createImageGeomActionPtr->spacing(), createImageGeomActionPtr->cellAttributeMatrixName(), + createImageGeomActionPtr->units())); + // The second action should be the array creation + const IDataAction* action1Ptr = imageReaderResult.outputActions.value().actions.at(1).get(); + const auto* createArrayActionPtr = dynamic_cast(action1Ptr); + if(createArrayActionPtr != nullptr) + { + resultOutputActions.value().appendAction(std::make_unique(createArrayActionPtr->type(), std::vector(outputDims.rbegin(), outputDims.rend()), + createArrayActionPtr->componentDims(), createArrayActionPtr->path(), createArrayActionPtr->dataFormat(), + createArrayActionPtr->fillValue())); + } + + Result<> actionsResult = resultOutputActions.value().applyAll(tmpDs, IDataAction::Mode::Preflight); + if(actionsResult.invalid()) + { + return {ConvertResultTo(std::move(actionsResult), {})}; + } + } + + DataPath currentImageGeomPath = imageGeomPath; + std::vector pathsToDelete; + + FilterList* filterListPtr = Application::Instance()->getFilterList(); + if(pResampleImagesChoiceValue != k_NoResampleModeIndex) + { + if(!filterListPtr->containsPlugin(k_SimplnxCorePluginId)) + { + PreflightResult errorResult = MakePreflightErrorResult(-18544, "The plugin SimplnxCore was not instantiated in this instance, so image resampling is not available."); + return errorResult; + } + + std::unique_ptr resampleImageGeomFilter = filterListPtr->createFilter(k_ResampleImageGeomFilterHandle); + if(nullptr == resampleImageGeomFilter.get()) + { + PreflightResult errorResult = MakePreflightErrorResult(-18545, "Unable to create an instance of the resample image geometry filter, so image resampling is not available."); + return errorResult; + } + + if(pResampleImagesChoiceValue == k_ScalingModeIndex && pScalingValue < 1.0f) + { + PreflightResult errorResult = MakePreflightErrorResult(-23508, fmt::format("Scaling value must be greater than or equal to 1.0f. Received: {}", pScalingValue)); + return errorResult; + } + + Arguments resampleImageGeomArgs; + resampleImageGeomArgs.insertOrAssign("input_image_geometry_path", std::make_any(currentImageGeomPath)); + resampleImageGeomArgs.insertOrAssign("remove_original_geometry", std::make_any(false)); + resampleImageGeomArgs.insertOrAssign("new_data_container_path", std::make_any(DataPath({imageGeomPath.getTargetName() + "_resampled"}))); + resampleImageGeomArgs.insertOrAssign("resampling_mode_index", std::make_any(pResampleImagesChoiceValue)); + resampleImageGeomArgs.insertOrAssign("scaling", std::make_any(std::vector{pScalingValue, pScalingValue, 100.0f})); + resampleImageGeomArgs.insertOrAssign("exact_dimensions", std::make_any(std::vector{pExactXYDimsValue[0], pExactXYDimsValue[1], outputDims[2]})); + + // Run resample image geometry filter and process results and messages + PreflightResult resampleImageResult = resampleImageGeomFilter->preflight(tmpDs, resampleImageGeomArgs, messageHandler, shouldCancel); + if(resampleImageResult.outputActions.invalid()) + { + return resampleImageResult; + } + + // The first output actions should be the geometry creation + action0Ptr = resampleImageResult.outputActions.value().actions.at(0).get(); + createImageGeomActionPtr = dynamic_cast(action0Ptr); + if(createImageGeomActionPtr != nullptr) + { + std::vector dims = createImageGeomActionPtr->dims(); + dims.back() = outputDims.back(); + outputDims = dims; + outputSpacing = createImageGeomActionPtr->spacing(); + outputOrigin = createImageGeomActionPtr->origin(); + outputUnits = createImageGeomActionPtr->units(); + resultOutputActions.value().appendAction(std::make_unique(createImageGeomActionPtr->path(), outputDims, createImageGeomActionPtr->origin(), + createImageGeomActionPtr->spacing(), createImageGeomActionPtr->cellAttributeMatrixName(), + createImageGeomActionPtr->units())); + // The second action should be the array creation + const IDataAction* action1Ptr = resampleImageResult.outputActions.value().actions.at(1).get(); + const auto* createArrayActionPtr = dynamic_cast(action1Ptr); + if(createArrayActionPtr != nullptr) + { + resultOutputActions.value().appendAction(std::make_unique(createArrayActionPtr->type(), std::vector(outputDims.rbegin(), outputDims.rend()), + createArrayActionPtr->componentDims(), createArrayActionPtr->path(), createArrayActionPtr->dataFormat(), + createArrayActionPtr->fillValue())); + } + + tmpDs = DataStructure(); + Result<> actionsResult = resultOutputActions.value().applyAll(tmpDs, IDataAction::Mode::Preflight); + if(actionsResult.invalid()) + { + return {ConvertResultTo(std::move(actionsResult), {})}; + } + } + + pathsToDelete.push_back(currentImageGeomPath); + currentImageGeomPath = DataPath({imageGeomPath.getTargetName() + "_resampled"}); + } + + const DataPath imageDataPath = currentImageGeomPath.createChildPath(cellDataName).createChildPath(pImageDataArrayNameValue); + auto& imageData = tmpDs.getDataRefAs(imageDataPath); + if(pConvertToGrayScaleValue) + { + if(imageData.getDataType() != DataType::uint8) + { + return MakePreflightErrorResult(-23504, fmt::format("The input DataType is {} which cannot be converted to grayscale. Please turn off the 'Convert To Grayscale' option.", + nx::core::DataTypeToString(imageData.getDataType()))); + } + + if(!filterListPtr->containsPlugin(k_SimplnxCorePluginId)) + { + PreflightResult errorResult = MakePreflightErrorResult(-23501, "Color to GrayScale conversion is disabled because the 'SimplnxCore' plugin was not loaded."); + return errorResult; + } + std::unique_ptr grayScaleFilter = filterListPtr->createFilter(k_ColorToGrayScaleFilterHandle); + if(nullptr == grayScaleFilter.get()) + { + PreflightResult errorResult = MakePreflightErrorResult(-23502, "Color to GrayScale conversion is disabled because the 'Color to GrayScale' filter is missing from the SimplnxCore plugin."); + return errorResult; + } + + Arguments grayscaleImageGeomArgs; + grayscaleImageGeomArgs.insertOrAssign("input_data_array_paths", std::make_any>({imageDataPath})); + grayscaleImageGeomArgs.insertOrAssign("output_array_prefix", std::make_any("grayscale_")); + grayscaleImageGeomArgs.insertOrAssign("color_weights", std::make_any(pColorWeightsValue)); + + // Run grayscale filter and process results and messages + PreflightResult grayscaleImageResult = grayScaleFilter->preflight(tmpDs, grayscaleImageGeomArgs, messageHandler, shouldCancel); + if(grayscaleImageResult.outputActions.invalid()) + { + return grayscaleImageResult; + } + Result<> actionsResult = grayscaleImageResult.outputActions.value().applyAll(tmpDs, IDataAction::Mode::Preflight); + if(actionsResult.invalid()) + { + return {ConvertResultTo(std::move(actionsResult), {})}; + } + + resultOutputActions = MergeOutputActionResults(resultOutputActions, grayscaleImageResult.outputActions); + + resultOutputActions.value().appendDeferredAction(std::make_unique(imageDataPath)); + const DataPath grayscaleImageDataPath = currentImageGeomPath.createChildPath(cellDataName).createChildPath("grayscale_" + pImageDataArrayNameValue); + resultOutputActions.value().appendDeferredAction(std::make_unique(grayscaleImageDataPath, pImageDataArrayNameValue)); + } + else + { + if(pChangeDataType && imageData.getComponentShape().at(0) != 1) + { + return MakePreflightErrorResult( + -23506, fmt::format("Changing the array type requires the input image data to be a scalar value OR the image data can be RGB but you must also select 'Convert to Grayscale'")); + } + } + + for(const DataPath& pathToDelete : pathsToDelete) + { + resultOutputActions.value().appendDeferredAction(std::make_unique(pathToDelete)); + } + + if(originSpacingProcessing == 1 && (shouldChangeOrigin || shouldChangeSpacing)) + { + std::vector originf(origin.size()); + std::ranges::transform(origin, originf.begin(), [](float64 v) { return static_cast(v); }); + std::vector spacingf(spacing.size()); + std::ranges::transform(spacing.begin(), spacing.end(), spacingf.begin(), [](float64 v) { return static_cast(v); }); + resultOutputActions.value().appendDeferredAction(std::make_unique(shouldChangeOrigin ? FloatVec3(originf) : std::optional{}, + shouldChangeSpacing ? FloatVec3(spacingf) : std::optional{}, currentImageGeomPath)); + outputSpacing = spacingf; + outputOrigin = originf; + } + + if(currentImageGeomPath != imageGeomPath) + { + resultOutputActions.value().appendDeferredAction(std::make_unique(currentImageGeomPath, imageGeomPath.getTargetName())); + } + + preflightUpdatedValues.push_back({"Output Geometry", nx::core::GeometryHelpers::Description::GenerateGeometryInfo(outputDims, outputSpacing, outputOrigin, outputUnits)}); + + return {std::move(resultOutputActions), std::move(preflightUpdatedValues)}; +} + +//------------------------------------------------------------------------------ +Result<> ReadImageStackFilter::executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + auto inputFileListInfo = filterArgs.value(k_InputFileListInfo_Key); + + ReadImageStackInputValues inputValues; + inputValues.fileList = inputFileListInfo.generate(); + inputValues.imageGeometryPath = filterArgs.value(k_ImageGeometryPath_Key); + inputValues.imageDataArrayName = filterArgs.value(k_ImageDataArrayPath_Key); + inputValues.cellDataName = filterArgs.value(k_CellDataName_Key); + inputValues.changeOrigin = filterArgs.value(k_ChangeOrigin_Key); + inputValues.origin = filterArgs.value(k_Origin_Key); + inputValues.changeSpacing = filterArgs.value(k_ChangeSpacing_Key); + inputValues.spacing = filterArgs.value(k_Spacing_Key); + inputValues.originSpacingProcessing = filterArgs.value(k_OriginSpacingProcessing_Key); + inputValues.imageTransformChoice = filterArgs.value(k_ImageTransformChoice_Key); + inputValues.convertToGrayScale = filterArgs.value(k_ConvertToGrayScale_Key); + inputValues.colorWeights = filterArgs.value(k_ColorWeights_Key); + inputValues.resampleImagesChoice = filterArgs.value(k_ResampleImagesChoice_Key); + inputValues.scaling = filterArgs.value(k_Scaling_Key); + inputValues.exactXYDimensions = filterArgs.value(k_ExactXYDimensions_Key); + inputValues.changeDataType = filterArgs.value(k_ChangeDataType_Key); + inputValues.imageDataTypeChoice = filterArgs.value(k_ImageDataType_Key); + inputValues.croppingOptions = filterArgs.value(k_CroppingOptions_Key); + + return ReadImageStack(dataStructure, messageHandler, shouldCancel, &inputValues)(); +} + +namespace +{ +namespace SIMPL +{ + +// TODO: PARAMETER_JSON_CONSTANTS +} // namespace SIMPL +} // namespace + +//------------------------------------------------------------------------------ +Result ReadImageStackFilter::FromSIMPLJson(const nlohmann::json& json) +{ + Arguments args = ReadImageStackFilter().getDefaultArguments(); + + std::vector> results; + + /* This is a NEW filter and not ported so this section does not matter */ + + Result<> conversionResult = MergeResults(std::move(results)); + + return ConvertResultTo(std::move(conversionResult), std::move(args)); +} + +} // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageStackFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageStackFilter.hpp new file mode 100644 index 0000000000..ee11f1c0ab --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadImageStackFilter.hpp @@ -0,0 +1,135 @@ +#pragma once + +#include "SimplnxCore/SimplnxCore_export.hpp" + +#include "simplnx/Filter/FilterTraits.hpp" +#include "simplnx/Filter/IFilter.hpp" + +namespace nx::core +{ +/** + * @class ReadImageStackFilter + * @brief This filter will .... + */ +class SIMPLNXCORE_EXPORT ReadImageStackFilter : public IFilter +{ +public: + ReadImageStackFilter() = default; + ~ReadImageStackFilter() noexcept override = default; + + ReadImageStackFilter(const ReadImageStackFilter&) = delete; + ReadImageStackFilter(ReadImageStackFilter&&) noexcept = delete; + + ReadImageStackFilter& operator=(const ReadImageStackFilter&) = delete; + ReadImageStackFilter& operator=(ReadImageStackFilter&&) noexcept = delete; + + // Parameter Keys + static constexpr StringLiteral k_InputFileListInfo_Key = "input_file_list_object"; + static constexpr StringLiteral k_CroppingOptions_Key = "cropping_options"; + static constexpr StringLiteral k_ChangeOrigin_Key = "change_origin"; + static constexpr StringLiteral k_Origin_Key = "origin"; + static constexpr StringLiteral k_ChangeSpacing_Key = "change_spacing"; + static constexpr StringLiteral k_Spacing_Key = "spacing"; + static constexpr StringLiteral k_OriginSpacingProcessing_Key = "origin_spacing_processing_index"; + static constexpr StringLiteral k_ImageGeometryPath_Key = "output_image_geometry_path"; + static constexpr StringLiteral k_ImageDataArrayPath_Key = "image_data_array_name"; + static constexpr StringLiteral k_CellDataName_Key = "cell_attribute_matrix_name"; + static constexpr StringLiteral k_ImageTransformChoice_Key = "image_transform_index"; + static constexpr StringLiteral k_ConvertToGrayScale_Key = "convert_to_gray_scale"; + static constexpr StringLiteral k_ColorWeights_Key = "color_weights"; + static constexpr StringLiteral k_ResampleImagesChoice_Key = "resample_images_index"; + static constexpr StringLiteral k_Scaling_Key = "scaling"; + static constexpr StringLiteral k_ExactXYDimensions_Key = "exact_xy_dimensions"; + static constexpr StringLiteral k_ChangeDataType_Key = "change_image_data_type"; + static constexpr StringLiteral k_ImageDataType_Key = "image_data_type_index"; + + /** + * @brief Reads SIMPL json and converts it simplnx Arguments. + * @param json + * @return Result + */ + static Result FromSIMPLJson(const nlohmann::json& json); + + /** + * @brief Returns the name of the filter. + * @return + */ + std::string name() const override; + + /** + * @brief Returns the C++ classname of this filter. + * @return + */ + std::string className() const override; + + /** + * @brief Returns the uuid of the filter. + * @return + */ + Uuid uuid() const override; + + /** + * @brief Returns the human readable name of the filter. + * @return + */ + std::string humanName() const override; + + /** + * @brief Returns the default tags for this filter. + * @return + */ + std::vector defaultTags() const override; + + /** + * @brief Returns the parameters of the filter (i.e. its inputs) + * @return + */ + Parameters parameters() const override; + + /** + * @brief Returns parameters version integer. + * Initial version should always be 1. + * Should be incremented everytime the parameters change. + * @return VersionType + */ + VersionType parametersVersion() const override; + + /** + * @brief Returns a copy of the filter. + * @return + */ + UniquePointer clone() const override; + +protected: + /** + * @brief Takes in a DataStructure and checks that the filter can be run on it with the given arguments. + * Returns any warnings/errors. Also returns the changes that would be applied to the DataStructure. + * Some parts of the actions may not be completely filled out if all the required information is not available at preflight time. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + PreflightResult preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; + + /** + * @brief Applies the filter's algorithm to the DataStructure with the given arguments. Returns any warnings/errors. + * On failure, there is no guarantee that the DataStructure is in a correct state. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param pipelineNode The node in the pipeline that is being executed + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + Result<> executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; +}; +} // namespace nx::core + +SIMPLNX_DEF_FILTER_TRAITS(nx::core, ReadImageStackFilter, "15e7784e-6a9b-457c-9f5c-f5983246c8e8"); +/* LEGACY UUID FOR THIS FILTER @OLD_UUID@ */ diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/WriteImageFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/WriteImageFilter.cpp new file mode 100644 index 0000000000..21a3094a77 --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/WriteImageFilter.cpp @@ -0,0 +1,218 @@ +#include "WriteImageFilter.hpp" + +#include "SimplnxCore/Filters/Algorithms/WriteImage.hpp" + +#include "simplnx/Common/AtomicFile.hpp" +#include "simplnx/DataStructure/DataPath.hpp" +#include "simplnx/DataStructure/DataStore.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Filter/Actions/EmptyAction.hpp" +#include "simplnx/Parameters/ArraySelectionParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/DataGroupSelectionParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/FileSystemPathParameter.hpp" +#include "simplnx/Parameters/GeometrySelectionParameter.hpp" +#include "simplnx/Parameters/NumberParameter.hpp" +#include "simplnx/Parameters/StringParameter.hpp" +#include "simplnx/Utilities/ImageIO/ImageIOFactory.hpp" +#include "simplnx/Utilities/SIMPLConversion.hpp" +#include "simplnx/Utilities/StringUtilities.hpp" + +#include + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +using namespace nx::core; + +namespace +{ +const std::set& GetScalarPixelAllowedTypes() +{ + static const std::set dataTypes = {nx::core::DataType::int8, nx::core::DataType::uint8, nx::core::DataType::int16, nx::core::DataType::uint16, + nx::core::DataType::int32, nx::core::DataType::uint32, nx::core::DataType::float32}; + return dataTypes; +} +} // namespace + +namespace nx::core +{ + +//------------------------------------------------------------------------------ +std::string WriteImageFilter::name() const +{ + return FilterTraits::name.str(); +} + +//------------------------------------------------------------------------------ +std::string WriteImageFilter::className() const +{ + return FilterTraits::className; +} + +//------------------------------------------------------------------------------ +Uuid WriteImageFilter::uuid() const +{ + return FilterTraits::uuid; +} + +//------------------------------------------------------------------------------ +std::string WriteImageFilter::humanName() const +{ + return "Write Image"; +} + +//------------------------------------------------------------------------------ +std::vector WriteImageFilter::defaultTags() const +{ + return {className(), "io", "output", "write", "export", "image", "jpg", "tiff", "bmp", "png"}; +} + +//------------------------------------------------------------------------------ +Parameters WriteImageFilter::parameters() const +{ + Parameters params; + + using ExtensionListType = std::unordered_set; + params.insertSeparator(Parameters::Separator{"Input Parameter(s)"}); + params.insert(std::make_unique(k_Plane_Key, "Plane", "Selection for plane normal for writing the images (XY, XZ, or YZ)", 0, ChoicesParameter::Choices{"XY", "XZ", "YZ"})); + params.insert( + std::make_unique(k_FileName_Key, "Output File", "Path to the output file to write.", fs::path(), ExtensionListType{}, FileSystemPathParameter::PathType::OutputFile)); + params.insert(std::make_unique(k_IndexOffset_Key, "Index Offset", "This is the starting index when writing multiple images", 0)); + params.insert(std::make_unique(k_TotalIndexDigits_Key, "Total Number of Index Digits", "This is the total number of digits to use when generating the index", 3)); + params.insert(std::make_unique(k_LeadingDigitCharacter_Key, "Fill Character", "The character to use for the leading digits if needed", "0")); + + params.insertSeparator(Parameters::Separator{"Input Cell Data"}); + params.insert(std::make_unique(k_ImageGeomPath_Key, "Image Geometry", "Select the Image Geometry Group from the DataStructure.", DataPath{}, + GeometrySelectionParameter::AllowedTypes{IGeometry::Type::Image})); + params.insert( + std::make_unique(k_ImageArrayPath_Key, "Input Image Data Array", "The image data that will be processed by this filter.", DataPath{}, ::GetScalarPixelAllowedTypes())); + + return params; +} + +//------------------------------------------------------------------------------ +IFilter::VersionType WriteImageFilter::parametersVersion() const +{ + return 1; +} + +//------------------------------------------------------------------------------ +IFilter::UniquePointer WriteImageFilter::clone() const +{ + return std::make_unique(); +} + +//------------------------------------------------------------------------------ +IFilter::PreflightResult WriteImageFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const +{ + auto plane = filterArgs.value(k_Plane_Key); + auto filePath = filterArgs.value(k_FileName_Key); + auto indexOffset = filterArgs.value(k_IndexOffset_Key); + auto imageArrayPath = filterArgs.value(k_ImageArrayPath_Key); + auto imageGeomPath = filterArgs.value(k_ImageGeomPath_Key); + auto totalDigits = filterArgs.value(k_TotalIndexDigits_Key); + auto fillChar = filterArgs.value(k_LeadingDigitCharacter_Key); + + // Validate output file format is supported + auto imageIOResult = CreateImageIO(filePath); + if(imageIOResult.invalid()) + { + return {ConvertResultTo(ConvertResult(std::move(imageIOResult)), {})}; + } + + // Validate fill character is a single character + if(fillChar.size() > 1) + { + return {MakeErrorResult(-27010, "The fill character should only be a single value.")}; + } + + // Stored fastest to slowest i.e. X Y Z + const auto& imageGeom = dataStructure.getDataRefAs(imageGeomPath); + const auto& imageArray = dataStructure.getDataRefAs(imageArrayPath); + + // Validate data type is in the supported set + DataType arrayDataType = imageArray.getDataType(); + const auto& allowedTypes = ::GetScalarPixelAllowedTypes(); + if(allowedTypes.find(arrayDataType) == allowedTypes.end()) + { + return {MakeErrorResult( + -27011, fmt::format("Unsupported data type '{}' for image writing. Supported types: int8, uint8, int16, uint16, int32, uint32, float32.", DataTypeToString(arrayDataType)))}; + } + + // Compute slice count based on plane and geometry dims + auto imageGeomDims = imageGeom.getDimensions(); + usize maxSlice = 1; + switch(plane) + { + case 0: // XY + maxSlice = imageGeomDims[2]; + break; + case 1: // XZ + maxSlice = imageGeomDims[1]; + break; + case 2: // YZ + maxSlice = imageGeomDims[0]; + break; + default: + break; + } + + // Generate example filename for PreflightValues + std::stringstream ss; + ss << fs::absolute(filePath).parent_path().string() << "/" << filePath.stem().string(); + ss << "_" << std::setw(totalDigits) << std::setfill(fillChar.empty() ? '0' : fillChar[0]) << maxSlice; + ss << filePath.extension().string(); + + Result resultOutputActions; + std::vector preflightUpdatedValues; + preflightUpdatedValues.push_back({"Example Output File", ss.str()}); + + return {std::move(resultOutputActions), std::move(preflightUpdatedValues)}; +} + +//------------------------------------------------------------------------------ +Result<> WriteImageFilter::executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, + const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const +{ + WriteImageInputValues inputValues; + + inputValues.outputFilePath = filterArgs.value(k_FileName_Key); + inputValues.planeIndex = filterArgs.value(k_Plane_Key); + inputValues.indexOffset = filterArgs.value(k_IndexOffset_Key); + inputValues.totalIndexDigits = filterArgs.value(k_TotalIndexDigits_Key); + inputValues.leadingDigitCharacter = filterArgs.value(k_LeadingDigitCharacter_Key); + inputValues.imageGeometryPath = filterArgs.value(k_ImageGeomPath_Key); + inputValues.imageDataArrayPath = filterArgs.value(k_ImageArrayPath_Key); + + return WriteImage(dataStructure, messageHandler, shouldCancel, &inputValues)(); +} + +namespace +{ +namespace SIMPL +{ +} // namespace SIMPL +} // namespace + +//------------------------------------------------------------------------------ +Result WriteImageFilter::FromSIMPLJson(const nlohmann::json& json) +{ + Arguments args = WriteImageFilter().getDefaultArguments(); + + std::vector> results; + + /* This is a NEW filter and not ported so this section does not matter */ + + Result<> conversionResult = MergeResults(std::move(results)); + + return ConvertResultTo(std::move(conversionResult), std::move(args)); +} + +} // namespace nx::core diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/WriteImageFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/WriteImageFilter.hpp new file mode 100644 index 0000000000..d42ba4542a --- /dev/null +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/WriteImageFilter.hpp @@ -0,0 +1,124 @@ +#pragma once + +#include "SimplnxCore/SimplnxCore_export.hpp" + +#include "simplnx/Filter/FilterTraits.hpp" +#include "simplnx/Filter/IFilter.hpp" + +namespace nx::core +{ +/** + * @class WriteImageFilter + * @brief This filter will .... + */ +class SIMPLNXCORE_EXPORT WriteImageFilter : public IFilter +{ +public: + WriteImageFilter() = default; + ~WriteImageFilter() noexcept override = default; + + WriteImageFilter(const WriteImageFilter&) = delete; + WriteImageFilter(WriteImageFilter&&) noexcept = delete; + + WriteImageFilter& operator=(const WriteImageFilter&) = delete; + WriteImageFilter& operator=(WriteImageFilter&&) noexcept = delete; + + // Parameter Keys + static constexpr StringLiteral k_Plane_Key = "plane_index"; + static constexpr StringLiteral k_FileName_Key = "file_name"; + static constexpr StringLiteral k_IndexOffset_Key = "index_offset"; + static constexpr StringLiteral k_ImageArrayPath_Key = "image_array_path"; + static constexpr StringLiteral k_ImageGeomPath_Key = "input_image_geometry_path"; + static constexpr StringLiteral k_TotalIndexDigits_Key = "total_index_digits"; + static constexpr StringLiteral k_LeadingDigitCharacter_Key = "leading_digit_character"; + + /** + * @brief Reads SIMPL json and converts it simplnx Arguments. + * @param json + * @return Result + */ + static Result FromSIMPLJson(const nlohmann::json& json); + + /** + * @brief Returns the name of the filter. + * @return + */ + std::string name() const override; + + /** + * @brief Returns the C++ classname of this filter. + * @return + */ + std::string className() const override; + + /** + * @brief Returns the uuid of the filter. + * @return + */ + Uuid uuid() const override; + + /** + * @brief Returns the human readable name of the filter. + * @return + */ + std::string humanName() const override; + + /** + * @brief Returns the default tags for this filter. + * @return + */ + std::vector defaultTags() const override; + + /** + * @brief Returns the parameters of the filter (i.e. its inputs) + * @return + */ + Parameters parameters() const override; + + /** + * @brief Returns parameters version integer. + * Initial version should always be 1. + * Should be incremented everytime the parameters change. + * @return VersionType + */ + VersionType parametersVersion() const override; + + /** + * @brief Returns a copy of the filter. + * @return + */ + UniquePointer clone() const override; + +protected: + /** + * @brief Takes in a DataStructure and checks that the filter can be run on it with the given arguments. + * Returns any warnings/errors. Also returns the changes that would be applied to the DataStructure. + * Some parts of the actions may not be completely filled out if all the required information is not available at preflight time. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + PreflightResult preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; + + /** + * @brief Applies the filter's algorithm to the DataStructure with the given arguments. Returns any warnings/errors. + * On failure, there is no guarantee that the DataStructure is in a correct state. + * @param dataStructure The input DataStructure instance + * @param filterArgs These are the input values for each parameter that is required for the filter + * @param pipelineNode The node in the pipeline that is being executed + * @param messageHandler The MessageHandler object + * @param shouldCancel Atomic boolean value that can be checked to cancel the filter + * @param executionContext The ExecutionContext that can be used to determine the correct absolute path from a relative path + * @return Returns a Result object with error or warning values if any of those occurred during execution of this function + */ + Result<> executeImpl(DataStructure& dataStructure, const Arguments& filterArgs, const PipelineFilter* pipelineNode, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, + const ExecutionContext& executionContext) const override; +}; +} // namespace nx::core + +SIMPLNX_DEF_FILTER_TRAITS(nx::core, WriteImageFilter, "a8b920c7-5445-4c8a-b7d7-6cabc578d587"); +/* LEGACY UUID FOR THIS FILTER @OLD_UUID@ */ diff --git a/src/Plugins/SimplnxCore/test/CMakeLists.txt b/src/Plugins/SimplnxCore/test/CMakeLists.txt index 148a53daa6..265e09e869 100644 --- a/src/Plugins/SimplnxCore/test/CMakeLists.txt +++ b/src/Plugins/SimplnxCore/test/CMakeLists.txt @@ -82,6 +82,7 @@ set(${PLUGIN_NAME}UnitTest_SRCS ErodeDilateMaskTest.cpp ExecuteProcessTest.cpp ExtractComponentAsArrayTest.cpp + ExtractFeatureBoundaries2DTest.cpp ExtractInternalSurfacesFromTriangleGeometryTest.cpp ExtractPipelineToFileTest.cpp ExtractVertexGeometryTest.cpp @@ -105,7 +106,6 @@ set(${PLUGIN_NAME}UnitTest_SRCS PadImageGeometryTest.cpp PartitionGeometryTest.cpp PipelineTest.cpp - ExtractFeatureBoundaries2DTest.cpp PointSampleEdgeGeometryTest.cpp PointSampleTriangleGeometryFilterTest.cpp QuickSurfaceMeshFilterTest.cpp @@ -113,12 +113,15 @@ set(${PLUGIN_NAME}UnitTest_SRCS ReadBinaryCTNorthstarTest.cpp ReadCSVFileTest.cpp ReadHDF5DatasetTest.cpp + ReadImageStackTest.cpp + ReadImageTest.cpp ReadRawBinaryTest.cpp ReadStlFileTest.cpp ReadStringDataArrayTest.cpp ReadTextDataArrayTest.cpp ReadVolumeGraphicsFileTest.cpp ReadVtkStructuredPointsTest.cpp + ReadZeissTxmFileTest.cpp RegularGridSampleSurfaceMeshTest.cpp RemoveFlaggedEdgesTest.cpp RemoveFlaggedFeaturesTest.cpp @@ -153,6 +156,7 @@ set(${PLUGIN_NAME}UnitTest_SRCS WriteAvizoUniformCoordinateTest.cpp WriteBinaryDataTest.cpp WriteFeatureDataCSVTest.cpp + WriteImageTest.cpp WriteLAMMPSFileTest.cpp WriteLosAlamosFFTTest.cpp WriteNodesAndElementsFilesTest.cpp @@ -160,7 +164,6 @@ set(${PLUGIN_NAME}UnitTest_SRCS WriteSPParksSitesTest.cpp WriteStlFileTest.cpp WriteVtkRectilinearGridTest.cpp - ReadZeissTxmFileTest.cpp ) create_simplnx_plugin_unit_test(PLUGIN_NAME ${PLUGIN_NAME} diff --git a/src/Plugins/SimplnxCore/test/ReadImageStackTest.cpp b/src/Plugins/SimplnxCore/test/ReadImageStackTest.cpp new file mode 100644 index 0000000000..5d1e299705 --- /dev/null +++ b/src/Plugins/SimplnxCore/test/ReadImageStackTest.cpp @@ -0,0 +1,1208 @@ +#include + +#include "SimplnxCore/Filters/ReadImageFilter.hpp" +#include "SimplnxCore/Filters/ReadImageStackFilter.hpp" +#include "SimplnxCore/SimplnxCore_test_dirs.hpp" + +#include "simplnx/Common/Types.hpp" +#include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/CropGeometryParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/Parameters/GeneratedFileListParameter.hpp" +#include "simplnx/Parameters/VectorParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" + +using namespace nx::core; +using namespace nx::core::UnitTest; + +namespace fs = std::filesystem; + +namespace +{ +// The read-image-stack test re-uses the ImageStack data from the ITKImageProcessing plugin source +// tree. See the note in WriteImageTest.cpp for why we reach across plugin boundaries here. +const std::string k_ImageStackDir = std::string(unit_test::k_SimplnxSourceDIr.view()) + "/src/Plugins/ITKImageProcessing/data/ImageStack"; +const DataPath k_ImageGeomPath = {{"ImageGeometry"}}; +const DataPath k_ImageDataPath = k_ImageGeomPath.createChildPath(ImageGeom::k_CellAttributeMatrixName).createChildPath("ImageData"); +// The image_flip_test_images directory lives inside the import_image_stack_test_v3 archive +// alongside the main exemplar file. The k_ImageFlipStackDir path below resolves to +// .../TestFiles/import_image_stack_test_v3/image_flip_test_images. +const std::string k_FlippedImageStackSubDirName = "image_flip_test_images"; +const DataPath k_XGeneratedImageGeomPath = DataPath({"xGeneratedImageGeom"}); +const DataPath k_YGeneratedImageGeomPath = DataPath({"yGeneratedImageGeom"}); +const DataPath k_XFlipImageGeomPath = DataPath({"xFlipImageGeom"}); +const DataPath k_YFlipImageGeomPath = DataPath({"yFlipImageGeom"}); +const std::string k_ImageDataName = "ImageData"; +const ChoicesParameter::ValueType k_NoImageTransform = 0; +const ChoicesParameter::ValueType k_FlipAboutXAxis = 1; +const ChoicesParameter::ValueType k_FlipAboutYAxis = 2; +const fs::path k_ImageFlipStackDir = fs::path(fmt::format("{}/import_image_stack_test_v3/{}", unit_test::k_TestFilesDir, k_FlippedImageStackSubDirName)); + +// Exemplar Array Paths +const DataPath k_XFlippedImageDataPath = k_XFlipImageGeomPath.createChildPath(Constants::k_Cell_Data).createChildPath(::k_ImageDataName); +const DataPath k_YFlippedImageDataPath = k_YFlipImageGeomPath.createChildPath(Constants::k_Cell_Data).createChildPath(::k_ImageDataName); + +void ExecuteImportImageStackXY(DataStructure& dataStructure, const std::string& filePrefix) +{ + UnitTest::LoadPlugins(); + + // Define Shared parameters + std::vector k_Origin = {0.0f, 0.0f, 0.0f}; + std::vector k_Spacing = {1.0f, 1.0f, 1.0f}; + GeneratedFileListParameter::ValueType k_FileListInfo; + + // Set File list for reads + { + k_FileListInfo.inputPath = k_ImageFlipStackDir.string(); + k_FileListInfo.startIndex = 1; + k_FileListInfo.endIndex = 1; + k_FileListInfo.incrementIndex = 1; + k_FileListInfo.fileExtension = ".tiff"; + k_FileListInfo.filePrefix = filePrefix; + k_FileListInfo.fileSuffix = ""; + k_FileListInfo.paddingDigits = 1; + k_FileListInfo.ordering = GeneratedFileListParameter::Ordering::LowToHigh; + } + + // Run generated X flip + { + ReadImageStackFilter filter; + Arguments args; + + args.insertOrAssign(ReadImageStackFilter::k_Origin_Key, std::make_any>(k_Origin)); + args.insertOrAssign(ReadImageStackFilter::k_Spacing_Key, std::make_any>(k_Spacing)); + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, std::make_any(k_FileListInfo)); + args.insertOrAssign(ReadImageStackFilter::k_ImageGeometryPath_Key, std::make_any(::k_XGeneratedImageGeomPath)); + args.insertOrAssign(ReadImageStackFilter::k_ImageTransformChoice_Key, std::make_any(::k_FlipAboutXAxis)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + } + + // Run generated Y flip + { + ReadImageStackFilter filter; + Arguments args; + + args.insertOrAssign(ReadImageStackFilter::k_Origin_Key, std::make_any>(k_Origin)); + args.insertOrAssign(ReadImageStackFilter::k_Spacing_Key, std::make_any>(k_Spacing)); + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, std::make_any(k_FileListInfo)); + args.insertOrAssign(ReadImageStackFilter::k_ImageGeometryPath_Key, std::make_any(::k_YGeneratedImageGeomPath)); + args.insertOrAssign(ReadImageStackFilter::k_ImageTransformChoice_Key, std::make_any(::k_FlipAboutYAxis)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + } +} + +void ReadInFlippedXYExemplars(DataStructure& dataStructure, const std::string& filePrefix) +{ + { + ReadImageFilter filter; + Arguments args; + + fs::path filePath = k_ImageFlipStackDir / (filePrefix + "flip_x.tiff"); + args.insertOrAssign(ReadImageFilter::k_FileName_Key, filePath); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, ::k_XFlipImageGeomPath); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(::k_ImageDataName)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + } + { + ReadImageFilter filter; + Arguments args; + + fs::path filePath = k_ImageFlipStackDir / (filePrefix + "flip_y.tiff"); + args.insertOrAssign(ReadImageFilter::k_FileName_Key, filePath); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, ::k_YFlipImageGeomPath); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(::k_ImageDataName)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + } +} + +void CompareXYFlippedGeometries(DataStructure& dataStructure) +{ + UnitTest::CompareImageGeometry(dataStructure, ::k_XFlipImageGeomPath, k_XGeneratedImageGeomPath); + UnitTest::CompareImageGeometry(dataStructure, ::k_YFlipImageGeomPath, k_YGeneratedImageGeomPath); + + // Processed + DataPath k_XGeneratedImageDataPath = k_XGeneratedImageGeomPath.createChildPath(Constants::k_Cell_Data).createChildPath(::k_ImageDataName); + DataPath k_YGeneratedImageDataPath = k_YGeneratedImageGeomPath.createChildPath(Constants::k_Cell_Data).createChildPath(::k_ImageDataName); + const auto& xGeneratedImageData = dataStructure.getDataRefAs(k_XGeneratedImageDataPath); + const auto& yGeneratedImageData = dataStructure.getDataRefAs(k_YGeneratedImageDataPath); + + // Exemplar + const auto& xFlippedImageData = dataStructure.getDataRefAs(k_XFlippedImageDataPath); + const auto& yFlippedImageData = dataStructure.getDataRefAs(k_YFlippedImageDataPath); + + UnitTest::CompareDataArrays(xGeneratedImageData, xFlippedImageData); + UnitTest::CompareDataArrays(yGeneratedImageData, yFlippedImageData); +} + +// Test data paths +const std::string k_TestDataDirName = "import_image_stack_test_v3"; +const fs::path k_TestDataDir = fs::path(unit_test::k_TestFilesDir.view()) / k_TestDataDirName; +const fs::path k_InputImagesDir = k_TestDataDir / "input_images"; +const fs::path k_ExemplarFile = k_TestDataDir / "import_image_stack_test_v3.dream3d"; + +// Standard test parameters +const std::string k_FilePrefix = "200x200_"; +const std::string k_FileExtension = ".tif"; + +// Cropping boundaries (crop to the colored square: 50:150 in X/Y, all Z) +const IntVec2Type k_VoxelCropX = {50, 150}; +const IntVec2Type k_VoxelCropY = {50, 150}; +const IntVec2Type k_VoxelCropZ = {0, 1}; + +const FloatVec2Type k_PhysicalCropX = {50.0f, 150.0f}; +const FloatVec2Type k_PhysicalCropY = {50.0f, 150.0f}; +const FloatVec2Type k_PhysicalCropZ = {0.0f, 1.0f}; + +// Resampling/flip/timing constants +const ChoicesParameter::ValueType k_NoResample = 0; +const ChoicesParameter::ValueType k_ScalingFactor = 1; +const ChoicesParameter::ValueType k_ExactDimensions = 2; +const ChoicesParameter::ValueType k_NoFlip = 0; +const ChoicesParameter::ValueType k_FlipX = 1; +const ChoicesParameter::ValueType k_FlipY = 2; +const ChoicesParameter::ValueType k_Preprocessed = 0; +const ChoicesParameter::ValueType k_Postprocessed = 1; + +/** + * @brief Helper to create standard file list for test images + */ +GeneratedFileListParameter::ValueType CreateStandardFileList() +{ + GeneratedFileListParameter::ValueType fileList; + fileList.inputPath = k_InputImagesDir.string(); + fileList.filePrefix = k_FilePrefix; + fileList.fileSuffix = ""; + fileList.fileExtension = k_FileExtension; + fileList.startIndex = 0; + fileList.endIndex = 2; + fileList.incrementIndex = 1; + fileList.paddingDigits = 1; + fileList.ordering = GeneratedFileListParameter::Ordering::LowToHigh; + return fileList; +} + +/** + * @brief Helper to create cropping options + */ +CropGeometryParameter::ValueType CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum type, bool cropX, bool cropY, bool cropZ) +{ + CropGeometryParameter::ValueType crop; + crop.type = type; + crop.cropX = cropX; + crop.cropY = cropY; + crop.cropZ = cropZ; + + if(type == CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume) + { + crop.xBoundVoxels = k_VoxelCropX; + crop.yBoundVoxels = k_VoxelCropY; + crop.zBoundVoxels = k_VoxelCropZ; + } + else if(type == CropGeometryParameter::CropValues::TypeEnum::PhysicalSubvolume) + { + crop.xBoundPhysical = k_PhysicalCropX; + crop.yBoundPhysical = k_PhysicalCropY; + crop.zBoundPhysical = k_PhysicalCropZ; + } + + return crop; +} + +/** + * @brief Execute filter with standard parameters + custom overrides + */ +Result<> ExecuteImportImageStack(DataStructure& dataStructure, const DataPath& outputGeomPath, const CropGeometryParameter::ValueType& cropOptions = {}, + ChoicesParameter::ValueType resampleMode = k_NoResample, float32 scalingFactor = 100.0f, const VectorUInt64Parameter::ValueType& exactDims = {200, 200}, + ChoicesParameter::ValueType flipMode = k_NoFlip, bool convertToGrayscale = false, bool changeOrigin = false, const std::vector& origin = {0.0, 0.0, 0.0}, + bool changeSpacing = false, const std::vector& spacing = {1.0, 1.0, 1.0}, ChoicesParameter::ValueType originSpacingTiming = k_Postprocessed) +{ + ReadImageStackFilter filter; + Arguments args; + + auto fileList = CreateStandardFileList(); + + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, fileList); + args.insertOrAssign(ReadImageStackFilter::k_ImageGeometryPath_Key, outputGeomPath); + args.insertOrAssign(ReadImageStackFilter::k_CroppingOptions_Key, cropOptions); + args.insertOrAssign(ReadImageStackFilter::k_ResampleImagesChoice_Key, resampleMode); + args.insertOrAssign(ReadImageStackFilter::k_ImageTransformChoice_Key, flipMode); + args.insertOrAssign(ReadImageStackFilter::k_ConvertToGrayScale_Key, convertToGrayscale); + args.insertOrAssign(ReadImageStackFilter::k_ChangeOrigin_Key, changeOrigin); + args.insertOrAssign(ReadImageStackFilter::k_Origin_Key, origin); + args.insertOrAssign(ReadImageStackFilter::k_ChangeSpacing_Key, changeSpacing); + args.insertOrAssign(ReadImageStackFilter::k_Spacing_Key, spacing); + args.insertOrAssign(ReadImageStackFilter::k_OriginSpacingProcessing_Key, originSpacingTiming); + + if(resampleMode == k_ScalingFactor) + { + args.insertOrAssign(ReadImageStackFilter::k_Scaling_Key, scalingFactor); + } + else if(resampleMode == k_ExactDimensions) + { + args.insertOrAssign(ReadImageStackFilter::k_ExactXYDimensions_Key, exactDims); + } + + auto preflightResult = filter.preflight(dataStructure, args); + if(preflightResult.outputActions.invalid()) + { + return ConvertResult(std::move(preflightResult.outputActions)); + } + + auto executeResult = filter.execute(dataStructure, args); + return executeResult.result; +} + +/** + * @brief Verify expected geometry dimensions + */ +void VerifyGeometryDimensions(const DataStructure& ds, const DataPath& geomPath, usize expectedX, usize expectedY, usize expectedZ) +{ + const auto* geom = ds.getDataAs(geomPath); + REQUIRE(geom != nullptr); + + SizeVec3 dims = geom->getDimensions(); + REQUIRE(dims[0] == expectedX); + REQUIRE(dims[1] == expectedY); + REQUIRE(dims[2] == expectedZ); +} + +/** + * @brief Verify origin and spacing + */ +void VerifyOriginSpacing(const DataStructure& ds, const DataPath& geomPath, const FloatVec3& expectedOrigin, const FloatVec3& expectedSpacing) +{ + const auto* geom = ds.getDataAs(geomPath); + REQUIRE(geom != nullptr); + + FloatVec3 origin = geom->getOrigin(); + FloatVec3 spacing = geom->getSpacing(); + + REQUIRE(origin[0] == Approx(expectedOrigin[0])); + REQUIRE(origin[1] == Approx(expectedOrigin[1])); + REQUIRE(origin[2] == Approx(expectedOrigin[2])); + + REQUIRE(spacing[0] == Approx(expectedSpacing[0])); + REQUIRE(spacing[1] == Approx(expectedSpacing[1])); + REQUIRE(spacing[2] == Approx(expectedSpacing[2])); +} + +} // namespace + +TEST_CASE("SimplnxCore::ReadImageStackFilter: NoInput", "[SimplnxCore][ReadImageStackFilter]") +{ + ReadImageStackFilter filter; + DataStructure dataStructure; + Arguments args; + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions) + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: NoImageGeometry", "[SimplnxCore][ReadImageStackFilter]") +{ + ReadImageStackFilter filter; + DataStructure dataStructure; + Arguments args; + + GeneratedFileListParameter::ValueType fileListInfo; + + fileListInfo.inputPath = k_ImageStackDir; + + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, std::make_any(fileListInfo)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions) + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: NoFiles", "[SimplnxCore][ReadImageStackFilter]") +{ + ReadImageStackFilter filter; + DataStructure dataStructure; + Arguments args; + + GeneratedFileListParameter::ValueType fileListInfo; + fileListInfo.inputPath = "doesNotExist.ghost"; + fileListInfo.startIndex = 75; + fileListInfo.endIndex = 77; + fileListInfo.fileExtension = "dcm"; + fileListInfo.filePrefix = "Image"; + fileListInfo.fileSuffix = ""; + fileListInfo.paddingDigits = 4; + + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, std::make_any(fileListInfo)); + args.insertOrAssign(ReadImageStackFilter::k_Origin_Key, std::make_any>(3)); + args.insertOrAssign(ReadImageStackFilter::k_Spacing_Key, std::make_any>(3)); + args.insertOrAssign(ReadImageStackFilter::k_ImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions) + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: FileDoesNotExist", "[SimplnxCore][ReadImageStackFilter]") +{ + ReadImageStackFilter filter; + DataStructure dataStructure; + Arguments args; + + GeneratedFileListParameter::ValueType fileListInfo; + fileListInfo.inputPath = k_ImageStackDir; + fileListInfo.startIndex = 75; + fileListInfo.endIndex = 79; + fileListInfo.fileExtension = "dcm"; + fileListInfo.filePrefix = "Image"; + fileListInfo.fileSuffix = ""; + fileListInfo.paddingDigits = 4; + + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, std::make_any(fileListInfo)); + args.insertOrAssign(ReadImageStackFilter::k_Origin_Key, std::make_any>(3)); + args.insertOrAssign(ReadImageStackFilter::k_Spacing_Key, std::make_any>(3)); + args.insertOrAssign(ReadImageStackFilter::k_ImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions) + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: CompareImage", "[SimplnxCore][ReadImageStackFilter]") +{ + UnitTest::LoadPlugins(); + + ReadImageStackFilter filter; + DataStructure dataStructure; + Arguments args; + + GeneratedFileListParameter::ValueType fileListInfo; + fileListInfo.inputPath = k_ImageStackDir; + fileListInfo.startIndex = 11; + fileListInfo.endIndex = 13; + fileListInfo.incrementIndex = 1; + fileListInfo.fileExtension = ".tif"; + fileListInfo.filePrefix = "slice_"; + fileListInfo.fileSuffix = ""; + fileListInfo.paddingDigits = 2; + fileListInfo.ordering = GeneratedFileListParameter::Ordering::LowToHigh; + + std::vector origin = {1.0f, 4.0f, 8.0f}; + std::vector spacing = {0.3f, 0.2f, 0.9f}; + + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, std::make_any(fileListInfo)); + args.insertOrAssign(ReadImageStackFilter::k_ChangeOrigin_Key, true); + args.insertOrAssign(ReadImageStackFilter::k_Origin_Key, std::make_any>(origin)); + args.insertOrAssign(ReadImageStackFilter::k_ChangeSpacing_Key, true); + args.insertOrAssign(ReadImageStackFilter::k_Spacing_Key, std::make_any>(spacing)); + args.insertOrAssign(ReadImageStackFilter::k_ImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + const auto* imageGeomPtr = dataStructure.getDataAs(k_ImageGeomPath); + REQUIRE(imageGeomPtr != nullptr); + + SizeVec3 imageDims = imageGeomPtr->getDimensions(); + FloatVec3 imageOrigin = imageGeomPtr->getOrigin(); + FloatVec3 imageSpacing = imageGeomPtr->getSpacing(); + + std::array dims = {524, 390, 3}; + + REQUIRE(imageDims[0] == dims[0]); + REQUIRE(imageDims[1] == dims[1]); + REQUIRE(imageDims[2] == dims[2]); + + REQUIRE(imageOrigin[0] == Approx(origin[0])); + REQUIRE(imageOrigin[1] == Approx(origin[1])); + REQUIRE(imageOrigin[2] == Approx(origin[2])); + + REQUIRE(imageSpacing[0] == Approx(spacing[0])); + REQUIRE(imageSpacing[1] == Approx(spacing[1])); + REQUIRE(imageSpacing[2] == Approx(spacing[2])); + + const auto* imageDataPtr = dataStructure.getDataAs(k_ImageDataPath); + REQUIRE(imageDataPtr != nullptr); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: Flipped Image Even-Even X/Y", "[SimplnxCore][ReadImageStackFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + const std::string filePrefix = "image_flip_even_even_"; + + DataStructure dataStructure; + + // Generate XY Image Geometries with ReadImageStackFilter + ::ExecuteImportImageStackXY(dataStructure, filePrefix); + + // Read in exemplars + ::ReadInFlippedXYExemplars(dataStructure, filePrefix); + +#ifdef SIMPLNX_WRITE_TEST_OUTPUT + UnitTest::WriteTestDataStructure(dataStructure, fmt::format("{}/even_even_import_image_stack_test.dream3d", unit_test::k_BinaryTestOutputDir)); +#endif + + // Compare against exemplars + ::CompareXYFlippedGeometries(dataStructure); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: Flipped Image Even-Odd X/Y", "[SimplnxCore][ReadImageStackFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + const std::string filePrefix = "image_flip_even_odd_"; + + DataStructure dataStructure; + + // Generate XY Image Geometries with ReadImageStackFilter + ::ExecuteImportImageStackXY(dataStructure, filePrefix); + + // Read in exemplars + ::ReadInFlippedXYExemplars(dataStructure, filePrefix); + +#ifdef SIMPLNX_WRITE_TEST_OUTPUT + UnitTest::WriteTestDataStructure(dataStructure, fmt::format("{}/even_odd_import_image_stack_test.dream3d", unit_test::k_BinaryTestOutputDir)); +#endif + + // Compare against exemplars + ::CompareXYFlippedGeometries(dataStructure); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: Flipped Image Odd-Even X/Y", "[SimplnxCore][ReadImageStackFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + const std::string filePrefix = "image_flip_odd_even_"; + + DataStructure dataStructure; + + ::ExecuteImportImageStackXY(dataStructure, filePrefix); + ::ReadInFlippedXYExemplars(dataStructure, filePrefix); + +#ifdef SIMPLNX_WRITE_TEST_OUTPUT + UnitTest::WriteTestDataStructure(dataStructure, fmt::format("{}/odd_even_import_image_stack_test.dream3d", unit_test::k_BinaryTestOutputDir)); +#endif + + ::CompareXYFlippedGeometries(dataStructure); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter: Flipped Image Odd-Odd X/Y", "[SimplnxCore][ReadImageStackFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + const std::string filePrefix = "image_flip_odd_odd_"; + + DataStructure dataStructure; + + ::ExecuteImportImageStackXY(dataStructure, filePrefix); + ::ReadInFlippedXYExemplars(dataStructure, filePrefix); + +#ifdef SIMPLNX_WRITE_TEST_OUTPUT + UnitTest::WriteTestDataStructure(dataStructure, fmt::format("{}/odd_odd_import_image_stack_test.dream3d", unit_test::k_BinaryTestOutputDir)); +#endif + + ::CompareXYFlippedGeometries(dataStructure); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Baseline_NoProcessing", "[SimplnxCore][ReadImageStackFilter][Baseline]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Baseline_Geometry"}); + auto result = ExecuteImportImageStack(ds, geomPath); + + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 3); + VerifyOriginSpacing(ds, geomPath, {0.0f, 0.0f, 0.0f}, {1.0f, 1.0f, 1.0f}); + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Baseline_Geometry"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Baseline_Geometry", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +// ============================================================================= +// CROPPING TESTS +// ============================================================================= + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Crop_Voxel_XOnly", "[SimplnxCore][ReadImageStackFilter][Cropping]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Voxel_X"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 200, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_Voxel_X"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_Voxel_X", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Crop_Voxel_YOnly", "[SimplnxCore][ReadImageStackFilter][Cropping]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Voxel_Y"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, false, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 101, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_Voxel_Y"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_Voxel_Y", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Crop_Voxel_ZOnly", "[SimplnxCore][ReadImageStackFilter][Cropping]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Voxel_Z"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, false, false, true); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 2); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_Voxel_Z"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_Voxel_Z", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Crop_Voxel_XY", "[SimplnxCore][ReadImageStackFilter][Cropping]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Voxel_XY"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 101, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_Voxel_XY"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_Voxel_XY", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Crop_Voxel_XYZ", "[SimplnxCore][ReadImageStackFilter][Cropping]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Voxel_XYZ"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, true); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 101, 2); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_Voxel_XYZ"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_Voxel_XYZ", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Crop_Physical_XY", "[SimplnxCore][ReadImageStackFilter][Cropping]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Physical_XY"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::PhysicalSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 101, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_Physical_XY"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_Physical_XY", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Crop_Physical_Z", "[SimplnxCore][ReadImageStackFilter][Cropping]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Physical_Z"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::PhysicalSubvolume, false, false, true); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 2); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_Physical_Z"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_Physical_Z", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +// ============================================================================= +// RESAMPLING TESTS +// ============================================================================= + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Resample_ScalingFactor", "[SimplnxCore][ReadImageStackFilter][Resampling]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Resample_Scaling50"}); + auto noCrop = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::NoCropping, false, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, noCrop, k_ScalingFactor, 50.0f); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 100, 100, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Resample_Scaling_50"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Resample_Scaling_50", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Resample_ExactDimensions", "[SimplnxCore][ReadImageStackFilter][Resampling]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Resample_Exact128x128"}); + auto noCrop = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::NoCropping, false, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, noCrop, k_ExactDimensions, 100.0f, {128, 128}); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 128, 128, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Resample_Exact_128x128"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Resample_Exact_128x128", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +// ============================================================================= +// GRAYSCALE TESTS +// ============================================================================= + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Grayscale_Conversion", "[SimplnxCore][ReadImageStackFilter][Grayscale]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Grayscale"}); + auto noCrop = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::NoCropping, false, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, noCrop, k_NoResample, 100.0f, {200, 200}, k_NoFlip, true); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 3); + + DataPath grayscalePath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + REQUIRE_NOTHROW(ds.getDataRefAs(grayscalePath)); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Grayscale_Conversion"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Grayscale_Conversion", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +// ============================================================================= +// FLIP TESTS +// ============================================================================= + +TEST_CASE("SimplnxCore::ReadImageStackFilter::FlipY", "[SimplnxCore][ReadImageStackFilter][Flip]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"FlipY_Test"}); + auto noCrop = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::NoCropping, false, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, noCrop, k_NoResample, 100.0f, {200, 200}, k_FlipY); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"FlipY_Test"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"FlipY_Test", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +// ============================================================================= +// ORIGIN/SPACING TESTS +// ============================================================================= + +TEST_CASE("SimplnxCore::ReadImageStackFilter::OriginSpacing_Preprocessed", "[SimplnxCore][ReadImageStackFilter][OriginSpacing]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"OriginSpacing_Preprocessed"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_NoResample, 100.0f, {200, 200}, k_NoFlip, false, true, {10.0, 20.0, 30.0}, true, {2.0, 2.0, 2.0}, k_Preprocessed); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 101, 3); + VerifyOriginSpacing(ds, geomPath, {110.0f, 120.0f, 30.0f}, {2.0f, 2.0f, 2.0f}); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"OriginSpacing_Preprocessed"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"OriginSpacing_Preprocessed", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::OriginSpacing_Postprocessed", "[SimplnxCore][ReadImageStackFilter][OriginSpacing]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"OriginSpacing_Postprocessed"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_NoResample, 100.0f, {200, 200}, k_NoFlip, false, true, {10.0, 20.0, 30.0}, true, {2.0, 2.0, 2.0}, k_Postprocessed); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 101, 3); + VerifyOriginSpacing(ds, geomPath, {10.0f, 20.0f, 30.0f}, {2.0f, 2.0f, 2.0f}); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"OriginSpacing_Postprocessed"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"OriginSpacing_Postprocessed", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::OriginSpacing_Preprocessed_WithZCrop", "[SimplnxCore][ReadImageStackFilter][OriginSpacing]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"OriginSpacing_Preprocessed_WithZCrop"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, false, false, true); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_NoResample, 100.0f, {200, 200}, k_NoFlip, false, true, {10.0, 20.0, 30.0}, true, {2.0, 2.0, 2.0}, k_Preprocessed); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 2); + VerifyOriginSpacing(ds, geomPath, {10.0f, 20.0f, 30.0f}, {2.0f, 2.0f, 2.0f}); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"OriginSpacing_Preprocessed_WithZCrop"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"OriginSpacing_Preprocessed_WithZCrop", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::OriginSpacing_Postprocessed_WithZCrop", "[SimplnxCore][ReadImageStackFilter][OriginSpacing]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"OriginSpacing_Postprocessed_WithZCrop"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, false, false, true); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_NoResample, 100.0f, {200, 200}, k_NoFlip, false, true, {10.0, 20.0, 30.0}, true, {2.0, 2.0, 2.0}, k_Postprocessed); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 2); + VerifyOriginSpacing(ds, geomPath, {10.0f, 20.0f, 30.0f}, {2.0f, 2.0f, 2.0f}); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"OriginSpacing_Postprocessed_WithZCrop"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"OriginSpacing_Postprocessed_WithZCrop", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +// ============================================================================= +// INTERACTION TESTS +// ============================================================================= + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Interaction_Crop_Resample", "[SimplnxCore][ReadImageStackFilter][Interaction]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Then_Resample"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_ExactDimensions, 100, {64, 64}); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 64, 64, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_And_Resample"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_And_Resample", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Interaction_Crop_Flip", "[SimplnxCore][ReadImageStackFilter][Interaction]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Then_FlipX"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_NoResample, 100.0f, {200, 200}, k_FlipX); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 101, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_And_FlipX"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_And_FlipX", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Interaction_Resample_Flip", "[SimplnxCore][ReadImageStackFilter][Interaction]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Resample_Then_FlipX"}); + auto noCrop = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::NoCropping, false, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, noCrop, k_ExactDimensions, 100.0f, {128, 128}, k_FlipX); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 128, 128, 3); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Resample_And_FlipX"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Resample_And_FlipX", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Interaction_Crop_Grayscale", "[SimplnxCore][ReadImageStackFilter][Interaction]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Crop_Then_Grayscale"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_NoResample, 100.0f, {200, 200}, k_NoFlip, true); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 101, 101, 3); + + DataPath grayscalePath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + REQUIRE_NOTHROW(ds.getDataRefAs(grayscalePath)); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Crop_And_Grayscale"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Crop_And_Grayscale", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Interaction_Resample_Grayscale", "[SimplnxCore][ReadImageStackFilter][Interaction]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Resample_Then_Grayscale"}); + auto noCrop = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::NoCropping, false, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, noCrop, k_ExactDimensions, 100.0f, {128, 128}, k_NoFlip, true); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 128, 128, 3); + + DataPath grayscalePath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + REQUIRE_NOTHROW(ds.getDataRefAs(grayscalePath)); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Resample_And_Grayscale"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Resample_And_Grayscale", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Interaction_Grayscale_Flip", "[SimplnxCore][ReadImageStackFilter][Interaction]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Grayscale_Then_FlipX"}); + auto noCrop = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::NoCropping, false, false, false); + + auto result = ExecuteImportImageStack(ds, geomPath, noCrop, k_NoResample, 100.0f, {200, 200}, k_FlipX, true); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 200, 200, 3); + + DataPath grayscalePath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + REQUIRE_NOTHROW(ds.getDataRefAs(grayscalePath)); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Grayscale_And_FlipX"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Grayscale_And_FlipX", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} + +TEST_CASE("SimplnxCore::ReadImageStackFilter::Interaction_FullPipeline", "[SimplnxCore][ReadImageStackFilter][Interaction]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "import_image_stack_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + DataStructure ds; + + const DataPath geomPath({"Full_Pipeline_Calculated"}); + auto cropOptions = CreateCropOptions(CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume, true, true, false); + + auto result = ExecuteImportImageStack(ds, geomPath, cropOptions, k_ScalingFactor, 50.0f, {100, 100}, k_FlipX, true); + SIMPLNX_RESULT_REQUIRE_VALID(result); + + VerifyGeometryDimensions(ds, geomPath, 50, 50, 3); + + DataPath grayscalePath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + REQUIRE_NOTHROW(ds.getDataRefAs(grayscalePath)); + + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + const auto* generatedGeom = ds.getDataAs(geomPath); + const auto* exemplarGeom = exemplarDS.getDataAs(DataPath({"Full_Pipeline"})); + UnitTest::CompareImageGeometry(exemplarGeom, generatedGeom); + + DataPath generatedDataPath = geomPath.createChildPath(Constants::k_Cell_Data).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Full_Pipeline", Constants::k_Cell_Data, k_ImageDataName}); + const auto& generatedArray = ds.getDataRefAs(generatedDataPath); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); +} diff --git a/src/Plugins/SimplnxCore/test/ReadImageTest.cpp b/src/Plugins/SimplnxCore/test/ReadImageTest.cpp new file mode 100644 index 0000000000..51e99c616e --- /dev/null +++ b/src/Plugins/SimplnxCore/test/ReadImageTest.cpp @@ -0,0 +1,489 @@ +#include + +#include "SimplnxCore/Filters/ReadImageFilter.hpp" +#include "SimplnxCore/SimplnxCore_test_dirs.hpp" + +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/CropGeometryParameter.hpp" +#include "simplnx/Parameters/DataObjectNameParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" + +namespace fs = std::filesystem; + +using namespace nx::core; +using namespace nx::core::UnitTest; + +namespace +{ +const std::string k_TestDataDirName = "itk_image_reader_test_v3"; +const fs::path k_TestDataDir = fs::path(unit_test::k_TestFilesDir.view()) / k_TestDataDirName; +const fs::path k_ExemplarFile = k_TestDataDir / "itk_image_reader_test_v3.dream3d"; +const fs::path k_InputImageFile = k_TestDataDir / "200x200_0.tif"; +const std::string k_ImageGeometryName = "[ImageGeometry]"; +const std::string k_ImageCellDataName = "Cell Data"; +const std::string k_ImageDataName = "ImageData"; + +// Values for ReadImageFilter::k_OriginSpacingProcessing_Key +// 0 = Preprocessed, 1 = Postprocessed +constexpr uint64 k_Preprocessed = 0; +constexpr uint64 k_Postprocessed = 1; +} // namespace + +TEST_CASE("SimplnxCore::ReadImageFilter: Read_Basic", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, false); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, false); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"Read_Basic"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"Read_Basic"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Read_Basic", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: Override_Origin", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + std::vector k_Origin{-32.0, -32.0, 0.0}; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, true); + args.insertOrAssign(ReadImageFilter::k_Origin_Key, k_Origin); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, false); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"Override_Origin"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"Override_Origin"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Override_Origin", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: Centering_Origin", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, true); + args.insertOrAssign(ReadImageFilter::k_CenterOrigin_Key, true); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, false); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"Centering_Origin"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"Centering_Origin"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Centering_Origin", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: Override_Spacing", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + std::vector k_Spacing{2.5, 3.0, 1.0}; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, false); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, true); + args.insertOrAssign(ReadImageFilter::k_Spacing_Key, k_Spacing); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"Override_Spacing"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"Override_Spacing"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Override_Spacing", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: OriginSpacing_Preprocessed", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + std::vector k_Origin{10.0, 20.0, 0.0}; + std::vector k_Spacing{2.0, 2.0, 1.0}; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + auto cropOptions = CropGeometryParameter::ValueType(); + cropOptions.type = CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume; + cropOptions.cropX = true; + cropOptions.cropY = true; + cropOptions.cropZ = false; + cropOptions.xBoundVoxels = {50, 150}; + cropOptions.yBoundVoxels = {50, 150}; + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, true); + args.insertOrAssign(ReadImageFilter::k_Origin_Key, k_Origin); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, true); + args.insertOrAssign(ReadImageFilter::k_Spacing_Key, k_Spacing); + args.insertOrAssign(ReadImageFilter::k_OriginSpacingProcessing_Key, k_Preprocessed); + args.insertOrAssign(ReadImageFilter::k_CroppingOptions_Key, cropOptions); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"OriginSpacing_Preprocessed"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"OriginSpacing_Preprocessed"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"OriginSpacing_Preprocessed", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: OriginSpacing_Postprocessed", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + std::vector k_Origin{10.0, 20.0, 0.0}; + std::vector k_Spacing{2.0, 2.0, 1.0}; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + auto cropOptions = CropGeometryParameter::ValueType(); + cropOptions.type = CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume; + cropOptions.cropX = true; + cropOptions.cropY = true; + cropOptions.cropZ = false; + cropOptions.xBoundVoxels = {50, 150}; + cropOptions.yBoundVoxels = {50, 150}; + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, true); + args.insertOrAssign(ReadImageFilter::k_Origin_Key, k_Origin); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, true); + args.insertOrAssign(ReadImageFilter::k_Spacing_Key, k_Spacing); + args.insertOrAssign(ReadImageFilter::k_OriginSpacingProcessing_Key, k_Postprocessed); + args.insertOrAssign(ReadImageFilter::k_CroppingOptions_Key, cropOptions); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"OriginSpacing_Postprocessed"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"OriginSpacing_Postprocessed"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"OriginSpacing_Postprocessed", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: DataType_Conversion", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + const uint64 k_DataTypeUInt16 = 1; + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, false); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, false); + args.insertOrAssign(ReadImageFilter::k_ChangeDataType_Key, true); + args.insertOrAssign(ReadImageFilter::k_ImageDataType_Key, k_DataTypeUInt16); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"DataType_Conversion"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"DataType_Conversion"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"DataType_Conversion", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: Interaction_Crop_DataType", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + const DataPath inputGeometryPath({k_ImageGeometryName}); + + auto cropOptions = CropGeometryParameter::ValueType(); + cropOptions.type = CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume; + cropOptions.cropX = true; + cropOptions.cropY = true; + cropOptions.cropZ = false; + cropOptions.xBoundVoxels = {50, 150}; + cropOptions.yBoundVoxels = {50, 150}; + + const uint64 k_DataTypeUInt32 = 2; + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, false); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, false); + args.insertOrAssign(ReadImageFilter::k_CroppingOptions_Key, cropOptions); + args.insertOrAssign(ReadImageFilter::k_ChangeDataType_Key, true); + args.insertOrAssign(ReadImageFilter::k_ImageDataType_Key, k_DataTypeUInt32); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"Interaction_Crop_DataType"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"Interaction_Crop_DataType"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Interaction_Crop_DataType", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadImageFilter: Interaction_All", "[SimplnxCore][ReadImageFilter]") +{ + const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "itk_image_reader_test_v3.tar.gz", k_TestDataDirName, true, true); + + UnitTest::LoadPlugins(); + ReadImageFilter filter; + DataStructure dataStructure; + Arguments args; + + std::vector k_Origin{5.0, 10.0, 0.0}; + std::vector k_Spacing{2.0, 2.0, 1.0}; + const DataPath inputGeometryPath({k_ImageGeometryName}); + + auto cropOptions = CropGeometryParameter::ValueType(); + cropOptions.type = CropGeometryParameter::CropValues::TypeEnum::VoxelSubvolume; + cropOptions.cropX = true; + cropOptions.cropY = true; + cropOptions.cropZ = false; + cropOptions.xBoundVoxels = {50, 150}; + cropOptions.yBoundVoxels = {50, 150}; + + const uint64 k_DataTypeUInt16 = 1; + + args.insertOrAssign(ReadImageFilter::k_FileName_Key, k_InputImageFile); + args.insertOrAssign(ReadImageFilter::k_ImageGeometryPath_Key, inputGeometryPath); + args.insertOrAssign(ReadImageFilter::k_CellDataName_Key, static_cast(k_ImageCellDataName)); + args.insertOrAssign(ReadImageFilter::k_ImageDataArrayPath_Key, static_cast(k_ImageDataName)); + args.insertOrAssign(ReadImageFilter::k_ChangeOrigin_Key, true); + args.insertOrAssign(ReadImageFilter::k_Origin_Key, k_Origin); + args.insertOrAssign(ReadImageFilter::k_ChangeSpacing_Key, true); + args.insertOrAssign(ReadImageFilter::k_Spacing_Key, k_Spacing); + args.insertOrAssign(ReadImageFilter::k_OriginSpacingProcessing_Key, k_Preprocessed); + args.insertOrAssign(ReadImageFilter::k_CroppingOptions_Key, cropOptions); + args.insertOrAssign(ReadImageFilter::k_ChangeDataType_Key, true); + args.insertOrAssign(ReadImageFilter::k_ImageDataType_Key, k_DataTypeUInt16); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + // Compare against exemplar + DataStructure exemplarDS = UnitTest::LoadDataStructure(k_ExemplarFile); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputGeometryPath)); + const auto& generatedGeom = dataStructure.getDataRefAs(inputGeometryPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(DataPath({"Interaction_Crop_OriginSpacing_Preprocessed_DataType"}))); + const auto& exemplarGeom = exemplarDS.getDataRefAs(DataPath({"Interaction_Crop_OriginSpacing_Preprocessed_DataType"})); + UnitTest::CompareImageGeometry(&exemplarGeom, &generatedGeom); + + DataPath generatedDataPath = inputGeometryPath.createChildPath(k_ImageCellDataName).createChildPath(k_ImageDataName); + DataPath exemplarDataPath = DataPath({"Interaction_Crop_OriginSpacing_Preprocessed_DataType", Constants::k_Cell_Data, k_ImageDataName}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(generatedDataPath)); + const auto& generatedArray = dataStructure.getDataRefAs(generatedDataPath); + REQUIRE_NOTHROW(exemplarDS.getDataRefAs(exemplarDataPath)); + const auto& exemplarArray = exemplarDS.getDataRefAs(exemplarDataPath); + UnitTest::CompareDataArrays(exemplarArray, generatedArray); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} diff --git a/src/Plugins/SimplnxCore/test/WriteImageTest.cpp b/src/Plugins/SimplnxCore/test/WriteImageTest.cpp new file mode 100644 index 0000000000..83a273140d --- /dev/null +++ b/src/Plugins/SimplnxCore/test/WriteImageTest.cpp @@ -0,0 +1,219 @@ +#include + +#include "SimplnxCore/Filters/ReadImageStackFilter.hpp" +#include "SimplnxCore/Filters/WriteImageFilter.hpp" +#include "SimplnxCore/SimplnxCore_test_dirs.hpp" + +#include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" +#include "simplnx/Parameters/GeneratedFileListParameter.hpp" +#include "simplnx/Parameters/NumberParameter.hpp" +#include "simplnx/Parameters/StringParameter.hpp" +#include "simplnx/UnitTest/UnitTestCommon.hpp" + +#include +#include +#include + +namespace fs = std::filesystem; + +using namespace nx::core; + +namespace +{ +// The WriteImage test re-uses the ImageStack data from the ITKImageProcessing plugin source tree. +// The ITK plugin keeps a small set of input slices in its in-source data directory and the ITK +// test references them via `unit_test::k_DataDir`. When this test lives in SimplnxCore, the +// `k_DataDir` macro points to SimplnxCore's data directory instead, so we reach over to the ITK +// plugin's data directory using `k_SimplnxSourceDIr`. +const std::string k_ImageStackDir = std::string(unit_test::k_SimplnxSourceDIr.view()) + "/src/Plugins/ITKImageProcessing/data/ImageStack"; +const DataPath k_ImageGeomPath = {{"ImageGeometry"}}; +const DataPath k_ImageDataPath = k_ImageGeomPath.createChildPath(ImageGeom::k_CellAttributeMatrixName).createChildPath("ImageData"); + +// WriteImageFilter plane choices (ChoicesParameter index values) +constexpr uint64 k_XYPlane = 0; +constexpr uint64 k_XZPlane = 1; +constexpr uint64 k_YZPlane = 2; + +std::string CreateRandomDirName() +{ + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> distrib(65, 90); + + std::string s(16, 'z'); + for(int i = 0; i < 16; ++i) + { + s[i] = static_cast(distrib(gen)); + } + + return s; +} + +void validateOutputFiles(size_t numImages, uint64 offset, const std::string& tempDirName, const std::string& tempDirPath) +{ + // Check for the existence of each image file, remove it as we go... + for(size_t i = 0; i < numImages; i++) + { + fs::path imagePath = fs::path() / fmt::format("{}/{}/slice_{:03d}.tif", unit_test::k_BinaryTestOutputDir.view(), tempDirName, i + offset); + INFO(fmt::format("Checking File: '{}' ", imagePath.string())); + REQUIRE(fs::exists(imagePath)); + REQUIRE(std::filesystem::remove(imagePath)); + } + + // Now make sure there are no files left in the directory. + int count = 0; + for(const auto& entry : std::filesystem::directory_iterator(tempDirPath)) + { + count++; + } + REQUIRE(count == 0); + + // Now delete the temp directory + try + { + std::filesystem::remove_all(tempDirPath); + std::cout << "Directory removed successfully: " << tempDirPath << std::endl; + } catch(std::filesystem::filesystem_error& e) + { + std::cout << "Error removing temp directory: " << tempDirPath << std::endl; + std::cout << " " << e.what() << std::endl; + } +} + +} // namespace + +TEST_CASE("SimplnxCore::WriteImageFilter: Write Stack", "[SimplnxCore][WriteImageFilter]") +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + { + ReadImageStackFilter filter; + Arguments args; + + GeneratedFileListParameter::ValueType fileListInfo; + fileListInfo.inputPath = k_ImageStackDir; + fileListInfo.startIndex = 11; + fileListInfo.endIndex = 13; + fileListInfo.incrementIndex = 1; + fileListInfo.fileExtension = ".tif"; + fileListInfo.filePrefix = "slice_"; + fileListInfo.fileSuffix = ""; + fileListInfo.paddingDigits = 2; + fileListInfo.ordering = GeneratedFileListParameter::Ordering::LowToHigh; + + std::vector origin = {1.0f, 4.0f, 8.0f}; + std::vector spacing = {0.3f, 1.2f, 0.9f}; + + args.insertOrAssign(ReadImageStackFilter::k_InputFileListInfo_Key, std::make_any(fileListInfo)); + args.insertOrAssign(ReadImageStackFilter::k_ChangeOrigin_Key, true); + args.insertOrAssign(ReadImageStackFilter::k_Origin_Key, std::make_any>(origin)); + args.insertOrAssign(ReadImageStackFilter::k_ChangeSpacing_Key, true); + args.insertOrAssign(ReadImageStackFilter::k_Spacing_Key, std::make_any>(spacing)); + args.insertOrAssign(ReadImageStackFilter::k_ImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + } + + { + WriteImageFilter filter; + + const std::string tempDirName = CreateRandomDirName(); + const std::string tempDirPath = fmt::format("{}/{}", unit_test::k_BinaryTestOutputDir.view(), tempDirName); + const std::string path = fmt::format("{}/{}/slice.tif", unit_test::k_BinaryTestOutputDir.view(), tempDirName); + + const fs::path outputPath = fs::path() / path; + + Arguments args; + const uint64 offset = 100; + args.insertOrAssign(WriteImageFilter::k_ImageGeomPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(WriteImageFilter::k_ImageArrayPath_Key, std::make_any(k_ImageDataPath)); + args.insertOrAssign(WriteImageFilter::k_FileName_Key, std::make_any(outputPath)); + args.insertOrAssign(WriteImageFilter::k_IndexOffset_Key, std::make_any(offset)); + args.insertOrAssign(WriteImageFilter::k_Plane_Key, std::make_any(k_XYPlane)); + args.insertOrAssign(WriteImageFilter::k_TotalIndexDigits_Key, std::make_any(3)); + args.insertOrAssign(WriteImageFilter::k_LeadingDigitCharacter_Key, std::make_any("0")); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + const auto* imageGeom = dataStructure.getDataAs(k_ImageGeomPath); + SizeVec3 imageDims = imageGeom->getDimensions(); + + validateOutputFiles(imageDims[2], offset, tempDirName, tempDirPath); + } + + { + WriteImageFilter filter; + + const std::string tempDirName = CreateRandomDirName(); + const std::string tempDirPath = fmt::format("{}/{}", unit_test::k_BinaryTestOutputDir.view(), tempDirName); + const std::string path = fmt::format("{}/{}/slice.tif", unit_test::k_BinaryTestOutputDir.view(), tempDirName); + + const fs::path outputPath = fs::path() / path; + + Arguments args; + const uint64 offset = 100; + args.insertOrAssign(WriteImageFilter::k_ImageGeomPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(WriteImageFilter::k_ImageArrayPath_Key, std::make_any(k_ImageDataPath)); + args.insertOrAssign(WriteImageFilter::k_FileName_Key, std::make_any(outputPath)); + args.insertOrAssign(WriteImageFilter::k_IndexOffset_Key, std::make_any(offset)); + args.insertOrAssign(WriteImageFilter::k_Plane_Key, std::make_any(k_XZPlane)); + args.insertOrAssign(WriteImageFilter::k_TotalIndexDigits_Key, std::make_any(3)); + args.insertOrAssign(WriteImageFilter::k_LeadingDigitCharacter_Key, std::make_any("0")); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + const auto* imageGeom = dataStructure.getDataAs(k_ImageGeomPath); + SizeVec3 imageDims = imageGeom->getDimensions(); + + validateOutputFiles(imageDims[1], offset, tempDirName, tempDirPath); + } + + { + WriteImageFilter filter; + + const std::string tempDirName = CreateRandomDirName(); + const std::string tempDirPath = fmt::format("{}/{}", unit_test::k_BinaryTestOutputDir.view(), tempDirName); + const std::string path = fmt::format("{}/{}/slice.tif", unit_test::k_BinaryTestOutputDir.view(), tempDirName); + + const fs::path outputPath = fs::path() / path; + + Arguments args; + const uint64 offset = 100; + args.insertOrAssign(WriteImageFilter::k_ImageGeomPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(WriteImageFilter::k_ImageArrayPath_Key, std::make_any(k_ImageDataPath)); + args.insertOrAssign(WriteImageFilter::k_FileName_Key, std::make_any(outputPath)); + args.insertOrAssign(WriteImageFilter::k_IndexOffset_Key, std::make_any(offset)); + args.insertOrAssign(WriteImageFilter::k_Plane_Key, std::make_any(k_YZPlane)); + args.insertOrAssign(WriteImageFilter::k_TotalIndexDigits_Key, std::make_any(3)); + args.insertOrAssign(WriteImageFilter::k_LeadingDigitCharacter_Key, std::make_any("0")); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) + + const auto* imageGeom = dataStructure.getDataAs(k_ImageGeomPath); + SizeVec3 imageDims = imageGeom->getDimensions(); + + validateOutputFiles(imageDims[0], offset, tempDirName, tempDirPath); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} diff --git a/src/simplnx/Utilities/ImageIO/IImageIO.hpp b/src/simplnx/Utilities/ImageIO/IImageIO.hpp new file mode 100644 index 0000000000..e7b0f17ada --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/IImageIO.hpp @@ -0,0 +1,74 @@ +#pragma once + +#include "simplnx/simplnx_export.hpp" + +#include "simplnx/Common/Result.hpp" +#include "simplnx/Common/Types.hpp" +#include "simplnx/Utilities/ImageIO/ImageMetadata.hpp" + +#include +#include + +namespace nx::core +{ + +/** + * @class IImageIO + * @brief Abstract interface for reading and writing 2D image files. + * + * Implementations handle specific format backends (stb for PNG/JPEG/BMP, + * libtiff for TIFF). The interface operates on raw byte buffers and + * ImageMetadata structs -- it knows nothing about DataStore, ImageGeom, + * or any other simplnx data structures. + * + * Pixel data buffers are packed row-major, top-to-bottom: + * buffer[y * width * numComponents * bytesPerPixel + x * numComponents * bytesPerPixel + c * bytesPerPixel] + */ +class SIMPLNX_EXPORT IImageIO +{ +public: + virtual ~IImageIO() = default; + + IImageIO() = default; + IImageIO(const IImageIO&) = delete; + IImageIO(IImageIO&&) noexcept = delete; + IImageIO& operator=(const IImageIO&) = delete; + IImageIO& operator=(IImageIO&&) noexcept = delete; + + /** + * @brief Reads image metadata (dimensions, type, components, origin, spacing, page count) + * without loading pixel data into memory. + * @param filePath Path to the image file + * @return ImageMetadata on success, or error Result with library-provided message + */ + virtual Result readMetadata(const std::filesystem::path& filePath) const = 0; + + /** + * @brief Reads pixel data into a caller-owned byte buffer. + * + * The buffer must be pre-sized to: width * height * numComponents * bytesPerPixel. + * Caller should obtain dimensions from readMetadata() first. + * Data is packed row-major, top-to-bottom. + * + * @param filePath Path to the image file + * @param buffer Pre-allocated output buffer for pixel data + * @return Empty Result on success, or error Result with library-provided message + */ + virtual Result<> readPixelData(const std::filesystem::path& filePath, std::vector& buffer) const = 0; + + /** + * @brief Writes a 2D image from a raw byte buffer. + * + * Buffer layout: row-major, width * height * numComponents * bytesPerPixel. + * The metadata struct provides dimensions, component count, and data type + * so the backend knows how to interpret the buffer. + * + * @param filePath Path to the output image file + * @param buffer Pixel data to write + * @param metadata Image dimensions, type, and component info + * @return Empty Result on success, or error Result with library-provided message + */ + virtual Result<> writePixelData(const std::filesystem::path& filePath, const std::vector& buffer, const ImageMetadata& metadata) const = 0; +}; + +} // namespace nx::core diff --git a/src/simplnx/Utilities/ImageIO/ImageIOFactory.cpp b/src/simplnx/Utilities/ImageIO/ImageIOFactory.cpp new file mode 100644 index 0000000000..821721f404 --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/ImageIOFactory.cpp @@ -0,0 +1,28 @@ +#include "ImageIOFactory.hpp" + +#include "simplnx/Utilities/ImageIO/StbImageIO.hpp" +#include "simplnx/Utilities/ImageIO/TiffImageIO.hpp" + +#include + +#include + +using namespace nx::core; + +Result> nx::core::CreateImageIO(const std::filesystem::path& filePath) +{ + std::string ext = filePath.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + if(ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") + { + return {std::make_unique()}; + } + + if(ext == ".tif" || ext == ".tiff") + { + return {std::make_unique()}; + } + + return MakeErrorResult>(-20200, fmt::format("Unsupported image format '{}'. Supported: .png, .jpg, .jpeg, .bmp, .tif, .tiff", ext)); +} diff --git a/src/simplnx/Utilities/ImageIO/ImageIOFactory.hpp b/src/simplnx/Utilities/ImageIO/ImageIOFactory.hpp new file mode 100644 index 0000000000..d301f0bf3e --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/ImageIOFactory.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "simplnx/simplnx_export.hpp" + +#include "simplnx/Common/Result.hpp" +#include "simplnx/Utilities/ImageIO/IImageIO.hpp" + +#include +#include + +namespace nx::core +{ + +/** + * @brief Creates the appropriate IImageIO backend based on file extension. + * + * Supported extensions: + * .png, .jpg, .jpeg, .bmp -> StbImageIO + * .tif, .tiff -> TiffImageIO + * + * @param filePath File path (only extension is examined) + * @return unique_ptr on success, or error Result for unsupported extensions + */ +SIMPLNX_EXPORT Result> CreateImageIO(const std::filesystem::path& filePath); + +} // namespace nx::core diff --git a/src/simplnx/Utilities/ImageIO/ImageMetadata.hpp b/src/simplnx/Utilities/ImageIO/ImageMetadata.hpp new file mode 100644 index 0000000000..e2decdf2ef --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/ImageMetadata.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "simplnx/simplnx_export.hpp" + +#include "simplnx/Common/Array.hpp" +#include "simplnx/Common/Types.hpp" + +#include + +namespace nx::core +{ + +/** + * @struct ImageMetadata + * @brief Holds metadata extracted from an image file without loading pixel data. + * + * The origin and spacing fields are std::optional to distinguish between + * "the file contained this value" and "the file had no such metadata." + * This allows callers to decide whether to use file values or user-provided overrides. + */ +struct SIMPLNX_EXPORT ImageMetadata +{ + usize width = 0; ///< X dimension in pixels + usize height = 0; ///< Y dimension in pixels + usize numComponents = 0; ///< 1=grayscale, 3=RGB, 4=RGBA + DataType dataType = DataType::uint8; ///< Pixel data type (uint8, uint16, or float32) + usize numPages = 1; ///< Number of pages (>1 for multi-page TIFF) + std::optional origin; ///< Image origin if stored in file + std::optional spacing; ///< Image spacing/resolution if stored in file +}; + +} // namespace nx::core diff --git a/src/simplnx/Utilities/ImageIO/StbImageIO.cpp b/src/simplnx/Utilities/ImageIO/StbImageIO.cpp new file mode 100644 index 0000000000..b25ae05d5a --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/StbImageIO.cpp @@ -0,0 +1,192 @@ +#include "StbImageIO.hpp" + +#include "simplnx/Common/Result.hpp" + +#define STB_IMAGE_IMPLEMENTATION +#include + +#define STB_IMAGE_WRITE_IMPLEMENTATION +#include + +#include + +#include +#include + +using namespace nx::core; + +namespace +{ +constexpr int32 k_ErrorInfoFailed = -20000; +constexpr int32 k_ErrorLoadFailed = -20001; +constexpr int32 k_ErrorWriteFailed = -20002; +constexpr int32 k_ErrorUnsupportedWriteFormat = -20003; +constexpr int32 k_ErrorUnsupportedDataType = -20004; +constexpr int32 k_ErrorBufferSizeMismatch = -20005; + +/** + * @brief Returns the number of bytes per element for the given DataType. + */ +usize bytesPerElement(DataType type) +{ + switch(type) + { + case DataType::uint8: + return 1; + case DataType::uint16: + return 2; + case DataType::float32: + return 4; + default: + return 0; + } +} +} // namespace + +// ----------------------------------------------------------------------------- +Result StbImageIO::readMetadata(const std::filesystem::path& filePath) const +{ + std::string pathStr = filePath.string(); + + int width = 0; + int height = 0; + int comp = 0; + int result = stbi_info(pathStr.c_str(), &width, &height, &comp); + if(result == 0) + { + const char* reason = stbi_failure_reason(); + return MakeErrorResult(k_ErrorInfoFailed, fmt::format("Failed to read image info from '{}': {}", pathStr, reason != nullptr ? reason : "unknown error")); + } + + ImageMetadata metadata; + metadata.width = static_cast(width); + metadata.height = static_cast(height); + metadata.numComponents = static_cast(comp); + + if(stbi_is_hdr(pathStr.c_str()) != 0) + { + metadata.dataType = DataType::float32; + } + else if(stbi_is_16_bit(pathStr.c_str()) != 0) + { + metadata.dataType = DataType::uint16; + } + else + { + metadata.dataType = DataType::uint8; + } + + metadata.numPages = 1; + // stb does not provide origin or spacing metadata + metadata.origin = std::nullopt; + metadata.spacing = std::nullopt; + + return {std::move(metadata)}; +} + +// ----------------------------------------------------------------------------- +Result<> StbImageIO::readPixelData(const std::filesystem::path& filePath, std::vector& buffer) const +{ + // First get metadata to determine the data type + Result metaResult = readMetadata(filePath); + if(metaResult.invalid()) + { + return ConvertResult(std::move(metaResult)); + } + const ImageMetadata& metadata = metaResult.value(); + + std::string pathStr = filePath.string(); + int width = 0; + int height = 0; + int comp = 0; + + usize bpe = bytesPerElement(metadata.dataType); + usize expectedSize = metadata.width * metadata.height * metadata.numComponents * bpe; + + if(buffer.size() != expectedSize) + { + return MakeErrorResult(k_ErrorBufferSizeMismatch, fmt::format("Buffer size {} does not match expected size {} for image '{}'", buffer.size(), expectedSize, pathStr)); + } + + if(metadata.dataType == DataType::float32) + { + float* data = stbi_loadf(pathStr.c_str(), &width, &height, &comp, 0); + if(data == nullptr) + { + const char* reason = stbi_failure_reason(); + return MakeErrorResult(k_ErrorLoadFailed, fmt::format("Failed to load HDR image '{}': {}", pathStr, reason != nullptr ? reason : "unknown error")); + } + std::memcpy(buffer.data(), data, expectedSize); + stbi_image_free(data); + } + else if(metadata.dataType == DataType::uint16) + { + stbi_us* data = stbi_load_16(pathStr.c_str(), &width, &height, &comp, 0); + if(data == nullptr) + { + const char* reason = stbi_failure_reason(); + return MakeErrorResult(k_ErrorLoadFailed, fmt::format("Failed to load 16-bit image '{}': {}", pathStr, reason != nullptr ? reason : "unknown error")); + } + std::memcpy(buffer.data(), data, expectedSize); + stbi_image_free(data); + } + else + { + stbi_uc* data = stbi_load(pathStr.c_str(), &width, &height, &comp, 0); + if(data == nullptr) + { + const char* reason = stbi_failure_reason(); + return MakeErrorResult(k_ErrorLoadFailed, fmt::format("Failed to load image '{}': {}", pathStr, reason != nullptr ? reason : "unknown error")); + } + std::memcpy(buffer.data(), data, expectedSize); + stbi_image_free(data); + } + + return {}; +} + +// ----------------------------------------------------------------------------- +Result<> StbImageIO::writePixelData(const std::filesystem::path& filePath, const std::vector& buffer, const ImageMetadata& metadata) const +{ + if(metadata.dataType != DataType::uint8) + { + return MakeErrorResult(k_ErrorUnsupportedDataType, fmt::format("stb_image_write only supports uint8 pixel data for writing. Got unsupported data type for '{}'.", filePath.string())); + } + + std::string pathStr = filePath.string(); + std::string ext = filePath.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + + int w = static_cast(metadata.width); + int h = static_cast(metadata.height); + int comp = static_cast(metadata.numComponents); + const void* data = buffer.data(); + + int result = 0; + + if(ext == ".png") + { + int strideBytes = w * comp; + result = stbi_write_png(pathStr.c_str(), w, h, comp, data, strideBytes); + } + else if(ext == ".bmp") + { + result = stbi_write_bmp(pathStr.c_str(), w, h, comp, data); + } + else if(ext == ".jpg" || ext == ".jpeg") + { + constexpr int k_JpegQuality = 95; + result = stbi_write_jpg(pathStr.c_str(), w, h, comp, data, k_JpegQuality); + } + else + { + return MakeErrorResult(k_ErrorUnsupportedWriteFormat, fmt::format("Unsupported write format '{}' for stb backend. Supported: .png, .bmp, .jpg, .jpeg", ext)); + } + + if(result == 0) + { + return MakeErrorResult(k_ErrorWriteFailed, fmt::format("Failed to write image to '{}'", pathStr)); + } + + return {}; +} diff --git a/src/simplnx/Utilities/ImageIO/StbImageIO.hpp b/src/simplnx/Utilities/ImageIO/StbImageIO.hpp new file mode 100644 index 0000000000..c1811f48b6 --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/StbImageIO.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "simplnx/simplnx_export.hpp" + +#include "simplnx/Utilities/ImageIO/IImageIO.hpp" + +namespace nx::core +{ + +/** + * @class StbImageIO + * @brief IImageIO backend using stb_image (read) and stb_image_write (write) + * for PNG, JPEG, and BMP formats. + */ +class SIMPLNX_EXPORT StbImageIO : public IImageIO +{ +public: + StbImageIO() = default; + ~StbImageIO() noexcept override = default; + + Result readMetadata(const std::filesystem::path& filePath) const override; + Result<> readPixelData(const std::filesystem::path& filePath, std::vector& buffer) const override; + Result<> writePixelData(const std::filesystem::path& filePath, const std::vector& buffer, const ImageMetadata& metadata) const override; +}; + +} // namespace nx::core diff --git a/src/simplnx/Utilities/ImageIO/TiffImageIO.cpp b/src/simplnx/Utilities/ImageIO/TiffImageIO.cpp new file mode 100644 index 0000000000..239f417d00 --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/TiffImageIO.cpp @@ -0,0 +1,425 @@ +#include "TiffImageIO.hpp" + +#include "simplnx/Common/Result.hpp" + +#include + +#include + +#include + +using namespace nx::core; + +namespace +{ +constexpr int32_t k_ErrorOpenFailed = -20100; +constexpr int32_t k_ErrorReadMetadataFailed = -20101; +constexpr int32_t k_ErrorReadPixelFailed = -20102; +constexpr int32_t k_ErrorWriteFailed = -20103; +constexpr int32_t k_ErrorUnsupportedFormat = -20104; +constexpr int32_t k_ErrorBufferSizeMismatch = -20105; + +// --------------------------------------------------------------------------- +// Thread-local storage for capturing libtiff error/warning messages +// --------------------------------------------------------------------------- +thread_local std::string s_TiffErrorMessage; + +void tiffErrorHandler(const char* /*module*/, const char* formatStr, va_list args) +{ + char buf[1024]; + vsnprintf(buf, sizeof(buf), formatStr, args); + s_TiffErrorMessage = buf; +} + +void tiffWarningHandler(const char* /*module*/, const char* /*formatStr*/, va_list /*args*/) +{ + // Intentionally suppress warnings +} + +/** + * @class TiffErrorGuard + * @brief RAII class to install/restore libtiff error and warning handlers. + */ +class TiffErrorGuard +{ +public: + TiffErrorGuard() + { + s_TiffErrorMessage.clear(); + m_PrevErrorHandler = TIFFSetErrorHandler(tiffErrorHandler); + m_PrevWarningHandler = TIFFSetWarningHandler(tiffWarningHandler); + } + + ~TiffErrorGuard() + { + TIFFSetErrorHandler(m_PrevErrorHandler); + TIFFSetWarningHandler(m_PrevWarningHandler); + } + + TiffErrorGuard(const TiffErrorGuard&) = delete; + TiffErrorGuard& operator=(const TiffErrorGuard&) = delete; + TiffErrorGuard(TiffErrorGuard&&) = delete; + TiffErrorGuard& operator=(TiffErrorGuard&&) = delete; + +private: + TIFFErrorHandler m_PrevErrorHandler = nullptr; + TIFFErrorHandler m_PrevWarningHandler = nullptr; +}; + +/** + * @brief RAII wrapper for TIFF* that calls TIFFClose on destruction. + */ +class TiffHandleGuard +{ +public: + explicit TiffHandleGuard(TIFF* tiff) + : m_Tiff(tiff) + { + } + + ~TiffHandleGuard() + { + if(m_Tiff != nullptr) + { + TIFFClose(m_Tiff); + } + } + + TiffHandleGuard(const TiffHandleGuard&) = delete; + TiffHandleGuard& operator=(const TiffHandleGuard&) = delete; + TiffHandleGuard(TiffHandleGuard&&) = delete; + TiffHandleGuard& operator=(TiffHandleGuard&&) = delete; + + TIFF* get() const + { + return m_Tiff; + } + +private: + TIFF* m_Tiff = nullptr; +}; + +/** + * @brief Returns the number of bytes per element for the given DataType. + */ +usize bytesPerElement(DataType type) +{ + switch(type) + { + case DataType::uint8: + return 1; + case DataType::uint16: + return 2; + case DataType::float32: + return 4; + default: + return 0; + } +} + +/** + * @brief Determines the DataType from TIFF tags. + */ +DataType determineTiffDataType(TIFF* tiff) +{ + uint16_t bitsPerSample = 8; + uint16_t sampleFormat = SAMPLEFORMAT_UINT; + + TIFFGetFieldDefaulted(tiff, TIFFTAG_BITSPERSAMPLE, &bitsPerSample); + TIFFGetFieldDefaulted(tiff, TIFFTAG_SAMPLEFORMAT, &sampleFormat); + + if(sampleFormat == SAMPLEFORMAT_IEEEFP && bitsPerSample == 32) + { + return DataType::float32; + } + if(bitsPerSample == 16) + { + return DataType::uint16; + } + return DataType::uint8; +} +} // namespace + +// ----------------------------------------------------------------------------- +Result TiffImageIO::readMetadata(const std::filesystem::path& filePath) const +{ + TiffErrorGuard errorGuard; + + std::string pathStr = filePath.string(); + TiffHandleGuard tiffGuard(TIFFOpen(pathStr.c_str(), "r")); + if(tiffGuard.get() == nullptr) + { + return MakeErrorResult(k_ErrorOpenFailed, fmt::format("Failed to open TIFF file '{}': {}", pathStr, s_TiffErrorMessage.empty() ? "unknown error" : s_TiffErrorMessage)); + } + + TIFF* tiff = tiffGuard.get(); + + uint32_t width = 0; + uint32_t height = 0; + if(TIFFGetField(tiff, TIFFTAG_IMAGEWIDTH, &width) == 0 || TIFFGetField(tiff, TIFFTAG_IMAGELENGTH, &height) == 0) + { + return MakeErrorResult(k_ErrorReadMetadataFailed, + fmt::format("Failed to read TIFF dimensions from '{}': {}", pathStr, s_TiffErrorMessage.empty() ? "missing width/height tags" : s_TiffErrorMessage)); + } + + uint16_t samplesPerPixel = 1; + TIFFGetFieldDefaulted(tiff, TIFFTAG_SAMPLESPERPIXEL, &samplesPerPixel); + + ImageMetadata metadata; + metadata.width = static_cast(width); + metadata.height = static_cast(height); + metadata.numComponents = static_cast(samplesPerPixel); + metadata.dataType = determineTiffDataType(tiff); + + // Count pages (directories) + usize pageCount = 0; + do + { + pageCount++; + } while(TIFFReadDirectory(tiff) != 0); + metadata.numPages = pageCount; + + // Read optional origin (TIFF X/Y position tags) + float xPosition = 0.0f; + float yPosition = 0.0f; + bool hasOrigin = false; + if(TIFFGetField(tiff, TIFFTAG_XPOSITION, &xPosition) != 0) + { + hasOrigin = true; + } + if(TIFFGetField(tiff, TIFFTAG_YPOSITION, &yPosition) != 0) + { + hasOrigin = true; + } + if(hasOrigin) + { + metadata.origin = FloatVec3(xPosition, yPosition, 0.0f); + } + + // Read optional spacing (TIFF resolution tags) + float xRes = 0.0f; + float yRes = 0.0f; + bool hasSpacing = false; + if(TIFFGetField(tiff, TIFFTAG_XRESOLUTION, &xRes) != 0 && xRes > 0.0f) + { + hasSpacing = true; + } + if(TIFFGetField(tiff, TIFFTAG_YRESOLUTION, &yRes) != 0 && yRes > 0.0f) + { + hasSpacing = true; + } + if(hasSpacing) + { + // Resolution is in pixels-per-unit; spacing is the inverse + float32 xSpacing = (xRes > 0.0f) ? (1.0f / xRes) : 1.0f; + float32 ySpacing = (yRes > 0.0f) ? (1.0f / yRes) : 1.0f; + metadata.spacing = FloatVec3(xSpacing, ySpacing, 1.0f); + } + + return {std::move(metadata)}; +} + +// ----------------------------------------------------------------------------- +Result<> TiffImageIO::readPixelData(const std::filesystem::path& filePath, std::vector& buffer) const +{ + TiffErrorGuard errorGuard; + + std::string pathStr = filePath.string(); + TiffHandleGuard tiffGuard(TIFFOpen(pathStr.c_str(), "r")); + if(tiffGuard.get() == nullptr) + { + return MakeErrorResult(k_ErrorOpenFailed, fmt::format("Failed to open TIFF file '{}': {}", pathStr, s_TiffErrorMessage.empty() ? "unknown error" : s_TiffErrorMessage)); + } + + TIFF* tiff = tiffGuard.get(); + + uint32_t width = 0; + uint32_t height = 0; + TIFFGetField(tiff, TIFFTAG_IMAGEWIDTH, &width); + TIFFGetField(tiff, TIFFTAG_IMAGELENGTH, &height); + + uint16_t samplesPerPixel = 1; + TIFFGetFieldDefaulted(tiff, TIFFTAG_SAMPLESPERPIXEL, &samplesPerPixel); + + DataType dataType = determineTiffDataType(tiff); + usize bpe = bytesPerElement(dataType); + if(bpe == 0) + { + return MakeErrorResult(k_ErrorUnsupportedFormat, fmt::format("Unsupported TIFF pixel format in '{}': could not determine bytes per element", pathStr)); + } + + usize expectedSize = static_cast(width) * static_cast(height) * static_cast(samplesPerPixel) * bpe; + if(buffer.size() != expectedSize) + { + return MakeErrorResult(k_ErrorBufferSizeMismatch, fmt::format("Buffer size {} does not match expected size {} for TIFF image '{}'", buffer.size(), expectedSize, pathStr)); + } + + // Check if the image is tiled + if(TIFFIsTiled(tiff) != 0) + { + // For tiled TIFFs, use TIFFReadRGBAImageOriented as a fallback. + // This converts to uint8 RGBA, so it only works well for uint8 images. + if(dataType != DataType::uint8) + { + return MakeErrorResult(k_ErrorUnsupportedFormat, fmt::format("Tiled TIFF with non-uint8 data is not supported for '{}'. Convert to stripped TIFF first.", pathStr)); + } + + std::vector raster(static_cast(width) * static_cast(height)); + if(TIFFReadRGBAImageOriented(tiff, width, height, raster.data(), ORIENTATION_TOPLEFT, 0) == 0) + { + return MakeErrorResult(k_ErrorReadPixelFailed, fmt::format("Failed to read tiled TIFF pixel data from '{}': {}", pathStr, s_TiffErrorMessage.empty() ? "unknown error" : s_TiffErrorMessage)); + } + + // TIFFReadRGBAImageOriented produces ABGR uint32 packed pixels. + // Extract the requested number of components. + usize pixelCount = static_cast(width) * static_cast(height); + for(usize i = 0; i < pixelCount; i++) + { + uint32_t pixel = raster[i]; + uint8 r = TIFFGetR(pixel); + uint8 g = TIFFGetG(pixel); + uint8 b = TIFFGetB(pixel); + uint8 a = TIFFGetA(pixel); + + usize offset = i * samplesPerPixel; + if(samplesPerPixel >= 1) + { + buffer[offset] = r; + } + if(samplesPerPixel >= 2) + { + buffer[offset + 1] = g; + } + if(samplesPerPixel >= 3) + { + buffer[offset + 2] = b; + } + if(samplesPerPixel >= 4) + { + buffer[offset + 3] = a; + } + } + + return {}; + } + + // Scanline-based reading + tsize_t scanlineSize = TIFFScanlineSize(tiff); + usize rowBytes = static_cast(width) * static_cast(samplesPerPixel) * bpe; + + // Use the larger of TIFFScanlineSize and our computed row size + usize scanlineBufSize = std::max(static_cast(scanlineSize), rowBytes); + std::vector scanlineBuf(scanlineBufSize); + + for(uint32_t row = 0; row < height; row++) + { + if(TIFFReadScanline(tiff, scanlineBuf.data(), row) < 0) + { + return MakeErrorResult(k_ErrorReadPixelFailed, fmt::format("Failed to read scanline {} from TIFF '{}': {}", row, pathStr, s_TiffErrorMessage.empty() ? "unknown error" : s_TiffErrorMessage)); + } + std::memcpy(buffer.data() + (static_cast(row) * rowBytes), scanlineBuf.data(), rowBytes); + } + + return {}; +} + +// ----------------------------------------------------------------------------- +Result<> TiffImageIO::writePixelData(const std::filesystem::path& filePath, const std::vector& buffer, const ImageMetadata& metadata) const +{ + TiffErrorGuard errorGuard; + + usize bpe = bytesPerElement(metadata.dataType); + if(bpe == 0) + { + return MakeErrorResult(k_ErrorUnsupportedFormat, fmt::format("Unsupported data type for TIFF writing to '{}'. Supported: uint8, uint16, float32.", filePath.string())); + } + + usize expectedSize = metadata.width * metadata.height * metadata.numComponents * bpe; + if(buffer.size() != expectedSize) + { + return MakeErrorResult(k_ErrorBufferSizeMismatch, fmt::format("Buffer size {} does not match expected size {} for TIFF write to '{}'", buffer.size(), expectedSize, filePath.string())); + } + + std::string pathStr = filePath.string(); + TiffHandleGuard tiffGuard(TIFFOpen(pathStr.c_str(), "w")); + if(tiffGuard.get() == nullptr) + { + return MakeErrorResult(k_ErrorOpenFailed, fmt::format("Failed to open TIFF file for writing '{}': {}", pathStr, s_TiffErrorMessage.empty() ? "unknown error" : s_TiffErrorMessage)); + } + + TIFF* tiff = tiffGuard.get(); + + uint32_t w = static_cast(metadata.width); + uint32_t h = static_cast(metadata.height); + uint16_t comp = static_cast(metadata.numComponents); + + TIFFSetField(tiff, TIFFTAG_IMAGEWIDTH, w); + TIFFSetField(tiff, TIFFTAG_IMAGELENGTH, h); + TIFFSetField(tiff, TIFFTAG_SAMPLESPERPIXEL, comp); + TIFFSetField(tiff, TIFFTAG_ORIENTATION, ORIENTATION_TOPLEFT); + TIFFSetField(tiff, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG); + TIFFSetField(tiff, TIFFTAG_COMPRESSION, COMPRESSION_LZW); + TIFFSetField(tiff, TIFFTAG_ROWSPERSTRIP, TIFFDefaultStripSize(tiff, 0)); + + // Set bits per sample and sample format based on data type + switch(metadata.dataType) + { + case DataType::uint8: + TIFFSetField(tiff, TIFFTAG_BITSPERSAMPLE, 8); + TIFFSetField(tiff, TIFFTAG_SAMPLEFORMAT, SAMPLEFORMAT_UINT); + break; + case DataType::uint16: + TIFFSetField(tiff, TIFFTAG_BITSPERSAMPLE, 16); + TIFFSetField(tiff, TIFFTAG_SAMPLEFORMAT, SAMPLEFORMAT_UINT); + break; + case DataType::float32: + TIFFSetField(tiff, TIFFTAG_BITSPERSAMPLE, 32); + TIFFSetField(tiff, TIFFTAG_SAMPLEFORMAT, SAMPLEFORMAT_IEEEFP); + break; + default: + return MakeErrorResult(k_ErrorUnsupportedFormat, fmt::format("Unsupported data type for TIFF writing to '{}'. Supported: uint8, uint16, float32.", pathStr)); + } + + // Set photometric interpretation + if(comp == 1) + { + TIFFSetField(tiff, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_MINISBLACK); + } + else + { + TIFFSetField(tiff, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_RGB); + } + + // Write optional origin + if(metadata.origin.has_value()) + { + const FloatVec3& origin = metadata.origin.value(); + TIFFSetField(tiff, TIFFTAG_XPOSITION, origin[0]); + TIFFSetField(tiff, TIFFTAG_YPOSITION, origin[1]); + } + + // Write optional spacing as resolution + if(metadata.spacing.has_value()) + { + const FloatVec3& spacing = metadata.spacing.value(); + float32 xRes = (spacing[0] > 0.0f) ? (1.0f / spacing[0]) : 1.0f; + float32 yRes = (spacing[1] > 0.0f) ? (1.0f / spacing[1]) : 1.0f; + TIFFSetField(tiff, TIFFTAG_XRESOLUTION, xRes); + TIFFSetField(tiff, TIFFTAG_YRESOLUTION, yRes); + TIFFSetField(tiff, TIFFTAG_RESOLUTIONUNIT, RESUNIT_NONE); + } + + // Write scanlines + usize rowBytes = static_cast(w) * static_cast(comp) * bpe; + for(uint32_t row = 0; row < h; row++) + { + const uint8* rowData = buffer.data() + (static_cast(row) * rowBytes); + // TIFFWriteScanline takes a non-const void* but does not modify the data + if(TIFFWriteScanline(tiff, const_cast(rowData), row) < 0) + { + return MakeErrorResult(k_ErrorWriteFailed, fmt::format("Failed to write scanline {} to TIFF '{}': {}", row, pathStr, s_TiffErrorMessage.empty() ? "unknown error" : s_TiffErrorMessage)); + } + } + + return {}; +} diff --git a/src/simplnx/Utilities/ImageIO/TiffImageIO.hpp b/src/simplnx/Utilities/ImageIO/TiffImageIO.hpp new file mode 100644 index 0000000000..5c1bc031c3 --- /dev/null +++ b/src/simplnx/Utilities/ImageIO/TiffImageIO.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "simplnx/simplnx_export.hpp" + +#include "simplnx/Utilities/ImageIO/IImageIO.hpp" + +namespace nx::core +{ + +/** + * @class TiffImageIO + * @brief IImageIO backend using libtiff for TIFF format support. + * + * Supports uint8, uint16, and float32 pixel types. + * Reads/writes scanline-by-scanline. + * Captures libtiff error messages via a thread-local error handler. + */ +class SIMPLNX_EXPORT TiffImageIO : public IImageIO +{ +public: + TiffImageIO() = default; + ~TiffImageIO() noexcept override = default; + + Result readMetadata(const std::filesystem::path& filePath) const override; + Result<> readPixelData(const std::filesystem::path& filePath, std::vector& buffer) const override; + Result<> writePixelData(const std::filesystem::path& filePath, const std::vector& buffer, const ImageMetadata& metadata) const override; +}; + +} // namespace nx::core diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index aae13f39c1..32dac83647 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -93,7 +93,15 @@ target_include_directories(simplnx_test PRIVATE ${SIMPLNX_GENERATED_DIR}) catch_discover_tests(simplnx_test) -download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME simpl_json_exemplars.tar.gz SHA512 550a1ad3c9c31b1093d961fdda2057d63a48f08c04d2831c70c1ec65d499f39b354a5a569903ff68e9b703ef45c23faba340aa0c61f9a927fbc845c15b87d05e ) + +if(EXISTS "${DREAM3D_DATA_DIR}" AND SIMPLNX_DOWNLOAD_TEST_FILES) + if(NOT EXISTS ${DREAM3D_DATA_DIR}/TestFiles/) + file(MAKE_DIRECTORY "${DREAM3D_DATA_DIR}/TestFiles/") + endif() + download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME simpl_json_exemplars.tar.gz SHA512 550a1ad3c9c31b1093d961fdda2057d63a48f08c04d2831c70c1ec65d499f39b354a5a569903ff68e9b703ef45c23faba340aa0c61f9a927fbc845c15b87d05e ) + download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME itk_image_reader_test_v3.tar.gz SHA512 e583911e69958f5a270f72f22a07527af313c1e3870923f1c95f1f55bb7c242d9e99daeed7100bfa1f5b4af37c952cf98abe0eb2486c1ec5248470b0ca2dcb85) + download_test_data(DREAM3D_DATA_DIR ${DREAM3D_DATA_DIR} ARCHIVE_NAME import_image_stack_test_v3.tar.gz SHA512 e9305ac5c3592129cba937bd426c3d987a020a05229cf4519d826bcc538d054b4a173d613ed3e72b7646a523f2f3874c7dab39404cc0ddabffa653cba845abd5) +endif() add_custom_target(Copy_simplnx_Test_Data ALL COMMAND ${CMAKE_COMMAND} -E copy_directory_if_different "${simplnx_SOURCE_DIR}/test/Data" "${DATA_DEST_DIR}/Test_Data" diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json index ab30d98c2d..c7f622fb03 100644 --- a/vcpkg-configuration.json +++ b/vcpkg-configuration.json @@ -7,8 +7,8 @@ "registries": [ { "kind": "git", - "repository": "https://github.com/bluequartzsoftware/simplnx-registry", - "baseline": "abb3d4151d1425152fd85a75d35f9d7b035a3b0d", + "repository": "https://github.com/imikejackson/simplnx-registry", + "baseline": "89eeb9bd56b09266e5350a94a91aa7ef48ed0253", "packages": [ "benchmark", "blosc", @@ -33,7 +33,9 @@ "vcpkg-cmake", "vcpkg-cmake-config", "zlib", - "zstd" + "zstd", + "tiff", + "stb" ] } ] diff --git a/vcpkg.json b/vcpkg.json index 0d9363aeef..a8576133a1 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -15,6 +15,12 @@ { "name": "eigen3" }, + { + "name": "tiff" + }, + { + "name": "stb" + }, { "name": "hdf5", "features": [ @@ -77,7 +83,7 @@ "dependencies": [ { "name": "ebsdlib", - "version>=": "2.3.0" + "version>=": "2.4.0" } ] },