Repository: wright-group/WrightTools

Issue:
Artists 'stitch_to_animation' using incorrect durations; looping is lost
`wt.artists.stitch_to_animation` no longer processes as before (e.g. May 2023). The duration (length of each frame) is now read in ms (as opposed to s) and any gif made from this command no longer loops (i.e. runs through once and stops). 

It appears image.io has undergone multiple updates over the past six months, which has relegated our implementation of its gif making tool to its legacy VPI.  Probably just need to figure out how to make gifs in newest VPI and change accordingly. 

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8c2ed7..0833b05 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,8 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
 
 ## [Unreleased]
 
+### Added
+- `Data.squeeze`: squeezing the data object to the shape of the axes.
+
 ### Fixed
 - `interact2D`: fixed bug where use_imshow broke the sliders
+- `artists.stitch_to_animation`: use imageio v3 api and declare pillow plugin
 - `data.join` ensures valid `method` is selected
 
 ### Changed
diff --git a/WrightTools/artists/_helpers.py b/WrightTools/artists/_helpers.py
index 3ead0c5..1e3232e 100644
--- a/WrightTools/artists/_helpers.py
+++ b/WrightTools/artists/_helpers.py
@@ -14,7 +14,7 @@ import matplotlib.pyplot as plt
 import matplotlib.patheffects as PathEffects
 from mpl_toolkits.axes_grid1 import make_axes_locatable
 
-import imageio
+import imageio.v3 as iio
 import warnings
 
 from .. import exceptions as wt_exceptions
@@ -1081,14 +1081,14 @@ def subplots_adjust(fig=None, inches=1):
         fig.subplots_adjust(top=top, right=right, bottom=bottom, left=left)
 
 
-def stitch_to_animation(images, outpath=None, *, duration=0.5, palettesize=256, verbose=True):
+def stitch_to_animation(paths, outpath=None, *, duration=0.5, palettesize=256, verbose=True):
     """Stitch a series of images into an animation.
 
     Currently supports animated gifs, other formats coming as needed.
 
     Parameters
     ----------
-    images : list of strings
+    paths : list of strings
         Filepaths to the images to stitch together, in order of apperence.
     outpath : string (optional)
         Path of output, including extension. If None, bases output path on path
@@ -1097,22 +1097,21 @@ def stitch_to_animation(images, outpath=None, *, duration=0.5, palettesize=256,
         Duration of (each) frame in seconds. Default is 0.5.
     palettesize : int (optional)
         The number of colors in the resulting animation. Input is rounded to
-        the nearest power of 2. Default is 1024.
+        the nearest power of 2. Default is 256.
     verbose : bool (optional)
         Toggle talkback. Default is True.
     """
     # parse filename
     if outpath is None:
-        outpath = os.path.splitext(images[0])[0] + ".gif"
+        outpath = os.path.splitext(paths[0])[0] + ".gif"
     # write
     t = wt_kit.Timer(verbose=False)
-    with t, imageio.get_writer(
-        outpath, mode="I", duration=duration, palettesize=palettesize
-    ) as writer:
-        for p in images:
-            image = imageio.imread(p)
-            writer.append_data(image)
-    # finish
+    with t, iio.imopen(outpath, "w") as gif:
+        for p in paths:
+            frame = iio.imread(p)
+            gif.write(
+                frame, plugin="pillow", duration=duration * 1e3, loop=0, palettesize=palettesize
+            )
     if verbose:
         interval = np.round(t.interval, 2)
         print("gif generated in {0} seconds - saved at {1}".format(interval, outpath))
diff --git a/WrightTools/data/_channel.py b/WrightTools/data/_channel.py
index b820dea..1ca740d 100644
--- a/WrightTools/data/_channel.py
+++ b/WrightTools/data/_channel.py
@@ -224,3 +224,13 @@ class Channel(Dataset):
         if verbose:
             print("%i outliers removed" % len(outliers))
         return outliers
+
+    def _to_dict(self):
+        out = {}
+        out["name"] = self.natural_name
+        out["values"] = self[:]
+        out["units"] = self.units
+        out["label"] = self.label
+        out["signed"] = self.signed
+        out.update(self.attrs)
+        return out
diff --git a/WrightTools/data/_data.py b/WrightTools/data/_data.py
index cdec37c..b9242a7 100644
--- a/WrightTools/data/_data.py
+++ b/WrightTools/data/_data.py
@@ -359,6 +359,78 @@ class Data(Group):
         idx = self._at_to_slice(**at)
         return self._from_slice(idx, name=name, parent=parent)
 
