Repository: yt-project/yt

Issue:
ENH: missing flexibility plot callbacks with a factor argument
### Bug report

**Bug summary**

This came out in a discussion with Samuel Whitman on Slack.
Currently, plot callbacks that have a "factor" arguement only accept a single float, which is often sufficient, but not always.
For instance

**Code for reproduction**

```python
import yt

ds = yt.load_sample("IsolatedGalaxy")

plot = yt.SlicePlot(ds, "z", "density", width=(0.04, 0.01))

plot.annotate_quiver(
    "velocity_x",
    "velocity_y",
    factor=16,
    normalize=True,
    plot_args={"color": "white"},
)

plot.save("/tmp/factor.png")
```

**Actual outcome**
![factor](https://user-images.githubusercontent.com/14075922/142736268-9381294d-bb73-4ca7-84c7-2dfe908e2e10.png)

As should be clear here, the resulting quiver plot isn't very useful because it's much denser in one direction than the one.
Internally, this is caused by having the default square resolution of the buffer (800, 800) used even for not physically square domains, and because "factor" is effectively counted in pixel space.

**Expected outcome**
It should not be necessary but we can approach something decent by changing the resolution of the frb itself
```python
plt.set_buff_size(800, 200)
```
![factor](https://user-images.githubusercontent.com/14075922/142736356-d1a760f4-ce18-4e0b-9f79-101f5d023414.png)

This is the solution I gave to Samuel, and it works, but the discoverability feels terrible from the user side.

1) I think that a better solution could be implemented by making `factor` arguments accept pairs of numbers instead of a single one, allowing the users to decouple x/y resolutions in a more discoverable fashion.
2) The _ideal_ solution would probably be to adapt the default size of image buffers for non-square projection domains, though it is probably best to fix #3632 first.

The first solution can be implemented immediately and doesn't collide with the second, nor does it loose all value if we ever implement the second one after that, so I'm just going to implement the easy one now.


Discussion:

Resolving patch:
diff --git a/yt/visualization/plot_modifications.py b/yt/visualization/plot_modifications.py
--- a/yt/visualization/plot_modifications.py
+++ b/yt/visualization/plot_modifications.py
@@ -1,8 +1,8 @@
 import re
 import warnings
 from functools import wraps
-from numbers import Number
-from typing import Optional, Tuple
+from numbers import Integral, Number
+from typing import Any, Dict, Optional, Tuple, Union
 
 import matplotlib
 import numpy as np
@@ -47,6 +47,24 @@ def _check_geometry(self, plot):
     return _check_geometry
 
 
+def _validate_factor_tuple(factor) -> Tuple[int, int]:
+    if (
+        is_sequence(factor)
+        and len(factor) == 2
+        and all(isinstance(_, Integral) for _ in factor)
+    ):
+        # - checking for "is_sequence" allows lists, numpy arrays and other containers
+        # - checking for Integral type allows numpy integer types
+        # in any case we return a with strict typing
+        return (int(factor[0]), int(factor[1]))
+    elif isinstance(factor, Integral):
+        return (int(factor), int(factor))
+    else:
+        raise TypeError(
+            f"Expected a single, or a pair of integers, received {factor!r}"
+        )
+
+
 class PlotCallback:
     # _supported_geometries is set by subclasses of PlotCallback to a tuple of
     # strings corresponding to the names of the geometries that a callback
@@ -308,10 +326,15 @@ class VelocityCallback(PlotCallback):
     _supported_geometries = ("cartesian", "spectral_cube", "polar", "cylindrical")
 
     def __init__(
-        self, factor=16, scale=None, scale_units=None, normalize=False, plot_args=None
+        self,
+        factor: Union[Tuple[int, int], int] = 16,
+        scale=None,
+        scale_units=None,
+        normalize=False,
+        plot_args=None,
     ):
         PlotCallback.__init__(self)
-        self.factor = factor
+        self.factor = _validate_factor_tuple(factor)
         self.scale = scale
         self.scale_units = scale_units
         self.normalize = normalize
@@ -398,10 +421,15 @@ class MagFieldCallback(PlotCallback):
     _supported_geometries = ("cartesian", "spectral_cube", "polar", "cylindrical")
 
     def __init__(
-        self, factor=16, scale=None, scale_units=None, normalize=False, plot_args=None
+        self,
+        factor: Union[Tuple[int, int], int] = 16,
+        scale=None,
+        scale_units=None,
+        normalize=False,
+        plot_args=None,
     ):
         PlotCallback.__init__(self)
-        self.factor = factor
+        self.factor = _validate_factor_tuple(factor)
         self.scale = scale
         self.scale_units = scale_units
         self.normalize = normalize
