Repository: pyvista/pyvista

Issue:
Add movie quality control
Hi everyone, first off I'd like to say PyVista is probably my favorite python package, it's truly phenomenal!

I seem to have the same issue when saving an mp4/mkv file, even if I use the `off_screen` option. It seems no matter what window size I choose, it gets capped at 1920x1080  - rather, it is first rescaled to 1924x1061 before `write_frame()` is called, and then rescaled by ImageIO to 1936x1072 - and the resulting video is quite pixelated. The video file size is also capped at around 1 MB.  The same goes for any screenshots, with the file size capped at 300 KB.

Since I am using a laptop with a dedicated GPU, at first I thought it was due to python utilizing integrated graphics. However, I have set it up such that it now always uses the dedicated GPU, but the problem persists. 

Additionally, I get the following warnings:

```
IMAGEIO FFMPEG_WRITER WARNING: input image is not divisible by macro_block_size=16, resizing from (1924, 1061) to (1936, 1072) to ensure video compatibility with most codecs and players. To prevent resizing, make your input image divisible by the macro_block_size or set the macro_block_size to 1 (risking incompatibility).
[swscaler @ 0000022afedf0f80] Warning: data is not aligned! This can lead to a speed loss
```

My code is not much different than the above, so I am not too sure what is causing this. I have a workaround, which is to just open the plotter and record the screen, but the movements are not as smooth as using the orbit paths. Any help would be greatly appreciated! 

Note: I am using Python 3.6, with pyvista 0.31

```
# Make grid
grid = pv.UniformGrid()
grid.dimensions = np.array(data.shape) + 1
grid.origin = origin
grid.spacing = spacing

grid.cell_arrays['data'] = data.flatten(order='F')

print('Setting up plotter...')
plotter = pv.Plotter(window_size=([1920, 1080]), off_screen=True,
                     line_smoothing=True, polygon_smoothing=True, multi_samples=8)
plotter.set_background(color=[0, 0, 0])

plotter.add_mesh(grid.slice(normal='x', origin=grid.center - 50 * np.array([1, 0, 0])), opacity=0.75,
                 show_scalar_bar=False, smooth_shading=True)
plotter.add_mesh(grid.slice(normal='y', origin=grid.center), opacity=0.75, show_scalar_bar=False, smooth_shading=True)
plotter.add_mesh(grid.slice(normal='z', origin=grid.center - 50 * np.array([0, 0, 1])), opacity=0.75,
                 show_scalar_bar=False, smooth_shading=True)

viewup = [0, 0, 1]
orbit = plotter.generate_orbital_path(factor=2.0, n_points=250, shift=0.0, viewup=viewup)

print('Saving to mkv file...')
plotter.show(auto_close=False)
plotter.open_movie('movie.mkv', framerate=60)
plotter.orbit_on_path(orbit, write_frames=True, viewup=viewup)
plotter.close()
```

_Originally posted by @adouverny in https://github.com/pyvista/pyvista-support/issues/81#issuecomment-869794847_

