Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add CTF file reader and interactive crystal map plot example #451

Merged
merged 35 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3be235e
Add ctf file reader and interactive IPF example
IMBalENce May 16, 2023
069bfbc
Update recommended review changes
IMBalENce May 29, 2023
d792fdf
Update examples/plotting/interactive_IPF.py
May 29, 2023
1e34a01
Update orix/io/plugins/ctf.py
May 29, 2023
d8f4005
Update orix/io/plugins/ctf.py
May 29, 2023
209f74c
Update orix/io/plugins/ctf.py
May 29, 2023
d3651fa
Refine the ctf reader for more efficient header reading
IMBalENce May 29, 2023
0db22b9
Add back Plugin description section to avoid io.load error
IMBalENce May 29, 2023
e489eb5
Update orix/io/plugins/ctf.py
May 30, 2023
25f9753
Update examples/plotting/interactive_IPF.py
May 30, 2023
a9cd442
Add depreciation warning for loadctf()
IMBalENce Dec 5, 2023
9c283a5
Fix test_io timeout issue, and simplify ctf reader
IMBalENce Dec 5, 2023
e476960
Enhance the interactive IPF plot example
IMBalENce Dec 5, 2023
6ba09f3
Update orix/io/plugins/ctf.py
IMBalENce Dec 5, 2023
6b8053a
minor fix in io.plugins.ctf
IMBalENce Dec 5, 2023
4671b73
Testing: Update test to pass with new unknown file type.
CSSFrancis Mar 26, 2024
4164f3a
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 27, 2024
b06875a
Merge branch 'develop' into ctf_reader
hakonanes Apr 25, 2024
f250898
Rename interactive xmap plot, simplify and use CrystalMap.plot()
hakonanes Apr 25, 2024
cba8ad1
Deprecate loadang (not just loadctf)
hakonanes Apr 27, 2024
e62b068
Prefer Formula over MaterialName for phase names from .ang files
hakonanes Apr 28, 2024
bc70df1
Move private crystal map functions to allow calls from other files
hakonanes Apr 28, 2024
0a8740b
Update CTF to allow reading from ASTAR, EMsoft and MTEX files
hakonanes Apr 28, 2024
de71123
List CTF reader in IO plugins in docs
hakonanes Apr 28, 2024
2b3b7f0
Merge branch 'hakonanes-ctf_reader' into ctf_reader
IMBalENce May 2, 2024
0d4429b
Test private function in crystal map module
hakonanes May 11, 2024
8ff2eed
Silence some test warnings from setuptools
hakonanes May 11, 2024
48c6505
Add test fixtures for CTF files in various formats
hakonanes May 11, 2024
0170f7b
Test Oxford Instruments and Bruker CTF files
hakonanes May 11, 2024
70e252b
Fix manifest exclude patterns
hakonanes May 11, 2024
1a39e98
Use type hint classes for tuple and dict instead of types, valid for 3.8
hakonanes May 11, 2024
0b8f46b
Complete testing of CTF reader
hakonanes May 11, 2024
2e2c8d9
Exclude orix/tests directory from test coverage
hakonanes May 11, 2024
0b34bc2
Remove a few more type hints using types (not supported on 3.8)
hakonanes May 11, 2024
a68d37e
Remove type hints using types also in CTF reader file
hakonanes May 11, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ Unreleased

Added
-----
- We can now read 2D crystal maps from Channel Text Files (CTFs) using ``io.load()``.

Changed
-------
- Phase names in crystal maps read from .ang files with ``io.load()`` now prefer to use
the abbreviated "Formula" instead of "MaterialName" in the file header.

Removed
-------

Deprecated
----------
- ``loadang()`` and ``loadctf()`` are deprecated and will be removed in the next minor
release. Please use ``io.load()`` instead.

Fixed
-----
Expand Down
98 changes: 98 additions & 0 deletions examples/plotting/interactive_xmap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""
============================
Interactive crystal map plot
============================

This example shows how to use
:doc:`matplotlib event connections <matplotlib:users/explain/figure/event_handling>` to
add an interactive click function to a :class:`~orix.crystal_map.CrystalMap` plot.
Here, we navigate an inverse pole figure (IPF) map and retreive the phase name and
corresponding Euler angles from the location of the click.

.. note::

This example uses the interactive capabilities of Matplotlib, and this will not
appear in the static documentation.
Please run this code on your machine to see the interactivity.