@@ -478,7 +506,7 @@ def __init__(
         self,
         field_x,
         field_y,
-        factor=16,
+        factor: Union[Tuple[int, int], int] = 16,
         scale=None,
         scale_units=None,
         normalize=False,
@@ -491,7 +519,7 @@ def __init__(
         self.field_y = field_y
         self.bv_x = bv_x
         self.bv_y = bv_y
-        self.factor = factor
+        self.factor = _validate_factor_tuple(factor)
         self.scale = scale
         self.scale_units = scale_units
         self.normalize = normalize
@@ -530,8 +558,8 @@ def _transformed_field(field, data):
 
         # We are feeding this size into the pixelizer, where it will properly
         # set it in reverse order
-        nx = plot.image._A.shape[1] // self.factor
-        ny = plot.image._A.shape[0] // self.factor
+        nx = plot.image._A.shape[1] // self.factor[0]
+        ny = plot.image._A.shape[0] // self.factor[1]
         pixX = plot.data.ds.coordinates.pixelize(
             plot.data.axis,
             plot.data,
@@ -588,7 +616,7 @@ def __init__(
         self,
         field,
         ncont=5,
-        factor=4,
+        factor: Union[Tuple[int, int], int] = 4,
         clim=None,
         plot_args=None,
         label=False,
@@ -602,7 +630,7 @@ def __init__(
         def_text_args = {"colors": "w"}
         self.ncont = ncont
         self.field = field
-        self.factor = factor
+        self.factor = _validate_factor_tuple(factor)
         self.clim = clim
         self.take_log = take_log
         if plot_args is None:
@@ -640,8 +668,8 @@ def __call__(self, plot):
 
         # We want xi, yi in plot coordinates
         xi, yi = np.mgrid[
-            xx0 : xx1 : numPoints_x / (self.factor * 1j),
-            yy0 : yy1 : numPoints_y / (self.factor * 1j),
+            xx0 : xx1 : numPoints_x / (self.factor[0] * 1j),
+            yy0 : yy1 : numPoints_y / (self.factor[1] * 1j),
         ]
         data = self.data_source or plot.data
 
@@ -685,7 +713,7 @@ def __call__(self, plot):
             triangulation = Triangulation(x, y)
             zi = LinearTriInterpolator(triangulation, z)(xi, yi)
         elif plot._type_name == "OffAxisProjection":
-            zi = plot.frb[self.field][:: self.factor, :: self.factor].transpose()
+            zi = plot.frb[self.field][:: self.factor[0], :: self.factor[1]].transpose()
 
         if self.take_log is None:
             field = data._determine_fields([self.field])[0]
@@ -908,18 +936,18 @@ def __init__(
         self,
         field_x,
         field_y,
-        factor=16,
+        factor: Union[Tuple[int, int], int] = 16,
         density=1,
         field_color=None,
         display_threshold=None,
         plot_args=None,
     ):
         PlotCallback.__init__(self)
-        def_plot_args = {}
+        def_plot_args: Optional[Dict[str, Any]] = {}
         self.field_x = field_x
         self.field_y = field_y
         self.field_color = field_color
-        self.factor = factor
+        self.factor = _validate_factor_tuple(factor)
         self.dens = density
         self.display_threshold = display_threshold
         if plot_args is None:
@@ -932,8 +960,8 @@ def __call__(self, plot):
 
         # We are feeding this size into the pixelizer, where it will properly
         # set it in reverse order
-        nx = plot.image._A.shape[1] // self.factor
-        ny = plot.image._A.shape[0] // self.factor
+        nx = plot.image._A.shape[1] // self.factor[0]
+        ny = plot.image._A.shape[0] // self.factor[1]
         pixX = plot.data.ds.coordinates.pixelize(
             plot.data.axis, plot.data, self.field_x, bounds, (nx, ny)
         )
@@ -1130,7 +1158,7 @@ def __init__(
         PlotCallback.__init__(self)
         self.field_x = field_x
         self.field_y = field_y
-        self.factor = factor
+        self.factor = _validate_factor_tuple(factor)
         self.scale = scale
         self.scale_units = scale_units
         self.normalize = normalize
@@ -1141,8 +1169,8 @@ def __init__(
     def __call__(self, plot):
         x0, x1, y0, y1 = self._physical_bounds(plot)
         xx0, xx1, yy0, yy1 = self._plot_bounds(plot)
-        nx = plot.image._A.shape[1] // self.factor
-        ny = plot.image._A.shape[0] // self.factor
+        nx = plot.image._A.shape[1] // self.factor[0]
+        ny = plot.image._A.shape[0] // self.factor[1]
         indices = np.argsort(plot.data["index", "dx"])[::-1].astype(np.int_)
 
         pixX = np.zeros((ny, nx), dtype="f8")


Test patch:
diff --git a/yt/visualization/tests/test_callbacks.py b/yt/visualization/tests/test_callbacks.py
--- a/yt/visualization/tests/test_callbacks.py
+++ b/yt/visualization/tests/test_callbacks.py
@@ -641,7 +641,7 @@ def test_contour_callback():
         slc.annotate_contour(
             ("gas", "plasma_beta"),
             ncont=2,
-            factor=7.0,
+            factor=7,
             take_log=False,
             clim=(1.0e-1, 1.0e1),
             label=True,