Skip to content

Commit fed8f05

Browse files
authored
Merge pull request #595 from hakonanes/ebsd-detector-save-load
Add saving and loading an EBSD detector to/from a text file
2 parents 1dad34a + 57c902f commit fed8f05

3 files changed

Lines changed: 239 additions & 21 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ Unreleased
1818

1919
Added
2020
-----
21+
- Saving and loading of an ``EBSDDetector``.
22+
(`#595 <https://github.com/pyxem/kikuchipy/pull/595>`_)
2123
- EBSD refinement methods now return the number of function evaluations.
2224
(`#593 <https://github.com/pyxem/kikuchipy/pull/593>`_)
2325
- Which points in a crystal map to refine can be controlled by passing a navigation

kikuchipy/detectors/ebsd_detector.py

Lines changed: 180 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717

1818
from __future__ import annotations
1919
from copy import deepcopy
20+
from datetime import datetime
2021
import logging
22+
from pathlib import Path
23+
import re
2124
from typing import List, Optional, Tuple, Union
2225

2326
from matplotlib.figure import Figure
@@ -33,6 +36,7 @@
3336
from skimage.transform import ProjectiveTransform
3437
from sklearn.linear_model import LinearRegression, RANSACRegressor
3538

39+
from kikuchipy import __version__
3640
from kikuchipy.indexing._hough_indexing import _get_indexer_from_detector
3741

3842

