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) diff --git a/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/docs/CAxisSegmentFeaturesFilter.md index 6983636597..8e437d7c15 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 [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. 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 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 8dd04ec515..4a801f86b3 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/CAxisSegmentFeatures.cpp @@ -4,14 +4,16 @@ #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 #include #include +#include #include +#include using namespace nx::core; using namespace nx::core::OrientationUtilities; @@ -29,8 +31,9 @@ CAxisSegmentFeatures::~CAxisSegmentFeatures() noexcept = default; // ----------------------------------------------------------------------------- Result<> CAxisSegmentFeatures::operator()() { - this->m_NeighborScheme = m_InputValues->NeighborScheme; - auto* imageGeometry = m_DataStructure.getDataAs(m_InputValues->ImageGeometryPath); + 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); m_CellPhases = m_DataStructure.getDataAs(m_InputValues->CellPhasesArrayPath); if(m_InputValues->UseMask) @@ -42,67 +45,92 @@ 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())); } } // 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); - usize numCells = m_CellPhases->getNumberOfTuples(); + 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) { - 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) { continue; } - // Only consider valid mask values - if(m_InputValues->UseMask && !m_GoodVoxelsArray->isTrue(cellIdx)) + if(static_cast(currentPhaseIdx) < numEnsembles && phaseValidated[currentPhaseIdx] != 0) { continue; } + if(m_GoodVoxelsArray != nullptr && !m_GoodVoxelsArray->isTrue(cellIdx)) + { + continue; + } + if(static_cast(currentPhaseIdx) >= numEnsembles) + { + 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 " - "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))); } + phaseValidated[currentPhaseIdx] = 1; } m_FeatureIdsArray = m_DataStructure.getDataAs(m_InputValues->FeatureIdsArrayPath); m_FeatureIdsArray->fill(0); - auto* active = m_DataStructure.getDataAs(m_InputValues->ActiveArrayPath); - active->fill(1); // Run the segmentation algorithm - execute(imageGeometry); + 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) + 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, "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 - 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 auto* activeArray = m_DataStructure.getDataAs(m_InputValues->ActiveArrayPath); 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) { - ClusterUtilities::RandomizeFeatureIds(m_FeatureIdsArray->getDataStoreRef(), this->m_FoundFeatures + 1); + ClusterUtilities::RandomizeFeatureIds(m_FeatureIdsArray->getDataStoreRef(), m_FoundFeatures + 1); } return {}; @@ -111,63 +139,63 @@ 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(); - // 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(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(randPoint)) && cellPhases[randPoint] > 0) + if((!m_InputValues->UseMask || m_GoodVoxelsArray->isTrue(candidatePoint)) && cellPhasesRef[candidatePoint] > 0) { - seed = static_cast(randPoint); + seed = static_cast(candidatePoint); } else { - randPoint += 1; + candidatePoint += 1; } } else { - randPoint += 1; + candidatePoint += 1; } } if(seed >= 0) { - auto& cellFeatureAM = m_DataStructure.getDataRefAs(m_InputValues->CellFeatureAttributeMatrixPath); - featureIds[static_cast(seed)] = gnum; - const ShapeType tDims = {static_cast(gnum) + 1}; - cellFeatureAM.resizeTuples(tDims); // This will resize the active array + // 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; } return seed; } // ----------------------------------------------------------------------------- -bool CAxisSegmentFeatures::determineGrouping(int64 referencepoint, int64 neighborpoint, int32 gnum) const +bool CAxisSegmentFeatures::determineGrouping(int64 referencePoint, int64 neighborPoint, int32 gnum) const { 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) { - 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(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(); @@ -187,7 +215,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/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/Algorithms/EBSDSegmentFeatures.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp index 000b1e28b3..4665a1ba25 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/EBSDSegmentFeatures.cpp @@ -44,11 +44,21 @@ 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) { - 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/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp index bd15933916..1361e14181 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/CAxisSegmentFeaturesFilter.cpp @@ -3,20 +3,21 @@ #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/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" +#include "simplnx/Parameters/NumberParameter.hpp" #include "simplnx/Utilities/SIMPLConversion.hpp" -#include "simplnx/Parameters/NumberParameter.hpp" - using namespace nx::core; namespace nx::core @@ -60,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"}); @@ -82,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")); @@ -115,13 +116,12 @@ 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); 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 @@ -129,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)); } @@ -150,12 +150,28 @@ 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. 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 - 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; @@ -182,7 +198,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..0761337de8 100644 --- a/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/CAxisSegmentFeaturesTest.cpp @@ -1,266 +1,738 @@ -#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 +using namespace nx::core::UnitTest; + +namespace +{ +namespace AnalyticalFixtures { -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)"); +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_CrystalStructuresArrayPath = k_InputGeometryPath.createChildPath(k_EnsembleName).createChildPath("CrystalStructures"); +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_ActivesArrayPath = k_InputGeometryPath.createChildPath(k_Grain_Data).createChildPath(k_ActiveName); + 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_FeatureIdsArrayPath = k_InputGeometryPath.createChildPath(k_CellDataName).createChildPath(k_FeatureIds); + // 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()); -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 + 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()); -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); + for(usize cellIdx = 0; cellIdx < numCells; cellIdx++) + { + 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; + } - // EBSD Segment Features/Semgent Features (Misorientation) Filter + (*td.crystalStructures)[0] = ebsdlib::CrystalStructure::UnknownCrystalStructure; + for(usize ensembleIdx = 1; ensembleIdx < numEnsembles; ensembleIdx++) { - CAxisSegmentFeaturesFilter filter; - Arguments args; + (*td.crystalStructures)[ensembleIdx] = ebsdlib::CrystalStructure::Hexagonal_High; + } - // 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)); + return td; +} - 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)); +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]; + } +} - 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)); +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; +} - 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)); +// 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 the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); +// 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); +} - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); +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++) + { + INFO(fmt::format("cell index {}", cellIdx)); + REQUIRE(featureIdsRef[cellIdx] == expectedFeatureIds[cellIdx]); } +} +// 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++) { - UInt8Array& actives = dataStructure.getDataRefAs(caxis_segment_features_constants::k_ActivesArrayPath); - size_t numFeatures = actives.getNumberOfTuples(); - REQUIRE(numFeatures == 57); + INFO(fmt::format("feature index {}", featureIdx)); + REQUIRE(activeRef[featureIdx] == 1); } +} +} // namespace AnalyticalFixtures +} // namespace - // 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_FeatureIdsFacePath); +using namespace AnalyticalFixtures; + +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pure-Phi Chain, Face)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") +{ + UnitTest::LoadPlugins(); - UnitTest::CompareDataArrays(generatedDataArray, exemplarDataArray); + // 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++) + { + SetPhi(td, cellIdx, phiValues[cellIdx]); } - UnitTest::CheckArraysInheritTupleDims(dataStructure, SmallIn100::k_TupleCheckIgnoredPaths); + auto executeResult = RunFilter(td.ds, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeatureIds(td.ds, {1, 1, 1, 2, 2, 3, 3, 4}); + CheckActiveArray(td.ds, 4); + UnitTest::CheckArraysInheritTupleDims(td.ds); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeatures:All", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Pi-Fold Antiparallel C-Axes)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { - 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); - - // EBSD Segment Features/Semgent Features (Misorientation) Filter - { - CAxisSegmentFeaturesFilter filter; - Arguments args; + UnitTest::LoadPlugins(); - // 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)); + // 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); - 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)); + auto executeResult = RunFilter(td.ds, 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(td.ds, {1, 1, 2}); + CheckActiveArray(td.ds, 2); + UnitTest::CheckArraysInheritTupleDims(td.ds); +} - 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 1 Analytical (Neighbor Scheme Face vs All)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") +{ + UnitTest::LoadPlugins(); - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + // 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}, + }; - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + for(const auto& expectation : expectations) + { + 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); + } } +} + +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Mask Excludes Voxel 0)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") +{ + 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 + { + std::string label; + DataType maskType; + }; + const std::vector variants = { + {"bool mask", DataType::boolean}, + {"uint8 mask", DataType::uint8}, + }; + for(const auto& variant : variants) { - UInt8Array& actives = dataStructure.getDataRefAs(caxis_segment_features_constants::k_ActivesArrayPath); - size_t numFeatures = actives.getNumberOfTuples(); - REQUIRE(numFeatures == 37); + 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][Class1]") +{ + UnitTest::LoadPlugins(); + + // 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::CAxisSegmentFeaturesFilter: Class 1 Analytical (3D Linearization, 3x2x2)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") +{ + UnitTest::LoadPlugins(); - // Loop and compare each array from the 'Exemplar Data / CellData' to the 'Data Container / CellData' group + // 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++) { - const auto& generatedDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsArrayPath); - const auto& exemplarDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsAllPath); + 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][Class1]") +{ + UnitTest::LoadPlugins(); - UnitTest::CompareDataArrays(generatedDataArray, exemplarDataArray); + // 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; + // 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()); + 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++) + { + 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; - UnitTest::CheckArraysInheritTupleDims(dataStructure, SmallIn100::k_TupleCheckIgnoredPaths); + auto executeResult = RunFilter(dataStructure, BuildArgs(10.0f, segment_features::k_6NeighborIndex, false)); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + CheckFeatureIds(dataStructure, {1, 1, 2}); + CheckActiveArray(dataStructure, 2); + UnitTest::CheckArraysInheritTupleDims(dataStructure); } -TEST_CASE("OrientationAnalysis::CAxisSegmentFeatures:MaskFace", "[OrientationAnalysis][CAxisSegmentFeaturesFilter]") +TEST_CASE("OrientationAnalysis::CAxisSegmentFeaturesFilter: Class 1 Analytical (Quats Outside Cell AttributeMatrix)", "[OrientationAnalysis][CAxisSegmentFeaturesFilter][Class1]") { - 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 + // 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++) { - CAxisSegmentFeaturesFilter filter; - Arguments args; + const std::array quat = QuatFromPhiDeg(phiValues[cellIdx]); + for(usize comp = 0; comp < 4; comp++) + { + (*quatsArrayPtr)[cellIdx * 4 + comp] = quat[comp]; + } + (*phasesArrayPtr)[cellIdx] = 1; + } - // 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)); + 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"))); - 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(td.ds, args); + 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)); + // 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); +} - 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][Class4]") +{ + 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; + }; + + const std::vector firstRun = runOnce(); + + // 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); + + // 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][Class4]") +{ + 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++) { - UInt8Array& actives = dataStructure.getDataRefAs(caxis_segment_features_constants::k_ActivesArrayPath); - size_t numFeatures = actives.getNumberOfTuples(); - REQUIRE(numFeatures == 31); + SetPhi(td, cellIdx, phiValues[cellIdx]); } - - // Loop and compare each array from the 'Exemplar Data / CellData' to the 'Data Container / CellData' group + 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++) { - const auto& generatedDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsArrayPath); - const auto& exemplarDataArray = dataStructure.getDataRefAs(caxis_segment_features_constants::k_FeatureIdsMaskFacePath); - - UnitTest::CompareDataArrays(generatedDataArray, exemplarDataArray); + (*maskArrayPtr)[cellIdx] = (maskValues[cellIdx] != 0); } - UnitTest::CheckArraysInheritTupleDims(dataStructure, SmallIn100::k_TupleCheckIgnoredPaths); + 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::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 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; + 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]") +{ + 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; + const Arguments args = BuildManualPreflightArgs(DataPath({"DataContainer", "OrientationData", "Quats"}), DataPath({"DataContainer", "OrientationData", "Phases"})); + + 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]") @@ -287,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/test/EBSDSegmentFeaturesFilterTest.cpp b/src/Plugins/OrientationAnalysis/test/EBSDSegmentFeaturesFilterTest.cpp index 004a6edb15..5026971ede 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,195 @@ 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); +} + +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(); + + // 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/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md new file mode 100644 index 0000000000..c7bedc5ee2 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/CAxisSegmentFeaturesFilter.md @@ -0,0 +1,135 @@ +# 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 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, 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, 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). | + +## 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. +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). + +## 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)`, `(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. + +## Code path coverage + +*20 of 20 paths enumerated; 18 exercised.* + +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()`. + +| # | 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, 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` | +| 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` | +| 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, 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`, 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 + +| 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 (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). | +| `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 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` (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 + +- **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, 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` +- `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..b47c1dab5d --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/legacy_6_5_171.json @@ -0,0 +1,233 @@ +{ + "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" + }, + { + "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 + } + }, + "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": { + "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", + "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": 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 new file mode 100644 index 0000000000..8da2d22baa --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/make_input.py @@ -0,0 +1,84 @@ +#!/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 + 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 +{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 + +# 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 # EbsdLib CrystalStructure enum value for Hexagonal_High +UNKNOWN = 999 # EbsdLib CrystalStructure "unknown" sentinel (ensemble 0) + + +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, dims=None): + n = len(phis) + 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: + 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]) + 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}") + + +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..db8279ab4c --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/pipelines/nx.d3dpipeline @@ -0,0 +1,137 @@ +{ + "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": { + "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}, + "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"}, + "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..8b4893f1e9 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/comparisons/CAxisSegmentFeaturesFilter/results/comparison.md @@ -0,0 +1,65 @@ +# Legacy Comparison: CAxisSegmentFeaturesFilter + +Date: 2026-07-22 (TC1–TC4); TC5_3D added and full suite rerun 2026-07-24 + +## Test Cases + +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 | +|---|---|---|---|---| +| 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 | +| 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 + +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 + +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,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 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 (TC5_3D against an independently computed reference flood fill). + +## 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..85b4c73738 --- /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 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. + +**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..7976321a73 --- /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) 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`* | + +## 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/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp index 51805d1765..6999b53dd3 100644 --- a/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp +++ b/src/Plugins/SimplnxCore/src/SimplnxCore/Filters/Algorithms/ScalarSegmentFeatures.cpp @@ -210,11 +210,21 @@ 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) { - 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/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp b/src/Plugins/SimplnxCore/test/ScalarSegmentFeaturesTest.cpp index 00cb47718f..f5f5c69500 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,164 @@ 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); +} + +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(); + + // 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); +} diff --git a/src/simplnx/Utilities/SegmentFeatures.cpp b/src/simplnx/Utilities/SegmentFeatures.cpp index 037b7175b0..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}); } } } @@ -143,17 +198,28 @@ 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; - int64 seed = 0; // Always use the very first value of the array that we are using to segment + // 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. + 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) @@ -170,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++; } @@ -209,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. @@ -217,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 {}; }