midas-plotting 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/PKG-INFO +1 -1
  2. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/__init__.py +1 -1
  3. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/ff.py +11 -9
  4. midas_plotting-0.5.0/midas_plotting/ipf.py +346 -0
  5. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/PKG-INFO +1 -1
  6. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/pyproject.toml +1 -1
  7. midas_plotting-0.5.0/tests/test_ipf.py +204 -0
  8. midas_plotting-0.4.0/midas_plotting/ipf.py +0 -184
  9. midas_plotting-0.4.0/tests/test_ipf.py +0 -100
  10. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/README.md +0 -0
  11. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/cli.py +0 -0
  12. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/grains.py +0 -0
  13. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/laue.py +0 -0
  14. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/maps.py +0 -0
  15. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/mic.py +0 -0
  16. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting/solutions.py +0 -0
  17. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/SOURCES.txt +0 -0
  18. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/dependency_links.txt +0 -0
  19. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/entry_points.txt +0 -0
  20. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/requires.txt +0 -0
  21. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/midas_plotting.egg-info/top_level.txt +0 -0
  22. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/setup.cfg +0 -0
  23. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/tests/test_ff.py +0 -0
  24. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/tests/test_laue.py +0 -0
  25. {midas_plotting-0.4.0 → midas_plotting-0.5.0}/tests/test_mic.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: midas-plotting
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels.
5
5
  Author-email: Hemant Sharma <hsharma@anl.gov>
6
6
  License: BSD-3-Clause
@@ -48,7 +48,7 @@ from .solutions import (
48
48
  )
49
49
  from .mic import MicMap, read_mic
50
50
 
