From a33f67a5c61ba4086b6bf99d786abb70b571a8eb Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Thu, 9 Jul 2026 12:57:40 -0400 Subject: [PATCH] VV: Compute IPF Colors fully V&V'ed Summary: - Confirmed no filter bugs (clean Port); fixed 2 algorithm-review warnings (removed dead GetAllOrientationOps() allocation; phase-warning counter is now std::atomic_int32_t, fixing a data race in the ParallelDataAlgorithm worker); - documented 1 deviation from DREAM3D 6.5.171 (D1 precision+library: +/-1/255 TSL quantization jitter on 14/343963 cells; byte-identical to the stored legacy reference); - retired 1 test (circular oracle that compared filter output against the legacy-produced IPFColors array inside so3_cubic_high_ipf_001.dream3d); - unit tests replaced with inlined Class 1 (Analytical) + Class 4 (Invariant) fixtures plus Class 2 (EbsdLib reference) and Class 3 (standard IPF corner) cross-checks (8 test cases, pass in-core and OOC); - added V&V source-tree deliverables (report + deviations; no provenance sidecar since the oracle dataset is inline with no archive); - renamed GoodVoxels->Mask in the InputValues struct and algorithm internals to match sibling filters, and documented the new Color Key schemes (TSL/PUCM/Nolze-Hielscher) in the user doc. Co-Authored-By: Claude Opus 4.8 --- .../docs/ComputeIPFColorsFilter.md | 12 + .../Filters/Algorithms/ComputeIPFColors.cpp | 31 +- .../Filters/Algorithms/ComputeIPFColors.hpp | 8 +- .../Filters/ComputeIPFColorsFilter.cpp | 12 +- .../test/ComputeIPFColorsTest.cpp | 456 ++++++++++++------ .../vv/ComputeIPFColorsFilter.md | 116 +++++ .../vv/deviations/ComputeIPFColorsFilter.md | 29 ++ 7 files changed, 501 insertions(+), 163 deletions(-) create mode 100644 src/Plugins/OrientationAnalysis/vv/ComputeIPFColorsFilter.md create mode 100644 src/Plugins/OrientationAnalysis/vv/deviations/ComputeIPFColorsFilter.md diff --git a/src/Plugins/OrientationAnalysis/docs/ComputeIPFColorsFilter.md b/src/Plugins/OrientationAnalysis/docs/ComputeIPFColorsFilter.md index 9cbe5109a5..3fd8ffcd74 100644 --- a/src/Plugins/OrientationAnalysis/docs/ComputeIPFColorsFilter.md +++ b/src/Plugins/OrientationAnalysis/docs/ComputeIPFColorsFilter.md @@ -8,6 +8,18 @@ Processing (Crystallography) This **Filter** will generate *inverse pole figure* (IPF) colors. The user can enter the *Reference Direction*, which defaults to [001]. The **Filter** also has the option to apply a black color to all "bad" **Elements**, as defined by a boolean *mask* array, which can be generated using the Threshold Objects **Filter** or any other filter that generates a "mask" of the data and outputs either a bool or uint8 array. +The IPF color for each **Element** is computed by [EbsdLib](https://github.com/BlueQuartzSoftware/EbsdLib) from the **Element**'s Euler angles, the reference direction, and the crystal structure (Laue class) of its phase. **Elements** whose phase has an unknown/unsupported crystal structure, and **Elements** masked as "bad", are colored black (0, 0, 0). + +### Color Key + +The *Color Key* parameter selects which IPF coloring scheme is used: + ++ **TSL** — legacy primary-corner standard-stereographic-triangle coloring (EDAX/OIM Analysis default). This is the scheme produced by DREAM3D 6.x and is the default. ++ **PUCM** — perceptually-uniform color map (Patala / MTEX-style). ++ **Nolze-Hielscher** — MTEX HSV-style coloring. + +The default (TSL) reproduces the coloring of earlier DREAM3D versions; PUCM and Nolze-Hielscher are new schemes with no DREAM3D 6.x equivalent. + ### Originating Data Notes + TSL (.ang file) diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.cpp index 8a473d5aa8..42b0492e74 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.cpp @@ -22,14 +22,14 @@ class ComputeIPFColorsImpl { public: ComputeIPFColorsImpl(ComputeIPFColors* filter, nx::core::FloatVec3 referenceDir, nx::core::Float32Array& eulers, nx::core::Int32Array& phases, nx::core::UInt32Array& crystalStructures, - int32_t numPhases, const nx::core::IDataArray* goodVoxels, nx::core::UInt8Array& colors, ebsdlib::ColorKeyKind colorKey) + int32_t numPhases, const nx::core::IDataArray* maskArray, nx::core::UInt8Array& colors, ebsdlib::ColorKeyKind colorKey) : m_Filter(filter) , m_ReferenceDir(referenceDir) , m_CellEulerAngles(eulers.getDataStoreRef()) , m_CellPhases(phases.getDataStoreRef()) , m_CrystalStructures(crystalStructures.getDataStoreRef()) , m_NumPhases(numPhases) - , m_GoodVoxels(goodVoxels) + , m_MaskArray(maskArray) , m_CellIPFColors(colors.getDataStoreRef()) , m_ColorKey(colorKey) { @@ -42,9 +42,9 @@ class ComputeIPFColorsImpl { using MaskArrayType = DataArray; const MaskArrayType* maskArray = nullptr; - if(nullptr != m_GoodVoxels) + if(nullptr != m_MaskArray) { - maskArray = dynamic_cast(m_GoodVoxels); + maskArray = dynamic_cast(m_MaskArray); } std::vector ops = ebsdlib::LaueOps::GetAllOrientationOps(); @@ -93,13 +93,13 @@ class ComputeIPFColorsImpl void run(size_t start, size_t end) const { - if(m_GoodVoxels != nullptr) + if(m_MaskArray != nullptr) { - if(m_GoodVoxels->getDataType() == DataType::boolean) + if(m_MaskArray->getDataType() == DataType::boolean) { convert(start, end); } - else if(m_GoodVoxels->getDataType() == DataType::uint8) + else if(m_MaskArray->getDataType() == DataType::uint8) { convert(start, end); } @@ -122,7 +122,7 @@ class ComputeIPFColorsImpl nx::core::Int32AbstractDataStore& m_CellPhases; nx::core::UInt32AbstractDataStore& m_CrystalStructures; int32_t m_NumPhases = 0; - const nx::core::IDataArray* m_GoodVoxels = nullptr; + const nx::core::IDataArray* m_MaskArray = nullptr; nx::core::UInt8AbstractDataStore& m_CellIPFColors; ebsdlib::ColorKeyKind m_ColorKey = ebsdlib::ColorKeyKind::TSL; }; @@ -143,9 +143,6 @@ ComputeIPFColors::~ComputeIPFColors() noexcept = default; // ----------------------------------------------------------------------------- Result<> ComputeIPFColors::operator()() { - - std::vector orientationOps = ebsdlib::LaueOps::GetAllOrientationOps(); - nx::core::Float32Array& eulers = m_DataStructure.getDataRefAs(m_InputValues->cellEulerAnglesArrayPath); nx::core::Int32Array& phases = m_DataStructure.getDataRefAs(m_InputValues->cellPhasesArrayPath); @@ -168,11 +165,11 @@ Result<> ComputeIPFColors::operator()() algArrays.push_back(&crystalStructures); algArrays.push_back(&ipfColors); - nx::core::IDataArray* goodVoxelsArray = nullptr; - if(m_InputValues->useGoodVoxels) + nx::core::IDataArray* maskArray = nullptr; + if(m_InputValues->useMask) { - goodVoxelsArray = m_DataStructure.getDataAs(m_InputValues->goodVoxelsArrayPath); - algArrays.push_back(goodVoxelsArray); + maskArray = m_DataStructure.getDataAs(m_InputValues->maskArrayPath); + algArrays.push_back(maskArray); } // Allow data-based parallelization @@ -180,13 +177,13 @@ Result<> ComputeIPFColors::operator()() dataAlg.setRange(0, totalPoints); dataAlg.requireArraysInMemory(algArrays); - dataAlg.execute(ComputeIPFColorsImpl(this, normRefDir, eulers, phases, crystalStructures, numPhases, goodVoxelsArray, ipfColors, m_InputValues->colorKey)); + dataAlg.execute(ComputeIPFColorsImpl(this, normRefDir, eulers, phases, crystalStructures, numPhases, maskArray, ipfColors, m_InputValues->colorKey)); if(m_PhaseWarningCount > 0) { std::string message = fmt::format("The Ensemble Phase information only references {} phase(s) but {} cell(s) had a phase value greater than {}. \ This indicates a problem with the input cell phase data. DREAM3D-NX will give INCORRECT RESULTS.", - (numPhases - 1), m_PhaseWarningCount, (numPhases - 1)); + (numPhases - 1), m_PhaseWarningCount.load(), (numPhases - 1)); return nx::core::MakeErrorResult(-48000, message); } diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.hpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.hpp index 0c854a54cc..d17fad385c 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.hpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.hpp @@ -10,6 +10,7 @@ #include +#include #include namespace nx::core @@ -21,8 +22,8 @@ namespace nx::core struct ORIENTATIONANALYSIS_EXPORT ComputeIPFColorsInputValues { std::vector referenceDirection; - bool useGoodVoxels; - DataPath goodVoxelsArrayPath; + bool useMask; + DataPath maskArrayPath; DataPath cellPhasesArrayPath; DataPath cellEulerAnglesArrayPath; DataPath crystalStructuresArrayPath; @@ -59,7 +60,8 @@ class ORIENTATIONANALYSIS_EXPORT ComputeIPFColors const std::atomic_bool& m_ShouldCancel; const ComputeIPFColorsInputValues* m_InputValues = nullptr; - int32_t m_PhaseWarningCount = 0; + // Written concurrently from the ParallelDataAlgorithm worker, so it must be atomic. + std::atomic_int32_t m_PhaseWarningCount = 0; }; } // namespace nx::core diff --git a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeIPFColorsFilter.cpp b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeIPFColorsFilter.cpp index 61dd3c716a..6345a4e62b 100644 --- a/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeIPFColorsFilter.cpp +++ b/src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/ComputeIPFColorsFilter.cpp @@ -105,10 +105,10 @@ IFilter::PreflightResult ComputeIPFColorsFilter::preflightImpl(const DataStructu { auto pReferenceDirValue = filterArgs.value(k_ReferenceDir_Key); - auto pUseGoodVoxelsValue = filterArgs.value(k_UseMask_Key); + auto pUseMaskValue = filterArgs.value(k_UseMask_Key); auto pCellEulerAnglesArrayPathValue = filterArgs.value(k_CellEulerAnglesArrayPath_Key); auto pCellPhasesArrayPathValue = filterArgs.value(k_CellPhasesArrayPath_Key); - auto pGoodVoxelsArrayPathValue = filterArgs.value(k_MaskArrayPath_Key); + auto pMaskArrayPathValue = filterArgs.value(k_MaskArrayPath_Key); auto pCrystalStructuresArrayPathValue = filterArgs.value(k_CrystalStructuresArrayPath_Key); auto pCellIPFColorsArrayNameValue = pCellEulerAnglesArrayPathValue.replaceName(filterArgs.value(k_CellIPFColorsArrayName_Key)); @@ -117,9 +117,9 @@ IFilter::PreflightResult ComputeIPFColorsFilter::preflightImpl(const DataStructu dataPaths.push_back(pCellEulerAnglesArrayPathValue); dataPaths.push_back(pCellPhasesArrayPathValue); - if(pUseGoodVoxelsValue) + if(pUseMaskValue) { - dataPaths.push_back(pGoodVoxelsArrayPathValue); + dataPaths.push_back(pMaskArrayPathValue); } auto tupleValidityCheck = dataStructure.validateNumberOfTuples(dataPaths); @@ -147,10 +147,10 @@ Result<> ComputeIPFColorsFilter::executeImpl(DataStructure& dataStructure, const ComputeIPFColorsInputValues inputValues; inputValues.referenceDirection = filterArgs.value(k_ReferenceDir_Key); - inputValues.useGoodVoxels = filterArgs.value(k_UseMask_Key); + inputValues.useMask = filterArgs.value(k_UseMask_Key); inputValues.cellEulerAnglesArrayPath = filterArgs.value(k_CellEulerAnglesArrayPath_Key); inputValues.cellPhasesArrayPath = filterArgs.value(k_CellPhasesArrayPath_Key); - inputValues.goodVoxelsArrayPath = filterArgs.value(k_MaskArrayPath_Key); + inputValues.maskArrayPath = filterArgs.value(k_MaskArrayPath_Key); inputValues.crystalStructuresArrayPath = filterArgs.value(k_CrystalStructuresArrayPath_Key); inputValues.cellIpfColorsArrayPath = inputValues.cellEulerAnglesArrayPath.replaceName(filterArgs.value(k_CellIPFColorsArrayName_Key)); diff --git a/src/Plugins/OrientationAnalysis/test/ComputeIPFColorsTest.cpp b/src/Plugins/OrientationAnalysis/test/ComputeIPFColorsTest.cpp index 2f2ef88772..a0412bf7c3 100644 --- a/src/Plugins/OrientationAnalysis/test/ComputeIPFColorsTest.cpp +++ b/src/Plugins/OrientationAnalysis/test/ComputeIPFColorsTest.cpp @@ -1,189 +1,372 @@ -/* -# Test Plan +/** + * @file ComputeIPFColorsTest.cpp + * + * V&V oracle for ComputeIPFColorsFilter. + * + * ComputeIPFColors is a thin orchestration layer on top of EbsdLib's + * LaueOps::generateIPFColor(). The per-Laue-class correctness of the actual + * color math (TSL / PUCM / Nolze-Hielscher) is verified upstream by EbsdLib's + * own suite (TSLColorKeyTest, PUCMColorKeyTest, NolzeHielscherColorKeyTest, + * ColorKeyKindTest). This file therefore V&Vs only the SIMPLNX *value-add* -- + * the per-cell dispatch, indexing, masking, reference-direction normalization, + * phase bounds handling, and output packing -- NOT the color algorithm itself. + * + * Oracle design (see src/Plugins/OrientationAnalysis/vv/ComputeIPFColorsFilter.md): + * Class 1 (Analytical) : masked cell -> (0,0,0); invalid crystal structure -> + * (0,0,0); reference-direction normalization; phase out + * of range -> error -48000. + * Class 2 (Reference) : colored cell == a direct in-process EbsdLib + * generateIPFColor() call on the same inputs. Verifies + * the filter routes data correctly, without re-testing + * EbsdLib's color math. + * Class 3 (Paper/std) : identity-orientation cubic cell viewed down [001] + * is the red corner of the standard IPF triangle + * (per EbsdLib TSLColorKeyTest: [001] -> r=1,g=0,b=0). + * Class 4 (Invariant) : output is 3-component uint8; non-colored cells are + * exactly (0,0,0); colored cells are non-black. + * + * The dataset is built entirely in-line (no exemplar archive) so no golden file + * captured from legacy DREAM3D or a prior SIMPLNX run can act as a circular + * oracle. The retired archive-based comparison used so3_cubic_high_ipf_001.dream3d + * whose IPFColors array was produced by legacy SIMPL/DREAM3D itself. + */ -Input Files: -DREAM3D_Data/TestFiles/ASCIIData/EulerAngles.csv -DREAM3D_Data/TestFiles/ASCIIData/Phases.csv - -Output DataArrays: -IPFColors (3 component UInt8 Array) - -Comparison Files: -DREAM3D_Data/TestFiles/ASCIIData/IPFColor.csv - -You will need to create a UInt32 DataArray with 2 values in it: [ 999, 1 ]. This will -be the input 'k_CrystalStructuresArrayPath_Key' path and data. - - -Compare the data sets. The values should be exactly the same. - -*/ #include "OrientationAnalysis/Filters/ComputeIPFColorsFilter.hpp" #include "OrientationAnalysis/OrientationAnalysis_test_dirs.hpp" +#include "simplnx/Common/RgbColor.hpp" #include "simplnx/Core/Application.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/ChoicesParameter.hpp" #include "simplnx/Parameters/VectorParameter.hpp" #include "simplnx/Pipeline/Pipeline.hpp" #include "simplnx/Pipeline/PipelineFilter.hpp" #include "simplnx/UnitTest/UnitTestCommon.hpp" -#include "simplnx/Utilities/Parsing/DREAM3D/Dream3dIO.hpp" -#include "simplnx/Utilities/Parsing/HDF5/IO/FileIO.hpp" #include -#include -#include -#include -#include +#include +#include -using namespace ebsdlib; - -#include -#include +#include #include -#include namespace fs = std::filesystem; using namespace nx::core; using namespace nx::core::UnitTest; -using namespace nx::core::Constants; -namespace nx::core::Constants +namespace { -constexpr StringLiteral k_ImageDataContainer("ImageDataContainer"); -constexpr StringLiteral k_OutputIPFColors("IPF Colors_Test_Output"); -} // namespace nx::core::Constants - -TEST_CASE("OrientationAnalysis::ComputeIPFColors", "[OrientationAnalysis][ComputeIPFColorsFilter]") +namespace CS = ebsdlib::CrystalStructure; + +//------------------------------------------------------------------------------ +// Inline analytical dataset. 6 cells, chosen so that each code path in +// ComputeIPFColors::convert() is exercised. Crystal-structure ensemble: +// ensemble 0 -> UnknownCrystalStructure (999) (the conventional DREAM3D +// "invalid phase" placeholder; >= LaueGroupEnd, so never colored) +// ensemble 1 -> Cubic_High (1) +// ensemble 2 -> Hexagonal_High (0) +// +// cell phase crystalStruct euler (rad) mask role +// 0 1 Cubic_High (0,0,0) true Class 3 identity -> red corner +// 1 1 Cubic_High (0.3,0.4,0.5) true Class 2 arbitrary cubic +// 2 2 Hexagonal_High (0.1,0.2,0.3) true Class 2 arbitrary hex (2nd Laue class) +// 3 1 Cubic_High (0.3,0.4,0.5) false Class 1 masked -> black +// 4 0 Unknown (999) (0.3,0.4,0.5) true Class 1 invalid crystal structure -> black +// 5 2 Hexagonal_High (0.7,0.8,0.9) true Class 2 arbitrary hex +//------------------------------------------------------------------------------ +constexpr usize k_NumCells = 6; +constexpr usize k_NumEnsembles = 3; + +const DataPath k_GeomPath({"DataContainer"}); +const DataPath k_CellDataPath = k_GeomPath.createChildPath("CellData"); +const DataPath k_EulersPath = k_CellDataPath.createChildPath("EulerAngles"); +const DataPath k_PhasesPath = k_CellDataPath.createChildPath("Phases"); +const DataPath k_MaskPath = k_CellDataPath.createChildPath("Mask"); +const DataPath k_MaskU8Path = k_CellDataPath.createChildPath("MaskU8"); +const DataPath k_CrystalStructuresPath = k_GeomPath.createChildPath("CellEnsembleData").createChildPath("CrystalStructures"); +const std::string k_IpfColorsName = "IPFColors"; +const DataPath k_IpfColorsPath = k_CellDataPath.createChildPath(k_IpfColorsName); + +// Cells that must receive a real (non-black) IPF color when the mask is on. +constexpr std::array k_ColoredCells = {0, 1, 2, 5}; +constexpr usize k_MaskedCell = 3; // masked "bad" -> black +constexpr usize k_InvalidCsCell = 4; // crystal structure 999 -> black + +DataStructure BuildAnalyticalDataset() { + DataStructure dataStructure; + auto* imageGeom = ImageGeom::Create(dataStructure, "DataContainer"); + imageGeom->setDimensions({k_NumCells, 1, 1}); + + auto* cellAM = AttributeMatrix::Create(dataStructure, "CellData", {k_NumCells}, imageGeom->getId()); + auto* eulersArray = CreateTestDataArray(dataStructure, "EulerAngles", {k_NumCells}, {3}, cellAM->getId()); + auto* phasesArray = CreateTestDataArray(dataStructure, "Phases", {k_NumCells}, {1}, cellAM->getId()); + auto* maskArray = CreateTestDataArray(dataStructure, "Mask", {k_NumCells}, {1}, cellAM->getId()); + auto* maskU8Array = CreateTestDataArray(dataStructure, "MaskU8", {k_NumCells}, {1}, cellAM->getId()); + + auto* ensembleAM = AttributeMatrix::Create(dataStructure, "CellEnsembleData", {k_NumEnsembles}, imageGeom->getId()); + auto* crystalStructuresArray = CreateTestDataArray(dataStructure, "CrystalStructures", {k_NumEnsembles}, {1}, ensembleAM->getId()); + + auto& csStore = crystalStructuresArray->getDataStoreRef(); + csStore[0] = CS::UnknownCrystalStructure; // 999 + csStore[1] = CS::Cubic_High; // 1 + csStore[2] = CS::Hexagonal_High; // 0 + + auto& eulerStore = eulersArray->getDataStoreRef(); + auto setEuler = [&](usize cell, float32 p1, float32 p, float32 p2) { + eulerStore[cell * 3] = p1; + eulerStore[cell * 3 + 1] = p; + eulerStore[cell * 3 + 2] = p2; + }; + setEuler(0, 0.0F, 0.0F, 0.0F); + setEuler(1, 0.3F, 0.4F, 0.5F); + setEuler(2, 0.1F, 0.2F, 0.3F); + setEuler(3, 0.3F, 0.4F, 0.5F); + setEuler(4, 0.3F, 0.4F, 0.5F); + setEuler(5, 0.7F, 0.8F, 0.9F); + + auto& phaseStore = phasesArray->getDataStoreRef(); + const std::array phases = {1, 1, 2, 1, 0, 2}; + for(usize i = 0; i < k_NumCells; i++) + { + phaseStore[i] = phases[i]; + } + auto& maskStore = maskArray->getDataStoreRef(); + auto& maskU8Store = maskU8Array->getDataStoreRef(); + for(usize i = 0; i < k_NumCells; i++) { - HomochoricDType ho(0.021797740480252403, 0.027063934475102136, 0.035554288118377242); - std::cout << ho << std::endl; - RodriguesDType rod = ho.toRodrigues(); - std::cout << rod << std::endl; - OrthoRhombicOps ops; - rod = ops.getODFFZRod(rod); - std::cout << rod << "\n"; - EulerDType eu = rod.toEuler(); - std::cout << eu << std::endl; + const bool good = (i != k_MaskedCell); + maskStore[i] = good; + maskU8Store[i] = good ? 1 : 0; } + return dataStructure; +} + +// Build the base filter arguments for the analytical dataset. refDir defaults to +// [0,0,1] (already unit length so the reference call needs no separate normalize). +Arguments MakeArgs(bool useMask, const DataPath& maskPath, const std::vector& refDir, ChoicesParameter::ValueType colorKey, const std::string& outputName) +{ + Arguments args; + args.insertOrAssign(ComputeIPFColorsFilter::k_ReferenceDir_Key, std::make_any(refDir)); + args.insertOrAssign(ComputeIPFColorsFilter::k_ColorKey_Key, std::make_any(colorKey)); + args.insertOrAssign(ComputeIPFColorsFilter::k_UseMask_Key, std::make_any(useMask)); + args.insertOrAssign(ComputeIPFColorsFilter::k_MaskArrayPath_Key, std::make_any(maskPath)); + args.insertOrAssign(ComputeIPFColorsFilter::k_CellEulerAnglesArrayPath_Key, std::make_any(k_EulersPath)); + args.insertOrAssign(ComputeIPFColorsFilter::k_CellPhasesArrayPath_Key, std::make_any(k_PhasesPath)); + args.insertOrAssign(ComputeIPFColorsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(k_CrystalStructuresPath)); + args.insertOrAssign(ComputeIPFColorsFilter::k_CellIPFColorsArrayName_Key, std::make_any(outputName)); + return args; +} + +// The Class 2 reference: a direct, independent EbsdLib call reproducing what the +// filter should compute for one colored cell. This is EbsdLib the trusted +// reference implementation (Class 2), not the filter's own output. +std::array EbsdLibReferenceColor(const std::array& euler, uint32 crystalStruct, const std::array& refDir, ebsdlib::ColorKeyKind kind) +{ + std::vector ops = ebsdlib::LaueOps::GetAllOrientationOps(); + std::array dEuler = euler; + std::array dRefDir = refDir; + ebsdlib::Rgb argb = ops[crystalStruct]->generateIPFColor(dEuler.data(), dRefDir.data(), false, kind); + return {static_cast(RgbColor::dRed(argb)), static_cast(RgbColor::dGreen(argb)), static_cast(RgbColor::dBlue(argb))}; +} +} // namespace + +TEST_CASE("OrientationAnalysis::ComputeIPFColorsFilter: Class 1/2/3 Oracle (inline analytical dataset)", "[OrientationAnalysis][ComputeIPFColorsFilter]") +{ UnitTest::LoadPlugins(); - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "so3_cubic_high_ipf_001.tar.gz", "so3_cubic_high_ipf_001.dream3d"); + DataStructure dataStructure = BuildAnalyticalDataset(); - DataStructure dataStructure; - { + ComputeIPFColorsFilter filter; + const Arguments args = MakeArgs(true, k_MaskPath, {0.0F, 0.0F, 1.0F}, 0 /*TSL*/, k_IpfColorsName); - // This test file was produced by SIMPL/DREAM3D. our results should match theirs - auto exemplarFilePath = fs::path(fmt::format("{}/so3_cubic_high_ipf_001.dream3d", unit_test::k_TestFilesDir)); - REQUIRE(fs::exists(exemplarFilePath)); - auto result = DREAM3D::ImportDataStructureFromFile(exemplarFilePath, false); - REQUIRE(result.valid()); - dataStructure = result.value(); + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + REQUIRE_NOTHROW(dataStructure.getDataRefAs(k_IpfColorsPath)); + const auto& colors = dataStructure.getDataRefAs(k_IpfColorsPath); + // Class 4: output is 3-component uint8 with the parent tuple count. + REQUIRE(colors.getNumberOfTuples() == k_NumCells); + REQUIRE(colors.getNumberOfComponents() == 3); + + // Pull the inputs back out so the reference call is driven by the same data the filter saw. + const auto& eulerStore = dataStructure.getDataRefAs(k_EulersPath).getDataStoreRef(); + const auto& phaseStore = dataStructure.getDataRefAs(k_PhasesPath).getDataStoreRef(); + const auto& csStore = dataStructure.getDataRefAs(k_CrystalStructuresPath).getDataStoreRef(); + const auto& colorStore = colors.getDataStoreRef(); + + auto colorAt = [&](usize cell) { return std::array{colorStore[cell * 3], colorStore[cell * 3 + 1], colorStore[cell * 3 + 2]}; }; + const std::array refDir = {0.0, 0.0, 1.0}; + + SECTION("Class 3 - identity cubic orientation viewed down [001] is the red IPF corner") + { + // Per EbsdLib TSLColorKeyTest: the [001] direction maps to r=1,g=0,b=0. + const std::array c0 = colorAt(0); + REQUIRE(c0[0] >= 250); // red channel saturated (allow rounding at the exact corner) + REQUIRE(c0[1] == 0); + REQUIRE(c0[2] == 0); } - // Instantiate the filter, a DataStructure object and an Arguments Object + SECTION("Class 2 - each colored cell equals a direct EbsdLib generateIPFColor call") { - ComputeIPFColorsFilter filter; - Arguments args; - - DataPath cellEulerAnglesPath({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_EulerAngles}); - DataPath cellPhasesArrayPath({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_Phases}); - DataPath goodVoxelsPath({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_Mask}); - DataPath crystalStructuresArrayPath({Constants::k_ImageDataContainer, Constants::k_CellEnsembleData, Constants::k_CrystalStructures}); - DataPath cellIPFColorsArrayName({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_OutputIPFColors}); - - // Create default Parameters for the filter. - args.insertOrAssign(ComputeIPFColorsFilter::k_ReferenceDir_Key, std::make_any({0.0F, 0.0F, 1.0F})); - args.insertOrAssign(ComputeIPFColorsFilter::k_UseMask_Key, std::make_any(true)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CellEulerAnglesArrayPath_Key, std::make_any(cellEulerAnglesPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CellPhasesArrayPath_Key, std::make_any(cellPhasesArrayPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_MaskArrayPath_Key, std::make_any(goodVoxelsPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(crystalStructuresArrayPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CellIPFColorsArrayName_Key, std::make_any(Constants::k_OutputIPFColors)); - - REQUIRE(dataStructure.getData(goodVoxelsPath) != nullptr); - REQUIRE(dataStructure.getData(cellEulerAnglesPath) != nullptr); - REQUIRE(dataStructure.getData(cellPhasesArrayPath) != nullptr); - - // Preflight the filter and check result - auto preflightResult = filter.preflight(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); - - // Execute the filter and check the result - auto executeResult = filter.execute(dataStructure, args); - SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + for(usize cell : k_ColoredCells) { - // Write out the DataStructure for later viewing/debugging - auto fileWriter = nx::core::HDF5::FileIO::WriteFile(std::filesystem::path(fmt::format("{}/ComputeIPFColors_Test.dream3d", unit_test::k_BinaryTestOutputDir))); - auto resultH5 = HDF5::DataStructureWriter::WriteFile(dataStructure, fileWriter); - SIMPLNX_RESULT_REQUIRE_VALID(resultH5); + const std::array euler = {eulerStore[cell * 3], eulerStore[cell * 3 + 1], eulerStore[cell * 3 + 2]}; + const uint32 crystalStruct = csStore[phaseStore[cell]]; + const std::array expected = EbsdLibReferenceColor(euler, crystalStruct, refDir, ebsdlib::ColorKeyKind::TSL); + const std::array actual = colorAt(cell); + INFO("cell " << cell); + REQUIRE(actual == expected); + // Class 4: a colored cell must not be black. + REQUIRE((actual[0] != 0 || actual[1] != 0 || actual[2] != 0)); } + } - DataPath ipfColors({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_Ipf_Colors}); - - UInt8Array& exemplar = dataStructure.getDataRefAs(ipfColors); - UInt8Array& output = dataStructure.getDataRefAs(cellIPFColorsArrayName); + SECTION("Class 1 - masked-off cell is black") + { + REQUIRE(colorAt(k_MaskedCell) == std::array{0, 0, 0}); + } - size_t totalElements = exemplar.getSize(); - bool valid = true; - for(size_t i = 0; i < totalElements; i++) - { - if(exemplar[i] != output[i]) - { - valid = false; - break; - } - } - REQUIRE(valid == true); + SECTION("Class 1 - cell whose crystal structure is >= LaueGroupEnd is black") + { + REQUIRE(colorAt(k_InvalidCsCell) == std::array{0, 0, 0}); } UnitTest::CheckArraysInheritTupleDims(dataStructure); } +TEST_CASE("OrientationAnalysis::ComputeIPFColorsFilter: uint8 mask array drives the black-out path", "[OrientationAnalysis][ComputeIPFColorsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = BuildAnalyticalDataset(); + + ComputeIPFColorsFilter filter; + // Same as the main oracle but the mask is a uint8 array -> exercises convert. + const Arguments args = MakeArgs(true, k_MaskU8Path, {0.0F, 0.0F, 1.0F}, 0 /*TSL*/, k_IpfColorsName); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& colorStore = dataStructure.getDataRefAs(k_IpfColorsPath).getDataStoreRef(); + auto colorAt = [&](usize cell) { return std::array{colorStore[cell * 3], colorStore[cell * 3 + 1], colorStore[cell * 3 + 2]}; }; + + // The uint8-masked "bad" cell is black; a good cell is colored. + REQUIRE(colorAt(k_MaskedCell) == std::array{0, 0, 0}); + const std::array c0 = colorAt(0); + REQUIRE((c0[0] != 0 || c0[1] != 0 || c0[2] != 0)); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("OrientationAnalysis::ComputeIPFColorsFilter: no-mask path colors every valid cell", "[OrientationAnalysis][ComputeIPFColorsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = BuildAnalyticalDataset(); + + ComputeIPFColorsFilter filter; + // useMask == false -> the algorithm's goodVoxels pointer is null (convert + // with a null mask). Cell 3, which was masked-black in the main oracle, must now + // be colored; cell 4 (invalid crystal structure) is still black. + const Arguments args = MakeArgs(false, DataPath{}, {0.0F, 0.0F, 1.0F}, 0 /*TSL*/, k_IpfColorsName); + + auto preflightResult = filter.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = filter.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + + const auto& eulerStore = dataStructure.getDataRefAs(k_EulersPath).getDataStoreRef(); + const auto& phaseStore = dataStructure.getDataRefAs(k_PhasesPath).getDataStoreRef(); + const auto& csStore = dataStructure.getDataRefAs(k_CrystalStructuresPath).getDataStoreRef(); + const auto& colorStore = dataStructure.getDataRefAs(k_IpfColorsPath).getDataStoreRef(); + auto colorAt = [&](usize cell) { return std::array{colorStore[cell * 3], colorStore[cell * 3 + 1], colorStore[cell * 3 + 2]}; }; + + // Cell 3 is now colored and matches the EbsdLib reference (mask no longer suppresses it). + const std::array euler3 = {eulerStore[k_MaskedCell * 3], eulerStore[k_MaskedCell * 3 + 1], eulerStore[k_MaskedCell * 3 + 2]}; + const std::array expected3 = EbsdLibReferenceColor(euler3, csStore[phaseStore[k_MaskedCell]], {0.0, 0.0, 1.0}, ebsdlib::ColorKeyKind::TSL); + REQUIRE(colorAt(k_MaskedCell) == expected3); + REQUIRE(colorAt(k_InvalidCsCell) == std::array{0, 0, 0}); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("OrientationAnalysis::ComputeIPFColorsFilter: reference direction is normalized", "[OrientationAnalysis][ComputeIPFColorsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = BuildAnalyticalDataset(); + + ComputeIPFColorsFilter filter; + // A non-unit reference direction [0,0,5] must produce exactly the same colors as + // the unit direction [0,0,1] because the algorithm normalizes it first. + auto runWith = [&](const std::vector& refDir, const std::string& outputName) { + ComputeIPFColorsFilter f; + const Arguments args = MakeArgs(true, k_MaskPath, refDir, 0 /*TSL*/, outputName); + auto preflightResult = f.preflight(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); + auto executeResult = f.execute(dataStructure, args); + SIMPLNX_RESULT_REQUIRE_VALID(executeResult.result); + }; + + runWith({0.0F, 0.0F, 1.0F}, "IPF_Unit"); + runWith({0.0F, 0.0F, 5.0F}, "IPF_NonUnit"); + + const auto& unitColors = dataStructure.getDataRefAs(k_CellDataPath.createChildPath("IPF_Unit")); + const auto& nonUnitColors = dataStructure.getDataRefAs(k_CellDataPath.createChildPath("IPF_NonUnit")); + REQUIRE(unitColors.getSize() == nonUnitColors.getSize()); + REQUIRE(std::equal(unitColors.cbegin(), unitColors.cend(), nonUnitColors.cbegin())); + + UnitTest::CheckArraysInheritTupleDims(dataStructure); +} + +TEST_CASE("OrientationAnalysis::ComputeIPFColorsFilter: phase index out of range returns -48000", "[OrientationAnalysis][ComputeIPFColorsFilter]") +{ + UnitTest::LoadPlugins(); + + DataStructure dataStructure = BuildAnalyticalDataset(); + + // Corrupt one cell's phase to reference an ensemble index that does not exist + // (numEnsembles == 3, so phase 5 is out of range). The per-cell loop increments + // the phase-warning count and the algorithm returns error -48000. + auto& phaseStore = dataStructure.getDataRefAs(k_PhasesPath).getDataStoreRef(); + phaseStore[0] = 5; + + ComputeIPFColorsFilter filter; + const Arguments args = MakeArgs(false, DataPath{}, {0.0F, 0.0F, 1.0F}, 0 /*TSL*/, k_IpfColorsName); + + 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 == -48000); +} + // ----------------------------------------------------------------------------- // Plumbing test: the k_ColorKey_Key choice index must route through executeImpl's -// switch into the right `ebsdlib::ColorKeyKind` and reach generateIPFColor. The +// switch into the right ebsdlib::ColorKeyKind and reach generateIPFColor. The // per-Laue-class correctness of TSL / PUCM / Nolze-Hielscher is covered by -// EbsdLib's ColorKeyKindTest; here we only assert that the simplnx side wiring -// is intact -- non-default choices must produce a different output array than -// the default (TSL) run on the same input data. +// EbsdLib's color-key tests; here we only assert that the simplnx side wiring is +// intact -- non-default choices must produce a different output array than the +// default (TSL) run on the same input data. TEST_CASE("OrientationAnalysis::ComputeIPFColorsFilter: ColorKey choice reaches algorithm", "[OrientationAnalysis][ComputeIPFColorsFilter]") { UnitTest::LoadPlugins(); - const nx::core::UnitTest::TestFileSentinel testDataSentinel(nx::core::unit_test::k_TestFilesDir, "so3_cubic_high_ipf_001.tar.gz", "so3_cubic_high_ipf_001.dream3d"); - - auto exemplarFilePath = fs::path(fmt::format("{}/so3_cubic_high_ipf_001.dream3d", unit_test::k_TestFilesDir)); - REQUIRE(fs::exists(exemplarFilePath)); - auto importResult = DREAM3D::ImportDataStructureFromFile(exemplarFilePath, false); - REQUIRE(importResult.valid()); - DataStructure dataStructure = importResult.value(); + DataStructure dataStructure = BuildAnalyticalDataset(); - const DataPath cellEulerAnglesPath({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_EulerAngles}); - const DataPath cellPhasesArrayPath({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_Phases}); - const DataPath goodVoxelsPath({Constants::k_ImageDataContainer, Constants::k_CellData, Constants::k_Mask}); - const DataPath crystalStructuresArrayPath({Constants::k_ImageDataContainer, Constants::k_CellEnsembleData, Constants::k_CrystalStructures}); - - // Run the filter once per kind, writing into a uniquely-named output array. auto runWithKind = [&](ChoicesParameter::ValueType kindIndex, const std::string& outputName) { ComputeIPFColorsFilter filter; - Arguments args; - args.insertOrAssign(ComputeIPFColorsFilter::k_ReferenceDir_Key, std::make_any({0.0F, 0.0F, 1.0F})); - args.insertOrAssign(ComputeIPFColorsFilter::k_UseMask_Key, std::make_any(true)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CellEulerAnglesArrayPath_Key, std::make_any(cellEulerAnglesPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CellPhasesArrayPath_Key, std::make_any(cellPhasesArrayPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_MaskArrayPath_Key, std::make_any(goodVoxelsPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CrystalStructuresArrayPath_Key, std::make_any(crystalStructuresArrayPath)); - args.insertOrAssign(ComputeIPFColorsFilter::k_CellIPFColorsArrayName_Key, std::make_any(outputName)); - args.insertOrAssign(ComputeIPFColorsFilter::k_ColorKey_Key, std::make_any(kindIndex)); - + const Arguments args = MakeArgs(true, k_MaskPath, {0.0F, 0.0F, 1.0F}, kindIndex, outputName); auto preflightResult = filter.preflight(dataStructure, args); SIMPLNX_RESULT_REQUIRE_VALID(preflightResult.outputActions); auto executeResult = filter.execute(dataStructure, args); @@ -194,15 +377,14 @@ TEST_CASE("OrientationAnalysis::ComputeIPFColorsFilter: ColorKey choice reaches runWithKind(1, "IPFColors_PUCM"); runWithKind(2, "IPFColors_NH"); - const auto& tslColors = dataStructure.getDataRefAs(DataPath({Constants::k_ImageDataContainer, Constants::k_CellData, "IPFColors_TSL"})); - const auto& pucmColors = dataStructure.getDataRefAs(DataPath({Constants::k_ImageDataContainer, Constants::k_CellData, "IPFColors_PUCM"})); - const auto& nhColors = dataStructure.getDataRefAs(DataPath({Constants::k_ImageDataContainer, Constants::k_CellData, "IPFColors_NH"})); + const auto& tslColors = dataStructure.getDataRefAs(k_CellDataPath.createChildPath("IPFColors_TSL")); + const auto& pucmColors = dataStructure.getDataRefAs(k_CellDataPath.createChildPath("IPFColors_PUCM")); + const auto& nhColors = dataStructure.getDataRefAs(k_CellDataPath.createChildPath("IPFColors_NH")); REQUIRE(tslColors.getSize() == pucmColors.getSize()); REQUIRE(tslColors.getSize() == nhColors.getSize()); - // Sanity: at least one tuple must differ between TSL and each other kind. If - // the switch in executeImpl ever silently collapsed every kind onto TSL, + // If the switch in executeImpl ever silently collapsed every kind onto TSL, // these arrays would be identical. REQUIRE(!std::equal(tslColors.cbegin(), tslColors.cend(), pucmColors.cbegin())); REQUIRE(!std::equal(tslColors.cbegin(), tslColors.cend(), nhColors.cbegin())); diff --git a/src/Plugins/OrientationAnalysis/vv/ComputeIPFColorsFilter.md b/src/Plugins/OrientationAnalysis/vv/ComputeIPFColorsFilter.md new file mode 100644 index 0000000000..4f7656eee4 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/ComputeIPFColorsFilter.md @@ -0,0 +1,116 @@ +# V&V Report: ComputeIPFColorsFilter + +| | | +|--------|--------------| +| Plugin | OrientationAnalysis | +| SIMPLNX UUID | `64cb4f27-6e5e-4dd2-8a03-0c448cb8f5e6` | +| SIMPLNX Human Name | Compute IPF Colors | +| DREAM3D 6.5.171 equivalent | `GenerateIPFColors` (SIMPL UUID `a50e6532-8075-5de5-ab63-945feb0de7f7`) — `Source/Plugins/OrientationAnalysis/OrientationAnalysisFilters/GenerateIPFColors.{h,cpp}` | +| Verified commit | ** | +| Status | READY FOR REVIEW | +| Sign-off | pending second-engineer review | + +## At a glance + +| Aspect | Current state | +|------------------------|------------------------------------------------------------------------------------------------------------------------------| +| Algorithm Relationship | **Port** of DREAM3D 6.5.171 `GenerateIPFColors`. The per-cell loop is a line-for-line translation; deltas are the color library (OrientationLib → EbsdLib), a new `Color Key` choice (TSL/PUCM/Nolze-Hielscher; legacy was TSL-only), bool-or-uint8 mask (legacy bool-only), and added cancel checks. | +| Oracle (confirmed) | **Class 1 + 4** (orchestration: mask→black, invalid crystal structure→black, refDir normalization, phase-out-of-range→`-48000`, output invariants) with **Class 2** (each colored cell == a direct in-process EbsdLib `generateIPFColor` call) and **Class 3** (identity cubic viewed down [001] = red IPF corner). 8 tests in `test/ComputeIPFColorsTest.cpp`, all pass in-core and OOC. | +| Code paths enumerated | 16 of 18 exercised. The 2 gaps are the mid-loop cancel branch and the unreachable `-23510` color-key default. | +| Tests today | 8 test cases: 1 main analytical oracle (4 SECTIONs), uint8-mask, no-mask, refDir-normalization, phase-out-of-range error, color-key wiring, preflight `-651`, SIMPL 6.4/6.5 backward-compat. | +| Exemplar archive | **None for this filter** — the oracle dataset is built inline in C++. The legacy-produced `so3_cubic_high_ipf_001.tar.gz` was **retired as a circular oracle** from this test (it is still downloaded for `CreateEnsembleInfoTest`, so the `download_test_data()` line remains). | +| Legacy comparison | **Run — SIMPLNX vs DREAM3D 6.5.171 (TSL).** SIMPLNX is byte-identical to the stored legacy `IPF Colors` (0/343,963); vs a fresh 6.5.171 run, 14/343,963 cells (0.004%) differ by exactly ±1/255 in one channel. One deviation: `ComputeIPFColorsFilter-D1` (precision + library, quantization jitter). | +| Bug flags | None. | +| V&V phase | Discovery, oracle design, oracle reconciliation (0 SIMPLNX bugs), algorithm review (2 warnings fixed: dead `orientationOps`, atomic phase-warning counter), dual-build, legacy comparison, and documentation complete. Outstanding: second-engineer sign-off. | + +## Summary + +`ComputeIPFColorsFilter` assigns each cell an inverse-pole-figure RGB color by delegating the color computation to EbsdLib `LaueOps::generateIPFColor()` for that cell's phase/crystal structure, with optional masking (bad cells → black) and a selectable color key. Because the color math is EbsdLib's (and is covered by EbsdLib's own `TSLColorKeyTest` / `PUCMColorKeyTest` / `NolzeHielscherColorKeyTest` / `ColorKeyKindTest`), V&V here targets only the SIMPLNX **value-add** — per-cell dispatch/indexing, masking, reference-direction normalization, phase-bounds handling, and output packing — using a Class 1/4 analytical-orchestration oracle, a Class 2 in-process EbsdLib cross-check, and a Class 3 standard-IPF-corner anchor. SIMPLNX matched the oracle on the first run (no filter bugs found) and is byte-identical to the stored legacy reference; the only difference vs a fresh 6.5.171 build is ±1/255 quantization jitter on 0.004% of cells (deviation D1). + +## Algorithm Relationship + +*Classification:* **Port** + +*Evidence:* The SIMPLNX algorithm `Algorithms/ComputeIPFColors.cpp` (203 lines) is a direct translation of `GenerateIPFColors::execute()` / `GenerateIPFColorsImpl::convert()` from DREAM3D 6.5.171. The SIMPL UUID `a50e6532-8075-5de5-ab63-945feb0de7f7` is retained via `OrientationAnalysisLegacyUUIDMapping.hpp` plus SIMPL 6.4/6.5 conversion fixtures. The control flow is preserved verbatim: per-cell init-to-black → mask lookup → phase-bounds sanity → `phase < numPhases && calcIPF && crystalStructures[phase] < LaueGroupEnd` coloring guard → `generateIPFColor` → RGB write, followed by the post-loop `-48000` phase-warning error. + +*Port-time deltas (each with its effect on output):* + +1. **Color library: OrientationLib → EbsdLib.** The `generateIPFColor` call now resolves to EbsdLib. Same standard conventions; produces the ±1/255 quantization jitter documented in `ComputeIPFColorsFilter-D1`. +2. **`Color Key` choice added (PR #1631).** New `ChoicesParameter` routing to `ebsdlib::ColorKeyKind` {TSL, PUCM, Nolze-Hielscher}. Legacy was TSL-only; the default (index 0 = TSL) reproduces legacy behavior. PUCM/Nolze-Hielscher have no legacy equivalent. +3. **Mask accepts bool *or* uint8.** Legacy required a `bool` mask; SIMPLNX `run()` dispatches `convert`/`convert`. No effect on output values; widens accepted input. +4. **Cancel checks added.** `shouldCancel()` guard at the top of the per-cell loop. UX-only. +5. **Reference direction normalization** is unchanged in intent (`FloatVec3::normalize()` vs legacy `MatrixMath::Normalize3x1`); both make the reference direction unit length before use. + +*Material PRs since baseline:* #1631 ("EbsdLib 3.0.0 + V&V of 6 Filters", added the Color Key), #1472 (EbsdLib 2.0.0 API), #1438 (microtexture cleanup), #1501 (Vec3 unification). None alter the coloring logic beyond the deltas above. + +## Oracle + +*Class:* **1 (Analytical)** + **4 (Invariant)** primary, **2 (Reference — EbsdLib)** and **3 (Paper/standard-IPF)** companions. + +Per the "test the value-add, not upstream" principle: EbsdLib is the trusted reference for the color math (it has its own test suite), so the oracle verifies that SIMPLNX *routes data correctly into and out of EbsdLib*, not that the color algorithm is correct. + +*Applied:* +- **Class 1 (Analytical, orchestration):** a masked-off cell is exactly `(0,0,0)`; a cell whose crystal structure is `≥ LaueGroupEnd` (999/Unknown) is exactly `(0,0,0)`; a non-unit reference direction `[0,0,5]` yields identical output to `[0,0,1]` (normalization); a cell whose phase index `≥ numPhases` produces error `-48000`. These are pure filter logic, derivable without reference to any DREAM3D implementation. +- **Class 4 (Invariant):** output is a 3-component uint8 array with the parent tuple count; non-colored cells are exactly black; colored cells are non-black. +- **Class 2 (Reference, EbsdLib):** for every colored cell, the filter output equals a direct, independent in-process `ebsdlib::LaueOps::GetAllOrientationOps()[crystalStruct]->generateIPFColor(euler, refDir, false, kind)` call — verifying the phase→crystal-structure→ops indexing, the 3-tuple Euler slicing, the reference-direction hand-off, and the color-key pass-through. +- **Class 3 (Standard IPF):** an identity-orientation (`(0,0,0)`) cubic cell viewed down `[001]` is the red corner of the standard IPF triangle. EbsdLib's own `TSLColorKeyTest` fixes `[001] → r=1, g=0, b=0`; the test asserts R ≥ 250, G = 0, B = 0. + +*Toy data:* built inline in C++ (`BuildAnalyticalDataset`) — a 6-cell ImageGeom, ensemble `CrystalStructures = [999 Unknown, 1 Cubic_High, 0 Hexagonal_High]`, cells chosen to hit identity-cubic, arbitrary cubic, arbitrary hex (two Laue classes), a masked cell, and an invalid-crystal-structure cell. + +*Encoded:* `test/ComputeIPFColorsTest.cpp` — 8 `TEST_CASE`s, all pass in-core and OOC. The main `Class 1/2/3 Oracle (inline analytical dataset)` case carries the Class 2/3/1/4 assertions across four `SECTION`s. + +*Second-engineer review:* pending (this V&V PR's review constitutes the second-engineer sign-off). + +## Code path coverage + +*16 of 18 code paths exercised. The 2 uncovered paths are the mid-loop cancel branch (requires injecting a cancel signal) and the color-key `default` error `-23510` (unreachable through normal preflight because `ChoicesParameter` already constrains the value to 0–2). Both are low-value.* + +Source: `src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeIPFColors.cpp` (203 lines). + +Logical phases: (a) `operator()` setup + parallel dispatch, (b) per-cell `convert()` loop, (c) post-loop phase-warning error; plus (d) filter-level preflight / executeImpl wiring. + +| # | Phase | Path | Test case | +|----|--------------|-----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------| +| 1 | (a) Dispatch | mask present & `boolean` → `convert` | `Class 1/2/3 Oracle` (bool `Mask`) | +| 2 | (a) Dispatch | mask present & `uint8` → `convert` | `uint8 mask array drives the black-out path` | +| 3 | (a) Dispatch | mask null (`useMask=false`) → `convert` with null mask | `no-mask path colors every valid cell` | +| 4 | (a) Setup | reference direction normalized before use | `reference direction is normalized` | +| 5 | (b) Per-cell | `shouldCancel()` → early return | *Not directly tested. Requires mid-execution cancel-signal injection; low-value UX guard.* | +| 6 | (b) Per-cell | init each cell to `(0,0,0)` | `Class 1/2/3 Oracle` — masked/invalid cells remain black | +| 7 | (b) Per-cell | `maskArray != null` → `calcIPF = mask[i]` | `Class 1/2/3 Oracle`, `uint8 mask ...` | +| 8 | (b) Per-cell | `phase >= numPhases` → `incrementPhaseWarningCount()` | `phase index out of range returns -48000` | +| 9 | (b) Per-cell | coloring branch: `phase= LaueGroupEnd` (999) → stays black | `Class 1/2/3 Oracle` (cell 4) | +| 12 | (b) Per-cell | not colored — `phase >= numPhases` → stays black | `phase index out of range returns -48000` | +| 13 | (c) Finalize | `m_PhaseWarningCount > 0` → return error `-48000` | `phase index out of range returns -48000` | +| 14 | (c) Finalize | `m_PhaseWarningCount == 0` → return success `{}` | every passing test | +| 15 | (d) Filter | `Color Key` switch 0/1/2 → TSL/PUCM/Nolze-Hielscher | `ColorKey choice reaches algorithm` | +| 16 | (d) Filter | `Color Key` `default` → error `-23510` | *Not directly tested. Unreachable via preflight — `ChoicesParameter` constrains the value to [0,2].* | +| 17 | (d) Filter | `validateNumberOfTuples` fails → error `-651` | `Preflight Error - Cell array tuple count mismatch (-651)` | +| 18 | (d) Filter | preflight creates the 3-component uint8 output array | every passing test (preflight is `REQUIRE`-valid) | + +## Test inventory + +| Test case | Status | Notes | +|-----------|--------|-------| +| `OrientationAnalysis::ComputeIPFColorsFilter: Class 1/2/3 Oracle (inline analytical dataset)` | new-for-V&V | Replaces the retired circular-oracle test. 4 SECTIONs: Class 3 red-corner, Class 2 EbsdLib cross-check over 4 colored cells, Class 1 masked→black, Class 1 invalid-CS→black. | +| `OrientationAnalysis::ComputeIPFColorsFilter: uint8 mask array drives the black-out path` | new-for-V&V | Exercises `convert` dispatch; masked cell black, good cell colored. | +| `OrientationAnalysis::ComputeIPFColorsFilter: no-mask path colors every valid cell` | new-for-V&V | Exercises null-mask `convert`; previously-masked cell now matches EbsdLib reference; invalid-CS cell still black. | +| `OrientationAnalysis::ComputeIPFColorsFilter: reference direction is normalized` | new-for-V&V | `[0,0,5]` output byte-identical to `[0,0,1]`. | +| `OrientationAnalysis::ComputeIPFColorsFilter: phase index out of range returns -48000` | new-for-V&V | Corrupts a phase to an out-of-range ensemble index; asserts execute error `-48000`. | +| `OrientationAnalysis::ComputeIPFColorsFilter: ColorKey choice reaches algorithm` | kept (retargeted) | Was on the retired exemplar; now runs on the inline dataset. Asserts TSL ≠ PUCM ≠ Nolze-Hielscher output. | +| `OrientationAnalysis::ComputeIPFColorsFilter: Preflight Error - Cell array tuple count mismatch (-651)` | kept | Synthetic mismatched tuple counts → `-651`. | +| `OrientationAnalysis::ComputeIPFColorsFilter: SIMPL Backwards Compatibility` | kept | `DYNAMIC_SECTION` over SIMPL 6.4 + 6.5 conversion fixtures; validates UUID + argument conversion. | +| *(retired)* `OrientationAnalysis::ComputeIPFColors` | retired | Circular oracle — compared filter output against the legacy-produced `IPF Colors` array inside `so3_cubic_high_ipf_001.dream3d` ("produced by SIMPL/DREAM3D … our results should match theirs"). Replaced by the analytical oracle above. | + +## Exemplar archive + +- **Archive:** None for this filter. The oracle dataset is constructed inline in C++ (`BuildAnalyticalDataset` in `test/ComputeIPFColorsTest.cpp`); no golden `.dream3d` is downloaded or compared, so no provenance sidecar is required. +- **Retired:** `so3_cubic_high_ipf_001.tar.gz` (SHA512 `dfe4598c…dd616b85`) as an oracle for this filter. Its `download_test_data()` entry in `test/CMakeLists.txt` remains because `CreateEnsembleInfoTest` still consumes the archive. + +## Deviations from DREAM3D 6.5.171 + +Comparison run on `so3_cubic_high_ipf_001` (343,963 single-phase cubic cells, TSL, refDir [001]) — see `vv/comparisons/ComputeIPFColorsFilter/`. + +- `ComputeIPFColorsFilter-D1` — 14/343,963 cells (0.004%) differ from a fresh 6.5.171 run by ±1/255 in one channel (SIMPLNX is byte-identical to the stored legacy reference); precision + library quantization jitter — see `vv/deviations/ComputeIPFColorsFilter.md`. diff --git a/src/Plugins/OrientationAnalysis/vv/deviations/ComputeIPFColorsFilter.md b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeIPFColorsFilter.md new file mode 100644 index 0000000000..fac9cc4804 --- /dev/null +++ b/src/Plugins/OrientationAnalysis/vv/deviations/ComputeIPFColorsFilter.md @@ -0,0 +1,29 @@ +# Deviations from DREAM3D 6.5.171: ComputeIPFColorsFilter + +This file lists every documented behavioral difference between this SIMPLNX filter and its DREAM3D 6.5.171 equivalent (`GenerateIPFColors`). + +Entries are referenced by stable ID (`ComputeIPFColorsFilter-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. + +--- + +## ComputeIPFColorsFilter-D1 + +| Field | Value | +|---|---| +| **Deviation ID** | `ComputeIPFColorsFilter-D1` | +| **Filter UUID** | `64cb4f27-6e5e-4dd2-8a03-0c448cb8f5e6` | +| **Status** | active | + +**Symptom:** On the TSL color scheme, a very small fraction of cells (14 of 343,963, or 0.004%, on the single-phase cubic `so3_cubic_high_ipf_001` dataset) receive an IPF color that differs from a freshly-run DREAM3D 6.5.171 by exactly ±1 in one of the three 8-bit RGB channels. + +**Root cause:** *precision + library.* IPF coloring is delegated to a crystallographic library — legacy DREAM3D uses its in-tree **OrientationLib**, SIMPLNX uses **EbsdLib**. Both implement the same standard TSL coloring and agree on the continuous RGB value to well within 1/255, but for the handful of orientations whose computed channel value lands right on a `×255` quantization boundary (observed at 57↔58, 130↔131, 164↔165, …) the minute intermediate-math differences between the two libraries tip the final `static_cast` by one integer level. The SIMPLNX orchestration (`Algorithms/ComputeIPFColors.cpp`) is otherwise a line-for-line port of `GenerateIPFColors::execute()`; it does not itself perform the color math. Notably, SIMPLNX reproduces the legacy `IPF Colors` array **stored inside the input file byte-for-byte** — the ±1 cells appear only against a fresh run of one particular 6.5.171 binary, confirming the difference is quantization jitter, not an algorithmic change. + +**Affected users:** Anyone diffing SIMPLNX IPF-color output against a specific 6.5.171 build at the single-bit level. Visually, and for every downstream use, the images are identical; a ±1/255 channel difference on 0.004% of cells is imperceptible. + +**Recommendation:** *either acceptable within tolerance ±1/255.* Neither result is more correct — both are the same continuous color quantized to 8 bits, differing only in rounding direction at the boundary. No legacy patch is warranted (the 6.5.171 output is not wrong). + +--- + +## Note on color schemes with no legacy equivalent + +DREAM3D 6.5.171 `GenerateIPFColors` produced **only** the TSL (EDAX/OIM) color scheme. SIMPLNX adds a `Color Key` choice exposing two additional EbsdLib schemes — **PUCM** (Patala / MTEX-style perceptually-uniform) and **Nolze-Hielscher** (MTEX HSV-style). These have no DREAM3D 6.5.171 counterpart, so no legacy comparison is possible or meaningful for them; they are a new feature, not a deviation. Their per-Laue-class correctness is covered upstream by EbsdLib's `PUCMColorKeyTest` and `NolzeHielscherColorKeyTest`.