+    def squeeze(self, name=None, parent=None):
+        """Reduce the data to the dimensionality of the (non-trivial) span of the axes.
+        i.e. if the joint shape of the axes has an array dimension with length 1, this
+        array dimension is squeezed.
+
+        channels and variables that span beyond the axes are removed.
+
+        Parameters
+        ----------
+        name : string (optional)
+            name of the new Data.
+        parent : WrightTools Collection instance (optional)
+            Collection to place the new "chop" collection within. Default is
+            None (new parent).
+
+        Returns
+        -------
+        out : wt.Data
+            new data object.  The new data object has dimensions of the
+            (non-trivial) span of the current axes
+
+        Examples
+        --------
+        >>> ...
+
+        See also
+        --------
+        Data.chop: Divide the dataset into its lower-dimensionality components.
+        ...
+        """
+        new = Data(name=name, parent=parent)
+
+        attrs = {
+            k: v
+            for k, v in self.attrs.items()
+            if k
+            not in [
+                "axes",
+                "channel_names",
+                "constants",
+                "name",
+                "source",
+                "item_names",
+                "variable_names",
+            ]
+        }
+        new.attrs.update(attrs)
+
+        joint_shape = wt_kit.joint_shape(*[ai[:] for ai in self.axes])
+        cull_dims = [j == 1 for j in joint_shape]
+        sl = [0 if cull else slice(None) for cull in cull_dims]
+        matches_broadcast_axes = lambda a: all(
+            [a.shape[i] == 1 for i in range(self.ndim) if cull_dims[i]]
+        )
+
+        for v in filter(matches_broadcast_axes, self.variables):
+            kwargs = v._to_dict()
+            kwargs["values"] = v[sl]
+            new.create_variable(**kwargs)
+
+        for c in filter(matches_broadcast_axes, self.channels):
+            kwargs = c._to_dict()
+            kwargs["values"] = c[sl]
+            new.create_channel(**kwargs)
+
+        # inherit constants
+        for c in self.constants:
+            new.create_constant(c.expression)
+
+        new.transform(*self.axis_expressions)
+        return new
+
     def chop(self, *args, at=None, parent=None, verbose=True) -> wt_collection.Collection:
         """Divide the dataset into its lower-dimensionality components.
 
@@ -508,21 +580,12 @@ class Data(Group):
             out = parent.create_data(name=name)
 
         for v in self.variables:
-            kwargs = {}
-            kwargs["name"] = v.natural_name
+            kwargs = v._to_dict()
             kwargs["values"] = v[idx]
-            kwargs["units"] = v.units
-            kwargs["label"] = v.label
-            kwargs.update(v.attrs)
             out.create_variable(**kwargs)
         for c in self.channels:
-            kwargs = {}
-            kwargs["name"] = c.natural_name
+            kwargs = c._to_dict()
             kwargs["values"] = c[idx]
-            kwargs["units"] = c.units
-            kwargs["label"] = c.label
-            kwargs["signed"] = c.signed
-            kwargs.update(c.attrs)
             out.create_channel(**kwargs)
 
         new_axes = [a.expression for a in self.axes if a[idx].size > 1]
diff --git a/WrightTools/data/_variable.py b/WrightTools/data/_variable.py
index 32e8560..8b1aff5 100644
--- a/WrightTools/data/_variable.py
+++ b/WrightTools/data/_variable.py
@@ -49,3 +49,12 @@ class Variable(Dataset):
     @label.setter
     def label(self, label):
         self.attrs["label"] = label
+
+    def _to_dict(self):
+        out = {}
+        out["name"] = self.natural_name
+        out["values"] = self[:]
+        out["units"] = self.units
+        out["label"] = self.label
+        out.update(self.attrs)
+        return out
diff --git a/setup.py b/setup.py
index 53d512f..6dbb6de 100755
--- a/setup.py
+++ b/setup.py
@@ -37,7 +37,7 @@ setup(
     python_requires=">=3.7",
     install_requires=[
         "h5py",
-        "imageio",
+        "imageio>=2.28.0",
         "matplotlib>=3.4.0",
         "numexpr",
         "numpy>=1.15.0",


Test patch:
diff --git a/tests/data/squeeze.py b/tests/data/squeeze.py
new file mode 100644
index 0000000..518d478
--- /dev/null
+++ b/tests/data/squeeze.py
@@ -0,0 +1,46 @@
+#! /usr/bin/env python3
+"""Test squeeze."""
+
+
+# --- import -------------------------------------------------------------------------------------
+
+
+import numpy as np
+import WrightTools as wt
+from WrightTools import datasets
+
+
+# --- tests --------------------------------------------------------------------------------------
+
+
+def test_squeeze():
+    d = wt.Data(name="test")
+    d.create_variable("x", values=np.arange(5)[:, None, None])
+    d.create_variable("y", values=np.arange(4)[None, :, None])
+    d.create_variable("redundant_array", values=np.tile(np.arange(3), (5, 4, 1)))
+
+    d.create_channel("keep", values=d.x[:] + d.y[:])
+    d.create_channel("throw_away", values=np.zeros((5, 4, 3)))
+
+    d.transform("x", "y")
+    d = d.squeeze()  # make sure it runs error free
+    assert d.ndim == 2
+    assert d.shape == (5, 4)
+
+
+def test_constants():
+    d = wt.Data(name="test")
+    d.create_variable("x", values=np.array([1]).reshape(1, 1))
+    d.create_constant("x")
+    d.create_variable("y", values=np.linspace(3, 5, 4).reshape(-1, 1))
+    d.create_variable("z", values=np.linspace(0, 1, 6).reshape(1, -1))
+    d.transform("y")
+    ds = d.squeeze()
+    assert "x" in ds.constant_expressions
+    d.print_tree()
+    ds.print_tree()
+
+
+if __name__ == "__main__":
+    test_squeeze()
+    test_constants()