Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
13a6df8
BUG: WriteLAMMPSFile - Fix division by zero with fewer than 1000 vert…
imikejackson Oct 30, 2026
e3e3d0c
BUG: ConvertColorToGrayScale - Write each input to its own output array
imikejackson Oct 30, 2026
d6d1b4e
BUG: PointSampleTriangleGeometry - Transfer complete face tuples to v…
imikejackson Oct 30, 2026
e679128
BUG: TriangleDihedralAngle - Use signed dot products for the corner a…
imikejackson Oct 30, 2026
759356c
BUG: ArrayCalculator - Reject operands with different component counts
imikejackson Oct 30, 2026
58928db
BUG: MorphologyEngine - Ignore out-of-plane kernel offsets on 2D images
imikejackson Oct 30, 2026
363f3f1
BUG: ValidateFeatureIdsToFeatureAttributeMatrixIndexing - Check every…
imikejackson Oct 30, 2026
c4ea91e
BUG: ComputeVectorColors - Do not create a mask array when the mask i…
imikejackson Oct 30, 2026
6704847
BUG: CropVertexGeometry - Keep the component shape of cropped arrays
imikejackson Oct 30, 2026
cf87fc9
BUG: FeatureFaceCurvature - Remove NaN triangles from a patch safely
imikejackson Oct 30, 2026
bd85192
BUG: ReadCSVFile - Give Consecutive Delimiters its documented meaning
imikejackson Oct 30, 2026
0d905ac
BUG: ReadH5Ebsd - Apply the HKL hexagonal correction without Phases s…
imikejackson Oct 30, 2026
43d45d0
BUG: ReadVtkStructuredPoints - Read the selected section when it come…
imikejackson Oct 30, 2026
2b2d710
BUG: ScalarSegmentFeatures - Compare 64-bit integers exactly
imikejackson Oct 30, 2026
d516ae0
BUG: StandardDeviationProjection - Use a stable variance update
imikejackson Oct 30, 2026
0ae5e99
BUG: SampleSurfaceMesh - Make the inside test ray leave the bounding box
imikejackson Oct 30, 2026
0ecf3da
BUG: WriteVtkRectilinearGrid - Write ASCII floats so that they round-…
imikejackson Oct 30, 2026
497c149
BUG: ComputeFeaturePhasesBinary - Write Feature-level phases as in 6.…
imikejackson Oct 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Plugins/ImageProcessing/docs/BinaryDilateImageFilter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions src/Plugins/ImageProcessing/docs/BinaryErodeImageFilter.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ 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.

The input array must be single-component (scalar) and one of the scalar numeric types (`int8`, `uint8`, `int16`, `uint16`, `int32`, `uint32`, `int64`, `uint64`, `float32`, or `float64`). This is an ITK-free, out-of-core-capable reimplementation of the legacy ITK Standard Deviation Projection Image Filter.

## 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

