Skip to content

Commit 6eec1d7

Browse files
authored
Save only unique frames and use GIF frame durations from PIL (#278)
- Rather than creating an image for each frame, create one only for unique frames and then use PIL to set the duration for each frame when stitching together the GIF - Refactored `DataMorpher._record_frames()` to just record and not advance the frame number - Changed frame numbers in file names to iteration numbers - Add `frame_numbers` parameter to `stitch_gif_animation()` for calculating durations - Improve testing of `stitch_gif_animation()` to count frames and durations
1 parent 5b5a36e commit 6eec1d7

File tree

4 files changed

+106
-103
lines changed

4 files changed

+106
-103
lines changed

src/data_morph/morpher.py

Lines changed: 32 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
import shutil
65
from contextlib import nullcontext
76
from functools import partial
87
from numbers import Number
@@ -200,9 +199,8 @@ def _record_frames(
200199
data: pd.DataFrame,
201200
bounds: BoundingBox,
202201
base_file_name: str,
203-
count: int,
204-
frame_number: int,
205-
) -> int:
202+
frame_number: str,
203+
) -> None:
206204
"""
207205
Record frame data as a plot and, when :attr:`write_data` is ``True``, as a CSV file.
208206
@@ -214,55 +212,25 @@ def _record_frames(
214212
The plotting limits.
215213
base_file_name : str
216214
The prefix to the file names for both the PNG and GIF files.
217-
count : int
218-
The number of frames to record with the data.
219-
frame_number : int
220-
The starting frame number.
221-
222-
Returns
223-
-------
224-
int
225-
The next frame number available for recording.
215+
frame_number : str
216+
The frame number with padding zeros added already.
226217
"""
227-
if (self.write_images or self.write_data) and count > 0:
228-
is_start = frame_number == 0
229-
img_file = (
230-
self.output_dir / f'{base_file_name}-image-{frame_number:03d}.png'
218+
if self.write_images:
219+
plot(
220+
data,
221+
save_to=self.output_dir / f'{base_file_name}-image-{frame_number}.png',
222+
decimals=self.decimals,
223+
x_bounds=bounds.x_bounds,
224+
y_bounds=bounds.y_bounds,
225+
dpi=150,
231226
)
232-
data_file = (
233-
self.output_dir / f'{base_file_name}-data-{frame_number:03d}.csv'
227+
if (
228+
self.write_data and int(frame_number) > 0
229+
): # don't write data for the initial frame (input data)
230+
data.to_csv(
231+
self.output_dir / f'{base_file_name}-data-{frame_number}.csv',
232+
index=False,
234233
)
235-
if self.write_images:
236-
plot(
237-
data,
238-
save_to=img_file,
239-
decimals=self.decimals,
240-
x_bounds=bounds.x_bounds,
241-
y_bounds=bounds.y_bounds,
242-
dpi=150,
243-
)
244-
if (
245-
self.write_data and not is_start
246-
): # don't write data for the initial frame (input data)
247-
data.to_csv(data_file, index=False)
248-
frame_number += 1
249-
if (duplicate_count := count - 1) > 0:
250-
for _ in range(duplicate_count):
251-
if data_file.exists():
252-
shutil.copy(
253-
data_file,
254-
self.output_dir
255-
/ f'{base_file_name}-data-{frame_number:03d}.csv',
256-
)
257-
if img_file.exists():
258-
shutil.copy(
259-
img_file,
260-
self.output_dir
261-
/ f'{base_file_name}-image-{frame_number:03d}.png',
262-
)
263-
frame_number += 1
264-
265-
return frame_number
266234

267235
def _is_close_enough(self, df1: pd.DataFrame, df2: pd.DataFrame) -> bool:
268236
"""
@@ -477,11 +445,9 @@ def morph(
477445
base_file_name=base_file_name,
478446
bounds=start_shape.plot_bounds,
479447
)
480-
frame_number = record_frames(
481-
data=morphed_data,
482-
count=max(freeze_for, 1),
483-
frame_number=0,
484-
)
448+
449+
frame_number_format = f'{{:0{len(str(iterations))}d}}'.format
450+
record_frames(data=morphed_data, frame_number=frame_number_format(0))
485451

486452
def _easing(
487453
frame: int, *, min_value: Number, max_value: Number
@@ -507,7 +473,7 @@ def _easing(
507473
task_id = progress_tracker.add_task(
508474
f'{start_shape.name} to {target_shape}'
509475
)
510-
for i in range(iterations):
476+
for i in range(1, iterations + 1):
511477
perturbed_data = self._perturb(
512478
morphed_data.copy(),
513479
target_shape=target_shape,
@@ -520,34 +486,35 @@ def _easing(
520486
if self._is_close_enough(start_shape.data, perturbed_data):
521487
morphed_data = perturbed_data
522488

523-
frame_number = record_frames(
524-
data=morphed_data,
525-
count=frame_numbers.count(i),
526-
frame_number=frame_number,
527-
)
489+
if frame_numbers.count(i):
490+
record_frames(
491+
data=morphed_data, frame_number=frame_number_format(i)
492+
)
528493

529494
if progress_tracker:
530495
progress_tracker.update(
531496
task_id,
532497
total=iterations,
533-
completed=i + 1,
534-
refresh=self._in_notebook and (i + 1) % 500 == 0,
498+
completed=i,
499+
refresh=self._in_notebook and (i) % 500 == 0,
535500
)
536501
else:
537-
progress[task_id] = {'progress': i + 1, 'total': iterations}
502+
progress[task_id] = {'progress': i, 'total': iterations}
538503

539504
if self.write_images:
540505
stitch_gif_animation(
541506
self.output_dir,
542507
start_shape.name,
508+
frame_numbers=frame_numbers,
543509
target_shape=target_shape,
544510
keep_frames=self.keep_frames,
545511
forward_only_animation=self.forward_only_animation,
546512
)
547513

548514
if self.write_data:
549515
morphed_data.to_csv(
550-
self.output_dir / f'{base_file_name}-data-{frame_number:03d}.csv',
516+
self.output_dir
517+
/ f'{base_file_name}-data-{frame_number_format(iterations)}.csv',
551518
index=False,
552519
)
553520

src/data_morph/plotting/animation.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import math
6+
import re
67
from functools import wraps
78
from pathlib import Path
89
from typing import TYPE_CHECKING, Callable
@@ -17,6 +18,7 @@ def stitch_gif_animation(
1718
output_dir: str | Path,
1819
start_shape: str,
1920
target_shape: str | Shape,
21+
frame_numbers: list[int],
2022
keep_frames: bool = False,
2123
forward_only_animation: bool = False,
2224
) -> None:
@@ -32,6 +34,10 @@ def stitch_gif_animation(
3234
The starting shape.
3335
target_shape : str or Shape
3436
The target shape for the morphing.
37+
frame_numbers : list[int]
38+
The saved frames to use in the GIF. Repeated consecutive frames will be shown
39+
as a single frame for a longer duration (i.e., x repeats, means x times longer
40+
than the default duration of 5 milliseconds).
3541
keep_frames : bool, default ``False``
3642
Whether to keep the individual frames after creating the animation.
3743
forward_only_animation : bool, default ``False``
@@ -44,22 +50,30 @@ def stitch_gif_animation(
4450
Frames are stitched together with Pillow.
4551
"""
4652
output_dir = Path(output_dir)
53+
iteration_pattern = re.compile(r'\d+')
54+
default_frame_duration = 5 # milliseconds
4755

4856
# find the frames and sort them
4957
imgs = sorted(output_dir.glob(f'{start_shape}-to-{target_shape}*.png'))
5058

51-
frames = [Image.open(img) for img in imgs]
59+
frames = []
60+
durations = []
61+
for img_file in imgs:
62+
iteration_number = int(iteration_pattern.search(img_file.stem).group(0))
63+
frames.append(Image.open(img_file))
64+
durations.append(frame_numbers.count(iteration_number) * default_frame_duration)
5265

5366
if not forward_only_animation:
5467
# add the animation in reverse
5568
frames.extend(frames[::-1])
69+
durations.extend(durations[::-1])
5670

5771
frames[0].save(
5872
output_dir / f'{start_shape}_to_{target_shape}.gif',
5973
format='GIF',
6074
append_images=frames[1:],
6175
save_all=True,
62-
duration=5,
76+
duration=durations,
6377
loop=0,
6478
)
6579

tests/plotting/test_animation.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
"""Test the animation module."""
22

3+
from contextlib import suppress
4+
35
import numpy as np
46
import pytest
7+
from PIL import Image
58

69
from data_morph.plotting import animation
710
from data_morph.plotting.animation import stitch_gif_animation
@@ -10,14 +13,16 @@
1013
pytestmark = pytest.mark.plotting
1114

1215

13-
def test_frame_stitching(sample_data, tmp_path):
16+
@pytest.mark.parametrize('forward_only', [True, False])
17+
def test_frame_stitching(sample_data, tmp_path, forward_only):
1418
"""Test stitching frames into a GIF animation."""
1519
start_shape = 'sample'
1620
target_shape = 'circle'
1721
bounds = [-5, 105]
22+
frame_numbers = list(range(10))
1823
rng = np.random.default_rng()
1924

20-
for frame in range(10):
25+
for frame in frame_numbers:
2126
plot(
2227
data=sample_data + rng.standard_normal(),
2328
x_bounds=bounds,
@@ -26,18 +31,44 @@ def test_frame_stitching(sample_data, tmp_path):
2631
decimals=2,
2732
)
2833

34+
duration_multipliers = [0, 0, 0, 0, 1, 1, *frame_numbers[2:], frame_numbers[-1]]
2935
stitch_gif_animation(
3036
output_dir=tmp_path,
3137
start_shape=start_shape,
3238
target_shape=target_shape,
39+
frame_numbers=duration_multipliers,
3340
keep_frames=False,
34-
forward_only_animation=False,
41+
forward_only_animation=forward_only,
3542
)
3643

3744
animation_file = tmp_path / f'{start_shape}_to_{target_shape}.gif'
3845
assert animation_file.is_file()
3946
assert not (tmp_path / f'{start_shape}-to-{target_shape}-{frame}.png').is_file()
4047

48+
with Image.open(animation_file) as img:
49+
# we subtract one when playing in reverse as well because the middle frame (last
50+
# in the forward direction) is combined into a single frame with the start of the
51+
# reversal as part of PIL's optimization
52+
assert img.n_frames == (
53+
len(frame_numbers) if forward_only else len(frame_numbers) * 2 - 1
54+
)
55+
for frame in range(len(frame_numbers)):
56+
with suppress(KeyError):
57+
# if we play in reverse, the midpoint will have double duration since
58+
# those two frames are combined
59+
rewind_multiplier = (
60+
2 if not forward_only and frame == len(frame_numbers) - 1 else 1
61+
)
62+
# duration only seems to be present on frames where it is different
63+
if frame_duration := img.info['duration']:
64+
assert (
65+
frame_duration
66+
== duration_multipliers.count(frame) * 5 * rewind_multiplier
67+
)
68+
with suppress(EOFError):
69+
# move to the next frame
70+
img.seek(img.tell() + 1)
71+
4172

4273
@pytest.mark.parametrize(
4374
('ease_function', 'step', 'expected'),

0 commit comments

Comments
 (0)