diff --git a/src/Plugins/ITKImageProcessing/docs/ITKImageWriterFilter.md b/src/Plugins/ITKImageProcessing/docs/ITKImageWriterFilter.md index 3e388d7a4c..5feb8c25a6 100644 --- a/src/Plugins/ITKImageProcessing/docs/ITKImageWriterFilter.md +++ b/src/Plugins/ITKImageProcessing/docs/ITKImageWriterFilter.md @@ -6,7 +6,11 @@ ITKImageProcessing (ITKImageProcessing) ## Description -This **Filter** will save images based on an array that represents grayscale, RGB or ARGB color values. If the input array represents a 3D volume, the **Filter** will output a series of slices along one of the orthogonal axes. The options are to produce XY slices along the Z axis, XZ slices along the Y axis or YZ slices along the X axis. The user has the option to save in one of 3 standard image formats: TIF, BMP, or PNG. The output files will be numbered sequentially starting at zero (0) and ending at the total dimensions for the chosen axis. For example, if the Z axis has 117 dimensions, 117 XY image files will be produced and numbered 0 to 116. Unless the data is a single slice then only a single image will be produced using the name given in the Output File parameter. +This **Filter** will save images based on scalar, vector, RGB, or RGBA values. Supported component counts are 1, 2, 3, 4, 10, 11, and 36. If the input array represents a 3D volume, the **Filter** will output a series of slices along one of the orthogonal axes. The options are to produce XY slices along the Z axis, XZ slices along the Y axis or YZ slices along the X axis. + +The available output formats are determined by the installed ITK image I/O backends and the filename extension. TIFF, BMP, and PNG are common 2D choices; not every pixel type is supported by every format. + +For a series, the *Output File* stem is followed by an underscore and the slice index. *Index Offset*, *Total Number of Index Digits*, and *Fill Character* control the first index and its formatting. For example, with 117 Z cells, offset *0*, width *3*, and fill character `0`, the XY output files are named `slice_000.tif` through `slice_116.tif`. A single-slice output keeps the exact name supplied in *Output File*. An example of a **Filter** that produces color data that can be used as input to this **Filter** is the {ref}`Generate IFP Colors ` **Filter**, which will generate RGB values for each voxel in the volume. diff --git a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp index d4df693d3c..e42aa7f220 100644 --- a/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp +++ b/src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp @@ -1,7 +1,6 @@ #include "ITKImageWriterFilter.hpp" #include "ITKImageProcessing/Common/ITKArrayHelper.hpp" -#include "ITKImageProcessing/ITKImageProcessingPlugin.hpp" #include "simplnx/Common/AtomicFile.hpp" #include "simplnx/DataStructure/DataPath.hpp" @@ -15,13 +14,11 @@ #include "simplnx/Parameters/GeometrySelectionParameter.hpp" #include "simplnx/Parameters/NumberParameter.hpp" #include "simplnx/Parameters/StringParameter.hpp" +#include "simplnx/Utilities/FilterUtilities.hpp" #include "simplnx/Utilities/ImageIO/ImageIOUtilities.hpp" #include "simplnx/Utilities/StringUtilities.hpp" #include -#include -#include -#include #include @@ -40,17 +37,17 @@ using namespace nx::core; namespace cxITKImageWriterFilter { using ArrayOptionsType = ITK::ScalarVectorPixelIdTypeList; +using RgbRgbaArrayOptionsType = ITK::ArrayOptions, ITK::ArrayUseAllTypes>; -bool Is2DFormat(const fs::path& fileName) +// Rejects 0 - 31 ASCII control characters +bool IsValidFillCharacter(char fillCharacter) { - fs::path ext = fileName.extension(); - auto supported2DExtensions = ITKImageProcessingPlugin::GetList2DSupportedFileExtensions(); - auto iter = std::find(supported2DExtensions.cbegin(), supported2DExtensions.cend(), ext); - return iter != supported2DExtensions.cend(); + return !(fillCharacter >= 0 && fillCharacter <= 31) && fillCharacter != '{' && fillCharacter != '}' && fillCharacter != '\\' && fillCharacter != '/' && fillCharacter != ':' && + fillCharacter != '*' && fillCharacter != '?' && fillCharacter != '"' && fillCharacter != '<' && fillCharacter != '>' && fillCharacter != '|'; } template -Result<> WriteAsOneFile(itk::Image& image, const fs::path& filePath /*, const IFilter::MessageHandler& messanger*/) +Result<> WriteAsOneFile(itk::Image& image, const fs::path& filePath) { auto atomicFileResult = AtomicFile::Create(filePath); if(atomicFileResult.invalid()) @@ -65,8 +62,6 @@ Result<> WriteAsOneFile(itk::Image& image, const fs::path& f using FileWriterType = itk::ImageFileWriter; auto writer = FileWriterType::New(); - // messanger(fmt::format("Saving {}", fileName)); - writer->SetInput(&image); writer->SetFileName(tempPath); writer->UseCompressionOn(); @@ -84,179 +79,137 @@ Result<> WriteAsOneFile(itk::Image& image, const fs::path& f return {}; } -template -Result<> WriteAs2DStack(itk::Image& image, uint32 z_size, const fs::path& filePath, uint64 indexOffset) +template +Result<> WriteImage(IDataStore& dataStore, const ITK::ImageGeomData& imageGeom, const fs::path& filePath) { - // Create list of AtomicFiles - std::vector> atomicFiles; - std::vector fileNames; + auto& typedDataStore = dynamic_cast>&>(dataStore); - for(uint64 index = indexOffset; index < (z_size - 1); index++) - { - atomicFiles.push_back(AtomicFile::Create(fs::absolute(fmt::format("{}/{}{:03d}{}", filePath.parent_path().string(), filePath.stem().string(), index, filePath.extension().string())))); - auto& atomicFileResult = atomicFiles.back(); - if(atomicFileResult.invalid()) - { - return ConvertResult(std::move(atomicFileResult)); - } - fileNames.push_back(atomicFileResult.value().tempFilePath().string()); - } + typename itk::Image::Pointer image = ITK::WrapDataStoreInImage(typedDataStore, imageGeom); + return WriteAsOneFile(*image, filePath); +} - // generate all the files in that new directory - try - { - using InputImageType = itk::Image; - using OutputImageType = itk::Image; - using SeriesWriterType = itk::ImageSeriesWriter; - auto writer = SeriesWriterType::New(); - writer->SetInput(&image); - writer->SetFileNames(fileNames); - writer->UseCompressionOn(); - writer->Update(); - } catch(const itk::ExceptionObject& err) +template +struct WriteImageFunctor +{ + Result<> operator()(IDataStore& dataStore, const ITK::ImageGeomData& imageGeom, const fs::path& filePath) const { - return MakeErrorResult(-21011, fmt::format("ITK exception was thrown while writing output file: {}", err.GetDescription())); + return WriteImage(dataStore, imageGeom, filePath); } +}; + +fs::path GenerateOutputFilePath(const fs::path& filePath, usize slice, usize maxSlice, int32 totalDigits, const std::string& fillChar) +{ + std::stringstream ss; + ss << fs::absolute(filePath).parent_path().string() << "/" << filePath.stem().string(); - for(auto& atomicFile : atomicFiles) + if(maxSlice != 1) { - Result<> commitResult = atomicFile.value().commit(); - if(commitResult.invalid()) - { - return commitResult; - } + ss << "_" << std::setw(totalDigits) << std::setfill(fillChar[0]) << slice; } + ss << filePath.extension().string(); - return {}; + return fs::path(ss.str()); } -template -Result<> WriteImage(IDataStore& dataStore, const ITK::ImageGeomData& imageGeom, const fs::path& filePath, uint64 indexOffset) +Result<> SaveImageData(const fs::path& fileName, IDataStore& sliceData, const ITK::ImageGeomData& imageGeom) { - using ImageType = itk::Image; - - auto& typedDataStore = dynamic_cast>&>(dataStore); - - typename itk::Image::Pointer image = ITK::WrapDataStoreInImage(typedDataStore, imageGeom); - if(Is2DFormat(filePath) && Dimensions == 3) + // If the parent path does not exist then try to create it. + if(!fs::exists(fileName.parent_path())) { - typename ImageType::SizeType size = image->GetLargestPossibleRegion().GetSize(); - if(size[2] < 2) + if(!fs::create_directories(fileName.parent_path())) { - return MakeErrorResult(-21012, "Image is 2D, not 3D."); + return MakeErrorResult(-19000, fmt::format("Error Creating output path for image '{}'", fileName.string())); } - - return WriteAs2DStack(*image, size[2], filePath, indexOffset); } - else + + if(sliceData.getNumberOfComponents() == 4) { - return WriteAsOneFile(*image, filePath); + return ITK::ArraySwitchFunc(sliceData, imageGeom, -21010, sliceData, imageGeom, fileName); } + return ITK::ArraySwitchFunc(sliceData, imageGeom, -21010, sliceData, imageGeom, fileName); } -template -struct WriteImageFunctor +struct CopyXYSlicesFunctor { - Result<> operator()(IDataStore& dataStore, const ITK::ImageGeomData& imageGeom, const fs::path& filePath, uint64 indexOffset) const + template + Result<> operator()(usize dA, usize dB, usize slice, const IDataStore& currentData, IDataStore& sliceData) const { - return WriteImage(dataStore, imageGeom, filePath, indexOffset); - } -}; - -template -void CopyTupleTyped(const IDataStore& currentData, IDataStore& sliceData, usize nComp, usize index, usize indexNew) -{ - const auto& currentDataTyped = dynamic_cast&>(currentData); - auto& sliceDataTyped = dynamic_cast&>(sliceData); + const auto& currentDataTyped = dynamic_cast&>(currentData); + auto& sliceDataTyped = dynamic_cast&>(sliceData); -#if 0 - const T* sourcePtr = currentDataTyped.data() + (nComp * index); - T* destPtr = sliceDataTyped.data() + (nComp * indexNew); - std::memcpy(destPtr, sourcePtr, currentData.getTypeSize() * nComp); -#endif + for(usize axisA = 0; axisA < dA; ++axisA) + { + for(usize axisB = 0; axisB < dB; ++axisB) + { + usize index = (slice * dA * dB) + (axisA * dB) + axisB; + usize indexNew = (axisA * dB) + axisB; + if(Result<> copyResult = sliceDataTyped.copyFrom(indexNew, currentDataTyped, index, 1); copyResult.invalid()) + { + return copyResult; + } + } + } - sliceDataTyped.copyFrom(indexNew, currentDataTyped, index, 1); -} + return {}; + } +}; -void CopyTuple(usize index, usize axisA, usize dB, usize axisB, usize nComp, const IDataStore& currentData, IDataStore& sliceData) +struct CopyXZSlicesFunctor { - usize indexNew = (axisA * dB) + axisB; - - DataType type = currentData.getDataType(); - - switch(type) + template + Result<> operator()(usize dA, usize dB, usize slice, const SizeVec3& dims, const IDataStore& currentData, IDataStore& sliceData) const { - case DataType::int8: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::uint8: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::int16: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::uint16: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::int32: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::uint32: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::int64: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::uint64: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::float32: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - case DataType::float64: { - CopyTupleTyped(currentData, sliceData, nComp, index, indexNew); - break; - } - default: { - throw std::runtime_error("ITKImageWriterFilter: Invalid DataType while attempting to copy tuples"); - } + const auto& currentDataTyped = dynamic_cast&>(currentData); + auto& sliceDataTyped = dynamic_cast&>(sliceData); + + for(usize axisA = 0; axisA < dA; ++axisA) + { + for(usize axisB = 0; axisB < dB; ++axisB) + { + usize index = (dims.getY() * axisA * dB) + (slice * dB) + axisB; + usize indexNew = (axisA * dB) + axisB; + if(Result<> copyResult = sliceDataTyped.copyFrom(indexNew, currentDataTyped, index, 1); copyResult.invalid()) + { + return copyResult; + } + } + } + return {}; } -} +}; -Result<> SaveImageData(const fs::path& filePath, IDataStore& sliceData, const ITK::ImageGeomData& imageGeom, usize slice, usize maxSlice, uint64 indexOffset, int32 totalDigits, - const std::string& fillChar) +struct CopyYZSlicesFunctor { - std::stringstream ss; - ss << fs::absolute(filePath).parent_path().string() << "/" << filePath.stem().string(); - - // If the parent path does not exist then try to create it. - if(!fs::exists(fs::absolute(filePath).parent_path())) + template + Result<> operator()(usize dA, usize dB, usize slice, const SizeVec3& dims, const IDataStore& currentData, IDataStore& sliceData) const { - if(!fs::create_directories(fs::absolute(filePath).parent_path())) + const auto& currentDataTyped = dynamic_cast&>(currentData); + auto& sliceDataTyped = dynamic_cast&>(sliceData); + + for(usize axisA = 0; axisA < dA; ++axisA) { - return MakeErrorResult(-19000, fmt::format("Error Creating output path for image '{}'", fs::absolute(filePath).string())); + for(usize axisB = 0; axisB < dB; ++axisB) + { + usize index = (dims.getX() * axisA * dB) + (axisB * dims.getX()) + slice; + usize indexNew = (axisA * dB) + axisB; + if(Result<> copyResult = sliceDataTyped.copyFrom(indexNew, currentDataTyped, index, 1); copyResult.invalid()) + { + return copyResult; + } + } } + return {}; } +}; - if(maxSlice != 1) +struct CreateDataStoreFunctor +{ + template + std::unique_ptr operator()(const ShapeType& sliceShape, const ShapeType& cDims) const { - ss << "_" << std::setw(totalDigits) << std::setfill(fillChar[0]) << slice; + return std::make_unique>(sliceShape, cDims, static_cast(0)); } - ss << filePath.extension().string(); - - auto fileName = fs::path(ss.str()); - - return ITK::ArraySwitchFunc(sliceData, imageGeom, -21010, sliceData, imageGeom, fileName, indexOffset); -} +}; } // namespace cxITKImageWriterFilter namespace nx::core @@ -309,7 +262,7 @@ Parameters ITKImageWriterFilter::parameters() const 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{}, - nx::core::ITK::GetScalarPixelAllowedTypes())); + nx::core::ITK::GetScalarPixelAllowedTypes(), ArraySelectionParameter::AllowedComponentShapes{{1}, {2}, {3}, {4}, {10}, {11}, {36}})); return params; } @@ -355,9 +308,18 @@ IFilter::PreflightResult ITKImageWriterFilter::preflightImpl(const DataStructure StringUtilities::formatDimensions3D(imageGeom.getDimensions())))}; } - if(fillChar.size() > 1) + if(fillChar.size() != 1) + { + return {MakeErrorResult(-25601, fmt::format("The fill character must contain exactly one character; received {} characters.", fillChar.size()))}; + } + if(!cxITKImageWriterFilter::IsValidFillCharacter(fillChar.at(0))) + { + return {MakeErrorResult(-25602, fmt::format("The fill character '{}' is not valid for format strings and file names.", fillChar))}; + } + if(!imageArray.getDataFormat().empty()) { - return {MakeErrorResult(-25601, "The fill character should only be a single value.")}; + return {MakeErrorResult(ITK::Constants::k_OutOfCoreDataNotSupported, + fmt::format("Input Array '{}' utilizes out-of-core data. This is not supported within ITK filters.", imageArrayPath.toString()))}; } Result resultOutputActions; @@ -381,8 +343,8 @@ IFilter::PreflightResult ITKImageWriterFilter::preflightImpl(const DataStructure } // Generate example filename for PreflightValues - const std::string indexStr = CreateIndexString(maxSlice, static_cast(totalDigits), fillChar); - const std::string exampleFileName = fmt::format("{}/{}_{}{}", fs::absolute(filePath).parent_path().string(), filePath.stem().string(), indexStr, filePath.extension().string()); + const std::string indexStr = maxSlice == 1 ? "" : fmt::format("_{}", CreateIndexString(indexOffset, static_cast(totalDigits), fillChar)); + const std::string exampleFileName = (fs::absolute(filePath).parent_path() / fmt::format("{}{}{}", filePath.stem().string(), indexStr, filePath.extension().string())).string(); preflightUpdatedValues.push_back({"Example Output File", exampleFileName}); @@ -402,20 +364,20 @@ Result<> ITKImageWriterFilter::executeImpl(DataStructure& dataStructure, const A auto totalDigits = filterArgs.value(k_TotalIndexDigits_Key); auto fillChar = filterArgs.value(k_LeadingDigitCharacter_Key); - const IDataArray* inputArray = dataStructure.getDataAs(imageArrayPath); - const auto& imageGeom = dataStructure.getDataRefAs(imageGeomPath); // Stored fastest to slowest i.e. X Y Z SizeVec3 dims = imageGeom.getDimensions(); const auto& imageArray = dataStructure.getDataRefAs(imageArrayPath); - usize nComp = imageArray.getNumberOfComponents(); const IDataStore& currentData = imageArray.getIDataStoreRef(); - - std::unique_ptr sliceData = currentData.createNewInstance(); + DataType dataType = currentData.getDataType(); + ShapeType cDims = currentData.getComponentShape(); ITK::ImageGeomData newImageGeom(imageGeom); + const FloatVec3 origin = imageGeom.getOrigin(); + const FloatVec3 spacing = imageGeom.getSpacing(); + switch(plane) { case k_XYPlane: { @@ -423,18 +385,25 @@ Result<> ITKImageWriterFilter::executeImpl(DataStructure& dataStructure, const A usize dB = dims.getY(); newImageGeom.dims = {dims.getX(), dims.getY(), 1}; + newImageGeom.origin = {origin.getX(), origin.getY(), 0.0f}; + newImageGeom.spacing = {spacing.getX(), spacing.getY(), 1.0f}; + + ShapeType sliceShape(std::reverse_iterator(newImageGeom.dims.end()), std::reverse_iterator(newImageGeom.dims.begin())); + std::unique_ptr sliceData = ExecuteDataFunction(cxITKImageWriterFilter::CreateDataStoreFunctor{}, dataType, sliceShape, cDims); for(usize slice = 0; slice < dims.getZ(); ++slice) { - for(usize axisA = 0; axisA < dA; ++axisA) + if(shouldCancel) { - for(usize axisB = 0; axisB < dB; ++axisB) - { - usize index = (slice * dA * dB) + (axisA * dB) + axisB; - cxITKImageWriterFilter::CopyTuple(index, axisA, dB, axisB, nComp, currentData, *sliceData); - } + return {}; + } + if(Result<> copyResult = ExecuteDataFunctionNoBool(cxITKImageWriterFilter::CopyXYSlicesFunctor{}, dataType, dA, dB, slice, currentData, *sliceData); copyResult.invalid()) + { + return copyResult; } - Result<> result = cxITKImageWriterFilter::SaveImageData(filePath, *sliceData, newImageGeom, slice + indexOffset, dims.getZ(), indexOffset, totalDigits, fillChar); + const fs::path outputFilePath = cxITKImageWriterFilter::GenerateOutputFilePath(filePath, slice + indexOffset, dims.getZ(), totalDigits, fillChar); + messageHandler(fmt::format("Writing file {} of {}: \"{}\"", slice + 1, dims.getZ(), outputFilePath.string())); + Result<> result = cxITKImageWriterFilter::SaveImageData(outputFilePath, *sliceData, newImageGeom); if(result.invalid()) { return result; @@ -447,18 +416,25 @@ Result<> ITKImageWriterFilter::executeImpl(DataStructure& dataStructure, const A usize dB = dims.getX(); newImageGeom.dims = {dims.getX(), dims.getZ(), 1}; + newImageGeom.origin = {origin.getX(), origin.getZ(), 0.0f}; + newImageGeom.spacing = {spacing.getX(), spacing.getZ(), 1.0f}; + + ShapeType sliceShape(std::reverse_iterator(newImageGeom.dims.end()), std::reverse_iterator(newImageGeom.dims.begin())); + std::unique_ptr sliceData = ExecuteDataFunction(cxITKImageWriterFilter::CreateDataStoreFunctor{}, dataType, sliceShape, cDims); for(usize slice = 0; slice < dims.getY(); ++slice) { - for(usize axisA = 0; axisA < dA; ++axisA) + if(shouldCancel) { - for(usize axisB = 0; axisB < dB; ++axisB) - { - usize index = (dims.getY() * axisA * dB) + (slice * dB) + axisB; - cxITKImageWriterFilter::CopyTuple(index, axisA, dB, axisB, nComp, currentData, *sliceData); - } + return {}; } - Result<> result = cxITKImageWriterFilter::SaveImageData(filePath, *sliceData, newImageGeom, slice + indexOffset, dims.getY(), indexOffset, totalDigits, fillChar); + if(Result<> copyResult = ExecuteDataFunctionNoBool(cxITKImageWriterFilter::CopyXZSlicesFunctor{}, dataType, dA, dB, slice, dims, currentData, *sliceData); copyResult.invalid()) + { + return copyResult; + } + const fs::path outputFilePath = cxITKImageWriterFilter::GenerateOutputFilePath(filePath, slice + indexOffset, dims.getY(), totalDigits, fillChar); + messageHandler(fmt::format("Writing file {} of {}: \"{}\"", slice + 1, dims.getY(), outputFilePath.string())); + Result<> result = cxITKImageWriterFilter::SaveImageData(outputFilePath, *sliceData, newImageGeom); if(result.invalid()) { return result; @@ -471,18 +447,25 @@ Result<> ITKImageWriterFilter::executeImpl(DataStructure& dataStructure, const A usize dB = dims.getY(); newImageGeom.dims = {dims.getY(), dims.getZ(), 1}; + newImageGeom.origin = {origin.getY(), origin.getZ(), 0.0f}; + newImageGeom.spacing = {spacing.getY(), spacing.getZ(), 1.0f}; + + ShapeType sliceShape(std::reverse_iterator(newImageGeom.dims.end()), std::reverse_iterator(newImageGeom.dims.begin())); + std::unique_ptr sliceData = ExecuteDataFunction(cxITKImageWriterFilter::CreateDataStoreFunctor{}, dataType, sliceShape, cDims); for(usize slice = 0; slice < dims.getX(); ++slice) { - for(usize axisA = 0; axisA < dA; ++axisA) + if(shouldCancel) { - for(usize axisB = 0; axisB < dB; ++axisB) - { - usize index = (dims.getX() * axisA * dB) + (axisB * dims.getX()) + slice; - cxITKImageWriterFilter::CopyTuple(index, axisA, dB, axisB, nComp, currentData, *sliceData); - } + return {}; + } + if(Result<> copyResult = ExecuteDataFunctionNoBool(cxITKImageWriterFilter::CopyYZSlicesFunctor{}, dataType, dA, dB, slice, dims, currentData, *sliceData); copyResult.invalid()) + { + return copyResult; } - Result<> result = cxITKImageWriterFilter::SaveImageData(filePath, *sliceData, newImageGeom, slice + indexOffset, dims.getX(), indexOffset, totalDigits, fillChar); + const fs::path outputFilePath = cxITKImageWriterFilter::GenerateOutputFilePath(filePath, slice + indexOffset, dims.getX(), totalDigits, fillChar); + messageHandler(fmt::format("Writing file {} of {}: \"{}\"", slice + 1, dims.getX(), outputFilePath.string())); + Result<> result = cxITKImageWriterFilter::SaveImageData(outputFilePath, *sliceData, newImageGeom); if(result.invalid()) { return result; diff --git a/src/Plugins/ITKImageProcessing/test/ITKImageWriterTest.cpp b/src/Plugins/ITKImageProcessing/test/ITKImageWriterTest.cpp index 6f2f92fc4d..896e8225e5 100644 --- a/src/Plugins/ITKImageProcessing/test/ITKImageWriterTest.cpp +++ b/src/Plugins/ITKImageProcessing/test/ITKImageWriterTest.cpp @@ -5,6 +5,8 @@ #include "ITKImageProcessing/ITKImageProcessing_test_dirs.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/FileSystemPathParameter.hpp" #include "simplnx/Parameters/GeneratedFileListParameter.hpp" @@ -18,6 +20,9 @@ #include #include +#include +#include + namespace fs = std::filesystem; using namespace nx::core; @@ -77,8 +82,328 @@ void validateOutputFiles(size_t numImages, uint64 offset, const std::string& tem } } +bool RequireExampleOutputFile(const IFilter::PreflightResult& preflightResult, const fs::path& expectedFilePath) +{ + return std::find_if(preflightResult.outputValues.begin(), preflightResult.outputValues.end(), [expectedFilePath](const IFilter::PreflightValue& value) { + if(value.name == "Example Output File") + { + return fs::path(value.value).lexically_normal() == fs::absolute(expectedFilePath).lexically_normal(); + } + return false; + }) != preflightResult.outputValues.end(); +} + +template +void CompareImageToExpected(const fs::path& filePath, const std::array& expectedDimensions, const std::vector& expectedPixels) +{ + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(filePath.string()); + REQUIRE_NOTHROW(reader->Update()); + + const auto& image = *reader->GetOutput(); + const auto dimensions = image.GetLargestPossibleRegion().GetSize(); + REQUIRE(dimensions[0] == expectedDimensions[0]); + REQUIRE(dimensions[1] == expectedDimensions[1]); + + for(usize y = 0; y < expectedDimensions[1]; ++y) + { + for(usize x = 0; x < expectedDimensions[0]; ++x) + { + typename ImageType::IndexType index; + index[0] = static_cast(x); + index[1] = static_cast(y); + CHECK(image.GetPixel(index) == expectedPixels[(y * expectedDimensions[0]) + x]); + } + } +} + +template +void CompareImageMetadata(const fs::path& filePath, const std::array& expectedSpacing, const std::array& expectedOrigin, bool checkOrigin) +{ + using ImageType = itk::Image; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(filePath.string()); + REQUIRE_NOTHROW(reader->Update()); + + const auto& image = *reader->GetOutput(); + const auto spacing = image.GetSpacing(); + const auto origin = image.GetOrigin(); + CHECK(spacing[0] == Approx(expectedSpacing[0])); + CHECK(spacing[1] == Approx(expectedSpacing[1])); + if(checkOrigin) + { + CHECK(origin[0] == Approx(expectedOrigin[0])); + CHECK(origin[1] == Approx(expectedOrigin[1])); + } +} + } // namespace +TEMPLATE_TEST_CASE("ITKImageProcessing::ITKImageWriterFilter: Analytical Pixel Order", "[ITKImageProcessing][ITKImageWriterFilter]", int8, uint8, int16, uint16, int32, uint32, int64, uint64, float32, + float64) +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + + // Class 1 oracle: the 3x2x2 fixture is value(x, y, z) = x + 10*y + 100*z. + DataStructure dataStructure; + + const SizeVec3 imageDims = {3, 2, 2}; + const ShapeType arrayDims(std::reverse_iterator(imageDims.end()), std::reverse_iterator(imageDims.begin())); + + auto* imageGeom = ImageGeom::Create(dataStructure, "ImageGeometry"); + imageGeom->setDimensions(imageDims); + imageGeom->setOrigin({10.0f, 20.0f, 40.0f}); + imageGeom->setSpacing({1.0f, 2.0f, 4.0f}); + auto* cellData = AttributeMatrix::Create(dataStructure, ImageGeom::k_CellAttributeMatrixName, arrayDims, imageGeom->getId()); + imageGeom->setCellData(*cellData); + auto* imageData = UnitTest::CreateTestDataArray(dataStructure, "ImageData", arrayDims, {1}, cellData->getId()); + auto& imageStore = imageData->getDataStoreRef(); + for(usize z = 0; z < imageDims.getZ(); ++z) + { + for(usize y = 0; y < imageDims.getY(); ++y) + { + for(usize x = 0; x < imageDims.getX(); ++x) + { + imageStore[(z * 6) + (y * 3) + x] = static_cast(x + (10 * y) + (100 * z)); + } + } + } + + const DataPath imageGeomPath({"ImageGeometry"}); + const DataPath imageDataPath = imageGeomPath.createChildPath(ImageGeom::k_CellAttributeMatrixName).createChildPath("ImageData"); + const fs::path outputDir = fs::path(unit_test::k_BinaryTestOutputDir.view()) / CreateRandomDirName(); + + const auto writeAndCheck = [&](const DataPath& inputPath, ChoicesParameter::ValueType plane, const std::string& name, const std::string& extension, const std::array& dimensions, + const std::array& expectedSpacing, const std::array& expectedOrigin, const auto& expectedSlices) { + ITKImageWriterFilter filter; + const fs::path outputPath = outputDir / name / fmt::format("slice{}", extension); + Arguments args; + args.insertOrAssign(ITKImageWriterFilter::k_ImageGeomPath_Key, std::make_any(imageGeomPath)); + args.insertOrAssign(ITKImageWriterFilter::k_ImageArrayPath_Key, std::make_any(inputPath)); + args.insertOrAssign(ITKImageWriterFilter::k_FileName_Key, std::make_any(outputPath)); + args.insertOrAssign(ITKImageWriterFilter::k_IndexOffset_Key, std::make_any(0)); + args.insertOrAssign(ITKImageWriterFilter::k_Plane_Key, std::make_any(plane)); + args.insertOrAssign(ITKImageWriterFilter::k_TotalIndexDigits_Key, std::make_any(3)); + args.insertOrAssign(ITKImageWriterFilter::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); + + for(usize slice = 0; slice < expectedSlices.size(); slice++) + { + const fs::path imagePath = outputDir / name / fmt::format("slice_{:03d}{}", slice, extension); + CompareImageToExpected(imagePath, dimensions, expectedSlices[slice]); + CompareImageMetadata(imagePath, expectedSpacing, expectedOrigin, extension != ".tif"); + } + + usize outputFileCount = 0; + for([[maybe_unused]] const auto& entry : fs::directory_iterator(outputDir / name)) + { + outputFileCount++; + } + REQUIRE(outputFileCount == expectedSlices.size()); + }; + + // Rows are y/z respectively. The non-square XY plane makes an X/Y transpose observable. + const std::string extension = std::is_same_v ? ".tif" : ".mha"; + writeAndCheck(imageDataPath, ITKImageWriterFilter::k_XYPlane, "xy", extension, {3, 2}, {1.0, 2.0}, {10.0, 20.0}, + std::vector>{{0, 1, 2, 10, 11, 12}, {100, 101, 102, 110, 111, 112}}); + writeAndCheck(imageDataPath, ITKImageWriterFilter::k_XZPlane, "xz", extension, {3, 2}, {1.0, 4.0}, {10.0, 40.0}, + std::vector>{{0, 1, 2, 100, 101, 102}, {10, 11, 12, 110, 111, 112}}); + writeAndCheck(imageDataPath, ITKImageWriterFilter::k_YZPlane, "yz", extension, {2, 2}, {2.0, 4.0}, {20.0, 40.0}, + std::vector>{{0, 10, 100, 110}, {1, 11, 101, 111}, {2, 12, 102, 112}}); + + std::error_code error; + fs::remove_all(outputDir, error); + REQUIRE_FALSE(error); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("ITKImageProcessing::ITKImageWriterFilter: Fill Character Validation", "[ITKImageProcessing][ITKImageWriterFilter]") +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + + const SizeVec3 imageDims = {1, 1, 2}; + const ShapeType arrayDims(std::reverse_iterator(imageDims.end()), std::reverse_iterator(imageDims.begin())); + + auto* imageGeom = ImageGeom::Create(dataStructure, "ImageGeometry"); + imageGeom->setDimensions(imageDims); + imageGeom->setSpacing({1.0f, 1.0f, 1.0f}); + auto* cellData = AttributeMatrix::Create(dataStructure, ImageGeom::k_CellAttributeMatrixName, arrayDims, imageGeom->getId()); + imageGeom->setCellData(*cellData); + UnitTest::CreateTestDataArray(dataStructure, "ImageData", arrayDims, {1}, cellData->getId()); + + ITKImageWriterFilter filter; + Arguments args; + args.insertOrAssign(ITKImageWriterFilter::k_ImageGeomPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(ITKImageWriterFilter::k_ImageArrayPath_Key, std::make_any(k_ImageDataPath)); + args.insertOrAssign(ITKImageWriterFilter::k_FileName_Key, std::make_any("invalid_fill.tif")); + args.insertOrAssign(ITKImageWriterFilter::k_IndexOffset_Key, std::make_any(0)); + args.insertOrAssign(ITKImageWriterFilter::k_Plane_Key, std::make_any(ITKImageWriterFilter::k_XYPlane)); + args.insertOrAssign(ITKImageWriterFilter::k_TotalIndexDigits_Key, std::make_any(3)); + const auto checkInvalidFillCharacter = [&](const StringParameter::ValueType& fillCharacter, int32 expectedCode) { + args.insertOrAssign(ITKImageWriterFilter::k_LeadingDigitCharacter_Key, std::make_any(fillCharacter)); + const auto preflightResult = filter.preflight(dataStructure, args); + REQUIRE(preflightResult.outputActions.invalid()); + REQUIRE(preflightResult.outputActions.errors()[0].code == expectedCode); + }; + + SECTION("Empty") + { + checkInvalidFillCharacter("", -25601); + } + SECTION("Format control character") + { + checkInvalidFillCharacter("{", -25602); + } + SECTION("Path separator") + { + checkInvalidFillCharacter("/", -25602); + } + SECTION("ASCII control character") + { + checkInvalidFillCharacter("\n", -25602); + } + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("ITKImageProcessing::ITKImageWriterFilter: Dimension Mismatch Validation", "[ITKImageProcessing][ITKImageWriterFilter]") +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "ImageGeometry"); + imageGeom->setDimensions({1, 1, 2}); + imageGeom->setSpacing({1.0f, 1.0f, 1.0f}); + UnitTest::CreateTestDataArray(dataStructure, "ImageData", {1, 1, 1}, {1}); + + ITKImageWriterFilter filter; + Arguments args; + args.insertOrAssign(ITKImageWriterFilter::k_ImageGeomPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(ITKImageWriterFilter::k_ImageArrayPath_Key, std::make_any(std::vector{"ImageData"})); + args.insertOrAssign(ITKImageWriterFilter::k_FileName_Key, std::make_any("dimension_mismatch.tif")); + args.insertOrAssign(ITKImageWriterFilter::k_IndexOffset_Key, std::make_any(0)); + args.insertOrAssign(ITKImageWriterFilter::k_Plane_Key, std::make_any(ITKImageWriterFilter::k_XYPlane)); + args.insertOrAssign(ITKImageWriterFilter::k_TotalIndexDigits_Key, std::make_any(3)); + args.insertOrAssign(ITKImageWriterFilter::k_LeadingDigitCharacter_Key, std::make_any("0")); + + const auto preflightResult = filter.preflight(dataStructure, args); + REQUIRE(preflightResult.outputActions.invalid()); + REQUIRE(preflightResult.outputActions.errors()[0].code == -25600); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("ITKImageProcessing::ITKImageWriterFilter: Single Slice Keeps Exact Output Name", "[ITKImageProcessing][ITKImageWriterFilter]") +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + + const SizeVec3 imageDims = {3, 1, 2}; + const ShapeType arrayDims(std::reverse_iterator(imageDims.end()), std::reverse_iterator(imageDims.begin())); + + auto* imageGeom = ImageGeom::Create(dataStructure, "ImageGeometry"); + imageGeom->setDimensions(imageDims); + imageGeom->setSpacing({1.0f, 1.0f, 1.0f}); + auto* cellData = AttributeMatrix::Create(dataStructure, ImageGeom::k_CellAttributeMatrixName, arrayDims, imageGeom->getId()); + imageGeom->setCellData(*cellData); + auto* imageData = UnitTest::CreateTestDataArray(dataStructure, "ImageData", arrayDims, {1}, cellData->getId()); + auto& imageStore = imageData->getDataStoreRef(); + for(usize z = 0; z < imageDims.getZ(); ++z) + { + for(usize x = 0; x < imageDims.getX(); ++x) + { + imageStore[(z * 3) + x] = static_cast(x + (100 * z)); + } + } + + const fs::path outputDir = fs::path(unit_test::k_BinaryTestOutputDir.view()) / CreateRandomDirName(); + const fs::path outputPath = outputDir / "volume.mha"; + ITKImageWriterFilter filter; + Arguments args; + args.insertOrAssign(ITKImageWriterFilter::k_ImageGeomPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(ITKImageWriterFilter::k_ImageArrayPath_Key, std::make_any(k_ImageDataPath)); + args.insertOrAssign(ITKImageWriterFilter::k_FileName_Key, std::make_any(outputPath)); + args.insertOrAssign(ITKImageWriterFilter::k_IndexOffset_Key, std::make_any(0)); + args.insertOrAssign(ITKImageWriterFilter::k_Plane_Key, std::make_any(ITKImageWriterFilter::k_XZPlane)); + args.insertOrAssign(ITKImageWriterFilter::k_TotalIndexDigits_Key, std::make_any(3)); + args.insertOrAssign(ITKImageWriterFilter::k_LeadingDigitCharacter_Key, std::make_any("0")); + + const auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + REQUIRE(RequireExampleOutputFile(preflightResult, outputPath)); + const auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + REQUIRE(fs::exists(outputPath)); + CompareImageToExpected(outputPath, {3, 2}, std::vector{0, 1, 2, 100, 101, 102}); + + std::error_code error; + fs::remove_all(outputDir, error); + REQUIRE_FALSE(error); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("ITKImageProcessing::ITKImageWriterFilter: RGBA Image Output", "[ITKImageProcessing][ITKImageWriterFilter]") +{ + auto app = Application::GetOrCreateInstance(); + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + const SizeVec3 imageDims = {1, 1, 1}; + const ShapeType arrayDims(std::reverse_iterator(imageDims.end()), std::reverse_iterator(imageDims.begin())); + auto* imageGeom = ImageGeom::Create(dataStructure, "ImageGeometry"); + imageGeom->setDimensions(imageDims); + imageGeom->setSpacing({1.0f, 1.0f, 1.0f}); + auto* cellData = AttributeMatrix::Create(dataStructure, ImageGeom::k_CellAttributeMatrixName, arrayDims, imageGeom->getId()); + imageGeom->setCellData(*cellData); + auto* imageData = UnitTest::CreateTestDataArray(dataStructure, "ImageData", arrayDims, {4}, cellData->getId()); + auto& imageStore = imageData->getDataStoreRef(); + imageStore[0] = 10; + imageStore[1] = 20; + imageStore[2] = 30; + imageStore[3] = 40; + + const fs::path outputDir = fs::path(unit_test::k_BinaryTestOutputDir.view()) / CreateRandomDirName(); + const fs::path outputPath = outputDir / "rgba.mha"; + ITKImageWriterFilter filter; + Arguments args; + args.insertOrAssign(ITKImageWriterFilter::k_ImageGeomPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(ITKImageWriterFilter::k_ImageArrayPath_Key, std::make_any(k_ImageDataPath)); + args.insertOrAssign(ITKImageWriterFilter::k_FileName_Key, std::make_any(outputPath)); + args.insertOrAssign(ITKImageWriterFilter::k_IndexOffset_Key, std::make_any(0)); + args.insertOrAssign(ITKImageWriterFilter::k_Plane_Key, std::make_any(ITKImageWriterFilter::k_XYPlane)); + args.insertOrAssign(ITKImageWriterFilter::k_TotalIndexDigits_Key, std::make_any(3)); + args.insertOrAssign(ITKImageWriterFilter::k_LeadingDigitCharacter_Key, std::make_any("0")); + + SIMPLNX_RESULT_REQUIRE_VALID(filter.preflight(dataStructure, args).outputActions); + SIMPLNX_RESULT_REQUIRE_VALID(filter.execute(dataStructure, args).result); + + using ImageType = itk::Image, 2>; + auto reader = itk::ImageFileReader::New(); + reader->SetFileName(outputPath.string()); + REQUIRE_NOTHROW(reader->Update()); + const auto pixel = reader->GetOutput()->GetPixel({0, 0}); + CHECK(pixel.GetRed() == 10); + CHECK(pixel.GetGreen() == 20); + CHECK(pixel.GetBlue() == 30); + CHECK(pixel.GetAlpha() == 40); + + std::error_code error; + fs::remove_all(outputDir, error); + REQUIRE_FALSE(error); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + TEST_CASE("ITKImageProcessing::ITKImageWriterFilter: Write Stack", "[ITKImageProcessing][ITKImageWriterFilter]") { auto app = Application::GetOrCreateInstance(); @@ -136,6 +461,7 @@ TEST_CASE("ITKImageProcessing::ITKImageWriterFilter: Write Stack", "[ITKImagePro auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions) + REQUIRE(RequireExampleOutputFile(preflightResult, outputPath.parent_path() / "slice_100.tif")); auto executeResult = filter.execute(dataStructure, args); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result) diff --git a/src/Plugins/ITKImageProcessing/vv/ITKImageWriterFilter.md b/src/Plugins/ITKImageProcessing/vv/ITKImageWriterFilter.md new file mode 100644 index 0000000000..ace5bb75af --- /dev/null +++ b/src/Plugins/ITKImageProcessing/vv/ITKImageWriterFilter.md @@ -0,0 +1,177 @@ +# V&V Report: ITKImageWriterFilter + +| | | +|---|---| +| Plugin | ITKImageProcessing | +| SIMPLNX UUID | `a181ee3e-1678-4133-b9c5-a9dd7bfec62f` | +| DREAM3D 6.5.171 equivalent | `ITKImageWriter` (SIMPL UUID `11473711-f94d-5d96-b749-ec36a81ad338`) - `Source/Plugins/ITKImageProcessing/ITKImageProcessingFilters/ITKImageWriter.{h,cpp}` | +| Verified commit | ** | +| Status | DRAFT | +| Sign-off | Pending second-engineer review | + +## At a glance + +| Aspect | Current state | +|---|---| +| Algorithm Relationship | Minor changes - same XY/XZ/YZ pixel extraction; NX uses SIMPLNX stores, AtomicFile, and current ITK APIs. Plane spacing and origin now follow the selected physical axes (D2). | +| Oracle (confirmed) | Classes 1 + 4 - one 3x2x2 scalar fixture (`value(x,y,z)=x+10y+100z`) has same XY/XZ/YZ pixels for all ten accepted scalar types; it verifies selected-plane spacing for all types and origin for MetaImage output. uint8 uses TIFF and the remaining types use MetaImage. | +| Code paths enumerated | 28 of 48 scoped logical paths exercised; 20 documented gaps cover unsupported inputs, defensive branches, progress and cancellation, untested vector component counts, injected write failures, and SIMPL conversion failure. | +| Tests today | 7 named test cases - 1 Class 1+4 Oracle, 2 preflight error-path tests, 1 single-slice exact-name test, 1 RGBA output test, 1 stack-writing test, and 1 SIMPL backwards-compatibility test | +| Exemplar archive | None - Class 1+4 oracle uses inline data | +| Legacy comparison | Reproduced against DREAM3D 6.5.171 with a pipeline-generated copy of the inline oracle and the archived Small IN100 input. All 577 decoded slices match exactly per implementation; D1 and D2 record filename-formatting and plane-metadata differences. See `vv/provenance/ITKImageWriterFilter.md`. | +| Bug flags | Unsigned uint32/uint64 dispatch was corrected for this V&V cycle. Current changes add invalid fill-character validation, component-count validation, RGBA dispatch, selected-plane physical metadata, and tuple-copy error propagation. | +| V&V phase | Regression tests and report amendments in progress | + +## Summary + +ITKImageWriterFilter exports ImageGeom cell data as an ITK image or a 2D image stack. A hand-derived non-square fixture verifies decoded pixel orientation and plane metadata independently of legacy, while the legacy comparison checks migration output on the toy and production fixtures. + +## Algorithm Relationship + +**Minor changes.** Port-time changes are SIMPLNX DataStructure stores, AtomicFile writes, and current ITK APIs. Scalar XY/XZ/YZ decoded pixels match the legacy behavior. The NX writer deliberately preserves the selected physical axes in XZ and YZ output spacing/origin; legacy output used identity 2D metadata (D2). The parameter set is unchanged, so `parametersVersion()` remains `2`; validation has been tightened without a parameter-schema change. + +**PR(s):** + +**2026** + +- [PR #1626](https://github.com/BlueQuartzSoftware/simplnx/pull/1626) ("ENH: Various small doc, bug and enhancement fixes.") - Small documentation, bug, and enhancement fixes. +- [PR #1585](https://github.com/BlueQuartzSoftware/simplnx/pull/1585) ("ENH: Add Image Reader/Writer that depend on Tiff and Stb libraries.") - Integrates TIFF/STB reader-writer support. +- [PR #1576](https://github.com/BlueQuartzSoftware/simplnx/pull/1576) ("ENH: Improve error messages across the codebase") - Improves error messages. +- [PR #1571](https://github.com/BlueQuartzSoftware/simplnx/pull/1571) ("DOC: Add standardized ChoicesParameter descriptions to filter docs") - Adds standardized output-plane choice descriptions. +- [PR #1555](https://github.com/BlueQuartzSoftware/simplnx/pull/1555) ("ENH: Again require in-memory data for ITK filters") - Restores the ITK in-memory data requirement. +- [PR #1490](https://github.com/BlueQuartzSoftware/simplnx/pull/1490) ("STY: Fix warnings about unintended slicing of object") - Removes object-slicing warnings. +- [PR #1476](https://github.com/BlueQuartzSoftware/simplnx/pull/1476) ("BUG/ENH: Fix Backwards Pipeline Compatibility and Add Testing") - Fixes backward-pipeline compatibility. + +**2025** + +- [PR #1489](https://github.com/BlueQuartzSoftware/simplnx/pull/1489) ("ENH: ItkImageWriter allow user to set the number of padding digits and the fill char") - Adds output-index padding and fill-character controls. +- [PR #1457](https://github.com/BlueQuartzSoftware/simplnx/pull/1457) ("STY: Clean up 'static inline' from filter headers") - Cleans up static-inline header declarations. +- [PR #1377](https://github.com/BlueQuartzSoftware/simplnx/pull/1377) ("STY: Ensure all code arguments are consistent across filters") - Standardizes argument style. +- [PR #1257](https://github.com/BlueQuartzSoftware/simplnx/pull/1257) ("ENH: Add missing documentation comments for preflight and execute methods in filters") - Adds preflight and execute documentation. +- [PR #1253](https://github.com/BlueQuartzSoftware/simplnx/pull/1253) ("ENH: Merge Initial Out-of-Core infrastructure") - Introduces the initial OOC infrastructure. +- [PR #1238](https://github.com/BlueQuartzSoftware/simplnx/pull/1238) ("ENH: Added pipeline relative path support") - Adds pipeline-relative path support. +- [PR #1232](https://github.com/BlueQuartzSoftware/simplnx/pull/1232) ("DOC: General code clean up to make parameters consistent and docs consistent") - Aligns parameter and documentation conventions. + +**2024** + +- [PR #1088](https://github.com/BlueQuartzSoftware/simplnx/pull/1088) ("Added versioning to filter parameters and json") - Adds filter and parameter versioning, including backwards-compatible parameter JSON reading. +- [PR #1082](https://github.com/BlueQuartzSoftware/simplnx/pull/1082) ("SIMPLConversion header optimization") - Optimizes SIMPL-conversion headers. +- [PR #975](https://github.com/BlueQuartzSoftware/simplnx/pull/975) ("DOC: Fix capitalization issue in the documentation for the GitHub link.") - Corrects GitHub link capitalization. +- [PR #956](https://github.com/BlueQuartzSoftware/simplnx/pull/956) ("ENH: Rename Filters that start with Find/Generate/Calculate to Compute") - Updates documentation for standardized filter names. +- [PR #941](https://github.com/BlueQuartzSoftware/simplnx/pull/941) ("Moved Result handling outside of AtomicFile") - Moves result handling outside AtomicFile. +- [PR #934](https://github.com/BlueQuartzSoftware/simplnx/pull/934) ("BUG: Pipeline and Filter human facing label cleanup") - Cleans up human-facing labels. +- [PR #931](https://github.com/BlueQuartzSoftware/simplnx/pull/931) ("ENH: All filter's class names end with \"Filter\".") - Applies the Filter class-name suffix. +- [PR #918](https://github.com/BlueQuartzSoftware/simplnx/pull/918) ("BUG: Update ITK Image Writer to Use Atomic File API") - Migrates the writer to the AtomicFile API. +- [PR #914](https://github.com/BlueQuartzSoftware/simplnx/pull/914) ("DOC: Update all doc files to have correct filter human name") - Corrects the documented filter human name. +- [PR #874](https://github.com/BlueQuartzSoftware/simplnx/pull/874) ("ENH: Refactor the Parameter Keys to make them consistent and easy to learn") - Refactors parameter keys. +- [PR #847](https://github.com/BlueQuartzSoftware/simplnx/pull/847) ("DOC: Link directly to the discussion page on GitHub.") - Updates the documentation discussion link. + +**2023** + +- [PR #801](https://github.com/BlueQuartzSoftware/simplnx/pull/801) ("ENH: Rename complex to simplnx") - Renames the framework namespace and repository identity. +- [PR #790](https://github.com/BlueQuartzSoftware/simplnx/pull/790) ("ENH: Write Temp Files for All Writers") - Adds temporary-file writing for writers. +- [PR #779](https://github.com/BlueQuartzSoftware/simplnx/pull/779) ("ENH: Implement SIMPL pipeline conversion") - Implements SIMPL pipeline conversion. +- [PR #753](https://github.com/BlueQuartzSoftware/simplnx/pull/753) ("API: Standardize I/O Naming to Read/Write") - Standardizes I/O naming. +- [PR #708](https://github.com/BlueQuartzSoftware/simplnx/pull/708) ("DOC: Update documentation files to allow Sphinx to generate html documentation") - Updates documentation for Sphinx generation. +- [PR #703](https://github.com/BlueQuartzSoftware/simplnx/pull/703) ("ENH: Enable Out-of-Core functionality") - Adds OOC infrastructure compatibility. +- [PR #673](https://github.com/BlueQuartzSoftware/simplnx/pull/673) ("ENH: Filter help text filled out. Docs updated with python section") - Adds filter help text and Python documentation. +- [PR #671](https://github.com/BlueQuartzSoftware/simplnx/pull/671) ("API: Add C++ Class Name to All Default Tags") - Adds C++ class names to default tags. +- [PR #593](https://github.com/BlueQuartzSoftware/simplnx/pull/593) ("ENH: Update ITK filters to follow naming and parameter layout conventions") - Aligns ITK filter naming and parameter layout. +- [PR #575](https://github.com/BlueQuartzSoftware/simplnx/pull/575) ("BUG: Fix issue where ITKImageWriter is double looping over the Z dim") - Fixes double iteration over Z. +- [PR #86](https://github.com/BlueQuartzSoftware/simplnx/pull/86) ("STYLE: Clean Up Includes") - Cleans up includes. + +**2022** + +- [PR #80](https://github.com/BlueQuartzSoftware/simplnx/pull/80) ("STYLE: Short Variable Patching") - Shortens variable names. +- [PR #56](https://github.com/BlueQuartzSoftware/simplnx/pull/56) ("Add Filter Comments") - Adds filter comments. +- [PR #54](https://github.com/BlueQuartzSoftware/simplnx/pull/54) ("DOCS: Update paths and CMake codes to prepare for documentation updates") - Updates documentation paths and CMake configuration. +- [PR #50](https://github.com/BlueQuartzSoftware/simplnx/pull/50) ("All Parameter keys should be snake_case") - Converts parameter keys to snake_case. +- [PR #37](https://github.com/BlueQuartzSoftware/simplnx/pull/37) ("Refactored geometry hierarchy") - Refactors the geometry hierarchy. +- [PR #30](https://github.com/BlueQuartzSoftware/simplnx/pull/30) ("UUID cleaning") - Cleans up filter UUIDs. +- [PR #2](https://github.com/BlueQuartzSoftware/simplnx/pull/2) ("Newly Created ITK Wrapped Filters") - Adds the newly generated ITK-wrapped filter implementation. + +## Oracle + +**Class:** 1 (Analytical) + 4 (Invariant). + +**Applied:** The 3x2x2 scalar fixture has `value(x,y,z)=x+10y+100z`; exact XY, XZ, and YZ pixel matrices are hand-derived and decoded for all ten accepted types. Its non-uniform origin `(10,20,40)` and spacing `(1,2,4)` verify that each written plane has the correct two physical axes: spacing is decoded for every type and origin for MetaImage output. TIFF does not preserve ITK origin metadata. File creation and decoded dimensions are companion invariants. + +**Encoded:** `test/ITKImageWriterTest.cpp::ITKImageProcessing::ITKImageWriterFilter: Analytical Pixel Order` - templated over all ten accepted scalar types. + +**Second-engineer review:** pending second-engineer review. + +## Code path coverage + +28 of 48 scoped logical paths exercised. Source: `src/Plugins/ITKImageProcessing/src/ITKImageProcessing/Filters/ITKImageWriterFilter.cpp` (518 lines). + +Scope: one row represents one filter-controlled logical behavior or failure exit. Repeated equivalent checks in the XY, XZ, and YZ loops are grouped. Element-type and component-count dispatch are counted as orthogonal families rather than as a Cartesian product. Internal implementation branches of parameter classes, dispatch utilities, AtomicFile, and ITK are excluded; configured parameter outcomes and success or failure results handled at this filter's call boundaries are included. + +| # | Phase | Path | Test case | +|---|---|---|---| +| 1 | Preflight | Valid dimensions, fill character, in-memory storage, and supported selections reach the preview calculation. | `Analytical Pixel Order`, `Single Slice Keeps Exact Output Name`, `RGBA Image Output`, and `Write Stack`. | +| 2 | Preflight | Dimension mismatch returns `-25600`. | `Dimension Mismatch Validation` uses a 1x1x1 array with a 1x1x2 geometry and asserts `-25600`. | +| 3 | Preflight | Fill-character length other than one returns `-25601`. | `Fill Character Validation` uses an empty string and asserts `-25601`. | +| 4 | Preflight | A one-character format, path, or ASCII control character returns `-25602`. | `Fill Character Validation` asserts `-25602` for `{`, `/`, and newline. | +| 5 | Preflight | OOC input returns `ITK::Constants::k_OutOfCoreDataNotSupported`. | *Not directly tested; ITK tests run only with in-memory arrays.* | +| 6 | Parameter validation | Unsupported element type is rejected by `ArraySelectionParameter`. | *Not directly tested; the selected array uses one of the ten accepted numeric element types in every execution test.* | +| 7 | Parameter validation | Unsupported component shape is rejected by `ArraySelectionParameter`. | *Not directly tested; current tests use component shapes `{1}` and `{4}`.* | +| 8 | Preflight | XY plane selects the Z slice count for the example output file. | `Write Stack` asserts the XY preview starts at the configured index offset. | +| 9 | Preflight | XZ plane selects the Y slice count for the example output file. | `Single Slice Keeps Exact Output Name` asserts the one-slice XZ preview. | +| 10 | Preflight | YZ plane selects the X slice count for the example output file. | *Not directly assertion-covered; YZ execution is tested, but its preview value is not asserted.* | +| 11 | Preflight | Defensive plane-switch default leaves `maxSlice == 1`. | *Not directly tested; `ChoicesParameter` rejects values outside XY, XZ, and YZ before `preflightImpl`.* | +| 12 | Preflight | Multi-slice preview includes the formatted index suffix and offset. | `Write Stack` asserts `slice_100.tif`. | +| 13 | Preflight | Single-slice preview suppresses the index suffix. | `Single Slice Keeps Exact Output Name` asserts the exact unsuffixed output path. | +| 14 | Execute | Runtime store creation and copy dispatch cover int8, uint8, int16, uint16, int32, uint32, int64, uint64, float32, and float64. | `Analytical Pixel Order` is templated over all ten element types and asserts decoded pixels. | +| 15 | Execute | XY extraction and selected-axis physical metadata. | `Analytical Pixel Order` asserts hand-derived pixels, spacing `(1,2)`, and MetaImage origin `(10,20)`. | +| 16 | Execute | XZ extraction and selected-axis physical metadata. | `Analytical Pixel Order` asserts hand-derived pixels, spacing `(1,4)`, and MetaImage origin `(10,40)`. | +| 17 | Execute | YZ extraction and selected-axis physical metadata. | `Analytical Pixel Order` asserts hand-derived pixels, spacing `(2,4)`, and MetaImage origin `(20,40)`. | +| 18 | Execute | Cancellation between slices returns before copying or writing the next slice. | *Not directly tested; requires cancel-signal injection during execution.* | +| 19 | Execute | Tuple copy succeeds and the next slice operation proceeds. | `Analytical Pixel Order` asserts every copied pixel for all planes and element types. | +| 20 | Execute | Tuple-copy failure propagates from `copyFrom`. | *Not directly tested. This defensive path is excluded by construction: `sliceData` uses the input data type and component shape and exactly the selected plane's tuple shape; preflight requires the input tuple dimensions to match the selected geometry, so computed source and destination ranges are valid.* | +| 21 | Execute | A per-slice message reports the one-based file number, total file count, and output path. | *Not directly tested; current tests do not capture the message handler.* | +| 22 | Filename | Multi-slice execution adds the configured padding, fill character, and index offset. | `Write Stack` asserts the expected offset filenames and that no extra files remain. | +| 23 | Filename | Single-slice execution keeps the exact output filename. | `Single Slice Keeps Exact Output Name` asserts the unsuffixed file exists and decodes correctly. | +| 24 | Filesystem | Output parent directory already exists. | Multi-slice tests assert files after the first slice, which reuse the directory created for that stack. | +| 25 | Filesystem | Missing output parent directory is created successfully. | Successful output tests use new random nested directories and assert the first output file. | +| 26 | Filesystem | Output parent directory creation fails and returns `-19000`. | *Not directly tested; requires filesystem failure injection.* | +| 27 | Component dispatch | Component shape `{1}` uses the scalar path. | `Analytical Pixel Order` covers `{1}` for all ten accepted element types. | +| 28 | Component dispatch | Component shape `{2}` uses the vector path. | *Not directly tested.* | +| 29 | Component dispatch | Component shape `{3}` uses the vector path. | *Not directly tested.* | +| 30 | Component dispatch | Component shape `{4}` uses the RGBA path. | `RGBA Image Output` decodes one uint8 pixel as `(10,20,30,40)`; other element types are not cross-product tested. | +| 31 | Component dispatch | Component shape `{10}` uses the vector path. | *Not directly tested.* | +| 32 | Component dispatch | Component shape `{11}` uses the vector path. | *Not directly tested.* | +| 33 | Component dispatch | Component shape `{36}` uses the vector path. | *Not directly tested.* | +| 34 | Component dispatch | Defensive `ArraySwitchFunc` failure returns `-21010`. | *Not directly tested; allowed element types and component shapes are constrained by the array-selection parameter.* | +| 35 | Atomic write | `AtomicFile::Create` succeeds and provides a temporary path. | Successful output tests assert the committed output files. | +| 36 | Atomic write | `AtomicFile::Create` failure propagates. | *Not directly tested; requires failure injection.* | +| 37 | Atomic write | ITK writer succeeds. | Successful output tests decode the written images and assert their contents. | +| 38 | Atomic write | ITK throws and the filter returns `-21011`. | *Not directly tested; requires ITK writer failure injection.* | +| 39 | Atomic write | Atomic commit succeeds. | Successful output tests assert the final output paths exist and decode correctly. | +| 40 | Atomic write | Atomic commit failure propagates. | *Not directly tested; requires commit failure injection.* | +| 41 | Execute | Successful `SaveImageData` result continues the plane loop. | `Analytical Pixel Order` and `Write Stack` assert that every expected slice is written. | +| 42 | Execute | Failed `SaveImageData` result propagates from the plane loop. | *Not directly tested; covered failure sources require injection.* | + +## Test inventory + +| Test case | Status | Notes | +|---|---|---| +| `ITKImageProcessing::ITKImageWriterFilter: Analytical Pixel Order` | new-for-V&V | Tests Class 1+4 Oracle over all accepted scalar types, including decoded XY/XZ/YZ pixels, spacing, and MetaImage origin. | +| `ITKImageProcessing::ITKImageWriterFilter: Fill Character Validation` | new-for-V&V | Empty fill character is rejected with `-25601`; format-control and path-separator characters are rejected with `-25602`. | +| `ITKImageProcessing::ITKImageWriterFilter: Dimension Mismatch Validation` | new-for-V&V | Mismatched array and geometry input is rejected during preflight with error `-25600`. | +| `ITKImageProcessing::ITKImageWriterFilter: Single Slice Keeps Exact Output Name` | new-for-V&V | A 3x1x2 ImageGeom XZ plane previews and writes an unsuffixed `.mha` file with exact decoded pixels. | +| `ITKImageProcessing::ITKImageWriterFilter: RGBA Image Output` | new-for-V&V | A uint8 RGBA pixel is dispatched and decodes as `(10,20,30,40)`. | +| `ITKImageProcessing::ITKImageWriterFilter: Write Stack` | kept | Checks that the preflight preview starts at the configured index offset and that the image is written as a stack of files. | +| `ITKImageProcessing::ITKImageWriterFilter: SIMPL Backwards Compatibility` | kept | Covers SIMPL json backwards compatibility. | + +## Exemplar archive + +No new exemplar archive was created for this V&V cycle: the Class 1 oracle is encoded entirely as inline expected values in the test source. + +The oracle and DREAM3D 6.5.171 comparison provenance, including the legacy setup pipeline, paired writer pipelines, runner hashes, input hashes, zero-tolerance comparison method, and machine-readable results, is recorded in [`provenance/ITKImageWriterFilter.md`](provenance/ITKImageWriterFilter.md). Reproducible comparison artifacts and were uploaded to OneDrive on 2026-07-30. + +## Deviations from DREAM3D 6.5.171 + +- `ITKImageWriterFilter-D1` - NX defaults to zero-padded slice indices while 6.5.171 does not - see `vv/deviations/ITKImageWriterFilter.md`. +- `ITKImageWriterFilter-D2` - NX writes the selected plane's physical spacing and origin, while 6.5.171 used identity 2D metadata for XY/XZ/YZ output - see `vv/deviations/ITKImageWriterFilter.md`. + +All pixels otherwise match exactly with zero tolerance: the analytical fixture has 2 XY, 2 XZ, and 3 YZ slices for each of ten scalar types; Small IN100 has 117 XY, 201 XZ, and 189 YZ float32 TIFF slices. diff --git a/src/Plugins/ITKImageProcessing/vv/deviations/ITKImageWriterFilter.md b/src/Plugins/ITKImageProcessing/vv/deviations/ITKImageWriterFilter.md new file mode 100644 index 0000000000..ce11ef405e --- /dev/null +++ b/src/Plugins/ITKImageProcessing/vv/deviations/ITKImageWriterFilter.md @@ -0,0 +1,37 @@ +# Deviations from DREAM3D 6.5.171: ITKImageWriterFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent. + +Entries are referenced by stable ID (ITKImageWriterFilter-D) from the V&V report and from public migration guidance. The ID is stable across renames; the Filter UUID field is the permanent cross-reference anchor. + +## ITKImageWriterFilter-D1 + +| Field | Value | +|---|---| +| **Deviation ID** | `ITKImageWriterFilter-D1` | +| **Filter UUID** | `a181ee3e-1678-4133-b9c5-a9dd7bfec62f` | +| **Status** | active | + +**Symptom:** For a multi-slice 2D output, 6.5.171 names the first slice `_0.ext`; NX defaults to `_000.ext`. + +**Root cause:** Algorithmic choice. NX added index offset, total-digit, and fill-character parameters and defaults the total digit count to three; 6.5.171 always wrote an unpadded zero-based suffix. + +**Affected users:** Any workflow that consumes 2D image-stack filenames directly, rather than discovering them by pattern, can fail to find NX output after migration. + +**Recommendation:** Trust SIMPLNX. To retain the legacy filename shape, set *Index Offset* to `0` and *Total Number of Index Digits* to `1`. + +## ITKImageWriterFilter-D2 + +| Field | Value | +|---|---| +| **Deviation ID** | `ITKImageWriterFilter-D2` | +| **Filter UUID** | `a181ee3e-1678-4133-b9c5-a9dd7bfec62f` | +| **Status** | active | + +**Symptom:** For XY, XZ, and YZ stack output, NX writes the two selected physical axes as the 2D image spacing and origin. DREAM3D 6.5.171 writes identity spacing and zero origin for the newly created 2D image. + +**Root cause:** Library/API adaptation. NX constructs the temporary 2D geometry from the selected ImageGeom axes so that output metadata describes the written plane; the legacy implementation created a new 2D geometry with default metadata. + +**Affected users:** Workflows that consume physical image metadata can observe different spacing and origin after migration when the selected source axes do not already have unit spacing and zero origin. + +**Recommendation:** Trust SIMPLNX. The NX metadata preserves the source ImageGeom's physical coordinate system for the output plane.