Repository: pyxem/kikuchipy (language: python)

Issue:
H5OINA reader
We recently installed a new Oxford symmetry detector with the latest AZtec Live software. The new software can already export the new [OI h5ebsd format](https://github.com/oinanoanalysis/h5oina/blob/master/H5OINAFile.md#ebsd) ver5 with processed patterns. I'm currently working on modify the `\kikuchipy\io\plugins\h5ebsd.py` reader to convert the .h5oina headers to dictionary. This seems to be reasonably straight-forward. I'll send you the code and raw data file for reviewing once tested. Perhaps I may work on the .tiff reader at some stage follow on the line.

As for the PC values, .h5oina store them in three 1-D arrays. I'll probably use the averaged values and set to the `md.ebsd_node` x, y, and z values for the time being. I'll also have a look on how you did the reader of Bruker's h5ebsd files in Orix.

_Originally posted by @IMBalENce in https://github.com/pyxem/kikuchipy/issues/459#issuecomment-1216054550_


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 690c7f5a..e242ecf7 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -10,20 +10,16 @@ All user facing changes to this project are documented in this file. The format
 on `Keep a Changelog <https://keepachangelog.com/en/1.1.0>`__, and this project tries
 its best to adhere to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`__.
 
-Contributors to each release are listed in alphabetical order by first name. List
-entries are sorted in descending chronological order.
+List entries are sorted in descending chronological order. Contributors to each release
+were listed in alphabetical order by first name until version 0.7.0.
 
 Unreleased
 ==========
 
-Contributors
-------------
-- Håkon Wiik Ånes
-- Magnus Nord
-- Zhou Xu
-
 Added
 -----
+- Reader of an ``EBSD`` signal from Oxford Instrument's h5ebsd format (H5OINA).
+  (`#562 <https://github.com/pyxem/kikuchipy/pull/562>`_)
 - Figures of reference frames of other software added to the documentation.
   (`#552 <https://github.com/pyxem/kikuchipy/pull/552>`_)
 - Whether to show progressbars from most signal methods (except indexing and refinement)
@@ -33,6 +29,12 @@ Added
 
 Changed
 -------
+- Most of the ``EBSD`` metadata structure is removed, in an effort to move all relevant
+  data to the attributes ``xmap``, ``static_background``, and ``detector``.
+  (`#562 <https://github.com/pyxem/kikuchipy/pull/562>`_)
+- h5ebsd plugin split into one plugin for each h5ebsd format (kikuchipy, EDAX TSL, and
+  Bruker Nano).
+  (`#562 <https://github.com/pyxem/kikuchipy/pull/562>`_)
 - ``EBSDDetector.plot()`` and ``PCCalibrationMovingScreen.plot()`` parameter
   ``return_fig_ax`` renamed to ``return_figure``.
   (`#552 <https://github.com/pyxem/kikuchipy/pull/552>`_)
@@ -45,14 +47,11 @@ Changed
   which accepts a ``allow_download`` parameter. If not given, the value is retreived
   from HyperSpy's preferences. (`#550 <https://github.com/pyxem/kikuchipy/pull/550>`_)
 
-Deprecated
-----------
-- ``ebsd_metadata()`` and ``metadata_nodes()`` are deprecated and will be removed in
-  v0.8.
-  (`#550 <https://github.com/pyxem/kikuchipy/pull/550>`_)
-
 Removed
 -------
+- Functions ``ebsd_metadata()`` and ``metadata_nodes()`` which have been deprecated
+  since v0.5. (`#550 <https://github.com/pyxem/kikuchipy/pull/550>`_,
+  `#562 <https://github.com/pyxem/kikuchipy/pull/562>`_)
 - The print information emitted from ``EBSD`` methods like
   ``remove_static_background()`` is removed.
   (`#550 <https://github.com/pyxem/kikuchipy/pull/550>`_)
diff --git a/doc/tutorials/load_save_data.ipynb b/doc/tutorials/load_save_data.ipynb
index 1311d644..1ac07197 100644
--- a/doc/tutorials/load_save_data.ipynb
+++ b/doc/tutorials/load_save_data.ipynb
@@ -186,7 +186,7 @@
    "metadata": {},
    "source": [
     "Some information may also be stored in a standard location in the `metadata`\n",
-    "attribute, where it can be used by EBSD class methods"
+    "attribute; but these are not used by EBSD class methods"
    ]
   },
   {
@@ -202,29 +202,6 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "<div class=\"alert alert-warning\">\n",
-    "\n",
-    "Warning\n",
-    "\n",
-    "The `Acquisition_instrument.SEM.Detector.EBSD` and `Sample.Phases` metadata\n",
-    "nodes are deprecated and will be removed in v0.6.\n",
-    "\n",
-    "There are three main reasons for this change: the first is that only the static\n",
-    "background array stored in the\n",
-    "`Acquisition_instrument.SEM.Detector.EBSD.static_background` node is used\n",
-    "internally, and so the remaining metadata is unnecessary. The background pattern\n",
-    "will be stored in its own `EBSD.static_background` property instead. The second\n",
-    "is that keeping track of the unnecessary metadata makes writing and maintaining\n",
-    "input/ouput plugins challenging. The third is that the\n",
-    "[EBSD.xmap](../reference/generated/kikuchipy.signals.EBSD.xmap.rst) and\n",
-    "[EBSD.detector](../reference/generated/kikuchipy.signals.EBSD.detector.rst) properties,\n",
-    "which keeps track of the\n",
-    "[CrystalMap](https://orix.readthedocs.io/en/stable/reference.html#orix.crystal_map.CrystalMap)\n",
-    "and [EBSDDetector](../reference/generated/kikuchipy.detectors.EBSDDetector.rst) for a\n",
-    "signal, respectively, should be used instead of the more \"static\" metadata.\n",
-    "\n",
-    "</div>\n",
-    "\n",
     "The number of patterns in horizontal and vertical direction, pattern size in\n",
     "pixels, scan step size and detector pixel size is stored in the `axes_manager`\n",
     "attribute:"
@@ -248,32 +225,20 @@
     "[EBSD](../reference/generated/kikuchipy.signals.EBSD.rst) class methods\n",
     "[set_scan_calibration()](../reference/generated/kikuchipy.signals.EBSD.set_scan_calibration.rst) and\n",
     "[set_detector_calibration()](../reference/generated/kikuchipy.signals.EBSD.set_detector_calibration.rst).\n",
-    "For example, to set or change detector pixel size"
-   ]
-  },
-  {
-   "cell_type": "code",
-   "execution_count": null,
-   "metadata": {},
-   "outputs": [],
-   "source": [
-    "s.set_experimental_parameters(\n",
-    "    beam_energy=15,  # kV\n",
-    "    xpc=0.5073,\n",
-    "    static_background=plt.imread(datadir + \"nordif/Background acquisition pattern.bmp\"),\n",
-    ")"
-   ]
-  },
-  {
-   "cell_type": "markdown",
-   "metadata": {},
-   "source": [
+    "\n",
     "In addition to the `metadata`, `original_metadata` and `axes_manager` properties,\n",
     "kikuchipy tries to read a\n",
     "[CrystalMap](https://orix.readthedocs.io/en/stable/reference.html#crystalmap) instance\n",
-    "with indexing results into a `xmap` property and an\n",
+    "with indexing results into the\n",
+    "[xmap](../reference/generated/kikuchipy.signals.EBSD.xmap) attribute, an\n",
     "[EBSDDetector](../reference/generated/kikuchipy.detectors.EBSDDetector.rst)\n",
-    "instance into a `detector` property"
+    "instance into the\n",
+    "[detector](../reference/generated/kikuchipy.signals.EBSD.detector) attribute,\n",
+    "and a static background into the\n",
+    "[static_background](../reference/generated/kikuchipy.signals.EBSD.static_background)\n",
+    "attribute. The `xmap` attribute is `None` if it is not read. Some readers might\n",
+    "fill it with an identity rotation per point and the same phase ID (of a `None`\n",
+    "phase for each point)."
    ]
   },
   {
@@ -311,14 +276,13 @@
     "\n",
     "Warning\n",
     "\n",
-    "EBSD methods inherited from HyperSpy that changes the pattern\n",
-    "array in any way, like cropping the navigation shape with\n",
-    "``EBSD.inav``, will not crop the ``CrystalMap`` stored in the\n",
-    "``xmap`` property. This also goes for the detector and static\n",
-    "background pattern properties. Remember for example to crop\n",
-    "the background pattern accordingly before calling\n",
-    "``EBSD.remove_static_background()`` after ``EBSD.isig`` is\n",
-    "used to crop the patterns.\n",
+    "EBSD methods inherited from HyperSpy that changes the pattern array in any way,\n",
+    "like cropping the navigation shape with `EBSD.inav`, will not crop the\n",
+    "`CrystalMap` stored in the `xmap` attribute, the `EBSDDetector` stored in the\n",
+    "`detector` attribute, or the `static_background` attribute correctly. Remember\n",
+    "for example to crop the background pattern accordingly before calling\n",
+    "`EBSD.remove_static_background()` after `EBSD.isig` is used to crop the\n",
+    "patterns.\n",
     "\n",
     "</div>"
    ]
@@ -545,16 +509,17 @@
     "\n",
     "Currently, kikuchipy has readers and writers for the following formats:\n",
     "\n",
-    "| Format                                                              | Read | Write |\n",
-    "| ------------------------------------------------------------------- | ---- | ----- |\n",
-    "| [EMsoft simulated EBSD HDF5](#EMsoft-simulated-EBSD-HDF5)           | Yes  | No    |\n",
-    "| [EMsoft EBSD master pattern HDF5](#EMsoft-EBSD-master-pattern-HDF5) | Yes  | No    |\n",
-    "| [Bruker Nano h5ebsd](#h5ebsd)                                       | Yes  | No    |\n",
-    "| [EDAX TSL h5ebsd](#h5ebsd)                                          | Yes  | No    |\n",
-    "| [kikuchipy h5ebsd](#h5ebsd)                                         | Yes  | Yes   |\n",
-    "| [NORDIF binary](#NORDIF-binary)                                     | Yes  | Yes   |\n",
-    "| [NORDIF calibration patterns](#NORDIF-calibration-patterns)         | Yes  | No    |\n",
-    "| [Oxford Instruments binary](#Oxford-Instruments-binary)             | Yes  | No    |\n",
+    "| Format                                                                     | Read | Write |\n",
+    "| -------------------------------------------------------------------------- | ---- | ----- |\n",
+    "| [EMsoft simulated EBSD HDF5](#EMsoft-simulated-EBSD-HDF5)                  | Yes  | No    |\n",
+    "| [EMsoft EBSD master pattern HDF5](#EMsoft-EBSD-master-pattern-HDF5)        | Yes  | No    |\n",
+    "| [kikuchipy h5ebsd](#kikuchipy-h5ebsd)                                      | Yes  | Yes   |\n",
+    "| [Bruker h5ebsd](#Bruker-h5ebsd)                                            | Yes  | No    |\n",
+    "| [EDAX h5ebsd](#EDAX-h5ebsd)                                                | Yes  | No    |\n",
+    "| [NORDIF binary](#NORDIF-binary)                                            | Yes  | Yes   |\n",
+    "| [NORDIF calibration patterns](#NORDIF-calibration-patterns)                | Yes  | No    |\n",
+    "| [Oxford Instruments binary](#Oxford-Instruments-binary)                    | Yes  | No    |\n",
+    "| [Oxford Instruments h5ebsd (H5OINA)](#Oxford-Instruments-h5ebsd-H5OINA)    | Yes  | No    |\n",
     "\n",
     "<div class=\"alert alert-info\">\n",
     "\n",
@@ -693,16 +658,16 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "### h5ebsd\n",
+    "### kikuchipy h5ebsd\n",
     "\n",
     "The h5ebsd format\n",
     "<cite data-cite=\"jackson2014h5ebsd\">Jackson et al. (2014)</cite> is based on the\n",
     "[HDF5 open standard](https://www.hdfgroup.org/solutions/hdf5/) (Hierarchical Data Format\n",
     "version 5). HDF5 files can be read and edited using e.g. the HDF Group's reader\n",
     "[HDFView](https://www.hdfgroup.org/downloads/hdfview) or the Python package\n",
-    "used by kikuchipy, [h5py](https://docs.h5py.org/en/stable). Upon loading an HDF5\n",
-    "file with extension .h5, .hdf5, or .h5ebsd, the correct reader is determined\n",
-    "from the file. Supported h5ebsd formats are listed in the\n",
+    "[h5py](https://docs.h5py.org/en/stable). Upon loading an HDF5 file with\n",
+    "extension .h5, .hdf5, or .h5ebsd, the correct reader is determined from the\n",
+    "file. Other supported h5ebsd formats are listed in the\n",
     "[table above](#Supported-EBSD-formats).\n",
     "\n",
     "If an h5ebsd file contains multiple scans, as many scans as desirable can be\n",
@@ -730,7 +695,7 @@
    "metadata": {},
    "source": [
     "Here, the h5ebsd\n",
-    "[file_reader()](../reference/generated/kikuchipy.io.plugins.h5ebsd.file_reader.rst)\n",
+    "[file_reader()](../reference/generated/kikuchipy.io.plugins.kikuchipy_h5ebsd.file_reader.rst)\n",
     "is called. If only `Scan 2` is to be read, `scan_group_names=\"Scan 2\"` can be\n",
     "passed"
    ]
@@ -781,17 +746,45 @@
    "metadata": {},
    "source": [
     "Here, the h5ebsd\n",
-    "[file_writer()](../reference/generated/kikuchipy.io.plugins.h5ebsd.file_writer.rst)\n",
-    "is called.\n",
+    "[file_writer()](../reference/generated/kikuchipy.io.plugins.kikuchipy_h5ebsd.file_writer.rst)\n",
+    "is called. The EBSD class attributes `xmap` (crystal map), `detector` (EBSD\n",
+    "detector) and `static_background` are written to and read from file if\n",
+    "available."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Bruker h5ebsd\n",
     "\n",
-    "<div class=\"alert alert-info\">\n",
+    "Bruker Nano's h5ebsd files with extension .h5, .hdf5, or .h5ebsd can be read.\n",
+    "Available parameters when calling `kikuchipy.load(\"bruker_patterns.h5\")` are\n",
+    "described in the\n",
+    "[file_reader()](../reference/generated/kikuchipy.io.plugins.bruker_h5ebsd.file_reader.rst).\n",
+    "As with the kikuchipy h5ebsd reader, multiple scans can be read at once.\n",
     "\n",
-    "Note\n",
-    "    \n",
-    "The `EBSD.xmap` and `EBSD.detector` properties are so far not written to this\n",
-    "file format.\n",
+    "The projection center (PC) arrays are read into the `detector` attribute, and\n",
+    "the static background pattern is read into the `static_background` attribute.\n",
+    "The orientation and phase data are so far not read, but note that this can be\n",
+    "read using\n",
+    "[orix](https://orix.readthedocs.io/en/stable/reference.html#module-orix.io.plugins.bruker_h5ebsd)."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### EDAX h5ebsd\n",
     "\n",
-    "</div>"
+    "EDAX's h5ebsd files with extension .h5, .hdf5, or .h5ebsd can be read.\n",
+    "Available parameters when calling `kikuchipy.load(\"edax_patterns.h5\")` are\n",
+    "described in the\n",
+    "[file_reader()](../reference/generated/kikuchipy.io.plugins.edax_h5ebsd.file_reader.rst).\n",
+    "As with the kikuchipy h5ebsd reader, multiple scans can be read at once.\n",
+    "\n",
+    "The projection center (PC) arrays are read into the `detector` attribute. The\n",
+    "orientation and phase data are so far not read."
    ]
   },
   {
@@ -953,6 +946,23 @@
     "determine the location of each non-indexed pattern."
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Oxford Instruments h5ebsd (H5OINA)\n",
+    "\n",
+    "Oxford Instruments' h5ebsd files (H5OINA) with extension .h5oina can be read.\n",
+    "Available parameters when calling `kikuchipy.load(\"patterns.h5oina\")` are\n",
+    "described in the\n",
+    "[file_reader()](../reference/generated/kikuchipy.io.plugins.oxford_h5ebsd.file_reader.rst).\n",
+    "As with the kikuchipy h5ebsd reader, multiple scans can be read at once.\n",
+    "\n",
+    "The projection center (PC) arrays are read into the `detector` attribute and the\n",
+    "static background pattern is read into the `static_background` attribute. The\n",
+    "orientation and phase data are so far not read."
+   ]
+  },
   {
    "cell_type": "markdown",
    "metadata": {},
diff --git a/doc/tutorials/reference_frames.ipynb b/doc/tutorials/reference_frames.ipynb
index 4caaac18..5318cfcd 100644
--- a/doc/tutorials/reference_frames.ipynb
+++ b/doc/tutorials/reference_frames.ipynb
@@ -650,7 +650,7 @@
     "}\n",
     "\n",
     "# Some wrangling to display a nice table\n",
-    "for (software, pc_i), pad in zip(pcs.items(), [0, 3, 5, 3, 3]):\n",
+    "for software, pc_i in pcs.items():\n",
     "    print(f\"{software:<9} {pc_i[0, 0]:>6.3f} {pc_i[0, 1]:>6.3f} {pc_i[0, 2]:>6.3f}\")"
    ]
   },
diff --git a/kikuchipy/data/bruker_h5ebsd/create_bruker_h5ebsd_file.py b/kikuchipy/data/bruker_h5ebsd/create_bruker_h5ebsd_file.py
index a828b729..a986b3a4 100644
--- a/kikuchipy/data/bruker_h5ebsd/create_bruker_h5ebsd_file.py
+++ b/kikuchipy/data/bruker_h5ebsd/create_bruker_h5ebsd_file.py
@@ -15,6 +15,12 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
+"""Creation of a dummy Bruker Nano h5ebsd file used when testing the
+Bruker h5ebsd reader.
+
+Only the groups and datasets read in the reader are included.
+"""
+
 import os
 
 from h5py import File
diff --git a/kikuchipy/data/oxford_h5ebsd/create_oxford_h5ebsd_file.py b/kikuchipy/data/oxford_h5ebsd/create_oxford_h5ebsd_file.py
new file mode 100644
index 00000000..ac047c69
--- /dev/null
+++ b/kikuchipy/data/oxford_h5ebsd/create_oxford_h5ebsd_file.py
@@ -0,0 +1,123 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Creation of a dummy Oxford Instruments h5ebsd file (H5OINA) used when
+testing the Oxford h5ebsd reader.
+
+Only the groups and datasets read in the reader are included.
+"""
+
+import os
+
+from h5py import File
+import numpy as np
+from orix.quaternion import Rotation
+
+import kikuchipy as kp
+
+
+# Orientations determined from indexing
+grain1 = (0.9542, -0.0183, -0.2806, 0.1018)
+grain2 = (0.9542, 0.0608, -0.2295, -0.1818)
+rot = Rotation([grain1, grain2, grain2, grain1, grain2, grain2, grain1, grain2, grain2])
+euler = rot.to_euler()
+
+s = kp.data.nickel_ebsd_small()
+ny, nx = s.axes_manager.navigation_shape[::-1]
+n = ny * nx
+sy, sx = s.axes_manager.signal_shape[::-1]
+dx = s.axes_manager["x"].scale
+
+dir_data = os.path.abspath(os.path.dirname(__file__))
+
+
+f = File(os.path.join(dir_data, "patterns.h5oina"), mode="w")
+
+# Top group
+f.create_dataset("Format Version", data=b"5.0")
+f.create_dataset("Index", data=b"1")
+f.create_dataset("Manufacturer", data=b"Oxford Instruments")
+f.create_dataset("Software Version", data=b"6.0.8014.1")
+scan = f.create_group("1")
+
+# EBSD
+ebsd = scan.create_group("EBSD")
+ones = np.ones(n)
+zeros = np.zeros(n)
+
+# Data
+data = ebsd.create_group("Data")
+data.create_dataset("Band Contrast", dtype="uint8", data=ones)
+data.create_dataset("Band Slope", dtype="uint8", data=ones)
+data.create_dataset("Bands", dtype="uint8", data=ones)
+data.create_dataset("Beam Position X", dtype="float32", data=ones)
+data["Beam Position X"].attrs["Unit"] = "um"
+data.create_dataset("Beam Position Y", dtype="float32", data=ones)
+data["Beam Position Y"].attrs["Unit"] = "um"
+data.create_dataset("Detector Distance", dtype="float32", data=ones)
+data.create_dataset("Error", dtype="uint8", data=ones)
+data["Error"].attrs["HighMAD"] = [np.int32(5)]
+data["Error"].attrs["LowBandContrast"] = [np.int32(3)]
+data["Error"].attrs["LowBandSlope"] = [np.int32(4)]
+data["Error"].attrs["NoSolution"] = [np.int32(2)]
+data["Error"].attrs["NotAnalyzed"] = [np.int32(0)]
+data["Error"].attrs["Replaced"] = [np.int32(7)]
+data["Error"].attrs["Success"] = [np.int32(1)]
+data["Error"].attrs["UnexpectedError"] = [np.int32(6)]
+data.create_dataset("Euler", dtype="float32", data=euler)
+data["Euler"].attrs["Unit"] = "rad"
+data.create_dataset("Mean Angular Deviation", dtype="float32", data=ones)
+data["Euler"].attrs["Mean Angular Deviation"] = "rad"
+data.create_dataset("Pattern Center X", dtype="float32", data=ones)
+data.create_dataset("Pattern Center Y", dtype="float32", data=ones)
+data.create_dataset("Pattern Quality", dtype="float32", data=ones)
+data.create_dataset("Phase", dtype="uint8", data=ones)
+s.remove_static_background()
+data.create_dataset(
+    "Processed Patterns", dtype="uint8", data=s.data.reshape((n, sy, sx))
+)
+x = np.array([0, 1, 2] * 3) * dx
+data.create_dataset("X", dtype="float32", data=x)
+data["X"].attrs["Unit"] = "um"
+data.create_dataset("Y", dtype="float32", data=np.sort(x))
+data["Y"].attrs["Unit"] = "um"
+
+# Header
+header = ebsd.create_group("Header")
+header.create_dataset("Beam Voltage", dtype="float32", data=20)
+header["Beam Voltage"].attrs["Unit"] = "kV"
+header.create_dataset("Magnification", dtype="float32", data=2000)
+header.create_dataset("Working Distance", dtype="float32", data=23.5)
+header["Working Distance"].attrs["Unit"] = "mm"
+header.create_dataset("X Cells", dtype="int32", data=3)
+header["X Cells"].attrs["Unit"] = "px"
+header.create_dataset("Y Cells", dtype="int32", data=3)
+header["Y Cells"].attrs["Unit"] = "px"
+header.create_dataset("X Step", dtype="float32", data=1.5)
+header["X Step"].attrs["Unit"] = "um"
+header.create_dataset("Y Step", dtype="float32", data=1.5)
+header["Y Step"].attrs["Unit"] = "um"
+header.create_dataset("Pattern Height", dtype="int32", data=60)
+header["Pattern Height"].attrs["Unit"] = "px"
+header.create_dataset("Pattern Width", dtype="int32", data=60)
+header["Pattern Width"].attrs["Unit"] = "px"
+header.create_dataset(
+    "Processed Static Background", dtype="uint8", data=s.static_background
+)
+header.create_dataset("Tilt Angle", dtype="float32", data=np.deg2rad(69.9))
+
+f.close()
diff --git a/kikuchipy/data/oxford_h5ebsd/patterns.h5oina b/kikuchipy/data/oxford_h5ebsd/patterns.h5oina
new file mode 100644
index 00000000..7dd62dbc
Binary files /dev/null and b/kikuchipy/data/oxford_h5ebsd/patterns.h5oina differ
diff --git a/kikuchipy/io/_io.py b/kikuchipy/io/_io.py
index e2cb8f7c..92c19a70 100644
--- a/kikuchipy/io/_io.py
+++ b/kikuchipy/io/_io.py
@@ -28,24 +28,30 @@
 
 import kikuchipy.signals
 from kikuchipy.io.plugins import (
+    bruker_h5ebsd,
+    edax_h5ebsd,
     emsoft_ebsd,
     emsoft_ebsd_master_pattern,
-    h5ebsd,
+    kikuchipy_h5ebsd,
     nordif,
     nordif_calibration_patterns,
     oxford_binary,
+    oxford_h5ebsd,
 )
 from kikuchipy.io._util import _get_input_bool, _ensure_directory
 
 
 plugins = [
+    bruker_h5ebsd,
+    edax_h5ebsd,
     emsoft_ebsd,
     emsoft_ebsd_master_pattern,
     hspy,
-    h5ebsd,
+    kikuchipy_h5ebsd,
     nordif,
     nordif_calibration_patterns,
     oxford_binary,
+    oxford_h5ebsd,
 ]
 
 default_write_ext = set()
@@ -58,7 +64,7 @@ def load(
     filename: Union[str, Path], lazy: bool = False, **kwargs
 ) -> Union["EBSD", "EBSDMasterPattern", List["EBSD"], List["EBSDMasterPattern"]]:
     """Load an :class:`~kikuchipy.signals.EBSD` or
-    :class:`~kikuchipy.signals.EBSDMasterPattern` object from a
+    :class:`~kikuchipy.signals.EBSDMasterPattern` signal from a
     supported file format.
 
     This function is a modified version of :func:`hyperspy.io.load`.