51
- __version__ = "0.4.0"
51
+ __version__ = "0.5.0"
52
52
  __all__ = [
53
53
  "MicMap", "read_mic", "GrainList", "read_grains", "ff", "laue",
54
54
  "LaueSolutions", "LaueSpots", "read_solutions", "read_spots",
@@ -26,7 +26,8 @@ from typing import Optional, Sequence
26
26
  import numpy as np
27
27
 
28
28
  from .grains import GrainList, read_grains
29
- from .ipf import direction_rgb, ipf_rgb_from_matrix, laue_class, sym_matrices, CUBIC
29
+ from .ipf import (direction_rgb, ipf_rgb_from_matrix, laue_class, sym_matrices,
30
+ sector_deg, triangle_corners, CUBIC)
30
31
 
31
32
  __all__ = [
32
33
  "ipf_legend", "grain_map", "grain_map_3d", "grain_size_distribution",
@@ -96,11 +97,13 @@ def ipf_legend(space_group: int = 225, ax=None, *, n: int = 400,
96
97
 
97
98
  fam = laue_class(space_group)
98
99
  # Sample the stereographic plane, back-project to directions, keep the ones
99
- # already inside the standard triangle.
100
- if fam == CUBIC:
101
- corners = np.array([[0, 0, 1.0], [1, 0, 1.0], [1, 1, 1.0]])
102
- else:
103
- corners = np.array([[0, 0, 1.0], [1, 0, 0.0], [np.sqrt(3) / 2, 0.5, 0.0]])
100
+ # already inside the standard triangle. Corners and the azimuthal sector
101
+ # come from midas_plotting.ipf, the SAME source the colour ramps fold by,
102
+ # so the key cannot describe a different triangle from the one drawn. The
103
+ # `else` branch here used to hard-code the 30 deg hexagonal triangle, which
104
+ # would have drawn a hexagonal key over a 4/mmm map once the tetragonal and
105
+ # orthorhombic triangles were added.
106
+ corners, labels = triangle_corners(space_group)
104
107
  # Normalise BEFORE projecting: the stereographic map is defined on unit
105
108
  # vectors. Projecting the raw index triple puts [111] at (0.5, 0.5)
106
109
  # instead of (0.366, 0.366), so the corner marker and its label sit
@@ -122,7 +125,8 @@ def ipf_legend(space_group: int = 225, ax=None, *, n: int = 400,
122
125
  (d[:, 1] <= d[:, 0] + 1e-9) & (d[:, 0] <= d[:, 2] + 1e-9)
123
126
  else:
124
127
  az = np.degrees(np.arctan2(d[:, 1], d[:, 0]))
125
- inside = (d[:, 2] >= -1e-9) & (az >= -1e-9) & (az <= 30.0 + 1e-9)
128
+ inside = (d[:, 2] >= -1e-9) & (az >= -1e-9) & \
129
+ (az <= sector_deg(space_group) + 1e-9)
126
130
 
127
131
  rgb = np.ones((d.shape[0], 3))
128
132
  if inside.any():
@@ -132,8 +136,6 @@ def ipf_legend(space_group: int = 225, ax=None, *, n: int = 400,
132
136
 
133
137
  ax.imshow(img, origin="lower", alpha=alpha,
134
138
  extent=(x0, x1, y0, y1))
135
- labels = (["[001]", "[101]", "[111]"] if fam == CUBIC
136
- else ["[0001]", r"[10$\bar{1}$0]", r"[2$\bar{1}\bar{1}$0]"])
137
139
  for (px, py), lab in zip(zip(cx, cy), labels):
138
140
  ax.plot(px, py, "k.", ms=4)
139
141
  ax.annotate(lab, (px, py), textcoords="offset points",
@@ -0,0 +1,346 @@
1
+ """Inverse-pole-figure colouring.
2
+
3
+ Colour encodes which crystal direction is parallel to a chosen sample axis, so
4
+ one grain is one colour and boundaries appear as colour discontinuities. That is
5
+ the property an Euler-to-RGB dump does NOT have: two orientations a fraction of
6
+ a degree apart can land on very different Euler triplets (and hence very
7
+ different colours) near the gimbal-lock line, which makes a single grain look
8
+ like several.
9
+
10
+ Symmetry operators come from :mod:`midas_stress`, never hand-listed here.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Sequence
15
+
16
+ import math
17
+ import numpy as np
18
+
19
+ __all__ = ["ipf_rgb", "ipf_rgb_from_matrix", "direction_rgb", "laue_class",
20
+ "sym_matrices", "sector_deg", "triangle_corners",
21
+ "CUBIC", "HEXAGONAL", "TRIGONAL", "TETRAGONAL", "ORTHORHOMBIC"]
22
+
23
+ CUBIC = "cubic"
24
+ TETRAGONAL = "tetragonal"
25
+ ORTHORHOMBIC = "orthorhombic"
26
+ HEXAGONAL = "hexagonal"
27
+ TRIGONAL = "trigonal"
28
+
29
+ # Laue class per space-group range, for the ones MIDAS actually reconstructs.
30
+ # Deliberately explicit rather than clever: a wrong guess here silently
31
+ # recolours a map without any other symptom.
32
+ #
33
+ # The ranges are LAUE CLASSES, not crystal systems, and the two do not coincide
34
+ # in the tetragonal case: 89-142 is 4/mmm, whose triangle closes at 45 deg on
35
+ # [110], while 75-88 is 4/m, which has no in-plane mirror and whose triangle
36
+ # spans the full 90 deg to [010]. Colouring a 4/m crystal with the 4/mmm
37
+ # triangle folds by a 2-fold it does not have, so 75-88 is left OUT and refuses
38
+ # rather than guessing. Implement `_rgb_tetragonal_4m` before widening it.
39
+ _SG_LAUE = [
40
+ (195, 230, CUBIC),
41
+ (168, 194, HEXAGONAL),
42
+ (143, 167, TRIGONAL),
43
+ (89, 142, TETRAGONAL), # 4/mmm only -- see the note above
44
+ (16, 74, ORTHORHOMBIC),
45
+ ]
46
+
47
+
48
+ def laue_class(space_group: int) -> str:
49
+ """Laue family used for the IPF triangle.
50
+
51
+ Raises for space groups whose triangle is not implemented, rather than
52
+ falling back to cubic -- a silent fallback would produce a plausible-looking
53
+ but meaningless map.
54
+ """
55
+ for lo, hi, name in _SG_LAUE:
56
+ if lo <= int(space_group) <= hi:
57
+ return name
58
+ raise NotImplementedError(
59
+ f"IPF colouring for space group {space_group} is not implemented "
60
+ f"(have: cubic 195-230, hexagonal 168-194, trigonal 143-167, "
61
+ f"tetragonal 4/mmm 89-142, orthorhombic 16-74). Refusing to guess. "
62
+ f"Note 75-88 is Laue class 4/m, whose triangle spans 90 deg, not the "
63
+ f"45 deg of 4/mmm; it is deliberately not implemented."
64
+ )
65
+
66
+
67
+ def sym_matrices(space_group: int) -> np.ndarray:
68
+ """``(n_sym, 3, 3)`` proper-rotation operators from midas_stress."""
69
+ from midas_stress.orientation import make_symmetries, quat_to_orient_mat
70
+
71
+ n, quats = make_symmetries(int(space_group))
72
+ q = np.asarray(quats)[: int(n)]
73
+ return np.stack([np.asarray(quat_to_orient_mat(qi)).reshape(3, 3) for qi in q])
74
+
75
+
76
+ def _reduce_cubic(d: np.ndarray) -> np.ndarray:
77
+ """Fold directions into the standard [001]-[101]-[111] triangle."""
78
+ d = np.abs(d)
79
+ d = np.sort(d, axis=-1) # u <= v <= w
80
+ return d
81
+
82
+
83
+ def _rgb_cubic(d: np.ndarray) -> np.ndarray:
84
+ u, v, w = d[:, 0], d[:, 1], d[:, 2]
85
+ rgb = np.stack([w - v, (v - u) * np.sqrt(2.0), u * np.sqrt(3.0)], axis=1)
86
+ return rgb
87
+
88
+
89
+
90
+ def _rgb_tetragonal(d: np.ndarray) -> np.ndarray:
91
+ """Standard [001]-[100]-[110] triangle for Laue class 4/mmm.
92
+
93
+ Same construction as :func:`_rgb_hexagonal`: red is the c-axis component,
94
+ and the in-plane azimuth interpolates green -> blue across the triangle's
95
+ angular width, here 45 degrees rather than 30. Added for I4/mmm (SG 139),
96
+ the Ruddlesden-Popper subcell — before this the function raised, which was
97
+ correct: colouring a tetragonal crystal with the cubic triangle folds by a
98
+ 3-fold the crystal does not have.
99
+ """
100
+ dz = np.abs(d[:, 2])
101
+ planar = np.hypot(d[:, 0], d[:, 1])
102
+ phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
103
+ phi = np.minimum(phi % 90.0, 90.0 - (phi % 90.0)) # fold to [0, 45]
104
+ t = np.clip(phi / 45.0, 0.0, 1.0)
105
+ return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
106
+
107
+
108
+ def _rgb_orthorhombic(d: np.ndarray) -> np.ndarray:
109
+ """Standard [001]-[100]-[010] triangle for Laue class mmm.
110
+
111
+ The triangle spans a full 90 degrees of azimuth because mmm has no
112
+ in-plane rotation relating a to b. Added for Fmmm (SG 69).
113
+ """
114
+ dz = np.abs(d[:, 2])
115
+ planar = np.hypot(d[:, 0], d[:, 1])
116
+ phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
117
+ t = np.clip(phi / 90.0, 0.0, 1.0)
118
+ return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
119
+
120
+
121
+ def _rgb_hexagonal(d: np.ndarray) -> np.ndarray:
122
+ """Standard [0001]-[10-10]-[2-1-10] triangle.
123
+
124
+ ``d`` is Cartesian with c along +z. After symmetry reduction the
125
+ representative has ``dz >= 0`` and azimuth in ``[0, 30]`` degrees.
126
+ """
127
+ dz = np.abs(d[:, 2])
128
+ planar = np.hypot(d[:, 0], d[:, 1])
129
+ phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
130
+ phi = np.minimum(phi % 60.0, 60.0 - (phi % 60.0)) # fold to [0, 30]
131
+ t = np.clip(phi / 30.0, 0.0, 1.0)
132
+ return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
133
+
134
+
135
+ def _trigonal_sector_deg(sym: np.ndarray) -> float:
136
+ """Azimuthal fundamental-sector width, measured from the operators.
137
+
138
+ Counts the distinct upper-hemisphere azimuths a generic direction is sent
139
+ to by the group plus the Laue centre. For -3m that is 6 images, so 60 deg
140
+ -- twice the hexagonal 30. Measured rather than hard-coded so a change in
141
+ the operator set cannot silently mis-scale the colour ramp.
142
+ """
143
+ g = np.array([0.3411, 0.1297, 0.4271]) # generic, no special azimuth
144
+ g = g / np.linalg.norm(g)
145
+ both = np.concatenate([sym @ g, -(sym @ g)], axis=0)
146
+ up = both[both[:, 2] >= -1e-12]
147
+ phi = np.sort(np.degrees(np.arctan2(up[:, 1], up[:, 0])) % 360.0)
148
+ keep = [phi[0]]
149
+ for a in phi[1:]:
150
+ if a - keep[-1] > 1e-6:
151
+ keep.append(a)
152
+ return 360.0 / len(keep)
153
+
154
+
155
+ def _rgb_trigonal(d_all: np.ndarray, sym: np.ndarray) -> np.ndarray:
156
+ """Standard [0001]-[10-10]-[01-10] triangle for Laue class -3m.
157
+
158
+ D3 has 6 proper rotations (3-fold about c, three 2-fold in the basal
159
+ plane) against 6/mmm's 12, so the sector spans 60 deg, not 30. Colouring
160
+ R-3m with the hexagonal triangle folds by a 6-fold axis the crystal does
161
+ not have and silently gives distinct orientations the same colour.
162
+
163
+ The azimuth is NOT hand-folded. Where MIDAS puts the 2-fold axes is a
164
+ convention, and assuming one sits at azimuth 0 gives a colouring that is
165
+ not symmetry invariant (measured: colour moved by up to 0.99 under
166
+ ``g -> S.g``). Instead take the whole orbit, add ``-d`` for the Laue
167
+ centre, keep the upper hemisphere and pick the smallest azimuth --
168
+ canonical whatever the operator convention.
169
+
170
+ ``d_all`` is ``(n, n_sym, 3)``, every symmetry image of each direction.
171
+ """
172
+ both = np.concatenate([d_all, -d_all], axis=1) # (n, 2s, 3)
173
+ phi = np.degrees(np.arctan2(both[:, :, 1], both[:, :, 0])) % 360.0
174
+ phi = np.where(both[:, :, 2] >= -1e-12, phi, np.inf) # upper only
175
+ k = np.argmin(phi, axis=1)
176
+ idx = np.arange(both.shape[0])
177
+ rep, phi_c = both[idx, k], phi[idx, k]
178
+ phi_c = np.where(np.isfinite(phi_c), phi_c, 0.0)
179
+ rep = rep / np.linalg.norm(rep, axis=1, keepdims=True)
180
+
181
+ dz = np.abs(rep[:, 2])
182
+ planar = np.hypot(rep[:, 0], rep[:, 1])
183
+ t = np.clip(phi_c / _trigonal_sector_deg(sym), 0.0, 1.0)
184
+ return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
185
+
186
+
187
+ def ipf_rgb(
188
+ euler: np.ndarray,
189
+ space_group: int = 225,
190
+ axis: Sequence[float] = (0.0, 0.0, 1.0),
191
+ *,
192
+ gamma: float = 0.5,
193
+ ) -> np.ndarray:
194
+ """RGB per orientation for the crystal direction parallel to ``axis``.
195
+
196
+ Parameters
197
+ ----------
198
+ euler : (N, 3) array
199
+ Bunge ZXZ Euler angles in **radians** -- the MIDAS ``.mic`` convention.
200
+ space_group : int
201
+ Used for the symmetry operators and to pick the triangle.
202
+ axis : length-3
203
+ Sample-frame direction. ``(0,0,1)`` gives the usual IPF-Z.
204
+ gamma : float
205
+ Perceptual lift applied as ``rgb ** gamma``. 0.5 (sqrt) matches the
206
+ common convention; 1.0 disables it.
207
+
208
+ Returns
209
+ -------
210
+ (N, 3) float array in [0, 1].
211
+ """
212
+ from midas_stress.orientation import euler_to_orient_mat_batch
213
+
214
+ euler = np.asarray(euler, dtype=float).reshape(-1, 3)
215
+ if euler.size == 0:
216
+ return np.zeros((0, 3))
217
+ g = np.asarray(euler_to_orient_mat_batch(euler)).reshape(-1, 3, 3)
218
+ return ipf_rgb_from_matrix(g, space_group, axis, gamma=gamma)
219
+
220
+
221
+ def ipf_rgb_from_matrix(
222
+ orient_mat: np.ndarray,
223
+ space_group: int = 225,
224
+ axis: Sequence[float] = (0.0, 0.0, 1.0),
225
+ *,
226
+ gamma: float = 0.5,
227
+ ) -> np.ndarray:
228
+ """RGB per orientation, from ``(N, 3, 3)`` orientation matrices.
229
+
230
+ The same colouring as :func:`ipf_rgb`, entered from the matrix rather than
231
+ from Euler angles. Far-field ``Grains.csv`` carries both (``O11..O33`` and
232
+ ``Eul0..2``); this avoids a needless matrix -> Euler -> matrix round trip,
233
+ which is lossy near the gimbal-lock configurations of the ZXZ convention.
234
+ """
235
+ g = np.asarray(orient_mat, dtype=float).reshape(-1, 3, 3)
236
+ if g.size == 0:
237
+ return np.zeros((0, 3))
238
+
239
+ a = np.asarray(axis, dtype=float)
240
+ n = np.linalg.norm(a)
241
+ if n == 0:
242
+ raise ValueError("axis must be non-zero")
243
+ a = a / n
244
+
245
+ # TRANSPOSE. MIDAS orientation matrices map CRYSTAL -> LAB (v_lab = g v_crystal),
246
+ # so the crystal direction parallel to the sample axis `a` is g^T a, not g a.
247
+ # This is not cosmetic: with g a, the colour is NOT symmetry-invariant for this
248
+ # convention -- the 24 equally valid representations of one grain spread over
249
+ # 0.96 in RGB, so a map's colour depended on which variant the indexer happened
250
+ # to store. Two grains agreeing to 0.5 deg came out 0.42 apart in RGB.
251
+ # Fixed 2026-09-03; see tests::test_ipf_colour_is_symmetry_invariant.
252
+ d = np.einsum("nji,j->ni", g, a) # crystal dir of the axis = g^T a
253
+ return direction_rgb(d, space_group, gamma=gamma)
254
+
255
+
256
+ def direction_rgb(
257
+ dirs: np.ndarray, space_group: int = 225, *, gamma: float = 0.5,
258
+ ) -> np.ndarray:
259
+ """RGB for **crystal directions** -- the colouring core.
260
+
261
+ ``dirs`` is ``(N, 3)`` in crystal coordinates; it is normalised here.
262
+ Both :func:`ipf_rgb` and the legend drawn by
263
+ ``midas_plotting.ff.ipf_legend`` go through this, so the key on a figure
264
+ is guaranteed to match the colours in the map beside it. A legend computed
265
+ by a separate copy of the triangle maths is a legend that eventually lies.
266
+ """
267
+ d = np.asarray(dirs, dtype=float).reshape(-1, 3)
268
+ if d.size == 0:
269
+ return np.zeros((0, 3))
270
+ nrm = np.linalg.norm(d, axis=1, keepdims=True)
271
+ d = np.divide(d, nrm, out=np.zeros_like(d), where=nrm > 0)
272
+
273
+ fam = laue_class(space_group)
274
+ sym = sym_matrices(space_group)
275
+ d = np.einsum("sij,nj->nsi", sym, d) # every equivalent
276
+
277
+ if fam == CUBIC:
278
+ red = _reduce_cubic(d)
279
+ pick = np.argmax(red[:, :, 2], axis=1) # closest to [001]
280
+ red = red[np.arange(red.shape[0]), pick]
281
+ red /= np.linalg.norm(red, axis=1, keepdims=True)
282
+ rgb = _rgb_cubic(red)
283
+ elif fam == TRIGONAL:
284
+ rgb = _rgb_trigonal(d, sym)
285
+ elif fam in (TETRAGONAL, ORTHORHOMBIC):
286
+ dd = d.copy()
287
+ dd[:, :, 2] = np.abs(dd[:, :, 2])
288
+ pick = np.argmax(dd[:, :, 2], axis=1) # closest to [001]
289
+ red = dd[np.arange(dd.shape[0]), pick]
290
+ red /= np.linalg.norm(red, axis=1, keepdims=True)
291
+ rgb = (_rgb_tetragonal(red) if fam == TETRAGONAL
292
+ else _rgb_orthorhombic(red))
293
+ else:
294
+ dd = d.copy()
295
+ dd[:, :, 2] = np.abs(dd[:, :, 2])
296
+ pick = np.argmax(dd[:, :, 2], axis=1) # closest to [0001]
297
+ red = dd[np.arange(dd.shape[0]), pick]
298
+ red /= np.linalg.norm(red, axis=1, keepdims=True)
299
+ rgb = _rgb_hexagonal(red)
300
+
301
+ rgb = np.clip(rgb, 0.0, None)
302
+ mx = rgb.max(axis=1, keepdims=True)
303
+ rgb = np.where(mx > 0, rgb / mx, rgb)
304
+ return np.clip(rgb ** float(gamma), 0.0, 1.0)
305
+
306
+ def sector_deg(space_group: int) -> float:
307
+ """Azimuthal width of the standard triangle, in degrees.
308
+
309
+ The SAME number the colour ramps fold by, so a legend built from this
310
+ cannot disagree with the map it explains. Cubic is excluded: its triangle
311
+ closes on [111], not on an in-plane direction, so an azimuth does not
312
+ describe it.
313
+ """
314
+ fam = laue_class(space_group)
315
+ if fam == CUBIC:
316
+ raise ValueError("the cubic triangle is not defined by an azimuth; "
317
+ "its third corner is [111]")
318
+ if fam == TETRAGONAL:
319
+ return 45.0
320
+ if fam == ORTHORHOMBIC:
321
+ return 90.0
322
+ if fam == HEXAGONAL:
323
+ return 30.0
324
+ if fam == TRIGONAL:
325
+ return _trigonal_sector_deg(sym_matrices(space_group))
326
+ raise NotImplementedError(f"no triangle implemented for {fam}")
327
+
328
+
329
+ def triangle_corners(space_group: int):
330
+ """``(corners, labels)`` for the standard triangle of this Laue class.
331
+
332
+ Cubic closes on [111]; every other implemented class is
333
+ [001] - [100] - (the in-plane direction at :func:`sector_deg`).
334
+ """
335
+ fam = laue_class(space_group)
336
+ if fam == CUBIC:
337
+ return (np.array([[0., 0., 1.], [1., 0., 1.], [1., 1., 1.]]),
338
+ ["[001]", "[101]", "[111]"])
339
+ phi = math.radians(sector_deg(space_group))
340
+ corners = np.array([[0., 0., 1.], [1., 0., 0.],
341
+ [math.cos(phi), math.sin(phi), 0.]])
342
+ lab = {TETRAGONAL: ["[001]", "[100]", "[110]"],
343
+ ORTHORHOMBIC: ["[001]", "[100]", "[010]"],
344
+ HEXAGONAL: ["[0001]", r"[10$\bar{1}$0]", r"[2$\bar{1}\bar{1}$0]"],
345
+ TRIGONAL: ["[0001]", r"[10$\bar{1}$0]", r"[01$\bar{1}$0]"]}[fam]
346
+ return corners, lab
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: midas-plotting
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels.
5
5
  Author-email: Hemant Sharma <hsharma@anl.gov>
6
6
  License: BSD-3-Clause
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "midas-plotting"
7
- version = "0.4.0"
7
+ version = "0.5.0"
8
8
  description = "Standard plots for MIDAS reconstructions - near-field, far-field and Laue: IPF maps and legends, grain maps, pole figures, strain and size distributions, and Laue texture diagnostics against their chance levels."
9
9
  readme = "README.md"
10
10
  license = { text = "BSD-3-Clause" }
@@ -0,0 +1,204 @@
1
+ """IPF colouring.
2
+
3
+ The property that matters: colour is a function of the crystal direction along
4
+ a sample axis, so it is invariant under symmetry-equivalent descriptions of the
5
+ same orientation. That is what makes one grain one colour, and it is what
6
+ Euler->RGB does not give.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+ import pytest
12
+
13
+ from midas_plotting.ipf import CUBIC, HEXAGONAL, ipf_rgb, laue_class, sym_matrices
14
+
15
+
16
+ def test_laue_class_known_families():
17
+ assert laue_class(225) == CUBIC
18
+ assert laue_class(229) == CUBIC
19
+ assert laue_class(194) == HEXAGONAL
20
+ assert laue_class(168) == HEXAGONAL
21
+
22
+
23
+ def test_laue_class_refuses_unimplemented_rather_than_guessing():
24
+ """A silent fallback to cubic would recolour a map with no other symptom.
25
+
26
+ SG 139 (tetragonal) and 69 (orthorhombic) were on this list until their
27
+ triangles were implemented; triclinic and monoclinic still are not, and the
28
+ contract is that they REFUSE rather than guess.
29
+
30
+ SG 75-88 is the subtle one. It is tetragonal by CRYSTAL SYSTEM but its Laue
31
+ class is 4/m, not 4/mmm: no in-plane mirror, so its triangle spans 90 deg to
32
+ [010] rather than 45 deg to [110]. Colouring it with the 4/mmm ramp folds by
33
+ a 2-fold the crystal does not have and yields a plausible, wrong map. It is
34
+ deliberately excluded until `_rgb_tetragonal_4m` exists.
35
+ """
36
+ for sg in (1, 2, 5, 15): # triclinic + monoclinic
37
+ with pytest.raises(NotImplementedError, match="not implemented"):
38
+ laue_class(sg)
39
+ for sg in (75, 81, 88): # Laue class 4/m, NOT 4/mmm
40
+ with pytest.raises(NotImplementedError, match="not implemented"):
41
+ laue_class(sg)
42
+
43
+
44
+ def test_tetragonal_range_is_the_laue_class_not_the_crystal_system():
45
+ """4/mmm starts at 89. The boundary is the whole point of the split."""
46
+ from midas_plotting.ipf import laue_class, sector_deg, TETRAGONAL
47
+ with pytest.raises(NotImplementedError):
48
+ laue_class(88)
49
+ assert laue_class(89) == TETRAGONAL
50
+ assert sector_deg(89) == 45.0 and sector_deg(142) == 45.0
51
+
52
+
53
+ def test_tetragonal_and_orthorhombic_triangles_are_distinct():
54
+ """4/mmm and mmm must not be the same colouring, and neither may be cubic.
55
+
56
+ The discriminator is [010]: under 4/mmm a 4-fold makes it equivalent to
57
+ [100] (both GREEN), while mmm has no in-plane rotation so [010] is the
58
+ third vertex (BLUE). A tetragonal map coloured with the mmm triangle -- or
59
+ with the cubic one, which folds by a 3-fold the crystal does not have --
60
+ would look plausible and be wrong.
61
+ """
62
+ import numpy as np
63
+ from midas_plotting.ipf import direction_rgb
64
+ dirs = np.array([[0., 0., 1.], [1., 0., 0.], [0., 1., 0.]])
65
+ tet = direction_rgb(dirs, space_group=139)
66
+ ort = direction_rgb(dirs, space_group=69)
67
+ assert tet[0, 0] > 0.9 and ort[0, 0] > 0.9 # [001] is red in both
68
+ assert np.allclose(tet[1], tet[2], atol=1e-6), "4/mmm: [100] and [010] must match"
69
+ assert not np.allclose(ort[1], ort[2], atol=0.5), "mmm: [100] and [010] must differ"
70
+ assert ort[2, 2] > 0.9, "mmm: [010] is the blue vertex"
71
+
72
+
73
+ def test_sym_matrices_are_proper_rotations():
74
+ for sg, n_expected in ((225, 24), (194, 12)):
75
+ S = sym_matrices(sg)
76
+ assert S.shape == (n_expected, 3, 3), (sg, S.shape)
77
+ dets = np.linalg.det(S)
78
+ np.testing.assert_allclose(dets, 1.0, atol=1e-9)
79
+ for M in S:
80
+ np.testing.assert_allclose(M @ M.T, np.eye(3), atol=1e-9)
81
+
82
+
83
+ def test_rgb_in_unit_range():
84
+ rng = np.random.default_rng(0)
85
+ e = rng.uniform(0, np.pi, size=(200, 3))
86
+ for sg in (225, 194):
87
+ rgb = ipf_rgb(e, sg)
88
+ assert rgb.shape == (200, 3)
89
+ assert rgb.min() >= 0.0 and rgb.max() <= 1.0
90
+ assert np.isfinite(rgb).all()
91
+
92
+
93
+ def test_cube_on_axis_is_red():
94
+ """The identity orientation puts [001] along Z -> the [001] triangle corner."""
95
+ rgb = ipf_rgb(np.zeros((1, 3)), 225)[0]
96
+ assert rgb[0] > 0.9
97
+ assert rgb[1] < 0.2 and rgb[2] < 0.2
98
+
99
+
100
+ def test_symmetry_equivalent_orientations_get_the_same_colour():
101
+ """The whole point: one grain, one colour.
102
+
103
+ Rotating by a symmetry operator describes the SAME crystal, so the colour
104
+ must not move. An Euler->RGB scheme fails this badly.
105
+ """
106
+ from midas_stress.orientation import (
107
+ euler_to_orient_mat_batch, orient_mat_to_euler,
108
+ )
109
+
110
+ rng = np.random.default_rng(3)
111
+ e0 = rng.uniform(0, np.pi, size=(12, 3))
112
+ g0 = np.asarray(euler_to_orient_mat_batch(e0)).reshape(-1, 3, 3)
113
+ S = sym_matrices(225)
114
+
115
+ base = ipf_rgb(e0, 225)
116
+ for op in S[1:6]:
117
+ # symmetry acts on the RIGHT for crystal->lab matrices (MIDAS convention):
118
+ # midas_stress.misorientation_om_batch calls g.S equivalent and S.g a
119
+ # DIFFERENT orientation (61 deg). Asserting the wrong side is what let the
120
+ # g-vs-g^T bug survive until 2026-09-03.
121
+ g1 = np.einsum("nij,jk->nik", g0, op)
122
+ e1 = np.array([np.asarray(orient_mat_to_euler(m.ravel().tolist())).ravel()
123
+ for m in g1])
124
+ np.testing.assert_allclose(ipf_rgb(e1, 225), base, atol=1e-6)
125
+
126
+
127
+ def test_empty_input():
128
+ assert ipf_rgb(np.zeros((0, 3)), 225).shape == (0, 3)
129
+
130
+
131
+ def test_zero_axis_rejected():
132
+ with pytest.raises(ValueError, match="non-zero"):
133
+ ipf_rgb(np.zeros((1, 3)), 225, axis=(0, 0, 0))
134
+
135
+
136
+ def test_gamma_one_disables_the_perceptual_lift():
137
+ e = np.array([[0.3, 0.4, 0.5]])
138
+ lin = ipf_rgb(e, 225, gamma=1.0)
139
+ sq = ipf_rgb(e, 225, gamma=0.5)
140
+ np.testing.assert_allclose(sq, np.sqrt(lin), atol=1e-9)
141
+
142
+
143
+ def test_hexagonal_c_axis_along_z_is_red():
144
+ """[0001] || Z is the first corner of the hexagonal triangle."""
145
+ rgb = ipf_rgb(np.zeros((1, 3)), 194)[0]
146
+ assert rgb[0] > 0.9
147
+
148
+
149
+ # --- trigonal (Laue -3m), added for NMC811 R-3m / SG 166 -------------------
150
+
151
+ def _random_oms(n, seed=0):
152
+ rng = np.random.default_rng(seed)
153
+ q = rng.normal(size=(n, 4))
154
+ q /= np.linalg.norm(q, axis=1, keepdims=True)
155
+ w, x, y, z = q.T
156
+ return np.stack([
157
+ 1 - 2 * (y * y + z * z), 2 * (x * y - z * w), 2 * (x * z + y * w),
158
+ 2 * (x * y + z * w), 1 - 2 * (x * x + z * z), 2 * (y * z - x * w),
159
+ 2 * (x * z - y * w), 2 * (y * z + x * w), 1 - 2 * (x * x + y * y),
160
+ ], axis=1).reshape(-1, 3, 3)
161
+
162
+
163
+ @pytest.mark.parametrize("sg", [166, 194, 225])
164
+ def test_ipf_colour_is_symmetry_invariant(sg):
165
+ """colour(S.g) == colour(g) for every proper rotation S.
166
+
167
+ This is the property that makes an IPF map mean anything: one grain, one
168
+ colour, whichever symmetrically equivalent orientation the indexer
169
+ happened to report. It is also what caught the first -3m triangle, which
170
+ hand-folded the azimuth assuming a 2-fold axis at 0 deg and moved the
171
+ colour by up to 0.99 under this test.
172
+ """
173
+ from midas_plotting.ipf import ipf_rgb_from_matrix, sym_matrices
174
+ g = _random_oms(2000, seed=sg)
175
+ base = ipf_rgb_from_matrix(g, sg)
176
+ for s in sym_matrices(sg):
177
+ got = ipf_rgb_from_matrix(np.einsum("nij,jk->nik", g, s), sg)
178
+ np.testing.assert_allclose(got, base, atol=1e-9)
179
+
180
+
181
+ def test_trigonal_sector_is_twice_the_hexagonal_one():
182
+ """-3m has 6 proper rotations to 6/mmm's 12, so a 60 deg sector, not 30.
183
+
184
+ Measured from the operator set, so a change there cannot silently
185
+ mis-scale the ramp.
186
+ """
187
+ from midas_plotting.ipf import _trigonal_sector_deg, sym_matrices
188
+ assert _trigonal_sector_deg(sym_matrices(166)) == pytest.approx(60.0)
189
+ assert _trigonal_sector_deg(sym_matrices(194)) == pytest.approx(30.0)
190
+
191
+
192
+ def test_trigonal_is_not_the_hexagonal_colouring():
193
+ """R-3m must not be quietly coloured as 6/mmm -- that merges orientations."""
194
+ from midas_plotting.ipf import ipf_rgb_from_matrix
195
+ g = _random_oms(3000, seed=7)
196
+ assert np.abs(ipf_rgb_from_matrix(g, 166)
197
+ - ipf_rgb_from_matrix(g, 194)).mean() > 0.05
198
+
199
+
200
+ def test_laue_class_still_refuses_what_it_cannot_draw():
201
+ from midas_plotting.ipf import laue_class
202
+ assert laue_class(166) == "trigonal"
203
+ with pytest.raises(NotImplementedError):
204
+ laue_class(2) # triclinic: no triangle implemented
@@ -1,184 +0,0 @@
1
- """Inverse-pole-figure colouring.
2
-
3
- Colour encodes which crystal direction is parallel to a chosen sample axis, so
4
- one grain is one colour and boundaries appear as colour discontinuities. That is
5
- the property an Euler-to-RGB dump does NOT have: two orientations a fraction of
6
- a degree apart can land on very different Euler triplets (and hence very
7
- different colours) near the gimbal-lock line, which makes a single grain look
8
- like several.
9
-
10
- Symmetry operators come from :mod:`midas_stress`, never hand-listed here.
11
- """
12
- from __future__ import annotations
13
-
14
- from typing import Sequence
15
-
16
- import numpy as np
17
-
18
- __all__ = ["ipf_rgb", "sym_matrices", "CUBIC", "HEXAGONAL"]
19
-
20
- CUBIC = "cubic"
21
- HEXAGONAL = "hexagonal"
22
-
23
- # Laue class per space-group range, for the ones MIDAS actually reconstructs.
24
- # Deliberately explicit rather than clever: a wrong guess here silently
25
- # recolours a map without any other symptom.
26
- _SG_LAUE = [
27
- (195, 230, CUBIC),
28
- (168, 194, HEXAGONAL),
29
- ]
30
-
31
-
32
- def laue_class(space_group: int) -> str:
33
- """Laue family used for the IPF triangle.
34
-
35
- Raises for space groups whose triangle is not implemented, rather than
36
- falling back to cubic -- a silent fallback would produce a plausible-looking
37
- but meaningless map.
38
- """
39
- for lo, hi, name in _SG_LAUE:
40
- if lo <= int(space_group) <= hi:
41
- return name
42
- raise NotImplementedError(
43
- f"IPF colouring for space group {space_group} is not implemented "
44
- f"(have: cubic 195-230, hexagonal 168-194). Refusing to guess."
45
- )
46
-
47
-
48
- def sym_matrices(space_group: int) -> np.ndarray:
49
- """``(n_sym, 3, 3)`` proper-rotation operators from midas_stress."""
50
- from midas_stress.orientation import make_symmetries, quat_to_orient_mat
51
-
52
- n, quats = make_symmetries(int(space_group))
53
- q = np.asarray(quats)[: int(n)]
54
- return np.stack([np.asarray(quat_to_orient_mat(qi)).reshape(3, 3) for qi in q])
55
-
56
-
57
- def _reduce_cubic(d: np.ndarray) -> np.ndarray:
58
- """Fold directions into the standard [001]-[101]-[111] triangle."""
59
- d = np.abs(d)
60
- d = np.sort(d, axis=-1) # u <= v <= w
61
- return d
62
-
63
-
64
- def _rgb_cubic(d: np.ndarray) -> np.ndarray:
65
- u, v, w = d[:, 0], d[:, 1], d[:, 2]
66
- rgb = np.stack([w - v, (v - u) * np.sqrt(2.0), u * np.sqrt(3.0)], axis=1)
67
- return rgb
68
-
69
-
70
- def _rgb_hexagonal(d: np.ndarray) -> np.ndarray:
71
- """Standard [0001]-[10-10]-[2-1-10] triangle.
72
-
73
- ``d`` is Cartesian with c along +z. After symmetry reduction the
74
- representative has ``dz >= 0`` and azimuth in ``[0, 30]`` degrees.
75
- """
76
- dz = np.abs(d[:, 2])
77
- planar = np.hypot(d[:, 0], d[:, 1])
78
- phi = np.degrees(np.arctan2(np.abs(d[:, 1]), np.abs(d[:, 0])))
79
- phi = np.minimum(phi % 60.0, 60.0 - (phi % 60.0)) # fold to [0, 30]
80
- t = np.clip(phi / 30.0, 0.0, 1.0)
81
- return np.stack([dz, planar * (1.0 - t), planar * t], axis=1)
82
-
83
-
84
- def ipf_rgb(
85
- euler: np.ndarray,
86
- space_group: int = 225,
87
- axis: Sequence[float] = (0.0, 0.0, 1.0),
88
- *,
89
- gamma: float = 0.5,
90
- ) -> np.ndarray:
91
- """RGB per orientation for the crystal direction parallel to ``axis``.
92
-
93
- Parameters
94
- ----------
95
- euler : (N, 3) array
96
- Bunge ZXZ Euler angles in **radians** -- the MIDAS ``.mic`` convention.
97
- space_group : int
98
- Used for the symmetry operators and to pick the triangle.
99
- axis : length-3
100
- Sample-frame direction. ``(0,0,1)`` gives the usual IPF-Z.
101
- gamma : float
102
- Perceptual lift applied as ``rgb ** gamma``. 0.5 (sqrt) matches the
103
- common convention; 1.0 disables it.
104
-
105
- Returns
106
- -------
107
- (N, 3) float array in [0, 1].
108
- """
109
- from midas_stress.orientation import euler_to_orient_mat_batch
110
-
111
- euler = np.asarray(euler, dtype=float).reshape(-1, 3)
112
- if euler.size == 0:
113
- return np.zeros((0, 3))
114
- g = np.asarray(euler_to_orient_mat_batch(euler)).reshape(-1, 3, 3)
115
- return ipf_rgb_from_matrix(g, space_group, axis, gamma=gamma)
116
-
117
-
118
- def ipf_rgb_from_matrix(
119
- orient_mat: np.ndarray,
120
- space_group: int = 225,
121
- axis: Sequence[float] = (0.0, 0.0, 1.0),
122
- *,
123
- gamma: float = 0.5,
124
- ) -> np.ndarray:
125
- """RGB per orientation, from ``(N, 3, 3)`` orientation matrices.
126
-
127
- The same colouring as :func:`ipf_rgb`, entered from the matrix rather than
128
- from Euler angles. Far-field ``Grains.csv`` carries both (``O11..O33`` and
129
- ``Eul0..2``); this avoids a needless matrix -> Euler -> matrix round trip,
130
- which is lossy near the gimbal-lock configurations of the ZXZ convention.
131
- """
132
- g = np.asarray(orient_mat, dtype=float).reshape(-1, 3, 3)
133
- if g.size == 0:
134
- return np.zeros((0, 3))
135
-
136
- a = np.asarray(axis, dtype=float)
137
- n = np.linalg.norm(a)
138
- if n == 0:
139
- raise ValueError("axis must be non-zero")
140
- a = a / n
141
-
142
- d = np.einsum("nij,j->ni", g, a) # crystal dir of the axis
143
- return direction_rgb(d, space_group, gamma=gamma)
144
-
145
-
146
- def direction_rgb(
147
- dirs: np.ndarray, space_group: int = 225, *, gamma: float = 0.5,
148
- ) -> np.ndarray:
149
- """RGB for **crystal directions** -- the colouring core.
150
-
151
- ``dirs`` is ``(N, 3)`` in crystal coordinates; it is normalised here.
152
- Both :func:`ipf_rgb` and the legend drawn by
153
- ``midas_plotting.ff.ipf_legend`` go through this, so the key on a figure
154
- is guaranteed to match the colours in the map beside it. A legend computed
155
- by a separate copy of the triangle maths is a legend that eventually lies.
156
- """
157
- d = np.asarray(dirs, dtype=float).reshape(-1, 3)
158
- if d.size == 0:
159
- return np.zeros((0, 3))
160
- nrm = np.linalg.norm(d, axis=1, keepdims=True)
161
- d = np.divide(d, nrm, out=np.zeros_like(d), where=nrm > 0)
162
-
163
- fam = laue_class(space_group)
164
- sym = sym_matrices(space_group)
165
- d = np.einsum("sij,nj->nsi", sym, d) # every equivalent
166
-
167
- if fam == CUBIC:
168
- red = _reduce_cubic(d)
169
- pick = np.argmax(red[:, :, 2], axis=1) # closest to [001]
170
- red = red[np.arange(red.shape[0]), pick]
171
- red /= np.linalg.norm(red, axis=1, keepdims=True)
172
- rgb = _rgb_cubic(red)
173
- else:
174
- dd = d.copy()
175
- dd[:, :, 2] = np.abs(dd[:, :, 2])
176
- pick = np.argmax(dd[:, :, 2], axis=1) # closest to [0001]
177
- red = dd[np.arange(dd.shape[0]), pick]
178
- red /= np.linalg.norm(red, axis=1, keepdims=True)
179
- rgb = _rgb_hexagonal(red)
180
-
181
- rgb = np.clip(rgb, 0.0, None)
182
- mx = rgb.max(axis=1, keepdims=True)
183
- rgb = np.where(mx > 0, rgb / mx, rgb)
184
- return np.clip(rgb ** float(gamma), 0.0, 1.0)
@@ -1,100 +0,0 @@
1
- """IPF colouring.
2
-
3
- The property that matters: colour is a function of the crystal direction along
4
- a sample axis, so it is invariant under symmetry-equivalent descriptions of the
5
- same orientation. That is what makes one grain one colour, and it is what
6
- Euler->RGB does not give.
7
- """
8
- from __future__ import annotations
9
-
10
- import numpy as np
11
- import pytest
12
-
13
- from midas_plotting.ipf import CUBIC, HEXAGONAL, ipf_rgb, laue_class, sym_matrices
14
-
15
-
16
- def test_laue_class_known_families():
17
- assert laue_class(225) == CUBIC
18
- assert laue_class(229) == CUBIC
19
- assert laue_class(194) == HEXAGONAL
20
- assert laue_class(168) == HEXAGONAL
21
-
22
-
23
- def test_laue_class_refuses_unimplemented_rather_than_guessing():
24
- """A silent fallback to cubic would recolour a map with no other symptom."""
25
- with pytest.raises(NotImplementedError, match="not implemented"):
26
- laue_class(2) # triclinic
27
- with pytest.raises(NotImplementedError):
28
- laue_class(139) # tetragonal
29
-
30
-
31
- def test_sym_matrices_are_proper_rotations():
32
- for sg, n_expected in ((225, 24), (194, 12)):
33
- S = sym_matrices(sg)
34
- assert S.shape == (n_expected, 3, 3), (sg, S.shape)
35
- dets = np.linalg.det(S)
36
- np.testing.assert_allclose(dets, 1.0, atol=1e-9)
37
- for M in S:
38
- np.testing.assert_allclose(M @ M.T, np.eye(3), atol=1e-9)
39
-
40
-
41
- def test_rgb_in_unit_range():
42
- rng = np.random.default_rng(0)
43
- e = rng.uniform(0, np.pi, size=(200, 3))
44
- for sg in (225, 194):
45
- rgb = ipf_rgb(e, sg)
46
- assert rgb.shape == (200, 3)
47
- assert rgb.min() >= 0.0 and rgb.max() <= 1.0
48
- assert np.isfinite(rgb).all()
49
-
50
-
51
- def test_cube_on_axis_is_red():
52
- """The identity orientation puts [001] along Z -> the [001] triangle corner."""
53
- rgb = ipf_rgb(np.zeros((1, 3)), 225)[0]
54
- assert rgb[0] > 0.9
55
- assert rgb[1] < 0.2 and rgb[2] < 0.2
56
-
57
-
58
- def test_symmetry_equivalent_orientations_get_the_same_colour():
59
- """The whole point: one grain, one colour.
60
-
61
- Rotating by a symmetry operator describes the SAME crystal, so the colour
62
- must not move. An Euler->RGB scheme fails this badly.
63
- """
64
- from midas_stress.orientation import (
65
- euler_to_orient_mat_batch, orient_mat_to_euler,
66
- )
67
-
68
- rng = np.random.default_rng(3)
69
- e0 = rng.uniform(0, np.pi, size=(12, 3))
70
- g0 = np.asarray(euler_to_orient_mat_batch(e0)).reshape(-1, 3, 3)
71
- S = sym_matrices(225)
72
-
73
- base = ipf_rgb(e0, 225)
74
- for op in S[1:6]:
75
- g1 = np.einsum("ij,njk->nik", op, g0)
76
- e1 = np.array([np.asarray(orient_mat_to_euler(m.ravel().tolist())).ravel()
77
- for m in g1])
78
- np.testing.assert_allclose(ipf_rgb(e1, 225), base, atol=1e-6)
79
-
80
-
81
- def test_empty_input():
82
- assert ipf_rgb(np.zeros((0, 3)), 225).shape == (0, 3)
83
-
84
-
85
- def test_zero_axis_rejected():
86
- with pytest.raises(ValueError, match="non-zero"):
87
- ipf_rgb(np.zeros((1, 3)), 225, axis=(0, 0, 0))
88
-
89
-
90
- def test_gamma_one_disables_the_perceptual_lift():
91
- e = np.array([[0.3, 0.4, 0.5]])
92
- lin = ipf_rgb(e, 225, gamma=1.0)
93
- sq = ipf_rgb(e, 225, gamma=0.5)
94
- np.testing.assert_allclose(sq, np.sqrt(lin), atol=1e-9)
95
-
96
-
97
- def test_hexagonal_c_axis_along_z_is_red():
98
- """[0001] || Z is the first corner of the hexagonal triangle."""
99
- rgb = ipf_rgb(np.zeros((1, 3)), 194)[0]
100
- assert rgb[0] > 0.9
File without changes
File without changes