Expand Down
180 changes: 179 additions & 1 deletion src/Plugins/ImageProcessing/test/MorphologyEngineTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ template <class T>
std::vector<T> BinaryOracle(const std::vector<T>& 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<usize>(std::count_if(se.offsets.begin(), se.offsets.end(), [](const SEOffset& off) { return off.dz == 0; })) : se.offsets.size();
std::vector<T> out(in.size());
for(usize z = 0; z < dimZ; ++z)
{
Expand All @@ -497,6 +497,11 @@ std::vector<T> BinaryOracle(const std::vector<T>& 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<int64>(x) + off.dx;
const int64 ny = static_cast<int64>(y) + off.dy;
const int64 nz = static_cast<int64>(z) + off.dz;
Expand Down Expand Up @@ -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<FilterT, BinaryDilateImageFilter>;

// 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<uint8> 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<uint8> 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<uint8> 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<uint8>(dataStructure, inputPath, ShapeType{1, 5, 5}, ShapeType{1});
auto copyInputResult = store->copyFromBuffer(0, nonstd::span<const uint8>(input.data(), input.size()));
SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult);
auto* inputArray = DataArray<uint8>::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>(DataPath({"Image Geometry"})));
args.insertOrAssign(FilterT::k_InputImageDataPath_Key, std::make_any<DataPath>(inputPath));
args.insertOrAssign(FilterT::k_OutputImageArrayName_Key, std::make_any<std::string>("Output"));
args.insertOrAssign(FilterT::k_KernelType_Key, std::make_any<uint64>(static_cast<uint64>(KernelType::Cross)));
args.insertOrAssign(FilterT::k_KernelRadius_Key, std::make_any<std::vector<uint32>>(std::vector<uint32>{1, 1, 1}));
args.insertOrAssign(FilterT::k_ForegroundValue_Key, std::make_any<float64>(1.0));
args.insertOrAssign(FilterT::k_BackgroundValue_Key, std::make_any<float64>(0.0));
args.insertOrAssign(FilterT::k_BoundaryToForeground_Key, std::make_any<bool>(boundaryToForeground));
auto result = scope.executeFilter(filter, dataStructure, args);
SIMPLNX_RESULT_REQUIRE_VALID(result.result);

const DataArray<uint8>* output = nullptr;
REQUIRE_NOTHROW(output = &dataStructure.getDataRefAs<DataArray<uint8>>(DataPath({"Image Geometry", "CellData", "Output"})));
scope.requireExpectedStore(*output);
std::vector<uint8> actual(expected.size());
auto copyOutputResult = output->getDataStoreRef().copyIntoBuffer(0, nonstd::span<uint8>(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<uint8> 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<uint8>(dataStructure, inputPath, ShapeType{1, 1, 3}, ShapeType{1});
auto copyInputResult = storePtr->copyFromBuffer(0, nonstd::span<const uint8>(expected.data(), expected.size()));
SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult);
auto* inputArrayPtr = DataArray<uint8>::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>(DataPath({"Image Geometry"})));
args.insertOrAssign(FilterT::k_InputImageDataPath_Key, std::make_any<DataPath>(inputPath));
args.insertOrAssign(FilterT::k_OutputImageArrayName_Key, std::make_any<std::string>("Output"));
args.insertOrAssign(FilterT::k_KernelType_Key, std::make_any<uint64>(static_cast<uint64>(KernelType::Annulus)));
args.insertOrAssign(FilterT::k_KernelRadius_Key, std::make_any<std::vector<uint32>>(std::vector<uint32>{0, 0, radiusZ}));
args.insertOrAssign(FilterT::k_ForegroundValue_Key, std::make_any<float64>(7.0));
args.insertOrAssign(FilterT::k_BackgroundValue_Key, std::make_any<float64>(3.0));
args.insertOrAssign(FilterT::k_BoundaryToForeground_Key, std::make_any<bool>(boundaryToForeground));
auto result = scope.executeFilter(filter, dataStructure, args);
SIMPLNX_RESULT_REQUIRE_VALID(result.result);

const DataArray<uint8>* outputPtr = nullptr;
REQUIRE_NOTHROW(outputPtr = &dataStructure.getDataRefAs<DataArray<uint8>>(DataPath({"Image Geometry", "CellData", "Output"})));
scope.requireExpectedStore(*outputPtr);
std::vector<uint8> actual(expected.size());
auto copyOutputResult = outputPtr->getDataStoreRef().copyIntoBuffer(0, nonstd::span<uint8>(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<uint8> values = {7, 3, 7};
// No in-plane offsets remain, so both stages copy the input.
const std::vector<uint8> expected = {7, 3, 7};
DataStore<uint8> input(ShapeType{1, 1, 3}, ShapeType{1}, 0);
DataStore<uint8> output(ShapeType{1, 1, 3}, ShapeType{1}, 9);
auto copyInputResult = input.copyFromBuffer(0, nonstd::span<const uint8>(values.data(), values.size()));
SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult);
auto requiredBytesResult = CalculateBinaryMorphologyCompositePipelineWorkingMemoryBytes<uint8>(dims, se, safeBorder);
SIMPLNX_RESULT_REQUIRE_VALID(requiredBytesResult);
auto result = ApplyBinaryMorphologyCompositePipeline<uint8>(input, output, dims, se, firstOp, secondOp, safeBorder, uint8{7}, uint8{3}, shouldCancel, requiredBytesResult.value());
SIMPLNX_RESULT_REQUIRE_VALID(result);
std::vector<uint8> actual(expected.size());
auto copyOutputResult = output.copyIntoBuffer(0, nonstd::span<uint8>(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<uint8> 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<uint8> 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<uint8> input(ShapeType{1, 5, 5}, ShapeType{1}, 0);
DataStore<uint8> output(ShapeType{1, 5, 5}, ShapeType{1}, 9);
auto copyInputResult = input.copyFromBuffer(0, nonstd::span<const uint8>(values.data(), values.size()));
SIMPLNX_RESULT_REQUIRE_VALID(copyInputResult);
auto requiredBytesResult = CalculateBinaryMorphologyCompositePipelineWorkingMemoryBytes<uint8>(dims, se, false);
SIMPLNX_RESULT_REQUIRE_VALID(requiredBytesResult);
auto result = ApplyBinaryMorphologyCompositePipeline<uint8>(input, output, dims, se, MorphOp::Erode, MorphOp::Dilate, false, uint8{1}, uint8{0}, shouldCancel, requiredBytesResult.value());
SIMPLNX_RESULT_REQUIRE_VALID(result);
std::vector<uint8> actual(expected.size());
auto copyOutputResult = output.copyIntoBuffer(0, nonstd::span<uint8>(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;
Expand Down
Loading
Loading