@@ -63,35 +67,33 @@ class EBSDDetector:
6367
----------
6468
shape
6569
Number of detector rows and columns in pixels. Default is
66-
``(1, 1)``.
70+
(1, 1).
6771
px_size
6872
Size of unbinned detector pixel in um, assuming a square
69-
pixel shape. Default is ``1``.
73+
pixel shape. Default is 1.
7074
binning
7175
Detector binning, i.e. how many pixels are binned into one.
72-
Default is ``1``, i.e. no binning.
76+
Default is 1, i.e. no binning.
7377
tilt
74-
Detector tilt from horizontal in degrees. Default is ``0``.
78+
Detector tilt from horizontal in degrees. Default is 0.
7579
azimuthal
7680
Sample tilt about the sample RD (downwards) axis. A positive
7781
angle means the sample normal moves towards the right
78-
looking from the sample to the detector. Default is ``0``.
82+
looking from the sample to the detector. Default is 0.
7983
sample_tilt
80-
Sample tilt from horizontal in degrees. Default is ``70``.
84+
Sample tilt from horizontal in degrees. Default is 70.
8185
pc
8286
X, Y and Z coordinates of the projection/pattern centers
8387
(PCs), describing the location of the beam on the sample
8488
measured relative to the detection screen. See *Notes* for
8589
the definition and conversions between conventions. If
8690
multiple PCs are passed, they are assumed to be on the form
87-
``[[x0, y0, z0], [x1, y1, z1], ...]``. Default is
88-
``[0.5, 0.5, 0.5]``.
91+
[[x0, y0, z0], [x1, y1, z1], ...]. Default is [0.5, 0.5, 0.5].
8992
convention
9093
PC convention. If not given, Bruker's convention is assumed.
91-
Options are ``"tsl"``/``"edax"``/``"amatek"``,
92-
``"oxford"``/``"aztec"``, ``"bruker"``, ``"emsoft"``,
93-
``"emsoft4"``, and ``"emsoft5"``. ``"emsoft"`` and ``"emsoft5"``
94-
is the same convention. See *Notes* for conversions between
94+
Options are "tsl"/"edax"/"amatek", "oxford"/"aztec", "bruker",
95+
"emsoft", "emsoft4", and "emsoft5". "emsoft" and "emsoft5" is
96+
the same convention. See *Notes* for conversions between
9597
conventions.
9698
9799
Notes
@@ -194,7 +196,9 @@ def __init__(
194196
self.azimuthal = azimuthal
195197
self.sample_tilt = sample_tilt
196198
self.pc = pc
197-
self._set_pc_from_convention(convention)
199+
if convention is None:
200+
convention = "bruker"
201+
self._set_pc_in_bruker_convention(convention)
198202

199203
def __repr__(self) -> str:
200204
pc_average = tuple(self.pc_average.round(3))
@@ -454,6 +458,72 @@ def r_max(self) -> np.ndarray:
454458
corners[..., 3] = self.x_min**2 + self.y_min**2 # Lo. left
455459
return np.atleast_2d(np.sqrt(np.max(corners, axis=-1)))
456460

461+
@classmethod
462+
def load(cls, fname: Union[Path, str]) -> EBSDDetector:
463+
"""Return an EBSD detector loaded from a text file saved with
464+
:meth:`save`.
465+
466+
Parameters
467+
----------
468+
fname
469+
Full path to file.
470+
471+
Returns
472+
-------
473+
detector
474+
Loaded EBSD detector.
475+
"""
476+
pc = np.loadtxt(fname)
477+
478+
keys = [
479+
"shape",
480+
"px_size",
481+
"binning",
482+
"tilt",
483+
"azimuthal",
484+
"sample_tilt",
485+
"convention",
486+
"navigation_shape",
487+
]
488+
489+
detector_kw = dict(zip(keys, [None] * len(keys)))
490+
with open(fname, mode="r") as f:
491+
header = []
492+
for line in f.readlines():
493+
if line[0] == "#":
494+
line = line[2:-1].lstrip(" ")
495+
if len(line) > 0:
496+
header.append(line)
497+
match = re.match(r"^(\w+|\w+\s\w+): (.*)", line)
498+
if match:
499+
groups = match.groups()
500+
if groups[0] in detector_kw and len(groups) > 1:
501+
detector_kw[groups[0]] = groups[1]
502+
else:
503+
break
504+
505+
for k in ["shape", "navigation_shape"]:
506+
shape = detector_kw[k]
507+
try:
508+
detector_kw[k] = tuple(int(i) for i in shape[1:-1].split(","))
509+
except ValueError: # pragma: no cover
510+
detector_kw[k] = None
511+
for k, dtype in zip(
512+
["px_size", "binning", "tilt", "azimuthal", "sample_tilt"],
513+
[float, int, float, float, float],
514+
):
515+
value = detector_kw[k].rstrip(" deg")
516+
try:
517+
detector_kw[k] = dtype(value)
518+
except (): # pragma: no cover
519+
detector_kw[k] = None
520+
521+
nav_shape = detector_kw.pop("navigation_shape")
522+
if isinstance(nav_shape, tuple):
523+
pc = pc.reshape(nav_shape + (3,))
524+
525+
return cls(pc=pc, **detector_kw)
526+
457527
def crop(self, extent: Union[Tuple[int, int, int, int], List[int]]) -> EBSDDetector:
458528
"""Return a new detector with its :attr:`shape` cropped and
459529
:attr:`pc` values updated accordingly.
@@ -750,8 +820,8 @@ def extrapolate_pc(
750820
binning: int = None,
751821
is_outlier: Optional[Union[tuple, list, np.ndarray]] = None,
752822
):
753-
r"""Return a new detector with projection centers (PCs)
754-
extrapolated from an average PC.
823+
r"""Return a new detector with projection centers (PCs) in a 2D
824+
map extrapolated from an average PC.
755825
756826
The average PC :math:`\bar{PC}` is calculated from :attr:`pc`,
757827
possibly excluding some PCs based on the ``is_outlier`` mask.
@@ -1505,14 +1575,71 @@ def plot_pc(
15051575
if return_figure:
15061576
return fig
15071577

1578+
def save(self, filename: str, convention: str = "Bruker", **kwargs) -> None:
1579+
"""Save detector in a text file with projection centers (PCs) in
1580+
the given convention.
1581+
1582+
Parameters
1583+
----------
1584+
filename
1585+
Name of text file to write to. See :func:`~numpy.savetxt`
1586+
for supported file formats.
1587+
convention
1588+
PC convention. Default is Bruker's convention. Options are
1589+
"tsl"/"edax", "oxford", "bruker", "emsoft", "emsoft4", and
1590+
"emsoft5". "emsoft" and "emsoft5" is the same convention.
1591+
See *Notes* in :class:`EBSDDetector` for conversions between
1592+
conventions.
1593+
**kwargs
1594+
Keyword arguments passed to :func:`~numpy.savetxt`, e.g.
1595+
``fmt="%.4f"`` to reduce the number of PC decimals from the
1596+
default 7 to 4.
1597+
"""
1598+
pc = self._get_pc_in_convention(convention)
1599+
pc = pc.reshape(-1, 3)
1600+
1601+
time_now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1602+
1603+
kwargs.setdefault(
1604+
"header",
1605+
(
1606+
f"EBSDDetector\n"
1607+
f" shape: {self.shape}\n"
1608+
f" px_size: {self.px_size}\n"
1609+
f" binning: {self.binning}\n"
1610+
f" tilt: {self.tilt} deg\n"
1611+
f" azimuthal: {self.azimuthal} deg\n"
1612+
f" sample_tilt: {self.sample_tilt} deg\n"
1613+
f" convention: {convention}\n"
1614+
f" navigation_shape: {self.navigation_shape}\n\n"
1615+
f"kikuchipy version: {__version__}\n"
1616+
f"Time: {time_now}\n\n"
1617+
"Column names: PCx, PCy, PCz"
1618+
),
1619+
)
1620+
kwargs.setdefault("fmt", "%.7f")
1621+
np.savetxt(fname=filename, X=pc, **kwargs)
1622+
15081623
# ------------------------ Private methods ----------------------- #
15091624

1510-
def _get_pc_from_convention(self, convention: Optional[str] = None) -> np.ndarray:
1511-
if convention is None or convention.lower() in CONVENTION_ALIAS["bruker"]:
1512-
return self.pc
1625+
def _get_pc_in_bruker_convention(self, convention: str = "bruker") -> np.ndarray:
1626+
"""Convert current :attr:`pc` to Bruker's convention from
1627+
another convention.
1628+
1629+
Parameters
1630+
----------
1631+
convention
1632+
Convention of the current PCs. Default is "bruker".
15131633
1634+
Returns
1635+
-------
1636+
pc
1637+
PC array in Bruker's convention.
1638+
"""
15141639
conv = convention.lower()
1515-
if conv in CONVENTION_ALIAS["tsl"] + CONVENTION_ALIAS["oxford"]:
1640+
if conv in CONVENTION_ALIAS["bruker"]:
1641+
return self.pc
1642+
elif conv in CONVENTION_ALIAS["tsl"] + CONVENTION_ALIAS["oxford"]:
15161643
return self._pc_tsl2bruker()
15171644
elif conv in CONVENTION_ALIAS["emsoft"]:
15181645
try:
@@ -1526,8 +1653,40 @@ def _get_pc_from_convention(self, convention: Optional[str] = None) -> np.ndarra
15261653
f"recognised conventions {CONVENTION_ALIAS_ALL}"
15271654
)
15281655

1529-
def _set_pc_from_convention(self, convention: Optional[str] = None):
1530-
self.pc = self._get_pc_from_convention(convention)
1656+
def _set_pc_in_bruker_convention(self, convention: str = "bruker"):
1657+
self.pc = self._get_pc_in_bruker_convention(convention)
1658+
1659+
def _get_pc_in_convention(self, convention: str = "bruker") -> np.ndarray:
1660+
"""Convert current :attr:`pc` from Bruker's convention to
1661+
another convention.
1662+
1663+
Parameters
1664+
----------
1665+
convention
1666+
Convention of the output PCs. Default is "bruker", which
1667+
means the PCs are returned without conversion.
1668+
1669+
Returns
1670+
-------
1671+
pc
1672+
PC array in specified convention.
1673+
"""
1674+
conv = convention.lower()
1675+
if conv in CONVENTION_ALIAS["bruker"]:
1676+
return self.pc
1677+
elif conv in CONVENTION_ALIAS["tsl"] + CONVENTION_ALIAS["oxford"]:
1678+
return self._pc_bruker2tsl()
1679+
elif conv in CONVENTION_ALIAS["emsoft"]:
1680+
try:
1681+
version = int(convention[-1])
1682+
except ValueError:
1683+
version = 5
1684+
return self._pc_bruker2emsoft(version)
1685+
else:
1686+
raise ValueError(
1687+
f"Projection center convention '{convention}' not among the "
1688+
f"recognised conventions {CONVENTION_ALIAS_ALL}"
1689+
)
15311690

15321691
def _pc_emsoft2bruker(self, version: int = 5) -> np.ndarray:
15331692
new_pc = np.zeros_like(self.pc, dtype=float)

kikuchipy/detectors/tests/test_ebsd_detector.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,3 +1069,60 @@ def test_get_indexer(self):
10691069
PhaseList(names=["a", "b"], space_groups=[220, 225])
10701070
)
10711071
assert indexer4.phaselist == ["BCC", "FCC"]
1072+
1073+
1074+
class TestSaveLoadDetector:
1075+
@pytest.mark.parametrize(
1076+
"nav_shape, shape, convention, sample_tilt, tilt, px_size, binning, azimuthal",
1077+
[
1078+
((3, 4), (10, 20), "bruker", 70, 0, 70, 1, 0),
1079+
((1, 5), (5, 5), "tsl", 69.5, 3.14, 57.2, 2, 3.7),
1080+
((4, 3), (6, 7), "emsoft", -69.5, -3.14, 57.2, 2, -3.7),
1081+
],
1082+
)
1083+
def test_save_load_detector(
1084+
self,
1085+
tmp_path,
1086+
nav_shape,
1087+
shape,
1088+
convention,
1089+
sample_tilt,
1090+
tilt,
1091+
px_size,
1092+
binning,
1093+
azimuthal,
1094+
):
1095+
det0 = kp.detectors.EBSDDetector(
1096+
shape=shape,
1097+
pc=(0.4, 0.3, 0.6),
1098+
sample_tilt=sample_tilt,
1099+
tilt=tilt,
1100+
px_size=px_size,
1101+
binning=binning,
1102+
azimuthal=azimuthal,
1103+
convention=convention,
1104+
)
1105+
det1 = det0.extrapolate_pc(
1106+
pc_indices=[0, 0],
1107+
navigation_shape=nav_shape,
1108+
step_sizes=(2, 2),
1109+
)
1110+
if any(i == 1 for i in nav_shape):
1111+
det1.pc = det1.pc.reshape(-1, 3)
1112+
fname = tmp_path / "det_temp.txt"
1113+
det1.save(fname, convention=convention)
1114+
1115+
det2 = kp.detectors.EBSDDetector.load(fname)
1116+
1117+
assert det2.shape == det1.shape
1118+
assert np.allclose(det2.pc, det1.pc, atol=1e-7)
1119+
assert np.isclose(det2.sample_tilt, det1.sample_tilt)
1120+
assert np.isclose(det2.tilt, det1.tilt)
1121+
assert np.isclose(det2.px_size, det1.px_size)
1122+
assert det2.binning == det1.binning
1123+
assert np.isclose(det2.azimuthal, det1.azimuthal)
1124+
1125+
def test_save_detector_raises(self, tmp_path):
1126+
det = kp.detectors.EBSDDetector()
1127+
with pytest.raises(ValueError, match="Projection center convention 'EMsofts' "):
1128+
det.save(tmp_path / "det_temp.txt", convention="EMsofts")

0 commit comments

Comments
 (0)