Discussion:
codecov[bot]: # [Codecov](https://codecov.io/gh/pyvista/pyvista/pull/1552?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) Report
> Merging [#1552](https://codecov.io/gh/pyvista/pyvista/pull/1552?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) (29535fb) into [main](https://codecov.io/gh/pyvista/pyvista/commit/1e82020eff67c0ce2530747421b1d71ec747973e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pyvista) (1e82020) will **decrease** coverage by `0.04%`.
> The diff coverage is `25.00%`.

```diff
@@            Coverage Diff             @@
##             main    #1552      +/-   ##
==========================================
- Coverage   90.17%   90.12%   -0.05%     
==========================================
  Files          68       68              
  Lines       12910    12917       +7     
==========================================
+ Hits        11641    11642       +1     
- Misses       1269     1275       +6     
```


Patch:
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 6c163b23..7bdec281 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -82,23 +82,23 @@ jobs:
     displayName: Build wheel and install pyvista
 
   - script: |
-      .ci/setup_headless_display.sh
+      sudo apt-get update && sudo apt-get install libgl1-mesa-glx xvfb -y
     displayName: Install headless display
 
   - script: |
       sudo apt-get install python3-tk
       pip install -r requirements_test.txt
-      python -c "import pyvista; print(pyvista.Report())"
+      xvfb-run python -c "import pyvista; print(pyvista.Report())"
       which python
       pip list
     displayName: 'Install test dependencies'
 
   - script: |
-      pytest -v --doctest-modules pyvista
+      xvfb-run pytest -v --doctest-modules pyvista
     displayName: 'Test Package Docstrings'
 
   - script: |
-      make doctest-modules-local-namespace
+      xvfb-run make doctest-modules-local-namespace
     displayName: 'Test Package Docstrings with Local Namespace'
 
   # don't run job on no-ci builds
diff --git a/doc/conf.py b/doc/conf.py
index 08824ffd..5096af6f 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -23,10 +23,10 @@ pyvista.set_error_output_file("errors.txt")
 pyvista.OFF_SCREEN = True  # Not necessary - simply an insurance policy
 # Preferred plotting style for documentation
 pyvista.set_plot_theme("document")
-pyvista.global_theme.window_size = np.array([1024, 768]) * 2
-pyvista.global_theme.font.size = 40
-pyvista.global_theme.font.label_size = 40
-pyvista.global_theme.font.title_size = 40
+pyvista.global_theme.window_size = [1024, 768]
+pyvista.global_theme.font.size = 22
+pyvista.global_theme.font.label_size = 22
+pyvista.global_theme.font.title_size = 22
 pyvista.global_theme.return_cpos = False
 pyvista.set_jupyter_backend(None)
 # Save figures in specified directory
diff --git a/examples/02-plot/movie.py b/examples/02-plot/movie.py
index b75fc118..f006abc1 100644
--- a/examples/02-plot/movie.py
+++ b/examples/02-plot/movie.py
@@ -1,4 +1,6 @@
 """
+.. _movie_example:
+
 Create a MP4 Movie
 ~~~~~~~~~~~~~~~~~~
 
diff --git a/examples/02-plot/orbit.py b/examples/02-plot/orbit.py
index fd5bc2ad..767d16fd 100644
--- a/examples/02-plot/orbit.py
+++ b/examples/02-plot/orbit.py
@@ -1,11 +1,15 @@
 """
+.. _orbiting_example:
+
 Orbiting
-~~~~~~~~~
+~~~~~~~~
 
 Orbit around a scene.
 
-NOTE: using ``p.open_movie('orbit.mp4')`` is better than
-``p.open_gif('orbit.gif')``
+.. note::
+   The quality of the movie will be better when using
+   ``p.open_movie('orbit.mp4')`` instead of
+   ``p.open_gif('orbit.gif')``
 
 For orbiting to work you first have to show the scene and leave the plotter open
 with ``.show(auto_close=False)``.
@@ -36,7 +40,7 @@ p.show(auto_close=False)
 viewup = [0.5, 0.5, 1]
 path = p.generate_orbital_path(factor=2.0, shift=10000, viewup=viewup, n_points=36)
 p.open_gif("orbit.gif")
-p.orbit_on_path(path, write_frames=True, viewup=[0, 0, 1])
+p.orbit_on_path(path, write_frames=True, viewup=[0, 0, 1], step=0.05)
 p.close()
 
 
@@ -51,5 +55,5 @@ p.add_mesh(mesh)
 p.show(auto_close=False)
 path = p.generate_orbital_path(factor=2.0, n_points=36, viewup=viewup, shift=0.2)
 p.open_gif("orbit.gif")
-p.orbit_on_path(path, write_frames=True, viewup=viewup)
+p.orbit_on_path(path, write_frames=True, viewup=viewup, step=0.05)
 p.close()
diff --git a/pyvista/plotting/plotting.py b/pyvista/plotting/plotting.py
index 05d1dc84..58936070 100644
--- a/pyvista/plotting/plotting.py
+++ b/pyvista/plotting/plotting.py
@@ -2957,23 +2957,43 @@ class BasePlotter(PickingHelper, WidgetHelper):
         self.add_actor(self.textActor, reset_camera=False, name=name, pickable=False)
         return self.textActor
 
-    def open_movie(self, filename, framerate=24):
+    def open_movie(self, filename, framerate=24, quality=5, **kwargs):
         """Establish a connection to the ffmpeg writer.
 
         Parameters
         ----------
         filename : str
             Filename of the movie to open.  Filename should end in mp4,
-            but other filetypes may be supported.  See "imagio.get_writer"
+            but other filetypes may be supported.  See ``imagio.get_writer``
 
         framerate : int, optional
             Frames per second.
 
+        quality : int, optional
+            Quality 10 is the top possible quality for any codec. The
+            range is ``0 - 10``.  Higher quality leads to a larger file.
+
+        **kwargs : dict
+            See the documentation for ``imageio.get_writer`` for additional kwargs.
+
+        Examples
+        --------
+        Open a MP4 movie and set the quality to maximum.
+
+        >>> import pyvista
+        >>> pl = pyvista.Plotter
+        >>> pl.open_movie('movie.mp4', quality=10)  # doctest:+SKIP
+
+        See Also
+        --------
+        See the documentation for `imageio.get_writer
+        <https://imageio.readthedocs.io/en/stable/userapi.html#imageio.get_writer>`_
+
         """
         from imageio import get_writer
         if isinstance(pyvista.FIGURE_PATH, str) and not os.path.isabs(filename):
             filename = os.path.join(pyvista.FIGURE_PATH, filename)
-        self.mwriter = get_writer(filename, fps=framerate)
+        self.mwriter = get_writer(filename, fps=framerate, quality=quality, **kwargs)
 
     def open_gif(self, filename):
         """Open a gif file.
@@ -2981,7 +3001,15 @@ class BasePlotter(PickingHelper, WidgetHelper):
         Parameters
         ----------
         filename : str
-            Filename of the gif to open.  Filename must end in gif.
+            Filename of the gif to open.  Filename must end in ``"gif"``.
+
+        Examples
+        --------
+        Open a gif file.
+
+        >>> import pyvista
+        >>> pl = pyvista.Plotter
+        >>> pl.open_gif('movie.gif')  # doctest:+SKIP
 
         """
         from imageio import get_writer
@@ -2993,7 +3021,19 @@ class BasePlotter(PickingHelper, WidgetHelper):
         self.mwriter = get_writer(filename, mode='I')
 
     def write_frame(self):
-        """Write a single frame to the movie file."""
+        """Write a single frame to the movie file.
+
+        Examples
+        --------
+        >>> import pyvista
+        >>> plotter = pyvista.Plotter()
+        >>> plotter.open_movie(filename)  # doctest:+SKIP
+        >>> plotter.add_mesh(pyvista.Sphere())  # doctest:+SKIP
+        >>> plotter.write_frame()  # doctest:+SKIP
+
+        See :ref:`movie_example` for a full example using this method.
+
+        """
         # if off screen, show has not been called and we must render
         # before extracting an image
         if self._first_time:
@@ -3769,19 +3809,32 @@ class BasePlotter(PickingHelper, WidgetHelper):
 
         Parameters
         ----------
-        factor : float
+        factor : float, optional
             A scaling factor when building the orbital extent.
 
-        n_points : int
+        n_points : int, optional
             Number of points on the orbital path.
 
-        viewup : list(float)
+        viewup : list(float), optional
             The normal to the orbital plane.
 
         shift : float, optional
             Shift the plane up/down from the center of the scene by
             this amount.
 
+        Examples
+        --------
+        Generate an orbital path around a sphere.
+
+        >>> import pyvista
+        >>> plotter = pyvista.Plotter()
+        >>> _ = plotter.add_mesh(pyvista.Sphere())
+        >>> viewup = [0, 0, 1]
+        >>> orbit = plotter.generate_orbital_path(factor=2.0, n_points=50,
+        ...                                       shift=0.0, viewup=viewup)
+
+        See :ref:`orbiting_example` for a full example using this method.
+
         """
         if viewup is None:
             viewup = self._theme.camera['viewup']
@@ -3804,32 +3857,59 @@ class BasePlotter(PickingHelper, WidgetHelper):
         return self.iren.fly_to(self.renderer, point)
 
     def orbit_on_path(self, path=None, focus=None, step=0.5, viewup=None,
-                      write_frames=False, threaded=False):
+                      write_frames=False, threaded=False, progress_bar=False):
         """Orbit on the given path focusing on the focus point.
 
         Parameters
         ----------
         path : pyvista.PolyData
             Path of orbital points. The order in the points is the order of
-            travel
+            travel.
 
         focus : list(float) of length 3, optional
             The point of focus the camera.
 
         step : float, optional
-            The timestep between flying to each camera position
+            The timestep between flying to each camera position.
 
-        viewup : list(float)
+        viewup : list(float), optional
             the normal to the orbital plane
 
-        write_frames : bool
-            Assume a file is open and write a frame on each camera view during
-            the orbit.
+        write_frames : bool, optional
+            Assume a file is open and write a frame on each camera
+            view during the orbit.
 
         threaded : bool, optional
             Run this as a background thread.  Generally used within a
             GUI (i.e. PyQt).
 
+        progress_bar : bool, optional
+            Show the progress bar when proceeding through the path.
+            This can be helpful to show progress when generating
+            movies with ``off_screen=True``.
+
+        Examples
+        --------
+        Plot an orbit around the earth.  Save the gif as a temporary file.
+
+        >>> import tempfile
+        >>> import os
+        >>> import pyvista
+        >>> filename = os.path.join(tempfile._get_default_tempdir(),
+        ...                         next(tempfile._get_candidate_names()) + '.gif')
+        >>> from pyvista import examples
+        >>> plotter = pyvista.Plotter(window_size=[300, 300])
+        >>> _ = plotter.add_mesh(examples.load_globe(), smooth_shading=True)
+        >>> plotter.open_gif(filename)
+        >>> viewup = [0, 0, 1]
+        >>> orbit = plotter.generate_orbital_path(factor=2.0, n_points=24,
+        ...                                       shift=0.0, viewup=viewup)
+        >>> plotter.orbit_on_path(orbit, write_frames=True, viewup=viewup, 
+        ...                       step=0.02)
+        >>> plotter.close()
+
+        See :ref:`orbiting_example` for a full example using this method.
+
         """
         if focus is None:
             focus = self.center
@@ -3844,17 +3924,32 @@ class BasePlotter(PickingHelper, WidgetHelper):
         # Make sure the whole scene is visible
         self.camera.thickness = path.length
 
+        if progress_bar:
+            try:  # pragma: no cover
+                from tqdm import tqdm
+            except ImportError:
+                raise ImportError("Please install `tqdm` to use ``progress_bar=True``")
+
         def orbit():
             """Define the internal thread for running the orbit."""
-            for point in points:
+            if progress_bar:  # pragma: no cover
+                points_seq = tqdm(points)
+            else:
+                points_seq = points
+
+            for point in points_seq:
+                tstart = time.time()  # include the render time in the step time
                 self.set_position(point)
                 self.set_focus(focus)
                 self.set_viewup(viewup)
                 self.renderer.ResetCameraClippingRange()
-                self.render()
-                time.sleep(step)
                 if write_frames:
                     self.write_frame()
+                else:
+                    self.render()
+                sleep_time = step - (time.time() - tstart)
+                if sleep_time > 0:
+                    time.sleep(sleep_time)
 
         if threaded:
             thread = Thread(target=orbit)
diff --git a/pyvista/plotting/scalar_bars.py b/pyvista/plotting/scalar_bars.py
index d9cf9c95..f6ba5711 100644
--- a/pyvista/plotting/scalar_bars.py
+++ b/pyvista/plotting/scalar_bars.py
@@ -35,16 +35,24 @@ class ScalarBars():
         return '\n'.join(lines)
 
     def _remove_mapper_from_plotter(self, actor, reset_camera=False, render=False):
-        """Remove an actor's mapper from the given plotter's _scalar_bar_mappers."""
+        """Remove an actor's mapper from the given plotter's _scalar_bar_mappers.
+
+        This ensures that when actors are removed, their corresponding
+        scalar bars are removed.
+
+        """
         try:
             mapper = actor.GetMapper()
         except AttributeError:
             return
+
+        # NOTE: keys to list to prevent iterator changing during loop
         for name in list(self._scalar_bar_mappers):
             try:
                 self._scalar_bar_mappers[name].remove(mapper)
             except ValueError:
                 pass
+
             if not self._scalar_bar_mappers[name]:
                 slot = self._plotter._scalar_bar_slot_lookup.pop(name, None)
                 if slot is not None:
@@ -79,15 +87,20 @@ class ScalarBars():
             else:
                 title = list(self._scalar_bar_actors.keys())[0]
 
+        actor = self._scalar_bar_actors.pop(title)
+        self._plotter.remove_actor(actor, render=render)
         self._scalar_bar_ranges.pop(title)
         self._scalar_bar_mappers.pop(title)
+
+        # add back in the scalar bar slot
+        slot = self._plotter._scalar_bar_slot_lookup.pop(title, None)
+        if slot is not None:
+            self._plotter._scalar_bar_slots.add(slot)
+
         widget = self._scalar_bar_widgets.pop(title, None)
         if widget is not None:
             widget.SetEnabled(0)
 
-        actor = self._scalar_bar_actors.pop(title)
-        self._plotter.remove_actor(actor, render=render)
-
     def __len__(self):
         """Return the number of scalar bar actors."""
         return len(self._scalar_bar_actors)
@@ -436,7 +449,6 @@ class ScalarBars():
         title_text.SetColor(color)
 
         self._scalar_bar_actors[title] = scalar_bar
-
         if interactive:
             scalar_widget = _vtk.vtkScalarBarWidget()
             scalar_widget.SetScalarBarActor(scalar_bar)
@@ -468,4 +480,5 @@ class ScalarBars():
         # finally, add to the actor and return the scalar bar
         self._plotter.add_actor(scalar_bar, reset_camera=False,
                                 pickable=False, render=render)
+
         return scalar_bar


Test patch:
diff --git a/tests/test_scalar_bars.py b/tests/test_scalar_bars.py
index e474d60b..97d1ca6b 100644
--- a/tests/test_scalar_bars.py
+++ b/tests/test_scalar_bars.py
@@ -67,3 +67,14 @@ def test_actor_removal(sphere):
     assert len(pl.scalar_bars) == 1
     pl.remove_actor(actor)
     assert len(pl.scalar_bars) == 0
+
+
+def test_add_remove_bar(sphere):
+    pl = pv.Plotter()
+    pl.add_mesh(sphere, scalars=sphere.points[:, 2], show_scalar_bar=False)
+
+    # verify that the number of slots is restored
+    init_slots = len(pl._scalar_bar_slots)
+    pl.add_scalar_bar()
+    pl.remove_scalar_bar()
+    assert len(pl._scalar_bar_slots) == init_slots