From c069a035870250985de3b4357f183e7970c13e70 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 22 Jul 2026 18:08:44 -0400 Subject: [PATCH 01/12] VV: Segment Features (C-Axis Misalignment) fully V&V'ed Summary: - Found and fixed 3 bugs (D1 unvalidated first seed in shared SegmentFeatures driver, D4 spurious -8363 rejection of unindexed/ masked cells + missing CrystalStructures bounds check, D5 stale ImageGeom cast crashing RectGrid input); - documented 5 deviations from DREAM3D 6.5.171 (D1 phantom first feature, D2 legacy always clock-randomizes FeatureIds, D3 NX-only hexagonal validation, D4 unindexed-cell rejection, D5 RectGrid); - retired 4 tests (circular-oracle segment_features_test_data exemplar consumers; archive remains for EBSDSegmentFeatures); - unit tests replaced with 13 inlined *Class 1 (Analytical) + Class 4 (Invariant)* test fixtures; - added 3 V&V source-tree deliverables (report, deviations, provenance) plus the 6.5.171 A/B comparison pipelines/results; - documented hexagonal-only requirement, mask/phase-0 semantics, RectGrid support, and deterministic FeatureId randomization in the user docs. Signed-off-by: Michael Jackson --- .../docs/CAxisSegmentFeaturesFilter.md | 22 +- .../Algorithms/CAxisSegmentFeatures.cpp | 39 +- .../Filters/CAxisSegmentFeaturesFilter.cpp | 10 +- .../test/CAxisSegmentFeaturesTest.cpp | 659 +++++++++++++----- .../vv/CAxisSegmentFeaturesFilter.md | 129 ++++ .../pipelines/legacy_6_5_171.json | 192 +++++ .../pipelines/make_input.py | 69 ++ .../pipelines/nx.d3dpipeline | 115 +++ .../results/.gitignore | 3 + .../results/comparison.md | 55 ++ .../deviations/CAxisSegmentFeaturesFilter.md | 95 +++ .../provenance/CAxisSegmentFeaturesFilter.md | 45 ++ src/simplnx/Utilities/SegmentFeatures.cpp | 6 +- 13 files changed, 1231 insertions(+), 208 deletions(-) create mode 100644 src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json create mode 100644 src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py create mode 100644 src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline create mode 100644 src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/.gitignore create mode 100644 src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md create mode 100644 src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md diff --git a/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md index 6983636597..dec18e93bc 100644 --- a/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md @@ -6,17 +6,27 @@ Reconstruction (Segmentation) ## Description -This **Filter** segments the **Features** by grouping neighboring **Cells** that satisfy the *C-axis misalignment tolerance*, i.e., have misalignment angle less than the value set by the user. The *C-axis misalignment* refers to the angle between the <001> directions (C-axis in the hexagonal system) that is present between neighboring **Cells**. The process by which the **Features** are identified is given below and is a standard *burn algorithm*. +This **Filter** segments the **Features** by grouping neighboring **Cells** that satisfy the *C-axis misalignment tolerance*, i.e., have misalignment angle less than the value set by the user. The *C-axis misalignment* refers to the angle between the <001> directions (C-axis in the hexagonal system) that is present between neighboring **Cells**. Because the c-axis is a direction (not a vector), the misalignment is folded into [0°, 90°]: two nearly antiparallel c-axes are considered aligned. The process by which the **Features** are identified is given below and is a standard *burn algorithm*. -1. Randomly select a **Cell**, add it to an empty list and set its *Feature Id* to the current **Feature** -2. Compare the **Cell** to each of its six (6) face-sharing neighbors (i.e., calculate the c-axis misalignment with each neighbor) +1. Select the next unassigned **Cell** in row-major order that is eligible to seed a **Feature** (not excluded by the mask and with a phase value greater than 0), add it to an empty list and set its *Feature Id* to the current **Feature** +2. Compare the **Cell** to each of its neighbors as selected by the *Neighbor Scheme* parameter (i.e., calculate the c-axis misalignment with each neighbor) 3. Add each neighbor **Cell** that has a C-axis misalignment below the user defined tolerance to the list created in 1. and set the *Feature Id* of the neighbor **Cell** to the current **Feature** 4. Remove the current **Cell** from the list and move to the next **Cell** and repeat 2. and 3.; continue until no **Cells** are left in the list -5. Increment the current **Feature** counter and repeat steps 1. through 4.; continue until no **Cells** remain unassigned in the dataset +5. Increment the current **Feature** counter and repeat steps 1. through 4.; continue until no eligible **Cells** remain unassigned in the dataset -The user has the option to *Use Mask Array*, which allows the user to set a boolean array for the **Cells** that remove **Cells** with a value of *false* from consideration in the above algorithm. This option is useful if the user has an array that either specifies the domain of the "sample" in the "image" or specifies if the orientation on the **Cell** is trusted/correct. +The user has the option to *Use Mask Array*, which allows the user to set a boolean (or uint8) array for the **Cells** that removes **Cells** with a value of *false* from consideration in the above algorithm. This option is useful if the user has an array that either specifies the domain of the "sample" in the "image" or specifies if the orientation on the **Cell** is trusted/correct. Masked-out **Cells** and unindexed **Cells** (phase 0) never join a **Feature** and keep a *Feature Id* of 0. -After all the **Features** have been identified, a **Feature Attribute Matrix** is created for the **Features** and each **Feature** is flagged as *Active* in a boolean array in the matrix. +After all the **Features** have been identified, a **Feature Attribute Matrix** is created for the **Features** and each **Feature** is flagged as *Active* in the matrix (index 0 is reserved and never active). + +The input geometry may be either an **Image Geometry** or a **RectGrid Geometry**. + +### Hexagonal Crystal Structures Required + +The c-axis is only a physically meaningful unique axis for hexagonal Laue classes. Every **Cell** that can participate in the segmentation (phase > 0 and not excluded by the mask) must belong to an **Ensemble** whose crystal structure is *Hexagonal-Low (6/m)* or *Hexagonal-High (6/mmm)*; otherwise the filter fails with error `-8363`. A phase value with no corresponding entry in the *Crystal Structures* array produces error `-8364`. Unindexed **Cells** (phase 0) and masked-out **Cells** are exempt from this requirement, so datasets with unindexed points — or with a non-hexagonal phase that has been masked out — process normally. + +### Randomize Feature Ids + +When *Randomize Feature Ids* is enabled the final *Feature Ids* are relabeled with a deterministic (fixed-seed) random permutation, which improves visual contrast between neighboring **Features** when coloring by *Feature Id*. The segmentation itself is unchanged, and repeated runs produce identical output. (Legacy DREAM.3D 6.x always randomized with a clock-derived seed, so its labeling differed on every run; see the migration deviation notes.) ### Neighbor Scheme diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp index 8dd04ec515..7627f23adf 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp @@ -4,7 +4,7 @@ #include "simplnx/Common/Constants.hpp" #include "simplnx/DataStructure/DataArray.hpp" -#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/DataStructure/Geometry/IGridGeometry.hpp" #include "simplnx/Utilities/ClusteringUtilities.hpp" #include @@ -30,7 +30,8 @@ CAxisSegmentFeatures::~CAxisSegmentFeatures() noexcept = default; Result<> CAxisSegmentFeatures::operator()() { this->m_NeighborScheme = m_InputValues->NeighborScheme; - auto* imageGeometry = m_DataStructure.getDataAs(m_InputValues->ImageGeometryPath); + // The geometry parameter accepts Image AND RectGrid geometries; both derive from IGridGeometry. + auto* gridGeom = m_DataStructure.getDataAs(m_InputValues->ImageGeometryPath); m_QuatsArray = m_DataStructure.getDataAs(m_InputValues->QuatsArrayPath); m_CellPhases = m_DataStructure.getDataAs(m_InputValues->CellPhasesArrayPath); if(m_InputValues->UseMask) @@ -50,25 +51,28 @@ Result<> CAxisSegmentFeatures::operator()() // a hexagonal phase. This guards against there being multiple phases defined in // and EBSD file but the non-hexagonal phases were actually never found const auto& crystalStructures = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath); - usize numCells = m_CellPhases->getNumberOfTuples(); + const usize numCells = m_CellPhases->getNumberOfTuples(); + const usize numEnsembles = crystalStructures.getNumberOfTuples(); for(usize cellIdx = 0; cellIdx < numCells; ++cellIdx) { - int32 currentPhaseIdx = m_CellPhases->getValue(cellIdx); - // Only consider valid ebsd phase values - if(currentPhaseIdx < 1) + const int32 currentPhaseIdx = m_CellPhases->getValue(cellIdx); + // Cells that can never seed or join a feature are exempt from the crystal-structure + // requirement: phase 0 is the conventional "unindexed" phase (its CrystalStructures entry is + // the 999 "unknown" sentinel), and masked-out cells are excluded from segmentation entirely. + if(currentPhaseIdx <= 0 || (m_GoodVoxelsArray != nullptr && !m_GoodVoxelsArray->isTrue(cellIdx))) { continue; } - // Only consider valid mask values - if(m_InputValues->UseMask && !m_GoodVoxelsArray->isTrue(cellIdx)) + if(static_cast(currentPhaseIdx) >= numEnsembles) { - continue; + return MakeErrorResult(-8364, fmt::format("Cell {} has a phase value of {} but the Crystal Structures array '{}' only has {} entries.", cellIdx, currentPhaseIdx, + m_InputValues->CrystalStructuresArrayPath.toString(), numEnsembles)); } const auto crystalStructureType = crystalStructures[currentPhaseIdx]; if(crystalStructureType != ebsdlib::CrystalStructure::Hexagonal_High && crystalStructureType != ebsdlib::CrystalStructure::Hexagonal_Low) { - return MakeErrorResult(-8363, fmt::format("Input data is using {} type crystal structures but segmenting features via c-axis mis orientation requires all phases to be either Hexagonal-Low 6/m " + return MakeErrorResult(-8363, fmt::format("Input data is using {} type crystal structures but segmenting features via c-axis misorientation requires all phases to be either Hexagonal-Low 6/m " "or Hexagonal-High 6/mmm type crystal structures.", CrystalStructureEnumToString(crystalStructureType))); } @@ -76,11 +80,11 @@ Result<> CAxisSegmentFeatures::operator()() m_FeatureIdsArray = m_DataStructure.getDataAs(m_InputValues->FeatureIdsArrayPath); m_FeatureIdsArray->fill(0); - auto* active = m_DataStructure.getDataAs(m_InputValues->ActiveArrayPath); - active->fill(1); + auto* activeArray = m_DataStructure.getDataAs(m_InputValues->ActiveArrayPath); + activeArray->fill(1); // Run the segmentation algorithm - execute(imageGeometry); + execute(gridGeom); // Sanity check the result. if(this->m_FoundFeatures < 1) { @@ -93,7 +97,6 @@ Result<> CAxisSegmentFeatures::operator()() cellFeaturesAM.resizeTuples(tDims); // This will resize the active array // make sure all values are initialized and "re-reserve" index 0 - auto* activeArray = m_DataStructure.getDataAs(m_InputValues->ActiveArrayPath); activeArray->getDataStore()->fill(1); (*activeArray)[0] = 0; @@ -152,9 +155,9 @@ bool CAxisSegmentFeatures::determineGrouping(int64 referencepoint, int64 neighbo bool group = false; const Eigen::Vector3f cAxis{0.0f, 0.0f, 1.0f}; - Float32Array& currentQuat = *m_QuatsArray; + const Float32Array& quats = *m_QuatsArray; Int32Array& featureIds = *m_FeatureIdsArray; - Int32Array& cellPhases = *m_CellPhases; + const Int32Array& cellPhases = *m_CellPhases; bool neighborPointIsGood = false; if(m_GoodVoxelsArray != nullptr) @@ -166,8 +169,8 @@ bool CAxisSegmentFeatures::determineGrouping(int64 referencepoint, int64 neighbo { if(cellPhases[referencepoint] == cellPhases[neighborpoint]) { - const ebsdlib::QuatF q1(currentQuat[referencepoint * 4], currentQuat[referencepoint * 4 + 1], currentQuat[referencepoint * 4 + 2], currentQuat[referencepoint * 4 + 3]); - const ebsdlib::QuatF q2(currentQuat[neighborpoint * 4 + 0], currentQuat[neighborpoint * 4 + 1], currentQuat[neighborpoint * 4 + 2], currentQuat[neighborpoint * 4 + 3]); + const ebsdlib::QuatF q1(quats[referencepoint * 4], quats[referencepoint * 4 + 1], quats[referencepoint * 4 + 2], quats[referencepoint * 4 + 3]); + const ebsdlib::QuatF q2(quats[neighborpoint * 4 + 0], quats[neighborpoint * 4 + 1], quats[neighborpoint * 4 + 2], quats[neighborpoint * 4 + 3]); const ebsdlib::OrientationMatrixFType oMatrix1 = q1.toOrientationMatrix(); const ebsdlib::OrientationMatrixFType oMatrix2 = q2.toOrientationMatrix(); diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp index bd15933916..484a036401 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp @@ -4,11 +4,12 @@ #include "simplnx/Common/Constants.hpp" #include "simplnx/DataStructure/DataPath.hpp" -#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" +#include "simplnx/DataStructure/Geometry/IGridGeometry.hpp" #include "simplnx/Filter/Actions/CreateArrayAction.hpp" #include "simplnx/Filter/Actions/CreateAttributeMatrixAction.hpp" #include "simplnx/Parameters/ArraySelectionParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" +#include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/DataGroupSelectionParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Parameters/GeometrySelectionParameter.hpp" @@ -121,7 +122,7 @@ IFilter::PreflightResult CAxisSegmentFeaturesFilter::preflightImpl(const DataStr auto tolerance = filterArgs.value(k_MisorientationTolerance_Key); if(tolerance == 0.0F) { - return {MakeErrorResult(-655, fmt::format("Misorientation Tolerance cannot equal ZERO.", humanName()))}; + return {MakeErrorResult(-655, "Misorientation Tolerance cannot equal ZERO.")}; } // Validate the Grid Geometry @@ -182,7 +183,10 @@ Result<> CAxisSegmentFeaturesFilter::executeImpl(DataStructure& dataStructure, c inputValues.CellPhasesArrayPath = filterArgs.value(k_CellPhasesArrayPath_Key); inputValues.MaskArrayPath = filterArgs.value(k_MaskArrayPath_Key); inputValues.CrystalStructuresArrayPath = filterArgs.value(k_CrystalStructuresArrayPath_Key); - inputValues.FeatureIdsArrayPath = inputValues.QuatsArrayPath.replaceName(filterArgs.value(k_FeatureIdsArrayName_Key)); + // Derive the FeatureIds path from the geometry's cell-data AttributeMatrix, exactly as + // preflightImpl created it (the Quats array is not required to live in that AttributeMatrix). + const auto& gridGeom = dataStructure.getDataRefAs(inputValues.ImageGeometryPath); + inputValues.FeatureIdsArrayPath = gridGeom.getCellDataPath().createChildPath(filterArgs.value(k_FeatureIdsArrayName_Key)); inputValues.CellFeatureAttributeMatrixPath = inputValues.ImageGeometryPath.createChildPath(filterArgs.value(k_CellFeatureAttributeMatrixName_Key)); inputValues.ActiveArrayPath = inputValues.CellFeatureAttributeMatrixPath.createChildPath(filterArgs.value(k_ActiveArrayName_Key)); inputValues.NeighborScheme = static_cast(filterArgs.value(k_NeighborScheme_Key)); diff --git a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp index 3759ade2ff..bea057737a 100644 --- a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp @@ -1,266 +1,565 @@ -#include - #include "OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.hpp" #include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" -#include "OrientationAnalysisTestUtils.hpp" +#include + +#include "simplnx/Common/Constants.hpp" #include "simplnx/Core/Application.hpp" #include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" #include "simplnx/DataStructure/Geometry/ImageGeom.hpp" -#include "simplnx/Parameters/ArrayCreationParameter.hpp" +#include "simplnx/DataStructure/Geometry/RectGridGeom.hpp" #include "simplnx/Parameters/ChoicesParameter.hpp" -#include "simplnx/Parameters/Dream3dImportParameter.hpp" -#include "simplnx/Parameters/GeometrySelectionParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" +#include "simplnx/Utilities/SegmentFeatures.hpp" + +#include +#include +#include #include -#include +#include +#include namespace fs = std::filesystem; using namespace nx::core; -using namespace nx::core::Constants; -namespace caxis_segment_features_constants -{ -inline constexpr StringLiteral k_InputGeometryName = "DataContainer"; -inline const DataPath k_InputGeometryPath({k_InputGeometryName}); -inline constexpr StringLiteral k_CellDataName = "CellData"; -inline constexpr StringLiteral k_EnsembleName = "CellEnsembleData"; -inline const DataPath k_QuatsArrayPath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath("Quats"); -inline const DataPath k_PhasesArrayPath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath("Phases"); -inline const DataPath k_MaskArrayPath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath("Mask (Y Pos)"); +using namespace nx::core::UnitTest; -inline const DataPath k_CrystalStructuresArrayPath = k_InputGeometryPath.createChildPath(k_EnsembleName).createChildPath("CrystalStructures"); +namespace +{ +namespace AnalyticalFixtures +{ +const std::string k_GeomName = "ImageGeometry"; +const DataPath k_ImageGeomPath = DataPath({k_GeomName}); +const DataPath k_CellDataPath = k_ImageGeomPath.createChildPath("CellData"); +const DataPath k_EnsembleDataPath = k_ImageGeomPath.createChildPath("CellEnsembleData"); + +const std::string k_QuatsName = "Quats"; +const std::string k_PhasesName = "Phases"; +const std::string k_MaskName = "Mask"; +const std::string k_CrystalStructuresName = "CrystalStructures"; +const std::string k_FeatureIdsName = "FeatureIds"; +const std::string k_CellFeatureAMName = "CellFeatureData"; +const std::string k_ActiveName = "Active"; + +const DataPath k_QuatsPath = k_CellDataPath.createChildPath(k_QuatsName); +const DataPath k_PhasesPath = k_CellDataPath.createChildPath(k_PhasesName); +const DataPath k_MaskPath = k_CellDataPath.createChildPath(k_MaskName); +const DataPath k_CrystalStructuresPath = k_EnsembleDataPath.createChildPath(k_CrystalStructuresName); +const DataPath k_FeatureIdsPath = k_CellDataPath.createChildPath(k_FeatureIdsName); +const DataPath k_CellFeatureAMPath = k_ImageGeomPath.createChildPath(k_CellFeatureAMName); +const DataPath k_ActivePath = k_CellFeatureAMPath.createChildPath(k_ActiveName); + +// --------------------------------------------------------------------------- +// Class 1 (Analytical) oracle derivation +// --------------------------------------------------------------------------- +// Quaternion for a pure Bunge ZXZ Euler rotation (phi1=0, Phi=phiDeg, phi2=0). This is a pure +// rotation about the x-axis by phiDeg degrees, which tilts the crystal c-axis (originally along +// +z in the crystal frame) by phiDeg degrees within the sample y-z plane: +// +// c_sample = g^T * [0,0,1] = (0, +/-sin(Phi), cos(Phi)) +// +// For two cells with pure-Phi tilts of phiA and phiB degrees the dot product of their sample-frame +// c-axes is exactly cos(phiA - phiB), so the c-axis angular distance is exactly |phiA - phiB| +// degrees. The algorithm accepts a pair when w <= tol OR (pi - w) <= tol, i.e. the effective +// metric is min(|phiA - phiB|, 180 - |phiA - phiB|), folded into [0, 90]. Expected segmentations +// below follow in closed form from the per-cell Phi values, the tolerance, and grid adjacency. +// Storage convention (shared with the sibling OA Class 1 fixtures): {x, y, z, w}. +std::array QuatFromPhiDeg(float32 phiDeg) +{ + const float32 halfAngleRad = (phiDeg * 0.5f) * Constants::k_PiOver180F; + return {std::sin(halfAngleRad), 0.0f, 0.0f, std::cos(halfAngleRad)}; +} -inline const DataPath k_ActivesArrayPath = k_InputGeometryPath.createChildPath(k_Grain_Data).createChildPath(k_ActiveName); +struct FixtureData +{ + DataStructure ds; + ImageGeom* geom = nullptr; + AttributeMatrix* cellAM = nullptr; + AttributeMatrix* ensembleAM = nullptr; + Float32Array* quats = nullptr; + Int32Array* phases = nullptr; + UInt32Array* crystalStructures = nullptr; +}; + +// Build a minimal ImageGeom of the given (x, y, z) dimensions with a Cell AttributeMatrix holding +// Quats (identity rotation) + Phases (all phase 1) and a CellEnsembleData AttributeMatrix holding +// CrystalStructures. CrystalStructures[0] is the conventional "unknown" sentinel (999); +// CrystalStructures[1..] default to Hexagonal_High. Tests override per-cell Phi tilts, phases, +// ensemble entries, and add mask arrays as needed. +FixtureData CreateScaffold(usize dimX, usize dimY, usize dimZ, usize numEnsembles = 2) +{ + FixtureData td; + const usize numCells = dimX * dimY * dimZ; -inline const DataPath k_FeatureIdsArrayPath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath(k_FeatureIds); + td.geom = ImageGeom::Create(td.ds, k_GeomName); + td.geom->setSpacing({1.0f, 1.0f, 1.0f}); + td.geom->setOrigin({0.0f, 0.0f, 0.0f}); + td.geom->setDimensions({dimX, dimY, dimZ}); -inline const DataPath k_FeatureIdsFacePath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath("CAxis_FeatureIds_Face"); -inline const DataPath k_FeatureIdsAllPath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath("CAxis_FeatureIds_All"); -inline const DataPath k_FeatureIdsMaskFacePath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath("CAxis_FeatureIds_Mask_Face"); -inline const DataPath k_FeatureIdsMaskAllPath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath("CAxis_FeatureIds_Mask_All"); -} // namespace caxis_segment_features_constants + // AttributeMatrix tuple shape is (z, y, x) + const ShapeType cellTupleShape = {dimZ, dimY, dimX}; + td.cellAM = AttributeMatrix::Create(td.ds, "CellData", cellTupleShape, td.geom->getId()); + td.geom->setCellData(*td.cellAM); + td.ensembleAM = AttributeMatrix::Create(td.ds, "CellEnsembleData", ShapeType{numEnsembles}, td.geom->getId()); -TEST_CASE("OrientationAnalysis::CAxisSegmentFeatures:Face", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") -{ - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "segment_features_test_data.tar.gz", "segment_features_test_data"); - // Read Exemplar DREAM3D File Filter - auto exemplarFilePath = fs::path(fmt::format("{}/segment_features_test_data/segment_features_test_data.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(exemplarFilePath); + td.quats = CreateTestDataArray(td.ds, k_QuatsName, cellTupleShape, {4}, td.cellAM->getId()); + td.phases = CreateTestDataArray(td.ds, k_PhasesName, cellTupleShape, {1}, td.cellAM->getId()); + td.crystalStructures = CreateTestDataArray(td.ds, k_CrystalStructuresName, {numEnsembles}, {1}, td.ensembleAM->getId()); - // EBSD Segment Features/Semgent Features (Misorientation) Filter + for(usize cellIdx = 0; cellIdx < numCells; cellIdx++) { - CAxisSegmentFeaturesFilter filter; - Arguments args; - - // Create default Parameters for the filter. - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + const std::array quat = QuatFromPhiDeg(0.0f); + for(usize comp = 0; comp < 4; comp++) + { + (*td.quats)[cellIdx * 4 + comp] = quat[comp]; + } + (*td.phases)[cellIdx] = 1; + } - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(caxis_segment_features_constants::k_MaskArrayPath)); + (*td.crystalStructures)[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + for(usize ensembleIdx = 1; ensembleIdx < numEnsembles; ensembleIdx++) + { + (*td.crystalStructures)[ensembleIdx] = ebsdlib::CrystalStructure::Hexagonal_High; + } - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(caxis_segment_features_constants::k_InputGeometryPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(caxis_segment_features_constants::k_QuatsArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(caxis_segment_features_constants::k_PhasesArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(caxis_segment_features_constants::k_CrystalStructuresArrayPath)); + return td; +} - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any(k_FeatureIds)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any(k_Grain_Data)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any(k_ActiveName)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); +void SetPhi(FixtureData& td, usize cellIdx, float32 phiDeg) +{ + const std::array quat = QuatFromPhiDeg(phiDeg); + for(usize comp = 0; comp < 4; comp++) + { + (*td.quats)[cellIdx * 4 + comp] = quat[comp]; + } +} - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); +Arguments BuildArgs(float32 toleranceDeg, ChoicesParameter::ValueType neighborScheme, bool useMask, bool randomizeIds = false) +{ + Arguments args; + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(toleranceDeg)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(neighborScheme)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(useMask)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(k_MaskPath)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(k_ImageGeomPath)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(k_QuatsPath)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(k_PhasesPath)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresPath)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any(k_FeatureIdsName)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any(k_CellFeatureAMName)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any(k_ActiveName)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(randomizeIds)); + return args; +} - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - } +// Preflight + execute; returns the execute result for error-path tests. +IFilter::ExecuteResult RunFilter(DataStructure& dataStructure, const Arguments& args) +{ + CAxisSegmentFeaturesFilter filter; + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + return filter.execute(dataStructure, args); +} +void CheckFeatureIds(const DataStructure& dataStructure, const std::vector& expectedFeatureIds) +{ + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_FeatureIdsPath)); + const auto& featureIdsRef = dataStructure.getDataRefAs(k_FeatureIdsPath).getDataStoreRef(); + REQUIRE(featureIdsRef.getNumberOfTuples() == expectedFeatureIds.size()); + for(usize cellIdx = 0; cellIdx < expectedFeatureIds.size(); cellIdx++) { - UInt8Array& actives = dataStructure.getDataRefAs(caxis_segment_features_constants::k_ActivesArrayPath); - size_t numFeatures = actives.getNumberOfTuples(); - REQUIRE(numFeatures == 57); + INFO(fmt::format("cell index {}", cellIdx)); + REQUIRE(featureIdsRef[cellIdx] == expectedFeatureIds[cellIdx]); } +} - // Loop and compare each array from the 'Exemplar Data / CellData' to the 'Data Container / CellData' group +// Verifies the Class 4 invariants shared by every successful run: the feature AttributeMatrix has +// (numFeatures + 1) tuples, Active[0] == 0 (index 0 is reserved for "unassigned"), and every real +// feature is flagged active. +void CheckActiveArray(const DataStructure& dataStructure, usize expectedNumFeatures) +{ + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_ActivePath)); + const auto& activeRef = dataStructure.getDataRefAs(k_ActivePath).getDataStoreRef(); + REQUIRE(activeRef.getNumberOfTuples() == expectedNumFeatures + 1); + REQUIRE(activeRef[0] == 0); + for(usize featureIdx = 1; featureIdx <= expectedNumFeatures; featureIdx++) { - const auto& generatedDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsArrayPath); - const auto& exemplarDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsFacePath); - - UnitTest::CompareDataArrays(generatedDataArray, exemplarDataArray); + INFO(fmt::format("feature index {}", featureIdx)); + REQUIRE(activeRef[featureIdx] == 1); } - - UnitTest::CheckArraysInheritTupleDims(dataStructure, SmallIn100::k_TupleCheckIgnoredPaths); } +} // namespace AnalyticalFixtures +} // namespace + +using namespace AnalyticalFixtures; -TEST_CASE("OrientationAnalysis::CAxisSegmentFeatures:All", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pure-Phi Chain, Face)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "segment_features_test_data.tar.gz", "segment_features_test_data"); - // Read Exemplar DREAM3D File Filter - auto exemplarFilePath = fs::path(fmt::format("{}/segment_features_test_data/segment_features_test_data.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(exemplarFilePath); + UnitTest::LoadPlugins(); - // EBSD Segment Features/Semgent Features (Misorientation) Filter + // 8x1x1 chain, tolerance 10 degrees. Phi per cell: [0, 5, 8, 45, 50, 120, 124, 90]. + // Pairwise folded c-axis distances along the chain: + // 0-1: 5 (group) 1-2: 3 (group) 2-3: 37 (break) + // 3-4: 5 (group) 4-5: 70 (break) + // 5-6: 4 (group) 6-7: 34 (break) + // Expected features: F1={0,1,2}, F2={3,4}, F3={5,6}, F4={7}. + FixtureData td = CreateScaffold(8, 1, 1); + const std::vector phiValues = {0.0f, 5.0f, 8.0f, 45.0f, 50.0f, 120.0f, 124.0f, 90.0f}; + for(usize cellIdx = 0; cellIdx < phiValues.size(); cellIdx++) { - CAxisSegmentFeaturesFilter filter; - Arguments args; + SetPhi(td, cellIdx, phiValues[cellIdx]); + } - // Create default Parameters for the filter. - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(1)); + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(caxis_segment_features_constants::k_MaskArrayPath)); + CheckFeatureIds(td.ds, {1, 1, 1, 2, 2, 3, 3, 4}); + CheckActiveArray(td.ds, 4); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(caxis_segment_features_constants::k_InputGeometryPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(caxis_segment_features_constants::k_QuatsArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(caxis_segment_features_constants::k_PhasesArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(caxis_segment_features_constants::k_CrystalStructuresArrayPath)); +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pi-Fold Antiparallel C-Axes)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any(k_FeatureIds)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any(k_Grain_Data)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any(k_ActiveName)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + // The c-axis is a direction, not a vector: the algorithm folds the angle via (pi - w) <= tol. + // Phi = 2 and Phi = 176 give nearly antiparallel c-axes (174 apart) whose folded distance is + // 6 degrees -> same feature at tolerance 10. Phi = 88 vs 176: folded distance 88 -> break. + FixtureData td = CreateScaffold(3, 1, 1); + SetPhi(td, 0, 2.0f); + SetPhi(td, 1, 176.0f); + SetPhi(td, 2, 88.0f); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - } + CheckFeatureIds(td.ds, {1, 1, 2}); + CheckActiveArray(td.ds, 2); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Neighbor Scheme Face vs All)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); + + // 2x2x1 grid (cell index = y*2 + x): + // cell 0 (0,0): Phi 0 cell 1 (1,0): Phi 45 + // cell 2 (0,1): Phi 90 cell 3 (1,1): Phi 0 + // All face-adjacent pairs differ by >= 45 degrees -> 4 singleton features under the Face scheme. + // The diagonal pair 0-3 is identical (0 degrees) -> the All scheme merges them across the corner. + struct SchemeExpectation + { + std::string label; + ChoicesParameter::ValueType scheme; + std::vector expectedFeatureIds; + usize expectedNumFeatures; + }; + const std::vector expectations = { + {"Face Neighbors", segment_features::k_6NeighborIndex, {1, 2, 3, 4}, 4}, + {"All Connected Neighbors", segment_features::k_26NeighborIndex, {1, 2, 3, 1}, 3}, + }; + for(const auto& expectation : expectations) { - UInt8Array& actives = dataStructure.getDataRefAs(caxis_segment_features_constants::k_ActivesArrayPath); - size_t numFeatures = actives.getNumberOfTuples(); - REQUIRE(numFeatures == 37); + DYNAMIC_SECTION(expectation.label) + { + FixtureData td = CreateScaffold(2, 2, 1); + SetPhi(td, 0, 0.0f); + SetPhi(td, 1, 45.0f); + SetPhi(td, 2, 90.0f); + SetPhi(td, 3, 0.0f); + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, expectation.scheme, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeatureIds(td.ds, expectation.expectedFeatureIds); + CheckActiveArray(td.ds, expectation.expectedNumFeatures); + UnitTest::CheckArraysInheritTupleDims(td.ds); + } } +} - // Loop and compare each array from the 'Exemplar Data / CellData' to the 'Data Container / CellData' group +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Mask Excludes Voxel 0)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); + + // Regression pin: the first seed must be validated by getSeed() exactly like every later seed. + // 5x1x1, Phi = [0, 20, 22, 0, 90], mask = [0, 1, 1, 0, 1], tolerance 10. + // Voxel 0 is masked out, so the first real seed is cell 1: F1={1,2} (distance 2), cell 3 is + // masked, F2={4}. Masked cells keep FeatureId 0. A driver loop that bursts from the raw index 0 + // without consulting getSeed() produces a phantom empty feature 1 and shifted ids [0,2,2,0,3]. + struct MaskVariant { - const auto& generatedDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsArrayPath); - const auto& exemplarDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsAllPath); + std::string label; + DataType maskType; + }; + const std::vector variants = { + {"bool mask", DataType::boolean}, + {"uint8 mask", DataType::uint8}, + }; - UnitTest::CompareDataArrays(generatedDataArray, exemplarDataArray); + for(const auto& variant : variants) + { + DYNAMIC_SECTION(variant.label) + { + FixtureData td = CreateScaffold(5, 1, 1); + const std::vector phiValues = {0.0f, 20.0f, 22.0f, 0.0f, 90.0f}; + const std::vector maskValues = {0, 1, 1, 0, 1}; + for(usize cellIdx = 0; cellIdx < phiValues.size(); cellIdx++) + { + SetPhi(td, cellIdx, phiValues[cellIdx]); + } + if(variant.maskType == DataType::boolean) + { + auto* maskArrayPtr = CreateTestDataArray(td.ds, k_MaskName, ShapeType{1, 1, 5}, {1}, td.cellAM->getId()); + for(usize cellIdx = 0; cellIdx < maskValues.size(); cellIdx++) + { + (*maskArrayPtr)[cellIdx] = (maskValues[cellIdx] != 0); + } + } + else + { + auto* maskArrayPtr = CreateTestDataArray(td.ds, k_MaskName, ShapeType{1, 1, 5}, {1}, td.cellAM->getId()); + for(usize cellIdx = 0; cellIdx < maskValues.size(); cellIdx++) + { + (*maskArrayPtr)[cellIdx] = maskValues[cellIdx]; + } + } + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, true)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeatureIds(td.ds, {0, 1, 1, 0, 2}); + CheckActiveArray(td.ds, 2); + UnitTest::CheckArraysInheritTupleDims(td.ds); + } } +} + +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Phase Separation)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); - UnitTest::CheckArraysInheritTupleDims(dataStructure, SmallIn100::k_TupleCheckIgnoredPaths); + // Cells only group when their phases match, even with identical orientations. 4x1x1, all + // Phi = 0, phases = [1, 1, 2, 2] -> two features split at the phase boundary. Phase 2 uses + // Hexagonal_Low to also exercise the 6/m acceptance branch of the crystal-structure validation. + FixtureData td = CreateScaffold(4, 1, 1, 3); + (*td.phases)[2] = 2; + (*td.phases)[3] = 2; + (*td.crystalStructures)[2] = ebsdlib::CrystalStructure::Hexagonal_Low; + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeatureIds(td.ds, {1, 1, 2, 2}); + CheckActiveArray(td.ds, 2); + UnitTest::CheckArraysInheritTupleDims(td.ds); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeatures:MaskFace", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (RectGrid Geometry)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "segment_features_test_data.tar.gz", "segment_features_test_data"); - // Read Exemplar DREAM3D File Filter - auto exemplarFilePath = fs::path(fmt::format("{}/segment_features_test_data/segment_features_test_data.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(exemplarFilePath); + UnitTest::LoadPlugins(); - // EBSD Segment Features/Semgent Features (Misorientation) Filter + // Regression pin: the geometry parameter accepts RectGrid in addition to Image, so the + // algorithm must fetch the geometry as IGridGeometry (a stale ImageGeom cast produced a null + // pointer and crashed). 3x1x1 RectGrid with non-uniform x bounds, Phi = [0, 5, 45], tol 10: + // cells 0-1 group (distance 5), cell 2 breaks (distance 40). + DataStructure dataStructure; + auto* rectGridGeom = RectGridGeom::Create(dataStructure, k_GeomName); + rectGridGeom->setDimensions(SizeVec3{3, 1, 1}); + auto* xBoundsArrayPtr = Float32Array::CreateWithStore(dataStructure, "xBounds", ShapeType{4}, ShapeType{1}, rectGridGeom->getId()); + auto* yBoundsArrayPtr = Float32Array::CreateWithStore(dataStructure, "yBounds", ShapeType{2}, ShapeType{1}, rectGridGeom->getId()); + auto* zBoundsArrayPtr = Float32Array::CreateWithStore(dataStructure, "zBounds", ShapeType{2}, ShapeType{1}, rectGridGeom->getId()); + const std::vector xBoundValues = {0.0f, 0.5f, 2.0f, 10.0f}; + for(usize boundIdx = 0; boundIdx < xBoundValues.size(); boundIdx++) { - CAxisSegmentFeaturesFilter filter; - Arguments args; - - // Create default Parameters for the filter. - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + xBoundsArrayPtr->setValue(boundIdx, xBoundValues[boundIdx]); + } + yBoundsArrayPtr->setValue(0, 0.0f); + yBoundsArrayPtr->setValue(1, 1.0f); + zBoundsArrayPtr->setValue(0, 0.0f); + zBoundsArrayPtr->setValue(1, 1.0f); + rectGridGeom->setBounds(xBoundsArrayPtr, yBoundsArrayPtr, zBoundsArrayPtr); + + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 3}, rectGridGeom->getId()); + rectGridGeom->setCellData(*cellAM); + auto* ensembleAM = AttributeMatrix::Create(dataStructure, "CellEnsembleData", ShapeType{2}, rectGridGeom->getId()); + auto* quatsArrayPtr = CreateTestDataArray(dataStructure, k_QuatsName, ShapeType{1, 1, 3}, {4}, cellAM->getId()); + auto* phasesArrayPtr = CreateTestDataArray(dataStructure, k_PhasesName, ShapeType{1, 1, 3}, {1}, cellAM->getId()); + auto* crystalStructuresArrayPtr = CreateTestDataArray(dataStructure, k_CrystalStructuresName, ShapeType{2}, {1}, ensembleAM->getId()); + + const std::vector phiValues = {0.0f, 5.0f, 45.0f}; + for(usize cellIdx = 0; cellIdx < phiValues.size(); cellIdx++) + { + const std::array quat = QuatFromPhiDeg(phiValues[cellIdx]); + for(usize comp = 0; comp < 4; comp++) + { + (*quatsArrayPtr)[cellIdx * 4 + comp] = quat[comp]; + } + (*phasesArrayPtr)[cellIdx] = 1; + } + (*crystalStructuresArrayPtr)[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + (*crystalStructuresArrayPtr)[1] = ebsdlib::CrystalStructure::Hexagonal_High; - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(true)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(caxis_segment_features_constants::k_MaskArrayPath)); + auto executeResult = RunFilter(dataStructure, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(caxis_segment_features_constants::k_InputGeometryPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(caxis_segment_features_constants::k_QuatsArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(caxis_segment_features_constants::k_PhasesArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(caxis_segment_features_constants::k_CrystalStructuresArrayPath)); + CheckFeatureIds(dataStructure, {1, 1, 2}); + CheckActiveArray(dataStructure, 2); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any(k_FeatureIds)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any(k_Grain_Data)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any(k_ActiveName)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants (RandomizeFeatureIds)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + // Randomization must relabel, never repartition. Re-uses the Pure-Phi Chain fixture whose + // ground-truth partition is {0,1,2} {3,4} {5,6} {7}. Invariants: the partition survives, the + // ids used are exactly a permutation of {1..4}, and the shuffle is deterministic (static seed). + const std::vector phiValues = {0.0f, 5.0f, 8.0f, 45.0f, 50.0f, 120.0f, 124.0f, 90.0f}; - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); + auto runOnce = [&]() -> std::vector { + FixtureData td = CreateScaffold(8, 1, 1); + for(usize cellIdx = 0; cellIdx < phiValues.size(); cellIdx++) + { + SetPhi(td, cellIdx, phiValues[cellIdx]); + } + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false, true)); SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - } + REQUIRE_NOTHROW(td.ds.getDataRefAs(k_FeatureIdsPath)); + const auto& featureIdsRef = td.ds.getDataRefAs(k_FeatureIdsPath).getDataStoreRef(); + std::vector featureIds(featureIdsRef.getNumberOfTuples()); + for(usize cellIdx = 0; cellIdx < featureIds.size(); cellIdx++) + { + featureIds[cellIdx] = featureIdsRef[cellIdx]; + } + CheckActiveArray(td.ds, 4); + UnitTest::CheckArraysInheritTupleDims(td.ds); + return featureIds; + }; - { - UInt8Array& actives = dataStructure.getDataRefAs(caxis_segment_features_constants::k_ActivesArrayPath); - size_t numFeatures = actives.getNumberOfTuples(); - REQUIRE(numFeatures == 31); - } + const std::vector firstRun = runOnce(); - // Loop and compare each array from the 'Exemplar Data / CellData' to the 'Data Container / CellData' group - { - const auto& generatedDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsArrayPath); - const auto& exemplarDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsMaskFacePath); + // Partition invariant: cells designed to share a feature still do; boundaries still hold. + REQUIRE(firstRun[0] == firstRun[1]); + REQUIRE(firstRun[1] == firstRun[2]); + REQUIRE(firstRun[3] == firstRun[4]); + REQUIRE(firstRun[5] == firstRun[6]); + const std::set distinctIds = {firstRun[0], firstRun[3], firstRun[5], firstRun[7]}; + REQUIRE(distinctIds.size() == 4); - UnitTest::CompareDataArrays(generatedDataArray, exemplarDataArray); - } + // Permutation invariant: the relabeled ids are exactly {1, 2, 3, 4}. + REQUIRE(distinctIds == std::set{1, 2, 3, 4}); - UnitTest::CheckArraysInheritTupleDims(dataStructure, SmallIn100::k_TupleCheckIgnoredPaths); + // Determinism invariant: the shuffle uses a fixed seed, so a second run is bit-identical. + const std::vector secondRun = runOnce(); + REQUIRE(firstRun == secondRun); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeatures:MaskAll", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Phase 0 (Unindexed) Cells Tolerated", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") { - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "segment_features_test_data.tar.gz", "segment_features_test_data"); - // Read Exemplar DREAM3D File Filter - auto exemplarFilePath = fs::path(fmt::format("{}/segment_features_test_data/segment_features_test_data.dream3d", unit_test::k_TestFilesDir)); - DataStructure dataStructure = UnitTest::LoadDataStructure(exemplarFilePath); + UnitTest::LoadPlugins(); - // EBSD Segment Features/Semgent Features (Misorientation) Filter - { - CAxisSegmentFeaturesFilter filter; - Arguments args; + // Regression pin: EBSD datasets conventionally store unindexed points as phase 0, and + // CrystalStructures[0] is the 999 "unknown" sentinel. The crystal-structure validation must skip + // phase-0 cells instead of rejecting the whole dataset. Phase-0 cells can never seed a feature + // (getSeed requires phase > 0) nor join one (grouping requires equal phases), so cell 0 keeps + // FeatureId 0 and the remaining identical-orientation cells form one feature. + FixtureData td = CreateScaffold(4, 1, 1); + (*td.phases)[0] = 0; - // Create default Parameters for the filter. - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(1)); + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(true)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(caxis_segment_features_constants::k_MaskArrayPath)); + CheckFeatureIds(td.ds, {0, 1, 1, 1}); + CheckActiveArray(td.ds, 1); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(caxis_segment_features_constants::k_InputGeometryPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(caxis_segment_features_constants::k_QuatsArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(caxis_segment_features_constants::k_PhasesArrayPath)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(caxis_segment_features_constants::k_CrystalStructuresArrayPath)); +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Masked Non-Hexagonal Cells Tolerated", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any(k_FeatureIds)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any(k_Grain_Data)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any(k_ActiveName)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + // A user may legitimately mask out a non-hexagonal phase and segment only the hexagonal cells. + // The crystal-structure validation must not reject cells that the mask already excludes. + // 4x1x1, phases = [1, 1, 2, 2] with phase 2 = Cubic_High, mask = [1, 1, 0, 0]. + FixtureData td = CreateScaffold(4, 1, 1, 3); + (*td.phases)[2] = 2; + (*td.phases)[3] = 2; + (*td.crystalStructures)[2] = ebsdlib::CrystalStructure::Cubic_High; + auto* maskArrayPtr = CreateTestDataArray(td.ds, k_MaskName, ShapeType{1, 1, 4}, {1}, td.cellAM->getId()); + (*maskArrayPtr)[0] = true; + (*maskArrayPtr)[1] = true; + (*maskArrayPtr)[2] = false; + (*maskArrayPtr)[3] = false; + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, true)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeatureIds(td.ds, {1, 1, 0, 0}); + CheckActiveArray(td.ds, 1); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Execute Error - Non-Hexagonal Crystal Structure (-8363)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); - } + // Unmasked cubic cells must be rejected: c-axis misalignment is only defined for hexagonal + // (6/m or 6/mmm) Laue classes. + FixtureData td = CreateScaffold(2, 1, 1); + (*td.crystalStructures)[1] = ebsdlib::CrystalStructure::Cubic_High; + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result); + REQUIRE(executeResult.result.errors()[0].code == -8363); +} + +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Execute Error - Phase Out of Ensemble Bounds (-8364)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); + + // A cell phase value with no corresponding CrystalStructures tuple must produce a clean error, + // not an out-of-bounds read. + FixtureData td = CreateScaffold(2, 1, 1); + (*td.phases)[1] = 7; + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result); + REQUIRE(executeResult.result.errors()[0].code == -8364); +} +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Execute Error - No Features Found (-87000)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); + + // Regression pin: with every cell masked out no seed exists, so the filter must fail with + // -87000 instead of reporting a phantom feature from an unvalidated first seed. + FixtureData td = CreateScaffold(3, 1, 1); + auto* maskArrayPtr = CreateTestDataArray(td.ds, k_MaskName, ShapeType{1, 1, 3}, {1}, td.cellAM->getId()); + for(usize cellIdx = 0; cellIdx < 3; cellIdx++) { - UInt8Array& actives = dataStructure.getDataRefAs(caxis_segment_features_constants::k_ActivesArrayPath); - size_t numFeatures = actives.getNumberOfTuples(); - REQUIRE(numFeatures == 25); + (*maskArrayPtr)[cellIdx] = false; } - // Loop and compare each array from the 'Exemplar Data / CellData' to the 'Data Container / CellData' group - { - const auto& generatedDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsArrayPath); - const auto& exemplarDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsMaskAllPath); + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, true)); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result); + REQUIRE(executeResult.result.errors()[0].code == -87000); +} - UnitTest::CompareDataArrays(generatedDataArray, exemplarDataArray); - } +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Zero Tolerance (-655)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") +{ + UnitTest::LoadPlugins(); - UnitTest::CheckArraysInheritTupleDims(dataStructure, SmallIn100::k_TupleCheckIgnoredPaths); + FixtureData td = CreateScaffold(2, 1, 1); + + CAxisSegmentFeaturesFilter filter; + auto preflightResult = filter.preflight(td.ds, BuildArgs(0.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors()[0].code == -655); } TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell array tuple count mismatch (-651)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md new file mode 100644 index 0000000000..853e78d32b --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -0,0 +1,129 @@ +# V&V Report: CAxisSegmentFeaturesFilter + +| | | +|----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `9fe07e17-aef1-4bf1-834c-d3a73dafc27d` | +| SIMPLNX Human Name | Segment Features (C-Axis Misalignment) | +| DREAM3D 6.5.171 equivalent | `CAxisSegmentFeatures` — `Source/Plugins/Reconstruction/ReconstructionFilters/CAxisSegmentFeatures.{h,cpp}` (SIMPL UUID `bff6be19-1219-5876-8838-1574ad29d965`) | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | pending second-engineer review | + +## At a glance + +| Aspect | Current state | +|------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| Algorithm Relationship | **Minor changes.** Line-for-line port of legacy `getSeed`/`determineGrouping` (same c-axis math, including the clamped `acos` and the π-fold), plus deliberate NX additions: crystal-structure validation, 26-neighbor scheme, uint8 masks, RectGrid input, deterministic opt-in FeatureId randomization. Three SIMPLNX defects (D1, D4, D5) found and **fixed during this V&V cycle**. | +| Oracle (confirmed) | **Class 1 (Analytical) primary + Class 4 (Invariant) companion** — pure-Phi Bunge quats make the folded c-axis distance between cells exactly `min(\|ΔΦ\|, 180°−\|ΔΦ\|)`; expected FeatureIds derive in closed form. Encoded as 8 fixture tests in `test/CAxisSegmentFeaturesTest.cpp`; all pass. | +| Code paths enumerated | 20 of 20 enumerated; 18 exercised (2 gaps: defensive mask-instantiation error, cancel-signal path). | +| Tests today | 15 test cases: 6 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, RectGrid), 1 Class 4 invariants (randomize), 2 tolerance pins (phase-0, masked non-hex), 3 execute-error, 2 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | +| Exemplar archive | **None — fixtures inlined in the test source.** The filter's consumption of `segment_features_test_data.tar.gz` (circular oracle) is retired; the archive remains for the EBSD segmentation tests. | +| Legacy comparison | **Run** (2026-07-22, `vv/comparisons/CAxisSegmentFeaturesFilter/`) — all 4 shared-behavior fixtures match 6.5.171 at the segmentation-partition level with identical feature counts; bit-identical ids are unattainable because 6.5.171 always clock-randomizes FeatureIds (D2). | +| Bug flags | D1, D4, D5 — all SIMPLNX defects, all **fixed this cycle** and pinned by tests. No legacy bug flags. | +| V&V phase | Discovery, relationship, oracle, reconciliation, algorithm review, tests (dual-build), legacy comparison, deviations, provenance, docs — complete. **Outstanding:** second-engineer review at PR (per sign-off convention). | + +## Summary + +`CAxisSegmentFeaturesFilter` flood-fills neighboring cells into features when their crystallographic c-axes (hexagonal [0001] directions) are misaligned by less than a user tolerance. Verification used a Class 1 analytical oracle (pure-Phi Bunge fixtures with closed-form expected segmentations) plus Class 4 invariants; reconciliation exposed and fixed three SIMPLNX defects (unvalidated first seed in the shared driver, spurious rejection of unindexed/masked cells, RectGrid crash). Post-fix SIMPLNX matches the oracle exactly and matches DREAM3D 6.5.171 at the partition level on every shared code path; 5 deviations are documented, 3 of them fixed-this-cycle SIMPLNX bugs. + +## Algorithm Relationship + +*Classification:* **Minor changes** + +*Evidence:* SIMPL UUID `bff6be19-1219-5876-8838-1574ad29d965` is preserved via `FromSIMPLJson` (see `test/simpl_conversion/6_*/CAxisSegmentFeaturesFilter.json`). `getSeed()` and `determineGrouping()` are line-for-line translations of the legacy methods (`CAxisSegmentFeatures.cpp` in the Reconstruction plugin), with the flood-fill driver hoisted into the shared `src/simplnx/Utilities/SegmentFeatures.cpp` (legacy used a `SegmentFeatures` base class the same way). + +*Port-time deltas:* + +1. **Math API.** Legacy `FOrientTransformsType::qu2om` + `MatrixMath` → NX `ebsdlib::Quaternion::toOrientationMatrix()` + Eigen. Same arithmetic (quat → OM, transpose, ·[0,0,1], normalize, clamped `acos`, `w ≤ tol || π−w ≤ tol`); the A/B run shows no observable difference. +2. **Crystal-structure validation added** (D3). Legacy computes the [001] misalignment for any Laue class; NX requires participating cells to be Hexagonal_High/Low (errors `-8363`/`-8364`). Deliberate correctness guard; changes behavior only for inputs whose legacy output was meaningless. +3. **FeatureId randomization exposed and made deterministic** (D2). Legacy always randomizes with a clock seed (not a parameter); NX exposes `Randomize Feature Ids` (default false) with a fixed-seed shuffle. +4. **26-neighbor "All Connected" scheme added** (NX-only option; default remains the legacy 6-face behavior). +5. **RectGrid geometry and uint8 masks accepted** (NX-only capability; legacy is Image + bool-mask only). +6. **D1 (fixed this cycle):** PR #1466's driver restructure made the first seed an unvalidated raw index 0 — phantom/misgrown first feature when voxel 0 could not seed. Restored `getSeed()` for the first seed (also fixes EBSD/Scalar segmentation, which share the driver). +7. **D4 (fixed this cycle):** the delta-2 validation loop originally checked phase-0 and masked-out cells (which can never participate) and indexed `CrystalStructures` without a bounds check. +8. **D5 (fixed this cycle):** stale `getDataAs` cast crashed RectGrid input; now `IGridGeometry`, matching the sibling algorithms. + +*Material PRs since baseline:* #1373 (26-neighbor option), #1444/#1498 (progress messaging), #1466 (feature-count fix; introduced D1), #1472 (EbsdLib 2.0 API), #1535 (preflight cleanup), #1501 (Vec3 consolidation). + +## Oracle + +*Class:* **1 (Analytical)** primary + **4 (Invariant)** companion. + +*Applied:* For pure Bunge Euler angles (φ1=0, Φ, φ2=0), stored as quats `{sin(Φ/2), 0, 0, cos(Φ/2)}`, the sample-frame c-axis of a cell is `(0, ±sin Φ, cos Φ)`, so the c-axis angle between two cells is exactly `|ΦA−ΦB|` and the algorithm's folded metric is `min(|ΔΦ|, 180°−|ΔΦ|)`. Expected FeatureIds follow in closed form from per-cell Φ, the tolerance, and grid adjacency (derivation comment at `AnalyticalFixtures::QuatFromPhiDeg`). Class 4 companions: feature AM has `numFeatures+1` tuples, `Active[0]==0`, all real features active, masked/unindexed cells keep id 0, randomization preserves the partition and is a deterministic permutation of `{1..N}`. + +*Encoded:* `test/CAxisSegmentFeaturesTest.cpp` — `Class 1 Analytical (Pure-Phi Chain, Face)`, `(Pi-Fold Antiparallel C-Axes)`, `(Neighbor Scheme Face vs All)` [2 sections], `(Mask Excludes Voxel 0)` [2 sections], `(Phase Separation)`, `(RectGrid Geometry)`, `Class 4 Invariants (RandomizeFeatureIds)` — all pass in both builds. + +*Second-engineer review:* pending at PR review (sign-off convention). The pure-Phi c-axis derivation is sibling-shared with the previously reviewed `GroupMicroTextureRegionsFilter` / `ComputeFeatureNeighborCAxisMisalignmentsFilter` Class 1 oracles. + +## Code path coverage + +*20 of 20 paths enumerated; 18 exercised.* + +Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp` (198 lines) + shared driver `src/simplnx/Utilities/SegmentFeatures.cpp` (execute loop). + +Logical phases: **(a) init + validation** in `operator()`, **(b) flood-fill driver** in `SegmentFeatures::execute`, **(c) seeding** in `getSeed`, **(d) grouping decision** in `determineGrouping`, **(e) finalize** in `operator()`. + +| # | Phase | Path | Test case | +|----|-------|--------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------| +| 1 | (a) | Mask array missing/wrong type at execute → error `-8362` | *Not directly tested. Defensive guard; `ArraySelectionParameter` validates the path through the normal IFilter API.* | +| 2 | (a) | Validation skips phase ≤ 0 cells | `Phase 0 (Unindexed) Cells Tolerated` | +| 3 | (a) | Validation skips masked-out cells | `Masked Non-Hexagonal Cells Tolerated` | +| 4 | (a) | Phase value ≥ CrystalStructures tuples → error `-8364` | `Execute Error - Phase Out of Ensemble Bounds (-8364)` | +| 5 | (a) | Participating non-hex cell → error `-8363` | `Execute Error - Non-Hexagonal Crystal Structure (-8363)` | +| 6 | (a) | Hexagonal_High and Hexagonal_Low both accepted | All Class 1 tests (High); `Class 1 Analytical (Phase Separation)` (Low, ensemble 2) | +| 7 | (a) | Geometry fetched as `IGridGeometry` (Image and RectGrid) | All tests (Image); `Class 1 Analytical (RectGrid Geometry)` (RectGrid) | +| 8 | (b) | First seed obtained via `getSeed` (D1 pin) | `Class 1 Analytical (Mask Excludes Voxel 0)`; `Execute Error - No Features Found (-87000)` | +| 9 | (b) | Face (6-neighbor) scheme | `Class 1 Analytical (Pure-Phi Chain, Face)` and others | +| 10 | (b) | All-connected (26-neighbor) scheme | `Class 1 Analytical (Neighbor Scheme Face vs All)` — "All Connected Neighbors" section | +| 11 | (b) | Cancel requested → early return | *Not directly tested. Requires cancel-signal injection; low-value gap.* | +| 12 | (c) | Seed scan skips owned / masked / phase ≤ 0 cells | `Mask Excludes Voxel 0`; `Phase 0 (Unindexed) Cells Tolerated` | +| 13 | (c) | Seed found → stamp FeatureId + resize feature AM | Every successful Class 1 test (`CheckActiveArray` tuple counts) | +| 14 | (c) | No seed remains → return −1, driver exits | Every test (loop termination); immediately in `No Features Found (-87000)` | +| 15 | (d) | Neighbor already owned or masked-out → reject | Chain fixtures (burst revisits owned neighbors); `Mask Excludes Voxel 0` (cells 0, 3) | +| 16 | (d) | Phases differ → reject | `Class 1 Analytical (Phase Separation)`; `Phase 0 (Unindexed) Cells Tolerated` | +| 17 | (d) | `w ≤ tol` → group | `Pure-Phi Chain` (Δ = 3°, 4°, 5° pairs) | +| 18 | (d) | `π − w ≤ tol` → group (antiparallel c-axes) | `Class 1 Analytical (Pi-Fold Antiparallel C-Axes)` | +| 19 | (e) | `foundFeatures < 1` → error `-87000`; else AM resize, Active refill, `Active[0]=0` | `No Features Found (-87000)` (error); `CheckActiveArray` in every passing test (success) | +| 20 | (e) | `RandomizeFeatureIds == true` → deterministic shuffle | `Class 4 Invariants (RandomizeFeatureIds)` | + +Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple mismatch → `-651` — are covered by the two `Preflight Error` tests; SIMPL 6.4/6.5 argument conversion by `SIMPL Backwards Compatibility`. + +## Test inventory + +| Test case | Status | Notes | +|-----------|--------|-------| +| `Class 1 Analytical (Pure-Phi Chain, Face)` | new-for-V&V | 8-cell chain, 4 features; 8 element-wise FeatureId assertions + Active invariants. | +| `Class 1 Analytical (Pi-Fold Antiparallel C-Axes)` | new-for-V&V | Exercises the `(π − w) ≤ tol` branch (Φ=2° vs 176° → folded 6°). | +| `Class 1 Analytical (Neighbor Scheme Face vs All)` | new-for-V&V | DYNAMIC_SECTION over both schemes on a 2×2×1 diagonal fixture; Face → 4 features, All → 3. | +| `Class 1 Analytical (Mask Excludes Voxel 0)` | new-for-V&V | DYNAMIC_SECTION over bool + uint8 masks; **D1 regression pin** (pre-fix: phantom feature + shifted ids). | +| `Class 1 Analytical (Phase Separation)` | new-for-V&V | Identical orientations split at a phase boundary; also covers Hexagonal_Low acceptance. | +| `Class 1 Analytical (RectGrid Geometry)` | new-for-V&V | **D5 regression pin** (pre-fix: null-pointer crash on RectGrid input). | +| `Class 4 Invariants (RandomizeFeatureIds)` | new-for-V&V | Partition preservation, permutation of {1..4}, same-seed determinism across two runs. | +| `Phase 0 (Unindexed) Cells Tolerated` | new-for-V&V | **D4 regression pin** (pre-fix: spurious `-8363` on the 999 sentinel). | +| `Masked Non-Hexagonal Cells Tolerated` | new-for-V&V | **D4 regression pin** (masked cubic phase must not be validated). | +| `Execute Error - Non-Hexagonal Crystal Structure (-8363)` | new-for-V&V | Unmasked cubic cells rejected. | +| `Execute Error - Phase Out of Ensemble Bounds (-8364)` | new-for-V&V | **D4 regression pin** (pre-fix: out-of-bounds read). | +| `Execute Error - No Features Found (-87000)` | new-for-V&V | All cells masked; **D1 regression pin** (pre-fix: success with 1 phantom feature). | +| `Preflight Error - Zero Tolerance (-655)` | new-for-V&V | Preflight rejects tolerance == 0. | +| `Preflight Error - Cell array tuple count mismatch (-651)` | kept | Synthetic tuple-count mismatch between Quats and Phases. | +| `SIMPL Backwards Compatibility` | kept | DYNAMIC_SECTION over SIMPL 6.4 + 6.5 conversion fixtures; UUID + argument-key conversion only. | +| *(retired)* `CAxisSegmentFeatures:Face` / `:All` / `:MaskFace` / `:MaskAll` | retired | Consumed the `segment_features_test_data.tar.gz` exemplar whose `CAxis_FeatureIds_*` arrays were generated from SIMPLNX output — a circular oracle (see provenance sidecar). Replaced by the Class 1 fixtures, which cover the same scheme × mask parameter cube with independent expected output. | + +All non-retired tests pass at the verified commit in **both** builds: in-core `NX-Com-Qt69-Vtk95-Rel` and OOC `simplnx-ooc-Rel` (15/15 each, 2026-07-22). The shared-driver fix (D1) was regression-checked against `EBSDSegmentFeatures` (5/5) and `ScalarSegmentFeatures` (2/2) in both builds. + +## Exemplar archive + +- **Archive:** None — fixtures inlined in `test/CAxisSegmentFeaturesTest.cpp` (namespace `AnalyticalFixtures`). +- **SHA512:** N/A +- **Provenance:** `src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md` (documents the retired circular-oracle archive consumption). + +## Deviations from DREAM3D 6.5.171 + +Comparison run 2026-07-22 on four pure-Phi fixtures (chain, π-fold, bool mask, phase-0) through the official 6.5.171 PipelineRunner and nxrunner from one shared legacy-format input (`vv/comparisons/CAxisSegmentFeaturesFilter/`). All four match at the segmentation-partition level with identical feature counts. + +- `CAxisSegmentFeaturesFilter-D1` — unvalidated first seed in SIMPLNX (post-#1466, pre-fix) could add a phantom feature or grow from a masked voxel — **fixed this cycle** — see `vv/deviations/CAxisSegmentFeaturesFilter.md` +- `CAxisSegmentFeaturesFilter-D2` — 6.5.171 always clock-randomizes FeatureIds (irreproducible); SIMPLNX is deterministic with opt-in randomization — see `vv/deviations/CAxisSegmentFeaturesFilter.md` +- `CAxisSegmentFeaturesFilter-D3` — SIMPLNX rejects non-hexagonal participating cells (`-8363`/`-8364`); legacy silently produced meaningless output — see `vv/deviations/CAxisSegmentFeaturesFilter.md` +- `CAxisSegmentFeaturesFilter-D4` — pre-fix SIMPLNX spuriously rejected unindexed/masked cells — **fixed this cycle** — see `vv/deviations/CAxisSegmentFeaturesFilter.md` +- `CAxisSegmentFeaturesFilter-D5` — pre-fix SIMPLNX crashed on RectGrid input — **fixed this cycle** — see `vv/deviations/CAxisSegmentFeaturesFilter.md` diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json new file mode 100644 index 0000000000..1f6b4e4afa --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json @@ -0,0 +1,192 @@ +{ + "0": { + "FilterVersion": "6.5.171", + "Filter_Enabled": true, + "Filter_Human_Label": "Read DREAM.3D Data File", + "Filter_Name": "DataContainerReader", + "Filter_Uuid": "{043cbde5-3878-5718-958f-ae75714df0df}", + "InputFile": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/caxis_ab_input.dream3d", + "OverwriteExistingDataContainers": false, + "InputFileDataContainerArrayProxy": { + "Data Containers": [ + { + "Attribute Matricies": [ + { + "Data Arrays": [ + {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC1_Chain/CellData", "Tuple Dimensions": [1], "Version": 2}, + {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC1_Chain/CellData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellData", + "Type": 3 + }, + { + "Data Arrays": [ + {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC1_Chain/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellEnsembleData", + "Type": 11 + } + ], + "Flag": 2, + "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, + "Name": "TC1_Chain" + }, + { + "Attribute Matricies": [ + { + "Data Arrays": [ + {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC2_PiFold/CellData", "Tuple Dimensions": [1], "Version": 2}, + {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC2_PiFold/CellData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellData", + "Type": 3 + }, + { + "Data Arrays": [ + {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC2_PiFold/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellEnsembleData", + "Type": 11 + } + ], + "Flag": 2, + "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, + "Name": "TC2_PiFold" + }, + { + "Attribute Matricies": [ + { + "Data Arrays": [ + {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellData", "Tuple Dimensions": [1], "Version": 2}, + {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellData", "Tuple Dimensions": [1], "Version": 2}, + {"Component Dimensions": [1], "Flag": 2, "Name": "Mask", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellData", + "Type": 3 + }, + { + "Data Arrays": [ + {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC3_Mask/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellEnsembleData", + "Type": 11 + } + ], + "Flag": 2, + "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, + "Name": "TC3_Mask" + }, + { + "Attribute Matricies": [ + { + "Data Arrays": [ + {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC4_Phase0/CellData", "Tuple Dimensions": [1], "Version": 2}, + {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC4_Phase0/CellData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellData", + "Type": 3 + }, + { + "Data Arrays": [ + {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC4_Phase0/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellEnsembleData", + "Type": 11 + } + ], + "Flag": 2, + "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, + "Name": "TC4_Phase0" + } + ], + "Version": 6 + } + }, + "1": { + "ActiveArrayName": "Active", + "CellFeatureAttributeMatrixName": "CellFeatureData", + "CellPhasesArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, + "CrystalStructuresArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, + "FeatureIdsArrayName": "FeatureIds", + "FilterVersion": "6.5.171", + "Filter_Enabled": true, + "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", + "Filter_Name": "CAxisSegmentFeatures", + "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", + "GoodVoxelsArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, + "MisorientationTolerance": 10.0, + "QuatsArrayPath": {"Data Container Name": "TC1_Chain", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, + "UseGoodVoxels": 0 + }, + "2": { + "ActiveArrayName": "Active", + "CellFeatureAttributeMatrixName": "CellFeatureData", + "CellPhasesArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, + "CrystalStructuresArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, + "FeatureIdsArrayName": "FeatureIds", + "FilterVersion": "6.5.171", + "Filter_Enabled": true, + "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", + "Filter_Name": "CAxisSegmentFeatures", + "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", + "GoodVoxelsArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, + "MisorientationTolerance": 10.0, + "QuatsArrayPath": {"Data Container Name": "TC2_PiFold", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, + "UseGoodVoxels": 0 + }, + "3": { + "ActiveArrayName": "Active", + "CellFeatureAttributeMatrixName": "CellFeatureData", + "CellPhasesArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, + "CrystalStructuresArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, + "FeatureIdsArrayName": "FeatureIds", + "FilterVersion": "6.5.171", + "Filter_Enabled": true, + "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", + "Filter_Name": "CAxisSegmentFeatures", + "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", + "GoodVoxelsArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, + "MisorientationTolerance": 10.0, + "QuatsArrayPath": {"Data Container Name": "TC3_Mask", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, + "UseGoodVoxels": 1 + }, + "4": { + "ActiveArrayName": "Active", + "CellFeatureAttributeMatrixName": "CellFeatureData", + "CellPhasesArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, + "CrystalStructuresArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, + "FeatureIdsArrayName": "FeatureIds", + "FilterVersion": "6.5.171", + "Filter_Enabled": true, + "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", + "Filter_Name": "CAxisSegmentFeatures", + "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", + "GoodVoxelsArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, + "MisorientationTolerance": 10.0, + "QuatsArrayPath": {"Data Container Name": "TC4_Phase0", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, + "UseGoodVoxels": 0 + }, + "5": { + "FilterVersion": "6.5.171", + "Filter_Enabled": true, + "Filter_Human_Label": "Write DREAM.3D Data File", + "Filter_Name": "DataContainerWriter", + "Filter_Uuid": "{3fcd4c43-9d75-5b86-aad4-4441bc914f37}", + "OutputFile": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/legacy_out.dream3d", + "WriteTimeSeries": false, + "WriteXdmfFile": false + }, + "PipelineBuilder": { + "Name": "CAxisSegmentFeatures Legacy 6.5.171 A/B", + "Number_Filters": 6, + "Version": 6 + } +} diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py new file mode 100644 index 0000000000..fe06de2d2c --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Author the shared legacy-format input file for the CAxisSegmentFeatures A/B comparison. + +Builds four pure-Phi Bunge fixtures (the same Class 1 analytical fixtures encoded in +CAxisSegmentFeaturesTest.cpp) as separate DataContainers in one legacy v7 .dream3d file that +BOTH the DREAM3D 6.5.171 PipelineRunner and nxrunner read: + + TC1_Chain 8x1x1 Phi = [0, 5, 8, 45, 50, 120, 124, 90], tol 10 -> partition {0,1,2}{3,4}{5,6}{7} + TC2_PiFold 3x1x1 Phi = [2, 176, 88], tol 10 -> partition {0,1}{2} + TC3_Mask 5x1x1 Phi = [0, 20, 22, 0, 90], mask [0,1,1,0,1] -> {1,2}{4}, cells 0,3 -> id 0 + TC4_Phase0 4x1x1 Phi = [0,0,0,0], phases [0,1,1,1] -> {1,2,3}, cell 0 -> id 0 + +A pure rotation about x by Phi tilts the c-axis by Phi in the sample y-z plane, so the c-axis +angular distance between two cells is exactly min(|dPhi|, 180 - |dPhi|). Quaternion storage is +{x, y, z, w} = {sin(Phi/2), 0, 0, cos(Phi/2)} for both SIMPL and NX. + +Usage: python3 make_input.py (writes ../results/caxis_ab_input.dream3d) +""" +import math +import os +import sys + +import numpy as np + +sys.path.insert(0, "/Users/mjackson/Workspace1/Claude_Support/skills/compare-legacy-dream3d") +from legacy_dream3d import D3DLegacyWriter # noqa: E402 + +HEX_HIGH = 0 +UNKNOWN = 999 + + +def quat_from_phi_deg(phi_deg): + half = math.radians(phi_deg) * 0.5 + return [math.sin(half), 0.0, 0.0, math.cos(half)] + + +def add_case(w, dc_name, phis, phases=None, mask=None, num_ensembles=2): + n = len(phis) + w.add_image_geom(dc_name, (n, 1, 1)) + w.add_attribute_matrix(dc_name, "CellData", (1, 1, n), "Cell") + quats = np.array([quat_from_phi_deg(p) for p in phis], dtype=np.float32) + w.add_data_array(dc_name, "CellData", "Quats", quats, comp_dims=(4,)) + if phases is None: + phases = [1] * n + w.add_data_array(dc_name, "CellData", "Phases", np.array(phases, dtype=np.int32)) + if mask is not None: + w.add_data_array(dc_name, "CellData", "Mask", np.array(mask, dtype=bool), object_type="DataArray") + w.add_attribute_matrix(dc_name, "CellEnsembleData", (num_ensembles,), "CellEnsemble") + structures = np.full(num_ensembles, HEX_HIGH, dtype=np.uint32) + structures[0] = UNKNOWN + w.add_data_array(dc_name, "CellEnsembleData", "CrystalStructures", structures) + + +def main(): + out_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "results") + os.makedirs(out_dir, exist_ok=True) + out_path = os.path.abspath(os.path.join(out_dir, "caxis_ab_input.dream3d")) + + with D3DLegacyWriter(out_path) as w: + add_case(w, "TC1_Chain", [0.0, 5.0, 8.0, 45.0, 50.0, 120.0, 124.0, 90.0]) + add_case(w, "TC2_PiFold", [2.0, 176.0, 88.0]) + add_case(w, "TC3_Mask", [0.0, 20.0, 22.0, 0.0, 90.0], mask=[0, 1, 1, 0, 1]) + add_case(w, "TC4_Phase0", [0.0, 0.0, 0.0, 0.0], phases=[0, 1, 1, 1]) + + print(f"wrote {out_path}") + + +if __name__ == "__main__": + main() diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline new file mode 100644 index 0000000000..78f59f5dad --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline @@ -0,0 +1,115 @@ +{ + "isDisabled": false, + "name": "nx.d3dpipeline", + "pinnedParams": [], + "pipeline": [ + { + "args": { + "import_data_object": { + "value": { + "data_paths": [], + "file_path": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/caxis_ab_input.dream3d", + "path_import_policy": 0 + }, + "version": 2 + }, + "parameters_version": 1 + }, + "comments": "Read the SAME legacy-format input file the 6.5.171 pipeline reads.", + "filter": {"name": "nx::core::ReadDREAM3DFilter", "uuid": "0dbd31c7-19e0-4077-83ef-f4a6459a0e2d"}, + "isDisabled": false + }, + { + "args": { + "input_image_geometry_path": {"value": "TC1_Chain", "version": 1}, + "misorientation_tolerance": {"value": 10.0, "version": 1}, + "use_mask": {"value": false, "version": 1}, + "quats_array_path": {"value": "TC1_Chain/CellData/Quats", "version": 1}, + "cell_phases_array_path": {"value": "TC1_Chain/CellData/Phases", "version": 1}, + "mask_array_path": {"value": "", "version": 1}, + "crystal_structures_array_path": {"value": "TC1_Chain/CellEnsembleData/CrystalStructures", "version": 1}, + "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, + "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, + "active_array_name": {"value": "Active", "version": 1}, + "randomize_feature_ids": {"value": false, "version": 1}, + "neighbor_scheme_index": {"value": 0, "version": 1}, + "parameters_version": 2 + }, + "comments": "TC1 chain, face, no mask. Expected partition {0,1,2}{3,4}{5,6}{7}.", + "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, + "isDisabled": false + }, + { + "args": { + "input_image_geometry_path": {"value": "TC2_PiFold", "version": 1}, + "misorientation_tolerance": {"value": 10.0, "version": 1}, + "use_mask": {"value": false, "version": 1}, + "quats_array_path": {"value": "TC2_PiFold/CellData/Quats", "version": 1}, + "cell_phases_array_path": {"value": "TC2_PiFold/CellData/Phases", "version": 1}, + "mask_array_path": {"value": "", "version": 1}, + "crystal_structures_array_path": {"value": "TC2_PiFold/CellEnsembleData/CrystalStructures", "version": 1}, + "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, + "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, + "active_array_name": {"value": "Active", "version": 1}, + "randomize_feature_ids": {"value": false, "version": 1}, + "neighbor_scheme_index": {"value": 0, "version": 1}, + "parameters_version": 2 + }, + "comments": "TC2 pi-fold antiparallel c-axes. Expected partition {0,1}{2}.", + "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, + "isDisabled": false + }, + { + "args": { + "input_image_geometry_path": {"value": "TC3_Mask", "version": 1}, + "misorientation_tolerance": {"value": 10.0, "version": 1}, + "use_mask": {"value": true, "version": 1}, + "quats_array_path": {"value": "TC3_Mask/CellData/Quats", "version": 1}, + "cell_phases_array_path": {"value": "TC3_Mask/CellData/Phases", "version": 1}, + "mask_array_path": {"value": "TC3_Mask/CellData/Mask", "version": 1}, + "crystal_structures_array_path": {"value": "TC3_Mask/CellEnsembleData/CrystalStructures", "version": 1}, + "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, + "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, + "active_array_name": {"value": "Active", "version": 1}, + "randomize_feature_ids": {"value": false, "version": 1}, + "neighbor_scheme_index": {"value": 0, "version": 1}, + "parameters_version": 2 + }, + "comments": "TC3 bool mask excludes voxels 0 and 3. Expected {1,2}{4}, masked cells id 0.", + "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, + "isDisabled": false + }, + { + "args": { + "input_image_geometry_path": {"value": "TC4_Phase0", "version": 1}, + "misorientation_tolerance": {"value": 10.0, "version": 1}, + "use_mask": {"value": false, "version": 1}, + "quats_array_path": {"value": "TC4_Phase0/CellData/Quats", "version": 1}, + "cell_phases_array_path": {"value": "TC4_Phase0/CellData/Phases", "version": 1}, + "mask_array_path": {"value": "", "version": 1}, + "crystal_structures_array_path": {"value": "TC4_Phase0/CellEnsembleData/CrystalStructures", "version": 1}, + "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, + "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, + "active_array_name": {"value": "Active", "version": 1}, + "randomize_feature_ids": {"value": false, "version": 1}, + "neighbor_scheme_index": {"value": 0, "version": 1}, + "parameters_version": 2 + }, + "comments": "TC4 unindexed (phase 0) cell 0. Expected {1,2,3}, cell 0 id 0.", + "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, + "isDisabled": false + }, + { + "args": { + "export_file_path": {"value": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/nx_out.dream3d", "version": 1}, + "write_xdmf_file": {"value": false, "version": 1}, + "parameters_version": 1 + }, + "comments": "", + "filter": {"name": "nx::core::WriteDREAM3DFilter", "uuid": "b3a95784-2ced-41ec-8d3d-0242ac130003"}, + "isDisabled": false + } + ], + "version": 1, + "workflowParams": [] +} diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/.gitignore b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/.gitignore new file mode 100644 index 0000000000..90f47baddc --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/.gitignore @@ -0,0 +1,3 @@ +# Regenerable A/B comparison outputs — reproduce via ../pipelines (see comparison.md) +*.dream3d +*.xdmf diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md new file mode 100644 index 0000000000..082d9925e2 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md @@ -0,0 +1,55 @@ +# Legacy Comparison: CAxisSegmentFeaturesFilter + +Date: 2026-07-22 + +## Test Cases + +Four pure-Phi Bunge fixtures (identical to the Class 1 analytical fixtures in +`test/CAxisSegmentFeaturesTest.cpp`), tolerance 10°, face neighbors: + +| Case | Grid | Phi per cell (deg) | Extras | Expected partition | +|---|---|---|---|---| +| TC1_Chain | 8×1×1 | 0, 5, 8, 45, 50, 120, 124, 90 | — | {0,1,2} {3,4} {5,6} {7} | +| TC2_PiFold | 3×1×1 | 2, 176, 88 | π-fold branch | {0,1} {2} | +| TC3_Mask | 5×1×1 | 0, 20, 22, 0, 90 | bool mask [0,1,1,0,1] | {1,2} {4}; cells 0,3 → id 0 | +| TC4_Phase0 | 4×1×1 | 0, 0, 0, 0 | phases [0,1,1,1] | {1,2,3}; cell 0 → id 0 | + +## Input Data + +One legacy v7 `.dream3d` file authored by `../pipelines/make_input.py` (legacy_dream3d writer), +read by BOTH runners, so the Quats/Phases/Mask/CrystalStructures inputs are byte-identical. + +## Runners + +- Legacy: `/Users/mjackson/Applications/DREAM3D.app/Contents/Bin/PipelineRunner` (official 6.5.171) +- NX: `/Users/mjackson/Workspace5/DREAM3D-Build/NX-Com-Qt69-Vtk95-Rel/Bin/nxrunner`, simplnx at the V&V commit (post D1/D4/D5 fixes) + +## Results + +| Case | Legacy FeatureIds | NX FeatureIds | Partition match | Feature count match | +|---|---|---|---|---| +| TC1_Chain | [1,1,1,4,4,3,3,2] | [1,1,1,2,2,3,3,4] | YES | YES (4+1 Active tuples) | +| TC2_PiFold | [2,2,1] | [1,1,2] | YES | YES (2+1) | +| TC3_Mask | [0,1,1,0,2] | [0,1,1,0,2] | YES | YES (2+1) | +| TC4_Phase0 | [0,1,1,1] | [0,1,1,1] | YES | YES (1+1) | + +**All four cases match at the segmentation-partition level, and feature counts are identical.** +Raw legacy ids are a random permutation of NX ids because 6.5.171 unconditionally randomizes +FeatureIds with a clock-derived seed (`CAxisSegmentFeatures-D2`); bit-identical FeatureIds are +not attainable against 6.5.171 by construction. NX ids additionally match the Class 1 oracle +expectation exactly. + +## Fixes Applied + +None in this comparison round — SIMPLNX had already been reconciled against the Class 1/4 +oracle earlier in the V&V cycle, which fixed three SIMPLNX defects (D1 unvalidated first seed, +D4 spurious rejection of unindexed/masked cells, D5 RectGrid crash). No legacy patch: legacy +output is not wrong on any shared code path. + +## Notes + +- Legacy 6.5.171 cannot disable FeatureIds randomization (not exposed as a parameter), so the + comparison is partition-level by necessity. Partition equality is the meaningful invariant. +- NX-only behavior not comparable against 6.5.171: 26-neighbor "All Connected" scheme, RectGrid + geometry input, uint8 masks, crystal-structure validation (D3). +- Reproduce: `python3 ../pipelines/make_input.py`, then run both pipelines in `../pipelines/`. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md new file mode 100644 index 0000000000..84ce062bb8 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md @@ -0,0 +1,95 @@ +# Deviations from DREAM3D 6.5.171: CAxisSegmentFeaturesFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent. + +Entries are referenced by stable ID (`CAxisSegmentFeaturesFilter-D`) from the V&V report and from public migration guidance. The ID is stable across renames; the Filter UUID field is the permanent cross-reference anchor. + +--- + +## CAxisSegmentFeaturesFilter-D1 + +| Field | Value | +|---|---| +| **Deviation ID** | `CAxisSegmentFeaturesFilter-D1` | +| **Filter UUID** | `9fe07e17-aef1-4bf1-834c-d3a73dafc27d` | +| **Status** | active (SIMPLNX bug **fixed during this V&V cycle**; documented for users of prior SIMPLNX releases) | + +**Symptom:** In SIMPLNX releases containing PR #1466 (2025-11-14) and prior to this V&V cycle, the filter could report one extra (empty) feature, shift every FeatureId up by one, or grow a feature from a masked-out / unindexed voxel — whenever voxel 0 of the image was not a legitimate seed. 6.5.171 never exhibits this. + +**Root cause:** Bug (SIMPLNX). The shared driver `src/simplnx/Utilities/SegmentFeatures.cpp::execute()` started the flood fill from the raw index `seed = 0` without calling `getSeed()`, so the first seed was neither validated against the mask/phase requirements nor stamped with its FeatureId. Legacy `SegmentFeatures::execute()` obtains every seed — including the first — from `getSeed()`. Restored in this V&V cycle (`seed = getSeed(gnum, nextSeed)` before the loop); the fix also applies to `EBSDSegmentFeatures` and `ScalarSegmentFeatures`, which share the driver. Pinned by the `Class 1 Analytical (Mask Excludes Voxel 0)` and `Execute Error - No Features Found (-87000)` test cases. + +**Affected users:** SIMPLNX users (post-#1466, pre-fix builds) whose datasets have a masked-out, unindexed, or already-owned cell at linear index 0 — common in EBSD scans with a mask. Legacy 6.5.171 users are unaffected. + +**Recommendation:** Trust SIMPLNX at or after this V&V commit (which agrees with 6.5.171). Results from affected intermediate SIMPLNX builds on masked data should be regenerated. + +--- + +## CAxisSegmentFeaturesFilter-D2 + +| Field | Value | +|---|---| +| **Deviation ID** | `CAxisSegmentFeaturesFilter-D2` | +| **Filter UUID** | `9fe07e17-aef1-4bf1-834c-d3a73dafc27d` | +| **Status** | active | + +**Symptom:** FeatureIds from 6.5.171 are a different (random) labeling on every run, while SIMPLNX produces the same FeatureIds on every run; the two versions never produce bit-identical FeatureIds arrays. + +**Root cause:** Algorithmic choice. 6.5.171 hard-codes `m_RandomizeFeatureIds = true` and seeds its RNG from the wall clock (`CAxisSegmentFeatures.cpp::initializeVoxelSeedGenerator`), and the option is not exposed as a pipeline parameter — legacy output labeling is irreproducible by construction. SIMPLNX exposes `Randomize Feature Ids` as a parameter (default `false`) and, when enabled, uses a fixed-seed `std::mt19937_64` (`ClusterUtilities::RandomizeFeatureIds`), so output is deterministic either way. The segmentation *partition* (which cells share a feature) is unaffected; the 2026-07-22 A/B run (`vv/comparisons/CAxisSegmentFeaturesFilter/`) matched partitions exactly on all four fixtures. + +**Affected users:** Anyone diffing raw FeatureIds arrays between versions or between two 6.5.171 runs; downstream statistics keyed by feature (sizes, misorientations) are invariant to the labeling. + +**Recommendation:** Trust SIMPLNX. Deterministic labeling is strictly more reproducible; compare segmentations at the partition level when validating against legacy runs. + +--- + +## CAxisSegmentFeaturesFilter-D3 + +| Field | Value | +|---|---| +| **Deviation ID** | `CAxisSegmentFeaturesFilter-D3` | +| **Filter UUID** | `9fe07e17-aef1-4bf1-834c-d3a73dafc27d` | +| **Status** | active | + +**Symptom:** On data containing non-hexagonal phases that participate in segmentation, 6.5.171 silently produces a segmentation; SIMPLNX fails with error `-8363` (and `-8364` for phase values with no CrystalStructures entry). + +**Root cause:** Algorithmic choice (deliberate SIMPLNX guard). The c-axis is only a physically meaningful unique axis for hexagonal (6/m, 6/mmm) Laue classes, but the c-axis math itself never consults the crystal structure — legacy computes the [001] misalignment for cubic/other phases and returns scientifically meaningless groupings. SIMPLNX validates that every cell that can participate in segmentation (phase > 0, not masked out) has a Hexagonal_High or Hexagonal_Low crystal structure (`CAxisSegmentFeatures.cpp::operator()`). Unindexed (phase 0) cells and masked-out cells are exempt, since they can never seed or join a feature. + +**Affected users:** Anyone who ran the legacy filter on multi-phase data with non-hexagonal phases — their legacy results for those phases were never meaningful. Pure-hexagonal workflows are unaffected. + +**Recommendation:** Trust SIMPLNX. The error is a correctness guard; mask out non-hexagonal phases (supported) to segment only the hexagonal cells. + +--- + +## CAxisSegmentFeaturesFilter-D4 + +| Field | Value | +|---|---| +| **Deviation ID** | `CAxisSegmentFeaturesFilter-D4` | +| **Filter UUID** | `9fe07e17-aef1-4bf1-834c-d3a73dafc27d` | +| **Status** | active (SIMPLNX bug **fixed during this V&V cycle**; documented for users of prior SIMPLNX releases) | + +**Symptom:** Prior to this V&V cycle, SIMPLNX rejected (error `-8363`) any dataset containing unindexed (phase 0) cells — whose `CrystalStructures[0]` entry is the conventional `999` sentinel — or masked-out non-hexagonal cells; 6.5.171 processes such datasets normally. + +**Root cause:** Bug (SIMPLNX). The D3 validation loop checked every cell's crystal structure, including phase-0 cells and cells excluded by the mask, neither of which can ever participate in segmentation (`getSeed` requires phase > 0 and a set mask bit; `determineGrouping` requires equal phases and a set mask bit). It also indexed `CrystalStructures[phase]` without a bounds check, so an out-of-range phase value read out of bounds instead of producing an error. Fixed by exempting phase ≤ 0 and masked-out cells and adding the `-8364` bounds error. Pinned by the `Phase 0 (Unindexed) Cells Tolerated`, `Masked Non-Hexagonal Cells Tolerated`, and `Execute Error - Phase Out of Ensemble Bounds (-8364)` test cases; the 2026-07-22 A/B run confirms post-fix parity with 6.5.171 on phase-0 data (TC4). + +**Affected users:** SIMPLNX users (pre-fix builds) with EBSD scans containing unindexed points — a very common case — or deliberately masked non-hexagonal phases. + +**Recommendation:** Trust SIMPLNX at or after this V&V commit; upgrade if the filter spuriously rejects data with unindexed points. + +--- + +## CAxisSegmentFeaturesFilter-D5 + +| Field | Value | +|---|---| +| **Deviation ID** | `CAxisSegmentFeaturesFilter-D5` | +| **Filter UUID** | `9fe07e17-aef1-4bf1-834c-d3a73dafc27d` | +| **Status** | active (SIMPLNX bug **fixed during this V&V cycle**; no legacy equivalent behavior) | + +**Symptom:** SIMPLNX accepts a RectGrid geometry as input (6.5.171 accepts only Image geometry); prior to this V&V cycle, selecting a RectGrid geometry passed preflight and then crashed at execute. + +**Root cause:** Bug (SIMPLNX). The algorithm fetched the geometry with a stale `getDataAs()` cast, returning a null pointer for RectGrid input, which the shared segmentation driver dereferenced. Fixed to `getDataAs()`, matching the parameter's allowed types and the sibling EBSD/Scalar segmentation algorithms. Pinned by the `Class 1 Analytical (RectGrid Geometry)` test case. + +**Affected users:** SIMPLNX users (pre-fix builds) segmenting RectGrid data — e.g., regularized serial-sectioning data. Legacy users are unaffected (the capability does not exist in 6.5.171). + +**Recommendation:** Trust SIMPLNX at or after this V&V commit. diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md new file mode 100644 index 0000000000..7d537ffa18 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md @@ -0,0 +1,45 @@ +# Exemplar Archive Provenance: CAxisSegmentFeaturesFilter (inline fixtures — no archive) + +This sidecar records the provenance of the oracle data for `CAxisSegmentFeaturesFilter`. There is **no exemplar archive**: all oracle fixtures are built inline in the test source, so the oracle provenance lives in code and in this note. + +--- + +## Archive identity + +| Field | Value | +|---|---| +| **Archive** | None — fixtures inlined in `test/CAxisSegmentFeaturesTest.cpp` (namespace `AnalyticalFixtures`) | +| **SHA512** | N/A | +| **Used by tests** | All `OrientationAnalysis::CAxisSegmentFeaturesFilter:*` test cases except `SIMPL Backwards Compatibility` (JSON fixtures) | +| **Generated by** | Michael Jackson | +| **Generated on** | 2026-07-22 | +| **Generated at commit** | *the V&V commit on `vv/c_axis_segmentation`* | + +## How it was generated + +`AnalyticalFixtures::CreateScaffold(dimX, dimY, dimZ, numEnsembles)` builds a minimal ImageGeom with Cell (Quats, Phases, optional Mask) and CellEnsemble (CrystalStructures) AttributeMatrices; one test builds a RectGridGeom variant directly. Per-cell orientations are pure Bunge (φ1=0, Φ, φ2=0) quaternions `{sin(Φ/2), 0, 0, cos(Φ/2)}` set via `SetPhi()`. All inputs are hand-constructed; no external data and no DREAM3D output (of any version) is consumed. + +## Canonical oracle output + +The oracle is **Class 1 (Analytical)** with **Class 4 (Invariant)** companions; expected values are hand-derived constants inlined next to the assertions. + +| DataPath | Source of expected values | +|---|---| +| `ImageGeometry/CellData/FeatureIds` | Class 1 hand derivation: pure-Phi c-axes make the folded angular distance between cells exactly `min(\|ΦA−ΦB\|, 180°−\|ΦA−ΦB\|)`; expected FeatureIds follow from tolerance + grid adjacency (derivation comment at `QuatFromPhiDeg`) | +| `ImageGeometry/CellFeatureData/Active` | Class 4 invariants: tuple count = numFeatures+1, `Active[0]==0`, all real features active (`CheckActiveArray`) | + +The same fixtures drive the 6.5.171 A/B comparison (`vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py`). + +## Oracle provenance (Classes 2, 3, 5 only) + +Not applicable — Classes 1 and 4; the derivation lives in the test source. + +## Second-engineer oracle review + +- **Reviewer:** pending — recommended at PR review. The pure-Phi Bunge c-axis derivation is sibling-shared with `GroupMicroTextureRegionsFilter` and `ComputeFeatureNeighborCAxisMisalignmentsFilter` Class 1 oracles, which have been through prior V&V cycles. +- **Date:** — +- **Skip reason:** not skipped; deferred to the PR review per the sign-off convention. + +## Regenerated to fix a circular-oracle situation? + +Yes — this inline-fixture suite **replaces the filter's consumption of `segment_features_test_data.tar.gz`** (which remains in the Data_Archive for the `EBSDSegmentFeatures` tests). That archive's `CAxis_FeatureIds_*` exemplar arrays were generated by running SIMPLNX itself, so they constituted a circular oracle: they encoded the pre-V&V SIMPLNX behavior, including the D1 unvalidated-first-seed defect era, and could not certify correctness. The four retired exemplar tests (`:Face`, `:All`, `:MaskFace`, `:MaskAll`) are superseded by the Class 1 analytical tests, which cover the same parameter cube (face/all neighbors × mask on/off) with independently derived expected output. diff --git a/src/simplnx/Utilities/SegmentFeatures.cpp b/src/simplnx/Utilities/SegmentFeatures.cpp index 037b7175b0..cc23e2274c 100644 --- a/src/simplnx/Utilities/SegmentFeatures.cpp +++ b/src/simplnx/Utilities/SegmentFeatures.cpp @@ -148,7 +148,11 @@ Result<> SegmentFeatures::execute(IGridGeometry* gridGeom) // Initialize a sequence of execution modifiers int32 gnum = 1; int64 nextSeed = 0; - int64 seed = 0; // Always use the very first value of the array that we are using to segment + // The first seed must be validated (and its cell stamped with gnum) by getSeed() exactly like + // every later seed; bursting from a raw index 0 can grow a feature from a masked or phase-0 + // voxel and leaves an empty feature behind whenever index 0 cannot legitimately seed anything. + int64 seed = getSeed(gnum, nextSeed); + nextSeed = seed + 1; usize size = 0; // Initialize containers From fbc6dbdcb3cfbf5aee68878083c0b2b94fecbcfd Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 22 Jul 2026 18:21:24 -0400 Subject: [PATCH 02/12] REV/TEST: Harden C-Axis segmentation from adversarial review findings * Add 3x2x2 axis-asymmetric Class 1 fixture pinning the x-fastest voxel linearization and the y/z stride neighbor branches (prior fixtures were invariant under dims permutation) * Assert the randomized FeatureIds differ from the canonical labeling so a dead randomizer fails the Class 4 test; add a masked variant pinning the FeatureId 0 -> 0 mapping through the shuffle * Preflight now rejects cell arrays whose tuple count differs from the geometry cell count (-652) instead of walking out of bounds at execute * Create FeatureIds with the cell AttributeMatrix tuple shape rather than the Quats array shape * Return cleanly from a canceled run instead of misreporting -87000 * Update V&V report (18 tests, new paths/pins) Signed-off-by: Michael Jackson --- .../Algorithms/CAxisSegmentFeatures.cpp | 12 ++- .../Filters/CAxisSegmentFeaturesFilter.cpp | 15 ++- .../test/CAxisSegmentFeaturesTest.cpp | 100 ++++++++++++++++++ .../vv/CAxisSegmentFeaturesFilter.md | 14 ++- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp index 7627f23adf..eeaef15051 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp @@ -84,7 +84,17 @@ Result<> CAxisSegmentFeatures::operator()() activeArray->fill(1); // Run the segmentation algorithm - execute(gridGeom); + Result<> segmentResult = execute(gridGeom); + if(segmentResult.invalid()) + { + return segmentResult; + } + // A canceled run returns successfully with no feature count; do not misreport it below as + // "no Features were detected". + if(m_ShouldCancel) + { + return {}; + } // Sanity check the result. if(this->m_FoundFeatures < 1) { diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp index 484a036401..ca866c37d9 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp @@ -3,6 +3,7 @@ #include "OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.hpp" #include "simplnx/Common/Constants.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" #include "simplnx/DataStructure/DataPath.hpp" #include "simplnx/DataStructure/Geometry/IGridGeometry.hpp" #include "simplnx/Filter/Actions/CreateArrayAction.hpp" @@ -151,9 +152,19 @@ IFilter::PreflightResult CAxisSegmentFeaturesFilter::preflightImpl(const DataStr return {MakeErrorResult(-651, fmt::format("The following DataArrays all must have equal number of tuples but this was not satisfied.\n{}", tupleValidityCheck.error()))}; } - // Create the Cell Level FeatureIds array + // The cell-level arrays must have exactly one tuple per geometry cell; the check above only + // validates the arrays against each other, not against the geometry the flood fill walks. const auto& quats = dataStructure.getDataRefAs(pQuatsArrayPathValue); - auto createFeatureIdsAction = std::make_unique(DataType::int32, quats.getIDataStore()->getTupleShape(), std::vector{1}, featureIdsPath); + if(quats.getNumberOfTuples() != inputGridGeom->getNumberOfCells()) + { + return {MakeErrorResult(-652, fmt::format("The selected cell arrays have {} tuples but the selected geometry '{}' has {} cells.", quats.getNumberOfTuples(), gridGeomPath.toString(), + inputGridGeom->getNumberOfCells()))}; + } + + // Create the Cell Level FeatureIds array with the cell AttributeMatrix's tuple shape so the + // created array always matches the AttributeMatrix that hosts it. + const auto& cellDataAM = dataStructure.getDataRefAs(inputCellDataPath); + auto createFeatureIdsAction = std::make_unique(DataType::int32, cellDataAM.getShape(), std::vector{1}, featureIdsPath); // Create the Feature Attribute Matrix auto createFeatureGroupAction = std::make_unique(pCellFeatureAttributeMatrixNameValue, std::vector{1}); diff --git a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp index bea057737a..1edaa24889 100644 --- a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp @@ -359,6 +359,31 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(td.ds); } +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (3D Linearization, 3x2x2)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); + + // Pins the x-fastest voxel linearization (cell index = z*6 + y*3 + x) and exercises the y- and + // z-stride neighbor branches that 1-D and single-slice fixtures cannot reach. The Phi field is + // axis-asymmetric: swapping any two dimensions in the neighbor decode changes the partition + // (verified by simulating the flood fill under every dims permutation), so a linearization bug + // cannot pass. Folded-distance edges at tol 10: x: 0-1(5); y: 0-3(8), 2-5(4), 6-9(9); + // z: 0-6(3), 3-9(4), 4-10(5). Expected partition: {0,1,3,6,9} {2,5} {4,10} {7} {8} {11}. + FixtureData td = CreateScaffold(3, 2, 2); + const std::vector phiValues = {0.0f, 5.0f, 40.0f, 8.0f, 90.0f, 44.0f, 3.0f, 60.0f, 130.0f, 12.0f, 85.0f, 170.0f}; + for(usize cellIdx = 0; cellIdx < phiValues.size(); cellIdx++) + { + SetPhi(td, cellIdx, phiValues[cellIdx]); + } + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeatureIds(td.ds, {1, 1, 2, 1, 3, 2, 1, 4, 5, 1, 3, 6}); + CheckActiveArray(td.ds, 6); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (RectGrid Geometry)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") { UnitTest::LoadPlugins(); @@ -454,11 +479,50 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants ( // Permutation invariant: the relabeled ids are exactly {1, 2, 3, 4}. REQUIRE(distinctIds == std::set{1, 2, 3, 4}); + // Non-identity invariant: with the fixed seed the shuffle produces a relabeling different from + // the canonical (unrandomized) labeling, so a dead/never-invoked randomizer fails this test. + REQUIRE(firstRun != std::vector{1, 1, 1, 2, 2, 3, 3, 4}); + // Determinism invariant: the shuffle uses a fixed seed, so a second run is bit-identical. const std::vector secondRun = runOnce(); REQUIRE(firstRun == secondRun); } +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); + + // FeatureId 0 is reserved for cells outside the segmentation; randomization must map 0 -> 0. + // Re-uses the mask fixture (voxels 0 and 3 masked out) with randomization enabled. + FixtureData td = CreateScaffold(5, 1, 1); + const std::vector phiValues = {0.0f, 20.0f, 22.0f, 0.0f, 90.0f}; + for(usize cellIdx = 0; cellIdx < phiValues.size(); cellIdx++) + { + SetPhi(td, cellIdx, phiValues[cellIdx]); + } + auto* maskArrayPtr = CreateTestDataArray(td.ds, k_MaskName, ShapeType{1, 1, 5}, {1}, td.cellAM->getId()); + const std::vector maskValues = {0, 1, 1, 0, 1}; + for(usize cellIdx = 0; cellIdx < maskValues.size(); cellIdx++) + { + (*maskArrayPtr)[cellIdx] = (maskValues[cellIdx] != 0); + } + + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, true, true)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(td.ds.getDataRefAs(k_FeatureIdsPath)); + const auto& featureIdsRef = td.ds.getDataRefAs(k_FeatureIdsPath).getDataStoreRef(); + // Masked cells keep FeatureId 0 through the shuffle. + REQUIRE(featureIdsRef[0] == 0); + REQUIRE(featureIdsRef[3] == 0); + // Partition invariant survives the shuffle: {1,2} together, {4} separate, both ids from {1,2}. + REQUIRE(featureIdsRef[1] == featureIdsRef[2]); + REQUIRE(featureIdsRef[1] != featureIdsRef[4]); + REQUIRE(std::set{featureIdsRef[1], featureIdsRef[4]} == std::set{1, 2}); + CheckActiveArray(td.ds, 2); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Phase 0 (Unindexed) Cells Tolerated", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") { UnitTest::LoadPlugins(); @@ -562,6 +626,42 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Ze REQUIRE(preflightResult.outputActions.errors()[0].code == -655); } +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell arrays smaller than geometry (-652)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") +{ + UnitTest::LoadPlugins(); + + // Quats and Phases agree with each other (9 tuples) but not with the 10-cell geometry; the + // -651 cross-array check passes and the -652 geometry check must catch it (pre-fix the flood + // fill would index the cell arrays out of bounds). + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "DataContainer"); + imageGeom->setDimensions({10, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", {9}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + UnitTest::CreateTestDataArray(dataStructure, "Quats", {9}, {4}, cellAM->getId()); + UnitTest::CreateTestDataArray(dataStructure, "Phases", {9}, {1}, cellAM->getId()); + auto* ensembleAM = AttributeMatrix::Create(dataStructure, "CellEnsembleData", {2}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", {2}, {1}, ensembleAM->getId()); + + CAxisSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"DataContainer"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellData", "Quats"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellData", "Phases"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellEnsembleData", "CrystalStructures"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors()[0].code == -652); +} + TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell array tuple count mismatch (-651)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md index 853e78d32b..c52159a010 100644 --- a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -17,7 +17,7 @@ | Algorithm Relationship | **Minor changes.** Line-for-line port of legacy `getSeed`/`determineGrouping` (same c-axis math, including the clamped `acos` and the π-fold), plus deliberate NX additions: crystal-structure validation, 26-neighbor scheme, uint8 masks, RectGrid input, deterministic opt-in FeatureId randomization. Three SIMPLNX defects (D1, D4, D5) found and **fixed during this V&V cycle**. | | Oracle (confirmed) | **Class 1 (Analytical) primary + Class 4 (Invariant) companion** — pure-Phi Bunge quats make the folded c-axis distance between cells exactly `min(\|ΔΦ\|, 180°−\|ΔΦ\|)`; expected FeatureIds derive in closed form. Encoded as 8 fixture tests in `test/CAxisSegmentFeaturesTest.cpp`; all pass. | | Code paths enumerated | 20 of 20 enumerated; 18 exercised (2 gaps: defensive mask-instantiation error, cancel-signal path). | -| Tests today | 15 test cases: 6 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, RectGrid), 1 Class 4 invariants (randomize), 2 tolerance pins (phase-0, masked non-hex), 3 execute-error, 2 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | +| Tests today | 18 test cases: 7 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, 3-D linearization, RectGrid), 2 Class 4 invariants (randomize non-identity/determinism, masked-zero preservation), 2 tolerance pins (phase-0, masked non-hex), 3 execute-error, 3 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | | Exemplar archive | **None — fixtures inlined in the test source.** The filter's consumption of `segment_features_test_data.tar.gz` (circular oracle) is retired; the archive remains for the EBSD segmentation tests. | | Legacy comparison | **Run** (2026-07-22, `vv/comparisons/CAxisSegmentFeaturesFilter/`) — all 4 shared-behavior fixtures match 6.5.171 at the segmentation-partition level with identical feature counts; bit-identical ids are unattainable because 6.5.171 always clock-randomizes FeatureIds (D2). | | Bug flags | D1, D4, D5 — all SIMPLNX defects, all **fixed this cycle** and pinned by tests. No legacy bug flags. | @@ -43,6 +43,7 @@ 6. **D1 (fixed this cycle):** PR #1466's driver restructure made the first seed an unvalidated raw index 0 — phantom/misgrown first feature when voxel 0 could not seed. Restored `getSeed()` for the first seed (also fixes EBSD/Scalar segmentation, which share the driver). 7. **D4 (fixed this cycle):** the delta-2 validation loop originally checked phase-0 and masked-out cells (which can never participate) and indexed `CrystalStructures` without a bounds check. 8. **D5 (fixed this cycle):** stale `getDataAs` cast crashed RectGrid input; now `IGridGeometry`, matching the sibling algorithms. +9. **Adversarial-review hardening (this cycle, no legacy behavior change):** preflight now rejects cell arrays whose tuple count differs from the geometry's cell count (`-652`, previously an out-of-bounds walk); FeatureIds is created with the cell AttributeMatrix's tuple shape instead of the Quats array's; a canceled run returns cleanly instead of misreporting `-87000`. *Material PRs since baseline:* #1373 (26-neighbor option), #1444/#1498 (progress messaging), #1466 (feature-count fix; introduced D1), #1472 (EbsdLib 2.0 API), #1535 (preflight cleanup), #1501 (Vec3 consolidation). @@ -74,7 +75,7 @@ Logical phases: **(a) init + validation** in `operator()`, **(b) flood-fill driv | 6 | (a) | Hexagonal_High and Hexagonal_Low both accepted | All Class 1 tests (High); `Class 1 Analytical (Phase Separation)` (Low, ensemble 2) | | 7 | (a) | Geometry fetched as `IGridGeometry` (Image and RectGrid) | All tests (Image); `Class 1 Analytical (RectGrid Geometry)` (RectGrid) | | 8 | (b) | First seed obtained via `getSeed` (D1 pin) | `Class 1 Analytical (Mask Excludes Voxel 0)`; `Execute Error - No Features Found (-87000)` | -| 9 | (b) | Face (6-neighbor) scheme | `Class 1 Analytical (Pure-Phi Chain, Face)` and others | +| 9 | (b) | Face (6-neighbor) scheme, incl. y-/z-stride branches and the x-fastest linearization | `Class 1 Analytical (Pure-Phi Chain, Face)`; `Class 1 Analytical (3D Linearization, 3x2x2)` (axis-asymmetric Phi field kills any dims-permutation mutant) | | 10 | (b) | All-connected (26-neighbor) scheme | `Class 1 Analytical (Neighbor Scheme Face vs All)` — "All Connected Neighbors" section | | 11 | (b) | Cancel requested → early return | *Not directly tested. Requires cancel-signal injection; low-value gap.* | | 12 | (c) | Seed scan skips owned / masked / phase ≤ 0 cells | `Mask Excludes Voxel 0`; `Phase 0 (Unindexed) Cells Tolerated` | @@ -85,9 +86,9 @@ Logical phases: **(a) init + validation** in `operator()`, **(b) flood-fill driv | 17 | (d) | `w ≤ tol` → group | `Pure-Phi Chain` (Δ = 3°, 4°, 5° pairs) | | 18 | (d) | `π − w ≤ tol` → group (antiparallel c-axes) | `Class 1 Analytical (Pi-Fold Antiparallel C-Axes)` | | 19 | (e) | `foundFeatures < 1` → error `-87000`; else AM resize, Active refill, `Active[0]=0` | `No Features Found (-87000)` (error); `CheckActiveArray` in every passing test (success) | -| 20 | (e) | `RandomizeFeatureIds == true` → deterministic shuffle | `Class 4 Invariants (RandomizeFeatureIds)` | +| 20 | (e) | `RandomizeFeatureIds == true` → deterministic, non-identity shuffle preserving id 0 | `Class 4 Invariants (RandomizeFeatureIds)` (non-identity + determinism); `Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)` | -Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple mismatch → `-651` — are covered by the two `Preflight Error` tests; SIMPL 6.4/6.5 argument conversion by `SIMPL Backwards Compatibility`. +Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple mismatch → `-651`, cell arrays vs geometry cell count → `-652` — are covered by the three `Preflight Error` tests; SIMPL 6.4/6.5 argument conversion by `SIMPL Backwards Compatibility`. The cancel early-return added to `operator()` (clean return instead of `-87000` on cancel) shares path 11's not-directly-tested status. ## Test inventory @@ -98,14 +99,17 @@ Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple m | `Class 1 Analytical (Neighbor Scheme Face vs All)` | new-for-V&V | DYNAMIC_SECTION over both schemes on a 2×2×1 diagonal fixture; Face → 4 features, All → 3. | | `Class 1 Analytical (Mask Excludes Voxel 0)` | new-for-V&V | DYNAMIC_SECTION over bool + uint8 masks; **D1 regression pin** (pre-fix: phantom feature + shifted ids). | | `Class 1 Analytical (Phase Separation)` | new-for-V&V | Identical orientations split at a phase boundary; also covers Hexagonal_Low acceptance. | +| `Class 1 Analytical (3D Linearization, 3x2x2)` | new-for-V&V | Pins the x-fastest linearization + y/z stride branches with an axis-asymmetric Phi field (added after adversarial review showed the original fixtures were invariant under dims permutation). | | `Class 1 Analytical (RectGrid Geometry)` | new-for-V&V | **D5 regression pin** (pre-fix: null-pointer crash on RectGrid input). | -| `Class 4 Invariants (RandomizeFeatureIds)` | new-for-V&V | Partition preservation, permutation of {1..4}, same-seed determinism across two runs. | +| `Class 4 Invariants (RandomizeFeatureIds)` | new-for-V&V | Partition preservation, permutation of {1..4}, non-identity vs canonical labeling (kills a dead randomizer), same-seed determinism across two runs. | +| `Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)` | new-for-V&V | Masked cells keep FeatureId 0 through the shuffle (pins the 0→0 mapping guarantee). | | `Phase 0 (Unindexed) Cells Tolerated` | new-for-V&V | **D4 regression pin** (pre-fix: spurious `-8363` on the 999 sentinel). | | `Masked Non-Hexagonal Cells Tolerated` | new-for-V&V | **D4 regression pin** (masked cubic phase must not be validated). | | `Execute Error - Non-Hexagonal Crystal Structure (-8363)` | new-for-V&V | Unmasked cubic cells rejected. | | `Execute Error - Phase Out of Ensemble Bounds (-8364)` | new-for-V&V | **D4 regression pin** (pre-fix: out-of-bounds read). | | `Execute Error - No Features Found (-87000)` | new-for-V&V | All cells masked; **D1 regression pin** (pre-fix: success with 1 phantom feature). | | `Preflight Error - Zero Tolerance (-655)` | new-for-V&V | Preflight rejects tolerance == 0. | +| `Preflight Error - Cell arrays smaller than geometry (-652)` | new-for-V&V | Cell arrays consistent with each other but not with the geometry's cell count (pre-fix: out-of-bounds walk at execute). | | `Preflight Error - Cell array tuple count mismatch (-651)` | kept | Synthetic tuple-count mismatch between Quats and Phases. | | `SIMPL Backwards Compatibility` | kept | DYNAMIC_SECTION over SIMPL 6.4 + 6.5 conversion fixtures; UUID + argument-key conversion only. | | *(retired)* `CAxisSegmentFeatures:Face` / `:All` / `:MaskFace` / `:MaskAll` | retired | Consumed the `segment_features_test_data.tar.gz` exemplar whose `CAxis_FeatureIds_*` arrays were generated from SIMPLNX output — a circular oracle (see provenance sidecar). Replaced by the Class 1 fixtures, which cover the same scheme × mask parameter cube with independent expected output. | From 03a56aebb5651008acd0ba34988c26765eee56a8 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 22 Jul 2026 18:32:58 -0400 Subject: [PATCH 03/12] REV: Address general review findings for C-Axis segmentation V&V * Add -653 preflight guard: the geometry cell AttributeMatrix hosting the created FeatureIds must match the geometry cell count (closes the residual hole behind the -652 check) * Add Class 1 fixture with Quats in a sibling AttributeMatrix, pinning the preflight/execute FeatureIds path agreement * Refresh V&V report bookkeeping (20 tests, 10 oracle fixtures, dual-build 20/20 rerun), name the path-derivation half of the executeImpl fix * Fix deviation-ID typo in comparison.md; make make_input.py's writer-module path overridable via LEGACY_DREAM3D_SKILL_DIR Signed-off-by: Michael Jackson --- .../Filters/CAxisSegmentFeaturesFilter.cpp | 8 +- .../test/CAxisSegmentFeaturesTest.cpp | 74 +++++++++++++++++++ .../vv/CAxisSegmentFeaturesFilter.md | 14 ++-- .../pipelines/make_input.py | 4 +- .../results/comparison.md | 2 +- 5 files changed, 93 insertions(+), 9 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp index ca866c37d9..1e2813bea4 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp @@ -162,8 +162,14 @@ IFilter::PreflightResult CAxisSegmentFeaturesFilter::preflightImpl(const DataStr } // Create the Cell Level FeatureIds array with the cell AttributeMatrix's tuple shape so the - // created array always matches the AttributeMatrix that hosts it. + // created array always matches the AttributeMatrix that hosts it. That AttributeMatrix must + // itself agree with the geometry, or FeatureIds would be smaller than the flood-fill walk. const auto& cellDataAM = dataStructure.getDataRefAs(inputCellDataPath); + if(cellDataAM.getNumberOfTuples() != inputGridGeom->getNumberOfCells()) + { + return {MakeErrorResult(-653, fmt::format("The geometry's cell AttributeMatrix '{}' has {} tuples but the selected geometry '{}' has {} cells.", inputCellDataPath.toString(), + cellDataAM.getNumberOfTuples(), gridGeomPath.toString(), inputGridGeom->getNumberOfCells()))}; + } auto createFeatureIdsAction = std::make_unique(DataType::int32, cellDataAM.getShape(), std::vector{1}, featureIdsPath); // Create the Feature Attribute Matrix diff --git a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp index 1edaa24889..6686bac4b6 100644 --- a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp @@ -437,6 +437,43 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(dataStructure); } +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Quats Outside Cell AttributeMatrix)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +{ + UnitTest::LoadPlugins(); + + // The Quats/Phases arrays are not required to live in the geometry's cell AttributeMatrix — + // only to match its cell count. FeatureIds must still be created in the geometry's cell + // AttributeMatrix (preflight and execute must derive the same path; pre-fix, execute derived + // it from the Quats array's parent and dereferenced a null array here). Same expectations as + // the 3-cell chain: Phi = [0, 5, 45], tol 10 -> {0,1} {2}. + FixtureData td = CreateScaffold(3, 1, 1); + auto* orientationAM = AttributeMatrix::Create(td.ds, "OrientationData", ShapeType{1, 1, 3}, td.geom->getId()); + auto* quatsArrayPtr = CreateTestDataArray(td.ds, "QuatsElsewhere", ShapeType{1, 1, 3}, {4}, orientationAM->getId()); + auto* phasesArrayPtr = CreateTestDataArray(td.ds, "PhasesElsewhere", ShapeType{1, 1, 3}, {1}, orientationAM->getId()); + const std::vector phiValues = {0.0f, 5.0f, 45.0f}; + for(usize cellIdx = 0; cellIdx < phiValues.size(); cellIdx++) + { + const std::array quat = QuatFromPhiDeg(phiValues[cellIdx]); + for(usize comp = 0; comp < 4; comp++) + { + (*quatsArrayPtr)[cellIdx * 4 + comp] = quat[comp]; + } + (*phasesArrayPtr)[cellIdx] = 1; + } + + Arguments args = BuildArgs(10.0f, segment_features::k_6NeighborIndex, false); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(k_ImageGeomPath.createChildPath("OrientationData").createChildPath("QuatsElsewhere"))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(k_ImageGeomPath.createChildPath("OrientationData").createChildPath("PhasesElsewhere"))); + + auto executeResult = RunFilter(td.ds, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + // FeatureIds lands in the geometry's cell AttributeMatrix, not next to the Quats array. + CheckFeatureIds(td.ds, {1, 1, 2}); + CheckActiveArray(td.ds, 2); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} + TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants (RandomizeFeatureIds)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") { UnitTest::LoadPlugins(); @@ -662,6 +699,43 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Ce REQUIRE(preflightResult.outputActions.errors()[0].code == -652); } +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell AttributeMatrix smaller than geometry (-653)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") +{ + UnitTest::LoadPlugins(); + + // The selected cell arrays match the geometry (10 tuples in a sibling AttributeMatrix) but the + // geometry's own cell AttributeMatrix — which hosts the created FeatureIds — only has 9 tuples. + // FeatureIds would be smaller than the flood-fill walk, so preflight must reject with -653. + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "DataContainer"); + imageGeom->setDimensions({10, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", {9}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* orientationAM = AttributeMatrix::Create(dataStructure, "OrientationData", {10}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "Quats", {10}, {4}, orientationAM->getId()); + UnitTest::CreateTestDataArray(dataStructure, "Phases", {10}, {1}, orientationAM->getId()); + auto* ensembleAM = AttributeMatrix::Create(dataStructure, "CellEnsembleData", {2}, imageGeom->getId()); + UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", {2}, {1}, ensembleAM->getId()); + + CAxisSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"DataContainer"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"DataContainer", "OrientationData", "Quats"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"DataContainer", "OrientationData", "Phases"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellEnsembleData", "CrystalStructures"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); + REQUIRE(preflightResult.outputActions.errors()[0].code == -653); +} + TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell array tuple count mismatch (-651)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md index c52159a010..00ea91e07e 100644 --- a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -15,9 +15,9 @@ | Aspect | Current state | |------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Algorithm Relationship | **Minor changes.** Line-for-line port of legacy `getSeed`/`determineGrouping` (same c-axis math, including the clamped `acos` and the π-fold), plus deliberate NX additions: crystal-structure validation, 26-neighbor scheme, uint8 masks, RectGrid input, deterministic opt-in FeatureId randomization. Three SIMPLNX defects (D1, D4, D5) found and **fixed during this V&V cycle**. | -| Oracle (confirmed) | **Class 1 (Analytical) primary + Class 4 (Invariant) companion** — pure-Phi Bunge quats make the folded c-axis distance between cells exactly `min(\|ΔΦ\|, 180°−\|ΔΦ\|)`; expected FeatureIds derive in closed form. Encoded as 8 fixture tests in `test/CAxisSegmentFeaturesTest.cpp`; all pass. | +| Oracle (confirmed) | **Class 1 (Analytical) primary + Class 4 (Invariant) companion** — pure-Phi Bunge quats make the folded c-axis distance between cells exactly `min(\|ΔΦ\|, 180°−\|ΔΦ\|)`; expected FeatureIds derive in closed form. Encoded as 10 fixture tests in `test/CAxisSegmentFeaturesTest.cpp`; all pass. | | Code paths enumerated | 20 of 20 enumerated; 18 exercised (2 gaps: defensive mask-instantiation error, cancel-signal path). | -| Tests today | 18 test cases: 7 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, 3-D linearization, RectGrid), 2 Class 4 invariants (randomize non-identity/determinism, masked-zero preservation), 2 tolerance pins (phase-0, masked non-hex), 3 execute-error, 3 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | +| Tests today | 20 test cases: 8 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, 3-D linearization, Quats-outside-cell-AM, RectGrid), 2 Class 4 invariants (randomize non-identity/determinism, masked-zero preservation), 2 tolerance pins (phase-0, masked non-hex), 3 execute-error, 4 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | | Exemplar archive | **None — fixtures inlined in the test source.** The filter's consumption of `segment_features_test_data.tar.gz` (circular oracle) is retired; the archive remains for the EBSD segmentation tests. | | Legacy comparison | **Run** (2026-07-22, `vv/comparisons/CAxisSegmentFeaturesFilter/`) — all 4 shared-behavior fixtures match 6.5.171 at the segmentation-partition level with identical feature counts; bit-identical ids are unattainable because 6.5.171 always clock-randomizes FeatureIds (D2). | | Bug flags | D1, D4, D5 — all SIMPLNX defects, all **fixed this cycle** and pinned by tests. No legacy bug flags. | @@ -43,7 +43,7 @@ 6. **D1 (fixed this cycle):** PR #1466's driver restructure made the first seed an unvalidated raw index 0 — phantom/misgrown first feature when voxel 0 could not seed. Restored `getSeed()` for the first seed (also fixes EBSD/Scalar segmentation, which share the driver). 7. **D4 (fixed this cycle):** the delta-2 validation loop originally checked phase-0 and masked-out cells (which can never participate) and indexed `CrystalStructures` without a bounds check. 8. **D5 (fixed this cycle):** stale `getDataAs` cast crashed RectGrid input; now `IGridGeometry`, matching the sibling algorithms. -9. **Adversarial-review hardening (this cycle, no legacy behavior change):** preflight now rejects cell arrays whose tuple count differs from the geometry's cell count (`-652`, previously an out-of-bounds walk); FeatureIds is created with the cell AttributeMatrix's tuple shape instead of the Quats array's; a canceled run returns cleanly instead of misreporting `-87000`. +9. **Review hardening (this cycle, no legacy behavior change):** preflight now rejects cell arrays whose tuple count differs from the geometry's cell count (`-652`, previously an out-of-bounds walk) and a cell AttributeMatrix smaller than the geometry (`-653`); `executeImpl` derives the FeatureIds path from the geometry's cell AttributeMatrix exactly as preflight does (previously from the Quats array's parent — a crash when Quats lived elsewhere) and creates it with that AttributeMatrix's tuple shape; a canceled run returns cleanly instead of misreporting `-87000`. *Material PRs since baseline:* #1373 (26-neighbor option), #1444/#1498 (progress messaging), #1466 (feature-count fix; introduced D1), #1472 (EbsdLib 2.0 API), #1535 (preflight cleanup), #1501 (Vec3 consolidation). @@ -53,7 +53,7 @@ *Applied:* For pure Bunge Euler angles (φ1=0, Φ, φ2=0), stored as quats `{sin(Φ/2), 0, 0, cos(Φ/2)}`, the sample-frame c-axis of a cell is `(0, ±sin Φ, cos Φ)`, so the c-axis angle between two cells is exactly `|ΦA−ΦB|` and the algorithm's folded metric is `min(|ΔΦ|, 180°−|ΔΦ|)`. Expected FeatureIds follow in closed form from per-cell Φ, the tolerance, and grid adjacency (derivation comment at `AnalyticalFixtures::QuatFromPhiDeg`). Class 4 companions: feature AM has `numFeatures+1` tuples, `Active[0]==0`, all real features active, masked/unindexed cells keep id 0, randomization preserves the partition and is a deterministic permutation of `{1..N}`. -*Encoded:* `test/CAxisSegmentFeaturesTest.cpp` — `Class 1 Analytical (Pure-Phi Chain, Face)`, `(Pi-Fold Antiparallel C-Axes)`, `(Neighbor Scheme Face vs All)` [2 sections], `(Mask Excludes Voxel 0)` [2 sections], `(Phase Separation)`, `(RectGrid Geometry)`, `Class 4 Invariants (RandomizeFeatureIds)` — all pass in both builds. +*Encoded:* `test/CAxisSegmentFeaturesTest.cpp` — `Class 1 Analytical (Pure-Phi Chain, Face)`, `(Pi-Fold Antiparallel C-Axes)`, `(Neighbor Scheme Face vs All)` [2 sections], `(Mask Excludes Voxel 0)` [2 sections], `(Phase Separation)`, `(3D Linearization, 3x2x2)`, `(Quats Outside Cell AttributeMatrix)`, `(RectGrid Geometry)`, `Class 4 Invariants (RandomizeFeatureIds)`, `Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)` — all pass in both builds. *Second-engineer review:* pending at PR review (sign-off convention). The pure-Phi c-axis derivation is sibling-shared with the previously reviewed `GroupMicroTextureRegionsFilter` / `ComputeFeatureNeighborCAxisMisalignmentsFilter` Class 1 oracles. @@ -88,7 +88,7 @@ Logical phases: **(a) init + validation** in `operator()`, **(b) flood-fill driv | 19 | (e) | `foundFeatures < 1` → error `-87000`; else AM resize, Active refill, `Active[0]=0` | `No Features Found (-87000)` (error); `CheckActiveArray` in every passing test (success) | | 20 | (e) | `RandomizeFeatureIds == true` → deterministic, non-identity shuffle preserving id 0 | `Class 4 Invariants (RandomizeFeatureIds)` (non-identity + determinism); `Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)` | -Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple mismatch → `-651`, cell arrays vs geometry cell count → `-652` — are covered by the three `Preflight Error` tests; SIMPL 6.4/6.5 argument conversion by `SIMPL Backwards Compatibility`. The cancel early-return added to `operator()` (clean return instead of `-87000` on cancel) shares path 11's not-directly-tested status. +Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple mismatch → `-651`, cell arrays vs geometry cell count → `-652`, cell AttributeMatrix vs geometry cell count → `-653` — are covered by the four `Preflight Error` tests; SIMPL 6.4/6.5 argument conversion by `SIMPL Backwards Compatibility`. The cancel early-return added to `operator()` (clean return instead of `-87000` on cancel) shares path 11's not-directly-tested status. ## Test inventory @@ -100,6 +100,7 @@ Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple m | `Class 1 Analytical (Mask Excludes Voxel 0)` | new-for-V&V | DYNAMIC_SECTION over bool + uint8 masks; **D1 regression pin** (pre-fix: phantom feature + shifted ids). | | `Class 1 Analytical (Phase Separation)` | new-for-V&V | Identical orientations split at a phase boundary; also covers Hexagonal_Low acceptance. | | `Class 1 Analytical (3D Linearization, 3x2x2)` | new-for-V&V | Pins the x-fastest linearization + y/z stride branches with an axis-asymmetric Phi field (added after adversarial review showed the original fixtures were invariant under dims permutation). | +| `Class 1 Analytical (Quats Outside Cell AttributeMatrix)` | new-for-V&V | Pins the preflight/execute FeatureIds path agreement when Quats lives in a sibling AttributeMatrix (pre-fix: execute dereferenced a null FeatureIds array). | | `Class 1 Analytical (RectGrid Geometry)` | new-for-V&V | **D5 regression pin** (pre-fix: null-pointer crash on RectGrid input). | | `Class 4 Invariants (RandomizeFeatureIds)` | new-for-V&V | Partition preservation, permutation of {1..4}, non-identity vs canonical labeling (kills a dead randomizer), same-seed determinism across two runs. | | `Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)` | new-for-V&V | Masked cells keep FeatureId 0 through the shuffle (pins the 0→0 mapping guarantee). | @@ -110,11 +111,12 @@ Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple m | `Execute Error - No Features Found (-87000)` | new-for-V&V | All cells masked; **D1 regression pin** (pre-fix: success with 1 phantom feature). | | `Preflight Error - Zero Tolerance (-655)` | new-for-V&V | Preflight rejects tolerance == 0. | | `Preflight Error - Cell arrays smaller than geometry (-652)` | new-for-V&V | Cell arrays consistent with each other but not with the geometry's cell count (pre-fix: out-of-bounds walk at execute). | +| `Preflight Error - Cell AttributeMatrix smaller than geometry (-653)` | new-for-V&V | Cell arrays match the geometry but the FeatureIds-hosting AttributeMatrix does not. | | `Preflight Error - Cell array tuple count mismatch (-651)` | kept | Synthetic tuple-count mismatch between Quats and Phases. | | `SIMPL Backwards Compatibility` | kept | DYNAMIC_SECTION over SIMPL 6.4 + 6.5 conversion fixtures; UUID + argument-key conversion only. | | *(retired)* `CAxisSegmentFeatures:Face` / `:All` / `:MaskFace` / `:MaskAll` | retired | Consumed the `segment_features_test_data.tar.gz` exemplar whose `CAxis_FeatureIds_*` arrays were generated from SIMPLNX output — a circular oracle (see provenance sidecar). Replaced by the Class 1 fixtures, which cover the same scheme × mask parameter cube with independent expected output. | -All non-retired tests pass at the verified commit in **both** builds: in-core `NX-Com-Qt69-Vtk95-Rel` and OOC `simplnx-ooc-Rel` (15/15 each, 2026-07-22). The shared-driver fix (D1) was regression-checked against `EBSDSegmentFeatures` (5/5) and `ScalarSegmentFeatures` (2/2) in both builds. +All non-retired tests pass at the verified commit in **both** builds: in-core `NX-Com-Qt69-Vtk95-Rel` and OOC `simplnx-ooc-Rel` (20/20 each, 2026-07-22). The shared-driver fix (D1) was regression-checked against `EBSDSegmentFeatures` (5/5) and `ScalarSegmentFeatures` (2/2) in both builds. ## Exemplar archive diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py index fe06de2d2c..c10f1a6b25 100644 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py @@ -22,7 +22,9 @@ import numpy as np -sys.path.insert(0, "/Users/mjackson/Workspace1/Claude_Support/skills/compare-legacy-dream3d") +# The legacy_dream3d writer module ships with the compare-legacy-dream3d skill; point +# LEGACY_DREAM3D_SKILL_DIR at a checkout of it if yours lives elsewhere. +sys.path.insert(0, os.environ.get("LEGACY_DREAM3D_SKILL_DIR", "/Users/mjackson/Workspace1/Claude_Support/skills/compare-legacy-dream3d")) from legacy_dream3d import D3DLegacyWriter # noqa: E402 HEX_HIGH = 0 diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md index 082d9925e2..add35b0a06 100644 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md @@ -35,7 +35,7 @@ read by BOTH runners, so the Quats/Phases/Mask/CrystalStructures inputs are byte **All four cases match at the segmentation-partition level, and feature counts are identical.** Raw legacy ids are a random permutation of NX ids because 6.5.171 unconditionally randomizes -FeatureIds with a clock-derived seed (`CAxisSegmentFeatures-D2`); bit-identical FeatureIds are +FeatureIds with a clock-derived seed (`CAxisSegmentFeaturesFilter-D2`); bit-identical FeatureIds are not attainable against 6.5.171 by construction. NX ids additionally match the Class 1 oracle expectation exactly. From cc5926e40b363a5769025424369ba22650de4f93 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 22 Jul 2026 18:38:21 -0400 Subject: [PATCH 04/12] STY: Final polish pass on C-Axis segmentation V&V * Remove dead pCrystalStructuresArrayPathValue in preflightImpl (the selection parameter already validates existence) * Rename determineGrouping parameters to camelBack per convention; rename getSeed's misnamed randPoint (linear scan) to candidatePoint * Fix comment typos ("in and EBSD file", "visual clarify") and note the hex-validation comment's actual guard wording * Normalize trailing period on the -87000 message * Refresh algorithm line count in the V&V report Signed-off-by: Michael Jackson --- .../Algorithms/CAxisSegmentFeatures.cpp | 36 +++++++++---------- .../Filters/CAxisSegmentFeaturesFilter.cpp | 1 - .../vv/CAxisSegmentFeaturesFilter.md | 2 +- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp index eeaef15051..80cc301302 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp @@ -49,7 +49,7 @@ Result<> CAxisSegmentFeatures::operator()() // Loop through all the "Phase" cell values and validate that any phase found is // a hexagonal phase. This guards against there being multiple phases defined in - // and EBSD file but the non-hexagonal phases were actually never found + // an EBSD file where the non-hexagonal phases were never actually indexed to a cell. const auto& crystalStructures = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath); const usize numCells = m_CellPhases->getNumberOfTuples(); const usize numEnsembles = crystalStructures.getNumberOfTuples(); @@ -98,7 +98,7 @@ Result<> CAxisSegmentFeatures::operator()() // Sanity check the result. if(this->m_FoundFeatures < 1) { - return MakeErrorResult(-87000, fmt::format("The number of Features is '{}' which means no Features were detected. A threshold value may be set incorrectly", this->m_FoundFeatures)); + return MakeErrorResult(-87000, fmt::format("The number of Features is '{}' which means no Features were detected. A threshold value may be set incorrectly.", this->m_FoundFeatures)); } // Resize the Feature Attribute Matrix @@ -110,7 +110,7 @@ Result<> CAxisSegmentFeatures::operator()() activeArray->getDataStore()->fill(1); (*activeArray)[0] = 0; - // Randomize the feature Ids for purely visual clarify. Having random Feature Ids + // Randomize the feature Ids purely for visual clarity. Having random Feature Ids // allows users visualizing the data to better discern each grain otherwise the coloring // would look like a smooth gradient. This is a user input parameter if(m_InputValues->RandomizeFeatureIds) @@ -128,25 +128,25 @@ int64 CAxisSegmentFeatures::getSeed(int32 gnum, int64 nextSeed) const const usize totalPoints = featureIds.getNumberOfTuples(); AbstractDataStore& cellPhases = m_CellPhases->getDataStoreRef(); - // start with the next voxel after the last seed - auto randPoint = static_cast(nextSeed); + // Linearly scan for the next eligible voxel, starting just after the last seed + auto candidatePoint = static_cast(nextSeed); int64 seed = -1; - while(seed == -1 && randPoint < totalPoints) + while(seed == -1 && candidatePoint < totalPoints) { - if(featureIds[randPoint] == 0) // If the GrainId of the voxel is ZERO then we can use this as a seed point + if(featureIds[candidatePoint] == 0) // If the GrainId of the voxel is ZERO then we can use this as a seed point { - if((!m_InputValues->UseMask || m_GoodVoxelsArray->isTrue(randPoint)) && cellPhases[randPoint] > 0) + if((!m_InputValues->UseMask || m_GoodVoxelsArray->isTrue(candidatePoint)) && cellPhases[candidatePoint] > 0) { - seed = static_cast(randPoint); + seed = static_cast(candidatePoint); } else { - randPoint += 1; + candidatePoint += 1; } } else { - randPoint += 1; + candidatePoint += 1; } } if(seed >= 0) @@ -160,7 +160,7 @@ int64 CAxisSegmentFeatures::getSeed(int32 gnum, int64 nextSeed) const } // ----------------------------------------------------------------------------- -bool CAxisSegmentFeatures::determineGrouping(int64 referencepoint, int64 neighborpoint, int32 gnum) const +bool CAxisSegmentFeatures::determineGrouping(int64 referencePoint, int64 neighborPoint, int32 gnum) const { bool group = false; @@ -172,15 +172,15 @@ bool CAxisSegmentFeatures::determineGrouping(int64 referencepoint, int64 neighbo bool neighborPointIsGood = false; if(m_GoodVoxelsArray != nullptr) { - neighborPointIsGood = m_GoodVoxelsArray->isTrue(neighborpoint); + neighborPointIsGood = m_GoodVoxelsArray->isTrue(neighborPoint); } - if(featureIds[neighborpoint] == 0 && (!m_InputValues->UseMask || neighborPointIsGood)) + if(featureIds[neighborPoint] == 0 && (!m_InputValues->UseMask || neighborPointIsGood)) { - if(cellPhases[referencepoint] == cellPhases[neighborpoint]) + if(cellPhases[referencePoint] == cellPhases[neighborPoint]) { - const ebsdlib::QuatF q1(quats[referencepoint * 4], quats[referencepoint * 4 + 1], quats[referencepoint * 4 + 2], quats[referencepoint * 4 + 3]); - const ebsdlib::QuatF q2(quats[neighborpoint * 4 + 0], quats[neighborpoint * 4 + 1], quats[neighborpoint * 4 + 2], quats[neighborpoint * 4 + 3]); + const ebsdlib::QuatF q1(quats[referencePoint * 4], quats[referencePoint * 4 + 1], quats[referencePoint * 4 + 2], quats[referencePoint * 4 + 3]); + const ebsdlib::QuatF q2(quats[neighborPoint * 4 + 0], quats[neighborPoint * 4 + 1], quats[neighborPoint * 4 + 2], quats[neighborPoint * 4 + 3]); const ebsdlib::OrientationMatrixFType oMatrix1 = q1.toOrientationMatrix(); const ebsdlib::OrientationMatrixFType oMatrix2 = q2.toOrientationMatrix(); @@ -200,7 +200,7 @@ bool CAxisSegmentFeatures::determineGrouping(int64 referencepoint, int64 neighbo if(w <= m_InputValues->MisorientationTolerance || (Constants::k_PiD - w) <= m_InputValues->MisorientationTolerance) { group = true; - featureIds[neighborpoint] = gnum; + featureIds[neighborPoint] = gnum; } } } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp index 1e2813bea4..1c590fc483 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp @@ -117,7 +117,6 @@ IFilter::PreflightResult CAxisSegmentFeaturesFilter::preflightImpl(const DataStr { auto pQuatsArrayPathValue = filterArgs.value(k_QuatsArrayPath_Key); auto pCellPhasesArrayPathValue = filterArgs.value(k_CellPhasesArrayPath_Key); - auto pCrystalStructuresArrayPathValue = filterArgs.value(k_CrystalStructuresArrayPath_Key); // Validate the tolerance != 0 auto tolerance = filterArgs.value(k_MisorientationTolerance_Key); diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md index 00ea91e07e..d74d1d731e 100644 --- a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -61,7 +61,7 @@ *20 of 20 paths enumerated; 18 exercised.* -Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp` (198 lines) + shared driver `src/simplnx/Utilities/SegmentFeatures.cpp` (execute loop). +Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp` (207 lines) + shared driver `src/simplnx/Utilities/SegmentFeatures.cpp` (execute loop). Logical phases: **(a) init + validation** in `operator()`, **(b) flood-fill driver** in `SegmentFeatures::execute`, **(c) seeding** in `getSeed`, **(d) grouping decision** in `determineGrouping`, **(e) finalize** in `operator()`. From 7fe71b02ca4cf575f1a17f7d2b8dd5034cd43d21 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 22 Jul 2026 21:54:28 -0400 Subject: [PATCH 05/12] TEST: Pin shared-driver first-seed validation in EBSD/Scalar segment tests * Add masked-voxel-0 regression tests to EBSDSegmentFeaturesFilter and ScalarSegmentFeatures suites, mirroring the CAxis pin: the first seed must come from getSeed(), or a masked index 0 yields a phantom empty feature and shifted FeatureIds * The driver fix lives in src/simplnx/Utilities/SegmentFeatures.cpp; each consumer now carries its own pin so a test refactor in one plugin cannot silently unpin the core-library behavior Signed-off-by: Michael Jackson --- .../test/EBSDSegmentFeaturesFilterTest.cpp | 77 +++++++++++++++++++ .../test/ScalarSegmentFeaturesTest.cpp | 60 +++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp b/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp index 004a6edb15..6e9396bf45 100644 --- a/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp @@ -4,7 +4,13 @@ #include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" #include "OrientationAnalysisTestUtils.hpp" +#include + +#include "simplnx/Common/Constants.hpp" #include "simplnx/Core/Application.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/Parameters/ArrayCreationParameter.hpp" #include "simplnx/Parameters/ChoicesParameter.hpp" #include "simplnx/Parameters/Dream3dImportParameter.hpp" @@ -15,6 +21,7 @@ #include +#include #include #include @@ -322,3 +329,73 @@ TEST_CASE("OrientationAnalysis::EBSDSegmentFeaturesFilter: SIMPL Backwards Compa } } } + +TEST_CASE("OrientationAnalysis::EBSDSegmentFeaturesFilter: Masked Voxel 0 Seed Validation", "[OrientationAnalysis][EBSDSegmentFeatures]") +{ + UnitTest::LoadPlugins(); + + // Regression pin for the shared SegmentFeatures driver: the first seed must be validated (and + // stamped) by getSeed() exactly like every later seed. 5x1x1 with voxel 0 masked out; per-cell + // orientations are pure rotations about x by Phi = [0, 20, 22, 0, 90] degrees, so pairwise + // misorientations equal |dPhi|. At tolerance 10 the expected features are F1 = {1, 2} (2 deg) + // and F2 = {4}; masked cells keep FeatureId 0. A driver that bursts from the raw index 0 + // produces a phantom empty feature 1 and shifted ids [0, 2, 2, 0, 3]. + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "Geometry"); + imageGeom->setDimensions({5, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 5}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* quatsArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Quats", ShapeType{1, 1, 5}, {4}, cellAM->getId()); + auto* phasesArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Phases", ShapeType{1, 1, 5}, {1}, cellAM->getId()); + auto* maskArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Mask", ShapeType{1, 1, 5}, {1}, cellAM->getId()); + auto* ensembleAM = AttributeMatrix::Create(dataStructure, "CellEnsembleData", ShapeType{2}, imageGeom->getId()); + auto* crystalStructuresArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", ShapeType{2}, {1}, ensembleAM->getId()); + + const std::vector phiDegrees = {0.0f, 20.0f, 22.0f, 0.0f, 90.0f}; + const std::vector maskValues = {false, true, true, false, true}; + for(usize cellIdx = 0; cellIdx < phiDegrees.size(); cellIdx++) + { + const float32 halfAngleRad = (phiDegrees[cellIdx] * 0.5f) * Constants::k_PiOver180F; + (*quatsArrayPtr)[cellIdx * 4 + 0] = std::sin(halfAngleRad); + (*quatsArrayPtr)[cellIdx * 4 + 1] = 0.0f; + (*quatsArrayPtr)[cellIdx * 4 + 2] = 0.0f; + (*quatsArrayPtr)[cellIdx * 4 + 3] = std::cos(halfAngleRad); + (*phasesArrayPtr)[cellIdx] = 1; + (*maskArrayPtr)[cellIdx] = maskValues[cellIdx]; + } + (*crystalStructuresArrayPtr)[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + (*crystalStructuresArrayPtr)[1] = ebsdlib::CrystalStructure::Hexagonal_High; + + EBSDSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(10.0F)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_UseMask_Key, std::make_any(true)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Mask"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"Geometry"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Quats"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Phases"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"Geometry", "CellEnsembleData", "CrystalStructures"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_IsPeriodic_Key, std::make_any(false)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"Geometry", "CellData", "FeatureIds"}))); + const auto& featureIdsRef = dataStructure.getDataRefAs(DataPath({"Geometry", "CellData", "FeatureIds"})).getDataStoreRef(); + const std::vector expectedFeatureIds = {0, 1, 1, 0, 2}; + for(usize cellIdx = 0; cellIdx < expectedFeatureIds.size(); cellIdx++) + { + INFO(fmt::format("cell index {}", cellIdx)); + REQUIRE(featureIdsRef[cellIdx] == expectedFeatureIds[cellIdx]); + } + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"Geometry", "CellFeatureData", "Active"}))); + REQUIRE(dataStructure.getDataRefAs(DataPath({"Geometry", "CellFeatureData", "Active"})).getNumberOfTuples() == 3); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} diff --git a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp index 00cb47718f..789ad14392 100644 --- a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp +++ b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp @@ -1,6 +1,9 @@ #include "SimplnxCore/Filters/ScalarSegmentFeaturesFilter.hpp" #include "SimplnxCore/SimplnxCore_test_dirs.hpp" +#include "simplnx/DataStructure/AttributeMatrix.hpp" +#include "simplnx/DataStructure/DataArray.hpp" +#include "simplnx/DataStructure/Geometry/ImageGeom.hpp" #include "simplnx/DataStructure/IO/HDF5/DataStructureWriter.hpp" #include "simplnx/Parameters/ArrayCreationParameter.hpp" #include "simplnx/Parameters/BoolParameter.hpp" @@ -171,3 +174,60 @@ TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Neighbor Scheme", "[Reconstructio } } } + +TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Masked Voxel 0 Seed Validation", "[SimplnxCore][ScalarSegmentFeatures]") +{ + UnitTest::LoadPlugins(); + + // Regression pin for the shared SegmentFeatures driver: the first seed must be validated (and + // stamped) by getSeed() exactly like every later seed. 5x1x1 with voxel 0 masked out; scalar + // values [9, 5, 5, 9, 7] at tolerance 1 give F1 = {1, 2} and F2 = {4}; masked cells keep + // FeatureId 0. A driver that bursts from the raw index 0 produces a phantom empty feature 1 + // and shifted ids [0, 2, 2, 0, 3]. + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "Geometry"); + imageGeom->setDimensions({5, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 5}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* scalarArrayPtr = CreateTestDataArray(dataStructure, "ScalarValues", ShapeType{1, 1, 5}, {1}, cellAM->getId()); + auto* maskArrayPtr = CreateTestDataArray(dataStructure, "Mask", ShapeType{1, 1, 5}, {1}, cellAM->getId()); + + const std::vector scalarValues = {9, 5, 5, 9, 7}; + const std::vector maskValues = {false, true, true, false, true}; + for(usize cellIdx = 0; cellIdx < scalarValues.size(); cellIdx++) + { + (*scalarArrayPtr)[cellIdx] = scalarValues[cellIdx]; + (*maskArrayPtr)[cellIdx] = maskValues[cellIdx]; + } + + ScalarSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_GridGeomPath_Key, std::make_any(DataPath({"Geometry"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_ScalarToleranceKey, std::make_any(1)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_InputArrayPathKey, std::make_any(DataPath({"Geometry", "CellData", "ScalarValues"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_UseMask_Key, std::make_any(true)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Mask"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_FeatureIdsName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_CellFeatureName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_RandomizeFeatures_Key, std::make_any(false)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_IsPeriodic_Key, std::make_any(false)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"Geometry", "CellData", "FeatureIds"}))); + const auto& featureIdsRef = dataStructure.getDataRefAs(DataPath({"Geometry", "CellData", "FeatureIds"})).getDataStoreRef(); + const std::vector expectedFeatureIds = {0, 1, 1, 0, 2}; + for(usize cellIdx = 0; cellIdx < expectedFeatureIds.size(); cellIdx++) + { + INFO(fmt::format("cell index {}", cellIdx)); + REQUIRE(featureIdsRef[cellIdx] == expectedFeatureIds[cellIdx]); + } + REQUIRE_NOTHROW(dataStructure.getDataRefAs(DataPath({"Geometry", "CellFeatureData", "Active"}))); + REQUIRE(dataStructure.getDataRefAs(DataPath({"Geometry", "CellFeatureData", "Active"})).getNumberOfTuples() == 3); + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} From d8cb035f86b71c954addfb722f901f9a3cc366d1 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 22 Jul 2026 21:54:28 -0400 Subject: [PATCH 06/12] DOC: Align algorithmic-choice root-cause wording with fleet practice * vv_policy.md restricted "algorithmic choice" to Rewrite relationships, but Port/Minor-changes reports across the fleet already use it for deliberate guards and behavior differences (added validation, changed defaults, exposed randomization) * Reword the taxonomy entry to allow any Algorithm Relationship, keeping the Rewrite UUID-defense requirement Signed-off-by: Michael Jackson --- docs/vv_templates/vv_policy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/vv_templates/vv_policy.md b/docs/vv_templates/vv_policy.md index ee13c6e774..2a58017b98 100644 --- a/docs/vv_templates/vv_policy.md +++ b/docs/vv_templates/vv_policy.md @@ -93,4 +93,4 @@ When SIMPLNX and 6.5.171 differ, each Deviation entry names one root cause (or a - **Precision** — different floating-point width or intermediate-math type - **Order of operations** — associativity differences in parallel reductions, different loop order, different accumulation - **Library** — Eigen vs. hand-rolled, different EbsdLib version, different HDF5 versions -- **Algorithmic choice** — deliberate change in method (used only for Rewrite relationships) +- **Algorithmic choice** — a deliberate difference in method or behavior (an added validation guard, a changed default, exposed randomization, a replacement algorithm). Any Algorithm Relationship may carry algorithmic-choice deviations — a **Port** or **Minor changes** filter that adds a guard legacy lacked is the common case. A **Rewrite** consists of algorithmic choices by definition, and a Rewrite whose outputs diverge must defend the shared UUID (see above) From 9f17573892112fb567953a56f97aa7968f3579f0 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Wed, 22 Jul 2026 22:05:25 -0400 Subject: [PATCH 07/12] STY: Apply senior-review nit findings for C-Axis segmentation V&V * Correct the -8363 message to say "every phase that participates in the segmentation" now that phase-0/masked cells are exempt; quote the path and punctuate the -8362 message; -87000 names the actual tolerance parameter; un-shout "zero" in -655 * Add direct include for std::clamp; Ref-suffix the DataStore references in getSeed and unify their spelled type; drop redundant this-> prefixes; unify cellFeatureAM naming * Alphabetize NumberParameter.hpp into the Parameters include block; rename preflight locals (cellFeatureAMPath, useMask) to match the values they hold; normalize parameter-description punctuation * Tag oracle tests [Class1]/[Class4] per sibling convention; Title Case the two new preflight test names (report references updated); collapse the three hand-built preflight arg blocks into BuildManualPreflightArgs; comment the RectGrid fixture's deliberate geometry-name reuse * Docs: c-axis is the [0001] direction; point "migration deviation notes" at the vv/deviations file; algorithm header docstring describes the algorithm rather than the filter * Provenance "Used by tests" no longer overclaims the preflight tests; annotate make_input.py enum constants; drop narration comment and unify int64 spelling in SegmentFeatures.cpp Signed-off-by: Michael Jackson --- .../docs/CAxisSegmentFeaturesFilter.md | 4 +- .../Algorithms/CAxisSegmentFeatures.cpp | 33 ++++---- .../Algorithms/CAxisSegmentFeatures.hpp | 4 +- .../Filters/CAxisSegmentFeaturesFilter.cpp | 21 +++-- .../test/CAxisSegmentFeaturesTest.cpp | 84 ++++++++----------- .../vv/CAxisSegmentFeaturesFilter.md | 6 +- .../pipelines/make_input.py | 4 +- .../provenance/CAxisSegmentFeaturesFilter.md | 2 +- src/simplnx/Utilities/SegmentFeatures.cpp | 3 +- 9 files changed, 74 insertions(+), 87 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md index dec18e93bc..8e437d7c15 100644 --- a/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md @@ -6,7 +6,7 @@ Reconstruction (Segmentation) ## Description -This **Filter** segments the **Features** by grouping neighboring **Cells** that satisfy the *C-axis misalignment tolerance*, i.e., have misalignment angle less than the value set by the user. The *C-axis misalignment* refers to the angle between the <001> directions (C-axis in the hexagonal system) that is present between neighboring **Cells**. Because the c-axis is a direction (not a vector), the misalignment is folded into [0°, 90°]: two nearly antiparallel c-axes are considered aligned. The process by which the **Features** are identified is given below and is a standard *burn algorithm*. +This **Filter** segments the **Features** by grouping neighboring **Cells** that satisfy the *c-axis misalignment tolerance*, i.e., have misalignment angle less than the value set by the user. The *c-axis misalignment* refers to the angle between the [0001] directions (the c-axis in the hexagonal system) that is present between neighboring **Cells**. Because the c-axis is a direction (not a vector), the misalignment is folded into [0°, 90°]: two nearly antiparallel c-axes are considered aligned. The process by which the **Features** are identified is given below and is a standard *burn algorithm*. 1. Select the next unassigned **Cell** in row-major order that is eligible to seed a **Feature** (not excluded by the mask and with a phase value greater than 0), add it to an empty list and set its *Feature Id* to the current **Feature** 2. Compare the **Cell** to each of its neighbors as selected by the *Neighbor Scheme* parameter (i.e., calculate the c-axis misalignment with each neighbor) @@ -26,7 +26,7 @@ The c-axis is only a physically meaningful unique axis for hexagonal Laue classe ### Randomize Feature Ids -When *Randomize Feature Ids* is enabled the final *Feature Ids* are relabeled with a deterministic (fixed-seed) random permutation, which improves visual contrast between neighboring **Features** when coloring by *Feature Id*. The segmentation itself is unchanged, and repeated runs produce identical output. (Legacy DREAM.3D 6.x always randomized with a clock-derived seed, so its labeling differed on every run; see the migration deviation notes.) +When *Randomize Feature Ids* is enabled the final *Feature Ids* are relabeled with a deterministic (fixed-seed) random permutation, which improves visual contrast between neighboring **Features** when coloring by *Feature Id*. The segmentation itself is unchanged, and repeated runs produce identical output. (Legacy DREAM.3D 6.x always randomized with a clock-derived seed, so its labeling differed on every run; see the migration deviation notes in `vv/deviations/CAxisSegmentFeaturesFilter.md` of the simplnx source tree.) ### Neighbor Scheme diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp index 80cc301302..f87869f3af 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp @@ -11,6 +11,7 @@ #include #include +#include #include using namespace nx::core; @@ -29,7 +30,7 @@ CAxisSegmentFeatures::~CAxisSegmentFeatures() noexcept = default; // ----------------------------------------------------------------------------- Result<> CAxisSegmentFeatures::operator()() { - this->m_NeighborScheme = m_InputValues->NeighborScheme; + m_NeighborScheme = m_InputValues->NeighborScheme; // The geometry parameter accepts Image AND RectGrid geometries; both derive from IGridGeometry. auto* gridGeom = m_DataStructure.getDataAs(m_InputValues->ImageGeometryPath); m_QuatsArray = m_DataStructure.getDataAs(m_InputValues->QuatsArrayPath); @@ -43,7 +44,7 @@ Result<> CAxisSegmentFeatures::operator()() { // This really should NOT be happening as the path was verified during preflight BUT we may be calling this from // somewhere else that is NOT going through the normal nx::core::IFilter API of Preflight and Execute - return MakeErrorResult(-8362, fmt::format("Mask Array DataPath does not exist or is not of the correct type (Bool | UInt8) {}", m_InputValues->MaskArrayPath.toString())); + return MakeErrorResult(-8362, fmt::format("Mask Array DataPath '{}' does not exist or is not of the correct type (Bool | UInt8).", m_InputValues->MaskArrayPath.toString())); } } @@ -72,8 +73,8 @@ Result<> CAxisSegmentFeatures::operator()() if(crystalStructureType != ebsdlib::CrystalStructure::Hexagonal_High && crystalStructureType != ebsdlib::CrystalStructure::Hexagonal_Low) { - return MakeErrorResult(-8363, fmt::format("Input data is using {} type crystal structures but segmenting features via c-axis misorientation requires all phases to be either Hexagonal-Low 6/m " - "or Hexagonal-High 6/mmm type crystal structures.", + return MakeErrorResult(-8363, fmt::format("Input data is using {} type crystal structures but segmenting features via c-axis misorientation requires every phase that participates in the " + "segmentation to be either Hexagonal-Low 6/m or Hexagonal-High 6/mmm type crystal structures.", CrystalStructureEnumToString(crystalStructureType))); } } @@ -96,15 +97,15 @@ Result<> CAxisSegmentFeatures::operator()() return {}; } // Sanity check the result. - if(this->m_FoundFeatures < 1) + if(m_FoundFeatures < 1) { - return MakeErrorResult(-87000, fmt::format("The number of Features is '{}' which means no Features were detected. A threshold value may be set incorrectly.", this->m_FoundFeatures)); + return MakeErrorResult(-87000, fmt::format("The number of Features is '{}' which means no Features were detected. The C-Axis Misorientation Tolerance may be set too low.", m_FoundFeatures)); } // Resize the Feature Attribute Matrix - ShapeType tDims = {static_cast(this->m_FoundFeatures + 1)}; - auto& cellFeaturesAM = m_DataStructure.getDataRefAs(m_InputValues->CellFeatureAttributeMatrixPath); - cellFeaturesAM.resizeTuples(tDims); // This will resize the active array + ShapeType tDims = {static_cast(m_FoundFeatures + 1)}; + auto& cellFeatureAM = m_DataStructure.getDataRefAs(m_InputValues->CellFeatureAttributeMatrixPath); + cellFeatureAM.resizeTuples(tDims); // This will resize the active array // make sure all values are initialized and "re-reserve" index 0 activeArray->getDataStore()->fill(1); @@ -115,7 +116,7 @@ Result<> CAxisSegmentFeatures::operator()() // would look like a smooth gradient. This is a user input parameter if(m_InputValues->RandomizeFeatureIds) { - ClusterUtilities::RandomizeFeatureIds(m_FeatureIdsArray->getDataStoreRef(), this->m_FoundFeatures + 1); + ClusterUtilities::RandomizeFeatureIds(m_FeatureIdsArray->getDataStoreRef(), m_FoundFeatures + 1); } return {}; @@ -124,18 +125,18 @@ Result<> CAxisSegmentFeatures::operator()() // ----------------------------------------------------------------------------- int64 CAxisSegmentFeatures::getSeed(int32 gnum, int64 nextSeed) const { - DataArray::store_type& featureIds = m_FeatureIdsArray->getDataStoreRef(); - const usize totalPoints = featureIds.getNumberOfTuples(); - AbstractDataStore& cellPhases = m_CellPhases->getDataStoreRef(); + AbstractDataStore& featureIdsRef = m_FeatureIdsArray->getDataStoreRef(); + const usize totalPoints = featureIdsRef.getNumberOfTuples(); + const AbstractDataStore& cellPhasesRef = m_CellPhases->getDataStoreRef(); // Linearly scan for the next eligible voxel, starting just after the last seed auto candidatePoint = static_cast(nextSeed); int64 seed = -1; while(seed == -1 && candidatePoint < totalPoints) { - if(featureIds[candidatePoint] == 0) // If the GrainId of the voxel is ZERO then we can use this as a seed point + if(featureIdsRef[candidatePoint] == 0) // If the GrainId of the voxel is ZERO then we can use this as a seed point { - if((!m_InputValues->UseMask || m_GoodVoxelsArray->isTrue(candidatePoint)) && cellPhases[candidatePoint] > 0) + if((!m_InputValues->UseMask || m_GoodVoxelsArray->isTrue(candidatePoint)) && cellPhasesRef[candidatePoint] > 0) { seed = static_cast(candidatePoint); } @@ -152,7 +153,7 @@ int64 CAxisSegmentFeatures::getSeed(int32 gnum, int64 nextSeed) const if(seed >= 0) { auto& cellFeatureAM = m_DataStructure.getDataRefAs(m_InputValues->CellFeatureAttributeMatrixPath); - featureIds[static_cast(seed)] = gnum; + featureIdsRef[static_cast(seed)] = gnum; const ShapeType tDims = {static_cast(gnum) + 1}; cellFeatureAM.resizeTuples(tDims); // This will resize the active array } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.hpp index b1d0fe9d88..b20de4f88f 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.hpp @@ -29,9 +29,9 @@ struct ORIENTATIONANALYSIS_EXPORT CAxisSegmentFeaturesInputValues /** * @class CAxisSegmentFeatures - * @brief This filter segments the Features by grouping neighboring Cells that satisfy the C-axis misalignment tolerance, i.e., have misalignment angle less than the value set by the user. + * @brief Segmentation algorithm that groups neighboring Cells whose c-axis misalignment angle is within the user tolerance. Provides the seeding and grouping hooks for the shared + * SegmentFeatures flood-fill driver. */ - class ORIENTATIONANALYSIS_EXPORT CAxisSegmentFeatures : public SegmentFeatures { public: diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp index 1c590fc483..1361e14181 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp @@ -14,11 +14,10 @@ #include "simplnx/Parameters/DataGroupSelectionParameter.hpp" #include "simplnx/Parameters/DataObjectNameParameter.hpp" #include "simplnx/Parameters/GeometrySelectionParameter.hpp" +#include "simplnx/Parameters/NumberParameter.hpp" #include "simplnx/Utilities/SIMPLConversion.hpp" -#include "simplnx/Parameters/NumberParameter.hpp" - using namespace nx::core; namespace nx::core @@ -62,7 +61,7 @@ Parameters CAxisSegmentFeaturesFilter::parameters() const params.insert(std::make_unique(k_MisorientationTolerance_Key, "C-Axis Misorientation Tolerance (Degrees)", "Tolerance (in degrees) used to determine if neighboring Cells belong to the same Feature", 5.0f)); - params.insert(std::make_unique(k_RandomizeFeatureIds_Key, "Randomize Feature Ids", "Specifies whether to randomize the feature ids", false)); + params.insert(std::make_unique(k_RandomizeFeatureIds_Key, "Randomize Feature Ids", "Specifies whether to randomize the Feature Ids with a deterministic shuffle", false)); params.insert(std::make_unique(k_NeighborScheme_Key, "Neighbor Scheme", "How many neighbors to use", segment_features::k_6NeighborIndex, segment_features::k_OperationChoices)); params.insertSeparator(Parameters::Separator{"Optional Data Mask"}); @@ -84,7 +83,7 @@ Parameters CAxisSegmentFeaturesFilter::parameters() const ArraySelectionParameter::AllowedTypes{DataType::uint32}, ArraySelectionParameter::AllowedComponentShapes{{1}})); params.insertSeparator(Parameters::Separator{"Output Cell Data"}); - params.insert(std::make_unique(k_FeatureIdsArrayName_Key, "Cell Feature Ids", "Specifies to which feature each cell belongs.", "FeatureIds")); + params.insert(std::make_unique(k_FeatureIdsArrayName_Key, "Cell Feature Ids", "Specifies to which Feature each Cell belongs", "FeatureIds")); params.insertSeparator(Parameters::Separator{"Output Feature Data"}); params.insert(std::make_unique(k_CellFeatureAttributeMatrixName_Key, "Feature Attribute Matrix", "The name of the created feature attribute matrix", "Cell Feature Data")); @@ -122,7 +121,7 @@ IFilter::PreflightResult CAxisSegmentFeaturesFilter::preflightImpl(const DataStr auto tolerance = filterArgs.value(k_MisorientationTolerance_Key); if(tolerance == 0.0F) { - return {MakeErrorResult(-655, "Misorientation Tolerance cannot equal ZERO.")}; + return {MakeErrorResult(-655, "Misorientation Tolerance cannot equal zero.")}; } // Validate the Grid Geometry @@ -130,17 +129,17 @@ IFilter::PreflightResult CAxisSegmentFeaturesFilter::preflightImpl(const DataStr const auto* inputGridGeom = dataStructure.getDataAs(gridGeomPath); DataPath inputCellDataPath = inputGridGeom->getCellDataPath(); auto featureIdsPath = inputCellDataPath.createChildPath(filterArgs.value(k_FeatureIdsArrayName_Key)); - auto pCellFeatureAttributeMatrixNameValue = gridGeomPath.createChildPath(filterArgs.value(k_CellFeatureAttributeMatrixName_Key)); - auto activeArrayPath = pCellFeatureAttributeMatrixNameValue.createChildPath(filterArgs.value(k_ActiveArrayName_Key)); + auto cellFeatureAMPath = gridGeomPath.createChildPath(filterArgs.value(k_CellFeatureAttributeMatrixName_Key)); + auto activeArrayPath = cellFeatureAMPath.createChildPath(filterArgs.value(k_ActiveArrayName_Key)); std::vector dataPaths; dataPaths.push_back(pQuatsArrayPathValue); dataPaths.push_back(pCellPhasesArrayPathValue); - // Validate the GoodVoxels/Mask Array combination - bool useGoodVoxels = filterArgs.value(k_UseMask_Key); - if(useGoodVoxels) + // The Mask array only participates in the tuple validation when it is in use + bool useMask = filterArgs.value(k_UseMask_Key); + if(useMask) { dataPaths.push_back(filterArgs.value(k_MaskArrayPath_Key)); } @@ -172,7 +171,7 @@ IFilter::PreflightResult CAxisSegmentFeaturesFilter::preflightImpl(const DataStr auto createFeatureIdsAction = std::make_unique(DataType::int32, cellDataAM.getShape(), std::vector{1}, featureIdsPath); // Create the Feature Attribute Matrix - auto createFeatureGroupAction = std::make_unique(pCellFeatureAttributeMatrixNameValue, std::vector{1}); + auto createFeatureGroupAction = std::make_unique(cellFeatureAMPath, std::vector{1}); auto createActiveAction = std::make_unique(DataType::uint8, std::vector{1}, std::vector{1}, activeArrayPath); nx::core::Result resultOutputActions; diff --git a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp index 6686bac4b6..0761337de8 100644 --- a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp @@ -155,6 +155,25 @@ Arguments BuildArgs(float32 toleranceDeg, ChoicesParameter::ValueType neighborSc return args; } +// Arguments for the hand-built preflight-error fixtures, which use a "DataContainer" geometry +// with per-test array placement instead of the AnalyticalFixtures scaffold. +Arguments BuildManualPreflightArgs(const DataPath& quatsPath, const DataPath& phasesPath) +{ + Arguments args; + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"DataContainer"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(quatsPath)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(phasesPath)); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellEnsembleData", "CrystalStructures"}))); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + return args; +} + // Preflight + execute; returns the execute result for error-path tests. IFilter::ExecuteResult RunFilter(DataStructure& dataStructure, const Arguments& args) { @@ -196,7 +215,7 @@ void CheckActiveArray(const DataStructure& dataStructure, usize expectedNumFeatu using namespace AnalyticalFixtures; -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pure-Phi Chain, Face)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pure-Phi Chain, Face)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -221,7 +240,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(td.ds); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pi-Fold Antiparallel C-Axes)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pi-Fold Antiparallel C-Axes)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -241,7 +260,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(td.ds); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Neighbor Scheme Face vs All)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Neighbor Scheme Face vs All)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -282,7 +301,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( } } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Mask Excludes Voxel 0)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Mask Excludes Voxel 0)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -339,7 +358,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( } } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Phase Separation)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Phase Separation)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -359,7 +378,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(td.ds); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (3D Linearization, 3x2x2)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (3D Linearization, 3x2x2)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -384,7 +403,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(td.ds); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (RectGrid Geometry)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (RectGrid Geometry)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -393,6 +412,8 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( // pointer and crashed). 3x1x1 RectGrid with non-uniform x bounds, Phi = [0, 5, 45], tol 10: // cells 0-1 group (distance 5), cell 2 breaks (distance 40). DataStructure dataStructure; + // Deliberately reuses k_GeomName ("ImageGeometry") so BuildArgs and the shared check helpers + // resolve their paths unchanged, even though the geometry here is a RectGrid. auto* rectGridGeom = RectGridGeom::Create(dataStructure, k_GeomName); rectGridGeom->setDimensions(SizeVec3{3, 1, 1}); auto* xBoundsArrayPtr = Float32Array::CreateWithStore(dataStructure, "xBounds", ShapeType{4}, ShapeType{1}, rectGridGeom->getId()); @@ -437,7 +458,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(dataStructure); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Quats Outside Cell AttributeMatrix)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Quats Outside Cell AttributeMatrix)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { UnitTest::LoadPlugins(); @@ -474,7 +495,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical ( UnitTest::CheckArraysInheritTupleDims(td.ds); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants (RandomizeFeatureIds)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants (RandomizeFeatureIds)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class4]") { UnitTest::LoadPlugins(); @@ -525,7 +546,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants ( REQUIRE(firstRun == secondRun); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 4 Invariants (RandomizeFeatureIds Preserves Masked Zeros)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class4]") { UnitTest::LoadPlugins(); @@ -663,7 +684,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Ze REQUIRE(preflightResult.outputActions.errors()[0].code == -655); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell arrays smaller than geometry (-652)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell Arrays Smaller Than Geometry (-652)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") { UnitTest::LoadPlugins(); @@ -681,25 +702,14 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Ce UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", {2}, {1}, ensembleAM->getId()); CAxisSegmentFeaturesFilter filter; - Arguments args; - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"DataContainer"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellData", "Quats"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellData", "Phases"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellEnsembleData", "CrystalStructures"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + const Arguments args = BuildManualPreflightArgs(DataPath({"DataContainer", "CellData", "Quats"}), DataPath({"DataContainer", "CellData", "Phases"})); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); REQUIRE(preflightResult.outputActions.errors()[0].code == -652); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell AttributeMatrix smaller than geometry (-653)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Cell AttributeMatrix Smaller Than Geometry (-653)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][preflight]") { UnitTest::LoadPlugins(); @@ -718,18 +728,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Ce UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", {2}, {1}, ensembleAM->getId()); CAxisSegmentFeaturesFilter filter; - Arguments args; - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"DataContainer"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"DataContainer", "OrientationData", "Quats"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"DataContainer", "OrientationData", "Phases"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellEnsembleData", "CrystalStructures"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + const Arguments args = BuildManualPreflightArgs(DataPath({"DataContainer", "OrientationData", "Quats"}), DataPath({"DataContainer", "OrientationData", "Phases"})); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); @@ -760,18 +759,7 @@ TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Preflight Error - Ce UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", {2}, {1}, ensembleAM->getId()); CAxisSegmentFeaturesFilter filter; - Arguments args; - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(5.0F)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"DataContainer"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellData", "Quats"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"DataContainer", "MismatchData", "Phases"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"DataContainer", "CellEnsembleData", "CrystalStructures"}))); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); - args.insertOrAssign(CAxisSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + const Arguments args = BuildManualPreflightArgs(DataPath({"DataContainer", "CellData", "Quats"}), DataPath({"DataContainer", "MismatchData", "Phases"})); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_INVALID(preflightResult.outputActions); diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md index d74d1d731e..29b7ea5346 100644 --- a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -17,7 +17,7 @@ | Algorithm Relationship | **Minor changes.** Line-for-line port of legacy `getSeed`/`determineGrouping` (same c-axis math, including the clamped `acos` and the π-fold), plus deliberate NX additions: crystal-structure validation, 26-neighbor scheme, uint8 masks, RectGrid input, deterministic opt-in FeatureId randomization. Three SIMPLNX defects (D1, D4, D5) found and **fixed during this V&V cycle**. | | Oracle (confirmed) | **Class 1 (Analytical) primary + Class 4 (Invariant) companion** — pure-Phi Bunge quats make the folded c-axis distance between cells exactly `min(\|ΔΦ\|, 180°−\|ΔΦ\|)`; expected FeatureIds derive in closed form. Encoded as 10 fixture tests in `test/CAxisSegmentFeaturesTest.cpp`; all pass. | | Code paths enumerated | 20 of 20 enumerated; 18 exercised (2 gaps: defensive mask-instantiation error, cancel-signal path). | -| Tests today | 20 test cases: 8 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, 3-D linearization, Quats-outside-cell-AM, RectGrid), 2 Class 4 invariants (randomize non-identity/determinism, masked-zero preservation), 2 tolerance pins (phase-0, masked non-hex), 3 execute-error, 4 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | +| Tests today | 20 test cases: 8 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, 3D linearization, Quats-outside-cell-AM, RectGrid), 2 Class 4 invariants (randomize non-identity/determinism, masked-zero preservation), 2 exemption pins (phase-0, masked non-hex), 3 execute-error, 4 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | | Exemplar archive | **None — fixtures inlined in the test source.** The filter's consumption of `segment_features_test_data.tar.gz` (circular oracle) is retired; the archive remains for the EBSD segmentation tests. | | Legacy comparison | **Run** (2026-07-22, `vv/comparisons/CAxisSegmentFeaturesFilter/`) — all 4 shared-behavior fixtures match 6.5.171 at the segmentation-partition level with identical feature counts; bit-identical ids are unattainable because 6.5.171 always clock-randomizes FeatureIds (D2). | | Bug flags | D1, D4, D5 — all SIMPLNX defects, all **fixed this cycle** and pinned by tests. No legacy bug flags. | @@ -110,8 +110,8 @@ Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple m | `Execute Error - Phase Out of Ensemble Bounds (-8364)` | new-for-V&V | **D4 regression pin** (pre-fix: out-of-bounds read). | | `Execute Error - No Features Found (-87000)` | new-for-V&V | All cells masked; **D1 regression pin** (pre-fix: success with 1 phantom feature). | | `Preflight Error - Zero Tolerance (-655)` | new-for-V&V | Preflight rejects tolerance == 0. | -| `Preflight Error - Cell arrays smaller than geometry (-652)` | new-for-V&V | Cell arrays consistent with each other but not with the geometry's cell count (pre-fix: out-of-bounds walk at execute). | -| `Preflight Error - Cell AttributeMatrix smaller than geometry (-653)` | new-for-V&V | Cell arrays match the geometry but the FeatureIds-hosting AttributeMatrix does not. | +| `Preflight Error - Cell Arrays Smaller Than Geometry (-652)` | new-for-V&V | Cell arrays consistent with each other but not with the geometry's cell count (pre-fix: out-of-bounds walk at execute). | +| `Preflight Error - Cell AttributeMatrix Smaller Than Geometry (-653)` | new-for-V&V | Cell arrays match the geometry but the FeatureIds-hosting AttributeMatrix does not. | | `Preflight Error - Cell array tuple count mismatch (-651)` | kept | Synthetic tuple-count mismatch between Quats and Phases. | | `SIMPL Backwards Compatibility` | kept | DYNAMIC_SECTION over SIMPL 6.4 + 6.5 conversion fixtures; UUID + argument-key conversion only. | | *(retired)* `CAxisSegmentFeatures:Face` / `:All` / `:MaskFace` / `:MaskAll` | retired | Consumed the `segment_features_test_data.tar.gz` exemplar whose `CAxis_FeatureIds_*` arrays were generated from SIMPLNX output — a circular oracle (see provenance sidecar). Replaced by the Class 1 fixtures, which cover the same scheme × mask parameter cube with independent expected output. | diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py index c10f1a6b25..79664d258c 100644 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py @@ -27,8 +27,8 @@ sys.path.insert(0, os.environ.get("LEGACY_DREAM3D_SKILL_DIR", "/Users/mjackson/Workspace1/Claude_Support/skills/compare-legacy-dream3d")) from legacy_dream3d import D3DLegacyWriter # noqa: E402 -HEX_HIGH = 0 -UNKNOWN = 999 +HEX_HIGH = 0 # EbsdLib CrystalStructure enum value for Hexagonal_High +UNKNOWN = 999 # EbsdLib CrystalStructure "unknown" sentinel (ensemble 0) def quat_from_phi_deg(phi_deg): diff --git a/src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md index 7d537ffa18..7976321a73 100644 --- a/src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/provenance/CAxisSegmentFeaturesFilter.md @@ -10,7 +10,7 @@ This sidecar records the provenance of the oracle data for `CAxisSegmentFeatures |---|---| | **Archive** | None — fixtures inlined in `test/CAxisSegmentFeaturesTest.cpp` (namespace `AnalyticalFixtures`) | | **SHA512** | N/A | -| **Used by tests** | All `OrientationAnalysis::CAxisSegmentFeaturesFilter:*` test cases except `SIMPL Backwards Compatibility` (JSON fixtures) | +| **Used by tests** | All `OrientationAnalysis::CAxisSegmentFeaturesFilter:*` test cases except `SIMPL Backwards Compatibility` (JSON fixtures) and the hand-built preflight-error tests (`-651`/`-652`/`-653`) | | **Generated by** | Michael Jackson | | **Generated on** | 2026-07-22 | | **Generated at commit** | *the V&V commit on `vv/c_axis_segmentation`* | diff --git a/src/simplnx/Utilities/SegmentFeatures.cpp b/src/simplnx/Utilities/SegmentFeatures.cpp index cc23e2274c..f61012faba 100644 --- a/src/simplnx/Utilities/SegmentFeatures.cpp +++ b/src/simplnx/Utilities/SegmentFeatures.cpp @@ -143,9 +143,8 @@ Result<> SegmentFeatures::execute(IGridGeometry* gridGeom) usize totalVoxels = udims[0] * udims[1] * udims[2]; - int64 dims[3] = {static_cast(udims[0]), static_cast(udims[1]), static_cast(udims[2])}; + int64 dims[3] = {static_cast(udims[0]), static_cast(udims[1]), static_cast(udims[2])}; - // Initialize a sequence of execution modifiers int32 gnum = 1; int64 nextSeed = 0; // The first seed must be validated (and its cell stamped with gnum) by getSeed() exactly like From 9452a59e51d73e7f7ccea815ddbe74353f3dd567 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 24 Jul 2026 10:43:39 -0400 Subject: [PATCH 08/12] PERF: Remove quadratic per-seed feature AM resize from CAxis getSeed * CAxisSegmentFeatures::getSeed now only stamps the seed's FeatureId, mirroring the sibling EBSD/Scalar segmentation algorithms; the feature AttributeMatrix is resized exactly once after segmentation completes. The per-seed resizeAttributeArrays() was a vestige of the legacy 6.5 port (needed there to refresh raw Active pointers) and cost O(F^2) copy traffic in the number of features. * Drop the redundant pre-execute Active fill; the authoritative fill happens after the post-run resize. * Mirror the CAxis cancel guard and execute() result propagation into EBSDSegmentFeatures and ScalarSegmentFeatures so a canceled run is not misreported as -87000 "no Features were detected". * Update V&V report code-path 13 to match the lean getSeed. Signed-off-by: Michael Jackson --- .../Filters/Algorithms/CAxisSegmentFeatures.cpp | 9 ++++----- .../Filters/Algorithms/EBSDSegmentFeatures.cpp | 12 +++++++++++- .../vv/CAxisSegmentFeaturesFilter.md | 2 +- .../Filters/Algorithms/ScalarSegmentFeatures.cpp | 12 +++++++++++- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp index f87869f3af..a929d507e5 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp @@ -81,8 +81,6 @@ Result<> CAxisSegmentFeatures::operator()() m_FeatureIdsArray = m_DataStructure.getDataAs(m_InputValues->FeatureIdsArrayPath); m_FeatureIdsArray->fill(0); - auto* activeArray = m_DataStructure.getDataAs(m_InputValues->ActiveArrayPath); - activeArray->fill(1); // Run the segmentation algorithm Result<> segmentResult = execute(gridGeom); @@ -108,6 +106,7 @@ Result<> CAxisSegmentFeatures::operator()() cellFeatureAM.resizeTuples(tDims); // This will resize the active array // make sure all values are initialized and "re-reserve" index 0 + auto* activeArray = m_DataStructure.getDataAs(m_InputValues->ActiveArrayPath); activeArray->getDataStore()->fill(1); (*activeArray)[0] = 0; @@ -152,10 +151,10 @@ int64 CAxisSegmentFeatures::getSeed(int32 gnum, int64 nextSeed) const } if(seed >= 0) { - auto& cellFeatureAM = m_DataStructure.getDataRefAs(m_InputValues->CellFeatureAttributeMatrixPath); + // Stamp the seed only; the Feature AttributeMatrix is resized once in operator() after the + // segmentation completes (matching the sibling EBSD/Scalar algorithms). Nothing reads the + // feature-level arrays while the flood fill runs. featureIdsRef[static_cast(seed)] = gnum; - const ShapeType tDims = {static_cast(gnum) + 1}; - cellFeatureAM.resizeTuples(tDims); // This will resize the active array } return seed; } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp index 000b1e28b3..43d252bf6e 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp @@ -44,7 +44,17 @@ Result<> EBSDSegmentFeatures::operator()() m_FeatureIdsArray->fill(0); // initialize the output array with zeros // Run the segmentation algorithm - execute(gridGeom); + Result<> segmentResult = execute(gridGeom); + if(segmentResult.invalid()) + { + return segmentResult; + } + // A canceled run returns successfully with no feature count; do not misreport it below as + // "no Features were detected". + if(m_ShouldCancel) + { + return {}; + } // Sanity check the result. if(this->m_FoundFeatures < 1) { diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md index 29b7ea5346..38bd0ac1a5 100644 --- a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -79,7 +79,7 @@ Logical phases: **(a) init + validation** in `operator()`, **(b) flood-fill driv | 10 | (b) | All-connected (26-neighbor) scheme | `Class 1 Analytical (Neighbor Scheme Face vs All)` — "All Connected Neighbors" section | | 11 | (b) | Cancel requested → early return | *Not directly tested. Requires cancel-signal injection; low-value gap.* | | 12 | (c) | Seed scan skips owned / masked / phase ≤ 0 cells | `Mask Excludes Voxel 0`; `Phase 0 (Unindexed) Cells Tolerated` | -| 13 | (c) | Seed found → stamp FeatureId + resize feature AM | Every successful Class 1 test (`CheckActiveArray` tuple counts) | +| 13 | (c) | Seed found → stamp FeatureId (feature AM resized once, post-run, in phase (e)) | Every successful Class 1 test (`CheckActiveArray` tuple counts) | | 14 | (c) | No seed remains → return −1, driver exits | Every test (loop termination); immediately in `No Features Found (-87000)` | | 15 | (d) | Neighbor already owned or masked-out → reject | Chain fixtures (burst revisits owned neighbors); `Mask Excludes Voxel 0` (cells 0, 3) | | 16 | (d) | Phases differ → reject | `Class 1 Analytical (Phase Separation)`; `Phase 0 (Unindexed) Cells Tolerated` | diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp index 51805d1765..4028234821 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp @@ -210,7 +210,17 @@ Result<> ScalarSegmentFeatures::operator()() } // Run the segmentation algorithm - execute(gridGeom); + Result<> segmentResult = execute(gridGeom); + if(segmentResult.invalid()) + { + return segmentResult; + } + // A canceled run returns successfully with no feature count; do not misreport it below as + // "no Features were detected". + if(m_ShouldCancel) + { + return {}; + } // Sanity check the result. if(this->m_FoundFeatures < 1) { From d8c0b151bfc44b816942baf3a71074fd723ecc95 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 24 Jul 2026 10:59:48 -0400 Subject: [PATCH 09/12] REV: Harden segmentation driver entry and speed up CAxis phase validation * SegmentFeatures::execute() returns before the first getSeed() when the run is already canceled on entry, so a pre-canceled run mutates nothing (getSeed stamps the seed cell's FeatureId as a side effect). * Cache validated phases in the CAxis crystal-structure loop so the mask is consulted only for the first cell of each phase instead of every cell; per-element mask access is expensive on out-of-core stores. * Reword the -87000 error in all three segmentation filters: the only reachable cause is that no cell was eligible to seed (masked out or unindexed), not a tolerance/threshold setting. Signed-off-by: Michael Jackson --- .../Algorithms/CAxisSegmentFeatures.cpp | 19 +++++++++++++++++-- .../Algorithms/EBSDSegmentFeatures.cpp | 2 +- .../Algorithms/ScalarSegmentFeatures.cpp | 2 +- src/simplnx/Utilities/SegmentFeatures.cpp | 6 ++++++ 4 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp index a929d507e5..4a801f86b3 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp @@ -13,6 +13,7 @@ #include #include +#include using namespace nx::core; using namespace nx::core::OrientationUtilities; @@ -54,13 +55,26 @@ Result<> CAxisSegmentFeatures::operator()() const auto& crystalStructures = m_DataStructure.getDataRefAs(m_InputValues->CrystalStructuresArrayPath); const usize numCells = m_CellPhases->getNumberOfTuples(); const usize numEnsembles = crystalStructures.getNumberOfTuples(); + // Each phase only needs to be validated once. Caching validated phases keeps the loop from + // paying a per-cell mask lookup (expensive per-element access on out-of-core stores) for every + // cell of an already-validated phase — in the common all-hexagonal case the mask is consulted + // only for the first cell of each phase. + std::vector phaseValidated(numEnsembles, 0); for(usize cellIdx = 0; cellIdx < numCells; ++cellIdx) { const int32 currentPhaseIdx = m_CellPhases->getValue(cellIdx); // Cells that can never seed or join a feature are exempt from the crystal-structure // requirement: phase 0 is the conventional "unindexed" phase (its CrystalStructures entry is // the 999 "unknown" sentinel), and masked-out cells are excluded from segmentation entirely. - if(currentPhaseIdx <= 0 || (m_GoodVoxelsArray != nullptr && !m_GoodVoxelsArray->isTrue(cellIdx))) + if(currentPhaseIdx <= 0) + { + continue; + } + if(static_cast(currentPhaseIdx) < numEnsembles && phaseValidated[currentPhaseIdx] != 0) + { + continue; + } + if(m_GoodVoxelsArray != nullptr && !m_GoodVoxelsArray->isTrue(cellIdx)) { continue; } @@ -77,6 +91,7 @@ Result<> CAxisSegmentFeatures::operator()() "segmentation to be either Hexagonal-Low 6/m or Hexagonal-High 6/mmm type crystal structures.", CrystalStructureEnumToString(crystalStructureType))); } + phaseValidated[currentPhaseIdx] = 1; } m_FeatureIdsArray = m_DataStructure.getDataAs(m_InputValues->FeatureIdsArrayPath); @@ -97,7 +112,7 @@ Result<> CAxisSegmentFeatures::operator()() // Sanity check the result. if(m_FoundFeatures < 1) { - return MakeErrorResult(-87000, fmt::format("The number of Features is '{}' which means no Features were detected. The C-Axis Misorientation Tolerance may be set too low.", m_FoundFeatures)); + return MakeErrorResult(-87000, "No Features were detected: no Cell was eligible to seed a Feature. Every Cell is either excluded by the Mask or has a Phase value of 0 (unindexed)."); } // Resize the Feature Attribute Matrix diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp index 43d252bf6e..4665a1ba25 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp @@ -58,7 +58,7 @@ Result<> EBSDSegmentFeatures::operator()() // Sanity check the result. if(this->m_FoundFeatures < 1) { - return MakeErrorResult(-87000, fmt::format("The number of Features is '{}' which means no Features were detected. A threshold value may be set incorrectly", this->m_FoundFeatures)); + return MakeErrorResult(-87000, "No Features were detected: no Cell was eligible to seed a Feature. Every Cell is either excluded by the Mask or has a Phase value of 0 (unindexed)."); } // Resize the Feature Attribute Matrix diff --git a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp index 4028234821..6999b53dd3 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp @@ -224,7 +224,7 @@ Result<> ScalarSegmentFeatures::operator()() // Sanity check the result. if(this->m_FoundFeatures < 1) { - return MakeErrorResult(-87000, fmt::format("The number of Features is '{}' which means no Features were detected. A threshold value may be set incorrectly", this->m_FoundFeatures)); + return MakeErrorResult(-87000, "No Features were detected: no Cell was eligible to seed a Feature. Every Cell is excluded by the Mask."); } // Resize the Feature Attribute Matrix diff --git a/src/simplnx/Utilities/SegmentFeatures.cpp b/src/simplnx/Utilities/SegmentFeatures.cpp index f61012faba..cd73563f3e 100644 --- a/src/simplnx/Utilities/SegmentFeatures.cpp +++ b/src/simplnx/Utilities/SegmentFeatures.cpp @@ -147,6 +147,12 @@ Result<> SegmentFeatures::execute(IGridGeometry* gridGeom) int32 gnum = 1; int64 nextSeed = 0; + // A run that is already canceled on entry must not mutate anything; getSeed() stamps the seed + // cell's FeatureId as a side effect. + if(m_ShouldCancel) + { + return {}; + } // The first seed must be validated (and its cell stamped with gnum) by getSeed() exactly like // every later seed; bursting from a raw index 0 can grow a feature from a masked or phase-0 // voxel and leaves an empty feature behind whenever index 0 cannot legitimately seed anything. From 1f33a66ecb4757d89e076f4dc6eff8dfc476127b Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 24 Jul 2026 10:59:48 -0400 Subject: [PATCH 10/12] TEST: Pin all-cells-masked -87000 behavior in EBSD/Scalar segmentation * With the validated first seed, a dataset where no voxel can seed now fails with -87000 instead of "succeeding" with one phantom, zero-cell feature; pin that behavior per sibling so the shared-driver change is covered beyond the partially-masked case. Signed-off-by: Michael Jackson --- .../test/EBSDSegmentFeaturesFilterTest.cpp | 53 +++++++++++++++++++ .../test/ScalarSegmentFeaturesTest.cpp | 41 ++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp b/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp index 6e9396bf45..9f2c1196c6 100644 --- a/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp @@ -399,3 +399,56 @@ TEST_CASE("OrientationAnalysis::EBSDSegmentFeaturesFilter: Masked Voxel 0 Seed V REQUIRE(dataStructure.getDataRefAs(DataPath({"Geometry", "CellFeatureData", "Active"})).getNumberOfTuples() == 3); UnitTest::CheckArraysInheritTupleDims(dataStructure); } + +TEST_CASE("OrientationAnalysis::EBSDSegmentFeaturesFilter: Execute Error - All Cells Masked (-87000)", "[OrientationAnalysis][EBSDSegmentFeatures]") +{ + UnitTest::LoadPlugins(); + + // Regression pin for the shared SegmentFeatures driver: with every cell masked out no seed + // exists, so the filter must fail with -87000. The pre-fix driver burst from the raw index 0 + // and "succeeded" with one phantom, zero-cell feature. + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "Geometry"); + imageGeom->setDimensions({3, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 3}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* quatsArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Quats", ShapeType{1, 1, 3}, {4}, cellAM->getId()); + auto* phasesArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Phases", ShapeType{1, 1, 3}, {1}, cellAM->getId()); + auto* maskArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Mask", ShapeType{1, 1, 3}, {1}, cellAM->getId()); + auto* ensembleAM = AttributeMatrix::Create(dataStructure, "CellEnsembleData", ShapeType{2}, imageGeom->getId()); + auto* crystalStructuresArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", ShapeType{2}, {1}, ensembleAM->getId()); + + for(usize cellIdx = 0; cellIdx < 3; cellIdx++) + { + (*quatsArrayPtr)[cellIdx * 4 + 0] = 0.0f; + (*quatsArrayPtr)[cellIdx * 4 + 1] = 0.0f; + (*quatsArrayPtr)[cellIdx * 4 + 2] = 0.0f; + (*quatsArrayPtr)[cellIdx * 4 + 3] = 1.0f; + (*phasesArrayPtr)[cellIdx] = 1; + (*maskArrayPtr)[cellIdx] = false; + } + (*crystalStructuresArrayPtr)[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + (*crystalStructuresArrayPtr)[1] = ebsdlib::CrystalStructure::Hexagonal_High; + + EBSDSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(10.0F)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_UseMask_Key, std::make_any(true)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Mask"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"Geometry"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Quats"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Phases"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"Geometry", "CellEnsembleData", "CrystalStructures"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_IsPeriodic_Key, std::make_any(false)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result); + REQUIRE(executeResult.result.errors()[0].code == -87000); +} diff --git a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp index 789ad14392..19aa1703de 100644 --- a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp +++ b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp @@ -231,3 +231,44 @@ TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Masked Voxel 0 Seed Validation", REQUIRE(dataStructure.getDataRefAs(DataPath({"Geometry", "CellFeatureData", "Active"})).getNumberOfTuples() == 3); UnitTest::CheckArraysInheritTupleDims(dataStructure); } + +TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Execute Error - All Cells Masked (-87000)", "[SimplnxCore][ScalarSegmentFeatures]") +{ + UnitTest::LoadPlugins(); + + // Regression pin for the shared SegmentFeatures driver: with every cell masked out no seed + // exists, so the filter must fail with -87000. The pre-fix driver burst from the raw index 0 + // and "succeeded" with one phantom, zero-cell feature. + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "Geometry"); + imageGeom->setDimensions({3, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 3}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* scalarArrayPtr = CreateTestDataArray(dataStructure, "ScalarValues", ShapeType{1, 1, 3}, {1}, cellAM->getId()); + auto* maskArrayPtr = CreateTestDataArray(dataStructure, "Mask", ShapeType{1, 1, 3}, {1}, cellAM->getId()); + for(usize cellIdx = 0; cellIdx < 3; cellIdx++) + { + (*scalarArrayPtr)[cellIdx] = 5; + (*maskArrayPtr)[cellIdx] = false; + } + + ScalarSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_GridGeomPath_Key, std::make_any(DataPath({"Geometry"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_ScalarToleranceKey, std::make_any(1)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_InputArrayPathKey, std::make_any(DataPath({"Geometry", "CellData", "ScalarValues"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_UseMask_Key, std::make_any(true)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Mask"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_FeatureIdsName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_CellFeatureName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_RandomizeFeatures_Key, std::make_any(false)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_IsPeriodic_Key, std::make_any(false)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_INVALID(executeResult.result); + REQUIRE(executeResult.result.errors()[0].code == -87000); +} From ae5e9dfa107b7057a54aecfb259ee842b9ed207e Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 24 Jul 2026 10:59:48 -0400 Subject: [PATCH 11/12] DOC: Add 3-D fixture to the CAxis legacy A/B comparison * TC5_3D (3x2x2, bool mask excluding voxels 0 and 5) exercises the y/z stride branches of the 3-D flood fill against 6.5.171; the original four fixtures are all 1-D lines. One feature spans x/y/z hops plus a pi-fold link; masked voxel 0 pins the validated-first-seed path in 3-D. * Full five-fixture suite rerun through both runners 2026-07-24: all partitions match with identical feature counts; NX equals the oracle. * Refresh V&V report: five-fixture comparison, current sibling suite counts (EBSD 7/7, Scalar 4/4). * nx.d3dpipeline writer args updated to parameters_version 2 (use_compression/compression_level) to silence the -5432 load warning. Signed-off-by: Michael Jackson --- .../vv/CAxisSegmentFeaturesFilter.md | 6 +-- .../pipelines/legacy_6_5_171.json | 43 ++++++++++++++++++- .../pipelines/make_input.py | 19 ++++++-- .../pipelines/nx.d3dpipeline | 24 ++++++++++- .../results/comparison.md | 26 +++++++---- .../deviations/CAxisSegmentFeaturesFilter.md | 2 +- 6 files changed, 103 insertions(+), 17 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md index 38bd0ac1a5..fd1884d303 100644 --- a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -19,7 +19,7 @@ | Code paths enumerated | 20 of 20 enumerated; 18 exercised (2 gaps: defensive mask-instantiation error, cancel-signal path). | | Tests today | 20 test cases: 8 Class 1 analytical (chain, π-fold, neighbor-scheme, mask bool/uint8, phase separation, 3D linearization, Quats-outside-cell-AM, RectGrid), 2 Class 4 invariants (randomize non-identity/determinism, masked-zero preservation), 2 exemption pins (phase-0, masked non-hex), 3 execute-error, 4 preflight-error, 1 SIMPL 6.4/6.5 conversion (DYNAMIC_SECTION). | | Exemplar archive | **None — fixtures inlined in the test source.** The filter's consumption of `segment_features_test_data.tar.gz` (circular oracle) is retired; the archive remains for the EBSD segmentation tests. | -| Legacy comparison | **Run** (2026-07-22, `vv/comparisons/CAxisSegmentFeaturesFilter/`) — all 4 shared-behavior fixtures match 6.5.171 at the segmentation-partition level with identical feature counts; bit-identical ids are unattainable because 6.5.171 always clock-randomizes FeatureIds (D2). | +| Legacy comparison | **Run** (2026-07-22, rerun with TC5_3D 2026-07-24, `vv/comparisons/CAxisSegmentFeaturesFilter/`) — all 5 shared-behavior fixtures (incl. a 3×2×2 masked fixture covering the y/z stride branches) match 6.5.171 at the segmentation-partition level with identical feature counts; bit-identical ids are unattainable because 6.5.171 always clock-randomizes FeatureIds (D2). | | Bug flags | D1, D4, D5 — all SIMPLNX defects, all **fixed this cycle** and pinned by tests. No legacy bug flags. | | V&V phase | Discovery, relationship, oracle, reconciliation, algorithm review, tests (dual-build), legacy comparison, deviations, provenance, docs — complete. **Outstanding:** second-engineer review at PR (per sign-off convention). | @@ -116,7 +116,7 @@ Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple m | `SIMPL Backwards Compatibility` | kept | DYNAMIC_SECTION over SIMPL 6.4 + 6.5 conversion fixtures; UUID + argument-key conversion only. | | *(retired)* `CAxisSegmentFeatures:Face` / `:All` / `:MaskFace` / `:MaskAll` | retired | Consumed the `segment_features_test_data.tar.gz` exemplar whose `CAxis_FeatureIds_*` arrays were generated from SIMPLNX output — a circular oracle (see provenance sidecar). Replaced by the Class 1 fixtures, which cover the same scheme × mask parameter cube with independent expected output. | -All non-retired tests pass at the verified commit in **both** builds: in-core `NX-Com-Qt69-Vtk95-Rel` and OOC `simplnx-ooc-Rel` (20/20 each, 2026-07-22). The shared-driver fix (D1) was regression-checked against `EBSDSegmentFeatures` (5/5) and `ScalarSegmentFeatures` (2/2) in both builds. +All non-retired tests pass at the verified commit in **both** builds: in-core `NX-Com-Qt69-Vtk95-Rel` and OOC `simplnx-ooc-Rel` (20/20 each, 2026-07-24). The shared-driver fix (D1) was regression-checked against the full `EBSDSegmentFeatures` (7/7) and `ScalarSegmentFeatures` (4/4) suites in both builds, including per-sibling masked-voxel-0 and all-cells-masked (`-87000`) regression pins. ## Exemplar archive @@ -126,7 +126,7 @@ All non-retired tests pass at the verified commit in **both** builds: in-core `N ## Deviations from DREAM3D 6.5.171 -Comparison run 2026-07-22 on four pure-Phi fixtures (chain, π-fold, bool mask, phase-0) through the official 6.5.171 PipelineRunner and nxrunner from one shared legacy-format input (`vv/comparisons/CAxisSegmentFeaturesFilter/`). All four match at the segmentation-partition level with identical feature counts. +Comparison run 2026-07-22, extended with a 3-D fixture and fully rerun 2026-07-24, on five pure-Phi fixtures (chain, π-fold, bool mask, phase-0, and a 3×2×2 masked fixture covering the y/z stride branches) through the official 6.5.171 PipelineRunner and nxrunner from one shared legacy-format input (`vv/comparisons/CAxisSegmentFeaturesFilter/`). All five match at the segmentation-partition level with identical feature counts. - `CAxisSegmentFeaturesFilter-D1` — unvalidated first seed in SIMPLNX (post-#1466, pre-fix) could add a phantom feature or grow from a masked voxel — **fixed this cycle** — see `vv/deviations/CAxisSegmentFeaturesFilter.md` - `CAxisSegmentFeaturesFilter-D2` — 6.5.171 always clock-randomizes FeatureIds (irreproducible); SIMPLNX is deterministic with opt-in randomization — see `vv/deviations/CAxisSegmentFeaturesFilter.md` diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json index 1f6b4e4afa..b47c1dab5d 100644 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json @@ -105,6 +105,31 @@ "Flag": 2, "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, "Name": "TC4_Phase0" + }, + { + "Attribute Matricies": [ + { + "Data Arrays": [ + {"Component Dimensions": [4], "Flag": 2, "Name": "Quats", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellData", "Tuple Dimensions": [1], "Version": 2}, + {"Component Dimensions": [1], "Flag": 2, "Name": "Phases", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellData", "Tuple Dimensions": [1], "Version": 2}, + {"Component Dimensions": [1], "Flag": 2, "Name": "Mask", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellData", + "Type": 3 + }, + { + "Data Arrays": [ + {"Component Dimensions": [1], "Flag": 2, "Name": "CrystalStructures", "Object Type": "DataArray", "Path": "/DataContainers/TC5_3D/CellEnsembleData", "Tuple Dimensions": [1], "Version": 2} + ], + "Flag": 2, + "Name": "CellEnsembleData", + "Type": 11 + } + ], + "Flag": 2, + "Geometry": {"Geometry_Type": 0, "Geometry_Type_Name": "ImageGeometry"}, + "Name": "TC5_3D" } ], "Version": 6 @@ -175,6 +200,22 @@ "UseGoodVoxels": 0 }, "5": { + "ActiveArrayName": "Active", + "CellFeatureAttributeMatrixName": "CellFeatureData", + "CellPhasesArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellData", "Data Array Name": "Phases"}, + "CrystalStructuresArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellEnsembleData", "Data Array Name": "CrystalStructures"}, + "FeatureIdsArrayName": "FeatureIds", + "FilterVersion": "6.5.171", + "Filter_Enabled": true, + "Filter_Human_Label": "Segment Features (C-Axis Misalignment)", + "Filter_Name": "CAxisSegmentFeatures", + "Filter_Uuid": "{bff6be19-1219-5876-8838-1574ad29d965}", + "GoodVoxelsArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellData", "Data Array Name": "Mask"}, + "MisorientationTolerance": 10.0, + "QuatsArrayPath": {"Data Container Name": "TC5_3D", "Attribute Matrix Name": "CellData", "Data Array Name": "Quats"}, + "UseGoodVoxels": 1 + }, + "6": { "FilterVersion": "6.5.171", "Filter_Enabled": true, "Filter_Human_Label": "Write DREAM.3D Data File", @@ -186,7 +227,7 @@ }, "PipelineBuilder": { "Name": "CAxisSegmentFeatures Legacy 6.5.171 A/B", - "Number_Filters": 6, + "Number_Filters": 7, "Version": 6 } } diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py index 79664d258c..8da2d22baa 100644 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py @@ -9,6 +9,15 @@ TC2_PiFold 3x1x1 Phi = [2, 176, 88], tol 10 -> partition {0,1}{2} TC3_Mask 5x1x1 Phi = [0, 20, 22, 0, 90], mask [0,1,1,0,1] -> {1,2}{4}, cells 0,3 -> id 0 TC4_Phase0 4x1x1 Phi = [0,0,0,0], phases [0,1,1,1] -> {1,2,3}, cell 0 -> id 0 + TC5_3D 3x2x2 Phi = [0,50,55, 4,90,53, 8,95,130, 6,176,120] -> see below + mask [0,1,1,1,1,0, 1,1,1,1,1,1] + +TC5_3D exercises the y- and z-stride branches of the 3-D flood fill against legacy (TC1-TC4 are +all 1-D lines). With x-fastest linearization (idx = x + 3y + 6z) and tol 10 the expected partition +is: {1,2} via x; {3,6,9,10} spanning a z hop (3->9: |4-6|=2), a y hop (9->6: |6-8|=2), and a +pi-fold x hop (9->10: fold(6,176)=10); singletons {4} and {7}; {8,11} via a y hop at z=1 +(|130-120|=10). Masked cells 0 and 5 keep id 0 (cell 0 also pins the validated-first-seed path). +Expected FeatureIds (canonical NX labeling): [0,1,1,2,3,0,2,4,5,2,2,5] -> 5 features. A pure rotation about x by Phi tilts the c-axis by Phi in the sample y-z plane, so the c-axis angular distance between two cells is exactly min(|dPhi|, 180 - |dPhi|). Quaternion storage is @@ -36,10 +45,13 @@ def quat_from_phi_deg(phi_deg): return [math.sin(half), 0.0, 0.0, math.cos(half)] -def add_case(w, dc_name, phis, phases=None, mask=None, num_ensembles=2): +def add_case(w, dc_name, phis, phases=None, mask=None, num_ensembles=2, dims=None): n = len(phis) - w.add_image_geom(dc_name, (n, 1, 1)) - w.add_attribute_matrix(dc_name, "CellData", (1, 1, n), "Cell") + if dims is None: + dims = (n, 1, 1) # (x, y, z) + assert dims[0] * dims[1] * dims[2] == n + w.add_image_geom(dc_name, dims) + w.add_attribute_matrix(dc_name, "CellData", (dims[2], dims[1], dims[0]), "Cell") quats = np.array([quat_from_phi_deg(p) for p in phis], dtype=np.float32) w.add_data_array(dc_name, "CellData", "Quats", quats, comp_dims=(4,)) if phases is None: @@ -63,6 +75,7 @@ def main(): add_case(w, "TC2_PiFold", [2.0, 176.0, 88.0]) add_case(w, "TC3_Mask", [0.0, 20.0, 22.0, 0.0, 90.0], mask=[0, 1, 1, 0, 1]) add_case(w, "TC4_Phase0", [0.0, 0.0, 0.0, 0.0], phases=[0, 1, 1, 1]) + add_case(w, "TC5_3D", [0.0, 50.0, 55.0, 4.0, 90.0, 53.0, 8.0, 95.0, 130.0, 6.0, 176.0, 120.0], mask=[0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 1], dims=(3, 2, 2)) print(f"wrote {out_path}") diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline index 78f59f5dad..db8279ab4c 100644 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline @@ -99,11 +99,33 @@ "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, "isDisabled": false }, + { + "args": { + "input_image_geometry_path": {"value": "TC5_3D", "version": 1}, + "misorientation_tolerance": {"value": 10.0, "version": 1}, + "use_mask": {"value": true, "version": 1}, + "quats_array_path": {"value": "TC5_3D/CellData/Quats", "version": 1}, + "cell_phases_array_path": {"value": "TC5_3D/CellData/Phases", "version": 1}, + "mask_array_path": {"value": "TC5_3D/CellData/Mask", "version": 1}, + "crystal_structures_array_path": {"value": "TC5_3D/CellEnsembleData/CrystalStructures", "version": 1}, + "feature_ids_array_name": {"value": "FeatureIds", "version": 1}, + "cell_feature_attribute_matrix_name": {"value": "CellFeatureData", "version": 1}, + "active_array_name": {"value": "Active", "version": 1}, + "randomize_feature_ids": {"value": false, "version": 1}, + "neighbor_scheme_index": {"value": 0, "version": 1}, + "parameters_version": 2 + }, + "comments": "TC5 3x2x2 (x-fastest), bool mask excludes voxels 0 and 5; features span y/z strides and a pi-fold x hop. Expected {1,2}{3,6,9,10}{4}{7}{8,11}, masked cells id 0.", + "filter": {"name": "nx::core::CAxisSegmentFeaturesFilter", "uuid": "9fe07e17-aef1-4bf1-834c-d3a73dafc27d"}, + "isDisabled": false + }, { "args": { "export_file_path": {"value": "/Users/mjackson/Workspace5/simplnx/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/nx_out.dream3d", "version": 1}, "write_xdmf_file": {"value": false, "version": 1}, - "parameters_version": 1 + "use_compression": {"value": true, "version": 1}, + "compression_level": {"value": 5, "version": 1}, + "parameters_version": 2 }, "comments": "", "filter": {"name": "nx::core::WriteDREAM3DFilter", "uuid": "b3a95784-2ced-41ec-8d3d-0242ac130003"}, diff --git a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md index add35b0a06..8b4893f1e9 100644 --- a/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md @@ -1,11 +1,12 @@ # Legacy Comparison: CAxisSegmentFeaturesFilter -Date: 2026-07-22 +Date: 2026-07-22 (TC1–TC4); TC5_3D added and full suite rerun 2026-07-24 ## Test Cases -Four pure-Phi Bunge fixtures (identical to the Class 1 analytical fixtures in -`test/CAxisSegmentFeaturesTest.cpp`), tolerance 10°, face neighbors: +Five pure-Phi Bunge fixtures (TC1–TC4 identical to the Class 1 analytical fixtures in +`test/CAxisSegmentFeaturesTest.cpp`; TC5_3D exercises the y/z stride branches of the 3-D flood +fill, which the 1-D fixtures cannot), tolerance 10°, face neighbors: | Case | Grid | Phi per cell (deg) | Extras | Expected partition | |---|---|---|---|---| @@ -13,6 +14,12 @@ Four pure-Phi Bunge fixtures (identical to the Class 1 analytical fixtures in | TC2_PiFold | 3×1×1 | 2, 176, 88 | π-fold branch | {0,1} {2} | | TC3_Mask | 5×1×1 | 0, 20, 22, 0, 90 | bool mask [0,1,1,0,1] | {1,2} {4}; cells 0,3 → id 0 | | TC4_Phase0 | 4×1×1 | 0, 0, 0, 0 | phases [0,1,1,1] | {1,2,3}; cell 0 → id 0 | +| TC5_3D | 3×2×2 | 0, 50, 55, 4, 90, 53, 8, 95, 130, 6, 176, 120 | bool mask [0,1,1,1,1,0,1,1,1,1,1,1] | {1,2} {3,6,9,10} {4} {7} {8,11}; cells 0,5 → id 0 | + +TC5_3D uses x-fastest linearization (idx = x + 3y + 6z). Feature {3,6,9,10} spans a z-stride hop +(3→9: |4−6| = 2°), a y-stride hop (9→6: |6−8| = 2°), and a π-fold x hop (9→10: fold(6°,176°) = 10°); +{8,11} spans a y-stride hop at z = 1 (|130−120| = 10°). Masked voxel 0 additionally pins the +validated-first-seed (D1) path in 3-D against legacy. ## Input Data @@ -26,18 +33,21 @@ read by BOTH runners, so the Quats/Phases/Mask/CrystalStructures inputs are byte ## Results +Full-suite rerun 2026-07-24 (legacy FeatureIds differ run-to-run by construction, D2): + | Case | Legacy FeatureIds | NX FeatureIds | Partition match | Feature count match | |---|---|---|---|---| -| TC1_Chain | [1,1,1,4,4,3,3,2] | [1,1,1,2,2,3,3,4] | YES | YES (4+1 Active tuples) | -| TC2_PiFold | [2,2,1] | [1,1,2] | YES | YES (2+1) | -| TC3_Mask | [0,1,1,0,2] | [0,1,1,0,2] | YES | YES (2+1) | +| TC1_Chain | [1,1,1,4,4,2,2,3] | [1,1,1,2,2,3,3,4] | YES | YES (4+1 Active tuples) | +| TC2_PiFold | [1,1,2] | [1,1,2] | YES | YES (2+1) | +| TC3_Mask | [0,2,2,0,1] | [0,1,1,0,2] | YES | YES (2+1) | | TC4_Phase0 | [0,1,1,1] | [0,1,1,1] | YES | YES (1+1) | +| TC5_3D | [0,4,4,2,1,0,2,5,3,2,2,3] | [0,1,1,2,3,0,2,4,5,2,2,5] | YES | YES (5+1) | -**All four cases match at the segmentation-partition level, and feature counts are identical.** +**All five cases match at the segmentation-partition level, and feature counts are identical.** Raw legacy ids are a random permutation of NX ids because 6.5.171 unconditionally randomizes FeatureIds with a clock-derived seed (`CAxisSegmentFeaturesFilter-D2`); bit-identical FeatureIds are not attainable against 6.5.171 by construction. NX ids additionally match the Class 1 oracle -expectation exactly. +expectation exactly (TC5_3D against an independently computed reference flood fill). ## Fixes Applied diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md index 84ce062bb8..85b4c73738 100644 --- a/src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/deviations/CAxisSegmentFeaturesFilter.md @@ -34,7 +34,7 @@ Entries are referenced by stable ID (`CAxisSegmentFeaturesFilter-D`) from the **Symptom:** FeatureIds from 6.5.171 are a different (random) labeling on every run, while SIMPLNX produces the same FeatureIds on every run; the two versions never produce bit-identical FeatureIds arrays. -**Root cause:** Algorithmic choice. 6.5.171 hard-codes `m_RandomizeFeatureIds = true` and seeds its RNG from the wall clock (`CAxisSegmentFeatures.cpp::initializeVoxelSeedGenerator`), and the option is not exposed as a pipeline parameter — legacy output labeling is irreproducible by construction. SIMPLNX exposes `Randomize Feature Ids` as a parameter (default `false`) and, when enabled, uses a fixed-seed `std::mt19937_64` (`ClusterUtilities::RandomizeFeatureIds`), so output is deterministic either way. The segmentation *partition* (which cells share a feature) is unaffected; the 2026-07-22 A/B run (`vv/comparisons/CAxisSegmentFeaturesFilter/`) matched partitions exactly on all four fixtures. +**Root cause:** Algorithmic choice. 6.5.171 hard-codes `m_RandomizeFeatureIds = true` and seeds its RNG from the wall clock (`CAxisSegmentFeatures.cpp::initializeVoxelSeedGenerator`), and the option is not exposed as a pipeline parameter — legacy output labeling is irreproducible by construction. SIMPLNX exposes `Randomize Feature Ids` as a parameter (default `false`) and, when enabled, uses a fixed-seed `std::mt19937_64` (`ClusterUtilities::RandomizeFeatureIds`), so output is deterministic either way. The segmentation *partition* (which cells share a feature) is unaffected; the A/B runs (2026-07-22, rerun with the 3-D fixture 2026-07-24, `vv/comparisons/CAxisSegmentFeaturesFilter/`) matched partitions exactly on all five fixtures. **Affected users:** Anyone diffing raw FeatureIds arrays between versions or between two 6.5.171 runs; downstream statistics keyed by feature (sizes, misorientations) are invariant to the labeling. From 20bcb38b762e7046a67e167fc17c965b794b962d Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 24 Jul 2026 11:14:07 -0400 Subject: [PATCH 12/12] BUG/PERF: Restore periodic segmentation and stop per-feature worklist refills * The IsPeriodic option (added in #1291) has been silently ignored since the #1373 neighbor refactor dropped the wrap logic from the shared SegmentFeatures driver. getFaceNeighbors/getAllNeighbors now take isPeriodic and emit the wrap-around neighbor for boundary cells (each axis wraps independently; axes of extent 1 never wrap), and the driver reports when a Feature crosses a periodic boundary, matching the documented behavior. Pinned by periodic-wrap tests for EBSD (face) and Scalar (face and 26-neighbor schemes). * The burst worklist was shrunk to size 1 after every feature, so the first accepted neighbor of each subsequent feature re-grew it by 100k elements and re-wrote 100k -1 sentinels that nothing ever reads. The list now keeps its high-water size across features and is never sentinel-filled; only indices below the live count are read. Fixes #1690 Fixes #1691 Signed-off-by: Michael Jackson --- .../test/EBSDSegmentFeaturesFilterTest.cpp | 69 +++++++++ .../vv/CAxisSegmentFeaturesFilter.md | 2 +- .../test/ScalarSegmentFeaturesTest.cpp | 63 ++++++++ src/simplnx/Utilities/SegmentFeatures.cpp | 144 +++++++++++++----- 4 files changed, 235 insertions(+), 43 deletions(-) diff --git a/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp b/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp index 9f2c1196c6..5026971ede 100644 --- a/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp @@ -400,6 +400,75 @@ TEST_CASE("OrientationAnalysis::EBSDSegmentFeaturesFilter: Masked Voxel 0 Seed V UnitTest::CheckArraysInheritTupleDims(dataStructure); } +TEST_CASE("OrientationAnalysis::EBSDSegmentFeaturesFilter: Periodic Boundary Wrap", "[OrientationAnalysis][EBSDSegmentFeatures]") +{ + UnitTest::LoadPlugins(); + + // Regression pin for the IsPeriodic parameter (previously a silent no-op in the shared + // SegmentFeatures driver). 4x1x1 line of pure rotations about x by Phi = [0, 30, 30, 2] + // degrees (Hexagonal_High): pairwise misorientations equal |dPhi|, so at tolerance 10 the + // non-periodic partition is {0} {1,2} {3}; periodic, the x boundary wraps and the end cells + // (misorientation 2 degrees) join: {0,3} {1,2}. + auto runFilter = [](bool isPeriodic) -> std::vector { + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "Geometry"); + imageGeom->setDimensions({4, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 4}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* quatsArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Quats", ShapeType{1, 1, 4}, {4}, cellAM->getId()); + auto* phasesArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "Phases", ShapeType{1, 1, 4}, {1}, cellAM->getId()); + auto* ensembleAM = AttributeMatrix::Create(dataStructure, "CellEnsembleData", ShapeType{2}, imageGeom->getId()); + auto* crystalStructuresArrayPtr = UnitTest::CreateTestDataArray(dataStructure, "CrystalStructures", ShapeType{2}, {1}, ensembleAM->getId()); + + const std::vector phiDegrees = {0.0f, 30.0f, 30.0f, 2.0f}; + for(usize cellIdx = 0; cellIdx < phiDegrees.size(); cellIdx++) + { + const float32 halfAngleRad = (phiDegrees[cellIdx] * 0.5f) * Constants::k_PiOver180F; + (*quatsArrayPtr)[cellIdx * 4 + 0] = std::sin(halfAngleRad); + (*quatsArrayPtr)[cellIdx * 4 + 1] = 0.0f; + (*quatsArrayPtr)[cellIdx * 4 + 2] = 0.0f; + (*quatsArrayPtr)[cellIdx * 4 + 3] = std::cos(halfAngleRad); + (*phasesArrayPtr)[cellIdx] = 1; + } + (*crystalStructuresArrayPtr)[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + (*crystalStructuresArrayPtr)[1] = ebsdlib::CrystalStructure::Hexagonal_High; + + EBSDSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_MisorientationTolerance_Key, std::make_any(10.0F)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(0)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Mask"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_SelectedImageGeometryPath_Key, std::make_any(DataPath({"Geometry"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_QuatsArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Quats"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CellPhasesArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Phases"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CrystalStructuresArrayPath_Key, std::make_any(DataPath({"Geometry", "CellEnsembleData", "CrystalStructures"}))); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_FeatureIdsArrayName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_CellFeatureAttributeMatrixName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_RandomizeFeatureIds_Key, std::make_any(false)); + args.insertOrAssign(EBSDSegmentFeaturesFilter::k_IsPeriodic_Key, std::make_any(isPeriodic)); + + 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(DataPath({"Geometry", "CellData", "FeatureIds"}))); + const auto& featureIdsRef = dataStructure.getDataRefAs(DataPath({"Geometry", "CellData", "FeatureIds"})).getDataStoreRef(); + std::vector featureIds(featureIdsRef.getNumberOfTuples()); + for(usize cellIdx = 0; cellIdx < featureIds.size(); cellIdx++) + { + featureIds[cellIdx] = featureIdsRef[cellIdx]; + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); + return featureIds; + }; + + REQUIRE(runFilter(false) == std::vector{1, 2, 2, 3}); + REQUIRE(runFilter(true) == std::vector{1, 2, 2, 1}); +} + TEST_CASE("OrientationAnalysis::EBSDSegmentFeaturesFilter: Execute Error - All Cells Masked (-87000)", "[OrientationAnalysis][EBSDSegmentFeatures]") { UnitTest::LoadPlugins(); diff --git a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md index fd1884d303..c7bedc5ee2 100644 --- a/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -116,7 +116,7 @@ Filter-level (preflight) paths — tolerance == 0 → `-655`, cell-array tuple m | `SIMPL Backwards Compatibility` | kept | DYNAMIC_SECTION over SIMPL 6.4 + 6.5 conversion fixtures; UUID + argument-key conversion only. | | *(retired)* `CAxisSegmentFeatures:Face` / `:All` / `:MaskFace` / `:MaskAll` | retired | Consumed the `segment_features_test_data.tar.gz` exemplar whose `CAxis_FeatureIds_*` arrays were generated from SIMPLNX output — a circular oracle (see provenance sidecar). Replaced by the Class 1 fixtures, which cover the same scheme × mask parameter cube with independent expected output. | -All non-retired tests pass at the verified commit in **both** builds: in-core `NX-Com-Qt69-Vtk95-Rel` and OOC `simplnx-ooc-Rel` (20/20 each, 2026-07-24). The shared-driver fix (D1) was regression-checked against the full `EBSDSegmentFeatures` (7/7) and `ScalarSegmentFeatures` (4/4) suites in both builds, including per-sibling masked-voxel-0 and all-cells-masked (`-87000`) regression pins. +All non-retired tests pass at the verified commit in **both** builds: in-core `NX-Com-Qt69-Vtk95-Rel` and OOC `simplnx-ooc-Rel` (20/20 each, 2026-07-24). The shared-driver fix (D1) was regression-checked against the full `EBSDSegmentFeatures` (8/8) and `ScalarSegmentFeatures` (5/5) suites in both builds, including per-sibling masked-voxel-0, all-cells-masked (`-87000`), and periodic-boundary-wrap regression pins. ## Exemplar archive diff --git a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp index 19aa1703de..f5f5c69500 100644 --- a/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp +++ b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp @@ -232,6 +232,69 @@ TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Masked Voxel 0 Seed Validation", UnitTest::CheckArraysInheritTupleDims(dataStructure); } +TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Periodic Boundary Wrap", "[SimplnxCore][ScalarSegmentFeatures]") +{ + UnitTest::LoadPlugins(); + + // Regression pin for the IsPeriodic parameter (previously a silent no-op in the shared + // SegmentFeatures driver). 4x1x1 line with scalar values [5, 9, 9, 5] at tolerance 1: + // non-periodic the ends stay separate ({0} {1,2} {3}); periodic the x boundary wraps and the + // end cells join ({0,3} {1,2}). The expectation is identical for the face and the 26-neighbor + // ("all connected") schemes, which exercises the wrap in both neighbor generators. + auto runFilter = [](bool isPeriodic, ChoicesParameter::ValueType neighborScheme) -> std::vector { + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "Geometry"); + imageGeom->setDimensions({4, 1, 1}); + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", ShapeType{1, 1, 4}, imageGeom->getId()); + imageGeom->setCellData(*cellAM); + auto* scalarArrayPtr = CreateTestDataArray(dataStructure, "ScalarValues", ShapeType{1, 1, 4}, {1}, cellAM->getId()); + const std::vector scalarValues = {5, 9, 9, 5}; + for(usize cellIdx = 0; cellIdx < scalarValues.size(); cellIdx++) + { + (*scalarArrayPtr)[cellIdx] = scalarValues[cellIdx]; + } + + ScalarSegmentFeaturesFilter filter; + Arguments args; + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_GridGeomPath_Key, std::make_any(DataPath({"Geometry"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_ScalarToleranceKey, std::make_any(1)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_InputArrayPathKey, std::make_any(DataPath({"Geometry", "CellData", "ScalarValues"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_UseMask_Key, std::make_any(false)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_MaskArrayPath_Key, std::make_any(DataPath({"Geometry", "CellData", "Mask"}))); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_FeatureIdsName_Key, std::make_any("FeatureIds")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_CellFeatureName_Key, std::make_any("CellFeatureData")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_ActiveArrayName_Key, std::make_any("Active")); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_RandomizeFeatures_Key, std::make_any(false)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_IsPeriodic_Key, std::make_any(isPeriodic)); + args.insertOrAssign(ScalarSegmentFeaturesFilter::k_NeighborScheme_Key, std::make_any(neighborScheme)); + + 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(DataPath({"Geometry", "CellData", "FeatureIds"}))); + const auto& featureIdsRef = dataStructure.getDataRefAs(DataPath({"Geometry", "CellData", "FeatureIds"})).getDataStoreRef(); + std::vector featureIds(featureIdsRef.getNumberOfTuples()); + for(usize cellIdx = 0; cellIdx < featureIds.size(); cellIdx++) + { + featureIds[cellIdx] = featureIdsRef[cellIdx]; + } + UnitTest::CheckArraysInheritTupleDims(dataStructure); + return featureIds; + }; + + const std::vector schemeNames = {"Face Neighbors", "All Connected Neighbors"}; + for(ChoicesParameter::ValueType neighborScheme = 0; neighborScheme < 2; neighborScheme++) + { + DYNAMIC_SECTION(schemeNames[neighborScheme]) + { + REQUIRE(runFilter(false, neighborScheme) == std::vector{1, 2, 2, 3}); + REQUIRE(runFilter(true, neighborScheme) == std::vector{1, 2, 2, 1}); + } + } +} + TEST_CASE("SimplnxCore::ScalarSegmentFeatures: Execute Error - All Cells Masked (-87000)", "[SimplnxCore][ScalarSegmentFeatures]") { UnitTest::LoadPlugins(); diff --git a/src/simplnx/Utilities/SegmentFeatures.cpp b/src/simplnx/Utilities/SegmentFeatures.cpp index cd73563f3e..c1d839a552 100644 --- a/src/simplnx/Utilities/SegmentFeatures.cpp +++ b/src/simplnx/Utilities/SegmentFeatures.cpp @@ -11,16 +11,29 @@ using namespace nx::core; namespace { /** - * @brief This will find the 6 face neighbor's indices. + * @brief A candidate neighbor cell. `wrapped` is true when the neighbor was reached by wrapping + * across a periodic boundary, so the driver can report non-contiguous features. + */ +struct NeighborPoint +{ + int64 index = 0; + bool wrapped = false; +}; + +/** + * @brief This will find the 6 face neighbor's indices. When isPeriodic is true, a boundary cell + * additionally gets the wrap-around neighbor on the opposite side of the volume (axes of extent 1 + * never wrap — the cell would be its own neighbor). * @param currentPoint * @param width * @param height * @param depth - * @return Vector of indices + * @param isPeriodic + * @return Vector of neighbor points */ -std::vector getFaceNeighbors(const int64 currentPoint, const int64 width, const int64 height, const int64 depth) +std::vector getFaceNeighbors(const int64 currentPoint, const int64 width, const int64 height, const int64 depth, const bool isPeriodic) { - std::vector neighbors; + std::vector neighbors; neighbors.reserve(6); // decode currentPoint -> (col, row, plane) @@ -34,43 +47,70 @@ std::vector getFaceNeighbors(const int64 currentPoint, const int64 width, if(col > 0) { - neighbors.push_back(currentPoint - 1); + neighbors.push_back({currentPoint - 1, false}); + } + else if(isPeriodic && width > 1) + { + neighbors.push_back({currentPoint + (width - 1), true}); } if(col < width - 1) { - neighbors.push_back(currentPoint + 1); + neighbors.push_back({currentPoint + 1, false}); + } + else if(isPeriodic && width > 1) + { + neighbors.push_back({currentPoint - (width - 1), true}); } if(row > 0) { - neighbors.push_back(currentPoint - width); + neighbors.push_back({currentPoint - width, false}); + } + else if(isPeriodic && height > 1) + { + neighbors.push_back({currentPoint + width * (height - 1), true}); } if(row < height - 1) { - neighbors.push_back(currentPoint + width); + neighbors.push_back({currentPoint + width, false}); + } + else if(isPeriodic && height > 1) + { + neighbors.push_back({currentPoint - width * (height - 1), true}); } if(plane > 0) { - neighbors.push_back(currentPoint - slice); + neighbors.push_back({currentPoint - slice, false}); + } + else if(isPeriodic && depth > 1) + { + neighbors.push_back({currentPoint + slice * (depth - 1), true}); } if(plane < depth - 1) { - neighbors.push_back(currentPoint + slice); + neighbors.push_back({currentPoint + slice, false}); + } + else if(isPeriodic && depth > 1) + { + neighbors.push_back({currentPoint - slice * (depth - 1), true}); } return neighbors; } /** - * @brief This will find all indices that are connected via the 26 face, edge or vertex neighbors + * @brief This will find all indices that are connected via the 26 face, edge or vertex neighbors. + * When isPeriodic is true, each axis wraps independently across the volume boundary (axes of + * extent 1 never wrap — the cell would be its own neighbor). * @param currentPoint * @param width * @param height * @param depth - * @return vector of indices + * @param isPeriodic + * @return vector of neighbor points */ -std::vector getAllNeighbors(const int64 currentPoint, const int64 width, const int64 height, const int64 depth) +std::vector getAllNeighbors(const int64 currentPoint, const int64 width, const int64 height, const int64 depth, const bool isPeriodic) { - std::vector neighbors; + std::vector neighbors; neighbors.reserve(26); // decode currentPoint -> (col, row, plane) @@ -82,24 +122,33 @@ std::vector getAllNeighbors(const int64 currentPoint, const int64 width, // stride for one z-slice const int64 slice = width * height; - // baseOffset == currentPoint - const int64 baseOffset = currentPoint; - for(int64 dz = -1; dz <= 1; ++dz) { - if(const int64 p = plane + dz; p < 0 || p >= depth) + int64 p = plane + dz; + bool zWrapped = false; + if(p < 0 || p >= depth) { - continue; + if(!isPeriodic || depth <= 1) + { + continue; + } + p = (p + depth) % depth; + zWrapped = true; } - const int64 dzOff = dz * slice; for(int64 dy = -1; dy <= 1; ++dy) { - if(const int64 r = row + dy; r < 0 || r >= height) + int64 r = row + dy; + bool yWrapped = false; + if(r < 0 || r >= height) { - continue; + if(!isPeriodic || height <= 1) + { + continue; + } + r = (r + height) % height; + yWrapped = true; } - const int64 dyOff = dy * width; for(int64 dx = -1; dx <= 1; ++dx) { @@ -108,12 +157,18 @@ std::vector getAllNeighbors(const int64 currentPoint, const int64 width, { continue; } - if(int64 c = col + dx; c < 0 || c >= width) + int64 c = col + dx; + bool xWrapped = false; + if(c < 0 || c >= width) { - continue; + if(!isPeriodic || width <= 1) + { + continue; + } + c = (c + width) % width; + xWrapped = true; } - int64 neighbor = baseOffset + dzOff + dyOff + dx; - neighbors.push_back(neighbor); + neighbors.push_back({c + (r * width) + (p * slice), xWrapped || yWrapped || zWrapped}); } } } @@ -159,10 +214,12 @@ Result<> SegmentFeatures::execute(IGridGeometry* gridGeom) int64 seed = getSeed(gnum, nextSeed); nextSeed = seed + 1; usize size = 0; + bool hasNonContiguousFeature = false; - // Initialize containers + // Initialize the burst worklist. Only indices below `size` are ever read, so the contents are + // never pre-filled; the list keeps its high-water size across features. constexpr usize initialVoxelsListSize = 100000; - std::vector voxelsList(initialVoxelsListSize, -1); + std::vector voxelsList(initialVoxelsListSize); usize totalVoxelsSegmented = 0; while(seed >= 0) @@ -179,35 +236,35 @@ Result<> SegmentFeatures::execute(IGridGeometry* gridGeom) { const int64 currentPoint = voxelsList[size - 1]; size -= 1; - std::vector neighPoints; + std::vector neighPoints; switch(m_NeighborScheme) { case NeighborScheme::Face: - neighPoints = getFaceNeighbors(currentPoint, dims[0], dims[1], dims[2]); + neighPoints = getFaceNeighbors(currentPoint, dims[0], dims[1], dims[2], m_IsPeriodic); break; case NeighborScheme::FaceEdgeVertex: - neighPoints = getAllNeighbors(currentPoint, dims[0], dims[1], dims[2]); + neighPoints = getAllNeighbors(currentPoint, dims[0], dims[1], dims[2], m_IsPeriodic); break; } - for(const auto& neighbor : neighPoints) + for(const auto& neighborPoint : neighPoints) { - if(determineGrouping(currentPoint, neighbor, gnum)) + if(determineGrouping(currentPoint, neighborPoint.index, gnum)) { - voxelsList[size] = neighbor; + if(neighborPoint.wrapped) + { + hasNonContiguousFeature = true; + } + voxelsList[size] = neighborPoint.index; size++; - if(neighbor == nextSeed) + if(neighborPoint.index == nextSeed) { - nextSeed = neighbor + 1; + nextSeed = neighborPoint.index + 1; } if(size >= voxelsList.size()) { size = voxelsList.size(); voxelsList.resize(size + initialVoxelsListSize); - for(std::vector::size_type j = size; j < voxelsList.size(); ++j) - { - voxelsList[j] = -1; - } } totalVoxelsSegmented++; } @@ -218,7 +275,6 @@ Result<> SegmentFeatures::execute(IGridGeometry* gridGeom) float percentComplete = static_cast(totalVoxelsSegmented) / static_cast(totalVoxels) * 100.0f; throttledMessenger.sendThrottledMessage([&]() { return fmt::format("{:.2f}% - Current Feature Count: {}", percentComplete, gnum); }); // Increment or set values for the next iteration - voxelsList.assign(size + 1, -1); gnum++; // Get the next seed value seed = getSeed(gnum, nextSeed); // If seed ends up being -1, then we will exit the loop. @@ -226,6 +282,10 @@ Result<> SegmentFeatures::execute(IGridGeometry* gridGeom) } m_FoundFeatures = gnum - 1; // Decrement the gnum because it will end up 1 larger than it should have been. + if(hasNonContiguousFeature) + { + m_MessageHelper.sendMessage("Non-contiguous Features were found: at least one Feature wraps across a periodic boundary."); + } m_MessageHelper.sendMessage(fmt::format("Total Features Found: {}", m_FoundFeatures)); return {}; }