You can copy and paste individual parts, or download the entire example using the
link at the bottom of the page.
"""

import matplotlib.pyplot as plt
import numpy as np

from orix import data, plot
from orix.crystal_map import CrystalMap

xmap = data.sdss_ferrite_austenite(allow_download=True)
print(xmap)

pg_laue = xmap.phases[1].point_group.laue
O_au = xmap["austenite"].orientations
O_fe = xmap["ferrite"].orientations

# Get IPF colors
ipf_key = plot.IPFColorKeyTSL(pg_laue)
rgb_au = ipf_key.orientation2color(O_au)
rgb_fe = ipf_key.orientation2color(O_fe)

# Combine IPF color arrays
rgb_all = np.zeros((xmap.size, 3))
phase_id_au = xmap.phases.id_from_name("austenite")
phase_id_fe = xmap.phases.id_from_name("ferrite")
rgb_all[xmap.phase_id == phase_id_au] = rgb_au
rgb_all[xmap.phase_id == phase_id_fe] = rgb_fe


def select_point(xmap: CrystalMap, rgb_all: np.ndarray) -> tuple[int, int]:
"""Return location of interactive user click on image.

Interactive function for showing the phase name and Euler angles
from the click-position.
"""
fig = xmap.plot(
rgb_all,
overlay="dp",
return_figure=True,
figure_kwargs={"figsize": (12, 8)},
)
ax = fig.axes[0]
ax.set_title("Click position")

# Extract array in the plot with IPF colors + dot product overlay
rgb_dp_2d = ax.images[0].get_array()

x = y = 0

def on_click(event):
x, y = (event.xdata, event.ydata)
if x is None:
print("Please click inside the IPF map")
return
print(x, y)

# Extract location in crystal map and extract phase name and
# Euler angles
xmap_yx = xmap[int(np.round(y)), int(np.round(x))]
phase_name = xmap_yx.phases_in_data[:].name
eu = xmap_yx.rotations.to_euler(degrees=True)[0].round(2)

# Format Euler angles
eu_str = "(" + ", ".join(np.array_str(eu)[1:-1].split()) + ")"

plt.clf()
plt.imshow(rgb_dp_2d)
plt.plot(x, y, "+", c="k", markersize=15, markeredgewidth=3)
plt.title(
f"Phase: {phase_name}, Euler angles: $(\phi_1, \Phi, \phi_2)$ = {eu_str}"
)
plt.draw()

fig.canvas.mpl_connect("button_press_event", on_click)

return x, y


x, y = select_point(xmap, rgb_all)
plt.show()
120 changes: 74 additions & 46 deletions orix/crystal_map/crystal_map.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
# along with orix. If not, see <http://www.gnu.org/licenses/>.

import copy
from typing import Optional, Tuple, Union
from typing import Dict, Optional, Tuple, Union

import matplotlib.pyplot as plt
import numpy as np
Expand Down Expand Up @@ -350,12 +350,12 @@ def y(self) -> Union[None, np.ndarray]:
@property
def dx(self) -> float:
"""Return the x coordinate step size."""
return self._step_size_from_coordinates(self._x)
return _step_size_from_coordinates(self._x)

@property
def dy(self) -> float:
"""Return the y coordinate step size."""
return self._step_size_from_coordinates(self._y)
return _step_size_from_coordinates(self._y)

@property
def row(self) -> Union[None, np.ndarray]:
Expand Down Expand Up @@ -1039,29 +1039,9 @@ def plot(
if return_figure:
return fig

@staticmethod
def _step_size_from_coordinates(coordinates: np.ndarray) -> float:
"""Return step size in input ``coordinates`` array.

Parameters
----------
coordinates
Linear coordinate array.

Returns
-------
step_size
Step size in ``coordinates`` array.
"""
unique_sorted = np.sort(np.unique(coordinates))
step_size = 0
if unique_sorted.size != 1:
step_size = unique_sorted[1] - unique_sorted[0]
return step_size

def _data_slices_from_coordinates(self, only_is_in_data: bool = True) -> tuple:
"""Return a tuple of slices defining the current data extent in
all directions.
"""Return a slices defining the current data extent in all
directions.

Parameters
----------
Expand All @@ -1072,23 +1052,14 @@ def _data_slices_from_coordinates(self, only_is_in_data: bool = True) -> tuple:
Returns
-------
slices
Data slice in each existing dimension, in (z, y, x) order.
Data slice in each existing direction in (y, x) order.
"""
if only_is_in_data:
coordinates = self._coordinates
else:
coordinates = self._all_coordinates

# Loop over dimension coordinates and step sizes
slices = []
for coords, step in zip(coordinates.values(), self._step_sizes.values()):
if coords is not None and step != 0:
c_min, c_max = np.min(coords), np.max(coords)
i_min = int(np.around(c_min / step))
i_max = int(np.around((c_max / step) + 1))
slices.append(slice(i_min, i_max))

return tuple(slices)
slices = _data_slices_from_coordinates(coordinates, self._step_sizes)
return slices

def _data_shape_from_coordinates(self, only_is_in_data: bool = True) -> tuple:
"""Return data shape based upon coordinate arrays.
Expand All @@ -1102,21 +1073,78 @@ def _data_shape_from_coordinates(self, only_is_in_data: bool = True) -> tuple:
Returns
-------
data_shape
Shape of data in all existing dimensions, in (z, y, x) order.
Shape of data in each existing direction in (y, x) order.
"""
data_shape = []
for dim_slice in self._data_slices_from_coordinates(only_is_in_data):
data_shape.append(dim_slice.stop - dim_slice.start)
return tuple(data_shape)


