Repository: yt-project/yt

Issue:
BUG: figures with height > width are too tall
### Bug report

**Bug summary**

related to #3631
Currently when plotting a region that happens to be broader in "height" (vertical axis) than in "width" (horizontal axis), the output figure may be very tall and not as readable as the standard square figure produced by yt.


**Code for reproduction**

```python
ds = yt.load_sample("KelvinHelmholtz")

slc = yt.SlicePlot(ds, "z", "density")
slc.save("/tmp/rect_1.png")
```

**Actual outcome**
![zy](https://user-images.githubusercontent.com/14075922/139574875-7b36dab3-81cc-4b31-867f-9a0784d54e50.png)

**Expected outcome**

I'd except a smaller figure, that would fit my screen while staying readable, on par with what I obtain after flipping axes:
```python
vals = ds.coordinates.y_axis["z"], ds.coordinates.x_axis["z"]
ds.coordinates.x_axis["z"], ds.coordinates.y_axis["z"] = vals
ds.coordinates.x_axis[2], ds.coordinates.y_axis[2] = vals
slc = yt.SlicePlot(ds, "z", "density")
slc.save("/tmp/rect_2.png")
```
![yz](https://user-images.githubusercontent.com/14075922/139574880-911ae4ed-e1cf-4600-b0be-375fa94b6a2f.png)

I think the root cause is trivially that the figure width is hardcoded while the aspect ratio is constant.
I would make more sense to hardcode the width of the largest dimension, wether it happens to be the width or the height of the figure, and it would be backwards compatible in cases where it doesn't matter.


Discussion:
After looking into the code I think it may even be considered a bug, because the following comment shows intention of implementing the behaviour I expect, not the one we actual see
https://github.com/yt-project/yt/blob/756746402d03857b0c7f6276c5612d5b93faa0fa/yt/visualization/base_plot_types.py#L343-L345

I think I got half the fix with this patch:
```patch
--- a/yt/visualization/base_plot_types.py
+++ b/yt/visualization/base_plot_types.py
@@ -343,12 +343,12 @@ class ImagePlotMPL(PlotMPL):
     def _get_best_layout(self):

         # Ensure the figure size along the long axis is always equal to _figure_size
-        if is_sequence(self._figure_size):
-            x_fig_size = self._figure_size[0]
-            y_fig_size = self._figure_size[1]
-        else:
-            x_fig_size = self._figure_size
-            y_fig_size = self._figure_size / self._aspect
+        if not is_sequence(self._figure_size):
+            self._figure_size = (self._figure_size, self._figure_size / self._aspect)
+            if self._aspect < 1:
+                self._figure_size = tuple(reversed(self._figure_size))
+
+        x_fig_size, y_fig_size = self._figure_size

         if hasattr(self, "_unit_aspect"):
             y_fig_size = y_fig_size * self._unit_aspect
```
running the script from the OP again yields

![zy](https://user-images.githubusercontent.com/14075922/139578129-926e26be-37c2-4025-975d-a89de78b6307.png)
(the axis-flipped version is unchanged)
So the elements of the figure have the desired size, but the window size is now too wide somehow.

Resolving patch:
diff --git a/yt/visualization/base_plot_types.py b/yt/visualization/base_plot_types.py
--- a/yt/visualization/base_plot_types.py
+++ b/yt/visualization/base_plot_types.py
@@ -340,15 +340,17 @@ def _init_image(self, data, cbnorm, cblinthresh, cmap, extent, aspect):
     def _get_best_layout(self):
 
         # Ensure the figure size along the long axis is always equal to _figure_size
+        unit_aspect = getattr(self, "_unit_aspect", 1)
         if is_sequence(self._figure_size):
-            x_fig_size = self._figure_size[0]
-            y_fig_size = self._figure_size[1]
+            x_fig_size, y_fig_size = self._figure_size
+            y_fig_size *= unit_aspect
         else:
-            x_fig_size = self._figure_size
-            y_fig_size = self._figure_size / self._aspect
-
-        if hasattr(self, "_unit_aspect"):
-            y_fig_size = y_fig_size * self._unit_aspect
+            x_fig_size = y_fig_size = self._figure_size
+            scaling = self._aspect / unit_aspect
+            if scaling < 1:
+                x_fig_size *= scaling
+            else:
+                y_fig_size /= scaling
 
         if self._draw_colorbar:
             cb_size = self._cb_size


Test patch:
diff --git a/tests/tests.yaml b/tests/tests.yaml
--- a/tests/tests.yaml
+++ b/tests/tests.yaml
@@ -170,7 +170,7 @@ answer_tests:
   local_cylindrical_background_011:  # PR 3670
     - yt/geometry/coordinates/tests/test_cylindrical_coordinates.py:test_noise_plots
 
-  local_spherical_background_004:  # PR 3670
+  local_spherical_background_005:  # PR 3633
     - yt/geometry/coordinates/tests/test_spherical_coordinates.py:test_noise_plots
 
   #local_particle_trajectory_001:
diff --git a/yt/utilities/answer_testing/framework.py b/yt/utilities/answer_testing/framework.py
--- a/yt/utilities/answer_testing/framework.py
+++ b/yt/utilities/answer_testing/framework.py
@@ -13,6 +13,7 @@
 import tempfile
 import time
 import urllib
+import warnings
 import zlib
 from collections import defaultdict
 
@@ -777,6 +778,32 @@ def dump_images(new_result, old_result, decimals=10):
         sys.stderr.write("\n")
 
 
+def ensure_image_comparability(a, b):
+    # pad nans to the right and the bottom of two images to make them comparable
+    # via matplotlib if they do not have the same shape
+    if a.shape == b.shape:
+        return a, b
+
+    assert a.shape[2:] == b.shape[2:]
+
+    warnings.warn(
+        f"Images have different shapes {a.shape} and {b.shape}. "
+        "Padding nans to make them comparable."
+    )
+    smallest_containing_shape = (
+        max(a.shape[0], b.shape[0]),
+        max(a.shape[1], b.shape[1]),
+        *a.shape[2:],
+    )
+    pa = np.full(smallest_containing_shape, np.nan)
+    pa[: a.shape[0], : a.shape[1], ...] = a
+
+    pb = np.full(smallest_containing_shape, np.nan)
+    pb[: b.shape[0], : b.shape[1], ...] = b
+
+    return pa, pb
+
+
 def compare_image_lists(new_result, old_result, decimals):
     fns = []
     for _ in range(2):
@@ -786,16 +813,26 @@ def compare_image_lists(new_result, old_result, decimals):
     num_images = len(old_result)
     assert num_images > 0
     for i in range(num_images):
-        mpimg.imsave(fns[0], np.loads(zlib.decompress(old_result[i])))
-        mpimg.imsave(fns[1], np.loads(zlib.decompress(new_result[i])))
+        expected = np.loads(zlib.decompress(old_result[i]))
+        actual = np.loads(zlib.decompress(new_result[i]))
+        expected_p, actual_p = ensure_image_comparability(expected, actual)
+
+        mpimg.imsave(fns[0], expected_p)
+        mpimg.imsave(fns[1], actual_p)
         results = compare_images(fns[0], fns[1], 10 ** (-decimals))
         if results is not None:
+            tempfiles = [
+                line.strip() for line in results.split("\n") if line.endswith(".png")
+            ]
+            for fn, img, padded in zip(
+                tempfiles, (expected, actual), (expected_p, actual_p)
+            ):
+                # padded images are convenient for comparison
+                # but what we really want to store and upload
+                # are the actual results
+                if padded.shape != img.shape:
+                    mpimg.imsave(fn, img)
             if os.environ.get("JENKINS_HOME") is not None:
-                tempfiles = [
-                    line.strip()
-                    for line in results.split("\n")
-                    if line.endswith(".png")
-                ]
                 for fn in tempfiles:
                     sys.stderr.write(f"\n[[ATTACHMENT|{fn}]]")
                 sys.stderr.write("\n")