Repository: pyxem/kikuchipy (language: python)

Issue:
Minor improvements and changes of orientation/PC refinement before v0.5 release
We now have orientation and/or projection/pattern center (PC) refinement in from #387!

There are some things we must improve and some things we can improve before a v0.5 release, which this issue keeps track of:
- [x] Update the pattern matching notebook with refinement after dictionary indexing.
- [x] Don't make a deepcopy of the entire signal and rescale the intensities, rescaling should be done per pattern in the refinement function if possible, or elsewhere. #405
- [x] Update the API reference. #405 
- [x] Perhaps move parts of the refinement module utilities elsewhere and use them other places, e.g. in dictionary generation.
- [x] Provide the user with more information about the refinement parameters, perhaps provide the optimization results from scipy, and other optimization details.
- [x] Simplify tests and shorten test times. #405
- [x] #411

The PR number behind each task is the relevant PR where this is done or planned to be done.
Refinement Module
#### Description of the change
Methods to refine: 
1. orientations
2. projection centers
3. both simultaneously

The initial orientations are stored in a `CrystalMap`, with the projection center estimates  from an `EBSDDetector`. The refinement process is made into an optimization problem and uses the user-friendly and efficient numerical routines implemented in scipy. 

#### Progress of the PR
- [x] [Docstrings for all functions](https://github.com/numpy/numpy/blob/master/doc/example.py)
- [x] Unit tests with pytest for all lines
- [x] Clean code style by [running black via pre-commit](https://kikuchipy.org/en/latest/contributing.html#code-style)

#### Minimal example of the bug fix or new feature
```python
>>> import kikuchipy as kp
>>> s = kp.data.nickel_ebsd_large(allow_download=True)
>>> s.remove_static_background()
>>> s.remove_dynamic_background()
>>> mp = kp.data.nickel_ebsd_master_pattern_small(projection="lambert", energy=20)
>>> r = sampling.get_sample_fundamental(
... resolution=4, space_group=mp.phase.space_group.number
... )
>>> detector = kp.detectors.EBSDDetector(
... shape=s.axes_manager.signal_shape[::-1],
... pc=[0.421, 0.7794, 0.5049],
... sample_tilt=70,
... convention="tsl",
... )
>>> sim = mp.get_patterns(
... rotations=r,
... detector=detector,
... energy=20,
... dtype_out=s.data.dtype,
... compute=True
... )
>>> xmap = s.dictionary_indexing(sim, n_slices=10, keep_n=10)
>>> refined_xmap = s.orientation_refinement(
... xmap=xmap, 
... master_pattern=mp, 
... detector=detector, 
... energy=20
... )
```

#### For reviewers
<!-- Don't remove the checklist below. -->
- [ ] The PR title is short, concise, and will make sense 1 year later.
- [ ] New functions are imported in corresponding `__init__.py`.
- [ ] New features, API changes, and deprecations are mentioned in the
      unreleased section in `doc/changelog.rst`.



Discussion / hints:

Looking forward to try this functionality, great PR!

If you need help with anything, tests, documentation (user guide, API), where to put things, please let me know.
Sure, I'm about to start writing the documentation for the different methods and clarifying the code a little. We've discussed off GitHub in the past about where the refinement itself should be, we could perhaps revive this conversation here. 
> We've discussed off GitHub in the past about where the refinement itself should be, we could perhaps revive this conversation here.

Can't remember what we discussed... But I've thought about it again.

We always have four objects:
1. `EBSD` signal, which might have a `EBSD.xmap` and `EBSD.detector` attached to it.
2. `EBSDMasterPattern`
3. `CrystalMap`, which might be attached to `EBSD`. Stores orientations. For this PR, let's assume it's single phase, i.e. all map points are used. This will be expanded in the future.
4. `EBSDDetector`, which might be attached to `EBSD`. Stores projection centers (PCs).

`CrystalMap` and `EBSDDetector` are optional parameters to the refinement routies. If they are not given, they should be retrieved from `EBSD.xmap` and `EBSD.detector`, respectively. An error is thrown if they are not present or are invalid.

We have three EBSD refinement routes:
1. Refine orientations with fixed PCs
2. Refine PCs with fixed orientations
3. Refine both simultaneously

Extra parameters apart from the ones listed above:
1. Signal mask. I'm fine with this not being part of this PR, and can be implemented in the future.
2. Optimization method with parameters and trust regions
3. Whether to compute the refinement right away

I think the simplest implementation, which retains all options you provide, is by providing three public `EBSD` class methods for the three EBSD refinement routes. These methods are simple, just like `EBSD.dictionary_indexing()`: they have nice long docstrings explaining everything, check that the crystal map and detector are provided and that they are compatible with the EBSD patterns, and then pass the parameters along to methods in a private class `kikuchipy.indexing._refinement.EBSDRefinement`. This class is your `Refinement` class, which sets up the refinement and carries it out, resulting in a new `CrystalMap`, a new `EBSDDetector` or new instances of both.

Notice that the `_refinement` module is private, just like `kikuchipy.indexing._pattern_matching` is. The module and class should have docstrings, but they can be for contributors, not users.

I think `EBSDRefinement` is a better name than `Refinement` because we might provide this functionality for TKD in the future (no concrete plans, but it's not unthinkable). I don't think the "Refinement" in "EBSDRefinement" can be misunderstood.

What do you think @friedkitteh? If anyone else has any comments, those would be much appreciated.
I think this is a good direction to take. However, I worry that the `EBSD` class method might be a bit clunky in the future when `CrystalMap` contains multiple phases. I haven't worked too much with multi-phase `CrystalMap` objects, so I am unsure how easy it would be to connect the different phases to a list of `EBSDMasterPattern` objects. 

Ideally (in my opinion), a list of [A, B, C] should return the same refinement result as [B, C, A], where A, B, C are different `EBSDMasterPattern` objects used in the single pattern simulations. 
> However, I worry that the `EBSD` class method might be a bit clunky in the future when `CrystalMap` contains multiple phases.

I agree, but I'm strongly of the opinion that the implementation in this PR should state in the first docstring visible to the user that a single phase crystal map is assumed, and don't think about it at all.

In the future, I think this logic should be placed in the `EBSDRefinement` class. The `EBSD` class should just make sure that such things as shapes are compatible, and that crystal map and detector are available.

> Ideally (in my opinion), a list of [A, B, C] should return the same refinement result as [B, C, A], where A, B, C are different `EBSDMasterPattern` objects used in the single pattern simulations.

I disagree, in that I think the refinement should only allow one master pattern. In case of a multi phase crystal map, only the rotations from points with a phase matching the master pattern's phase should be refined. The result should be a crystal map with the points with other phases set to "not indexed". Then the other phases' master patterns should be used in successive refinements. Finally, the crystal maps can be merged, to obtain one final crystal map. 

This route provides the highest flexibility, and requires little extra effort to set up. The user has separate crystal maps for all phases, which often is desired. For example, when you discover that one of the master patterns were incorrect and must use another one, but don't want to repeat the refinement for the other correct master patterns.
So if I am understanding correctly: the `EBSD` signal contains a `CrystalMap`, and the refinement method only refines the points related to the `EBSDMasterPattern`, and this is repeated for all the different phases until they are merged at the end. 

However, doesn't this assume that the initial indexing assigns the correct phase to a point, which is expected, but not guaranteed, especially for patterns with low quality where the difference in similarity is low? I guess, if it is known that there is a possibility for misindexing, the individual `CrystalMap` objects from the dictionary indexing routine can be refined and merged outside of the `EBSD` signal method. 
> So if I am understanding correctly: the `EBSD` signal contains a `CrystalMap`, and the refinement method only refines the points related to the `EBSDMasterPattern`, and this is repeated for all the different phases until they are merged at the end.

Yes, I think this is the most flexible approach. The crystal map doesn't have to be attached to `EBSD.xmap`: if a crystal map is given to the refinement method, the one attached isn't used. Whether `EBSD.xmap` should then be overwritten after refinement should be up to the user. I think it shouldn't, so if this was a parameter, it should by default be False.

> However, doesn't this assume that the initial indexing assigns the correct phase to a point, which is expected, but not guaranteed, especially for patterns with low quality where the difference in similarity is low?

Yes, isn't that a required assumption for the refinement? As you say, if not, the user should do dictionary indexing on all patterns, and then refine those results.
The way I imagined the refinement routine originally, was that it would be given a list of `CrystalMap` objects and a list of `EBSDMasterPattern` objects, one for each phase. Then, for every pair the current refinement methods would return a refined `CrystalMap` leading to a new list of refined `CrystalMap` objects which would be merged into a single `CrystalMap` at the end.

Something like this: 
```python

>>> [xmapA, xmapB] = s.dictionary_indexing([simA, simB], n_slices=10, keep_n=2)

>>> refined_xmap = s.some_refinement_wrapper_function(..., [xmapA, xmapB], [masterA, masterB], merged_xmap=True)

```
 

This is a lot more time consuming than just refining the assigned phase as discussed above, but it _should_ make it so the correct phase is always assigned after refinement. Perhaps if the NCC score for a given point is much lower (user parameter) than the best overall score from all the `CrystalMap` objects it can skip that point. 
> The way I imagined the refinement routine originally, was that it would be given a list of `CrystalMap` objects and a list of `EBSDMasterPattern` objects, one for each phase. Then, for every pair the current refinement methods would return a refined `CrystalMap` leading to a new list of refined `CrystalMap` objects which would be merged into a single `CrystalMap` at the end.

I actually think `EBSD.dictionary_indexing()` should only allow one master pattern. Merging of results should not be done by anything else than the merge_crystal_maps() function. Again, it requires little effort on the user's part to write the extra lines required to merge the crystal maps after e.g. two calls to `EBSD.dictionary_indexing()`.

This is simpler to maintain, allows for maximum flexibility, and is arguably more elegant programming wise. I'm of the opinion that one thing should do one thing.

> This is a lot more time consuming than just refining the assigned phase as discussed above, but it _should_ make it so the correct phase is always assigned after refinement. Perhaps if the NCC score for a given point is much lower (user parameter) than the best overall score from all the `CrystalMap` objects it can skip that point.

The problem with this approach is that if merging is done by the `EBSD` refinement routines, it should also allow all the parameters to  `merge_crystal_maps()` ([API](https://kikuchipy.org/en/latest/reference.html#kikuchipy.indexing.merge_crystal_maps)). This quickly becomes difficult to maintain, because changing something in `merge_crystal_maps()` must also change something in the `EBSD` refinement routines, although it has nothing to do with either indexing or refinement.
Solid points!
Glad we agree on keeping number of parameters to a managable amount!

With this approach the user is required to write a few more lines, but it will still be very simple do to refinement, in my opinion.
Okay! Finally ready for the first review. I will be offline while I relocate and expect to be back online sometime around July 9th.
Great! Good work. It'll be reviewed within then.
I've tried out the functionality on the "large" Ni EBSD data set included in the `kikuchipy.data` module ([user guide](https://kikuchipy.org/en/stable/pattern_matching.html)) and on the single crystal Si pattern used in [the PC calibration moving screen user guide](https://kikuchipy.org/en/stable/reference_frames.html#Projection-center-calibration)), and based on a qualitative assessment, I'm really stoked about the results!

___

Normalized cross correlation scores after dictionary indexing to 333 227 simulated Ni patterns (used EMsoft orientation sampling):
Before refinement | After refinement
:----:|:----:
![nickel_ebsd_large_di_scores](https://user-images.githubusercontent.com/12139781/124944491-a3121500-e00d-11eb-9dbc-e59e09a1e7f5.png) | ![nickel_ebsd_large_di_refined_scores](https://user-images.githubusercontent.com/12139781/124944501-a60d0580-e00d-11eb-85a9-439d545884dc.png)

Histogram of the normalized cross correlation scores shown in the maps above:

![nickel_ebsd_large_scores_di_ref](https://user-images.githubusercontent.com/12139781/124948457-005b9580-e011-11eb-9f2d-771ae6ec1437.png)

___

Geometrical simulations of Si as done in the PC calibration user guide ("Reference frames", link above). Note that the initial orientation is determined from TSL, while the PC is solely determined by the moving screen technique. PC in TSL's convention (x*, y*, z*) and the normalized cross correlation score are shown in the figure titles.
Moving screen (0.5123, 0.8606, 0.4981), r = 0.0199 | PC refinement (0.5234, 0.8302, 0.4978), r = 0.3675 | Orientation and PC refinement (0.5177, 0.8466, 0.4867), r = 0.4090
:--:|:--:|:--:
![si_moving_screen_before_pc_refinement](https://user-images.githubusercontent.com/12139781/124944971-0dc35080-e00e-11eb-9b6f-f8abf66b8efc.png) | ![si_moving_screen_after_pc_refinement](https://user-images.githubusercontent.com/12139781/124944992-1156d780-e00e-11eb-8d2e-140898fceaaa.png) | ![si_moving_screen_after_full_refinement](https://user-images.githubusercontent.com/12139781/124945015-16b42200-e00e-11eb-9437-c5a12e1eed98.png)

___

Quick comments I have, which we should include in this PR, after trying the functionality out are these (note that a thorough review is underway):
1. Rescaling of experimental patterns to float32 should not be inplace, but of deep copies of them just before they are refined.
2. I think we should always print the refinement information, like method and desirable parameters, to stdout (in general just your standard print), so that the user, even though they don't care about those, still get the information.

Apart from this I have several other suggestions for improvements, but I think that should be left for later PRs when we start to use the refinement and get a feel for the workflow and what things to store where etc. I suggest we leave updates of the pattern matching notebook to a later PR.
> 1. Rescaling of experimental patterns to float32 should not be inplace, but of deep copies of them just before they are refined.

Yep, I 100% agree with you on this.



> 2. I think we should always print the refinement information, like method and desirable parameters, to stdout (in general just your standard print), so that the user, even though they don't care about those, still get the information.

Do you mean something like:

`Refining 10 000 orientations, using Nelder-Mead with fatol=0.0001, xatol=0.0001, ..., : `
`[########################################] | 100% Completed`

> Do you mean something like:
> 
> `Refining 10 000 orientations, using Nelder-Mead with fatol=0.0001, xatol=0.0001, ..., : `
> `[########################################] | 100% Completed`

I was thinking more in the line of what HyperSpy does for their `BaseSignal.decomposition()` as shown in our [multivariate analysis notebook](https://kikuchipy.org/en/stable/multivariate_analysis.html#Denoising-EBSD-patterns-with-dimensionality-reduction) from cell 7.

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7677eafb..fc57b21e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,5 +1,5 @@
 repos:
   - repo: https://github.com/psf/black
-    rev: 21.6b0
+    rev: 21.7b0
     hooks:
       - id: black
diff --git a/doc/bibliography.bib b/doc/bibliography.bib
index d5a1e501..8f7d98b9 100644
--- a/doc/bibliography.bib
+++ b/doc/bibliography.bib
@@ -1,45 +1,24 @@
-@article{wright2015introduction,
-	author = {Wright, Stuart I. and Nowell, Matthew M. and Lindeman, Scott P. and Camus, Patrick P. and {De Graef}, Marc and Jackson, Michael A.},
-	doi = {10.1016/j.ultramic.2015.08.001},
-	issn = {18792723},
+@article{brewick2019nlpar,
+	author = {Brewick, Patrick T. and Wright, Stuart I. and Rowenhorst, David J.},
+	doi = {10.1016/j.ultramic.2019.02.013},
 	journal = {Ultramicroscopy},
-	keywords = {EBSD, Electron Backscatter Diffraction, indexing, orientation{\_}averaging},
-	pages = {81–94},
-	pmid = {26342553},
-	publisher = {Elsevier},
-	title = {{Introduction and comparison of new EBSD post-processing methodologies}},
-	volume = {159},
-	year = {2015}
-}
-@article{marquardt2017quantitative,
-	author = {Marquardt, Katharina and {De Graef}, Marc and Singh, Saransh and Marquardt, Hauke and Rosenthal, Anja and Koizuimi, Sanae},
-	doi = {10.2138/am-2017-6062},
-	issn = {19453027},
-	journal = {American Mineralogist},
-	keywords = {electron backscatter diffraction, dictionary indexing, indexing},
-	number = {9},
-	pages = {1843–1855},
-	title = {{Quantitative electron backscatter diffraction (EBSD) data analyses using the dictionary indexing (DI) approach: Overcoming indexing difficulties on geological materials}},
-	volume = {102},
-	year = {2017}
-}
-@article{jackson2019dictionary,
-	author = {Jackson, Michael A. and Pascal, Elena and {De Graef}, Marc},
-	journal = {Integrating Materials and Manufacturing Innovation},
-	pages = {1–21},
-	publisher = {Springer},
-	title = {{Dictionary Indexing of Electron Back-Scatter Diffraction Patterns: a Hands-On Tutorial}},
-	year = {2019},
-	doi = {10.1007/s40192-019-00137-4},
+	pages = {50–61},
+	title = {{NLPAR: Non-local smoothing for enhanced EBSD pattern indexing}},
+	volume = {200},
+	year = {2019}
 }
-@book{gonzalez2017digital,
-	author = {Gonzalez, Rafael C. and Woods, Richard E.},
-	edition = {4th},
-	isbn = {978-0133356724},
-	keywords = {image processng, computer vision},
-	publisher = {Pearson Education Limited},
-	title = {{Digital Image Processing}},
-	year = {2017}
+@article{britton2016tutorial,
+	author = {Britton, T. B. and Jiang, J. and Guo, Y. and Vilalta-Clemente, A. and Wallis, D. and Hansen, L. N. and Winkelmann, Aimo and Wilkinson, Angus J.},
+	doi = {10.1016/j.matchar.2016.04.008},
+	isbn = {1044-5803},
+	issn = {10445803},
+	journal = {Materials Characterization},
+	keywords = {Crystal orientation, Electron backscatter diffraction, Electron microscopy, Texture, ebsd, indexing},
+	pages = {113–126},
+	publisher = {The Authors},
+	title = {{Tutorial: Crystal orientations and EBSD - Or which way is up?}},
+	volume = {117},
+	year = {2016}
 }
 @article{callahan2013dynamical,
 	author = {Callahan, Patrick G. and {De Graef}, Marc},
@@ -51,24 +30,6 @@ @article{callahan2013dynamical
 	title = {{Dynamical Electron Backscatter Diffraction Patterns. Part I: Pattern Simulations}},
 	year = {2013}
 }
-@article{jackson2014h5ebsd,
-	author = {Jackson, Michael A. and Groeber, Michael A. and Uchic, Michael D. and Rowenhorst, David J. and {De Graef}, Marc},
-	doi = {10.1186/2193-9772-3-4},
-	journal = {Integrating Materials and Manufacturing Innovation},
-	keywords = {h5ebsd, hdf5, ebsd, emsoft},
-	number = {1},
-	pages = {4},
-	publisher = {Springer},
-	title = {{h5ebsd: an archival data format for electron back-scatter diffraction data sets}},
-	volume = {3},
-	year = {2014}
-}
-@phdthesis{lassen1994automated,
-	author = {Lassen, Niels Christian Krieger},
-	school = {Institute of Mathematical Modelling},
-	title = {{Automated Determanation of Crystal Orientations from Electron Backscattering Patterns}},
-	year = {1994}
-}
 @article{chen2015dictionary,
 	author = {Chen, Yu H. and Park, Se Un and Wei, Dennis and Newstadt, Greg and Jackson, Michael A. and Simmons, Jeff P. and {De Graef}, Marc and Hero, Alfred O.},
 	doi = {10.1017/S1431927615000756},
@@ -81,31 +42,6 @@ @article{chen2015dictionary
 	volume = {21},
 	year = {2015}
 }
-@book{goshtasby2012image,
-	author = {Goshtasby, Ardeshir A.},
-	publisher = {Springer Science \& Business Media},
-	title = {{Image registration: Principles, tools and methods}},
-	year = {2012}
-}
-@book{aanes2019electron,
-  author = {Ånes, Håkon W. and Hjelen, Jarle and van Helvoort, Antonius T. J. and Marthinsen, Knut},
-  title = {{Electron backscatter patterns from Nickel acquired with varying camera gain}},
-  year = 2019,
-  note = {[Data set].},
-  publisher = {Zenodo},
-  doi = {10.5281/zenodo.3265037},
-}
-@article{singh2017application,
-	author = {Singh, Saransh and Ram, Farangis and {De Graef}, Marc},
-	doi = {10.1107/S1600576717014200},
-	journal = {Journal of Applied Crystallography},
-	number = {6},
-	pages = {1664–1676},
-	publisher = {International Union of Crystallography},
-	title = {{Application of forward models to crystal orientation refinement}},
-	volume = {50},
-	year = {2017}
-}
 @article{foden2019indexing,
 	author = {Foden, Alexander and Collins, David M. and Wilkinson, Angus J. and Britton, Thomas B.},
 	journal = {Ultramicroscopy},
@@ -116,6 +52,72 @@ @article{foden2019indexing
 	volume = {207},
 	year = {2019}
 }
+@book{gonzalez2017digital,
+	author = {Gonzalez, Rafael C. and Woods, Richard E.},
+	edition = {4th},
+	isbn = {978-0133356724},
+	keywords = {image processng, computer vision},
+	publisher = {Pearson Education Limited},
+	title = {{Digital Image Processing}},
+	year = {2017}
+}
+@book{goshtasby2012image,
+	author = {Goshtasby, Ardeshir A.},
+	publisher = {Springer Science \& Business Media},
+	title = {{Image registration: Principles, tools and methods}},
+	year = {2012}
+}
+@article{hjelen1991electron,
+	author = {Hjelen, Jarle and Hoel, Eivind and Ørsund, Roar},
+	doi = {10.1016/0739-6260(91)90128-M},
+	journal = {Micron and Microscopica Acta},
+	keywords = {ebsd, indexing},
+	number = {1-2},
+	pages = {137–138},
+	publisher = {Pergamon},
+	title = {{Electron diffraction in the SEM}},
+	volume = {22},
+	year = {1991}
+}
+@article{jackson2014h5ebsd,
+	author = {Jackson, Michael A. and Groeber, Michael A. and Uchic, Michael D. and Rowenhorst, David J. and {De Graef}, Marc},
+	doi = {10.1186/2193-9772-3-4},
+	journal = {Integrating Materials and Manufacturing Innovation},
+	keywords = {h5ebsd, hdf5, ebsd, emsoft},
+	number = {1},
+	pages = {4},
+	publisher = {Springer},
+	title = {{h5ebsd: an archival data format for electron back-scatter diffraction data sets}},
+	volume = {3},
+	year = {2014}
+}
+@article{jackson2019dictionary,
+	author = {Jackson, Michael A. and Pascal, Elena and {De Graef}, Marc},
+	journal = {Integrating Materials and Manufacturing Innovation},
+	pages = {1–21},
+	publisher = {Springer},
+	title = {{Dictionary Indexing of Electron Back-Scatter Diffraction Patterns: a Hands-On Tutorial}},
+	year = {2019},
+	doi = {10.1007/s40192-019-00137-4},
+}
+@phdthesis{lassen1994automated,
+	author = {Lassen, Niels Christian Krieger},
+	school = {Institute of Mathematical Modelling},
+	title = {{Automated Determanation of Crystal Orientations from Electron Backscattering Patterns}},
+	year = {1994}
+}
+@article{marquardt2017quantitative,
+	author = {Marquardt, Katharina and {De Graef}, Marc and Singh, Saransh and Marquardt, Hauke and Rosenthal, Anja and Koizuimi, Sanae},
+	doi = {10.2138/am-2017-6062},
+	issn = {19453027},
+	journal = {American Mineralogist},
+	keywords = {electron backscatter diffraction, dictionary indexing, indexing},
+	number = {9},
+	pages = {1843–1855},
+	title = {{Quantitative electron backscatter diffraction (EBSD) data analyses using the dictionary indexing (DI) approach: Overcoming indexing difficulties on geological materials}},
+	volume = {102},
+	year = {2017}
+}
 @article{nolze2016pattern,
 	author = {Nolze, Gert and Winkelmann, Aimo and Boyle, Alan P.},
 	doi = {10.1016/j.ultramic.2015.10.010},
@@ -139,6 +141,65 @@ @article{nolze2017electron
 	volume = {52},
 	year = {2017}
 }
+@article{pang2020optimization,
+    title = {Global optimization for accurate determination of EBSD pattern centers},
+    volume = {209},
+    ISSN = {0304-3991},
+    DOI = {10.1016/j.ultramic.2019.112876},
+    journal = {Ultramicroscopy},
+    publisher = {Ultramicroscopy},
+    author = {Pang, Edward L. and Larsen, Peter M. and Schuh, Christopher A.},
+    year = {2020},
+    pages = {112876}
+}
+@article{singh2017application,
+	author = {Singh, Saransh and Ram, Farangis and {De Graef}, Marc},
+	doi = {10.1107/S1600576717014200},
+	journal = {Journal of Applied Crystallography},
+	number = {6},
+	pages = {1664–1676},
+	publisher = {International Union of Crystallography},
+	title = {{Application of forward models to crystal orientation refinement}},
+	volume = {50},
+	year = {2017}
+}
+@article{wilkinson2006high,
+	author = {Wilkinson, Angus J. and Meaden, Graham and Dingley, David J.},
+	doi = {10.1016/j.ultramic.2005.10.001},
+	isbn = {0123739071},
+	issn = {03043991},
+	journal = {Ultramicroscopy},
+	keywords = {electron backscatter diffraction, strain, stress, high-resolution electron backscatter diffraction, fast fourier transform},
+	number = {4-5},
+	pages = {307–313},
+	title = {{High-resolution elastic strain measurement from electron backscatter diffraction patterns: New levels of sensitivity}},
+	volume = {106},
+	year = {2006}
+}
+@article{winkelmann2020refined,
+	author = {Winkelmann, Aimo and Nolze, Gert and Cios, Grzegorz and Tokarski, Tomasz and Bała, Piotr},
+	doi = {10.3390/ma13122816},
+	journal = {Materials},
+	number = {12},
+	pages = {2816},
+	publisher = {Multidisciplinary Digital Publishing Institute},
+	title = {{Refined Calibration Model for Improving the Orientation Precision of Electron Backscatter Diffraction Maps}},
+	volume = {13},
+	year = {2020}
+}
+@article{wright2015introduction,
+	author = {Wright, Stuart I. and Nowell, Matthew M. and Lindeman, Scott P. and Camus, Patrick P. and {De Graef}, Marc and Jackson, Michael A.},
+	doi = {10.1016/j.ultramic.2015.08.001},
+	issn = {18792723},
+	journal = {Ultramicroscopy},
+	keywords = {EBSD, Electron Backscatter Diffraction, indexing, orientation{\_}averaging},
+	pages = {81–94},
+	pmid = {26342553},
+	publisher = {Elsevier},
+	title = {{Introduction and comparison of new EBSD post-processing methodologies}},
+	volume = {159},
+	year = {2015}
+}
 @article{wright2015electron,
 	author = {Wright, Stuart I. and Nowell, Matthew M. and {De Kloe}, René and Camus, Patrick and Rampton, Travis},
 	doi = {10.1016/j.ultramic.2014.10.002},
@@ -153,18 +214,13 @@ @article{wright2015electron
 	volume = {148},
 	year = {2015}
 }
-@article{britton2016tutorial,
-	author = {Britton, T. B. and Jiang, J. and Guo, Y. and Vilalta-Clemente, A. and Wallis, D. and Hansen, L. N. and Winkelmann, Aimo and Wilkinson, Angus J.},
-	doi = {10.1016/j.matchar.2016.04.008},
-	isbn = {1044-5803},
-	issn = {10445803},
-	journal = {Materials Characterization},
-	keywords = {Crystal orientation, Electron backscatter diffraction, Electron microscopy, Texture, ebsd, indexing},
-	pages = {113–126},
-	publisher = {The Authors},
-	title = {{Tutorial: Crystal orientations and EBSD - Or which way is up?}},
-	volume = {117},
-	year = {2016}
+@book{aanes2019electron,
+  author = {Ånes, Håkon W. and Hjelen, Jarle and van Helvoort, Antonius T. J. and Marthinsen, Knut},
+  title = {{Electron backscatter patterns from Nickel acquired with varying camera gain}},
+  year = 2019,
+  note = {[Data set].},
+  publisher = {Zenodo},
+  doi = {10.5281/zenodo.3265037},
 }
 @inproceedings{aanes2020processing,
 	author = {Ånes, HW and Hjelen, J and Sørensen, BE and van Helvoort, ATJ and Marthinsen, K},
@@ -177,49 +233,3 @@ @inproceedings{aanes2020processing
 	volume = {891},
 	year = {2020}
 }
-@article{brewick2019nlpar,
-	author = {Brewick, Patrick T. and Wright, Stuart I. and Rowenhorst, David J.},
-	doi = {10.1016/j.ultramic.2019.02.013},
-	journal = {Ultramicroscopy},
-	pages = {50–61},
-	title = {{NLPAR: Non-local smoothing for enhanced EBSD pattern indexing}},
-	volume = {200},
-	year = {2019}
-}
-@article{hjelen1991electron,
-	author = {Hjelen, Jarle and Hoel, Eivind and Ørsund, Roar},
-	doi = {10.1016/0739-6260(91)90128-M},
-	journal = {Micron and Microscopica Acta},
-	keywords = {ebsd, indexing},
-	number = {1-2},
-	pages = {137–138},
-	publisher = {Pergamon},
-	title = {{Electron diffraction in the SEM}},
-	volume = {22},
-	year = {1991}
-}
-@article{wilkinson2006high,
-	author = {Wilkinson, Angus J. and Meaden, Graham and Dingley, David J.},
-	doi = {10.1016/j.ultramic.2005.10.001},
-	isbn = {0123739071},
-	issn = {03043991},
-	journal = {Ultramicroscopy},
-	keywords = {electron backscatter diffraction, strain, stress, high-resolution electron backscatter diffraction, fast fourier transform},
-	number = {4-5},
-	pages = {307–313},
-	title = {{High-resolution elastic strain measurement from electron backscatter diffraction patterns: New levels of sensitivity}},
-	volume = {106},
-	year = {2006}
-}
-
-@article{pang2020optimization,
-    title={Global optimization for accurate determination of EBSD pattern centers},
-    volume={209},
-    ISSN={0304-3991},
-    DOI={10.1016/j.ultramic.2019.112876},
-    journal={Ultramicroscopy},
-    publisher={Ultramicroscopy},
-    author={Pang, Edward L. and Larsen, Peter M. and Schuh, Christopher A.},
-    year={2020},
-    pages={112876}
-}
\ No newline at end of file
diff --git a/doc/changelog.rst b/doc/changelog.rst
index 20cd6ab7..eaaeb0a2 100644
--- a/doc/changelog.rst
+++ b/doc/changelog.rst
@@ -22,6 +22,8 @@ Contributors
 
 Added
 -----
+- How to use the new orientation and/or projection center refinements to the pattern
+  matching notebook. (`#405 <https://github.com/pyxem/kikuchipy/pull/405>`_)
 - Notebooks to the documentation as shorter or longer "Examples" that don't fit in the
   user guide. (`#403 <https://github.com/pyxem/kikuchipy/pull/403>`_)
 - Refinement module for EBSD refinement. Allows for the refinement of
@@ -30,6 +32,10 @@ Added
 
 Changed
 -------
+- Performance improvements to EBSD dictionary generation, giving a substantial speed-up.
+  (`#405 <https://github.com/pyxem/kikuchipy/pull/405>`_)
+- Rename projection methods from `project()`/`iproject()` to
+  `vector2xy()`/`xy2vector()`. (`#405 <https://github.com/pyxem/kikuchipy/pull/405>`_)
 - URLs of user guide topics have an extra "/user_guide/<topic>" added to them.
   (`#403 <https://github.com/pyxem/kikuchipy/pull/403>`_)
 
diff --git a/doc/conf.py b/doc/conf.py
index c7d84eb0..f3313f77 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -109,13 +109,13 @@
 
     <div class="admonition note">
       This page was generated from
-      <a class="reference external" href="https://github.com/pyxem/kikuchipy/blob/"""
-    + f"{release_version}"
-    + r"""/{{ docname|e }}">{{ docname|e }}</a>.
+      <a class="reference external" href="""
+    + f"'https://github.com/pyxem/kikuchipy/blob/{release_version}"
+    + r"""/{{ docname|e }}'>{{ docname|e }}</a>.
       Interactive online version:
-      <span style="white-space: nowrap;"><a href="https://mybinder.org/v2/gh/pyxem/kikuchipy/"""
-    + f"{release_version}"
-    + r"""?filepath={{ docname|e }}"><img alt="Binder badge" src="https://mybinder.org/badge_logo.svg" style="vertical-align:text-bottom"></a>.</span>
+      <span style="white-space: nowrap;"><a href="""
+    + f"'https://mybinder.org/v2/gh/pyxem/kikuchipy/{release_version}"
+    + r"""?filepath={{ docname|e }}'><img alt="Binder badge" src="https://mybinder.org/badge_logo.svg" style="vertical-align:text-bottom"></a>.</span>
       <script>
         if (document.location.host) {
           $(document.currentScript).replaceWith(
diff --git a/doc/contributing.rst b/doc/contributing.rst
index 825a1262..98c8f5c0 100644
--- a/doc/contributing.rst
+++ b/doc/contributing.rst
@@ -214,6 +214,14 @@ terminal. For an even nicer presentation, you can use ``coverage.py`` directly::
 Then, you can open the created ``htmlcov/index.html`` in the browser and inspect
 the coverage in more detail.
 
+To run only a specific test function or class, .e.g the ``TestEBSD`` class::
+
+    $ pytest -k TestEBSD
+
+This is useful when you only want to run a specific test and not the full test suite,
+e.g. when you're creating or updating a test. But remember to run the full test suite
+before pushing!
+
 Docstring examples are tested
 `with pytest <https://docs.pytest.org/en/stable/doctest.html>`_ as well::
 
@@ -242,11 +250,20 @@ The desired data cache directory used by pooch can be set with a global
 `KIKUCHIPY_DATA_DIR` variable locally, e.g. by setting
 `export KIKUCHIPY_DATA_DIR=~/kikuchipy_data` in `~/.bashrc`.
 
+Improving performance
+=====================
+When we write code, it's important that we (1) get the correct result, (2) don't fill up
+memory, and (3) that the computation doesn't take too long. To keep memory in check, we
+use `Dask <https://docs.dask.org/en/latest/>`_ wherever possible. To speed up
+computations, we use `Numba <https://numba.pydata.org/numba-doc/dev/>`_ wherever
+possible.
+
 Continuous integration (CI)
 ===========================
 
 We use `GitHub Actions <https://github.com/pyxem/kikuchipy/actions>`_ to ensure
 that kikuchipy can be installed on Windows, macOS and Linux (Ubuntu). After a
-successful installation, the CI server runs the tests. After the tests return no
-errors, code coverage is reported to `Coveralls
-<https://coveralls.io/github/pyxem/kikuchipy?branch=master>`_.
+successful installation of the package, the CI server runs the tests. After the tests
+return no errors, code coverage is reported to `Coveralls
+<https://coveralls.io/github/pyxem/kikuchipy?branch=master>`_. Add "[skip ci]" or to a
+commit message to skip this workflow on any commit to a pull request, as explained
diff --git a/doc/examples.rst b/doc/examples.rst
index 480efa0a..19432dc4 100644
--- a/doc/examples.rst
+++ b/doc/examples.rst
@@ -2,7 +2,7 @@
 Examples
 ========
 
-Shorter examples or longer tutorials that doesn't fit in the `User guide <index.rst>`_.
+Shorter examples or longer tutorials that don't easily fit in the `User guide <index.rst>`_.
 
 Demonstrations given at workshops
 =================================
diff --git a/doc/reference.rst b/doc/reference.rst
index 6b02298d..a247c14f 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -246,6 +246,9 @@ indexing
 
 .. autosummary::
     StaticPatternMatching
+    compute_refine_orientation_results
+    compute_refine_orientation_projection_center_results
+    compute_refine_projection_center_results
     orientation_similarity_map
     merge_crystal_maps
     similarity_metrics
@@ -258,6 +261,9 @@ indexing
     .. automethod:: __init__
     .. automethod:: __call__
 
+.. autofunction:: compute_refine_orientation_results
+.. autofunction:: compute_refine_orientation_projection_center_results
+.. autofunction:: compute_refine_projection_center_results
 .. autofunction:: orientation_similarity_map
 .. autofunction:: merge_crystal_maps
 
@@ -517,6 +523,9 @@ from HyperSpy.
     normalize_intensity
     plot_virtual_bse_intensity
     rebin
+    refine_orientation
+    refine_orientation_projection_center
+    refine_projection_center
     remove_dynamic_background
     remove_static_background
     rescale_intensity
diff --git a/doc/user_guide/pattern_matching.ipynb b/doc/user_guide/pattern_matching.ipynb
index baece7c9..7eecacf2 100644
--- a/doc/user_guide/pattern_matching.ipynb
+++ b/doc/user_guide/pattern_matching.ipynb
@@ -17,24 +17,34 @@
     "# Pattern matching\n",
     "\n",
     "Crystal orientations can be determined from experimental EBSD patterns by\n",
-    "matching them to a dictionary of simulated patterns of known orientations\n",
-    "<cite data-cite=\"chen2015dictionary\">Chen et al. (2015)</cite>,\n",
+    "matching them to a dictionary of simulated patterns of known orientations, see\n",
+    "e.g. <cite data-cite=\"chen2015dictionary\">Chen et al. (2015)</cite>,\n",
     "<cite data-cite=\"nolze2016pattern\">Nolze et al. (2016)</cite>, \n",
     "<cite data-cite=\"foden2019indexing\">Foden et al. (2019)</cite>.\n",
     "\n",
-    "Here, we will demonstrate pattern matching using a Ni data set of 4125 EBSD\n",
-    "patterns and a dynamically simulated master pattern from EMsoft, both of low\n",
-    "resolution and found in the [kikuchipy.data](../reference.rst#data) module.\n",
+    "Here, we will demonstrate *dictionary indexing* (DI) using a Ni data set of 4125\n",
+    "EBSD patterns and a dynamically simulated master pattern from EMsoft, both of\n",
+    "low resolution and found in the [kikuchipy.data](../reference.rst#data) module.\n",
+    "The pattern dictionary is generated from a discrete grid of orientations with a\n",
+    "fixed set of projection center (PC) parameters. The true orientation is likely\n",
+    "to fall in between multiple grid points, which means there is always a lower\n",
+    "angular accuracy associated with each DI run. We can improve upon each\n",
+    "orientation by letting it deviate from the discrete grid points. We do this by\n",
+    "maximizing the similarity between experimental and simulated patterns using\n",
+    "numerical optimization algorithms from the\n",
+    "[SciPy library](https://docs.scipy.org/doc/scipy/reference/optimize.html). This\n",
+    "is here called *orientation refinement*. We could instead keep the orientations\n",
+    "fixed and let the PC parameters deviate from their fixed value used in the\n",
+    "dictionary, here called *projection center refinement*. Finally, we can also\n",
+    "refine both at the same time, here called *orientation and projection center\n",
+    "refinement*. The need for orientation or orientation and PC refinement is\n",
+    "discussed in e.g.\n",
+    "<cite data-cite=\"singh2017application\">Singh et al. (2017)</cite>,\n",
+    "<cite data-cite=\"winkelmann2020refined\">Winkelmann et al. (2020)</cite>, and\n",
+    "<cite data-cite=\"pang2020optimization\">Pang et al. (2020)</cite>.\n",
     "\n",
-    "<div class=\"alert alert-info\">\n",
-    "\n",
-    "Note\n",
-    "\n",
-    "The generated pattern dictionary is discrete, but no refinement of the best\n",
-    "matching orientation is provided. The need for the latter is discussed in e.g.\n",
-    "<cite data-cite=\"singh2017application\">Singh et al. (2017)</cite>.\n",
-    "\n",
-    "</div>\n",
+    "The term *pattern matching* is here used for the combined approach of DI\n",
+    "followed by refinement.\n",
     "\n",
     "Before we can generate a dictionary of\n",
     "simulated patterns, we need a master pattern containing all possible scattering\n",
@@ -98,6 +108,10 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
+    "## Dictionary indexing\n",
+    "\n",
+    "### Load a master pattern\n",
+    "\n",
     "Next, we load a dynamically simulated Nickel master pattern generated with\n",
     "EMsoft, in the northern hemisphere projection of the square Lambert projection\n",
     "for an accelerating voltage of 20 keV"
@@ -109,7 +123,8 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "mp = kp.data.nickel_ebsd_master_pattern_small(projection=\"lambert\", energy=20)\n",
+    "energy = 20\n",
+    "mp = kp.data.nickel_ebsd_master_pattern_small(projection=\"lambert\", energy=energy)\n",
     "mp"
    ]
   },
@@ -163,6 +178,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
+    "### Sample orientation space\n",
+    "\n",
     "If we don't know anything about the possible crystal (unit cell) orientations in\n",
     "our sample, the safest thing to do is to generate a dictionary of orientations\n",
     "uniformly distributed in a candidate phase's orientation space. To achieve this,\n",
@@ -179,10 +196,10 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "r = sampling.get_sample_fundamental(\n",
+    "rotations = sampling.get_sample_fundamental(\n",
     "    resolution=4, space_group=ni.space_group.number\n",
     ")\n",
-    "r"
+    "rotations"
    ]
   },
   {
@@ -201,6 +218,13 @@
     "</div>"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Define the detector-sample-geometry"
+   ]
+  },
   {
    "cell_type": "markdown",
    "metadata": {},
@@ -251,6 +275,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
+    "### Generate dictionary\n",
+    "\n",
     "Now we're ready to generate our dictionary of simulated patterns by projecting\n",
     "parts of the master pattern onto our detector for all sampled orientations,\n",
     "using the\n",
@@ -266,9 +292,9 @@
    "outputs": [],
    "source": [
     "sim = mp.get_patterns(\n",
-    "    rotations=r,\n",
+    "    rotations=rotations,\n",
     "    detector=detector,\n",
-    "    energy=20,\n",
+    "    energy=energy,\n",
     "    dtype_out=s.data.dtype,\n",
     "    compute=True\n",
     ")\n",
@@ -304,6 +330,8 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
+    "### Perform indexing\n",
+    "\n",
     "Finally, let's use the\n",
     "[dictionary_indexing()](../reference.rst#kikuchipy.signals.EBSD.dictionary_indexing)\n",
     "method to match the simulated patterns to our nine experimental patterns, using\n",
@@ -350,8 +378,37 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "Let's inspect our matching results by plotting a map of the highest $r$\n",
-    "(stored in the `scores` property)"
+    "### Inspect indexing results\n",
+    "\n",
+    "So far, the [orix library](https://orix.readthedocs.io) cannot produce any\n",
+    "orientation color maps, pole figures etc. This has to be done in other software,\n",
+    "like MTEX.\n",
+    "\n",
+    "Here, we'll inspect the map of best matching scores and a so-called orientation\n",
+    "similarity (OS) map, which compares the best matching orientations for each\n",
+    "pattern to it’s nearest neighbours.\n",
+    "\n",
+    "Let's get the NCC map in the correct shape from the CrystalMap’s scores property\n",
+    "and the OS map with\n",
+    "[orientation_similarity_map()](../reference.rst#kikuchipy.indexing.orientation_similarity_map)\n",
+    "using the full list of 10 best matches per point"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ncc_map = xmap.scores[:, 0].reshape(*xmap.shape)\n",
+    "os_map = kp.indexing.orientation_similarity_map(xmap)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "And plot the maps"
    ]
   },
   {
@@ -360,16 +417,30 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "fig, ax = plt.subplots(subplot_kw=dict(projection=\"plot_map\"))\n",
-    "ax.plot_map(xmap, xmap.scores[:, 0], scalebar=False)\n",
-    "ax.add_colorbar(label=r\"$r$\")\n",
-    "_ = ax.axis(\"off\")"
+    "fig, ax = plt.subplots(ncols=2, figsize=(9, 3))\n",
+    "\n",
+    "im0 = ax[0].imshow(ncc_map)\n",
+    "ax[0].axis(\"off\")\n",
+    "fig.colorbar(im0, ax=ax[0], label=\"NCC\")\n",
+    "\n",
+    "im1 = ax[1].imshow(os_map)\n",
+    "ax[1].axis(\"off\")\n",
+    "fig.colorbar(im1, ax=ax[1], label=\"Orientation similarity\")\n",
+    "\n",
+    "fig.tight_layout();"
    ]
   },
   {
    "cell_type": "markdown",
    "metadata": {},
    "source": [
+    "We can see that some of the grains have orientations which doesn't match that\n",
+    "well to any orientation (pattern) in the dictionary. Note that refinment will\n",
+    "not be able to improve much upon those orientations that didn't get a good match\n",
+    "from DI. The simplest way to improve the results for the darker grains is to\n",
+    "reduce the orientation sampling characteristic distance. This will increase our\n",
+    "dictionary size and thus our indexing time, but will lead to better results.\n",
+    "\n",
     "We can use the crystal map property `simulation_indices` to get the best\n",
     "matching simulated patterns from the dictionary of simulated patterns"
    ]
@@ -419,7 +490,7 @@
    "execution_count": null,
    "metadata": {
     "nbsphinx-thumbnail": {
-     "tooltip": "Crystal orientation determination by comparing experimental EBSD patterns to a dictionary of simulated patterns obtained from a master pattern"
+     "tooltip": "Crystal orientation determination by comparing experimental EBSD patterns to a dictionary of simulated patterns obtained from a master pattern followed by orientation refinement"
     },
     "tags": [
      "nbsphinx-thumbnail"
@@ -441,6 +512,469 @@
     "fig.tight_layout(h_pad=0.5, w_pad=1)"
    ]
   },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Refinement\n",
+    "\n",
+    "Let's look at the effect of three refinement routes, all implemented as `EBSD`\n",
+    "methods:\n",
+    "\n",
+    "1. Refine orientations while keeping the PCs fixed: [refine_orientation()](../reference.rst#kikuchipy.signals.EBSD.refine_orientation) \n",
+    "2. Refine PCs while keeping the orientations fixed: [refine_projection_center()](../reference.rst#kikuchipy.signals.EBSD.refine_projection_center) \n",
+    "3. Refine orientations and PCs at the same time: [refine_orientation_projection_center()](../reference.rst#kikuchipy.signals.EBSD.refine_orientation_projection_center) \n",
+    "\n",
+    "For each run we will compare the maps and histograms of NCC scores before and\n",
+    "after refinement, and also the PC parameters if appropriate.\n",
+    "\n",
+    "As stated at the top, we use the numerical optimization routines from the\n",
+    "[SciPy library](https://docs.scipy.org/doc/scipy/reference/optimize.html). For\n",
+    "every orientation and/or PC, we want to iteratively increase the similarity (NCC\n",
+    "score) between our experimental pattern and a new simulated pattern projected\n",
+    "onto our EBSD detector for every iteration until the score increase from one\n",
+    "iteration to the next is below a certain threshold, by default 0.0001 in most\n",
+    "cases. The orientation and/or PC is updated slightly for every iteration. We\n",
+    "have access to both local and global optimization algorithms. Consult the\n",
+    "kikuchipy docstring methods linked below and the SciPy documentation for all\n",
+    "available options.\n",
+    "\n",
+    "Note that while we here refine orientations obtained from DI, any orientation\n",
+    "results, e.g. from Hough indexing, can be refined with this approach, as long as\n",
+    "an appropriate master pattern and\n",
+    "[EBSDDetector](../reference.rst#kikuchipy.detectors.EBSDDetector) is provided,\n",
+    "and the orientation results are passed as a\n",
+    "[CrystalMap](https://orix.readthedocs.io/en/stable/reference.html#orix.crystal_map.CrystalMap).\n",
+    "\n",
+    "### Refine orientations\n",
+    "\n",
+    "We use\n",
+    "[refine_orientation()](../reference.rst#kikuchipy.signals.EBSD.refine_orientation)\n",
+    "to refine orientations while keeping the PCs fixed, using the default local\n",
+    "Nelder-Mead simplex method"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "xmap_refined = s.refine_orientation(\n",
+    "    xmap=xmap,\n",
+    "    detector=detector,\n",
+    "    master_pattern=mp,\n",
+    "    energy=energy,\n",
+    "    method=\"minimize\",  # Default\n",
+    "    method_kwargs=dict(method=\"Nelder-Mead\", options=dict(fatol=1e-4)),  # Default    \n",
+    "    compute=True,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Compare the NCC score maps. We use the same colorbar bounds for both maps"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ncc_after_orientation_refinement = xmap_refined.get_map_data(\"scores\")\n",
+    "\n",
+    "ncc_di_min = np.min(ncc_map)\n",
+    "ncc_di_max = np.max(ncc_map)\n",
+    "ncc_ori_ref_min = np.min(ncc_after_orientation_refinement)\n",
+    "ncc_ori_ref_max = np.max(ncc_after_orientation_refinement)\n",
+    "\n",
+    "vmin = min([ncc_di_min, ncc_ori_ref_min])\n",
+    "vmax = max([ncc_di_max, ncc_ori_ref_max])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fig, ax = plt.subplots(ncols=2, figsize=(9, 3))\n",
+    "im0 = ax[0].imshow(ncc_map, vmin=vmin, vmax=vmax)\n",
+    "ax[0].axis(\"off\")\n",
+    "fig.colorbar(im0, ax=ax[0], label=\"NCC from DI\")\n",
+    "im1 = ax[1].imshow(ncc_after_orientation_refinement, vmin=vmin, vmax=vmax)\n",
+    "ax[1].axis(\"off\")\n",
+    "ncc_after_label = \"NCC after orientation refinement\"\n",
+    "fig.colorbar(im1, ax=ax[1], label=ncc_after_label)\n",
+    "fig.tight_layout();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Compare the histograms"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bins = np.linspace(vmin, vmax, 100)\n",
+    "fig, ax = plt.subplots(figsize=(9, 5))\n",
+    "_ = ax.hist(ncc_map.ravel(), bins, alpha=0.5, label=\"NCC from DI\")\n",
+    "_ = ax.hist(\n",
+    "    ncc_after_orientation_refinement.ravel(),\n",
+    "    bins,\n",
+    "    alpha=0.5,\n",
+    "    label=ncc_after_label,\n",
+    ")\n",
+    "ax.set_xlabel(\"Normalized cross correlation scores\")\n",
+    "ax.set_ylabel(\"Frequency\")\n",
+    "ax.legend()\n",
+    "fig.tight_layout();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "We can see that orientation refinement found the best orientation\n",
+    "(with a fixed PC) for most of the grains, but not for the ones which didn't find\n",
+    "the correct best match in the dictionary. Clearly, the dictionary is too sparse."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Refine projection centers\n",
+    "\n",
+    "We use\n",
+    "[refine_projection_center()](../reference.rst#kikuchipy.signals.EBSD.refine_projection_center)\n",
+    "to refine PCs while keeping the orientations fixed, using the local modified\n",
+    "Powell method. This method is also known as Bound Optimization BY Quadratic\n",
+    "Approximation (BOBYQA), and is used in EMsoft and discussed by\n",
+    "<cite data-cite=\"singh2017application\">Singh et al. (2017)</cite>. We will pass\n",
+    "a `trust_region` of +/- 5% for the PC parameters to use as upper and lower\n",
+    "bounds during refinement.\n",
+    "\n",
+    "We can also pass `compute=False`, to do the\n",
+    "refinement later. The results will then be a list of\n",
+    "[dask.delayed.Delayed](https://docs.dask.org/en/latest/delayed-api.html#dask.delayed.Delayed)\n",
+    "instances. We can pass this list to\n",
+    "[kikuchipy.indexing.compute_refine_projection_center_results()](../reference.rst#kikuchipy.indexing.compute_refine_projection_center_results)\n",
+    "and perform the refinement to retrieve the results"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "delayed_results = s.refine_projection_center(\n",
+    "    xmap=xmap,\n",
+    "    detector=detector,\n",
+    "    master_pattern=mp,\n",
+    "    energy=energy,\n",
+    "    method=\"minimize\",\n",
+    "    method_kwargs=dict(method=\"Powell\", options=dict(ftol=1e-3)),\n",
+    "    trust_region=[0.05, 0.05, 0.05],\n",
+    "    compute=False,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ncc_after_pc_refinement, detector_refined = kp.indexing.compute_refine_projection_center_results(\n",
+    "    results=delayed_results, detector=detector, xmap=xmap\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Note that `refine_orientation()` and `refine_orientation_projection_center()`\n",
+    "also takes the `compute` parameter, and there similar functions to compute the\n",
+    "refinement results:\n",
+    "[kikuchipy.indexing.compute_refine_orientation_results()](../reference.rst#kikuchipy.indexing.compute_refine_orientation_results)\n",
+    "and\n",
+    "[kikuchipy.indexing.compute_refine_orientation_projection_center_results()](../reference.rst#kikuchipy.indexing.compute_refine_orientation_projection_center_results).\n",
+    "\n",
+    "Let's plot the refined scores and PCs"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ncc_pc_ref_min = np.min(ncc_after_pc_refinement)\n",
+    "ncc_pc_ref_max = np.max(ncc_after_pc_refinement)\n",
+    "\n",
+    "vmin2 = min([ncc_di_min, ncc_pc_ref_min])\n",
+    "vmax2 = max([ncc_di_max, ncc_pc_ref_max])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fig, ax = plt.subplots(ncols=2, figsize=(9, 3))\n",
+    "im0 = ax[0].imshow(ncc_map, vmin=vmin2, vmax=vmax2)\n",
+    "ax[0].axis(\"off\")\n",
+    "fig.colorbar(im0, ax=ax[0], label=\"NCC from DI\")\n",
+    "im1 = ax[1].imshow(ncc_after_pc_refinement, vmin=vmin2, vmax=vmax2)\n",
+    "ax[1].axis(\"off\")\n",
+    "ncc_after_pc_label = \"NCC after PC refinement\"\n",
+    "fig.colorbar(im1, ax=ax[1], label=ncc_after_pc_label)\n",
+    "fig.tight_layout();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Compare the histograms"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bins = np.linspace(vmin2, vmax2, 100)\n",
+    "fig, ax = plt.subplots(figsize=(9, 5))\n",
+    "_ = ax.hist(ncc_map.ravel(), bins, alpha=0.5, label=\"NCC from DI\")\n",
+    "_ = ax.hist(\n",
+    "    ncc_after_pc_refinement.ravel(),\n",
+    "    bins,\n",
+    "    alpha=0.5,\n",
+    "    label=ncc_after_pc_label,\n",
+    ")\n",
+    "ax.set_xlabel(\"Normalized cross correlation scores\")\n",
+    "ax.set_ylabel(\"Frequency\")\n",
+    "ax.legend()\n",
+    "fig.tight_layout();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let's also inspect the refined PC parameters"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\n",
+    "    f\"PC used in DI:\\t\\t{detector.pc_average}\\n\"\n",
+    "    f\"PC after PC refinement:\\t{detector_refined.pc_average}\"\n",
+    ")\n",
+    "\n",
+    "fig, ax = plt.subplots(ncols=3, figsize=(15, 3))\n",
+    "im0 = ax[0].imshow(detector_refined.pcx)\n",
+    "fig.colorbar(im0, ax=ax[0], label=\"Projection center x\")\n",
+    "ax[0].axis(\"off\")\n",
+    "im1 = ax[1].imshow(detector_refined.pcy)\n",
+    "fig.colorbar(im1, ax=ax[1], label=\"Projection center y\")\n",
+    "ax[1].axis(\"off\")\n",
+    "im2 = ax[2].imshow(detector_refined.pcz)\n",
+    "fig.colorbar(im2, ax=ax[2], label=\"Projection center z\")\n",
+    "ax[2].axis(\"off\")\n",
+    "fig.tight_layout();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "### Refine orientations and projection centers\n",
+    "\n",
+    "We use\n",
+    "[refine_orientation_projection_center()](../reference.rst#kikuchipy.signals.EBSD.refine_orientation_projection_center)\n",
+    "to refine orientations and PCs at the same time. For the purpose of saving time\n",
+    "and computation resources here, we'll just do this for the upper left quarter of\n",
+    "the data set"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "nav_shape = s.axes_manager.navigation_shape[::-1]\n",
+    "y1, x1 = np.array(nav_shape) // 2\n",
+    "slices2d = (slice(0, y1), slice(0, x1))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "s2 = s.inav[slices2d[::-1]]  # HyperSpy flips the usual NumPy (row, column) to (column, row)\n",
+    "s2"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "xmap2 = xmap[slices2d]\n",
+    "xmap2"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "xmap_refined2, detector_refined2 = s2.refine_orientation_projection_center(\n",
+    "    xmap=xmap2,\n",
+    "    detector=detector,\n",
+    "    master_pattern=mp,\n",
+    "    energy=energy,\n",
+    "    method=\"minimize\",\n",
+    "    method_kwargs=dict(options=dict(fatol=1e-3)),\n",
+    "    compute=True,\n",
+    ")"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "xmap_refined2"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Compare the NCC score maps. We use the same colorbar bounds for both maps"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "ncc_after_orientation_pc_refinement = xmap_refined2.get_map_data(\"scores\")\n",
+    "ncc_map2 = ncc_map[slices2d]\n",
+    "\n",
+    "ncc2_di_min = np.min(ncc_map2)\n",
+    "ncc2_di_max = np.max(ncc_map2)\n",
+    "ncc_ori_pc_ref_min = np.min(ncc_after_orientation_pc_refinement)\n",
+    "ncc_ori_pc_ref_max = np.max(ncc_after_orientation_pc_refinement)\n",
+    "\n",
+    "vmin3 = min([ncc2_di_min, ncc_ori_pc_ref_min])\n",
+    "vmax3 = max([ncc2_di_max, ncc_ori_pc_ref_max])"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "fig, ax = plt.subplots(ncols=2, figsize=(9, 3))\n",
+    "im0 = ax[0].imshow(ncc_map2, vmin=vmin, vmax=vmax)\n",
+    "ax[0].axis(\"off\")\n",
+    "fig.colorbar(im0, ax=ax[0], label=\"NCC from DI\")\n",
+    "im1 = ax[1].imshow(ncc_after_orientation_pc_refinement, vmin=vmin3, vmax=vmax3)\n",
+    "ax[1].axis(\"off\")\n",
+    "ncc_after_ori_pc_label = \"NCC after ori./PC refinement\"\n",
+    "fig.colorbar(im1, ax=ax[1], label=ncc_after_ori_pc_label)\n",
+    "fig.tight_layout();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Compare the histograms"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "bins = np.linspace(vmin3, vmax3, 100)\n",
+    "fig, ax = plt.subplots(figsize=(9, 5))\n",
+    "_ = ax.hist(ncc_map2.ravel(), bins, alpha=0.5, label=\"NCC from DI\")\n",
+    "_ = ax.hist(\n",
+    "    ncc_after_orientation_pc_refinement.ravel(),\n",
+    "    bins,\n",
+    "    alpha=0.5,\n",
+    "    label=ncc_after_ori_pc_label,\n",
+    ")\n",
+    "ax.set_xlabel(\"Normalized cross correlation scores\")\n",
+    "ax.set_ylabel(\"Frequency\")\n",
+    "ax.legend()\n",
+    "fig.tight_layout();"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "Let's also inspect the refined PC parameters"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "print(\n",
+    "    f\"PC used in DI:\\t\\t{detector.pc_average}\\n\"\n",
+    "    f\"PC after PC refinement:\\t{detector_refined2.pc_average}\"\n",
+    ")\n",
+    "\n",
+    "fig, ax = plt.subplots(ncols=3, figsize=(15, 3))\n",
+    "im0 = ax[0].imshow(detector_refined2.pcx)\n",
+    "fig.colorbar(im0, ax=ax[0], label=\"Projection center x\")\n",
+    "ax[0].axis(\"off\")\n",
+    "im1 = ax[1].imshow(detector_refined2.pcy)\n",
+    "fig.colorbar(im1, ax=ax[1], label=\"Projection center y\")\n",
+    "ax[1].axis(\"off\")\n",
+    "im2 = ax[2].imshow(detector_refined2.pcz)\n",
+    "fig.colorbar(im2, ax=ax[2], label=\"Projection center z\")\n",
+    "ax[2].axis(\"off\")\n",
+    "fig.tight_layout();"
+   ]
+  },
   {
    "cell_type": "code",
    "execution_count": null,
@@ -472,7 +1006,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.8.10"
+   "version": "3.9.6"
   }
  },
  "nbformat": 4,
diff --git a/kikuchipy/__init__.py b/kikuchipy/__init__.py
index e3744760..8229028e 100644
--- a/kikuchipy/__init__.py
+++ b/kikuchipy/__init__.py
@@ -16,7 +16,9 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
+# List of public modules
 # Import order must not be changed
+from kikuchipy import signals
 from kikuchipy import crystallography
 from kikuchipy import detectors
 from kikuchipy import draw
@@ -24,7 +26,6 @@
 from kikuchipy import indexing
 from kikuchipy import pattern
 from kikuchipy import projections
-from kikuchipy import signals
 from kikuchipy import generators
 from kikuchipy import simulations
 from kikuchipy.io._io import load
diff --git a/kikuchipy/_rotation/__init__.py b/kikuchipy/_rotation/__init__.py
new file mode 100644
index 00000000..b83c52aa
--- /dev/null
+++ b/kikuchipy/_rotation/__init__.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
+
+"""Private tools for handling crystal orientations and directions.
+
+The functions here is taken directly from orix, but modified to use
+Numba. Ideally, these should be imported from orix instead.
+
+This module and documentation is only relevant for kikuchipy developers,
+not for users.
+
+.. warning:
+    This module and its submodules are for internal use only.  Do not
+    use them in your own code. We may change the API at any time with no
+    warning.
+"""
+
+# TODO: Implement these and similar functions in orix
+
+import numba as nb
+import numpy as np
+
+
+@nb.jit("float64[:](float64, float64, float64)", nogil=True, nopython=True)
+def _rotation_from_euler(alpha: float, beta: float, gamma: float) -> np.ndarray:
+    """Convert three Euler angles (alpha, beta, gamma) to a unit
+    quaternion.
+
+    Taken from :meth:`orix.quaternion.Rotation.from_euler`.
+
+    Parameters
+    ----------
+    alpha, beta, gamma
+        Euler angles in the Bunge convention in radians.
+
+    Returns
+    -------
+    rotation
+        Unit quaternion.
+
+    Notes
+    -----
+    This function is optimized with Numba, so care must be taken with
+    array shapes and data types.
+    """
+    sigma = 0.5 * np.add(alpha, gamma)
+    delta = 0.5 * np.subtract(alpha, gamma)
+    c = np.cos(beta / 2)
+    s = np.sin(beta / 2)
+
+    rotation = np.zeros(4)
+    rotation[0] = c * np.cos(sigma)
+    rotation[1] = -s * np.cos(delta)
+    rotation[2] = -s * np.sin(delta)
+    rotation[3] = -c * np.sin(sigma)
+
+    if rotation[0] < 0:
+        rotation = -rotation
+
+    return rotation
+
+
+@nb.jit("float64[:, :](float64[:], float64[:, :])", nogil=True, nopython=True)
+def _rotate_vector(rotation: np.ndarray, vector: np.ndarray) -> np.ndarray:
+    """Rotation of vector(s) by a quaternion.
+
+    Taken from :meth:`orix.quaternion.Quaternion.__mul__`.
+
+    Parameters
+    ----------
+    rotation
+        Quaternion rotation as an array of shape (4,) and data type
+        64-bit floats.
+    vector
+        Vector(s) as an array of shape (n, 3) and data type 64-bit
+        floats.
+
+    Returns
+    -------
+    rotated_vector
+
+    Notes
+    -----
+    This function is optimized with Numba, so care must be taken with
+    array shapes and data types.
+    """
+    a, b, c, d = rotation
+    x = vector[:, 0]
+    y = vector[:, 1]
+    z = vector[:, 2]
+    rotated_vector = np.zeros(vector.shape)
+    aa = a ** 2
+    bb = b ** 2
+    cc = c ** 2
+    dd = d ** 2
+    ac = a * c
+    ab = a * b
+    ad = a * d
+    bc = b * c
+    bd = b * d
+    cd = c * d
+    rotated_vector[:, 0] = (aa + bb - cc - dd) * x + 2 * ((ac + bd) * z + (bc - ad) * y)
+    rotated_vector[:, 1] = (aa - bb + cc - dd) * y + 2 * ((ad + bc) * x + (cd - ab) * z)
+    rotated_vector[:, 2] = (aa - bb - cc + dd) * z + 2 * ((ab + cd) * y + (bd - ac) * x)
+    return rotated_vector
diff --git a/kikuchipy/_util/__init__.py b/kikuchipy/_util/__init__.py
index 8aadf56d..2723c998 100644
--- a/kikuchipy/_util/__init__.py
+++ b/kikuchipy/_util/__init__.py
@@ -18,7 +18,7 @@
 
 """Helper functions and classes for managing kikuchipy.
 
-This module and documentation is only relevant for kikuchioy developers,
+This module and documentation is only relevant for kikuchipy developers,
 not for users.
 
 .. warning:
@@ -28,6 +28,3 @@
 """
 
 from kikuchipy._util._util import deprecated
-
-
-__all__ = ["deprecated"]
diff --git a/kikuchipy/detectors/ebsd_detector.py b/kikuchipy/detectors/ebsd_detector.py
index e4847c6f..762c38f6 100644
--- a/kikuchipy/detectors/ebsd_detector.py
+++ b/kikuchipy/detectors/ebsd_detector.py
@@ -122,6 +122,13 @@ def __init__(
         self.pc = pc
         self._set_pc_convention(convention)
 
+    def __repr__(self) -> str:
+        return (
+            f"{self.__class__.__name__} {self.shape}, "
+            f"px_size {self.px_size} um, binning {self.binning}, "
+            f"tilt {self.tilt}, azimuthal {self.azimuthal}, pc {tuple(self.pc_average)}"
+        )
+
     @property
     def specimen_scintillator_distance(self) -> float:
         """Specimen to scintillator distance (SSD), known in EMsoft as
@@ -355,69 +362,6 @@ def r_max(self) -> np.ndarray:
         corners[..., 3] = self.x_min ** 2 + self.y_min ** 2  # Lo. left
         return np.atleast_2d(np.sqrt(np.max(corners, axis=-1)))
 
-    def __repr__(self) -> str:
-        return (
-            f"{self.__class__.__name__} {self.shape}, "
-            f"px_size {self.px_size} um, binning {self.binning}, "
-            f"tilt {self.tilt}, azimuthal {self.azimuthal}, pc {tuple(self.pc_average)}"
-        )
-
-    def _set_pc_convention(self, convention: Optional[str] = None):
-        if convention is None or convention.lower() == "bruker":
-            pass
-        elif convention.lower() in ["tsl", "edax", "amatek"]:
-            self.pc = self._pc_tsl2bruker()
-        elif convention.lower() == "oxford":
-            self.pc = self._pc_tsl2bruker()
-        elif convention.lower() in ["emsoft", "emsoft4", "emsoft5"]:
-            try:
-                version = int(convention[-1])
-            except ValueError:
-                version = 5
-            self.pc = self._pc_emsoft2bruker(version=version)
-        else:
-            conventions = [
-                "bruker",
-                "emsoft",
-                "emsoft4",
-                "emsoft5",
-                "oxford",
-                "tsl",
-            ]
-            raise ValueError(
-                f"Projection center convention '{convention}' not among the "
-                f"recognised conventions {conventions}."
-            )
-
-    def _pc_emsoft2bruker(self, version: int = 5) -> np.ndarray:
-        new_pc = np.zeros_like(self.pc, dtype=np.float32)
-        if version == 5:
-            new_pc[..., 0] = 0.5 + (-self.pcx / (self.ncols * self.binning))
-        else:
-            new_pc[..., 0] = 0.5 + (self.pcx / (self.ncols * self.binning))
-        new_pc[..., 1] = 0.5 - (self.pcy / (self.nrows * self.binning))
-        new_pc[..., 2] = self.pcz / (self.nrows * self.px_size * self.binning)
-        return new_pc
-
-    def _pc_tsl2bruker(self) -> np.ndarray:
-        new_pc = deepcopy(self.pc)
-        new_pc[..., 1] = 1 - self.pcy
-        return new_pc
-
-    def _pc_bruker2emsoft(self, version: int = 5) -> np.ndarray:
-        new_pc = np.zeros_like(self.pc, dtype=np.float32)
-        new_pc[..., 0] = self.ncols * (self.pcx - 0.5)
-        if version == 5:
-            new_pc[..., 0] = -new_pc[..., 0]
-        new_pc[..., 1] = self.nrows * (0.5 - self.pcy)
-        new_pc[..., 2] = self.nrows * self.px_size * self.pcz
-        return new_pc * self.binning
-
-    def _pc_bruker2tsl(self) -> np.ndarray:
-        new_pc = deepcopy(self.pc)
-        new_pc[..., 1] = 1 - self.pcy
-        return new_pc
-
     def pc_emsoft(self, version: int = 5) -> np.ndarray:
         """Return PC in the EMsoft convention.
 
@@ -618,3 +562,61 @@ def plot(
 
         if return_fig_ax:
             return fig, ax
+
+    # ------------------------ Private methods ----------------------- #
+
+    def _set_pc_convention(self, convention: Optional[str] = None):
+        if convention is None or convention.lower() == "bruker":
+            pass
+        elif convention.lower() in ["tsl", "edax", "amatek"]:
+            self.pc = self._pc_tsl2bruker()
+        elif convention.lower() == "oxford":
+            self.pc = self._pc_tsl2bruker()
+        elif convention.lower() in ["emsoft", "emsoft4", "emsoft5"]:
+            try:
+                version = int(convention[-1])
+            except ValueError:
+                version = 5
+            self.pc = self._pc_emsoft2bruker(version=version)
+        else:
+            conventions = [
+                "bruker",
+                "emsoft",
+                "emsoft4",
+                "emsoft5",
+                "oxford",
+                "tsl",
+            ]
+            raise ValueError(
+                f"Projection center convention '{convention}' not among the "
+                f"recognised conventions {conventions}."
+            )
+
+    def _pc_emsoft2bruker(self, version: int = 5) -> np.ndarray:
+        new_pc = np.zeros_like(self.pc, dtype=np.float32)
+        if version == 5:
+            new_pc[..., 0] = 0.5 + (-self.pcx / (self.ncols * self.binning))
+        else:
+            new_pc[..., 0] = 0.5 + (self.pcx / (self.ncols * self.binning))
+        new_pc[..., 1] = 0.5 - (self.pcy / (self.nrows * self.binning))
+        new_pc[..., 2] = self.pcz / (self.nrows * self.px_size * self.binning)
+        return new_pc
+
+    def _pc_tsl2bruker(self) -> np.ndarray:
+        new_pc = deepcopy(self.pc)
+        new_pc[..., 1] = 1 - self.pcy
+        return new_pc
+
+    def _pc_bruker2emsoft(self, version: int = 5) -> np.ndarray:
+        new_pc = np.zeros_like(self.pc, dtype=np.float32)
+        new_pc[..., 0] = self.ncols * (self.pcx - 0.5)
+        if version == 5:
+            new_pc[..., 0] = -new_pc[..., 0]
+        new_pc[..., 1] = self.nrows * (0.5 - self.pcy)
+        new_pc[..., 2] = self.nrows * self.px_size * self.pcz
+        return new_pc * self.binning
+
+    def _pc_bruker2tsl(self) -> np.ndarray:
+        new_pc = deepcopy(self.pc)
+        new_pc[..., 1] = 1 - self.pcy
+        return new_pc
diff --git a/kikuchipy/indexing/__init__.py b/kikuchipy/indexing/__init__.py
index 2418bc8d..cb21e319 100644
--- a/kikuchipy/indexing/__init__.py
+++ b/kikuchipy/indexing/__init__.py
@@ -26,9 +26,17 @@
 from kikuchipy.indexing._merge_crystal_maps import merge_crystal_maps
 from kikuchipy.indexing.orientation_similarity_map import orientation_similarity_map
 from kikuchipy.indexing import similarity_metrics
+from kikuchipy.indexing._refinement._refinement import (
+    compute_refine_orientation_results,
+    compute_refine_orientation_projection_center_results,
+    compute_refine_projection_center_results,
+)
 from kikuchipy.indexing._static_pattern_matching import StaticPatternMatching
 
 __all__ = [
+    "compute_refine_orientation_results",
+    "compute_refine_orientation_projection_center_results",
+    "compute_refine_projection_center_results",
     "merge_crystal_maps",
     "orientation_similarity_map",
     "similarity_metrics",
diff --git a/kikuchipy/indexing/_merge_crystal_maps.py b/kikuchipy/indexing/_merge_crystal_maps.py
index edfe8369..11690fbd 100644
--- a/kikuchipy/indexing/_merge_crystal_maps.py
+++ b/kikuchipy/indexing/_merge_crystal_maps.py
@@ -22,7 +22,7 @@
 
 import numpy as np
 from orix.crystal_map import CrystalMap, PhaseList
-from orix.quaternion.rotation import Rotation
+from orix.quaternion import Rotation
 
 from kikuchipy.indexing.similarity_metrics import SimilarityMetric, _SIMILARITY_METRICS
 
diff --git a/kikuchipy/indexing/_refinement.py b/kikuchipy/indexing/_refinement.py
deleted file mode 100644
index e4d6d9ee..00000000
--- a/kikuchipy/indexing/_refinement.py
+++ /dev/null
@@ -1,1719 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2019-2021 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 sys
-from typing import Optional, Union
-
-import dask
-from dask.diagnostics import ProgressBar
-import numba
-import numpy as np
-from orix.crystal_map import CrystalMap
-from orix.quaternion import Rotation
-import scipy.optimize
-
-from kikuchipy.pattern import rescale_intensity
-
-
-def full_refinement(
-    xmap: CrystalMap,
-    master_pattern,
-    signal,
-    detector,
-    energy: Union[int, float],
-    mask: Optional[np.ndarray] = None,
-    method: Optional[str] = None,
-    method_kwargs: Optional[dict] = None,
-    trust_region: Optional[list] = None,
-    compute: bool = True,
-) -> tuple:
-    """Performs an orientation and projection center refinement
-    using the best orientation, for each point, from the initial indexing
-    results stored in a single phase
-    :class:`~orix.crystal_map.CrystalMap` and the projection center
-    estimates stored in an
-    :class:`~kikuchipy.detectors.EBSDDetector`.
-
-    Parameters
-    ----------
-    xmap : CrystalMap
-        A crystal map storing the results of the initial EBSD
-        indexing.
-    master_pattern : EBSDMasterPattern
-        EBSDMasterPattern in the square Lambert projection.
-    signal : EBSD
-        Experimental EBSD data.
-    detector : EBSDDetecor
-        EBSD detector describing the detector dimensions and the
-        detector-sample geometry with either a single, fixed
-        projection/pattern center or a projection center for each
-        scan point.
-    energy : int
-        Acceleration voltage, in kV, used to simulate the desired
-        master pattern.
-    mask : np.ndarray, optional
-        Boolean mask to be applied to the simulated patterns. True values are
-        masked away.
-    method : str, optional
-        Name of the scipy.optimize function to be used. Must be one
-        of "minimize", "differential_evolution", "dual_annealing",
-        or "basinhopping". If not specified, "minimize" is used.
-    method_kwargs : dict, optional
-        Keyword arguments passed to the scipy.optimize function
-        specified above.
-    trust_region : list, optional
-        List of how wide the bounds, centered on the initial
-        orientation indexing result and projection center,
-        should be for (phi1, Phi, phi2) in degrees and
-        (PCx, PCy, PCz) in the Bruker convention.
-        Only used for methods that support bounds
-        (excluding Powell). Defaults to [1, 1, 1, 0.05, 0.05, 0.05]
-    compute : bool, optional
-        Whether to return a computed result, by default True.
-        For more information see :func:`~dask.array.Array.compute`.
-
-    Returns
-    -------
-    CrystalMap, EBSDDetector
-        A crystal map with the refined orientations and a new
-        EBSD detector with refined projection centers.
-    """
-    method, method_kwargs = _get_method(method, method_kwargs)
-
-    # Convert from Quaternions to Euler angles
-    euler = Rotation.to_euler(xmap.rotations)
-
-    # Extract best rotation from xmap if given more than 1
-    if xmap.rotations_per_point > 1:
-        euler = euler[:, 0, :]
-
-    if not trust_region:
-        trust_region = [
-            0.0174532925,
-            0.0174532925,
-            0.0174532925,
-            0.05,
-            0.05,
-            0.05,
-        ]
-    else:
-        trust_region = np.deg2rad(trust_region[:3]).tolist() + trust_region[3:]
-
-    if signal.data.dtype != np.float32:
-        exp = signal.deepcopy()
-        exp.rescale_intensity(dtype_out=np.float32)
-    else:
-        exp = signal
-
-    exp_data = exp.data
-    exp_shape = exp_data.shape
-
-    pc = detector.pc
-
-    # Set the PC equal across the scan if not given
-    if len(pc) == 1:
-        pc_val = pc[0]
-        pc = np.full((exp_shape[0] * exp_shape[1], 3), pc_val)
-
-    # 2D nav-dim
-    if len(exp_shape) == 4:
-        exp_data = exp_data.reshape(
-            (exp_shape[0] * exp_shape[1], exp_shape[2], exp_shape[3])
-        )
-    elif len(exp_shape) == 2:  # 0D nav-dim
-        exp_data = exp_data.reshape(((1,) + exp_data.shape))
-
-    (
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-    ) = _get_single_pattern_params(master_pattern, energy)
-
-    theta_c = np.deg2rad(detector.tilt)
-    sigma = np.deg2rad(detector.sample_tilt)
-    alpha = (np.pi / 2) - sigma + theta_c
-
-    detector_data = [detector.ncols, detector.nrows, detector.px_size, alpha]
-
-    if mask is None:
-        mask = 1
-    else:
-        mask = ~mask
-
-    pre_args = (
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-        detector_data,
-        mask,
-    )
-
-    to_print = [
-        "Refinement info:",
-        f"method={method.__name__}",
-        f"method kwargs={method_kwargs}",
-    ]
-
-    if method.__name__ not in ["minimize", "basinhopping"]:
-        to_print.append(f"trust region={trust_region}")
-
-    pre_args = dask.delayed(pre_args)
-    trust_region = dask.delayed(trust_region)
-
-    if isinstance(exp_data, dask.array.core.Array):
-        patterns_in_chunk = exp_data.chunks[0]
-        partitons = exp_data.to_delayed()  # List of delayed objects
-        # equal to the number of chunks
-        inner_index = 0
-        refined_params = []
-        for k, part in enumerate(partitons):
-            data = part[0, 0]
-            num_patterns = patterns_in_chunk[k]
-            for i in range(num_patterns):
-                res = dask.delayed(_full_refinement_solver)(
-                    euler[i + inner_index],
-                    pc[i + inner_index],
-                    data[i],
-                    pre_args,
-                    method,
-                    method_kwargs,
-                    trust_region,
-                )
-                refined_params.append(res)
-
-            inner_index += num_patterns  # Increase the index for
-            # the next chunk
-    else:  # NumPy array
-        refined_params = [
-            dask.delayed(_full_refinement_solver)(
-                euler[i],
-                pc[i],
-                exp_data[i],
-                pre_args,
-                method,
-                method_kwargs,
-                trust_region,
-            )
-            for i in range(euler.shape[0])
-        ]
-    if compute:
-        with ProgressBar():
-            print(
-                "\n".join([str(pr) for pr in to_print]),
-                f"\nRefining {xmap.size} orientations and " f"projection " f"centers:",
-                file=sys.stdout,
-            )
-            results = dask.compute(*refined_params, scheduler="threads")
-            refined_euler = np.empty((euler.shape[0], 3), dtype=np.float32)
-            refined_pc = np.empty((euler.shape[0], 3), dtype=np.float32)
-            refined_scores = np.empty((euler.shape[0]), dtype=np.float32)
-            for i in range(euler.shape[0]):
-                refined_scores[i] = results[i][0]
-
-                refined_euler[i][0] = results[i][1]
-                refined_euler[i][1] = results[i][2]
-                refined_euler[i][2] = results[i][3]
-
-                refined_pc[i][0] = results[i][4]
-                refined_pc[i][1] = results[i][5]
-                refined_pc[i][2] = results[i][6]
-
-            new_det = detector.deepcopy()
-            new_det.pc = refined_pc
-            refined_rotations = Rotation.from_euler(refined_euler)
-            xmap_dict = xmap.__dict__
-
-            output = CrystalMap(
-                rotations=refined_rotations,
-                phase_id=xmap_dict["_phase_id"],
-                x=xmap_dict["_x"],
-                y=xmap_dict["_y"],
-                phase_list=xmap_dict["phases"],
-                prop={
-                    "scores": refined_scores,
-                },
-                is_in_data=xmap_dict["is_in_data"],
-                scan_unit=xmap_dict["scan_unit"],
-            )
-    else:
-        output = dask.delayed(refined_params)
-        new_det = -1
-    return output, new_det
-
-
-def refine_orientations(
-    xmap: CrystalMap,
-    master_pattern,
-    signal,
-    detector,
-    energy: Union[int, float],
-    mask: Optional[np.ndarray] = None,
-    method: Optional[str] = None,
-    method_kwargs: Optional[dict] = None,
-    trust_region: Optional[list] = None,
-    compute: bool = True,
-) -> CrystalMap:
-    """Performs an orientation refinement using the best orientation,
-    for each point, from the initial indexing results stored in a single phase
-    :class:`~orix.crystal_map.CrystalMap` and the fixed
-    detector-sample geometry.
-
-    Parameters
-    ----------
-    xmap : CrystalMap
-        A crystal map storing the results of the initial EBSD
-    master_pattern : EBSDMasterPattern
-        EBSDMasterPattern in the square Lambert projection.
-    signal : EBSD
-        Experimental EBSD data.
-    detector : EBSDDetector
-        EBSD detector describing the detector dimensions and the
-        detector-sample geometry with either a single, fixed
-        projection/pattern center or a projection center for each
-        scan point.
-    energy : int
-       Acceleration voltage, in kV, used to simulate the desired
-        master pattern.
-    mask : np.ndarray
-        Boolean mask to be applied to the simulated patterns. True values are
-        masked away.
-    method : str, optional
-        Name of the scipy.optimize function to be used. Must be one
-        of "minimize", "differential_evolution", "dual_annealing",
-        or "basinhopping". If not specified, "minimize" is used.
-    method_kwargs : dict, optional
-        Keyword arguments passed to the scipy.optimize function
-        specified above.
-    trust_region : list, optional
-        List of how wide the bounds, centered on the initial
-        orientation indexing result, should be for
-        (phi1, Phi, phi2) in degrees. Only used for methods that
-        support bounds (excluding Powell). Defaults to [1, 1, 1].
-    compute : bool
-        Whether to return a computed result, by default True.
-        For more information see :func:`~dask.array.Array.compute`
-
-    Returns
-    -------
-    CrystalMap
-        A new crystal map where the orientations have been
-        refined.
-    """
-    method, method_kwargs = _get_method(method, method_kwargs)
-
-    # Convert from Quaternions to Euler angles
-    euler = Rotation.to_euler(xmap.rotations)
-
-    # Extract best rotation from xmap if given more than 1
-    if xmap.rotations_per_point > 1:
-        euler = euler[:, 0, :]
-
-    if signal.data.dtype != np.float32:
-        exp = signal.deepcopy()
-        exp.rescale_intensity(dtype_out=np.float32)
-    else:
-        exp = signal
-    exp_data = exp.data
-    exp_shape = exp_data.shape
-
-    if len(exp_shape) == 4:
-        exp_data = exp_data.reshape(
-            (exp_shape[0] * exp_shape[1], exp_shape[2], exp_shape[3])
-        )
-    elif len(exp_shape) == 2:  # 0D nav-dim
-        exp_data = exp_data.reshape(((1,) + exp_data.shape))
-
-    if not trust_region:
-        trust_region = [0.0174532925, 0.0174532925, 0.0174532925]  # 1 deg
-    else:
-        trust_region = np.deg2rad(trust_region)
-
-    scan_points = exp_data.shape[0]
-
-    theta_c = np.deg2rad(detector.tilt)
-    sigma = np.deg2rad(detector.sample_tilt)
-    alpha = (np.pi / 2) - sigma + theta_c
-
-    dncols = detector.ncols
-    dnrows = detector.nrows
-    px_size = detector.px_size
-
-    pc_emsoft = detector.pc_emsoft()
-    if len(pc_emsoft) == 1:
-        xpc = np.full(scan_points, pc_emsoft[..., 0])
-        ypc = np.full(scan_points, pc_emsoft[..., 1])
-        L = np.full(scan_points, pc_emsoft[..., 2])
-    else:
-        xpc = pc_emsoft[..., 0]
-        ypc = pc_emsoft[..., 1]
-        L = pc_emsoft[..., 2]
-
-    (
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-    ) = _get_single_pattern_params(master_pattern, energy)
-
-    if mask is None:
-        mask = 1
-    else:
-        mask = ~mask
-
-    pre_args = (
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-        mask,
-    )
-
-    to_print = [
-        "Refinement info:",
-        f"method={method.__name__}",
-        f"method kwargs={method_kwargs}",
-    ]
-
-    if method.__name__ not in ["minimize", "basinhopping"]:
-        to_print.append(f"trust region={trust_region}")
-
-    pre_args = dask.delayed(pre_args)
-    trust_region = dask.delayed(trust_region)
-
-    if isinstance(exp_data, dask.array.core.Array):
-        patterns_in_chunk = exp_data.chunks[0]
-        partitons = exp_data.to_delayed()  # List of delayed objects
-        # equal to the number of chunks
-        inner_index = 0
-        refined_params = []
-        for k, part in enumerate(partitons):
-            data = part[0, 0]
-            num_patterns = patterns_in_chunk[k]
-
-            dc = dask.delayed(_fast_get_dc_multiple_pc)(
-                xpc[inner_index : num_patterns + inner_index],
-                ypc[inner_index : num_patterns + inner_index],
-                L[inner_index : num_patterns + inner_index],
-                num_patterns,
-                dncols,
-                dnrows,
-                px_size,
-                alpha,
-            )
-
-            for i in range(num_patterns):
-                res = dask.delayed(_refine_orientations_solver)(
-                    data[i],
-                    euler[inner_index + i],
-                    dc[i],
-                    method,
-                    method_kwargs,
-                    pre_args,
-                    trust_region,
-                )
-                refined_params.append(res)
-
-            inner_index += num_patterns  # Increase the index for
-            # the next chunk
-
-    else:  # numpy array
-        dc = _fast_get_dc_multiple_pc(
-            xpc, ypc, L, scan_points, dncols, dnrows, px_size, alpha
-        )
-
-        refined_params = [
-            dask.delayed(_refine_orientations_solver)(
-                exp_data[i],
-                euler[i],
-                dc[i],
-                method,
-                method_kwargs,
-                pre_args,
-                trust_region,
-            )
-            for i in range(euler.shape[0])
-        ]
-
-    if compute:
-        with ProgressBar():
-            print(
-                "\n".join([str(pr) for pr in to_print]),
-                f"\nRefining {xmap.size} orientations:",
-                file=sys.stdout,
-            )
-            results = dask.compute(*refined_params)
-            refined_euler = np.empty((xmap.size, 3), dtype=np.float32)
-            refined_scores = np.empty((xmap.size), dtype=np.float32)
-            for i in range(xmap.size):
-                refined_scores[i] = results[i][0]
-
-                refined_euler[i][0] = results[i][1]
-                refined_euler[i][1] = results[i][2]
-                refined_euler[i][2] = results[i][3]
-
-            refined_rotations = Rotation.from_euler(refined_euler)
-
-            xmap_dict = xmap.__dict__
-
-            output = CrystalMap(
-                rotations=refined_rotations,
-                phase_id=xmap_dict["_phase_id"],
-                x=xmap_dict["_x"],
-                y=xmap_dict["_y"],
-                phase_list=xmap_dict["phases"],
-                prop={
-                    "scores": refined_scores,
-                },
-                is_in_data=xmap_dict["is_in_data"],
-                scan_unit=xmap_dict["scan_unit"],
-            )
-    else:
-        output = dask.delayed(refined_params)
-    return output
-
-
-def refine_projection_center(
-    xmap: CrystalMap,
-    master_pattern,
-    signal,
-    detector,
-    energy: Union[int, float],
-    mask: Optional[np.array] = None,
-    method: Optional[str] = None,
-    method_kwargs: Optional[dict] = None,
-    trust_region: Optional[list] = None,
-    compute: bool = True,
-) -> tuple:
-    """Performs a projection center refinement using the
-    fixed indexing results stored in a single phase
-    :class:`~orix.crystal_map.CrystalMap` and the projection center
-    estimates stored in an
-    :class:`~kikuchipy.detectors.EBSDDetector`.
-
-    Parameters
-    ----------
-    xmap : CrystalMap
-        A crystal map storing the results of the initial EBSD
-        indexing.
-    master_pattern : EBSDMasterPattern
-        EBSDMasterPattern in the square Lambert projection.
-    signal : EBSD
-        Experimental EBSD data.
-    detector : EBSDDetector
-        EBSD detector describing the detector dimensions and the
-        detector-sample geometry with either a single, fixed
-        projection/pattern center or a projection center for each
-        scan point.
-    energy : int
-        Acceleration voltage, in kV, used to simulate the desired
-        master pattern.
-    mask : np.ndarray, optional
-        Boolean mask to be applied to the simulated patterns. True values are
-        masked away.
-    method : str, optional
-        Name of the scipy.optimize function to be used. Must be one
-        of "minimize", "differential_evolution", "dual_annealing",
-        or "basinhopping". If not specified, "minimize" is used.
-    method_kwargs : dict, optional
-        Keyword arguments passed to the scipy.optimize function
-        specified above.
-    trust_region : list, optional
-        List of how wide the bounds, centered on the projection
-        center, should be for (PCx, PCy, PCz) in Bruker convention.
-        Only used for methods that support bounds
-        (excluding Powell). Defaults to [0.05, 0.05, 0.05].
-    compute : bool
-        Whether to return a computed result, by default True.
-        For more information see :func:`~dask.array.Array.compute`.
-
-    Returns
-    -------
-    np.ndarray, EBSDDetector
-        An array containing the similarity metric after refinement,
-        and a new EBSD detector with refined projection centers.
-    """
-    method, method_kwargs = _get_method(method, method_kwargs)
-
-    # Extract best rotation from xmap if given more than 1
-    if len(xmap.rotations.shape) > 1:
-        r = xmap.rotations[:, 0].data
-    else:
-        r = xmap.rotations.data
-
-    if signal.data.dtype != np.float32:
-        exp = signal.deepcopy()
-        exp.rescale_intensity(dtype_out=np.float32)
-    else:
-        exp = signal
-    exp_data = exp.data
-    exp_shape = exp_data.shape
-
-    pc = detector.pc
-
-    # Set the PC equal across the scan if not given
-    if len(pc) == 1:
-        pc_val = pc[0]
-        pc = np.full((exp_shape[0] * exp_shape[1], 3), pc_val)
-
-    # 2D nav-dim
-    if len(exp_shape) == 4:
-        exp_data = exp_data.reshape(
-            (exp_shape[0] * exp_shape[1], exp_shape[2], exp_shape[3])
-        )
-    elif len(exp_shape) == 2:  # 0D nav-dim
-        exp_data = exp_data.reshape(((1,) + exp_data.shape))
-
-    if not trust_region:
-        trust_region = [0.05, 0.05, 0.05]
-
-    (
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-    ) = _get_single_pattern_params(master_pattern, energy)
-
-    theta_c = np.deg2rad(detector.tilt)
-    sigma = np.deg2rad(detector.sample_tilt)
-    alpha = (np.pi / 2) - sigma + theta_c
-
-    detector_data = [detector.ncols, detector.nrows, detector.px_size, alpha]
-
-    if mask is None:
-        mask = 1
-    else:
-        mask = ~mask
-
-    pre_args = (
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-        detector_data,
-        mask,
-    )
-    to_print = [
-        "Refinement info:",
-        f"method={method.__name__}",
-        f"method kwargs={method_kwargs}",
-    ]
-
-    if method.__name__ not in ["minimize", "basinhopping"]:
-        to_print.append(f"trust region={trust_region}")
-
-    pre_args = dask.delayed(pre_args)
-    trust_region = dask.delayed(trust_region)
-
-    if isinstance(exp_data, dask.array.core.Array):
-        patterns_in_chunk = exp_data.chunks[0]
-        partitons = exp_data.to_delayed()  # List of delayed objects
-        # equal to the number of chunks
-        inner_index = 0
-        refined_params = []
-        for k, part in enumerate(partitons):
-            data = part[0, 0]
-            num_patterns = patterns_in_chunk[k]
-            for i in range(num_patterns):
-                res = dask.delayed(_refine_pc_solver)(
-                    data[i],
-                    r[i + inner_index],
-                    pc[i + inner_index],
-                    method,
-                    method_kwargs,
-                    pre_args,
-                    trust_region,
-                )
-                refined_params.append(res)
-
-            inner_index += num_patterns  # Increase the index for
-            # the next chunk
-    else:  # NumPy array
-        refined_params = [
-            dask.delayed(_refine_pc_solver)(
-                exp_data[i],
-                r[i],
-                pc[i],
-                method,
-                method_kwargs,
-                pre_args,
-                trust_region,
-            )
-            for i in range(xmap.size)
-        ]
-
-    output = refined_params
-    if compute:
-        with ProgressBar():
-            print(
-                "\n".join([str(pr) for pr in to_print]),
-                f"\nRefining {xmap.size} projection centers:",
-                file=sys.stdout,
-            )
-            results = dask.compute(*refined_params)
-
-            refined_pc = np.empty((xmap.size, 3), dtype=np.float32)
-            refined_scores = np.empty((xmap.size), dtype=np.float32)
-            for i in range(xmap.size):
-                refined_scores[i] = results[i][0]
-
-                refined_pc[i][0] = results[i][1]
-                refined_pc[i][1] = results[i][2]
-                refined_pc[i][2] = results[i][3]
-
-            new_det = detector.deepcopy()
-            new_det.pc = refined_pc
-
-            output = (refined_scores, new_det)
-
-    return output
-
-
-def _get_single_pattern_params(mp, energy):
-    """Helper function that checks if the master pattern is in the
-    square Lambert projection.
-
-    Parameters
-    ----------
-    mp
-      Master pattern in the square Lambert projection.
-    energy
-        Acceleration voltage, in kV, used to simulate the desired
-        master pattern.
-
-    Returns
-    -------
-    master_north, master_south
-        Northern and southern hemisphere of the master pattern in
-        the square Lambert projection.
-    npx, npy
-        Number of pixels on the master pattern in the x and y direction.
-    scale
-        Factor to scale up from the square Lambert projection to the
-        master pattern.
-    """
-
-    if mp.projection != "lambert":
-        raise NotImplementedError(
-            "Master pattern must be in the square Lambert projection"
-        )
-
-    # Get the master pattern arrays created by a desired energy
-    north_slice = ()
-    if "energy" in [i.name for i in mp.axes_manager.navigation_axes]:
-        energies = mp.axes_manager["energy"].axis
-        north_slice += ((np.abs(energies - energy)).argmin(),)
-    south_slice = north_slice
-    if mp.hemisphere == "both":
-        north_slice = (0,) + north_slice
-        south_slice = (1,) + south_slice
-    elif not mp.phase.point_group.contains_inversion:
-        raise AttributeError(
-            "For crystals of point groups without inversion symmetry, like "
-            f"the current {mp.phase.point_group.name}, both hemispheres "
-            "must be present in the master pattern signal"
-        )
-    master_north = mp.data[north_slice]
-    master_south = mp.data[south_slice]
-    npx, npy = mp.axes_manager.signal_shape
-    scale = (npx - 1) / 2
-
-    if master_north.dtype != np.float32:
-        master_north = rescale_intensity(master_north, dtype_out=np.float32)
-        master_south = rescale_intensity(master_south, dtype_out=np.float32)
-
-    return master_north, master_south, npx, npy, scale
-
-
-### NUMBA FUNCTIONS ###
-@numba.njit(nogil=True)
-def _fast_get_dc_multiple_pc(
-    xpc: np.ndarray,
-    ypc: np.ndarray,
-    L: np.ndarray,
-    scan_points: int,
-    ncols: int,
-    nrows: int,
-    px_size: Union[int, float],
-    alpha: float,
-) -> np.ndarray:
-    """Get the direction cosines between the detector and sample, with
-     varying projection center.
-     Based on :func:`~kikuchipy.indexing.refinement._fast_get_dc`.
-
-    Parameters
-    ----------
-    xpc, ypc, L
-        Projection center coordinates in the EMsoft convention for
-        each scan point.
-    scan_points
-        Number of patterns in the scan.
-    ncols, nrows
-        Number of pixels in the x- and y-direction on the detector.
-    px_size
-        Pixel size in um.
-    alpha
-        Defined as (np.pi / 2) - sigma + theta_c, where sigma is the
-        sample tilt and theta_c is the detector tilt.
-
-    Returns
-    -------
-        Direction cosines unit vectors for each detector pixel.
-    """
-    nrows = int(nrows)
-    ncols = int(ncols)
-
-    ca = np.cos(alpha)
-    sa = np.sin(alpha)
-
-    # 1 DC per scan point
-    r_g_array = np.zeros((scan_points, nrows, ncols, 3), dtype=np.float32)
-    for k in range(scan_points):
-        det_x = -1 * ((-xpc[k] - (1.0 - ncols) * 0.5) - np.arange(0, ncols)) * px_size
-        det_y = ((ypc[k] - (1.0 - nrows) * 0.5) - np.arange(0, nrows)) * px_size
-        L2 = L[k]
-        for i in range(nrows):
-            for j in range(ncols):
-                x = det_y[nrows - i - 1] * ca + sa * L2
-                y = det_x[j]
-                z = -sa * det_y[nrows - i - 1] + ca * L2
-                r_g_array[k][i][j][0] = x
-                r_g_array[k][i][j][1] = y
-                r_g_array[k][i][j][2] = z
-
-    norm = np.sqrt(np.sum(np.square(r_g_array), axis=-1))
-    norm = np.expand_dims(norm, axis=-1)
-    r_g_array = r_g_array / norm
-
-    return r_g_array
-
-
-@numba.njit(nogil=True)
-def _fast_get_dc(
-    xpc: Union[int, float],
-    ypc: Union[int, float],
-    L: Union[int, float],
-    ncols: int,
-    nrows: int,
-    px_size: Union[int, float],
-    alpha: float,
-) -> np.ndarray:
-    """Get the direction cosines between the detector and sample, with
-     a single, fixed projction center, as done in EMsoft
-     and :cite:`callahan2013dynamical`.
-
-    Parameters
-    ----------
-    xpc, ypc, L
-        Projection center coordinates in the EMsoft convention.
-    ncols, nrows
-        Number of pixels in the x- and y-direction on the detector.
-    px_size
-        Pixel size in um.
-    alpha
-        Defined as (np.pi / 2) - sigma + theta_c, where sigma is the
-        sample tilt and theta_c is the detector tilt.
-
-    Returns
-    -------
-        Direction cosines unit vectors for each detector pixel.
-    """
-    # alpha: alpha = (np.pi / 2) - sigma + theta_c
-    # Detector coordinates in microns
-    nrows = int(nrows)
-    ncols = int(ncols)
-    det_x = -1 * ((-xpc - (1.0 - ncols) * 0.5) - np.arange(0, ncols)) * px_size
-    det_y = ((ypc - (1.0 - nrows) * 0.5) - np.arange(0, nrows)) * px_size
-
-    # Auxilliary angle to rotate between reference frames
-
-    ca = np.cos(alpha)
-    sa = np.sin(alpha)
-
-    r_g_array = np.zeros((nrows, ncols, 3), dtype=np.float32)
-
-    for i in range(nrows):
-        for j in range(ncols):
-            x = det_y[nrows - i - 1] * ca + sa * L
-            y = det_x[j]
-            z = -sa * det_y[nrows - i - 1] + ca * L
-            r_g_array[i][j][0] = x
-            r_g_array[i][j][1] = y
-            r_g_array[i][j][2] = z
-
-    norm = np.sqrt(np.sum(np.square(r_g_array), axis=-1))
-    norm = np.expand_dims(norm, axis=-1)
-    r_g_array = r_g_array / norm
-
-    return r_g_array
-
-
-@numba.njit(nogil=True)
-def _fast_simulate_single_pattern(
-    r: np.ndarray,
-    dc: np.ndarray,
-    master_north: np.ndarray,
-    master_south: np.ndarray,
-    npx: int,
-    npy: int,
-    scale: Union[int, float],
-) -> np.ndarray:
-    """Simulates a single EBSD pattern for a given rotation. The pattern
-    is found by bi-quadratic interpolation of the master pattern as
-    described in EMsoft.
-
-    Parameters
-    ----------
-    r
-        Rotation represented by a unit quaternion.
-    dc
-        Direction cosines unit vector between detector and sample.
-    master_north, master_south
-        Northern and southern hemisphere of the master pattern in
-        the square Lambert projection.
-    npx, npy
-        Number of pixels on the master pattern in the x and y direction.
-    scale
-        Factor to scale up from the square Lambert projection to the
-        master pattern.
-
-    Returns
-    -------
-    pattern
-        Simulated EBSD pattern.
-    """
-
-    # From orix.quaternion.Quaternion.__mul__
-
-    a = r[0]
-    b = r[1]
-    c = r[2]
-    d = r[3]
-
-    x = dc[..., 0]
-    y = dc[..., 1]
-    z = dc[..., 2]
-
-    x_new = (a ** 2 + b ** 2 - c ** 2 - d ** 2) * x + 2 * (
-        (a * c + b * d) * z + (b * c - a * d) * y
-    )
-    y_new = (a ** 2 - b ** 2 + c ** 2 - d ** 2) * y + 2 * (
-        (a * d + b * c) * x + (c * d - a * b) * z
-    )
-    z_new = (a ** 2 - b ** 2 - c ** 2 + d ** 2) * z + 2 * (
-        (a * b + c * d) * y + (b * d - a * c) * x
-    )
-    rotated_dc = np.stack((x_new, y_new, z_new), axis=-1)
-
-    (
-        nii,
-        nij,
-        niip,
-        nijp,
-        di,
-        dj,
-        dim,
-        djm,
-    ) = _fast_get_lambert_interpolation_parameters(
-        rotated_direction_cosines=rotated_dc,
-        npx=npx,
-        npy=npy,
-        scale=scale,
-    )
-    pattern = np.zeros(shape=rotated_dc.shape[0:-1], dtype=np.float32)
-    for i in range(rotated_dc.shape[0]):
-        for j in range(rotated_dc.shape[1]):
-            _nii = nii[i][j]
-            _nij = nij[i][j]
-            _niip = niip[i][j]
-            _nijp = nijp[i][j]
-            _di = di[i][j]
-            _dj = dj[i][j]
-            _dim = dim[i][j]
-            _djm = djm[i][j]
-            if rotated_dc[..., 2][i][j] >= 0:
-                pattern[i][j] = (
-                    master_north[_nii, _nij] * _dim * _djm
-                    + master_north[_niip, _nij] * _di * _djm
-                    + master_north[_nii, _nijp] * _dim * _dj
-                    + master_north[_niip, _nijp] * _di * _dj
-                )
-            else:
-                pattern[i][j] = (
-                    master_south[_nii, _nij] * _dim * _djm
-                    + master_south[_niip, _nij] * _di * _djm
-                    + master_south[_nii, _nijp] * _dim * _dj
-                    + master_south[_niip, _nijp] * _di * _dj
-                )
-    return pattern
-
-
-@numba.njit(nogil=True)
-def _fast_lambert_projection(
-    v: np.ndarray,
-) -> np.ndarray:
-    """Lambert projection of a vector as described in
-    :cite:`callahan2013dynamical`.
-
-    Parameters
-    ----------
-    v
-        Rotated direction cosines with Cartesian coordinates.
-
-    Returns
-    -------
-    lambert
-        Rotated direction cosines in the square Lambert projection.
-    """
-    w = np.atleast_2d(v)
-    norm = np.sqrt(np.sum(np.square(w), axis=-1))
-    norm = np.expand_dims(norm, axis=-1)
-    w = w / norm
-
-    x = w[..., 0]
-    y = w[..., 1]
-    z = w[..., 2]
-
-    # Arrays used in both setting X and Y
-    sqrt_z = np.sqrt(2 * (1 - np.abs(z)))
-    sign_x = np.sign(x)
-    sign_y = np.sign(y)
-    abs_yx = np.abs(y) <= np.abs(x)
-
-    # Reusable constants
-    sqrt_pi = np.sqrt(np.pi)
-    sqrt_pi_half = sqrt_pi / 2
-    two_over_sqrt_pi = 2 / sqrt_pi
-
-    # Ensure (0, 0) is returned where |z| = 1
-    lambert = np.zeros(x.shape + (2,), dtype=np.float32)
-    # z_not_one = np.abs(z) != 1
-
-    # I believe it currently returns invalid results for the
-    # vector [0, 0, 1] as discussed in
-    # https://github.com/pyxem/kikuchipy/issues/272
-
-    # Numba does not support the fix implemented in the main code,
-    # one workaround could be to implement a standard loop setting
-    # the values
-
-    # Equations (10a) and (10b) from Callahan and De Graef (2013)
-    lambert[..., 0] = np.where(
-        abs_yx,
-        sign_x * sqrt_z * sqrt_pi_half,
-        sign_y * sqrt_z * (two_over_sqrt_pi * np.arctan(x / y)),
-    )
-    lambert[..., 1] = np.where(
-        abs_yx,
-        sign_x * sqrt_z * (two_over_sqrt_pi * np.arctan(y / x)),
-        sign_y * sqrt_z * sqrt_pi_half,
-    )
-    return lambert
-
-
-@numba.njit(nogil=True)
-def _fast_get_lambert_interpolation_parameters(
-    rotated_direction_cosines: np.ndarray,
-    npx: int,
-    npy: int,
-    scale: Union[int, float],
-) -> tuple:
-    """Get interpolation parameters in the square Lambert projection, as
-    implemented in EMsoft.
-
-    Parameters
-    ----------
-    rotated_direction_cosines
-        Rotated direction cosines vector.
-    npx, npy
-        Number of pixels on the master pattern in the x and y direction.
-    scale
-        Factor to scale up from the square Lambert projection to the
-        master pattern.
-
-    Returns
-    -------
-    nii, nij
-        Row and column coordinate of a point.
-    niip, nijp
-        Row and column coordinate of neighboring point.
-    di, dim, dj, djm
-        Row and column interpolation weight factors.
-    """
-
-    xy = (
-        scale
-        * _fast_lambert_projection(rotated_direction_cosines)
-        / (np.sqrt(np.pi / 2))
-    )
-
-    i = xy[..., 1]
-    j = xy[..., 0]
-    nii = (i + scale).astype(np.int32)
-    nij = (j + scale).astype(np.int32)
-    niip = nii + 1
-    nijp = nij + 1
-    niip = np.where(niip < npx, niip, nii).astype(np.int32)
-    nijp = np.where(nijp < npy, nijp, nij).astype(np.int32)
-    nii = np.where(nii < 0, niip, nii).astype(np.int32)
-    nij = np.where(nij < 0, nijp, nij).astype(np.int32)
-    di = i - nii + scale
-    dj = j - nij + scale
-    dim = 1.0 - di
-    djm = 1.0 - dj
-
-    return nii, nij, niip, nijp, di, dj, dim, djm
-
-
-# OBJECTIVE FUNCTIONS
-def _orientation_objective_function_euler(
-    x: np.ndarray,
-    *args: tuple,
-) -> float:
-    """Objective function to be minimized when optimizing euler angles
-    (phi1, Phi, phi2).
-
-    Parameters
-    ----------
-    x
-        1-D array containing the current (phi1, Phi, phi2)
-        in radians.
-    args
-        Tuple of fixed parameters needed completely specify
-        the function.
-
-    Returns
-    -------
-        Objective function value.
-    """
-    experimental = args[0]
-    master_north = args[1]
-    master_south = args[2]
-    npx = args[3]
-    npy = args[4]
-    scale = args[5]
-    mask = args[6]
-    dc = args[7]
-
-    rotation = _rotation_from_euler(x[0], x[1], x[2])
-
-    sim_pattern = _fast_simulate_single_pattern(
-        rotation,
-        dc,
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-    )
-
-    sim_pattern = sim_pattern * mask
-
-    result = _py_ncc(experimental, sim_pattern)
-    return 1 - result
-
-
-def _projection_center_objective_function(
-    x: np.ndarray,
-    *args: tuple,
-) -> float:
-    """Objective function to be minimized when optimizing the projection
-    center coordinates (PCx, PCy, PCz).
-
-    Parameters
-    ----------
-    x
-        1-D array containing the current (PCx, PCy, PCz) in the
-        Bruker convention.
-    args
-        Tuple of fixed parameters needed completely specify
-        the function.
-
-    Returns
-    -------
-        Objective function value.
-    """
-    x_star = x[0]
-    y_star = x[1]
-    z_star = x[2]
-
-    experimental = args[0]
-    master_north = args[1]
-    master_south = args[2]
-    npx = args[3]
-    npy = args[4]
-    scale = args[5]
-    detector_data = args[6]
-    mask = args[7]
-    rotation = args[8]
-
-    detector_ncols = detector_data[0]
-    detector_nrows = detector_data[1]
-    detector_px_size = detector_data[2]
-    alpha = detector_data[3]
-
-    xpc = detector_ncols * (x_star - 0.5)  # Might be sign issue here?
-    xpc = -xpc
-    ypc = detector_nrows * (0.5 - y_star)
-    L = detector_nrows * detector_px_size * z_star
-
-    dc = _fast_get_dc(
-        xpc, ypc, L, detector_ncols, detector_nrows, detector_px_size, alpha
-    )
-
-    sim_pattern = _fast_simulate_single_pattern(
-        rotation,
-        dc,
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-    )
-
-    sim_pattern = sim_pattern * mask
-
-    result = _py_ncc(experimental, sim_pattern)
-    return 1 - result
-
-
-def _full_objective_function_euler(x: np.ndarray, *args: tuple) -> float:
-    """Objective function to be minimized when optimizing euler angles
-    (phi1, Phi, phi2) and projection center coordinates (PCx, PCy, PCz).
-
-    Parameters
-    ----------
-    x
-        1-D array containing the current (phi1, Phi, phi2) in radians
-        and (PCx, PCy, PCz) in Bruker convention.
-    args
-        Tuple of fixed parameters needed completely specify
-        the function.
-
-    Returns
-    -------
-        Objective function value.
-    """
-    experimental = args[0]
-    master_north = args[1]
-    master_south = args[2]
-    npx = args[3]
-    npy = args[4]
-    scale = args[5]
-    detector_data = args[6]
-    mask = args[7]
-
-    detector_ncols = detector_data[0]
-    detector_nrows = detector_data[1]
-    detector_px_size = detector_data[2]
-
-    rotation = _rotation_from_euler(x[0], x[1], x[2])
-
-    x_star = x[3]
-    y_star = x[4]
-    z_star = x[5]
-
-    xpc = detector_ncols * (x_star - 0.5)  # Might be sign issue here?
-    xpc = -xpc
-    ypc = detector_nrows * (0.5 - y_star)
-    L = detector_nrows * detector_px_size * z_star
-
-    alpha2 = detector_data[3]  # Different alpha
-    dc = _fast_get_dc(
-        xpc, ypc, L, detector_ncols, detector_nrows, detector_px_size, alpha2
-    )
-
-    sim_pattern = _fast_simulate_single_pattern(
-        rotation,
-        dc,
-        master_north,
-        master_south,
-        npx,
-        npy,
-        scale,
-    )
-    sim_pattern = sim_pattern * mask
-
-    result = _py_ncc(experimental, sim_pattern)
-    return 1 - result
-
-
-# SOLVERS
-def _full_refinement_solver(
-    r: np.ndarray,
-    pc: np.ndarray,
-    exp: np.ndarray,
-    pre_args: tuple,
-    method: callable,
-    method_kwargs: dict,
-    trust_region: list,
-) -> tuple:
-    """Maximizes the similarity between an experimental pattern and
-    a simulated pattern by optimizing the euler angles (phi1, Phi, phi2)
-    and the projection center coordinates (PCx, PCy, PCz).
-
-    Parameters
-    ----------
-    r
-        Euler angles (phi1, Phi, phi2) in radians.
-    pc
-        Projection center coordinates in Bruker convention.
-    exp
-        One experimental pattern with shape (n x m) and data type
-        float 32.
-    pre_args
-        Tuple of fixed parameters used for single pattern
-        simulations.
-    method
-        scipy.optimize function.
-    method_kwargs
-        Keyword arguments for the specific scipy.optimize function.
-        For the list of possible keyword arguments, see
-        the scipy documentation.
-    trust_region
-        List of how wide the bounds, centered on r and pc,
-        should be for (phi1, Phi, phi2, PCx, PCy, PCz).
-        Only used for methods that support bounds (excluding Powell).
-
-    Returns
-    -------
-        score
-            Highest NCC value.
-        phi1, Phi , phi2
-            The euler angles which gave the highest score, in radians.
-        pcx, pxy, pxz
-            The projection center coordinates which gave the highest
-            score, in the Bruker convention.
-    """
-    phi1_0 = r[..., 0]
-    Phi_0 = r[..., 1]
-    phi2_0 = r[..., 2]
-    eu_x0 = np.array((phi1_0, Phi_0, phi2_0))
-
-    args = (exp,) + pre_args
-
-    full_x0 = np.concatenate((eu_x0, pc), axis=None)
-
-    if method.__name__ == "minimize":
-        soln = method(
-            _full_objective_function_euler,
-            x0=full_x0,
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "differential_evolution":
-        soln = method(
-            _full_objective_function_euler,
-            bounds=[
-                (full_x0[0] - trust_region[0], full_x0[0] + trust_region[0]),
-                (full_x0[1] - trust_region[1], full_x0[1] + trust_region[1]),
-                (full_x0[2] - trust_region[2], full_x0[2] + trust_region[2]),
-                (full_x0[3] - trust_region[3], full_x0[3] + trust_region[3]),
-                (full_x0[4] - trust_region[4], full_x0[4] + trust_region[4]),
-                (full_x0[5] - trust_region[5], full_x0[5] + trust_region[5]),
-            ],
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "dual_annealing":
-        soln = method(
-            _full_objective_function_euler,
-            bounds=[
-                (full_x0[0] - trust_region[0], full_x0[0] + trust_region[0]),
-                (full_x0[1] - trust_region[1], full_x0[1] + trust_region[1]),
-                (full_x0[2] - trust_region[2], full_x0[2] + trust_region[2]),
-                (full_x0[3] - trust_region[3], full_x0[3] + trust_region[3]),
-                (full_x0[4] - trust_region[4], full_x0[4] + trust_region[4]),
-                (full_x0[5] - trust_region[5], full_x0[5] + trust_region[5]),
-            ],
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "basinhopping":
-        method_kwargs["minimizer_kwargs"]["args"] = args
-        soln = method(
-            _full_objective_function_euler,
-            x0=full_x0,
-            **method_kwargs,
-        )
-
-    score = 1 - soln.fun
-    phi1 = soln.x[0]
-    Phi = soln.x[1]
-    phi2 = soln.x[2]
-    pcx = soln.x[3]
-    pxy = soln.x[4]
-    pxz = soln.x[5]
-
-    return (score, phi1, Phi, phi2, pcx, pxy, pxz)
-
-
-def _refine_pc_solver(
-    exp: np.ndarray,
-    r: np.ndarray,
-    pc: np.ndarray,
-    method: callable,
-    method_kwargs: dict,
-    pre_args: tuple,
-    trust_region: list,
-) -> tuple:
-    """Maximizes the similarity between an experimental pattern and a
-    simulated pattern by optimizing the projection center
-    coordinates (PCx, PCy, PCz) used in the simulation.
-
-    Parameters
-    ----------
-    exp
-        One experimental pattern with shape (n x m) and data type
-        float 32.
-    r
-        Euler angles (phi1, Phi, phi2) in radians.
-    pc
-        Projection center coordinates in Bruker convention.
-    method
-        scipy.optimize function.
-    method_kwargs
-        Keyword arguments for the specific scipy.optimize function.
-        For the list of possible keyword arguments, see
-        the scipy documentation.
-    pre_args
-        Tuple of fixed parameters used for single pattern
-        simulations.
-    trust_region
-        List of how wide the bounds, centered pc, should be for
-        (PCx, PCy, PCz). Only used for methods that support bounds
-        (excluding Powell).
-
-    Returns
-    -------
-        score
-            Highest NCC value.
-        pcx, pxy, pxz
-            The projection center coordinates which gave the highest
-            score, in the Bruker convention.
-    """
-    args = (exp,) + pre_args + (r,)
-    pc_x0 = pc
-
-    if method.__name__ == "minimize":
-        soln = method(
-            _projection_center_objective_function,
-            x0=pc_x0,
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "differential_evolution":
-        soln = method(
-            _projection_center_objective_function,
-            bounds=[
-                (pc_x0[0] - trust_region[0], pc_x0[0] + trust_region[0]),
-                (pc_x0[1] - trust_region[1], pc_x0[1] + trust_region[1]),
-                (pc_x0[2] - trust_region[2], pc_x0[2] + trust_region[2]),
-            ],
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "dual_annealing":
-        soln = method(
-            _projection_center_objective_function,
-            bounds=[
-                (pc_x0[0] - trust_region[0], pc_x0[0] + trust_region[0]),
-                (pc_x0[1] - trust_region[1], pc_x0[1] + trust_region[1]),
-                (pc_x0[2] - trust_region[2], pc_x0[2] + trust_region[2]),
-            ],
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "basinhopping":
-        method_kwargs["minimizer_kwargs"]["args"] = args
-        soln = method(
-            _projection_center_objective_function,
-            x0=pc_x0,
-            **method_kwargs,
-        )
-
-    score = 1 - soln.fun
-    pcx = soln.x[0]
-    pcy = soln.x[1]
-    pcz = soln.x[2]
-    return (score, pcx, pcy, pcz)
-
-
-def _refine_orientations_solver(
-    exp: np.ndarray,
-    r: np.ndarray,
-    dc: np.ndarray,
-    method: callable,
-    method_kwargs: dict,
-    pre_args: tuple,
-    trust_region: list,
-) -> tuple:
-    """Maximizes the similarity between an experimental pattern and a
-    simulated pattern by optimizing the euler angles (phi1, Phi, phi2)
-    used in the simulation.
-
-    Parameters
-    ----------
-    exp
-        One experimental pattern with shape (n x m) and data type
-        float 32.
-    r
-        Euler angles (phi1, Phi, phi2) in radians.
-    dc
-        Direction cosines with shape (n x m) and data type float 32.
-    method
-        scipy.optimize function.
-    method_kwargs
-        Keyword arguments for the specific scipy.optimize function.
-        For the list of possible keyword arguments,
-        see the scipy documentation.
-    pre_args
-        Tuple of fixed parameters used for single pattern
-        simulations.
-    trust_region
-        List of how wide the bounds, centered on r, should be for
-        (phi1, Phi, phi2). Only used for methods that
-        support bounds (excluding Powell).
-
-    Returns
-    -------
-        score
-            Highest NCC value.
-        phi1, Phi , phi2
-            The euler angles which gave the highest score, in radians.
-    """
-
-    phi1 = r[..., 0]
-    Phi = r[..., 1]
-    phi2 = r[..., 2]
-
-    args = (exp,) + pre_args + (dc,)
-
-    r_x0 = np.array((phi1, Phi, phi2), dtype=np.float32)
-
-    if method.__name__ == "minimize":
-        soln = method(
-            _orientation_objective_function_euler,
-            x0=r_x0,
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "differential_evolution":
-        soln = method(
-            _orientation_objective_function_euler,
-            bounds=[
-                (r_x0[0] - trust_region[0], r_x0[0] + trust_region[0]),
-                (r_x0[1] - trust_region[1], r_x0[1] + trust_region[1]),
-                (r_x0[2] - trust_region[2], r_x0[2] + trust_region[2]),
-            ],
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "dual_annealing":
-        soln = method(
-            _orientation_objective_function_euler,
-            bounds=[
-                (r_x0[0] - trust_region[0], r_x0[0] + trust_region[0]),
-                (r_x0[1] - trust_region[1], r_x0[1] + trust_region[1]),
-                (r_x0[2] - trust_region[2], r_x0[2] + trust_region[2]),
-            ],
-            args=args,
-            **method_kwargs,
-        )
-    elif method.__name__ == "basinhopping":
-        method_kwargs["minimizer_kwargs"]["args"] = args
-        soln = method(
-            _orientation_objective_function_euler,
-            x0=r_x0,
-            **method_kwargs,
-        )
-
-    score = 1 - soln.fun
-    refined_phi1 = soln.x[0]
-    refined_Phi = soln.x[1]
-    refined_phi2 = soln.x[2]
-
-    return (score, refined_phi1, refined_Phi, refined_phi2)
-
-
-# SIMILARITY METRICS
-@numba.njit(fastmath=True)
-def _py_ncc(a: np.ndarray, b: np.ndarray) -> float:
-    """Calculates the normalized cross-correlation coefficient (NCC)
-    between two patterns of the same size and with data type float32.
-
-    Parameters
-    ----------
-    a, b
-        np.ndarray with shape (n x m) and with data type np.float32.
-
-    Returns
-    -------
-        float representing the NCC between a and b.
-    """
-    abar = np.mean(a)
-    bbar = np.mean(b)
-    astar = a - abar
-    bstar = b - bbar
-    return np.sum(astar * bstar) / np.sqrt(
-        np.sum(np.square(astar)) * np.sum(np.square(bstar))
-    )
-
-
-def _refinement_parameter_check(exp, xmap, detector, method, mask):
-    """Helper function to determine if the input to the different EBSD
-    refinement methods are valid.
-
-    Parameters
-    ----------
-    exp
-        Experimental data
-    xmap
-        CrystalMap containing the indexing result.
-    detector
-        EBSDDetector describing the experimental geometry.
-    method
-        Name of the scipy optimization method.
-    mask
-        Mask applied to the patterns.
-
-    Raises
-    ------
-    ValueError
-        If one of the parameters are invalid.
-    """
-    # Signal and Detector must have same shape
-    if exp.axes_manager.signal_shape[::-1] != detector.shape:
-        raise ValueError("Detector must have same shape as the signal shape")
-
-    # Minimization strategy must be supported
-    # Invalid local minimizers will be caught by the scipy functions
-    if method is not None:
-        supported_methods = [
-            "minimize",
-            "differential_evolution",
-            "dual_annealing",
-            "basinhopping",
-        ]
-        if method not in supported_methods:
-            raise ValueError("Method not supported")
-
-    scan_points = exp.axes_manager.navigation_size
-    if scan_points == 0:
-        scan_points = 1
-
-    # Must have 1 PC or n x m PCs
-    if len(detector.pc) != 1 and len(detector.pc) != scan_points:
-        raise ValueError(
-            "Detector must have exactly one projection center, or one projection center per scan point"
-        )
-
-    # xmap must be single phase
-    if len(xmap.phases.ids) != 1:
-        raise ValueError("Crystal map must have exactly one phase")
-
-    # Same number of rotations in xmap as scan points
-    if xmap.size != scan_points:
-        raise ValueError(
-            "Number of rotations in crystal map must be equal to the number of experimental patterns"
-        )
-
-    # mask must fit pattern
-    if mask is not None:
-        if exp.axes_manager.signal_shape != mask.shape:
-            raise ValueError("Mask and signal must have the same shape")
-
-
-def _get_method(method, method_kwargs):
-    """Helper function that gets the correct optimization function,
-    and sets reasonable keyword arguments if not specified.
-
-    Parameters
-    ----------
-    method : str, optional
-        Name of the scipy optimization method
-    method_kwargs : dict, optional
-        Keyword arguments for function
-
-    Returns
-    -------
-    method : function
-        Scipy optimization function.
-    method_kwargs : dict
-        Keyword arguments for function.
-    """
-    if not method:
-        method = "minimize"
-    if method == "minimize" and not method_kwargs:
-        method_kwargs = {"method": "Nelder-Mead"}
-    elif not method_kwargs:
-        method_kwargs = {}
-    method = getattr(scipy.optimize, method)
-
-    return method, method_kwargs
-
-
-@numba.njit(fastmath=True)
-def _rotation_from_euler(phi1, Phi, phi2):
-    """Converts the three Euler angles (phi1, Phi, phi2), in radians, to a
-    unit quaternion.
-
-    Parameters
-    ----------
-    phi1, Phi, phi2
-        Euler angles in radians.
-
-    Returns
-    -------
-    q
-        Unit quaternion representing the same rotation.
-    """
-    # From Orix.rotation.from_euler()
-    alpha = phi1
-    beta = Phi
-    gamma = phi2
-
-    sigma = 0.5 * np.add(alpha, gamma)
-    delta = 0.5 * np.subtract(alpha, gamma)
-    c = np.cos(beta / 2)
-    s = np.sin(beta / 2)
-
-    # Using P = 1 from A.6
-    q = np.zeros(4, dtype=np.float32)
-    q[0] = c * np.cos(sigma)
-    q[1] = -s * np.cos(delta)
-    q[2] = -s * np.sin(delta)
-    q[3] = -c * np.sin(sigma)
-
-    condition = q[0] < 0
-
-    if condition:
-        q[1] = -q[1]
-        q[2] = -q[2]
-        q[3] = -q[3]
-        q[0] = -q[0]
-
-    return q
diff --git a/kikuchipy/indexing/_refinement/__init__.py b/kikuchipy/indexing/_refinement/__init__.py
new file mode 100644
index 00000000..2d6525bc
--- /dev/null
+++ b/kikuchipy/indexing/_refinement/__init__.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
+
+"""Private tools for refinement of crystal orientations and projection
+centers by optimizing the similarity between experimental and simulated
+patterns.
+
+This module and documentation is only relevant for kikuchipy developers,
+not for users.
+
+.. warning:
+    This module and its submodules are for internal use only.  Do not
+    use them in your own code. We may change the API at any time with no
+    warning.
+"""
+
+# fmt: off
+SUPPORTED_OPTIMIZATION_METHODS = {
+    # Local
+    "minimize": {
+        "type": "local",
+        "supports_bounds": True
+    },
+    # Global
+    "basinhopping": {
+        "type": "global",
+        "supports_bounds": False
+    },
+    "differential_evolution": {
+        "type": "global",
+        "supports_bounds": True
+    },
+    "dual_annealing": {
+        "type": "global",
+        "supports_bounds": True
+    },
+    "shgo": {
+        "type": "global",
+        "supports_bounds": True
+    },
+}
+# fmt: on
diff --git a/kikuchipy/indexing/_refinement/_objective_functions.py b/kikuchipy/indexing/_refinement/_objective_functions.py
new file mode 100644
index 00000000..e561f6fe
--- /dev/null
+++ b/kikuchipy/indexing/_refinement/_objective_functions.py
@@ -0,0 +1,205 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
+
+"""Objective functions for the refinement of crystal orientations and
+projection centers by optimizing the similarity between experimental
+and simulated patterns.
+"""
+
+import numpy as np
+
+from kikuchipy.indexing.similarity_metrics import _ncc_single_patterns_2d_float32
+from kikuchipy._rotation import _rotation_from_euler
+from kikuchipy.signals.util._master_pattern import (
+    _project_single_pattern_from_master_pattern,
+    _get_direction_cosines_for_single_pc,
+)
+
+
+def _refine_orientation_objective_function(x: np.ndarray, *args: tuple) -> float:
+    """Objective function to be minimized when optimizing an orientation
+    (Euler angles).
+
+    Parameters
+    ----------
+    x
+        1D array containing the current Euler angles (phi1, Phi, phi2)
+        in radians.
+    args
+        Tuple of fixed parameters needed to completely specify the
+        function. The expected contents are:
+            0. 2D experimental pattern of 32-bit floats
+            1. 1D direction cosines
+            2. 2D northern hemisphere of master pattern of 32-bit floats
+            3. 2D southern hemisphere of master pattern of 32-bit floats
+            4. Number of master pattern columns
+            5. Number of master pattern rows
+            6. Master pattern scale
+            7. 1D signal mask
+            8. Number of pattern pixels
+
+    Returns
+    -------
+        Objective function value (normalized cross-correlation score).
+    """
+    simulated_pattern = _project_single_pattern_from_master_pattern(
+        rotation=_rotation_from_euler(alpha=x[0], beta=x[1], gamma=x[2]),
+        direction_cosines=args[1],
+        master_north=args[2],
+        master_south=args[3],
+        npx=args[4],
+        npy=args[5],
+        scale=args[6],
+        n_pixels=args[8],
+        rescale=False,
+        out_min=0,  # Required, but not used here
+        out_max=1,  # Required, but not used here
+        dtype_out=np.float32,
+    )
+    simulated_pattern = simulated_pattern * args[7]  # Multiply by mask
+    experimental = args[0]
+    simulated_pattern = simulated_pattern.reshape(experimental.shape)
+    score = _ncc_single_patterns_2d_float32(exp=experimental, sim=simulated_pattern)
+    return 1 - score
+
+
+def _refine_projection_center_objective_function(x: np.ndarray, *args: tuple) -> float:
+    """Objective function to be minimized when optimizing projection
+    center (PC) parameters PCx, PCy, and PCz.
+
+    Parameters
+    ----------
+    x
+        1D array containing the current PC parameters (PCx, PCy, PCz).
+    args
+        Tuple of fixed parameters needed to completely specify the
+        function. The expected contents are:
+            0. 2D experimental pattern of 32-bit floats
+            1. 1D rotation as quaternion
+            2. 2D northern hemisphere of master pattern of 32-bit floats
+            3. 2D southern hemisphere of master pattern of 32-bit floats
+            4. Number of master pattern columns
+            5. Number of master pattern rows
+            6. Master pattern scale
+            7. 1D signal mask
+            8. Number of pattern pixels
+            9. Number of pattern rows
+            10. Number of pattern columns
+            11. Detector tilt
+            12. Detector azimuthal angle
+            13. Sample tilt
+
+    Returns
+    -------
+        Objective function value (normalized cross-correlation score).
+    """
+    n_pixels = args[8]
+    direction_cosines = _get_direction_cosines_for_single_pc(
+        pcx=x[0],
+        pcy=x[1],
+        pcz=x[2],
+        nrows=args[9],
+        ncols=args[10],
+        tilt=args[11],
+        azimuthal=args[12],
+        sample_tilt=args[13],
+    ).reshape((n_pixels, 3))
+    simulated_pattern = _project_single_pattern_from_master_pattern(
+        rotation=args[1],
+        direction_cosines=direction_cosines,
+        master_north=args[2],
+        master_south=args[3],
+        npx=args[4],
+        npy=args[5],
+        scale=args[6],
+        n_pixels=n_pixels,
+        rescale=False,
+        out_min=0,  # Required, but not used here
+        out_max=1,  # Required, but not used here
+        dtype_out=np.float32,
+    )
+    simulated_pattern = simulated_pattern * args[7]  # Multiply by mask
+    experimental = args[0]
+    simulated_pattern = simulated_pattern.reshape(experimental.shape)
+    score = _ncc_single_patterns_2d_float32(exp=experimental, sim=simulated_pattern)
+    return 1 - score
+
+
+def _refine_orientation_projection_center_objective_function(
+    x: np.ndarray, *args: tuple
+) -> float:
+    """Objective function to be minimized when optimizing orientations
+    and projection center (PC) parameters PCx, PCy, and PCz.
+
+    Parameters
+    ----------
+    x
+        1D array containing the current Euler angles (phi1, Phi, phi2)
+        and PC parameters (PCx, PCy, PCz).
+    args
+        Tuple of fixed parameters needed to completely specify the
+        function. The expected contents are:
+            0. 2D experimental pattern of 32-bit floats
+            1. 2D northern hemisphere of master pattern of 32-bit floats
+            2. 2D southern hemisphere of master pattern of 32-bit floats
+            3. Number of master pattern columns
+            4. Number of master pattern rows
+            5. Master pattern scale
+            6. 1D signal mask
+            7. Number of pattern pixels
+            8. Number of pattern rows
+            9. Number of pattern columns
+            10. Detector tilt
+            11. Detector azimuthal angle
+            12. Sample tilt
+
+    Returns
+    -------
+        Objective function value (normalized cross-correlation score).
+    """
+    rotation = _rotation_from_euler(alpha=x[0], beta=x[1], gamma=x[2])
+    n_pixels = args[7]
+    direction_cosines = _get_direction_cosines_for_single_pc(
+        pcx=x[3],
+        pcy=x[4],
+        pcz=x[5],
+        nrows=args[8],
+        ncols=args[9],
+        tilt=args[10],
+        azimuthal=args[11],
+        sample_tilt=args[12],
+    ).reshape((n_pixels, 3))
+    simulated_pattern = _project_single_pattern_from_master_pattern(
+        rotation=rotation,
+        direction_cosines=direction_cosines,
+        master_north=args[1],
+        master_south=args[2],
+        npx=args[3],
+        npy=args[4],
+        scale=args[5],
+        n_pixels=n_pixels,
+        rescale=False,
+        out_min=0,  # Required, but not used here
+        out_max=1,  # Required, but not used here
+        dtype_out=np.float32,
+    )
+    simulated_pattern = simulated_pattern * args[6]  # Multiply by mask
+    experimental = args[0]
+    simulated_pattern = simulated_pattern.reshape(experimental.shape)
+    score = _ncc_single_patterns_2d_float32(exp=experimental, sim=simulated_pattern)
+    return 1 - score
diff --git a/kikuchipy/indexing/_refinement/_refinement.py b/kikuchipy/indexing/_refinement/_refinement.py
new file mode 100644
index 00000000..0b8a0b91
--- /dev/null
+++ b/kikuchipy/indexing/_refinement/_refinement.py
@@ -0,0 +1,745 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
+
+"""Setup of refinement refinement of crystal orientations and projection
+centers by optimizing the similarity between experimental and simulated
+patterns.
+"""
+
+import gc
+import sys
+from typing import Callable, Optional, Tuple, Union
+
+import dask
+from dask.diagnostics import ProgressBar
+import dask.array as da
+import numpy as np
+from orix.crystal_map import CrystalMap, PhaseList
+from orix.quaternion import Rotation
+import scipy.optimize
+
+from kikuchipy.indexing._refinement._solvers import (
+    _refine_orientation_solver,
+    _refine_orientation_projection_center_solver,
+    _refine_projection_center_solver,
+)
+from kikuchipy.indexing._refinement import SUPPORTED_OPTIMIZATION_METHODS
+from kikuchipy.pattern import rescale_intensity
+from kikuchipy.signals.util._master_pattern import (
+    _get_direction_cosines_for_single_pc_from_detector,
+)
+
+
+DEFAULT_TRUST_REGION_EULER_DEG = np.ones(3)
+DEFAULT_TRUST_REGION_PC = np.full(3, 0.05)
+
+
+# Note about use of dask.delayed
+# ------------------------------
+# Private refinement functions might call dask.delayed on a Dask array,
+# specifically the experimental patterns. This is discouraged in the
+# Dask documentation:
+# https://docs.dask.org/en/latest/delayed-best-practices.html#don-t-call-dask-delayed-on-other-dask-collections
+# However, since only one pattern is passed to dask.delayed at a time,
+# this might be OK.
+
+
+def _refine_setup(
+    xmap: CrystalMap,
+    detector,
+    master_pattern,
+    energy: Union[int, float],
+    patterns_dtype: np.dtype,
+    mask: Optional[np.ndarray] = None,
+    method: Optional[str] = None,
+    method_kwargs: Optional[dict] = None,
+) -> Tuple[Callable, dict, tuple, int, tuple, int, int, int, Rotation, dict, tuple]:
+    """Set up and return everything that is common to all refinement
+    functions.
+    """
+    method, method_kwargs = _get_optimization_method_with_kwargs(method, method_kwargs)
+
+    # Get navigation shape and signal shape
+    nav_shape = xmap.shape
+    n_patterns = xmap.size
+    sig_shape = detector.shape
+    nrows, ncols = sig_shape
+    n_pixels = detector.size
+
+    # Get rotations in the correct shape
+    if xmap.rotations_per_point > 1:
+        rotations = xmap.rotations[:, 0]
+    else:
+        rotations = xmap.rotations
+
+    # Build up dictionary of keyword arguments to pass to the refinement
+    # solver
+    solver_kwargs = dict(method=method, method_kwargs=method_kwargs)
+
+    # Determine whether pattern intensities must be rescaled
+    dtype_desired = np.float32
+    if patterns_dtype != dtype_desired:
+        solver_kwargs["rescale"] = True
+    else:
+        solver_kwargs["rescale"] = False
+
+    # Prepare parameters for the objective function which are constant
+    # during optimization
+    fixed_parameters = _check_master_pattern_and_get_data(master_pattern, energy)
+    fixed_parameters += (_prepare_mask(mask),)
+    fixed_parameters += (n_pixels,)
+
+    return (
+        method,
+        method_kwargs,
+        nav_shape,
+        n_patterns,
+        sig_shape,
+        nrows,
+        ncols,
+        n_pixels,
+        rotations,
+        solver_kwargs,
+        fixed_parameters,
+    )
+
+
+def _refine_orientation(
+    xmap: CrystalMap,
+    detector,
+    master_pattern,
+    energy: Union[int, float],
+    patterns: Union[np.ndarray, da.Array],
+    mask: Optional[np.ndarray] = None,
+    method: Optional[str] = None,
+    method_kwargs: Optional[dict] = None,
+    trust_region: Union[None, np.ndarray, list] = None,
+    compute: bool = True,
+) -> CrystalMap:
+    """See the docstring of
+    :meth:`kikuchipy.signals.EBSD.refine_orientation`.
+    """
+    (
+        method,
+        method_kwargs,
+        nav_shape,
+        n_patterns,
+        sig_shape,
+        nrows,
+        ncols,
+        n_pixels,
+        rotations,
+        solver_kwargs,
+        fixed_parameters,
+    ) = _refine_setup(
+        xmap=xmap,
+        detector=detector,
+        master_pattern=master_pattern,
+        energy=energy,
+        patterns_dtype=patterns.dtype,
+        mask=mask,
+        method=method,
+        method_kwargs=method_kwargs,
+    )
+
+    # Get rotations in the correct shape
+    euler = rotations.to_euler()
+    euler = euler.reshape(nav_shape + (3,))
+
+    if trust_region is None:
+        trust_region = DEFAULT_TRUST_REGION_EULER_DEG
+        solver_kwargs["trust_region_passed"] = False
+    else:
+        solver_kwargs["trust_region_passed"] = True
+    solver_kwargs["trust_region"] = dask.delayed(np.deg2rad(trust_region))
+
+    # Prepare parameters for the objective function which are constant
+    # during optimization
+    solver_kwargs["fixed_parameters"] = dask.delayed(fixed_parameters)
+
+    # Determine whether a new PC is used for every pattern
+    new_pc = np.prod(detector.navigation_shape) != 1 and n_patterns > 1
+
+    # Delay data once
+    patterns = dask.delayed(patterns)
+    euler = dask.delayed(euler)
+
+    refined_parameters = []
+    if new_pc:
+        # Patterns have been indexed with varying PCs, so we
+        # re-compute the direction cosines for every pattern during
+        # refinement
+        pcx = detector.pcx.astype(float).reshape(nav_shape)
+        pcy = detector.pcy.astype(float).reshape(nav_shape)
+        pcz = detector.pcz.astype(float).reshape(nav_shape)
+        pcx = dask.delayed(pcx)
+        pcy = dask.delayed(pcy)
+        pcz = dask.delayed(pcz)
+        for idx in np.ndindex(*nav_shape):
+            delayed_solution = dask.delayed(_refine_orientation_solver)(
+                pattern=patterns[idx],
+                rotation=euler[idx],
+                pcx=pcx[idx],
+                pcy=pcy[idx],
+                pcz=pcz[idx],
+                nrows=nrows,
+                ncols=ncols,
+                tilt=detector.tilt,
+                azimuthal=detector.azimuthal,
+                sample_tilt=detector.sample_tilt,
+                **solver_kwargs,
+            )
+            refined_parameters.append(delayed_solution)
+    else:
+        # Patterns have been indexed with the same PC, so we use the
+        # same direction cosines during refinement of all patterns
+        dc = _get_direction_cosines_for_single_pc_from_detector(detector).reshape(
+            (n_pixels, 3)
+        )
+        for idx in np.ndindex(*nav_shape):
+            delayed_solution = dask.delayed(_refine_orientation_solver)(
+                pattern=patterns[idx],
+                rotation=euler[idx],
+                direction_cosines=dc,
+                **solver_kwargs,
+            )
+            refined_parameters.append(delayed_solution)
+
+    print(
+        _refinement_info_message(
+            method=method,
+            method_kwargs=method_kwargs,
+            trust_region=list(trust_region),
+            trust_region_passed=solver_kwargs["trust_region_passed"],
+        )
+    )
+    if compute:
+        output = compute_refine_orientation_results(
+            results=refined_parameters, xmap=xmap, master_pattern=master_pattern
+        )
+    else:
+        output = refined_parameters
+    gc.collect()
+
+    return output
+
+
+def _refine_projection_center(
+    xmap: CrystalMap,
+    detector,
+    master_pattern,
+    energy: Union[int, float],
+    patterns: Union[np.ndarray, da.Array],
+    mask: Optional[np.ndarray] = None,
+    method: Optional[str] = None,
+    method_kwargs: Optional[dict] = None,
+    trust_region: Union[None, np.ndarray, list] = None,
+    compute: bool = True,
+) -> tuple:
+    """See the docstring of
+    :meth:`kikuchipy.signals.EBSD.refine_projection_center`.
+    """
+    (
+        method,
+        method_kwargs,
+        nav_shape,
+        n_patterns,
+        sig_shape,
+        nrows,
+        ncols,
+        n_pixels,
+        rotations,
+        solver_kwargs,
+        fixed_parameters,
+    ) = _refine_setup(
+        xmap=xmap,
+        detector=detector,
+        master_pattern=master_pattern,
+        energy=energy,
+        patterns_dtype=patterns.dtype,
+        mask=mask,
+        method=method,
+        method_kwargs=method_kwargs,
+    )
+
+    # Get rotations in the correct shape
+    rotations = rotations.data
+    rotations = rotations.reshape(nav_shape + (4,))
+
+    if trust_region is None:
+        trust_region = DEFAULT_TRUST_REGION_PC
+        solver_kwargs["trust_region_passed"] = False
+    else:
+        solver_kwargs["trust_region_passed"] = True
+    solver_kwargs["trust_region"] = dask.delayed(trust_region)
+
+    # Prepare parameters for the objective function which are constant
+    # during optimization
+    fixed_parameters += (nrows,)
+    fixed_parameters += (ncols,)
+    fixed_parameters += (detector.tilt,)
+    fixed_parameters += (detector.azimuthal,)
+    fixed_parameters += (detector.sample_tilt,)
+    solver_kwargs["fixed_parameters"] = dask.delayed(fixed_parameters)
+
+    pc = _prepare_projection_centers(
+        detector=detector, n_patterns=n_patterns, nav_shape=nav_shape
+    )
+
+    # Delay data once
+    patterns = dask.delayed(patterns)
+    rotations = dask.delayed(rotations)
+    pc = dask.delayed(pc)
+
+    refined_parameters = []
+    for idx in np.ndindex(*nav_shape):
+        delayed_solution = dask.delayed(_refine_projection_center_solver)(
+            pattern=patterns[idx],
+            rotation=rotations[idx],
+            pc=pc[idx],
+            **solver_kwargs,
+        )
+        refined_parameters.append(delayed_solution)
+
+    print(
+        _refinement_info_message(
+            method=method,
+            method_kwargs=method_kwargs,
+            trust_region=list(trust_region),
+            trust_region_passed=solver_kwargs["trust_region_passed"],
+        )
+    )
+    if compute:
+        output = compute_refine_projection_center_results(
+            results=refined_parameters,
+            detector=detector,
+            xmap=xmap,
+        )
+    else:
+        output = refined_parameters
+    gc.collect()
+
+    return output
+
+
+def _refine_orientation_projection_center(
+    xmap: CrystalMap,
+    detector,
+    master_pattern,
+    energy: Union[int, float],
+    patterns: Union[np.ndarray, da.Array],
+    mask: Optional[np.ndarray] = None,
+    method: Optional[str] = None,
+    method_kwargs: Optional[dict] = None,
+    trust_region: Union[None, np.ndarray, list] = None,
+    compute: bool = True,
+) -> tuple:
+    """See the docstring of
+    :meth:`kikuchipy.signals.EBSD.refine_orientation_projection_center`.
+    """
+    (
+        method,
+        method_kwargs,
+        nav_shape,
+        n_patterns,
+        sig_shape,
+        nrows,
+        ncols,
+        n_pixels,
+        rotations,
+        solver_kwargs,
+        fixed_parameters,
+    ) = _refine_setup(
+        xmap=xmap,
+        detector=detector,
+        master_pattern=master_pattern,
+        energy=energy,
+        patterns_dtype=patterns.dtype,
+        mask=mask,
+        method=method,
+        method_kwargs=method_kwargs,
+    )
+
+    # Get rotations in the correct shape
+    euler = rotations.to_euler()
+    euler = euler.reshape(nav_shape + (3,))
+
+    if trust_region is None:
+        trust_region = np.concatenate(
+            [DEFAULT_TRUST_REGION_EULER_DEG, DEFAULT_TRUST_REGION_PC]
+        )
+        solver_kwargs["trust_region_passed"] = False
+    else:
+        solver_kwargs["trust_region_passed"] = True
+    trust_region[:3] = np.deg2rad(trust_region[:3])
+    solver_kwargs["trust_region"] = dask.delayed(trust_region)
+
+    # Prepare parameters for the objective function which are constant
+    # during optimization
+    fixed_parameters += (nrows,)
+    fixed_parameters += (ncols,)
+    fixed_parameters += (detector.tilt,)
+    fixed_parameters += (detector.azimuthal,)
+    fixed_parameters += (detector.sample_tilt,)
+    solver_kwargs["fixed_parameters"] = dask.delayed(fixed_parameters)
+
+    pc = _prepare_projection_centers(
+        detector=detector, n_patterns=n_patterns, nav_shape=nav_shape
+    )
+
+    # Stack Euler angles and PC parameters into one array of shape
+    # `nav_shape` + (6,)
+    euler_pc = np.dstack([euler, pc])
+
+    # Delay data once
+    patterns = dask.delayed(patterns)
+    euler_pc = dask.delayed(euler_pc)
+
+    refined_parameters = []
+    for idx in np.ndindex(*nav_shape):
+        delayed_solution = dask.delayed(_refine_orientation_projection_center_solver)(
+            pattern=patterns[idx],
+            euler_pc=euler_pc[idx],
+            **solver_kwargs,
+        )
+        refined_parameters.append(delayed_solution)
+
+    print(
+        _refinement_info_message(
+            method=method,
+            method_kwargs=method_kwargs,
+            trust_region=list(trust_region),
+            trust_region_passed=solver_kwargs["trust_region_passed"],
+        )
+    )
+    if compute:
+        output = compute_refine_orientation_projection_center_results(
+            results=refined_parameters,
+            detector=detector,
+            xmap=xmap,
+            master_pattern=master_pattern,
+        )
+    else:
+        output = refined_parameters
+    gc.collect()
+
+    return output
+
+
+def _get_optimization_method_with_kwargs(
+    method: str = "minimize", method_kwargs: Optional[dict] = None
+) -> Tuple[Callable, dict]:
+    """Return correct optimization function and reasonable keyword
+    arguments if not given.
+
+    Parameters
+    ----------
+    method
+        Name of a supported SciPy optimization method. See `method`
+        parameter in :meth:`kikuchipy.signals.EBSD.refine_orientation`.
+        Default is "minimize".
+    method_kwargs : dict, optional
+        Keyword arguments to pass to function.
+
+    Returns
+    -------
+    method
+        SciPy optimization function.
+    method_kwargs
+        Keyword arguments to pass to function.
+    """
+    supported_methods = list(SUPPORTED_OPTIMIZATION_METHODS)
+    if method not in supported_methods:
+        raise ValueError(
+            f"Method {method} not in the list of supported methods {supported_methods}"
+        )
+    if method_kwargs is None:
+        method_kwargs = {}
+    if method == "minimize" and "method" not in method_kwargs:
+        method_kwargs["method"] = "Nelder-Mead"
+    if method == "basinhopping" and "minimizer_kwargs" not in method_kwargs:
+        method_kwargs["minimizer_kwargs"] = {}
+    method = getattr(scipy.optimize, method)
+    return method, method_kwargs
+
+
+def _check_master_pattern_and_get_data(
+    master_pattern, energy: Union[int, float]
+) -> Tuple[np.ndarray, np.ndarray, int, int, float]:
+    """Check whether the master pattern is suitable for projection, and
+    return the northern and southern hemispheres along with their shape.
+
+    Parameters
+    ----------
+    master_pattern : kikuchipy.signals.EBSDMasterPattern
+      Master pattern in the square Lambert projection.
+    energy
+        Accelerating voltage of the electron beam in kV specifying which
+        master pattern energy to use during projection of simulated
+        patterns.
+
+    Returns
+    -------
+    master_north, master_south
+        Northern and southern hemispheres of master pattern of data type
+        32-bit float.
+    npx, npy
+        Number of columns and rows of the master pattern.
+    scale
+        Factor to scale up from the square Lambert projection to the
+        master pattern.
+    """
+    master_pattern._is_suitable_for_projection(raise_if_not=True)
+    (
+        master_north,
+        master_south,
+    ) = master_pattern._get_master_pattern_arrays_from_energy(energy=energy)
+    npx, npy = master_pattern.axes_manager.signal_shape
+    scale = (npx - 1) / 2
+    dtype_desired = np.float32
+    if master_north.dtype != dtype_desired:
+        master_north = rescale_intensity(master_north, dtype_out=dtype_desired)
+        master_south = rescale_intensity(master_south, dtype_out=dtype_desired)
+    return master_north, master_south, npx, npy, scale
+
+
+def _prepare_mask(mask: Optional[np.ndarray] = None) -> Union[int, np.ndarray]:
+    """Ensure a valid mask or 1 is returned.
+
+    Parameters
+    ----------
+    mask
+
+    Returns
+    -------
+    mask
+        Either a 1D array or 1.
+    """
+    if mask is None:
+        mask = 1
+    else:
+        mask = ~mask.ravel()
+    return mask
+
+
+def _prepare_projection_centers(
+    detector, n_patterns: int, nav_shape: tuple
+) -> np.ndarray:
+    """Return an array of projection center (PC) parameters of the
+    appropriate shape and data type.
+
+    Parameters
+    ----------
+    detector : ~kikuchipy.detectors.EBSDDetector
+        Detector with PCs.
+    n_patterns
+        Number of patterns to use in refinement.
+    nav_shape
+        Navigation shape of EBSD signal to use in refinement.
+
+    Returns
+    -------
+    pc
+        PC array of shape nav_shape + (3,) of data type 32-bit float.
+    """
+    # Determine whether a new PC is used for every pattern
+    new_pc = np.prod(detector.navigation_shape) != 1 and n_patterns > 1
+    if new_pc:
+        # Patterns have been indexed with varying PCs, so we use these
+        # as the starting point for every pattern
+        pc = detector.pc.astype(float).reshape(nav_shape + (3,))
+    else:
+        # Patterns have been indexed with the same PC, so we use this as
+        # the starting point for every pattern
+        pc = np.full(nav_shape + (3,), detector.pc[0], dtype=float)
+    return pc
+
+
+def _refinement_info_message(
+    method: Callable,
+    method_kwargs: dict,
+    trust_region: list,
+    trust_region_passed: bool,
+) -> str:
+    """Return a message with useful refinement information.
+
+    Parameters
+    ----------
+    method
+        SciPy optimization method.
+    method_kwargs
+        Keyword arguments to be passed to the optimization method.
+    trust_region
+        Trust region to use for bounds on parameters.
+    trust_region_passed
+        Whether a trust region was passed. Used when determining whether
+        to print the trust region.
+
+    Returns
+    -------
+    msg
+        Message with useful refinement information.
+    """
+    method_name = method.__name__
+    method_dict = SUPPORTED_OPTIMIZATION_METHODS[method_name]
+    if method_name == "minimize":
+        method_name = f"{method_kwargs['method']} (minimize)"
+    optimization_type = method_dict["type"]
+    msg = (
+        "Refinement information:\n"
+        f"\t{optimization_type.capitalize()} optimization method: {method_name}\n"
+        f"\tKeyword arguments passed to method: {method_kwargs}"
+    )
+    if trust_region_passed or (
+        optimization_type == "global" and method_dict["supports_bounds"]
+    ):
+        msg += f"\n\tTrust region: {trust_region}"
+    return msg
+
+
+def compute_refine_orientation_results(results: list, xmap: CrystalMap, master_pattern):
+    """Compute the results from
+    :meth:`~kikuchipy.signals.EBSD.refine_orientation` and return the
+    :class:`~orix.crystal_map.CrystalMap`.
+
+    Parameters
+    ----------
+    results
+        Results returned from `refine_orientation()`, which is a list of
+        :class:`~dask.delayed.Delayed`.
+    xmap
+        Crystal map passed to `refine_orientation()` to obtain
+        `results`.
+    master_pattern : ~kikuchipy.signals.EBSDMasterPattern
+        Master pattern passed to `refine_orientation()` to obtain
+        `results`.
+
+    Returns
+    -------
+    refined_xmap : :class:`~orix.crystal_map.CrystalMap`
+        Crystal map with refined orientations and scores.
+    """
+    n_patterns = len(results)
+    with ProgressBar():
+        print(f"Refining {n_patterns} orientation(s):", file=sys.stdout)
+        computed_results = dask.compute(*results)
+        # (n, score, phi1, Phi, phi2)
+        computed_results = np.array(computed_results)
+        xmap_refined = CrystalMap(
+            rotations=Rotation.from_euler(computed_results[:, 1:]),
+            phase_id=np.zeros(n_patterns),
+            x=xmap.x,
+            y=xmap.y,
+            phase_list=PhaseList(phases=master_pattern.phase),
+            prop=dict(scores=computed_results[:, 0]),
+            scan_unit=xmap.scan_unit,
+        )
+    return xmap_refined
+
+
+def compute_refine_projection_center_results(results: list, detector, xmap: CrystalMap):
+    """Compute the results from
+    :meth:`~kikuchipy.signals.EBSD.refine_projection_center` and return
+    the score array and :class:`~kikuchipy.detectors.EBSDDetector`.
+
+    Parameters
+    ----------
+    results
+        Results returned from `refine_projection_center()`, which is a
+        list of :class:`~dask.delayed.Delayed`.
+    detector : ~kikuchipy.detectors.EBSDDetector
+        Detector passed to `refine_projection_center()` to obtain
+        `results`.
+    xmap
+        Crystal map passed to `refine_projection_center()` to obtain
+        `results`.
+
+    Returns
+    -------
+    new_scores : :class:`numpy.ndarray`
+        Score array.
+    new_detector : :class:`~kikuchipy.detectors.EBSDDetector`
+        EBSD detector with refined projection center parameters.
+    """
+    n_patterns = len(results)
+    nav_shape = xmap.shape
+    with ProgressBar():
+        print(f"Refining {n_patterns} projection center(s):", file=sys.stdout)
+        computed_results = dask.compute(*results)
+        # (n, score, PCx, PCy, PCz)
+        computed_results = np.array(computed_results)
+        new_detector = detector.deepcopy()
+        new_detector.pc = computed_results[:, 1:].reshape(nav_shape + (3,))
+    return computed_results[:, 0].reshape(nav_shape), new_detector
+
+
+def compute_refine_orientation_projection_center_results(
+    results: list,
+    detector,
+    xmap: CrystalMap,
+    master_pattern,
+):
+    """Compute the results from
+    :meth:`~kikuchipy.signals.EBSD.refine_orientation_projection_center`
+    and return the :class:`~orix.crystal_map.CrystalMap` and
+    :class:`~kikuchipy.detectors.EBSDDetector`.
+
+    Parameters
+    ----------
+    results
+        Results returned from `refine_orientation_projection_center()`,
+        which is a list of :class:`~dask.delayed.Delayed`.
+    detector : ~kikuchipy.detectors.EBSDDetector
+        Detector passed to `refine_orientation_projection_center()` to
+        obtain `results`.
+    xmap
+        Crystal map passed to `refine_orientation_projection_center()`
+        to obtain `results`.
+    master_pattern : ~kikuchipy.signals.EBSDMasterPattern
+        Master pattern passed to
+        `refine_orientation_projection_center()` to obtain `results`.
+
+    Returns
+    -------
+    xmap_refined : :class:`~orix.crystal_map.CrystalMap`
+        Crystal map with refined orientations and scores.
+    new_detector : :class:`~kikuchipy.detectors.EBSDDetector`
+        EBSD detector with refined projection center parameters.
+    """
+    n_patterns = len(results)
+    nav_shape = xmap.shape
+    with ProgressBar():
+        print(
+            f"Refining {n_patterns} orientation(s) and projection center(s):",
+            file=sys.stdout,
+        )
+        computed_results = dask.compute(*results)
+        computed_results = np.array(computed_results)
+        # (n, score, phi1, Phi, phi2, PCx, PCy, PCz)
+        xmap_refined = CrystalMap(
+            rotations=Rotation.from_euler(computed_results[:, 1:4]),
+            phase_id=np.zeros(n_patterns),
+            x=xmap.x,
+            y=xmap.y,
+            phase_list=PhaseList(phases=master_pattern.phase),
+            prop=dict(scores=computed_results[:, 0]),
+            scan_unit=xmap.scan_unit,
+        )
+        new_detector = detector.deepcopy()
+        new_detector.pc = computed_results[:, 4:].reshape(nav_shape + (3,))
+    return xmap_refined, new_detector
diff --git a/kikuchipy/indexing/_refinement/_solvers.py b/kikuchipy/indexing/_refinement/_solvers.py
new file mode 100644
index 00000000..1cd225ff
--- /dev/null
+++ b/kikuchipy/indexing/_refinement/_solvers.py
@@ -0,0 +1,392 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
+
+"""Solvers for the refinement of crystal orientations and projection
+centers by optimizing the similarity between experimental and simulated
+patterns.
+"""
+
+from typing import Callable, Optional, Tuple
+
+import numba as nb
+import numpy as np
+from scipy.optimize import Bounds
+
+from kikuchipy.indexing._refinement._objective_functions import (
+    _refine_orientation_objective_function,
+    _refine_orientation_projection_center_objective_function,
+    _refine_projection_center_objective_function,
+)
+from kikuchipy.indexing._refinement import SUPPORTED_OPTIMIZATION_METHODS
+from kikuchipy.pattern._pattern import _rescale
+from kikuchipy.signals.util._master_pattern import _get_direction_cosines_for_single_pc
+
+
+def _refine_orientation_solver(
+    pattern: np.ndarray,
+    rescale: bool,
+    rotation: np.ndarray,
+    method: Callable,
+    method_kwargs: dict,
+    fixed_parameters: tuple,
+    trust_region: list,
+    trust_region_passed: bool,
+    direction_cosines: Optional[np.ndarray] = None,
+    pcx: Optional[float] = None,
+    pcy: Optional[float] = None,
+    pcz: Optional[float] = None,
+    nrows: Optional[int] = None,
+    ncols: Optional[int] = None,
+    tilt: Optional[float] = None,
+    azimuthal: Optional[float] = None,
+    sample_tilt: Optional[float] = None,
+) -> Tuple[float, float, float, float]:
+    """Maximize the similarity between an experimental pattern and a
+    projected simulated pattern by optimizing the orientation (Euler
+    angles) used in the projection.
+
+    Parameters
+    ----------
+    pattern
+        Experimental pattern of shape (nrows, ncols).
+    rescale
+        Whether pattern intensities must be rescaled to [-1, 1] and data
+        type 32-bit floats.
+    rotation
+        Euler angles (phi1, Phi, phi2) in radians.
+    method
+        A supported :mod:`scipy.optimize` function. See `method`
+        parameter in :meth:`kikuchipy.signals.EBSD.refine_orientation`.
+    method_kwargs
+        Keyword arguments passed to the `method` function. For the list
+        of possible arguments, see the SciPy documentation.
+    fixed_parameters
+        Fixed parameters used in the projection.
+    trust_region
+        List of +/- angular deviation in degrees as bound constraints on
+        the three Euler angles.
+    trust_region_passed
+        Whether `trust_region` was passed to the public refinement
+        method.
+    direction_cosines
+        Vector array of shape (n pixels, 3) and data type 32-bit floats.
+        If not given, `pcx`, `pcy`, `pcz`, `nrows`, `ncols`, `tilt`,
+        `azimuthal`, and `sample_tilt` must be passed.
+    pcx
+        Projection center (PC) x coordinate. Must be passed if
+        `direction_cosines` is not given.
+    pcy
+        PC y coordinate. Must be passed if `direction_cosines` is not
+        given.
+    pcz
+        PC z coordinate. Must be passed if `direction_cosines` is not
+        given.
+    nrows
+        Number of detector rows. Must be passed if `direction_cosines`
+        is not given.
+    ncols
+        Number of detector columns. Must be passed if
+        `direction_cosines` is not given.
+    tilt
+        Detector tilt from horizontal in degrees. Must be passed if
+        `direction_cosines` is not given.
+    azimuthal
+        Sample tilt about the sample RD axis in degrees. Must be passed
+        if `direction_cosines` is not given.
+    sample_tilt
+        Sample tilt from horizontal in degrees. Must be passed if
+        `direction_cosines` is not given.
+
+    Returns
+    -------
+    score
+        Highest normalized cross-correlation score.
+    phi1, Phi, phi2
+        Optimized orientation (Euler angles) in radians.
+    """
+    if rescale:
+        pattern = _rescale_pattern(pattern.astype(np.float32))
+
+    if direction_cosines is None:
+        direction_cosines = _get_direction_cosines_for_single_pc(
+            pcx=pcx,
+            pcy=pcy,
+            pcz=pcz,
+            nrows=nrows,
+            ncols=ncols,
+            tilt=tilt,
+            azimuthal=azimuthal,
+            sample_tilt=sample_tilt,
+        )
+        direction_cosines = direction_cosines.reshape((nrows * ncols, 3))
+
+    params = (pattern,) + (direction_cosines,) + fixed_parameters
+    method_name = method.__name__
+
+    if method_name == "minimize":
+        if trust_region_passed:
+            alpha_dev, beta_dev, gamma_dev = trust_region
+            alpha, beta, gamma = rotation
+            method_kwargs["bounds"] = Bounds(
+                [alpha - alpha_dev, beta - beta_dev, gamma - gamma_dev],
+                [alpha + alpha_dev, beta + beta_dev, gamma + gamma_dev],
+            )
+        solution = method(
+            fun=_refine_orientation_objective_function,
+            x0=rotation,
+            args=params,
+            **method_kwargs,
+        )
+    elif SUPPORTED_OPTIMIZATION_METHODS[method_name]["supports_bounds"]:
+        alpha_dev, beta_dev, gamma_dev = trust_region
+        alpha, beta, gamma = rotation
+        solution = method(
+            func=_refine_orientation_objective_function,
+            args=params,
+            bounds=[
+                [alpha - alpha_dev, alpha + alpha_dev],
+                [beta - beta_dev, beta + beta_dev],
+                [gamma - gamma_dev, gamma + gamma_dev],
+            ],
+            **method_kwargs,
+        )
+    else:  # Is always "basinhopping", due to prior check of method name
+        method_kwargs["minimizer_kwargs"].update(args=params)
+        solution = method(
+            func=_refine_orientation_objective_function,
+            x0=rotation,
+            **method_kwargs,
+        )
+
+    x = solution.x
+    return 1 - solution.fun, x[0], x[1], x[2]
+
+
+def _refine_projection_center_solver(
+    pattern: np.ndarray,
+    rescale: bool,
+    rotation: np.ndarray,
+    method: Callable,
+    method_kwargs: dict,
+    fixed_parameters: tuple,
+    trust_region: list,
+    trust_region_passed: bool,
+    pc: np.ndarray,
+) -> Tuple[float, float, float, float]:
+    """Maximize the similarity between an experimental pattern and a
+    projected simulated pattern by optimizing the projection center (PC)
+    parameters used in the projection.
+
+    Parameters
+    ----------
+    pattern
+        Experimental pattern of shape (nrows, ncols).
+    rescale
+        Whether pattern intensities must be rescaled to [-1, 1] and data
+        type 32-bit floats.
+    rotation
+        Rotation as a quaternion array with shape (4,).
+    method
+        A supported :mod:`scipy.optimize` function. See `method`
+        parameter in
+        :meth:`kikuchipy.signals.EBSD.refine_projection_center`.
+    method_kwargs
+        Keyword arguments passed to the `method` function. For the list
+        of possible arguments, see the SciPy documentation.
+    fixed_parameters
+        Fixed parameters used in the projection.
+    trust_region
+        List of +/- percentage deviations as bound constraints on
+        the PC parameters in the Bruker convention. The parameter
+        range is [0, 1].
+    trust_region_passed
+        Whether `trust_region` was passed to the public refinement
+        method.
+    pc
+        Projection center (PC) coordinates (PCx, PCy, PCz).
+
+    Returns
+    -------
+    score
+        Highest normalized cross-correlation score.
+    pcx_refined, pcy_refined, pcz_refined
+        Optimized PC parameters in the Bruker convention.
+    """
+    if rescale:
+        pattern = _rescale_pattern(pattern.astype(np.float32))
+
+    params = (pattern,) + (rotation,) + fixed_parameters
+    method_name = method.__name__
+
+    if method_name == "minimize":
+        if trust_region_passed:
+            pcx_dev, pcy_dev, pcz_dev = trust_region
+            pcx, pcy, pcz = pc
+            method_kwargs["bounds"] = Bounds(
+                [pcx - pcx_dev, pcy - pcy_dev, pcz - pcz_dev],
+                [pcx + pcx_dev, pcy + pcy_dev, pcz + pcz_dev],
+            )
+        solution = method(
+            fun=_refine_projection_center_objective_function,
+            x0=pc,
+            args=params,
+            **method_kwargs,
+        )
+    elif SUPPORTED_OPTIMIZATION_METHODS[method_name]["supports_bounds"]:
+        pcx_dev, pcy_dev, pcz_dev = trust_region
+        pcx, pcy, pcz = pc
+        solution = method(
+            func=_refine_projection_center_objective_function,
+            args=params,
+            bounds=[
+                [pcx - pcx_dev, pcx + pcx_dev],
+                [pcy - pcy_dev, pcy + pcy_dev],
+                [pcz - pcz_dev, pcz + pcz_dev],
+            ],
+            **method_kwargs,
+        )
+    else:  # Is always "basinhopping", due to prior check of method name
+        method_kwargs["minimizer_kwargs"].update(args=params)
+        solution = method(
+            func=_refine_projection_center_objective_function,
+            x0=pc,
+            **method_kwargs,
+        )
+
+    x = solution.x
+    return 1 - solution.fun, x[0], x[1], x[2]
+
+
+def _refine_orientation_projection_center_solver(
+    pattern: np.ndarray,
+    rescale: bool,
+    euler_pc: np.ndarray,
+    method: Callable,
+    method_kwargs: dict,
+    fixed_parameters: tuple,
+    trust_region: list,
+    trust_region_passed: bool,
+) -> Tuple[float, float, float, float, float, float, float]:
+    """Maximize the similarity between an experimental pattern and a
+    projected simulated pattern by optimizing the orientation and
+    projection center (PC) parameters used in the projection.
+
+    Parameters
+    ----------
+    pattern
+        Experimental pattern of shape (nrows, ncols).
+    rescale
+        Whether pattern intensities must be rescaled to [-1, 1] and data
+        type 32-bit floats.
+    euler_pc
+        Array with Euler angles (phi1, Phi, phi2) in radians and PC
+        parameters (PCx, PCy, PCz) in range [0, 1].
+    method
+        A supported :mod:`scipy.optimize` function. See `method`
+        parameter in
+        :meth:`kikuchipy.signals.EBSD.refine_orientation_projection_center`.
+    method_kwargs
+        Keyword arguments passed to the `method` function. For the list
+        of possible arguments, see the SciPy documentation.
+    fixed_parameters
+        Fixed parameters used in the projection.
+    trust_region
+        List of +/- angular deviations in degrees as bound
+        constraints on the three Euler angles and +/- percentage
+        deviations as bound constraints on the PC parameters in the
+        Bruker convention.
+    trust_region_passed
+        Whether `trust_region` was passed to the public refinement
+        method.
+
+    Returns
+    -------
+    score
+        Highest normalized cross-correlation score.
+    phi1, Phi, phi2
+        Optimized orientation (Euler angles) in radians.
+    pcx_refined, pcy_refined, pcz_refined
+        Optimized PC parameters in the Bruker convention.
+    """
+    if rescale:
+        pattern = _rescale_pattern(pattern.astype(np.float32))
+
+    params = (pattern,) + fixed_parameters
+    method_name = method.__name__
+
+    if method_name == "minimize":
+        if trust_region_passed:
+            alpha_dev, beta_dev, gamma_dev, pcx_dev, pcy_dev, pcz_dev = trust_region
+            alpha, beta, gamma, pcx, pcy, pcz = euler_pc
+            method_kwargs["bounds"] = Bounds(
+                [
+                    alpha - alpha_dev,
+                    beta - beta_dev,
+                    gamma - gamma_dev,
+                    pcx - pcx_dev,
+                    pcy - pcy_dev,
+                    pcz - pcz_dev,
+                ],
+                [
+                    alpha + alpha_dev,
+                    beta + beta_dev,
+                    gamma + gamma_dev,
+                    pcx + pcx_dev,
+                    pcy + pcy_dev,
+                    pcz + pcz_dev,
+                ],
+            )
+        solution = method(
+            fun=_refine_orientation_projection_center_objective_function,
+            x0=euler_pc,
+            args=params,
+            **method_kwargs,
+        )
+    elif SUPPORTED_OPTIMIZATION_METHODS[method_name]["supports_bounds"]:
+        alpha_dev, beta_dev, gamma_dev, pcx_dev, pcy_dev, pcz_dev = trust_region
+        alpha, beta, gamma, pcx, pcy, pcz = euler_pc
+        solution = method(
+            func=_refine_orientation_projection_center_objective_function,
+            args=params,
+            bounds=[
+                [alpha - alpha_dev, alpha + alpha_dev],
+                [beta - beta_dev, beta + beta_dev],
+                [gamma - gamma_dev, gamma + gamma_dev],
+                [pcx - pcx_dev, pcx + pcx_dev],
+                [pcy - pcy_dev, pcy + pcy_dev],
+                [pcz - pcz_dev, pcz + pcz_dev],
+            ],
+            **method_kwargs,
+        )
+    else:  # Is always "basinhopping", due to prior check of method name
+        method_kwargs["minimizer_kwargs"].update(args=params)
+        solution = method(
+            func=_refine_orientation_projection_center_objective_function,
+            x0=euler_pc,
+            **method_kwargs,
+        )
+
+    x = solution.x
+    return 1 - solution.fun, x[0], x[1], x[2], x[3], x[4], x[5]
+
+
+@nb.jit("float32[:, :](float32[:, :])", cache=True, nopython=True, nogil=True)
+def _rescale_pattern(pattern: np.ndarray) -> np.ndarray:
+    imin = np.min(pattern)
+    imax = np.max(pattern)
+    return _rescale(pattern, imin=imin, imax=imax, omin=-1, omax=1)
diff --git a/kikuchipy/indexing/similarity_metrics.py b/kikuchipy/indexing/similarity_metrics.py
index d29f2757..f998a001 100644
--- a/kikuchipy/indexing/similarity_metrics.py
+++ b/kikuchipy/indexing/similarity_metrics.py
@@ -22,6 +22,7 @@
 from typing import Callable, Tuple, Union
 
 import dask.array as da
+import numba as nb
 import numpy as np
 
 from kikuchipy.pattern._pattern import _normalize, _zero_mean
@@ -527,3 +528,27 @@ def _ndp_einsum(
 
 
 _SIMILARITY_METRICS = {"ncc": ncc, "ndp": ndp}
+
+
+@nb.jit("float64(float32[:, :], float32[:, :])", cache=True, nogil=True, nopython=True)
+def _ncc_single_patterns_2d_float32(exp: np.ndarray, sim: np.ndarray) -> float:
+    """Return the normalized cross-correlation (NCC) coefficient
+    between two 2D patterns.
+
+    Parameters
+    ----------
+    exp, sim
+        2D arrays of equal shape and of data type 32-bit floats.
+
+    Returns
+    -------
+    NCC coefficient as 64-bit float.
+    """
+    exp_mean = np.mean(exp)
+    sim_mean = np.mean(sim)
+    exp_normed = exp - exp_mean
+    sim_normed = sim - sim_mean
+    return np.divide(
+        np.sum(exp_normed * sim_normed),
+        np.sqrt(np.sum(np.square(exp_normed)) * np.sum(np.square(sim_normed))),
+    )
diff --git a/kikuchipy/pattern/_pattern.py b/kikuchipy/pattern/_pattern.py
index fb4fa83f..b5923112 100644
--- a/kikuchipy/pattern/_pattern.py
+++ b/kikuchipy/pattern/_pattern.py
@@ -18,7 +18,7 @@
 
 from typing import Union, Tuple, Optional, List
 
-from numba import njit
+import numba as nb
 import numpy as np
 from numpy.fft import fft2, rfft2, ifft2, irfft2, fftshift, ifftshift
 from scipy.ndimage import gaussian_filter
@@ -32,7 +32,7 @@ def rescale_intensity(
     pattern: np.ndarray,
     in_range: Optional[Tuple[Union[int, float], ...]] = None,
     out_range: Optional[Tuple[Union[int, float], ...]] = None,
-    dtype_out: Optional[np.dtype] = None,
+    dtype_out: Union[None, np.dtype, type] = None,
 ) -> np.ndarray:
     """Rescale intensities in an EBSD pattern.
 
@@ -86,7 +86,7 @@ def rescale_intensity(
     return _rescale(pattern, imin, imax, omin, omax).astype(dtype_out)
 
 
-@njit
+@nb.jit(nogil=True, nopython=True, cache=True)
 def _rescale(
     pattern: np.ndarray,
     imin: Union[int, float],
@@ -475,7 +475,7 @@ def fft_filter(
     return np.real(ifft(filtered_fft, shift=shift))
 
 
-@njit
+@nb.jit(nopython=True, nogil=True, cache=True)
 def fft_spectrum(fft_pattern: np.ndarray) -> np.ndarray:
     """Compute the FFT spectrum of a Fourier transformed EBSD pattern.
 
@@ -492,7 +492,7 @@ def fft_spectrum(fft_pattern: np.ndarray) -> np.ndarray:
     return np.sqrt(fft_pattern.real ** 2 + fft_pattern.imag ** 2)
 
 
-@njit
+@nb.jit(nopython=True, nogil=True, cache=True)
 def normalize_intensity(
     pattern: np.ndarray, num_std: int = 1, divide_by_square_root: bool = False
 ) -> np.ndarray:
diff --git a/kikuchipy/projections/gnomonic_projection.py b/kikuchipy/projections/gnomonic_projection.py
index 85ea58a9..a3ec3147 100644
--- a/kikuchipy/projections/gnomonic_projection.py
+++ b/kikuchipy/projections/gnomonic_projection.py
@@ -28,7 +28,7 @@ class GnomonicProjection(SphericalProjection):
     """Gnomonic projection of a vector as implemented in MTEX."""
 
     @classmethod
-    def project(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
+    def vector2xy(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
         """Convert from 3D cartesian coordinates (x, y, z) to 2D
         Gnomonic coordinates (x_g, y_g).
 
@@ -49,9 +49,9 @@ def project(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
         ...     GnomonicProjection
         ... )
         >>> v = np.random.random_sample(30).reshape((10, 3))
-        >>> xy = GnomonicProjection.project(v)
+        >>> xy = GnomonicProjection.vector2xy(v)
         """
-        polar_coordinates = super().project(v)
+        polar_coordinates = super().vector2xy(v)
         polar, azimuth = polar_coordinates[..., 0], polar_coordinates[..., 1]
 
         # Map to upper hemisphere
@@ -72,7 +72,7 @@ def project(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
         return gnomonic
 
     @staticmethod
-    def iproject(xy: np.ndarray) -> Vector3d:
+    def xy2vector(xy: np.ndarray) -> Vector3d:
         """Convert from 2D Gnomonic coordinates (x_g, y_g) to 3D
         cartesian coordiantes (x, y, z).
 
@@ -95,7 +95,7 @@ def iproject(xy: np.ndarray) -> Vector3d:
         ...     GnomonicProjection
         ... )
         >>> xy_g = np.random.random_sample(20).reshape((10, 2))
-        >>> xyz = GnomonicProjection.iproject(xy_g)
+        >>> xyz = GnomonicProjection.xy2vector(xy_g)
         """
         x, y = xy[..., 0], xy[..., 1]
         polar = np.arctan(np.sqrt(x ** 2 + y ** 2))
diff --git a/kikuchipy/projections/lambert_projection.py b/kikuchipy/projections/lambert_projection.py
index 9439e992..5d78f916 100644
--- a/kikuchipy/projections/lambert_projection.py
+++ b/kikuchipy/projections/lambert_projection.py
@@ -18,63 +18,49 @@
 
 from typing import Union
 
+import numba as nb
 import numpy as np
 from orix.vector import Vector3d
 
 from kikuchipy.projections.gnomonic_projection import GnomonicProjection
 
 
+# Reusable constants
+SQRT_PI = np.sqrt(np.pi)
+SQRT_PI_HALF = SQRT_PI / 2
+TWO_OVER_SQRT_PI = 2 / SQRT_PI
+
+
 class LambertProjection:
-    """Lambert projection of a vector [Callahan2013]_."""
+    """Lambert projection of a vector :cite:`callahan2013dynamical`."""
 
     @classmethod
-    def project(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
-        """Convert (n, 3) vector from Cartesian to the Lambert projection."""
+    def vector2xy(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
+        """Convert vector(s) from Cartesian to the Lambert projection.
+
+        Parameters
+        ----------
+        v
+            Vectors of any shape as long as the last dimension is (3,).
+
+        Returns
+        -------
+        xy
+            Vectors in the square Lambert projection, of the same shape
+            as the input vectors, with the last dimension as (2,).
+        """
         if isinstance(v, Vector3d):
-            w = v.unit.data
-            x = w[..., 0]
-            y = w[..., 1]
-            z = w[..., 2]
+            w = v.data
         else:
-            w = np.atleast_2d(v)
-            norm = np.linalg.norm(w, axis=-1)[..., np.newaxis]
-            w = w / norm
-            x = w[..., 0]
-            y = w[..., 1]
-            z = w[..., 2]
-
-        # Arrays used in both setting X and Y
-        sqrt_z = np.sqrt(2 * (1 - abs(z)))
-        sign_x = np.sign(x)
-        sign_y = np.sign(y)
-        abs_yx = abs(y) <= abs(x)
-
-        # Reusable constants
-        sqrt_pi = np.sqrt(np.pi)
-        sqrt_pi_half = sqrt_pi / 2
-        two_over_sqrt_pi = 2 / sqrt_pi
-
-        # Ensure (0, 0) is returned where |z| = 1
-        lambert = np.zeros(x.shape + (2,), dtype=x.dtype)
-        z_not_one = abs(z) != 1
-
-        # Equations (10a) and (10b) from Callahan and De Graef (2013)
-        with np.errstate(divide="ignore", invalid="ignore"):
-            lambert[z_not_one, 0] = np.where(
-                abs_yx,
-                sign_x * sqrt_z * sqrt_pi_half,
-                sign_y * sqrt_z * (two_over_sqrt_pi * np.arctan(x / y)),
-            )[z_not_one]
-            lambert[z_not_one, 1] = np.where(
-                abs_yx,
-                sign_x * sqrt_z * (two_over_sqrt_pi * np.arctan(y / x)),
-                sign_y * sqrt_z * sqrt_pi_half,
-            )[z_not_one]
-
-        return lambert
+            w = v
+        original_shape = w.shape[:-1]
+        w = w.reshape((-1, 3)).astype(float)
+        xy = _vector2xy(w)
+        xy = xy.reshape(original_shape + (2,))
+        return xy
 
     @staticmethod
-    def iproject(xy: np.ndarray) -> Vector3d:
+    def xy2vector(xy: np.ndarray) -> Vector3d:
         """Convert (n, 2) array from Lambert to Cartesian coordinates."""
         X = xy[..., 0]
         Y = xy[..., 1]
@@ -107,9 +93,8 @@ def lambert_to_gnomonic(xy: np.ndarray) -> np.ndarray:
         Gnomonic."""
         # These two functions could probably be combined into 1 to decrease
         # runtime
-        vec = LambertProjection.iproject(xy)
-        xy = GnomonicProjection.project(vec)
-        return xy
+        v = LambertProjection.xy2vector(xy)
+        return GnomonicProjection.vector2xy(v)
 
     @staticmethod
     def gnomonic_to_lambert(xy: np.ndarray) -> np.ndarray:
@@ -117,12 +102,54 @@ def gnomonic_to_lambert(xy: np.ndarray) -> np.ndarray:
         Lambert."""
         # These two functions could probably be combined into 1 to decrease
         # runtime
-        vec = GnomonicProjection.iproject(xy)
-        xy = LambertProjection.project(vec)
-        return xy
+        v = GnomonicProjection.xy2vector(xy)
+        return LambertProjection.vector2xy(v)
 
 
+@nb.jit(nogil=True, nopython=True)
 def _eq_c(p: Union[np.ndarray, float, int]) -> Union[np.ndarray, float, int]:
-    """Private function used inside LambertProjection.iproject to increase
-    readability."""
+    """Private function used inside LambertProjection.xy2vector to
+    increase readability.
+    """
     return (2 * p / np.pi) * np.sqrt(np.pi - p ** 2)
+
+
+@nb.jit("float64[:, :](float64[:, :])", nogil=True, cache=True, nopython=True)
+def _vector2xy(v: np.ndarray) -> np.ndarray:
+    """Lambert projection of vector(s) :cite:`callahan2013dynamical`.
+
+    Parameters
+    ----------
+    v
+        Vector(s) in an array of shape (n, 3) and 64-bit float data
+        type in Cartesian coordinates.
+
+    Returns
+    -------
+    lambert
+        Square Lambert coordinates (X, Y) in array of shape (n, 2) and
+        data type 64-bit float.
+    """
+    # Normalize vectors (vectorized operation is faster than per vector)
+    norm = np.sqrt(np.sum(np.square(v), axis=1))
+    norm = np.expand_dims(norm, axis=1)
+    w = v / norm
+
+    n_vectors = v.shape[0]
+    lambert_xy = np.zeros((n_vectors, 2))
+    for i in nb.prange(n_vectors):
+        x, y, z = w[i]
+        abs_z = np.abs(z)
+        sqrt_z = np.sqrt(2 * (1 - abs_z))
+        if abs_z == 1:  # (X, Y) = (0, 0)
+            continue
+        elif np.abs(y) <= np.abs(x):
+            sign_x = np.sign(x)
+            lambert_xy[i, 0] = sign_x * sqrt_z * SQRT_PI_HALF
+            lambert_xy[i, 1] = sign_x * sqrt_z * TWO_OVER_SQRT_PI * np.arctan(y / x)
+        else:
+            sign_y = np.sign(y)
+            lambert_xy[i, 0] = sign_y * sqrt_z * TWO_OVER_SQRT_PI * np.arctan(x / y)
+            lambert_xy[i, 1] = sign_y * sqrt_z * SQRT_PI_HALF
+
+    return lambert_xy
diff --git a/kikuchipy/projections/spherical_projection.py b/kikuchipy/projections/spherical_projection.py
index d9943c81..5bbb73c1 100644
--- a/kikuchipy/projections/spherical_projection.py
+++ b/kikuchipy/projections/spherical_projection.py
@@ -41,7 +41,7 @@ class SphericalProjection:
     spherical_region = SphericalRegion([0, 0, 1])
 
     @classmethod
-    def project(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
+    def vector2xy(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
         """Convert from cartesian to spherical coordinates according to
         the ISO 31-11 standard [SphericalWolfram]_.
 
@@ -63,7 +63,7 @@ def project(cls, v: Union[Vector3d, np.ndarray]) -> np.ndarray:
         ...     SphericalProjection
         ... )
         >>> v = np.random.random_sample(30).reshape((10, 3))
-        >>> theta, phi, r = SphericalProjection.project(v).T
+        >>> theta, phi, r = SphericalProjection.vector2xy(v).T
         >>> np.allclose(np.arccos(v[:, 2] / r), theta)
         True
         >>> np.allclose(np.arctan2(v[:, 1], v[:, 0]), phi)
diff --git a/kikuchipy/release.py b/kikuchipy/release.py
index 60edf223..080d0e9c 100644
--- a/kikuchipy/release.py
+++ b/kikuchipy/release.py
@@ -21,12 +21,12 @@
 
 author = "kikuchipy developers"
 copyright = f"Copyright 2019-{datetime.now().year}, kikuchipy"
-# Initial commiter first, then sorted by line contributions
+# Initial committer first, then sorted by line contributions
 credits = [
     "Håkon Wiik Ånes",
+    "Lars Andreas Hastad Lervik",
     "Ole Natlandsmyr",
     "Tina Bergh",
-    "Lars Andreas Hastad Lervik",
 ]
 license = "GPLv3+"
 maintainer = "Håkon Wiik Ånes"
diff --git a/kikuchipy/signals/ebsd.py b/kikuchipy/signals/ebsd.py
index e3e0e054..a5aeb3e0 100644
--- a/kikuchipy/signals/ebsd.py
+++ b/kikuchipy/signals/ebsd.py
@@ -48,12 +48,11 @@
     fft_filter,
     _dynamic_background_frequency_space_setup,
 )
-from kikuchipy.indexing import StaticPatternMatching
-from kikuchipy.indexing._refinement import (
-    full_refinement,
-    refine_orientations,
-    refine_projection_center,
-    _refinement_parameter_check,
+from kikuchipy.indexing._static_pattern_matching import StaticPatternMatching
+from kikuchipy.indexing._refinement._refinement import (
+    _refine_orientation,
+    _refine_orientation_projection_center,
+    _refine_projection_center,
 )
 from kikuchipy.indexing.similarity_metrics import SimilarityMetric
 from kikuchipy.signals.util._metadata import (
@@ -69,7 +68,8 @@
     _rechunk_learning_results,
     _update_learning_results,
 )
-from kikuchipy.signals.util._crystal_map import crystal_map_is_compatible_with_signal
+from kikuchipy.signals.util._detector import _detector_is_compatible_with_signal
+from kikuchipy.signals.util._crystal_map import _crystal_map_is_compatible_with_signal
 from kikuchipy.signals.util._map_helper import (
     _get_neighbour_dot_product_matrices,
     _get_average_dot_product_map,
@@ -98,7 +98,7 @@ class EBSD(CommonImage, Signal2D):
     _lazy = False
 
     def __init__(self, *args, **kwargs):
-        """Create an :class:`~kikuchipy.signals.EBSD` object from a
+        """Create an :class:`~kikuchipy.signals.EBSD` instance from a
         :class:`hyperspy.signals.Signal2D` or a :class:`numpy.ndarray`.
         See the docstring of :class:`hyperspy.signal.BaseSignal` for
         optional input parameters.
@@ -132,6 +132,16 @@ def detector(self) -> EBSDDetector:
         """
         return self._detector
 
+    @detector.setter
+    def detector(self, value: EBSDDetector):
+        if _detector_is_compatible_with_signal(
+            detector=value,
+            navigation_shape=self.axes_manager.navigation_shape[::-1],
+            signal_shape=self.axes_manager.signal_shape[::-1],
+            raise_if_not=True,
+        ):
+            self._detector = value
+
     @property
     def xmap(self) -> CrystalMap:
         """A :class:`~orix.crystal_map.CrystalMap` containing the
@@ -142,8 +152,8 @@ def xmap(self) -> CrystalMap:
 
     @xmap.setter
     def xmap(self, value: CrystalMap):
-        if crystal_map_is_compatible_with_signal(
-            value, self.axes_manager, raise_if_false=True
+        if _crystal_map_is_compatible_with_signal(
+            value, self.axes_manager.navigation_axes[::-1], raise_if_not=True
         ):
             self._xmap = value
 
@@ -1010,93 +1020,109 @@ def dictionary_indexing(
             get_orientation_similarity_map=get_orientation_similarity_map,
         )
 
-    def orientation_refinement(
+    def refine_orientation(
         self,
         xmap: CrystalMap,
-        master_pattern,
         detector: EBSDDetector,
+        master_pattern,
         energy: Union[int, float],
         mask: Optional[np.ndarray] = None,
-        method: Optional[str] = None,
+        method: Optional[str] = "minimize",
         method_kwargs: Optional[dict] = None,
         trust_region: Optional[list] = None,
         compute: bool = True,
-    ) -> CrystalMap:
-        """Performs an orientation refinement using the initial indexing
-        results, in the EDAX TSL sample reference frame (RD,
-        TD, ND), stored in a single phase
-        :class:`~orix.crystal_map.CrystalMap` and the fixed
-        detector-sample geometry. The refinement uses a subset of the
-        optimization routines implemented in scipy and attempts to
-        maximize the similarity between experimental and simulated
-        patterns, as calculated by the NCC, using the best orientation as a
-        starting point.
+    ):
+        r"""Refine orientations by searching orientation space around
+        the best indexed solution using fixed projection centers.
+
+        Refinement attempts to optimize (maximize) the similarity
+        between patterns in this signal and simulated patterns
+        projected from a master pattern. The only supported similarity
+        metric is the normalized cross-correlation (NCC). The
+        orientation, represented by three Euler angles
+        (:math:`\phi_1`, :math:`\Phi`, :math:`\phi_2`), is changed
+        during projection, while the sample-detector geometry,
+        represented by the three projection center (PC) parameters
+        (PCx, PCy, PCz), are fixed.
+
+        A subset of the optimization methods in SciPy are available:
+            - Local optimization:
+                - :func:`~scipy.optimize.minimize`
+                  (includes Nelder-Mead, Powell etc.)
+            - Global optimization:
+                - :func:`~scipy.optimize.differential_evolution`
+                - :func:`~scipy.optimize.dual_annealing`
+                - :func:`~scipy.optimize.basinhopping`
+                - :func:`~scipy.optimize.shgo`
 
         Parameters
         ----------
-        xmap : CrystalMap
-            A crystal map storing the results of the initial EBSD
-            indexing.
+        xmap
+            Single phase crystal map with at least one orientation per
+            point. The orientations are assumed to be relative to the
+            EDAX TSL sample reference frame RD-TD-ND.
+        detector
+            Detector describing the detector-sample geometry with either
+            one PC to be used for all map points or one for each point.
         master_pattern : EBSDMasterPattern
-            EBSDMasterPattern in the square Lambert projection.
-        detector : EBSDDetector
-            EBSD detector describing the detector dimensions and the
-            detector-sample geometry with either a single, fixed
-            projection/pattern center or a projection center for each
-            scan point.
-        energy : int
-            Acceleration voltage, in kV, used to simulate the desired
-            master pattern.
-        mask : np.ndarray
-            Boolean mask to be applied to the simulated patterns. True values
-            are masked away.
+            Master pattern in the square Lambert projection of the same
+            phase as the one in the crystal map.
+        energy
+            Accelerating voltage of the electron beam in kV specifying
+            which master pattern energy to use during projection of
+            simulated patterns.
+        mask
+            Boolean mask of signal shape to be applied to the simulated
+            pattern before comparison. Pixels set to `True` are masked
+            away. If not given, all pixels are compared.
         method : str, optional
-            Name of the scipy.optimize function to be used. Must be one
-            of "minimize", "differential_evolution", "dual_annealing",
-            or "basinhopping". If not specified, "minimize" is used.
-        method_kwargs : dict, optional
-            Keyword arguments passed to the scipy.optimize function
-            specified above.
-        trust_region : list, optional
-            List of how wide the bounds, centered on the initial
-            orientation indexing result, should be for
-            (phi1, Phi, phi2) in degrees. Only used for methods that
-            support bounds (excluding Powell). Defaults to [1, 1, 1].
-        compute : bool
-            Whether to return a computed result, by default True. If compute is
-            set to False, a Delayed list object is returned where the Euler
-            angles (phi1, Phi, phi2) in radians for index i is given by
-            List[i][0], List[i][1], List[i][2].
-            For more information see :func:`~dask.array.Array.compute`.
+            Name of the :mod:`scipy.optimize` optimization method, among
+            "minimize", "differential_evolution", "dual_annealing",
+            "basinhopping", and "shgo". Default is "minimize", which
+            by default performs local optimization with the Nelder-Mead
+            method unless another "minimize" method is passed to
+            `method_kwargs`.
+        method_kwargs
+            Keyword arguments passed to the :mod:`scipy.optimize`
+            `method`. For example, to perform refinement with the
+            modified Powell algorithm, pass `method="minimize"` and
+            `method_kwargs=dict(method="Powell")`.
+        trust_region
+            List of +/- angular deviation in degrees as bound
+            constraints on the three Euler angles. If not given and
+            `method` requires bounds, they are set to [1, 1, 1]. If
+            given, `method` is assumed to support bounds and they are
+            passed to `method`.
+        compute
+            Whether to refine now (True) or later (False). Default is
+            True. See :meth:`~dask.array.Array.compute` for more
+            details.
 
         Returns
         -------
-        CrystalMap
-            A new crystal map where the orientations have been
-            refined.
+        :class:`~orix.crystal_map.CrystalMap` or list of :class:`~dask.delayed.Delayed`
+            Crystal map with refined orientations and similarity metrics
+            in a "scores" property if `compute` is True. If
+            `compute` is False, a list of `Delayed` instances, one per
+            experimental pattern, is returned, to be computed later. See
+            :func:`~kikuchipy.indexing.compute_refine_orientation_results`.
+            One delayed instance has the optimized score and the three
+            Euler angles in radians in element 0, 1, 2, and 3,
+            respectively.
 
-        Notes
-        -----
-        The method creates a deepcopy of the signal and rescales it to float32
-        if the initial signal has a different data type, such as uint8. This
-        might lead to memory issues if your experimental data set is large. To
-        ensure that the method runs smoothly, it is recommended that you
-        rescale the signal prior to refinement, and if the data set becomes
-        larger than memory use a LazyEBSD object instead for your experimental
-        data.
-
-
-        See ~kikuchipy.signals.EBSD.rescale_intensity
+        See Also
+        --------
+        scipy.optimize
+        refine_projection_center
+        refine_orientation_projection_center
         """
-        _refinement_parameter_check(
-            exp=self, xmap=xmap, detector=detector, method=method, mask=mask
-        )
-        return refine_orientations(
+        self._check_refinement_parameters(xmap=xmap, detector=detector, mask=mask)
+        return _refine_orientation(
             xmap=xmap,
-            master_pattern=master_pattern,
-            signal=self,
             detector=detector,
+            master_pattern=master_pattern,
             energy=energy,
+            patterns=self.data,
             mask=mask,
             method=method,
             method_kwargs=method_kwargs,
@@ -1104,94 +1130,108 @@ def orientation_refinement(
             compute=compute,
         )
 
-    def pc_refinement(
+    def refine_projection_center(
         self,
         xmap: CrystalMap,
-        master_pattern,
         detector: EBSDDetector,
+        master_pattern,
         energy: Union[int, float],
         mask: Optional[np.ndarray] = None,
-        method: Optional[str] = None,
+        method: Optional[str] = "minimize",
         method_kwargs: Optional[dict] = None,
         trust_region: Optional[list] = None,
         compute: bool = True,
-    ) -> Tuple[np.ndarray, EBSDDetector]:
-        """Performs a projection center refinement using the
-        best orientation, for each point, from the initial indexing stored in a
-        single phase
-        :class:`~orix.crystal_map.CrystalMap` and the projection center
-        estimates stored in an
-        :class:`~kikuchipy.detectors.ebsd_detector.EBSDDetector`.
-        The refinement uses a subset of the optimization routines
-        implemented in scipy and attempts to  maximize the similarity
-        between experimental and simulated patterns as calculated by
-        the NCC.
+    ):
+        """Refine projection centers by searching the parameter space
+        using fixed orientations.
+
+        Refinement attempts to optimize (maximize) the similarity
+        between patterns in this signal and simulated patterns projected
+        from a master pattern. The only supported similarity metric is
+        the normalized cross-correlation (NCC). The sample-detector
+        geometry, represented by the three projection center (PC)
+        parameters (PCx, PCy, PCz), is changed during projection, while
+        the orientations are fixed.
+
+        A subset of the optimization methods in SciPy are available:
+            - Local optimization:
+                - :func:`~scipy.optimize.minimize`
+                  (includes Nelder-Mead, Powell etc.)
+            - Global optimization:
+                - :func:`~scipy.optimize.differential_evolution`
+                - :func:`~scipy.optimize.dual_annealing`
+                - :func:`~scipy.optimize.basinhopping`
+                - :func:`~scipy.optimize.shgo`
 
         Parameters
         ----------
-        xmap : CrystalMap
-            A crystal map storing the results of the initial EBSD
-            indexing.
+        xmap
+            Single phase crystal map with at least one orientation per
+            point. The orientations are assumed to be relative to the
+            EDAX TSL sample reference frame RD-TD-ND.
+        detector
+            Detector describing the detector-sample geometry with either
+            one PC to be used for all map points or one for each point.
         master_pattern : EBSDMasterPattern
-            EBSDMasterPattern in the square Lambert projection.
-        detector : EBSDDetector
-            EBSD detector describing the detector dimensions and the
-            detector-sample geometry with either a single, fixed
-            projection/pattern center or a projection center for each
-            scan point.
-        energy : int
-            Acceleration voltage, in kV, used to simulate the desired
-            master pattern.
-        mask : np.ndarray, optional
-            Boolean mask to be applied to the simulated patterns. True values
-            are masked away.
+            Master pattern in the square Lambert projection of the same
+            phase as the one in the crystal map.
+        energy
+            Accelerating voltage of the electron beam in kV specifying
+            which master pattern energy to use during projection of
+            simulated patterns.
+        mask
+            Boolean mask of signal shape to be applied to the simulated
+            pattern before comparison. Pixels set to `True` are masked
+            away. If not given, all pixels are compared.
         method : str, optional
-            Name of the scipy.optimize function to be used. Must be one
-            of "minimize", "differential_evolution", "dual_annealing",
-            or "basinhopping". If not specified, "minimize" is used.
-        method_kwargs : dict, optional
-            Keyword arguments passed to the scipy.optimize function
-            specified above.
-        trust_region : list, optional
-            List of how wide the bounds, centered on the projection
-            center, should be for (PCx, PCy, PCz) in Bruker convention.
-            Only used for methods that support bounds
-            (excluding Powell). Defaults to [0.05, 0.05, 0.05].
-        compute : bool
-            Whether to return a computed result, by default True.  If compute is
-            set to False, a Delayed list object is returned where the
-            similarity metric score for index i is given by List[i][0], and the
-            projection center in the Bruker convention (PCx, PCy, PCz) for index
-            i is given by List[i][1], List[i][2], List[i][3].
-            For more information see :func:`~dask.array.Array.compute`.
+            Name of the :mod:`scipy.optimize` optimization method, among
+            "minimize", "differential_evolution", "dual_annealing",
+            "basinhopping", and "shgo". Default is "minimize", which
+            by default performs local optimization with the Nelder-Mead
+            method unless another "minimize" method is passed to
+            `method_kwargs`.
+        method_kwargs
+            Keyword arguments passed to the :mod:`scipy.optimize`
+            `method`. For example, to perform refinement with the
+            modified Powell algorithm, pass `method="minimize"` and
+            `method_kwargs=dict(method="Powell")`.
+        trust_region
+            List of +/- percentage deviations as bound constraints on
+            the PC parameters in the Bruker convention. The parameter
+            range is [0, 1]. If not given and `method` requires bounds,
+            they are set to [0.05, 0.05, 0.05]. If given, `method` is
+            assumed to support bounds and they are passed to `method`.
+        compute
+            Whether to refine now (True) or later (False). Default is
+            True. See :meth:`~dask.array.Array.compute` for more
+            details.
+
         Returns
         -------
-         np.ndarray, EBSDDetector
-            An array containing the similarity metric after refinement,
-            and a new EBSD detector with refined projection centers.
-
-        Notes
-        -----
-        The method creates a deepcopy of the signal and rescales it to float32
-        if the initial signal has a different data type, such as uint8. This
-        might lead to memory issues if your experimental data set is large. To
-        ensure that the method runs smoothly, it is recommended that you
-        rescale the signal prior to refinement, and if the data set becomes
-        larger than memory use a LazyEBSD object instead for your experimental
-        data.
-
-        See ~kikuchipy.signals.EBSD.rescale_intensity
+        :class:`numpy.ndarray` and :class:`~kikuchipy.detectors.EBSDDetector`,\
+        or list of :class:`~dask.delayed.Delayed`
+            New similarity metrics and a new EBSD detector instance with
+            the refined PCs if `compute` is True. If `compute` is False,
+            a list of `Delayed` instances, one per experimental pattern,
+            is returned, to be computed later. See
+            :func:`~kikuchipy.indexing.compute_refine_projection_center_results`.
+            One delayed instance has the optimized score and the three
+            PC parameters in the Bruker convention in element 0, 1, 2,
+            and 3, respectively.
 
+        See Also
+        --------
+        scipy.optimize
+        refine_orientation
+        refine_orientation_projection_center
         """
-        _refinement_parameter_check(
-            exp=self, xmap=xmap, detector=detector, method=method, mask=mask
-        )
-        return refine_projection_center(
+        self._check_refinement_parameters(xmap=xmap, detector=detector, mask=mask)
+        return _refine_projection_center(
             xmap=xmap,
-            master_pattern=master_pattern,
-            signal=self,
             detector=detector,
+            master_pattern=master_pattern,
             energy=energy,
+            patterns=self.data,
             mask=mask,
             method=method,
             method_kwargs=method_kwargs,
@@ -1199,106 +1239,123 @@ def pc_refinement(
             compute=compute,
         )
 
-    def full_refinement(
+    def refine_orientation_projection_center(
         self,
         xmap: CrystalMap,
-        master_pattern,
         detector: EBSDDetector,
+        master_pattern,
         energy: Union[int, float],
         mask: Optional[np.ndarray] = None,
-        method: Optional[str] = None,
+        method: Optional[str] = "minimize",
         method_kwargs: Optional[dict] = None,
         trust_region: Optional[list] = None,
         compute: bool = True,
-    ) -> Tuple[CrystalMap, EBSDDetector]:
-        """Performs an orientation and projection center refinement
-        using the initial indexing results, in the EDAX TSL sample reference
-        frame (RD, TD, ND), stored in a single phase
-        :class:`~orix.crystal_map.CrystalMap` and the projection center
-        estimates stored in an
-        :class:`~kikuchipy.detectors.ebsd_detector.EBSDDetector`.
-        The refinement uses a subset of the optimization routines
-        implemented in scipy and attempts to  maximize the similarity
-        between experimental and simulated patterns, as calculated by the NCC,
-        using the best orientation as a starting point.
+    ):
+        r"""Refine orientations and projection centers simultaneously by
+        searching the orientation and PC parameter space.
+
+        Refinement attempts to optimize (maximize) the similarity
+        between patterns in this signal and simulated patterns
+        projected from a master pattern. The only supported
+        similarity metric is the normalized cross-correlation (NCC).
+        The orientation, represented by three Euler angles
+        (:math:`\phi_1`, :math:`\Phi`, :math:`\phi_2`), and the
+        sample-detector geometry, represented by the three projection
+        center (PC) parameters (PCx, PCy, PCz), are changed during
+        projection.
+
+        A subset of the optimization methods in SciPy are available:
+            - Local optimization:
+                - :func:`~scipy.optimize.minimize`
+                  (includes Nelder-Mead, Powell etc.)
+            - Global optimization:
+                - :func:`~scipy.optimize.differential_evolution`
+                - :func:`~scipy.optimize.dual_annealing`
+                - :func:`~scipy.optimize.basinhopping`
+                - :func:`~scipy.optimize.shgo`
 
         Parameters
         ----------
-        xmap : CrystalMap
-            A crystal map storing the results of the initial EBSD
-            indexing.
+        xmap
+            Single phase crystal map with at least one orientation per
+            point. The orientations are assumed to be relative to the
+            EDAX TSL sample reference frame RD-TD-ND.
+        detector
+            Detector describing the detector-sample geometry with either
+            one PC to be used for all map points or one for each point.
         master_pattern : EBSDMasterPattern
-            EBSDMasterPattern in the square Lambert projection.
-        detector : EBSDDetector
-            EBSD detector describing the detector dimensions and the
-            detector-sample geometry with either a single, fixed
-            projection/pattern center or a projection center for each
-            scan point.
-        energy : int
-            Acceleration voltage, in kV, used to simulate the desired
-            master pattern.
-        mask : np.ndarray, optional
-            Boolean mask to be applied to the simulated patterns. True values
-            are masked away.
+            Master pattern in the square Lambert projection of the same
+            phase as the one in the crystal map.
+        energy
+            Accelerating voltage of the electron beam in kV specifying
+            which master pattern energy to use during projection of
+            simulated patterns.
+        mask
+            Boolean mask of signal shape to be applied to the simulated
+            pattern before comparison. Pixels set to `True` are masked
+            away. If not given, all pixels are compared.
         method : str, optional
-            Name of the scipy.optimize function to be used. Must be one
-            of "minimize", "differential_evolution", "dual_annealing",
-            or "basinhopping". If not specified, "minimize" is used.
-        method_kwargs : dict, optional
-            Keyword arguments passed to the scipy.optimize function
-            specified above.
-        trust_region : list, optional
-            List of how wide the bounds, centered on the initial
-            orientation indexing result and projection center,
-            should be for (phi1, Phi, phi2) in degrees and
-            (PCx, PCy, PCz) in the Bruker convention.
-            Only used for methods that support bounds
-            (excluding Powell). Defaults to [1, 1, 1, 0.05, 0.05, 0.05]
-        compute : bool
-            Whether to return a computed result, by default True. If compute is
-            set to False, a Delayed list object is returned where the Euler
-            angles (phi1, Phi, phi2) in radians for index i is given by
-            List[i][0], List[i][1], List[i][2], and the projection center
-            (PCx, PCy, PCz) in the Bruker convention is given by
-            List[i][3], List[i][4], List[i][5].
-            For more information see :func:`~dask.array.Array.compute`.
+            Name of the :mod:`scipy.optimize` optimization method, among
+            "minimize", "differential_evolution", "dual_annealing",
+            "basinhopping", and "shgo". Default is "minimize", which
+            by default performs local optimization with the Nelder-Mead
+            method unless another "minimize" method is passed to
+            `method_kwargs`.
+        method_kwargs
+            Keyword arguments passed to the :mod:`scipy.optimize`
+            `method`. For example, to perform refinement with the
+            modified Powell algorithm, pass `method="minimize"` and
+            `method_kwargs=dict(method="Powell")`.
+        trust_region
+            List of +/- angular deviations in degrees as bound
+            constraints on the three Euler angles and +/- percentage
+            deviations as bound constraints on the PC parameters in the
+            Bruker convention. The latter parameter range is [0, 1]. If
+            not given and `method` requires bounds, they are set to
+            [1, 1, 1, 0.05, 0.05, 0.05]. If given, `method` is assumed
+            to support bounds and they are passed to `method`.
+        compute
+            Whether to refine now (True) or later (False). Default is
+            True. See :meth:`~dask.array.Array.compute` for more
+            details.
 
         Returns
         -------
-        CrystalMap, EBSDDetector
-            A crystal map with the refined orientations and a new
-            EBSD detector with refined projection centers.
+        :class:`~orix.crystal_map.CrystalMap` and :class:`~kikuchipy.detectors.EBSDDetector`, or list of :class:`~dask.delayed.Delayed`
+            Crystal map with refined orientations and a new EBSD
+            detector instance with the refined PCs, if `compute` is
+            True. If `compute` is False, a list of `Delayed` instances,
+            one per experimental pattern, is returned, to be computed
+            later. See
+            :func:`~kikuchipy.indexing.compute_refine_orientation_projection_center_results`.
+            One delayed instance has the optimized score, the three
+            Euler angles in radians, and the three PC parameters in the
+            Bruker convention in element 0, 1, 2, 3, 4, 5, and 6,
+            respectively.
+
+        See Also
+        --------
+        scipy.optimize
+        refine_orientation
+        refine_projection_center
 
         Notes
         -----
         The method attempts to refine the orientations and projection
-        center at the same time for each scan point. The optimization
+        center at the same time for each map point. The optimization
         landscape is sloppy :cite:`pang2020optimization`, where the
-        different parameters can make up for each other. Thus, it is
+        orientation and PC can make up for each other. Thus, it is
         possible that the set of parameters that yield the highest
         similarity is incorrect. It is left to the user to ensure that
         the output is reasonable.
-
-        The method creates a deepcopy of the signal and rescales it to float32
-        if the initial signal has a different data type, such as uint8. This
-        might lead to memory issues if your experimental data set is large. To
-        ensure that the method runs smoothly, it is recommended that you
-        rescale the signal prior to refinement, and if the data set becomes
-        larger than memory use a LazyEBSD object instead for your experimental
-        data.
-
-
-        See ~kikuchipy.signals.EBSD.rescale_intensity
         """
-        _refinement_parameter_check(
-            exp=self, xmap=xmap, detector=detector, method=method, mask=mask
-        )
-        return full_refinement(
+        self._check_refinement_parameters(xmap=xmap, detector=detector, mask=mask)
+        return _refine_orientation_projection_center(
             xmap=xmap,
-            master_pattern=master_pattern,
-            signal=self,
             detector=detector,
+            master_pattern=master_pattern,
             energy=energy,
+            patterns=self.data,
             mask=mask,
             method=method,
             method_kwargs=method_kwargs,
@@ -1762,21 +1819,6 @@ def plot_virtual_bse_intensity(
         # Plot the result
         out.plot(**kwargs)
 
-    @staticmethod
-    def _get_sum_signal(signal, out_signal_axes: Optional[List] = None):
-        out = signal.nansum(signal.axes_manager.signal_axes)
-        if out_signal_axes is None:
-            out_signal_axes = list(
-                np.arange(min(signal.axes_manager.navigation_dimension, 2))
-            )
-        if len(out_signal_axes) > signal.axes_manager.navigation_dimension:
-            raise ValueError(
-                "The length of 'out_signal_axes' cannot be longer than the navigation "
-                "dimension of the signal."
-            )
-        out.set_signal_type("")
-        return out.transpose(out_signal_axes)
-
     def get_virtual_bse_intensity(
         self,
         roi: BaseInteractiveROI,
@@ -1831,10 +1873,7 @@ def deepcopy(self):
             new._xmap = self.xmap.deepcopy()
         else:
             new._xmap = copy.deepcopy(self.xmap)
-        if self.detector is not None:
-            new._detector = self.detector.deepcopy()
-        else:
-            new._detector = copy.deepcopy(self.detector)
+        new._detector = self.detector.deepcopy()
         return new
 
     def save(
@@ -1982,6 +2021,47 @@ def rebin(self, new_shape=None, scale=None, crop=True, out=None):
         if return_signal:
             return s_out
 
+    # ------------------------ Private methods ----------------------- #
+
+    def _check_refinement_parameters(
+        self,
+        xmap: CrystalMap,
+        detector: EBSDDetector,
+        mask: Union[np.ndarray, None],
+    ):
+        """Raise ValueError if EBSD refinement input is invalid."""
+        _crystal_map_is_compatible_with_signal(
+            xmap=xmap,
+            navigation_axes=self.axes_manager.navigation_axes[::-1],
+            raise_if_not=True,
+        )
+        sig_shape = self.axes_manager.signal_shape[::-1]
+        _detector_is_compatible_with_signal(
+            detector=detector,
+            navigation_shape=self.axes_manager.navigation_shape[::-1],
+            signal_shape=sig_shape,
+            raise_if_not=True,
+        )
+        if len(xmap.phases.ids) != 1:
+            raise ValueError("Crystal map must have exactly one phase")
+        if mask is not None and sig_shape != mask.shape:
+            raise ValueError("Mask and signal must have the same shape")
+
+    @staticmethod
+    def _get_sum_signal(signal, out_signal_axes: Optional[List] = None):
+        out = signal.nansum(signal.axes_manager.signal_axes)
+        if out_signal_axes is None:
+            out_signal_axes = list(
+                np.arange(min(signal.axes_manager.navigation_dimension, 2))
+            )
+        if len(out_signal_axes) > signal.axes_manager.navigation_dimension:
+            raise ValueError(
+                "The length of 'out_signal_axes' cannot be longer than the navigation "
+                "dimension of the signal."
+            )
+        out.set_signal_type("")
+        return out.transpose(out_signal_axes)
+
 
 class LazyEBSD(LazySignal2D, EBSD):
     """Lazy implementation of the :class:`EBSD` class.
diff --git a/kikuchipy/signals/ebsd_master_pattern.py b/kikuchipy/signals/ebsd_master_pattern.py
index b189a928..3c74daa3 100644
--- a/kikuchipy/signals/ebsd_master_pattern.py
+++ b/kikuchipy/signals/ebsd_master_pattern.py
@@ -17,8 +17,9 @@
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
 import copy
+import gc
 import sys
-from typing import Optional, Union
+from typing import Tuple, Union
 
 import dask.array as da
 from dask.diagnostics import ProgressBar
@@ -26,15 +27,17 @@
 from hyperspy._signals.signal2d import Signal2D
 import numpy as np
 from orix.crystal_map import CrystalMap, Phase, PhaseList
-from orix.vector import Vector3d
 from orix.quaternion import Rotation
+from skimage.util.dtype import dtype_range
 
 from kikuchipy.detectors.ebsd_detector import EBSDDetector
-from kikuchipy.pattern import rescale_intensity
-from kikuchipy.projections.lambert_projection import LambertProjection
 from kikuchipy.signals import LazyEBSD, EBSD
 from kikuchipy.signals._common_image import CommonImage
 from kikuchipy.signals.util._dask import get_chunking
+from kikuchipy.signals.util._master_pattern import (
+    _get_direction_cosines_for_single_pc_from_detector,
+    _project_patterns_from_master_pattern,
+)
 
 
 class EBSDMasterPattern(CommonImage, Signal2D):
@@ -70,8 +73,10 @@ class EBSDMasterPattern(CommonImage, Signal2D):
 
     def __init__(self, *args, **kwargs):
         """Create an :class:`~kikuchipy.signals.EBSDMasterPattern`
-        object from a :class:`hyperspy.signals.Signal2D` or a
-        :class:`numpy.ndarray`.
+        instance from a :class:`hyperspy.signals.Signal2D` or a
+        :class:`numpy.ndarray`. See the docstring of
+        :class:`hyperspy.signal.BaseSignal` for optional input
+        parameters.
         """
         Signal2D.__init__(self, *args, **kwargs)
         self.phase = kwargs.pop("phase", Phase())
@@ -83,7 +88,7 @@ def get_patterns(
         rotations: Rotation,
         detector: EBSDDetector,
         energy: Union[int, float],
-        dtype_out: type = np.float32,
+        dtype_out: Union[type, np.dtype] = np.float32,
         compute: bool = False,
         **kwargs,
     ) -> Union[EBSD, LazyEBSD]:
@@ -96,8 +101,8 @@ def get_patterns(
         Parameters
         ----------
         rotations
-            Set of crystal rotations to get patterns from. The shape of
-            this object, a maximum of two dimensions, determines the
+            Crystal rotations to get patterns from. The shape of this
+            instance, a maximum of two dimensions, determines the
             navigation shape of the output signal.
         detector
             EBSD detector describing the detector dimensions and the
@@ -105,7 +110,9 @@ def get_patterns(
             projection/pattern center.
         energy
             Acceleration voltage, in kV, used to simulate the desired
-            master pattern to create a dictionary from.
+            master pattern to create a dictionary from. If only a single
+            energy is present in the signal, this will be returned no
+            matter its energy.
         dtype_out
             Data type of the returned patterns, by default np.float32.
         compute
@@ -122,33 +129,30 @@ def get_patterns(
         -------
         EBSD or LazyEBSD
             Signal with navigation and signal shape equal to the
-            rotation object's and detector shape, respectively.
+            rotation instance and detector shape, respectively.
 
         Notes
         -----
         If the master pattern phase has a non-centrosymmetric point
         group, both the northern and southern hemispheres must be
         provided. For more details regarding the reference frame visit
-        the reference frame user guide at:
-        https://kikuchipy.org/en/latest/reference_frames.html.
+        the reference frame user guide.
         """
-        if self.projection != "lambert":
-            raise NotImplementedError(
-                "Master pattern must be in the square Lambert projection"
-            )
+        self._is_suitable_for_projection(raise_if_not=True)
+
         if len(detector.pc) > 1:
             raise NotImplementedError(
                 "Detector must have exactly one projection center"
             )
 
-        # Get suitable chunks when iterating over the rotations. The
-        # signal axes are not chunked.
+        # Get suitable chunks when iterating over the rotations. Signal
+        # axes are not chunked.
         nav_shape = rotations.shape
         nav_dim = len(nav_shape)
         if nav_dim > 2:
             raise ValueError(
-                "The rotations object can only have one or two dimensions, but an "
-                f"object with {nav_dim} was passed"
+                "`rotations` can only have one or two dimensions, but an instance with "
+                f"{nav_dim} dimensions was passed"
             )
         data_shape = nav_shape + detector.shape
         chunks = get_chunking(
@@ -160,35 +164,24 @@ def get_patterns(
             dtype=dtype_out,
         )
 
-        # Get the master pattern arrays created by a desired energy
-        north_slice = ()
-        if "energy" in [i.name for i in self.axes_manager.navigation_axes]:
-            energies = self.axes_manager["energy"].axis
-            north_slice += ((np.abs(energies - energy)).argmin(),)
-        south_slice = north_slice
-        if self.hemisphere == "both":
-            north_slice = (0,) + north_slice
-            south_slice = (1,) + south_slice
-        elif not self.phase.point_group.contains_inversion:
-            raise AttributeError(
-                "For crystals of point groups without inversion symmetry, like the "
-                "current {self.phase.point_group.name}, both hemispheres must be "
-                "present in the master pattern signal"
-            )
-        master_north = self.data[north_slice]
-        master_south = self.data[south_slice]
-
         # Whether to rescale pattern intensities after projection
-        rescale = False
-        if dtype_out != np.float32:
+        if dtype_out != self.data.dtype:
             rescale = True
+            if isinstance(dtype_out, np.dtype):
+                dtype_out = dtype_out.type
+            out_min, out_max = dtype_range[dtype_out]
+        else:
+            rescale = False
+            # Cannot be None due to Numba, so they are set to something
+            # here. Values aren't used unless `rescale` is True.
+            out_min, out_max = 1, 2
 
         # Get direction cosines for each detector pixel relative to the
         # source point
-        dc = _get_direction_cosines(detector)
+        direction_cosines = _get_direction_cosines_for_single_pc_from_detector(detector)
 
         # Get dask array from rotations
-        r_da = da.from_array(rotations.data, chunks=chunks[:nav_dim] + (-1,))
+        rot_da = da.from_array(rotations.data, chunks=chunks[:nav_dim] + (-1,))
 
         # Which axes to drop and add when iterating over the rotations
         # dask array to produce the EBSD signal array, i.e. drop the
@@ -201,19 +194,24 @@ def get_patterns(
             drop_axis = 2
             new_axis = (2, 3)
 
+        master_north, master_south = self._get_master_pattern_arrays_from_energy(energy)
+
         # Project simulated patterns onto detector
         npx, npy = self.axes_manager.signal_shape
         scale = (npx - 1) / 2
-        simulated = r_da.map_blocks(
-            _get_patterns_chunk,
-            dc=dc,
+        # TODO: Use dask.delayed instead?
+        simulated = rot_da.map_blocks(
+            _project_patterns_from_master_pattern,
+            direction_cosines=direction_cosines,
             master_north=master_north,
             master_south=master_south,
-            npx=npx,
-            npy=npy,
-            scale=scale,
-            rescale=rescale,
+            npx=int(npx),
+            npy=int(npy),
+            scale=float(scale),
             dtype_out=dtype_out,
+            rescale=rescale,
+            out_min=out_min,
+            out_max=out_max,
             drop_axis=drop_axis,
             new_axis=new_axis,
             chunks=chunks,
@@ -246,19 +244,22 @@ def get_patterns(
         ]
 
         if compute:
+            patterns = np.zeros(shape=simulated.shape, dtype=simulated.dtype)
             with ProgressBar():
                 print(
                     f"Creating a dictionary of {nav_shape} simulated patterns:",
                     file=sys.stdout,
                 )
-                patterns = simulated.compute()
+                simulated.store(patterns, compute=True)
             out = EBSD(patterns, axes=axes, **kwargs)
         else:
             out = LazyEBSD(simulated, axes=axes, **kwargs)
+        gc.collect()
 
         return out
 
     # ------ Methods overwritten from hyperspy.signals.Signal2D ------ #
+
     def deepcopy(self):
         new = super().deepcopy()
         new.phase = self.phase.deepcopy()
@@ -266,6 +267,66 @@ def deepcopy(self):
         new.hemisphere = copy.deepcopy(self.hemisphere)
         return new
 
+    # ------------------------ Private methods ----------------------- #
+
+    def _get_master_pattern_arrays_from_energy(
+        self, energy: Union[int, float]
+    ) -> Tuple[np.ndarray, np.ndarray]:
+        """Return northern and southern master patterns created with a
+        single, given energy.
+
+        Parameters
+        ----------
+        energy
+            Acceleration voltage in kV. If only a single energy is
+            present in the signal, this will be returned no matter its
+            energy.
+
+        Returns
+        -------
+        master_north, master_south
+            Northern and southern hemispheres of master pattern.
+        """
+        if "energy" in [i.name for i in self.axes_manager.navigation_axes]:
+            master_patterns = self.inav[float(energy)].data
+        else:  # Assume a single energy
+            master_patterns = self.data
+        if self.hemisphere == "both":
+            master_north, master_south = master_patterns
+        else:
+            master_north = master_south = master_patterns
+        return master_north, master_south
+
+    def _is_suitable_for_projection(self, raise_if_not: bool = False):
+        """Check whether the master pattern is suitable for projection
+        onto an EBSD detector and return a bool or raise an error
+        message if desired.
+        """
+        suitable = True
+        error = None
+        if self.projection != "lambert":
+            error = NotImplementedError(
+                "Master pattern must be in the square Lambert projection"
+            )
+            suitable = False
+        pg = self.phase.point_group
+        if pg is None:
+            error = AttributeError(
+                "Master pattern `phase` attribute must have a valid point group"
+            )
+            suitable = False
+        elif self.hemisphere != "both" and not pg.contains_inversion:
+            error = AttributeError(
+                "For point groups without inversion symmetry, like the current "
+                f"{pg.name}, both hemispheres must be present in the master pattern "
+                "signal"
+            )
+            suitable = False
+        if not suitable and raise_if_not:
+            raise error
+        else:
+            return suitable
+
 
 class LazyEBSDMasterPattern(EBSDMasterPattern, LazySignal2D):
     """Lazy implementation of the :class:`EBSDMasterPattern` class.
@@ -280,205 +341,3 @@ class LazyEBSDMasterPattern(EBSDMasterPattern, LazySignal2D):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-
-
-def _get_direction_cosines(detector: EBSDDetector) -> Vector3d:
-    """Get the direction cosines between the detector and sample as done
-    in EMsoft and :cite:`callahan2013dynamical`.
-
-    Parameters
-    ----------
-    detector : EBSDDetector
-        EBSDDetector object with a certain detector geometry and one
-        projection center.
-
-    Returns
-    -------
-    Vector3d
-        Direction cosines for each detector pixel.
-    """
-    nrows, ncols = detector.shape
-
-    pcx, pcy, pcz = detector.pc[0]
-    xpc = ncols * (0.5 - pcx)
-    ypc = nrows * (0.5 - pcy)
-    L = nrows * pcz
-
-    ncols_array = np.arange(ncols)
-    nrows_array = np.arange(nrows)
-
-    # Detector coordinates in microns
-    det_x = -((-xpc - (1.0 - ncols) * 0.5) - ncols_array)
-    det_y = (ypc - (1.0 - nrows) * 0.5) - nrows_array
-
-    # Auxilliary angle to rotate between reference frames
-    theta_c = np.radians(detector.tilt)
-    sigma = np.radians(detector.sample_tilt)
-
-    alpha = (np.pi / 2) - sigma + theta_c
-    ca = np.cos(alpha)
-    sa = np.sin(alpha)
-
-    # Angle between normal of sample and detector. A positive angle
-    # means the detector normal moves towards the right looking from the
-    # detector to the sample
-    omega = np.radians(detector.azimuthal)
-    cw = np.cos(omega)
-    sw = np.sin(omega)
-
-    r_g_array = np.zeros((nrows, ncols, 3))
-
-    Ls = -sw * det_x + L * cw
-    Lc = cw * det_x + L * sw
-
-    i, j = np.meshgrid(nrows_array[::-1], ncols_array, indexing="ij")
-
-    r_g_array[..., 0] = det_y[i] * ca + sa * Ls[j]
-    r_g_array[..., 1] = Lc[j]
-    r_g_array[..., 2] = -sa * det_y[i] + ca * Ls[j]
-    r_g = Vector3d(r_g_array)
-
-    return r_g.unit
-
-
-def _get_lambert_interpolation_parameters(
-    rotated_direction_cosines: Vector3d, npx: int, npy: int, scale: Union[int, float]
-) -> tuple:
-    """Get interpolation parameters in the square Lambert projection, as
-    implemented in EMsoft.
-
-    Parameters
-    ----------
-    rotated_direction_cosines
-        Rotated direction cosines vector.
-    npx
-        Number of pixels on the master pattern in the x direction.
-    npy
-        Number of pixels on the master pattern in the y direction.
-    scale
-        Factor to scale up from the square Lambert projection to the
-        master pattern.
-
-    Returns
-    -------
-    nii : numpy.ndarray
-        Row coordinate of a point.
-    nij : numpy.ndarray
-        Column coordinate of a point.
-    niip : numpy.ndarray
-        Row coordinate of neighboring point.
-    nijp : numpy.ndarray
-        Column coordinate of a neighboring point.
-    di : numpy.ndarray
-        Row interpolation weight factor.
-    dj : numpy.ndarray
-        Column interpolation weight factor.
-    dim : numpy.ndarray
-        Row interpolation weight factor.
-    djm : numpy.ndarray
-        Column interpolation weight factor.
-    """
-    # Direction cosines to the square Lambert projection
-    xy = (
-        scale
-        * LambertProjection.project(rotated_direction_cosines)
-        / (np.sqrt(np.pi / 2))
-    )
-
-    i = xy[..., 1]
-    j = xy[..., 0]
-    nii = (i + scale).astype(int)
-    nij = (j + scale).astype(int)
-    niip = nii + 1
-    nijp = nij + 1
-    niip = np.where(niip < npx, niip, nii).astype(int)
-    nijp = np.where(nijp < npy, nijp, nij).astype(int)
-    nii = np.where(nii < 0, niip, nii).astype(int)
-    nij = np.where(nij < 0, nijp, nij).astype(int)
-    di = i - nii + scale
-    dj = j - nij + scale
-    dim = 1.0 - di
-    djm = 1.0 - dj
-
-    return nii, nij, niip, nijp, di, dj, dim, djm
-
-
-def _get_patterns_chunk(
-    rotations_array: np.ndarray,
-    dc: Vector3d,
-    master_north: np.ndarray,
-    master_south: np.ndarray,
-    npx: int,
-    npy: int,
-    scale: Union[int, float],
-    rescale: bool,
-    dtype_out: Optional[type] = np.float32,
-) -> np.ndarray:
-    """Get the EBSD patterns on the detector for each rotation in the
-    chunk. Each pattern is found by a bi-quadratic interpolation of the
-    master pattern as described in EMsoft.
-
-    Parameters
-    ----------
-    rotations_array
-        Array of rotations of shape (..., 4) for a given chunk in
-        quaternions.
-    dc
-        Direction cosines unit vector between detector and sample.
-    master_north
-        Northern hemisphere of the master pattern.
-    master_south
-        Southern hemisphere of the master pattern.
-    npx
-        Number of pixels in the x-direction on the master pattern.
-    npy
-        Number of pixels in the y-direction on the master pattern.
-    scale
-        Factor to scale up from square Lambert projection to the master
-        pattern.
-    rescale
-        Whether to call rescale_intensities() or not.
-    dtype_out
-        Data type of the returned patterns, by default np.float32.
-
-    Returns
-    -------
-    numpy.ndarray
-        3D or 4D array with simulated patterns.
-    """
-    rotations = Rotation(rotations_array)
-    rotations_shape = rotations_array.shape[:-1]
-    simulated = np.empty(shape=rotations_shape + dc.shape, dtype=dtype_out)
-    for i in np.ndindex(rotations_shape):
-        rotated_dc = rotations[i] * dc
-        (
-            nii,
-            nij,
-            niip,
-            nijp,
-            di,
-            dj,
-            dim,
-            djm,
-        ) = _get_lambert_interpolation_parameters(
-            rotated_direction_cosines=rotated_dc, npx=npx, npy=npy, scale=scale
-        )
-        pattern = np.where(
-            rotated_dc.z >= 0,
-            (
-                master_north[nii, nij] * dim * djm
-                + master_north[niip, nij] * di * djm
-                + master_north[nii, nijp] * dim * dj
-                + master_north[niip, nijp] * di * dj
-            ),
-            (
-                master_south[nii, nij] * dim * djm
-                + master_south[niip, nij] * di * djm
-                + master_south[nii, nijp] * dim * dj
-                + master_south[niip, nijp] * di * dj
-            ),
-        )
-        if rescale:
-            pattern = rescale_intensity(pattern, dtype_out=dtype_out)
-        simulated[i] = pattern
-    return simulated
diff --git a/kikuchipy/signals/util/__init__.py b/kikuchipy/signals/util/__init__.py
index 19c1f1b7..56839781 100644
--- a/kikuchipy/signals/util/__init__.py
+++ b/kikuchipy/signals/util/__init__.py
@@ -16,8 +16,9 @@
 # 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 and
-for controlling chunking of :class:`dask.array.Array`.
+"""Signal utilities for handling signal metadata and attributes, output
+from signal methods, and for controlling chunking of lazy signal data in
+:class:`~dask.array.Array`.
 """
 
 from kikuchipy.signals.util._dask import get_chunking, get_dask_array
diff --git a/kikuchipy/signals/util/_crystal_map.py b/kikuchipy/signals/util/_crystal_map.py
index 545d2dba..4fc3d491 100644
--- a/kikuchipy/signals/util/_crystal_map.py
+++ b/kikuchipy/signals/util/_crystal_map.py
@@ -16,37 +16,40 @@
 # You should have received a copy of the GNU General Public License
 # along with kikuchipy. If not, see <http://www.gnu.org/licenses/>.
 
-"""Utilities for combining a crystallographic map,
-:class:`orix.crystal_map.CrystalMap` with an EBSD signal,
-:class:`kikuchipy.signals.EBSD`.
+"""Utilities for working with a :class:`~orix.crystal_map.CrystalMap`
+and an :class:`~kikuchipy.signals.EBSD` signal.
 """
 
 import warnings
 
-from hyperspy.axes import AxesManager
+import numpy as np
 from orix.crystal_map import CrystalMap
 
 
-def crystal_map_is_compatible_with_signal(
-    xmap: CrystalMap, axes_manager: AxesManager, raise_if_false: bool = False
+def _crystal_map_is_compatible_with_signal(
+    xmap: CrystalMap, navigation_axes: tuple, raise_if_not: bool = False
 ) -> bool:
-    nav_axes = axes_manager.navigation_axes[::-1]
-    nav_shape = tuple([a.size for a in nav_axes])
-    nav_scale = tuple([a.scale for a in nav_axes])
-    compatible = None
+    """Check whether a signal's navigation axes are compatible with a
+    crystal map.
+    """
+    nav_shape = tuple([a.size for a in navigation_axes])
+    nav_scale = tuple([a.scale for a in navigation_axes])
+
     try:
-        xmap_scale = tuple([xmap._step_sizes[a.name] for a in nav_axes])
+        xmap_scale = tuple([xmap._step_sizes[a.name] for a in navigation_axes])
     except KeyError:
         warnings.warn(
             "The signal navigation axes must be named 'x' and/or 'y' in order to "
             "compare the signal navigation scale to the CrystalMap step sizes 'dx' and "
             "'dy' (see `EBSD.axes_manager`)"
         )
-        xmap_scale = (None,) * len(nav_axes)
-        compatible = False
-    compatible = xmap.shape == nav_shape and xmap_scale == nav_scale
-    if not compatible and raise_if_false:
-        raise AttributeError(
+        xmap_scale = list(xmap._step_sizes.values())[: -len(nav_shape)]
+
+    compatible = xmap.shape == nav_shape and np.allclose(
+        xmap_scale, nav_scale, atol=1e-6
+    )
+    if not compatible and raise_if_not:
+        raise ValueError(
             f"The crystal map shape {xmap.shape} and step sizes {xmap_scale} aren't "
             f"compatible with the signal navigation shape {nav_shape} and step sizes "
             f"{nav_scale} (see `EBSD.axes_manager`)"
diff --git a/kikuchipy/signals/util/_dask.py b/kikuchipy/signals/util/_dask.py
index 99ed861f..6b9060fe 100644
--- a/kikuchipy/signals/util/_dask.py
+++ b/kikuchipy/signals/util/_dask.py
@@ -29,7 +29,7 @@ def get_chunking(
     sig_dim: Optional[int] = None,
     chunk_shape: Optional[int] = None,
     chunk_bytes: Union[int, float, str, None] = 30e6,
-    dtype: Optional[np.dtype] = None,
+    dtype: Optional[type] = None,
 ) -> tuple:
     """Get a chunk tuple based on the shape of the signal data.
 
@@ -100,7 +100,7 @@ def get_chunking(
     return chunks
 
 
-def get_dask_array(signal, dtype: Optional[np.dtype] = None, **kwargs) -> da.Array:
+def get_dask_array(signal, dtype: Optional[type] = None, **kwargs) -> da.Array:
     """Return dask array of patterns with appropriate chunking.
 
     Parameters
@@ -224,7 +224,7 @@ def _rechunk_learning_results(
 def _update_learning_results(
     learning_results,
     components: Union[None, int, List[int]],
-    dtype_out: np.dtype,
+    dtype_out: type,
 ) -> Tuple[np.ndarray, np.ndarray]:
     """Update learning results before calling
     :meth:`hyperspy.learn.mva.MVA.get_decomposition_model` by
diff --git a/kikuchipy/signals/util/_detector.py b/kikuchipy/signals/util/_detector.py
new file mode 100644
index 00000000..02b6de2c
--- /dev/null
+++ b/kikuchipy/signals/util/_detector.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
+
+"""Utilities for attaching an EBSD detector,
+:class:`kikuchipy.detectors.EBSDDetector` to an EBSD signal,
+:class:`kikuchipy.signals.EBSD`.
+"""
+
+from kikuchipy.detectors import EBSDDetector
+
+
+def _detector_is_compatible_with_signal(
+    detector: EBSDDetector,
+    navigation_shape: tuple,
+    signal_shape: tuple,
+    raise_if_not: bool = False,
+) -> bool:
+    """Check whether a signal's navigation and signal shape is
+    compatible with a detector and return a bool or raise a ValueError
+    if it is not.
+    """
+    # TODO: Check pixel scale
+    compatible = True
+    error_msg = None
+
+    if signal_shape != detector.shape:
+        compatible = False
+        error_msg = "Detector and signal must have the same signal shape"
+
+    detector_nav_shape = detector.navigation_shape
+    if detector_nav_shape != (1,) and detector_nav_shape != navigation_shape:
+        compatible = False
+        error_msg = (
+            "Detector must have exactly one projection center (PC), or one PC per "
+            "pattern in an array of shape signal's navigation shape + (3,)"
+        )
+
+    if raise_if_not and not compatible:
+        raise ValueError(error_msg)
+    else:
+        return compatible
diff --git a/kikuchipy/signals/util/_master_pattern.py b/kikuchipy/signals/util/_master_pattern.py
new file mode 100644
index 00000000..bc2e7f01
--- /dev/null
+++ b/kikuchipy/signals/util/_master_pattern.py
@@ -0,0 +1,541 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
+
+"""Private tools for projecting parts of dynamically simulated master
+patterns into a detector.
+"""
+
+from typing import Optional, Tuple, Union
+
+import numba as nb
+import numpy as np
+
+from kikuchipy.pattern._pattern import _rescale
+from kikuchipy.projections.lambert_projection import _vector2xy
+from kikuchipy._rotation import _rotate_vector
+
+
+# Reusable constants
+SQRT_PI_HALF = np.sqrt(np.pi / 2)
+
+
+def _get_direction_cosines_for_single_pc_from_detector(detector) -> np.ndarray:
+    pcx, pcy, pcz = detector.pc[0].astype(float)
+    return _get_direction_cosines_for_single_pc(
+        pcx=pcx,
+        pcy=pcy,
+        pcz=pcz,
+        nrows=detector.nrows,
+        ncols=detector.ncols,
+        tilt=detector.tilt,
+        azimuthal=detector.azimuthal,
+        sample_tilt=detector.sample_tilt,
+    )
+
+
+@nb.jit(
+    "Tuple((float64, float64, float64, float64))(float64, float64, float64)",
+    nogil=True,
+    nopython=True,
+)
+def _get_cosine_sine_of_alpha_and_azimuthal(
+    sample_tilt: float, tilt: float, azimuthal: float
+) -> Tuple[float, float, float, float]:
+    alpha = (np.pi / 2) - np.deg2rad(sample_tilt) + np.deg2rad(tilt)
+    azimuthal = np.deg2rad(azimuthal)
+    return np.cos(alpha), np.sin(alpha), np.cos(azimuthal), np.sin(azimuthal)
+
+
+@nb.jit(
+    (
+        "float64[:, :, :]"
+        "(float64, float64, float64, int64, int64, float64, float64, float64)"
+    ),
+    nogil=True,
+    nopython=True,
+)
+def _get_direction_cosines_for_single_pc(
+    pcx: float,
+    pcy: float,
+    pcz: float,
+    nrows: int,
+    ncols: int,
+    tilt: float,
+    azimuthal: float,
+    sample_tilt: float,
+) -> np.ndarray:
+    """Get the direction cosines between the detector and sample for a
+    single projection center, as implemented in EMsoft and shown in
+    :cite:`callahan2013dynamical`.
+
+    Parameters
+    ----------
+    pcx
+        Projection center (PC) x coordinate.
+    pcy
+        PC y coordinate.
+    pcz
+        PC z coordinate.
+    nrows
+        Number of detector rows.
+    ncols
+        Number of detector columns.
+    tilt
+        Detector tilt from horizontal in degrees.
+    azimuthal
+        Sample tilt about the sample RD axis in degrees.
+    sample_tilt
+        Sample tilt from horizontal in degrees.
+
+    Returns
+    -------
+    r_g_array
+        Direction cosines for each detector pixel of shape (nrows,
+        ncols, 3) and data type of 64-bit floats.
+
+    See Also
+    --------
+    kikuchipy.detectors.EBSDDetector
+
+    Notes
+    -----
+    This function is optimized with Numba, so care must be taken with
+    array shapes and data types.
+    """
+    nrows_array = np.arange(nrows)
+    ncols_array = np.arange(ncols)
+
+    # Bruker to EMsoft's v5 PC convention
+    xpc = ncols * (0.5 - pcx)
+    ypc = nrows * (0.5 - pcy)
+    zpc = nrows * pcz
+
+    det_x = xpc + (1 - ncols) * 0.5 + ncols_array
+    det_y = ypc - (1 - nrows) * 0.5 - nrows_array
+
+    ca, sa, cw, sw = _get_cosine_sine_of_alpha_and_azimuthal(
+        sample_tilt=sample_tilt,
+        tilt=tilt,
+        azimuthal=azimuthal,
+    )
+
+    Ls = -sw * det_x + zpc * cw
+    Lc = cw * det_x + zpc * sw
+
+    r_g_array = np.zeros((nrows, ncols, 3))
+
+    for row in nb.prange(nrows):
+        rr = nrows - row - 1
+        for col in nb.prange(ncols):
+            r_g_array[row, col, 0] = det_y[rr] * ca + sa * Ls[col]
+            r_g_array[row, col, 1] = Lc[col]
+            r_g_array[row, col, 2] = -sa * det_y[rr] + ca * Ls[col]
+
+    # Normalize
+    norm = np.sqrt(np.sum(np.square(r_g_array), axis=-1))
+    norm = np.expand_dims(norm, axis=-1)
+    r_g_array = np.divide(r_g_array, norm)
+
+    return r_g_array
+
+
+@nb.jit(
+    (
+        "float64[:, :, :, :]"
+        "(float64[:], float64[:], float64[:], int64, int64, float64, float64, float64)"
+    ),
+    nogil=True,
+    nopython=True,
+)
+def _get_direction_cosines_for_multiple_pcs(
+    pcx: np.ndarray,
+    pcy: np.ndarray,
+    pcz: np.ndarray,
+    nrows: int,
+    ncols: int,
+    tilt: float,
+    azimuthal: float,
+    sample_tilt: float,
+) -> np.ndarray:
+    """Get the direction cosines between the detector and sample for
+    multiple projection centers, as implemented in EMsoft and shown in
+    :cite:`callahan2013dynamical`.
+
+    Parameters
+    ----------
+    pcx
+        Projection center (PC) x coordinates. Must be a 1D array.
+    pcy
+        PC y coordinates. Must be a 1D array.
+    pcz
+        PC z coordinates. Must be a 1D array.
+    nrows
+        Number of detector rows.
+    ncols
+        Number of detector columns.
+    tilt
+        Detector tilt from horizontal in degrees.
+    azimuthal
+        Sample tilt about the sample RD axis in degrees.
+    sample_tilt
+        Sample tilt from horizontal in degrees.
+
+    Returns
+    -------
+    r_g_array
+        Direction cosines for each detector pixel for each PC, of shape
+        (n PCs, nrows, ncols, 3) and data type of 64-bit floats.
+
+    See Also
+    --------
+    kikuchipy.detectors.EBSDDetector
+
+    Notes
+    -----
+    This function is optimized with Numba, so care must be taken with
+    array shapes and data types.
+    """
+    nrows_array = np.arange(nrows)
+    ncols_array = np.arange(ncols)
+
+    ca, sa, cw, sw = _get_cosine_sine_of_alpha_and_azimuthal(
+        sample_tilt=sample_tilt,
+        tilt=tilt,
+        azimuthal=azimuthal,
+    )
+
+    det_x_factor = (1 - ncols) * 0.5
+    det_y_factor = (1 - nrows) * 0.5
+
+    n_pcs = pcx.size
+    r_g_array = np.zeros((n_pcs, nrows, ncols, 3))
+
+    for pci in nb.prange(n_pcs):
+        # Bruker to EMsoft's v5 PC convention
+        xpc = ncols * (0.5 - pcx[pci])
+        ypc = nrows * (0.5 - pcy[pci])
+        zpc = nrows * pcz[pci]
+
+        det_x = xpc + det_x_factor + ncols_array
+        det_y = ypc - det_y_factor - nrows_array
+
+        Ls = -sw * det_x + zpc * cw
+        Lc = cw * det_x + zpc * sw
+
+        for row in nb.prange(nrows):
+            rr = nrows - row - 1
+            for col in nb.prange(ncols):
+                r_g_array[pci, row, col, 0] = det_y[rr] * ca + sa * Ls[col]
+                r_g_array[pci, row, col, 1] = Lc[col]
+                r_g_array[pci, row, col, 2] = -sa * det_y[rr] + ca * Ls[col]
+
+    # Normalize
+    norm = np.sqrt(np.sum(np.square(r_g_array), axis=-1))
+    norm = np.expand_dims(norm, axis=-1)
+    r_g_array = np.divide(r_g_array, norm)
+
+    return r_g_array
+
+
+@nb.jit(nogil=True, nopython=True)
+def _project_patterns_from_master_pattern(
+    rotations: np.ndarray,
+    direction_cosines: np.ndarray,
+    master_north: np.ndarray,
+    master_south: np.ndarray,
+    npx: int,
+    npy: int,
+    scale: float,
+    rescale: bool,
+    out_min: Union[int, float],
+    out_max: Union[int, float],
+    dtype_out: Optional[type] = np.float32,
+) -> np.ndarray:
+    """Project one simulated EBSD pattern onto a detector per rotation,
+    given one direction cosine per detector pixel, describing the
+    detector's view of the sample.
+
+    Parameters
+    ----------
+    rotations
+        Array of rotations of shape (..., 4) for a given chunk as
+        quaternions.
+    direction_cosines
+        Direction cosines (unit vectors) between detector and sample of
+        shape (nrows, ncols, 3).
+    master_north
+        Northern hemisphere of the master pattern.
+    master_south
+        Southern hemisphere of the master pattern.
+    npx
+        Number of pixels in the x-direction on the master pattern.
+    npy
+        Number of pixels in the y-direction on the master pattern.
+    scale
+        Factor to scale up from square Lambert projection to the master
+        pattern.
+    rescale
+        Whether to rescale pattern intensities.
+    dtype_out
+        NumPy data type of the returned patterns, by default 32-bit
+        float.
+
+    Returns
+    -------
+    numpy.ndarray
+        3D or 4D array with simulated patterns.
+
+    Notes
+    -----
+    This function is optimized with Numba, so care must be taken with
+    array shapes and data types.
+    """
+    nav_shape = rotations.shape[:-1]
+    sig_shape = direction_cosines.shape[:-1]
+    n_pixels = sig_shape[0] * sig_shape[1]
+    simulated = np.zeros(nav_shape + (n_pixels,), dtype=dtype_out)
+
+    direction_cosines_flat = direction_cosines.reshape((-1, 3))
+
+    for i in np.ndindex(nav_shape):
+        simulated[i] = _project_single_pattern_from_master_pattern(
+            rotation=rotations[i],
+            direction_cosines=direction_cosines_flat,
+            master_north=master_north,
+            master_south=master_south,
+            npx=npx,
+            npy=npy,
+            scale=scale,
+            n_pixels=n_pixels,
+            rescale=rescale,
+            out_min=out_min,
+            out_max=out_max,
+            dtype_out=dtype_out,
+        )
+
+    return simulated.reshape(nav_shape + sig_shape)
+
+
+@nb.jit(nogil=True, nopython=True)
+def _project_single_pattern_from_master_pattern(
+    rotation: np.ndarray,
+    direction_cosines: np.ndarray,
+    master_north: np.ndarray,
+    master_south: np.ndarray,
+    npx: int,
+    npy: int,
+    scale: float,
+    n_pixels: int,
+    rescale: bool,
+    out_min: Union[int, float],
+    out_max: Union[int, float],
+    dtype_out: type,
+) -> np.ndarray:
+    """Project a single 1D EBSD pattern onto a detector given one
+    rotation and one direction cosine per detector pixel, describing the
+    detector's view of the sample.
+
+    Parameters
+    ----------
+    rotation
+        Array of one rotation of shape (4,).
+    direction_cosines
+        Direction cosines (unit vectors) between detector and sample of
+        shape (n_pixels, 3).
+    master_north
+        Northern hemisphere of the master pattern.
+    master_south
+        Southern hemisphere of the master pattern.
+    npx
+        Number of pixels in the x-direction on the master pattern.
+    npy
+        Number of pixels in the y-direction on the master pattern.
+    scale
+        Factor to scale up from square Lambert projection to the master
+        pattern.
+    n_pixels
+        Number of detector pixels.
+    rescale
+        Whether to rescale pattern intensities.
+    dtype_out
+        NumPy data type of the returned patterns.
+
+    Returns
+    -------
+    numpy.ndarray
+        1D simulated EBSD pattern of data type `dtype_out`.
+
+    Notes
+    -----
+    This function is optimized with Numba, so care must be taken with
+    array shapes and data types.
+    """
+    # Rotate the detector's view of the crystal
+    rotated_direction_cosines = _rotate_vector(rotation, direction_cosines)
+
+    (nii, nij, niip, nijp, di, dj, dim, djm,) = _get_lambert_interpolation_parameters(
+        v=rotated_direction_cosines, npx=npx, npy=npy, scale=scale
+    )
+
+    # Loop over the detector pixels and fill in intensities one by one
+    # from the correct hemisphere of the master pattern
+    pattern = np.zeros((n_pixels,))
+    for i in nb.prange(n_pixels):
+        if rotated_direction_cosines[i, 2] >= 0:
+            mp = master_north
+        else:
+            mp = master_south
+        pattern[i] = _get_pixel_from_master_pattern(
+            mp, nii[i], nij[i], niip[i], nijp[i], di[i], dj[i], dim[i], djm[i]
+        )
+
+    # Potentially rescale pattern intensities to desired data type
+    if rescale:
+        pattern = _rescale(pattern, np.min(pattern), np.max(pattern), out_min, out_max)
+
+    return pattern.astype(dtype_out)
+
+
+@nb.jit(
+    (
+        "Tuple((int32[:], int32[:], int32[:], int32[:], float64[:], float64[:], "
+        "float64[:], float64[:]))(float64[:, :], int64, int64, float64)"
+    ),
+    nogil=True,
+    nopython=True,
+)
+def _get_lambert_interpolation_parameters(
+    v: np.ndarray,
+    npx: int,
+    npy: int,
+    scale: float,
+) -> tuple:
+    """Return interpolation parameters in the square Lambert projection
+    from vectors (direction cosines), as implemented in EMsoft.
+
+    Parameters
+    ----------
+    v
+        Vectors (direction cosines) of shape (n, 3) and as 64-bit
+        floats.
+    npx
+        Number of horizontal master pattern pixels as a 64-bit integer.
+    npy
+        Number of vertical master pattern pixels as a 64-bit integer.
+    scale
+        Factor to scale up from the square Lambert projection to the
+        master pattern, as a 64-bit float.
+
+    Returns
+    -------
+    nii : numpy.ndarray
+        1D array of each vector's row coordinate as 32-bit integers.
+    nij : numpy.ndarray
+        1D array of each vector's column coordinate as 32-bit integers.
+    niip : numpy.ndarray
+        1D array of each vector's neighbouring row coordinate as 32-bit
+        integers.
+    nijp : numpy.ndarray
+        1D array of each vector's neighbouring column coordinate as
+        32-bit integers.
+    di : numpy.ndarray
+        1D array of each vector's row interpolation weight factor as
+        64-bit floats.
+    dj : numpy.ndarray
+        1D array of each vector's column interpolation weight factor as
+        64-bit floats.
+    dim : numpy.ndarray
+        1D array of each vector's neighbouring row interpolation weight
+        factor as 64-bit floats.
+    djm : numpy.ndarray
+        1D array of each vector's neighbouring column interpolation
+        weight factor as 64-bit floats.
+    """
+    xy = scale * _vector2xy(v) / SQRT_PI_HALF
+
+    i = xy[:, 1]
+    j = xy[:, 0]
+
+    dtype = np.int32
+    n = i.size
+    nii = np.zeros(n, dtype=dtype)
+    nij = np.zeros(n, dtype=dtype)
+    niip = np.zeros(n, dtype=dtype)
+    nijp = np.zeros(n, dtype=dtype)
+    di = np.zeros(n)
+    dj = np.zeros(n)
+    dim = np.zeros(n)
+    djm = np.zeros(n)
+    # Use loop to avoid numpy.where() for the conditionals
+    for ii in nb.prange(n):
+        i_this = i[ii]
+        j_this = j[ii]
+
+        nii_i = dtype(i_this + scale)
+        nij_i = dtype(j_this + scale)
+        niip_i = nii_i + 1
+        nijp_i = nij_i + 1
+        if niip_i > npx:
+            niip_i = nii_i  # pragma: no cover
+        if nijp_i > npy:
+            nijp_i = nij_i  # pragma: no cover
+        if nii_i < 0:
+            nii_i = niip_i  # pragma: no cover
+        if nij_i < 0:
+            nij_i = nijp_i  # pragma: no cover
+
+        nii[ii] = nii_i
+        nij[ii] = nij_i
+        niip[ii] = niip_i
+        nijp[ii] = nijp_i
+        di[ii] = i_this - nii_i + scale
+        dj[ii] = j_this - nij_i + scale
+        dim[ii] = 1 - di[ii]
+        djm[ii] = 1 - dj[ii]
+
+    return nii, nij, niip, nijp, di, dj, dim, djm
+
+
+@nb.jit(nogil=True, nopython=True)
+def _get_pixel_from_master_pattern(
+    mp: np.ndarray,
+    nii: int,
+    nij: int,
+    niip: int,
+    nijp: int,
+    di: float,
+    dj: float,
+    dim: float,
+    djm: float,
+):
+    """Return an intensity from a master pattern in the square Lambert
+    projection using bi-linear interpolation.
+
+    Notes
+    -----
+    This function is optimized with Numba, so care must be taken with
+    array shapes and data types.
+    """
+    # Cannot specify output data type because master pattern array can
+    # be either integer or float
+    return (
+        mp[nii, nij] * dim * djm
+        + mp[niip, nij] * di * djm
+        + mp[nii, nijp] * dim * dj
+        + mp[niip, nijp] * di * dj
+    )


Test patch:
diff --git a/kikuchipy/_rotation/tests/__init__.py b/kikuchipy/_rotation/tests/__init__.py
new file mode 100644
index 00000000..c8719dac
--- /dev/null
+++ b/kikuchipy/_rotation/tests/__init__.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
diff --git a/kikuchipy/_rotation/tests/test_rotation.py b/kikuchipy/_rotation/tests/test_rotation.py
new file mode 100644
index 00000000..01d56939
--- /dev/null
+++ b/kikuchipy/_rotation/tests/test_rotation.py
@@ -0,0 +1,53 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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
+from orix import quaternion, vector
+
+import kikuchipy as kp
+
+
+class TestRotationVectorTools:
+    def test_rotate_vector(self):
+        """Make sure the Numba function is covered and returns the same
+        result as orix.
+        """
+        rot = np.array([0.7071, 0.7071, 0, 0])
+        dc = kp.signals.util._master_pattern._get_direction_cosines_for_single_pc.py_func(
+            pcx=0.5,
+            pcy=0.5,
+            pcz=0.5,
+            nrows=480,
+            ncols=640,
+            tilt=10,
+            azimuthal=0,
+            sample_tilt=70,
+        )
+        dc = dc.reshape((-1, 3))
+        rotated_dc = kp._rotation._rotate_vector.py_func(rot, dc)
+
+        rot_orix = quaternion.Rotation(rot)
+        dc_orix = vector.Vector3d(dc)
+        rotated_dc_orix = rot_orix * dc_orix
+
+        assert np.allclose(rotated_dc, rotated_dc_orix.data, atol=1e-3)
+
+    def test_rotation_from_euler(self):
+        euler = np.array([1, 2, 3])
+        rot_orix = quaternion.Rotation.from_euler(euler).data
+        assert np.allclose(rot_orix, kp._rotation._rotation_from_euler.py_func(*euler))
diff --git a/kikuchipy/conftest.py b/kikuchipy/conftest.py
index 1f60bfbc..f7c8a35b 100644
--- a/kikuchipy/conftest.py
+++ b/kikuchipy/conftest.py
@@ -21,15 +21,15 @@
 import os
 from packaging import version
 import tempfile
-from typing import Tuple
 
+import dask.array as da
 from diffpy.structure import Atom, Lattice, Structure
 from diffsims.crystallography import ReciprocalLatticePoint
 from hyperspy import __version__ as hs_version
 from hyperspy.misc.utils import DictionaryTreeBrowser
 import matplotlib.pyplot as plt
 import numpy as np
-from orix.crystal_map import CrystalMap, Phase, PhaseList
+from orix.crystal_map import CrystalMap, create_coordinate_arrays, Phase, PhaseList
 from orix.quaternion.rotation import Rotation
 from orix.vector import Vector3d, neo_euler
 import pytest
@@ -69,28 +69,6 @@ def assert_dictionary(dict1, dict2):
                 assert dict1[key] == dict2[key]
 
 
-def _get_spatial_array_dicts(
-    nav_shape: Tuple[int, int], step_sizes: Tuple[int, int] = (1.5, 1)
-) -> Tuple[dict, int]:
-    ny, nx = nav_shape
-    dy, dx = step_sizes
-    d = {"x": None, "y": None, "z": None}
-    map_size = 1
-    if nx > 1:
-        if ny > 1:
-            d["x"] = np.tile(np.arange(nx) * dx, ny)
-        else:
-            d["x"] = np.arange(nx) * dx
-        map_size *= nx
-    if ny > 1:
-        if nx > 1:
-            d["y"] = np.sort(np.tile(np.arange(ny) * dy, nx))
-        else:
-            d["y"] = np.arange(ny) * dy
-        map_size *= ny
-    return d, map_size
-
-
 # ------------------------------ Setup ------------------------------ #
 
 
@@ -118,7 +96,10 @@ def dummy_signal():
         dtype=np.uint8
     ).reshape((3, 3, 3, 3))
     # fmt: on
-    return kp.signals.EBSD(dummy_array)
+    s = kp.signals.EBSD(dummy_array)
+    s.axes_manager.navigation_axes[1].name = "x"
+    s.axes_manager.navigation_axes[0].name = "y"
+    yield s
 
 
 @pytest.fixture
@@ -128,7 +109,41 @@ def dummy_background():
     compare the output from methods using this background (as input) to
     hard-coded outputs.
     """
-    return np.array([5, 4, 5, 4, 3, 4, 4, 4, 3], dtype=np.uint8).reshape((3, 3))
+    yield np.array([5, 4, 5, 4, 3, 4, 4, 4, 3], dtype=np.uint8).reshape((3, 3))
+
+
+@pytest.fixture(params=[[(3, 3), (3, 3), False, np.float32]])
+def ebsd_with_axes_and_random_data(request):
+    """EBSD signal with minimally defined axes and random data.
+
+    Parameters
+    ----------
+    navigation_shape : tuple
+    signal_shape : tuple
+    lazy : bool
+    dtype : numpy.dtype
+    """
+    nav_shape, sig_shape, lazy, dtype = request.param
+    nav_ndim = len(nav_shape)
+    sig_ndim = len(sig_shape)
+    data_shape = nav_shape + sig_shape
+    axes = []
+    if nav_ndim == 1:
+        axes.append(dict(name="x", size=nav_shape[0], scale=1))
+    if nav_ndim == 2:
+        axes.append(dict(name="y", size=nav_shape[0], scale=1))
+        axes.append(dict(name="x", size=nav_shape[1], scale=1))
+    #    if sig_ndim == 1:
+    #        axes.append(dict(name="dx", size=sig_shape[0], scale=1))
+    if sig_ndim == 2:
+        axes.append(dict(name="dy", size=sig_shape[0], scale=1))
+        axes.append(dict(name="dx", size=sig_shape[1], scale=1))
+    if lazy:
+        data = da.random.random(data_shape).astype(dtype)
+        yield kp.signals.LazyEBSD(data, axes=axes)
+    else:
+        data = np.random.random(data_shape).astype(dtype)
+        yield kp.signals.EBSD(data, axes=axes)
 
 
 @pytest.fixture(params=["h5"])
@@ -136,9 +151,8 @@ def save_path_hdf5(request):
     """Temporary file in a temporary directory for use when tests need
     to write, and sometimes read again, a signal to, and from, a file.
     """
-    ext = request.param
     with tempfile.TemporaryDirectory() as tmp:
-        file_path = os.path.join(tmp, "patterns_temp." + ext)
+        file_path = os.path.join(tmp, "patterns_temp." + request.param)
         yield file_path
         gc.collect()
 
@@ -146,7 +160,7 @@ def save_path_hdf5(request):
 @pytest.fixture
 def nickel_structure():
     """A diffpy.structure with a Nickel crystal structure."""
-    return Structure(
+    yield Structure(
         atoms=[Atom("Ni", [0, 0, 0])],
         lattice=Lattice(3.5236, 3.5236, 3.5236, 90, 90, 90),
     )
@@ -157,7 +171,7 @@ def nickel_phase(nickel_structure):
     """A orix.crystal_map.Phase with a Nickel crystal structure and
     symmetry operations.
     """
-    return Phase(name="ni", structure=nickel_structure, space_group=225)
+    yield Phase(name="ni", structure=nickel_structure, space_group=225)
 
 
 @pytest.fixture(params=[[[1, 1, 1], [2, 0, 0], [2, 2, 0]]])
@@ -165,23 +179,26 @@ def nickel_rlp(request, nickel_phase):
     """A set of reciprocal lattice points for a Nickel crystal
     structure with a minimum interplanar spacing.
     """
-    return ReciprocalLatticePoint(phase=nickel_phase, hkl=request.param)
+    yield ReciprocalLatticePoint(phase=nickel_phase, hkl=request.param)
 
 
 @pytest.fixture
 def pc1():
     """One projection center (PC) in TSL convention."""
-    return [0.4210, 0.7794, 0.5049]
+    yield [0.4210, 0.7794, 0.5049]
 
 
-@pytest.fixture(params=[(1,)])
+@pytest.fixture(params=[[(1,), (60, 60)]])
 def detector(request, pc1):
-    """A NORDIF UF1100 EBSD detector with a TSL PC."""
-    return kp.detectors.EBSDDetector(
-        shape=(60, 60),
+    """An EBSD detector of a given shape with a number of PCs given by
+    a navigation shape.
+    """
+    nav_shape, sig_shape = request.param
+    yield kp.detectors.EBSDDetector(
+        shape=sig_shape,
         binning=8,
         px_size=70,
-        pc=np.ones(request.param + (3,)) * pc1,
+        pc=np.ones(nav_shape + (3,)) * pc1,
         sample_tilt=70,
         tilt=0,
         convention="tsl",
@@ -197,7 +214,7 @@ def nickel_rotations():
     Nickel data set in this set of scans:
     https://zenodo.org/record/3265037.
     """
-    return Rotation(
+    yield Rotation(
         np.array(
             [
                 [0.8662, 0.2033, -0.3483, -0.2951],
@@ -233,7 +250,7 @@ def nickel_rotations():
 @pytest.fixture
 def r_tsl2bruker():
     """A rotation from the TSL to Bruker crystal reference frame."""
-    return Rotation.from_neo_euler(
+    yield Rotation.from_neo_euler(
         neo_euler.AxAngle.from_axes_angles(Vector3d.zvector(), np.pi / 2)
     )
 
@@ -243,7 +260,7 @@ def nickel_ebsd_simulation_generator(nickel_phase, detector, nickel_rotations):
     """Generator for EBSD simulations of Kikuchi bands for the Nickel
     data set referenced above.
     """
-    return kp.generators.EBSDSimulationGenerator(
+    yield kp.generators.EBSDSimulationGenerator(
         detector=detector, phase=nickel_phase, rotations=nickel_rotations
     )
 
@@ -291,7 +308,7 @@ def nickel_kikuchi_band(nickel_rlp, nickel_rotations, pc1):
         hkl_detector[is_in_some_pattern], source=0, destination=nav_dim
     )
 
-    return kp.simulations.features.KikuchiBand(
+    yield kp.simulations.features.KikuchiBand(
         phase=phase,
         hkl=hkl,
         hkl_detector=hkl_detector,
@@ -344,7 +361,7 @@ def nickel_zone_axes(nickel_kikuchi_band, nickel_rotations, pc1):
         uvw_detector[is_in_some_pattern], source=0, destination=nav_dim
     )
 
-    return kp.simulations.features.ZoneAxis(
+    yield kp.simulations.features.ZoneAxis(
         phase=phase,
         uvw=uvw,
         uvw_detector=uvw_detector,
@@ -366,9 +383,9 @@ def _get_single_phase_xmap(
         prop_names=["scores", "simulation_indices"],
         name="a",
         phase_id=0,
-        step_sizes=(1.5, 1),
+        step_sizes=None,
     ):
-        d, map_size = _get_spatial_array_dicts(nav_shape, step_sizes)
+        d, map_size = create_coordinate_arrays(shape=nav_shape, step_sizes=step_sizes)
         rot_idx = np.random.choice(
             np.arange(rotations.size), map_size * rotations_per_point
         )
@@ -462,7 +479,7 @@ def oxford_binary_file(tmpdir, request):
 
     f.close()
 
-    return f
+    yield f
 
 
 # ---------------------- pytest doctest-modules ---------------------- #
diff --git a/kikuchipy/detectors/tests/test_ebsd_detector.py b/kikuchipy/detectors/tests/test_ebsd_detector.py
index 191b4964..666cadcb 100644
--- a/kikuchipy/detectors/tests/test_ebsd_detector.py
+++ b/kikuchipy/detectors/tests/test_ebsd_detector.py
@@ -23,7 +23,7 @@
 import numpy as np
 import pytest
 
-from kikuchipy.detectors import EBSDDetector
+import kikuchipy as kp
 
 
 class TestEBSDDetector:
@@ -33,7 +33,7 @@ def test_init(self, pc1):
         px_size = 3
         binning = 4
         tilt = 5
-        det = EBSDDetector(
+        det = kp.detectors.EBSDDetector(
             shape=shape, px_size=px_size, binning=binning, tilt=tilt, pc=pc1
         )
         assert det.shape == shape
@@ -50,14 +50,14 @@ def test_init(self, pc1):
     )
     def test_nav_shape_dim(self, pc1, nav_shape, desired_nav_shape, desired_nav_dim):
         """Navigation shape and dimension is derived correctly from PC shape."""
-        det = EBSDDetector(pc=np.tile(pc1, nav_shape))
+        det = kp.detectors.EBSDDetector(pc=np.tile(pc1, nav_shape))
         assert det.navigation_shape == desired_nav_shape
         assert det.navigation_dimension == desired_nav_dim
 
     @pytest.mark.parametrize("pc_type", [list, tuple, np.asarray])
     def test_pc_initialization(self, pc1, pc_type):
         """Initialize PC of valid types."""
-        det = EBSDDetector(pc=pc_type(pc1))
+        det = kp.detectors.EBSDDetector(pc=pc_type(pc1))
         assert isinstance(det.pc, np.ndarray)
 
     @pytest.mark.parametrize(
@@ -94,7 +94,9 @@ def test_detector_dimensions(
         px_size_binned,
     ):
         """Initialization yields expected derived values."""
-        detector = EBSDDetector(shape=shape, px_size=px_size, binning=binning, pc=pc)
+        detector = kp.detectors.EBSDDetector(
+            shape=shape, px_size=px_size, binning=binning, pc=pc
+        )
         assert detector.specimen_scintillator_distance == ssd
         assert detector.width == width
         assert detector.height == height
@@ -104,7 +106,7 @@ def test_detector_dimensions(
 
     def test_repr(self, pc1):
         """Expected string representation."""
-        det = EBSDDetector(
+        det = kp.detectors.EBSDDetector(
             shape=(1, 2), px_size=3, binning=4, tilt=5, azimuthal=2, pc=pc1
         )
         assert repr(det) == (
@@ -114,7 +116,7 @@ def test_repr(self, pc1):
 
     def test_deepcopy(self, pc1):
         """Yields the expected parameters and an actual deep copy."""
-        detector1 = EBSDDetector(pc=pc1)
+        detector1 = kp.detectors.EBSDDetector(pc=pc1)
         detector2 = detector1.deepcopy()
         detector1.pcx += 0.1
         assert np.allclose(detector1.pcx, 0.521)
@@ -125,7 +127,7 @@ def test_set_pc_coordinates(self, pc1):
         ny, nx = (2, 3)
         nav_shape = (ny, nx)
         n = ny * nx
-        detector = EBSDDetector(pc=np.tile(pc1, nav_shape + (1,)))
+        detector = kp.detectors.EBSDDetector(pc=np.tile(pc1, nav_shape + (1,)))
         assert detector.navigation_shape == nav_shape
 
         new_pc = np.zeros(nav_shape + (3,))
@@ -147,7 +149,9 @@ def test_set_pc_coordinates(self, pc1):
     )
     def test_pc_average(self, pc, desired_pc_average):
         """Calculation of PC average."""
-        assert np.allclose(EBSDDetector(pc=pc).pc_average, desired_pc_average)
+        assert np.allclose(
+            kp.detectors.EBSDDetector(pc=pc).pc_average, desired_pc_average
+        )
 
     @pytest.mark.parametrize(
         "pc, desired_nav_shape, desired_nav_ndim",
@@ -159,7 +163,7 @@ def test_pc_average(self, pc, desired_pc_average):
     )
     def test_set_navigation_shape(self, pc, desired_nav_shape, desired_nav_ndim):
         """Change shape of PC array."""
-        detector = EBSDDetector(pc=pc)
+        detector = kp.detectors.EBSDDetector(pc=pc)
         detector.navigation_shape = desired_nav_shape
         assert detector.navigation_shape == desired_nav_shape
         assert detector.navigation_dimension == desired_nav_ndim
@@ -167,7 +171,7 @@ def test_set_navigation_shape(self, pc, desired_nav_shape, desired_nav_ndim):
 
     def test_set_navigation_shape_raises(self, pc1):
         """Desired error message."""
-        detector = EBSDDetector(pc=pc1)
+        detector = kp.detectors.EBSDDetector(pc=pc1)
         with pytest.raises(ValueError, match="A maximum dimension of 2"):
             detector.navigation_shape = (1, 2, 3)
 
@@ -182,7 +186,7 @@ def test_set_navigation_shape_raises(self, pc1):
     )
     def test_gnomonic_range(self, pc1, shape, desired_x_range, desired_y_range):
         """Gnomonic x/y range, x depends on aspect ratio."""
-        detector = EBSDDetector(shape=shape, pc=pc1)
+        detector = kp.detectors.EBSDDetector(shape=shape, pc=pc1)
         assert np.allclose(detector.x_range, desired_x_range)
         assert np.allclose(detector.y_range, desired_y_range)
 
@@ -197,7 +201,7 @@ def test_gnomonic_range(self, pc1, shape, desired_x_range, desired_y_range):
     )
     def test_gnomonic_scale(self, pc1, shape, desired_x_scale, desired_y_scale):
         """Gnomonic x/y scale."""
-        detector = EBSDDetector(shape=shape, pc=pc1)
+        detector = kp.detectors.EBSDDetector(shape=shape, pc=pc1)
         assert np.allclose(detector.x_scale, desired_x_scale, atol=1e-6)
         assert np.allclose(detector.y_scale, desired_y_scale, atol=1e-6)
 
@@ -259,7 +263,7 @@ def test_set_pc_from_emsoft(self, shape, pc, px_size, binning, version, desired_
         """PC EMsoft -> Bruker -> EMsoft, also checking to_tsl() and
         to_bruker().
         """
-        det = EBSDDetector(
+        det = kp.detectors.EBSDDetector(
             shape=shape,
             pc=pc,
             px_size=px_size,
@@ -279,7 +283,7 @@ def test_set_pc_from_emsoft(self, shape, pc, px_size, binning, version, desired_
     def test_set_pc_from_emsoft_no_version(self):
         """PC EMsoft -> Bruker, no EMsoft version specified gives v5."""
         assert np.allclose(
-            EBSDDetector(
+            kp.detectors.EBSDDetector(
                 shape=(60, 60),
                 pc=[-3.4848, 114.2016, 15767.7],
                 px_size=59.2,
@@ -301,10 +305,12 @@ def test_set_pc_from_emsoft_no_version(self):
     )
     def test_set_pc_from_tsl_oxford(self, pc, convention, desired_pc):
         """PC TSL -> Bruker -> TSL."""
-        det = EBSDDetector(pc=pc, convention=convention)
+        det = kp.detectors.EBSDDetector(pc=pc, convention=convention)
         assert np.allclose(det.pc, desired_pc)
         assert np.allclose(det.pc_tsl(), pc)
-        assert np.allclose(EBSDDetector(pc=det.pc_tsl(), convention="tsl").pc_tsl(), pc)
+        assert np.allclose(
+            kp.detectors.EBSDDetector(pc=det.pc_tsl(), convention="tsl").pc_tsl(), pc
+        )
 
     @pytest.mark.parametrize(
         "pc, convention",
@@ -317,13 +323,13 @@ def test_set_pc_from_tsl_oxford(self, pc, convention, desired_pc):
     )
     def test_set_pc_from_bruker(self, pc, convention):
         """PC Bruker returns Bruker PC, which is the default."""
-        det = EBSDDetector(pc=pc, convention=convention)
+        det = kp.detectors.EBSDDetector(pc=pc, convention=convention)
         assert np.allclose(det.pc, pc)
 
     def test_set_pc_convention_raises(self, pc1):
         """Wrong convention raises."""
         with pytest.raises(ValueError, match="Projection center convention "):
-            _ = EBSDDetector(pc=pc1, convention="nordif")
+            _ = kp.detectors.EBSDDetector(pc=pc1, convention="nordif")
 
     @pytest.mark.parametrize(
         "coordinates, show_pc, pattern, zoom, desired_label",
@@ -511,7 +517,7 @@ def test_plot_extent(self, detector, coordinates):
     )
     def test_property_shapes(self, shape, desired_shapes):
         """Expected property shapes when varying navigation shape."""
-        det = EBSDDetector(pc=np.ones(shape + (3,)))
+        det = kp.detectors.EBSDDetector(pc=np.ones(shape + (3,)))
         assert det.bounds.shape == desired_shapes[0]
         assert det.x_min.shape == desired_shapes[1]
         assert det.y_min.shape == desired_shapes[2]
diff --git a/kikuchipy/indexing/_refinement/tests/__init__.py b/kikuchipy/indexing/_refinement/tests/__init__.py
new file mode 100644
index 00000000..c8719dac
--- /dev/null
+++ b/kikuchipy/indexing/_refinement/tests/__init__.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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/>.
diff --git a/kikuchipy/indexing/_refinement/tests/test_refinement.py b/kikuchipy/indexing/_refinement/tests/test_refinement.py
new file mode 100644
index 00000000..fe61ebf7
--- /dev/null
+++ b/kikuchipy/indexing/_refinement/tests/test_refinement.py
@@ -0,0 +1,94 @@
+# -*- coding: utf-8 -*-
+# Copyright 2019-2021 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
+from orix.crystal_map import Phase
+import pytest
+
+import kikuchipy as kp
+
+
+class TestEBSDRefinementSetup:
+    @pytest.mark.parametrize(
+        "method, method_kwargs", [("minimize", None), ("differential_evolution", None)]
+    )
+    def test_get_optimization_method_with_kwargs(self, method, method_kwargs):
+        (
+            method_out,
+            method_kwargs_out,
+        ) = kp.indexing._refinement._refinement._get_optimization_method_with_kwargs(
+            method, method_kwargs
+        )
+        assert callable(method_out)
+        assert method_out.__name__ == method
+        assert isinstance(method_kwargs_out, dict)
+
+        if method == "minimize":
+            assert method_kwargs_out["method"] == "Nelder-Mead"
+
+    def test_get_optimization_method_with_kwargs_raises(self):
+        method = "wait-and-see"
+        with pytest.raises(ValueError, match=f"Method {method} not in the list of"):
+            _ = kp.indexing._refinement._refinement._get_optimization_method_with_kwargs(
+                method
+            )
+
+    def test_check_master_pattern_and_get_data(self):
+        axes = [
+            dict(name="hemisphere", size=2, scale=1),
+            dict(name="energy", size=5, offset=16, scale=1),
+            dict(name="dy", size=5, scale=1),
+            dict(name="dx", size=5, scale=1),
+        ]
+        mp_data = np.random.rand(2, 5, 5, 5).astype(np.float64)
+        mp = kp.signals.EBSDMasterPattern(
+            mp_data,
+            axes=axes,
+            projection="lambert",
+            hemisphere="both",
+            phase=Phase("ni", 225),
+        )
+        (
+            mpn,
+            mps,
+            npx,
+            npy,
+            scale,
+        ) = kp.indexing._refinement._refinement._check_master_pattern_and_get_data(
+            master_pattern=mp,
+            energy=20,
+        )
+
+        assert npx == mp_data.shape[3]
+        assert npy == mp_data.shape[2]
+        assert scale == (mp_data.shape[3] - 1) / 2
+
+        # Master patterns are rescaled
+        assert mpn.dtype == np.float32
+        assert not np.allclose(mpn, mp_data[0, -1])
+        assert not np.allclose(mps, mp_data[1, -1])
+        assert np.min(mpn) >= -1
+        assert np.max(mpn) <= 1
+
+
+class TestRefinementSolvers:
+    def test_rescale_pattern_numba_function(self):
+        a = np.random.random(10).reshape((2, 5)).astype(np.float32)
+        b = kp.indexing._refinement._solvers._rescale_pattern.py_func(a)
+        assert not np.allclose(a, b)
+        assert b.dtype == a.dtype
diff --git a/kikuchipy/indexing/tests/test_merge_crystal_maps.py b/kikuchipy/indexing/tests/test_merge_crystal_maps.py
index bd8ba63f..3f3fb3c7 100644
--- a/kikuchipy/indexing/tests/test_merge_crystal_maps.py
+++ b/kikuchipy/indexing/tests/test_merge_crystal_maps.py
@@ -29,14 +29,14 @@ class TestMergeCrystalMaps:
     @pytest.mark.parametrize(
         "map_shape, rot_per_point, phase_names",
         [
-            ((0, 3), 10, ["a", "b"]),
-            ((0, 4), 1, ["a", "b", "c"]),
-            ((3, 0), 5, ["austenite", "ferrite"]),
-            ((4, 0), 1, ["al", "cu", "si"]),
+            ((3,), 10, ["a", "b"]),
+            ((4,), 1, ["a", "b", "c"]),
+            ((3,), 5, ["austenite", "ferrite"]),
+            ((4,), 1, ["al", "cu", "si"]),
         ],
     )
     def test_merge_crystal_maps_1d(
-        self, get_single_phase_xmap, map_shape, rot_per_point, phase_names
+        self, map_shape, rot_per_point, phase_names, get_single_phase_xmap
     ):
         """Crystal maps with a 1D navigation shape can be merged
         successfully and yields an expected output.
@@ -54,10 +54,17 @@ def test_merge_crystal_maps_1d(
         desired_idx = np.arange(np.prod(data_shape)).reshape(data_shape)
 
         xmaps = []
-        xmap_args = (map_shape, rot_per_point, [scores_prop, sim_idx_prop])
+        xmap_kwargs = dict(
+            nav_shape=map_shape,
+            rotations_per_point=rot_per_point,
+            prop_names=[scores_prop, sim_idx_prop],
+            step_sizes=(1,),
+        )
         phase_ids = np.arange(n_phases)
         for i in range(n_phases):
-            xmap = get_single_phase_xmap(*xmap_args, phase_names[i], phase_ids[i])
+            xmap = get_single_phase_xmap(
+                name=phase_names[i], phase_id=phase_ids[i], **xmap_kwargs
+            )
             # All maps have at least one point with the best score
             xmap[i].prop[scores_prop] += i + 1
             xmaps.append(xmap)
@@ -280,7 +287,7 @@ def test_warning_merge_maps_with_same_phase(
             "desired_merged_sim_idx"
         ),
         [
-            ((2, 0), 1, 1, [[1, 1], [2, 1]], [[0, 2], [3, 1]]),
+            ((2,), 1, 1, [[1, 1], [2, 1]], [[0, 2], [3, 1]]),
             ((1, 2), 1, 1, [[1, 1], [2, 1]], [[0, 2], [3, 1]]),
             (
                 (1, 3),
@@ -328,7 +335,7 @@ def test_mean_n_best(
         desired_merged_scores,
         desired_merged_sim_idx,
     ):
-        """Ensure that the mergesorted scores and simulation index
+        """Ensure that the merge sorted scores and simulation index
         properties in the merged map has the correct values and shape.
         """
         prop_names = ["scores", "simulation_indices"]
diff --git a/kikuchipy/indexing/tests/test_refinement.py b/kikuchipy/indexing/tests/test_refinement.py
deleted file mode 100644
index 11045d06..00000000
--- a/kikuchipy/indexing/tests/test_refinement.py
+++ /dev/null
@@ -1,138 +0,0 @@
-# -*- coding: utf-8 -*-
-# Copyright 2019-2021 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.indexing._refinement import (
-    _py_ncc,
-    _fast_get_lambert_interpolation_parameters,
-    _fast_lambert_projection,
-    _fast_simulate_single_pattern,
-    _fast_get_dc,
-    _fast_get_dc_multiple_pc,
-    _rotation_from_euler,
-)
-
-
-class TestEBSDRefinementMethods:
-
-    # Added .py_func to the JIT functions
-    def test_ncc(self):
-        a = np.random.rand(100, 100)
-        assert _py_ncc.py_func(a, a) == pytest.approx(1.0)
-
-    def test_get_dc_multiple_pc(self):
-        xpc = np.array((0.5, 0.4))
-        ypc = np.array((0.5, 0.4))
-        L = np.array((200, 300))
-        scan_points = 2
-        ncols = 60
-        nrows = 60
-        px_size = 1
-        alpha = 0.34
-
-        out = _fast_get_dc_multiple_pc.py_func(
-            xpc, ypc, L, scan_points, ncols, nrows, px_size, alpha
-        )
-
-        assert out.shape == (scan_points, nrows, ncols, 3)
-
-    def test_get_dc(self):
-        xpc = 0.5
-        ypc = 0.5
-        L = 200
-        ncols = 60
-        nrows = 60
-        px_size = 1
-        alpha = 0.34
-
-        out = _fast_get_dc.py_func(xpc, ypc, L, ncols, nrows, px_size, alpha)
-
-        assert out.shape == (nrows, ncols, 3)
-
-    def test_single_pattern_sim(self):
-        r = np.array((1, 0, 0, 0))
-        dc = np.random.rand(60, 60, 3)
-        master_north = np.random.rand(401, 401)
-        master_south = np.random.rand(401, 401)
-        npx = 401
-        npy = 401
-        scale = 1
-
-        out1 = _fast_simulate_single_pattern.py_func(
-            r, dc, master_north, master_south, npx, npy, scale
-        )
-
-        assert out1.shape == (60, 60)
-
-        out2 = _fast_simulate_single_pattern.py_func(
-            r, -dc, master_north, master_south, npx, npy, scale
-        )
-
-        assert out2.shape == (60, 60)
-
-    def test_lambert_params(self):
-        rdc = np.random.rand(60, 60)
-        npx = 401
-        npy = 401
-        scale = 1
-        a, b, c, d, e, f, g, h = _fast_get_lambert_interpolation_parameters.py_func(
-            rdc, npx, npy, scale
-        )
-
-        single_shape = (60,)
-        assert a.shape == single_shape
-        assert b.shape == single_shape
-        assert c.shape == single_shape
-        assert d.shape == single_shape
-        assert e.shape == single_shape
-        assert f.shape == single_shape
-        assert g.shape == single_shape
-        assert h.shape == single_shape
-
-    def test_lambert_projection(self):
-        v = np.random.rand(60, 60, 3)
-        w = _fast_lambert_projection.py_func(v)
-        assert w.shape == (60, 60, 2)
-
-    def test_euler_to_rotation(self):
-        phi1 = 0.0
-        Phi = 0.0
-        phi2 = 0.0
-
-        q = _rotation_from_euler.py_func(phi1, Phi, phi2)
-
-        assert (q == np.array((1, 0, 0, 0))).all()
-
-        phi1 = 1.0
-        Phi = 2.0
-        phi2 = 3.0
-        key = np.array((0.2248, 0.4546, -0.7081, 0.4913))
-
-        q2 = _rotation_from_euler.py_func(phi1, Phi, phi2)
-
-        assert np.allclose(key, q2, atol=1e-04)
-
-        phi1 = 0.3
-        Phi = 0.47
-        phi2 = 0.58
-        key = np.array((0.8799, -0.2306, 0.0325, -0.4142))
-
-        q3 = _rotation_from_euler.py_func(phi1, Phi, phi2)
-        assert np.allclose(key, q3, atol=1e-04)
diff --git a/kikuchipy/indexing/tests/test_similarity_metrics.py b/kikuchipy/indexing/tests/test_similarity_metrics.py
index 5b25b5d1..0729ed33 100644
--- a/kikuchipy/indexing/tests/test_similarity_metrics.py
+++ b/kikuchipy/indexing/tests/test_similarity_metrics.py
@@ -26,8 +26,9 @@
     SimilarityMetric,
     MetricScope,
     FlatSimilarityMetric,
-    _SIMILARITY_METRICS,
     _get_number_of_simulated,
+    _ncc_single_patterns_2d_float32,
+    _SIMILARITY_METRICS,
     _zncc_einsum,
 )
 
@@ -274,3 +275,12 @@ def test_ndp(self):
 
         # many to many
         assert pytest.approx(ndp_metric(expt_da, sim_da).compute()[1, 0, 1]) == 1
+
+
+class TestNumbaAcceleratedMetrics:
+    def test_ncc_single_patterns_2d_float32(self):
+        r = _ncc_single_patterns_2d_float32.py_func(
+            exp=np.linspace(0, 0.5, 100, dtype=np.float32).reshape((10, 10)),
+            sim=np.linspace(0.5, 1, 100, dtype=np.float32).reshape((10, 10)),
+        )
+        assert r == 1
diff --git a/kikuchipy/projections/tests/test_gnomonic_projection.py b/kikuchipy/projections/tests/test_gnomonic_projection.py
index 47e90b4a..3453976f 100644
--- a/kikuchipy/projections/tests/test_gnomonic_projection.py
+++ b/kikuchipy/projections/tests/test_gnomonic_projection.py
@@ -41,7 +41,7 @@ def test_project(self):
                 [0.7585, 0.1885, 0.2678],
             ]
         )
-        xy = GnomonicProjection.project(v)
+        xy = GnomonicProjection.vector2xy(v)
 
         # Desired project result?
         assert np.allclose(
@@ -64,7 +64,7 @@ def test_project(self):
         )
 
         # Same result passing Vector3d
-        assert np.allclose(xy, GnomonicProjection.project(Vector3d(v)))
+        assert np.allclose(xy, GnomonicProjection.vector2xy(Vector3d(v)))
 
     def test_iproject(self):
         """Projecting Gnomonic coordinates to cartesian coordinates
@@ -86,7 +86,7 @@ def test_iproject(self):
         )
 
         assert np.allclose(
-            GnomonicProjection.iproject(xy).data,
+            GnomonicProjection.xy2vector(xy).data,
             np.array(
                 [
                     [0.5316, 0.7603, 0.3729],
diff --git a/kikuchipy/projections/tests/test_lambert_projection.py b/kikuchipy/projections/tests/test_lambert_projection.py
index d4543f22..433c4e7b 100644
--- a/kikuchipy/projections/tests/test_lambert_projection.py
+++ b/kikuchipy/projections/tests/test_lambert_projection.py
@@ -20,66 +20,74 @@
 from orix.vector import Vector3d
 import pytest
 
-from kikuchipy.projections.lambert_projection import LambertProjection, _eq_c
+from kikuchipy.projections.lambert_projection import (
+    LambertProjection,
+    _eq_c,
+    _vector2xy,
+)
 
 
 class TestLambertProjection:
-    def test_project_vector3d(self):
+    def test_vector2xy_vector(self):
         """Works for Vector3d objects with single and multiple vectors"""
 
         vector_one = Vector3d((0.578, 0.578, 0.578))
-        output_a = LambertProjection.project(vector_one)
+        output_a = LambertProjection.vector2xy(vector_one)
         expected_a = np.array((0.81417, 0.81417))
 
-        assert (output_a[..., 0, 0]) == pytest.approx(expected_a[0], abs=1e-3)
-        assert output_a[..., 0, 1] == pytest.approx(expected_a[1], abs=1e-3)
+        assert np.allclose(output_a[..., 0, 0], expected_a[0], atol=1e-3)
+        assert np.allclose(output_a[..., 0, 1], expected_a[1], atol=1e-3)
 
         vector_two = Vector3d(
             [[0.578, 0.578, 0.578], [0, 0.707, 0.707], [0.707, 0, 0.707]]
         )
-        with np.errstate(divide="ignore"):
-            output_b = LambertProjection.project(vector_two)
+        output_b = LambertProjection.vector2xy(vector_two)
 
         expected_x = np.array((0.81417, 0, 0.678))
         expected_y = np.array((0.81417, 0.678, 0))
 
-        assert output_b[..., 0, 0] == pytest.approx(expected_x[0], abs=1e-3)
-        assert output_b[..., 0, 1] == pytest.approx(expected_y[0], abs=1e-3)
-        assert output_b[..., 1, 0] == pytest.approx(expected_x[1], abs=1e-3)
-        assert output_b[..., 1, 1] == pytest.approx(expected_y[1], abs=1e-3)
-        assert output_b[..., 2, 0] == pytest.approx(expected_x[2], abs=1e-3)
-        assert output_b[..., 2, 1] == pytest.approx(expected_y[2], abs=1e-3)
-
-    def test_project_ndarray(self):
-        "Works for numpy ndarrays"
-        ipt = np.array((0.578, 0.578, 0.578))
-        output = LambertProjection.project(ipt)
-        expected = np.array((0.81417, 0.81417))
-        assert output[..., 0] == pytest.approx(expected[0], rel=1e-3)
-
-        xyz = np.array(([0, 0, 1], [0, 1, 0], [2, 0, 0], [0, 0, -3]))
-        xyz2 = np.array(([0, 0, -1], [0, -1, 0], [-2, 0, 0], [0, 0, 3]))
-
-        xy = LambertProjection.project(xyz)
-        xy2 = LambertProjection.project(xyz2)
-
-        assert np.allclose(
-            xy, ([0, 0], [0, np.sqrt(np.pi / 2)], [np.sqrt(np.pi / 2), 0], [0, 0])
-        )
-        assert np.allclose(
-            xy2, ([0, 0], [0, -np.sqrt(np.pi / 2)], [-np.sqrt(np.pi / 2), 0], [0, 0])
+        assert np.allclose(output_b[..., 0, 0], expected_x[0], atol=1e-3)
+        assert np.allclose(output_b[..., 0, 1], expected_y[0], atol=1e-3)
+        assert np.allclose(output_b[..., 1, 0], expected_x[1], atol=1e-3)
+        assert np.allclose(output_b[..., 1, 1], expected_y[1], atol=1e-3)
+        assert np.allclose(output_b[..., 2, 0], expected_x[2], atol=1e-3)
+        assert np.allclose(output_b[..., 2, 1], expected_y[2], atol=1e-3)
+
+    def test_vector2xy_array(self):
+        """Works for numpy arrays."""
+        output = LambertProjection.vector2xy(np.array([0.578, 0.578, 0.578]))
+        assert np.allclose(output, 0.81480, atol=1e-5)
+
+        xyz = np.array(
+            [
+                [0, 0, 1],
+                [0, 1, 0],
+                [2, 0, 0],
+                [0, 0, -3],
+                [0, 0, -1],
+                [0, -1, 0],
+                [-2, 0, 0],
+                [0, 0, 3],
+            ]
         )
-
-    def test_iproject(self):
+        lambert_xy = [
+            [0, 0],
+            [0, np.sqrt(np.pi / 2)],
+            [np.sqrt(np.pi / 2), 0],
+            [0, 0],
+            [0, 0],
+            [0, -np.sqrt(np.pi / 2)],
+            [-np.sqrt(np.pi / 2), 0],
+            [0, 0],
+        ]
+        assert np.allclose(LambertProjection.vector2xy(xyz), lambert_xy)
+        assert np.allclose(_vector2xy.py_func(xyz), lambert_xy)
+
+    def test_xy2vector(self):
         """Conversion from Lambert to Cartesian coordinates works"""
-        vec = np.array((0.81417, 0.81417))
-        expected = Vector3d(
-            (0.5770240896680434, 0.5770240896680434, 0.5780020760218183)
-        )  # Vector3d(1,)
-        output = LambertProjection.iproject(vec)  # Vector3d(1, 1)
-        assert output[0].x.data[0] == pytest.approx(expected.x.data[0], rel=1e-3)
-        assert output[0].y.data[0] == pytest.approx(expected.y.data[0], rel=1e-3)
-        assert output[0].z.data[0] == pytest.approx(expected.z.data[0], rel=1e-3)
+        lambert_xy = np.array([0.81480, 0.81480])
+        v = LambertProjection.xy2vector(lambert_xy)
+        assert np.allclose(v.data, 0.578, atol=1e-3)
 
     def test_eq_c(self):
         """Helper function works"""
@@ -87,7 +95,7 @@ def test_eq_c(self):
         value = arr_1[..., 0]
         output_arr = np.array((0.61655, 0.61655, 0.61655))
         expected = output_arr[..., 0]
-        output = _eq_c(value)
+        output = _eq_c.py_func(value)
         assert output == pytest.approx(expected, rel=1e-5)
 
     def test_lambert_to_gnomonic(self):
@@ -112,9 +120,9 @@ def test_shape_respect(self):
         assert v.shape == (sx, sx)
         assert v.data.shape == (sx, sx, 3)
         # Forward
-        xy_lambert = LambertProjection.project(v)
+        xy_lambert = LambertProjection.vector2xy(v)
         assert xy_lambert.shape == (sx, sx, 2)
         # and back
-        xyz_frm_lambert = LambertProjection.iproject(xy_lambert)
+        xyz_frm_lambert = LambertProjection.xy2vector(xy_lambert)
         assert xyz_frm_lambert.shape == v.shape
         assert xyz_frm_lambert.data.shape == v.data.shape
diff --git a/kikuchipy/projections/tests/test_spherical_projection.py b/kikuchipy/projections/tests/test_spherical_projection.py
index 2a10cd98..70cda0c2 100644
--- a/kikuchipy/projections/tests/test_spherical_projection.py
+++ b/kikuchipy/projections/tests/test_spherical_projection.py
@@ -34,7 +34,7 @@ def test_spherical_projection():
     v = Vector3d(v_arr)
 
     # Vector3d
-    polar = SphericalProjection.project(v_arr)
+    polar = SphericalProjection.vector2xy(v_arr)
     assert np.allclose(polar[..., 0], v.polar.data)
     assert np.allclose(polar[..., 1], v.azimuth.data)
     assert np.allclose(polar[..., 2], v.radial.data)
@@ -43,7 +43,7 @@ def test_spherical_projection():
     assert np.allclose(get_radial(v), v.radial.data)
 
     # NumPy array
-    polar2 = SphericalProjection.project(v)
+    polar2 = SphericalProjection.vector2xy(v)
     assert np.allclose(polar2[..., 0], v.polar.data)
     assert np.allclose(polar2[..., 1], v.azimuth.data)
     assert np.allclose(polar2[..., 2], v.radial.data)
diff --git a/kikuchipy/signals/tests/test_ebsd.py b/kikuchipy/signals/tests/test_ebsd.py
index 0b9b2562..e750d87c 100644
--- a/kikuchipy/signals/tests/test_ebsd.py
+++ b/kikuchipy/signals/tests/test_ebsd.py
@@ -18,14 +18,13 @@
 
 import os
 
+import dask
 import dask.array as da
-from dask.delayed import Delayed
 from hyperspy.utils.roi import RectangularROI
 import matplotlib
 from matplotlib.pyplot import close
 import numpy as np
-from orix.crystal_map import CrystalMap, Phase, PhaseList
-from orix.quaternion import Rotation
+from orix.crystal_map import CrystalMap, Phase
 import pytest
 from scipy.ndimage import correlate
 from skimage.exposure import rescale_intensity
@@ -1169,18 +1168,15 @@ def test_init_xmap(self, dummy_signal):
 
         ssim = kp.load(EMSOFT_FILE)
         xmap = ssim.xmap
-        assert isinstance(xmap, CrystalMap)
         assert xmap.phases[0].name == "ni"
 
     def test_attribute_carry_over_from_lazy(self):
         ssim = kp.load(EMSOFT_FILE, lazy=True)
         xmap_lazy = ssim.xmap.deepcopy()
-        assert isinstance(xmap_lazy, CrystalMap)
         assert xmap_lazy.phases[0].name == "ni"
 
         ssim.compute()
         xmap = ssim.xmap
-        assert isinstance(xmap, CrystalMap)
         assert xmap.phases[0].name == "ni"
         assert np.allclose(xmap.rotations.data, xmap_lazy.rotations.data)
 
@@ -1197,12 +1193,12 @@ def test_set_xmap(self, get_single_phase_xmap):
         xmap_bad = get_single_phase_xmap(
             nav_shape=nav_shape[::-1], step_sizes=step_sizes
         )
-        with pytest.raises(AttributeError, match="The crystal map shape"):
+        with pytest.raises(ValueError, match="The crystal map shape"):
             s.xmap = xmap_bad
-        with pytest.raises(AttributeError, match="The crystal map shape"):
+        with pytest.raises(ValueError, match="The crystal map shape"):
             s.axes_manager["x"].scale = 2
             s.xmap = xmap_good
-        with pytest.raises(AttributeError, match="The crystal map shape"):
+        with pytest.raises(ValueError, match="The crystal map shape"):
             s.axes_manager["x"].scale = 1
             s.axes_manager["x"].name = "x2"
             with pytest.warns(UserWarning, match="The signal navigation axes"):
@@ -1211,26 +1207,81 @@ def test_set_xmap(self, get_single_phase_xmap):
     def test_attribute_carry_over_from_deepcopy(self, get_single_phase_xmap):
         s = kp.data.nickel_ebsd_small(lazy=True)
         nav_axes = s.axes_manager.navigation_axes[::-1]
-        nav_shape = (a.size for a in nav_axes)
-        nav_scales = (a.scale for a in nav_axes)
+        nav_shape = tuple(a.size for a in nav_axes)
+        nav_scales = tuple(a.scale for a in nav_axes)
 
         xmap = get_single_phase_xmap(nav_shape=nav_shape, step_sizes=nav_scales)
         s.xmap = xmap
 
         s2 = s.deepcopy()
-        assert isinstance(s2.xmap, CrystalMap)
         r1 = s.xmap.rotations.data
         r2 = s2.xmap.rotations.data
         assert not np.may_share_memory(r1, r2)
         assert np.allclose(r1, r2)
-        assert not np.may_share_memory(s.detector.pc, s2.detector.pc)
 
 
 class TestEBSDDetectorProperty:
-    def test_attribute_carry_over_from_deepcopy(self):
+    def test_attribute_carry_over_from_deepcopy(self, dummy_signal):
+        dummy_signal2 = dummy_signal.deepcopy()
+
+        pc1 = dummy_signal.detector.pc
+        pc2 = dummy_signal2.detector.pc
+        assert not np.may_share_memory(pc1, pc2)
+        assert np.allclose(pc1, pc2)
+
+    def test_attribute_carry_over_from_lazy(self, dummy_signal):
+        dummy_signal_lazy = dummy_signal.deepcopy().as_lazy()
+        dummy_signal_lazy.compute()
+        pc = dummy_signal.detector.pc
+        pc_lazy = dummy_signal_lazy.detector.pc
+        assert not np.may_share_memory(pc, pc_lazy)
+        assert np.allclose(pc, pc_lazy)
+
+    def test_set_detector(self):
         s = kp.data.nickel_ebsd_small(lazy=True)
-        s._detector = None
-        _ = s.deepcopy()
+        sig_shape = s.axes_manager.signal_shape[::-1]
+
+        # Success
+        detector_good = kp.detectors.EBSDDetector(shape=sig_shape)
+        s.detector = detector_good
+
+        # Failure
+        with pytest.raises(ValueError, match="Detector and signal must have the same"):
+            s.detector = kp.detectors.EBSDDetector(shape=(59, 60))
+        with pytest.raises(ValueError, match="Detector must have exactly one "):
+            s.detector = kp.detectors.EBSDDetector(
+                shape=sig_shape, pc=np.ones((3, 4, 3))
+            )
+
+    @pytest.mark.parametrize(
+        "detector, signal_nav_shape, compatible, error_msg_start",
+        [
+            (((1,), (5, 5)), (2, 3), True, None),
+            (((2, 3), (5, 5)), (2, 3), True, None),
+            (((1,), (5, 4)), (2, 3), False, "Detector and signal must have the same"),
+            (((3, 2), (5, 5)), (2, 3), False, "Detector must have exactly"),
+            (((2, 3), (5, 5)), (), False, "Detector must have exactly"),
+        ],
+        indirect=["detector"],
+    )
+    def test_compatible_with_signal(
+        self, detector, signal_nav_shape, compatible, error_msg_start
+    ):
+        s = kp.signals.EBSD(np.ones(signal_nav_shape + (5, 5), dtype=int))
+        func_kwargs = dict(
+            detector=detector,
+            navigation_shape=s.axes_manager.navigation_shape[::-1],
+            signal_shape=s.axes_manager.signal_shape[::-1],
+        )
+        assert (
+            kp.signals.util._detector._detector_is_compatible_with_signal(**func_kwargs)
+            == compatible
+        )
+        if not compatible:
+            with pytest.raises(ValueError, match=error_msg_start):
+                kp.signals.util._detector._detector_is_compatible_with_signal(
+                    raise_if_not=True, **func_kwargs
+                )
 
 
 class TestDictionaryIndexing:
@@ -1245,7 +1296,7 @@ def test_dictionary_indexing(self, dummy_signal):
 
         assert np.allclose(xmap.scores[:, 0], 1)
 
-    def test_match_patterns_warns(self, dummy_signal):
+    def test_dictionary_indexing_warns(self, dummy_signal):
         # TODO: Remove test after v0.4 is released
         s_dict = kp.signals.EBSD(dummy_signal.data.reshape(-1, 3, 3))
         s_dict.axes_manager[0].name = "x"
@@ -1255,494 +1306,462 @@ def test_match_patterns_warns(self, dummy_signal):
 
 
 class TestEBSDRefinement:
-    array0 = np.random.rand(10, 10, 60, 60)
-    s0 = kp.signals.EBSD(array0)
-    xmap = CrystalMap.empty((100,))
-    mp = kp.data.nickel_ebsd_master_pattern_small(projection="lambert")
-    detector = kp.detectors.EBSDDetector(
-        shape=(60, 60),
-        pc=[0.5, 0.5, 0.5],
-        sample_tilt=70,
-        convention="tsl",
-    )
-
-    xmap2 = CrystalMap.empty((99,))
-
-    pl = PhaseList(names=["a", "b"], space_groups=[10, 20])
-    indx = np.zeros((100,))
-    indx[50:] = 1
-    xmap3 = CrystalMap(
-        rotations=Rotation.random(100),
-        phase_id=indx,
-        phase_list=pl,
+    """Note that it is the calls to the :mod:`scipy.optimize` methods
+    that take up test time. The setup here in kikuchipy and the array
+    sizes don't matter that much.
+    """
+
+    axes = [
+        dict(name="hemisphere", size=2, scale=1),
+        dict(name="energy", size=5, offset=16, scale=1),
+        dict(name="dy", size=5, scale=1),
+        dict(name="dx", size=5, scale=1),
+    ]
+    mp_data = np.random.rand(2, 5, 5, 5).astype(np.float32)
+    mp = kp.signals.EBSDMasterPattern(
+        mp_data,
+        axes=axes,
+        projection="lambert",
+        hemisphere="both",
+        phase=Phase("ni", 225),
     )
 
-    detector2 = kp.detectors.EBSDDetector(
-        shape=(6, 60),
-        pc=[0.5, 0.5, 0.5],
-        sample_tilt=70,
-        convention="tsl",
-    )
-
-    detector3 = kp.detectors.EBSDDetector(
-        shape=(60, 60),
-        pc=[[0.5, 0.5, 0.5], [0.45, 0.45, 0.45]],
-        sample_tilt=70,
-        convention="tsl",
+    @pytest.mark.parametrize(
+        "ebsd_with_axes_and_random_data, detector, error_msg",
+        [
+            (((2,), (3, 2), True, np.float32), ((2,), (2, 3)), "Detector and signal m"),
+            (((3,), (2, 3), True, np.float32), ((2,), (2, 3)), "Detector must have ex"),
+        ],
+        indirect=["ebsd_with_axes_and_random_data", "detector"],
     )
-
-    mask = np.ones((6, 60))
-
-    mask2 = np.zeros((60, 60), dtype=np.uint8)
-
-    mp2 = kp.data.nickel_ebsd_master_pattern_small(projection="stereographic")
-
-    array1 = da.random.random((10, 10, 60, 60))
-    s1 = kp.signals.LazyEBSD(array1)
-
-    array2 = da.random.random((2, 60, 60))
-    s2 = kp.signals.LazyEBSD(array2)
-    xmap4 = CrystalMap.empty((2,))
-
-    array3 = da.random.random((60, 60)).astype(np.float32)
-    single = kp.signals.LazyEBSD(array3)
-    xmap_single = CrystalMap.empty((1,))
-
-    def test_orientation_refinement(self):
-
-        # From test_ebsd_masterpattern.py
-        mp_a = kp.signals.EBSDMasterPattern(
-            np.random.rand(2, 10, 1001, 1001), dtype=np.float32
-        )
-        mp_a.axes_manager[0].name = "hemisphere"
-        mp_a.axes_manager[1].name = "energy"
-        mp_a.projection = "lambert"
-        mp_a.hemisphere = "both"
-        mp_a.phase = Phase("Ni", 225)
-
-        refined_xmap = self.s0.orientation_refinement(
-            xmap=self.xmap, master_pattern=mp_a, detector=self.detector, energy=20
-        )
-
-        assert isinstance(refined_xmap, CrystalMap)
-        assert len(refined_xmap.rotations.data) == 100
-
-        mp_b = kp.signals.EBSDMasterPattern(
-            np.random.rand(10, 1001, 1001), dtype=np.float32
-        )
-        mp_b.axes_manager[0].name = "energy"
-        mp_b.projection = "lambert"
-        mp_b.phase = Phase("Ni", 225)
-
-        refined_xmap = self.s0.orientation_refinement(
-            xmap=self.xmap, master_pattern=mp_b, detector=self.detector, energy=20
+    def test_refinement_check_raises(
+        self,
+        ebsd_with_axes_and_random_data,
+        detector,
+        error_msg,
+        get_single_phase_xmap,
+    ):
+        s = ebsd_with_axes_and_random_data
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(refined_xmap, CrystalMap)
-        assert len(refined_xmap.rotations.data) == 100
-
-        mp_c = kp.signals.EBSDMasterPattern(np.zeros((11, 11)))
-        mp_c.projection = "lambert"
-        mp_c.phase = Phase("!Ni", 220)
-        with pytest.raises(AttributeError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap, master_pattern=mp_c, detector=self.detector, energy=20
+        xmap.phases[0].name = self.mp.phase.name
+        with pytest.raises(ValueError, match=error_msg):
+            _ = s.refine_orientation(
+                xmap=xmap, master_pattern=self.mp, detector=detector, energy=20
             )
 
-        mp_d = kp.signals.EBSDMasterPattern(np.zeros((2, 11, 11)))
-        with pytest.raises(NotImplementedError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap, master_pattern=mp_d, detector=self.detector, energy=20
-            )
+    # ---------------------- Refine orientations --------------------- #
 
-        refined_xmap = self.s0.orientation_refinement(
-            xmap=self.xmap, master_pattern=self.mp, detector=self.detector, energy=20
-        )
-
-        assert isinstance(refined_xmap, CrystalMap)
-        assert len(refined_xmap.rotations.data) == 100
-
-        out = self.s0.orientation_refinement(
-            xmap=self.xmap,
-            master_pattern=self.mp,
-            detector=self.detector,
-            energy=20,
-            compute=False,
-        )
-
-        assert isinstance(out, Delayed)
-
-        with pytest.raises(NotImplementedError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap,
-                master_pattern=self.mp2,
-                detector=self.detector,
-                energy=20,
-            )
-
-        # Signal and detector must have same shape
-        with pytest.raises(ValueError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap,
-                master_pattern=self.mp,
-                detector=self.detector2,
-                energy=20,
-            )
-
-        # Method must be supported
-        with pytest.raises(ValueError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap,
-                master_pattern=self.mp,
-                detector=self.detector,
-                energy=20,
-                method="shgo",
-            )
-
-        # Must have 1 or n x m PCs
-        with pytest.raises(ValueError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap,
-                master_pattern=self.mp,
-                detector=self.detector3,
-                energy=20,
-            )
-
-        # xmap must be single phase
-        with pytest.raises(ValueError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap3,
-                master_pattern=self.mp,
-                detector=self.detector,
-                energy=20,
-            )
-
-        # xmap must have 1 rotation for every pattern
-        with pytest.raises(ValueError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap2,
-                master_pattern=self.mp,
-                detector=self.detector,
-                energy=20,
-            )
-
-        # mask must fit pattern
-        with pytest.raises(ValueError):
-            _ = self.s0.orientation_refinement(
-                xmap=self.xmap,
-                master_pattern=self.mp,
-                detector=self.detector,
-                energy=20,
-                mask=self.mask,
-            )
-
-        refined_xmap1 = self.s1.orientation_refinement(
-            xmap=self.xmap, master_pattern=self.mp, detector=self.detector, energy=20
-        )
-
-        assert isinstance(refined_xmap1, CrystalMap)
-        assert len(refined_xmap1.rotations.data) == 100
-
-        refined_xmap_single = self.single.orientation_refinement(
-            xmap=self.xmap_single,
-            master_pattern=self.mp,
-            detector=self.detector,
-            energy=20,
-        )
-        assert isinstance(refined_xmap_single, CrystalMap)
-        assert len(refined_xmap_single.rotations.data) == 1
-
-        refined_xmap_single = self.single.orientation_refinement(
-            xmap=self.xmap_single,
-            master_pattern=self.mp,
-            detector=self.detector,
-            energy=20,
-            mask=self.mask2,
-        )
-        assert isinstance(refined_xmap_single, CrystalMap)
-        assert len(refined_xmap_single.rotations.data) == 1
-
-        refined_xmap_single = self.single.orientation_refinement(
-            xmap=CrystalMap(Rotation.random((1, 2))),
-            master_pattern=self.mp,
-            detector=self.detector,
-            energy=20,
-        )
-        assert isinstance(refined_xmap_single, CrystalMap)
-        assert len(refined_xmap_single.rotations.data) == 1
-
-        refined_xmap2 = self.s2.orientation_refinement(
-            xmap=self.xmap4,
-            master_pattern=self.mp,
-            detector=self.detector3,
-            trust_region=[0.5, 0.5, 0.5],
-            energy=20,
-        )
-
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert len(refined_xmap2.rotations.data) == 2
-
-        # BH
-        refined_xmap2 = self.s2.orientation_refinement(
-            xmap=self.xmap4,
-            master_pattern=self.mp,
-            method="basinhopping",
-            method_kwargs={"minimizer_kwargs": {"method": "Nelder-Mead"}},
-            detector=self.detector3,
-            trust_region=[0.5, 0.5, 0.5],
-            energy=20,
-        )
-
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert len(refined_xmap2.rotations.data) == 2
-
-        # DA
-        refined_xmap2 = self.s2.orientation_refinement(
-            xmap=self.xmap4,
-            master_pattern=self.mp,
-            method="dual_annealing",
-            detector=self.detector3,
-            trust_region=[0.5, 0.5, 0.5],
-            energy=20,
+    @pytest.mark.parametrize(
+        "ebsd_with_axes_and_random_data, detector, method_kwargs, trust_region",
+        [
+            (
+                ((2,), (2, 3), True, np.float32),
+                ((2,), (2, 3)),
+                dict(method="Nelder-Mead"),
+                None,
+            ),
+            (
+                ((3, 2), (2, 3), False, np.uint8),
+                ((1,), (2, 3)),
+                dict(method="Powell"),
+                [1, 1, 1],
+            ),
+        ],
+        indirect=["ebsd_with_axes_and_random_data", "detector"],
+    )
+    def test_refine_orientation_local(
+        self,
+        ebsd_with_axes_and_random_data,
+        detector,
+        method_kwargs,
+        trust_region,
+        get_single_phase_xmap,
+    ):
+        s = ebsd_with_axes_and_random_data
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert len(refined_xmap2.rotations.data) == 2
-
-        # DE
-        refined_xmap2 = self.s2.orientation_refinement(
-            xmap=self.xmap4,
+        xmap.phases[0].name = self.mp.phase.name
+        method_kwargs.update(dict(options=dict(maxiter=10)))
+        xmap_refined = s.refine_orientation(
+            xmap=xmap,
             master_pattern=self.mp,
-            method="differential_evolution",
-            detector=self.detector3,
-            trust_region=[0.5, 0.5, 0.5],
             energy=20,
+            detector=detector,
+            trust_region=trust_region,
+            method_kwargs=method_kwargs,
         )
+        assert xmap_refined.shape == xmap.shape
+        assert not np.allclose(xmap_refined.rotations.data, xmap.rotations.data)
 
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert len(refined_xmap2.rotations.data) == 2
-
-    def test_pc_refinement(self):
-
-        (scores, new_det) = self.s0.pc_refinement(
-            xmap=self.xmap, master_pattern=self.mp, detector=self.detector, energy=20
+    def test_refine_orientation_not_compute(
+        self,
+        dummy_signal,
+        get_single_phase_xmap,
+    ):
+        s = dummy_signal
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(new_det, type(self.detector))
-        assert len(scores) == 100
-
-        out = self.s0.pc_refinement(
-            xmap=self.xmap,
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        xmap.phases[0].name = self.mp.phase.name
+        xmap_refined = s.refine_orientation(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector,
             energy=20,
+            detector=detector,
+            method_kwargs=dict(options=dict(maxiter=10)),
             compute=False,
         )
+        assert isinstance(xmap_refined, list)
+        assert dask.is_dask_collection(xmap_refined[0])
+        assert len(xmap_refined) == 9
 
-        assert isinstance(out, list)
-
-        (scores1, new_det1) = self.s1.pc_refinement(
-            xmap=self.xmap, master_pattern=self.mp, detector=self.detector, energy=20
+    @pytest.mark.filterwarnings("ignore: The line search algorithm did not converge")
+    @pytest.mark.filterwarnings("ignore: Angles are assumed to be in radians, ")
+    @pytest.mark.parametrize(
+        "method, method_kwargs",
+        [
+            (
+                "basinhopping",
+                dict(minimizer_kwargs=dict(method="Nelder-Mead"), niter=1),
+            ),
+            ("differential_evolution", dict(maxiter=1)),
+            ("dual_annealing", dict(maxiter=1)),
+            (
+                "shgo",
+                dict(
+                    sampling_method="sobol",
+                    options=dict(f_tol=1e-3, maxiter=1),
+                    minimizer_kwargs=dict(
+                        method="Nelder-Mead", options=dict(fatol=1e-3)
+                    ),
+                ),
+            ),
+        ],
+    )
+    def test_refine_orientation_global(
+        self,
+        method,
+        method_kwargs,
+        ebsd_with_axes_and_random_data,
+        get_single_phase_xmap,
+    ):
+        s = ebsd_with_axes_and_random_data
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(new_det1, type(self.detector))
-        assert len(scores1) == 100
-
-        (scores1, new_det1) = self.single.pc_refinement(
-            xmap=self.xmap_single,
+        xmap.phases[0].name = self.mp.phase.name
+        xmap_refined = s.refine_orientation(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector,
             energy=20,
+            detector=detector,
+            method=method,
+            method_kwargs=method_kwargs,
+            trust_region=(0.5, 0.5, 0.5),
         )
-
-        assert isinstance(new_det1, type(self.detector))
-        assert len(scores1) == 1
-
-        (scores1, new_det1) = self.single.pc_refinement(
-            xmap=CrystalMap(Rotation.random((1, 2))),
-            master_pattern=self.mp,
-            detector=self.detector,
-            energy=20,
+        assert xmap_refined.shape == xmap.shape
+        assert not np.allclose(xmap_refined.rotations.data, xmap.rotations.data)
+
+    def test_refine_raises(self, dummy_signal, get_single_phase_xmap):
+        s = dummy_signal
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
+        xmap.phases[0].name = self.mp.phase.name
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        refine_kwargs = dict(master_pattern=self.mp, energy=20, detector=detector)
 
-        assert isinstance(new_det1, type(self.detector))
-        assert len(scores1) == 1
+        with pytest.raises(ValueError, match="Method a not in the list of supported"):
+            _ = s.refine_orientation(xmap=xmap, method="a", **refine_kwargs)
 
-        (scores1, new_det1) = self.single.pc_refinement(
-            xmap=CrystalMap(Rotation.random((1, 2))),
-            master_pattern=self.mp,
-            detector=self.detector,
-            energy=20,
-            mask=self.mask2,
-        )
-
-        assert isinstance(new_det1, type(self.detector))
-        assert len(scores1) == 1
-
-        (scores2, new_det2) = self.s2.pc_refinement(
-            xmap=self.xmap4,
-            master_pattern=self.mp,
-            detector=self.detector3,
-            trust_region=[0.01, 0.01, 0.01],
-            energy=20,
-        )
-
-        assert isinstance(new_det2, type(self.detector))
-        assert len(scores2) == 2
+        with pytest.raises(ValueError, match="Mask and signal must have the same "):
+            _ = s.refine_orientation(
+                xmap=xmap, mask=np.zeros((10, 20)), **refine_kwargs
+            )
 
-        # BH
-        (scores2, new_det2) = self.s2.pc_refinement(
-            xmap=self.xmap4,
-            master_pattern=self.mp,
-            detector=self.detector3,
-            trust_region=[0.01, 0.01, 0.01],
-            energy=20,
-            method="basinhopping",
-            method_kwargs={"minimizer_kwargs": {"method": "Nelder-Mead"}},
+        xmap.phases.add(Phase(name="b", point_group="m-3m"))
+        xmap._phase_id[0] = 1
+        with pytest.raises(ValueError, match="Crystal map must have exactly one phase"):
+            _ = s.refine_orientation(xmap=xmap, **refine_kwargs)
+
+    def test_refine_mask(self, dummy_signal, get_single_phase_xmap):
+        s = dummy_signal
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(new_det2, type(self.detector))
-        assert len(scores2) == 2
-        # DA
-        (scores2, new_det2) = self.s2.pc_refinement(
-            xmap=self.xmap4,
+        xmap.phases[0].name = self.mp.phase.name
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        refine_kwargs = dict(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector3,
-            trust_region=[0.01, 0.01, 0.01],
             energy=20,
-            method="dual_annealing",
+            detector=detector,
+            method="minimize",
+            method_kwargs=dict(method="Nelder-Mead", options=dict(maxiter=10)),
         )
+        xmap_refined_no_mask = s.refine_orientation(**refine_kwargs)
+        mask = np.zeros(s.axes_manager.signal_shape[::-1], dtype=bool)
+        mask[0, 0] = 1  # Mask away upper left pixel
+        xmap_refined_mask = s.refine_orientation(mask=mask, **refine_kwargs)
 
-        assert isinstance(new_det2, type(self.detector))
-        assert len(scores2) == 2
-        # DE
-        (scores2, new_det2) = self.s2.pc_refinement(
-            xmap=self.xmap4,
-            master_pattern=self.mp,
-            detector=self.detector3,
-            trust_region=[0.01, 0.01, 0.01],
-            energy=20,
-            method="differential_evolution",
+        assert not np.allclose(
+            xmap_refined_no_mask.rotations.data, xmap_refined_mask.rotations.data
         )
 
-        assert isinstance(new_det2, type(self.detector))
-        assert len(scores2) == 2
+    # ------------------- Refine projection centers ------------------ #
 
-    def test_full_refinement(self):
-
-        (refined_xmap, new_det) = self.s0.full_refinement(
-            xmap=self.xmap, master_pattern=self.mp, detector=self.detector, energy=20
+    @pytest.mark.parametrize(
+        "ebsd_with_axes_and_random_data, detector, method_kwargs, trust_region",
+        [
+            (
+                ((4,), (3, 4), True, np.float32),
+                ((4,), (3, 4)),
+                dict(method="Nelder-Mead"),
+                None,
+            ),
+            (
+                ((3, 2), (2, 3), False, np.uint8),
+                ((1,), (2, 3)),
+                dict(method="Powell"),
+                [0.01, 0.01, 0.01],
+            ),
+        ],
+        indirect=["ebsd_with_axes_and_random_data", "detector"],
+    )
+    def test_refine_projection_center_local(
+        self,
+        ebsd_with_axes_and_random_data,
+        detector,
+        method_kwargs,
+        trust_region,
+        get_single_phase_xmap,
+    ):
+        s = ebsd_with_axes_and_random_data
+        nav_shape = s.axes_manager.navigation_shape[::-1]
+        xmap = get_single_phase_xmap(
+            nav_shape=nav_shape,
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(refined_xmap, CrystalMap)
-        assert isinstance(new_det, type(self.detector))
-        assert len(refined_xmap.rotations.data) == 100
-
-        (refined_xmap, new_det) = self.single.full_refinement(
-            xmap=self.xmap_single,
+        print(xmap.scores.shape)
+        xmap.phases[0].name = self.mp.phase.name
+        method_kwargs.update(dict(options=dict(maxiter=10)))
+        new_scores, new_detector = s.refine_projection_center(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector,
             energy=20,
+            detector=detector,
+            trust_region=trust_region,
+            method_kwargs=method_kwargs,
         )
+        assert new_scores.shape == nav_shape
+        assert not np.allclose(xmap.get_map_data("scores"), new_scores)
+        assert isinstance(new_detector, kp.detectors.EBSDDetector)
+        assert new_detector.pc.shape == nav_shape + (3,)
 
-        assert isinstance(refined_xmap, CrystalMap)
-        assert isinstance(new_det, type(self.detector))
-        assert len(refined_xmap.rotations.data) == 1
-
-        (refined_xmap, new_det) = self.single.full_refinement(
-            xmap=CrystalMap(Rotation.random((1, 2))),
-            master_pattern=self.mp,
-            detector=self.detector,
-            energy=20,
+    @pytest.mark.filterwarnings("ignore: The line search algorithm did not converge")
+    @pytest.mark.parametrize(
+        "method, method_kwargs",
+        [
+            (
+                "basinhopping",
+                dict(minimizer_kwargs=dict(method="Nelder-Mead"), niter=1),
+            ),
+            ("basinhopping", None),
+            ("differential_evolution", dict(maxiter=1)),
+            ("dual_annealing", dict(maxiter=1)),
+            (
+                "shgo",
+                dict(
+                    sampling_method="sobol",
+                    options=dict(f_tol=1e-3, maxiter=1),
+                    minimizer_kwargs=dict(
+                        method="Nelder-Mead", options=dict(fatol=1e-3)
+                    ),
+                ),
+            ),
+        ],
+    )
+    def test_refine_projection_center_global(
+        self,
+        method,
+        method_kwargs,
+        ebsd_with_axes_and_random_data,
+        get_single_phase_xmap,
+    ):
+        s = ebsd_with_axes_and_random_data
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(refined_xmap, CrystalMap)
-        assert isinstance(new_det, type(self.detector))
-        assert len(refined_xmap.rotations.data) == 1
-
-        (refined_xmap, new_det) = self.single.full_refinement(
-            xmap=CrystalMap(Rotation.random((1, 2))),
+        xmap.phases[0].name = self.mp.phase.name
+        new_scores, new_detector = s.refine_projection_center(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector,
             energy=20,
-            mask=self.mask2,
+            detector=detector,
+            method=method,
+            method_kwargs=method_kwargs,
+            trust_region=(0.01, 0.01, 0.01),
         )
+        assert new_scores.shape == xmap.shape
+        assert not np.allclose(new_scores, xmap.get_map_data("scores"))
+        assert isinstance(new_detector, kp.detectors.EBSDDetector)
+        assert not np.allclose(detector.pc, new_detector.pc[0, 0])
 
-        assert isinstance(refined_xmap, CrystalMap)
-        assert isinstance(new_det, type(self.detector))
-        assert len(refined_xmap.rotations.data) == 1
-
-        out = self.s0.full_refinement(
-            xmap=self.xmap,
+    def test_refine_projection_center_not_compute(
+        self,
+        dummy_signal,
+        get_single_phase_xmap,
+    ):
+        s = dummy_signal
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
+        )
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        xmap.phases[0].name = self.mp.phase.name
+        delayed_results = s.refine_projection_center(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector,
             energy=20,
+            detector=detector,
+            method_kwargs=dict(options=dict(maxiter=10)),
             compute=False,
         )
+        assert isinstance(delayed_results, list)
+        assert dask.is_dask_collection(delayed_results[0])
+        assert len(delayed_results) == 9
 
-        assert isinstance(out, tuple)
+    # ---------- Refine orientations and projection centers ---------- #
 
-        (refined_xmap1, new_det1) = self.s1.full_refinement(
-            xmap=self.xmap, master_pattern=self.mp, detector=self.detector, energy=20
+    @pytest.mark.parametrize(
+        "method_kwargs, trust_region",
+        [
+            (dict(method="Nelder-Mead"), None),
+            (dict(method="Powell"), [0.5, 0.5, 0.5, 0.01, 0.01, 0.01]),
+        ],
+    )
+    def test_refine_orientation_projection_center_local(
+        self,
+        dummy_signal,
+        method_kwargs,
+        trust_region,
+        get_single_phase_xmap,
+    ):
+        s = dummy_signal
+        nav_shape = s.axes_manager.navigation_shape[::-1]
+        xmap = get_single_phase_xmap(
+            nav_shape=nav_shape,
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(refined_xmap1, CrystalMap)
-        assert isinstance(new_det1, type(self.detector))
-        assert len(refined_xmap1.rotations.data) == 100
-
-        (refined_xmap2, new_det2) = self.s2.full_refinement(
-            xmap=self.xmap4,
+        xmap.phases[0].name = self.mp.phase.name
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        method_kwargs.update(dict(options=dict(maxiter=10)))
+        xmap_refined, detector_refined = s.refine_orientation_projection_center(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector3,
             energy=20,
-            trust_region=[0.1, 0.1, 0.1, 0.01, 0.01, 0.01],
+            detector=detector,
+            trust_region=trust_region,
+            method_kwargs=method_kwargs,
         )
+        assert xmap_refined.shape == xmap.shape
+        assert not np.allclose(xmap_refined.rotations.data, xmap.rotations.data)
+        assert isinstance(detector_refined, kp.detectors.EBSDDetector)
+        assert detector_refined.pc.shape == nav_shape + (3,)
 
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert isinstance(new_det2, type(self.detector))
-        assert len(refined_xmap2.rotations.data) == 2
-
-        # BH
-        (refined_xmap2, new_det2) = self.s2.full_refinement(
-            xmap=self.xmap4,
-            master_pattern=self.mp,
-            detector=self.detector3,
-            energy=20,
-            method="basinhopping",
-            method_kwargs={"minimizer_kwargs": {"method": "Nelder-Mead"}},
-            trust_region=[0.1, 0.1, 0.1, 0.01, 0.01, 0.01],
+    @pytest.mark.filterwarnings("ignore: The line search algorithm did not converge")
+    @pytest.mark.filterwarnings("ignore: Angles are assumed to be in radians, ")
+    @pytest.mark.parametrize(
+        "method, method_kwargs",
+        [
+            (
+                "basinhopping",
+                dict(minimizer_kwargs=dict(method="Nelder-Mead"), niter=1),
+            ),
+            ("differential_evolution", dict(maxiter=1)),
+            ("dual_annealing", dict(maxiter=1)),
+            (
+                "shgo",
+                dict(
+                    sampling_method="sobol",
+                    options=dict(f_tol=1e-3, maxiter=1),
+                    minimizer_kwargs=dict(
+                        method="Nelder-Mead", options=dict(fatol=1e-3)
+                    ),
+                ),
+            ),
+        ],
+    )
+    def test_refine_orientation_projection_center_global(
+        self,
+        method,
+        method_kwargs,
+        dummy_signal,
+        get_single_phase_xmap,
+    ):
+        s = dummy_signal
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
         )
-
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert isinstance(new_det2, type(self.detector))
-        assert len(refined_xmap2.rotations.data) == 2
-        # DA
-        (refined_xmap2, new_det2) = self.s2.full_refinement(
-            xmap=self.xmap4,
+        xmap.phases[0].name = self.mp.phase.name
+        xmap_refined, new_detector = s.refine_orientation_projection_center(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector3,
             energy=20,
-            method="dual_annealing",
-            trust_region=[0.1, 0.1, 0.1, 0.01, 0.01, 0.01],
+            detector=detector,
+            method=method,
+            method_kwargs=method_kwargs,
+            trust_region=[0.5, 0.5, 0.5, 0.01, 0.01, 0.01],
         )
+        assert xmap_refined.shape == xmap.shape
+        assert not np.allclose(xmap_refined.rotations.data, xmap.rotations.data)
+        assert isinstance(new_detector, kp.detectors.EBSDDetector)
+        assert not np.allclose(detector.pc, new_detector.pc[0, 0])
 
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert isinstance(new_det2, type(self.detector))
-        assert len(refined_xmap2.rotations.data) == 2
-
-        # DE
-        (refined_xmap2, new_det2) = self.s2.full_refinement(
-            xmap=self.xmap4,
+    def test_refine_orientation_projection_center_not_compute(
+        self, dummy_signal, get_single_phase_xmap
+    ):
+        s = dummy_signal
+        xmap = get_single_phase_xmap(
+            nav_shape=s.axes_manager.navigation_shape[::-1],
+            rotations_per_point=1,
+            step_sizes=tuple(a.scale for a in s.axes_manager.navigation_axes)[::-1],
+        )
+        xmap.phases[0].name = self.mp.phase.name
+        detector = kp.detectors.EBSDDetector(shape=s.axes_manager.signal_shape[::-1])
+        delayed_results = s.refine_orientation_projection_center(
+            xmap=xmap,
             master_pattern=self.mp,
-            detector=self.detector3,
             energy=20,
-            method="differential_evolution",
-            trust_region=[0.1, 0.1, 0.1, 0.01, 0.01, 0.01],
+            detector=detector,
+            method_kwargs=dict(options=dict(maxiter=1)),
+            compute=False,
         )
-
-        assert isinstance(refined_xmap2, CrystalMap)
-        assert isinstance(new_det2, type(self.detector))
-        assert len(refined_xmap2.rotations.data) == 2
+        assert isinstance(delayed_results, list)
+        assert dask.is_dask_collection(delayed_results[0])
+        assert len(delayed_results) == 9
 
 
 class TestAverageNeighbourDotProductMap:
diff --git a/kikuchipy/signals/tests/test_ebsd_masterpattern.py b/kikuchipy/signals/tests/test_ebsd_master_pattern.py
similarity index 53%
rename from kikuchipy/signals/tests/test_ebsd_masterpattern.py
rename to kikuchipy/signals/tests/test_ebsd_master_pattern.py
index 546fb6b2..9a956373 100644
--- a/kikuchipy/signals/tests/test_ebsd_masterpattern.py
+++ b/kikuchipy/signals/tests/test_ebsd_master_pattern.py
@@ -23,23 +23,26 @@
 from hyperspy._signals.signal2d import Signal2D
 import numpy as np
 from orix.crystal_map import Phase
-from orix.vector import Vector3d
 from orix.quaternion import Rotation
 import pytest
 
 import kikuchipy as kp
 from kikuchipy import load
-from kikuchipy.conftest import assert_dictionary
 from kikuchipy.data import nickel_ebsd_master_pattern_small
 from kikuchipy.io.plugins.tests.test_emsoft_ebsd_masterpattern import (
     setup_axes_manager,
     METADATA,
 )
 from kikuchipy.signals.tests.test_ebsd import assert_dictionary
-from kikuchipy.signals.ebsd_master_pattern import (
-    _get_direction_cosines,
+from kikuchipy.signals.util._master_pattern import (
+    _get_cosine_sine_of_alpha_and_azimuthal,
+    _get_direction_cosines_for_multiple_pcs,
+    _get_direction_cosines_for_single_pc,
+    _get_direction_cosines_for_single_pc_from_detector,
     _get_lambert_interpolation_parameters,
-    _get_patterns_chunk,
+    _get_pixel_from_master_pattern,
+    _project_patterns_from_master_pattern,
+    _project_single_pattern_from_master_pattern,
 )
 from kikuchipy.indexing.similarity_metrics import ncc, ndp
 
@@ -156,7 +159,7 @@ def test_properties(self, projection, hemisphere):
         assert mp2.phase.point_group.name != mp.phase.point_group.name
 
 
-class TestSimulatedPatternDictionary:
+class TestProjectingPatternsFromLambert:
     detector = kp.detectors.EBSDDetector(
         shape=(480, 640),
         px_size=50,
@@ -167,136 +170,107 @@ class TestSimulatedPatternDictionary:
     )
 
     def test_get_direction_cosines(self):
-        out = _get_direction_cosines(self.detector)
-        assert isinstance(out, Vector3d)
-
-    def test_get_lambert_interpolation_parameters(self):
-        dc = _get_direction_cosines(self.detector)
-        npx = 1001
-        npy = 1001
-        scale = 500
-        nii, nij, niip, nijp = _get_lambert_interpolation_parameters(
-            rotated_direction_cosines=dc, npx=npx, npy=npy, scale=scale
-        )[:4]
-
-        assert (nii <= niip).all()
-        assert (nij <= nijp).all()
-
-        assert (nii < npx).all()
-        assert (nij < npy).all()
-        assert (niip < npx).all()
-        assert (nijp < npx).all()
-
-        assert (nii >= 0).all()
-        assert (nij >= 0).all()
-        assert (niip >= 0).all()
-        assert (nijp >= 0).all()
+        detector = self.detector
+        dc = _get_direction_cosines_for_single_pc_from_detector(detector)
+        assert dc.shape == detector.shape + (3,)
+        assert np.max(dc) <= 1
+
+        dc2 = _get_direction_cosines_for_single_pc.py_func(
+            pcx=detector.pcx,
+            pcy=detector.pcy,
+            pcz=detector.pcz,
+            nrows=detector.nrows,
+            ncols=detector.ncols,
+            tilt=detector.tilt,
+            azimuthal=detector.azimuthal,
+            sample_tilt=detector.sample_tilt,
+        )
+        assert np.allclose(dc, dc2)
 
     def test_get_patterns(self):
-        # Ni Test
         emsoft_key = load(EMSOFT_EBSD_FILE)
         emsoft_key = emsoft_key.data[0]
 
-        angles = np.array((120, 45, 60))
-        r = Rotation.from_euler(np.radians(angles))
-        kp_mp = nickel_ebsd_master_pattern_small(
-            projection="lambert", hemisphere="both"
-        )
-        kp_pattern = kp_mp.get_patterns(
-            rotations=r, detector=self.detector, energy=20, dtype_out=np.uint8
+        r = Rotation.from_euler(np.radians([120, 45, 60]))
+        mp1 = nickel_ebsd_master_pattern_small(projection="lambert", hemisphere="both")
+        kp_pattern = mp1.get_patterns(
+            rotations=r, detector=self.detector, energy=20, dtype_out=emsoft_key.dtype
         )
         kp_pat = kp_pattern.data[0].compute()
-
+        assert kp_pat.dtype == emsoft_key.dtype
         ncc1 = ncc(kp_pat, emsoft_key)
         ndp1 = ndp(kp_pat, emsoft_key)
-
         assert ncc1 >= 0.935
         assert ndp1 >= 0.935
 
         detector_shape = self.detector.shape
         r2 = Rotation.from_euler(((0, 0, 0), (1, 1, 1), (2, 2, 2)))
-        mp_a = kp.signals.EBSDMasterPattern(np.zeros((2, 10, 11, 11)))
-        mp_a.axes_manager[0].name = "hemisphere"
-        mp_a.axes_manager[1].name = "energy"
-        mp_a.projection = "lambert"
-        mp_a.phase = Phase("Ni", 225)
-        out_a = mp_a.get_patterns(r2, self.detector, 5)
-
-        assert isinstance(out_a, kp.signals.LazyEBSD)
+        mp2 = kp.signals.EBSDMasterPattern(np.zeros((2, 10, 11, 11)))
+        mp2.axes_manager[0].name = "hemisphere"
+        mp2.axes_manager[1].name = "energy"
+        mp2.projection = "lambert"
+        mp2.phase = Phase("Ni", 225)
+        out2 = mp2.get_patterns(r2, self.detector, 5)
+        assert isinstance(out2, kp.signals.LazyEBSD)
         desired_data_shape = (3,) + detector_shape[::-1]
-        assert out_a.axes_manager.shape == desired_data_shape
-
-        mp_b = kp.signals.EBSDMasterPattern(np.zeros((10, 11, 11)))
-        mp_b.axes_manager[0].name = "energy"
-        mp_b.projection = "lambert"
-        mp_b.phase = Phase("Ni", 225)
-        out_b = mp_b.get_patterns(r2, self.detector, 5)
-
-        assert isinstance(out_b, kp.signals.LazyEBSD)
-        assert out_b.axes_manager.shape == desired_data_shape
-
-        mp_c = kp.signals.EBSDMasterPattern(np.zeros((11, 11)))
-        mp_c.projection = "lambert"
-        mp_c.phase = Phase("Ni", 225)
-        out_c = mp_c.get_patterns(r2, self.detector, 5)
-        out_c_2 = mp_c.get_patterns(r2, self.detector, 5, compute=True)
-
-        assert isinstance(out_c, kp.signals.LazyEBSD)
-        assert isinstance(out_c_2, kp.signals.EBSD)
-        assert out_c.axes_manager.shape == desired_data_shape
-
-        mp_c2 = kp.signals.EBSDMasterPattern(np.zeros((11, 11)))
-        mp_c2.projection = "lambert"
-        mp_c2.phase = Phase("!Ni", 220)
+        assert out2.axes_manager.shape == desired_data_shape
+
+        mp3 = kp.signals.EBSDMasterPattern(np.zeros((10, 11, 11)))
+        mp3.axes_manager[0].name = "energy"
+        mp3.projection = "lambert"
+        mp3.phase = Phase("Ni", 225)
+        out3 = mp3.get_patterns(r2, self.detector, 5)
+        assert isinstance(out3, kp.signals.LazyEBSD)
+        assert out3.axes_manager.shape == desired_data_shape
+
+        mp4 = kp.signals.EBSDMasterPattern(np.zeros((11, 11)))
+        mp4.projection = "lambert"
+        mp4.phase = Phase("Ni", 225)
+        out41 = mp4.get_patterns(r2, self.detector, 5)
+        out42 = mp4.get_patterns(r2, self.detector, 5, compute=True)
+
+        assert isinstance(out41, kp.signals.LazyEBSD)
+        assert isinstance(out42, kp.signals.EBSD)
+        assert out41.axes_manager.shape == desired_data_shape
+
+        mp5 = kp.signals.EBSDMasterPattern(np.zeros((11, 11)))
+        mp5.projection = "lambert"
+        mp5.phase = Phase("!Ni", 220)
         with pytest.raises(AttributeError):
-            mp_c2.get_patterns(r2, self.detector, 5)
+            _ = mp5.get_patterns(r2, self.detector, 5)
 
-        mp_d = kp.signals.EBSDMasterPattern(np.zeros((2, 11, 11)))
-        with pytest.raises(NotImplementedError):
-            mp_d.get_patterns(r2, self.detector, 5)
+        mp6 = kp.signals.EBSDMasterPattern(np.zeros((2, 11, 11)))
+        with pytest.raises(AttributeError, match="Master pattern `phase` attribute"):
+            _ = mp6.get_patterns(r2, self.detector, 5)
 
-        mp_e = kp.signals.EBSDMasterPattern(np.zeros((10, 11, 11)))
-        mp_e.axes_manager[0].name = "energy"
-        mp_e.projection = "lambert"
-        mp_e.phase = Phase("!Ni", 220)
-        with pytest.raises(AttributeError):
-            mp_e.get_patterns(r2, self.detector, 5)
+        mp7 = kp.signals.EBSDMasterPattern(np.zeros((10, 11, 11)))
+        mp7.axes_manager[0].name = "energy"
+        mp7.projection = "lambert"
+        mp7.phase = Phase("!Ni", 220)
+        with pytest.raises(AttributeError, match="For point groups without inversion"):
+            _ = mp7.get_patterns(r2, self.detector, 5)
 
-        # More than one Projection center is currently not supported so
-        # it should fail
+        # More than one PC is currently not supported so should fail
         d2 = kp.detectors.EBSDDetector(
             shape=(10, 10),
             px_size=50,
             pc=((0, 0, 15000), (0, 0, 15000)),
             convention="emsoft4",
-            tilt=0,
             sample_tilt=70,
         )
         with pytest.raises(NotImplementedError):
-            mp_c.get_patterns(r2, d2, 5)
+            _ = mp4.get_patterns(r2, d2, 5)
 
         # TODO: Create tests for other structures
 
-    def test_get_patterns_chunk(self):
-        r = Rotation.from_euler(((0, 0, 0), (1, 1, 1), (2, 2, 2)))
-        dc = _get_direction_cosines(self.detector)
-
-        mpn = np.empty((1001, 1001))
-        mps = mpn
-        npx = 1001
-        npy = npx
-        out = _get_patterns_chunk(
-            rotations_array=r.data,
-            dc=dc,
-            master_north=mpn,
-            master_south=mps,
-            npx=npx,
-            npy=npy,
-            scale=(npx - 1) / 2,
-            rescale=False,
+    def test_get_patterns_dtype(self):
+        r = Rotation.identity()
+        mp = nickel_ebsd_master_pattern_small(projection="lambert")
+        dtype_out = np.dtype("float64")
+        pattern = mp.get_patterns(
+            rotations=r, detector=self.detector, energy=20, dtype_out=dtype_out
         )
-
-        assert out.shape == r.shape + dc.shape
+        assert pattern.data.dtype == dtype_out
 
     def test_simulated_patterns_xmap_detector(self):
         mp = nickel_ebsd_master_pattern_small(projection="lambert")
@@ -329,7 +303,7 @@ def test_get_patterns_navigation_shape_raises(self):
         mp = nickel_ebsd_master_pattern_small(projection="lambert")
         r = Rotation(np.random.uniform(low=0, high=1, size=(1, 2, 3, 4)))
         detector = kp.detectors.EBSDDetector(shape=(60, 60))
-        with pytest.raises(ValueError, match="The rotations object can only"):
+        with pytest.raises(ValueError, match="`rotations` can only have one or two "):
             _ = mp.get_patterns(rotations=r, detector=detector, energy=20)
 
     def test_detector_azimuthal(self):
@@ -351,11 +325,136 @@ def test_detector_azimuthal(self):
         mp = nickel_ebsd_master_pattern_small(projection="lambert")
         r = Rotation.identity()
 
-        kwargs = dict(rotations=r, energy=20, compute=True)
+        kwargs = dict(rotations=r, energy=20, compute=True, dtype_out=np.uint8)
         sim1 = mp.get_patterns(detector=det1, **kwargs)
         sim2 = mp.get_patterns(detector=det2, **kwargs)
         sim3 = mp.get_patterns(detector=det3, **kwargs)
 
         assert not np.allclose(sim1.data, sim2.data)
-        assert np.allclose(sim2.data.mean(), 44.06, atol=1e-2)
-        assert np.allclose(sim3.data.mean(), 43.88, atol=1e-2)
+        assert np.allclose(sim2.data.mean(), 43.56, atol=1e-2)
+        assert np.allclose(sim3.data.mean(), 43.39, atol=1e-2)
+
+    def test_project_patterns_from_master_pattern(self):
+        """Make sure the Numba function is covered."""
+        r = Rotation.from_euler(((0, 0, 0), (1, 1, 1), (2, 2, 2)))
+        dc = _get_direction_cosines_for_single_pc_from_detector(self.detector)
+
+        npx = npy = 101
+        mpn = mps = np.zeros((npy, npx))
+        patterns = _project_patterns_from_master_pattern.py_func(
+            rotations=r.data,
+            direction_cosines=dc,
+            master_north=mpn,
+            master_south=mps,
+            npx=npx,
+            npy=npy,
+            scale=float((npx - 1) / 2),
+            dtype_out=mpn.dtype,
+            rescale=False,
+            # Aren't used
+            out_min=1,
+            out_max=2,
+        )
+
+        assert patterns.shape == r.shape + dc.shape[:-1]
+
+    @pytest.mark.parametrize(
+        "dtype_out, intensity_range", [(np.float32, (0, 1)), (np.uint8, (0, 255))]
+    )
+    def test_project_single_pattern_from_master_pattern(
+        self, dtype_out, intensity_range
+    ):
+        """Make sure the Numba function is covered."""
+        dc = _get_direction_cosines_for_single_pc_from_detector(self.detector)
+        npx = npy = 101
+        mpn = mps = np.random.random(npy * npx).reshape((npy, npx))
+
+        pattern = _project_single_pattern_from_master_pattern.py_func(
+            rotation=np.array([1, 1, 0, 0], dtype=float),
+            direction_cosines=dc.reshape((-1, 3)),
+            master_north=mpn,
+            master_south=mps,
+            npx=npx,
+            npy=npy,
+            scale=1,
+            n_pixels=self.detector.size,
+            rescale=True,
+            out_min=intensity_range[0],
+            out_max=intensity_range[1],
+            dtype_out=dtype_out,
+        )
+        assert pattern.shape == (self.detector.size,)
+        assert pattern.dtype == dtype_out
+        assert np.min(pattern) == intensity_range[0]
+        assert np.max(pattern) == intensity_range[1]
+
+    def test_get_lambert_interpolation_parameters(self):
+        """Make sure the Numba function is covered."""
+        dc = _get_direction_cosines_for_single_pc_from_detector(self.detector)
+        npx = npy = 101
+        scale = (npx - 1) // 2
+        nii, nij, niip, nijp = _get_lambert_interpolation_parameters.py_func(
+            v=dc.reshape((-1, 3)), npx=npx, npy=npy, scale=scale
+        )[:4]
+
+        assert np.all(nii <= niip)
+        assert np.all(nij <= nijp)
+        assert np.all(nii < npx)
+        assert np.all(nij < npy)
+        assert np.all(niip < npx)
+        assert np.all(nijp < npx)
+        assert np.all(nii >= 0)
+        assert np.all(nij >= 0)
+        assert np.all(niip >= 0)
+        assert np.all(nijp >= 0)
+
+    def test_get_pixel_from_master_pattern(self):
+        """Make sure the Numba function is covered."""
+        dc = _get_direction_cosines_for_single_pc_from_detector(self.detector)
+        npx = npy = 101
+        scale = (npx - 1) // 2
+        (
+            nii,
+            nij,
+            niip,
+            nijp,
+            di,
+            dj,
+            dim,
+            djm,
+        ) = _get_lambert_interpolation_parameters(
+            v=dc.reshape((-1, 3)), npx=npx, npy=npy, scale=scale
+        )
+        mp = np.ones((npy, npx), dtype=float)
+        value = _get_pixel_from_master_pattern.py_func(
+            mp, nii[0], nij[0], niip[0], nijp[0], di[0], dj[0], dim[0], djm[0]
+        )
+        assert value == 1.0
+
+    def test_get_cosine_sine_of_alpha_and_azimuthal(self):
+        """Make sure the Numba function is covered."""
+        values = _get_cosine_sine_of_alpha_and_azimuthal.py_func(
+            sample_tilt=70, tilt=10, azimuthal=5
+        )
+        assert np.allclose(values, [0.866, 0.5, 0.996, 0.087], atol=1e-3)
+
+    def test_get_direction_cosines_for_multiple_pcs(self):
+        """Make sure the Numba function is covered."""
+        detector = self.detector
+        dc0 = _get_direction_cosines_for_single_pc_from_detector(detector)
+        nav_shape = (2, 3)
+        detector.pc = np.full(nav_shape + (3,), detector.pc)
+        nrows, ncols = detector.shape
+        dc = _get_direction_cosines_for_multiple_pcs.py_func(
+            pcx=detector.pcx.ravel(),
+            pcy=detector.pcy.ravel(),
+            pcz=detector.pcz.ravel(),
+            nrows=nrows,
+            ncols=ncols,
+            tilt=detector.tilt,
+            azimuthal=detector.azimuthal,
+            sample_tilt=detector.sample_tilt,
+        )
+
+        assert np.allclose(dc0, dc[0])
+        assert dc.shape == (np.prod(nav_shape), nrows, ncols, 3)