def _data_slices_from_coordinates(
coords: Dict[str, np.ndarray], steps: Union[Dict[str, float], None] = None
) -> Tuple[slice]:
"""Return a list of slices defining the current data extent in all
directions.

Parameters
----------
coords
Dictionary with coordinate arrays.
steps
Dictionary with step sizes in each direction. If not given, they
are computed from *coords*.

Returns
-------
slices
Data slice in each direction.
"""
if steps is None:
steps = {
"x": _step_size_from_coordinates(coords["x"]),
"y": _step_size_from_coordinates(coords["y"]),
}
slices = []
for coords, step in zip(coords.values(), steps.values()):
if coords is not None and step != 0:
c_min, c_max = np.min(coords), np.max(coords)
i_min = int(np.around(c_min / step))
i_max = int(np.around((c_max / step) + 1))
slices.append(slice(i_min, i_max))
slices = tuple(slices)
return slices


def _step_size_from_coordinates(coordinates: np.ndarray) -> float:
"""Return step size in input *coordinates* array.

Parameters
----------
coordinates
Linear coordinate array.

Returns
-------
step_size
Step size in *coordinates* array.
"""
unique_sorted = np.sort(np.unique(coordinates))
if unique_sorted.size != 1:
step_size = unique_sorted[1] - unique_sorted[0]
else:
step_size = 0
return step_size


def create_coordinate_arrays(
shape: Optional[tuple] = None, step_sizes: Optional[tuple] = None
) -> Tuple[dict, int]:
"""Create flattened coordinate arrays from a given map shape and
"""Return flattened coordinate arrays from a given map shape and
step sizes, suitable for initializing a
:class:`~orix.crystal_map.CrystalMap`. Arrays for 1D or 2D maps can
be returned.
:class:`~orix.crystal_map.CrystalMap`.

Arrays for 1D or 2D maps can be returned.

Parameters
----------
Expand All @@ -1125,13 +1153,13 @@ def create_coordinate_arrays(
and ten columns.
step_sizes
Map step sizes. If not given, it is set to 1 px in each map
direction given by ``shape``.
direction given by *shape*.

Returns
-------
d
Dictionary with keys ``"y"`` and ``"x"``, depending on the
length of ``shape``, with coordinate arrays.
Dictionary with keys ``"x"`` and ``"y"``, depending on the
length of *shape*, with coordinate arrays.
map_size
Number of map points.

Expand All @@ -1145,10 +1173,10 @@ def create_coordinate_arrays(
>>> create_coordinate_arrays((2, 3), (1.5, 1.5))
({'x': array([0. , 1.5, 3. , 0. , 1.5, 3. ]), 'y': array([0. , 0. , 0. , 1.5, 1.5, 1.5])}, 6)
"""
if shape is None:
if not shape:
shape = (5, 10)
ndim = len(shape)
if step_sizes is None:
if not step_sizes:
step_sizes = (1,) * ndim

if ndim == 3 or len(step_sizes) > 2:
Expand Down
6 changes: 5 additions & 1 deletion orix/io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from h5py import File, is_hdf5
import numpy as np

from orix._util import deprecated
from orix.crystal_map import CrystalMap
from orix.io.plugins import plugin_list
from orix.io.plugins._h5ebsd import hdf5group2dict
Expand All @@ -45,7 +46,6 @@
extensions = [plugin.file_extensions for plugin in plugin_list if plugin.writes]


# Lists what will be imported when calling "from orix.io import *"
__all__ = [
"loadang",
"loadctf",
Expand All @@ -54,6 +54,8 @@
]


# TODO: Remove after 0.13.0
@deprecated(since="0.13", removal="0.14", alternative="io.load")
def loadang(file_string: str) -> Rotation:
"""Load ``.ang`` files.

Expand All @@ -73,6 +75,8 @@ def loadang(file_string: str) -> Rotation:
return Rotation.from_euler(euler)


# TODO: Remove after 0.13.0
@deprecated(since="0.13", removal="0.14", alternative="io.load")
def loadctf(file_string: str) -> Rotation:
"""Load ``.ctf`` files.

Expand Down
4 changes: 3 additions & 1 deletion orix/io/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,17 @@

ang
bruker_h5ebsd
ctf
emsoft_h5ebsd
orix_hdf5
"""

from orix.io.plugins import ang, bruker_h5ebsd, emsoft_h5ebsd, orix_hdf5
from orix.io.plugins import ang, bruker_h5ebsd, ctf, emsoft_h5ebsd, orix_hdf5

plugin_list = [
ang,
bruker_h5ebsd,
ctf,
emsoft_h5ebsd,
orix_hdf5,
]
Loading
Loading