midas-plotting 0.3.0__py3-none-any.whl
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.
- midas_plotting/__init__.py +59 -0
- midas_plotting/cli.py +264 -0
- midas_plotting/ff.py +522 -0
- midas_plotting/grains.py +301 -0
- midas_plotting/ipf.py +184 -0
- midas_plotting/laue.py +671 -0
- midas_plotting/maps.py +197 -0
- midas_plotting/mic.py +87 -0
- midas_plotting/solutions.py +398 -0
- midas_plotting-0.3.0.dist-info/METADATA +161 -0
- midas_plotting-0.3.0.dist-info/RECORD +14 -0
- midas_plotting-0.3.0.dist-info/WHEEL +5 -0
- midas_plotting-0.3.0.dist-info/entry_points.txt +2 -0
- midas_plotting-0.3.0.dist-info/top_level.txt +1 -0
midas_plotting/grains.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
"""Reading MIDAS far-field ``Grains.csv``.
|
|
2
|
+
|
|
3
|
+
The FF analogue of :mod:`midas_plotting.mic`: one place that knows the column
|
|
4
|
+
layout so analysis scripts stop re-deriving it.
|
|
5
|
+
|
|
6
|
+
Columns are looked up **by name** from the ``%ID ...`` header line, never by
|
|
7
|
+
position. That matters more here than it looks: ``Grains.csv`` has grown to 47
|
|
8
|
+
columns, and `midas-fit-grain` 0.5.6 shipped a cyclic rotation of the
|
|
9
|
+
``DiffPos`` / ``DiffOme`` / ``DiffAngle`` columns (fixed in 0.5.7) that a
|
|
10
|
+
positional reader would silently inherit -- one grain's ω residual read 223.87°
|
|
11
|
+
where the true value was 0.054°.
|
|
12
|
+
|
|
13
|
+
As a further guard, :func:`read_grains` recomputes the orientation matrix from
|
|
14
|
+
the Euler angles and compares it against the ``O11..O33`` columns. Both describe
|
|
15
|
+
the same orientation, so any disagreement means the row is being sliced wrong
|
|
16
|
+
(or the file was written by a broken version), and it is far better to hear
|
|
17
|
+
about that than to plot it.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import warnings
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Optional
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
__all__ = ["GrainList", "read_grains"]
|
|
29
|
+
|
|
30
|
+
_OM_NAMES = [f"O{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
|
|
31
|
+
_EUL_NAMES = ["Eul0", "Eul1", "Eul2"]
|
|
32
|
+
_LATTICE_NAMES = ["a", "b", "c", "alpha", "beta", "gamma"]
|
|
33
|
+
_FAB = [f"eFab{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
|
|
34
|
+
_KEN = [f"eKen{i}{j}" for i in (1, 2, 3) for j in (1, 2, 3)]
|
|
35
|
+
|
|
36
|
+
#: Euler/orientation-matrix agreement above this is treated as a real
|
|
37
|
+
#: inconsistency. ``Grains.csv`` is written at ~6 significant figures, so
|
|
38
|
+
#: round-trip differences of ~1e-6 are expected and harmless.
|
|
39
|
+
OM_EULER_TOL = 1e-3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class GrainList:
|
|
44
|
+
"""A parsed FF ``Grains.csv``.
|
|
45
|
+
|
|
46
|
+
Attributes
|
|
47
|
+
----------
|
|
48
|
+
ids : (N,) int
|
|
49
|
+
pos : (N, 3) float
|
|
50
|
+
Grain centre-of-mass X, Y, Z in **micrometres**. Trustworthy to about
|
|
51
|
+
~100 µm on a typical reconstruction -- do not read the six decimals the
|
|
52
|
+
file prints (``FF_HEDM_Lab_Notebook.md`` §2d).
|
|
53
|
+
euler : (N, 3) float
|
|
54
|
+
Bunge ZXZ Euler angles in **radians**, matching the ``.mic`` convention
|
|
55
|
+
so :func:`midas_plotting.ipf_rgb` accepts them directly.
|
|
56
|
+
orient_mat : (N, 3, 3) float
|
|
57
|
+
lattice : (N, 6) float
|
|
58
|
+
a, b, c (Å) and alpha, beta, gamma (degrees).
|
|
59
|
+
radius : (N,) float
|
|
60
|
+
``GrainRadius`` in µm. Correct only with ``midas-process-grains``
|
|
61
|
+
>= 0.6.1; older versions report ~the sample-wide mean for every grain.
|
|
62
|
+
completeness : (N,) float
|
|
63
|
+
diff_pos, diff_ome, diff_angle : (N,) float
|
|
64
|
+
Residuals. Cyclically mislabeled by `midas-fit-grain` 0.5.6.
|
|
65
|
+
strain_fab, strain_ken : (N, 3, 3) float or None
|
|
66
|
+
rmse_strain : (N,) float or None
|
|
67
|
+
phase : (N,) int or None
|
|
68
|
+
header : dict
|
|
69
|
+
The ``%key value`` preamble (NumGrains, BeamCenter, ...).
|
|
70
|
+
columns : list of str
|
|
71
|
+
raw : (N, C) float
|
|
72
|
+
path : Path
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
ids: np.ndarray
|
|
76
|
+
pos: np.ndarray
|
|
77
|
+
euler: np.ndarray
|
|
78
|
+
orient_mat: np.ndarray
|
|
79
|
+
lattice: np.ndarray
|
|
80
|
+
radius: np.ndarray
|
|
81
|
+
completeness: np.ndarray
|
|
82
|
+
diff_pos: np.ndarray
|
|
83
|
+
diff_ome: np.ndarray
|
|
84
|
+
diff_angle: np.ndarray
|
|
85
|
+
strain_fab: Optional[np.ndarray]
|
|
86
|
+
strain_ken: Optional[np.ndarray]
|
|
87
|
+
rmse_strain: Optional[np.ndarray]
|
|
88
|
+
phase: Optional[np.ndarray]
|
|
89
|
+
header: dict
|
|
90
|
+
columns: list
|
|
91
|
+
raw: np.ndarray
|
|
92
|
+
path: Path
|
|
93
|
+
|
|
94
|
+
def __len__(self) -> int:
|
|
95
|
+
return int(self.ids.size)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def space_group(self) -> Optional[int]:
|
|
99
|
+
"""Space group from the file's own ``%\tSpaceGroup:`` line, if present.
|
|
100
|
+
|
|
101
|
+
Plot functions default to this rather than to a hard-coded 225, so a
|
|
102
|
+
hexagonal or tetragonal sample is not silently coloured with the cubic
|
|
103
|
+
IPF triangle -- which produces a plausible-looking figure that is
|
|
104
|
+
simply wrong.
|
|
105
|
+
"""
|
|
106
|
+
v = self.header.get("SpaceGroup")
|
|
107
|
+
try:
|
|
108
|
+
return int(str(v).strip())
|
|
109
|
+
except (TypeError, ValueError):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def lattice_parameter(self) -> Optional[np.ndarray]:
|
|
114
|
+
"""The header's reference lattice parameter (a, b, c, al, be, ga)."""
|
|
115
|
+
v = self.header.get("Lattice Parameter")
|
|
116
|
+
if not v:
|
|
117
|
+
return None
|
|
118
|
+
try:
|
|
119
|
+
arr = np.array([float(x) for x in str(v).split()], dtype=float)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
return arr if arr.size == 6 else None
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def n_grains(self) -> int:
|
|
126
|
+
return len(self)
|
|
127
|
+
|
|
128
|
+
def strain(self, convention: str = "fab") -> np.ndarray:
|
|
129
|
+
"""``(N, 3, 3)`` strain tensor in the requested convention."""
|
|
130
|
+
c = convention.lower()
|
|
131
|
+
if c in ("fab", "fable", "efab"):
|
|
132
|
+
s = self.strain_fab
|
|
133
|
+
elif c in ("ken", "kenesei", "eken"):
|
|
134
|
+
s = self.strain_ken
|
|
135
|
+
else:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"unknown strain convention {convention!r}; use 'fab' or 'ken'")
|
|
138
|
+
if s is None:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"{self.path.name} has no {c} strain columns "
|
|
141
|
+
"(ProcessGrains may have been run without strain output)")
|
|
142
|
+
return s
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _parse(path: Path):
|
|
146
|
+
"""Split a Grains.csv into (header dict, column names, data rows).
|
|
147
|
+
|
|
148
|
+
The preamble is not uniform. Real files contain all of:
|
|
149
|
+
|
|
150
|
+
%NumGrains 2
|
|
151
|
+
%PhaseInfo <- key with no value
|
|
152
|
+
%\tSpaceGroup:225 <- TAB-indented `key:value` continuation
|
|
153
|
+
%\tLattice Parameter:4.0782 ... <- key with spaces, colon-separated
|
|
154
|
+
%ID\tO11\t... <- the column header
|
|
155
|
+
|
|
156
|
+
so a naive `body.split()[0]` raises on the indented lines. The column
|
|
157
|
+
header is identified by content (it names the orientation-matrix columns)
|
|
158
|
+
rather than by position, since the number of preamble lines varies with
|
|
159
|
+
the number of phases.
|
|
160
|
+
"""
|
|
161
|
+
header, columns, rows = {}, None, []
|
|
162
|
+
for line in Path(path).read_text().splitlines():
|
|
163
|
+
if not line.strip():
|
|
164
|
+
continue
|
|
165
|
+
if not line.startswith("%"):
|
|
166
|
+
rows.append(line.split("\t"))
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
body = line[1:]
|
|
170
|
+
fields = [f.strip() for f in body.split("\t")]
|
|
171
|
+
# Column header: names O11 (and therefore the whole grain record).
|
|
172
|
+
if "O11" in fields or (fields and fields[0] in ("ID", "GrainID")
|
|
173
|
+
and len(fields) > 5):
|
|
174
|
+
columns = fields
|
|
175
|
+
continue
|
|
176
|
+
|
|
177
|
+
stripped = body.strip()
|
|
178
|
+
if not stripped:
|
|
179
|
+
continue
|
|
180
|
+
if ":" in stripped: # `SpaceGroup:225`
|
|
181
|
+
k, _, v = stripped.partition(":")
|
|
182
|
+
header[k.strip()] = v.strip()
|
|
183
|
+
else: # `NumGrains 2` / `PhaseInfo`
|
|
184
|
+
parts = stripped.split()
|
|
185
|
+
header[parts[0]] = " ".join(parts[1:])
|
|
186
|
+
return header, columns, rows
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def read_grains(path, *, check_orientation: bool = True) -> GrainList:
|
|
190
|
+
"""Parse an FF ``Grains.csv``.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
path : str or Path
|
|
195
|
+
check_orientation : bool
|
|
196
|
+
Recompute the orientation matrix from the Euler columns and compare
|
|
197
|
+
against ``O11..O33``. A mismatch means the columns are being read
|
|
198
|
+
wrong; a warning is emitted rather than an exception, so a file written
|
|
199
|
+
by an old or unusual version can still be inspected -- but do not
|
|
200
|
+
trust orientation-derived output (including IPF colour) when it fires.
|
|
201
|
+
"""
|
|
202
|
+
path = Path(path)
|
|
203
|
+
header, columns, rows = _parse(path)
|
|
204
|
+
if columns is None:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"{path}: no column header found. Expected a line beginning "
|
|
207
|
+
"'%ID' or '%GrainID' listing tab-separated column names.")
|
|
208
|
+
if not rows:
|
|
209
|
+
raise ValueError(f"{path}: header present but no grain rows.")
|
|
210
|
+
|
|
211
|
+
idx = {name: i for i, name in enumerate(columns)}
|
|
212
|
+
arr = np.array([[float(v) for v in r] for r in rows], dtype=float)
|
|
213
|
+
if arr.shape[1] != len(columns):
|
|
214
|
+
raise ValueError(
|
|
215
|
+
f"{path}: {arr.shape[1]} data columns but {len(columns)} header "
|
|
216
|
+
"names -- the file is malformed or tab/space separated "
|
|
217
|
+
"inconsistently.")
|
|
218
|
+
|
|
219
|
+
def col(name, required=True):
|
|
220
|
+
if name not in idx:
|
|
221
|
+
if required:
|
|
222
|
+
raise ValueError(
|
|
223
|
+
f"{path}: required column {name!r} not found. "
|
|
224
|
+
f"Columns present: {columns}")
|
|
225
|
+
return None
|
|
226
|
+
return arr[:, idx[name]]
|
|
227
|
+
|
|
228
|
+
def block(names):
|
|
229
|
+
if not all(n in idx for n in names):
|
|
230
|
+
return None
|
|
231
|
+
return np.stack([arr[:, idx[n]] for n in names], axis=1)
|
|
232
|
+
|
|
233
|
+
id_name = "ID" if "ID" in idx else "GrainID"
|
|
234
|
+
ids = col(id_name).astype(int)
|
|
235
|
+
pos = np.stack([col("X"), col("Y"), col("Z")], axis=1)
|
|
236
|
+
|
|
237
|
+
om_flat = block(_OM_NAMES)
|
|
238
|
+
if om_flat is None:
|
|
239
|
+
raise ValueError(f"{path}: orientation matrix columns O11..O33 missing.")
|
|
240
|
+
orient_mat = om_flat.reshape(-1, 3, 3)
|
|
241
|
+
|
|
242
|
+
eul = block(_EUL_NAMES)
|
|
243
|
+
if eul is None:
|
|
244
|
+
# Older writers omitted the Euler columns; derive them so downstream
|
|
245
|
+
# (and ipf_rgb) has a single, consistent source.
|
|
246
|
+
from midas_stress.orientation import orient_mat_to_euler
|
|
247
|
+
|
|
248
|
+
eul = np.array([np.asarray(orient_mat_to_euler(m.reshape(-1).tolist()),
|
|
249
|
+
dtype=float).reshape(3)
|
|
250
|
+
for m in orient_mat])
|
|
251
|
+
|
|
252
|
+
if check_orientation:
|
|
253
|
+
_check_orientation(path, eul, orient_mat)
|
|
254
|
+
|
|
255
|
+
lattice = block(_LATTICE_NAMES)
|
|
256
|
+
if lattice is None:
|
|
257
|
+
lattice = np.full((len(ids), 6), np.nan)
|
|
258
|
+
|
|
259
|
+
fab = block(_FAB)
|
|
260
|
+
ken = block(_KEN)
|
|
261
|
+
return GrainList(
|
|
262
|
+
ids=ids,
|
|
263
|
+
pos=pos,
|
|
264
|
+
euler=eul,
|
|
265
|
+
orient_mat=orient_mat,
|
|
266
|
+
lattice=lattice,
|
|
267
|
+
radius=col("GrainRadius", required=False),
|
|
268
|
+
completeness=col("Confidence", required=False),
|
|
269
|
+
diff_pos=col("DiffPos", required=False),
|
|
270
|
+
diff_ome=col("DiffOme", required=False),
|
|
271
|
+
diff_angle=col("DiffAngle", required=False),
|
|
272
|
+
strain_fab=None if fab is None else fab.reshape(-1, 3, 3),
|
|
273
|
+
strain_ken=None if ken is None else ken.reshape(-1, 3, 3),
|
|
274
|
+
rmse_strain=col("RMSErrorStrain", required=False),
|
|
275
|
+
phase=None if "PhaseNr" not in idx else col("PhaseNr").astype(int),
|
|
276
|
+
header=header,
|
|
277
|
+
columns=columns,
|
|
278
|
+
raw=arr,
|
|
279
|
+
path=path,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _check_orientation(path, euler, orient_mat) -> None:
|
|
284
|
+
"""Euler and O11..O33 must describe the same orientation."""
|
|
285
|
+
try:
|
|
286
|
+
from midas_stress.orientation import euler_to_orient_mat_batch
|
|
287
|
+
except Exception: # noqa: BLE001
|
|
288
|
+
return
|
|
289
|
+
rec = np.asarray(euler_to_orient_mat_batch(euler)).reshape(-1, 3, 3)
|
|
290
|
+
dev = float(np.abs(rec - orient_mat).max())
|
|
291
|
+
if dev > OM_EULER_TOL:
|
|
292
|
+
warnings.warn(
|
|
293
|
+
f"{path.name}: Euler angles and the O11..O33 matrix disagree by "
|
|
294
|
+
f"{dev:.3g} (tolerance {OM_EULER_TOL:g}). The columns are probably "
|
|
295
|
+
"being read wrong, or the file was written by a version with a "
|
|
296
|
+
"column-ordering bug -- `midas-fit-grain` 0.5.6 shipped one. Do "
|
|
297
|
+
"NOT trust orientation-derived output (IPF colour, pole figures, "
|
|
298
|
+
"misorientation) from this file until it is resolved.",
|
|
299
|
+
RuntimeWarning,
|
|
300
|
+
stacklevel=3,
|
|
301
|
+
)
|
midas_plotting/ipf.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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)
|