Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ dependencies = [
"matplotlib>=3.3",
"numpy>=1.20",
"pandas>=1.2",
"pytweening>=1.0.5",
"scipy>=1.10.0",
"tqdm>=4.64.1",
]
Expand Down
20 changes: 13 additions & 7 deletions src/data_morph/morpher.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@

import numpy as np
import pandas as pd
import pytweening
import tqdm

from .bounds.bounding_box import BoundingBox
from .data.dataset import Dataset
from .data.stats import get_values
from .plotting.animation import stitch_gif_animation
from .plotting.animation import (
ease_in_out_quadratic,
ease_in_out_sine,
ease_in_sine,
ease_out_sine,
linear,
stitch_gif_animation,
)
from .plotting.static import plot
from .shapes.bases.shape import Shape

Expand Down Expand Up @@ -156,13 +162,13 @@ def _select_frames(
frames = [0] * freeze_for

if ramp_in and not ramp_out:
easing_function = pytweening.easeInSine
easing_function = ease_in_sine
elif ramp_out and not ramp_in:
easing_function = pytweening.easeOutSine
easing_function = ease_out_sine
elif ramp_out and ramp_in:
easing_function = pytweening.easeInOutSine
easing_function = ease_in_out_sine
else:
easing_function = pytweening.linear
easing_function = linear

# add transition frames
frames.extend(
Expand Down Expand Up @@ -447,7 +453,7 @@ def morph(

def _tweening(frame, *, min_value, max_value): # numpydoc ignore=PR01,RT01
"""Determine the next value with tweening."""
return (max_value - min_value) * pytweening.easeInOutQuad(
return (max_value - min_value) * ease_in_out_quadratic(
(iterations - frame) / iterations
) + min_value

Expand Down
137 changes: 136 additions & 1 deletion src/data_morph/plotting/animation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
"""Utility functions for animations."""

import glob
import math
from functools import wraps
from pathlib import Path
from typing import Union
from typing import Callable, Union

from PIL import Image

Expand Down Expand Up @@ -63,3 +65,136 @@ def stitch_gif_animation(
# remove the image files
for img in imgs:
Path(img).unlink()


def check_step(
easing_function: Callable[[Union[int, float]], Union[int, float]],
) -> Callable[[Union[int, float]], Union[int, float]]:
"""
Decorator to check if the step is a float or int and if it is between 0 and 1.

Parameters
----------
easing_function : Callable
The easing function to be checked.

Returns
-------
Callable
The easing function with the check for the step.
"""

@wraps(easing_function)
def wrapper(step: Union[int, float]) -> Union[int, float]:
"""
Wrapper function to check the step.

Parameters
----------
step : int or float
The current step of the animation, from 0 to 1.

Returns
-------
int or float
The eased value at the current step, from 0.0 to 1.0.
"""
if not (isinstance(step, (int, float)) and 0 <= step <= 1):
raise ValueError('Step must be an integer or float, between 0 and 1.')
return easing_function(step)

return wrapper


@check_step
def ease_in_sine(step: Union[int, float]) -> float:
"""
An ease-in sinusoidal function to generate animation steps (slow to fast).

Parameters
----------
step : int or float
The current step of the animation, from 0 to 1.

Returns
-------
float
The eased value at the current step, from 0.0 to 1.0.
"""
return -1 * math.cos(step * math.pi / 2) + 1


@check_step
def ease_out_sine(step: Union[int, float]) -> float:
"""
An ease-out sinusoidal function to generate animation steps (fast to slow).

Parameters
----------
step : int or float
The current step of the animation, from 0 to 1.

Returns
-------
float
The eased value at the current step, from 0.0 to 1.0.
"""
return math.sin(step * math.pi / 2)


@check_step
def ease_in_out_sine(step: Union[int, float]) -> float:
"""
An ease-in and ease-out sinusoidal function to generate animation steps (slow to fast to slow).

Parameters
----------
step : int or float
The current step of the animation, from 0 to 1.

Returns
-------
float
The eased value at the current step, from 0.0 to 1.0.
"""
return -0.5 * (math.cos(math.pi * step) - 1)


@check_step
def ease_in_out_quadratic(step: Union[int, float]) -> Union[int, float]:
"""
An ease-in and ease-out quadratic function to generate animation steps (slow to fast to slow).

Parameters
----------
step : int or float
The current step of the animation, from 0 to 1.

Returns
-------
int or float
The eased value at the current step, from 0.0 to 1.0.
"""
if step < 0.5:
return 2 * step**2
else:
step = step * 2 - 1
return -0.5 * (step * (step - 2) - 1)


@check_step
def linear(step: Union[int, float]) -> Union[int, float]:
"""
A linear function to generate animation steps.

Parameters
----------
step : int or float
The current step of the animation, from 0 to 1.

Returns
-------
int or float
The eased value at the current step, from 0.0 to 1.0.
"""
return step
54 changes: 54 additions & 0 deletions tests/plotting/test_animation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import numpy as np
import pytest

from data_morph.plotting import animation
from data_morph.plotting.animation import stitch_gif_animation
from data_morph.plotting.static import plot

Expand Down Expand Up @@ -35,3 +36,56 @@ def test_frame_stitching(sample_data, tmp_path):
animation_file = tmp_path / f'{start_shape}_to_{target_shape}.gif'
assert animation_file.is_file()
assert not (tmp_path / f'{start_shape}-to-{target_shape}-{frame}.png').is_file()


@pytest.mark.parametrize(
['ease_function', 'step', 'expected'],
[
('linear', 0.1, 0.1),
('linear', 0.5, 0.5),
('linear', 0.9, 0.9),
('ease_in_sine', 0.1, 0.012312),
('ease_in_sine', 0.5, 0.292893),
('ease_in_sine', 0.9, 0.843566),
('ease_out_sine', 0.1, 0.156434),
('ease_out_sine', 0.5, 0.707107),
('ease_out_sine', 0.9, 0.987688),
('ease_in_out_sine', 0.1, 0.024472),
('ease_in_out_sine', 0.5, 0.5),
('ease_in_out_sine', 0.9, 0.975528),
('ease_in_out_quadratic', 0.1, 0.02),
('ease_in_out_quadratic', 0.5, 0.5),
('ease_in_out_quadratic', 0.9, 0.98),
],
)
def test_easing_functions(ease_function, step, expected):
"""Test that easing functions return expected values."""
ease_func = getattr(animation, ease_function)
assert round(ease_func(step), ndigits=6) == expected


@pytest.mark.parametrize(
'invalid_step',
[
'string',
-1,
2,
],
)
@pytest.mark.parametrize(
'ease_function',
[
'linear',
'ease_in_sine',
'ease_out_sine',
'ease_in_out_sine',
'ease_in_out_quadratic',
],
)
def test_invalid_easing_step(ease_function, invalid_step):
"""Test that an invalid step type will produce a ValueError when passed to an easing function."""
with pytest.raises(
ValueError, match='Step must be an integer or float, between 0 and 1.'
):
ease_func = getattr(animation, ease_function)
ease_func(invalid_step)
3 changes: 2 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Test the __main__ module."""

import subprocess
import sys

import pytest

Expand All @@ -10,5 +11,5 @@
@pytest.mark.parametrize(['flag', 'return_code'], [['--version', 0], ['', 2]])
def test_main_access_cli(flag, return_code):
"""Confirm that CLI can be accessed via __main__."""
result = subprocess.run(['python', '-m', 'data_morph', flag])
result = subprocess.run([sys.executable, '-m', 'data_morph', flag])
assert result.returncode == return_code