diff --git a/hexrd/texture/__init__.py b/hexrd/texture/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hexrd/texture/kernels.py b/hexrd/texture/kernels.py new file mode 100644 index 00000000..dcf2a528 --- /dev/null +++ b/hexrd/texture/kernels.py @@ -0,0 +1,233 @@ +""" +SO(3) Kernel Functions for Texture Analysis + +Implements radial basis functions on the rotation group SO(3) used for +constructing smooth orientation distribution functions. +""" + +import warnings + +import numpy as np +from abc import ABC, abstractmethod +from scipy.special import beta as betafn + + +class SO3Kernel(ABC): + """ + Abstract base class for kernels on the SO(3) rotation group. + + All SO(3) kernels should inherit from this class and implement + the eval() method for kernel evaluation. + """ + + @abstractmethod + def eval( + self, R1: np.ndarray, R2: np.ndarray + ) -> np.ndarray: + """ + Evaluate kernel between two rotations. + + Parameters + ---------- + R1, R2 : array_like + Rotation matrices of shape (..., 3, 3) + + Returns + ------- + numpy.ndarray + Kernel values + """ + pass + + +class DeLaValleePoussinKernel(SO3Kernel): + """ + De la Vallée Poussin kernel on SO(3). + + A radially symmetric kernel on SO(3) defined by: + + K(ω) = C · cos(ω/2)^(2κ) + + where ω is the misorientation angle, κ is the shape parameter, and + C = B(3/2, 1/2) / B(3/2, κ + 1/2) is the normalization constant + (B denotes the Beta function). + + The halfwidth h is the angle at which the kernel drops to half its + peak value. It is related to κ analytically by: + + κ = ln(0.5) / (2 · ln(cos(h/2))) + + Parameters + ---------- + halfwidth : float + Half-width parameter in radians — the angle at which K drops + to half its maximum. Must be > 0, typically in [π/180, π/2]. + + Attributes + ---------- + halfwidth : float + Half-width parameter in radians + kappa : float + Shape parameter κ derived from half-width + norm_constant : float + Normalization constant from the Beta function + + Examples + -------- + >>> kernel = DeLaValleePoussinKernel(halfwidth=np.radians(15)) + >>> R1 = np.eye(3) + >>> R2 = np.eye(3) # Same orientation + >>> value = kernel.eval(R1, R2) # Maximum value = C + """ + + def __init__(self, halfwidth: float) -> None: + """ + Initialize de la Vallée Poussin kernel. + + Parameters + ---------- + halfwidth : float + Half-width parameter in radians + """ + if halfwidth <= 0: + raise ValueError("Half-width must be positive") + if halfwidth > np.pi / 2: + warnings.warn( + f"Large half-width " + f"{np.degrees(halfwidth):.1f}° may produce " + f"very broad distributions", + UserWarning, + ) + + self._halfwidth = float(halfwidth) + + # Shape parameter κ from the half-maximum condition K(h) = K(0)/2: + # cos(h/2)^(2κ) = 1/2 + # κ = ln(0.5) / (2·ln(cos(h/2))) + self._kappa = 0.5 * np.log(0.5) / np.log(np.cos(halfwidth / 2.0)) + + # Normalization constant: C = B(3/2, 1/2) / B(3/2, κ + 1/2) + self._C = betafn(1.5, 0.5) / betafn(1.5, self._kappa + 0.5) + + @property + def halfwidth(self) -> float: + """float: Half-width in radians (angle where K = K_max / 2).""" + return self._halfwidth + + @property + def kappa(self) -> float: + """float: Shape parameter κ.""" + return self._kappa + + @property + def norm_constant(self) -> float: + """float: Normalization constant from Beta function.""" + return self._C + + def misorientation_angle( + self, R1: np.ndarray, R2: np.ndarray + ) -> np.ndarray: + """ + Calculate misorientation angle between rotation matrices. + + Uses the formula: cos(ω) = (trace(R1^T @ R2) - 1) / 2 + + Parameters + ---------- + R1, R2 : array_like + Rotation matrices of shape (..., 3, 3) + + Returns + ------- + numpy.ndarray + Misorientation angles in radians, shape matches input broadcasting + """ + R1 = np.asarray(R1) + R2 = np.asarray(R2) + + if R1.shape[-2:] != (3, 3) or R2.shape[-2:] != (3, 3): + raise ValueError( + "Input matrices must have shape (..., 3, 3)" + ) + + # Relative rotation: R1^T @ R2 + R1_T = np.swapaxes(R1, -2, -1) + relative = np.matmul(R1_T, R2) + trace = np.trace(relative, axis1=-2, axis2=-1) + + # cos(ω) = (tr(R) - 1) / 2, clamped for numerical safety + cos_omega = np.clip((trace - 1.0) / 2.0, -1.0, 1.0) + return np.arccos(cos_omega) + + def eval( + self, R1: np.ndarray, R2: np.ndarray + ) -> np.ndarray: + """ + Evaluate de la Vallée Poussin kernel between rotations. + + K(ω) = C · cos(ω/2)^(2κ) + + Parameters + ---------- + R1, R2 : array_like + Rotation matrices of shape (..., 3, 3) + + Returns + ------- + numpy.ndarray + Kernel values, shape matches broadcasting of R1 and R2 + + Examples + -------- + >>> kernel = DeLaValleePoussinKernel( + ... halfwidth=np.radians(10) + ... ) + >>> value = kernel.eval(np.eye(3), np.eye(3)) + """ + omega = self.misorientation_angle(R1, R2) + co2 = np.cos(omega / 2.0) + return self._C * co2 ** (2.0 * self._kappa) + + def eval_centered( + self, R: np.ndarray, center: np.ndarray + ) -> np.ndarray: + """ + Evaluate kernel centered at a specific orientation. + + Equivalent to eval(center, R) with clearer semantics + for unimodal distributions. + + Parameters + ---------- + R : array_like + Rotation matrices to evaluate at, shape (..., 3, 3) + center : array_like + Central (modal) orientation, shape (3, 3) + + Returns + ------- + numpy.ndarray + Kernel values relative to center orientation + """ + return self.eval(center, R) + + def __repr__(self) -> str: + """String representation of kernel.""" + hw_deg = np.degrees(self.halfwidth) + return ( + f"DeLaValleePoussinKernel(" + f"halfwidth={self.halfwidth:.6f} rad " + f"= {hw_deg:.2f}°, " + f"kappa={self.kappa:.3f})" + ) + + def __str__(self) -> str: + """Human-readable description.""" + hw_deg = np.degrees(self.halfwidth) + return ( + f"de la Vallée Poussin kernel with " + f"{hw_deg:.1f}° half-width\n" + f"kappa = {self.kappa:.3f}, " + f"C = {self.norm_constant:.6e}\n" + f"K(ω) = C · cos(ω/2)^(2κ)" + ) diff --git a/tests/test_delavalleepoussin_kernel.py b/tests/test_delavalleepoussin_kernel.py new file mode 100644 index 00000000..cb5210ef --- /dev/null +++ b/tests/test_delavalleepoussin_kernel.py @@ -0,0 +1,190 @@ +"""Tests for DeLaValleePoussinKernel.""" + +import numpy as np +import unittest +from scipy.special import beta as betafn + +from hexrd.texture.kernels import DeLaValleePoussinKernel + +class TestDeLaValleePoussinKernel(unittest.TestCase): + """Test DeLaValleePoussinKernel.""" + + def test_kappa_from_halfwidth(self): + """Test that κ is derived analytically from half-width.""" + halfwidth_rad = np.radians(4.0) + kernel = DeLaValleePoussinKernel(halfwidth=halfwidth_rad) + + # Regression guard: verify against the formula directly + expected_kappa = ( + 0.5 * np.log(0.5) + / np.log(np.cos(halfwidth_rad / 2.0)) + ) + self.assertAlmostEqual( + kernel.kappa, expected_kappa, places=10 + ) + + def test_kernel_evaluation_at_identity(self): + """Test that K(0) equals the normalization constant. + + At ω = 0, cos(0/2) = 1, so K(0) = C · 1^(2κ) = C. + """ + halfwidth_rad = np.radians(4.0) + kernel = DeLaValleePoussinKernel(halfwidth=halfwidth_rad) + + identity = np.eye(3) + value = kernel.eval(identity, identity) + + # Verify against independently computed norm constant + expected_C = ( + betafn(1.5, 0.5) + / betafn(1.5, kernel.kappa + 0.5) + ) + self.assertAlmostEqual(value, expected_C, places=5) + self.assertAlmostEqual( + value, kernel.norm_constant, places=10 + ) + + def test_kernel_decreases_with_misorientation(self): + """Test that kernel value decreases as misorientation grows.""" + halfwidth_rad = np.radians(4.0) + kernel = DeLaValleePoussinKernel(halfwidth=halfwidth_rad) + + identity = np.eye(3) + + # 22.5° rotation about x-axis + rotated = np.array([ + [1.0000, 0.0000, 0.0000], + [0.0000, 0.9239, -0.3827], + [0.0000, 0.3827, 0.9239], + ]) + + value_identity = kernel.eval(identity, identity) + value_rotated = kernel.eval(identity, rotated) + + # 22.5° is well beyond the 4° halfwidth, so kernel ≈ 0 + self.assertGreater(value_identity, value_rotated) + self.assertAlmostEqual(value_rotated, 0.0, places=3) + + def test_halfwidth_is_half_maximum(self): + """Test that K(halfwidth) = K(0) / 2.""" + halfwidth_rad = np.radians(4.0) + kernel = DeLaValleePoussinKernel(halfwidth=halfwidth_rad) + + # K(0) = norm_constant + peak = kernel.norm_constant + + # Evaluate at exactly the halfwidth angle + co2 = np.cos(halfwidth_rad / 2.0) + value_at_hw = ( + kernel.norm_constant * co2 ** (2.0 * kernel.kappa) + ) + + self.assertAlmostEqual(value_at_hw, peak / 2.0, places=5) + + # --- Invalid input tests --- + + def test_negative_halfwidth_raises(self): + """Test that negative half-width raises ValueError.""" + with self.assertRaises(ValueError): + DeLaValleePoussinKernel(halfwidth=-0.1) + + def test_zero_halfwidth_raises(self): + """Test that zero half-width raises ValueError.""" + with self.assertRaises(ValueError): + DeLaValleePoussinKernel(halfwidth=0.0) + + def test_large_halfwidth_warns(self): + """Test that half-width > π/2 emits a warning.""" + with self.assertWarns(UserWarning): + DeLaValleePoussinKernel(halfwidth=np.radians(100)) + + # --- misorientation_angle tests --- + + def test_misorientation_angle_identity(self): + """Test misorientation angle between identical rotations is 0.""" + kernel = DeLaValleePoussinKernel(halfwidth=np.radians(10)) + angle = kernel.misorientation_angle(np.eye(3), np.eye(3)) + self.assertAlmostEqual(float(angle), 0.0, places=10) + + def test_misorientation_angle_known_rotation(self): + """Test misorientation angle for a known rotation.""" + kernel = DeLaValleePoussinKernel(halfwidth=np.radians(10)) + + # 90° rotation about z-axis + R90z = np.array([ + [0, -1, 0], + [1, 0, 0], + [0, 0, 1], + ], dtype=float) + + angle = kernel.misorientation_angle(np.eye(3), R90z) + self.assertAlmostEqual( + float(angle), np.radians(90), places=8 + ) + + def test_misorientation_angle_bad_shape(self): + """Test that non-(3,3) inputs raise ValueError.""" + kernel = DeLaValleePoussinKernel(halfwidth=np.radians(10)) + with self.assertRaises(ValueError): + kernel.misorientation_angle( + np.eye(2), np.eye(3) + ) + + # --- eval_centered test --- + + def test_eval_centered_matches_eval(self): + """Test that eval_centered(R, center) == eval(center, R).""" + kernel = DeLaValleePoussinKernel( + halfwidth=np.radians(10) + ) + center = np.eye(3) + R = np.array([ + [0, -1, 0], + [1, 0, 0], + [0, 0, 1], + ], dtype=float) + + val_eval = kernel.eval(center, R) + val_centered = kernel.eval_centered(R, center) + self.assertAlmostEqual( + float(val_eval), float(val_centered), places=10 + ) + + # --- Batch evaluation test --- + + def test_batch_evaluation(self): + """Test eval with a batch of rotation matrices (N, 3, 3).""" + kernel = DeLaValleePoussinKernel( + halfwidth=np.radians(10) + ) + identity = np.eye(3) + + # Build batch: identity + 90° rotations about x, y, z + R90x = np.array([ + [1, 0, 0], + [0, 0, -1], + [0, 1, 0], + ], dtype=float) + R90y = np.array([ + [ 0, 0, 1], + [ 0, 1, 0], + [-1, 0, 0], + ], dtype=float) + R90z = np.array([ + [0, -1, 0], + [1, 0, 0], + [0, 0, 1], + ], dtype=float) + batch = np.stack([identity, R90x, R90y, R90z]) + + values = kernel.eval(identity, batch) + + self.assertEqual(values.shape, (4,)) + # First entry (identity vs identity) should be the peak + self.assertAlmostEqual( + values[0], kernel.norm_constant, places=5 + ) + # Remaining entries (90° away) should be near zero + for val in values[1:]: + self.assertAlmostEqual(float(val), 0.0, places=5) +