Skip to content

Commit aa3d21b

Browse files
committed
VV: Compute Average C-Axis Orientations fully V&V'ed
Summary: - Confirmed no SIMPLNX-side bugs (algorithm verified correct as-is); applied 2 algorithm refinements during the Phase-7 review (counter==0 → NaN at finalize + final per-feature normalize) — these are intentional design alignments that surface as D1 and D2 deviations vs DREAM3D 6.5.171 but match the intended algorithm and are bit-identical to the 6.5.172 backport; - documented 2 deviations from DREAM3D 6.5.171 (D1 counter==0 → NaN at finalize replaces legacy's (0,0,1) rescue at F0/F5/F6; D2 precision-sensitive direction + unit-vector vs unnormalized magnitude at F7 antipodal-flip cancellation boundary); - retired 1 test (legacy-by-reputation exemplar consumer of 7_2_AvgCAxis.tar.gz — confirmed circular oracle per policy line 33: reference values were produced by a "special build of DREAM3D 6.6.379 with micro-texture bug fixes," not by an independent oracle); - unit tests replaced with 3 tests: 1 Class 1 (Analytical) valid-execution exemplar against a hand-built 11-cell/8-feature dataset + 1 negative all-non-hex error test + 1 SIMPL 6.4/6.5 backwards-compat DYNAMIC_SECTION — covers 8/12 enumerated code paths (gaps: all-hex-ensemble preflight, background-voxel skip, 2 cancel-check paths); - added 3 V&V source-tree deliverables (report, deviations, provenance — provenance file attributes the dataset design to MAJ with subsequent design changes and Python generation-script integration by Claude Opus 4.7, signed off by MAJ); - published new compute_avg_c_axis.tar.gz (replaces retired 7_2_AvgCAxis.tar.gz) with hand-built Class 1 dataset + Class 4 (Invariant) ||AvgCAxes|| == 1.0 unit-vector contract for F7; empirical three-way A/B (SIMPLNX vs 6.5.171 official vs 6.5.172 backport) confirmed SIMPLNX bit-identical to 6.5.172 across all 8 features, conclusively isolating D1 and D2 root causes; - updated user-facing doc with new ComputeAvgCAxes_HexagonalCAxis figure (SVG + PNG) explaining the c-axis-in-sample-frame convention.
1 parent 7cff7f2 commit aa3d21b

10 files changed

Lines changed: 761 additions & 131 deletions

File tree

src/Plugins/OrientationAnalysis/docs/ComputeAvgCAxesFilter.md

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,29 +6,44 @@ Statistics (Crystallography)
66

77
## Description
88

9-
This **Filter** determines the average C-axis location of each **Feature** by the following algorithm:
9+
This **Filter** computes the average C-axis direction for each **Feature** (grain) in hexagonal materials. The result is a unit vector per **Feature** that indicates where the grain's C-axis points in the sample reference frame.
1010

11-
1. Gather all **Elements** that belong to the **Feature**
12-
2. Determine the location of the c-axis in the sample *reference frame* for the rotated quaternions for all **Elements**. This is achieved by converting the input quaternion to
13-
an orientation matrix (which represents a passive transform matrix), taking the transpose of the matrix to convert it from passive to active, and then multiplying the
14-
transposed matrix by the crystallographic C-Axis direction vector <001>.
15-
3. Average the locations and store the average for the **Feature**
11+
### What is the C-Axis?
1612

17-
*Note:* This **Filter** will only work properly for *Hexagonal* materials. The **Filter** does not apply any symmetry
18-
operators because there is only one c-axis (<001>) in *Hexagonal* materials and thus all symmetry operators will leave
19-
the c-axis in the same position in the sample *reference frame*. However, in *Cubic* materials, for example, the {100}
20-
family of directions are all equivalent and the <001> direction will change location in the sample *reference frame* when
21-
symmetry operators are applied.
13+
In hexagonal crystal structures (such as titanium, magnesium, and zinc), the *C-axis* is the unique crystallographic direction that runs along the long axis of the hexagonal unit cell (the [001] direction). This axis is important because many mechanical and physical properties of hexagonal materials vary depending on whether they are measured along or perpendicular to the C-axis.
2214

23-
This filter will error out if **ALL** phases are non-hexagonal. Any non-hexagonal phases will have their computed values set to NaN value.
15+
![Fig. 1: The C-axis in a hexagonal unit cell.](Images/ComputeAvgCAxes_HexagonalCAxis.png)
2416

25-
The output is a direction vector for each feature.
17+
### How This Filter Works
18+
19+
Each **Cell** (voxel) in a grain has its own measured orientation. This filter determines where each cell's C-axis points in the physical sample coordinate system, then averages those directions across all cells belonging to the same **Feature**:
20+
21+
1. For each **Cell**, the filter uses the cell's orientation (quaternion) to rotate the crystal [001] direction into the sample reference frame.
22+
2. The rotated C-axis directions are accumulated for each **Feature**, ensuring all directions point into the same hemisphere for a consistent average.
23+
3. The accumulated directions are normalized to produce a unit vector for each **Feature**.
24+
25+
### Hexagonal Materials Only
26+
27+
This filter only produces valid results for hexagonal phases (6/mmm or 6/m symmetry). In hexagonal materials, the C-axis is unique -- there is only one [001] direction, so crystal symmetry does not create ambiguity.
28+
29+
In cubic materials, the [001], [010], and [100] directions are all crystallographically equivalent. Applying symmetry operators would move the [001] direction to different positions in the sample frame, making a simple average meaningless. For this reason, the filter **silently skips non-hexagonal cells** during accumulation. A **Feature** whose contributing cells are *all* non-hexagonal (or which has no cells assigned to it at all) will have its output set to **NaN**.
30+
31+
The filter will produce an error if no hexagonal phases are present in the data (`-76402`: "No phases that have a crystal symmetry of Hexagonal (6/mmm or 6/m) were found.").
32+
33+
### Required Input Sources
34+
35+
This filter operates on previously segmented data and requires that several prior operations have already been run:
36+
37+
- **Cell Quaternions** -- typically read from EBSD data via [Read H5EBSD](ReadH5EbsdFilter.md), [Read CTF Data](ReadCtfDataFilter.md), or [Read ANG Data](ReadAngDataFilter.md); can also be produced from Euler angles by [Convert Orientations](ConvertOrientationsFilter.md).
38+
- **Cell Feature Ids** -- produced by a segmentation filter such as [Segment Features (Misorientation)](EBSDSegmentFeaturesFilter.md) or [Segment Features (C-Axis Misalignment)](CAxisSegmentFeaturesFilter.md).
39+
- **Cell Phases** -- typically read from EBSD data alongside the quaternions.
40+
- **Crystal Structures** -- ensemble-level array read from EBSD data or created by [Create Ensemble Info](CreateEnsembleInfoFilter.md).
2641

2742
% Auto generated parameter table will be inserted here
2843

2944
## Example Pipelines
3045

31-
EBSD_Hexagonal_Data_Analysis
46+
+ `EBSD_Hexagonal_Data_Analysis`
3247

3348
## License & Copyright
3449

64.6 KB
Loading
Lines changed: 93 additions & 0 deletions
Loading

src/Plugins/OrientationAnalysis/src/OrientationAnalysis/Filters/Algorithms/ComputeAvgCAxes.cpp

Lines changed: 53 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,6 @@ ComputeAvgCAxes::ComputeAvgCAxes(DataStructure& dataStructure, const IFilter::Me
2626
// -----------------------------------------------------------------------------
2727
ComputeAvgCAxes::~ComputeAvgCAxes() noexcept = default;
2828

29-
// -----------------------------------------------------------------------------
30-
const std::atomic_bool& ComputeAvgCAxes::getCancel()
31-
{
32-
return m_ShouldCancel;
33-
}
34-
3529
// -----------------------------------------------------------------------------
3630
Result<> ComputeAvgCAxes::operator()()
3731
{
@@ -66,41 +60,40 @@ Result<> ComputeAvgCAxes::operator()()
6660
const auto& quats = m_DataStructure.getDataRefAs<Float32Array>(m_InputValues->QuatsArrayPath);
6761
const auto& cellPhases = m_DataStructure.getDataRefAs<Int32Array>(m_InputValues->CellPhasesArrayPath);
6862
auto& avgCAxes = m_DataStructure.getDataRefAs<Float32Array>(m_InputValues->AvgCAxesArrayPath);
63+
avgCAxes.fill(0.0f); // Initialize all output values to ZERO defensively.
6964

7065
const usize totalPoints = featureIds.getNumberOfTuples();
7166
const usize totalFeatures = avgCAxes.getNumberOfTuples();
7267

73-
const Eigen::Vector3d cAxis{0.0f, 0.0f, 1.0f};
74-
Eigen::Vector3d c1{0.0f, 0.0f, 0.0f};
68+
const Eigen::Vector3d cAxis{0.0, 0.0, 1.0};
69+
70+
std::vector<int32> cellCount(totalFeatures, 0);
7571

76-
std::vector<int32> counter(totalFeatures, 0);
72+
m_MessageHandler({IFilter::Message::Type::Info, "Computing cell contributions"});
7773

7874
// Loop over each cell
7975
for(usize i = 0; i < totalPoints; i++)
8076
{
8177
if(m_ShouldCancel)
8278
{
83-
return {};
79+
return result;
8480
}
8581

8682
int32 currentFeatureId = featureIds[i];
8783
// If the featureId for a given cell is valid ( > 0) then analyze that value
8884
if(currentFeatureId > 0)
8985
{
90-
const int32 currentCellPhase = cellPhases[i]; // Get the current cell phase
91-
const auto crystalStructureType = crystalStructures[currentCellPhase]; // Get the CrystalStructure, i.e., Laue class of the cell
86+
const int32 currentCellPhase = cellPhases[i]; // Get the current cell phase
87+
const auto currentCrystalStructure = crystalStructures[currentCellPhase]; // Get the CrystalStructure, i.e., Laue class of the cell
9288
const usize cAxesIndex = 3 * currentFeatureId;
9389

94-
// Ensure the Laue class is correct, otherwise mark the values with a NaN and continue
95-
if(crystalStructureType != ebsdlib::CrystalStructure::Hexagonal_High && crystalStructureType != ebsdlib::CrystalStructure::Hexagonal_Low)
90+
// If the Laue class is not Hexagonal, then continue to the next cell
91+
if(currentCrystalStructure != ebsdlib::CrystalStructure::Hexagonal_High && currentCrystalStructure != ebsdlib::CrystalStructure::Hexagonal_Low)
9692
{
97-
avgCAxes[cAxesIndex] = NAN;
98-
avgCAxes[cAxesIndex + 1] = NAN;
99-
avgCAxes[cAxesIndex + 2] = NAN;
10093
continue;
10194
}
10295

103-
counter[currentFeatureId]++; // Increment the count
96+
cellCount[currentFeatureId]++; // Increment the counter if we are the appropriate Laue class.
10497
const usize quatIndex = i * 4;
10598

10699
// Create the 3x3 Orientation Matrix from the Quaternion. This represents a passive rotation matrix
@@ -110,73 +103,73 @@ Result<> ComputeAvgCAxes::operator()()
110103
// Multiply the active transformation matrix by the C-Axis (as Miller Index). This actively rotates
111104
// the crystallographic C-Axis (which is along the <0,0,1> direction) into the physical sample
112105
// reference frame
113-
c1 = oMatrix.transpose() * cAxis;
106+
Eigen::Vector3d cellCAxis = oMatrix.transpose() * cAxis;
114107

115108
// normalize so that the magnitude is 1
116-
c1.normalize();
109+
cellCAxis.normalize();
117110

118111
// Compute the running average c-axis and normalize the result
119-
Eigen::Vector3d curCAxis{0.0f, 0.0f, 0.0f};
120-
curCAxis[0] = avgCAxes[cAxesIndex] / static_cast<float32>(counter[currentFeatureId]);
121-
curCAxis[1] = avgCAxes[cAxesIndex + 1] / static_cast<float32>(counter[currentFeatureId]);
122-
curCAxis[2] = avgCAxes[cAxesIndex + 2] / static_cast<float32>(counter[currentFeatureId]);
123-
curCAxis.normalize();
112+
Eigen::Vector3d runningCAxisAvg{avgCAxes[cAxesIndex] / static_cast<float32>(cellCount[currentFeatureId]), avgCAxes[cAxesIndex + 1] / static_cast<float32>(cellCount[currentFeatureId]),
113+
avgCAxes[cAxesIndex + 2] / static_cast<float32>(cellCount[currentFeatureId])};
114+
runningCAxisAvg.normalize();
124115

125116
// Ensure that angle between the current point's sample reference frame C-Axis
126117
// and the running average sample C-Axis is positive
127-
float64 w = ImageRotationUtilities::CosBetweenVectors(c1, curCAxis);
128-
if(w < 0.0)
118+
float64 cosAngle = ImageRotationUtilities::CosBetweenVectors(cellCAxis, runningCAxisAvg);
119+
if(cosAngle < 0.0)
129120
{
130-
c1 *= -1.0f;
121+
cellCAxis *= -1.0f;
131122
}
132123

133124
// Continue summing up the rotations
134-
float value = avgCAxes[cAxesIndex] + c1[0];
135-
avgCAxes[cAxesIndex] = value;
125+
// Eigen math is double; output array is float32
126+
float temp = avgCAxes[cAxesIndex] + cellCAxis[0];
127+
avgCAxes[cAxesIndex] = temp;
136128

137-
value = avgCAxes[cAxesIndex + 1] + c1[1];
138-
avgCAxes[cAxesIndex + 1] = value;
129+
temp = avgCAxes[cAxesIndex + 1] + cellCAxis[1];
130+
avgCAxes[cAxesIndex + 1] = temp;
139131

140-
value = avgCAxes[cAxesIndex + 2] + c1[2];
141-
avgCAxes[cAxesIndex + 2] = value;
132+
temp = avgCAxes[cAxesIndex + 2] + cellCAxis[2];
133+
avgCAxes[cAxesIndex + 2] = temp;
142134
}
143135
}
144136

145-
for(size_t i = 1; i < totalFeatures; i++)
137+
// Now that each feature's Axis is summed up, compute the final average C-Axis
138+
m_MessageHandler({IFilter::Message::Type::Info, "Computing final feature average C-Axis values"});
139+
140+
for(usize currentFeatureId = 0; currentFeatureId < totalFeatures; currentFeatureId++)
146141
{
147142
if(m_ShouldCancel)
148143
{
149-
return {};
144+
return result;
150145
}
151146

152-
const usize tupleIndex = i * 3;
153-
float32 avgCAxesValue = avgCAxes[tupleIndex];
154-
if(std::isnan(avgCAxesValue))
155-
{
156-
continue;
157-
}
158-
// If we got passed the last check this could happen if the cell points were
159-
// masked out? Maybe?
160-
if(counter[i] == 0)
147+
// If the value of this feature's counter == 0, then there are 2 possibilities
148+
// that could have happened:
149+
// [1] The feature's phase was not hexagonal and therefore in the "totalPoints" loop above the counter never got incremented.
150+
// [2] There is a featureId that does not have any voxels assigned to it. We need more information here to determine what to do.
151+
// - If we had the "Feature-Phases array then we could look up the phase and then we would know. This would require another input from the user
152+
// - If the featureId does not have any voxels assigned, then how would you generate an average anyway, so applying NaN to the value is the right move here
153+
154+
const usize cAxesIndex = 3 * currentFeatureId;
155+
if(cellCount[currentFeatureId] == 0)
161156
{
162-
avgCAxes[tupleIndex] = 0;
163-
avgCAxes[tupleIndex + 1] = 0;
164-
avgCAxes[tupleIndex + 2] = 1;
157+
avgCAxes[cAxesIndex] = NAN;
158+
avgCAxes[cAxesIndex + 1] = NAN;
159+
avgCAxes[cAxesIndex + 2] = NAN;
165160
}
166161
else
167162
{
168-
// Compute the final average c-axis value
169-
float value = avgCAxes[3 * i];
170-
value /= static_cast<float>(counter[i]);
171-
avgCAxes[3 * i] = value;
172-
173-
value = avgCAxes[3 * i + 1];
174-
value /= static_cast<float>(counter[i]);
175-
avgCAxes[3 * i + 1] = value;
176-
177-
value = avgCAxes[3 * i + 2];
178-
value /= static_cast<float>(counter[i]);
179-
avgCAxes[3 * i + 2] = value;
163+
// Divide the accumulated sum by the cell count, then normalize so the
164+
// output is a unit-magnitude C-axis direction. The antipodal-flip rule
165+
// guarantees |sum| >= sqrt(cellCount), so the divided vector's magnitude
166+
// is >= 1/sqrt(cellCount) > 0 -- no near-zero guard needed.
167+
Eigen::Vector3d finalAvg{avgCAxes[cAxesIndex] / static_cast<float64>(cellCount[currentFeatureId]), avgCAxes[cAxesIndex + 1] / static_cast<float64>(cellCount[currentFeatureId]),
168+
avgCAxes[cAxesIndex + 2] / static_cast<float64>(cellCount[currentFeatureId])};
169+
finalAvg.normalize();
170+
avgCAxes[cAxesIndex] = static_cast<float32>(finalAvg[0]);
171+
avgCAxes[cAxesIndex + 1] = static_cast<float32>(finalAvg[1]);
172+
avgCAxes[cAxesIndex + 2] = static_cast<float32>(finalAvg[2]);
180173
}
181174
}
182175
return result;

0 commit comments

Comments
 (0)