@@ -80,6 +86,11 @@ def load(
     signals
         Signal or a list of signals.
 
+    Raises
+    ------
+    IOError
+        If the file was not found or could not be read.
+
     Examples
     --------
     Import nine patterns from an HDF5 file in a directory ``DATA_DIR``
@@ -189,22 +200,25 @@ def _plugin_from_footprints(filename: str, plugins) -> Optional[object]:
     filename
         Input file name.
     plugins
-        Potential plugins.
+        Potential plugins reading HDF5 files.
 
     Returns
     -------
     plugin
-        One of the potential plugins, or None if no footprint was found.
+        One of the potential plugins, or ``None`` if no footprint was
+        found.
     """
 
-    def _hdfgroups2dict(group):
+    def _hdf5group2dict(group):
         d = {}
         for key, val in group.items():
-            key = key.lstrip().lower()
+            key_lower = key.lstrip().lower()
             if isinstance(val, Group):
-                d[key] = _hdfgroups2dict(val)
+                d[key_lower] = _hdf5group2dict(val)
+            elif key_lower == "manufacturer":
+                d[key_lower] = key
             else:
-                d[key] = 1
+                d[key_lower] = 1
         return d
 
     def _exists(obj, chain):
@@ -212,24 +226,43 @@ def _exists(obj, chain):
         if key in obj:
             return _exists(obj[key], chain) if chain else obj[key]
 
-    f = File(filename, mode="r")
-    d = _hdfgroups2dict(f["/"])
-
-    plugin = None
-    plugins_with_footprints = [p for p in plugins if hasattr(p, "footprint")]
-    for p in plugins_with_footprints:
-        n_matches = 0
-        n_desired_matches = len(p.footprint)
-        for fp in p.footprint:
-            fp = fp.lower().split("/")
-            if _exists(d, fp) is not None:
-                n_matches += 1
-        if n_matches == n_desired_matches:
-            plugin = p
-
-    f.close()
-
-    return plugin
+    with File(filename) as f:
+        d = _hdf5group2dict(f["/"])
+
+        plugins_with_footprints = [p for p in plugins if hasattr(p, "footprint")]
+        plugins_with_manufacturer = [
+            p for p in plugins_with_footprints if hasattr(p, "manufacturer")
+        ]
+
+        matching_plugin = None
+        # Check manufacturer if possible (all h5ebsd files have this)
+        for key, val in d.items():
+            if key == "manufacturer":
+                # Extracting the manufacturer is finicky
+                man = f[val][()]
+                if isinstance(man, np.ndarray) and len(man) == 1:
+                    man = man[0]
+                if isinstance(man, bytes):
+                    man = man.decode("latin-1")
+                for p in plugins_with_manufacturer:
+                    if man.lower() == p.manufacturer:
+                        matching_plugin = p
+                        break
+
+        # If no match found, continue searching
+        if matching_plugin is None:
+            for p in plugins_with_footprints:
+                n_matches = 0
+                n_desired_matches = len(p.footprint)
+                for fp in p.footprint:
+                    fp = fp.lower().split("/")
+                    if _exists(d, fp) is not None:
+                        n_matches += 1
+                if n_matches == n_desired_matches:
+                    matching_plugin = p
+                    break
+
+    return matching_plugin
 
 
 def _assign_signal_subclass(
@@ -370,7 +403,11 @@ def _save(
         is_file = os.path.isfile(filename)
 
         # Check if we are to add signal to an already existing h5ebsd file
-        if writer.format_name == "h5ebsd" and overwrite is not True and is_file:
+        if (
+            writer.format_name == "kikuchipy_h5ebsd"
+            and overwrite is not True
+            and is_file
+        ):
             if add_scan is None:
                 q = "Add scan to '{}' (y/n)?\n".format(filename)
                 add_scan = _get_input_bool(q)
diff --git a/kikuchipy/io/_util.py b/kikuchipy/io/_util.py
index e3cee4bb..a5656006 100644
--- a/kikuchipy/io/_util.py
+++ b/kikuchipy/io/_util.py
@@ -15,13 +15,10 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-from functools import reduce
 import os
-from typing import Union, Any, List, Optional
+from typing import Union, Any
 import warnings
 
-from hyperspy.misc.utils import DictionaryTreeBrowser
-
 
 def _get_input_bool(question: str) -> bool:
     """Get input from user on boolean choice, returning the answer.
@@ -76,66 +73,6 @@ def _get_input_variable(question: str, var_type: Any) -> Union[None, Any]:
         return None
 
 
-def _delete_from_nested_dictionary(
-    dictionary: Union[dict, DictionaryTreeBrowser], keys: List[str]
-) -> Union[dict, DictionaryTreeBrowser]:
-    """Delete key(s) from a nested dictionary.
-
-    Parameters
-    ----------
-    dictionary
-        Dictionary to delete key(s) from.
-    keys
-        Key(s) to delete.
-
-    Returns
-    -------
-    modified_dict
-        Dictionary without deleted keys.
-    """
-    dict_type = type(dictionary)
-    if isinstance(dictionary, DictionaryTreeBrowser):
-        dictionary = dictionary.as_dictionary()
-    modified_dict = {}
-    for key, val in dictionary.items():
-        if key not in keys:
-            if isinstance(val, dict):
-                modified_dict[key] = _delete_from_nested_dictionary(val, keys)
-            else:
-                modified_dict[key] = val
-    if dict_type != dict:  # Revert to DictionaryTreeBrowser
-        modified_dict = DictionaryTreeBrowser(modified_dict)
-    return modified_dict
-
-
-def _get_nested_dictionary(
-    dictionary: Union[dict, DictionaryTreeBrowser],
-    keys: Union[str, List[str]],
-    default: Optional[Any] = None,
-) -> dict:
-    """Get key from a nested dictionary, returning a default value if
-    not found.
-
-    Parameters
-    ----------
-    dictionary
-        Dictionary to search through
-    keys
-        List of keys to get values from.
-    default
-        Default value to return if `keys` are not found.
-    """
-    if isinstance(dictionary, DictionaryTreeBrowser):
-        dictionary = dictionary.as_dictionary()
-    if not isinstance(keys, list):
-        keys = keys.split(".")
-    return reduce(
-        lambda d, key: d.get(key, default) if isinstance(d, dict) else default,
-        keys,
-        dictionary,
-    )
-
-
 def _ensure_directory(filename: str):
     """Check if the filename path exists, create it if not."""
     directory = os.path.split(filename)[0]
diff --git a/kikuchipy/io/plugins/__init__.py b/kikuchipy/io/plugins/__init__.py
index 1033c4d0..783535e3 100644
--- a/kikuchipy/io/plugins/__init__.py
+++ b/kikuchipy/io/plugins/__init__.py
@@ -25,21 +25,27 @@
     :toctree: ../generated/
     :template: custom-module-template.rst
 
+    bruker_h5ebsd
+    edax_h5ebsd
     emsoft_ebsd
     emsoft_ebsd_master_pattern
-    h5ebsd
+    kikuchipy_h5ebsd
     nordif
     nordif_calibration_patterns
     oxford_binary
+    oxford_h5ebsd
 """
 
 __all__ = [
+    "bruker_h5ebsd",
+    "edax_h5ebsd",
     "emsoft_ebsd",
     "emsoft_ebsd_master_pattern",
-    "h5ebsd",
+    "kikuchipy_h5ebsd",
     "nordif",
     "nordif_calibration_patterns",
     "oxford_binary",
+    "oxford_h5ebsd",
 ]
 
 
diff --git a/kikuchipy/io/plugins/_h5ebsd.py b/kikuchipy/io/plugins/_h5ebsd.py
new file mode 100644
index 00000000..63d600d9
--- /dev/null
+++ b/kikuchipy/io/plugins/_h5ebsd.py
@@ -0,0 +1,495 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Generic, private parent class for all h5ebsd file plugins."""
+
+import abc
+import os
+from typing import Union, List, Tuple, Optional
+import warnings
+
+import dask.array as da
+import h5py
+import numpy as np
+
+
+__all__ = ["_dict2hdf5group", "_hdf5group2dict", "H5EBSDReader"]
+
+
+def _hdf5group2dict(
+    group: h5py.Group,
+    dictionary: Union[None, dict] = None,
+    recursive: bool = False,
+    data_dset_names: Optional[list] = None,
+) -> dict:
+    """Return a dictionary with values from datasets in a group.
+
+    Parameters
+    ----------
+    group
+        HDF5 group object.
+    dictionary
+        To fill dataset values into.
+    recursive
+        Whether to add subgroups to ``dictionary`` (default is
+        ``False``).
+    data_dset_names
+        List of names of HDF5 data sets with data to not read.
+
+    Returns
+    -------
+    dictionary
+        Dataset values in group (and subgroups if ``recursive=True``).
+    """
+    if data_dset_names is None:
+        data_dset_names = []
+    if dictionary is None:
+        dictionary = {}
+    for key, val in group.items():
+        # Prepare value for entry in dictionary
+        if isinstance(val, h5py.Dataset):
+            if key not in data_dset_names:
+                val = val[()]
+            if isinstance(val, np.ndarray) and len(val) == 1:
+                val = val[0]
+                key = key.lstrip()  # EDAX has some leading whitespaces
+            if isinstance(val, bytes):
+                val = val.decode("latin-1")
+        # Check whether to extract subgroup or write value to dictionary
+        if isinstance(val, h5py.Group) and recursive:
+            dictionary[key] = {}
+            _hdf5group2dict(
+                group=group[key],
+                dictionary=dictionary[key],
+                data_dset_names=data_dset_names,
+                recursive=recursive,
+            )
+        elif key in data_dset_names:
+            pass
+        else:
+            dictionary[key] = val
+    return dictionary
+
+
+def _dict2hdf5group(dictionary: dict, group: h5py.Group, **kwargs):
+    """Write a dictionary to datasets in a new group in an opened HDF5
+    file format.
+
+    Parameters
+    ----------
+    dictionary
+        Dictionary with keys as dataset names.
+    group
+        HDF5 group to write dictionary to.
+    **kwargs
+        Keyword arguments passed to :meth:`h5py:Group.require_dataset`.
+    """
+    for key, val in dictionary.items():
+        ddtype = type(val)
+        dshape = (1,)
+        if isinstance(val, dict):
+            _dict2hdf5group(val, group.create_group(key), **kwargs)
+            continue  # Jump to next item in dictionary
+        elif isinstance(val, str):
+            ddtype = "S" + str(len(val) + 1)
+            val = val.encode()
+        elif ddtype == np.dtype("O"):
+            try:
+                if isinstance(val, (np.ndarray, da.Array)):
+                    ddtype = val.dtype
+                else:
+                    ddtype = val[0].dtype
+                dshape = np.shape(val)
+            except TypeError:
+                warnings.warn(
+                    "The HDF5 writer could not write the following (key, value) pair to"
+                    f" file: ({key}, {val})"
+                )
+                break  # or continue?
+        group.create_dataset(key, shape=dshape, dtype=ddtype, **kwargs)
+        group[key][()] = val
+
+
+class H5EBSDReader(abc.ABC):
+    """Abstract class implementing a reader of an h5ebsd file in a
+    format specific to each manufacturer.
+
+    The file contents are ment to be used for initializing a
+    :class:`~kikuchipy.signals.EBSD` signal.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+    """
+
+    manufacturer_patterns = {
+        "bruker nano": "RawPatterns",
+        "edax": "Pattern",
+        "kikuchipy": "patterns",
+        "oxford instruments": "Processed Patterns",
+    }
+
+    def __init__(self, filename: str, **kwargs):
+        self.filename = filename
+        self.file = h5py.File(filename, **kwargs)
+        self.scan_groups = self.get_scan_groups()
+        self.manufacturer, self.version = self.get_manufacturer_version()
+        self.check_file()
+        self.patterns_name = self.manufacturer_patterns[self.manufacturer]
+
+    def __repr__(self):
+        return f"{self.__class__.__name__} ({self.version}): {self.filename}"
+
+    @property
+    def scan_group_names(self) -> List[str]:
+        """Return a list of available scan group names."""
+        return [group.name.lstrip("/") for group in self.scan_groups]
+
+    def check_file(self):
+        """Check if the file is a valid h5ebsd file by searching for
+        datasets containing manufacturer, version and scans in the top
+        group.
+
+        Raises
+        ------
+        IOError
+            If there are no datasets in the top group named
+            ``"manufacturer"`` and ``"version"``, or if there are no
+            groups in the top group containing the datasets
+            ``"EBSD/Data"`` and ``"EBSD/Header"``, or if there is no
+            reader for the file ``"manufacturer"``.
+        """
+        error = None
+        if self.manufacturer is None or self.version is None:
+            error = "manufacturer and/or version could not be read from its top group"
+        elif not any(
+            "EBSD/Data" in group and "EBSD/Header" in group
+            for group in self.scan_groups
+        ):
+            error = (
+                "no top groups with subgroup name 'EBSD' with subgroups 'Data' and "
+                "'Header' were found"
+            )
+        man, ver = self.get_manufacturer_version()
+        man = man.lower()
+        supported_manufacturers = list(self.manufacturer_patterns.keys())
+        if man not in supported_manufacturers and error is None:
+            error = (
+                f"'{man}' is not among supported manufacturers "
+                f"{supported_manufacturers}"
+            )
+        if error is not None:
+            raise IOError(f"{self.filename} is not a supported h5ebsd file, as {error}")
+
+    def get_manufacturer_version(self) -> Tuple[str, str]:
+        """Get manufacturer and version from the top group.
+
+        Returns
+        -------
+        manufacturer
+            File manufacturer.
+        version
+            File version.
+        """
+        manufacturer = None
+        version = None
+        for key, val in _hdf5group2dict(group=self.file["/"]).items():
+            if key.lower() == "manufacturer":
+                manufacturer = val.lower()
+            elif key.lower() in ["version", "format version"]:
+                version = val.lower()
+        return manufacturer, version
+
+    def get_scan_groups(self) -> List[h5py.Group]:
+        """Return a list of the groups with scans.
+
+        Assumes all top groups contain a scan.
+
+        Returns
+        -------
+        scan_groups
+            List of available scan groups.
+        """
+        scan_groups = []
+        for key in self.file.keys():
+            if isinstance(self.file[key], h5py.Group):
+                scan_groups.append(self.file[key])
+        return scan_groups
+
+    def get_desired_scan_groups(
+        self, group_names: Union[None, str, List[str]] = None
+    ) -> List[h5py.Group]:
+        """Return a list of the desired group(s) with scan(s).
+
+        Parameters
+        ----------
+        group_names
+            Name or a list of names of the desired top group(s). If not
+            given, the first scan group is returned.
+
+        Returns
+        -------
+        scan_groups
+            A list of the desired scan group(s).
+        """
+        # Get desired scan groups
+        scan_groups = []
+        if group_names is None:  # Return the first scan group
+            scan_groups.append(self.scan_groups[0])
+        else:
+            if isinstance(group_names, str):
+                group_names = [group_names]
+            for desired_name in group_names:
+                scan_is_here = False
+                for name, scan in zip(self.scan_group_names, self.scan_groups):
+                    if desired_name == name:
+                        scan_groups.append(scan)
+                        scan_is_here = True
+                        break
+                if not scan_is_here:
+                    error_str = (
+                        f"Scan '{desired_name}' is not among the available scans "
+                        f"{self.scan_group_names} in '{self.filename}'."
+                    )
+                    if len(group_names) == 1:
+                        raise IOError(error_str)
+                    else:
+                        warnings.warn(error_str)
+        return scan_groups
+
+    def get_data(
+        self,
+        group: h5py.Group,
+        data_shape: tuple,
+        lazy: bool = False,
+        indices: Optional[np.ndarray] = None,
+    ) -> Union[np.ndarray, da.Array]:
+        """Read and return patterns from file as a NumPy or Dask array.
+
+        Parameters
+        ----------
+        group
+            Group with patterns.
+        data_shape
+            Output shape of pattern array, (ny, nx, sy, sx) = (
+            map rows, map columns, pattern rows, pattern columns).
+        lazy
+            Whether to read dataset lazily (default is ``False``).
+        indices
+            Mapping from pattern entry in the file to the 2D map, only
+            used in the Bruker Nano reader.
+
+        Returns
+        -------
+        data
+            Patterns, possibly padded.
+
+        Raises
+        ------
+        KeyError
+            If patterns cannot be found in the expected dataset.
+
+        Warns
+        -----
+        UserWarning
+            If pattern array is smaller than the data shape determined
+            from other datasets in the file.
+        """
+        ny, nx, sy, sx = data_shape
+
+        # Get HDF5 dataset with pattern array
+        try:
+            data_dset = group["EBSD/Data/" + self.patterns_name]
+        except KeyError:
+            raise KeyError(
+                "Could not find patterns in the expected dataset "
+                f"'EBSD/Data/{self.patterns_name}'"
+            )
+
+        # Get array from dataset
+        if lazy:
+            if data_dset.chunks is None:
+                chunks = "auto"
+            else:
+                chunks = data_dset.chunks
+            data = da.from_array(data_dset, chunks=chunks)
+        else:
+            data = np.asanyarray(data_dset)
+
+        # Reshape array
+        try:
+            if indices is not None:
+                data = data[indices]
+            data = data.reshape((ny, nx, sy, sx)).squeeze()
+        except ValueError:
+            warnings.warn(
+                f"Pattern size ({sx} x {sy}) and scan size ({nx} x {ny}) larger than "
+                "file size. Will attempt to load by zero padding incomplete frames"
+            )
+            # Data is stored image by image
+            pw = [(0, ny * nx * sy * sx - data.size)]
+            if lazy:
+                data = da.pad(data.flatten(), pw)
+            else:
+                data = np.pad(data.flatten(), pw)
+            data = data.reshape((ny, nx, sy, sx))
+
+        return data
+
+    @staticmethod
+    def get_axes_list(data_shape: tuple, data_scale: tuple) -> List[dict]:
+        """Return a description of each data axis.
+
+        Parameters
+        ----------
+        data_shape
+            4D shape of pattern array, ``(ny, nx, sy, sx)`` = (
+            map rows, map columns, pattern rows, pattern columns).
+        data_scale
+            Map scale and detector pixel size, ``(dy, dx, px_size)``.
+
+        Returns
+        -------
+        axes_list
+            Description of each data axis as a list of dictionaries.
+        """
+        ny, nx, sy, sx = data_shape
+        dy, dx, px_size = data_scale
+
+        data_ndim = sum([ny != 1, nx != 1]) + 2
+
+        units = ["um"] * 4
+        scales = np.ones(4)
+
+        # Calibrate scan dimension and detector dimension
+        scales[0] *= dy
+        scales[1] *= dx
+        scales[2] *= px_size
+        scales[3] *= px_size
+
+        # Set axes names
+        names = ["y", "x", "dy", "dx"]
+        if data_ndim == 3:
+            if ny > nx:
+                names.remove("x")
+                scales = np.delete(scales, 1)
+                data_shape = np.delete(data_shape, 1)
+            else:
+                names.remove("y")
+                scales = np.delete(scales, 0)
+                data_shape = np.delete(data_shape, 0)
+        elif data_ndim == 2:
+            names = names[2:]
+            scales = scales[2:]
+            data_shape = data_shape[2:]
+
+        # Create list of axis objects
+        axes_list = [
+            {
+                "size": data_shape[i],
+                "index_in_array": i,
+                "name": names[i],
+                "scale": scales[i],
+                "offset": 0.0,
+                "units": units[i],
+            }
+            for i in range(data_ndim)
+        ]
+
+        return axes_list
+
+    def get_metadata_filename_title(self, group_name: str) -> Tuple[str, str]:
+        """Return filename without full path and a scan title for the
+        signal metadata.
+
+        Parameters
+        ----------
+        group_name
+            Name of scan group.
+
+        Returns
+        -------
+        fname
+            Filename without full path.
+        title
+            Scan title.
+        """
+        fname = os.path.basename(self.filename).split(".")[0]
+        title = fname + " " + group_name[1:].split("/")[0]
+        if len(title) > 20:
+            title = f"{title:.20}..."
+        return fname, title
+
+    def read(
+        self,
+        group_names: Union[None, str, List[str]] = None,
+        lazy: bool = False,
+    ) -> List[dict]:
+        """Return a list of dictionaries which can be used to create
+        :class:`~kikuchipy.signals.EBSD` signals.
+
+        The file is closed after reading if ``lazy=False``.
+
+        Parameters
+        ----------
+        group_names
+            Name or a list of names of the desired top HDF5 group(s). If
+            not given, the first scan group is returned.
+        lazy
+            Read dataset lazily (default is ``False``). If ``False``,
+            the file is closed after reading.
+
+        Returns
+        -------
+        scan_list
+            List of dictionaries with keys ``"axes"``, ``"data"``,
+            ``"metadata"``, ``"original_metadata"``, ``"detector"``,
+            (possibly) ``"static_background"``, and ``"xmap"``.
+        """
+        scan_dict_list = []
+        for scan in self.get_desired_scan_groups(group_names):
+            scan_dict_list.append(self.scan2dict(scan, lazy))
+
+        if not lazy:
+            self.file.close()
+
+        return scan_dict_list
+
+    @abc.abstractmethod
+    def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
+        """Read (possibly lazily) patterns from group.
+
+        Parameters
+        ----------
+        group
+            HDF5 group with patterns.
+        lazy
+            Read dataset lazily (default is ``False``).
+
+        Returns
+        -------
+        scan_dict
+            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
+            ``"original_metadata"``, ``"detector"``,
+            ``"static_background"``, and ``"xmap"``. This dictionary can
+             be passed as keyword arguments to create an
+             :class:`~kikuchipy.signals.EBSD` signal.
+        """
+        return NotImplemented  # pragma: no cover
diff --git a/kikuchipy/io/plugins/bruker_h5ebsd.py b/kikuchipy/io/plugins/bruker_h5ebsd.py
new file mode 100644
index 00000000..654a73b3
--- /dev/null
+++ b/kikuchipy/io/plugins/bruker_h5ebsd.py
@@ -0,0 +1,258 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Reader of EBSD data from a Bruker Nano h5ebsd file."""
+
+from pathlib import Path
+from typing import List, Union
+
+import h5py
+import numpy as np
+from orix.crystal_map import CrystalMap
+
+from kikuchipy.detectors import EBSDDetector
+from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict, H5EBSDReader
+
+
+__all__ = ["file_reader"]
+
+
+# Plugin characteristics
+# ----------------------
+format_name = "bruker_h5ebsd"
+description = (
+    "Read support for electron backscatter diffraction patterns stored "
+    "in an HDF5 file formatted in Bruker Nano's h5ebsd format, similar "
+    "to the format described in Jackson et al.: h5ebsd: an archival "
+    "data format for electron back-scatter diffraction data sets. "
+    "Integrating Materials and Manufacturing Innovation 2014 3:4, doi: "
+    "https://dx.doi.org/10.1186/2193-9772-3-4."
+)
+full_support = False
+# Recognised file extension
+file_extensions = ["h5", "hdf5", "h5ebsd"]
+default_extension = 0
+# Writing capabilities (signal dimensions, navigation dimensions)
+writes = False
+
+# Unique HDF5 footprint
+footprint = ["manufacturer", "version"]
+manufacturer = "bruker nano"
+
+
+class BrukerH5EBSDReader(H5EBSDReader):
+    """Bruker Nano h5ebsd file reader.
+
+    The file contents are ment to be used for initializing a
+    :class:`~kikuchipy.signals.EBSD` signal.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+    """
+
+    def __init__(self, filename: str, **kwargs):
+        super().__init__(filename, **kwargs)
+
+    def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
+        """Read (possibly lazily) patterns from group.
+
+        Parameters
+        ----------
+        group
+            Group with patterns.
+        lazy
+            Whether to read dataset lazily (default is ``False``).
+
+        Returns
+        -------
+        scan_dict
+            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
+            ``"original_metadata"``, ``"detector"``,
+            ``"static_background"``, and ``"xmap"``. This dictionary can
+             be passed as keyword arguments to create an
+             :class:`~kikuchipy.signals.EBSD` signal.
+
+        Raises
+        ------
+        IOError
+            If patterns are not acquired in a square grid.
+        KeyError
+            If patterns cannot be found in the expected dataset.
+        ValueError
+            If a non-rectangular region of interest is used.
+
+        Warns
+        -----
+        UserWarning
+            If pattern array is smaller than the data shape determined
+            from other datasets in the file.
+        """
+        hd = _hdf5group2dict(group["EBSD/Header"], recursive=True)
+        dd = _hdf5group2dict(group["EBSD/Data"], data_dset_names=self.patterns_name)
+
+        # Ensure file can be read
+        grid_type = hd.get("Grid Type")
+        if grid_type != "isometric":
+            raise IOError(f"Only square grids are supported, not {grid_type}")
+
+        # Get region of interest (ROI, only rectangular shape supported)
+        indices = None
+        roi = False
+        try:
+            sd = _hdf5group2dict(group["EBSD/SEM"])
+            iy = sd["IY"][()]
+            ix = sd["IX"][()]
+            roi = True
+        except KeyError:
+            ny = hd["NROWS"]
+            nx = hd["NCOLS"]
+        if roi:
+            ny_roi, nx_roi, is_rectangular = _bruker_roi_is_rectangular(iy, ix)
+            if is_rectangular:
+                ny = ny_roi
+                nx = nx_roi
+                # Get indices of patterns in the 2D map
+                idx = np.array([iy - iy.min(), ix - ix.min()])
+                indices = np.ravel_multi_index(idx, (ny, nx)).argsort()
+            else:
+                raise ValueError("Only a rectangular region of interest is supported")
+
+        # Get other data shapes
+        sy, sx = hd["PatternHeight"], hd["PatternWidth"]
+        dy, dx = hd["YSTEP"], hd["XSTEP"]
+        px_size = hd.get("DetectorFullHeightMicrons", 1) / hd.get(
+            "UnClippedPatternHeight", 1
+        )
+
+        # --- Metadata
+        fname, title = self.get_metadata_filename_title(group.name)
+        metadata = {
+            "Acquisition_instrument": {
+                "SEM": {
+                    "beam_energy": hd.get("KV"),
+                    "magnification": hd.get("Magnification"),
+                    "working_distance": hd.get("WD"),
+                },
+            },
+            "General": {
+                "original_filename": hd.get("OriginalFile", fname),
+                "title": title,
+            },
+            "Signal": {"signal_type": "EBSD", "record_by": "image"},
+        }
+        scan_dict = {"metadata": metadata}
+
+        # --- Data
+        data = self.get_data(
+            group,
+            data_shape=(ny, nx, sy, sx),
+            lazy=lazy,
+            indices=indices,
+        )
+        scan_dict["data"] = data
+
+        # --- Axes
+        scan_dict["axes"] = self.get_axes_list((ny, nx, sy, sx), (dy, dx, px_size))
+
+        # --- Original metadata
+        scan_dict["original_metadata"] = {
+            "manufacturer": self.manufacturer,
+            "version": self.version,
+        }
+        scan_dict["original_metadata"].update(hd)
+
+        # --- Crystal map
+        # TODO: Use reader from orix
+        xmap = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
+        scan_dict["xmap"] = xmap
+
+        # --- Static background
+        scan_dict["static_background"] = hd.get("StaticBackground")
+
+        # --- Detector
+        pc = np.column_stack(
+            (dd.get("PCX", 0.5), dd.get("PCY", 0.5), dd.get("DD", 0.5))
+        )
+        if pc.size > 3:
+            pc = pc.reshape((ny, nx, 3))
+        scan_dict["detector"] = EBSDDetector(
+            shape=(sy, sx),
+            px_size=px_size,
+            tilt=hd.get("CameraTilt", 0),
+            sample_tilt=hd.get("Sample Tilt", 70),
+            pc=pc,
+        )
+
+        return scan_dict
+
+
+def _bruker_roi_is_rectangular(iy, ix):
+    iy_unique, iy_unique_counts = np.unique(iy, return_counts=True)
+    ix_unique, ix_unique_counts = np.unique(ix, return_counts=True)
+    is_rectangular = (
+        np.all(np.diff(np.sort(iy_unique)) == 1)
+        and np.all(np.diff(np.sort(ix_unique)) == 1)
+        and np.unique(iy_unique_counts).size == 1
+        and np.unique(ix_unique_counts).size == 1
+    )
+    iy2 = np.max(iy) - np.min(iy) + 1
+    ix2 = np.max(ix) - np.min(ix) + 1
+    return iy2, ix2, is_rectangular
+
+
+def file_reader(
+    filename: Union[str, Path],
+    scan_group_names: Union[None, str, List[str]] = None,
+    lazy: bool = False,
+    **kwargs,
+) -> List[dict]:
+    """Read electron backscatter diffraction patterns, a crystal map,
+    and an EBSD detector from a Bruker h5ebsd file
+    :cite:`jackson2014h5ebsd`.
+
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    scan_group_names
+        Name or a list of names of HDF5 top group(s) containing the
+        scan(s) to return. If not given (default), the first scan in the
+        file is returned.
+    lazy
+        Open the data lazily without actually reading the data from disk
+        until required. Allows opening arbitrary sized datasets. Default
+        is ``False``.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+
+    Returns
+    -------
+    scan_dict_list
+        List of one or more dictionaries with the keys ``"axes"``,
+        ``"data"``, ``"metadata"``, ``"original_metadata"``,
+        ``"detector"``, ``"static_background"``, and ``"xmap"``. This
+        dictionary can be passed as keyword arguments to create an
+        :class:`~kikuchipy.signals.EBSD` signal.
+    """
+    reader = BrukerH5EBSDReader(filename, **kwargs)
+    return reader.read(scan_group_names, lazy)
diff --git a/kikuchipy/io/plugins/edax_h5ebsd.py b/kikuchipy/io/plugins/edax_h5ebsd.py
new file mode 100644
index 00000000..ef56fde1
--- /dev/null
+++ b/kikuchipy/io/plugins/edax_h5ebsd.py
@@ -0,0 +1,202 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Reader of EBSD data from an EDAX TSL h5ebsd file."""
+
+from pathlib import Path
+from typing import List, Union
+
+import h5py
+from orix.crystal_map import CrystalMap
+
+from kikuchipy.detectors import EBSDDetector
+from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict, H5EBSDReader
+
+
+__all__ = ["file_reader"]
+
+
+# Plugin characteristics
+# ----------------------
+format_name = "edax_h5ebsd"
+description = (
+    "Read support for electron backscatter diffraction patterns stored "
+    "in an HDF5 file formatted in EDAX TSL's h5ebsd format, similar to "
+    "the format described in Jackson et al.: h5ebsd: an archival data "
+    "format for electron back-scatter diffraction data sets. "
+    "Integrating Materials and Manufacturing Innovation 2014 3:4, doi: "
+    "https://dx.doi.org/10.1186/2193-9772-3-4."
+)
+full_support = False
+# Recognised file extension
+file_extensions = ["h5", "hdf5", "h5ebsd"]
+default_extension = 0
+# Writing capabilities (signal dimensions, navigation dimensions)
+writes = False
+
+# Unique HDF5 footprint
+footprint = ["manufacturer", "version"]
+manufacturer = "edax"
+
+
+class EDAXH5EBSDReader(H5EBSDReader):
+    """EDAX TSL h5ebsd file reader.
+
+    The file contents are ment to be used for initializing a
+    :class:`~kikuchipy.signals.EBSD` signal.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+    """
+
+    def __init__(self, filename: str, **kwargs):
+        super().__init__(filename, **kwargs)
+
+    def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
+        """Read (possibly lazily) patterns from group.
+
+        Parameters
+        ----------
+        group
+            Group with patterns.
+        lazy
+            Whether to read dataset lazily (default is ``False``).
+
+        Returns
+        -------
+        scan_dict
+            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
+            ``"original_metadata"``, ``"detector"``,
+            ``"static_background"``, and ``"xmap"``. This dictionary can
+             be passed as keyword arguments to create an
+             :class:`~kikuchipy.signals.EBSD` signal.
+
+        Raises
+        ------
+        IOError
+            If patterns are not acquired in a square grid.
+        """
+        hd = _hdf5group2dict(group["EBSD/Header"], recursive=True)
+        if "SEM-PRIAS Images" in group.keys():
+            sd = _hdf5group2dict(group["SEM-PRIAS Images"])
+        else:
+            sd = {}
+
+        # Ensure file can be read
+        grid_type = hd.get("Grid Type")
+        if grid_type != "SqrGrid":
+            raise IOError(f"Only square grids are supported, not {grid_type}")
+
+        # Get data shapes
+        ny, nx = hd["nRows"], hd["nColumns"]
+        sy, sx = hd["Pattern Height"], hd["Pattern Width"]
+        dy, dx = hd.get("Step Y", 1), hd.get("Step X", 1)
+        px_size = 1.0
+
+        # --- Metadata
+        fname, title = self.get_metadata_filename_title(group.name)
+        metadata = {
+            "Acquisition_instrument": {
+                "SEM": {
+                    "working_distance": hd.get("Working Distance"),
+                    "magnification": sd.get("Header", {}).get("Mag"),
+                },
+            },
+            "General": {"original_filename": fname, "title": title},
+            "Signal": {"signal_type": "EBSD", "record_by": "image"},
+        }
+        scan_dict = {"metadata": metadata}
+
+        # --- Data
+        data = self.get_data(group, data_shape=(ny, nx, sy, sx), lazy=lazy)
+        scan_dict["data"] = data
+
+        # --- Axes
+        scan_dict["axes"] = self.get_axes_list((ny, nx, sy, sx), (dy, dx, px_size))
+
+        # --- Original metadata
+        scan_dict["original_metadata"] = {
+            "manufacturer": self.manufacturer,
+            "version": self.version,
+        }
+        scan_dict["original_metadata"].update(hd)
+
+        # --- Crystal map
+        # TODO: Implement reader of EDAX h5ebsd crystal maps in orix
+        xmap = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
+        scan_dict["xmap"] = xmap
+
+        # --- Detector
+        scan_dict["detector"] = EBSDDetector(
+            shape=(sy, sx),
+            px_size=px_size,
+            tilt=hd.get("Camera Elevation Angle", 0),
+            azimuthal=hd.get("Camera Azimuthal Angle", 0),
+            sample_tilt=hd.get("Sample Tilt", 70),
+            pc=(
+                hd.get("Pattern Center Calibration", {}).get("x-star", 0.5),
+                hd.get("Pattern Center Calibration", {}).get("y-star", 0.5),
+                hd.get("Pattern Center Calibration", {}).get("z-star", 0.5),
+            ),
+            convention="edax",
+        )
+
+        return scan_dict
+
+
+def file_reader(
+    filename: Union[str, Path],
+    scan_group_names: Union[None, str, List[str]] = None,
+    lazy: bool = False,
+    **kwargs,
+) -> List[dict]:
+    """Read electron backscatter diffraction patterns, a crystal map,
+    and an EBSD detector from an EDAX h5ebsd file
+    :cite:`jackson2014h5ebsd`.
+
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    scan_group_names
+        Name or a list of names of HDF5 top group(s) containing the
+        scan(s) to return. If not given (default), the first scan in the
+        file is returned.
+    lazy
+        Open the data lazily without actually reading the data from disk
+        until required. Allows opening arbitrary sized datasets. Default
+        is ``False``.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+
+    Returns
+    -------
+    scan_dict_list
+        List of one or more dictionaries with the keys ``"axes"``,
+        ``"data"``, ``"metadata"``, ``"original_metadata"``,
+        ``"detector"``, and ``"xmap"``. This
+        dictionary can be passed as keyword arguments to create an
+        :class:`~kikuchipy.signals.EBSD` signal.
+    """
+    reader = EDAXH5EBSDReader(filename, **kwargs)
+    return reader.read(scan_group_names, lazy)
diff --git a/kikuchipy/io/plugins/emsoft_ebsd.py b/kikuchipy/io/plugins/emsoft_ebsd.py
index e34f1217..8f8fdc56 100644
--- a/kikuchipy/io/plugins/emsoft_ebsd.py
+++ b/kikuchipy/io/plugins/emsoft_ebsd.py
@@ -15,12 +15,11 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-"""Read support for simulated EBSD patterns in EMsoft's HDF5 format."""
+"""Reader of simulated EBSD patterns from an EMsoft HDF5 file."""
 
 import os
 from pathlib import Path
 from typing import List, Tuple, Union
-import warnings
 
 import dask.array as da
 from diffpy.structure import Atom, Lattice, Structure
@@ -29,12 +28,8 @@
 from orix.crystal_map import CrystalMap, Phase, PhaseList
 from orix.quaternion import Rotation
 
-from kikuchipy.io.plugins.h5ebsd import hdf5group2dict
-from kikuchipy.signals.util._metadata import (
-    ebsd_metadata,
-    metadata_nodes,
-    _set_metadata_from_mapping,
-)
+from kikuchipy.detectors import EBSDDetector
+from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
 
 
 __all__ = ["file_reader"]
@@ -68,6 +63,8 @@ def file_reader(
     """Read dynamically simulated electron backscatter diffraction
     patterns from EMsoft's format produced by their EMEBSD.f90 program.
 
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
     Parameters
     ----------
     filename
@@ -91,66 +88,62 @@ def file_reader(
 
     _check_file_format(f)
 
-    # Read original metadata
-    omd = hdf5group2dict(f["/"], data_dset_names=["EBSDPatterns"], recursive=True)
-
-    # Set metadata and original metadata dictionaries
-    md = _get_metadata(omd)
-    md.update(
-        {
-            "Signal": {"signal_type": "EBSD", "record_by": "image"},
-            "General": {
-                "title": f.filename.split("/")[-1].split(".")[0],
-                "original_filename": f.filename.split("/")[-1],
-            },
-        }
-    )
-    scan = {"metadata": md, "original_metadata": omd}
+    group = f["/"]
+    hd = _hdf5group2dict(group, data_dset_names=["EBSDPatterns"], recursive=True)
+    nml_dict = hd["NMLparameters"]["EBSDNameList"]
+
+    # --- Metadata
+    fname = os.path.basename(filename).split(".")[0]
+    metadata = {
+        "Acquisition_instrument": {
+            "SEM": {"beam_energy": nml_dict["energymax"]},
+        },
+        "General": {"original_filename": fname, "title": fname},
+        "Signal": {"signal_type": "EBSD", "record_by": "image"},
+    }
+    scan = {"metadata": metadata, "original_metadata": hd}
 
-    # Read patterns
+    # --- Data
     dataset = f["EMData/EBSD/EBSDPatterns"]
     if lazy:
         chunks = "auto" if dataset.chunks is None else dataset.chunks
-        patterns = da.from_array(dataset, chunks=chunks)
+        data = da.from_array(dataset, chunks=chunks)
     else:
-        patterns = np.asanyarray(dataset)
-
+        data = np.asanyarray(dataset)
     # Reshape data if desired
-    sy = omd["NMLparameters"]["EBSDNameList"]["numsy"]
-    sx = omd["NMLparameters"]["EBSDNameList"]["numsx"]
+    sy = nml_dict["numsy"]
+    sx = nml_dict["numsx"]
     if scan_size is not None:
         if isinstance(scan_size, int):
             new_shape = (scan_size, sy, sx)
         else:
             new_shape = scan_size + (sy, sx)
-        patterns = patterns.reshape(new_shape)
-
-    scan["data"] = patterns
+        data = data.reshape(new_shape)
+    scan["data"] = data
 
-    # Set navigation and signal axes
-    pixel_size = omd["NMLparameters"]["EBSDNameList"]["delta"]
-    ndim = patterns.ndim
+    # --- Axes
+    px_size = nml_dict["delta"]
     units = ["px", "um", "um"]
     names = ["x", "dy", "dx"]
-    scales = np.array([1, pixel_size, pixel_size])
-    if ndim == 4:
+    scales = np.array([1, px_size, px_size])
+    if data.ndim == 4:
         units = ["px"] + units
         names = ["y"] + names
         scales = np.append([1], scales)
     scan["axes"] = [
         {
-            "size": patterns.shape[i],
+            "size": data.shape[i],
             "index_in_array": i,
             "name": names[i],
             "scale": scales[i],
             "offset": 0,
             "units": units[i],
         }
-        for i in range(patterns.ndim)
+        for i in range(data.ndim)
     ]
 
-    # Get crystal map
-    phase = _crystaldata2phase(hdf5group2dict(f["CrystalData"]))
+    # --- Crystal map
+    phase = _crystaldata2phase(_hdf5group2dict(f["CrystalData"]))
     xtal_fname = f["EMData/EBSD/xtalname"][()][0].decode().split("/")[-1]
     phase.name, _ = os.path.splitext(xtal_fname)
     scan["xmap"] = CrystalMap(
@@ -158,6 +151,16 @@ def file_reader(
         phase_list=PhaseList(phase),
     )
 
+    # --- Detector
+    scan["detector"] = EBSDDetector(
+        shape=(sy, sx),
+        binning=nml_dict["binning"],
+        px_size=px_size,
+        tilt=nml_dict["thetac"],
+        pc=(nml_dict["xpc"], nml_dict["ypc"], nml_dict["L"]),
+        convention="emsoft",
+    )
+
     if not lazy:
         f.close()
 
@@ -182,37 +185,6 @@ def _check_file_format(file: File):
         )
 
 
-def _get_metadata(omd: dict) -> dict:
-    """Return metadata dictionary from original metadata dictionary.
-
-    Parameters
-    ----------
-    omd
-        Dictionary with original metadata.
-
-    Returns
-    -------
-    md
-        Dictionary with metadata.
-    """
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    md = ebsd_metadata()
-    sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-    md.set_item(f"{ebsd_node}.manufacturer", "EMsoft")
-    mapping = {
-        f"{ebsd_node}.version": ["EMheader", "EBSD", "Version"],
-        f"{ebsd_node}.binning": ["NMLparameters", "EBSDNameList", "binning"],
-        f"{ebsd_node}.elevation_angle": ["NMLparameters", "EBSDNameList", "thetac"],
-        f"{ebsd_node}.exposure_time": ["NMLparameters", "EBSDNameList", "dwelltime"],
-        f"{ebsd_node}.xpc": ["NMLparameters", "EBSDNameList", "xpc"],
-        f"{ebsd_node}.ypc": ["NMLparameters", "EBSDNameList", "ypc"],
-        f"{ebsd_node}.zpc": ["NMLparameters", "EBSDNameList", "L"],
-        f"{sem_node}.beam_energy": ["NMLparameters", "EBSDNameList", "energymax"],
-    }
-    _set_metadata_from_mapping(omd, md, mapping)
-    return md.as_dictionary()
-
-
 def _crystaldata2phase(dictionary: dict) -> Phase:
     """Return a :class:`~orix.crystal_map.Phase` object from a
     dictionary with EMsoft CrystalData group content.
diff --git a/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py b/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
index 4d74932c..752d12b5 100644
--- a/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
+++ b/kikuchipy/io/plugins/emsoft_ebsd_master_pattern.py
@@ -15,8 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-"""Read support for simulated EBSD master patterns in EMsoft's HDF5
-format.
+"""Reader of simulated EBSD master patterns from an EMsoft HDF5 file.
 """
 
 import os
@@ -28,7 +27,7 @@
 from h5py import File, Group, Dataset
 import numpy as np
 
-from kikuchipy.io.plugins.h5ebsd import hdf5group2dict
+from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
 from kikuchipy.io.plugins.emsoft_ebsd import _crystaldata2phase
 
 
@@ -64,6 +63,8 @@ def file_reader(
     """Read electron backscatter diffraction master patterns from
     EMsoft's HDF5 file format :cite:`callahan2013dynamical`.
 
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
     Parameters
     ----------
     filename
@@ -119,11 +120,11 @@ def file_reader(
             "original_filename": f.filename.split("/")[-1],
         },
     }
-    nml_params = hdf5group2dict(f["NMLparameters"], recursive=True)
+    nml_params = _hdf5group2dict(f["NMLparameters"], recursive=True)
 
     # Get phase information and add it to both the original metadata and
     # a Phase object
-    crystal_data = hdf5group2dict(f["CrystalData"])
+    crystal_data = _hdf5group2dict(f["CrystalData"])
     nml_params["CrystalData"] = crystal_data
     phase = _crystaldata2phase(crystal_data)
     if phase.name == "":
diff --git a/kikuchipy/io/plugins/h5ebsd.py b/kikuchipy/io/plugins/h5ebsd.py
deleted file mode 100644
index 4af43e0b..00000000
--- a/kikuchipy/io/plugins/h5ebsd.py
+++ /dev/null
@@ -1,979 +0,0 @@
-# Copyright 2019-2022 The kikuchipy developers
-#
-# This file is part of kikuchipy.
-#
-# kikuchipy is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# kikuchipy is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
-
-"""Read/write support for EBSD patterns in some HDF5 file formats."""
-
-import os
-from pathlib import Path
-from typing import Union, List, Tuple, Optional, Dict
-import warnings
-
-import dask.array as da
-from hyperspy.misc.utils import DictionaryTreeBrowser
-from hyperspy.exceptions import VisibleDeprecationWarning
-from hyperspy.io_plugins.hspy import overwrite_dataset
-import h5py
-import numpy as np
-
-from kikuchipy.io._util import (
-    _get_input_variable,
-    _get_nested_dictionary,
-    _delete_from_nested_dictionary,
-)
-from kikuchipy.signals.util._metadata import (
-    ebsd_metadata,
-    metadata_nodes,
-    _phase_metadata,
-    _update_phase_info,
-)
-
-
-__all__ = ["file_reader", "file_writer"]
-
-
-# Plugin characteristics
-# ----------------------
-format_name = "h5ebsd"
-description = (
-    "Read/write support for electron backscatter diffraction patterns "
-    "stored in an HDF5 file formatted in the h5ebsd format introduced "
-    "in Jackson et al.: h5ebsd: an archival data format for electron"
-    "back-scatter diffraction data sets. Integrating Materials and"
-    "Manufacturing Innovation 2014 3:4, doi: "
-    "https://dx.doi.org/10.1186/2193-9772-3-4."
-)
-full_support = False
-# Recognised file extension
-file_extensions = ["h5", "hdf5", "h5ebsd"]
-default_extension = 1
-# Writing capabilities (signal dimensions, navigation dimensions)
-writes = [(2, 2), (2, 1), (2, 0)]
-
-# Unique HDF5 footprint
-footprint = ["manufacturer", "version"]
-
-
-def file_reader(
-    filename: Union[str, Path],
-    scan_group_names: Union[None, str, List[str]] = None,
-    lazy: bool = False,
-    **kwargs,
-) -> List[dict]:
-    """Read electron backscatter diffraction patterns from an h5ebsd
-    file :cite:`jackson2014h5ebsd`.
-
-    A valid h5ebsd file has at least one top group with the subgroup
-    'EBSD' with the subgroups 'Data' (patterns etc.) and 'Header'
-    (``metadata`` etc.).
-
-    Parameters
-    ----------
-    filename
-        Full file path of the HDF file.
-    scan_group_names
-        Name or a list of names of HDF5 top group(s) containing the
-        scan(s) to return. If not given (default), the first scan in the
-        file is returned.
-    lazy
-        Open the data lazily without actually reading the data from disk
-        until required. Allows opening arbitrary sized datasets. Default
-        is ``False``.
-    **kwargs
-        Key word arguments passed to :class:`h5py.File`.
-
-    Returns
-    -------
-    scan_dict_list
-        Data, axes, metadata and original metadata.
-    """
-    mode = kwargs.pop("mode", "r")
-    f = h5py.File(filename, mode=mode, **kwargs)
-
-    # Check if h5ebsd file, and get all scan groups
-    check_h5ebsd(f)
-
-    # Get manufacturer and version and check if reading the file is supported
-    man, ver = manufacturer_version(f)
-    man_pats = manufacturer_pattern_names()
-    if any(man.lower() == s for s in man_pats.keys()) is not True:
-        raise IOError(
-            f"Manufacturer {man} not among recognised manufacturers "
-            f"{list(man_pats.keys())}."
-        )
-
-    # Get scans to return
-    scans_return = get_desired_scan_groups(file=f, scan_group_names=scan_group_names)
-
-    # Parse file
-    scan_dict_list = []
-    for scan in scans_return:
-        scan_dict_list.append(h5ebsd2signaldict(scan, man, ver, lazy=lazy))
-
-    if not lazy:
-        f.close()
-
-    return scan_dict_list
-
-
-def check_h5ebsd(file: h5py.File):
-    """Check if HDF file is an h5ebsd file by searching for datasets
-    containing manufacturer, version and scans in the top group.
-
-    Parameters
-    ----------
-    file: h5py:File
-        File where manufacturer, version and scan datasets should
-        reside in the top group.
-    """
-    top_groups = list(file["/"].keys())
-    scan_groups = get_scan_groups(file)
-    n_groups = len(top_groups)
-    if len(scan_groups) != n_groups - 2:
-        raise IOError(
-            f"'{file.filename}' is not an h5ebsd file, as manufacturer and/or version "
-            "could not be read from its top group."
-        )
-
-    if not any(
-        "EBSD/Data" in group and "EBSD/Header" in group for group in scan_groups
-    ):
-        raise IOError(
-            f"'{file.filename}' is not an h5ebsd file, as no top groups with subgroup "
-            "name 'EBSD' with subgroups 'Data' and 'Header' was detected"
-        )
-
-
-def get_scan_groups(file: h5py.File) -> List[h5py.Group]:
-    """Return a list of the scan group names from an h5ebsd file.
-
-    Parameters
-    ----------
-    file : h5py:File
-        File where manufacturer, version and scan datasets should reside
-        in the top group.
-
-    Returns
-    -------
-    scan_groups : h5py:Group
-        List of available scan groups.
-    """
-    scan_groups = []
-    for key in file["/"].keys():
-        if key.lstrip().lower() not in ["manufacturer", "version"]:
-            scan_groups.append(file[key])
-
-    return scan_groups
-
-
-def manufacturer_version(file: h5py.File) -> Tuple[str, str]:
-    """Get manufacturer and version from h5ebsd file.
-
-    Parameters
-    ----------
-    file : h5py:File
-        File with manufacturer and version datasets in the top group.
-
-    Returns
-    -------
-    manufacturer : str
-    version : str
-    """
-    manufacturer = None
-    version = None
-    for key, val in hdf5group2dict(group=file["/"]).items():
-        if key.lower() == "manufacturer":
-            manufacturer = val
-        if key.lower() == "version":
-            version = val
-    return manufacturer, version
-
-
-def manufacturer_pattern_names() -> Dict[str, str]:
-    """Return mapping of string of supported manufacturers to the names
-    of their HDF dataset where the patterns are stored.
-
-    Returns
-    -------
-    dict
-    """
-    return {"kikuchipy": "patterns", "edax": "Pattern", "bruker nano": "RawPatterns"}
-
-
-def get_desired_scan_groups(
-    file: h5py.File, scan_group_names: Union[None, str, List[str]] = None
-) -> List[h5py.Group]:
-    """Get the desired HDF5 groups with scans within them.
-
-    Parameters
-    ----------
-    file : h5py:File
-        File where manufacturer, version and scan datasets should
-        reside in the top group.
-    scan_group_names
-        Name or a list of names of the desired top HDF5 group(s). If
-        None, the first scan group is returned.
-
-    Returns
-    -------
-    scan_groups
-        A list of the desired scan group(s) in the file.
-    """
-    # Get available scan group names in the file
-    scan_groups_file = get_scan_groups(file)
-
-    # Get desired scan groups
-    scan_groups = []
-    if scan_group_names is None:  # Return the first scan group
-        scan_groups.append(scan_groups_file[0])
-    else:
-        if isinstance(scan_group_names, str):
-            scan_group_names = [scan_group_names]
-        for desired_scan in scan_group_names:
-            scan_is_here = False
-            for scan in scan_groups_file:
-                if desired_scan == scan.name.lstrip("/"):
-                    scan_groups.append(scan)
-                    scan_is_here = True
-                    break
-            if not scan_is_here:
-                error_str = (
-                    f"Scan '{desired_scan}' is not among the available scans "
-                    f"{scan_groups_file} in '{file.name}'."
-                )
-                if len(scan_group_names) == 1:
-                    raise IOError(error_str)
-                else:
-                    warnings.warn(error_str)
-
-    return scan_groups
-
-
-def hdf5group2dict(
-    group: h5py.Group,
-    dictionary: Union[None, dict, DictionaryTreeBrowser] = None,
-    recursive: bool = False,
-    data_dset_names: Optional[list] = None,
-    **kwargs,
-) -> dict:
-    """Return a dictionary with values from datasets in a group in an
-    opened HDF5 file.
-
-    Parameters
-    ----------
-    group : h5py:Group
-        HDF group object.
-    dictionary
-        To fill dataset values into.
-    recursive
-        Whether to add subgroups to dictionary.
-    data_dset_names
-        List of names of HDF data sets with data to not read.
-
-    Returns
-    -------
-    dictionary : dict
-        Dataset values in group (and subgroups if recursive=True).
-    """
-    if "lazy" in kwargs.keys():
-        warnings.warn(
-            "The 'lazy' parameter is not used in this method. Passing it will raise an "
-            "error from v0.3.",
-            VisibleDeprecationWarning,
-        )
-
-    if data_dset_names is None:
-        data_dset_names = []
-    if dictionary is None:
-        dictionary = {}
-    for key, val in group.items():
-        # Prepare value for entry in dictionary
-        if isinstance(val, h5py.Dataset):
-            if key not in data_dset_names:
-                val = val[()]
-            if isinstance(val, np.ndarray) and len(val) == 1:
-                val = val[0]
-                key = key.lstrip()  # EDAX has some leading whitespaces
-            if isinstance(val, bytes):
-                val = val.decode("latin-1")
-        # Check whether to extract subgroup or write value to dictionary
-        if isinstance(val, h5py.Group) and recursive:
-            dictionary[key] = {}
-            hdf5group2dict(
-                group=group[key],
-                dictionary=dictionary[key],
-                data_dset_names=data_dset_names,
-                recursive=recursive,
-            )
-        elif key in data_dset_names:
-            pass
-        else:
-            dictionary[key] = val
-    return dictionary
-
-
-def h5ebsd2signaldict(
-    scan_group: h5py.Group, manufacturer: str, version: str, lazy: bool = False
-) -> dict:
-    """Return a dictionary with ``signal``, ``metadata`` and
-    ``original_metadata`` from an h5ebsd scan.
-
-    Parameters
-    ----------
-    scan_group : h5py:Group
-        HDF group of scan.
-    manufacturer
-        Manufacturer of file. Options are
-        "kikuchipy"/"EDAX"/"Bruker Nano".
-    version
-        Version of manufacturer software.
-    lazy
-        Read dataset lazily.
-
-    Returns
-    -------
-    scan : dict
-        Dictionary with patterns, ``metadata`` and
-        ``original_metadata``.
-    """
-    md, omd, scan_size = h5ebsdheader2dicts(scan_group, manufacturer, version, lazy)
-    md.set_item("Signal.signal_type", "EBSD")
-    md.set_item("Signal.record_by", "image")
-
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    static_bg_node = metadata_nodes("ebsd") + ".static_background"
-    scan = {
-        "metadata": md.as_dictionary(),
-        "original_metadata": omd.as_dictionary(),
-        "attributes": {},
-        "static_background": md.get_item(static_bg_node, None),
-    }
-
-    # Get data dataset
-    man_pats = manufacturer_pattern_names()
-    data_dset = None
-    for man, pats in man_pats.items():
-        if manufacturer.lower() == man.lower():
-            try:
-                data_dset = scan_group["EBSD/Data/" + pats]
-            except KeyError:
-                raise KeyError(
-                    "Could not find patterns in the expected dataset "
-                    f"'EBSD/Data/{pats}'"
-                )
-            break
-
-    # Get data from group
-    if lazy:
-        if data_dset.chunks is None:
-            chunks = "auto"
-        else:
-            chunks = data_dset.chunks
-        data = da.from_array(data_dset, chunks=chunks)
-    else:
-        data = np.asanyarray(data_dset)
-
-    sx, sy = scan_size.sx, scan_size.sy
-    nx, ny = scan_size.nx, scan_size.ny
-    try:
-        if "indices" in scan_size.keys():
-            # Because of Bruker region of interest
-            data = data[scan_size.indices]
-        data = data.reshape((ny, nx, sy, sx)).squeeze()
-    except ValueError:
-        warnings.warn(
-            f"Pattern size ({sx} x {sy}) and scan size ({nx} x {ny}) larger than file "
-            "size. Will attempt to load by zero padding incomplete frames"
-        )
-        # Data is stored image by image
-        pw = [(0, ny * nx * sy * sx - data.size)]
-        if lazy:
-            data = da.pad(data.flatten(), pw, mode="constant")
-        else:
-            data = np.pad(data.flatten(), pw, mode="constant")
-        data = data.reshape((ny, nx, sy, sx))
-    scan["data"] = data
-
-    units = ["um"] * 4
-    scales = np.ones(4)
-    # Calibrate scan dimension and detector dimension
-    scales[0] *= scan_size.step_y
-    scales[1] *= scan_size.step_x
-    scales[2] *= scan_size.delta
-    scales[3] *= scan_size.delta
-    # Set axes names
-    names = ["y", "x", "dy", "dx"]
-    if data.ndim == 3:
-        if ny > nx:
-            names.remove("x")
-            scales = np.delete(scales, 1)
-        else:
-            names.remove("y")
-            scales = np.delete(scales, 0)
-    elif data.ndim == 2:
-        names = names[2:]
-        scales = scales[2:]
-
-    # Create axis objects for each axis
-    axes = [
-        {
-            "size": data.shape[i],
-            "index_in_array": i,
-            "name": names[i],
-            "scale": scales[i],
-            "offset": 0.0,
-            "units": units[i],
-        }
-        for i in range(data.ndim)
-    ]
-    scan["axes"] = axes
-
-    return scan
-
-
-def h5ebsdheader2dicts(
-    scan_group: h5py.Group, manufacturer: str, version: str, lazy: bool = False
-) -> Tuple[DictionaryTreeBrowser, DictionaryTreeBrowser, DictionaryTreeBrowser]:
-    """Return three dictionaries in HyperSpy's
-    :class:`hyperspy.misc.utils.DictionaryTreeBrowser` format, one
-    with the h5ebsd scan header parameters as kikuchipy metadata,
-    another with all datasets in the header as original metadata, and
-    the last with info about scan size, image size and detector pixel
-    size.
-
-    Parameters
-    ----------
-    scan_group : h5py:Group
-        HDF group of scan data and header.
-    manufacturer
-        Manufacturer of file. Options are
-        "kikuchipy"/"EDAX"/"Bruker Nano"
-    version
-        Version of manufacturer software used to create file.
-    lazy
-        Read dataset lazily.
-
-    Returns
-    -------
-    md
-        kikuchipy ``metadata`` elements available in file.
-    omd
-        All metadata available in file.
-    scan_size
-        Scan, image, step and detector pixel size available in file.
-    """
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    md = ebsd_metadata()
-    title = (
-        scan_group.file.filename.split("/")[-1].split(".")[0]
-        + " "
-        + scan_group.name[1:].split("/")[0]
-    )
-    if len(title) > 20:
-        title = "{:.20}...".format(title)
-    md.set_item("General.title", title)
-
-    if "edax" in manufacturer.lower():
-        md, omd, scan_size = edaxheader2dicts(scan_group, md)
-    elif "bruker" in manufacturer.lower():
-        md, omd, scan_size = brukerheader2dicts(scan_group, md)
-    else:  # kikuchipy
-        md, omd, scan_size = kikuchipyheader2dicts(scan_group, md)
-
-    ebsd_node = metadata_nodes("ebsd")
-    md.set_item(ebsd_node + ".manufacturer", manufacturer)
-    md.set_item(ebsd_node + ".version", version)
-
-    return md, omd, scan_size
-
-
-def kikuchipyheader2dicts(
-    scan_group: h5py.Group, md: DictionaryTreeBrowser
-) -> Tuple[DictionaryTreeBrowser, DictionaryTreeBrowser, DictionaryTreeBrowser]:
-    """Return scan metadata dictionaries from a kikuchipy h5ebsd file.
-
-    Parameters
-    ----------
-    scan_group : h5py:Group
-        HDF group of scan data and header.
-    md
-        Dictionary with empty fields from kikuchipy's metadata.
-
-    Returns
-    -------
-    md
-        kikuchipy ``metadata`` elements available in kikuchipy file.
-    omd
-        All metadata available in kikuchipy file.
-    scan_size
-        Scan, image, step and detector pixel size available in
-        kikuchipy file.
-    """
-    # Data sets to not read via hdf5group2dict
-    pattern_dset_names = list(manufacturer_pattern_names().values())
-
-    omd = DictionaryTreeBrowser()
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-    md.set_item(
-        ebsd_node,
-        hdf5group2dict(
-            group=scan_group["EBSD/Header"], data_dset_names=pattern_dset_names
-        ),
-    )
-    md = _delete_from_nested_dictionary(md, "Phases")
-    phase_node = "Sample.Phases"
-    md.set_item(
-        sem_node,
-        hdf5group2dict(
-            group=scan_group["SEM/Header"], data_dset_names=pattern_dset_names
-        ),
-    )
-    md.set_item(
-        phase_node,
-        hdf5group2dict(
-            group=scan_group["EBSD/Header/Phases"],
-            data_dset_names=pattern_dset_names,
-            recursive=True,
-        ),
-    )
-
-    # Get and remove scan info values from metadata
-    mapping = {
-        "sx": "pattern_width",
-        "sy": "pattern_height",
-        "nx": "n_columns",
-        "ny": "n_rows",
-        "step_x": "step_x",
-        "step_y": "step_y",
-        "delta": "detector_pixel_size",
-    }
-    scan_size = DictionaryTreeBrowser()
-    for k, v in mapping.items():
-        scan_size.set_item(k, _get_nested_dictionary(md, ebsd_node + "." + v))
-    md = _delete_from_nested_dictionary(md, mapping.values())
-
-    return md, omd, scan_size
-
-
-def edaxheader2dicts(
-    scan_group: h5py.Group, md: DictionaryTreeBrowser
-) -> Tuple[DictionaryTreeBrowser, DictionaryTreeBrowser, DictionaryTreeBrowser]:
-    """Return scan metadata dictionaries from an EDAX TSL h5ebsd file.
-
-    Parameters
-    ----------
-    scan_group : h5py:Group
-        HDF group of scan data and header.
-    md
-        Dictionary with empty fields from kikuchipy's metadata.
-
-    Returns
-    -------
-    md
-        kikuchipy ``metadata`` elements available in EDAX file.
-    omd
-        All metadata available in EDAX file.
-    scan_size
-        Scan, image, step and detector pixel size available in EDAX
-        file.
-    """
-    # Get header group as dictionary
-    pattern_dset_names = list(manufacturer_pattern_names().values())
-    hd = hdf5group2dict(
-        group=scan_group["EBSD/Header"],
-        data_dset_names=pattern_dset_names,
-        recursive=True,
-    )
-
-    # Populate metadata dictionary
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-    md.set_item(ebsd_node + ".azimuth_angle", hd["Camera Azimuthal Angle"])
-    md.set_item(ebsd_node + ".elevation_angle", hd["Camera Elevation Angle"])
-    grid_type = hd["Grid Type"]
-    if grid_type == "SqrGrid":
-        md.set_item(ebsd_node + ".grid_type", "square")
-    else:
-        raise IOError(
-            f"Only square grids are supported, however a {grid_type} grid was passed"
-        )
-    md.set_item(ebsd_node + ".sample_tilt", hd["Sample Tilt"])
-    md.set_item("General.authors", hd["Operator"])
-    md.set_item("General.notes", hd["Notes"])
-    md.set_item(ebsd_node + ".xpc", hd["Pattern Center Calibration"]["x-star"])
-    md.set_item(ebsd_node + ".ypc", hd["Pattern Center Calibration"]["y-star"])
-    md.set_item(ebsd_node + ".zpc", hd["Pattern Center Calibration"]["z-star"])
-    md.set_item(sem_node + ".working_distance", hd["Working Distance"])
-    if "SEM-PRIAS Images" in scan_group.keys():
-        md.set_item(
-            sem_node + ".magnification", scan_group["SEM-PRIAS Images/Header/Mag"][0]
-        )
-    # Loop over phases in group and add to metadata
-    for phase_no, phase in hd["Phase"].items():
-        phase["material_name"] = phase["MaterialName"]
-        phase["lattice_constants"] = [
-            phase["Lattice Constant a"],
-            phase["Lattice Constant b"],
-            phase["Lattice Constant c"],
-            phase["Lattice Constant alpha"],
-            phase["Lattice Constant beta"],
-            phase["Lattice Constant gamma"],
-        ]
-        md = _update_phase_info(md, phase, phase_no)
-
-    # Populate original metadata dictionary
-    omd = DictionaryTreeBrowser({"edax_header": hd})
-
-    # Populate scan size dictionary
-    scan_size = DictionaryTreeBrowser()
-    scan_size.set_item("sx", hd["Pattern Width"])
-    scan_size.set_item("sy", hd["Pattern Height"])
-    scan_size.set_item("nx", hd["nColumns"])
-    scan_size.set_item("ny", hd["nRows"])
-    scan_size.set_item("step_x", hd["Step X"])
-    scan_size.set_item("step_y", hd["Step Y"])
-    scan_size.set_item("delta", 1.0)
-
-    return md, omd, scan_size
-
-
-def brukerheader2dicts(
-    scan_group: h5py.Group, md: DictionaryTreeBrowser
-) -> Tuple[DictionaryTreeBrowser, DictionaryTreeBrowser, DictionaryTreeBrowser]:
-    """Return scan metadata dictionaries from a Bruker h5ebsd file.
-
-    Parameters
-    ----------
-    scan_group : h5py:Group
-        HDF group of scan data and header.
-    md : hyperspy.misc.utils.DictionaryTreeBrowser
-        Dictionary with empty fields from kikuchipy's metadata.
-
-    Returns
-    -------
-    md
-        kikuchipy ``metadata`` elements available in Bruker file.
-    omd
-        All metadata available in Bruker file.
-    scan_size
-        Scan, image, step and detector pixel size available in Bruker
-        file.
-    """
-    # Get header group and data group as dictionaries
-    pattern_dset_names = list(manufacturer_pattern_names().values())
-    hd = hdf5group2dict(
-        group=scan_group["EBSD/Header"],
-        data_dset_names=pattern_dset_names,
-        recursive=True,
-    )
-    dd = hdf5group2dict(
-        group=scan_group["EBSD/Data"], data_dset_names=pattern_dset_names
-    )
-
-    # Populate metadata dictionary
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-    md.set_item(ebsd_node + ".elevation_angle", hd["CameraTilt"])
-    grid_type = hd["Grid Type"]
-    if grid_type == "isometric":
-        md.set_item(ebsd_node + ".grid_type", "square")
-    else:
-        raise IOError(
-            f"Only square grids are supported, however a {grid_type} grid was passed"
-        )
-    # Values: data set name, data group, function to apply, node
-    dset_mapping = {
-        "sample_tilt": ["SampleTilt", hd, None, ebsd_node],
-        "xpz": ["PCX", dd, np.nanmean, ebsd_node],
-        "ypc": ["PCY", dd, np.nanmean, ebsd_node],
-        "zpc": ["DD", dd, np.nanmean, ebsd_node],
-        "static_background": ["StaticBackground", hd, None, ebsd_node],
-        "working_distance": ["WD", hd, None, sem_node],
-        "beam_energy": ["KV", hd, None, sem_node],
-        "magnification": ["Magnification", hd, None, sem_node],
-    }
-    for k, v in dset_mapping.items():
-        dset_name, dset_group, func, node = v
-        try:
-            dset = dset_group[dset_name]
-            if func:
-                dset = func(dset)
-        except KeyError:
-            dset = None
-        md.set_item(node + f".{k}", dset)
-    # Loop over phases
-    for phase_no, phase in hd["Phases"].items():
-        phase["material_name"] = phase["Name"]
-        phase["space_group"] = phase["IT"]
-        phase["atom_coordinates"] = {}
-        for key, val in phase["AtomPositions"].items():
-            atom = val.split(",")
-            atom[1:] = list(map(float, atom[1:]))
-            phase["atom_coordinates"][key] = {
-                "atom": atom[0],
-                "coordinates": atom[1:4],
-                "site_occupation": atom[4],
-                "debye_waller_factor": atom[5],
-            }
-        md = _update_phase_info(md, phase, phase_no)
-
-    # Populate original metadata dictionary
-    omd = DictionaryTreeBrowser({"bruker_header": hd})
-
-    # Initialize scan size dictionary
-    scan_size = DictionaryTreeBrowser()
-
-    # Get region of interest (ROI, only rectangular shape supported)
-    try:
-        sd = hdf5group2dict(group=scan_group["EBSD/SEM"])
-        ir = sd["IY"][()]
-        ic = sd["IX"][()]
-        roi = True
-    except KeyError:
-        roi = False
-        nr = hd["NROWS"]
-        nc = hd["NCOLS"]
-    if roi:
-        nr_roi, nc_roi, is_rectangular = _bruker_roi_is_rectangular(ir, ic)
-        if is_rectangular:
-            nr = nr_roi
-            nc = nc_roi
-            # Get indices of patterns in the 2D map
-            idx = np.array([ir - np.min(ir), ic - np.min(ic)])
-            scan_size["indices"] = np.ravel_multi_index(idx, (nr, nc)).argsort()
-        else:
-            raise ValueError("Only a rectangular region of interest is supported")
-
-    # Populate scan size dictionary
-    scan_size.set_item("sx", hd["PatternWidth"])
-    scan_size.set_item("sy", hd["PatternHeight"])
-    scan_size.set_item("nx", nc)
-    scan_size.set_item("ny", nr)
-    scan_size.set_item("step_x", hd["XSTEP"])
-    scan_size.set_item("step_y", hd["YSTEP"])
-    scan_size.set_item(
-        "delta", hd["DetectorFullHeightMicrons"] / hd["UnClippedPatternHeight"]
-    )
-
-    return md, omd, scan_size
-
-
-def _bruker_roi_is_rectangular(ir, ic):
-    ir_unique, ir_unique_counts = np.unique(ir, return_counts=True)
-    ic_unique, ic_unique_counts = np.unique(ic, return_counts=True)
-    is_rectangular = (
-        np.all(np.diff(np.sort(ir_unique)) == 1)
-        and np.all(np.diff(np.sort(ic_unique)) == 1)
-        and np.unique(ir_unique_counts).size == 1
-        and np.unique(ic_unique_counts).size == 1
-    )
-    ir2 = np.max(ir) - np.min(ir) + 1
-    ic2 = np.max(ic) - np.min(ic) + 1
-    return ir2, ic2, is_rectangular
-
-
-# (n rows, n columns). Source: internet
-BRUKER_DETECTOR_MODEL_RESOLUTION = dict(
-    eflash_fs=(480, 640),
-    eflash_hd=(1200, 1600),
-    eflash_hr=(1200, 1600),
-    eflash_xs=(540, 720),
-)
-
-
-def file_writer(
-    filename: str,
-    signal,
-    add_scan: Optional[bool] = None,
-    scan_number: int = 1,
-    **kwargs,
-):
-    """Write an :class:`~kikuchipy.signals.EBSD` or
-    :class:`~kikuchipy.signals.LazyEBSD` signal to an existing,
-    but not open, or new h5ebsd file.
-
-    Only writing to kikuchipy's h5ebsd format is supported.
-
-    Parameters
-    ----------
-    filename
-        Full path of HDF file.
-    signal : kikuchipy.signals.EBSD or kikuchipy.signals.LazyEBSD
-        Signal instance.
-    add_scan
-        Add signal to an existing, but not open, h5ebsd file. If it does
-        not exist it is created and the signal is written to it.
-    scan_number
-        Scan number in name of HDF dataset when writing to an existing,
-        but not open, h5ebsd file.
-    kwargs
-        Keyword arguments passed to :meth:`h5py:Group.require_dataset`.
-    """
-    # Set manufacturer and version to use in file
-    from kikuchipy.release import version as ver_signal
-
-    man_ver_dict = {"manufacturer": "kikuchipy", "version": ver_signal}
-
-    # Open file in correct mode
-    mode = "w"
-    if os.path.isfile(filename) and add_scan:
-        mode = "r+"
-    try:
-        f = h5py.File(filename, mode=mode)
-    except OSError:
-        raise OSError("Cannot write to an already open file.")
-
-    if os.path.isfile(filename) and add_scan:
-        check_h5ebsd(f)
-        man_file, ver_file = manufacturer_version(f)
-        if man_ver_dict["manufacturer"].lower() != man_file.lower():
-            f.close()
-            raise IOError(
-                f"Only writing to kikuchipy's (and not {man_file}'s) h5ebsd format is "
-                "supported."
-            )
-        man_ver_dict["version"] = ver_file
-
-        # Get valid scan number
-        scans_file = [f[k] for k in f["/"].keys() if "Scan" in k]
-        scan_nos = [int(i.name.split()[-1]) for i in scans_file]
-        for i in scan_nos:
-            if i == scan_number:
-                q = f"Scan {i} already in file, enter another scan number:\n"
-                scan_number = _get_input_variable(q, int)
-                if scan_number is None:
-                    raise IOError("Invalid scan number.")
-    else:  # File did not exist
-        dict2h5ebsdgroup(man_ver_dict, f["/"], **kwargs)
-
-    scan_group = f.create_group("Scan " + str(scan_number))
-
-    # Create scan dictionary with EBSD and SEM metadata
-    # Add scan size, image size and detector pixel size to dictionary to write
-    data_shape = [1] * 4  # (ny, nx, sy, sx)
-    data_scales = [1] * 4  # (y, x, dy, dx)
-    nav_extent = [0, 1, 0, 1]  # (x0, x1, y0, y1)
-    am = signal.axes_manager
-    nav_axes = am.navigation_axes
-    nav_dim = am.navigation_dimension
-    if nav_dim == 1:
-        nav_axis = nav_axes[0]
-        if nav_axis.name == "y":
-            data_shape[0] = nav_axis.size
-            data_scales[0] = nav_axis.scale
-            nav_extent[2:] = am.navigation_extent
-        else:  # nav_axis.name == "x" or something else
-            data_shape[1] = nav_axis.size
-            data_scales[1] = nav_axis.scale
-            nav_extent[:2] = am.navigation_extent
-    elif nav_dim == 2:
-        data_shape[:2] = [i.size for i in nav_axes][::-1]
-        data_scales[:2] = [i.scale for i in nav_axes][::-1]
-        nav_extent = am.navigation_extent
-    data_shape[2:] = am.signal_shape[::-1]
-    data_scales[2:] = [i.scale for i in am.signal_axes]
-    ny, nx, sy, sx = data_shape
-    scale_ny, scale_nx, scale_sy, _ = data_scales
-    md = signal.metadata.deepcopy()
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-    md.set_item(ebsd_node + ".pattern_width", sx)
-    md.set_item(ebsd_node + ".pattern_height", sy)
-    md.set_item(ebsd_node + ".n_columns", nx)
-    md.set_item(ebsd_node + ".n_rows", ny)
-    md.set_item(ebsd_node + ".step_x", scale_nx)
-    md.set_item(ebsd_node + ".step_y", scale_ny)
-    md.set_item(ebsd_node + ".detector_pixel_size", scale_sy)
-    # Separate EBSD and SEM metadata
-    det_str, ebsd_str = ebsd_node.split(".")[-2:]  # Detector and EBSD nodes
-    md_sem = md.get_item(sem_node).copy().as_dictionary()  # SEM node as dict
-    md_det = md_sem.pop(det_str)  # Remove/assign detector node from SEM node
-    md_ebsd = md_det.pop(ebsd_str)
-    # Phases
-    if md.get_item("Sample.Phases") is None:
-        md = _update_phase_info(md, _phase_metadata())  # Add default phase
-    md_ebsd["Phases"] = md.Sample.Phases.as_dictionary()
-    for phase in md_ebsd["Phases"].keys():  # Ensure coordinates are arrays
-        atom_coordinates = md_ebsd["Phases"][phase]["atom_coordinates"]
-        for atom in atom_coordinates.keys():
-            atom_coordinates[atom]["coordinates"] = np.array(
-                atom_coordinates[atom]["coordinates"]
-            )
-    scan = {"EBSD": {"Header": md_ebsd}, "SEM": {"Header": md_sem}}
-
-    # Write scan dictionary to HDF groups
-    dict2h5ebsdgroup(scan, scan_group)
-
-    # Write signal to file
-    man_pats = manufacturer_pattern_names()
-    dset_pattern_name = man_pats["kikuchipy"]
-    overwrite_dataset(
-        scan_group.create_group("EBSD/Data"),
-        signal.data.reshape(nx * ny, sy, sx),
-        dset_pattern_name,
-        signal_axes=(2, 1),
-        **kwargs,
-    )
-    nx_start, nx_stop, ny_start, ny_stop = nav_extent
-    sample_pos = {
-        "y_sample": np.tile(np.linspace(ny_start, ny_stop, ny), nx),
-        "x_sample": np.tile(np.linspace(nx_start, nx_stop, nx), ny),
-    }
-    dict2h5ebsdgroup(sample_pos, scan_group["EBSD/Data"])
-
-    f.close()
-
-
-def dict2h5ebsdgroup(dictionary: dict, group: h5py.Group, **kwargs):
-    """Write a dictionary from ``metadata`` to datasets in a new group
-    in an opened HDF file in the h5ebsd format.
-
-    Parameters
-    ----------
-    dictionary
-        ``Metadata``, with keys as dataset names.
-    group : h5py:Group
-        HDF group to write dictionary to.
-    kwargs
-        Keyword arguments passed to :meth:`h5py:Group.require_dataset`.
-    """
-    for key, val in dictionary.items():
-        ddtype = type(val)
-        dshape = (1,)
-        if isinstance(val, (dict, DictionaryTreeBrowser)):
-            if isinstance(val, DictionaryTreeBrowser):
-                val = val.as_dictionary()
-            dict2h5ebsdgroup(val, group.create_group(key), **kwargs)
-            continue  # Jump to next item in dictionary
-        elif isinstance(val, str):
-            ddtype = "S" + str(len(val) + 1)
-            val = val.encode()
-        elif ddtype == np.dtype("O"):
-            try:
-                if isinstance(val, (np.ndarray, da.Array)):
-                    ddtype = val.dtype
-                else:
-                    ddtype = val[0].dtype
-                dshape = np.shape(val)
-            except TypeError:
-                warnings.warn(
-                    "The hdf5 writer could not write the following information to the "
-                    f"file '{key} : {val}'"
-                )
-                break  # or continue?
-        group.create_dataset(key, shape=dshape, dtype=ddtype, **kwargs)
-        group[key][()] = val
diff --git a/kikuchipy/io/plugins/kikuchipy_h5ebsd.py b/kikuchipy/io/plugins/kikuchipy_h5ebsd.py
new file mode 100644
index 00000000..47bcaa09
--- /dev/null
+++ b/kikuchipy/io/plugins/kikuchipy_h5ebsd.py
@@ -0,0 +1,483 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Reader and writer of EBSD data from a kikuchipy h5ebsd file."""
+
+import os
+from pathlib import Path
+from typing import List, Optional, Tuple, Union
+
+import h5py
+from hyperspy.io_plugins.hspy import overwrite_dataset
+import numpy as np
+from orix.crystal_map import CrystalMap
+from orix.io.plugins.orix_hdf5 import crystalmap2dict, dict2crystalmap
+from orix import __version__ as orix_version
+
+from kikuchipy.detectors import EBSDDetector
+from kikuchipy.io.plugins._h5ebsd import _dict2hdf5group, _hdf5group2dict, H5EBSDReader
+from kikuchipy.io._util import _get_input_variable
+from kikuchipy.release import version as kikuchipy_version
+from kikuchipy.signals.util._crystal_map import _crystal_map_is_compatible_with_signal
+
+
+__all__ = ["file_reader", "file_writer"]
+
+
+# Plugin characteristics
+# ----------------------
+format_name = "kikuchipy_h5ebsd"
+description = (
+    "Read/write support for electron backscatter diffraction patterns "
+    "stored in an HDF5 file formatted in kikuchipy's h5ebsd format, "
+    "similar to the format described in Jackson et al.: h5ebsd: an "
+    "archival data format for electron back-scatter diffraction data "
+    "sets. Integrating Materials and Manufacturing Innovation 2014 3:4,"
+    " doi: https://dx.doi.org/10.1186/2193-9772-3-4."
+)
+full_support = True
+# Recognised file extension
+file_extensions = ["h5", "hdf5", "h5ebsd"]
+default_extension = 1
+# Writing capabilities (signal dimensions, navigation dimensions)
+writes = [(2, 2), (2, 1), (2, 0)]
+
+# Unique HDF5 footprint
+footprint = ["manufacturer", "version"]
+manufacturer = "kikuchipy"
+
+
+class KikuchipyH5EBSDReader(H5EBSDReader):
+    """kikuchipy h5ebsd file reader.
+
+    The file contents are ment to be used for initializing a
+    :class:`~kikuchipy.signals.EBSD` signal.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+    """
+
+    def __init__(self, filename: str, **kwargs):
+        super().__init__(filename, **kwargs)
+
+    def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
+        """Read (possibly lazily) patterns from group.
+
+        Parameters
+        ----------
+        group
+            Group with patterns.
+        lazy
+            Whether to read dataset lazily (default is ``False``).
+
+        Returns
+        -------
+        scan_dict
+            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
+            ``"original_metadata"``, ``"detector"``,
+            ``"static_background"``, and ``"xmap"``. This dictionary can
+             be passed as keyword arguments to create an
+             :class:`~kikuchipy.signals.EBSD` signal.
+        """
+        hd = _hdf5group2dict(group["EBSD/Header"], recursive=True)
+
+        # Note: When written, these were obtained from the
+        # `axes_manager` attribute, not the `xmap` one
+        ny, nx = hd["n_rows"], hd["n_columns"]
+        sy, sx = hd["pattern_height"], hd["pattern_width"]
+        dy, dx = hd.get("step_y", 1), hd.get("step_x", 1)
+        px_size = hd.get("detector_pixel_size", 1)
+
+        # --- Metadata
+        fname, title = self.get_metadata_filename_title(group.name)
+        metadata = {
+            "Acquisition_instrument": {
+                "SEM": _hdf5group2dict(
+                    group["SEM/Header"], data_dset_names=[self.patterns_name]
+                ),
+            },
+            "General": {"original_filename": fname, "title": title},
+            "Signal": {"signal_type": "EBSD", "record_by": "image"},
+        }
+        scan_dict = {"metadata": metadata}
+
+        # --- Data
+        data = self.get_data(group, data_shape=(ny, nx, sy, sx), lazy=lazy)
+        scan_dict["data"] = data
+
+        # --- Axes
+        scan_dict["axes"] = self.get_axes_list((ny, nx, sy, sx), (dy, dx, px_size))
+
+        # --- Original metadata
+        scan_dict["original_metadata"] = {
+            "manufacturer": self.manufacturer,
+            "version": self.version,
+        }
+        scan_dict["original_metadata"].update(hd)
+
+        # --- Crystal map
+        if "CrystalMap" in group["EBSD"]:
+            xmap = dict2crystalmap(
+                _hdf5group2dict(group["EBSD/CrystalMap/crystal_map"], recursive=True)
+            )
+        else:
+            xmap = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
+        scan_dict["xmap"] = xmap
+
+        # --- Static background
+        scan_dict["static_background"] = hd.get("static_background")
+
+        # --- Detector
+        pc = np.column_stack(
+            (hd.get("pcx", 0.5), hd.get("pcy", 0.5), hd.get("pcz", 0.5))
+        )
+        if pc.size > 3:
+            pc = pc.reshape((ny, nx, 3))
+        scan_dict["detector"] = EBSDDetector(
+            shape=(sy, sx),
+            px_size=px_size,
+            binning=hd.get("binning", 1),
+            tilt=hd.get("elevation_angle", 0),
+            azimuthal=hd.get("azimuth_angle", 0),
+            sample_tilt=hd.get("sample_tilt", 70),
+            pc=pc,
+        )
+
+        return scan_dict
+
+
+def file_reader(
+    filename: Union[str, Path],
+    scan_group_names: Union[None, str, List[str]] = None,
+    lazy: bool = False,
+    **kwargs,
+) -> List[dict]:
+    """Read electron backscatter diffraction patterns, a crystal map,
+    and an EBSD detector from a kikuchipy h5ebsd file
+    :cite:`jackson2014h5ebsd`.
+
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
+    The file is closed after reading if ``lazy=False``.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    scan_group_names
+        Name or a list of names of HDF5 top group(s) containing the
+        scan(s) to return. If not given (default), the first scan in the
+        file is returned.
+    lazy
+        Open the data lazily without actually reading the data from disk
+        until required. Allows opening arbitrary sized datasets. Default
+        is ``False``.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+
+    Returns
+    -------
+    scan_dict_list
+        List of one or more dictionaries with the keys ``"axes"``,
+        ``"data"``, ``"metadata"``, ``"original_metadata"``,
+        ``"detector"``, ``"static_background"``, and ``"xmap"``. This
+        dictionary can be passed as keyword arguments to create an
+        :class:`~kikuchipy.signals.EBSD` signal.
+    """
+    reader = KikuchipyH5EBSDReader(filename, **kwargs)
+    return reader.read(scan_group_names, lazy)
+
+
+class KikuchipyH5EBSDWriter:
+    """kikuchipy h5ebsd file writer.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    signal : kikuchipy.signals.EBSD
+        EBSD signal.
+    add_scan
+        Whether to add the signal to the file if it exists and is
+        closed. If the file exists but this is not ``True``, the file
+        will be overwritten.
+    """
+
+    def __init__(
+        self, filename: Union[str, Path], signal, add_scan: Optional[bool] = None
+    ):
+        self.filename = filename
+        self.signal = signal
+        self.file_exists = os.path.isfile(filename)
+        self.add_scan = add_scan
+
+        if self.file_exists and self.add_scan:
+            mode = "r+"
+        else:
+            mode = "w"
+            self.file_exists = False
+        try:
+            self.file = h5py.File(self.filename, mode=mode)
+        except OSError:
+            raise OSError("Cannot write to an already open file")
+
+        self.scan_groups = self.get_scan_groups()
+
+        if self.file_exists:
+            self.check_file()
+
+    def __repr__(self):
+        return f"{self.__class__.__name__}: {self.filename}"
+
+    @property
+    def data_shape_scale(self) -> Tuple[tuple, tuple]:
+        """Return the shape and scale of the data to write to file."""
+        data_shape = [1] * 4  # (ny, nx, sy, sx)
+        data_scales = [1] * 3  # (dy, dx, px_size)
+        am = self.signal.axes_manager
+        nav_axes = am.navigation_axes
+        nav_dim = am.navigation_dimension
+        if nav_dim == 1:
+            nav_axis = nav_axes[0]
+            if nav_axis.name == "y":
+                data_shape[0] = nav_axis.size
+                data_scales[0] = nav_axis.scale
+            else:  # nav_axis.name == "x", or something else
+                data_shape[1] = nav_axis.size
+                data_scales[1] = nav_axis.scale
+        elif nav_dim == 2:
+            data_shape[:2] = [a.size for a in nav_axes][::-1]
+            data_scales[:2] = [a.scale for a in nav_axes[::-1]]
+        data_shape[2:] = am.signal_shape[::-1]
+        data_scales[2] = am.signal_axes[0].scale
+        return tuple(data_shape), tuple(data_scales)
+
+    @property
+    def scan_group_names(self) -> List[str]:
+        """Return a list of available scan group names."""
+        return [group.name.lstrip("/") for group in self.scan_groups]
+
+    def check_file(self):
+        """Check if the file, if it is old, is a valid kikuchipy h5ebsd
+        file.
+
+        Raises
+        ------
+        IOError
+            If the file was not created with kikuchipy, or if there are
+            no groups in the top group containing the datasets
+            ``"EBSD/Data"`` and ``"EBSD/Header"``.
+        """
+        error = None
+        top_groups = _hdf5group2dict(self.file["/"])
+        if top_groups.get("manufacturer") != "kikuchipy":
+            error = "it was not created with kikuchipy"
+        elif not any(
+            "EBSD/Data" in group and "EBSD/Header" in group
+            for group in self.scan_groups
+        ):
+            error = (
+                "no top groups with subgroup name 'EBSD' with subgroups 'Data' and"
+                "'Header' were found"
+            )
+        if error is not None:
+            raise IOError(
+                f"{self.filename} is not a supported kikuchipy h5ebsd file, as {error}"
+            )
+
+    def get_scan_groups(self) -> list:
+        """Return a list of groups with scans."""
+        scan_groups = []
+        if self.file_exists:
+            for key in self.file.keys():
+                if isinstance(self.file[key], h5py.Group) and key.startswith("Scan"):
+                    scan_groups.append(self.file[key])
+        return scan_groups
+
+    def get_valid_scan_number(self, scan_number: int = 1) -> int:
+        """Return a valid scan number.
+
+        Parameters
+        ----------
+        scan_number
+            Scan number in the file, e.g. 1 for "Scan 1" (default).
+
+        Returns
+        -------
+        valid_scan_number
+            ``scan_number`` or a new valid number if an existing scan in
+            the file has this number.
+
+        Raises
+        ------
+        IOError
+            If asked for a new scan number and that scan number is not
+            a number.
+        """
+        scan_nos = [int(name.split()[-1]) for name in self.scan_group_names]
+        for i in scan_nos:
+            if i == scan_number:
+                q = f"Scan {i} already in file, enter another scan number:\n"
+                scan_number = _get_input_variable(q, int)
+                if scan_number in [None, i]:
+                    raise IOError("Invalid scan number")
+        return scan_number
+
+    def get_xmap_dict(self) -> dict:
+        """Return a dictionary produced from :attr:`signal.xmap` or an
+        empty one.
+        """
+        (ny, nx, *_), (dy, dx, _) = self.data_shape_scale
+        xmap = self.signal.xmap
+        if xmap is None or not _crystal_map_is_compatible_with_signal(
+            xmap, self.signal.axes_manager.navigation_axes[::-1]
+        ):
+            xmap = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
+        return crystalmap2dict(xmap)
+
+    def write(self, scan_number: int = 1, **kwargs):
+        """Write an :class:`~kikuchipy.signals.EBSD` to file.
+
+        The file is closed after writing.
+
+        Parameters
+        ----------
+        scan_number
+            Scan number in the file, e.g. "Scan 1" (default).
+        **kwargs
+            Keyword arguments passed to
+            :meth:`h5py.Group.require_dataset`.
+
+        Raises
+        ------
+        ValueError
+            If the file exists but ``add_scan`` is ``None`` or
+            ``False``.
+        """
+        if self.file_exists:
+            valid_scan_number = self.get_valid_scan_number(scan_number)
+        else:
+            valid_scan_number = scan_number
+            _dict2hdf5group(
+                {"manufacturer": "kikuchipy", "version": kikuchipy_version},
+                self.file["/"],
+                **kwargs,
+            )
+        group = self.file.create_group(f"Scan {valid_scan_number}")
+
+        # --- Patterns
+        (ny, nx, sy, sx), (dy, dx, px_size) = self.data_shape_scale
+        overwrite_dataset(
+            group.create_group("EBSD/Data"),
+            data=self.signal.data.reshape(ny * nx, sy, sx),
+            key="patterns",
+            signal_axes=(2, 1),
+            **kwargs,
+        )
+
+        # --- Crystal map
+        _dict2hdf5group(
+            {
+                "manufacturer": "orix",
+                "version": orix_version,
+                "crystal_map": self.get_xmap_dict(),
+            },
+            group.create_group("EBSD/CrystalMap"),
+            **kwargs,
+        )
+
+        # --- Header
+        static_bg = self.signal.static_background
+        if static_bg is None:
+            static_bg = -1
+        detector = self.signal.detector
+        _dict2hdf5group(
+            {
+                "azimuth_angle": detector.azimuthal,
+                "binning": detector.binning,
+                "elevation_angle": detector.tilt,
+                "n_columns": nx,
+                "n_rows": ny,
+                "pattern_width": sx,
+                "pattern_height": sy,
+                "pcx": detector.pcx,
+                "pcy": detector.pcy,
+                "pcz": detector.pcz,
+                "detector_pixel_size": detector.px_size,
+                "sample_tilt": detector.sample_tilt,
+                "static_background": static_bg,
+                "step_x": dx,
+                "step_y": dy,
+            },
+            group.create_group("EBSD/Header"),
+            **kwargs,
+        )
+
+        # --- SEM Header
+        md = self.signal.metadata.as_dictionary()
+        md_sem = md.get("Acquisition_instrument", {}).get("SEM", {})
+        _dict2hdf5group(
+            {
+                "beam_energy": md_sem.get("beam_energy", 0),
+                "magnification": md_sem.get("magnification", 0),
+                "microscope": md_sem.get("microscope", ""),
+                "working_distance": md_sem.get("working_distance", 0),
+            },
+            group.create_group("SEM/Header"),
+            **kwargs,
+        )
+
+        self.file.close()
+
+
+def file_writer(
+    filename: str,
+    signal,
+    add_scan: Optional[bool] = None,
+    scan_number: int = 1,
+    **kwargs,
+):
+    """Write an :class:`~kikuchipy.signals.EBSD` or
+    :class:`~kikuchipy.signals.LazyEBSD` signal to an existing but not
+    open or new h5ebsd file.
+
+    Not ment to be used directly; use
+    :func:`~kikuchipy.signals.EBSD.save`.
+
+    The file is closed after writing.
+
+    Parameters
+    ----------
+    filename
+        Full path of HDF5 file.
+    signal : kikuchipy.signals.EBSD or kikuchipy.signals.LazyEBSD
+        Signal instance.
+    add_scan
+        Add signal to an existing, but not open, h5ebsd file. If it does
+        not exist it is created and the signal is written to it.
+    scan_number
+        Scan number in name of HDF dataset when writing to an existing,
+        but not open, h5ebsd file.
+    **kwargs
+        Keyword arguments passed to :meth:`h5py.Group.require_dataset`.
+    """
+    writer = KikuchipyH5EBSDWriter(filename, signal, add_scan)
+    writer.write(scan_number, **kwargs)
diff --git a/kikuchipy/io/plugins/nordif.py b/kikuchipy/io/plugins/nordif.py
index 2d10b003..c245fecd 100644
--- a/kikuchipy/io/plugins/nordif.py
+++ b/kikuchipy/io/plugins/nordif.py
@@ -15,25 +15,19 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-"""Read/write support for EBSD patterns in NORDIF's binary format."""
+"""Reader and writer of EBSD patterns from a NORDIF binary file."""
 
-import datetime
 import os
 from pathlib import Path
 import re
-import time
 from typing import Dict, List, Optional, Tuple, Union
 import warnings
 
-from hyperspy.misc.utils import DictionaryTreeBrowser
 import numpy as np
 from matplotlib.pyplot import imread
+from orix.crystal_map import CrystalMap
 
-from kikuchipy.signals.util._metadata import (
-    ebsd_metadata,
-    metadata_nodes,
-    _phase_metadata,
-)
+from kikuchipy.detectors import EBSDDetector
 
 
 __all__ = ["file_reader", "file_writer"]
@@ -61,6 +55,8 @@ def file_reader(
 ) -> List[Dict]:
     """Read electron backscatter patterns from a NORDIF data file.
 
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
     Parameters
     ----------
     filename
@@ -104,23 +100,23 @@ def file_reader(
         setting_file = os.path.join(folder, "Setting.txt")
     setting_file_exists = os.path.isfile(setting_file)
     if setting_file_exists:
-        md, omd, scan_size_file = get_settings_from_file(setting_file)
+        md, omd, scan_size_file, detector_dict = get_settings_from_file(setting_file)
         if not scan_size:
-            scan_size = (scan_size_file.nx, scan_size_file.ny)
+            scan_size = (scan_size_file["nx"], scan_size_file["ny"])
         if not pattern_size:
-            pattern_size = (scan_size_file.sx, scan_size_file.sy)
+            pattern_size = (scan_size_file["sx"], scan_size_file["sy"])
     else:
-        if scan_size is None and pattern_size is None:
+        if scan_size is None or pattern_size is None:
             raise ValueError(
                 "No setting file found and no scan_size or pattern_size detected in "
                 "input arguments. These must be set if no setting file is provided"
             )
         warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-        md = ebsd_metadata()
-        omd = DictionaryTreeBrowser()
+        md = {}
+        omd = {}
+        detector_dict = None
 
-    # Read static background pattern, to be passed to EBSD.__init__() to
-    # set the EBSD.static_background property
+    # --- Static background
     static_bg_file = os.path.join(folder, "Background acquisition pattern.bmp")
     try:
         scan["static_background"] = imread(static_bg_file)
@@ -131,13 +127,18 @@ def file_reader(
             "can be set as 'EBSD.static_background'"
         )
 
-    # Set required and other parameters in metadata
-    md.set_item("General.original_filename", filename)
-    md.set_item("General.title", os.path.splitext(os.path.split(filename)[1])[0])
-    md.set_item("Signal.signal_type", "EBSD")
-    md.set_item("Signal.record_by", "image")
-    scan["metadata"] = md.as_dictionary()
-    scan["original_metadata"] = omd.as_dictionary()
+    # --- Metadata
+    md.update(
+        {
+            "General": {
+                "original_filename": filename,
+                "title": os.path.splitext(os.path.split(filename)[1])[0],
+            },
+            "Signal": {"signal_type": "EBSD", "record_by": "image"},
+        }
+    )
+    scan["metadata"] = md
+    scan["original_metadata"] = omd
 
     # Set scan size and image size
     if isinstance(scan_size, int):
@@ -173,8 +174,10 @@ def file_reader(
     scales = np.ones(4)
 
     # Calibrate scan dimension
+    dy = dx = 1
     try:
-        scales[:2] = scales[:2] * scan_size_file.step_x
+        dy = dx = scan_size_file["step_x"]
+        scales[:2] = scales[:2] * dx
     except (TypeError, UnboundLocalError):
         warnings.warn(
             "Could not calibrate scan dimensions, this can be done using "
@@ -195,14 +198,19 @@ def file_reader(
     ]
     scan["axes"] = axes
 
+    # --- Detector
+    if detector_dict is not None:
+        scan["detector"] = EBSDDetector(**detector_dict)
+
+    # --- Crystal map
+    scan["xmap"] = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
+
     f.close()
 
     return [scan]
 
 
-def get_settings_from_file(
-    filename: str,
-) -> Tuple[DictionaryTreeBrowser, DictionaryTreeBrowser, DictionaryTreeBrowser]:
+def get_settings_from_file(filename: str) -> Tuple[dict, dict, dict, dict]:
     """Return metadata with parameters from NORDIF setting file.
 
     Parameters
@@ -218,94 +226,70 @@ def get_settings_from_file(
         Metadata that does not fit into HyperSpy's metadata structure.
     scan_size
         Information on image size, scan size and scan steps.
+    detector
+        Dictionary for creating an EBSD detector.
     """
     f = open(filename, "r", encoding="latin-1")  # Avoid byte strings
     content = f.read().splitlines()
 
     # Get line numbers of setting blocks
     blocks = {
-        "[NORDIF]": -1,
         "[Microscope]": -1,
-        "[EBSD detector]": -1,
         "[Detector angles]": -1,
         "[Acquisition settings]": -1,
         "[Area]": -1,
-        "[Specimen]": -1,
     }
     for i, line in enumerate(content):
         for block in blocks:
             if block in line:
                 blocks[block] = i
-    l_nordif = blocks["[NORDIF]"]
     l_mic = blocks["[Microscope]"]
-    l_det = blocks["[EBSD detector]"]
     l_ang = blocks["[Detector angles]"]
     l_acq = blocks["[Acquisition settings]"]
     l_area = blocks["[Area]"]
-    l_specimen = blocks["[Specimen]"]
 
     # Create metadata and original metadata structures
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    md = ebsd_metadata()
-    sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-    omd = DictionaryTreeBrowser()
-    omd.set_item("nordif_header", content)
-
-    # Get metadata values from settings file using regular expressions
-    azimuth_angle = get_string(content, "Azimuthal\t(.*)\t", l_ang + 4, f)
-    md.set_item(ebsd_node + ".azimuth_angle", float(azimuth_angle))
     beam_energy = get_string(content, "Accelerating voltage\t(.*)\tkV", l_mic + 5, f)
-    md.set_item(sem_node + ".beam_energy", float(beam_energy))
-    detector = get_string(content, "Model\t(.*)\t", l_det + 1, f)
-    detector = re.sub("[^a-zA-Z0-9]", repl="", string=detector)
-    md.set_item(ebsd_node + ".detector", "NORDIF " + detector)
-    elevation_angle = get_string(content, "Elevation\t(.*)\t", l_ang + 5, f)
-    md.set_item(ebsd_node + ".elevation_angle", float(elevation_angle))
-    exposure_time = get_string(content, "Exposure time\t(.*)\t", l_acq + 3, f)
-    md.set_item(ebsd_node + ".exposure_time", float(exposure_time) / 1e6)
-    frame_rate = get_string(content, "Frame rate\t(.*)\tfps", l_acq + 1, f)
-    md.set_item(ebsd_node + ".frame_rate", int(frame_rate))
-    gain = get_string(content, "Gain\t(.*)\t", l_acq + 4, f)
-    md.set_item(ebsd_node + ".gain", float(gain))
-    magnification = get_string(content, "Magnification\t(.*)\t#", l_mic + 3, f)
-    md.set_item(sem_node + ".magnification", int(magnification))
     mic_manufacturer = get_string(content, "Manufacturer\t(.*)\t", l_mic + 1, f)
     mic_model = get_string(content, "Model\t(.*)\t", l_mic + 2, f)
-    md.set_item(sem_node + ".microscope", mic_manufacturer + " " + mic_model)
-    sample_tilt = get_string(content, "Tilt angle\t(.*)\t", l_mic + 7, f)
-    md.set_item(ebsd_node + ".sample_tilt", float(sample_tilt))
-    scan_time = get_string(content, "Scan time\t(.*)\t", l_area + 7, f)
-    scan_time = time.strptime(scan_time, "%H:%M:%S")
-    scan_time = datetime.timedelta(
-        hours=scan_time.tm_hour, minutes=scan_time.tm_min, seconds=scan_time.tm_sec
-    ).total_seconds()
-    md.set_item(ebsd_node + ".scan_time", int(scan_time))
-    version = get_string(content, "Software version\t(.*)\t", l_nordif + 1, f)
-    md.set_item(ebsd_node + ".version", version)
-    working_distance = get_string(content, "Working distance\t(.*)\tmm", l_mic + 6, f)
-    md.set_item(sem_node + ".working_distance", float(working_distance))
-    md.set_item(ebsd_node + ".grid_type", "square")
-    md.set_item(ebsd_node + ".manufacturer", "NORDIF")
-    specimen = get_string(content, "Name\t(.*)\t", l_specimen + 1, f)
-    pmd = _phase_metadata()
-    pmd["material_name"] = specimen
-    md.set_item("Sample.Phases.1", pmd)
+    mag = get_string(content, "Magnification\t(.*)\t#", l_mic + 3, f)
+    wd = get_string(content, "Working distance\t(.*)\tmm", l_mic + 6, f)
+    md = {
+        "Acquisition_instrument": {
+            "SEM": {
+                "beam_energy": float(beam_energy),
+                "magnification": int(mag),
+                "microscope": mic_manufacturer + " " + mic_model,
+                "working_distance": float(wd),
+            }
+        }
+    }
+    omd = {"nordif_header": content}
 
     # Get scan size values
-    scan_size = DictionaryTreeBrowser()
     num_samp = get_string(content, "Number of samples\t(.*)\t#", l_area + 6, f)
     ny, nx = [int(i) for i in num_samp.split("x")]
-    scan_size.set_item("nx", int(nx))
-    scan_size.set_item("ny", int(ny))
     pattern_size = get_string(content, "Resolution\t(.*)\tpx", l_acq + 2, f)
     sx, sy = [int(i) for i in pattern_size.split("x")]
-    scan_size.set_item("sx", int(sx))
-    scan_size.set_item("sy", int(sy))
-    step_size = get_string(content, "Step size\t(.*)\t", l_area + 5, f)
-    scan_size.set_item("step_x", float(step_size))
-    scan_size.set_item("step_y", float(step_size))
+    step_size = float(get_string(content, "Step size\t(.*)\t", l_area + 5, f))
+    scan_size = {
+        "ny": ny,
+        "nx": nx,
+        "sy": sy,
+        "sx": sx,
+        "step_y": step_size,
+        "step_x": step_size,
+    }
+
+    # Detector
+    detector = {
+        "shape": (sy, sx),
+        "sample_tilt": float(get_string(content, "Tilt angle\t(.*)\t", l_mic + 7, f)),
+        "tilt": float(get_string(content, "Elevation\t(.*)\t", l_ang + 5, f)),
+        "azimuthal": float(get_string(content, "Azimuthal\t(.*)\t", l_ang + 4, f)),
+    }
 
-    return md, omd, scan_size
+    return md, omd, scan_size, detector
 
 
 def get_string(content: list, expression: str, line_no: int, file) -> str:
@@ -333,7 +317,7 @@ def get_string(content: list, expression: str, line_no: int, file) -> str:
             f"Failed to read line {line_no - 1} in settings file '{file.name}' using "
             f"regular expression '{expression}'"
         )
-        return 0
+        return ""
     else:
         return match.group(1)
 
diff --git a/kikuchipy/io/plugins/nordif_calibration_patterns.py b/kikuchipy/io/plugins/nordif_calibration_patterns.py
index c0d2f2c1..1656382a 100644
--- a/kikuchipy/io/plugins/nordif_calibration_patterns.py
+++ b/kikuchipy/io/plugins/nordif_calibration_patterns.py
@@ -15,7 +15,7 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-"""Read support for NORDIF's calibration patterns."""
+"""Reader of EBSD calibration patterns from NORDIF files."""
 
 import os
 from pathlib import Path
@@ -48,6 +48,8 @@ def file_reader(filename: Union[str, Path], lazy: bool = False) -> List[dict]:
     """Reader electron backscatter patterns from .bmp files stored in a
     NORDIF project directory, their filenames listed in a text file.
 
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
     Parameters
     ----------
     filename
@@ -61,7 +63,7 @@ def file_reader(filename: Union[str, Path], lazy: bool = False) -> List[dict]:
         Data, axes, metadata and original metadata.
     """
     # Get metadata from setting file
-    md, omd, _ = get_settings_from_file(filename)
+    md, omd, _, detector = get_settings_from_file(filename)
     dirname = os.path.dirname(filename)
 
     scan = {}
@@ -78,13 +80,19 @@ def file_reader(filename: Union[str, Path], lazy: bool = False) -> List[dict]:
         )
 
     # Set required and other parameters in metadata
-    md.set_item("General.original_filename", filename)
-    md.set_item("General.title", "Calibration patterns")
-    md.set_item("Signal.signal_type", "EBSD")
-    md.set_item("Signal.record_by", "image")
+    md.update(
+        {
+            "General": {
+                "original_filename": filename,
+                "title": "Calibration patterns",
+            },
+            "Signal": {"signal_type": "EBSD", "record_by": "image"},
+        }
+    )
+    scan["metadata"] = md
+    scan["original_metadata"] = omd
 
-    scan["metadata"] = md.as_dictionary()
-    scan["original_metadata"] = omd.as_dictionary()
+    scan["detector"] = detector
 
     coordinates = _get_coordinates(filename)
 
@@ -133,7 +141,7 @@ def _get_coordinates(filename: str) -> List[Tuple[int]]:
     return xy
 
 
-def _get_patterns(dirname: str, coordinates: List[Tuple[int, int]]) -> np.ndarray:
+def _get_patterns(dirname: str, coordinates: List[Tuple[int]]) -> np.ndarray:
     patterns = []
     for x, y in coordinates:
         fname_pattern = f"Calibration ({x},{y}).bmp"
diff --git a/kikuchipy/io/plugins/oxford_binary.py b/kikuchipy/io/plugins/oxford_binary.py
index 170f3271..6eeb2294 100644
--- a/kikuchipy/io/plugins/oxford_binary.py
+++ b/kikuchipy/io/plugins/oxford_binary.py
@@ -15,8 +15,8 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-"""Read support for uncompressed EBSD patterns in Oxford Instruments'
-binary .ebsp file format.
+"""Reader of uncompressed EBSD patterns from a Oxford Instruments binary
+.ebsp file.
 
 Information about the file format was provided by Oxford Instruments.
 """
@@ -53,6 +53,8 @@ def file_reader(filename: Union[str, Path], lazy: bool = False) -> List[dict]:
     Only uncompressed patterns can be read. If only non-indexed patterns
     are stored in the file, the navigation shape will be 1D.
 
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
     Parameters
     ----------
     filename
diff --git a/kikuchipy/io/plugins/oxford_h5ebsd.py b/kikuchipy/io/plugins/oxford_h5ebsd.py
new file mode 100644
index 00000000..a7ef406f
--- /dev/null
+++ b/kikuchipy/io/plugins/oxford_h5ebsd.py
@@ -0,0 +1,201 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+"""Reader of EBSD data from an Oxford Instruments h5ebsd (H5OINA) file.
+"""
+
+from pathlib import Path
+from typing import List, Union
+
+import h5py
+import numpy as np
+from orix.crystal_map import CrystalMap
+
+from kikuchipy.detectors import EBSDDetector
+from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict, H5EBSDReader
+
+
+__all__ = ["file_reader"]
+
+
+# Plugin characteristics
+# ----------------------
+format_name = "oxford_h5ebsd"
+description = (
+    "Read support for electron backscatter diffraction patterns stored "
+    "in an HDF5 file formatted in Oxford Instruments' h5ebsd format, "
+    "named H5OINA. The format is similar to the format described in "
+    "Jackson et al.: h5ebsd: an archival data format for electron "
+    "back-scatter diffraction data sets. Integrating Materials and "
+    "Manufacturing Innovation 2014 3:4, doi: "
+    "https://dx.doi.org/10.1186/2193-9772-3-4."
+)
+full_support = False
+# Recognised file extension
+file_extensions = ["h5oina"]
+default_extension = 0
+# Writing capabilities (signal dimensions, navigation dimensions)
+writes = False
+
+
+class OxfordH5EBSDReader(H5EBSDReader):
+    """Oxford Instruments h5ebsd (H5OINA) file reader.
+
+    The file contents are ment to be used for initializing a
+    :class:`~kikuchipy.signals.EBSD` signal.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+    """
+
+    def __init__(self, filename: str, **kwargs):
+        super().__init__(filename, **kwargs)
+
+    def scan2dict(self, group: h5py.Group, lazy: bool = False) -> dict:
+        """Read (possibly lazily) patterns from group.
+
+        Parameters
+        ----------
+        group
+            Group with patterns.
+        lazy
+            Whether to read dataset lazily (default is ``False``).
+
+        Returns
+        -------
+        scan_dict
+            Dictionary with keys ``"axes"``, ``"data"``, ``"metadata"``,
+            ``"original_metadata"``, ``"detector"``,
+            ``"static_background"``, and ``"xmap"``. This dictionary can
+             be passed as keyword arguments to create an
+             :class:`~kikuchipy.signals.EBSD` signal.
+
+        Raises
+        ------
+        IOError
+            If patterns are not acquired in a square grid.
+        """
+        hd = _hdf5group2dict(group["EBSD/Header"], recursive=True)
+        dd = _hdf5group2dict(group["EBSD/Data"], data_dset_names=self.patterns_name)
+
+        # Get data shapes
+        ny, nx = hd["Y Cells"], hd["X Cells"]
+        sy, sx = hd["Pattern Height"], hd["Pattern Width"]
+        dy, dx = hd.get("Y Step", 1), hd.get("X Step", 1)
+        px_size = 1.0
+
+        # --- Metadata
+        fname, title = self.get_metadata_filename_title(group.name)
+        metadata = {
+            "Acquisition_instrument": {
+                "SEM": {
+                    "beam_energy": hd.get("Beam Voltage"),
+                    "magnification": hd.get("Magnification"),
+                    "working_distance": hd.get("Working Distance"),
+                },
+            },
+            "General": {"original_filename": fname, "title": title},
+            "Signal": {"signal_type": "EBSD", "record_by": "image"},
+        }
+        scan_dict = {"metadata": metadata}
+
+        # --- Data
+        data = self.get_data(group, data_shape=(ny, nx, sy, sx), lazy=lazy)
+        scan_dict["data"] = data
+
+        # --- Axes
+        scan_dict["axes"] = self.get_axes_list((ny, nx, sy, sx), (dy, dx, px_size))
+
+        # --- Original metadata
+        scan_dict["original_metadata"] = {
+            "manufacturer": self.manufacturer,
+            "version": self.version,
+        }
+        scan_dict["original_metadata"].update(hd)
+
+        # --- Crystal map
+        # TODO: Implement reader of Oxford Instruments h5ebsd crystal
+        #  maps in orix
+        xmap = CrystalMap.empty(shape=(ny, nx), step_sizes=(dy, dx))
+        scan_dict["xmap"] = xmap
+
+        # --- Static background
+        scan_dict["static_background"] = hd.get("Processed Static Background")
+
+        # --- Detector
+        pc = np.column_stack(
+            (
+                dd.get("Pattern Center X", 0.5),
+                dd.get("Pattern Center Y", 0.5),
+                dd.get("Detector Distance", 0.5),
+            )
+        )
+        if pc.size > 3:
+            pc = pc.reshape((ny, nx, 3))
+        scan_dict["detector"] = EBSDDetector(
+            shape=(sy, sx),
+            px_size=px_size,
+            sample_tilt=np.rad2deg(hd.get("Tilt Angle", np.deg2rad(70))),
+            pc=pc,
+            convention="oxford",
+        )
+
+        return scan_dict
+
+
+def file_reader(
+    filename: Union[str, Path],
+    scan_group_names: Union[None, str, List[str]] = None,
+    lazy: bool = False,
+    **kwargs,
+) -> List[dict]:
+    """Read electron backscatter diffraction patterns, a crystal map,
+    and an EBSD detector from an Oxford Instruments h5ebsd (H5OINA) file
+    :cite:`jackson2014h5ebsd`.
+
+    Not ment to be used directly; use :func:`~kikuchipy.load`.
+
+    Parameters
+    ----------
+    filename
+        Full file path of the HDF5 file.
+    scan_group_names
+        Name or a list of names of HDF5 top group(s) containing the
+        scan(s) to return. If not given (default), the first scan in the
+        file is returned.
+    lazy
+        Open the data lazily without actually reading the data from disk
+        until required. Allows opening arbitrary sized datasets. Default
+        is ``False``.
+    **kwargs
+        Keyword arguments passed to :class:`h5py.File`.
+
+    Returns
+    -------
+    scan_dict_list
+        List of one or more dictionaries with the keys ``"axes"``,
+        ``"data"``, ``"metadata"``, ``"original_metadata"``,
+        ``"detector"``, and ``"xmap"``. This
+        dictionary can be passed as keyword arguments to create an
+        :class:`~kikuchipy.signals.EBSD` signal.
+    """
+    reader = OxfordH5EBSDReader(filename, **kwargs)
+    return reader.read(scan_group_names, lazy)
diff --git a/kikuchipy/signals/ebsd.py b/kikuchipy/signals/ebsd.py
index 00db562a..160455f6 100644
--- a/kikuchipy/signals/ebsd.py
+++ b/kikuchipy/signals/ebsd.py
@@ -20,7 +20,6 @@
 import gc
 import numbers
 import os
-import sys
 from typing import Union, List, Optional, Tuple, Iterable
 import warnings
 
@@ -62,12 +61,6 @@
     _remove_static_background_divide,
     _remove_dynamic_background,
 )
-from kikuchipy.signals.util._metadata import (
-    ebsd_metadata,
-    metadata_nodes,
-    _update_phase_info,
-    _write_parameters_to_dictionary,
-)
 from kikuchipy.signals.util._dask import (
     get_dask_array,
     get_chunking,
@@ -83,7 +76,7 @@
 )
 from kikuchipy.signals._common_image import CommonImage
 from kikuchipy.signals.virtual_bse_image import VirtualBSEImage
-from kikuchipy._util import deprecated, deprecated_argument
+from kikuchipy._util import deprecated_argument
 
 
 class EBSD(CommonImage, hs.signals.Signal2D):
@@ -131,7 +124,7 @@ class EBSD(CommonImage, hs.signals.Signal2D):
     >>> s
     <EBSD, title: patterns My awes0m4 ..., dimensions: (3, 3|60, 60)>
     >>> s.detector
-    EBSDDetector (60, 60), px_size 1.0 um, binning 1, tilt 0, azimuthal 0, pc (0.5, 0.5, 0.5)
+    EBSDDetector (60, 60), px_size 1.0 um, binning 8, tilt 0, azimuthal 0, pc (0.5, 0.5, 0.5)
     >>> s.static_background
     array([[84, 87, 90, ..., 27, 29, 30],
            [87, 90, 93, ..., 27, 28, 30],
@@ -149,27 +142,17 @@ class EBSD(CommonImage, hs.signals.Signal2D):
     _custom_properties = ["detector", "static_background", "xmap"]
 
     def __init__(self, *args, **kwargs):
-        hs.signals.Signal2D.__init__(self, *args, **kwargs)
+        super(EBSD, self).__init__(*args, **kwargs)
 
-        self._detector = kwargs.pop(
+        self._detector = kwargs.get(
             "detector",
             EBSDDetector(
                 shape=self.axes_manager.signal_shape,
                 px_size=self.axes_manager.signal_axes[0].scale,
             ),
         )
-        self._static_background = kwargs.pop("static_background", None)
-        self._xmap = kwargs.pop("xmap", None)
-
-        # Update metadata if object is initialised from numpy array
-        warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-        if not self.metadata.has_item(metadata_nodes("ebsd")):
-            md = self.metadata.as_dictionary()
-            md.update(ebsd_metadata().as_dictionary())
-            self.metadata.add_dictionary(md)
-        if not self.metadata.has_item("Sample.Phases"):
-            with warnings.catch_warnings():
-                self.set_phase_parameters()
+        self._static_background = kwargs.get("static_background")
+        self._xmap = kwargs.get("xmap")
 
     # ---------------------- Custom properties ----------------------- #
 
@@ -237,247 +220,6 @@ def static_background(self, value: Union[np.ndarray, da.Array]):
 
     # ------------------------ Custom methods ------------------------ #
 
-    @deprecated(
-        since="0.5", removal="0.6", alternative="kikuchipy.detectors.EBSDDetector"
-    )
-    def set_experimental_parameters(
-        self,
-        detector=None,
-        azimuth_angle=None,
-        elevation_angle=None,
-        sample_tilt=None,
-        working_distance=None,
-        binning=None,
-        exposure_time=None,
-        grid_type=None,
-        gain=None,
-        frame_number=None,
-        frame_rate=None,
-        scan_time=None,
-        beam_energy=None,
-        xpc=None,
-        ypc=None,
-        zpc=None,
-        static_background=None,
-        manufacturer=None,
-        version=None,
-        microscope=None,
-        magnification=None,
-    ) -> None:
-        """Set experimental parameters in signal metadata.
-
-        Parameters
-        ----------
-        detector : str, optional
-            Detector manufacturer and model.
-        azimuth_angle : float, optional
-            Azimuth angle of the detector in degrees. If the azimuth is
-            zero, the detector is perpendicular to the tilt axis.
-        elevation_angle : float, optional
-            Elevation angle of the detector in degrees. If the elevation
-            is zero, the detector is perpendicular to the incident beam.
-        sample_tilt : float, optional
-            Sample tilt angle from horizontal in degrees.
-        working_distance : float, optional
-            Working distance in mm.
-        binning : int, optional
-            Camera binning.
-        exposure_time : float, optional
-            Camera exposure time in µs.
-        grid_type : str, optional
-            Scan grid type, only square grid is supported.
-        gain : float, optional
-            Camera gain, typically in dB.
-        frame_number : float, optional
-            Number of patterns integrated during acquisition.
-        frame_rate : float, optional
-            Frames per s.
-        scan_time : float, optional
-            Scan time in s.
-        beam_energy : float, optional
-            Energy of the electron beam in kV.
-        xpc : float, optional
-            Pattern center horizontal coordinate with respect to
-            detector center, as viewed from the detector to the sample.
-        ypc : float, optional
-            Pattern center vertical coordinate with respect to
-            detector center, as viewed from the detector to the sample.
-        zpc : float, optional
-            Specimen to scintillator distance.
-        static_background : numpy.ndarray, optional
-            Static background pattern.
-        manufacturer : str, optional
-            Manufacturer of software used to collect patterns.
-        version : str, optional
-            Version of software used to collect patterns.
-        microscope : str, optional
-            Microscope used to collect patterns.
-        magnification : int, optional
-            Microscope magnification at which patterns were collected.
-
-        See Also
-        --------
-        set_phase_parameters
-
-        Examples
-        --------
-        >>> import kikuchipy as kp
-        >>> s = kp.data.nickel_ebsd_small()
-        >>> node = kp.signals.util.metadata_nodes("ebsd")
-        >>> s.metadata.get_item(node + ".xpc")
-        -5.64
-        >>> s.set_experimental_parameters(xpc=0.50726)
-        >>> s.metadata.get_item(node + ".xpc")
-        0.50726
-        """
-        md = self.metadata
-        warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-        sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-        _write_parameters_to_dictionary(
-            {
-                "beam_energy": beam_energy,
-                "magnification": magnification,
-                "microscope": microscope,
-                "working_distance": working_distance,
-            },
-            md,
-            sem_node,
-        )
-        _write_parameters_to_dictionary(
-            {
-                "azimuth_angle": azimuth_angle,
-                "binning": binning,
-                "detector": detector,
-                "elevation_angle": elevation_angle,
-                "exposure_time": exposure_time,
-                "frame_number": frame_number,
-                "frame_rate": frame_rate,
-                "gain": gain,
-                "grid_type": grid_type,
-                "manufacturer": manufacturer,
-                "version": version,
-                "sample_tilt": sample_tilt,
-                "scan_time": scan_time,
-                "xpc": xpc,
-                "ypc": ypc,
-                "zpc": zpc,
-                "static_background": static_background,
-            },
-            md,
-            ebsd_node,
-        )
-
-    @deprecated(
-        since="0.5",
-        removal="0.6",
-        alternative="kikuchipy.signals.EBSD.xmap",
-        alternative_is_function=False,
-    )
-    def set_phase_parameters(
-        self,
-        number=1,
-        atom_coordinates=None,
-        formula=None,
-        info=None,
-        lattice_constants=None,
-        laue_group=None,
-        material_name=None,
-        point_group=None,
-        setting=None,
-        source=None,
-        space_group=None,
-        symmetry=None,
-    ) -> None:
-        """Set parameters for one phase in signal metadata.
-
-        A phase node with default values is created if none is present
-        in the metadata when this method is called.
-
-        Parameters
-        ----------
-        number : int, optional
-            Phase number.
-        atom_coordinates : dict, optional
-            Dictionary of dictionaries with one or more of the atoms in
-            the unit cell, on the form `{'1': {'atom': 'Ni',
-            'coordinates': [0, 0, 0], 'site_occupation': 1,
-            'debye_waller_factor': 0}, '2': {'atom': 'O',... etc.`
-            `debye_waller_factor` in units of nm^2, and
-            `site_occupation` in range [0, 1].
-        formula : str, optional
-            Phase formula, e.g. 'Fe2' or 'Ni'.
-        info : str, optional
-            Whatever phase info the user finds relevant.
-        lattice_constants : numpy.ndarray or list of floats, optional
-            Six lattice constants a, b, c, alpha, beta, gamma.
-        laue_group : str, optional
-            Phase Laue group.
-        material_name : str, optional
-            Name of material.
-        point_group : str, optional
-            Phase point group.
-        setting : int, optional
-            Space group's origin setting.
-        source : str, optional
-            Literature reference for phase data.
-        space_group : int, optional
-            Number between 1 and 230.
-        symmetry : int, optional
-            Phase symmetry.
-
-        See Also
-        --------
-        set_experimental_parameters
-
-        Examples
-        --------
-        >>> import kikuchipy as kp
-        >>> s = kp.data.nickel_ebsd_small()
-        >>> s.metadata.Sample.Phases.Number_1.atom_coordinates.Number_1
-        ├── atom = Ni
-        ├── coordinates = array([0, 0, 0])
-        ├── debye_waller_factor = 0.0035
-        └── site_occupation = 1
-        >>> s.set_phase_parameters(
-        ...     number=1,
-        ...     atom_coordinates={"1": {
-        ...         "atom": "Fe",
-        ...         "coordinates": [0, 0, 0],
-        ...         "site_occupation": 1,
-        ...         "debye_waller_factor": 0.005
-        ...     }}
-        ... )
-        >>> s.metadata.Sample.Phases.Number_1.atom_coordinates.Number_1
-        ├── atom = Fe
-        ├── coordinates = array([0, 0, 0])
-        ├── debye_waller_factor = 0.005
-        └── site_occupation = 1
-        """
-        # Ensure atom coordinates are numpy arrays
-        if atom_coordinates is not None:
-            for phase, _ in atom_coordinates.items():
-                atom_coordinates[phase]["coordinates"] = np.array(
-                    atom_coordinates[phase]["coordinates"]
-                )
-
-        inputs = {
-            "atom_coordinates": atom_coordinates,
-            "formula": formula,
-            "info": info,
-            "lattice_constants": lattice_constants,
-            "laue_group": laue_group,
-            "material_name": material_name,
-            "point_group": point_group,
-            "setting": setting,
-            "source": source,
-            "space_group": space_group,
-            "symmetry": symmetry,
-        }
-
-        # Remove None values
-        phase = {k: v for k, v in inputs.items() if v is not None}
-        _update_phase_info(self.metadata, phase, number)
-
     def set_scan_calibration(
         self, step_x: Union[int, float] = 1.0, step_y: Union[int, float] = 1.0
     ) -> None:
diff --git a/kikuchipy/signals/util/__init__.py b/kikuchipy/signals/util/__init__.py
index 3f81c030..34da813c 100644
--- a/kikuchipy/signals/util/__init__.py
+++ b/kikuchipy/signals/util/__init__.py
@@ -15,16 +15,13 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-"""Signal utilities for handling signal metadata and attributes, output
-from signal methods, and for controlling chunking of lazy signal data in
+"""Signal utilities for controlling chunking of lazy signal data in
 :class:`~dask.array.Array`.
 """
 
 __all__ = [
-    "ebsd_metadata",
     "get_chunking",
     "get_dask_array",
-    "metadata_nodes",
 ]
 
 
@@ -34,10 +31,8 @@ def __dir__():
 
 def __getattr__(name):
     _import_mapping = {
-        "ebsd_metadata": "_metadata",
         "get_chunking": "_dask",
         "get_dask_array": "_dask",
-        "metadata_nodes": "_metadata",
     }
     if name in __all__:
         import importlib
diff --git a/kikuchipy/signals/util/_metadata.py b/kikuchipy/signals/util/_metadata.py
deleted file mode 100644
index da8e895c..00000000
--- a/kikuchipy/signals/util/_metadata.py
+++ /dev/null
@@ -1,232 +0,0 @@
-# Copyright 2019-2022 The kikuchipy developers
-#
-# This file is part of kikuchipy.
-#
-# kikuchipy is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# kikuchipy is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
-
-import re
-from typing import List, Union
-import warnings
-
-import numpy as np
-from hyperspy.misc.utils import DictionaryTreeBrowser
-
-from kikuchipy.io._util import _get_nested_dictionary
-from kikuchipy._util import deprecated
-
-
-@deprecated(since=0.5, removal=0.7)
-def ebsd_metadata() -> DictionaryTreeBrowser:
-    """Return a dictionary in HyperSpy's DictionaryTreeBrowser format
-    with the default kikuchipy EBSD metadata.
-
-    See :meth:`~kikuchipy.signals.EBSD.set_experimental_parameters` for
-    an explanation of the parameters.
-
-    Returns
-    -------
-    md
-        EBSD metadata.
-    """
-    md = DictionaryTreeBrowser()
-    warnings.filterwarnings("ignore", category=np.VisibleDeprecationWarning)
-    sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-    ebsd = {
-        "azimuth_angle": -1.0,
-        "binning": 1,
-        "detector": "",
-        "elevation_angle": -1.0,
-        "exposure_time": -1,
-        "frame_number": -1,
-        "frame_rate": -1,
-        "gain": -1.0,
-        "grid_type": "",
-        "sample_tilt": -1.0,
-        "scan_time": -1.0,
-        "static_background": -1,
-        "xpc": -1.0,
-        "ypc": -1.0,
-        "zpc": -1.0,
-    }
-    sem = {
-        "microscope": "",
-        "magnification": -1,
-        "beam_energy": -1.0,
-        "working_distance": -1.0,
-    }
-    md.set_item(sem_node, sem)
-    md.set_item(ebsd_node, ebsd)
-    return md
-
-
-@deprecated(since=0.7, removal=0.8)
-def metadata_nodes(
-    nodes: Union[None, str, List[str]] = None
-) -> Union[List[str], str, List]:
-    """Return SEM and/or EBSD metadata nodes.
-
-    This is a convenience function so that we only have to define these
-    node strings here.
-
-    Parameters
-    ----------
-    nodes
-        Metadata nodes to return. Options are ``"sem"`` and ``"ebsd"``.
-        If not specified, all nodes are returned.
-
-    Returns
-    -------
-    nodes_to_return
-        Metadata nodes.
-    """
-    available_nodes = {
-        "sem": "Acquisition_instrument.SEM",
-        "ebsd": "Acquisition_instrument.SEM.Detector.EBSD",
-    }
-
-    if nodes is None:
-        nodes_to_return = list(available_nodes.values())
-    else:
-        if not hasattr(nodes, "__iter__") or isinstance(nodes, str):
-            nodes = (nodes,)  # Make iterable
-        nodes_to_return = []
-        for n in nodes:
-            for name, node in available_nodes.items():
-                if n == name:
-                    nodes_to_return.append(node)
-
-    if len(nodes_to_return) == 1:
-        nodes_to_return = nodes_to_return[0]
-    return nodes_to_return
-
-
-def _phase_metadata() -> dict:
-    """Return a dictionary with a default kikuchipy phase structure.
-
-    Returns
-    -------
-    pd
-        Phase metadata.
-    """
-    pd = {
-        "atom_coordinates": {
-            "1": {
-                "atom": "",
-                "coordinates": np.zeros(3),
-                "site_occupation": 0.0,
-                "debye_waller_factor": 0.0,
-            }
-        },
-        "formula": "",
-        "info": "",
-        "lattice_constants": np.zeros(6),
-        "laue_group": "",
-        "material_name": "",
-        "point_group": "",
-        "setting": 0,
-        "space_group": 0,
-        "symmetry": 0,
-        "source": "",
-    }
-    return pd
-
-
-def _update_phase_info(
-    metadata: DictionaryTreeBrowser, dictionary: dict, phase_number: int = 1
-) -> DictionaryTreeBrowser:
-    """Update information of phase in metadata, adding it if it doesn't
-    already exist.
-
-    Parameters
-    ----------
-    metadata
-        Metadata to update.
-    dictionary
-        Dictionary with only values to update.
-    phase_number
-        Number of phase to update.
-
-    Returns
-    -------
-    metadata : DictionaryTreeBrowser
-        Updated metadata.
-    """
-    # Check if metadata has phases
-    if not metadata.has_item("Sample.Phases"):
-        metadata.add_node("Sample.Phases")
-
-    # Check if phase number is already in metadata
-    phase = metadata.Sample.Phases.get_item(str(phase_number))
-    if phase is None:
-        phase = _phase_metadata()
-    phase = dict(phase)
-
-    # Loop over input dictionary and update items in phase dictionary
-    for key, val in dictionary.items():
-        key = re.sub(r"(\w)([A-Z])", r"\1 \2", key)  # Space before UPPERCASE
-        key = key.lower()
-        key = key.replace(" ", "_")
-        if key in phase:
-            if isinstance(val, list):
-                val = np.array(val)
-            phase[key] = val
-
-    # Update phase info in metadata
-    metadata.Sample.Phases.add_dictionary({str(phase_number): phase})
-
-    return metadata
-
-
-def _write_parameters_to_dictionary(
-    parameters: dict, dictionary: DictionaryTreeBrowser, node: str
-):
-    """Write dictionary of parameters to DictionaryTreeBrowser.
-
-    Parameters
-    ----------
-    parameters
-        Dictionary of parameters to write to dictionary.
-    dictionary
-        Dictionary to write parameters to.
-    node
-        String like 'Acquisition_instrument.SEM' etc. with dictionary
-        nodes to write parameters to.
-    """
-    for key, val in parameters.items():
-        if val is not None:
-            dictionary.set_item(node + "." + key, val)
-
-
-def _set_metadata_from_mapping(omd: dict, md: DictionaryTreeBrowser, mapping: dict):
-    """Update metadata dictionary inplace from original metadata
-    dictionary via a mapping.
-
-    Parameters
-    ----------
-    omd
-        Dictionary with original metadata.
-    md
-        Dictionary with metadata to update.
-    mapping
-        Mapping between `omd` and `md`.
-    """
-    for key_out, key_in in mapping.items():
-        try:
-            if isinstance(key_in, list):
-                value = _get_nested_dictionary(omd, key_in)
-            else:
-                value = omd[key_in]
-            md.set_item(key_out, value)
-        except KeyError:
-            warnings.warn(f"Could not read {key_in} from file.")
diff --git a/setup.cfg b/setup.cfg
index 3515b6a9..5626581c 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -11,7 +11,7 @@ addopts =
     --ignore=examples/*/*.py
     # Scripts for creating dummy data
     --ignore=kikuchipy/data/bruker_h5ebsd/create_bruker_h5ebsd_file.py
-    --ignore=kikuchipy/data/bruker_h5ebsd/create_bruker_h5ebsd_file.py
+    --ignore=kikuchipy/data/bruker_h5ebsd/create_oxford_h5ebsd_file.py
     --ignore=kikuchipy/data/emsoft_ebsd/create_dummy_emsoft_ebsd_file.py
     --ignore=kikuchipy/data/oxford_binary/create_oxford_binary_file.py
     --ignore-glob=kikuchipy/data/emsoft_ebsd_master_pattern/*.py


Test patch:
diff --git a/kikuchipy/_util/tests/test_import.py b/kikuchipy/_util/tests/test_import.py
index 15a7f60e..dd491e74 100644
--- a/kikuchipy/_util/tests/test_import.py
+++ b/kikuchipy/_util/tests/test_import.py
@@ -283,12 +283,15 @@ def test_dir_io_plugins(self):
         import kikuchipy.io.plugins
 
         assert dir(kikuchipy.io.plugins) == [
+            "bruker_h5ebsd",
+            "edax_h5ebsd",
             "emsoft_ebsd",
             "emsoft_ebsd_master_pattern",
-            "h5ebsd",
+            "kikuchipy_h5ebsd",
             "nordif",
             "nordif_calibration_patterns",
             "oxford_binary",
+            "oxford_h5ebsd",
         ]
 
     def test_dir_pattern(self):
@@ -333,12 +336,7 @@ def test_dir_signals(self):
     def test_dir_signals_util(self):
         import kikuchipy.signals.util
 
-        assert dir(kikuchipy.signals.util) == [
-            "ebsd_metadata",
-            "get_chunking",
-            "get_dask_array",
-            "metadata_nodes",
-        ]
+        assert dir(kikuchipy.signals.util) == ["get_chunking", "get_dask_array"]
 
     def test_dir_simulations(self):
         import kikuchipy.simulations
diff --git a/kikuchipy/conftest.py b/kikuchipy/conftest.py
index ca738932..371b8cf8 100644
--- a/kikuchipy/conftest.py
+++ b/kikuchipy/conftest.py
@@ -26,7 +26,6 @@
 from diffpy.structure import Atom, Lattice, Structure
 from diffsims.crystallography import ReciprocalLatticePoint
 import hyperspy.api as hs
-from hyperspy.misc.utils import DictionaryTreeBrowser
 import matplotlib.pyplot as plt
 import numpy as np
 from orix.crystal_map import CrystalMap, create_coordinate_arrays, Phase, PhaseList
@@ -59,9 +58,6 @@ def assert_dictionary(dict1, dict2):
 
     Used to compare signal's axes managers or metadata in tests.
     """
-    if isinstance(dict1, DictionaryTreeBrowser):
-        dict1 = dict1.as_dictionary()
-        dict2 = dict2.as_dictionary()
     for key in dict2.keys():
         if key in ["is_binned", "binned"] and version.parse(
             hs.__version__
@@ -72,7 +68,9 @@ def assert_dictionary(dict1, dict2):
         if isinstance(dict2[key], dict):
             assert_dictionary(dict1[key], dict2[key])
         else:
-            if isinstance(dict2[key], list) or isinstance(dict1[key], list):
+            if isinstance(dict2[key], list) or isinstance(
+                dict1[key], list
+            ):  # pragma: no cover
                 dict2[key] = np.array(dict2[key])
                 dict1[key] = np.array(dict1[key])
             if isinstance(dict2[key], (np.ndarray, Number)):
@@ -167,8 +165,7 @@ def save_path_hdf5(request):
     to write, and sometimes read again, a signal to, and from, a file.
     """
     with tempfile.TemporaryDirectory() as tmp:
-        file_path = os.path.join(tmp, "patterns_temp." + request.param)
-        yield file_path
+        yield os.path.join(tmp, "patterns." + request.param)
         gc.collect()
 
 
@@ -543,6 +540,40 @@ def nickel_ebsd_small_di_xmap():
     yield xmap
 
 
+@pytest.fixture
+def ni_kikuchipy_h5ebsd_file(tmp_path, nickel_ebsd_small_di_xmap, detector):
+    """Temporary file in kikuchipy's h5ebsd format with a crystal map
+    and detector stored.
+    """
+    s = kp.data.nickel_ebsd_small()
+    s.xmap = nickel_ebsd_small_di_xmap
+    detector.pc = np.ones((3, 3, 3)) * detector.pc
+    s.detector = detector
+    fname = tmp_path / "kp_file.h5"
+    s.save(fname)
+    yield fname
+
+
+@pytest.fixture
+def ni_small_axes_manager():
+    """Axes manager for :func:`kikuchipy.data.nickel_ebsd_small`."""
+    names = ["y", "x", "dy", "dx"]
+    scales = [1.5, 1.5, 1, 1]
+    sizes = [3, 3, 60, 60]
+    navigates = [True, True, False, False]
+    axes_manager = {}
+    for i in range(len(names)):
+        axes_manager[f"axis-{i}"] = {
+            "name": names[i],
+            "scale": scales[i],
+            "offset": 0.0,
+            "size": sizes[i],
+            "units": "um",
+            "navigate": navigates[i],
+        }
+    yield axes_manager
+
+
 # ---------------------- pytest doctest-modules ---------------------- #
 
 
diff --git a/kikuchipy/data/tests/test_data.py b/kikuchipy/data/tests/test_data.py
index 98348e29..90f0245e 100644
--- a/kikuchipy/data/tests/test_data.py
+++ b/kikuchipy/data/tests/test_data.py
@@ -105,10 +105,7 @@ def test_load_silicon_ebsd_moving_screen_in(self):
 
         assert s.data.shape == (480, 480)
         assert s.data.dtype == np.uint8
-        assert isinstance(
-            s.metadata.Acquisition_instrument.SEM.Detector.EBSD.static_background,
-            np.ndarray,
-        )
+        assert isinstance(s.static_background, np.ndarray)
 
     def test_load_silicon_ebsd_moving_screen_out5mm(self):
         """Download external Si pattern."""
@@ -116,10 +113,7 @@ def test_load_silicon_ebsd_moving_screen_out5mm(self):
 
         assert s.data.shape == (480, 480)
         assert s.data.dtype == np.uint8
-        assert isinstance(
-            s.metadata.Acquisition_instrument.SEM.Detector.EBSD.static_background,
-            np.ndarray,
-        )
+        assert isinstance(s.static_background, np.ndarray)
 
     def test_load_silicon_ebsd_moving_screen_out10mm(self):
         """Download external Si pattern."""
@@ -127,7 +121,4 @@ def test_load_silicon_ebsd_moving_screen_out10mm(self):
 
         assert s.data.shape == (480, 480)
         assert s.data.dtype == np.uint8
-        assert isinstance(
-            s.metadata.Acquisition_instrument.SEM.Detector.EBSD.static_background,
-            np.ndarray,
-        )
+        assert isinstance(s.static_background, np.ndarray)
diff --git a/kikuchipy/io/plugins/tests/test_bruker_h5ebsd.py b/kikuchipy/io/plugins/tests/test_bruker_h5ebsd.py
new file mode 100644
index 00000000..08cd43b4
--- /dev/null
+++ b/kikuchipy/io/plugins/tests/test_bruker_h5ebsd.py
@@ -0,0 +1,58 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+
+import os
+
+from h5py import File
+import pytest
+
+from kikuchipy import load
+from kikuchipy.conftest import assert_dictionary
+
+
+DIR_PATH = os.path.dirname(__file__)
+DATA_PATH = os.path.join(DIR_PATH, "../../../data")
+BRUKER_FILE = os.path.join(DATA_PATH, "bruker_h5ebsd/patterns.h5")
+BRUKER_FILE_ROI = os.path.join(DATA_PATH, "bruker_h5ebsd/patterns_roi.h5")
+BRUKER_FILE_ROI_NONRECTANGULAR = os.path.join(
+    DATA_PATH, "bruker_h5ebsd/patterns_roi_nonrectangular.h5"
+)
+
+
+class TestBrukerH5EBSD:
+    def test_load(self, ni_small_axes_manager):
+        # Cover grid type check
+        with File(BRUKER_FILE, mode="r+") as f:
+            grid = f["Scan 0/EBSD/Header/Grid Type"]
+            grid[()] = "hexagonal".encode()
+        with pytest.raises(IOError, match="Only square grids are"):
+            _ = load(BRUKER_FILE)
+        with File(BRUKER_FILE, mode="r+") as f:
+            grid = f["Scan 0/EBSD/Header/Grid Type"]
+            grid[()] = "isometric".encode()
+
+        s = load(BRUKER_FILE)
+        assert s.data.shape == (3, 3, 60, 60)
+        assert_dictionary(s.axes_manager.as_dictionary(), ni_small_axes_manager)
+
+    def test_load_roi(self):
+        s = load(BRUKER_FILE_ROI)
+        assert s.data.shape == (3, 2, 60, 60)
+
+        with pytest.raises(ValueError, match="Only a rectangular region of"):
+            _ = load(BRUKER_FILE_ROI_NONRECTANGULAR)
diff --git a/kikuchipy/io/plugins/tests/test_edax_h5ebsd.py b/kikuchipy/io/plugins/tests/test_edax_h5ebsd.py
new file mode 100644
index 00000000..59274bd0
--- /dev/null
+++ b/kikuchipy/io/plugins/tests/test_edax_h5ebsd.py
@@ -0,0 +1,58 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+import shutil
+
+from h5py import File
+import pytest
+
+from kikuchipy import load
+from kikuchipy.conftest import assert_dictionary
+
+
+DIR_PATH = os.path.dirname(__file__)
+DATA_PATH = os.path.join(DIR_PATH, "../../../data")
+EDAX_FILE = os.path.join(DATA_PATH, "edax_h5ebsd/patterns.h5")
+
+
+class TestEDAXH5EBSD:
+    def test_load(self, tmp_path, ni_small_axes_manager):
+        s = load(EDAX_FILE)
+        assert s.data.shape == (3, 3, 60, 60)
+        assert_dictionary(s.axes_manager.as_dictionary(), ni_small_axes_manager)
+        mag = s.metadata.Acquisition_instrument.SEM.magnification
+
+        # No 'SEM-PRIAS' group available
+        tmp_file = tmp_path / os.path.basename(EDAX_FILE)
+        shutil.copy(EDAX_FILE, tmp_file)
+        with File(tmp_file, mode="r+") as f:
+            f["Scan 1"].move("SEM-PRIAS Images", "SSEM-PRIAS Images")
+        s2 = load(tmp_file)
+        assert s2.metadata.Acquisition_instrument.SEM.magnification != mag
+
+        # Not a square grid
+        with File(tmp_file, mode="r+") as f:
+            grid = f["Scan 1/EBSD/Header/Grid Type"]
+            grid[()] = "HexGrid".encode()
+        with pytest.raises(IOError, match="Only square grids are"):
+            _ = load(tmp_file)
+
+    def test_save_error(self):
+        s = load(EDAX_FILE)
+        with pytest.raises(OSError, match="(.*) is not a supported kikuchipy"):
+            s.save(EDAX_FILE, add_scan=True)
diff --git a/kikuchipy/io/plugins/tests/test_emsoft_ebsd.py b/kikuchipy/io/plugins/tests/test_emsoft_ebsd.py
index b003221c..d752bf2a 100644
--- a/kikuchipy/io/plugins/tests/test_emsoft_ebsd.py
+++ b/kikuchipy/io/plugins/tests/test_emsoft_ebsd.py
@@ -25,7 +25,7 @@
 
 from kikuchipy.io._io import load
 from kikuchipy.io.plugins.emsoft_ebsd import _check_file_format, _crystaldata2phase
-from kikuchipy.io.plugins.h5ebsd import hdf5group2dict
+from kikuchipy.io.plugins._h5ebsd import _hdf5group2dict
 
 DIR_PATH = os.path.dirname(__file__)
 EMSOFT_FILE = os.path.join(DIR_PATH, "../../../data/emsoft_ebsd/simulated_ebsd.h5")
@@ -74,8 +74,8 @@ def test_check_file_format(self, save_path_hdf5):
 
     def test_crystaldata2phase(self):
         """A Phase object is correctly returned."""
-        with File(EMSOFT_FILE, mode="r") as f:
-            xtal_dict = hdf5group2dict(f["CrystalData"])
+        with File(EMSOFT_FILE) as f:
+            xtal_dict = _hdf5group2dict(f["CrystalData"])
         phase = _crystaldata2phase(xtal_dict)
 
         assert phase.name == ""
@@ -99,8 +99,8 @@ def test_crystaldata2phase_single_atom(self):
         """A Phase object is correctly returned when there is only one
         atom present.
         """
-        with File(EMSOFT_FILE, mode="r") as f:
-            xtal_dict = hdf5group2dict(f["CrystalData"])
+        with File(EMSOFT_FILE) as f:
+            xtal_dict = _hdf5group2dict(f["CrystalData"])
         xtal_dict["Natomtypes"] = 1
         xtal_dict["AtomData"] = xtal_dict["AtomData"][:, 0][..., np.newaxis]
         xtal_dict["Atomtypes"] = xtal_dict["Atomtypes"][0]
diff --git a/kikuchipy/io/plugins/tests/test_h5ebsd.py b/kikuchipy/io/plugins/tests/test_kikuchipy_h5ebsd.py
similarity index 64%
rename from kikuchipy/io/plugins/tests/test_h5ebsd.py
rename to kikuchipy/io/plugins/tests/test_kikuchipy_h5ebsd.py
index 945d7537..53d76297 100644
--- a/kikuchipy/io/plugins/tests/test_h5ebsd.py
+++ b/kikuchipy/io/plugins/tests/test_kikuchipy_h5ebsd.py
@@ -19,22 +19,21 @@
 
 import dask.array as da
 from hyperspy.api import load as hs_load
-from hyperspy.misc.utils import DictionaryTreeBrowser
-from hyperspy.exceptions import VisibleDeprecationWarning
 from h5py import File, Dataset
 import numpy as np
+from orix import quaternion
 import pytest
 
+import kikuchipy as kp
 from kikuchipy.data import nickel_ebsd_small
 from kikuchipy.conftest import assert_dictionary
 from kikuchipy.io._io import load
-from kikuchipy.io.plugins.h5ebsd import (
-    check_h5ebsd,
-    dict2h5ebsdgroup,
-    hdf5group2dict,
+from kikuchipy.io.plugins._h5ebsd import _dict2hdf5group
+from kikuchipy.io.plugins.kikuchipy_h5ebsd import (
+    KikuchipyH5EBSDReader,
+    KikuchipyH5EBSDWriter,
 )
 from kikuchipy.signals.ebsd import EBSD
-from kikuchipy.signals.util._metadata import metadata_nodes
 
 
 DIR_PATH = os.path.dirname(__file__)
@@ -44,90 +43,64 @@
     DATA_PATH, "kikuchipy_h5ebsd/patterns_nochunks.h5"
 )
 KIKUCHIPY_FILE_GROUP_NAMES = ["My awes0m4 Xcan #! with a long title", "Scan 2"]
-EDAX_FILE = os.path.join(DATA_PATH, "edax_h5ebsd/patterns.h5")
-BRUKER_FILE = os.path.join(DATA_PATH, "bruker_h5ebsd/patterns.h5")
-BRUKER_FILE_ROI = os.path.join(DATA_PATH, "bruker_h5ebsd/patterns_roi.h5")
-BRUKER_FILE_ROI_NONRECTANGULAR = os.path.join(
-    DATA_PATH, "bruker_h5ebsd/patterns_roi_nonrectangular.h5"
-)
 BG_FILE = os.path.join(DATA_PATH, "nordif/Background acquisition image.bmp")
-AXES_MANAGER = {
-    "axis-0": {
-        "name": "y",
-        "scale": 1.5,
-        "offset": 0.0,
-        "size": 3,
-        "units": "um",
-        "navigate": True,
-    },
-    "axis-1": {
-        "name": "x",
-        "scale": 1.5,
-        "offset": 0.0,
-        "size": 3,
-        "units": "um",
-        "navigate": True,
-    },
-    "axis-2": {
-        "name": "dy",
-        "scale": 1.0,
-        "offset": 0.0,
-        "size": 60,
-        "units": "um",
-        "navigate": False,
-    },
-    "axis-3": {
-        "name": "dx",
-        "scale": 1.0,
-        "offset": 0.0,
-        "size": 60,
-        "units": "um",
-        "navigate": False,
-    },
-}
-
-
-class Testh5ebsd:
-    def test_load_kikuchipy(self):
-        s = load(KIKUCHIPY_FILE)
 
-        assert s.data.shape == (3, 3, 60, 60)
-        assert_dictionary(s.axes_manager.as_dictionary(), AXES_MANAGER)
-
-    def test_load_edax(self):
-        with File(EDAX_FILE, mode="r+") as f:
-            grid = f["Scan 1/EBSD/Header/Grid Type"]
-            grid[()] = "HexGrid".encode()
-        with pytest.raises(IOError, match="Only square grids are"):
-            _ = load(EDAX_FILE)
-        with File(EDAX_FILE, mode="r+") as f:
-            grid = f["Scan 1/EBSD/Header/Grid Type"]
-            grid[()] = "SqrGrid".encode()
-
-        s = load(EDAX_FILE)
-        assert s.data.shape == (3, 3, 60, 60)
-        assert_dictionary(s.axes_manager.as_dictionary(), AXES_MANAGER)
-
-    def test_load_bruker(self):
-        with File(BRUKER_FILE, mode="r+") as f:
-            grid = f["Scan 0/EBSD/Header/Grid Type"]
-            grid[()] = "hexagonal".encode()
-        with pytest.raises(IOError, match="Only square grids are"):
-            _ = load(BRUKER_FILE)
-        with File(BRUKER_FILE, mode="r+") as f:
-            grid = f["Scan 0/EBSD/Header/Grid Type"]
-            grid[()] = "isometric".encode()
-
-        s = load(BRUKER_FILE)
-        assert s.data.shape == (3, 3, 60, 60)
-        assert_dictionary(s.axes_manager.as_dictionary(), AXES_MANAGER)
 
-    def test_load_bruker_roi(self):
-        s = load(BRUKER_FILE_ROI)
-        assert s.data.shape == (3, 2, 60, 60)
+class TestH5EBSD:
+    def test_repr(self):
+        reader = KikuchipyH5EBSDReader(KIKUCHIPY_FILE)
+        repr_str_list = repr(reader).split(" ")
+        assert repr_str_list[:2] == ["KikuchipyH5EBSDReader", "(0.1):"]
+        assert repr_str_list[2][-11:] == "patterns.h5"
 
-        with pytest.raises(ValueError, match="Only a rectangular region of"):
-            _ = load(BRUKER_FILE_ROI_NONRECTANGULAR)
+    def test_check_file_invalid_version(self, save_path_hdf5):
+        f = File(save_path_hdf5, mode="w")
+        _dict2hdf5group({"manufacturer": "kikuchipy", "versionn": "0.1"}, f["/"])
+        f.close()
+        with pytest.raises(IOError, match="(.*) as manufacturer"):
+            _ = KikuchipyH5EBSDReader(save_path_hdf5)
+
+    def test_check_file_no_scan_groups(self, save_path_hdf5):
+        f = File(save_path_hdf5, mode="w")
+        _dict2hdf5group({"manufacturer": "kikuchipy", "version": "0.1"}, f["/"])
+        f.close()
+        with pytest.raises(IOError, match="(.*) as no top groups"):
+            _ = KikuchipyH5EBSDReader(save_path_hdf5)
+
+    def test_dict2hdf5roup(self, save_path_hdf5):
+        with File(save_path_hdf5, mode="w") as f:
+            with pytest.warns(UserWarning, match="(c, set())"):
+                _dict2hdf5group({"a": [np.array(24.5)], "c": set()}, f["/"])
+
+
+class TestKikuchipyH5EBSD:
+    def test_load(self, ni_small_axes_manager):
+        s = load(KIKUCHIPY_FILE)
+
+        assert s.data.shape == (3, 3, 60, 60)
+        assert_dictionary(s.axes_manager.as_dictionary(), ni_small_axes_manager)
+
+    def test_save_load_xmap(self, detector, save_path_hdf5):
+        mp = kp.data.nickel_ebsd_master_pattern_small(projection="lambert")
+        r = quaternion.Rotation.from_euler(np.deg2rad([[0, 0, 0], [45, 0, 0]]))
+        sim1 = mp.get_patterns(
+            rotations=r,
+            detector=detector,
+            energy=20,
+            dtype_out=np.uint8,
+            compute=True,
+        )
+        xmap1 = sim1.xmap.deepcopy()
+        assert xmap1.size == 2
+        assert np.allclose(xmap1.rotations.data, r.data)
+        pg = xmap1.phases[0].point_group.name
+        assert pg == mp.phase.point_group.name
+        sim1.save(save_path_hdf5)
+
+        sim2 = kp.load(save_path_hdf5)
+        xmap2 = sim2.xmap.deepcopy()
+        assert xmap2.size == xmap1.size
+        assert xmap2.phases[0].point_group.name == pg
 
     def test_load_manufacturer(self, save_path_hdf5):
         s = EBSD((255 * np.random.rand(10, 3, 5, 5)).astype(np.uint8))
@@ -139,32 +112,11 @@ def test_load_manufacturer(self, save_path_hdf5):
             manufacturer[()] = "Nope".encode()
 
         with pytest.raises(
-            OSError, match="Manufacturer Nope not among recognised manufacturers"
+            OSError,
+            match="(.*) is not a supported h5ebsd file, as 'nope' is not among ",
         ):
             _ = load(save_path_hdf5)
 
-    @pytest.mark.parametrize(
-        "delete, error",
-        [
-            ("man_ver", ".* is not an h5ebsd file, as manufacturer"),
-            ("scans", ".* is not an h5ebsd file, as no top groups with "),
-        ],
-    )
-    def test_check_h5ebsd(self, save_path_hdf5, delete, error):
-        s = EBSD((255 * np.random.rand(10, 3, 5, 5)).astype(np.uint8))
-        s.save(save_path_hdf5)
-
-        with File(save_path_hdf5, mode="r+") as f:
-            if delete == "man_ver":
-                del f["manufacturer"]
-                del f["version"]
-                with pytest.raises(OSError, match=error):
-                    check_h5ebsd(f)
-            else:
-                del f["Scan 1"]
-                with pytest.raises(OSError, match=error):
-                    check_h5ebsd(f)
-
     def test_read_patterns(self, save_path_hdf5):
         s = EBSD((255 * np.random.rand(10, 3, 5, 5)).astype(np.uint8))
         s.save(save_path_hdf5)
@@ -174,7 +126,7 @@ def test_read_patterns(self, save_path_hdf5):
                 _ = load(save_path_hdf5)
 
     @pytest.mark.parametrize("lazy", (True, False))
-    def test_load_with_padding(self, save_path_hdf5, lazy):
+    def test_load_with_padding(self, save_path_hdf5, lazy, ni_small_axes_manager):
         s = load(KIKUCHIPY_FILE)
         s.save(save_path_hdf5)
 
@@ -183,36 +135,27 @@ def test_load_with_padding(self, save_path_hdf5, lazy):
             f["Scan 1/EBSD/Header/n_columns"][()] = new_n_columns
         with pytest.warns(UserWarning, match="Will attempt to load by zero"):
             s_reload = load(save_path_hdf5, lazy=lazy)
-        AXES_MANAGER["axis-1"]["size"] = new_n_columns
-        assert_dictionary(s_reload.axes_manager.as_dictionary(), AXES_MANAGER)
+        ni_small_axes_manager["axis-1"]["size"] = new_n_columns
+        assert_dictionary(s_reload.axes_manager.as_dictionary(), ni_small_axes_manager)
 
-    @pytest.mark.parametrize("remove_phases", (True, False))
-    def test_load_save_cycle(self, save_path_hdf5, remove_phases):
+    def test_load_save_cycle(self, save_path_hdf5):
         s = load(KIKUCHIPY_FILE)
 
         # Check that metadata is read correctly
-        assert s.metadata.Acquisition_instrument.SEM.Detector.EBSD.xpc == -5.64
+        assert s.detector.binning == 8
         assert s.metadata.General.title == "patterns My awes0m4 ..."
 
-        if remove_phases:
-            del s.metadata.Sample.Phases
         s.save(save_path_hdf5, overwrite=True)
         s_reload = load(save_path_hdf5)
         np.testing.assert_equal(s.data, s_reload.data)
 
-        # Change data set name and package version to make metadata equal, and
-        # redo deleting of phases
-        s_reload.metadata.General.title = s.metadata.General.title
-        with pytest.warns(np.VisibleDeprecationWarning):
-            ebsd_node = metadata_nodes("ebsd")
-        s_reload.metadata.set_item(
-            ebsd_node + ".version", s.metadata.get_item(ebsd_node + ".version")
-        )
-        if remove_phases:
-            s.metadata.Sample.set_item("Phases", s_reload.metadata.Sample.Phases)
-        np.testing.assert_equal(
-            s_reload.metadata.as_dictionary(), s.metadata.as_dictionary()
-        )
+        # Change data set name and original filename to make metadata
+        # equal
+        md = s.metadata
+        md2 = s_reload.metadata
+        md2.General.title = md.General.title
+        md2.General.original_filename = md.General.original_filename
+        np.testing.assert_equal(md2.as_dictionary(), md.as_dictionary())
 
     def test_load_save_hyperspy_cycle(self, tmp_path):
         s = load(KIKUCHIPY_FILE)
@@ -224,7 +167,6 @@ def test_load_save_hyperspy_cycle(self, tmp_path):
 
         # Write both patterns and learning results to the HSpy file
         # format
-        #        file = tmp_path / "patterns.hspy"
         os.chdir(tmp_path)
         file = "patterns.hspy"
         s.save(file)
@@ -330,21 +272,9 @@ def test_save_multiple(self, save_path_hdf5, scan_number):
         else:
             s2.save(save_path_hdf5, add_scan=True, scan_number=scan_number)
 
-    def test_save_edax(self):
-        s = load(EDAX_FILE)
-        with pytest.raises(OSError, match="Only writing to kikuchipy's"):
-            s.save(EDAX_FILE, add_scan=True)
-
-    def test_dict2h5ebsdgroup(self, save_path_hdf5):
-        dictionary = {"a": [np.array(24.5)], "b": DictionaryTreeBrowser(), "c": set()}
-        with File(save_path_hdf5, mode="w") as f:
-            group = f.create_group(name="a_group")
-            with pytest.warns(UserWarning, match="The hdf5 writer could not"):
-                dict2h5ebsdgroup(dictionary, group)
-
     def test_read_lazily_no_chunks(self):
         # First, make sure the data image dataset is not actually chunked
-        f = File(KIKUCHIPY_FILE_NO_CHUNKS, mode="r")
+        f = File(KIKUCHIPY_FILE_NO_CHUNKS)
         data_dset = f["Scan 1/EBSD/Data/patterns"]
         assert data_dset.chunks is None
         f.close()
@@ -353,11 +283,6 @@ def test_read_lazily_no_chunks(self):
         s = load(KIKUCHIPY_FILE_NO_CHUNKS, lazy=True)
         assert s.data.chunks == ((60,), (60,))
 
-    def test_hdf5group2dict_raises_deprecation_warning(self):
-        f = File(KIKUCHIPY_FILE, mode="r")
-        with pytest.warns(VisibleDeprecationWarning, match="The 'lazy' "):
-            _ = hdf5group2dict(group=f["/"], lazy=True)
-
     def test_save_load_1d_nav(self, save_path_hdf5):
         """Save-load cycle of signals with one navigation dimension."""
         desired_shape = (3, 60, 60)
@@ -382,7 +307,8 @@ def test_save_load_1d_nav(self, save_path_hdf5):
 
         # Maintain axis name
         s_y_only2.axes_manager["y"].name = "x"
-        s_y_only2.save(save_path_hdf5, overwrite=True)
+        with pytest.warns(UserWarning, match="^The `xmap`"):
+            s_y_only2.save(save_path_hdf5, overwrite=True)
         s_x_only3 = load(save_path_hdf5)
         assert s_x_only3.data.shape == desired_shape
         assert s_x_only3.axes_manager.navigation_axes[0].name == "x"
@@ -393,7 +319,8 @@ def test_save_load_0d_nav(self, save_path_hdf5):
         s = nickel_ebsd_small()
         s0 = s.inav[0, 0]
         s0.save(save_path_hdf5)
-        s1 = load(save_path_hdf5)
+        with pytest.warns(DeprecationWarning, match="Calling nonzero"):
+            s1 = load(save_path_hdf5)
         assert s1.data.shape == (60, 60)
         assert s1.axes_manager.navigation_axes == ()
 
@@ -408,3 +335,22 @@ def test_save_load_non_square_patterns(self, save_path_hdf5):
         s2 = load(save_path_hdf5)
         assert s.data.shape == s2.data.shape
         assert np.allclose(s.data, s2.data)
+
+    def test_load_with_detector_multiple_pc(self, ni_kikuchipy_h5ebsd_file):
+        s = kp.load(ni_kikuchipy_h5ebsd_file)
+        assert s.detector.pc.shape == (3, 3, 3)
+
+    def test_writer_check_file(self, save_path_hdf5):
+        s = kp.data.nickel_ebsd_small(lazy=True)
+        f = File(save_path_hdf5, mode="w")
+        _dict2hdf5group({"manufacturer": "kikuchipy", "version": "0.1"}, f["/"])
+        f.close()
+        with pytest.raises(IOError, match="(.*) as no top groups"):
+            _ = KikuchipyH5EBSDWriter(save_path_hdf5, s, add_scan=True)
+
+    def test_writer_repr(self, save_path_hdf5):
+        s = kp.data.nickel_ebsd_small()
+        writer = KikuchipyH5EBSDWriter(save_path_hdf5, s)
+        repr_str_list = repr(writer).split(" ")
+        assert repr_str_list[0] == "KikuchipyH5EBSDWriter:"
+        assert repr_str_list[1][-11:] == "patterns.h5"
diff --git a/kikuchipy/io/plugins/tests/test_nordif.py b/kikuchipy/io/plugins/tests/test_nordif.py
index 0e0ea14f..755bd04c 100644
--- a/kikuchipy/io/plugins/tests/test_nordif.py
+++ b/kikuchipy/io/plugins/tests/test_nordif.py
@@ -28,9 +28,11 @@
 import dask.array as da
 from matplotlib.pyplot import imread
 import numpy as np
+from orix.crystal_map import CrystalMap
 import pytest
 
 from kikuchipy.conftest import assert_dictionary
+from kikuchipy.detectors import EBSDDetector
 from kikuchipy.io._io import load
 from kikuchipy.io.plugins.nordif import get_settings_from_file, get_string
 from kikuchipy.signals.ebsd import EBSD
@@ -49,51 +51,6 @@
             "magnification": 200,
             "beam_energy": 20.0,
             "working_distance": 24.7,
-            "Detector": {
-                "EBSD": {
-                    "azimuth_angle": 0.0,
-                    "binning": 1,
-                    "detector": "NORDIF UF1100",
-                    "elevation_angle": 0.0,
-                    "exposure_time": 0.0035,
-                    "frame_number": -1,
-                    "frame_rate": 202,
-                    "gain": 0.0,
-                    "grid_type": "square",
-                    "sample_tilt": 70.0,
-                    "scan_time": 148,
-                    "static_background": -1,
-                    "xpc": -1.0,
-                    "ypc": -1.0,
-                    "zpc": -1.0,
-                    "version": "3.1.2",
-                    "manufacturer": "NORDIF",
-                }
-            },
-        }
-    },
-    "Sample": {
-        "Phases": {
-            "1": {
-                "atom_coordinates": {
-                    "1": {
-                        "atom": "",
-                        "coordinates": np.array([0.0, 0.0, 0.0]),
-                        "site_occupation": 0.0,
-                        "debye_waller_factor": 0.0,
-                    }
-                },
-                "formula": "",
-                "info": "",
-                "lattice_constants": np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0]),
-                "laue_group": "",
-                "material_name": "Ni",
-                "point_group": "",
-                "setting": 0,
-                "source": "",
-                "space_group": 0,
-                "symmetry": 0,
-            }
         }
     },
 }
@@ -194,6 +151,12 @@
     ]
 }
 SCAN_SIZE_FILE = {"nx": 3, "ny": 3, "sx": 60, "sy": 60, "step_x": 1.5, "step_y": 1.5}
+DETECTOR = {
+    "shape": (60, 60),
+    "sample_tilt": 70,
+    "tilt": 0,
+    "azimuthal": 0,
+}
 AXES_MANAGER = {
     "axis-0": {
         "name": "y",
@@ -241,10 +204,10 @@ def save_path_nordif():
 class TestNORDIF:
     def test_get_settings_from_file(self):
         settings = get_settings_from_file(SETTING_FILE)
-        answers = [METADATA, ORIGINAL_METADATA, SCAN_SIZE_FILE]
+        answers = [METADATA, ORIGINAL_METADATA, SCAN_SIZE_FILE, DETECTOR]
         assert len(settings) == len(answers)
         for setting_read, answer in zip(settings, answers):
-            np.testing.assert_equal(setting_read.as_dictionary(), answer)
+            np.testing.assert_equal(setting_read, answer)
 
     @pytest.mark.parametrize("line_no, correct", [(10, True), (11, False)])
     def test_get_string(self, line_no, correct):
@@ -255,13 +218,13 @@ def test_get_string(self, line_no, correct):
             sample_tilt = get_string(
                 content=content, expression=exp, line_no=line_no, file=f
             )
-            assert sample_tilt == str(70)
+            assert sample_tilt == "70"
         else:
             with pytest.warns(UserWarning):
                 sample_tilt = get_string(
                     content=content, expression=exp, line_no=line_no, file=f
                 )
-            assert sample_tilt == 0
+            assert sample_tilt == ""
 
     @pytest.mark.parametrize("setting_file", (None, SETTING_FILE))
     def test_load(self, setting_file):
@@ -273,6 +236,9 @@ def test_load(self, setting_file):
         static_bg = imread(BG_FILE)
         assert np.allclose(s.static_background, static_bg)
 
+        assert isinstance(s.xmap, CrystalMap)
+        assert isinstance(s.detector, EBSDDetector)
+
     @pytest.mark.parametrize(
         "nav_shape, sig_shape",
         [((3, 3), (60, 60)), ((3, 4), (60, 60)), (None, None)],
@@ -301,11 +267,13 @@ def test_load_save_cycle(self, save_path_nordif):
 
         scan_time_string = s.original_metadata["nordif_header"][80][10:18]
         scan_time = time.strptime(scan_time_string, "%H:%M:%S")
-        scan_time = datetime.timedelta(
-            hours=scan_time.tm_hour, minutes=scan_time.tm_min, seconds=scan_time.tm_sec
-        ).total_seconds()
         assert (
-            s.metadata.Acquisition_instrument.SEM.Detector.EBSD.scan_time == scan_time
+            datetime.timedelta(
+                hours=scan_time.tm_hour,
+                minutes=scan_time.tm_min,
+                seconds=scan_time.tm_sec,
+            ).total_seconds()
+            == 148
         )
         assert s.metadata.General.title == "Pattern"
 
@@ -338,7 +306,7 @@ def test_load_save_lazy(self, save_path_nordif):
         assert s.data.shape == s_reload.data.shape
 
     def test_load_to_memory(self):
-        s = load(PATTERN_FILE, lazy=False)
+        s = load(PATTERN_FILE)
         assert isinstance(s.data, np.ndarray)
         assert not isinstance(s.data, np.memmap)
 
diff --git a/kikuchipy/io/plugins/tests/test_oxford_h5ebsd.py b/kikuchipy/io/plugins/tests/test_oxford_h5ebsd.py
new file mode 100644
index 00000000..d68fb7ab
--- /dev/null
+++ b/kikuchipy/io/plugins/tests/test_oxford_h5ebsd.py
@@ -0,0 +1,43 @@
+# Copyright 2019-2022 The kikuchipy developers
+#
+# This file is part of kikuchipy.
+#
+# kikuchipy is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# kikuchipy is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+import numpy as np
+
+import kikuchipy as kp
+from kikuchipy.conftest import assert_dictionary
+
+
+DIR_PATH = os.path.dirname(__file__)
+DATA_PATH = os.path.join(DIR_PATH, "../../../data")
+OXFORD_FILE = os.path.join(DATA_PATH, "oxford_h5ebsd/patterns.h5oina")
+
+
+class TestOxfordH5EBSD:
+    def test_load(self, tmp_path, ni_small_axes_manager, nickel_ebsd_small_di_xmap):
+        s = kp.load(OXFORD_FILE)
+        assert s.data.shape == (3, 3, 60, 60)
+        assert_dictionary(s.axes_manager.as_dictionary(), ni_small_axes_manager)
+        assert s.metadata.Acquisition_instrument.SEM.beam_energy == 20
+        assert s.detector.pc.shape == (3, 3, 3)
+        assert np.isclose(s.detector.sample_tilt, 69.9, atol=0.1)
+
+        s2 = kp.data.nickel_ebsd_small()
+        s2.remove_static_background()
+        assert np.allclose(s.data, s2.data)
+        assert np.allclose(s.static_background, s2.static_background)
diff --git a/kikuchipy/io/tests/test_io.py b/kikuchipy/io/tests/test_io.py
index d798de36..2db78e92 100644
--- a/kikuchipy/io/tests/test_io.py
+++ b/kikuchipy/io/tests/test_io.py
@@ -23,7 +23,7 @@
 import pytest
 
 from kikuchipy.io._io import load
-from kikuchipy.io.plugins import h5ebsd
+from kikuchipy.io.plugins import kikuchipy_h5ebsd
 from kikuchipy.io._io import _assign_signal_subclass, _dict2signal
 from kikuchipy.signals.ebsd import EBSD, LazyEBSD
 
@@ -49,7 +49,7 @@ def test_load(self, filename):
             gc.collect()
 
     def test_dict2signal(self):
-        scan_dict = h5ebsd.file_reader(KIKUCHIPY_FILE)[0]
+        scan_dict = kikuchipy_h5ebsd.file_reader(KIKUCHIPY_FILE)[0]
         scan_dict["metadata"]["Signal"]["record_by"] = "not-image"
         with pytest.raises(ValueError, match="kikuchipy only supports"):
             _ = _dict2signal(scan_dict)
diff --git a/kikuchipy/signals/tests/test_ebsd.py b/kikuchipy/signals/tests/test_ebsd.py
index d95b7b3d..f1a5562a 100644
--- a/kikuchipy/signals/tests/test_ebsd.py
+++ b/kikuchipy/signals/tests/test_ebsd.py
@@ -51,15 +51,6 @@ def test_init(self):
         s1 = kp.signals.EBSD(array1)
         assert array1.shape == s1.axes_manager.shape
 
-        # SEM metadata
-        kp_md = kp.signals.util.ebsd_metadata()
-        with pytest.warns(np.VisibleDeprecationWarning):
-            sem_node = kp.signals.util.metadata_nodes("sem")
-        assert_dictionary(kp_md.get_item(sem_node), s1.metadata.get_item(sem_node))
-
-        # Phases metadata
-        assert s1.metadata.has_item("Sample.Phases")
-
     def test_as_lazy(self, dummy_signal):
         lazy_signal = dummy_signal.as_lazy()
 
@@ -71,63 +62,6 @@ def test_as_lazy(self, dummy_signal):
             dummy_signal.metadata.as_dictionary(), lazy_signal.metadata.as_dictionary()
         )
 
-    def test_set_experimental_parameters(self, dummy_signal):
-        p = {
-            "detector": "NORDIF UF-1100",
-            "azimuth_angle": 1.0,
-            "elevation_angle": 1.0,
-            "sample_tilt": 70.0,
-            "working_distance": 23.2,
-            "binning": 8,
-            "exposure_time": 0.01,
-            "grid_type": "square",
-            "gain": 10,
-            "frame_number": 4,
-            "frame_rate": 100,
-            "scan_time": 60.0,
-            "beam_energy": 20.0,
-            "xpc": 0.5,
-            "ypc": 0.5,
-            "zpc": 15000.0,
-            "static_background": np.ones(shape=(10, 10)),
-            "manufacturer": "NORDIF",
-            "version": "3.1.2",
-            "microscope": "Hitachi SU-6600",
-            "magnification": 500,
-        }
-        dummy_signal.set_experimental_parameters(**p)
-        with pytest.warns(np.VisibleDeprecationWarning):
-            ebsd_node = kp.signals.util.metadata_nodes("ebsd")
-        md_dict = dummy_signal.metadata.get_item(ebsd_node).as_dictionary()
-        assert_dictionary(p, md_dict)
-
-    def test_set_phase_parameters(self, dummy_signal):
-        p = {
-            "number": 1,
-            "atom_coordinates": {
-                "1": {
-                    "atom": "Ni",
-                    "coordinates": [0, 0, 0],
-                    "site_occupation": 1,
-                    "debye_waller_factor": 0.0035,
-                }
-            },
-            "formula": "Ni",
-            "info": "Some sample info",
-            "lattice_constants": [0.35236, 0.35236, 0.35236, 90, 90, 90],
-            "laue_group": "m3m",
-            "material_name": "Ni",
-            "point_group": "432",
-            "space_group": 225,
-            "setting": 1,
-            "source": "Peng",
-            "symmetry": 43,
-        }
-        dummy_signal.set_phase_parameters(**p)
-        md_dict = dummy_signal.metadata.get_item("Sample.Phases.1").as_dictionary()
-        p.pop("number")
-        assert_dictionary(p, md_dict)
-
     def test_set_scan_calibration(self, dummy_signal):
         (new_step_x, new_step_y) = (2, 3)
         dummy_signal.set_scan_calibration(step_x=new_step_x, step_y=new_step_y)
diff --git a/kikuchipy/signals/util/tests/test_metadata.py b/kikuchipy/signals/util/tests/test_metadata.py
deleted file mode 100644
index 1754e2c4..00000000
--- a/kikuchipy/signals/util/tests/test_metadata.py
+++ /dev/null
@@ -1,72 +0,0 @@
-# Copyright 2019-2022 The kikuchipy developers
-#
-# This file is part of kikuchipy.
-#
-# kikuchipy is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# kikuchipy is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
-import numpy as np
-import pytest
-
-from kikuchipy.signals.util._metadata import (
-    ebsd_metadata,
-    metadata_nodes,
-    _set_metadata_from_mapping,
-)
-
-
-class TestMetadata:
-    def test_ebsd_metadata(self):
-        with pytest.warns(np.VisibleDeprecationWarning):
-            sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-        with pytest.warns(np.VisibleDeprecationWarning):
-            md = ebsd_metadata()
-        assert md.get_item(sem_node + ".microscope") == ""
-        assert md.get_item(ebsd_node + ".xpc") == -1.0
-
-    def test_metadata_nodes(self):
-        sem_node = "Acquisition_instrument.SEM"
-        ebsd_node = sem_node + ".Detector.EBSD"
-
-        with pytest.warns(np.VisibleDeprecationWarning):
-            assert metadata_nodes("sem") == sem_node
-        with pytest.warns(np.VisibleDeprecationWarning):
-            assert metadata_nodes("ebsd") == ebsd_node
-        with pytest.warns(np.VisibleDeprecationWarning):
-            assert metadata_nodes() == [sem_node, ebsd_node]
-        with pytest.warns(np.VisibleDeprecationWarning):
-            assert metadata_nodes(["ebsd", "sem"]) == [ebsd_node, sem_node]
-
-    def test_set_metadata_from_mapping(self):
-        """Updating DictionaryTreeBrowser with values from a dictionary
-        via a mapping.
-        """
-        with pytest.warns(np.VisibleDeprecationWarning):
-            sem_node, ebsd_node = metadata_nodes(["sem", "ebsd"])
-        with pytest.warns(np.VisibleDeprecationWarning):
-            md = ebsd_metadata()
-
-        old_val1, new_val1 = (-1, 20)
-        old_val2, new_val2 = (-1, 2)
-        omd = {"V": new_val1, "xpc": {"xpc2": {"xpc3": new_val2}}}
-        key1 = f"{sem_node}.beam_energy"
-        key2 = f"{ebsd_node}.xpc"
-        assert md.get_item(key1) == old_val1
-        assert md.get_item(key2) == old_val2
-
-        mapping = {key1: "V", key2: ["xpc", "xpc2", "xpc3"]}
-        _set_metadata_from_mapping(omd, md, mapping)
-        assert md.get_item(key1) == new_val1
-        assert md.get_item(key2) == new_val2
-
-        with pytest.warns(UserWarning, match="Could not read"):
-            _ = _set_metadata_from_mapping(omd, md, {"a": "b"})