diff --git a/src/Plugins/ImageProcessing/docs/BinaryDilateImageFilter.md b/src/Plugins/ImageProcessing/docs/BinaryDilateImageFilter.md index 2c26bca99a..ca87e8d132 100644 --- a/src/Plugins/ImageProcessing/docs/BinaryDilateImageFilter.md +++ b/src/Plugins/ImageProcessing/docs/BinaryDilateImageFilter.md @@ -18,6 +18,8 @@ The input array must be single-component (scalar) and have an integer type. Mode The *Boundary To Foreground* parameter controls how neighbors that fall outside the image bounds are treated: when enabled, an out-of-image neighbor counts as foreground; when disabled (the default for dilation), it counts as background. +For a single-slice image (Z = 1), the filter uses only the kernel offsets in the XY plane. It ignores offsets that have a Z component. The boundary setting applies only at the X and Y edges. An Annulus kernel with X and Y radius 0 has no XY offsets. In that case, the output is a copy of the input. + ### Kernel Type The *Kernel Type* parameter selects the structuring element used for the morphological operation: diff --git a/src/Plugins/ImageProcessing/docs/BinaryErodeImageFilter.md b/src/Plugins/ImageProcessing/docs/BinaryErodeImageFilter.md index 75e858cc43..196bf2c5ed 100644 --- a/src/Plugins/ImageProcessing/docs/BinaryErodeImageFilter.md +++ b/src/Plugins/ImageProcessing/docs/BinaryErodeImageFilter.md @@ -18,6 +18,8 @@ The input array must be single-component (scalar) and have an integer type. Mode The *Boundary To Foreground* parameter controls how neighbors that fall outside the image bounds are treated: when enabled (the default for erosion), an out-of-image neighbor counts as foreground, so foreground touching the image border is not eroded away by the missing neighbors; when disabled, an out-of-image neighbor counts as background. +For a single-slice image (Z = 1), the filter uses only the kernel offsets in the XY plane. It ignores offsets that have a Z component. The boundary setting applies only at the X and Y edges. An Annulus kernel with X and Y radius 0 has no XY offsets. In that case, the output is a copy of the input. + ### Kernel Type The *Kernel Type* parameter selects the structuring element used for the morphological operation: diff --git a/src/Plugins/ImageProcessing/docs/StandardDeviationProjectionImageFilter.md b/src/Plugins/ImageProcessing/docs/StandardDeviationProjectionImageFilter.md index 8034e5d328..dedfabc3cc 100644 --- a/src/Plugins/ImageProcessing/docs/StandardDeviationProjectionImageFilter.md +++ b/src/Plugins/ImageProcessing/docs/StandardDeviationProjectionImageFilter.md @@ -12,7 +12,7 @@ Collapses the selected **Projection Dimension** of a scalar Image Geometry to a The **Projection Dimension** selects the collapsed axis: 0 = X, 1 = Y, 2 = Z. -The standard deviation is the *sample* standard deviation: the column mean is formed with the population divisor *N*, but the sum of squared deviations is divided by *N − 1* before taking the square root, matching the legacy ITK Standard Deviation Projection Image Filter. A column of length 1 (or a degenerate projected axis) yields 0. The output element type is always **Float64** (double), regardless of the input element type. The statistic is accumulated with Kahan-compensated running sums so that long columns do not lose precision. +The standard deviation is the *sample* standard deviation: the column mean is formed with the population divisor *N*, but the sum of squared deviations is divided by *N − 1* before taking the square root, matching the legacy ITK Standard Deviation Projection Image Filter. A column of length 1 (or a degenerate projected axis) yields 0. The output element type is always **Float64** (double), regardless of the input element type. The statistic uses Welford's online update to avoid cancellation when large values have small differences. When **Perform In-Place** is enabled, the input Image Geometry is replaced by the projected (collapsed) geometry containing the output array. Because the whole original geometry is replaced, any *other* arrays that were in its cell-data Attribute Matrix are discarded (they have the original, non-collapsed shape and cannot survive the projection); the filter emits a preflight warning listing them. Disable **Perform In-Place** to keep the original geometry: the input geometry is then preserved and the result is written to a newly created geometry named by **Created Image Geometry**, whose cell-data Attribute Matrix name is copied from the input geometry. @@ -20,7 +20,7 @@ The input array must be single-component (scalar) and one of the scalar numeric ## Algorithm -The filter reduces each input pencil along the selected X, Y, or Z axis and writes one value to the corresponding slot in the collapsed image. A pencil retains Kahan-compensated sum and squared-sum state and emits the sample standard deviation, with zero for one sample. In-memory and three-dimensional inputs use axis-aware slab staging. For a true two-dimensional image with an out-of-core endpoint, fixed-budget row blocks or X tiles and bulk datastore transfers keep resident cell data at or below 64 MiB. +The filter reduces each input pencil along the selected X, Y, or Z axis and writes one value to the corresponding slot in the collapsed image. Each pencil retains a running mean, a sum of squared deviations, and a count. It emits the sample standard deviation, with zero for one sample. In-memory and three-dimensional inputs use axis-aware slab staging. For a true two-dimensional image with an out-of-core endpoint, fixed-budget row blocks or X tiles and bulk datastore transfers keep resident cell data at or below 64 MiB. % Auto generated parameter table will be inserted here diff --git a/src/Plugins/ImageProcessing/test/MorphologyEngineTest.cpp b/src/Plugins/ImageProcessing/test/MorphologyEngineTest.cpp index c48faa4e2e..9386b4672f 100644 --- a/src/Plugins/ImageProcessing/test/MorphologyEngineTest.cpp +++ b/src/Plugins/ImageProcessing/test/MorphologyEngineTest.cpp @@ -486,7 +486,7 @@ template std::vector BinaryOracle(const std::vector& in, usize dimX, usize dimY, usize dimZ, const StructuringElement& se, MorphOp op, T fg, T bg, bool boundaryToForeground) { const bool dilate = (op == MorphOp::Dilate); - const usize numOffsets = se.offsets.size(); + const usize numOffsets = dimZ == 1 ? static_cast(std::count_if(se.offsets.begin(), se.offsets.end(), [](const SEOffset& off) { return off.dz == 0; })) : se.offsets.size(); std::vector out(in.size()); for(usize z = 0; z < dimZ; ++z) { @@ -497,6 +497,11 @@ std::vector BinaryOracle(const std::vector& in, usize dimX, usize dimY, us usize fgCount = 0; for(const SEOffset& off : se.offsets) { + // A 2D kernel has no out-of-plane neighbors; only X/Y bounds apply. + if(dimZ == 1 && off.dz != 0) + { + continue; + } const int64 nx = static_cast(x) + off.dx; const int64 ny = static_cast(y) + off.dy; const int64 nz = static_cast(z) + off.dz; @@ -988,6 +993,179 @@ TEST_CASE("ImageProcessing::MorphologyEngine: rolling composite pipeline uses tr } } +TEMPLATE_TEST_CASE("ImageProcessing::MorphologyEngine: binary 2D boundary ignores absent Z neighbors", "[ImageProcessing][MorphologyEngine][Binary2DBoundaryRegression]", BinaryDilateImageFilter, + BinaryErodeImageFilter) +{ + UnitTest::LoadPlugins(); + using FilterT = TestType; + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + const bool boundaryToForeground = GENERATE(false, true); + CAPTURE(scenario, boundaryToForeground); + UnitTest::AlgorithmTestScope scope(scenario); + constexpr bool k_Dilate = std::is_same_v; + + // A radius-one 2D Cross contains the center and its four axial neighbors. + // Dilation of one point yields that cross. Foreground outside X/Y adds the perimeter. + // Erosion of the cross leaves only its center, with either boundary setting. + const std::vector cross = {0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0}; + const std::vector point = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + const std::vector crossAndPerimeter = {1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1}; + const auto& input = k_Dilate ? point : cross; + const auto& expected = k_Dilate ? (boundaryToForeground ? crossAndPerimeter : cross) : point; + + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "Image Geometry"); + REQUIRE(imageGeom != nullptr); + imageGeom->setDimensions({5, 5, 1}); + auto* cellData = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 5, 5}, imageGeom->getId()); + REQUIRE(cellData != nullptr); + imageGeom->setCellData(*cellData); + const DataPath inputPath({"Image Geometry", "CellData", "Input"}); + auto store = DataStoreUtilities::CreateDataStore(dataStructure, inputPath, ShapeType{1, 5, 5}, ShapeType{1}); + auto copyInputResult = store->copyFromBuffer(0, nonstd::span(input.data(), input.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult); + auto* inputArray = DataArray::Create(dataStructure, "Input", store, cellData->getId()); + REQUIRE(inputArray != nullptr); + scope.requireExpectedStore(*inputArray); + + FilterT filter; + Arguments args = filter.getDefaultArguments(); + args.insertOrAssign(FilterT::k_InputImageGeomPath_Key, std::make_any(DataPath({"Image Geometry"}))); + args.insertOrAssign(FilterT::k_InputImageDataPath_Key, std::make_any(inputPath)); + args.insertOrAssign(FilterT::k_OutputImageArrayName_Key, std::make_any("Output")); + args.insertOrAssign(FilterT::k_KernelType_Key, std::make_any(static_cast(KernelType::Cross))); + args.insertOrAssign(FilterT::k_KernelRadius_Key, std::make_any>(std::vector{1, 1, 1})); + args.insertOrAssign(FilterT::k_ForegroundValue_Key, std::make_any(1.0)); + args.insertOrAssign(FilterT::k_BackgroundValue_Key, std::make_any(0.0)); + args.insertOrAssign(FilterT::k_BoundaryToForeground_Key, std::make_any(boundaryToForeground)); + auto result = scope.executeFilter(filter, dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + + const DataArray* output = nullptr; + REQUIRE_NOTHROW(output = &dataStructure.getDataRefAs>(DataPath({"Image Geometry", "CellData", "Output"}))); + scope.requireExpectedStore(*output); + std::vector actual(expected.size()); + auto copyOutputResult = output->getDataStoreRef().copyIntoBuffer(0, nonstd::span(actual.data(), actual.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyOutputResult); + REQUIRE(actual == expected); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEMPLATE_TEST_CASE("ImageProcessing::MorphologyEngine: empty XY binary kernel preserves input", "[ImageProcessing][MorphologyEngine][Binary2DEmptyKernelRegression]", BinaryDilateImageFilter, + BinaryErodeImageFilter) +{ + UnitTest::LoadPlugins(); + using FilterT = TestType; + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + const bool boundaryToForeground = GENERATE(false, true); + const uint32 radiusZ = GENERATE(uint32{0}, uint32{1}, uint32{3}); + CAPTURE(scenario, boundaryToForeground, radiusZ); + UnitTest::AlgorithmTestScope scope(scenario); + + // An Annulus with zero X/Y radii has no XY offsets, regardless of its Z radius. + // Mixed input distinguishes copy-through from a constant boundary value. + const std::vector expected = {7, 3, 7}; + DataStructure dataStructure; + auto* imageGeomPtr = ImageGeom::Create(dataStructure, "Image Geometry"); + REQUIRE(imageGeomPtr != nullptr); + imageGeomPtr->setDimensions({3, 1, 1}); + auto* cellDataPtr = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 3}, imageGeomPtr->getId()); + REQUIRE(cellDataPtr != nullptr); + imageGeomPtr->setCellData(*cellDataPtr); + const DataPath inputPath({"Image Geometry", "CellData", "Input"}); + auto storePtr = DataStoreUtilities::CreateDataStore(dataStructure, inputPath, ShapeType{1, 1, 3}, ShapeType{1}); + auto copyInputResult = storePtr->copyFromBuffer(0, nonstd::span(expected.data(), expected.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult); + auto* inputArrayPtr = DataArray::Create(dataStructure, "Input", storePtr, cellDataPtr->getId()); + REQUIRE(inputArrayPtr != nullptr); + scope.requireExpectedStore(*inputArrayPtr); + + FilterT filter; + Arguments args = filter.getDefaultArguments(); + args.insertOrAssign(FilterT::k_InputImageGeomPath_Key, std::make_any(DataPath({"Image Geometry"}))); + args.insertOrAssign(FilterT::k_InputImageDataPath_Key, std::make_any(inputPath)); + args.insertOrAssign(FilterT::k_OutputImageArrayName_Key, std::make_any("Output")); + args.insertOrAssign(FilterT::k_KernelType_Key, std::make_any(static_cast(KernelType::Annulus))); + args.insertOrAssign(FilterT::k_KernelRadius_Key, std::make_any>(std::vector{0, 0, radiusZ})); + args.insertOrAssign(FilterT::k_ForegroundValue_Key, std::make_any(7.0)); + args.insertOrAssign(FilterT::k_BackgroundValue_Key, std::make_any(3.0)); + args.insertOrAssign(FilterT::k_BoundaryToForeground_Key, std::make_any(boundaryToForeground)); + auto result = scope.executeFilter(filter, dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + + const DataArray* outputPtr = nullptr; + REQUIRE_NOTHROW(outputPtr = &dataStructure.getDataRefAs>(DataPath({"Image Geometry", "CellData", "Output"}))); + scope.requireExpectedStore(*outputPtr); + std::vector actual(expected.size()); + auto copyOutputResult = outputPtr->getDataStoreRef().copyIntoBuffer(0, nonstd::span(actual.data(), actual.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyOutputResult); + REQUIRE(actual == expected); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("ImageProcessing::MorphologyEngine: binary composite empty XY kernel preserves input", "[ImageProcessing][MorphologyEngine][Binary2DCompositeEmptyKernelRegression]") +{ + UnitTest::LoadPlugins(); + std::atomic_bool shouldCancel{false}; + + for(const int32 radiusZ : {1, 3}) + { + for(const bool opening : {true, false}) + { + for(const bool safeBorder : {false, true}) + { + if(opening && safeBorder) + { + continue; + } + DYNAMIC_SECTION("Empty in-plane Annulus: radiusZ=" << radiusZ << ", opening=" << opening << ", safeBorder=" << safeBorder) + { + const MorphOp firstOp = opening ? MorphOp::Erode : MorphOp::Dilate; + const MorphOp secondOp = opening ? MorphOp::Dilate : MorphOp::Erode; + const SizeVec3 dims{3, 1, 1}; + const StructuringElement se = MakeStructuringElement(KernelType::Annulus, {0, 0, radiusZ}); + const std::vector values = {7, 3, 7}; + // No in-plane offsets remain, so both stages copy the input. + const std::vector expected = {7, 3, 7}; + DataStore input(ShapeType{1, 1, 3}, ShapeType{1}, 0); + DataStore output(ShapeType{1, 1, 3}, ShapeType{1}, 9); + auto copyInputResult = input.copyFromBuffer(0, nonstd::span(values.data(), values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult); + auto requiredBytesResult = CalculateBinaryMorphologyCompositePipelineWorkingMemoryBytes(dims, se, safeBorder); + SIMPLNX_RESULT_REQUIRE_VALID(requiredBytesResult); + auto result = ApplyBinaryMorphologyCompositePipeline(input, output, dims, se, firstOp, secondOp, safeBorder, uint8{7}, uint8{3}, shouldCancel, requiredBytesResult.value()); + SIMPLNX_RESULT_REQUIRE_VALID(result); + std::vector actual(expected.size()); + auto copyOutputResult = output.copyIntoBuffer(0, nonstd::span(actual.data(), actual.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyOutputResult); + REQUIRE(actual == expected); + } + } + } + } + + SECTION("Cross Opening removes the inner corner of a foreground block") + { + const SizeVec3 dims{5, 5, 1}; + const StructuringElement se = MakeStructuringElement(KernelType::Cross, {1, 1, 1}); + const std::vector values = {1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + // The corner block survives except for its inner corner at (2, 2). + const std::vector expected = {1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + DataStore input(ShapeType{1, 5, 5}, ShapeType{1}, 0); + DataStore output(ShapeType{1, 5, 5}, ShapeType{1}, 9); + auto copyInputResult = input.copyFromBuffer(0, nonstd::span(values.data(), values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult); + auto requiredBytesResult = CalculateBinaryMorphologyCompositePipelineWorkingMemoryBytes(dims, se, false); + SIMPLNX_RESULT_REQUIRE_VALID(requiredBytesResult); + auto result = ApplyBinaryMorphologyCompositePipeline(input, output, dims, se, MorphOp::Erode, MorphOp::Dilate, false, uint8{1}, uint8{0}, shouldCancel, requiredBytesResult.value()); + SIMPLNX_RESULT_REQUIRE_VALID(result); + std::vector actual(expected.size()); + auto copyOutputResult = output.copyIntoBuffer(0, nonstd::span(actual.data(), actual.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyOutputResult); + REQUIRE(actual == expected); + } +} + TEMPLATE_TEST_CASE("ImageProcessing::MorphologyEngine: BinaryMorphScanline dilate/erode matches fg-count oracle (3D + 2D)", "[ImageProcessing][MorphologyEngine]", int32, uint8, float32) { using T = TestType; diff --git a/src/Plugins/ImageProcessing/test/StandardDeviationProjectionImageFilterTest.cpp b/src/Plugins/ImageProcessing/test/StandardDeviationProjectionImageFilterTest.cpp index d15e761839..5430dde319 100644 --- a/src/Plugins/ImageProcessing/test/StandardDeviationProjectionImageFilterTest.cpp +++ b/src/Plugins/ImageProcessing/test/StandardDeviationProjectionImageFilterTest.cpp @@ -3,20 +3,22 @@ #include "ImageProcessing/Filters/StandardDeviationProjectionImageFilter.hpp" +#include "simplnx/Utilities/ImageProcessing/AxisProjectionEngine.hpp" +#include "simplnx/Utilities/ImageProcessing/ProjectionReducers.hpp" + #include +#include #include #include +#include using namespace nx::core; namespace { -// Tolerance for the Float64 standard-deviation output. This reducer computes the standard deviation with -// the one-pass algebraic identity (sumSq - sum^2/N)/(N-1) instead of ITK's two-pass sum of squared -// deviations; the two are only ALGEBRAICALLY equal, so a slightly looser tolerance than the exact-integer -// Sum/Mean is used. It is still ~4 orders of magnitude tighter than the SAMPLE-vs-POPULATION difference -// (a factor of sqrt(N/(N-1))), so the test still fails hard if the (N-1) sample convention were wrong. +// Welford's update and the closed-form oracle can differ through rounding. These tolerances remain +// much smaller than the difference between sample and population standard deviation. constexpr float64 k_StdDevAbsTol = 1.0e-4; constexpr float64 k_StdDevRelTol = 1.0e-7; @@ -43,6 +45,94 @@ float64 StdDevExpectedForSlot(uint32 projDim, usize /*slot*/, usize dim) } } // namespace +TEMPLATE_TEST_CASE("ImageProcessing::StandardDeviationProjectionImageFilter: large-offset sample deviation", "[ImageProcessing][StandardDeviationProjectionImageFilter][StdDevStability]", float64, + float32, int32) +{ + UnitTest::LoadPlugins(); + const UnitTest::PreferencesSentinel prefsSentinel(DataStorageMode::ForceInCore, 0); + const uint32 projDim = GENERATE(0u, 1u, 2u); + CAPTURE(projDim); + + // For {a, a+d, a+2d}, the mean is a+d and sample variance is (d*d + 0 + d*d)/(3-1) = d*d. + // Float32 spacing at 1e9 is 64; using increments of 1 would round all three inputs to the same value. + constexpr float64 k_Step = std::is_same_v ? 64.0 : 1.0; + const std::array values = {static_cast(1.0e9), static_cast(1.0e9 + k_Step), static_cast(1.0e9 + 2.0 * k_Step)}; + SizeVec3 dims = {1, 1, 1}; + dims[projDim] = 3; + const ShapeType cellShape = {dims[2], dims[1], dims[0]}; + DataStructure ds; + auto* imageGeom = ImageGeom::Create(ds, "Image Geometry"); + imageGeom->setDimensions(dims); + imageGeom->setSpacing({1.0f, 1.0f, 1.0f}); + imageGeom->setOrigin({0.0f, 0.0f, 0.0f}); + auto* cellAM = AttributeMatrix::Create(ds, "CellData", cellShape, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + const DataPath inputPath({"Image Geometry", "CellData", "Input"}); + auto store = DataStoreUtilities::CreateDataStore(ds, inputPath, cellShape, {1}); + auto* array = DataArray::Create(ds, "Input", store, cellAM->getId()); + REQUIRE(array != nullptr); + const auto writeResult = store->copyFromBuffer(0, nonstd::span(values.data(), values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(writeResult); + + StandardDeviationProjectionImageFilter filter; + Arguments args; + args.insertOrAssign(StandardDeviationProjectionImageFilter::k_InputImageGeomPath_Key, std::make_any(DataPath({"Image Geometry"}))); + args.insertOrAssign(StandardDeviationProjectionImageFilter::k_InputImageDataPath_Key, std::make_any(inputPath)); + args.insertOrAssign(StandardDeviationProjectionImageFilter::k_ProjectionDimension_Key, std::make_any(projDim)); + args.insertOrAssign(StandardDeviationProjectionImageFilter::k_RemoveOriginalGeometry_Key, std::make_any(false)); + args.insertOrAssign(StandardDeviationProjectionImageFilter::k_OutputImageGeomName_Key, std::make_any("Projected Image")); + args.insertOrAssign(StandardDeviationProjectionImageFilter::k_OutputImageArrayName_Key, std::make_any("Output")); + const auto preflightResult = filter.preflight(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + // ApplyAxisProjection selects by store type and has no AlgorithmTestScope force flags or path witness. + const auto executeResult = filter.execute(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const DataPath outputPath({"Projected Image", "CellData", "Output"}); + REQUIRE_NOTHROW(ds.getDataRefAs(outputPath)); + const auto& output = ds.getDataRefAs(outputPath); + REQUIRE(output.getNumberOfTuples() == 1); + REQUIRE(output[0] == Approx(k_Step).margin(1.0e-12)); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("ImageProcessing::StandardDeviationProjectionImageFilter: large-offset staged and bounded deviation", "[ImageProcessing][StandardDeviationProjectionImageFilter][StdDevStability]") +{ + UnitTest::LoadPlugins(); + namespace projection_detail = nx::core::ImageProcessing::detail; + using nx::core::ImageProcessing::StdDevReduce; + const usize projDim = GENERATE(0, 1, 2); + CAPTURE(projDim); + SizeVec3 dims = {1, 1, 1}; + dims[projDim] = 3; + DataStore input({dims[2], dims[1], dims[0]}, {1}, 0.0); + DataStore output({1, 1, 1}, {1}, 0.0); + const std::array values = {1.0e9, 1.0e9 + 1.0, 1.0e9 + 2.0}; + const auto writeResult = input.copyFromBuffer(0, nonstd::span(values.data(), values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(writeResult); + const auto shape = projection_detail::ValidateAxisProjection(input, output, dims, projDim); + REQUIRE(shape.valid()); + const std::atomic_bool shouldCancel = false; + + // Call the existing engine entry points directly: these routes cannot be forced by AlgorithmTestScope. + // In-memory buffers exercise the algorithms here, not a real disk-backed store. + SECTION("resident slab staging") + { + const auto result = projection_detail::ApplyAxisProjectionResident(input, output, shape.value(), projDim, StdDevReduce{}, shouldCancel, IFilter::MessageHandler{}); + SIMPLNX_RESULT_REQUIRE_VALID(result); + REQUIRE(output[0] == Approx(1.0).margin(1.0e-12)); // deviations {-1, 0, 1}: sqrt(2/(3-1)) = 1 + } + if(projDim < 2) + { + SECTION("bounded true-2-D rows") + { + const auto result = projection_detail::ApplyAxisProjection2D(input, output, shape.value(), projDim, StdDevReduce{}, shouldCancel, projection_detail::k_AxisProjection2DTargetBytes); + SIMPLNX_RESULT_REQUIRE_VALID(result); + REQUIRE(output[0] == Approx(1.0).margin(1.0e-12)); + } + } +} + // ----------------------------------------------------------------------------- // (1) Computed-expected correctness grid (in-core). The legacy ITK Standard Deviation Projection filter is // not registered at runtime, so correctness is gated on the independent closed form (the sample standard @@ -76,10 +166,9 @@ TEST_CASE("ImageProcessing::StandardDeviationProjectionImageFilter: Computed-exp } // ----------------------------------------------------------------------------- -// (2) Reducer edges (StdDevReduce). Two branches the ramp grids never reach: +// (2) Reducer edges (StdDevReduce). Two cases the ramp grids never reach: // - the N <= 1 guard, when a projected column has a single sample (projected axis extent 1); -// - the variance < 0 clamp, when a column is constant (catastrophic cancellation can make the algebraic -// one-pass variance a tiny negative before the clamp). +// - zero variance, when every value in a column is the same. // Both must produce a finite, exact 0 standard deviation. // ----------------------------------------------------------------------------- TEST_CASE("ImageProcessing::StandardDeviationProjectionImageFilter: single-sample column (N<=1 guard) -> 0", "[ImageProcessing][StandardDeviationProjectionImageFilter]") @@ -130,7 +219,7 @@ TEST_CASE("ImageProcessing::StandardDeviationProjectionImageFilter: single-sampl } } -TEST_CASE("ImageProcessing::StandardDeviationProjectionImageFilter: constant column (variance clamp) -> 0", "[ImageProcessing][StandardDeviationProjectionImageFilter]") +TEST_CASE("ImageProcessing::StandardDeviationProjectionImageFilter: constant column -> 0", "[ImageProcessing][StandardDeviationProjectionImageFilter]") { UnitTest::LoadPlugins(); const UnitTest::PreferencesSentinel prefsSentinel(nx::core::DataStorageMode::ForceInCore, 0); diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.cpp index 0a9fc3ba0e..5730cebdd2 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.cpp @@ -216,7 +216,13 @@ nx::core::Result<> LoadEbsdData(const nx::core::ReadH5EbsdInputValues* mInputVal ebsdReader->setSliceStart(mInputValues->startSlice); ebsdReader->setSliceEnd(mInputValues->endSlice); ebsdReader->readAllArrays(false); - ebsdReader->setArraysToRead(selectedArrayNames); + auto arraysToRead = selectedArrayNames; + if(manufacturer == ebsdlib::Ctf::Manufacturer && selectedArrayNames.find(ebsdlib::CellData::EulerAngles) != selectedArrayNames.end()) + { + // HKL Euler correction needs source phases even when they are not an output. + arraysToRead.insert(eulerNames[3]); + } + ebsdReader->setArraysToRead(arraysToRead); err = ebsdReader->loadData(dcDims[0], dcDims[1], dcDims[2], mRefFrameZDir); if(err < 0) { @@ -235,11 +241,9 @@ nx::core::Result<> LoadEbsdData(const nx::core::ReadH5EbsdInputValues* mInputVal auto* phasePtr = reinterpret_cast(ebsdReader->getPointerByName(eulerNames[3])); nx::core::DataPath phaseDataPath = cellAttributeMatrixPath.createChildPath(ebsdlib::H5Ebsd::Phases); - nx::core::Int32Array* phaseDataArrayPtr = nullptr; - if(selectedArrayNames.find(eulerNames[3]) != selectedArrayNames.end()) { - phaseDataArrayPtr = dataStructure.getDataAs(phaseDataPath); + auto* phaseDataArrayPtr = dataStructure.getDataAs(phaseDataPath); if(nx::core::Result<> ioResult = phaseDataArrayPtr->getDataStoreRef().copyFromBuffer(0, nonstd::span(phasePtr, totalPoints)); ioResult.invalid()) { return nx::core::ConvertResult(std::move(ioResult)); @@ -264,18 +268,11 @@ nx::core::Result<> LoadEbsdData(const nx::core::ReadH5EbsdInputValues* mInputVal constexpr usize k_ChunkTuples = 65536; auto eulerBuf = std::make_unique(k_ChunkTuples * 3); - // Oxford correction caches all cell phases to avoid random out-of-core reads. - // This cache is volume-sized and makes the correction path unbounded. - std::unique_ptr phaseCache; + // Reuse the reader-owned phase buffer; cache only the ensemble crystal structures. std::unique_ptr xtalCache; - bool applyHexCorrection = (manufacturer == ebsdlib::Ctf::Manufacturer && phaseDataArrayPtr != nullptr); + bool applyHexCorrection = (manufacturer == ebsdlib::Ctf::Manufacturer); if(applyHexCorrection) { - phaseCache = std::make_unique(totalPoints); - if(nx::core::Result<> ioResult = phaseDataArrayPtr->getDataStoreRef().copyIntoBuffer(0, nonstd::span(phaseCache.get(), totalPoints)); ioResult.invalid()) - { - return nx::core::ConvertResult(std::move(ioResult)); - } usize numXtal = xtalData.getSize(); xtalCache = std::make_unique(numXtal); if(nx::core::Result<> ioResult = xtalData.getDataStoreRef().copyIntoBuffer(0, nonstd::span(xtalCache.get(), numXtal)); ioResult.invalid()) @@ -294,7 +291,7 @@ nx::core::Result<> LoadEbsdData(const nx::core::ReadH5EbsdInputValues* mInputVal eulerBuf[i * 3] = euler0[srcIdx] * degToRad; eulerBuf[i * 3 + 1] = euler1[srcIdx] * degToRad; eulerBuf[i * 3 + 2] = euler2[srcIdx] * degToRad; - if(applyHexCorrection && xtalCache[phaseCache[srcIdx]] == ebsdlib::CrystalStructure::Hexagonal_High) + if(applyHexCorrection && xtalCache[phasePtr[srcIdx]] == ebsdlib::CrystalStructure::Hexagonal_High) { eulerBuf[i * 3 + 2] += (30.0F * degToRad); } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.hpp index fb5d28f2c2..bfeb7b389a 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ReadH5Ebsd.hpp @@ -52,9 +52,9 @@ struct ORIENTATIONANALYSIS_EXPORT ReadH5EbsdInputValues * apply the stored Euler and sample reference-frame transforms after import. * * Destination writes use bulk DataStore operations and support out-of-core - * stores. EbsdLib still materializes each selected source array. Oxford hexagonal - * correction also caches the complete cell-phase array, so the import is not a - * bounded-memory operation. + * stores. EbsdLib allocates complete source arrays, so memory use grows with the + * volume size. Oxford hexagonal correction uses the reader's phase buffer even + * when Phases is not selected for output. * * The current import and conversion loops do not inspect cancellation. The flag * is passed only to optional downstream rotation filters. Bulk-I/O and resize diff --git a/src/Plugins/OrientationAnalysis/test/ReadH5EbsdTest.cpp b/src/Plugins/OrientationAnalysis/test/ReadH5EbsdTest.cpp index 625b1b6f0f..8085674cdd 100644 --- a/src/Plugins/OrientationAnalysis/test/ReadH5EbsdTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ReadH5EbsdTest.cpp @@ -15,7 +15,11 @@ #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include #include +#include +#include +#include #include #include @@ -30,6 +34,57 @@ namespace { const std::string k_MaterialName("MaterialName"); +fs::path WriteHexagonalH5EbsdFixture() +{ + const fs::path outputDir(unit_test::k_BinaryTestOutputDir.view()); + const auto ctfPath = outputDir / "read_h5ebsd_hex_selection.ctf"; + { + std::ofstream file(ctfPath); + REQUIRE(file.is_open()); + file << "Channel Text File\n" + "Prj\tHex selection regression\nAuthor\tUnitTest\nJobMode\tGrid\n" + "XCells\t3\nYCells\t1\nXStep\t1\nYStep\t1\n" + "AcqE1\t0\nAcqE2\t0\nAcqE3\t0\n" + "Euler angles refer to Sample Coordinate system (CS0)!\tMag\t2000\tCoverage\t100\tDevice\t0\tKV\t20\tTiltAngle\t70\tTiltAxis\t0\n" + "Phases\t2\n" + "2.5;2.5;4.25\t90;90;120\tHexagonal\t9\tHex phase\n" + "3.625;3.625;3.625\t90;90;90\tCubic\t11\tCubic phase\n" + "Phase\tX\tY\tBands\tError\tEuler1\tEuler2\tEuler3\tMAD\tBC\tBS\n" + "1\t0\t0\t7\t0\t30\t45\t60\t0\t100\t100\n" + "2\t1\t0\t7\t0\t30\t45\t60\t0\t100\t100\n" + "0\t2\t0\t0\t0\t30\t45\t60\t0\t100\t100\n"; + file.close(); + REQUIRE(file.good()); + } + const auto h5Path = outputDir / "read_h5ebsd_hex_selection.h5ebsd"; + const hid_t fileId = H5Support::H5Utilities::createFile(h5Path.string()); + REQUIRE(fileId >= 0); + H5Support::H5ScopedFileSentinel sentinel(fileId, true); + auto importer = ebsdlib::H5CtfImporter::New(); + REQUIRE(importer->importFile(fileId, 0, ctfPath.string()) >= 0); + REQUIRE(importer->getErrorCode() == 0); + namespace H5Lite = H5Support::H5Lite; + REQUIRE(H5Lite::writeStringDataset(fileId, ebsdlib::H5Ebsd::Manufacturer, ebsdlib::Ctf::Manufacturer) >= 0); + REQUIRE(H5Lite::writeScalarDataset(fileId, ebsdlib::H5Ebsd::ZStartIndex, 0) >= 0); + REQUIRE(H5Lite::writeScalarDataset(fileId, ebsdlib::H5Ebsd::ZEndIndex, 0) >= 0); + REQUIRE(H5Lite::writeScalarDataset(fileId, ebsdlib::H5Ebsd::XPoints, 3) >= 0); + REQUIRE(H5Lite::writeScalarDataset(fileId, ebsdlib::H5Ebsd::YPoints, 1) >= 0); + for(const auto& name : {ebsdlib::H5Ebsd::XResolution, ebsdlib::H5Ebsd::YResolution, ebsdlib::H5Ebsd::ZResolution}) + { + REQUIRE(H5Lite::writeScalarDataset(fileId, name, 1.0F) >= 0); + } + REQUIRE(H5Lite::writeScalarDataset(fileId, ebsdlib::H5Ebsd::StackingOrder, 0) >= 0); + for(const auto& name : {ebsdlib::H5Ebsd::SampleTransformationAngle, ebsdlib::H5Ebsd::EulerTransformationAngle}) + { + REQUIRE(H5Lite::writeScalarDataset(fileId, name, 0.0F) >= 0); + } + for(const auto& name : {ebsdlib::H5Ebsd::SampleTransformationAxis, ebsdlib::H5Ebsd::EulerTransformationAxis}) + { + REQUIRE(H5Lite::writeVectorDataset(fileId, name, {3}, {0.0F, 0.0F, 1.0F}) >= 0); + } + return h5Path; +} + // 189 x 201 points at 0.25 spacing span [0,47.25] x [0,50.25]. // A 180-degree Y rotation negates X and Z: minima are -47.25 and -sliceCount * 0.25. constexpr usize k_XPoints = 189; @@ -233,6 +288,82 @@ void CompareImportedArrays(const DataStructure& automatic, const DataStructure& } } // namespace +TEST_CASE("OrientationAnalysis::ReadH5Ebsd: HKL hex correction is independent of phase selection", "[OrientationAnalysis][ReadH5Ebsd]") +{ + UnitTest::LoadPlugins(); + const auto inputPath = WriteHexagonalH5EbsdFixture(); + const bool convertToRadians = GENERATE(true, false); + CAPTURE(convertToRadians); + // Hand calculation: only phase 1 is hexagonal, so phi2 is 60 + 30 = 90 degrees. + // The cubic and unindexed cells keep 60 degrees. Other channels keep 30 and 45 degrees. + constexpr float64 k_Pi = 3.14159265358979323846; + const std::array expectedRadians = {k_Pi / 6, k_Pi / 4, k_Pi / 2, k_Pi / 6, k_Pi / 4, k_Pi / 3, k_Pi / 6, k_Pi / 4, k_Pi / 3}; + const std::array expectedDegrees = {30, 45, 90, 30, 45, 60, 30, 45, 60}; + const auto& expected = convertToRadians ? expectedRadians : expectedDegrees; + std::array eulersOnly = {}; + for(const auto& selection : std::vector>{{Constants::k_EulerAngles}, {Constants::k_EulerAngles, Constants::k_Phases}, {Constants::k_Phases}}) + { + CAPTURE(selection); + const bool hasEuler = selection.front() == Constants::k_EulerAngles; + const bool hasPhases = selection.back() == Constants::k_Phases; + ReadH5EbsdFileParameter::ValueType input; + input.inputFilePath = inputPath.string(); + input.startSlice = 0; + input.endSlice = 0; + input.eulerRepresentation = ebsdlib::AngleRepresentation::Degrees; + input.useRecommendedTransform = convertToRadians; + input.selectedArrayNames = selection; + Arguments args; + args.insertOrAssign(ReadH5EbsdFilter::k_ReadH5EbsdParameter_Key, std::make_any(input)); + args.insertOrAssign(ReadH5EbsdFilter::k_CreatedImageGeometryPath_Key, std::make_any(Constants::k_DataContainerPath)); + args.insertOrAssign(ReadH5EbsdFilter::k_CellAttributeMatrixName_Key, std::make_any(Constants::k_CellData)); + args.insertOrAssign(ReadH5EbsdFilter::k_CellEnsembleAttributeMatrixName_Key, std::make_any(Constants::k_EnsembleAttributeMatrix)); + ReadH5EbsdFilter filter; + DataStructure dataStructure; + const auto preflight = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); + const auto result = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + const auto eulerPath = Constants::k_CellAttributeMatrix.createChildPath(Constants::k_EulerAngles); + const auto phasePath = Constants::k_CellAttributeMatrix.createChildPath(Constants::k_Phases); + CHECK(dataStructure.containsData(eulerPath) == hasEuler); + CHECK(dataStructure.containsData(phasePath) == hasPhases); + if(hasEuler) + { + REQUIRE_NOTHROW(dataStructure.getDataRefAs(eulerPath)); + const auto& eulers = dataStructure.getDataRefAs(eulerPath); + REQUIRE(eulers.getSize() == expected.size()); + std::array values = {}; + const auto copyResult = eulers.getDataStoreRef().copyIntoBuffer(0, nonstd::span(values)); + SIMPLNX_RESULT_REQUIRE_VALID(copyResult); + for(usize i = 0; i < values.size(); ++i) + { + CAPTURE(i); + CHECK(values[i] == Approx(expected[i]).epsilon(0).margin(1.0e-6)); + } + if(hasPhases) + { + CHECK(values == eulersOnly); + } + else + { + eulersOnly = values; + } + } + if(hasPhases) + { + REQUIRE_NOTHROW(dataStructure.getDataRefAs(phasePath)); + const auto& phases = dataStructure.getDataRefAs(phasePath); + REQUIRE(phases.getSize() == 3); + std::array values = {}; + const auto copyResult = phases.getDataStoreRef().copyIntoBuffer(0, nonstd::span(values)); + SIMPLNX_RESULT_REQUIRE_VALID(copyResult); + CHECK(values == std::array{1, 2, 0}); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} + TEST_CASE("OrientationAnalysis::ReadH5Ebsd: Valid filter execution", "[OrientationAnalysis][ReadH5Ebsd]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/SimplnxCore/docs/ComputeFeaturePhasesBinaryFilter.md b/src/Plugins/SimplnxCore/docs/ComputeFeaturePhasesBinaryFilter.md index e675a2b789..46e92f324c 100644 --- a/src/Plugins/SimplnxCore/docs/ComputeFeaturePhasesBinaryFilter.md +++ b/src/Plugins/SimplnxCore/docs/ComputeFeaturePhasesBinaryFilter.md @@ -6,17 +6,36 @@ Generic (Misc) ## Description -This **Filter** assigns a binary **Ensemble** Id number to each **Cell** based on a boolean (true/false) mask. A **Cell** is a single voxel of an **Image Geometry**, and an **Ensemble** is a group of **Cells** that share a common phase or classification. The input is a single-component boolean mask array; the output is a single-component **Ensemble** array in which every *true* **Cell** is assigned **Ensemble** 1 and every *false* **Cell** is assigned **Ensemble** 0. +This **Filter** assigns phase 0 or 1 to each **Feature**. A Feature is a group of Cells with the same Feature Id. An **Ensemble** is a group of Features with the same phase. The output is a single-component int32 array in the selected *Cell Feature Attribute Matrix*. Its tuple count and shape match that matrix. Phase labels have no units. -This **Filter** is generally useful any time data has already been reduced to a two-class (binary) distinction and you need an integer **Ensemble** label for downstream processing — for example separating "selected" from "unselected", "inside" from "outside", or any pass/fail criterion. A common materials-science example is an image segmented into precipitates and non-precipitates: the precipitates (mask *true*) are assigned to **Ensemble** 1 and the non-precipitates (mask *false*) to **Ensemble** 0. +The mask must have one component and use boolean or uint8 values. Zero means phase 0. Any nonzero value means phase 1. The location of the selected mask determines the mode. + +### Cell Mask + +If the mask's parent Attribute Matrix is different from the selected Feature Attribute Matrix, the filter uses Cell-mask mode. The mask and *Cell Feature Ids* must have the same tuple count. + +Cells are processed in array order. Each Cell sets the phase of its Feature. If Cells in a Feature have different mask values, the last Cell decides the phase. Features with no Cells keep phase 0. Feature Id 0 is processed in the same way as other Feature Ids. Each Feature Id must be at least 0 and less than the Feature Attribute Matrix tuple count. + +### Feature Mask + +If the mask's parent Attribute Matrix is the selected Feature Attribute Matrix, the filter uses Feature-mask mode. Each mask value sets the phase of the corresponding Feature. This includes Feature 0. + +*Cell Feature Ids* values are not read in this mode. The parameter still requires a valid single-component int32 array selection, but its tuple count and values do not affect the result. Alternative paths to the same Feature Attribute Matrix select the same mode. + +### Cell Ensemble Attribute Matrix + +*Create Cell Ensemble Attribute Matrix* is off by default. When enabled, the filter creates an empty Attribute Matrix with 2 tuples, one for each phase. It is a sibling of the selected Feature Attribute Matrix, under the same parent. The default name is *Cell Ensemble Data*. Choose a name that does not already exist at that location. ### Required Input Sources -- **Mask** -- the per-**Cell** boolean mask array, typically produced by [Multi-Threshold Objects](MultiThresholdObjectsFilter.md). +- **Mask** -- a boolean or uint8 array, for example from [Multi-Threshold Objects](MultiThresholdObjectsFilter.md). +- **Cell Feature Ids** -- the Feature labels from segmentation, used only in Cell-mask mode. -% Auto generated parameter table will be inserted here +## Algorithm -**Cell Feature Ids** must have 1 component. +Both modes use bulk reads in chunks of at most 1,000,000 tuples. Cell-mask mode also keeps a Feature-sized phase cache and writes it after processing the Cells. Feature-mask mode converts and writes each chunk directly. Neither mode needs a full Cell-sized temporary array. + +% Auto generated parameter table will be inserted here ## License & Copyright diff --git a/src/Plugins/SimplnxCore/docs/FeatureFaceCurvatureFilter.md b/src/Plugins/SimplnxCore/docs/FeatureFaceCurvatureFilter.md index b74f8388b6..a8d7f03b19 100644 --- a/src/Plugins/SimplnxCore/docs/FeatureFaceCurvatureFilter.md +++ b/src/Plugins/SimplnxCore/docs/FeatureFaceCurvatureFilter.md @@ -16,6 +16,8 @@ The principal directions can have their signs flipped: they come from eigenvecto *Note*: Computing the Weingarten matrix values is an experimental feature, and there is no guarantee at this time that the values are correct. +The fitting neighborhood contains the triangle itself and its neighbors within the **Neighborhood Ring Count**. Only neighbors with the same pair of **Face Labels** are included. If this neighborhood initially contains fewer than two triangles, the filter returns an error. Otherwise, triangles with a NaN component in their face normal or face centroid are excluded from the fit. If the evaluated triangle is excluded, all of its requested outputs are NaN. The outputs are also NaN if this exclusion leaves fewer than two triangles. The evaluated triangle counts toward this minimum. + ### Units Principal curvatures and mean curvature are reported in **1/length** (the reciprocal of the geometry length units; the radius of the best-fit circle is its reciprocal). Gaussian curvature, being a product of two curvatures, is reported in **1/length^2**. Principal directions are **dimensionless** unit 3-vectors. diff --git a/src/Plugins/SimplnxCore/docs/ScalarSegmentFeaturesFilter.md b/src/Plugins/SimplnxCore/docs/ScalarSegmentFeaturesFilter.md index afc57f56b1..5363cc1949 100644 --- a/src/Plugins/SimplnxCore/docs/ScalarSegmentFeaturesFilter.md +++ b/src/Plugins/SimplnxCore/docs/ScalarSegmentFeaturesFilter.md @@ -49,7 +49,7 @@ Both paths produce identical segmentation results. After segmentation, the Featu When operating on out-of-core data, the CCL path uses a rolling 2-slot buffer system. Before processing each Z-slice, the algorithm bulk-reads the scalar input and mask arrays for that slice into contiguous in-memory buffers. All voxel comparisons then read from these buffers rather than the underlying disk-backed DataStore, eliminating chunk load/evict cycles. Two buffer slots are maintained simultaneously (current and previous slice) because the CCL algorithm must compare voxels across adjacent slices. -All scalar types are converted to `float64` in the buffer for uniform comparison, so a single comparison code path handles all input data types. +The buffer preserves `int64` and `uint64` values exactly. Their absolute differences use unsigned arithmetic to avoid overflow. At tolerance 0, distinct integers remain separate, including values above 2^53. Other scalar types use a `float64` buffer. ### Neighbor Scheme diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp index 1d41281a2b..d59ef01600 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ArrayCalculator.cpp @@ -547,6 +547,13 @@ Result simulateShapes(const DataStructure& dataStructure, const stack.pop_back(); const RpnNodeShape left = stack.back(); stack.pop_back(); + if(!left.isScalar && !right.isScalar && left.numComponents != right.numComponents) + { + return MakeErrorResult( + static_cast(CalculatorErrorCode::InconsistentCompDims), + fmt::format("Operator '{}' has array operands with mismatching component counts (left: {}, right: {}). Use arrays with the same number of components or a scalar operand.", op->token, + left.numComponents, right.numComponents)); + } const bool resultScalar = left.isScalar && right.isScalar; const usize numComps = left.isScalar ? right.numComponents : left.numComponents; stack.push_back({resultScalar, resultScalar ? 1 : numComps}); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/CalculateTriangleGroupCurvatures.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/CalculateTriangleGroupCurvatures.cpp index a2e58fad51..62b52bd6b6 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/CalculateTriangleGroupCurvatures.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/CalculateTriangleGroupCurvatures.cpp @@ -43,6 +43,8 @@ #include +#include + namespace nx::core { namespace @@ -65,10 +67,6 @@ std::shared_ptr extractPatchData(int64_t triId, FindNRingNeigh if(std::isnan(data[t * 3]) || std::isnan(data[t * 3 + 1]) || std::isnan(data[t * 3 + 2])) { iter = triPatch.erase(iter); - if(*iter == triId) - { - triId = *(triPatch.begin()); - } } else { @@ -76,18 +74,13 @@ std::shared_ptr extractPatchData(int64_t triId, FindNRingNeigh } } - if(triPatch.empty()) + // Keep the requested seed and require a neighbor to construct the local frame. + if(triPatch.size() < 2 || triPatch.count(triId) == 0) { return nullptr; } - size_t totalTuples = triPatch.size(); - if(triPatch.count(triId) == 0) - { - totalTuples++; - } - - ShapeType tupleShape = {totalTuples}; + ShapeType tupleShape = {triPatch.size()}; std::optional initValue = {}; std::shared_ptr extractedData = std::make_shared(tupleShape, cDims, initValue); // This little chunk makes sure the current seed triangles centroid and normal data appear @@ -217,11 +210,25 @@ void CalculateTriangleGroupCurvatures::operator()() const throw std::runtime_error("NRingNeighbor failed to find more than one triangles."); } + const auto markInvalid = [this, triId]() { + for(auto* output : {m_PrincipleCurvature1, m_PrincipleCurvature2, m_PrincipleDirection1, m_PrincipleDirection2, m_GaussianCurvature, m_MeanCurvature, m_WeingartenMatrix}) + { + if(output != nullptr) + { + for(usize component = 0; component < output->getNumberOfComponents(); ++component) + { + output->setComponent(triId, component, std::numeric_limits::quiet_NaN()); + } + } + } + }; + size_t beforeSize = triPatch.size(); std::shared_ptr patchNormals = extractPatchData(triId, triPatch, m_SurfaceMeshFaceNormals->getDataStoreRef()); - // if every triangle got removed because of NaN values in the Normals, then bail from this iteration of the loop + // A removed seed or fewer than two valid triangles cannot define a curvature. if(nullptr == patchNormals) { + markInvalid(); continue; } @@ -233,9 +240,10 @@ void CalculateTriangleGroupCurvatures::operator()() const beforeSize = triPatch.size(); std::shared_ptr patchCentroids = extractPatchData(triId, triPatch, m_SurfaceMeshTriangleCentroids->getDataStoreRef()); - // if every triangle got removed because of NaN values in the Normals, then bail from this iteration of the loop + // A removed seed or fewer than two valid triangles cannot define a curvature. if(nullptr == patchCentroids) { + markInvalid(); continue; } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.cpp index 53d34f3f02..cc9a267533 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.cpp @@ -117,6 +117,45 @@ struct ComputeFeaturePhasesBinaryFunctor return featurePhasesStore.copyFromBuffer(0, nonstd::span(featurePhasesCache.data(), featurePhasesCache.size())); } }; +/** + * @brief Converts a Feature mask directly with bounded bulk buffers; Feature IDs are not accessed. + */ +struct ConvertFeatureMaskFunctor +{ + template + Result<> operator()(const IDataArray& maskArray, IDataArray& featurePhasesArray, const std::atomic_bool& shouldCancel) const + { + const auto& maskStore = maskArray.getIDataStoreRefAs>(); + auto& outputStore = featurePhasesArray.getIDataStoreRefAs>(); + const usize numFeatures = maskStore.getNumberOfTuples(); + const usize chunkTuples = std::min(k_TargetChunkTuples, numFeatures); + auto maskBuffer = std::make_unique(chunkTuples); + auto outputBuffer = std::make_unique(chunkTuples); + for(usize offset = 0; offset < numFeatures; offset += chunkTuples) + { + if(shouldCancel) + { + return {}; + } + const usize count = std::min(chunkTuples, numFeatures - offset); + auto result = maskStore.copyIntoBuffer(offset, nonstd::span(maskBuffer.get(), count)); + if(result.invalid()) + { + return result; + } + for(usize index = 0; index < count; index++) + { + outputBuffer[index] = maskBuffer[index] != static_cast(0) ? 1 : 0; + } + result = outputStore.copyFromBuffer(offset, nonstd::span(outputBuffer.get(), count)); + if(result.invalid()) + { + return result; + } + } + return {}; + } +}; } // namespace ComputeFeaturePhasesBinary::ComputeFeaturePhasesBinary(DataStructure& dataStructure, const IFilter::MessageHandler& mesgHandler, const std::atomic_bool& shouldCancel, @@ -132,9 +171,8 @@ ComputeFeaturePhasesBinary::~ComputeFeaturePhasesBinary() noexcept = default; Result<> ComputeFeaturePhasesBinary::operator()() { - const auto& featureIdsArray = m_DataStructure.getDataRefAs(m_InputValues->FeatureIdsArrayPath); const auto& maskArray = m_DataStructure.getDataRefAs(m_InputValues->MaskArrayPath); - const DataPath featurePhasesPath = m_InputValues->CellDataAttributeMatrixPath.createChildPath(m_InputValues->FeaturePhasesArrayName); + const DataPath featurePhasesPath = m_InputValues->CellFeatureAttributeMatrixPath.createChildPath(m_InputValues->FeaturePhasesArrayName); auto& featurePhasesArray = m_DataStructure.getDataRefAs(featurePhasesPath); if(maskArray.getDataType() != DataType::boolean && maskArray.getDataType() != DataType::uint8) @@ -148,6 +186,12 @@ Result<> ComputeFeaturePhasesBinary::operator()() return {}; } + if(m_DataStructure.getData(m_InputValues->MaskArrayPath.getParent()) == m_DataStructure.getData(m_InputValues->CellFeatureAttributeMatrixPath)) + { + return ExecuteDataFunction(ConvertFeatureMaskFunctor{}, maskArray.getDataType(), maskArray, featurePhasesArray, m_ShouldCancel); + } + + const auto& featureIdsArray = m_DataStructure.getDataRefAs(m_InputValues->FeatureIdsArrayPath); return ExecuteDataFunction(ComputeFeaturePhasesBinaryFunctor{}, maskArray.getDataType(), featureIdsArray, maskArray, featurePhasesArray, m_InputValues->FeatureIdsArrayPath, featurePhasesPath, m_ShouldCancel); } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.hpp index e52c66c1a7..a6dcb354bc 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ComputeFeaturePhasesBinary.hpp @@ -23,7 +23,7 @@ namespace nx::core */ struct SIMPLNXCORE_EXPORT ComputeFeaturePhasesBinaryInputValues { - AttributeMatrixSelectionParameter::ValueType CellDataAttributeMatrixPath; + AttributeMatrixSelectionParameter::ValueType CellFeatureAttributeMatrixPath; ArraySelectionParameter::ValueType FeatureIdsArrayPath; DataObjectNameParameter::ValueType FeaturePhasesArrayName; ArraySelectionParameter::ValueType MaskArrayPath; @@ -31,11 +31,12 @@ struct SIMPLNXCORE_EXPORT ComputeFeaturePhasesBinaryInputValues /** * @class ComputeFeaturePhasesBinary - * @brief Assigns each referenced feature a binary phase from its cells' mask values. + * @brief Assigns binary Feature phases from a Cell or Feature mask. * * Cell Feature IDs and mask values use 1,000,000-tuple bulk buffers. Serial cell order preserves * last-cell-wins mask semantics. The feature cache grows only to the largest referenced Feature ID - * and retains existing values for unreferenced output features. + * and leaves unreferenced output Features at zero. A mask in the selected Feature Attribute Matrix + * is converted directly in bounded chunks without reading Cell Feature IDs. */ class SIMPLNXCORE_EXPORT ComputeFeaturePhasesBinary { @@ -64,8 +65,8 @@ class SIMPLNXCORE_EXPORT ComputeFeaturePhasesBinary * @brief Assigns binary phases from mask values. * @return Success, or a mask-type, Feature ID, or bulk-I/O error. * - * When a chunk checkpoint observes cancellation, the method returns success before the output - * bulk write. Cancellation during that write is not checked. + * Cancellation is checked between chunks. Cell-mask mode returns before its final output write; + * Feature-mask mode can leave earlier output chunks written. Bulk I/O is not interrupted. */ Result<> operator()(); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadVtkStructuredPoints.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadVtkStructuredPoints.cpp index 26e1138045..3169d9244c 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadVtkStructuredPoints.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ReadVtkStructuredPoints.cpp @@ -187,7 +187,7 @@ Result<> SkipBinaryData(std::istream& in, usize numElements, const DataPath& dat } /** - * @brief Skips one ASCII or binary array during preflight. + * @brief Skips one ASCII or binary array without storing its values. * @tparam T Specifies the declared scalar type. * @param in Provides the VTK stream at the first value. * @param binary Selects binary or ASCII parsing. @@ -428,7 +428,7 @@ char* LowerCase(char* str, const usize len) } /** - * @brief Dispatches a preflight data-block skip from an NX DataType. + * @brief Dispatches a data-block skip from an NX DataType. * @param nxDType Specifies the converted VTK scalar type. * @param in Provides the VTK stream at the first value. * @param binary Selects binary or ASCII parsing. @@ -865,9 +865,10 @@ Result<> ReadVtkStructuredPoints::readFile() return {}; } - if(sectionType == k_CellDataKeyword && m_InputValues->ReadCellData) + // Traverse an unselected first section when the other section is requested. + if(sectionType == k_CellDataKeyword && (m_InputValues->ReadCellData || (i == 0 && m_InputValues->ReadPointData))) { - if(cellDims[0] * cellDims[1] * cellDims[2] != numValues) + if(m_InputValues->ReadCellData && cellDims[0] * cellDims[1] * cellDims[2] != numValues) { return MakeErrorResult(to_underlying(ErrorCodes::MismatchedCellsAndTuplesErr), fmt::format("Number of cells ({}) does not match number of tuples ({}) in the Attribute Matrix.", cellDims[0] * cellDims[1] * cellDims[2], numValues)); @@ -882,9 +883,9 @@ Result<> ReadVtkStructuredPoints::readFile() numValues = dtResult.value(); sectionType = numValues > 0 ? k_PointDataKeyword : ""; } - else if(sectionType == k_PointDataKeyword && m_InputValues->ReadPointData) + else if(sectionType == k_PointDataKeyword && (m_InputValues->ReadPointData || (i == 0 && m_InputValues->ReadCellData))) { - if(pointDims[0] * pointDims[1] * pointDims[2] != numValues) + if(m_InputValues->ReadPointData && pointDims[0] * pointDims[1] * pointDims[2] != numValues) { return MakeErrorResult(to_underlying(ErrorCodes::MismatchedPointsAndTuplesErr), fmt::format("Number of points ({}) does not match number of tuples ({}) in the Attribute Matrix.", pointDims[0] * pointDims[1] * pointDims[2], numValues)); @@ -1159,7 +1160,8 @@ Result<> ReadVtkStructuredPoints::readDataArray(std::istream& in, int32 numPts, arrayDataPath = m_InputValues->ImageGeomPath.createChildPath(m_InputValues->CellAttributeMatrixName).createChildPath(name); } - if(m_Preflight) + const bool skipArray = m_CurrentSectionType == CurrentSectionType::Cell ? !m_InputValues->ReadCellData : !m_InputValues->ReadPointData; + if(m_Preflight || skipArray) { Result nxDTypeResult = ConvertVtkDataType(scalarType); if(nxDTypeResult.invalid()) @@ -1168,9 +1170,12 @@ Result<> ReadVtkStructuredPoints::readDataArray(std::istream& in, int32 numPts, } const nx::core::DataType nxDType = nxDTypeResult.value(); - ShapeType tupleShape = {m_CurrentGeomDims[2], m_CurrentGeomDims[1], m_CurrentGeomDims[0]}; - auto createArrayAction = std::make_unique(nxDType, tupleShape, std::vector{numComp}, arrayDataPath); - m_OutputActions.value().appendAction(std::move(createArrayAction)); + if(!skipArray) + { + ShapeType tupleShape = {m_CurrentGeomDims[2], m_CurrentGeomDims[1], m_CurrentGeomDims[0]}; + auto createArrayAction = std::make_unique(nxDType, tupleShape, std::vector{numComp}, arrayDataPath); + m_OutputActions.value().appendAction(std::move(createArrayAction)); + } return preflightSkipVolume(nxDType, in, m_FileIsBinary, static_cast(numPts) * numComp, arrayDataPath, m_ShouldCancel); } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp index 8d8848440d..3af5536f19 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "simplnx/DataStructure/DataStore.hpp" #include "simplnx/DataStructure/Geometry/IGridGeometry.hpp" @@ -73,9 +74,9 @@ class TSpecificCompareFunctorBool : public SegmentFeatures::CompareFunctor * @brief Compares resident scalar values in their native type. * @tparam T Specifies the scalar value type. * - * The buffered CCL normally compares preloaded float64 slice values; this - * fallback preserves native typed behavior outside that window. Signed - * subtraction requires a representable absolute difference. + * The buffered CCL compares preloaded slice values; this fallback preserves + * native typed behavior outside that window. The int64 difference uses unsigned + * arithmetic. Other signed differences must be representable in their native type. */ template class TSpecificCompareFunctor : public SegmentFeatures::CompareFunctor @@ -108,7 +109,7 @@ class TSpecificCompareFunctor : public SegmentFeatures::CompareFunctor * @param neighIndex Neighbor flat value index. * @return True when both indexes are in range and their difference is within tolerance. * @pre index and neighIndex are nonnegative. - * @pre Signed differences are representable in T. + * @pre Signed differences for types other than int64 are representable in T. */ bool compare(int64 index, int64 neighIndex) override { @@ -117,6 +118,16 @@ class TSpecificCompareFunctor : public SegmentFeatures::CompareFunctor return false; } + if constexpr(std::is_same_v) + { + const int64 value = m_Data[index]; + const int64 neighborValue = m_Data[neighIndex]; + // Order the signed values before unsigned subtraction. Modular arithmetic + // then gives the exact difference even when the values span zero. + const uint64 difference = value >= neighborValue ? static_cast(value) - static_cast(neighborValue) : static_cast(neighborValue) - static_cast(value); + return m_Tolerance >= 0 && difference <= static_cast(m_Tolerance); + } + if(m_Data[index] >= m_Data[neighIndex]) { return (m_Data[index] - m_Data[neighIndex]) <= m_Tolerance; @@ -132,7 +143,7 @@ class TSpecificCompareFunctor : public SegmentFeatures::CompareFunctor /** * @struct FillScalarSliceBufferFunctor - * @brief Bulk-reads one typed scalar slice and widens it to float64. + * @brief Bulk-reads one scalar slice and preserves 64-bit integer precision. */ struct FillScalarSliceBufferFunctor { @@ -142,14 +153,15 @@ struct FillScalarSliceBufferFunctor * @param dataArray Supplies scalar values. * @param baseIndex First flat source value. * @param sliceSize Number of values to read. - * @param buffer Receives widened values. + * @param buffer Receives values that fit float64. + * @param integerBuffer Receives exact, ordered 64-bit integer values. * @param bufferOffset First destination-buffer index. * @return Source bulk-read result. * @pre baseIndex is nonnegative. * @pre Source and destination ranges contain sliceSize values. */ template - Result<> operator()(IDataArray* dataArray, int64 baseIndex, usize sliceSize, std::vector& buffer, usize bufferOffset) + Result<> operator()(IDataArray* dataArray, int64 baseIndex, usize sliceSize, std::vector& buffer, std::vector& integerBuffer, usize bufferOffset) { auto& store = dataArray->template getIDataStoreRefAs>(); // A raw buffer provides contiguous storage for every dispatched type. @@ -161,7 +173,20 @@ struct FillScalarSliceBufferFunctor } for(usize i = 0; i < sliceSize; i++) { - buffer[bufferOffset + i] = static_cast(tempBuffer[i]); + if constexpr(std::is_same_v) + { + // Map [INT64_MIN, INT64_MAX] to [0, UINT64_MAX] without signed arithmetic. + // This constant offset preserves ordering and absolute differences. + integerBuffer[bufferOffset + i] = static_cast(tempBuffer[i]) ^ (uint64{1} << 63); + } + else if constexpr(std::is_same_v) + { + integerBuffer[bufferOffset + i] = tempBuffer[i]; + } + else + { + buffer[bufferOffset + i] = static_cast(tempBuffer[i]); + } } return {}; } @@ -355,7 +380,15 @@ bool ScalarSegmentFeatures::areNeighborsSimilar(int64 point1, int64 point2) cons return false; } - // Widened comparison avoids type dispatch in the CCL neighbor loop. + if(!m_IntegerScalarBuffer.empty()) + { + const uint64 val1 = m_IntegerScalarBuffer[off1]; + const uint64 val2 = m_IntegerScalarBuffer[off2]; + const uint64 diff = val1 >= val2 ? val1 - val2 : val2 - val1; + return m_InputValues->ScalarTolerance >= 0 && diff <= static_cast(m_InputValues->ScalarTolerance); + } + + // Other scalar types use the float64 buffer. float64 val1 = m_ScalarBuffer[off1]; float64 val2 = m_ScalarBuffer[off2]; float64 diff = val1 >= val2 ? (val1 - val2) : (val2 - val1); @@ -376,7 +409,15 @@ void ScalarSegmentFeatures::allocateSliceBuffers(int64 dimX, int64 dimY) { m_BufSliceSize = dimX * dimY; const usize sliceSize = static_cast(m_BufSliceSize); - m_ScalarBuffer.resize(2 * sliceSize); + const DataType dataType = m_InputDataArray->getDataType(); + if(dataType == DataType::int64 || dataType == DataType::uint64) + { + m_IntegerScalarBuffer.resize(2 * sliceSize); + } + else + { + m_ScalarBuffer.resize(2 * sliceSize); + } m_MaskBuffer.resize(2 * sliceSize); m_BufferedSliceZ[0] = -1; m_BufferedSliceZ[1] = -1; @@ -389,6 +430,7 @@ void ScalarSegmentFeatures::deallocateSliceBuffers() { m_UseSliceBuffers = false; m_ScalarBuffer = std::vector(); + m_IntegerScalarBuffer = std::vector(); m_MaskBuffer = std::vector(); m_BufferedSliceZ[0] = -1; m_BufferedSliceZ[1] = -1; @@ -431,7 +473,7 @@ Result<> ScalarSegmentFeatures::prepareForSlice(int64 iz, int64 dimX, int64 dimY const usize slotOffset = static_cast(slot) * sliceSize; const int64 baseIndex = iz * m_BufSliceSize; - // Widen one scalar slice into the selected LRU slot. + // Load one scalar slice into the selected LRU slot without losing integer precision. DataType dataType = m_InputDataArray->getDataType(); if(dataType == DataType::boolean) { @@ -450,7 +492,7 @@ Result<> ScalarSegmentFeatures::prepareForSlice(int64 iz, int64 dimX, int64 dimY } else { - auto readResult = ExecuteDataFunctionNoBool(FillScalarSliceBufferFunctor{}, dataType, m_InputDataArray, baseIndex, sliceSize, m_ScalarBuffer, slotOffset); + auto readResult = ExecuteDataFunctionNoBool(FillScalarSliceBufferFunctor{}, dataType, m_InputDataArray, baseIndex, sliceSize, m_ScalarBuffer, m_IntegerScalarBuffer, slotOffset); if(readResult.invalid()) { return readResult; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.hpp index b8aa7c411a..f37df7d71a 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.hpp @@ -47,9 +47,8 @@ struct SIMPLNXCORE_EXPORT ScalarSegmentFeaturesInputValues * final IDs. Cancellation or an error can therefore leave provisional or partly * resolved output. Feature data is resized only after CCL completes. * - * Two LRU slots retain scalar and mask Z slices. Scalar values widen to float64 - * so one comparison path serves all input types. Distinct int64 or uint64 values - * above exact float64 precision can compare as equal. Buffered Boolean values use + * Two LRU slots retain scalar and mask Z slices. The int64 and uint64 inputs use + * exact unsigned differences. Other scalar types widen to float64. Buffered Boolean values use * numeric difference, so a tolerance of one or more joins false and true. The * direct fallback instead compares Boolean values for exact equality. A * negative tolerance prevents every finite buffered neighbor match. @@ -126,8 +125,9 @@ class SIMPLNXCORE_EXPORT ScalarSegmentFeatures : public SegmentFeatures * @param point2 Flat index of its neighbor. * @return True when point2 is valid and the scalar difference is within tolerance. * - * Active buffering compares widened float64 values and rejects unloaded - * slices. Inactive buffering uses the native typed comparator. Signed native + * Active buffering preserves 64-bit integer precision and rejects unloaded + * slices. Other scalar types use float64. Inactive buffering uses the native + * typed comparator with unsigned differences for int64. Other signed native * subtraction requires a representable difference. */ bool areNeighborsSimilar(int64 point1, int64 point2) const override; @@ -168,6 +168,7 @@ class SIMPLNXCORE_EXPORT ScalarSegmentFeatures : public SegmentFeatures IDataArray* m_InputDataArray = nullptr; std::vector m_ScalarBuffer; + std::vector m_IntegerScalarBuffer; std::vector m_MaskBuffer; int64 m_BufSliceSize = 0; std::array m_BufferedSliceZ = {-1, -1}; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TriangleDihedralAngle.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TriangleDihedralAngle.cpp index 6ef0c99df9..146b23c544 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TriangleDihedralAngle.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TriangleDihedralAngle.cpp @@ -63,11 +63,11 @@ class CalculateDihedralAnglesImpl else { std::vector dihedralAnglesVec; - dihedralAnglesVec.push_back(k_radToDeg * acos((std::fabs(vecAB.dot(vecAC)) / (magAB * magAC)))); + dihedralAnglesVec.push_back(k_radToDeg * acos(std::clamp(vecAB.dot(vecAC) / (magAB * magAC), -1.0, 1.0))); - // 180 - angle because AB points out of vertex and BC points into vertex, so angle is actually angle outside of triangle - dihedralAnglesVec.push_back(180.0 - (k_radToDeg * acos((std::fabs(vecAB.dot(vecBC)) / (magAB * magBC))))); - dihedralAnglesVec.push_back(k_radToDeg * acos((std::fabs(vecBC.dot(vecAC)) / (magBC * magAC)))); + // vecAB points out of B and vecBC points into B, so subtract their angle from 180 to get the interior angle at B. + dihedralAnglesVec.push_back(180.0 - (k_radToDeg * acos(std::clamp(vecAB.dot(vecBC) / (magAB * magBC), -1.0, 1.0)))); + dihedralAnglesVec.push_back(k_radToDeg * acos(std::clamp(vecBC.dot(vecAC) / (magBC * magAC), -1.0, 1.0))); m_DihedralAngles[triangleIndex] = *std::min_element(dihedralAnglesVec.begin(), dihedralAnglesVec.end()); } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TupleTransfer.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TupleTransfer.hpp index 7cfc073a7e..23fe4ded55 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TupleTransfer.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/TupleTransfer.hpp @@ -592,10 +592,10 @@ class SIMPLNXCORE_EXPORT AbstractTupleTransfer /** * @brief Copies one tuple for point-sampled triangle output. - * @param faceIndex First destination value index. - * @param firstcIndex First source value index or source cell index, by subclass. + * @param triangleIndex Source triangle tuple index. + * @param vertexIndex Destination vertex tuple index. */ - virtual void pointSampleTransfer(size_t faceIndex, size_t firstcIndex) = 0; + virtual void pointSampleTransfer(size_t triangleIndex, size_t vertexIndex) = 0; /** * @brief Copies adjacent tuples to one QuickSurfaceMesh face by value access. @@ -694,16 +694,16 @@ class TransferTuple : public AbstractTupleTransfer TransferTuple& operator=(TransferTuple&&) noexcept = delete; /** - * @brief Copies one source value range to a destination value range. - * @param faceIndex First destination value index. - * @param firstcIndex First source value index. - * @pre Both value ranges contain m_NumComps values. + * @brief Copies a complete triangle tuple to a sampled vertex tuple. + * @param triangleIndex Source triangle tuple index. + * @param vertexIndex Destination vertex tuple index. + * @pre Both tuple indexes are valid. Source and destination component counts match. */ - void pointSampleTransfer(size_t faceIndex, size_t firstcIndex) override + void pointSampleTransfer(size_t triangleIndex, size_t vertexIndex) override { for(size_t i = 0; i < m_NumComps; i++) { - m_FaceRef[faceIndex + i] = m_CellRef[firstcIndex + i]; + m_FaceRef[vertexIndex * m_NumComps + i] = m_CellRef[triangleIndex * m_NumComps + i]; } } @@ -852,21 +852,18 @@ class TransferFeatureTuple : public AbstractTupleTransfer TransferFeatureTuple& operator=(TransferFeatureTuple&&) noexcept = delete; /** - * @brief Copies feature values through the current flat-offset point-sample rule. - * @param faceIndex First destination value index. - * @param firstcIndex Source cell index. - * @pre The resolved Feature ID and destination range are valid. - * - * This method uses Feature ID as the first flat feature-data value. It does not - * multiply by m_NumComps. Multi-component input therefore does not select the - * complete tuple at that Feature ID. + * @brief Copies a complete feature tuple to a sampled vertex tuple. + * @param triangleIndex Source triangle index in the Feature ID array. + * @param vertexIndex Destination vertex tuple index. + * @pre The triangle index, resolved Feature ID, and destination tuple index are valid. + * @pre Source and destination component counts match. */ - void pointSampleTransfer(size_t faceIndex, size_t firstcIndex) override + void pointSampleTransfer(size_t triangleIndex, size_t vertexIndex) override { - K firstFeatureId = m_FeatureIdsRef[firstcIndex]; + K firstFeatureId = m_FeatureIdsRef[triangleIndex]; for(size_t i = 0; i < m_NumComps; i++) { - m_FaceRef[faceIndex + i] = m_FeatureDataRef[firstFeatureId + i]; + m_FaceRef[vertexIndex * m_NumComps + i] = m_FeatureDataRef[firstFeatureId * m_NumComps + i]; } } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteLAMMPSFile.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteLAMMPSFile.cpp index 544913e094..d1455d0606 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteLAMMPSFile.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/WriteLAMMPSFile.cpp @@ -5,6 +5,7 @@ #include "simplnx/Utilities/FilterUtilities.hpp" #include "simplnx/Utilities/ThrottledMessageHandler.hpp" +#include #include namespace fs = std::filesystem; @@ -130,7 +131,7 @@ Result<> WriteLAMMPSFile::operator()() ThrottledMessageHandler progressThrottle(m_MessageHandler); // Write the Atom positions (Vertices) usize numVerts = verts.getNumberOfTuples(); - usize increment = numVerts / 1000; + usize increment = std::max(numVerts / 1000, 1); for(usize i = 0; i < numVerts; i++) { if(m_ShouldCancel) diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.cpp index 91a3d089d7..d2baa5bfd4 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.cpp @@ -6,9 +6,10 @@ #include "simplnx/DataStructure/DataArray.hpp" #include "simplnx/DataStructure/DataPath.hpp" #include "simplnx/Filter/Actions/CreateArrayAction.hpp" +#include "simplnx/Filter/Actions/CreateAttributeMatrixAction.hpp" #include "simplnx/Parameters/ArraySelectionParameter.hpp" #include "simplnx/Parameters/AttributeMatrixSelectionParameter.hpp" -#include "simplnx/Parameters/DataGroupCreationParameter.hpp" +#include "simplnx/Parameters/BoolParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Utilities/SIMPLConversion.hpp" @@ -54,26 +55,32 @@ Parameters ComputeFeaturePhasesBinaryFilter::parameters() const // Create the parameter descriptors that are needed for this filter params.insertSeparator(Parameters::Separator{"Input Cell Data"}); - params.insert(std::make_unique(k_FeatureIdsArrayPath_Key, "Cell Feature Ids", "Data Array that specifies to which Feature each Element belongs", DataPath{}, - ArraySelectionParameter::AllowedTypes{DataType::int32}, ArraySelectionParameter::AllowedComponentShapes{{1}})); + params.insert(std::make_unique(k_FeatureIdsArrayPath_Key, "Cell Feature Ids", "Cell Feature Ids used only with a Cell mask. Values are not read with a Feature mask.", + DataPath{}, ArraySelectionParameter::AllowedTypes{DataType::int32}, ArraySelectionParameter::AllowedComponentShapes{{1}})); - params.insertSeparator(Parameters::Separator{"Optional Data Mask"}); - params.insert(std::make_unique(k_MaskArrayPath_Key, "Mask Array", "Data Array that specifies if the Cell is to be counted in the algorithm", DataPath{}, - ArraySelectionParameter::AllowedTypes{DataType::boolean, DataType::uint8}, ArraySelectionParameter::AllowedComponentShapes{{1}})); - params.insert(std::make_unique(k_CellDataAMPath_Key, "Cell Data Attribute Matrix", - "The Cell Data Attribute Matrix within the Image Geometry where the Binary Phases Array will be created", DataPath{})); + params.insertSeparator(Parameters::Separator{"Input Mask and Feature Data"}); + params.insert(std::make_unique(k_MaskArrayPath_Key, "Mask Array", "Boolean or uint8 Cell or Feature mask. Zero assigns phase 0; any nonzero value assigns phase 1.", + DataPath{}, ArraySelectionParameter::AllowedTypes{DataType::boolean, DataType::uint8}, ArraySelectionParameter::AllowedComponentShapes{{1}})); + params.insert(std::make_unique(k_CellFeatureAMPath_Key, "Cell Feature Attribute Matrix", + "The Feature Attribute Matrix where the binary phases array will be created. A mask in this matrix selects Feature-mask mode.", + DataPath{})); - params.insertSeparator(Parameters::Separator{"Output Cell Data"}); + params.insertSeparator(Parameters::Separator{"Output Feature Data"}); params.insert(std::make_unique(k_FeaturePhasesArrayName_Key, "Binary Feature Phases Array Name", "Created Data Array name to specify to which Ensemble each Feature belongs", "Binary Phases Array")); + params.insertLinkableParameter(std::make_unique(k_CreateCellEnsembleAM_Key, "Create Cell Ensemble Attribute Matrix", + "Create an empty Attribute Matrix with 2 tuples beside the selected Feature Attribute Matrix.", false)); + params.insert(std::make_unique(k_CellEnsembleAMName_Key, "Cell Ensemble Attribute Matrix Name", "Name of the created Cell Ensemble Attribute Matrix", "Cell Ensemble Data")); + params.linkParameters(k_CreateCellEnsembleAM_Key, k_CellEnsembleAMName_Key, true); + return params; } //------------------------------------------------------------------------------ IFilter::VersionType ComputeFeaturePhasesBinaryFilter::parametersVersion() const { - return 1; + return 2; } //------------------------------------------------------------------------------ @@ -86,21 +93,33 @@ IFilter::UniquePointer ComputeFeaturePhasesBinaryFilter::clone() const IFilter::PreflightResult ComputeFeaturePhasesBinaryFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const { - auto pCellDataAMPathValue = filterArgs.value(k_CellDataAMPath_Key); - auto pFeaturePhasesArrayNameValue = filterArgs.value(k_FeaturePhasesArrayName_Key); - - PreflightResult preflightResult; - nx::core::Result resultOutputActions; - std::vector preflightUpdatedValues; - - auto cellDataAM = dataStructure.getDataAs(pCellDataAMPathValue); + const auto featureAMPath = filterArgs.value(k_CellFeatureAMPath_Key); + const auto maskPath = filterArgs.value(k_MaskArrayPath_Key); + const auto featureIdsPath = filterArgs.value(k_FeatureIdsArrayPath_Key); + const auto& featureAM = dataStructure.getDataRefAs(featureAMPath); + const auto& mask = dataStructure.getDataRefAs(maskPath); + + // Compare resolved objects so alternate paths to the same Attribute Matrix select the same mode. + if(dataStructure.getData(maskPath.getParent()) != &featureAM) { - auto createArrayAction = std::make_unique(DataType::int32, cellDataAM->getShape(), std::vector{1}, pCellDataAMPathValue.createChildPath(pFeaturePhasesArrayNameValue)); - resultOutputActions.value().appendAction(std::move(createArrayAction)); + const auto& featureIds = dataStructure.getDataRefAs(featureIdsPath); + if(mask.getNumberOfTuples() != featureIds.getNumberOfTuples()) + { + return {MakeErrorResult( + -53802, fmt::format("Cell mask '{}' has {} tuples, but Feature Ids '{}' has {} tuples. Select arrays with the same tuple count, or select a mask in Feature Attribute Matrix '{}'.", + maskPath.toString(), mask.getNumberOfTuples(), featureIdsPath.toString(), featureIds.getNumberOfTuples(), featureAMPath.toString()))}; + } } - // Return both the resultOutputActions and the preflightUpdatedValues via std::move() - return {std::move(resultOutputActions), std::move(preflightUpdatedValues)}; + Result resultOutputActions; + const auto outputPath = featureAMPath.createChildPath(filterArgs.value(k_FeaturePhasesArrayName_Key)); + resultOutputActions.value().appendAction(std::make_unique(DataType::int32, featureAM.getShape(), ShapeType{1}, outputPath)); + if(filterArgs.value(k_CreateCellEnsembleAM_Key)) + { + const auto ensemblePath = featureAMPath.replaceName(filterArgs.value(k_CellEnsembleAMName_Key)); + resultOutputActions.value().appendAction(std::make_unique(ensemblePath, ShapeType{2})); + } + return {std::move(resultOutputActions)}; } //------------------------------------------------------------------------------ @@ -110,7 +129,7 @@ Result<> ComputeFeaturePhasesBinaryFilter::executeImpl(DataStructure& dataStruct ComputeFeaturePhasesBinaryInputValues inputValues; inputValues.FeatureIdsArrayPath = filterArgs.value(k_FeatureIdsArrayPath_Key); inputValues.MaskArrayPath = filterArgs.value(k_MaskArrayPath_Key); - inputValues.CellDataAttributeMatrixPath = filterArgs.value(k_CellDataAMPath_Key); + inputValues.CellFeatureAttributeMatrixPath = filterArgs.value(k_CellFeatureAMPath_Key); inputValues.FeaturePhasesArrayName = filterArgs.value(k_FeaturePhasesArrayName_Key); return ComputeFeaturePhasesBinary(dataStructure, messageHandler, shouldCancel, &inputValues)(); @@ -137,7 +156,10 @@ Result ComputeFeaturePhasesBinaryFilter::FromSIMPLJson(const nlohmann results.push_back(SIMPLConversion::ConvertParameter(args, json, SIMPL::k_GoodVoxelsArrayPathKey, k_MaskArrayPath_Key)); results.push_back( SIMPLConversion::ConvertParameter(args, json, SIMPL::k_FeaturePhasesArrayPathKey, k_FeaturePhasesArrayName_Key)); - results.push_back(SIMPLConversion::ConvertParameter(args, json, SIMPL::k_FeatureIdsArrayPathKey, k_CellDataAMPath_Key)); + results.push_back(SIMPLConversion::ConvertParameter(args, json, SIMPL::k_FeaturePhasesArrayPathKey, k_CellFeatureAMPath_Key)); + + results.push_back(SIMPLConversion::ConvertParameter(args, json, SIMPL::k_CellEnsembleAttributeMatrixNameKey, k_CellEnsembleAMName_Key)); + args.insertOrAssign(k_CreateCellEnsembleAM_Key, std::make_any(true)); Result<> conversionResult = MergeResults(std::move(results)); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.hpp index 74487a06bc..dab14ec18d 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.hpp @@ -9,7 +9,7 @@ namespace nx::core { /** * @class ComputeFeaturePhasesBinaryFilter - * @brief This filter will .... + * @brief Assigns a binary phase to each Feature from a Cell or Feature mask. */ class SIMPLNXCORE_EXPORT ComputeFeaturePhasesBinaryFilter : public IFilter { @@ -27,7 +27,9 @@ class SIMPLNXCORE_EXPORT ComputeFeaturePhasesBinaryFilter : public IFilter static constexpr StringLiteral k_FeatureIdsArrayPath_Key = "feature_ids_array_path"; static constexpr StringLiteral k_MaskArrayPath_Key = "mask_array_path"; static constexpr StringLiteral k_FeaturePhasesArrayName_Key = "feature_phases_array_name"; - static constexpr StringLiteral k_CellDataAMPath_Key = "cell_data_attribute_matrix_path"; + static constexpr StringLiteral k_CellFeatureAMPath_Key = "cell_feature_attribute_matrix_path"; + static constexpr StringLiteral k_CreateCellEnsembleAM_Key = "create_cell_ensemble_attribute_matrix"; + static constexpr StringLiteral k_CellEnsembleAMName_Key = "cell_ensemble_attribute_matrix_name"; /** * @brief Reads SIMPL json and converts it simplnx Arguments. diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeVectorColorsFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeVectorColorsFilter.cpp index 8cd34eb5e1..47a7059980 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeVectorColorsFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ComputeVectorColorsFilter.cpp @@ -14,11 +14,6 @@ using namespace nx::core; -namespace -{ -const DataPath k_MaskArrayPath = DataPath({"mask array"}); -} - namespace nx::core { //------------------------------------------------------------------------------ @@ -90,9 +85,7 @@ IFilter::UniquePointer ComputeVectorColorsFilter::clone() const IFilter::PreflightResult ComputeVectorColorsFilter::preflightImpl(const DataStructure& dataStructure, const Arguments& filterArgs, const MessageHandler& messageHandler, const std::atomic_bool& shouldCancel, const ExecutionContext& executionContext) const { - auto pUseGoodVoxelsValue = filterArgs.value(k_UseMask_Key); auto pVectorsArrayPathValue = filterArgs.value(k_VectorsArrayPath_Key); - auto pGoodVoxelsArrayPathValue = filterArgs.value(k_MaskArrayPath_Key); auto pCellVectorColorsArrayNameValue = filterArgs.value(k_CellVectorColorsArrayName_Key); PreflightResult preflightResult; @@ -105,12 +98,6 @@ IFilter::PreflightResult ComputeVectorColorsFilter::preflightImpl(const DataStru resultOutputActions.value().appendAction(std::move(action)); } - if(!pUseGoodVoxelsValue) - { - auto action = std::make_unique(DataType::boolean, vectorsTupShape, std::vector{1}, k_MaskArrayPath, "", "true"); - resultOutputActions.value().appendAction(std::move(action)); - } - return {std::move(resultOutputActions), std::move(preflightUpdatedValues)}; } @@ -122,14 +109,7 @@ Result<> ComputeVectorColorsFilter::executeImpl(DataStructure& dataStructure, co inputValues.UseMask = filterArgs.value(k_UseMask_Key); inputValues.VectorsArrayPath = filterArgs.value(k_VectorsArrayPath_Key); - if(!inputValues.UseMask) - { - inputValues.MaskArrayPath = k_MaskArrayPath; - } - else - { - inputValues.MaskArrayPath = filterArgs.value(k_MaskArrayPath_Key); - } + inputValues.MaskArrayPath = filterArgs.value(k_MaskArrayPath_Key); inputValues.CellVectorColorsArrayPath = inputValues.VectorsArrayPath.replaceName(filterArgs.value(k_CellVectorColorsArrayName_Key)); return ComputeVectorColors(dataStructure, messageHandler, shouldCancel, &inputValues)(); diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/CropVertexGeometryFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/CropVertexGeometryFilter.cpp index cc3c752382..346e5ef498 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/CropVertexGeometryFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/CropVertexGeometryFilter.cpp @@ -168,8 +168,8 @@ IFilter::PreflightResult CropVertexGeometryFilter::preflightImpl(const DataStruc DataType type = targetArray.getDataType(); auto tDims = targetArray.getNumberOfTuples(); - auto cDims = targetArray.getNumberOfComponents(); - auto createArrayAction = std::make_unique(type, std::vector{tDims}, std::vector{cDims}, croppedVertexDataPath.createChildPath(targetArrayPath.getTargetName())); + auto cDims = targetArray.getComponentShape(); + auto createArrayAction = std::make_unique(type, std::vector{tDims}, cDims, croppedVertexDataPath.createChildPath(targetArrayPath.getTargetName())); actions.appendAction(std::move(createArrayAction)); } diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadCSVFileFilter.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadCSVFileFilter.cpp index 43542c669c..b2d2793da6 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadCSVFileFilter.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/ReadCSVFileFilter.cpp @@ -117,7 +117,7 @@ Result<> cacheHeaders(int32 instanceId, const ReadCSVData& readCsvData) if(currentLine == readCsvData.headersLine) { - auto headers = StringUtilities::split(line, readCsvData.delimiters, readCsvData.consecutiveDelimiters); + auto headers = StringUtilities::split(line, readCsvData.delimiters, !readCsvData.consecutiveDelimiters); s_HeaderCache[instanceId].Headers = headers; s_HeaderCache[instanceId].HeadersLine = readCsvData.headersLine; break; diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/utils/VtkUtilities.hpp b/src/Plugins/SimplnxCore/src/SimplnxCore/utils/VtkUtilities.hpp index a9afd7bd84..f08432b9c0 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/utils/VtkUtilities.hpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/utils/VtkUtilities.hpp @@ -265,12 +265,9 @@ struct WriteVtkDataArrayFunctor { buffer.append(fmt::format(" {:d}", static_cast(values[localIndex]))); } - else if constexpr(std::is_floating_point_v) - { - buffer.append(fmt::format(" {:f}", values[localIndex])); - } else { + // '{}' writes the shortest text that round-trips float and double values. buffer.append(fmt::format(" {}", values[localIndex])); } if(buffer.size() > (k_BufferDumpVal - 32)) diff --git a/src/Plugins/SimplnxCore/test/ArrayCalculatorTest.cpp b/src/Plugins/SimplnxCore/test/ArrayCalculatorTest.cpp index fb4464aa20..0a9988f78e 100644 --- a/src/Plugins/SimplnxCore/test/ArrayCalculatorTest.cpp +++ b/src/Plugins/SimplnxCore/test/ArrayCalculatorTest.cpp @@ -115,6 +115,24 @@ DataStructure createDataStructure() return dataStructure; } + +DataStructure CreateComponentShapeDataStructure() +{ + DataStructure ds; + auto* am = AttributeMatrix::Create(ds, k_AttributeMatrix, {1}); + REQUIRE(am != nullptr); + auto* arrayA = Float64Array::CreateWithStore(ds, "A", {1}, {3}, am->getId()); + auto* arrayB = Float64Array::CreateWithStore(ds, "B", {1}, {3}, am->getId()); + REQUIRE(arrayA != nullptr); + REQUIRE(arrayB != nullptr); + (*arrayA)[0] = 1; + (*arrayA)[1] = 2; + (*arrayA)[2] = 4; + (*arrayB)[0] = 10; + (*arrayB)[1] = 20; + (*arrayB)[2] = 40; + return ds; +} } // namespace // ----------------------------------------------------------------------------- @@ -1428,6 +1446,71 @@ TEST_CASE("SimplnxCore::ArrayCalculatorFilter: Preflight rejects missing binary UnitTest::CheckArraysInheritTupleDims(ds); } +TEST_CASE("SimplnxCore::ArrayCalculatorFilter: Component shape mismatch after extraction", "[SimplnxCore][ArrayCalculatorFilter][ComponentShapeRegression]") +{ + UnitTest::LoadPlugins(); + const std::string equation = GENERATE("A + (B)[0]", "(B)[0] + A"); + CAPTURE(equation); + DataStructure ds = CreateComponentShapeDataStructure(); + ArrayCalculatorFilter filter; + Arguments args; + args.insertOrAssign(ArrayCalculatorFilter::k_CalculatorParameter_Key, + std::make_any(CalculatorParameter::ValueType{k_AttributeMatrixPath, equation, CalculatorParameter::Radians})); + args.insertOrAssign(ArrayCalculatorFilter::k_ScalarType_Key, std::make_any(NumericType::float64)); + args.insertOrAssign(ArrayCalculatorFilter::k_CalculatedArray_Key, std::make_any(k_AttributeArrayPath)); + + // Component extraction leaves an array with one component, not a broadcast scalar. + // Its component count differs from A's three components in either operand order. + const auto result = filter.preflight(ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(result.outputActions); + REQUIRE(result.outputActions.errors().size() == 1); + REQUIRE(result.outputActions.errors().front().code == static_cast(CalculatorErrorCode::InconsistentCompDims)); + REQUIRE_FALSE(ds.containsData(k_AttributeArrayPath)); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("SimplnxCore::ArrayCalculatorFilter: Component shape and scalar broadcast controls", "[SimplnxCore][ArrayCalculatorFilter][ComponentShapeRegression]") +{ + UnitTest::LoadPlugins(); + DataStructure ds = CreateComponentShapeDataStructure(); + ArrayCalculatorFilter filter; + std::string equation; + std::vector expected; + // Hand calculation for A = (1, 2, 4) and B = (10, 20, 40). + SECTION("Matching three-component arrays") + { + equation = "A + B"; + expected = {11, 22, 44}; + } + SECTION("Matching extracted components") + { + equation = "(A)[1] + (B)[0]"; + expected = {12}; + } + SECTION("Scalar on the right") + { + equation = "A + 5"; + expected = {6, 7, 9}; + } + SECTION("Scalar on the left") + { + equation = "5 + A"; + expected = {6, 7, 9}; + } + + const auto result = createAndExecuteArrayCalculatorFilter(equation, k_AttributeArrayPath, CalculatorParameter::Radians, ds, filter); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + REQUIRE_NOTHROW(ds.getDataRefAs(k_AttributeArrayPath)); + const auto& output = ds.getDataRefAs(k_AttributeArrayPath); + REQUIRE(output.getNumberOfTuples() == 1); + REQUIRE(output.getNumberOfComponents() == expected.size()); + for(usize i = 0; i < expected.size(); i++) + { + REQUIRE(output[i] == expected[i]); + } + UnitTest::CheckArraysInheritTupleDims(ds); +} + TEST_CASE("SimplnxCore::ArrayCalculatorFilter: Bulk multi-page calculation and failure propagation", "[SimplnxCore][ArrayCalculatorFilter]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/SimplnxCore/test/ComputeFeaturePhasesBinaryTest.cpp b/src/Plugins/SimplnxCore/test/ComputeFeaturePhasesBinaryTest.cpp index db7f9ef0e9..2dfda819ba 100644 --- a/src/Plugins/SimplnxCore/test/ComputeFeaturePhasesBinaryTest.cpp +++ b/src/Plugins/SimplnxCore/test/ComputeFeaturePhasesBinaryTest.cpp @@ -1,70 +1,29 @@ +#include "SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" -#include -#include -#include -#include #include "simplnx/Core/Application.hpp" #include "simplnx/DataStructure/AttributeMatrix.hpp" #include "simplnx/DataStructure/DataArray.hpp" -#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/DataStructure/DataGroup.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" -#include "simplnx/Utilities/DataStoreUtilities.hpp" - -#include "SimplnxCore/Filters/ComputeFeaturePhasesBinaryFilter.hpp" +#include #include -#include +#include +#include +#include +#include using namespace nx::core; namespace fs = std::filesystem; -namespace -{ -const std::string k_BinaryFeaturePhasesName = "BinaryFeaturePhases"; - -const DataPath k_ExemplarArray = DataPath({Constants::k_SmallIN100, Constants::k_EbsdScanData, "BinaryPhases"}); -const DataPath k_GeneratedArray = DataPath({Constants::k_SmallIN100, Constants::k_EbsdScanData, k_BinaryFeaturePhasesName}); -} // namespace - -TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Valid Filter Execution", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter]") -{ - UnitTest::LoadPlugins(); - - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "bin_feature_phases.tar.gz", "bin_feature_phases.dream3d"); - - DataStructure dataStructure = UnitTest::LoadDataStructure(fs::path(fmt::format("{}/bin_feature_phases/6_6_find_feature_phases_binary.dream3d", unit_test::k_TestFilesDir))); - - { - // Create the filter arguments for the calculation. - ComputeFeaturePhasesBinaryFilter filter; - Arguments args; - - args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_FeatureIdsArrayPath_Key, std::make_any(DataPath({Constants::k_SmallIN100, Constants::k_EbsdScanData, Constants::k_FeatureIds}))); - args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_MaskArrayPath_Key, std::make_any(DataPath({Constants::k_SmallIN100, Constants::k_EbsdScanData, Constants::k_Mask}))); - args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_CellDataAMPath_Key, std::make_any(DataPath({Constants::k_SmallIN100, Constants::k_EbsdScanData}))); - - args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_FeaturePhasesArrayName_Key, std::make_any(k_BinaryFeaturePhasesName)); - - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); - - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - } - - UnitTest::CompareArrays(dataStructure, k_ExemplarArray, k_GeneratedArray); - - UnitTest::CheckArraysInheritTupleDims(dataStructure); -} - TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: SIMPL Backwards Compatibility", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter][BackwardsCompatibility]") { - auto app = Application::GetOrCreateInstance(); UnitTest::LoadPlugins(); + auto app = Application::GetOrCreateInstance(); auto filterList = app->getFilterList(); const fs::path conversionDir = fs::path(nx::core::unit_test::k_SourceDir.view()) / "test" / "simpl_conversion"; @@ -97,13 +56,16 @@ TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: SIMPL Backwards Compat CHECK(args.value(ComputeFeaturePhasesBinaryFilter::k_FeatureIdsArrayPath_Key) == DataPath({"DataContainer", "CellData", "TestArray"})); CHECK(args.value(ComputeFeaturePhasesBinaryFilter::k_MaskArrayPath_Key) == DataPath({"DataContainer", "CellData", "TestArray"})); CHECK(args.value(ComputeFeaturePhasesBinaryFilter::k_FeaturePhasesArrayName_Key) == "TestArray"); - CHECK(args.value(ComputeFeaturePhasesBinaryFilter::k_CellDataAMPath_Key) == DataPath({"DataContainer", "CellData"})); + CHECK(args.value(ComputeFeaturePhasesBinaryFilter::k_CellFeatureAMPath_Key) == DataPath({"DataContainer", "CellFeatureData"})); + CHECK(args.value(ComputeFeaturePhasesBinaryFilter::k_CreateCellEnsembleAM_Key)); + CHECK(args.value(ComputeFeaturePhasesBinaryFilter::k_CellEnsembleAMName_Key) == "Legacy Ensembles"); } } } TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Cell Feature Ids requires 1 component", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter][ComponentShape]") { + UnitTest::LoadPlugins(); DataStructure dataStructure; auto* cellData = AttributeMatrix::Create(dataStructure, "Cell Data", {2}); REQUIRE(cellData != nullptr); @@ -120,10 +82,254 @@ TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Cell Feature Ids requi auto args = filter.getDefaultArguments(); args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_FeatureIdsArrayPath_Key, std::make_any(DataPath({"Cell Data", "Feature Ids"}))); args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_MaskArrayPath_Key, std::make_any(DataPath({"Cell Data", "Mask"}))); - args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_CellDataAMPath_Key, std::make_any(DataPath({"Feature Data"}))); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_CellFeatureAMPath_Key, std::make_any(DataPath({"Feature Data"}))); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); REQUIRE(std::any_of(preflightResult.outputActions.errors().begin(), preflightResult.outputActions.errors().end(), [](const Error& error) { return error.code == -208; })); UnitTest::CheckArraysInheritTupleDims(dataStructure); } + +namespace +{ +Arguments BinaryPhaseArguments(const ComputeFeaturePhasesBinaryFilter& filter, const DataPath& maskPath) +{ + auto args = filter.getDefaultArguments(); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_FeatureIdsArrayPath_Key, std::make_any(DataPath({"Container", "Cells", "Ids"}))); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_MaskArrayPath_Key, std::make_any(maskPath)); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_CellFeatureAMPath_Key, std::make_any(DataPath({"Container", "Features"}))); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_FeaturePhasesArrayName_Key, std::make_any("Phases")); + return args; +} + +template +void CreateBinaryPhaseInputs(DataStructure& ds, bool featureMask, usize maskTuples = 6) +{ + auto* container = DataGroup::Create(ds, "Container"); + REQUIRE(container != nullptr); + auto* cells = AttributeMatrix::Create(ds, "Cells", {6}, container->getId()); + REQUIRE(cells != nullptr); + auto* features = AttributeMatrix::Create(ds, "Features", {2, 2}, container->getId()); + REQUIRE(features != nullptr); + const std::array ids = {0, 1, 2, 1, 2, 1}; + auto* idArray = UnitTest::CreateTestDataArray(ds, "Ids", {6}, {1}, cells->getId()); + auto copyIdsResult = idArray->getDataStoreRef().copyFromBuffer(0, nonstd::span(ids.data(), ids.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyIdsResult); + if(featureMask) + { + const std::array values = {static_cast(0), static_cast(1), static_cast(1), static_cast(0)}; + auto* mask = UnitTest::CreateTestDataArray(ds, "Mask", {2, 2}, {1}, features->getId()); + auto copyMaskResult = mask->getDataStoreRef().copyFromBuffer(0, nonstd::span(values.data(), values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyMaskResult); + // Invalid values and a different tuple count must be ignored in Feature-mask mode. + idArray->fill(-7); + } + else + { + auto* maskAM = AttributeMatrix::Create(ds, "Mask Cells", {maskTuples}, container->getId()); + REQUIRE(maskAM != nullptr); + auto* mask = UnitTest::CreateTestDataArray(ds, "Mask", {maskTuples}, {1}, maskAM->getId()); + if(maskTuples == 6) + { + const std::array values = {static_cast(1), static_cast(1), static_cast(0), static_cast(0), static_cast(1), static_cast(0)}; + auto copyMaskResult = mask->getDataStoreRef().copyFromBuffer(0, nonstd::span(values.data(), values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyMaskResult); + } + } +} + +void CheckBinaryPhases(const DataStructure& ds, const std::array& expected) +{ + const auto* output = ds.getDataAs(DataPath({"Container", "Features", "Phases"})); + REQUIRE(output != nullptr); + CHECK(output->getTupleShape() == ShapeType{2, 2}); + CHECK(output->getNumberOfComponents() == 1); + std::array actual{}; + auto readResult = output->getDataStoreRef().copyIntoBuffer(0, nonstd::span(actual.data(), actual.size())); + SIMPLNX_RESULT_REQUIRE_VALID(readResult); + CHECK(actual == expected); + CHECK(ds.getData(DataPath({"Container", "Cells", "Phases"})) == nullptr); +} +} // namespace + +TEMPLATE_TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Cell mask hand oracle", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter]", bool, uint8) +{ + UnitTest::LoadPlugins(); + DataStructure ds; + CreateBinaryPhaseInputs(ds, false); + ComputeFeaturePhasesBinaryFilter filter; + auto args = BinaryPhaseArguments(filter, DataPath({"Container", "Mask Cells", "Mask"})); + auto preflightResult = filter.preflight(ds, args).outputActions; + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult); + auto executeResult = filter.execute(ds, args).result; + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + // Feature 0 is true. Feature 1 ends false. Feature 2 ends true. Feature 3 is unreferenced. + CheckBinaryPhases(ds, {1, 0, 1, 0}); + CHECK(ds.getData(DataPath({"Container", "Cell Ensemble Data"})) == nullptr); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEMPLATE_TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Feature mask hand oracle", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter]", bool, uint8) +{ + UnitTest::LoadPlugins(); + DataStructure ds; + CreateBinaryPhaseInputs(ds, true); + if constexpr(std::is_same_v) + { + auto* mask = ds.getDataAs(DataPath({"Container", "Features", "Mask"})); + REQUIRE(mask != nullptr); + const std::array values = {0, 2, 255, 0}; + auto copyMaskResult = mask->getDataStoreRef().copyFromBuffer(0, nonstd::span(values.data(), values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyMaskResult); + } + ComputeFeaturePhasesBinaryFilter filter; + const bool alias = GENERATE(false, true); + DataPath maskPath({"Container", "Features", "Mask"}); + if(alias) + { + auto* aliasGroup = DataGroup::Create(ds, "Alias"); + REQUIRE(aliasGroup != nullptr); + auto* features = ds.getDataAs(DataPath({"Container", "Features"})); + REQUIRE(features != nullptr); + REQUIRE(ds.setAdditionalParent(features->getId(), aliasGroup->getId())); + maskPath = DataPath({"Alias", "Features", "Mask"}); + } + auto args = BinaryPhaseArguments(filter, maskPath); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_CreateCellEnsembleAM_Key, std::make_any(true)); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_CellEnsembleAMName_Key, std::make_any("Ensembles")); + auto preflightResult = filter.preflight(ds, args).outputActions; + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult); + auto executeResult = filter.execute(ds, args).result; + SIMPLNX_RESULT_REQUIRE_VALID(executeResult); + CheckBinaryPhases(ds, {0, 1, 1, 0}); + const auto* ensemble = ds.getDataAs(DataPath({"Container", "Ensembles"})); + REQUIRE(ensemble != nullptr); + CHECK(ensemble->getShape() == ShapeType{2}); + CHECK(ds.getData(DataPath({"Ensembles"})) == nullptr); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Cell mask tuple mismatch", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter]") +{ + UnitTest::LoadPlugins(); + const usize maskTuples = GENERATE(5, 7); + DataStructure ds; + CreateBinaryPhaseInputs(ds, false, maskTuples); + ComputeFeaturePhasesBinaryFilter filter; + auto args = BinaryPhaseArguments(filter, DataPath({"Container", "Mask Cells", "Mask"})); + auto result = filter.preflight(ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(result.outputActions); + REQUIRE(result.outputActions.errors().size() == 1); + CHECK(result.outputActions.errors()[0].code == -53802); +} + +TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Feature Id range", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter]") +{ + UnitTest::LoadPlugins(); + const int32 invalidId = GENERATE(-1, 4); + DataStructure ds; + CreateBinaryPhaseInputs(ds, false); + auto* ids = ds.getDataAs(DataPath({"Container", "Cells", "Ids"})); + REQUIRE(ids != nullptr); + ids->setValue(5, invalidId); + ComputeFeaturePhasesBinaryFilter filter; + auto args = BinaryPhaseArguments(filter, DataPath({"Container", "Mask Cells", "Mask"})); + auto preflightResult = filter.preflight(ds, args).outputActions; + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult); + auto result = filter.execute(ds, args); + SIMPLNX_RESULT_REQUIRE_INVALID(result.result); + REQUIRE(result.result.errors().size() == 1); + CHECK(result.result.errors()[0].code == -53801); +} + +TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Equal counts in different matrices use Cell mode", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter]") +{ + UnitTest::LoadPlugins(); + DataStructure ds; + CreateBinaryPhaseInputs(ds, false); + auto* cells = ds.getDataAs(DataPath({"Container", "Cells"})); + auto* maskCells = ds.getDataAs(DataPath({"Container", "Mask Cells"})); + REQUIRE(cells != nullptr); + REQUIRE(maskCells != nullptr); + auto resizeResult = cells->resizeTuples({4}); + SIMPLNX_RESULT_REQUIRE_VALID(resizeResult); + resizeResult = maskCells->resizeTuples({4}); + SIMPLNX_RESULT_REQUIRE_VALID(resizeResult); + auto* ids = ds.getDataAs(DataPath({"Container", "Cells", "Ids"})); + auto* mask = ds.getDataAs(DataPath({"Container", "Mask Cells", "Mask"})); + REQUIRE(ids != nullptr); + REQUIRE(mask != nullptr); + const std::array idValues = {2, 1, 2, 0}; + const std::array maskValues = {255, 2, 0, 255}; + auto copyResult = ids->getDataStoreRef().copyFromBuffer(0, nonstd::span(idValues.data(), idValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyResult); + copyResult = mask->getDataStoreRef().copyFromBuffer(0, nonstd::span(maskValues.data(), maskValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyResult); + ComputeFeaturePhasesBinaryFilter filter; + auto args = BinaryPhaseArguments(filter, DataPath({"Container", "Mask Cells", "Mask"})); + args.insertOrAssign(ComputeFeaturePhasesBinaryFilter::k_CreateCellEnsembleAM_Key, std::make_any(true)); + auto result = filter.execute(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + CheckBinaryPhases(ds, {1, 1, 0, 0}); + const auto* ensemble = ds.getDataAs(DataPath({"Container", "Cell Ensemble Data"})); + REQUIRE(ensemble != nullptr); + CHECK(ensemble->getShape() == ShapeType{2}); + UnitTest::CheckArraysInheritTupleDims(ds); +} + +TEST_CASE("SimplnxCore::ComputeFeaturePhasesBinaryFilter: Empty and chunk-boundary masks", "[SimplnxCore][ComputeFeaturePhasesBinaryFilter]") +{ + UnitTest::LoadPlugins(); + const bool featureMask = GENERATE(false, true); + const usize tupleCount = GENERATE(usize(0), usize(1'000'001)); + DataStructure ds; + CreateBinaryPhaseInputs(ds, featureMask); + const DataPath maskAMPath = featureMask ? DataPath({"Container", "Features"}) : DataPath({"Container", "Mask Cells"}); + auto* maskAM = ds.getDataAs(maskAMPath); + REQUIRE(maskAM != nullptr); + auto resizeResult = maskAM->resizeTuples({tupleCount}); + SIMPLNX_RESULT_REQUIRE_VALID(resizeResult); + auto* mask = ds.getDataAs(maskAMPath.createChildPath("Mask")); + REQUIRE(mask != nullptr); + mask->fill(255); + if(tupleCount > 0) + { + mask->setValue(tupleCount - 1, 0); + } + if(!featureMask) + { + auto* cells = ds.getDataAs(DataPath({"Container", "Cells"})); + REQUIRE(cells != nullptr); + resizeResult = cells->resizeTuples({tupleCount}); + SIMPLNX_RESULT_REQUIRE_VALID(resizeResult); + auto* ids = ds.getDataAs(DataPath({"Container", "Cells", "Ids"})); + REQUIRE(ids != nullptr); + ids->fill(1); + if(tupleCount > 0) + { + ids->setValue(0, 0); + } + } + ComputeFeaturePhasesBinaryFilter filter; + auto args = BinaryPhaseArguments(filter, maskAMPath.createChildPath("Mask")); + auto result = filter.execute(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + if(!featureMask) + { + CheckBinaryPhases(ds, tupleCount == 0 ? std::array{0, 0, 0, 0} : std::array{1, 0, 0, 0}); + } + else + { + const auto* output = ds.getDataAs(DataPath({"Container", "Features", "Phases"})); + REQUIRE(output != nullptr); + CHECK(output->getNumberOfTuples() == tupleCount); + if(tupleCount > 0) + { + std::array boundary{}; + auto readResult = output->getDataStoreRef().copyIntoBuffer(tupleCount - 2, nonstd::span(boundary.data(), boundary.size())); + SIMPLNX_RESULT_REQUIRE_VALID(readResult); + CHECK(boundary == std::array{1, 0}); + } + } + UnitTest::CheckArraysInheritTupleDims(ds); +} diff --git a/src/Plugins/SimplnxCore/test/ComputeVectorColorsTest.cpp b/src/Plugins/SimplnxCore/test/ComputeVectorColorsTest.cpp index 5f22b7c456..e76bc309b2 100644 --- a/src/Plugins/SimplnxCore/test/ComputeVectorColorsTest.cpp +++ b/src/Plugins/SimplnxCore/test/ComputeVectorColorsTest.cpp @@ -27,6 +27,57 @@ DataPath eulerAnglesPath = ebsdPath.createChildPath(Constants::k_EulerAngles); const std::string k_VecColorsNX = "Vector Colors"; } // namespace +TEST_CASE("SimplnxCore::ComputeVectorColorsFilter: Repeated unmasked execution creates only colors", "[SimplnxCore][ComputeVectorColorsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + const DataPath vectorsPath({"Vectors"}); + auto vectorsStore = DataStoreUtilities::CreateDataStore(dataStructure, vectorsPath, {2}, {3}); + REQUIRE(Float32Array::Create(dataStructure, vectorsPath.getTargetName(), vectorsStore) != nullptr); + const std::array vectors = {1.0f, 0.0f, 0.0f, 0.0f, 0.0f, 1.0f}; + const auto writeResult = vectorsStore->copyFromBuffer(0, nonstd::span(vectors.data(), vectors.size())); + SIMPLNX_RESULT_REQUIRE_VALID(writeResult); + + // Hand calculation: +X has trend 0 and plunge 90, giving pure red. + // +Z has plunge 0, so every channel blends fully to white. + const std::array expectedColors = {255, 0, 0, 255, 255, 255}; + std::vector expectedPaths = {vectorsPath}; + for(const std::string outputName : {"First Colors", "Second Colors"}) + { + CAPTURE(outputName); + ComputeVectorColorsFilter filter; + Arguments args; + args.insertOrAssign(ComputeVectorColorsFilter::k_UseMask_Key, std::make_any(false)); + args.insertOrAssign(ComputeVectorColorsFilter::k_VectorsArrayPath_Key, std::make_any(vectorsPath)); + args.insertOrAssign(ComputeVectorColorsFilter::k_CellVectorColorsArrayName_Key, std::make_any(outputName)); + + const auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + const auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const DataPath colorsPath({outputName}); + expectedPaths.push_back(colorsPath); + CHECK(dataStructure.getData(DataPath({"mask array"})) == nullptr); + CHECK(dataStructure.getAllDataPaths().size() == expectedPaths.size()); + for(const auto& path : expectedPaths) + { + CHECK(dataStructure.getData(path) != nullptr); + } + + const auto* colors = dataStructure.getDataAs(colorsPath); + REQUIRE(colors != nullptr); + CHECK(colors->getTupleShape() == ShapeType{2}); + CHECK(colors->getComponentShape() == ShapeType{3}); + std::array actualColors = {}; + const auto readResult = colors->getDataStoreRef().copyIntoBuffer(0, nonstd::span(actualColors.data(), actualColors.size())); + SIMPLNX_RESULT_REQUIRE_VALID(readResult); + CHECK(actualColors == expectedColors); + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} + TEST_CASE("SimplnxCore::ComputeVectorColorsFilter: Valid Filter Execution", "[SimplnxCore][ComputeVectorColorsFilter]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/SimplnxCore/test/ConvertColorToGrayScaleTest.cpp b/src/Plugins/SimplnxCore/test/ConvertColorToGrayScaleTest.cpp index 292565e50e..6bde570bec 100644 --- a/src/Plugins/SimplnxCore/test/ConvertColorToGrayScaleTest.cpp +++ b/src/Plugins/SimplnxCore/test/ConvertColorToGrayScaleTest.cpp @@ -3,6 +3,7 @@ #include "simplnx/Common/Array.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" #include "simplnx/DataStructure/DataArray.hpp" #include "simplnx/DataStructure/DataGroup.hpp" #include "simplnx/DataStructure/Geometry/VertexGeom.hpp" @@ -19,6 +20,7 @@ #include "simplnx/Utilities/DataStoreUtilities.hpp" #include "simplnx/Utilities/ImageProcessing/ColorToGrayScale.hpp" +#include #include #include #include @@ -394,6 +396,96 @@ TEST_CASE("SimplnxCore::ConvertColorToGrayScale: Valid Execution", "[SimplnxCore scope.execute([&] { RunTest(9, ConvertColorToGrayScale::ConversionType::SingleChannel, {0.2125f, 0.7154f, 0.0721f}, 2, false); }); } +TEST_CASE("SimplnxCore::ConvertColorToGrayScale: Multiple input arrays", "[SimplnxCore][ConvertColorToGrayScaleFilter]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + + DataStructure dataStructure; + const std::array matrixNames = {"ColorsA", "ColorsB"}; + const std::array, 2> colors = {{{100, 0, 0, 0, 100, 0}, {0, 0, 100, 100, 100, 100, 200, 100, 0}}}; + // Round 0.2125*R + 0.7154*G + 0.0721*B to the nearest integer: + // A: 21.25 -> 21, 71.54 -> 72. B: 7.21 -> 7, 100 -> 100, 114.04 -> 114. + const std::array, 2> expectedGray = {{{21, 72}, {7, 100, 114}}}; + MultiArraySelectionParameter::ValueType inputPaths; + for(usize arrayIndex = 0; arrayIndex < colors.size(); ++arrayIndex) + { + const usize tupleCount = expectedGray[arrayIndex].size(); + auto* matrix = AttributeMatrix::Create(dataStructure, matrixNames[arrayIndex], {tupleCount}); + REQUIRE(matrix != nullptr); + auto* input = UInt8Array::CreateWithStore>(dataStructure, "RGB", {tupleCount}, {3}, matrix->getId()); + REQUIRE(input != nullptr); + const auto writeResult = input->getDataStoreRef().copyFromBuffer(0, nonstd::span(colors[arrayIndex].data(), colors[arrayIndex].size())); + SIMPLNX_RESULT_REQUIRE_VALID(writeResult); + inputPaths.emplace_back(DataPath({matrixNames[arrayIndex], "RGB"})); + } + + ConvertColorToGrayScaleFilter filter; + auto args = filter.getDefaultArguments(); + args.insertOrAssign(ConvertColorToGrayScaleFilter::k_ConversionAlgorithm_Key, std::make_any(0)); + args.insertOrAssign(ConvertColorToGrayScaleFilter::k_ColorWeights_Key, std::make_any(std::vector{0.2125F, 0.7154F, 0.0721F})); + args.insertOrAssign(ConvertColorToGrayScaleFilter::k_InputDataArrayPath_Key, std::make_any(inputPaths)); + args.insertOrAssign(ConvertColorToGrayScaleFilter::k_OutputArrayPrefix_Key, std::make_any("Gray_")); + auto preflight = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); + auto execution = scope.executeFilter(filter, dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(execution.result); + + for(usize arrayIndex = 0; arrayIndex < inputPaths.size(); ++arrayIndex) + { + const DataPath outputPath({matrixNames[arrayIndex], "Gray_RGB"}); + CAPTURE(outputPath.toString()); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(outputPath)); + const auto& output = dataStructure.getDataRefAs(outputPath); + REQUIRE(output.getNumberOfTuples() == expectedGray[arrayIndex].size()); + REQUIRE(output.getNumberOfComponents() == 1); + for(usize tuple = 0; tuple < expectedGray[arrayIndex].size(); ++tuple) + { + CHECK(output[tuple] == expectedGray[arrayIndex][tuple]); + } + REQUIRE_NOTHROW(dataStructure.getDataRefAs(inputPaths[arrayIndex])); + const auto& input = dataStructure.getDataRefAs(inputPaths[arrayIndex]); + for(usize value = 0; value < colors[arrayIndex].size(); ++value) + { + CHECK(input[value] == colors[arrayIndex][value]); + } + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ConvertColorToGrayScale: Mismatched output path count", "[SimplnxCore][ConvertColorToGrayScaleFilter]") +{ + UnitTest::LoadPlugins(); + const usize outputCount = GENERATE(1, 3); + CAPTURE(outputCount); + DataStructure dataStructure; + for(const auto& name : {"A", "B"}) + { + auto* input = UInt8Array::CreateWithStore>(dataStructure, name, {1}, {3}); + REQUIRE(input != nullptr); + input->fill(11); + } + auto* output = UInt8Array::CreateWithStore>(dataStructure, "Gray", {1}, {1}); + REQUIRE(output != nullptr); + output->fill(199); + + ConvertColorToGrayScaleInputValues inputValues{}; + inputValues.ConversionAlgorithm = 3; + inputValues.ColorWeights = {0.2125F, 0.7154F, 0.0721F}; + inputValues.InputDataArrayPaths = {DataPath({"A"}), DataPath({"B"})}; + inputValues.OutputDataArrayPaths.assign(outputCount, DataPath({"Gray"})); + // Invalid counts must be rejected before selecting an algorithm or writing any output. + const auto result = ConvertColorToGrayScaleArrays(dataStructure, inputValues, {}, std::atomic_bool{false}); + SIMPLNX_RESULT_REQUIRE_INVALID(result); + REQUIRE(result.errors().size() == 1); + CHECK(result.errors()[0].code == -10706); + CHECK(result.errors()[0].message == fmt::format("Grayscale conversion needs one output path for each input path. Input paths: 2. Output paths: {}.", outputCount)); + CHECK((*output)[0] == 199); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + TEST_CASE("SimplnxCore::ConvertColorToGrayScaleFilter: SIMPL Backwards Compatibility", "[SimplnxCore][ConvertColorToGrayScaleFilter][BackwardsCompatibility]") { auto app = Application::GetOrCreateInstance(); diff --git a/src/Plugins/SimplnxCore/test/CropVertexGeometryTest.cpp b/src/Plugins/SimplnxCore/test/CropVertexGeometryTest.cpp index cf2b8484ef..745c438c14 100644 --- a/src/Plugins/SimplnxCore/test/CropVertexGeometryTest.cpp +++ b/src/Plugins/SimplnxCore/test/CropVertexGeometryTest.cpp @@ -6,6 +6,7 @@ #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include #include #include #include @@ -113,6 +114,82 @@ TEST_CASE("SimplnxCore::CropVertexGeometryFilter(Data)", "[SimplnxCore][CropVert UnitTest::CheckArraysInheritTupleDims(dataStructure); } +TEST_CASE("SimplnxCore::CropVertexGeometryFilter(PreserveComponentShapes)", "[SimplnxCore][CropVertexGeometryFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + auto* vertexGeom = VertexGeom::Create(dataStructure, "VertexGeom"); + REQUIRE(vertexGeom != nullptr); + auto* vertices = Float32Array::CreateWithStore(dataStructure, "Vertices", {3}, {3}, vertexGeom->getId()); + REQUIRE(vertices != nullptr); + vertexGeom->setVertices(*vertices); + auto* vertexData = AttributeMatrix::Create(dataStructure, k_VertexAttributeMatrixName, {3}, vertexGeom->getId()); + REQUIRE(vertexData != nullptr); + vertexGeom->setVertexAttributeMatrix(*vertexData); + + // In the box [0, 1]^3, vertex 0 is outside; vertices 1 and 2 are kept in that order. + const std::array coordinates = {-1, 0, 0, 0, 0, 0, 1, 1, 1}; + auto coordinatesWriteResult = vertices->getDataStoreRef().copyFromBuffer(0, coordinates); + SIMPLNX_RESULT_REQUIRE_VALID(coordinatesWriteResult); + auto* matrices = Int32Array::CreateWithStore(dataStructure, "Matrices", {3}, {3, 3}, vertexData->getId()); + auto* vectors = Float32Array::CreateWithStore(dataStructure, "Vectors", {3}, {2}, vertexData->getId()); + REQUIRE(matrices != nullptr); + REQUIRE(vectors != nullptr); + const std::array matrixValues = {-9, -8, -7, -6, -5, -4, -3, -2, -1, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 23, 24, 25, 26, 27, 28, 29}; + const std::array vectorValues = {-2, -1, 10, 11, 20, 21}; + auto matricesWriteResult = matrices->getDataStoreRef().copyFromBuffer(0, matrixValues); + SIMPLNX_RESULT_REQUIRE_VALID(matricesWriteResult); + auto vectorsWriteResult = vectors->getDataStoreRef().copyFromBuffer(0, vectorValues); + SIMPLNX_RESULT_REQUIRE_VALID(vectorsWriteResult); + + CropVertexGeometryFilter filter; + Arguments args; + args.insert(CropVertexGeometryFilter::k_SelectedVertexGeometryPath_Key, std::make_any(k_VertexGeomPath)); + args.insert(CropVertexGeometryFilter::k_CreatedVertexGeometryPath_Key, std::make_any(k_CroppedGeomPath)); + args.insert(CropVertexGeometryFilter::k_VertexAttributeMatrixName_Key, std::make_any(k_VertexAttributeMatrixName)); + args.insert(CropVertexGeometryFilter::k_MinPos_Key, std::make_any>(std::vector{0, 0, 0})); + args.insert(CropVertexGeometryFilter::k_MaxPos_Key, std::make_any>(std::vector{1, 1, 1})); + args.insert(CropVertexGeometryFilter::k_TargetArrayPaths_Key, + std::make_any>(std::vector{k_VertexDataPath.createChildPath("Matrices"), k_VertexDataPath.createChildPath("Vectors")})); + + auto result = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + + const auto croppedDataPath = k_CroppedGeomPath.createChildPath(k_VertexAttributeMatrixName); + const auto* croppedMatrices = dataStructure.getDataAs(croppedDataPath.createChildPath("Matrices")); + const auto* croppedVectors = dataStructure.getDataAs(croppedDataPath.createChildPath("Vectors")); + const auto* croppedGeom = dataStructure.getDataAs(k_CroppedGeomPath); + REQUIRE(croppedMatrices != nullptr); + REQUIRE(croppedVectors != nullptr); + REQUIRE(croppedGeom != nullptr); + REQUIRE(croppedGeom->getVertices() != nullptr); + REQUIRE(croppedMatrices->getNumberOfTuples() == 2); + REQUIRE(croppedVectors->getNumberOfTuples() == 2); + REQUIRE(croppedGeom->getNumberOfVertices() == 2); + CHECK(croppedMatrices->getComponentShape() == ShapeType{3, 3}); + CHECK(croppedVectors->getComponentShape() == ShapeType{2}); + + // Literal oracle: discard the first tuple and retain the two boundary vertices. + const std::array expectedMatrices = {11, 12, 13, 14, 15, 16, 17, 18, 19, 21, 22, 23, 24, 25, 26, 27, 28, 29}; + const std::array expectedVectors = {10, 11, 20, 21}; + const std::array expectedCoordinates = {0, 0, 0, 1, 1, 1}; + std::array actualMatrices{}; + std::array actualVectors{}; + std::array actualCoordinates{}; + auto matricesReadResult = croppedMatrices->getDataStoreRef().copyIntoBuffer(0, actualMatrices); + SIMPLNX_RESULT_REQUIRE_VALID(matricesReadResult); + auto vectorsReadResult = croppedVectors->getDataStoreRef().copyIntoBuffer(0, actualVectors); + SIMPLNX_RESULT_REQUIRE_VALID(vectorsReadResult); + auto coordinatesReadResult = croppedGeom->getVertices()->getDataStoreRef().copyIntoBuffer(0, actualCoordinates); + SIMPLNX_RESULT_REQUIRE_VALID(coordinatesReadResult); + CHECK(actualMatrices == expectedMatrices); + CHECK(actualVectors == expectedVectors); + CHECK(actualCoordinates == expectedCoordinates); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + TEST_CASE("SimplnxCore::CropVertexGeometryFilter: SIMPL Backwards Compatibility", "[SimplnxCore][CropVertexGeometryFilter][BackwardsCompatibility]") { auto app = Application::GetOrCreateInstance(); diff --git a/src/Plugins/SimplnxCore/test/FeatureFaceCurvatureTest.cpp b/src/Plugins/SimplnxCore/test/FeatureFaceCurvatureTest.cpp index a157b33fed..5c0cfbf636 100644 --- a/src/Plugins/SimplnxCore/test/FeatureFaceCurvatureTest.cpp +++ b/src/Plugins/SimplnxCore/test/FeatureFaceCurvatureTest.cpp @@ -6,6 +6,10 @@ #include +#include +#include +#include + using namespace nx::core; namespace @@ -148,3 +152,118 @@ TEST_CASE("SimplnxCore::FeatureFaceCurvatureFilter: Test Algorithm", "[FeatureFa UnitTest::CheckArraysInheritTupleDims(dataStructure); } + +TEST_CASE("SimplnxCore::FeatureFaceCurvatureFilter: NaN patch data", "[FeatureFaceCurvatureFilter][NaNPatch]") +{ + UnitTest::LoadPlugins(); + const bool useNormals = GENERATE(false, true); + const bool optionalOutputs = GENERATE(false, true); + const usize badComponent = GENERATE(0, 1, 2, 3, 4, 5); + // Nine faces leave eight valid samples for the seven-coefficient cubic fit. + const usize badFace = GENERATE(8, 0, 4); + const usize validCount = GENERATE(8, 1, 0); + CAPTURE(useNormals, optionalOutputs, badComponent, badFace, validCount); + + DataStructure dataStructure; + auto* geom = TriangleGeom::Create(dataStructure, "Triangles"); + REQUIRE(geom != nullptr); + auto* faceData = AttributeMatrix::Create(dataStructure, "FaceData", {9}, geom->getId()); + REQUIRE(faceData != nullptr); + geom->setFaceAttributeMatrix(*faceData); + auto* vertices = Float32Array::Create(dataStructure, "Vertices", std::make_unique>(ShapeType{19}, ShapeType{3}, 0.0F), geom->getId()); + auto* faces = DataArray::Create(dataStructure, "Faces", std::make_unique>(ShapeType{9}, ShapeType{3}, 0), geom->getId()); + REQUIRE(vertices != nullptr); + REQUIRE(faces != nullptr); + geom->setVertices(*vertices); + geom->setFaceList(*faces); + auto* labels = Int32Array::Create(dataStructure, "Labels", std::make_unique>(ShapeType{9}, ShapeType{2}, 1), faceData->getId()); + auto* ids = Int32Array::Create(dataStructure, "Ids", std::make_unique>(ShapeType{9}, ShapeType{1}, 1), faceData->getId()); + auto* normals = Float64Array::Create(dataStructure, "Normals", std::make_unique>(ShapeType{9}, ShapeType{3}, 0.0), faceData->getId()); + auto* centroids = Float64Array::Create(dataStructure, "Centroids", std::make_unique>(ShapeType{9}, ShapeType{3}, 0.0), faceData->getId()); + REQUIRE(labels != nullptr); + REQUIRE(ids != nullptr); + REQUIRE(normals != nullptr); + REQUIRE(centroids != nullptr); + + for(usize t = 0; t < 9; ++t) + { + // All faces share vertex 0, so every one-ring patch is the sorted set {0,...,8}. + // The other vertices are 1.5*c +/- (-cy,cx), giving centroid c and normal +Z. + const float64 x = 1.0 + static_cast(t % 3); + const float64 y = 1.0 + static_cast((t / 3) * (t / 3)); + vertices->setComponent(2 * t + 1, 0, static_cast(1.5 * x + y)); + vertices->setComponent(2 * t + 1, 1, static_cast(1.5 * y - x)); + vertices->setComponent(2 * t + 2, 0, static_cast(1.5 * x - y)); + vertices->setComponent(2 * t + 2, 1, static_cast(1.5 * y + x)); + faces->setComponent(t, 1, 2 * t + 1); + faces->setComponent(t, 2, 2 * t + 2); + labels->setComponent(t, 1, 2); + normals->setComponent(t, 2, 1.0); + centroids->setComponent(t, 0, x); + centroids->setComponent(t, 1, y); + const bool invalid = validCount == 0 || (validCount == 1 ? t != badFace : t == badFace); + if(invalid) + { + auto* data = badComponent < 3 ? normals : centroids; + data->setComponent(t, badComponent % 3, std::numeric_limits::quiet_NaN()); + } + } + + const DataPath geomPath({"Triangles"}); + const DataPath facePath = geomPath.createChildPath("FaceData"); + Arguments args; + args.insertOrAssign(FeatureFaceCurvatureFilter::k_TriangleGeomPath_Key, std::make_any(geomPath)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_FaceAttribMatrixPath_Key, std::make_any(facePath)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_NeighborhoodRing_Key, std::make_any(1)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_UseFaceNormals_Key, std::make_any(useNormals)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_ComputePrincipalDirection_Key, std::make_any(optionalOutputs)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_ComputeGaussianCurvature_Key, std::make_any(optionalOutputs)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_ComputeMeanCurvaturePath_Key, std::make_any(optionalOutputs)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_ComputeWeingartenMatrix_Key, std::make_any(optionalOutputs)); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_FaceLabelsPath_Key, std::make_any(facePath.createChildPath("Labels"))); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_FeatureFaceIdsPath_Key, std::make_any(facePath.createChildPath("Ids"))); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_FaceNormalsPath_Key, std::make_any(facePath.createChildPath("Normals"))); + args.insertOrAssign(FeatureFaceCurvatureFilter::k_FaceCentroidsPath_Key, std::make_any(facePath.createChildPath("Centroids"))); + const std::array outputKeys = {FeatureFaceCurvatureFilter::k_PrincipalCurvature1Path_Key, FeatureFaceCurvatureFilter::k_PrincipalCurvature2Path_Key, + FeatureFaceCurvatureFilter::k_GaussianCurvaturePath_Key, FeatureFaceCurvatureFilter::k_MeanCurvaturePath_Key, + FeatureFaceCurvatureFilter::k_WeingartenMatrixPath_Key, FeatureFaceCurvatureFilter::k_PrincipalDirection1Path_Key, + FeatureFaceCurvatureFilter::k_PrincipalDirection2Path_Key}; + for(usize i = 0; i < outputKeys.size(); ++i) + { + args.insertOrAssign(outputKeys[i], std::make_any(facePath.createChildPath("Output" + std::to_string(i)))); + } + FeatureFaceCurvatureFilter filter; + 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 i = 0; i < (optionalOutputs ? outputKeys.size() : 2); ++i) + { + const auto* output = dataStructure.getDataAs(facePath.createChildPath("Output" + std::to_string(i))); + REQUIRE(output != nullptr); + for(usize t = 0; t < 9; ++t) + { + for(usize c = 0; c < output->getNumberOfComponents(); ++c) + { + CAPTURE(i, t, c); + const float64 value = output->getDataStoreRef().getComponentValue(t, c); + if(validCount < 2 || t == badFace) + { + CHECK(std::isnan(value)); + } + else if(i < 5) + { + // Closed-form oracle: z=0 has zero Hessian, principal/mean/Gaussian + // curvatures and Weingarten matrix, including after removing a sample. + CHECK(value == Approx(0.0).margin(1.0e-12)); + } + else + { + CHECK(std::isfinite(value)); + } + } + } + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} diff --git a/src/Plugins/SimplnxCore/test/PointSampleTriangleGeometryFilterTest.cpp b/src/Plugins/SimplnxCore/test/PointSampleTriangleGeometryFilterTest.cpp index 19f1d2883f..09000e5c6b 100644 --- a/src/Plugins/SimplnxCore/test/PointSampleTriangleGeometryFilterTest.cpp +++ b/src/Plugins/SimplnxCore/test/PointSampleTriangleGeometryFilterTest.cpp @@ -1,4 +1,5 @@ #include "SimplnxCore/Filters/PointSampleTriangleGeometryFilter.hpp" +#include "SimplnxCore/Filters/Algorithms/TupleTransfer.hpp" #include "SimplnxCore/Filters/ComputeTriangleAreasFilter.hpp" #include "SimplnxCore/Filters/ReadStlFileFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" @@ -20,9 +21,12 @@ #include +#include +#include #include #include #include +#include namespace fs = std::filesystem; using namespace nx::core; @@ -279,3 +283,171 @@ TEST_CASE("SimplnxCore::PointSampleTriangleGeometryFilter: SIMPL Backwards Compa } } } + +TEMPLATE_TEST_CASE("SimplnxCore::PointSampleTriangleGeometryFilter: Transfer complete face tuples", "[SimplnxCore][PointSampleTriangleGeometryFilter]", int8, uint8, int16, uint16, int32, uint32, + int64, uint64, float32, float64, bool) +{ + UnitTest::LoadPlugins(); + const usize numComponents = GENERATE(usize{3}, usize{1}, usize{2}); + const usize numTriangles = GENERATE(usize{4}, usize{1}); + const int32 numSamples = GENERATE(2, 8); + CAPTURE(numComponents, numTriangles, numSamples); + + DataStructure dataStructure; + auto* triangles = TriangleGeom::Create(dataStructure, "Triangles"); + REQUIRE(triangles != nullptr); + auto* coordinates = UnitTest::CreateTestDataArray(dataStructure, "Vertices", {3 * numTriangles}, {3}, triangles->getId()); + auto* faces = UnitTest::CreateTestDataArray(dataStructure, "Faces", {numTriangles}, {3}, triangles->getId()); + REQUIRE(coordinates != nullptr); + REQUIRE(faces != nullptr); + for(usize face = 0; face < numTriangles; ++face) + { + // Each triangle has A=(0,0,z), B=(2,0,z), C=(1,2,z), with a separate plane. + const std::array vertices = {0, 0, static_cast(10 * face), 2, 0, static_cast(10 * face), 1, 2, static_cast(10 * face)}; + for(usize index = 0; index < vertices.size(); ++index) + { + (*coordinates)[9 * face + index] = vertices[index]; + } + for(usize corner = 0; corner < 3; ++corner) + { + (*faces)[3 * face + corner] = 3 * face + corner; + } + } + triangles->setVertices(*coordinates); + triangles->setFaceList(*faces); + auto* faceData = AttributeMatrix::Create(dataStructure, "Face Data", {numTriangles}, triangles->getId()); + auto* vertexData = AttributeMatrix::Create(dataStructure, "Vertex Data", {3 * numTriangles}, triangles->getId()); + REQUIRE(faceData != nullptr); + REQUIRE(vertexData != nullptr); + triangles->setFaceAttributeMatrix(*faceData); + triangles->setVertexAttributeMatrix(*vertexData); + auto* areas = UnitTest::CreateTestDataArray(dataStructure, "Areas", {numTriangles}, {1}, faceData->getId()); + auto* values = UnitTest::CreateTestDataArray(dataStructure, "Values", {numTriangles}, {numComponents}, faceData->getId()); + REQUIRE(areas != nullptr); + REQUIRE(values != nullptr); + areas->fill(2.0); + const auto expectedValue = [](usize face, usize component) -> TestType { + if constexpr(std::is_same_v) + { + constexpr std::array, 4> k_Values = {{{false, true, false}, {true, false, true}, {true, true, false}, {false, false, true}}}; + return k_Values[face][component]; + } + else + { + constexpr std::array, 4> k_Values = {{{11, 12, 13}, {21, 22, 23}, {31, 32, 33}, {41, 42, 43}}}; + return static_cast(k_Values[face][component]); + } + }; + for(usize face = 0; face < numTriangles; ++face) + { + for(usize component = 0; component < numComponents; ++component) + { + (*values)[face * numComponents + component] = expectedValue(face, component); + } + } + + PointSampleTriangleGeometryFilter filter; + Arguments args; + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_NumberOfSamples_Key, std::make_any(numSamples)); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_UseMask_Key, std::make_any(false)); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_UseSeed_Key, std::make_any(true)); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_SeedValue_Key, std::make_any(2205)); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_TriangleGeometry_Key, std::make_any(DataPath({"Triangles"}))); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_TriangleAreasArrayPath_Key, std::make_any(DataPath({"Triangles", "Face Data", "Areas"}))); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_SelectedDataArrayPaths_Key, + std::make_any(MultiArraySelectionParameter::ValueType{DataPath({"Triangles", "Face Data", "Values"})})); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_VertexGeometryPath_Key, std::make_any(DataPath({"Samples"}))); + args.insertOrAssign(PointSampleTriangleGeometryFilter::k_VertexDataGroupName_Key, std::make_any("Vertex Data")); + const auto preflight = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); + const auto result = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + + const DataPath outputPath({"Samples"}); + const DataPath outputValuesPath({"Samples", "Vertex Data", "Values"}); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(outputPath)); + REQUIRE_NOTHROW(dataStructure.getDataRefAs>(outputValuesPath)); + const auto& output = dataStructure.getDataRefAs(outputPath); + const auto& outputValues = dataStructure.getDataRefAs>(outputValuesPath); + REQUIRE(output.getNumberOfVertices() == static_cast(numSamples)); + REQUIRE(outputValues.getNumberOfTuples() == static_cast(numSamples)); + REQUIRE(outputValues.getNumberOfComponents() == numComponents); + for(usize sample = 0; sample < static_cast(numSamples); ++sample) + { + const auto point = output.getVertexCoordinate(sample); + // Solve P=A+v(B-A)+w(C-A): w=y/2, v=(x-w)/2, u=1-v-w. + const float64 w = point[1] / 2.0; + const float64 v = (point[0] - w) / 2.0; + const float64 u = 1.0 - v - w; + constexpr float64 k_Tolerance = 1.0e-5; + usize containingTriangle = numTriangles; + usize matches = 0; + for(usize face = 0; face < numTriangles; ++face) + { + if(std::abs(point[2] - static_cast(10 * face)) <= k_Tolerance && u >= -k_Tolerance && v >= -k_Tolerance && w >= -k_Tolerance) + { + containingTriangle = face; + ++matches; + } + } + CAPTURE(sample, containingTriangle); + REQUIRE(matches == 1); + for(usize component = 0; component < numComponents; ++component) + { + CAPTURE(component); + CHECK(outputValues[sample * numComponents + component] == expectedValue(containingTriangle, component)); + } + } + for(usize face = 0; face < numTriangles; ++face) + { + CHECK((*areas)[face] == 2.0); + for(usize component = 0; component < numComponents; ++component) + { + CHECK((*values)[face * numComponents + component] == expectedValue(face, component)); + } + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::PointSampleTriangleGeometryFilter: Transfer complete feature tuples", "[SimplnxCore][PointSampleTriangleGeometryFilter]") +{ + UnitTest::LoadPlugins(); + DataStructure dataStructure; + auto* features = UnitTest::CreateTestDataArray(dataStructure, "Features", {3}, {3}); + auto* featureIds = UnitTest::CreateTestDataArray(dataStructure, "FeatureIds", {2}, {1}); + auto* samples = UnitTest::CreateTestDataArray(dataStructure, "Samples", {8}, {3}); + REQUIRE(features != nullptr); + REQUIRE(featureIds != nullptr); + REQUIRE(samples != nullptr); + const std::array expected = {11, 12, 13, 21, 22, 23, 31, 32, 33}; + for(usize index = 0; index < expected.size(); ++index) + { + (*features)[index] = expected[index]; + } + (*featureIds)[0] = 2; + (*featureIds)[1] = 1; + samples->fill(-1); + TransferFeatureTuple transfer(dataStructure, DataPath({"Features"}), DataPath({"Samples"}), DataPath({"FeatureIds"})); + // This shared override is not exposed by the filter, but uses the same tuple-index contract. + transfer.pointSampleTransfer(1, 0); + for(usize component = 0; component < 3; ++component) + { + REQUIRE((*samples)[component] == expected[3 + component]); + } + transfer.pointSampleTransfer(0, 7); + for(usize component = 0; component < 3; ++component) + { + CHECK((*samples)[21 + component] == expected[6 + component]); + } + for(usize index = 3; index < 21; ++index) + { + CHECK((*samples)[index] == -1); + } + for(usize index = 0; index < expected.size(); ++index) + { + CHECK((*features)[index] == expected[index]); + } + CHECK((*featureIds)[0] == 2); + CHECK((*featureIds)[1] == 1); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} diff --git a/src/Plugins/SimplnxCore/test/ReadCSVFileTest.cpp b/src/Plugins/SimplnxCore/test/ReadCSVFileTest.cpp index f91e629efb..f8300691a0 100644 --- a/src/Plugins/SimplnxCore/test/ReadCSVFileTest.cpp +++ b/src/Plugins/SimplnxCore/test/ReadCSVFileTest.cpp @@ -11,6 +11,7 @@ #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" #include "simplnx/Utilities/AlgorithmDispatch.hpp" +#include "simplnx/Utilities/FileUtilities.hpp" #include "simplnx/Utilities/StringInterpretationUtilities.hpp" #include "simplnx/Utilities/StringUtilities.hpp" @@ -727,6 +728,121 @@ TEST_CASE("SimplnxCore::ReadCSVFileFilter: SIMPL Backwards Compatibility", "[Sim const Arguments args = pipelineFilter->getArguments(); // Fixture has Wizard_AutomaticAM=1, so the CreationFilter branch fires and sets k_CreatedDataGroup_Key. CHECK(args.value(ReadCSVFileFilter::k_CreatedDataGroup_Key) == DataPath({"DataContainer", "CellData"})); + // DREAM.3D 6.x dropped every empty token whatever Wizard_ConsecutiveDelimiters was (fixture: 0), so the converted option must collapse delimiters. + CHECK(args.value(ReadCSVFileFilter::k_ReadCSVData_Key).consecutiveDelimiters); } } } + +TEST_CASE("SimplnxCore::ReadCSVFileFilter: Consecutive delimiters collapse space-aligned values", "[SimplnxCore][ReadCSVFileFilter]") +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + const auto headerMode = GENERATE(ReadCSVData::HeaderMode::LINE, ReadCSVData::HeaderMode::CUSTOM); + CAPTURE(scenario, headerMode); + UnitTest::AlgorithmTestScope scope(scenario); + + fs::create_directories(k_TestInput.parent_path()); + const fs::path inputPath = k_TestInput.parent_path() / "ConsecutiveDelimiters.txt"; + { + std::ofstream file(inputPath); + REQUIRE(file.is_open()); + file << "A B C\n1 2 3\n"; + } + + ReadCSVData data; + data.inputFilePath = inputPath.string(); + data.headerMode = headerMode; + data.headersLine = 1; + data.startImportRow = 2; + data.customHeaders = {"A", "B", "C"}; + data.delimiters = {' '}; + data.consecutiveDelimiters = true; + data.dataTypes = {CSVType::int32, CSVType::int32, CSVType::int32}; + data.skippedArrayMask = {false, false, false}; + data.tupleDims = {1}; + + Arguments args; + args.insertOrAssign(ReadCSVFileFilter::k_ReadCSVData_Key, std::make_any(data)); + args.insertOrAssign(ReadCSVFileFilter::k_UseExistingGroup_Key, std::make_any(false)); + args.insertOrAssign(ReadCSVFileFilter::k_CreatedDataGroup_Key, std::make_any(DataPath({"Values"}))); + ReadCSVFileFilter filter; + DataStructure dataStructure; + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = scope.executeFilter(filter, dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // Handwritten oracle: each run of spaces separates one of the three integers. + const std::vector> expected = {{"A", 1}, {"B", 2}, {"C", 3}}; + for(const auto& [name, value] : expected) + { + const auto* array = dataStructure.getDataAs(DataPath({"Values", name})); + REQUIRE(array != nullptr); + REQUIRE(array->getSize() == 1); + REQUIRE(array->at(0) == value); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadCSVFileFilter: Consecutive delimiters off preserves empty fields", "[SimplnxCore][ReadCSVFileFilter]") +{ + UnitTest::LoadPlugins(); + fs::create_directories(k_TestInput.parent_path()); + const fs::path inputPath = k_TestInput.parent_path() / "EmptyDelimiterFields.txt"; + { + std::ofstream file(inputPath); + REQUIRE(file.is_open()); + file << "1 2 3\n"; + } + + ReadCSVData data; + data.inputFilePath = inputPath.string(); + data.headerMode = ReadCSVData::HeaderMode::CUSTOM; + data.startImportRow = 1; + data.customHeaders = {"A", "B", "C", "D", "E", "F"}; + data.delimiters = {' '}; + data.consecutiveDelimiters = false; + data.dataTypes = std::vector(6, CSVType::string); + data.skippedArrayMask = std::vector(6, false); + data.tupleDims = {1}; + + Arguments args; + args.insertOrAssign(ReadCSVFileFilter::k_ReadCSVData_Key, std::make_any(data)); + args.insertOrAssign(ReadCSVFileFilter::k_UseExistingGroup_Key, std::make_any(false)); + args.insertOrAssign(ReadCSVFileFilter::k_CreatedDataGroup_Key, std::make_any(DataPath({"Values"}))); + ReadCSVFileFilter filter; + DataStructure dataStructure; + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // Handwritten oracle: two spaces introduce one empty field; three introduce two. + const std::vector expected = {"1", "", "2", "", "", "3"}; + for(usize i = 0; i < expected.size(); ++i) + { + const auto* array = dataStructure.getDataAs(DataPath({"Values", data.customHeaders[i]})); + REQUIRE(array != nullptr); + REQUIRE(array->getSize() == 1); + REQUIRE(array->at(0) == expected[i]); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("SimplnxCore::ReadCSVFileFilter: Consecutive delimiters header tokens", "[SimplnxCore][ReadCSVFileFilter]") +{ + UnitTest::LoadPlugins(); + const bool consecutiveDelimiters = GENERATE(true, false); + CAPTURE(consecutiveDelimiters); + fs::create_directories(k_TestInput.parent_path()); + const fs::path inputPath = k_TestInput.parent_path() / "DelimiterHeaders.txt"; + { + std::ofstream file(inputPath); + REQUIRE(file.is_open()); + file << "A B C\n"; + } + + const auto result = FileUtilities::CSV::ReadHeaders(inputPath.string(), 1, {' '}, consecutiveDelimiters); + SIMPLNX_RESULT_REQUIRE_VALID(result); + const std::vector expected = consecutiveDelimiters ? std::vector{"A", "B", "C"} : std::vector{"A", "", "B", "", "", "C"}; + REQUIRE(result.value() == expected); +} diff --git a/src/Plugins/SimplnxCore/test/ReadVtkStructuredPointsTest.cpp b/src/Plugins/SimplnxCore/test/ReadVtkStructuredPointsTest.cpp index 5588a3c251..9ed33e3ef8 100644 --- a/src/Plugins/SimplnxCore/test/ReadVtkStructuredPointsTest.cpp +++ b/src/Plugins/SimplnxCore/test/ReadVtkStructuredPointsTest.cpp @@ -15,7 +15,9 @@ #include +#include #include +#include namespace fs = std::filesystem; #include "SimplnxCore/Filters/Algorithms/ReadVtkStructuredPoints.hpp" @@ -70,6 +72,110 @@ void test_invalid_case(const fs::path& filePath, int32 expectedErrCode) REQUIRE(executeResult.result.errors()[0].code == expectedErrCode); } +namespace +{ +template +void WriteSectionValues(std::ostream& output, nonstd::span values, bool binary) +{ + for(const T value : values) + { + if(binary) + { + // Legacy VTK stores the most significant byte first, independently of host byte order. + for(usize byte = sizeof(T); byte > 0; byte--) + { + output.put(static_cast((static_cast(value) >> (8 * (byte - 1))) & 0xff)); + } + } + else + { + output << value << ' '; + } + } + output << '\n'; +} +} // namespace + +TEST_CASE("SimplnxCore::ReadVtkStructuredPointsFilter: Section Selection", "[SimplnxCore][ReadVtkStructuredPointsFilter]") +{ + UnitTest::LoadPlugins(); + + const bool binary = GENERATE(false, true); + const bool pointsFirst = GENERATE(false, true); + // Exercise the regression (second only), plus first-only, both, and neither controls. + const int32 selection = GENERATE(0, 1, 2, 3); + const bool readPoints = selection == 2 || (selection == 0 && !pointsFirst) || (selection == 1 && pointsFirst); + const bool readCells = selection == 2 || (selection == 0 && pointsFirst) || (selection == 1 && !pointsFirst); + CAPTURE(binary, pointsFirst, selection); + + // Smallest nondegenerate 3D grid: 2*2*2 = 8 points, (2-1)^3 = 1 cell. + // All expected values are literal input values; no reader or writer supplies the oracle. + const std::array pointScalars = {-32760, 513, 7, -9, 11, 13, 15, 17}; + const std::array cellScalars = {-257}; + const std::array pointVectors = {1, -2, 3, 4, 5, -6, 7, 8, 9, -10, 11, 12, 13, 14, 15, 16, -17, 18, 19, 20, 21, 22, 23, -24}; + const std::array cellVectors = {65537, -2, 3}; + const fs::path filePath = fs::path(unit_test::k_BinaryDir.view()) / fmt::format("ReadVtkSectionSelection_{}_{}.vtk", binary, pointsFirst); + { + std::ofstream output(filePath, std::ios::binary | std::ios::trunc); + REQUIRE(output.is_open()); + output << "# vtk DataFile Version 3.0\nSection selection\n" << (binary ? "BINARY\n" : "ASCII\n"); + output << "DATASET STRUCTURED_POINTS\nDIMENSIONS 2 2 2\nSPACING 1 1 1\nORIGIN 0 0 0\n"; + for(const bool pointSection : {pointsFirst, !pointsFirst}) + { + output << (pointSection ? "POINT_DATA 8\n" : "CELL_DATA 1\n"); + output << "SCALARS Scalars short 1\nLOOKUP_TABLE default\n"; + WriteSectionValues(output, pointSection ? nonstd::span(pointScalars) : nonstd::span(cellScalars), binary); + output << "VECTORS Vectors int\n"; + WriteSectionValues(output, pointSection ? nonstd::span(pointVectors) : nonstd::span(cellVectors), binary); + } + output.close(); + REQUIRE(output.good()); + } + + ReadVtkStructuredPointsFilter filter; + DataStructure ds; + Arguments args = createArgs(filePath); + args.insertOrAssign(ReadVtkStructuredPointsFilter::k_ReadPointData_Key, std::make_any(readPoints)); + args.insertOrAssign(ReadVtkStructuredPointsFilter::k_ReadCellData_Key, std::make_any(readCells)); + const auto preflightResult = filter.preflight(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + const auto executeResult = filter.execute(ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + for(const bool pointSection : {false, true}) + { + const DataPath geomPath({pointSection ? "VTK Point Data" : "VTK Cell Data"}); + if(!(pointSection ? readPoints : readCells)) + { + REQUIRE(ds.getData(geomPath) == nullptr); + continue; + } + const auto* geom = ds.getDataAs(geomPath); + REQUIRE(geom != nullptr); + REQUIRE(geom->getDimensions() == (pointSection ? SizeVec3{2, 2, 2} : SizeVec3{1, 1, 1})); + const DataPath matrixPath = geomPath.createChildPath(pointSection ? "Vertex Data" : "Cell Data"); + const auto* scalars = ds.getDataAs(matrixPath.createChildPath("Scalars")); + const auto* vectors = ds.getDataAs(matrixPath.createChildPath("Vectors")); + REQUIRE(scalars != nullptr); + REQUIRE(vectors != nullptr); + const auto expectedScalars = pointSection ? nonstd::span(pointScalars) : nonstd::span(cellScalars); + const auto expectedVectors = pointSection ? nonstd::span(pointVectors) : nonstd::span(cellVectors); + REQUIRE(scalars->getNumberOfTuples() == expectedScalars.size()); + REQUIRE(scalars->getNumberOfComponents() == 1); + REQUIRE(vectors->getNumberOfTuples() == expectedScalars.size()); + REQUIRE(vectors->getNumberOfComponents() == 3); + for(usize index = 0; index < expectedScalars.size(); index++) + { + REQUIRE((*scalars)[index] == expectedScalars[index]); + } + for(usize index = 0; index < expectedVectors.size(); index++) + { + REQUIRE((*vectors)[index] == expectedVectors[index]); + } + } + UnitTest::CheckArraysInheritTupleDims(ds); +} + TEST_CASE("SimplnxCore::ReadVtkStructuredPointsFilter", "[SimplnxCore][ReadVtkStructuredPointsFilter]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp index 4b1e46488d..0edbcfdbab 100644 --- a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp +++ b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp @@ -1,3 +1,4 @@ +#include "SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.hpp" #include "SimplnxCore/Filters/ScalarSegmentFeaturesFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" @@ -17,6 +18,8 @@ #include +#include +#include #include using namespace nx::core; @@ -599,3 +602,101 @@ TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Geometry cell tuple count", "[Sim REQUIRE(errorRef.message.find("'Image' has 12 cells") != std::string::npos); } } + +TEMPLATE_TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Exact 64-bit Integer Differences", "[SimplnxCore][ScalarSegmentFeatures][IntegerPrecision]", int64, uint64) +{ + UnitTest::LoadPlugins(); + const auto scenario = GENERATE(from_range(UnitTest::SelectAlgorithmTestScenariosForInMemoryStores())); + CAPTURE(scenario); + UnitTest::AlgorithmTestScope scope(scenario); + const bool alongZ = GENERATE(false, true); + const auto neighborScheme = GENERATE(ChoicesParameter::ValueType{0}, ChoicesParameter::ValueType{1}); + CAPTURE(alongZ, neighborScheme); + + constexpr TestType k_Large = TestType{1} << 53; + constexpr TestType k_Min = std::numeric_limits::min(); + constexpr TestType k_Max = std::numeric_limits::max(); + constexpr int32 k_MaxTolerance = std::numeric_limits::max(); + struct Fixture + { + std::array values; + int32 tolerance; + std::array expectedIds; + }; + // Hand oracle: unequal neighbors stay separate at tolerance zero; the repeated + // pair joins. Differences of one join at tolerance one. The full type range + // exceeds one, while max - (max - INT32_MAX) equals INT32_MAX exactly. + const std::array fixtures = {{ + {{k_Large, k_Large + 1, k_Large + 1}, 0, {1, 2, 2}}, + {{k_Large + 1, k_Large, k_Large}, 0, {1, 2, 2}}, + {{k_Max, k_Max - 1, k_Max - 1}, 0, {1, 2, 2}}, + {{k_Min, k_Min + 1, k_Min + 1}, 0, {1, 2, 2}}, + {{k_Min, k_Max, k_Max}, 0, {1, 2, 2}}, + {{k_Large, k_Large + 1, k_Large + 1}, 1, {1, 1, 1}}, + {{k_Max, k_Min, k_Min}, 1, {1, 2, 2}}, + {{k_Max, k_Max - k_MaxTolerance, k_Max - k_MaxTolerance}, k_MaxTolerance, {1, 1, 1}}, + }}; + for(usize fixtureIndex = 0; fixtureIndex < fixtures.size(); fixtureIndex++) + { + DYNAMIC_SECTION("Fixture " << fixtureIndex) + { + const auto& fixture = fixtures[fixtureIndex]; + CAPTURE(fixture.values, fixture.tolerance); + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, std::string(k_GeomName)); + REQUIRE(imageGeom != nullptr); + imageGeom->setDimensions(alongZ ? SizeVec3{1, 1, 3} : SizeVec3{3, 1, 1}); + const ShapeType cellShape = alongZ ? ShapeType{3, 1, 1} : ShapeType{1, 1, 3}; + auto* cellAM = AttributeMatrix::Create(dataStructure, std::string(k_CellDataName), cellShape, imageGeom->getId()); + REQUIRE(cellAM != nullptr); + imageGeom->setCellData(*cellAM); + const DataPath scalarPath = k_GeomPath.createChildPath(k_CellDataName).createChildPath("ScalarData"); + auto scalarStore = DataStoreUtilities::CreateDataStore(dataStructure, scalarPath, cellShape, {1}); + auto* scalarArray = DataArray::Create(dataStructure, "ScalarData", scalarStore, cellAM->getId()); + REQUIRE(scalarArray != nullptr); + auto writeResult = scalarStore->copyFromBuffer(0, nonstd::span(fixture.values.data(), fixture.values.size())); + SIMPLNX_RESULT_REQUIRE_VALID(writeResult); + + ScalarSegmentFeaturesFilter filter; + Arguments args; + SetupArgs(args, false, false, fixture.tolerance, neighborScheme, false); + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = scope.executeFilter(filter, dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_FeatureIdsPath)); + const auto& featureIds = dataStructure.getDataRefAs(k_FeatureIdsPath); + std::array actualIds = {}; + auto readResult = featureIds.getDataStoreRef().copyIntoBuffer(0, nonstd::span(actualIds.data(), actualIds.size())); + SIMPLNX_RESULT_REQUIRE_VALID(readResult); + CHECK(actualIds == fixture.expectedIds); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_ActivePath)); + CHECK(dataStructure.getDataRefAs(k_ActivePath).getNumberOfTuples() == static_cast(fixture.expectedIds.back() + 1)); + + // The public CCL entry point remains callable after operator() releases its + // slice buffers. Its native comparator must obey the same integer oracle. + ScalarSegmentFeaturesInputValues inputValues{}; + inputValues.ScalarTolerance = fixture.tolerance; + inputValues.NeighborScheme = static_cast(neighborScheme); + inputValues.ImageGeometryPath = k_GeomPath; + inputValues.InputDataPath = scalarPath; + inputValues.FeatureIdsArrayPath = k_FeatureIdsPath; + inputValues.CellFeatureAttributeMatrixPath = k_ActivePath.getParent(); + inputValues.ActiveArrayPath = k_ActivePath; + const std::atomic_bool shouldCancel = false; + const IFilter::MessageHandler messageHandler; + ScalarSegmentFeatures algorithm(dataStructure, &inputValues, shouldCancel, messageHandler); + auto initializeResult = scope.execute([&] { return algorithm(); }); + SIMPLNX_RESULT_REQUIRE_VALID(initializeResult); + auto& featureIdsStoreRef = dataStructure.getDataRefAs(k_FeatureIdsPath).getDataStoreRef(); + auto unbufferedResult = scope.execute([&] { return algorithm.executeCCL(imageGeom, featureIdsStoreRef); }); + SIMPLNX_RESULT_REQUIRE_VALID(unbufferedResult); + auto unbufferedReadResult = featureIdsStoreRef.copyIntoBuffer(0, nonstd::span(actualIds.data(), actualIds.size())); + SIMPLNX_RESULT_REQUIRE_VALID(unbufferedReadResult); + INFO("Public CCL after slice-buffer release"); + CHECK(actualIds == fixture.expectedIds); + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } + } +} diff --git a/src/Plugins/SimplnxCore/test/TriangleDihedralAngleFilterTest.cpp b/src/Plugins/SimplnxCore/test/TriangleDihedralAngleFilterTest.cpp index 12a3f606b6..21189f4cda 100644 --- a/src/Plugins/SimplnxCore/test/TriangleDihedralAngleFilterTest.cpp +++ b/src/Plugins/SimplnxCore/test/TriangleDihedralAngleFilterTest.cpp @@ -11,8 +11,12 @@ #include +#include +#include +#include #include #include +#include #include namespace fs = std::filesystem; @@ -159,3 +163,83 @@ TEST_CASE("SimplnxCore::TriangleDihedralAngleFilter: SIMPL Backwards Compatibili } } } + +TEST_CASE("SimplnxCore::TriangleDihedralAngleFilter: Permutation invariant minimum", "[SimplnxCore][TriangleDihedralAngleFilter]") +{ + UnitTest::LoadPlugins(); + + // Issue #1790 (70-30-80 degrees); a right triangle with atan(1/10) at B; an obtuse triangle (135 degrees at A); + // a collinear face (C = -2B exactly in float32) whose normalized dot products round past +/-1 without the clamp. + const std::array, 4> fixtures = { + {{{0, 0, 0, 1, 0, 0, 0.1736481785774231F, 0.477094441652298F, 0}}, {{0, 0, 0, 10, 0, 0, 0, 1, 0}}, {{0, 0, 0, 1, 0, 0, -0.5F, 0.5F, 0}}, {{0, 0, 0, 0.7F, 3.0F, 0.1F, -1.4F, -6.0F, -0.2F}}}}; + // Closed-form minimum interior angle of each fixture, in degrees. + const float64 radToDeg = 180.0 / std::acos(-1.0); + const std::array closedFormMinimums = {{30.0, std::atan(0.1) * radToDeg, std::atan(1.0 / 3.0) * radToDeg, 0.0}}; + // ABC, BCA, CAB, followed by their reversed windings. + const std::array orders = {{0, 1, 2, 1, 2, 0, 2, 0, 1, 0, 2, 1, 2, 1, 0, 1, 0, 2}}; + for(usize fixture = 0; fixture < fixtures.size(); ++fixture) + { + CAPTURE(fixture); + DataStructure dataStructure; + auto* geom = TriangleGeom::Create(dataStructure, "Triangles"); + REQUIRE(geom != nullptr); + auto* faceData = AttributeMatrix::Create(dataStructure, "Face Data", std::vector{6}, geom->getId()); + auto* vertexData = AttributeMatrix::Create(dataStructure, "Vertex Data", std::vector{3}, geom->getId()); + REQUIRE(faceData != nullptr); + REQUIRE(vertexData != nullptr); + geom->setFaceAttributeMatrix(*faceData); + geom->setVertexAttributeMatrix(*vertexData); + auto* vertices = Float32Array::CreateWithStore>(dataStructure, "Vertices", std::vector{3}, std::vector{3}, geom->getId()); + auto* faces = DataArray::CreateWithStore>(dataStructure, "Faces", std::vector{6}, std::vector{3}, geom->getId()); + REQUIRE(vertices != nullptr); + REQUIRE(faces != nullptr); + for(usize i = 0; i < 9; ++i) + { + (*vertices)[i] = fixtures[fixture][i]; + } + for(usize i = 0; i < orders.size(); ++i) + { + (*faces)[i] = orders[i]; + } + geom->setVertices(*vertices); + geom->setFaceList(*faces); + + // Independent reference: atan2 of each corner's cross magnitude and dot. + float64 expected = std::numeric_limits::infinity(); + for(usize corner = 0; corner < 3; ++corner) + { + std::array toNext{}; + std::array toPrev{}; + for(usize axis = 0; axis < 3; ++axis) + { + toNext[axis] = static_cast((*vertices)[3 * ((corner + 1) % 3) + axis]) - (*vertices)[3 * corner + axis]; + toPrev[axis] = static_cast((*vertices)[3 * ((corner + 2) % 3) + axis]) - (*vertices)[3 * corner + axis]; + } + const std::array cross = {{toNext[1] * toPrev[2] - toNext[2] * toPrev[1], toNext[2] * toPrev[0] - toNext[0] * toPrev[2], toNext[0] * toPrev[1] - toNext[1] * toPrev[0]}}; + const float64 dot = toNext[0] * toPrev[0] + toNext[1] * toPrev[1] + toNext[2] * toPrev[2]; + const float64 magnitude = std::sqrt(cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]); + expected = std::min(expected, std::atan2(magnitude, dot) * radToDeg); + } + // The computed oracle must agree with the hand-derived closed form. + REQUIRE(std::abs(expected - closedFormMinimums[fixture]) < 1.0e-5); + TriangleDihedralAngleFilter filter; + Arguments args; + args.insertOrAssign(TriangleDihedralAngleFilter::k_TGeometryDataPath_Key, std::make_any(DataPath({"Triangles"}))); + args.insertOrAssign(TriangleDihedralAngleFilter::k_SurfaceMeshTriangleDihedralAnglesArrayName_Key, std::make_any("Minimum")); + auto preflight = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflight.outputActions); + auto result = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(result.result); + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"Triangles", "Face Data", "Minimum"}))); + const auto& output = dataStructure.getDataRefAs(DataPath({"Triangles", "Face Data", "Minimum"})); + for(usize face = 0; face < 6; ++face) + { + CAPTURE(face, expected, output[face]); + CHECK(std::isfinite(output[face])); + // Allow float32 edge-subtraction rounding; the oracle subtracts in float64. + CHECK(std::abs(output[face] - expected) < 1.0e-5); + CHECK(output[face] <= 60.0 + 1.0e-5); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); + } +} diff --git a/src/Plugins/SimplnxCore/test/UncertainRegularGridSampleSurfaceMeshTest.cpp b/src/Plugins/SimplnxCore/test/UncertainRegularGridSampleSurfaceMeshTest.cpp index 9ebcec3dd0..013cf89d15 100644 --- a/src/Plugins/SimplnxCore/test/UncertainRegularGridSampleSurfaceMeshTest.cpp +++ b/src/Plugins/SimplnxCore/test/UncertainRegularGridSampleSurfaceMeshTest.cpp @@ -2,14 +2,17 @@ #include #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/Geometry/TriangleGeom.hpp" #include "simplnx/Parameters/NumberParameter.hpp" #include "simplnx/Parameters/VectorParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/DataStoreUtilities.hpp" #include "SimplnxCore/Filters/UncertainRegularGridSampleSurfaceMeshFilter.hpp" +#include #include #include #include @@ -132,3 +135,72 @@ TEST_CASE("SimplnxCore::UncertainRegularGridSampleSurfaceMeshFilter: SIMPL Backw } } } + +TEST_CASE("SimplnxCore::UncertainRegularGridSampleSurfaceMeshFilter: Ray exits long thin feature", "[SimplnxCore][UncertainRegularGridSampleSurfaceMeshFilter][SampleSurfaceMeshRay]") +{ + UnitTest::LoadPlugins(); + DataStructure dataStructure; + + // A sheared 1 x 1 x 20 box: 0 < x < 1, 0 < y - z/8 < 1, 0 < z < 20. + // The shear keeps a long ray inside the thin feature and uses exact binary coordinates. + // The first ray from IsPointInPolyhedron() is deterministic. With the current Ray basis its + // direction is close to (0, 1/8, 1), so the shear y = z/8 follows it. A ray of half the box + // diagonal then ends inside the feature at z = 1. If the Ray basis or the random numbers change + // (see issue #1861), check again that this test fails with a half-diagonal ray. + const std::array vertices = {0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 0, 2.5F, 20, 1, 2.5F, 20, 1, 3.5F, 20, 0, 3.5F, 20}; + const std::array faces = {0, 2, 1, 0, 3, 2, 4, 5, 6, 4, 6, 7, 0, 1, 5, 0, 5, 4, 1, 2, 6, 1, 6, 5, 2, 3, 7, 2, 7, 6, 3, 0, 4, 3, 4, 7}; + const std::array labels = {1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0}; + + auto* triangleGeom = TriangleGeom::Create(dataStructure, k_TriGeomName); + REQUIRE(triangleGeom != nullptr); + auto* verticesArray = + Float32Array::Create(dataStructure, "Vertices", DataStoreUtilities::CreateDataStore(dataStructure, k_TriGeomPath.createChildPath("Vertices"), {8}, {3}), triangleGeom->getId()); + auto* facesArray = IGeometry::MeshIndexArrayType::Create( + dataStructure, "Faces", DataStoreUtilities::CreateDataStore(dataStructure, k_TriGeomPath.createChildPath("Faces"), {12}, {3}), triangleGeom->getId()); + REQUIRE(verticesArray != nullptr); + REQUIRE(facesArray != nullptr); + auto verticesResult = verticesArray->getDataStoreRef().copyFromBuffer(0, nonstd::span(vertices)); + SIMPLNX_RESULT_REQUIRE_VALID(verticesResult); + auto facesResult = facesArray->getDataStoreRef().copyFromBuffer(0, nonstd::span(faces)); + SIMPLNX_RESULT_REQUIRE_VALID(facesResult); + triangleGeom->setVertices(*verticesArray); + triangleGeom->setFaceList(*facesArray); + auto* faceData = AttributeMatrix::Create(dataStructure, Constants::k_FaceData, ShapeType{12}, triangleGeom->getId()); + REQUIRE(faceData != nullptr); + triangleGeom->setFaceAttributeMatrix(*faceData); + auto* faceLabels = Int32Array::Create(dataStructure, Constants::k_FaceLabels, DataStoreUtilities::CreateDataStore(dataStructure, k_FaceLabelsPath, {12}, {2}), faceData->getId()); + REQUIRE(faceLabels != nullptr); + auto labelsResult = faceLabels->getDataStoreRef().copyFromBuffer(0, nonstd::span(labels)); + SIMPLNX_RESULT_REQUIRE_VALID(labelsResult); + + const float32 z = GENERATE(1.0F, 19.0F); + CAPTURE(z); + UncertainRegularGridSampleSurfaceMeshFilter filter; + auto args = filter.getDefaultArguments(); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_UseSeed_Key, true); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_SeedValue_Key, uint64{5489}); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_Uncertainty_Key, std::vector{0, 0, 0}); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_Dimensions_Key, std::vector{3, 1, 1}); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_Spacing_Key, std::vector{1, 1, 1}); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_Origin_Key, std::vector{-1, z / 8, z - 0.5F}); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_TriangleGeometryPath_Key, k_TriGeomPath); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_SurfaceMeshFaceLabelsArrayPath_Key, k_FaceLabelsPath); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_ImageGeomPath_Key, k_GeneratedImageGeomPath); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_CellAMName_Key, std::string(Constants::k_CellData)); + args.insertOrAssign(UncertainRegularGridSampleSurfaceMeshFilter::k_FeatureIdsArrayName_Key, std::string(Constants::k_FeatureIds)); + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_GeneratedFeatureIdsPath)); + const auto& featureIds = dataStructure.getDataRefAs(k_GeneratedFeatureIdsPath); + std::array actual = {}; + auto readResult = featureIds.getDataStoreRef().copyIntoBuffer(0, nonstd::span(actual)); + SIMPLNX_RESULT_REQUIRE_VALID(readResult); + // Centers have x = {-0.5, 0.5, 1.5}, y - z/8 = 0.5, and z = 1 or 19. + // Only the middle center satisfies all three strict box inequalities. + const std::array expected = {0, 1, 0}; + CHECK(actual == expected); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} diff --git a/src/Plugins/SimplnxCore/test/WriteLAMMPSFileTest.cpp b/src/Plugins/SimplnxCore/test/WriteLAMMPSFileTest.cpp index b342addedd..36bb44b1cf 100644 --- a/src/Plugins/SimplnxCore/test/WriteLAMMPSFileTest.cpp +++ b/src/Plugins/SimplnxCore/test/WriteLAMMPSFileTest.cpp @@ -1,7 +1,10 @@ #include "SimplnxCore/SimplnxCore_test_dirs.hpp" #include +#include "simplnx/Common/ScopeGuard.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/Filter/Actions/CreateArrayAction.hpp" +#include "simplnx/Filter/Actions/CreateVertexGeometryAction.hpp" #include "simplnx/Parameters/ArraySelectionParameter.hpp" #include "simplnx/Parameters/FileSystemPathParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" @@ -10,6 +13,7 @@ #include "SimplnxCore/Filters/WriteLAMMPSFileFilter.hpp" +#include #include #include @@ -96,6 +100,76 @@ TEST_CASE("SimplnxCore::WriteLAMMPSFileFilter: Valid Filter Execution", "[Simpln UnitTest::CheckArraysInheritTupleDims(dataStructure); } +TEST_CASE("SimplnxCore::WriteLAMMPSFileFilter: Eight Vertices", "[SimplnxCore][WriteLAMMPSFileFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure; + CreateVertexGeometryAction createGeometry(k_VertexGeom, 8, Constants::k_Vertex_Data, VertexGeom::k_SharedVertexListName); + auto geometryResult = createGeometry.apply(dataStructure, IDataAction::Mode::Execute); + SIMPLNX_RESULT_REQUIRE_VALID(geometryResult); + CreateArrayAction createLabels(DataType::int32, {8}, {1}, k_AtomLabelsGeom); + auto labelsResult = createLabels.apply(dataStructure, IDataAction::Mode::Execute); + SIMPLNX_RESULT_REQUIRE_VALID(labelsResult); + + auto* vertices = dataStructure.getDataAs(k_VertexGeom.createChildPath(VertexGeom::k_SharedVertexListName)); + auto* labels = dataStructure.getDataAs(k_AtomLabelsGeom); + REQUIRE(vertices != nullptr); + REQUIRE(labels != nullptr); + // Eight corners of a box, with distinct axis extents and two atom types. + const std::array coordinates = {0.0F, 0.0F, 0.0F, 1.0F, 0.0F, 0.0F, 0.0F, 2.0F, 0.0F, 1.0F, 2.0F, 0.0F, 0.0F, 0.0F, 3.0F, 1.0F, 0.0F, 3.0F, 0.0F, 2.0F, 3.0F, 1.0F, 2.0F, 3.0F}; + const std::array atomLabels = {1, 2, 1, 2, 2, 1, 2, 1}; + auto coordinatesWriteResult = vertices->getDataStoreRef().copyFromBuffer(0, nonstd::span(coordinates.data(), coordinates.size())); + SIMPLNX_RESULT_REQUIRE_VALID(coordinatesWriteResult); + auto labelsWriteResult = labels->getDataStoreRef().copyFromBuffer(0, nonstd::span(atomLabels.data(), atomLabels.size())); + SIMPLNX_RESULT_REQUIRE_VALID(labelsWriteResult); + + const fs::path outputPath = fs::path(unit_test::k_BinaryTestOutputDir.view()) / "LAMMPSEightVertices.txt"; + auto outputFileGuard = MakeScopeGuard([&outputPath]() noexcept { + std::error_code errorCode; + fs::remove(outputPath, errorCode); + }); + + WriteLAMMPSFileFilter filter; + Arguments args; + args.insertOrAssign(WriteLAMMPSFileFilter::k_OutputFile_Key, std::make_any(outputPath)); + args.insertOrAssign(WriteLAMMPSFileFilter::k_VertexGeomPath, std::make_any(k_VertexGeom)); + args.insertOrAssign(WriteLAMMPSFileFilter::k_AtomLabelsPath_Key, std::make_any(k_AtomLabelsGeom)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // Hand-written oracle: ids are one-based; labels and coordinates are unchanged. + const std::string expected = "LAMMPS data file\n" + "\n" + "8 atoms\n" + "\n" + "2 atom types\n" + "\n" + "0.000000 1.000000 xlo xhi\n" + "0.000000 2.000000 ylo yhi\n" + "0.000000 3.000000 zlo zhi\n" + "\n" + "Atoms\n" + "\n" + "1 1 0.000000 0.000000 0.000000 0 0 0\n" + "2 2 1.000000 0.000000 0.000000 0 0 0\n" + "3 1 0.000000 2.000000 0.000000 0 0 0\n" + "4 2 1.000000 2.000000 0.000000 0 0 0\n" + "5 2 0.000000 0.000000 3.000000 0 0 0\n" + "6 1 1.000000 0.000000 3.000000 0 0 0\n" + "7 2 0.000000 2.000000 3.000000 0 0 0\n" + "8 1 1.000000 2.000000 3.000000 0 0 0\n" + "\n"; + REQUIRE(fs::exists(outputPath)); + const std::vector contents = readIn(outputPath); + REQUIRE(std::string(contents.begin(), contents.end()) == expected); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + TEST_CASE("SimplnxCore::WriteLAMMPSFileFilter: SIMPL Backwards Compatibility", "[SimplnxCore][WriteLAMMPSFileFilter][BackwardsCompatibility]") { auto app = Application::GetOrCreateInstance(); diff --git a/src/Plugins/SimplnxCore/test/WriteVtkRectilinearGridTest.cpp b/src/Plugins/SimplnxCore/test/WriteVtkRectilinearGridTest.cpp index 0f166f3f50..1a6eb8ed6c 100644 --- a/src/Plugins/SimplnxCore/test/WriteVtkRectilinearGridTest.cpp +++ b/src/Plugins/SimplnxCore/test/WriteVtkRectilinearGridTest.cpp @@ -1,6 +1,7 @@ #include "SimplnxCore/SimplnxCore_test_dirs.hpp" #include +#include "simplnx/Common/Types.hpp" #include "simplnx/Core/Application.hpp" #include "simplnx/DataStructure/AttributeMatrix.hpp" #include "simplnx/DataStructure/DataArray.hpp" @@ -11,16 +12,23 @@ #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/DataStoreUtilities.hpp" #include "SimplnxCore/Filters/WriteVtkRectilinearGridFilter.hpp" +#include + #include +#include #include #include #include +#include #include #include #include +#include +#include #include #include namespace fs = std::filesystem; @@ -35,6 +43,84 @@ const DataPath k_BulkImagePath({"BulkImage"}); const DataPath k_BulkCellDataPath = k_BulkImagePath.createChildPath("CellData"); const DataPath k_BulkBoolPath = k_BulkCellDataPath.createChildPath("BoolValues"); +bool CompareVtkAsciiFiles(std::istream& computedFile, std::istream& exemplarFile) +{ + std::string computedLine; + std::string exemplarLine; + std::string sectionType; + for(usize lineIndex = 0; std::getline(exemplarFile, exemplarLine); lineIndex++) + { + if(!std::getline(computedFile, computedLine)) + { + return false; + } + if(lineIndex == 1) // Skip the version line, as in the binary comparison. + { + continue; + } + std::istringstream header(exemplarLine); + std::string headerName; + header >> headerName; + const bool isTypeHeader = headerName == "SCALARS" || headerName == "X_COORDINATES" || headerName == "Y_COORDINATES" || headerName == "Z_COORDINATES"; + if(isTypeHeader) + { + std::string nameOrCount; + if(!(header >> nameOrCount >> sectionType)) + { + return false; + } + } + std::istringstream computedTokens(computedLine); + std::istringstream exemplarTokens(exemplarLine); + std::string computedToken; + std::string exemplarToken; + while(exemplarTokens >> exemplarToken) + { + if(!(computedTokens >> computedToken)) + { + return false; + } + if(computedToken == exemplarToken) + { + continue; + } + if(isTypeHeader || (sectionType != "float" && sectionType != "double")) + { + return false; + } + const usize digitStart = exemplarToken.front() == '-' || exemplarToken.front() == '+' ? 1 : 0; + const usize decimalPoint = exemplarToken.find('.'); + const auto isDigit = [](char character) { return character >= '0' && character <= '9'; }; + if(decimalPoint == std::string::npos || decimalPoint <= digitStart || exemplarToken.size() - decimalPoint - 1 != 6 || + !std::all_of(exemplarToken.begin() + static_cast(digitStart), exemplarToken.begin() + static_cast(decimalPoint), isDigit) || + !std::all_of(exemplarToken.begin() + static_cast(decimalPoint) + 1, exemplarToken.end(), isDigit)) + { + return false; + } + try + { + usize parsedCharacters = 0; + const std::string roundedToken = sectionType == "float" ? fmt::format("{:f}", std::stof(computedToken, &parsedCharacters)) : fmt::format("{:f}", std::stod(computedToken, &parsedCharacters)); + if(parsedCharacters != computedToken.size() || roundedToken != exemplarToken) + { + return false; + } + } catch(const std::invalid_argument&) + { + return false; + } catch(const std::out_of_range&) + { + return false; + } + } + if(computedTokens >> computedToken) + { + return false; + } + } + return exemplarFile.eof() && !exemplarFile.bad() && !std::getline(computedFile, computedLine) && computedFile.eof() && !computedFile.bad(); +} + class VtkFailOnLaterReadStore : public DataStore { public: @@ -162,6 +248,118 @@ std::string ExpectedBulkVtk(bool binary) } } // namespace +TEST_CASE("SimplnxCore::WriteVtkRectilinearGridFilter: ASCII comparison preserves exemplar precision", "[SimplnxCore][WriteVtkRectilinearGridFilter][VtkAsciiComparison]") +{ + UnitTest::LoadPlugins(); + + const auto compare = [](const std::string& computed, const std::string& exemplar) { + std::istringstream computedFile(computed); + std::istringstream exemplarFile(exemplar); + return CompareVtkAsciiFiles(computedFile, exemplarFile); + }; + const auto compareData = [&compare](const std::string& computed, const std::string& exemplar) { + const std::string header = "SCALARS Values double 1\nversion\n"; + return compare(header + computed, header + exemplar); + }; + CHECK(compare("SCALARS Values float 1\nnew version\n -1 1e-7 1.2345674\n", "SCALARS Values float 1\nold version\n -1.000000 0.000000 1.234567\n")); + CHECK_FALSE(compareData("1.234568\n", "1.234567\n")); + CHECK_FALSE(compareData("1 2\n", "1.000000\n")); + CHECK_FALSE(compareData("1\n", "1.000000 2.000000\n")); + CHECK_FALSE(compareData("1\nextra\n", "1.000000\n")); + CHECK_FALSE(compareData("1\n", "1.000000\nextra\n")); + CHECK_FALSE(compare("SCALARS Other float 1\n", "SCALARS Values float 1\n")); + CHECK_FALSE(compareData("1.0\n", "1\n")); + CHECK_FALSE(compareData("1\n", "1.00000\n")); + CHECK_FALSE(compareData("1junk\n", "1.000000\n")); + CHECK_FALSE(compareData("invalid\n", "1.000000\n")); + CHECK_FALSE(compareData("1e999\n", "1.000000\n")); + + CHECK(compare("SCALARS Values float 1\nversion\n3.5833335\n", "SCALARS Values float 1\nversion\n3.583333\n")); + CHECK_FALSE(compare("SCALARS Values float 1\nversion\n3.5833335\n", "SCALARS Values float 1\nversion\n3.583334\n")); + CHECK(compareData("3.5833335\n", "3.583334\n")); + CHECK_FALSE(compareData("3.5833335\n", "3.583333\n")); + + for(const std::string axis : {"X_COORDINATES", "Y_COORDINATES", "Z_COORDINATES"}) + { + CAPTURE(axis); + const std::string header = axis + " 1 float\nversion\n"; + CHECK(compare(header + "3.5833335\n", header + "3.583333\n")); + } + for(const std::string type : {"char", "unsigned_char", "short", "unsigned_short", "int", "unsigned_int", "long", "unsigned_long"}) + { + CAPTURE(type); + const std::string header = "SCALARS Values " + type + " 1\nversion\n"; + CHECK(compare(header + "1\n", header + "1\n")); + CHECK_FALSE(compare(header + "1\n", header + "1.000000\n")); + } + CHECK(compare("X_COORDINATES 1 float\nversion\n3.5833335\nSCALARS Values double 1\nLOOKUP_TABLE default\n3.5833335\n", + "X_COORDINATES 1 float\nversion\n3.583333\nSCALARS Values double 1\nLOOKUP_TABLE default\n3.583334\n")); + CHECK_FALSE(compare("SCALARS Values float 1\nversion\n3.5833335\nSCALARS Ids int 1\nLOOKUP_TABLE default\n1\n", + "SCALARS Values float 1\nversion\n3.583333\nSCALARS Ids int 1\nLOOKUP_TABLE default\n1.000000\n")); +} + +TEMPLATE_TEST_CASE("SimplnxCore::WriteVtkRectilinearGridFilter: ASCII floating point round trip", "[SimplnxCore][WriteVtkRectilinearGridFilter][VtkAsciiFloats]", float32, float64) +{ + UnitTest::LoadPlugins(); + + // The serialization oracle is the identity: parsing must recover each input + // exactly, including values smaller than six fixed decimal places can retain. + // 1/3 has no short decimal form, so it needs the full precision of each type. + constexpr std::array k_ExpectedValues = {static_cast(1.0e-7), static_cast(123456.789), static_cast(-2.5e-12), static_cast(1.0 / 3.0)}; + const DataPath imagePath({"FloatImage"}); + const DataPath cellPath = imagePath.createChildPath("CellData"); + const DataPath valuesPath = cellPath.createChildPath("Values"); + const ShapeType tupleShape = {1, 1, 4}; + + DataStructure dataStructure; + auto* imagePtr = ImageGeom::Create(dataStructure, imagePath.getTargetName()); + REQUIRE(imagePtr != nullptr); + imagePtr->setDimensions({4, 1, 1}); + imagePtr->setSpacing({1.0F, 1.0F, 1.0F}); + imagePtr->setOrigin({0.0F, 0.0F, 0.0F}); + auto* cellDataPtr = AttributeMatrix::Create(dataStructure, cellPath.getTargetName(), tupleShape, imagePtr->getId()); + REQUIRE(cellDataPtr != nullptr); + imagePtr->setCellData(*cellDataPtr); + auto store = DataStoreUtilities::CreateDataStore(dataStructure, valuesPath, tupleShape, {1}); + auto* valuesPtr = DataArray::Create(dataStructure, valuesPath.getTargetName(), store, cellDataPtr->getId()); + REQUIRE(valuesPtr != nullptr); + auto copyResult = store->copyFromBuffer(0, nonstd::span(k_ExpectedValues.data(), k_ExpectedValues.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyResult); + + const std::string vtkType = std::is_same_v ? "float" : "double"; + const fs::path outputPath = fs::path(unit_test::k_BinaryTestOutputDir.view()) / ("VtkAsciiFloats_" + vtkType + ".vtk"); + const WriteVtkRectilinearGridFilter filter; + Arguments args = filter.getDefaultArguments(); + args.insertOrAssign(WriteVtkRectilinearGridFilter::k_OutputFile_Key, std::make_any(outputPath)); + args.insertOrAssign(WriteVtkRectilinearGridFilter::k_WriteBinaryFile_Key, std::make_any(false)); + args.insertOrAssign(WriteVtkRectilinearGridFilter::k_ImageGeometryPath_Key, std::make_any(imagePath)); + args.insertOrAssign(WriteVtkRectilinearGridFilter::k_SelectedDataArrayPaths_Key, std::make_any(MultiArraySelectionParameter::ValueType{valuesPath})); + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + std::ifstream output(outputPath); + REQUIRE(output.is_open()); + std::string line; + while(std::getline(output, line) && line != "SCALARS Values " + vtkType + " 1") + { + } + REQUIRE(line == "SCALARS Values " + vtkType + " 1"); + REQUIRE(std::getline(output, line)); + REQUIRE(line == "LOOKUP_TABLE default"); + for(usize index = 0; index < k_ExpectedValues.size(); index++) + { + CAPTURE(vtkType, index); + TestType actual = 0; + REQUIRE(output >> actual); + CHECK(actual == k_ExpectedValues[index]); + } + output >> std::ws; + REQUIRE(output.eof()); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + TEST_CASE("SimplnxCore::WriteVtkRectilinearGridFilter: Valid Filter Execution", "[SimplnxCore][WriteVtkRectilinearGridFilter]") { UnitTest::LoadPlugins(); @@ -209,7 +407,9 @@ TEST_CASE("SimplnxCore::WriteVtkRectilinearGridFilter: Valid Filter Execution", std::vector linesToSkip{1}; // skip the version line std::ifstream computedFile(computedOutputPath); std::ifstream exemplarFile(exemplarOutputPath); - UnitTest::CompareAsciiFiles(computedFile, exemplarFile, linesToSkip); + REQUIRE(computedFile.is_open()); + REQUIRE(exemplarFile.is_open()); + REQUIRE(CompareVtkAsciiFiles(computedFile, exemplarFile)); std::ifstream computedBinaryFile(computedBinaryOutputPath, std::ios::binary); std::ifstream exemplarBinaryFile(exemplarBinaryOutputPath, std::ios::binary); UnitTest::CompareAsciiFiles(computedBinaryFile, exemplarBinaryFile, linesToSkip); diff --git a/src/Plugins/SimplnxCore/test/simpl_conversion/6_4/ComputeFeaturePhasesBinaryFilter.json b/src/Plugins/SimplnxCore/test/simpl_conversion/6_4/ComputeFeaturePhasesBinaryFilter.json index 6d585e3d01..fdc68d9bd7 100644 --- a/src/Plugins/SimplnxCore/test/simpl_conversion/6_4/ComputeFeaturePhasesBinaryFilter.json +++ b/src/Plugins/SimplnxCore/test/simpl_conversion/6_4/ComputeFeaturePhasesBinaryFilter.json @@ -20,8 +20,9 @@ }, "FeaturePhasesArrayPath": { "Data Container Name": "DataContainer", - "Attribute Matrix Name": "CellData", + "Attribute Matrix Name": "CellFeatureData", "Data Array Name": "TestArray" - } + }, + "CellEnsembleAttributeMatrixName": "Legacy Ensembles" } } diff --git a/src/Plugins/SimplnxCore/test/simpl_conversion/6_5/ComputeFeaturePhasesBinaryFilter.json b/src/Plugins/SimplnxCore/test/simpl_conversion/6_5/ComputeFeaturePhasesBinaryFilter.json index 4cd054261e..5d3aac9e7e 100644 --- a/src/Plugins/SimplnxCore/test/simpl_conversion/6_5/ComputeFeaturePhasesBinaryFilter.json +++ b/src/Plugins/SimplnxCore/test/simpl_conversion/6_5/ComputeFeaturePhasesBinaryFilter.json @@ -21,8 +21,9 @@ }, "FeaturePhasesArrayPath": { "Data Container Name": "DataContainer", - "Attribute Matrix Name": "CellData", + "Attribute Matrix Name": "CellFeatureData", "Data Array Name": "TestArray" - } + }, + "CellEnsembleAttributeMatrixName": "Legacy Ensembles" } } diff --git a/src/Plugins/SimplnxCore/vv/ComputeFeaturePhasesFilter.md b/src/Plugins/SimplnxCore/vv/ComputeFeaturePhasesFilter.md index 960f9bb305..4f807b9cdb 100644 --- a/src/Plugins/SimplnxCore/vv/ComputeFeaturePhasesFilter.md +++ b/src/Plugins/SimplnxCore/vv/ComputeFeaturePhasesFilter.md @@ -120,4 +120,4 @@ See `vv/deviations/ComputeFeaturePhasesFilter.md` for full entries and compariso ## Note: ComputeFeaturePhasesBinaryFilter -`ComputeFeaturePhasesBinaryFilter` (UUID `16010080-a913-443a-b5b3-bd43391fe3c0`, SIMPL equivalent `FindFeaturePhasesBinary` UUID `64d20c7b-697c-5ff1-9d1d-8a27b071f363`) is a distinct algorithm requiring its own V&V document at `vv/ComputeFeaturePhasesBinaryFilter.md`. Its existing execution test (`bin_feature_phases.tar.gz`) should be audited for circular-oracle issues. +`ComputeFeaturePhasesBinaryFilter` (UUID `16010080-a913-443a-b5b3-bd43391fe3c0`, SIMPL equivalent `FindFeaturePhasesBinary` UUID `64d20c7b-697c-5ff1-9d1d-8a27b071f363`) is a distinct algorithm requiring its own V&V document at `vv/ComputeFeaturePhasesBinaryFilter.md`. Its tests use hand-computed Feature-level expectations for Cell-mask and Feature-mask modes. The `bin_feature_phases.tar.gz` archive contains only a Cell-level `BinaryPhases` output and is not used as a correctness oracle. diff --git a/src/simplnx/Parameters/ReadCSVFileParameter.cpp b/src/simplnx/Parameters/ReadCSVFileParameter.cpp index a6eaaf7f30..43558e8e82 100644 --- a/src/simplnx/Parameters/ReadCSVFileParameter.cpp +++ b/src/simplnx/Parameters/ReadCSVFileParameter.cpp @@ -115,7 +115,6 @@ constexpr StringLiteral k_DelimitersKey = "Wizard_Delimiters"; constexpr StringLiteral k_HeaderLineKey = "Wizard_HeaderLine"; constexpr StringLiteral k_HeaderIsCustomKey = "Wizard_HeaderIsCustom"; constexpr StringLiteral k_HeaderUsesDefaultsKey = "Wizard_HeaderUseDefaults"; -constexpr StringLiteral k_ConsecutiveDelimitersKey = "Wizard_ConsecutiveDelimiters"; constexpr StringLiteral k_TupleDimsKey = "Wizard_TupleDims"; std::vector ConvertCSVTypeStrings(const std::vector& dataTypes) @@ -186,7 +185,9 @@ Result ReadASCIIWizardDa value.headerMode = ValueType::HeaderMode::LINE; } - value.consecutiveDelimiters = static_cast(json[k_ConsecutiveDelimitersKey].get()); + // DREAM.3D 6.x dropped every empty token whatever Wizard_ConsecutiveDelimiters was, and saved its column counts that way. + // Collapse delimiters so converted pipelines read the same columns. + value.consecutiveDelimiters = true; return {std::move(value)}; } diff --git a/src/simplnx/Utilities/DataArrayUtilities.cpp b/src/simplnx/Utilities/DataArrayUtilities.cpp index 7594958cd2..13d7756980 100644 --- a/src/simplnx/Utilities/DataArrayUtilities.cpp +++ b/src/simplnx/Utilities/DataArrayUtilities.cpp @@ -366,18 +366,18 @@ Result<> ValidateFeatureIdsToFeatureAttributeMatrixIndexing(const DataStructure& } // Fixed-size bulk reads avoid one virtual store and cache operation per feature ID. - const usize totalTuples = featureIdsStore.getNumberOfTuples(); + const usize totalValues = featureIdsStore.getSize(); constexpr usize k_BatchSize = 40000; int32 globalMin = std::numeric_limits::max(); int32 globalMax = std::numeric_limits::lowest(); std::vector batch(k_BatchSize); - for(usize offset = 0; offset < totalTuples; offset += k_BatchSize) + for(usize offset = 0; offset < totalValues; offset += k_BatchSize) { if(shouldCancel != nullptr && *shouldCancel) { return {}; } - const usize count = std::min(k_BatchSize, totalTuples - offset); + const usize count = std::min(k_BatchSize, totalValues - offset); auto readResult = featureIdsStore.copyIntoBuffer(offset, nonstd::span(batch.data(), count)); if(readResult.invalid()) { diff --git a/src/simplnx/Utilities/FileUtilities.cpp b/src/simplnx/Utilities/FileUtilities.cpp index 2763ab3c46..51a7cea49b 100644 --- a/src/simplnx/Utilities/FileUtilities.cpp +++ b/src/simplnx/Utilities/FileUtilities.cpp @@ -408,7 +408,7 @@ Result<> ParseLine(std::fstream& inStream, const ParsersVector& dataParsers, con std::string line; std::getline(inStream, line); line = StringUtilities::replace(line, "\r", ""); - std::vector tokens = StringUtilities::split(line, delimiters, consecutiveDelimiters); + std::vector tokens = StringUtilities::split(line, delimiters, !consecutiveDelimiters); if(tokens.empty()) { // An empty interior row cannot provide one token for each configured column. @@ -507,7 +507,7 @@ Result> ReadHeaders(const std::string& inputFilePath, u std::string headersLine; std::getline(in, headersLine); - auto headers = StringUtilities::split(headersLine, delimiters, consecutiveDelimiters); + auto headers = StringUtilities::split(headersLine, delimiters, !consecutiveDelimiters); return {headers}; } } // namespace CSV diff --git a/src/simplnx/Utilities/FileUtilities.hpp b/src/simplnx/Utilities/FileUtilities.hpp index f923e9c031..4bc4276473 100644 --- a/src/simplnx/Utilities/FileUtilities.hpp +++ b/src/simplnx/Utilities/FileUtilities.hpp @@ -327,7 +327,7 @@ SIMPLNX_EXPORT Result<> FlushParsers(const ParsersVector& dataParsers); * @param dataParsers Supplies one parser or null entry for each column. * @param headers Supplies column names for diagnostics. * @param delimiters Specifies accepted delimiters. - * @param consecutiveDelimiters Selects whether adjacent delimiters form empty tokens. + * @param consecutiveDelimiters Treats adjacent delimiters as one separator when true; retains empty fields when false. * @param lineNumber Specifies the current one-based file line. * @param beginIndex Specifies the one-based line that maps to destination index zero. * @param flushRequired Receives true when one or more bounded parser buffers become full. @@ -367,7 +367,7 @@ SIMPLNX_EXPORT bool SkipNumberOfLines(std::fstream& inStream, usize numberOfLine * @param inputFilePath Identifies the CSV file. * @param headersLineNum Specifies the one-based header line. * @param delimiters Specifies accepted delimiters. - * @param consecutiveDelimiters Selects whether adjacent delimiters form empty tokens. + * @param consecutiveDelimiters Treats adjacent delimiters as one separator when true; retains empty fields when false. * @return Header tokens, or a file-open or line-position error. * @pre headersLineNum is at least one. */ diff --git a/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.cpp b/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.cpp index 9e68a30345..4e0cacda7b 100644 --- a/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.cpp +++ b/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.cpp @@ -585,7 +585,12 @@ const std::atomic_bool& ConvertColorToGrayScale::getCancel() Result<> ConvertColorToGrayScale::operator()() { - // Current behavior reuses the first output path for every selected input path. + if(m_InputValues->OutputDataArrayPaths.size() != m_InputValues->InputDataArrayPaths.size()) + { + return MakeErrorResult(-10706, fmt::format("Grayscale conversion needs one output path for each input path. Input paths: {}. Output paths: {}.", m_InputValues->InputDataArrayPaths.size(), + m_InputValues->OutputDataArrayPaths.size())); + } + auto outputPathIter = m_InputValues->OutputDataArrayPaths.begin(); for(const auto& arrayPath : m_InputValues->InputDataArrayPaths) { @@ -608,6 +613,7 @@ Result<> ConvertColorToGrayScale::operator()() { return result; } + ++outputPathIter; } return {}; diff --git a/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.hpp b/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.hpp index 9b72faa453..69ec498792 100644 --- a/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.hpp +++ b/src/simplnx/Utilities/ImageProcessing/ColorToGrayScale.hpp @@ -76,7 +76,7 @@ class SIMPLNX_EXPORT ConvertColorToGrayScale /** * @brief Converts selected RGB arrays to grayscale arrays. - * @return Error from the selected conversion, or success after cancellation. + * @return Error for unequal input/output path counts or from the selected conversion, or success after cancellation. * * The Direct path does not test cancellation within an array. The Scanline path checks before each chunk. */ @@ -105,7 +105,7 @@ SIMPLNX_EXPORT IFilter::PreflightResult PreflightColorToGrayScale(const DataStru * @param inputValues Contains conversion settings and array paths. * @param messageHandler Receives algorithm messages. * @param shouldCancel Stops conversion when the value is true. - * @return An error if a bulk datastore operation fails. + * @return An error if explicit input/output path counts differ or a bulk datastore operation fails. */ SIMPLNX_EXPORT Result<> ConvertColorToGrayScaleArrays(DataStructure& dataStructure, const ConvertColorToGrayScaleInputValues& inputValues, const IFilter::MessageHandler& messageHandler, const std::atomic_bool& shouldCancel); diff --git a/src/simplnx/Utilities/ImageProcessing/MorphologyEngine.hpp b/src/simplnx/Utilities/ImageProcessing/MorphologyEngine.hpp index f93bf2e9d2..6477f93bca 100644 --- a/src/simplnx/Utilities/ImageProcessing/MorphologyEngine.hpp +++ b/src/simplnx/Utilities/ImageProcessing/MorphologyEngine.hpp @@ -485,19 +485,31 @@ struct BinaryMorphology2DBlockBody void operator()(const Range& range) const { + // A single-slice image has no Z neighbors, including at its boundary. + const usize planeOffsets = static_cast(std::count_if(offsets, offsets + numOffsets, [](const SEOffset& offset) { return offset.dz == 0; })); for(usize tuple = range.min(); tuple < range.max(); ++tuple) { const usize localX = tuple % outputWidth; const usize localY = tuple / outputWidth; const usize x = outputXBegin + localX; const usize y = outputYBegin + localY; + // Removing Z offsets can leave an empty kernel, which preserves the input. + if(planeOffsets == 0) + { + output[tuple] = input[(y - inputYBegin) * inputWidth + (x - inputXBegin)]; + continue; + } usize foregroundCount = 0; for(usize offsetIndex = 0; offsetIndex < numOffsets; ++offsetIndex) { const SEOffset& offset = offsets[offsetIndex]; + if(offset.dz != 0) + { + continue; + } const int64 neighborX = static_cast(x) + offset.dx; const int64 neighborY = static_cast(y) + offset.dy; - const bool inBounds = offset.dz == 0 && neighborX >= 0 && neighborX < static_cast(dimX) && neighborY >= 0 && neighborY < static_cast(dimY); + const bool inBounds = neighborX >= 0 && neighborX < static_cast(dimX) && neighborY >= 0 && neighborY < static_cast(dimY); if(!inBounds) { foregroundCount += boundaryToForeground ? 1 : 0; @@ -506,7 +518,7 @@ struct BinaryMorphology2DBlockBody const usize inputIndex = (static_cast(neighborY) - inputYBegin) * inputWidth + (static_cast(neighborX) - inputXBegin); foregroundCount += input[inputIndex] == foreground ? 1 : 0; } - output[tuple] = dilate ? (foregroundCount > 0 ? foreground : background) : (foregroundCount == numOffsets ? foreground : background); + output[tuple] = dilate ? (foregroundCount > 0 ? foreground : background) : (foregroundCount == planeOffsets ? foreground : background); } } }; @@ -5042,12 +5054,14 @@ class BinaryMorphScanline * increments, and the accumulator's @ref detail::FgCountAccumulator::getValue applies the pinned binary * formula: Dilate emits fg when ANY window voxel is foreground (count > 0), Erode only when ALL are * (count == |SE|). Membership is equality-based, so arbitrary non-{fg,bg} labels count as background and the - * output is strictly @c {fg, bg}. + * output is strictly @c {fg, bg}. On a single-slice image only the in-plane offsets are in the window, so + * Erode compares with their count. * * Boundary. Out-of-image SE neighbors are folded via addBoundary/removeBoundary, which count as foreground * iff @p boundaryToForeground -- bit-identical to @ref BinaryMorphScanline treating an OOB neighbor as fg iff * boundaryToForeground. The empty-SE (radius-0 Annulus) case passes the input through unchanged, matching - * @ref BinaryMorphScanline so the two agree on that degenerate case. + * @ref BinaryMorphScanline so the two agree on that degenerate case. On a single-slice image, an SE with no + * in-plane offsets (an Annulus with X and Y radius 0) is handled the same way. * * Its required constructor arguments match @ref BinaryMorphScanline so both drop into @ref DispatchAlgorithm. * @@ -5108,11 +5122,12 @@ class BinaryMorphDirect progressThrottle.incrementPercent(delta, 1); }; - // Degenerate structuring element (empty offset list, only reachable for a radius-0 Annulus): pass the - // input through unchanged, byte-for-byte identical to BinaryMorphScanline so the two paths still agree. - // This rare edge case stays serial; its output-plane scratch lives here so the parallel fast path below - // never allocates it. - if(m_SE.offsets.empty()) + // Only in-plane offsets belong to a single-slice image's moving window. + const usize windowSize = dimZ == 1 ? static_cast(std::count_if(m_SE.offsets.begin(), m_SE.offsets.end(), [](const SEOffset& offset) { return offset.dz == 0; })) : m_SE.offsets.size(); + + // An empty effective kernel preserves the input, including when removing Z offsets empties an Annulus. + // This serial path allocates one output plane without allocating the full resident input buffer. + if(windowSize == 0) { auto outPlane = std::make_unique(sliceValues); for(usize z = 0; z < dimZ; ++z) @@ -5171,13 +5186,13 @@ class BinaryMorphDirect } // Per-axis "entering"/"leaving" offset sets for a +1 step, shared with MorphDirect (see - // detail::ComputeAxisAddRemoveSets). The two sets have equal size, so the window always holds |SE| entries. + // detail::ComputeAxisAddRemoveSets). The two sets have equal size, so the window always holds windowSize + // entries. On a single-slice image the Z offsets are skipped in both sets; an X/Y step keeps dz. const detail::AxisAddRemoveSets axisSets = detail::ComputeAxisAddRemoveSets(m_SE); const std::array, 3>& added = axisSets.added; const std::array, 3>& removed = axisSets.removed; using Acc = detail::FgCountAccumulator; - const usize windowSize = m_SE.offsets.size(); const int64 nX = static_cast(dimX); const int64 nY = static_cast(dimY); @@ -5221,6 +5236,10 @@ class BinaryMorphDirect { for(const SEOffset& q : added[a]) { + if(dimZ == 1 && q.dz != 0) + { + continue; + } const int64 x = cx + q.dx; const int64 y = cy + q.dy; const int64 z = cz + q.dz; @@ -5235,6 +5254,10 @@ class BinaryMorphDirect } for(const SEOffset& o : removed[a]) { + if(dimZ == 1 && o.dz != 0) + { + continue; + } const int64 x = cx + o.dx; const int64 y = cy + o.dy; const int64 z = cz + o.dz; @@ -5256,6 +5279,10 @@ class BinaryMorphDirect auto seedAcc = [&](Acc& h, int64 zStart) { for(const SEOffset& o : m_SE.offsets) { + if(dimZ == 1 && o.dz != 0) + { + continue; + } const int64 x = o.dx; const int64 y = o.dy; const int64 z = zStart + o.dz; @@ -5529,6 +5556,14 @@ Result<> ApplyBinaryMorphologyCompositePipeline(const AbstractDataStore& inpu { return {}; } + // A single-slice image has no Z neighbors. Drop out-of-plane offsets like the other binary routes. + // If none remain, the existing empty-kernel branches copy the input. + if(dims[2] == 1 && std::any_of(se.offsets.begin(), se.offsets.end(), [](const SEOffset& offset) { return offset.dz != 0; })) + { + StructuringElement inPlaneSE = se; + std::erase_if(inPlaneSE.offsets, [](const SEOffset& offset) { return offset.dz != 0; }); + return ApplyBinaryMorphologyCompositePipeline(input, output, dims, inPlaneSE, firstOp, secondOp, safeBorder, foreground, background, shouldCancel, grantedWorkingMemoryBytes); + } auto requiredBytesResult = CalculateBinaryMorphologyCompositePipelineWorkingMemoryBytes(dims, se, safeBorder); if(requiredBytesResult.invalid()) { diff --git a/src/simplnx/Utilities/ImageProcessing/ProjectionReducers.hpp b/src/simplnx/Utilities/ImageProcessing/ProjectionReducers.hpp index ff6b2c48ea..c1bdb7f304 100644 --- a/src/simplnx/Utilities/ImageProcessing/ProjectionReducers.hpp +++ b/src/simplnx/Utilities/ImageProcessing/ProjectionReducers.hpp @@ -172,17 +172,12 @@ struct SumReduce }; /** - * @brief Associative standard-deviation reducer. Accumulates a Kahan-compensated running sum and a - * Kahan-compensated running sum-of-squares plus a count per output slot; finalize() returns the - * SAMPLE standard deviation, i.e. the one-pass algebraic form of the legacy ITK - * StandardDeviationProjectionImageFilter accumulator - * (itkStandardDeviationProjectionImageFilter.h, Functor::StandardDeviationAccumulator::GetValue(): - * mean = sum / N; squaredSum = sum_i (x_i - mean)^2; return sqrt(squaredSum / (N - 1))). ITK forms - * the mean with the POPULATION divisor N but divides the squared-deviation total by (N - 1) - * (SAMPLE variance), and guards N <= 1 by returning 0 -- both replicated here. The output type is - * Float64 (the façade's AlwaysFloat64 OutTypeMap), matching the legacy filter's double - * FilterOutputType. Parity against ITK's two-pass form is algebraic (see the identity below), not - * bit-identical, so the projection parity tests compare within a tolerance. + * @brief Computes sample standard deviation with Welford's online update. + * + * Each output slot retains a mean, a sum of squared deviations, and a count. + * This avoids cancellation between large squared sums and requires constant memory per slot. + * The sample divisor (N - 1) and zero result for N <= 1 match ITK's StandardDeviationAccumulator. + * The filter emits Float64 values. Comparisons with ITK's two-pass calculation use a tolerance. */ struct StdDevReduce { @@ -191,10 +186,8 @@ struct StdDevReduce template struct State { - float64 sum = 0.0; - float64 sumComp = 0.0; // Kahan compensation for the running sum - float64 sumSq = 0.0; // running sum of squares - float64 sumSqComp = 0.0; // Kahan compensation for the running sum of squares + float64 mean = 0.0; + float64 sumSquaredDeviations = 0.0; usize count = 0; }; @@ -202,22 +195,11 @@ struct StdDevReduce void accumulate(State& state, TIn value) const { const float64 dv = static_cast(value); - // Kahan-compensated running sum. - { - const float64 y = dv - state.sumComp; - const float64 t = state.sum + y; - state.sumComp = (t - state.sum) - y; - state.sum = t; - } - // Kahan-compensated running sum of squares. - { - const float64 sq = dv * dv; - const float64 y = sq - state.sumSqComp; - const float64 t = state.sumSq + y; - state.sumSqComp = (t - state.sumSq) - y; - state.sumSq = t; - } ++state.count; + // Welford's update uses deviations from the old and new means, without subtracting large squared sums. + const float64 delta = dv - state.mean; + state.mean += delta / static_cast(state.count); + state.sumSquaredDeviations += delta * (dv - state.mean); } template @@ -228,13 +210,8 @@ struct StdDevReduce { return TOut{}; } - const float64 n = static_cast(state.count); - // One-pass algebraic identity for ITK's two-pass sum_i (x_i - mean)^2 with mean = sum / N: - // sum_i (x_i - mean)^2 == sumSq - (sum * sum) / N - // then the SAMPLE variance divides by (N - 1), exactly as ITK does. - float64 variance = (state.sumSq - (state.sum * state.sum) / n) / (n - 1.0); - // Guard the tiny negative that catastrophic cancellation can produce when every value along the - // pencil is (nearly) equal, so std::sqrt never sees a negative argument. + float64 variance = state.sumSquaredDeviations / static_cast(state.count - 1); + // Guard against a negative variance from rounding before taking the square root. if(variance < 0.0) { variance = 0.0; diff --git a/src/simplnx/Utilities/SampleSurfaceMesh.cpp b/src/simplnx/Utilities/SampleSurfaceMesh.cpp index b7e69cbe5b..6a88fc1581 100644 --- a/src/simplnx/Utilities/SampleSurfaceMesh.cpp +++ b/src/simplnx/Utilities/SampleSurfaceMesh.cpp @@ -315,7 +315,8 @@ struct SampleSurfaceMeshFunctor for(usize featureId = 0; featureId < numFeatures; featureId++) { BoundingBox3Df boundingBox(GeometryMath::FindBoundingBoxOfFaces(triangleGeom, faceLists[featureId])); - float32 radius = GeometryMath::FindDistanceBetweenPoints(boundingBox.getMinPoint(), boundingBox.getMaxPoint()) / 2; + // The ray starts at the sample point, not the box center. Use the full diagonal with a margin. + float32 radius = GeometryMath::FindDistanceBetweenPoints(boundingBox.getMinPoint(), boundingBox.getMaxPoint()) * 1.01F; featureBounds.emplace_back(FeatureBoundingVolume{boundingBox, radius}); } diff --git a/src/simplnx/Utilities/StringUtilities.hpp b/src/simplnx/Utilities/StringUtilities.hpp index c0fdcc448e..c81b288679 100644 --- a/src/simplnx/Utilities/StringUtilities.hpp +++ b/src/simplnx/Utilities/StringUtilities.hpp @@ -302,9 +302,9 @@ inline std::vector specific_split(std::string_view str, nonstd::spa return {}; } -inline std::vector split(std::string_view str, nonstd::span delimiters, bool consecutiveDelimiters) +inline std::vector split(std::string_view str, nonstd::span delimiters, bool keepEmptyTokens) { - if(consecutiveDelimiters) + if(keepEmptyTokens) { // Split Allow All was selected to match QString's base split functionality return detail::optimized_split(str, delimiters); diff --git a/test/DataArrayTest.cpp b/test/DataArrayTest.cpp index d7faf7021d..cb65fbc668 100644 --- a/test/DataArrayTest.cpp +++ b/test/DataArrayTest.cpp @@ -47,6 +47,64 @@ using namespace nx::core; +TEST_CASE("DataArrayUtilities::ValidateFeatureIds checks every component", "[simplnx][DataArrayUtilities][FeatureIdsValidation]") +{ + UnitTest::LoadPlugins(); + + const usize components = GENERATE(usize{1}, usize{2}); + const int32 lastId = GENERATE(-1, 1, 2); + const bool ignoreNegativeValues = GENERATE(false, true); + const bool useFeatureArray = GENERATE(false, true); + CAPTURE(components, lastId, ignoreNegativeValues, useFeatureArray); + + DataStructure dataStructure; + auto* featureAM = AttributeMatrix::Create(dataStructure, "Features", ShapeType{2}); + REQUIRE(featureAM != nullptr); + const DataPath featureArrayPath({"Features", "Values"}); + auto featureStore = DataStoreUtilities::CreateDataStore(dataStructure, featureArrayPath, ShapeType{2}, ShapeType{1}); + REQUIRE(Int32Array::Create(dataStructure, "Values", featureStore, featureAM->getId()) != nullptr); + + const DataPath idsPath({"FeatureIds"}); + auto idsStore = DataStoreUtilities::CreateDataStore(dataStructure, idsPath, ShapeType{2}, ShapeType{components}); + auto* featureIds = Int32Array::Create(dataStructure, "FeatureIds", idsStore); + REQUIRE(featureIds != nullptr); + std::vector ids(2 * components, 0); + ids.back() = lastId; + const auto copyResult = idsStore->copyFromBuffer(0, nonstd::span(ids.data(), ids.size())); + SIMPLNX_RESULT_REQUIRE_VALID(copyResult); + + // Two feature tuples have valid indices 0 and 1. Index 2 is outside the array; + // -1 is valid only when negative labels are explicitly ignored. + const auto sourcePath = useFeatureArray ? featureArrayPath : DataPath({"Features"}); + const auto result = ValidateFeatureIdsToFeatureAttributeMatrixIndexing(dataStructure, sourcePath, *featureIds, ignoreNegativeValues, {}); + if(lastId == 2 || (lastId == -1 && !ignoreNegativeValues)) + { + SIMPLNX_RESULT_REQUIRE_INVALID(result); + REQUIRE(result.errors().size() == 1); + REQUIRE(result.errors()[0].code == (lastId == -1 ? -5355 : -5351)); + } + else + { + SIMPLNX_RESULT_REQUIRE_VALID(result); + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("DataArrayUtilities::ValidateFeatureIds accepts empty arrays", "[simplnx][DataArrayUtilities][FeatureIdsValidation]") +{ + UnitTest::LoadPlugins(); + + const usize components = GENERATE(usize{1}, usize{2}); + DataStructure dataStructure; + REQUIRE(AttributeMatrix::Create(dataStructure, "Features", ShapeType{0}) != nullptr); + auto store = DataStoreUtilities::CreateDataStore(dataStructure, DataPath({"FeatureIds"}), ShapeType{0}, ShapeType{components}); + auto* featureIds = Int32Array::Create(dataStructure, "FeatureIds", store); + REQUIRE(featureIds != nullptr); + const auto result = ValidateFeatureIdsToFeatureAttributeMatrixIndexing(dataStructure, DataPath({"Features"}), *featureIds, false, {}); + SIMPLNX_RESULT_REQUIRE_VALID(result); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + TEST_CASE("DataArrayUtilities::ValidateCellTupleCount", "[simplnx][DataArrayUtilities]") { const bool useRectGrid = GENERATE(false, true); diff --git a/test/StringUtilitiesTest.cpp b/test/StringUtilitiesTest.cpp index 33e1393613..392959412c 100644 --- a/test/StringUtilitiesTest.cpp +++ b/test/StringUtilitiesTest.cpp @@ -176,3 +176,10 @@ TEST_CASE("Simplnx::StringUtilities Utility Function Test3", "[Simplnx][StringUt REQUIRE(result == std::vector{"", "This", "Is", "", "A", "", "Baseline", "Test", "", ""}); } + +TEST_CASE("Simplnx::StringUtilities::Split preserves keep-empty contract", "[Simplnx][StringUtilities]") +{ + const std::array delimiters = {' '}; + REQUIRE(StringUtilities::split("1 2 3", delimiters, true) == std::vector{"1", "", "2", "", "", "3"}); + REQUIRE(StringUtilities::split("1 2 3", delimiters, false) == std::vector{"1", "2", "3"}); +}