From bb207ee1649a433f520317657b5d0f0b334f06e0 Mon Sep 17 00:00:00 2001 From: WassCodeur Date: Thu, 11 Jul 2024 19:47:50 +0000 Subject: [PATCH] RF: Add keyword arguments to module: animation --- fury/animation/animation.py | 2872 ++++++++++---------- fury/animation/helpers.py | 312 +-- fury/animation/interpolator.py | 746 ++--- fury/animation/tests/test_animation.py | 234 +- fury/animation/tests/test_helpers.py | 226 +- fury/animation/tests/test_interpolators.py | 706 ++--- fury/animation/tests/test_timeline.py | 134 +- fury/animation/timeline.py | 995 +++---- fury/tests/test_gltf.py | 2 +- fury/tests/test_window.py | 2 +- 10 files changed, 3128 insertions(+), 3101 deletions(-) diff --git a/fury/animation/animation.py b/fury/animation/animation.py index f2fa56bb97..d2a6c57145 100644 --- a/fury/animation/animation.py +++ b/fury/animation/animation.py @@ -1,1428 +1,1444 @@ -from collections import defaultdict -from time import perf_counter -from warnings import warn - -import numpy as np -from scipy.spatial import transform - -from fury import utils -from fury.actor import line -from fury.animation.interpolator import ( # noqa F401 - linear_interpolator, - slerp, - spline_interpolator, - step_interpolator, -) -from fury.lib import Actor, Camera, Transform - - -class Animation: - """Keyframe animation class. - - Animation is responsible for keyframe animations for a single or a - group of actors. - It's used to handle multiple attributes and properties of Fury actors such - as transformations, color, and scale. - It also accepts custom data and interpolates them, such as temperature. - Linear interpolation is used by default to interpolate data between the - main keyframes. - - Attributes - ---------- - actors : Actor or list[Actor], optional, default: None - Actor/s to be animated. - length : float or int, default: None, optional - the fixed length of the animation. If set to None, the animation will - get its duration from the keyframes being set. - loop : bool, optional, default: True - Whether to loop the animation (True) of play once (False). - motion_path_res : int, default: None - the number of line segments used to visualizer the animation's motion - path (visualizing position). - - """ - - def __init__(self, actors=None, length=None, loop=True, motion_path_res=None): - super().__init__() - self._data = defaultdict(dict) - self._animations = [] - self._actors = [] - self._static_actors = [] - self._timeline = None - self._parent_animation = None - self._scene = None - self._start_time = 0 - self._length = length - self._duration = length if length else 0 - self._loop = loop - self._current_timestamp = 0 - self._max_timestamp = 0 - self._added_to_scene = True - self._motion_path_res = motion_path_res - self._motion_path_actor = None - self._transform = Transform() - self._general_callbacks = [] - # Adding actors to the animation - if actors is not None: - self.add_actor(actors) - - def update_duration(self): - """Update and return the duration of the Animation. - - Returns - ------- - float - The duration of the animation. - - """ - if self._length is not None: - self._duration = self._length - else: - self._duration = max( - self._max_timestamp, - max([0] + [anim.update_duration() for anim in self.child_animations]), - ) - - return self.duration - - @property - def duration(self): - """Return the duration of the animation. - - Returns - ------- - float - The duration of the animation. - - """ - return self._duration - - @property - def current_timestamp(self): - """Return the current time of the animation. - - Returns - ------- - float - The current time of the animation. - - """ - if self._timeline: - return self._timeline.current_timestamp - elif self.parent_animation: - return self.parent_animation.current_timestamp - return self._current_timestamp - - def update_motion_path(self): - """Update motion path visualization actor""" - res = self._motion_path_res - tl = self - while isinstance(tl._parent_animation, Animation): - if res: - break - tl = tl._parent_animation - res = tl._motion_path_res - if not res: - return - - lines = [] - colors = [] - if self.is_interpolatable("position"): - ts = np.linspace(0, self.duration, res) - [lines.append(self.get_position(t).tolist()) for t in ts] - if self.is_interpolatable("color"): - [colors.append(self.get_color(t)) for t in ts] - elif len(self._actors) >= 1: - colors = sum([i.vcolors[0] / 255 for i in self._actors]) / len( - self._actors - ) - else: - colors = [1, 1, 1] - - if len(lines) > 0: - lines = np.array([lines]) - if isinstance(colors, list): - colors = np.array([colors]) - - mpa = line(lines, colors=colors, opacity=0.6) - if self._scene: - # remove old motion path actor - if self._motion_path_actor is not None: - self._scene.rm(self._motion_path_actor) - self._scene.add(mpa) - self._motion_path_actor = mpa - - def _get_data(self): - """Get animation data. - - Returns - ------- - dict: - The animation data containing keyframes and interpolators. - - """ - return self._data - - def _get_attribute_data(self, attrib): - """Get animation data for a specific attribute. - - Parameters - ---------- - attrib: str - The attribute name to get data for. - - Returns - ------- - dict: - The animation data for a specific attribute. - - """ - data = self._get_data() - - if attrib not in data: - data[attrib] = { - "keyframes": defaultdict(dict), - "interpolator": { - "base": (linear_interpolator if attrib != "rotation" else slerp), - "func": None, - "args": defaultdict(), - }, - "callbacks": [], - } - return data.get(attrib) - - def get_keyframes(self, attrib=None): - """Get a keyframe for a specific or all attributes. - - Parameters - ---------- - attrib: str, optional, default: None - The name of the attribute. - If None, all keyframes for all set attributes will be returned. - - """ - data = self._get_data() - if attrib is None: - attribs = data.keys() - return { - attrib: data.get(attrib, {}).get("keyframes", {}) for attrib in attribs - } - return data.get(attrib, {}).get("keyframes", {}) - - def set_keyframe( - self, attrib, timestamp, value, update_interpolator=True, **kwargs - ): - """Set a keyframe for a certain attribute. - - Parameters - ---------- - attrib: str - The name of the attribute. - timestamp: float - Timestamp of the keyframe. - value: ndarray or float or bool - Value of the keyframe at the given timestamp. - update_interpolator: bool, optional - Interpolator will be reinitialized if True - - Other Parameters - ---------------- - in_cp: ndarray, shape (1, M), optional - The in control point in case of using cubic Bézier interpolator. - out_cp: ndarray, shape (1, M), optional - The out control point in case of using cubic Bézier interpolator. - in_tangent: ndarray, shape (1, M), optional - The in tangent at that position for the cubic spline curve. - out_tangent: ndarray, shape (1, M), optional - The out tangent at that position for the cubic spline curve. - - """ - attrib_data = self._get_attribute_data(attrib) - keyframes = attrib_data.get("keyframes") - - keyframes[timestamp] = { - "value": np.array(value).astype(float), - **{ - par: np.array(val).astype(float) - for par, val in kwargs.items() - if val is not None - }, - } - - if update_interpolator: - interp = attrib_data.get("interpolator") - interp_base = interp.get( - "base", linear_interpolator if attrib != "rotation" else slerp - ) - args = interp.get("args", {}) - self.set_interpolator(attrib, interp_base, **args) - - if timestamp > self._max_timestamp: - self._max_timestamp = timestamp - if self._timeline is not None: - self._timeline.update_duration() - else: - self.update_duration() - self.update_animation(0) - self.update_motion_path() - - def set_keyframes(self, attrib, keyframes): - """Set multiple keyframes for a certain attribute. - - Parameters - ---------- - attrib: str - The name of the attribute. - keyframes: dict - A dict object containing keyframes to be set. - - Notes - ----- - Keyframes can be on any of the following forms: - >>> key_frames_simple = {1: [1, 2, 1], 2: [3, 4, 5]} - >>> key_frames_bezier = {1: {'value': [1, 2, 1]}, - >>> 2: {'value': [3, 4, 5], 'in_cp': [1, 2, 3]}} - >>> pos_keyframes = {1: np.array([1, 2, 3]), 3: np.array([5, 5, 5])} - >>> Animation.set_keyframes('position', pos_keyframes) - - """ - for t, keyframe in keyframes.items(): - if isinstance(keyframe, dict): - self.set_keyframe(attrib, t, **keyframe) - else: - self.set_keyframe(attrib, t, keyframe) - - def is_inside_scene_at(self, timestamp): - """Check if the Animation is set to be inside the scene at a specific - timestamp. - - Returns - ------- - bool - True if the Animation is set to be inside the scene at the given - timestamp. - - Notes - ----- - If the parent Animation is set to be out of the scene at that time, all - of their child animations will be out of the scene as well. - - """ - parent = self._parent_animation - parent_in_scene = True - if parent is not None: - parent_in_scene = parent._added_to_scene - - if self.is_interpolatable("in_scene"): - in_scene = parent_in_scene and self.get_value("in_scene", timestamp) - else: - in_scene = parent_in_scene - return in_scene - - def add_to_scene_at(self, timestamp): - """Set timestamp for adding Animation to scene event. - - Parameters - ---------- - timestamp: float - Timestamp of the event. - - """ - if not self.is_interpolatable("in_scene"): - self.set_keyframe("in_scene", timestamp, True) - self.set_interpolator("in_scene", step_interpolator) - else: - self.set_keyframe("in_scene", timestamp, True) - - def remove_from_scene_at(self, timestamp): - """Set timestamp for removing Animation to scene event. - - Parameters - ---------- - timestamp: float - Timestamp of the event. - - """ - if not self.is_interpolatable("in_scene"): - self.set_keyframe("in_scene", timestamp, False) - self.set_interpolator("in_scene", step_interpolator) - else: - self.set_keyframe("in_scene", timestamp, False) - - def _handle_scene_event(self, timestamp): - should_be_in_scene = self.is_inside_scene_at(timestamp) - if self._scene is not None: - if should_be_in_scene and not self._added_to_scene: - self._scene.add(*self._actors) - self._added_to_scene = True - elif not should_be_in_scene and self._added_to_scene: - self._scene.rm(*self._actors) - self._added_to_scene = False - - def set_interpolator(self, attrib, interpolator, is_evaluator=False, **kwargs): - """Set keyframes interpolator for a certain property - - Parameters - ---------- - attrib: str - The name of the property. - interpolator: callable - The generator function of the interpolator to be used to - interpolate/evaluate keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes such as: - >>> def get_position(t): - >>> return np.array([np.sin(t), np.cos(t) * 5, 5]) - - Other Parameters - ---------------- - spline_degree: int, optional - The degree of the spline in case of setting a spline interpolator. - - Notes - ----- - If an evaluator is used to set the values of actor's properties such as - position, scale, color, rotation, or opacity, it has to return a value - with the same shape as the evaluated property, i.e.: for scale, it - has to return an array with shape 1x3, and for opacity, it has to - return a 1x1, an int, or a float value. - - Examples - -------- - >>> Animation.set_interpolator('position', linear_interpolator) - - >>> pos_fun = lambda t: np.array([np.sin(t), np.cos(t), 0]) - >>> Animation.set_interpolator('position', pos_fun) - - """ - attrib_data = self._get_attribute_data(attrib) - keyframes = attrib_data.get("keyframes", {}) - interp_data = attrib_data.get("interpolator", {}) - if is_evaluator: - interp_data["base"] = None - interp_data["func"] = interpolator - else: - interp_data["base"] = interpolator - interp_data["args"] = kwargs - # Maintain interpolator base in case new keyframes are added. - if len(keyframes) == 0: - return - new_interp = interpolator(keyframes, **kwargs) - interp_data["func"] = new_interp - - # update motion path - self.update_duration() - self.update_motion_path() - - def is_interpolatable(self, attrib): - """Check whether a property is interpolatable. - - Parameters - ---------- - attrib: str - The name of the property. - - Returns - ------- - bool - True if the property is interpolatable by the Animation. - - Notes - ----- - True means that it's safe to use `Interpolator.interpolate(t)` for the - specified property. And False means the opposite. - - """ - data = self._data - return bool(data.get(attrib, {}).get("interpolator", {}).get("func")) - - def set_position_interpolator(self, interpolator, is_evaluator=False, **kwargs): - """Set the position interpolator. - - Parameters - ---------- - interpolator: callable - The generator function of the interpolator that would handle the - position keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes. - - Other Parameters - ---------------- - degree: int - The degree of the spline interpolation in case of setting - the `spline_interpolator`. - - Examples - -------- - >>> Animation.set_position_interpolator(spline_interpolator, degree=5) - - """ - self.set_interpolator( - "position", interpolator, is_evaluator=is_evaluator, **kwargs - ) - - def set_scale_interpolator(self, interpolator, is_evaluator=False): - """Set the scale interpolator. - - Parameters - ---------- - interpolator: callable - The generator function of the interpolator that would handle - the scale keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes. - - Examples - -------- - >>> Animation.set_scale_interpolator(step_interpolator) - - """ - self.set_interpolator("scale", interpolator, is_evaluator=is_evaluator) - - def set_rotation_interpolator(self, interpolator, is_evaluator=False): - """Set the rotation interpolator . - - Parameters - ---------- - interpolator: callable - The generator function of the interpolator that would handle the - rotation (orientation) keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes. - - Examples - -------- - >>> Animation.set_rotation_interpolator(slerp) - - """ - self.set_interpolator("rotation", interpolator, is_evaluator=is_evaluator) - - def set_color_interpolator(self, interpolator, is_evaluator=False): - """Set the color interpolator. - - Parameters - ---------- - interpolator: callable - The generator function of the interpolator that would handle - the color keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes. - - Examples - -------- - >>> Animation.set_color_interpolator(lab_color_interpolator) - - """ - self.set_interpolator("color", interpolator, is_evaluator=is_evaluator) - - def set_opacity_interpolator(self, interpolator, is_evaluator=False): - """Set the opacity interpolator. - - Parameters - ---------- - interpolator: callable - The generator function of the interpolator that would handle - the opacity keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes. - - Examples - -------- - >>> Animation.set_opacity_interpolator(step_interpolator) - - """ - self.set_interpolator("opacity", interpolator, is_evaluator=is_evaluator) - - def get_value(self, attrib, timestamp): - """Return the value of an attribute at any given timestamp. - - Parameters - ---------- - attrib: str - The attribute name. - timestamp: float - The timestamp to interpolate at. - - """ - value = ( - self._data.get(attrib, {}).get("interpolator", {}).get("func")(timestamp) - ) - return value - - def get_current_value(self, attrib): - """Return the value of an attribute at current time. - - Parameters - ---------- - attrib: str - The attribute name. - - """ - return ( - self._data.get(attrib) - .get("interpolator") - .get("func")(self._timeline.current_timestamp) - ) - - def set_position(self, timestamp, position, **kwargs): - """Set a position keyframe at a specific timestamp. - - Parameters - ---------- - timestamp: float - Timestamp of the keyframe - position: ndarray, shape (1, 3) - Position value - - Other Parameters - ---------------- - in_cp: float - The control point in case of using `cubic Bézier interpolator` when - time exceeds this timestamp. - out_cp: float - The control point in case of using `cubic Bézier interpolator` when - time precedes this timestamp. - in_tangent: ndarray, shape (1, M), optional - The in tangent at that position for the cubic spline curve. - out_tangent: ndarray, shape (1, M), optional - The out tangent at that position for the cubic spline curve. - - Notes - ----- - `in_cp` and `out_cp` only needed when using the cubic bezier - interpolation method. - - """ - self.set_keyframe("position", timestamp, position, **kwargs) - - def set_position_keyframes(self, keyframes): - """Set a dict of position keyframes at once. - Should be in the following form: - {timestamp_1: position_1, timestamp_2: position_2} - - Parameters - ---------- - keyframes: dict - A dict with timestamps as keys and positions as values. - - Examples - -------- - >>> pos_keyframes = {1, np.array([0, 0, 0]), 3, np.array([50, 6, 6])} - >>> Animation.set_position_keyframes(pos_keyframes) - - """ - self.set_keyframes("position", keyframes) - - def set_rotation(self, timestamp, rotation, **kwargs): - """Set a rotation keyframe at a specific timestamp. - - Parameters - ---------- - timestamp: float - Timestamp of the keyframe - rotation: ndarray, shape(1, 3) or shape(1, 4) - Rotation data in euler degrees with shape(1, 3) or in quaternions - with shape(1, 4). - - Notes - ----- - Euler rotations are executed by rotating first around Z then around X, - and finally around Y. - - """ - no_components = len(np.array(rotation).flatten()) - if no_components == 4: - self.set_keyframe("rotation", timestamp, rotation, **kwargs) - elif no_components == 3: - # user is expected to set rotation order by default as setting - # orientation of a `vtkActor` ordered as z->x->y. - rotation = np.asarray(rotation, dtype=float) - rotation = transform.Rotation.from_euler( - "zxy", rotation[[2, 0, 1]], degrees=True - ).as_quat() - self.set_keyframe("rotation", timestamp, rotation, **kwargs) - else: - warn( - f"Keyframe with {no_components} components is not a " - f"valid rotation data. Skipped!", - stacklevel=2, - ) - - def set_rotation_as_vector(self, timestamp, vector, **kwargs): - """Set a rotation keyframe at a specific timestamp. - - Parameters - ---------- - timestamp: float - Timestamp of the keyframe - vector: ndarray, shape(1, 3) - Directional vector that describes the rotation. - - """ - quat = transform.Rotation.from_rotvec(vector).as_quat() - self.set_keyframe("rotation", timestamp, quat, **kwargs) - - def set_scale(self, timestamp, scalar, **kwargs): - """Set a scale keyframe at a specific timestamp. - - Parameters - ---------- - timestamp: float - Timestamp of the keyframe - scalar: ndarray, shape(1, 3) - Scale keyframe value associated with the timestamp. - - """ - self.set_keyframe("scale", timestamp, scalar, **kwargs) - - def set_scale_keyframes(self, keyframes): - """Set a dict of scale keyframes at once. - Should be in the following form: - {timestamp_1: scale_1, timestamp_2: scale_2} - - Parameters - ---------- - keyframes: dict - A dict with timestamps as keys and scales as values. - - Examples - -------- - >>> scale_keyframes = {1, np.array([1, 1, 1]), 3, np.array([2, 2, 3])} - >>> Animation.set_scale_keyframes(scale_keyframes) - - """ - self.set_keyframes("scale", keyframes) - - def set_color(self, timestamp, color, **kwargs): - """Set color keyframe at a specific timestamp. - - Parameters - ---------- - timestamp: float - Timestamp of the keyframe - color: ndarray, shape(1, 3) - Color keyframe value associated with the timestamp. - - """ - self.set_keyframe("color", timestamp, color, **kwargs) - - def set_color_keyframes(self, keyframes): - """Set a dict of color keyframes at once. - Should be in the following form: - {timestamp_1: color_1, timestamp_2: color_2} - - Parameters - ---------- - keyframes: dict - A dict with timestamps as keys and color as values. - - Examples - -------- - >>> color_keyframes = {1, np.array([1, 0, 1]), 3, np.array([0, 0, 1])} - >>> Animation.set_color_keyframes(color_keyframes) - - """ - self.set_keyframes("color", keyframes) - - def set_opacity(self, timestamp, opacity, **kwargs): - """Set opacity keyframe at a specific timestamp. - - Parameters - ---------- - timestamp: float - Timestamp of the keyframe - opacity: ndarray, shape(1, 3) - Opacity keyframe value associated with the timestamp. - - """ - self.set_keyframe("opacity", timestamp, opacity, **kwargs) - - def set_opacity_keyframes(self, keyframes): - """Set a dict of opacity keyframes at once. - Should be in the following form: - {timestamp_1: opacity_1, timestamp_2: opacity_2} - - Parameters - ---------- - keyframes: dict(float: ndarray, shape(1, 1) or float or int) - A dict with timestamps as keys and opacities as values. - - Notes - ----- - Opacity values should be between 0 and 1. - - Examples - -------- - >>> opacity = {1, np.array([1, 1, 1]), 3, np.array([2, 2, 3])} - >>> Animation.set_scale_keyframes(opacity) - - """ - self.set_keyframes("opacity", keyframes) - - def get_position(self, t): - """Return the interpolated position. - - Parameters - ---------- - t: float - The time to interpolate position at. - - Returns - ------- - ndarray(1, 3): - The interpolated position. - - """ - return self.get_value("position", t) - - def get_rotation(self, t, as_quat=False): - """Return the interpolated rotation. - - Parameters - ---------- - t: float - the time to interpolate rotation at. - as_quat: bool - Returned rotation will be as quaternion if True. - - Returns - ------- - ndarray(1, 3): - The interpolated rotation as Euler degrees by default. - - """ - rot = self.get_value("rotation", t) - if len(rot) == 4: - if as_quat: - return rot - r = transform.Rotation.from_quat(rot) - degrees = r.as_euler("zxy", degrees=True)[[1, 2, 0]] - return degrees - elif not as_quat: - return rot - return transform.Rotation.from_euler( - "zxy", rot[[2, 0, 1]], degrees=True - ).as_quat() - - def get_scale(self, t): - """Return the interpolated scale. - - Parameters - ---------- - t: float - The time to interpolate scale at. - - Returns - ------- - ndarray(1, 3): - The interpolated scale. - - """ - return self.get_value("scale", t) - - def get_color(self, t): - """Return the interpolated color. - - Parameters - ---------- - t: float - The time to interpolate color value at. - - Returns - ------- - ndarray(1, 3): - The interpolated color. - - """ - return self.get_value("color", t) - - def get_opacity(self, t): - """Return the opacity value. - - Parameters - ---------- - t: float - The time to interpolate opacity at. - - Returns - ------- - ndarray(1, 1): - The interpolated opacity. - - """ - return self.get_value("opacity", t) - - def add(self, item): - """Add an item to the Animation. - This item can be an Actor, Animation, list of Actors, or a list of - Animations. - - Parameters - ---------- - item: Animation, vtkActor, list[Animation], or list[vtkActor] - Actor/s to be animated by the Animation. - - """ - if isinstance(item, list): - for a in item: - self.add(a) - return - elif isinstance(item, Actor): - self.add_actor(item) - elif isinstance(item, Animation): - self.add_child_animation(item) - else: - raise ValueError(f"Object of type {type(item)} can't be animated") - - def add_child_animation(self, animation): - """Add child Animation or list of Animations. - - Parameters - ---------- - animation: Animation or list[Animation] - Animation/s to be added. - - """ - if isinstance(animation, list): - for a in animation: - self.add_child_animation(a) - return - animation._parent_animation = self - animation.update_motion_path() - self._animations.append(animation) - self.update_duration() - - def add_actor(self, actor, static=False): - """Add an actor or list of actors to the Animation. - - Parameters - ---------- - actor: vtkActor or list(vtkActor) - Actor/s to be animated by the Animation. - static: bool - Indicated whether the actor should be animated and controlled by - the animation or just a static actor that gets added to the scene - along with the Animation. - - """ - if isinstance(actor, list): - for a in actor: - self.add_actor(a, static=static) - elif static: - if actor not in self.static_actors: - self._static_actors.append(actor) - else: - if actor not in self._actors: - actor.vcolors = utils.colors_from_actor(actor) - self._actors.append(actor) - - @property - def timeline(self): - """Return the Timeline handling the current animation. - - Returns - ------- - Timeline: - The Timeline handling the current animation, None, if there is no - associated Timeline. - - """ - return self._timeline - - @timeline.setter - def timeline(self, timeline): - """Assign the Timeline responsible for handling the Animation. - - Parameters - ---------- - timeline: Timeline - The Timeline handling the current animation, None, if there is no - associated Timeline. - - """ - self._timeline = timeline - if self._animations: - for animation in self._animations: - animation.timeline = timeline - - @property - def parent_animation(self): - """Return the hierarchical parent Animation for current Animation. - - Returns - ------- - Animation: - The parent Animation. - - """ - return self._parent_animation - - @parent_animation.setter - def parent_animation(self, parent_animation): - """Assign a parent Animation for the current Animation. - - Parameters - ---------- - parent_animation: Animation - The parent Animation instance. - - """ - self._parent_animation = parent_animation - - @property - def actors(self): - """Return a list of actors. - - Returns - ------- - list: - List of actors controlled by the Animation. - - """ - return self._actors - - @property - def child_animations(self) -> "list[Animation]": - """Return a list of child Animations. - - Returns - ------- - list: - List of child Animations of this Animation. - - """ - return self._animations - - def add_static_actor(self, actor): - """Add an actor or list of actors as static actor/s which will not be - controlled nor animated by the Animation. All static actors will be - added to the scene when the Animation is added to the scene. - - Parameters - ---------- - actor: vtkActor or list(vtkActor) - Static actor/s. - - """ - self.add_actor(actor, static=True) - - @property - def static_actors(self): - """Return a list of static actors. - - Returns - ------- - list: - List of static actors. - - """ - return self._static_actors - - def remove_animations(self): - """Remove all child Animations from the Animation""" - self._animations.clear() - - def remove_actor(self, actor): - """Remove an actor from the Animation. - - Parameters - ---------- - actor: vtkActor - Actor to be removed from the Animation. - - """ - self._actors.remove(actor) - - def remove_actors(self): - """Remove all actors from the Animation""" - self._actors.clear() - - @property - def loop(self): - """Get loop condition of the current animation. - - Returns - ------- - bool - Whether the animation in loop mode (True) or play one mode (False). - - """ - return self._loop - - @loop.setter - def loop(self, loop): - """Set the animation to loop or play once. - - Parameters - ---------- - loop: bool - The loop condition to be set. (True) to loop the animation, and - (False) to play only once. - - """ - self._loop = loop - - def add_update_callback(self, callback, prop=None): - """Add a function to be called each time animation is updated - This function must accept only one argument which is the current value - of the named property. - - - Parameters - ---------- - callback: callable - The function to be called whenever the animation is updated. - prop: str, optional, default: None - The name of the property. - - Notes - ----- - If no attribute name was provided, current time of the animation will - be provided instead of current value for the callback. - - """ - if prop is None: - self._general_callbacks.append(callback) - return - attrib = self._get_attribute_data(prop) - attrib.get("callbacks", []).append(callback) - - def update_animation(self, time=None): - """Update the animation. - - Update the animation at a certain time. This will make sure all - attributes are calculated and set to the actors at that given time. - - Parameters - ---------- - time: float or int, optional, default: None - The time to update animation at. If None, the animation will play - without adding it to a Timeline. - - """ - has_handler = True - if time is None: - time = perf_counter() - self._start_time - has_handler = False - - # handling in/out of scene events - in_scene = self.is_inside_scene_at(time) - self._handle_scene_event(time) - - if self.duration: - if self._loop and time > self.duration: - time = time % self.duration - elif time > self.duration: - time = self.duration - if isinstance(self._parent_animation, Animation): - self._transform.DeepCopy(self._parent_animation._transform) - else: - self._transform.Identity() - - self._current_timestamp = time - - # actors properties - if in_scene: - if self.is_interpolatable("position"): - position = self.get_position(time) - self._transform.Translate(*position) - - if self.is_interpolatable("opacity"): - opacity = self.get_opacity(time) - [act.GetProperty().SetOpacity(opacity) for act in self.actors] - - if self.is_interpolatable("rotation"): - x, y, z = self.get_rotation(time) - # Rotate in the same order as VTK defaults. - self._transform.RotateZ(z) - self._transform.RotateX(x) - self._transform.RotateY(y) - - if self.is_interpolatable("scale"): - scale = self.get_scale(time) - self._transform.Scale(*scale) - - if self.is_interpolatable("color"): - color = self.get_color(time) - for act in self.actors: - act.vcolors[:] = color * 255 - utils.update_actor(act) - - # update actors' transformation matrix - [act.SetUserTransform(self._transform) for act in self.actors] - - for attrib in self._data: - callbacks = self._data.get(attrib, {}).get("callbacks", []) - if callbacks != [] and self.is_interpolatable(attrib): - value = self.get_value(attrib, time) - [cbk(value) for cbk in callbacks] - - # Executing general callbacks that's not related to any attribute - [callback(time) for callback in self._general_callbacks] - - # Also update all child Animations. - [animation.update_animation(time) for animation in self._animations] - - if self._scene and not has_handler: - self._scene.reset_clipping_range() - - def add_to_scene(self, scene): - """Add this Animation, its actors and sub Animations to the scene""" - [scene.add(actor) for actor in self._actors] - [scene.add(static_act) for static_act in self._static_actors] - [scene.add(animation) for animation in self._animations] - - if self._motion_path_actor: - scene.add(self._motion_path_actor) - self._scene = scene - self._added_to_scene = True - self._start_time = perf_counter() - self.update_animation(0) - - def remove_from_scene(self, scene): - """Remove Animation, its actors and sub Animations from the scene""" - [scene.rm(act) for act in self.actors] - [scene.rm(static_act) for static_act in self._static_actors] - for anim in self.child_animations: - anim.remove_from_scene(scene) - if self._motion_path_actor: - scene.rm(self._motion_path_actor) - self._added_to_scene = False - - -class CameraAnimation(Animation): - """Camera keyframe animation class. - - This is used for animating a single camera using a set of keyframes. - - Attributes - ---------- - camera : Camera, optional, default: None - Camera to be animated. If None, active camera will be animated. - length : float or int, default: None, optional - the fixed length of the animation. If set to None, the animation will - get its duration from the keyframes being set. - loop : bool, optional, default: True - Whether to loop the animation (True) of play once (False). - motion_path_res : int, default: None - the number of line segments used to visualizer the animation's motion - path (visualizing position). - - """ - - def __init__(self, camera=None, length=None, loop=True, motion_path_res=None): - super(CameraAnimation, self).__init__( - length=length, loop=loop, motion_path_res=motion_path_res - ) - self._camera = camera - - @property - def camera(self) -> Camera: - """Return the camera assigned to this animation. - - Returns - ------- - Camera: - The camera that is being animated by this CameraAnimation. - - """ - return self._camera - - @camera.setter - def camera(self, camera: Camera): - """Set a camera to be animated. - - Parameters - ---------- - camera: Camera - The camera to be animated - - """ - self._camera = camera - - def set_focal(self, timestamp, position, **kwargs): - """Set camera's focal position keyframe. - - Parameters - ---------- - timestamp: float - The time to interpolate opacity at. - position: ndarray, shape(1, 3) - The camera position - - """ - self.set_keyframe("focal", timestamp, position, **kwargs) - - def set_view_up(self, timestamp, direction, **kwargs): - """Set the camera view-up direction keyframe. - - Parameters - ---------- - timestamp: float - The time to interpolate at. - direction: ndarray, shape(1, 3) - The camera view-up direction - - """ - self.set_keyframe("view_up", timestamp, direction, **kwargs) - - def set_focal_keyframes(self, keyframes): - """Set multiple camera focal position keyframes at once. - Should be in the following form: - {timestamp_1: focal_1, timestamp_2: focal_1, ...} - - Parameters - ---------- - keyframes: dict - A dict with timestamps as keys and camera focal positions as - values. - - Examples - -------- - >>> focal_pos = {0, np.array([1, 1, 1]), 3, np.array([20, 0, 0])} - >>> CameraAnimation.set_focal_keyframes(focal_pos) - - """ - self.set_keyframes("focal", keyframes) - - def set_view_up_keyframes(self, keyframes): - """Set multiple camera view up direction keyframes. - Should be in the following form: - {timestamp_1: view_up_1, timestamp_2: view_up_2, ...} - - Parameters - ---------- - keyframes: dict - A dict with timestamps as keys and camera view up vectors as - values. - - Examples - -------- - >>> view_ups = {0, np.array([1, 0, 0]), 3, np.array([0, 1, 0])} - >>> CameraAnimation.set_view_up_keyframes(view_ups) - - """ - self.set_keyframes("view_up", keyframes) - - def get_focal(self, t): - """Return the interpolated camera's focal position. - - Parameters - ---------- - t: float - The time to interpolate at. - - Returns - ------- - ndarray(1, 3): - The interpolated camera's focal position. - - Notes - ----- - The returned focal position does not necessarily reflect the current - camera's focal position, but the expected one. - - """ - return self.get_value("focal", t) - - def get_view_up(self, t): - """Return the interpolated camera's view-up directional vector. - - Parameters - ---------- - t: float - The time to interpolate at. - - Returns - ------- - ndarray(1, 3): - The interpolated camera view-up directional vector. - - Notes - ----- - The returned focal position does not necessarily reflect the actual - camera view up directional vector, but the expected one. - - """ - return self.get_value("view_up", t) - - def set_focal_interpolator(self, interpolator, is_evaluator=False): - """Set the camera focal position interpolator. - - Parameters - ---------- - interpolator: callable - The generator function of the interpolator that would handle the - interpolation of the camera focal position keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes. - - """ - self.set_interpolator("focal", interpolator, is_evaluator=is_evaluator) - - def set_view_up_interpolator(self, interpolator, is_evaluator=False): - """Set the camera up-view vector animation interpolator. - - Parameters - ---------- - interpolator: callable - The generator function of the interpolator that would handle the - interpolation of the camera view-up keyframes. - is_evaluator: bool, optional - Specifies whether the `interpolator` is time-only based evaluation - function that does not depend on keyframes. - - """ - self.set_interpolator("view_up", interpolator, is_evaluator=is_evaluator) - - def update_animation(self, time=None): - """Update the camera animation. - - Parameters - ---------- - time: float or int, optional, default: None - The time to update the camera animation at. If None, the animation - will play. - - """ - if self._camera is None: - if self._scene: - self._camera = self._scene.camera() - self.update_animation(time) - return - else: - if self.is_interpolatable("rotation"): - pos = self._camera.GetPosition() - translation = np.identity(4) - translation[:3, 3] = pos - # camera axis is reverted - rot = -self.get_rotation(time, as_quat=True) - rot = transform.Rotation.from_quat(rot).as_matrix() - rot = np.array([[*rot[0], 0], [*rot[1], 0], [*rot[2], 0], [0, 0, 0, 1]]) - rot = translation @ rot @ np.linalg.inv(translation) - self._camera.SetModelTransformMatrix(rot.flatten()) - - if self.is_interpolatable("position"): - cam_pos = self.get_position(time) - self._camera.SetPosition(cam_pos) - - if self.is_interpolatable("focal"): - cam_foc = self.get_focal(time) - self._camera.SetFocalPoint(cam_foc) - - if self.is_interpolatable("view_up"): - cam_up = self.get_view_up(time) - self._camera.SetViewUp(cam_up) - elif not self.is_interpolatable("view_up"): - # to preserve up-view as default after user interaction - self._camera.SetViewUp(0, 1, 0) - if self._scene: - self._scene.reset_clipping_range() +from collections import defaultdict +from time import perf_counter +from warnings import warn + +import numpy as np +from scipy.spatial import transform + +from fury import utils +from fury.actor import line +from fury.animation.interpolator import ( # noqa F401 + linear_interpolator, + slerp, + spline_interpolator, + step_interpolator, +) +from fury.decorators import warn_on_args_to_kwargs +from fury.lib import Actor, Camera, Transform + + +class Animation: + """Keyframe animation class. + + Animation is responsible for keyframe animations for a single or a + group of actors. + It's used to handle multiple attributes and properties of Fury actors such + as transformations, color, and scale. + It also accepts custom data and interpolates them, such as temperature. + Linear interpolation is used by default to interpolate data between the + main keyframes. + + Attributes + ---------- + actors : Actor or list[Actor], optional, default: None + Actor/s to be animated. + length : float or int, default: None, optional + the fixed length of the animation. If set to None, the animation will + get its duration from the keyframes being set. + loop : bool, optional, default: True + Whether to loop the animation (True) of play once (False). + motion_path_res : int, default: None + the number of line segments used to visualizer the animation's motion + path (visualizing position). + + """ + + @warn_on_args_to_kwargs() + def __init__(self, *, actors=None, length=None, loop=True, motion_path_res=None): + super().__init__() + self._data = defaultdict(dict) + self._animations = [] + self._actors = [] + self._static_actors = [] + self._timeline = None + self._parent_animation = None + self._scene = None + self._start_time = 0 + self._length = length + self._duration = length if length else 0 + self._loop = loop + self._current_timestamp = 0 + self._max_timestamp = 0 + self._added_to_scene = True + self._motion_path_res = motion_path_res + self._motion_path_actor = None + self._transform = Transform() + self._general_callbacks = [] + # Adding actors to the animation + if actors is not None: + self.add_actor(actors) + + def update_duration(self): + """Update and return the duration of the Animation. + + Returns + ------- + float + The duration of the animation. + + """ + if self._length is not None: + self._duration = self._length + else: + self._duration = max( + self._max_timestamp, + max([0] + [anim.update_duration() for anim in self.child_animations]), + ) + + return self.duration + + @property + def duration(self): + """Return the duration of the animation. + + Returns + ------- + float + The duration of the animation. + + """ + return self._duration + + @property + def current_timestamp(self): + """Return the current time of the animation. + + Returns + ------- + float + The current time of the animation. + + """ + if self._timeline: + return self._timeline.current_timestamp + elif self.parent_animation: + return self.parent_animation.current_timestamp + return self._current_timestamp + + def update_motion_path(self): + """Update motion path visualization actor""" + res = self._motion_path_res + tl = self + while isinstance(tl._parent_animation, Animation): + if res: + break + tl = tl._parent_animation + res = tl._motion_path_res + if not res: + return + + lines = [] + colors = [] + if self.is_interpolatable("position"): + ts = np.linspace(0, self.duration, res) + [lines.append(self.get_position(t).tolist()) for t in ts] + if self.is_interpolatable("color"): + [colors.append(self.get_color(t)) for t in ts] + elif len(self._actors) >= 1: + colors = sum([i.vcolors[0] / 255 for i in self._actors]) / len( + self._actors + ) + else: + colors = [1, 1, 1] + + if len(lines) > 0: + lines = np.array([lines]) + if isinstance(colors, list): + colors = np.array([colors]) + + mpa = line(lines, colors=colors, opacity=0.6) + if self._scene: + # remove old motion path actor + if self._motion_path_actor is not None: + self._scene.rm(self._motion_path_actor) + self._scene.add(mpa) + self._motion_path_actor = mpa + + def _get_data(self): + """Get animation data. + + Returns + ------- + dict: + The animation data containing keyframes and interpolators. + + """ + return self._data + + def _get_attribute_data(self, attrib): + """Get animation data for a specific attribute. + + Parameters + ---------- + attrib: str + The attribute name to get data for. + + Returns + ------- + dict: + The animation data for a specific attribute. + + """ + data = self._get_data() + + if attrib not in data: + data[attrib] = { + "keyframes": defaultdict(dict), + "interpolator": { + "base": (linear_interpolator if attrib != "rotation" else slerp), + "func": None, + "args": defaultdict(), + }, + "callbacks": [], + } + return data.get(attrib) + + @warn_on_args_to_kwargs() + def get_keyframes(self, *, attrib=None): + """Get a keyframe for a specific or all attributes. + + Parameters + ---------- + attrib: str, optional, default: None + The name of the attribute. + If None, all keyframes for all set attributes will be returned. + + """ + data = self._get_data() + if attrib is None: + attribs = data.keys() + return { + attrib: data.get(attrib, {}).get("keyframes", {}) for attrib in attribs + } + return data.get(attrib, {}).get("keyframes", {}) + + @warn_on_args_to_kwargs() + def set_keyframe( + self, attrib, timestamp, value, *, update_interpolator=True, **kwargs + ): + """Set a keyframe for a certain attribute. + + Parameters + ---------- + attrib: str + The name of the attribute. + timestamp: float + Timestamp of the keyframe. + value: ndarray or float or bool + Value of the keyframe at the given timestamp. + update_interpolator: bool, optional + Interpolator will be reinitialized if True + + Other Parameters + ---------------- + in_cp: ndarray, shape (1, M), optional + The in control point in case of using cubic Bézier interpolator. + out_cp: ndarray, shape (1, M), optional + The out control point in case of using cubic Bézier interpolator. + in_tangent: ndarray, shape (1, M), optional + The in tangent at that position for the cubic spline curve. + out_tangent: ndarray, shape (1, M), optional + The out tangent at that position for the cubic spline curve. + + """ + attrib_data = self._get_attribute_data(attrib) + keyframes = attrib_data.get("keyframes") + + keyframes[timestamp] = { + "value": np.array(value).astype(float), + **{ + par: np.array(val).astype(float) + for par, val in kwargs.items() + if val is not None + }, + } + + if update_interpolator: + interp = attrib_data.get("interpolator") + interp_base = interp.get( + "base", linear_interpolator if attrib != "rotation" else slerp + ) + args = interp.get("args", {}) + self.set_interpolator(attrib, interp_base, **args) + + if timestamp > self._max_timestamp: + self._max_timestamp = timestamp + if self._timeline is not None: + self._timeline.update_duration() + else: + self.update_duration() + self.update_animation(time=0) + self.update_motion_path() + + def set_keyframes(self, attrib, keyframes): + """Set multiple keyframes for a certain attribute. + + Parameters + ---------- + attrib: str + The name of the attribute. + keyframes: dict + A dict object containing keyframes to be set. + + Notes + ----- + Keyframes can be on any of the following forms: + >>> key_frames_simple = {1: [1, 2, 1], 2: [3, 4, 5]} + >>> key_frames_bezier = {1: {'value': [1, 2, 1]}, + >>> 2: {'value': [3, 4, 5], 'in_cp': [1, 2, 3]}} + >>> pos_keyframes = {1: np.array([1, 2, 3]), 3: np.array([5, 5, 5])} + >>> Animation.set_keyframes('position', pos_keyframes) + + """ + for t, keyframe in keyframes.items(): + if isinstance(keyframe, dict): + self.set_keyframe(attrib, t, **keyframe) + else: + self.set_keyframe(attrib, t, keyframe) + + def is_inside_scene_at(self, timestamp): + """Check if the Animation is set to be inside the scene at a specific + timestamp. + + Returns + ------- + bool + True if the Animation is set to be inside the scene at the given + timestamp. + + Notes + ----- + If the parent Animation is set to be out of the scene at that time, all + of their child animations will be out of the scene as well. + + """ + parent = self._parent_animation + parent_in_scene = True + if parent is not None: + parent_in_scene = parent._added_to_scene + + if self.is_interpolatable("in_scene"): + in_scene = parent_in_scene and self.get_value("in_scene", timestamp) + else: + in_scene = parent_in_scene + return in_scene + + def add_to_scene_at(self, timestamp): + """Set timestamp for adding Animation to scene event. + + Parameters + ---------- + timestamp: float + Timestamp of the event. + + """ + if not self.is_interpolatable("in_scene"): + self.set_keyframe("in_scene", timestamp, True) + self.set_interpolator("in_scene", step_interpolator) + else: + self.set_keyframe("in_scene", timestamp, True) + + def remove_from_scene_at(self, timestamp): + """Set timestamp for removing Animation to scene event. + + Parameters + ---------- + timestamp: float + Timestamp of the event. + + """ + if not self.is_interpolatable("in_scene"): + self.set_keyframe("in_scene", timestamp, False) + self.set_interpolator("in_scene", step_interpolator) + else: + self.set_keyframe("in_scene", timestamp, False) + + def _handle_scene_event(self, timestamp): + should_be_in_scene = self.is_inside_scene_at(timestamp) + if self._scene is not None: + if should_be_in_scene and not self._added_to_scene: + self._scene.add(*self._actors) + self._added_to_scene = True + elif not should_be_in_scene and self._added_to_scene: + self._scene.rm(*self._actors) + self._added_to_scene = False + + @warn_on_args_to_kwargs() + def set_interpolator(self, attrib, interpolator, *, is_evaluator=False, **kwargs): + """Set keyframes interpolator for a certain property + + Parameters + ---------- + attrib: str + The name of the property. + interpolator: callable + The generator function of the interpolator to be used to + interpolate/evaluate keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes such as: + >>> def get_position(t): + >>> return np.array([np.sin(t), np.cos(t) * 5, 5]) + + Other Parameters + ---------------- + spline_degree: int, optional + The degree of the spline in case of setting a spline interpolator. + + Notes + ----- + If an evaluator is used to set the values of actor's properties such as + position, scale, color, rotation, or opacity, it has to return a value + with the same shape as the evaluated property, i.e.: for scale, it + has to return an array with shape 1x3, and for opacity, it has to + return a 1x1, an int, or a float value. + + Examples + -------- + >>> Animation.set_interpolator('position', linear_interpolator) + + >>> pos_fun = lambda t: np.array([np.sin(t), np.cos(t), 0]) + >>> Animation.set_interpolator('position', pos_fun) + + """ + attrib_data = self._get_attribute_data(attrib) + keyframes = attrib_data.get("keyframes", {}) + interp_data = attrib_data.get("interpolator", {}) + if is_evaluator: + interp_data["base"] = None + interp_data["func"] = interpolator + else: + interp_data["base"] = interpolator + interp_data["args"] = kwargs + # Maintain interpolator base in case new keyframes are added. + if len(keyframes) == 0: + return + new_interp = interpolator(keyframes, **kwargs) + interp_data["func"] = new_interp + + # update motion path + self.update_duration() + self.update_motion_path() + + def is_interpolatable(self, attrib): + """Check whether a property is interpolatable. + + Parameters + ---------- + attrib: str + The name of the property. + + Returns + ------- + bool + True if the property is interpolatable by the Animation. + + Notes + ----- + True means that it's safe to use `Interpolator.interpolate(t)` for the + specified property. And False means the opposite. + + """ + data = self._data + return bool(data.get(attrib, {}).get("interpolator", {}).get("func")) + + @warn_on_args_to_kwargs() + def set_position_interpolator(self, interpolator, *, is_evaluator=False, **kwargs): + """Set the position interpolator. + + Parameters + ---------- + interpolator: callable + The generator function of the interpolator that would handle the + position keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes. + + Other Parameters + ---------------- + degree: int + The degree of the spline interpolation in case of setting + the `spline_interpolator`. + + Examples + -------- + >>> Animation.set_position_interpolator(spline_interpolator, degree=5) + + """ + self.set_interpolator( + "position", interpolator, is_evaluator=is_evaluator, **kwargs + ) + + @warn_on_args_to_kwargs() + def set_scale_interpolator(self, interpolator, *, is_evaluator=False): + """Set the scale interpolator. + + Parameters + ---------- + interpolator: callable + The generator function of the interpolator that would handle + the scale keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes. + + Examples + -------- + >>> Animation.set_scale_interpolator(step_interpolator) + + """ + self.set_interpolator("scale", interpolator, is_evaluator=is_evaluator) + + @warn_on_args_to_kwargs() + def set_rotation_interpolator(self, interpolator, *, is_evaluator=False): + """Set the rotation interpolator . + + Parameters + ---------- + interpolator: callable + The generator function of the interpolator that would handle the + rotation (orientation) keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes. + + Examples + -------- + >>> Animation.set_rotation_interpolator(slerp) + + """ + self.set_interpolator("rotation", interpolator, is_evaluator=is_evaluator) + + @warn_on_args_to_kwargs() + def set_color_interpolator(self, interpolator, *, is_evaluator=False): + """Set the color interpolator. + + Parameters + ---------- + interpolator: callable + The generator function of the interpolator that would handle + the color keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes. + + Examples + -------- + >>> Animation.set_color_interpolator(lab_color_interpolator) + + """ + self.set_interpolator("color", interpolator, is_evaluator=is_evaluator) + + @warn_on_args_to_kwargs() + def set_opacity_interpolator(self, interpolator, *, is_evaluator=False): + """Set the opacity interpolator. + + Parameters + ---------- + interpolator: callable + The generator function of the interpolator that would handle + the opacity keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes. + + Examples + -------- + >>> Animation.set_opacity_interpolator(step_interpolator) + + """ + self.set_interpolator("opacity", interpolator, is_evaluator=is_evaluator) + + def get_value(self, attrib, timestamp): + """Return the value of an attribute at any given timestamp. + + Parameters + ---------- + attrib: str + The attribute name. + timestamp: float + The timestamp to interpolate at. + + """ + value = ( + self._data.get(attrib, {}).get("interpolator", {}).get("func")(timestamp) + ) + return value + + def get_current_value(self, attrib): + """Return the value of an attribute at current time. + + Parameters + ---------- + attrib: str + The attribute name. + + """ + return ( + self._data.get(attrib) + .get("interpolator") + .get("func")(self._timeline.current_timestamp) + ) + + def set_position(self, timestamp, position, **kwargs): + """Set a position keyframe at a specific timestamp. + + Parameters + ---------- + timestamp: float + Timestamp of the keyframe + position: ndarray, shape (1, 3) + Position value + + Other Parameters + ---------------- + in_cp: float + The control point in case of using `cubic Bézier interpolator` when + time exceeds this timestamp. + out_cp: float + The control point in case of using `cubic Bézier interpolator` when + time precedes this timestamp. + in_tangent: ndarray, shape (1, M), optional + The in tangent at that position for the cubic spline curve. + out_tangent: ndarray, shape (1, M), optional + The out tangent at that position for the cubic spline curve. + + Notes + ----- + `in_cp` and `out_cp` only needed when using the cubic bezier + interpolation method. + + """ + self.set_keyframe("position", timestamp, position, **kwargs) + + def set_position_keyframes(self, keyframes): + """Set a dict of position keyframes at once. + Should be in the following form: + {timestamp_1: position_1, timestamp_2: position_2} + + Parameters + ---------- + keyframes: dict + A dict with timestamps as keys and positions as values. + + Examples + -------- + >>> pos_keyframes = {1, np.array([0, 0, 0]), 3, np.array([50, 6, 6])} + >>> Animation.set_position_keyframes(pos_keyframes) + + """ + self.set_keyframes("position", keyframes) + + def set_rotation(self, timestamp, rotation, **kwargs): + """Set a rotation keyframe at a specific timestamp. + + Parameters + ---------- + timestamp: float + Timestamp of the keyframe + rotation: ndarray, shape(1, 3) or shape(1, 4) + Rotation data in euler degrees with shape(1, 3) or in quaternions + with shape(1, 4). + + Notes + ----- + Euler rotations are executed by rotating first around Z then around X, + and finally around Y. + + """ + no_components = len(np.array(rotation).flatten()) + if no_components == 4: + self.set_keyframe("rotation", timestamp, rotation, **kwargs) + elif no_components == 3: + # user is expected to set rotation order by default as setting + # orientation of a `vtkActor` ordered as z->x->y. + rotation = np.asarray(rotation, dtype=float) + rotation = transform.Rotation.from_euler( + "zxy", rotation[[2, 0, 1]], degrees=True + ).as_quat() + self.set_keyframe("rotation", timestamp, rotation, **kwargs) + else: + warn( + f"Keyframe with {no_components} components is not a " + f"valid rotation data. Skipped!", + stacklevel=2, + ) + + def set_rotation_as_vector(self, timestamp, vector, **kwargs): + """Set a rotation keyframe at a specific timestamp. + + Parameters + ---------- + timestamp: float + Timestamp of the keyframe + vector: ndarray, shape(1, 3) + Directional vector that describes the rotation. + + """ + quat = transform.Rotation.from_rotvec(vector).as_quat() + self.set_keyframe("rotation", timestamp, quat, **kwargs) + + def set_scale(self, timestamp, scalar, **kwargs): + """Set a scale keyframe at a specific timestamp. + + Parameters + ---------- + timestamp: float + Timestamp of the keyframe + scalar: ndarray, shape(1, 3) + Scale keyframe value associated with the timestamp. + + """ + self.set_keyframe("scale", timestamp, scalar, **kwargs) + + def set_scale_keyframes(self, keyframes): + """Set a dict of scale keyframes at once. + Should be in the following form: + {timestamp_1: scale_1, timestamp_2: scale_2} + + Parameters + ---------- + keyframes: dict + A dict with timestamps as keys and scales as values. + + Examples + -------- + >>> scale_keyframes = {1, np.array([1, 1, 1]), 3, np.array([2, 2, 3])} + >>> Animation.set_scale_keyframes(scale_keyframes) + + """ + self.set_keyframes("scale", keyframes) + + def set_color(self, timestamp, color, **kwargs): + """Set color keyframe at a specific timestamp. + + Parameters + ---------- + timestamp: float + Timestamp of the keyframe + color: ndarray, shape(1, 3) + Color keyframe value associated with the timestamp. + + """ + self.set_keyframe("color", timestamp, color, **kwargs) + + def set_color_keyframes(self, keyframes): + """Set a dict of color keyframes at once. + Should be in the following form: + {timestamp_1: color_1, timestamp_2: color_2} + + Parameters + ---------- + keyframes: dict + A dict with timestamps as keys and color as values. + + Examples + -------- + >>> color_keyframes = {1, np.array([1, 0, 1]), 3, np.array([0, 0, 1])} + >>> Animation.set_color_keyframes(color_keyframes) + + """ + self.set_keyframes("color", keyframes) + + def set_opacity(self, timestamp, opacity, **kwargs): + """Set opacity keyframe at a specific timestamp. + + Parameters + ---------- + timestamp: float + Timestamp of the keyframe + opacity: ndarray, shape(1, 3) + Opacity keyframe value associated with the timestamp. + + """ + self.set_keyframe("opacity", timestamp, opacity, **kwargs) + + def set_opacity_keyframes(self, keyframes): + """Set a dict of opacity keyframes at once. + Should be in the following form: + {timestamp_1: opacity_1, timestamp_2: opacity_2} + + Parameters + ---------- + keyframes: dict(float: ndarray, shape(1, 1) or float or int) + A dict with timestamps as keys and opacities as values. + + Notes + ----- + Opacity values should be between 0 and 1. + + Examples + -------- + >>> opacity = {1, np.array([1, 1, 1]), 3, np.array([2, 2, 3])} + >>> Animation.set_scale_keyframes(opacity) + + """ + self.set_keyframes("opacity", keyframes) + + def get_position(self, t): + """Return the interpolated position. + + Parameters + ---------- + t: float + The time to interpolate position at. + + Returns + ------- + ndarray(1, 3): + The interpolated position. + + """ + return self.get_value("position", t) + + def get_rotation(self, t, as_quat=False): + """Return the interpolated rotation. + + Parameters + ---------- + t: float + the time to interpolate rotation at. + as_quat: bool + Returned rotation will be as quaternion if True. + + Returns + ------- + ndarray(1, 3): + The interpolated rotation as Euler degrees by default. + + """ + rot = self.get_value("rotation", t) + if len(rot) == 4: + if as_quat: + return rot + r = transform.Rotation.from_quat(rot) + degrees = r.as_euler("zxy", degrees=True)[[1, 2, 0]] + return degrees + elif not as_quat: + return rot + return transform.Rotation.from_euler( + "zxy", rot[[2, 0, 1]], degrees=True + ).as_quat() + + def get_scale(self, t): + """Return the interpolated scale. + + Parameters + ---------- + t: float + The time to interpolate scale at. + + Returns + ------- + ndarray(1, 3): + The interpolated scale. + + """ + return self.get_value("scale", t) + + def get_color(self, t): + """Return the interpolated color. + + Parameters + ---------- + t: float + The time to interpolate color value at. + + Returns + ------- + ndarray(1, 3): + The interpolated color. + + """ + return self.get_value("color", t) + + def get_opacity(self, t): + """Return the opacity value. + + Parameters + ---------- + t: float + The time to interpolate opacity at. + + Returns + ------- + ndarray(1, 1): + The interpolated opacity. + + """ + return self.get_value("opacity", t) + + def add(self, item): + """Add an item to the Animation. + This item can be an Actor, Animation, list of Actors, or a list of + Animations. + + Parameters + ---------- + item: Animation, vtkActor, list[Animation], or list[vtkActor] + Actor/s to be animated by the Animation. + + """ + if isinstance(item, list): + for a in item: + self.add(a) + return + elif isinstance(item, Actor): + self.add_actor(item) + elif isinstance(item, Animation): + self.add_child_animation(item) + else: + raise ValueError(f"Object of type {type(item)} can't be animated") + + def add_child_animation(self, animation): + """Add child Animation or list of Animations. + + Parameters + ---------- + animation: Animation or list[Animation] + Animation/s to be added. + + """ + if isinstance(animation, list): + for a in animation: + self.add_child_animation(a) + return + animation._parent_animation = self + animation.update_motion_path() + self._animations.append(animation) + self.update_duration() + + @warn_on_args_to_kwargs() + def add_actor(self, actor, *, static=False): + """Add an actor or list of actors to the Animation. + + Parameters + ---------- + actor: vtkActor or list(vtkActor) + Actor/s to be animated by the Animation. + static: bool + Indicated whether the actor should be animated and controlled by + the animation or just a static actor that gets added to the scene + along with the Animation. + + """ + if isinstance(actor, list): + for a in actor: + self.add_actor(a, static=static) + elif static: + if actor not in self.static_actors: + self._static_actors.append(actor) + else: + if actor not in self._actors: + actor.vcolors = utils.colors_from_actor(actor) + self._actors.append(actor) + + @property + def timeline(self): + """Return the Timeline handling the current animation. + + Returns + ------- + Timeline: + The Timeline handling the current animation, None, if there is no + associated Timeline. + + """ + return self._timeline + + @timeline.setter + def timeline(self, timeline): + """Assign the Timeline responsible for handling the Animation. + + Parameters + ---------- + timeline: Timeline + The Timeline handling the current animation, None, if there is no + associated Timeline. + + """ + self._timeline = timeline + if self._animations: + for animation in self._animations: + animation.timeline = timeline + + @property + def parent_animation(self): + """Return the hierarchical parent Animation for current Animation. + + Returns + ------- + Animation: + The parent Animation. + + """ + return self._parent_animation + + @parent_animation.setter + def parent_animation(self, parent_animation): + """Assign a parent Animation for the current Animation. + + Parameters + ---------- + parent_animation: Animation + The parent Animation instance. + + """ + self._parent_animation = parent_animation + + @property + def actors(self): + """Return a list of actors. + + Returns + ------- + list: + List of actors controlled by the Animation. + + """ + return self._actors + + @property + def child_animations(self) -> "list[Animation]": + """Return a list of child Animations. + + Returns + ------- + list: + List of child Animations of this Animation. + + """ + return self._animations + + def add_static_actor(self, actor): + """Add an actor or list of actors as static actor/s which will not be + controlled nor animated by the Animation. All static actors will be + added to the scene when the Animation is added to the scene. + + Parameters + ---------- + actor: vtkActor or list(vtkActor) + Static actor/s. + + """ + self.add_actor(actor, static=True) + + @property + def static_actors(self): + """Return a list of static actors. + + Returns + ------- + list: + List of static actors. + + """ + return self._static_actors + + def remove_animations(self): + """Remove all child Animations from the Animation""" + self._animations.clear() + + def remove_actor(self, actor): + """Remove an actor from the Animation. + + Parameters + ---------- + actor: vtkActor + Actor to be removed from the Animation. + + """ + self._actors.remove(actor) + + def remove_actors(self): + """Remove all actors from the Animation""" + self._actors.clear() + + @property + def loop(self): + """Get loop condition of the current animation. + + Returns + ------- + bool + Whether the animation in loop mode (True) or play one mode (False). + + """ + return self._loop + + @loop.setter + def loop(self, loop): + """Set the animation to loop or play once. + + Parameters + ---------- + loop: bool + The loop condition to be set. (True) to loop the animation, and + (False) to play only once. + + """ + self._loop = loop + + def add_update_callback(self, callback, prop=None): + """Add a function to be called each time animation is updated + This function must accept only one argument which is the current value + of the named property. + + + Parameters + ---------- + callback: callable + The function to be called whenever the animation is updated. + prop: str, optional, default: None + The name of the property. + + Notes + ----- + If no attribute name was provided, current time of the animation will + be provided instead of current value for the callback. + + """ + if prop is None: + self._general_callbacks.append(callback) + return + attrib = self._get_attribute_data(prop) + attrib.get("callbacks", []).append(callback) + + @warn_on_args_to_kwargs() + def update_animation(self, *, time=None): + """Update the animation. + + Update the animation at a certain time. This will make sure all + attributes are calculated and set to the actors at that given time. + + Parameters + ---------- + time: float or int, optional, default: None + The time to update animation at. If None, the animation will play + without adding it to a Timeline. + + """ + has_handler = True + if time is None: + time = perf_counter() - self._start_time + has_handler = False + + # handling in/out of scene events + in_scene = self.is_inside_scene_at(time) + self._handle_scene_event(time) + + if self.duration: + if self._loop and time > self.duration: + time = time % self.duration + elif time > self.duration: + time = self.duration + if isinstance(self._parent_animation, Animation): + self._transform.DeepCopy(self._parent_animation._transform) + else: + self._transform.Identity() + + self._current_timestamp = time + + # actors properties + if in_scene: + if self.is_interpolatable("position"): + position = self.get_position(time) + self._transform.Translate(*position) + + if self.is_interpolatable("opacity"): + opacity = self.get_opacity(time) + [act.GetProperty().SetOpacity(opacity) for act in self.actors] + + if self.is_interpolatable("rotation"): + x, y, z = self.get_rotation(time) + # Rotate in the same order as VTK defaults. + self._transform.RotateZ(z) + self._transform.RotateX(x) + self._transform.RotateY(y) + + if self.is_interpolatable("scale"): + scale = self.get_scale(time) + self._transform.Scale(*scale) + + if self.is_interpolatable("color"): + color = self.get_color(time) + for act in self.actors: + act.vcolors[:] = color * 255 + utils.update_actor(act) + + # update actors' transformation matrix + [act.SetUserTransform(self._transform) for act in self.actors] + + for attrib in self._data: + callbacks = self._data.get(attrib, {}).get("callbacks", []) + if callbacks != [] and self.is_interpolatable(attrib): + value = self.get_value(attrib, time) + [cbk(value) for cbk in callbacks] + + # Executing general callbacks that's not related to any attribute + [callback(time) for callback in self._general_callbacks] + + # Also update all child Animations. + [animation.update_animation(time=time) for animation in self._animations] + + if self._scene and not has_handler: + self._scene.reset_clipping_range() + + def add_to_scene(self, scene): + """Add this Animation, its actors and sub Animations to the scene""" + [scene.add(actor) for actor in self._actors] + [scene.add(static_act) for static_act in self._static_actors] + [scene.add(animation) for animation in self._animations] + + if self._motion_path_actor: + scene.add(self._motion_path_actor) + self._scene = scene + self._added_to_scene = True + self._start_time = perf_counter() + self.update_animation(time=0) + + def remove_from_scene(self, scene): + """Remove Animation, its actors and sub Animations from the scene""" + [scene.rm(act) for act in self.actors] + [scene.rm(static_act) for static_act in self._static_actors] + for anim in self.child_animations: + anim.remove_from_scene(scene) + if self._motion_path_actor: + scene.rm(self._motion_path_actor) + self._added_to_scene = False + + +class CameraAnimation(Animation): + """Camera keyframe animation class. + + This is used for animating a single camera using a set of keyframes. + + Attributes + ---------- + camera : Camera, optional, default: None + Camera to be animated. If None, active camera will be animated. + length : float or int, default: None, optional + the fixed length of the animation. If set to None, the animation will + get its duration from the keyframes being set. + loop : bool, optional, default: True + Whether to loop the animation (True) of play once (False). + motion_path_res : int, default: None + the number of line segments used to visualizer the animation's motion + path (visualizing position). + + """ + + @warn_on_args_to_kwargs() + def __init__(self, *, camera=None, length=None, loop=True, motion_path_res=None): + super(CameraAnimation, self).__init__( + length=length, loop=loop, motion_path_res=motion_path_res + ) + self._camera = camera + + @property + def camera(self) -> Camera: + """Return the camera assigned to this animation. + + Returns + ------- + Camera: + The camera that is being animated by this CameraAnimation. + + """ + return self._camera + + @camera.setter + def camera(self, camera: Camera): + """Set a camera to be animated. + + Parameters + ---------- + camera: Camera + The camera to be animated + + """ + self._camera = camera + + def set_focal(self, timestamp, position, **kwargs): + """Set camera's focal position keyframe. + + Parameters + ---------- + timestamp: float + The time to interpolate opacity at. + position: ndarray, shape(1, 3) + The camera position + + """ + self.set_keyframe("focal", timestamp, position, **kwargs) + + def set_view_up(self, timestamp, direction, **kwargs): + """Set the camera view-up direction keyframe. + + Parameters + ---------- + timestamp: float + The time to interpolate at. + direction: ndarray, shape(1, 3) + The camera view-up direction + + """ + self.set_keyframe("view_up", timestamp, direction, **kwargs) + + def set_focal_keyframes(self, keyframes): + """Set multiple camera focal position keyframes at once. + Should be in the following form: + {timestamp_1: focal_1, timestamp_2: focal_1, ...} + + Parameters + ---------- + keyframes: dict + A dict with timestamps as keys and camera focal positions as + values. + + Examples + -------- + >>> focal_pos = {0, np.array([1, 1, 1]), 3, np.array([20, 0, 0])} + >>> CameraAnimation.set_focal_keyframes(focal_pos) + + """ + self.set_keyframes("focal", keyframes) + + def set_view_up_keyframes(self, keyframes): + """Set multiple camera view up direction keyframes. + Should be in the following form: + {timestamp_1: view_up_1, timestamp_2: view_up_2, ...} + + Parameters + ---------- + keyframes: dict + A dict with timestamps as keys and camera view up vectors as + values. + + Examples + -------- + >>> view_ups = {0, np.array([1, 0, 0]), 3, np.array([0, 1, 0])} + >>> CameraAnimation.set_view_up_keyframes(view_ups) + + """ + self.set_keyframes("view_up", keyframes) + + def get_focal(self, t): + """Return the interpolated camera's focal position. + + Parameters + ---------- + t: float + The time to interpolate at. + + Returns + ------- + ndarray(1, 3): + The interpolated camera's focal position. + + Notes + ----- + The returned focal position does not necessarily reflect the current + camera's focal position, but the expected one. + + """ + return self.get_value("focal", t) + + def get_view_up(self, t): + """Return the interpolated camera's view-up directional vector. + + Parameters + ---------- + t: float + The time to interpolate at. + + Returns + ------- + ndarray(1, 3): + The interpolated camera view-up directional vector. + + Notes + ----- + The returned focal position does not necessarily reflect the actual + camera view up directional vector, but the expected one. + + """ + return self.get_value("view_up", t) + + @warn_on_args_to_kwargs() + def set_focal_interpolator(self, interpolator, *, is_evaluator=False): + """Set the camera focal position interpolator. + + Parameters + ---------- + interpolator: callable + The generator function of the interpolator that would handle the + interpolation of the camera focal position keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes. + + """ + self.set_interpolator("focal", interpolator, is_evaluator=is_evaluator) + + @warn_on_args_to_kwargs() + def set_view_up_interpolator(self, interpolator, *, is_evaluator=False): + """Set the camera up-view vector animation interpolator. + + Parameters + ---------- + interpolator: callable + The generator function of the interpolator that would handle the + interpolation of the camera view-up keyframes. + is_evaluator: bool, optional + Specifies whether the `interpolator` is time-only based evaluation + function that does not depend on keyframes. + + """ + self.set_interpolator("view_up", interpolator, is_evaluator=is_evaluator) + + @warn_on_args_to_kwargs() + def update_animation(self, *, time=None): + """Update the camera animation. + + Parameters + ---------- + time: float or int, optional, default: None + The time to update the camera animation at. If None, the animation + will play. + + """ + if self._camera is None: + if self._scene: + self._camera = self._scene.camera() + self.update_animation(tile=time) + return + else: + if self.is_interpolatable("rotation"): + pos = self._camera.GetPosition() + translation = np.identity(4) + translation[:3, 3] = pos + # camera axis is reverted + rot = -self.get_rotation(time, as_quat=True) + rot = transform.Rotation.from_quat(rot).as_matrix() + rot = np.array([[*rot[0], 0], [*rot[1], 0], [*rot[2], 0], [0, 0, 0, 1]]) + rot = translation @ rot @ np.linalg.inv(translation) + self._camera.SetModelTransformMatrix(rot.flatten()) + + if self.is_interpolatable("position"): + cam_pos = self.get_position(time) + self._camera.SetPosition(cam_pos) + + if self.is_interpolatable("focal"): + cam_foc = self.get_focal(time) + self._camera.SetFocalPoint(cam_foc) + + if self.is_interpolatable("view_up"): + cam_up = self.get_view_up(time) + self._camera.SetViewUp(cam_up) + elif not self.is_interpolatable("view_up"): + # to preserve up-view as default after user interaction + self._camera.SetViewUp(0, 1, 0) + if self._scene: + self._scene.reset_clipping_range() diff --git a/fury/animation/helpers.py b/fury/animation/helpers.py index 03746f386e..bd54785e79 100644 --- a/fury/animation/helpers.py +++ b/fury/animation/helpers.py @@ -1,154 +1,158 @@ -import numpy as np - - -def get_previous_timestamp(timestamps, current_time, include_last=False): - """Return the maximum previous timestamp of a given time. - - Parameters - ---------- - timestamps : ndarray - Sorted list of timestamps. - current_time : float or int - The time to get previous timestamp for. - include_last: bool, optional, default: False - If `True`, even the last timestamp will be considered a valid previous - timestamp. - - Returns - ------- - float or int - The previous timestamp - - """ - for timestamp in timestamps[::-1] if include_last else timestamps[-2::-1]: - if timestamp <= current_time: - return timestamp - return timestamps[0] - - -def get_next_timestamp(timestamps, current_time, include_first=False): - """Return the minimum next timestamp of a given time. - - Parameters - ---------- - timestamps : ndarray - Sorted list of timestamps. - current_time : float or int - The time to get previous timestamp for. - include_first: bool, optional, default: False - If `True`, even the first timestamp will be considered a valid next - timestamp. - - Returns - ------- - float or int - The next timestamp - - """ - for timestamp in timestamps[:] if include_first else timestamps[1:]: - if timestamp > current_time: - return timestamp - return timestamps[-1] - - -def get_timestamps_from_keyframes(keyframes): - """Return a sorted array of timestamps given dict of keyframes. - - Parameters - ---------- - keyframes : dict - keyframes dict that contains timestamps as keys. - - Returns - ------- - ndarray - Array of sorted timestamps extracted from the keyframes. - - """ - return np.sort(np.array(list(keyframes)), axis=None) - - -def get_values_from_keyframes(keyframes): - """Return an array of keyframes values sorted using timestamps. - - Parameters - ---------- - keyframes : dict - keyframes dict that contains timestamps as keys and data as values. - - Returns - ------- - ndarray - Array of sorted values extracted from the keyframes. - - """ - return np.asarray( - [keyframes.get(t, {}).get("value", None) for t in sorted(keyframes.keys())] - ) - - -def get_time_tau(t, t0, t1): - """Return a capped time tau between 0 and 1. - - Parameters - ---------- - t : float or int - Current time to calculate tau for. - t0 : float or int - Lower timestamp of the time period. - t1 : float or int - Higher timestamp of the time period. - - Returns - ------- - float - The time tau - - """ - return 0 if t <= t0 else 1 if t >= t1 else (t - t0) / (t1 - t0) - - -def lerp(v0, v1, t0, t1, t): - """Return a linearly interpolated value. - - Parameters - ---------- - v0: ndarray or float or int. - The first value - v1: ndarray or float or int. - The second value - t : float or int - Current time to interpolate at. - t0 : float or int - Timestamp associated with v0. - t1 : float or int - Timestamp associated with v1. - - Returns - ------- - ndarray or float - The interpolated value - - """ - if t0 == t1: - return v0 - v = v1 - v0 - dt = get_time_tau(t, t0, t1) - return dt * v + v0 - - -def euclidean_distances(points): - """Return a list of euclidean distances of a list of points or values. - - Parameters - ---------- - points: ndarray - Array of points or valued to calculate euclidean distances between. - - Returns - ------- - list - A List of euclidean distance between each consecutive points or values. - - """ - return [np.linalg.norm(x - y) for x, y in zip(points, points[1:])] +import numpy as np + +from fury.decorators import warn_on_args_to_kwargs + + +@warn_on_args_to_kwargs() +def get_previous_timestamp(timestamps, current_time, *, include_last=False): + """Return the maximum previous timestamp of a given time. + + Parameters + ---------- + timestamps : ndarray + Sorted list of timestamps. + current_time : float or int + The time to get previous timestamp for. + include_last: bool, optional, default: False + If `True`, even the last timestamp will be considered a valid previous + timestamp. + + Returns + ------- + float or int + The previous timestamp + + """ + for timestamp in timestamps[::-1] if include_last else timestamps[-2::-1]: + if timestamp <= current_time: + return timestamp + return timestamps[0] + + +@warn_on_args_to_kwargs() +def get_next_timestamp(timestamps, current_time, *, include_first=False): + """Return the minimum next timestamp of a given time. + + Parameters + ---------- + timestamps : ndarray + Sorted list of timestamps. + current_time : float or int + The time to get previous timestamp for. + include_first: bool, optional, default: False + If `True`, even the first timestamp will be considered a valid next + timestamp. + + Returns + ------- + float or int + The next timestamp + + """ + for timestamp in timestamps[:] if include_first else timestamps[1:]: + if timestamp > current_time: + return timestamp + return timestamps[-1] + + +def get_timestamps_from_keyframes(keyframes): + """Return a sorted array of timestamps given dict of keyframes. + + Parameters + ---------- + keyframes : dict + keyframes dict that contains timestamps as keys. + + Returns + ------- + ndarray + Array of sorted timestamps extracted from the keyframes. + + """ + return np.sort(np.array(list(keyframes)), axis=None) + + +def get_values_from_keyframes(keyframes): + """Return an array of keyframes values sorted using timestamps. + + Parameters + ---------- + keyframes : dict + keyframes dict that contains timestamps as keys and data as values. + + Returns + ------- + ndarray + Array of sorted values extracted from the keyframes. + + """ + return np.asarray( + [keyframes.get(t, {}).get("value", None) for t in sorted(keyframes.keys())] + ) + + +def get_time_tau(t, t0, t1): + """Return a capped time tau between 0 and 1. + + Parameters + ---------- + t : float or int + Current time to calculate tau for. + t0 : float or int + Lower timestamp of the time period. + t1 : float or int + Higher timestamp of the time period. + + Returns + ------- + float + The time tau + + """ + return 0 if t <= t0 else 1 if t >= t1 else (t - t0) / (t1 - t0) + + +def lerp(v0, v1, t0, t1, t): + """Return a linearly interpolated value. + + Parameters + ---------- + v0: ndarray or float or int. + The first value + v1: ndarray or float or int. + The second value + t : float or int + Current time to interpolate at. + t0 : float or int + Timestamp associated with v0. + t1 : float or int + Timestamp associated with v1. + + Returns + ------- + ndarray or float + The interpolated value + + """ + if t0 == t1: + return v0 + v = v1 - v0 + dt = get_time_tau(t, t0, t1) + return dt * v + v0 + + +def euclidean_distances(points): + """Return a list of euclidean distances of a list of points or values. + + Parameters + ---------- + points: ndarray + Array of points or valued to calculate euclidean distances between. + + Returns + ------- + list + A List of euclidean distance between each consecutive points or values. + + """ + return [np.linalg.norm(x - y) for x, y in zip(points, points[1:])] diff --git a/fury/animation/interpolator.py b/fury/animation/interpolator.py index a7228a7dd7..783a3d9f29 100644 --- a/fury/animation/interpolator.py +++ b/fury/animation/interpolator.py @@ -1,373 +1,373 @@ -import numpy as np -from scipy.interpolate import splev, splprep -from scipy.spatial import transform - -from fury.animation.helpers import ( - euclidean_distances, - get_next_timestamp, - get_previous_timestamp, - get_time_tau, - get_timestamps_from_keyframes, - get_values_from_keyframes, - lerp, -) -from fury.colormap import hsv2rgb, lab2rgb, rgb2hsv, rgb2lab, rgb2xyz, xyz2rgb - - -def spline_interpolator(keyframes, degree): - """N-th degree spline interpolator for keyframes. - - This is a general n-th degree spline interpolator to be used for any shape - of keyframes data. - - Parameters - ---------- - keyframes: dict - Keyframe data containing timestamps and values to form the spline - curve. Data should be on the following format: - >>> {1: {'value': np.array([...])}, 2: {'value': np.array([...])}} - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - """ - if len(keyframes) < (degree + 1): - raise ValueError( - f"Minimum {degree + 1} " - f"keyframes must be set in order to use " - f"{degree}-degree spline" - ) - timestamps = get_timestamps_from_keyframes(keyframes) - - values = get_values_from_keyframes(keyframes) - distances = euclidean_distances(values) - distances_sum = sum(distances) - cumulative_dist_sum = np.cumsum([0] + distances) - tck = splprep(values.T, k=degree, full_output=1, s=0)[0][0] - - def interpolate(t): - t0 = get_previous_timestamp(timestamps, t) - t1 = get_next_timestamp(timestamps, t) - mi_index = np.where(timestamps == t0)[0][0] - dt = get_time_tau(t, t0, t1) - section = cumulative_dist_sum[mi_index] - ts = (section + dt * distances[mi_index]) / distances_sum - return np.array(splev(ts, tck)) - - return interpolate - - -def cubic_spline_interpolator(keyframes): - """Cubic spline interpolator for keyframes. - - This is a general cubic spline interpolator to be used for any shape of - keyframes data. - - Parameters - ---------- - keyframes: dict - Keyframe data containing timestamps and values to form the cubic spline - curve. - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - See Also - -------- - spline_interpolator - - """ - return spline_interpolator(keyframes, degree=3) - - -def step_interpolator(keyframes): - """Step interpolator for keyframes. - - This is a simple step interpolator to be used for any shape of - keyframes data. - - Parameters - ---------- - keyframes: dict - Keyframe data containing timestamps and values to form the spline - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - """ - timestamps = get_timestamps_from_keyframes(keyframes) - - def interpolate(t): - previous_t = get_previous_timestamp(timestamps, t, include_last=True) - return keyframes.get(previous_t).get("value") - - return interpolate - - -def linear_interpolator(keyframes): - """Linear interpolator for keyframes. - - This is a general linear interpolator to be used for any shape of - keyframes data. - - Parameters - ---------- - keyframes: dict - Keyframe data to be linearly interpolated. - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - """ - timestamps = get_timestamps_from_keyframes(keyframes) - is_single = len(keyframes) == 1 - - def interpolate(t): - if is_single: - t = timestamps[0] - return keyframes.get(t).get("value") - t0 = get_previous_timestamp(timestamps, t) - t1 = get_next_timestamp(timestamps, t) - p0 = keyframes.get(t0).get("value") - p1 = keyframes.get(t1).get("value") - return lerp(p0, p1, t0, t1, t) - - return interpolate - - -def cubic_bezier_interpolator(keyframes): - """Cubic Bézier interpolator for keyframes. - - This is a general cubic Bézier interpolator to be used for any shape of - keyframes data. - - Parameters - ---------- - keyframes : dict - Keyframes to be interpolated at any time. - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - Notes - ----- - If no control points are set in the keyframes, The cubic - Bézier interpolator will almost behave as a linear interpolator. - - """ - timestamps = get_timestamps_from_keyframes(keyframes) - - for ts in timestamps: - # keyframe at timestamp - kf_ts = keyframes.get(ts) - if kf_ts.get("in_cp") is None: - kf_ts["in_cp"] = kf_ts.get("value") - - if kf_ts.get("out_cp") is None: - kf_ts["out_cp"] = kf_ts.get("value") - - def interpolate(t): - t0 = get_previous_timestamp(timestamps, t) - t1 = get_next_timestamp(timestamps, t) - k0 = keyframes.get(t0) - k1 = keyframes.get(t1) - p0 = k0.get("value") - p1 = k0.get("out_cp") - p2 = k1.get("in_cp") - p3 = k1.get("value") - dt = get_time_tau(t, t0, t1) - val = ( - (1 - dt) ** 3 * p0 - + 3 * (1 - dt) ** 2 * dt * p1 - + 3 * (1 - dt) * dt**2 * p2 - + dt**3 * p3 - ) - return val - - return interpolate - - -def slerp(keyframes): - """Spherical based rotation keyframes interpolator. - - A rotation interpolator to be used for rotation keyframes. - - Parameters - ---------- - keyframes : dict - Rotation keyframes to be interpolated at any time. - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - Notes - ----- - Rotation keyframes must be in the form of quaternions. - - """ - timestamps = get_timestamps_from_keyframes(keyframes) - - quat_rots = [] - for ts in timestamps: - quat_rots.append(keyframes.get(ts).get("value")) - rotations = transform.Rotation.from_quat(quat_rots) - # if only one keyframe specified, linear interpolator is used. - if len(timestamps) == 1: - return linear_interpolator(keyframes) - slerp_interp = transform.Slerp(timestamps, rotations) - min_t = timestamps[0] - max_t = timestamps[-1] - - def interpolate(t): - t = min_t if t < min_t else max_t if t > max_t else t - v = slerp_interp(t) - q = v.as_quat() - return q - - return interpolate - - -def color_interpolator(keyframes, rgb2space, space2rgb): - """Custom-space color interpolator. - - Interpolate values linearly inside a custom color space. - - Parameters - ---------- - keyframes : dict - Rotation keyframes to be interpolated at any time. - rgb2space: function - A functions that take color value in rgb and return that color - converted to the targeted space. - space2rgb: function - A functions that take color value in the targeted space and returns - that color in rgb space. - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - """ - timestamps = get_timestamps_from_keyframes(keyframes) - space_keyframes = {} - is_single = len(keyframes) == 1 - for ts, keyframe in keyframes.items(): - space_keyframes[ts] = rgb2space(keyframe.get("value")) - - def interpolate(t): - if is_single: - t = timestamps[0] - return keyframes.get(t).get("value") - t0 = get_previous_timestamp(timestamps, t) - t1 = get_next_timestamp(timestamps, t) - c0 = space_keyframes.get(t0) - c1 = space_keyframes.get(t1) - space_color_val = lerp(c0, c1, t0, t1, t) - return space2rgb(space_color_val) - - return interpolate - - -def hsv_color_interpolator(keyframes): - """HSV interpolator for color keyframes - - See Also - -------- - color_interpolator - - """ - return color_interpolator(keyframes, rgb2hsv, hsv2rgb) - - -def lab_color_interpolator(keyframes): - """LAB interpolator for color keyframes - - See Also - -------- - color_interpolator - - """ - return color_interpolator(keyframes, rgb2lab, lab2rgb) - - -def xyz_color_interpolator(keyframes): - """XYZ interpolator for color keyframes - - See Also - -------- - color_interpolator - - """ - return color_interpolator(keyframes, rgb2xyz, xyz2rgb) - - -def tan_cubic_spline_interpolator(keyframes): - """Cubic spline interpolator for keyframes using tangents. - glTF contains additional tangent information for the cubic spline - interpolator. - - Parameters - ---------- - keyframes: dict - Keyframe data containing timestamps and values to form the cubic spline - curve. - - Returns - ------- - function - The interpolation function that take time and return interpolated - value at that time. - - """ - timestamps = get_timestamps_from_keyframes(keyframes) - for time in keyframes: - data = keyframes.get(time) - value = data.get("value") - if data.get("in_tangent") is None: - data["in_tangent"] = np.zeros_like(value) - if data.get("in_tangent") is None: - data["in_tangent"] = np.zeros_like(value) - - def interpolate(t): - t0 = get_previous_timestamp(timestamps, t) - t1 = get_next_timestamp(timestamps, t) - - dt = get_time_tau(t, t0, t1) - - time_delta = t1 - t0 - - p0 = keyframes.get(t0).get("value") - tan_0 = keyframes.get(t0).get("out_tangent") * time_delta - p1 = keyframes.get(t1).get("value") - tan_1 = keyframes.get(t1).get("in_tangent") * time_delta - # cubic spline equation using tangents - t2 = dt * dt - t3 = t2 * dt - return ( - (2 * t3 - 3 * t2 + 1) * p0 - + (t3 - 2 * t2 + dt) * tan_0 - + (-2 * t3 + 3 * t2) * p1 - + (t3 - t2) * tan_1 - ) - - return interpolate +import numpy as np +from scipy.interpolate import splev, splprep +from scipy.spatial import transform + +from fury.animation.helpers import ( + euclidean_distances, + get_next_timestamp, + get_previous_timestamp, + get_time_tau, + get_timestamps_from_keyframes, + get_values_from_keyframes, + lerp, +) +from fury.colormap import hsv2rgb, lab2rgb, rgb2hsv, rgb2lab, rgb2xyz, xyz2rgb + + +def spline_interpolator(keyframes, degree): + """N-th degree spline interpolator for keyframes. + + This is a general n-th degree spline interpolator to be used for any shape + of keyframes data. + + Parameters + ---------- + keyframes: dict + Keyframe data containing timestamps and values to form the spline + curve. Data should be on the following format: + >>> {1: {'value': np.array([...])}, 2: {'value': np.array([...])}} + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + """ + if len(keyframes) < (degree + 1): + raise ValueError( + f"Minimum {degree + 1} " + f"keyframes must be set in order to use " + f"{degree}-degree spline" + ) + timestamps = get_timestamps_from_keyframes(keyframes) + + values = get_values_from_keyframes(keyframes) + distances = euclidean_distances(values) + distances_sum = sum(distances) + cumulative_dist_sum = np.cumsum([0] + distances) + tck = splprep(values.T, k=degree, full_output=1, s=0)[0][0] + + def interpolate(t): + t0 = get_previous_timestamp(timestamps, t) + t1 = get_next_timestamp(timestamps, t) + mi_index = np.where(timestamps == t0)[0][0] + dt = get_time_tau(t, t0, t1) + section = cumulative_dist_sum[mi_index] + ts = (section + dt * distances[mi_index]) / distances_sum + return np.array(splev(ts, tck)) + + return interpolate + + +def cubic_spline_interpolator(keyframes): + """Cubic spline interpolator for keyframes. + + This is a general cubic spline interpolator to be used for any shape of + keyframes data. + + Parameters + ---------- + keyframes: dict + Keyframe data containing timestamps and values to form the cubic spline + curve. + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + See Also + -------- + spline_interpolator + + """ + return spline_interpolator(keyframes, degree=3) + + +def step_interpolator(keyframes): + """Step interpolator for keyframes. + + This is a simple step interpolator to be used for any shape of + keyframes data. + + Parameters + ---------- + keyframes: dict + Keyframe data containing timestamps and values to form the spline + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + """ + timestamps = get_timestamps_from_keyframes(keyframes) + + def interpolate(t): + previous_t = get_previous_timestamp(timestamps, t, include_last=True) + return keyframes.get(previous_t).get("value") + + return interpolate + + +def linear_interpolator(keyframes): + """Linear interpolator for keyframes. + + This is a general linear interpolator to be used for any shape of + keyframes data. + + Parameters + ---------- + keyframes: dict + Keyframe data to be linearly interpolated. + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + """ + timestamps = get_timestamps_from_keyframes(keyframes) + is_single = len(keyframes) == 1 + + def interpolate(t): + if is_single: + t = timestamps[0] + return keyframes.get(t).get("value") + t0 = get_previous_timestamp(timestamps, t) + t1 = get_next_timestamp(timestamps, t) + p0 = keyframes.get(t0).get("value") + p1 = keyframes.get(t1).get("value") + return lerp(p0, p1, t0, t1, t) + + return interpolate + + +def cubic_bezier_interpolator(keyframes): + """Cubic Bézier interpolator for keyframes. + + This is a general cubic Bézier interpolator to be used for any shape of + keyframes data. + + Parameters + ---------- + keyframes : dict + Keyframes to be interpolated at any time. + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + Notes + ----- + If no control points are set in the keyframes, The cubic + Bézier interpolator will almost behave as a linear interpolator. + + """ + timestamps = get_timestamps_from_keyframes(keyframes) + + for ts in timestamps: + # keyframe at timestamp + kf_ts = keyframes.get(ts) + if kf_ts.get("in_cp") is None: + kf_ts["in_cp"] = kf_ts.get("value") + + if kf_ts.get("out_cp") is None: + kf_ts["out_cp"] = kf_ts.get("value") + + def interpolate(t): + t0 = get_previous_timestamp(timestamps, t) + t1 = get_next_timestamp(timestamps, t) + k0 = keyframes.get(t0) + k1 = keyframes.get(t1) + p0 = k0.get("value") + p1 = k0.get("out_cp") + p2 = k1.get("in_cp") + p3 = k1.get("value") + dt = get_time_tau(t, t0, t1) + val = ( + (1 - dt) ** 3 * p0 + + 3 * (1 - dt) ** 2 * dt * p1 + + 3 * (1 - dt) * dt**2 * p2 + + dt**3 * p3 + ) + return val + + return interpolate + + +def slerp(keyframes): + """Spherical based rotation keyframes interpolator. + + A rotation interpolator to be used for rotation keyframes. + + Parameters + ---------- + keyframes : dict + Rotation keyframes to be interpolated at any time. + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + Notes + ----- + Rotation keyframes must be in the form of quaternions. + + """ + timestamps = get_timestamps_from_keyframes(keyframes) + + quat_rots = [] + for ts in timestamps: + quat_rots.append(keyframes.get(ts).get("value")) + rotations = transform.Rotation.from_quat(quat_rots) + # if only one keyframe specified, linear interpolator is used. + if len(timestamps) == 1: + return linear_interpolator(keyframes) + slerp_interp = transform.Slerp(timestamps, rotations) + min_t = timestamps[0] + max_t = timestamps[-1] + + def interpolate(t): + t = min_t if t < min_t else max_t if t > max_t else t + v = slerp_interp(t) + q = v.as_quat() + return q + + return interpolate + + +def color_interpolator(keyframes, rgb2space, space2rgb): + """Custom-space color interpolator. + + Interpolate values linearly inside a custom color space. + + Parameters + ---------- + keyframes : dict + Rotation keyframes to be interpolated at any time. + rgb2space: function + A functions that take color value in rgb and return that color + converted to the targeted space. + space2rgb: function + A functions that take color value in the targeted space and returns + that color in rgb space. + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + """ + timestamps = get_timestamps_from_keyframes(keyframes) + space_keyframes = {} + is_single = len(keyframes) == 1 + for ts, keyframe in keyframes.items(): + space_keyframes[ts] = rgb2space(keyframe.get("value")) + + def interpolate(t): + if is_single: + t = timestamps[0] + return keyframes.get(t).get("value") + t0 = get_previous_timestamp(timestamps, t) + t1 = get_next_timestamp(timestamps, t) + c0 = space_keyframes.get(t0) + c1 = space_keyframes.get(t1) + space_color_val = lerp(c0, c1, t0, t1, t) + return space2rgb(space_color_val) + + return interpolate + + +def hsv_color_interpolator(keyframes): + """HSV interpolator for color keyframes + + See Also + -------- + color_interpolator + + """ + return color_interpolator(keyframes, rgb2hsv, hsv2rgb) + + +def lab_color_interpolator(keyframes): + """LAB interpolator for color keyframes + + See Also + -------- + color_interpolator + + """ + return color_interpolator(keyframes, rgb2lab, lab2rgb) + + +def xyz_color_interpolator(keyframes): + """XYZ interpolator for color keyframes + + See Also + -------- + color_interpolator + + """ + return color_interpolator(keyframes, rgb2xyz, xyz2rgb) + + +def tan_cubic_spline_interpolator(keyframes): + """Cubic spline interpolator for keyframes using tangents. + glTF contains additional tangent information for the cubic spline + interpolator. + + Parameters + ---------- + keyframes: dict + Keyframe data containing timestamps and values to form the cubic spline + curve. + + Returns + ------- + function + The interpolation function that take time and return interpolated + value at that time. + + """ + timestamps = get_timestamps_from_keyframes(keyframes) + for time in keyframes: + data = keyframes.get(time) + value = data.get("value") + if data.get("in_tangent") is None: + data["in_tangent"] = np.zeros_like(value) + if data.get("in_tangent") is None: + data["in_tangent"] = np.zeros_like(value) + + def interpolate(t): + t0 = get_previous_timestamp(timestamps, t) + t1 = get_next_timestamp(timestamps, t) + + dt = get_time_tau(t, t0, t1) + + time_delta = t1 - t0 + + p0 = keyframes.get(t0).get("value") + tan_0 = keyframes.get(t0).get("out_tangent") * time_delta + p1 = keyframes.get(t1).get("value") + tan_1 = keyframes.get(t1).get("in_tangent") * time_delta + # cubic spline equation using tangents + t2 = dt * dt + t3 = t2 * dt + return ( + (2 * t3 - 3 * t2 + 1) * p0 + + (t3 - 2 * t2 + dt) * tan_0 + + (-2 * t3 + 3 * t2) * p1 + + (t3 - t2) * tan_1 + ) + + return interpolate diff --git a/fury/animation/tests/test_animation.py b/fury/animation/tests/test_animation.py index 207e94a613..54aada2bc1 100644 --- a/fury/animation/tests/test_animation.py +++ b/fury/animation/tests/test_animation.py @@ -1,117 +1,117 @@ -import numpy as np -import numpy.testing as npt - -from fury import actor -from fury.animation import Animation, CameraAnimation -from fury.animation.interpolator import ( - cubic_bezier_interpolator, - cubic_spline_interpolator, - linear_interpolator, - spline_interpolator, - step_interpolator, -) -from fury.lib import Camera - - -def assert_not_equal(x, y): - npt.assert_equal(np.any(np.not_equal(x, y)), True) - - -def test_animation(): - shaders = False - anim = Animation() - - cube_actor = actor.cube(np.array([[0, 0, 0]])) - anim.add(cube_actor) - - assert cube_actor in anim.actors - assert cube_actor not in anim.static_actors - - anim.add_static_actor(cube_actor) - assert cube_actor in anim.static_actors - - anim = Animation(cube_actor) - assert cube_actor in anim.actors - - anim_main = Animation() - anim_main.add_child_animation(anim) - assert anim in anim_main.child_animations - - anim = Animation(cube_actor) - anim.set_position(0, np.array([1, 1, 1])) - # overriding a keyframe - anim.set_position(0, np.array([0, 0, 0])) - anim.set_position(3, np.array([2, 2, 2])) - anim.set_position(5, np.array([3, 15, 2])) - anim.set_position(7, np.array([4, 2, 20])) - - anim.set_opacity(0, 0) - anim.set_opacity(7, 1) - - anim.set_rotation(0, np.array([90, 0, 0])) - anim.set_rotation(7, np.array([0, 180, 0])) - - anim.set_scale(0, np.array([1, 1, 1])) - anim.set_scale(7, np.array([5, 5, 5])) - - anim.set_color(0, np.array([1, 0, 1])) - - npt.assert_almost_equal(anim.get_position(0), np.array([0, 0, 0])) - npt.assert_almost_equal(anim.get_position(7), np.array([4, 2, 20])) - - anim.set_position_interpolator(linear_interpolator) - anim.set_position_interpolator(cubic_bezier_interpolator) - anim.set_position_interpolator(step_interpolator) - anim.set_position_interpolator(cubic_spline_interpolator) - anim.set_position_interpolator(spline_interpolator, degree=2) - anim.set_rotation_interpolator(step_interpolator) - anim.set_scale_interpolator(linear_interpolator) - anim.set_opacity_interpolator(step_interpolator) - anim.set_color_interpolator(linear_interpolator) - - npt.assert_almost_equal(anim.get_position(0), np.array([0, 0, 0])) - npt.assert_almost_equal(anim.get_position(7), np.array([4, 2, 20])) - - npt.assert_almost_equal(anim.get_color(7), np.array([1, 0, 1])) - anim.set_color(25, np.array([0.2, 0.2, 0.5])) - assert_not_equal(anim.get_color(7), np.array([1, 0, 1])) - assert_not_equal(anim.get_color(25), np.array([0.2, 0.2, 0.5])) - - cube = actor.cube(np.array([[0, 0, 0]])) - anim.add_actor(cube) - anim.update_animation(0) - if not shaders: - transform = cube.GetUserTransform() - npt.assert_almost_equal(anim.get_position(0), transform.GetPosition()) - npt.assert_almost_equal(anim.get_scale(0), transform.GetScale()) - npt.assert_almost_equal(anim.get_rotation(0), transform.GetOrientation()) - - -def test_camera_animation(): - cam = Camera() - anim = CameraAnimation(cam) - - assert anim.camera is cam - - anim.set_position(0, [1, 2, 3]) - anim.set_position(3, [3, 2, 1]) - - anim.set_focal(0, [10, 20, 30]) - anim.set_focal(3, [30, 20, 10]) - - anim.set_rotation(0, np.array([180, 0, 0])) - - anim.update_animation(0) - npt.assert_almost_equal(cam.GetPosition(), np.array([1, 2, 3])) - npt.assert_almost_equal(cam.GetFocalPoint(), np.array([10, 20, 30])) - anim.update_animation(3) - npt.assert_almost_equal(cam.GetPosition(), np.array([3, 2, 1])) - npt.assert_almost_equal(cam.GetFocalPoint(), np.array([30, 20, 10])) - anim.update_animation(1.5) - npt.assert_almost_equal(cam.GetPosition(), np.array([2, 2, 2])) - npt.assert_almost_equal(cam.GetFocalPoint(), np.array([20, 20, 20])) - rot = np.zeros(16) - matrix = cam.GetModelTransformMatrix() - matrix.DeepCopy(rot.ravel(), matrix) - expected = np.array([[1, 0, 0, 0], [0, -1, 0, 4], [0, 0, -1, 2], [0, 0, 0, 1]]) - npt.assert_almost_equal(expected, rot.reshape([4, 4])) +import numpy as np +import numpy.testing as npt + +from fury import actor +from fury.animation import Animation, CameraAnimation +from fury.animation.interpolator import ( + cubic_bezier_interpolator, + cubic_spline_interpolator, + linear_interpolator, + spline_interpolator, + step_interpolator, +) +from fury.lib import Camera + + +def assert_not_equal(x, y): + npt.assert_equal(np.any(np.not_equal(x, y)), True) + + +def test_animation(): + shaders = False + anim = Animation() + + cube_actor = actor.cube(np.array([[0, 0, 0]])) + anim.add(cube_actor) + + assert cube_actor in anim.actors + assert cube_actor not in anim.static_actors + + anim.add_static_actor(cube_actor) + assert cube_actor in anim.static_actors + + anim = Animation(actors=cube_actor) + assert cube_actor in anim.actors + + anim_main = Animation() + anim_main.add_child_animation(anim) + assert anim in anim_main.child_animations + + anim = Animation(actors=cube_actor) + anim.set_position(0, np.array([1, 1, 1])) + # overriding a keyframe + anim.set_position(0, np.array([0, 0, 0])) + anim.set_position(3, np.array([2, 2, 2])) + anim.set_position(5, np.array([3, 15, 2])) + anim.set_position(7, np.array([4, 2, 20])) + + anim.set_opacity(0, 0) + anim.set_opacity(7, 1) + + anim.set_rotation(0, np.array([90, 0, 0])) + anim.set_rotation(7, np.array([0, 180, 0])) + + anim.set_scale(0, np.array([1, 1, 1])) + anim.set_scale(7, np.array([5, 5, 5])) + + anim.set_color(0, np.array([1, 0, 1])) + + npt.assert_almost_equal(anim.get_position(0), np.array([0, 0, 0])) + npt.assert_almost_equal(anim.get_position(7), np.array([4, 2, 20])) + + anim.set_position_interpolator(linear_interpolator) + anim.set_position_interpolator(cubic_bezier_interpolator) + anim.set_position_interpolator(step_interpolator) + anim.set_position_interpolator(cubic_spline_interpolator) + anim.set_position_interpolator(spline_interpolator, degree=2) + anim.set_rotation_interpolator(step_interpolator) + anim.set_scale_interpolator(linear_interpolator) + anim.set_opacity_interpolator(step_interpolator) + anim.set_color_interpolator(linear_interpolator) + + npt.assert_almost_equal(anim.get_position(0), np.array([0, 0, 0])) + npt.assert_almost_equal(anim.get_position(7), np.array([4, 2, 20])) + + npt.assert_almost_equal(anim.get_color(7), np.array([1, 0, 1])) + anim.set_color(25, np.array([0.2, 0.2, 0.5])) + assert_not_equal(anim.get_color(7), np.array([1, 0, 1])) + assert_not_equal(anim.get_color(25), np.array([0.2, 0.2, 0.5])) + + cube = actor.cube(np.array([[0, 0, 0]])) + anim.add_actor(cube) + anim.update_animation(time=0) + if not shaders: + transform = cube.GetUserTransform() + npt.assert_almost_equal(anim.get_position(0), transform.GetPosition()) + npt.assert_almost_equal(anim.get_scale(0), transform.GetScale()) + npt.assert_almost_equal(anim.get_rotation(0), transform.GetOrientation()) + + +def test_camera_animation(): + cam = Camera() + anim = CameraAnimation(camera=cam) + + assert anim.camera is cam + + anim.set_position(0, [1, 2, 3]) + anim.set_position(3, [3, 2, 1]) + + anim.set_focal(0, [10, 20, 30]) + anim.set_focal(3, [30, 20, 10]) + + anim.set_rotation(0, np.array([180, 0, 0])) + + anim.update_animation(time=0) + npt.assert_almost_equal(cam.GetPosition(), np.array([1, 2, 3])) + npt.assert_almost_equal(cam.GetFocalPoint(), np.array([10, 20, 30])) + anim.update_animation(time=3) + npt.assert_almost_equal(cam.GetPosition(), np.array([3, 2, 1])) + npt.assert_almost_equal(cam.GetFocalPoint(), np.array([30, 20, 10])) + anim.update_animation(time=1.5) + npt.assert_almost_equal(cam.GetPosition(), np.array([2, 2, 2])) + npt.assert_almost_equal(cam.GetFocalPoint(), np.array([20, 20, 20])) + rot = np.zeros(16) + matrix = cam.GetModelTransformMatrix() + matrix.DeepCopy(rot.ravel(), matrix) + expected = np.array([[1, 0, 0, 0], [0, -1, 0, 4], [0, 0, -1, 2], [0, 0, 0, 1]]) + npt.assert_almost_equal(expected, rot.reshape([4, 4])) diff --git a/fury/animation/tests/test_helpers.py b/fury/animation/tests/test_helpers.py index 3b5daf4f24..022d30f6a0 100644 --- a/fury/animation/tests/test_helpers.py +++ b/fury/animation/tests/test_helpers.py @@ -1,113 +1,113 @@ -import numpy as np -import numpy.testing as npt - -import fury.animation.helpers as helpers -import fury.testing as ft - - -def test_get_timestamps_from_keyframes(): - keyframes = { - 0: {"value": np.array([0, 0, 0])}, - 1: {"value": np.array([1, 0, 0])}, - 2: {"value": np.array([2, 0, 0])}, - } - # Test `get_timestamps_from_keyframes` - timestamps = helpers.get_timestamps_from_keyframes(keyframes) - npt.assert_equal(len(timestamps), len(keyframes)) - npt.assert_equal(np.array(sorted(keyframes.keys())), timestamps) - - -def test_lerp(): - v0 = np.array([0, 0, 0, 0, 0]) - v1 = np.array([1, 1, 1, 1, 1]) - t0 = 1 - t1 = 2 - for t in range(-100, 100, 1): - t /= 10 - # lerp - interp_value = helpers.lerp(v0, v1, t0, t1, t) - npt.assert_array_equal( - helpers.get_time_tau(t, t0, t1) * (v1 - v0) + v0, interp_value - ) - npt.assert_array_equal(helpers.lerp(v0, v1, t0, t1, t0), v0) - npt.assert_array_equal(helpers.lerp(v0, v1, t0, t1, t1), v1) - - -def test_get_values_from_keyframes(): - keyframes = { - 0: {"value": np.array([0, 0, 0])}, - 1: {"value": np.array([1, 0, 0])}, - 2: {"value": np.array([2, 0, 0])}, - } - values = helpers.get_values_from_keyframes(keyframes) - npt.assert_array_equal(values, np.array([i["value"] for i in keyframes.values()])) - - values = helpers.get_values_from_keyframes({}) - npt.assert_array_equal(values, np.array([])) - - values = helpers.get_values_from_keyframes({1: {}}) - npt.assert_array_equal(values, np.array([None])) - - -def test_get_next_timestamp(): - timestamps = np.array([1, 2, 3, 4, 5, 6]) - for t in range(-100, 100, 1): - t /= 10 - next_ts = helpers.get_next_timestamp(timestamps, t) - npt.assert_(next_ts in timestamps, "Timestamp is not valid") - ft.assert_greater_equal(next_ts, min(max(timestamps), t)) - next_ts_2 = helpers.get_next_timestamp(timestamps, t, include_first=True) - ft.assert_less_equal(next_ts_2, next_ts) - npt.assert_(next_ts_2 in timestamps, "Timestamp is not valid") - - ts = helpers.get_next_timestamp(timestamps, 0.5, include_first=False) - ft.assert_equal(ts, 2) - ts = helpers.get_next_timestamp(timestamps, 0.5, include_first=True) - ft.assert_equal(ts, 1) - - -def test_get_previous_timestamp(): - timestamps = np.array([1, 2, 3, 4, 5, 6]) - for t in range(-100, 100, 1): - t /= 10 - previous_ts = helpers.get_previous_timestamp(timestamps, t) - npt.assert_(previous_ts in timestamps, "Timestamp is not valid") - ft.assert_less_equal(previous_ts, max(min(timestamps), t)) - previous_ts_2 = helpers.get_previous_timestamp(timestamps, t, include_last=True) - ft.assert_greater_equal(previous_ts_2, previous_ts) - npt.assert_(previous_ts_2 in timestamps, "Timestamp is not valid") - - ts = helpers.get_previous_timestamp(timestamps, 5.5, include_last=False) - ft.assert_equal(ts, 5) - ts = helpers.get_previous_timestamp(timestamps, 5.5, include_last=True) - ft.assert_equal(ts, 5) - ts = helpers.get_previous_timestamp(timestamps, 7, include_last=False) - ft.assert_equal(ts, 5) - ts = helpers.get_previous_timestamp(timestamps, 7, include_last=True) - ft.assert_equal(ts, 6) - - -def test_get_time_tau(): - t0 = 5 - t1 = 20 - for t in range(-100, 100, 1): - t /= 10 - tt = helpers.get_time_tau(t, t0, t1) - ft.assert_greater_equal(tt, 0) - ft.assert_less_equal(tt, 1) - ft.assert_equal(helpers.get_time_tau(5, 5, 20), 0) - ft.assert_equal(helpers.get_time_tau(20, 5, 20), 1) - ft.assert_equal(helpers.get_time_tau(14, 5, 20), 0.6) - ft.assert_equal(helpers.get_time_tau(1.5, 1, 2), 0.5) - - -def test_euclidean_distances(): - points = [ - np.array([0, 0, 0]), - np.array([1, 0, 0]), - np.array([2, 0, 0]), - np.array([0, 0, 0]), - ] - distance = helpers.euclidean_distances(points) - expected_distances = np.array([1, 1, 2]) - npt.assert_equal(distance, expected_distances) +import numpy as np +import numpy.testing as npt + +import fury.animation.helpers as helpers +import fury.testing as ft + + +def test_get_timestamps_from_keyframes(): + keyframes = { + 0: {"value": np.array([0, 0, 0])}, + 1: {"value": np.array([1, 0, 0])}, + 2: {"value": np.array([2, 0, 0])}, + } + # Test `get_timestamps_from_keyframes` + timestamps = helpers.get_timestamps_from_keyframes(keyframes) + npt.assert_equal(len(timestamps), len(keyframes)) + npt.assert_equal(np.array(sorted(keyframes.keys())), timestamps) + + +def test_lerp(): + v0 = np.array([0, 0, 0, 0, 0]) + v1 = np.array([1, 1, 1, 1, 1]) + t0 = 1 + t1 = 2 + for t in range(-100, 100, 1): + t /= 10 + # lerp + interp_value = helpers.lerp(v0, v1, t0, t1, t) + npt.assert_array_equal( + helpers.get_time_tau(t, t0, t1) * (v1 - v0) + v0, interp_value + ) + npt.assert_array_equal(helpers.lerp(v0, v1, t0, t1, t0), v0) + npt.assert_array_equal(helpers.lerp(v0, v1, t0, t1, t1), v1) + + +def test_get_values_from_keyframes(): + keyframes = { + 0: {"value": np.array([0, 0, 0])}, + 1: {"value": np.array([1, 0, 0])}, + 2: {"value": np.array([2, 0, 0])}, + } + values = helpers.get_values_from_keyframes(keyframes) + npt.assert_array_equal(values, np.array([i["value"] for i in keyframes.values()])) + + values = helpers.get_values_from_keyframes({}) + npt.assert_array_equal(values, np.array([])) + + values = helpers.get_values_from_keyframes({1: {}}) + npt.assert_array_equal(values, np.array([None])) + + +def test_get_next_timestamp(): + timestamps = np.array([1, 2, 3, 4, 5, 6]) + for t in range(-100, 100, 1): + t /= 10 + next_ts = helpers.get_next_timestamp(timestamps, t) + npt.assert_(next_ts in timestamps, "Timestamp is not valid") + ft.assert_greater_equal(next_ts, min(max(timestamps), t)) + next_ts_2 = helpers.get_next_timestamp(timestamps, t, include_first=True) + ft.assert_less_equal(next_ts_2, next_ts) + npt.assert_(next_ts_2 in timestamps, "Timestamp is not valid") + + ts = helpers.get_next_timestamp(timestamps, 0.5, include_first=False) + ft.assert_equal(ts, 2) + ts = helpers.get_next_timestamp(timestamps, 0.5, include_first=True) + ft.assert_equal(ts, 1) + + +def test_get_previous_timestamp(): + timestamps = np.array([1, 2, 3, 4, 5, 6]) + for t in range(-100, 100, 1): + t /= 10 + previous_ts = helpers.get_previous_timestamp(timestamps, t) + npt.assert_(previous_ts in timestamps, "Timestamp is not valid") + ft.assert_less_equal(previous_ts, max(min(timestamps), t)) + previous_ts_2 = helpers.get_previous_timestamp(timestamps, t, include_last=True) + ft.assert_greater_equal(previous_ts_2, previous_ts) + npt.assert_(previous_ts_2 in timestamps, "Timestamp is not valid") + + ts = helpers.get_previous_timestamp(timestamps, 5.5, include_last=False) + ft.assert_equal(ts, 5) + ts = helpers.get_previous_timestamp(timestamps, 5.5, include_last=True) + ft.assert_equal(ts, 5) + ts = helpers.get_previous_timestamp(timestamps, 7, include_last=False) + ft.assert_equal(ts, 5) + ts = helpers.get_previous_timestamp(timestamps, 7, include_last=True) + ft.assert_equal(ts, 6) + + +def test_get_time_tau(): + t0 = 5 + t1 = 20 + for t in range(-100, 100, 1): + t /= 10 + tt = helpers.get_time_tau(t, t0, t1) + ft.assert_greater_equal(tt, 0) + ft.assert_less_equal(tt, 1) + ft.assert_equal(helpers.get_time_tau(5, 5, 20), 0) + ft.assert_equal(helpers.get_time_tau(20, 5, 20), 1) + ft.assert_equal(helpers.get_time_tau(14, 5, 20), 0.6) + ft.assert_equal(helpers.get_time_tau(1.5, 1, 2), 0.5) + + +def test_euclidean_distances(): + points = [ + np.array([0, 0, 0]), + np.array([1, 0, 0]), + np.array([2, 0, 0]), + np.array([0, 0, 0]), + ] + distance = helpers.euclidean_distances(points) + expected_distances = np.array([1, 1, 2]) + npt.assert_equal(distance, expected_distances) diff --git a/fury/animation/tests/test_interpolators.py b/fury/animation/tests/test_interpolators.py index 5856676d29..c4ad3fee18 100644 --- a/fury/animation/tests/test_interpolators.py +++ b/fury/animation/tests/test_interpolators.py @@ -1,353 +1,353 @@ -from itertools import combinations - -import numpy as np -import numpy.testing as npt - -from fury.animation.interpolator import ( - cubic_bezier_interpolator, - cubic_spline_interpolator, - hsv_color_interpolator, - lab_color_interpolator, - linear_interpolator, - slerp, - spline_interpolator, - step_interpolator, - xyz_color_interpolator, -) - - -def assert_not_equal(x, y): - npt.assert_equal(np.any(np.not_equal(x, y)), True) - - -def test_step_interpolator(): - data = { - 1: {"value": np.array([1, 2, 3])}, - 2: {"value": np.array([0, 0, 0])}, - 3: {"value": np.array([5, 5, 5])}, - } - - interpolator = step_interpolator(data) - - pos1 = interpolator(2) - pos2 = interpolator(2.9) - npt.assert_equal(pos1, pos2) - - pos3 = interpolator(3) - assert_not_equal(pos3, pos2) - - pos_initial = interpolator(1) - pos_final = interpolator(3) - - # test when time exceeds or precedes the interpolation range - npt.assert_equal(interpolator(-999), pos_initial) - npt.assert_equal(interpolator(999), pos_final) - - for t in range(-10, 40, 1): - npt.assert_equal(interpolator(t / 10).shape, data.get(1).get("value").shape) - - for ts in data.keys(): - npt.assert_equal(interpolator(ts), data.get(ts).get("value")) - - interp = step_interpolator({}) - try: - interp(1) - raise Exception("This shouldn't work since no keyframes were provided!") - except IndexError: - ... - - data = {1: {"value": np.array([1, 2, 3])}} - interp = step_interpolator(data) - npt.assert_equal(interp(-100), np.array([1, 2, 3])) - npt.assert_equal(interp(100), np.array([1, 2, 3])) - - data = {1: {"value": None}} - interp = step_interpolator(data) - npt.assert_equal(interp(-100), None) - - -def test_linear_interpolator(): - data = { - 1: {"value": np.array([1, 2, 3])}, - 2: {"value": np.array([0, 0, 0])}, - 3: {"value": np.array([5, 5, 5])}, - } - - interpolator = linear_interpolator(data) - - pos1 = interpolator(2) - pos2 = interpolator(2.1) - assert_not_equal(pos1, pos2) - - npt.assert_equal(pos1, data.get(2).get("value")) - - for ts in data.keys(): - npt.assert_equal(interpolator(ts), data.get(ts).get("value")) - - for t in range(-10, 40, 1): - npt.assert_equal(interpolator(t / 10).shape, data.get(1).get("value").shape) - - pos_initial = interpolator(1) - pos_final = interpolator(3) - - # test when time exceeds or precedes the interpolation range - npt.assert_equal(interpolator(-999), pos_initial) - npt.assert_equal(interpolator(999), pos_final) - - interp = linear_interpolator({}) - try: - interp(1) - raise Exception("This shouldn't work since no keyframes were provided!") - except IndexError: - ... - - data = {1: {"value": np.array([1, 2, 3])}} - interp = linear_interpolator(data) - npt.assert_equal(interp(-100), np.array([1, 2, 3])) - npt.assert_equal(interp(100), np.array([1, 2, 3])) - - data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} - interp = linear_interpolator(data) - try: - interp(1) - raise Exception("This shouldn't work since invalid keyframes were provided!") - except TypeError: - ... - - -def test_cubic_spline_interpolator(): - data = { - 1: {"value": np.array([1, 2, 3])}, - 2: {"value": np.array([0, 0, 0])}, - 3: {"value": np.array([5, 5, 5])}, - 4: {"value": np.array([7, 7, 7])}, - } - - interpolator = cubic_spline_interpolator(data) - - pos1 = interpolator(2) - npt.assert_almost_equal(pos1, data.get(2).get("value")) - - for ts in data.keys(): - npt.assert_almost_equal(interpolator(ts), data.get(ts).get("value")) - - for t in range(-10, 40, 1): - npt.assert_almost_equal( - interpolator(t / 10).shape, data.get(1).get("value").shape - ) - - pos_initial = interpolator(1) - pos_final = interpolator(4) - - # test when time exceeds or precedes the interpolation range - npt.assert_almost_equal(interpolator(-999), pos_initial) - npt.assert_almost_equal(interpolator(999), pos_final) - - try: - cubic_spline_interpolator({}) - raise Exception("At least 4 keyframes must be provided!") - except ValueError: - ... - - data = { - 1: {"value": None}, - 2: {"value": np.array([1, 1, 1])}, - 3: {"value": None}, - 4: {"value": None}, - } - - # Interpolator should not work with invalid data! - with npt.assert_raises((ValueError, TypeError)): - cubic_spline_interpolator(data) - - -def test_cubic_bezier_interpolator(): - data_1 = {1: {"value": np.array([-2, 0, 0])}, 2: {"value": np.array([18, 0, 0])}} - - data_2 = { - 1: {"value": np.array([-2, 0, 0]), "out_cp": np.array([-15, 6, 0])}, - 2: {"value": np.array([18, 0, 0]), "in_cp": np.array([27, 18, 0])}, - } - - # with control points - interp_1 = cubic_bezier_interpolator(data_1) - # without control points - interp_2 = cubic_bezier_interpolator(data_2) - # linear interpolator - interp_linear = linear_interpolator(data_2) - - assert_not_equal(interp_1(1.5), interp_2(1.5)) - - npt.assert_equal(interp_1(1.5), interp_linear(1.5)) - assert_not_equal(interp_1(1.2), interp_linear(1.2)) - assert_not_equal(interp_2(1.5), interp_linear(1.5)) - - # start and end points - npt.assert_equal(interp_1(1), interp_2(1)) - npt.assert_equal(interp_1(2), interp_2(2)) - - for ts in data_1.keys(): - expected = data_1.get(ts).get("value") - npt.assert_almost_equal(interp_1(ts), expected) - npt.assert_almost_equal(interp_2(ts), expected) - - for t in range(-10, 40, 1): - npt.assert_almost_equal( - interp_1(t / 10).shape, data_1.get(1).get("value").shape - ) - - pos_initial = interp_1(1) - pos_final = interp_2(2) - - # test when time exceeds or precedes the interpolation range - npt.assert_almost_equal(interp_1(-999), pos_initial) - npt.assert_almost_equal(interp_2(-999), pos_initial) - - npt.assert_almost_equal(interp_1(999), pos_final) - npt.assert_almost_equal(interp_2(999), pos_final) - - interp = cubic_bezier_interpolator({}) - - try: - interp(1) - raise Exception("This shouldn't work since no keyframes were provided!") - except IndexError: - ... - - data = {1: {"value": np.array([1, 2, 3])}} - interp = cubic_bezier_interpolator(data) - npt.assert_equal(interp(-10), np.array([1, 2, 3])) - npt.assert_equal(interp(100), np.array([1, 2, 3])) - - data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} - interp = cubic_bezier_interpolator(data) - try: - interp(1) - raise Exception("This shouldn't work since no keyframes were provided!") - except TypeError: - ... - - -def test_n_spline_interpolator(): - data = {i: {"value": np.random.random(3) * 10} for i in range(10)} - - interps = [spline_interpolator(data, degree=i) for i in range(1, 6)] - - for i in interps: - npt.assert_equal(i(-999), i(0)) - npt.assert_equal(i(999), i(10)) - for t in range(10): - npt.assert_almost_equal(i(t), data.get(t).get("value")) - for t in range(-100, 100, 1): - i(t / 10) - try: - spline_interpolator({}, 5) - raise Exception("At least 6 keyframes must be provided!") - except ValueError: - ... - - data = { - 1: {"value": None}, - 2: {"value": np.array([1, 1, 1])}, - 3: {"value": None}, - 4: {"value": None}, - } - - # Interpolator should not work with invalid data! - with npt.assert_raises((TypeError, ValueError)): - spline_interpolator(data, 2) - - -def test_color_interpolators(): - data = {1: {"value": np.array([1, 0.5, 0])}, 2: {"value": np.array([0.5, 0, 1])}} - - color_interps = [ - hsv_color_interpolator(data), - linear_interpolator(data), - lab_color_interpolator(data), - xyz_color_interpolator(data), - ] - - for interp in color_interps: - npt.assert_almost_equal(interp(-999), interp(1)) - npt.assert_almost_equal(interp(999), interp(2)) - - for interps in combinations(color_interps, 2): - for timestamp in data.keys(): - npt.assert_almost_equal(interps[0](timestamp), interps[1](timestamp)) - # excluded main keyframes - for timestamp in range(101, 200, 1): - timestamp /= 100 - assert_not_equal(interps[0](timestamp), interps[1](timestamp)) - color_interps_functions = [ - hsv_color_interpolator, - linear_interpolator, - lab_color_interpolator, - xyz_color_interpolator, - ] - for interpolator in color_interps_functions: - interp = interpolator({}) - try: - interp(1) - raise Exception("This shouldn't work since no keyframes were provided!") - except IndexError: - ... - - data = {1: {"value": np.array([1, 2, 3])}} - interp = interpolator(data) - npt.assert_equal(interp(-10), np.array([1, 2, 3])) - npt.assert_equal(interp(10), np.array([1, 2, 3])) - - data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} - try: - interpolator(data) - msg = "This shouldn't work since invalid keyframes " - msg += "were provided! and hence can't be converted to" - msg += "targeted color space." - raise msg - except ( - TypeError, - AttributeError, - ): - ... - - -def test_slerp(): - data = { - 1: {"value": np.array([0, 0, 0, 1])}, - 2: {"value": np.array([0, 0.7071068, 0, 0.7071068])}, - } - - interp_slerp = slerp(data) - interp_lerp = linear_interpolator(data) - - npt.assert_equal(interp_slerp(-999), interp_slerp(1)) - npt.assert_equal(interp_slerp(999), interp_slerp(2)) - - npt.assert_almost_equal(interp_slerp(1), interp_lerp(1)) - npt.assert_almost_equal(interp_slerp(2), interp_lerp(2)) - assert_not_equal(interp_slerp(1.5), interp_lerp(1.5)) - - for timestamp in range(-100, 100, 1): - timestamp /= 10 - interp_slerp(timestamp) - - try: - interp = slerp({}) - interp(1) - raise Exception("This shouldn't work since no keyframes were provided!") - except ValueError: - ... - - data = {1: {"value": np.array([1, 2, 3, 1])}} - interp = slerp(data) - npt.assert_equal(interp(-100), np.array([1, 2, 3, 1])) - npt.assert_equal(interp(100), np.array([1, 2, 3, 1])) - - data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} - try: - interp = slerp(data) - interp(1) - raise Exception("This shouldn't work since invalid keyframes were provided!") - except ValueError: - ... +from itertools import combinations + +import numpy as np +import numpy.testing as npt + +from fury.animation.interpolator import ( + cubic_bezier_interpolator, + cubic_spline_interpolator, + hsv_color_interpolator, + lab_color_interpolator, + linear_interpolator, + slerp, + spline_interpolator, + step_interpolator, + xyz_color_interpolator, +) + + +def assert_not_equal(x, y): + npt.assert_equal(np.any(np.not_equal(x, y)), True) + + +def test_step_interpolator(): + data = { + 1: {"value": np.array([1, 2, 3])}, + 2: {"value": np.array([0, 0, 0])}, + 3: {"value": np.array([5, 5, 5])}, + } + + interpolator = step_interpolator(data) + + pos1 = interpolator(2) + pos2 = interpolator(2.9) + npt.assert_equal(pos1, pos2) + + pos3 = interpolator(3) + assert_not_equal(pos3, pos2) + + pos_initial = interpolator(1) + pos_final = interpolator(3) + + # test when time exceeds or precedes the interpolation range + npt.assert_equal(interpolator(-999), pos_initial) + npt.assert_equal(interpolator(999), pos_final) + + for t in range(-10, 40, 1): + npt.assert_equal(interpolator(t / 10).shape, data.get(1).get("value").shape) + + for ts in data.keys(): + npt.assert_equal(interpolator(ts), data.get(ts).get("value")) + + interp = step_interpolator({}) + try: + interp(1) + raise Exception("This shouldn't work since no keyframes were provided!") + except IndexError: + ... + + data = {1: {"value": np.array([1, 2, 3])}} + interp = step_interpolator(data) + npt.assert_equal(interp(-100), np.array([1, 2, 3])) + npt.assert_equal(interp(100), np.array([1, 2, 3])) + + data = {1: {"value": None}} + interp = step_interpolator(data) + npt.assert_equal(interp(-100), None) + + +def test_linear_interpolator(): + data = { + 1: {"value": np.array([1, 2, 3])}, + 2: {"value": np.array([0, 0, 0])}, + 3: {"value": np.array([5, 5, 5])}, + } + + interpolator = linear_interpolator(data) + + pos1 = interpolator(2) + pos2 = interpolator(2.1) + assert_not_equal(pos1, pos2) + + npt.assert_equal(pos1, data.get(2).get("value")) + + for ts in data.keys(): + npt.assert_equal(interpolator(ts), data.get(ts).get("value")) + + for t in range(-10, 40, 1): + npt.assert_equal(interpolator(t / 10).shape, data.get(1).get("value").shape) + + pos_initial = interpolator(1) + pos_final = interpolator(3) + + # test when time exceeds or precedes the interpolation range + npt.assert_equal(interpolator(-999), pos_initial) + npt.assert_equal(interpolator(999), pos_final) + + interp = linear_interpolator({}) + try: + interp(1) + raise Exception("This shouldn't work since no keyframes were provided!") + except IndexError: + ... + + data = {1: {"value": np.array([1, 2, 3])}} + interp = linear_interpolator(data) + npt.assert_equal(interp(-100), np.array([1, 2, 3])) + npt.assert_equal(interp(100), np.array([1, 2, 3])) + + data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} + interp = linear_interpolator(data) + try: + interp(1) + raise Exception("This shouldn't work since invalid keyframes were provided!") + except TypeError: + ... + + +def test_cubic_spline_interpolator(): + data = { + 1: {"value": np.array([1, 2, 3])}, + 2: {"value": np.array([0, 0, 0])}, + 3: {"value": np.array([5, 5, 5])}, + 4: {"value": np.array([7, 7, 7])}, + } + + interpolator = cubic_spline_interpolator(data) + + pos1 = interpolator(2) + npt.assert_almost_equal(pos1, data.get(2).get("value")) + + for ts in data.keys(): + npt.assert_almost_equal(interpolator(ts), data.get(ts).get("value")) + + for t in range(-10, 40, 1): + npt.assert_almost_equal( + interpolator(t / 10).shape, data.get(1).get("value").shape + ) + + pos_initial = interpolator(1) + pos_final = interpolator(4) + + # test when time exceeds or precedes the interpolation range + npt.assert_almost_equal(interpolator(-999), pos_initial) + npt.assert_almost_equal(interpolator(999), pos_final) + + try: + cubic_spline_interpolator({}) + raise Exception("At least 4 keyframes must be provided!") + except ValueError: + ... + + data = { + 1: {"value": None}, + 2: {"value": np.array([1, 1, 1])}, + 3: {"value": None}, + 4: {"value": None}, + } + + # Interpolator should not work with invalid data! + with npt.assert_raises((ValueError, TypeError)): + cubic_spline_interpolator(data) + + +def test_cubic_bezier_interpolator(): + data_1 = {1: {"value": np.array([-2, 0, 0])}, 2: {"value": np.array([18, 0, 0])}} + + data_2 = { + 1: {"value": np.array([-2, 0, 0]), "out_cp": np.array([-15, 6, 0])}, + 2: {"value": np.array([18, 0, 0]), "in_cp": np.array([27, 18, 0])}, + } + + # with control points + interp_1 = cubic_bezier_interpolator(data_1) + # without control points + interp_2 = cubic_bezier_interpolator(data_2) + # linear interpolator + interp_linear = linear_interpolator(data_2) + + assert_not_equal(interp_1(1.5), interp_2(1.5)) + + npt.assert_equal(interp_1(1.5), interp_linear(1.5)) + assert_not_equal(interp_1(1.2), interp_linear(1.2)) + assert_not_equal(interp_2(1.5), interp_linear(1.5)) + + # start and end points + npt.assert_equal(interp_1(1), interp_2(1)) + npt.assert_equal(interp_1(2), interp_2(2)) + + for ts in data_1.keys(): + expected = data_1.get(ts).get("value") + npt.assert_almost_equal(interp_1(ts), expected) + npt.assert_almost_equal(interp_2(ts), expected) + + for t in range(-10, 40, 1): + npt.assert_almost_equal( + interp_1(t / 10).shape, data_1.get(1).get("value").shape + ) + + pos_initial = interp_1(1) + pos_final = interp_2(2) + + # test when time exceeds or precedes the interpolation range + npt.assert_almost_equal(interp_1(-999), pos_initial) + npt.assert_almost_equal(interp_2(-999), pos_initial) + + npt.assert_almost_equal(interp_1(999), pos_final) + npt.assert_almost_equal(interp_2(999), pos_final) + + interp = cubic_bezier_interpolator({}) + + try: + interp(1) + raise Exception("This shouldn't work since no keyframes were provided!") + except IndexError: + ... + + data = {1: {"value": np.array([1, 2, 3])}} + interp = cubic_bezier_interpolator(data) + npt.assert_equal(interp(-10), np.array([1, 2, 3])) + npt.assert_equal(interp(100), np.array([1, 2, 3])) + + data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} + interp = cubic_bezier_interpolator(data) + try: + interp(1) + raise Exception("This shouldn't work since no keyframes were provided!") + except TypeError: + ... + + +def test_n_spline_interpolator(): + data = {i: {"value": np.random.random(3) * 10} for i in range(10)} + + interps = [spline_interpolator(data, degree=i) for i in range(1, 6)] + + for i in interps: + npt.assert_equal(i(-999), i(0)) + npt.assert_equal(i(999), i(10)) + for t in range(10): + npt.assert_almost_equal(i(t), data.get(t).get("value")) + for t in range(-100, 100, 1): + i(t / 10) + try: + spline_interpolator({}, 5) + raise Exception("At least 6 keyframes must be provided!") + except ValueError: + ... + + data = { + 1: {"value": None}, + 2: {"value": np.array([1, 1, 1])}, + 3: {"value": None}, + 4: {"value": None}, + } + + # Interpolator should not work with invalid data! + with npt.assert_raises((TypeError, ValueError)): + spline_interpolator(data, 2) + + +def test_color_interpolators(): + data = {1: {"value": np.array([1, 0.5, 0])}, 2: {"value": np.array([0.5, 0, 1])}} + + color_interps = [ + hsv_color_interpolator(data), + linear_interpolator(data), + lab_color_interpolator(data), + xyz_color_interpolator(data), + ] + + for interp in color_interps: + npt.assert_almost_equal(interp(-999), interp(1)) + npt.assert_almost_equal(interp(999), interp(2)) + + for interps in combinations(color_interps, 2): + for timestamp in data.keys(): + npt.assert_almost_equal(interps[0](timestamp), interps[1](timestamp)) + # excluded main keyframes + for timestamp in range(101, 200, 1): + timestamp /= 100 + assert_not_equal(interps[0](timestamp), interps[1](timestamp)) + color_interps_functions = [ + hsv_color_interpolator, + linear_interpolator, + lab_color_interpolator, + xyz_color_interpolator, + ] + for interpolator in color_interps_functions: + interp = interpolator({}) + try: + interp(1) + raise Exception("This shouldn't work since no keyframes were provided!") + except IndexError: + ... + + data = {1: {"value": np.array([1, 2, 3])}} + interp = interpolator(data) + npt.assert_equal(interp(-10), np.array([1, 2, 3])) + npt.assert_equal(interp(10), np.array([1, 2, 3])) + + data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} + try: + interpolator(data) + msg = "This shouldn't work since invalid keyframes " + msg += "were provided! and hence can't be converted to" + msg += "targeted color space." + raise msg + except ( + TypeError, + AttributeError, + ): + ... + + +def test_slerp(): + data = { + 1: {"value": np.array([0, 0, 0, 1])}, + 2: {"value": np.array([0, 0.7071068, 0, 0.7071068])}, + } + + interp_slerp = slerp(data) + interp_lerp = linear_interpolator(data) + + npt.assert_equal(interp_slerp(-999), interp_slerp(1)) + npt.assert_equal(interp_slerp(999), interp_slerp(2)) + + npt.assert_almost_equal(interp_slerp(1), interp_lerp(1)) + npt.assert_almost_equal(interp_slerp(2), interp_lerp(2)) + assert_not_equal(interp_slerp(1.5), interp_lerp(1.5)) + + for timestamp in range(-100, 100, 1): + timestamp /= 10 + interp_slerp(timestamp) + + try: + interp = slerp({}) + interp(1) + raise Exception("This shouldn't work since no keyframes were provided!") + except ValueError: + ... + + data = {1: {"value": np.array([1, 2, 3, 1])}} + interp = slerp(data) + npt.assert_equal(interp(-100), np.array([1, 2, 3, 1])) + npt.assert_equal(interp(100), np.array([1, 2, 3, 1])) + + data = {1: {"value": None}, 2: {"value": np.array([1, 1, 1])}} + try: + interp = slerp(data) + interp(1) + raise Exception("This shouldn't work since invalid keyframes were provided!") + except ValueError: + ... diff --git a/fury/animation/tests/test_timeline.py b/fury/animation/tests/test_timeline.py index 79a3301be6..ed17f900ed 100644 --- a/fury/animation/tests/test_timeline.py +++ b/fury/animation/tests/test_timeline.py @@ -1,67 +1,67 @@ -import time - -import numpy as np -import numpy.testing as npt - -from fury.animation import Animation, Timeline -import fury.testing as ft -from fury.ui import PlaybackPanel - - -def assert_not_equal(x, y): - npt.assert_equal(np.any(np.not_equal(x, y)), True) - - -def test_timeline(): - tl = Timeline(playback_panel=True) - - # test playback panel - ft.assert_true(isinstance(tl.playback_panel, PlaybackPanel)) - - for t in [-10, 0, 2.2, 7, 100]: - tl.seek(t) - ft.assert_less_equal(tl.current_timestamp, tl.duration) - ft.assert_greater_equal(tl.current_timestamp, 0) - - ft.assert_greater_equal(tl.current_timestamp, tl.playback_panel.current_time) - - if 0 <= t <= tl.duration: - npt.assert_almost_equal(tl.current_timestamp, t) - # check if seeking a certain time affects the time slider's value. - npt.assert_almost_equal( - tl.current_timestamp, tl.playback_panel.current_time - ) - - tl.play() - t_before = tl.current_timestamp - time.sleep(0.1) - assert_not_equal(tl.current_timestamp, t_before) - ft.assert_true(tl.playing) - - tl.pause() - t_before = tl.current_timestamp - ft.assert_true(tl.paused) - time.sleep(0.1) - npt.assert_almost_equal(tl.current_timestamp, t_before) - - tl.stop() - ft.assert_true(tl.stopped) - npt.assert_almost_equal(tl.current_timestamp, 0) - - length = 8 - tl_2 = Timeline(length=length) - anim = Animation(length=12) - tl_2.add_animation(anim) - assert anim in tl_2.animations - - anim.set_position(12, [1, 2, 1]) - assert tl_2.duration == length - - tl_2 = Timeline(anim, length=11) - assert tl_2.duration == 11 - - tl = Timeline(playback_panel=True) - assert tl.has_playback_panel is True - - tl.loop = True - assert tl.loop is True +import time + +import numpy as np +import numpy.testing as npt + +from fury.animation import Animation, Timeline +import fury.testing as ft +from fury.ui import PlaybackPanel + + +def assert_not_equal(x, y): + npt.assert_equal(np.any(np.not_equal(x, y)), True) + + +def test_timeline(): + tl = Timeline(playback_panel=True) + + # test playback panel + ft.assert_true(isinstance(tl.playback_panel, PlaybackPanel)) + + for t in [-10, 0, 2.2, 7, 100]: + tl.seek(t) + ft.assert_less_equal(tl.current_timestamp, tl.duration) + ft.assert_greater_equal(tl.current_timestamp, 0) + + ft.assert_greater_equal(tl.current_timestamp, tl.playback_panel.current_time) + + if 0 <= t <= tl.duration: + npt.assert_almost_equal(tl.current_timestamp, t) + # check if seeking a certain time affects the time slider's value. + npt.assert_almost_equal( + tl.current_timestamp, tl.playback_panel.current_time + ) + + tl.play() + t_before = tl.current_timestamp + time.sleep(0.1) + assert_not_equal(tl.current_timestamp, t_before) + ft.assert_true(tl.playing) + + tl.pause() + t_before = tl.current_timestamp + ft.assert_true(tl.paused) + time.sleep(0.1) + npt.assert_almost_equal(tl.current_timestamp, t_before) + + tl.stop() + ft.assert_true(tl.stopped) + npt.assert_almost_equal(tl.current_timestamp, 0) + + length = 8 + tl_2 = Timeline(length=length) + anim = Animation(length=12) + tl_2.add_animation(anim) + assert anim in tl_2.animations + + anim.set_position(12, [1, 2, 1]) + assert tl_2.duration == length + + tl_2 = Timeline(animations=anim, length=11) + assert tl_2.duration == 11 + + tl = Timeline(playback_panel=True) + assert tl.has_playback_panel is True + + tl.loop = True + assert tl.loop is True diff --git a/fury/animation/timeline.py b/fury/animation/timeline.py index 7156a809d4..d57825bbef 100644 --- a/fury/animation/timeline.py +++ b/fury/animation/timeline.py @@ -1,494 +1,501 @@ -import os -from time import perf_counter - -from PIL import Image -import numpy as np - -from fury import window -from fury.animation.animation import Animation -from fury.lib import RenderWindow, WindowToImageFilter, numpy_support -from fury.ui.elements import PlaybackPanel - - -class Timeline: - """Keyframe animation Timeline. - - Timeline is responsible for handling the playback of keyframes animations. - It has multiple playback options which makes it easy - to control the playback, speed, state of the animation with/without a GUI - playback panel. - - Attributes - ---------- - animations : Animation or list[Animation], optional, default: None - Actor/s to be animated directly by the Timeline (main Animation). - playback_panel : bool, optional - If True, the timeline will have a playback panel set, which can be used - to control the playback of the timeline. - length : float or int, default: None, optional - the fixed length of the timeline. If set to None, the timeline will get - its length from the animations that it controls automatically. - loop : bool, optional - Whether loop playing the timeline or play once. - - """ - - def __init__(self, animations=None, playback_panel=False, loop=True, length=None): - self._scene = None - self.playback_panel = None - self._current_timestamp = 0 - self._speed = 1.0 - self._last_started_time = 0 - self._playing = False - self._animations = [] - self._loop = loop - self._length = length - self._duration = length if length is not None else 0.0 - - if playback_panel: - - def set_loop(is_loop): - self._loop = is_loop - - def set_speed(speed): - self.speed = speed - - self.playback_panel = PlaybackPanel(loop=self._loop) - self.playback_panel.on_play = self.play - self.playback_panel.on_stop = self.stop - self.playback_panel.on_pause = self.pause - self.playback_panel.on_loop_toggle = set_loop - self.playback_panel.on_progress_bar_changed = self.seek - self.playback_panel.on_speed_changed = set_speed - - if animations is not None: - self.add_animation(animations) - - def update_duration(self): - """Update and return the duration of the Timeline. - - Returns - ------- - float - The duration of the Timeline. - - """ - if self._length is not None: - self._duration = self._length - else: - self._duration = max( - [0.0] + [anim.update_duration() for anim in self._animations] - ) - if self.has_playback_panel: - self.playback_panel.final_time = self.duration - return self.duration - - @property - def duration(self): - """Return the duration of the Timeline. - - Returns - ------- - float - The duration of the Timeline. - - """ - return self._duration - - def play(self): - """Play the animation""" - if not self.playing: - if self.current_timestamp >= self.duration: - self.current_timestamp = 0 - self._last_started_time = ( - perf_counter() - self._current_timestamp / self.speed - ) - self._playing = True - - def pause(self): - """Pause the animation""" - self._current_timestamp = self.current_timestamp - self._playing = False - - def stop(self): - """Stop the animation""" - self._current_timestamp = 0 - self._playing = False - self.update(force=True) - - def restart(self): - """Restart the animation""" - self._current_timestamp = 0 - self._playing = True - self.update(force=True) - - @property - def current_timestamp(self): - """Get current timestamp of the Timeline. - - Returns - ------- - float - The current time of the Timeline. - - """ - if self.playing: - self._current_timestamp = ( - perf_counter() - self._last_started_time - ) * self.speed - return self._current_timestamp - - @current_timestamp.setter - def current_timestamp(self, timestamp): - """Set the current timestamp of the Timeline. - - Parameters - ---------- - timestamp: float - The time to set as current time of the Timeline. - - """ - self.seek(timestamp) - - def seek(self, timestamp): - """Set the current timestamp of the Timeline. - - Parameters - ---------- - timestamp: float - The time to seek. - - """ - # assuring timestamp value is in the timeline range - if timestamp < 0: - timestamp = 0 - elif timestamp > self.duration: - timestamp = self.duration - if self.playing: - self._last_started_time = perf_counter() - timestamp / self.speed - else: - self._current_timestamp = timestamp - self.update(force=True) - - def seek_percent(self, percent): - """Seek a percentage of the Timeline's final timestamp. - - Parameters - ---------- - percent: float - Value from 1 to 100. - - """ - t = percent * self.duration / 100 - self.seek(t) - - @property - def playing(self): - """Return whether the Timeline is playing. - - Returns - ------- - bool - True if the Timeline is playing. - - """ - return self._playing - - @property - def stopped(self): - """Return whether the Timeline is stopped. - - Returns - ------- - bool - True if Timeline is stopped. - - """ - return not self.playing and not self._current_timestamp - - @property - def paused(self): - """Return whether the Timeline is paused. - - Returns - ------- - bool - True if the Timeline is paused. - - """ - return not self.playing and self._current_timestamp is not None - - @property - def speed(self): - """Return the speed of the timeline's playback. - - Returns - ------- - float - The speed of the timeline's playback. - - """ - return self._speed - - @speed.setter - def speed(self, speed): - """Set the speed of the timeline's playback. - - Parameters - ---------- - speed: float - The speed of the timeline's playback. - - """ - current = self.current_timestamp - if speed <= 0: - return - self._speed = speed - self._last_started_time = perf_counter() - self.current_timestamp = current - - @property - def loop(self): - """Get loop condition of the timeline. - - Returns - ------- - bool - Whether the playback is in loop mode (True) or play one mode - (False). - - """ - return self._loop - - @loop.setter - def loop(self, loop): - """Set the timeline's playback to loop or play once. - - Parameters - ---------- - loop: bool - The loop condition to be set. (True) to loop the playback, and - (False) to play only once. - - """ - self._loop = loop - - @property - def has_playback_panel(self): - """Return whether the `Timeline` has a playback panel. - - Returns - ------- - bool: 'True' if the `Timeline` has a playback panel. otherwise, 'False' - - """ - return self.playback_panel is not None - - def record( - self, - fname=None, - fps=30, - speed=1.0, - size=(900, 768), - order_transparent=True, - multi_samples=8, - max_peels=4, - show_panel=False, - ): - """Record the animation - - Parameters - ---------- - fname : str, optional - The file name. Save a GIF file if name ends with '.gif', or mp4 - video if name ends with'.mp4'. - If None, this method will only return an array of frames. - fps : int, optional - The number of frames per second of the record. - size : (int, int) - ``(width, height)`` of the window. Default is (900, 768). - speed : float, optional, default 1.0 - The speed of the animation. - order_transparent : bool, optional - Default False. Use depth peeling to sort transparent objects. - If True also enables anti-aliasing. - multi_samples : int, optional - Number of samples for anti-aliasing (Default 8). - For no anti-aliasing use 0. - max_peels : int, optional - Maximum number of peels for depth peeling (Default 4). - show_panel : bool, optional, default False - Controls whether to show the playback (if True) panel of hide it - (if False) - - Returns - ------- - ndarray: - The recorded frames. - - Notes - ----- - It's recommended to use 50 or 30 FPS while recording to a GIF file. - - """ - ext = os.path.splitext(fname)[-1] - - mp4 = ext == ".mp4" - - if mp4: - try: - import cv2 - except ImportError as err: - raise ImportError( - "OpenCV must be installed in order to " "save as MP4 video." - ) from err - fourcc = cv2.VideoWriter.fourcc(*"mp4v") - out = cv2.VideoWriter(fname, fourcc, fps, size) - - duration = self.duration - step = speed / fps - frames = [] - t = 0 - scene = self._scene - if not scene: - scene = window.Scene() - scene.add(self) - - _hide_panel = False - if self.has_playback_panel and not show_panel: - self.playback_panel.hide() - _hide_panel = True - render_window = RenderWindow() - render_window.SetOffScreenRendering(1) - render_window.AddRenderer(scene) - render_window.SetSize(*size) - - if order_transparent: - window.antialiasing(scene, render_window, multi_samples, max_peels, 0) - - render_window = RenderWindow() - render_window.SetOffScreenRendering(1) - render_window.AddRenderer(scene) - render_window.SetSize(*size) - - if order_transparent: - window.antialiasing(scene, render_window, multi_samples, max_peels, 0) - - window_to_image_filter = WindowToImageFilter() - - print("Recording...") - while t < duration: - self.seek(t) - render_window.Render() - window_to_image_filter.SetInput(render_window) - window_to_image_filter.Update() - window_to_image_filter.Modified() - vtk_image = window_to_image_filter.GetOutput() - h, w, _ = vtk_image.GetDimensions() - vtk_array = vtk_image.GetPointData().GetScalars() - components = vtk_array.GetNumberOfComponents() - snap = numpy_support.vtk_to_numpy(vtk_array).reshape(w, h, components) - corrected_snap = np.flipud(snap) - - if mp4: - cv_img = cv2.cvtColor(corrected_snap, cv2.COLOR_RGB2BGR) - out.write(cv_img) - else: - pillow_snap = Image.fromarray(corrected_snap) - frames.append(pillow_snap) - - t += step - - print("Saving...") - - if fname is None: - return frames - - if mp4: - out.release() - else: - frames[0].save( - fname, - append_images=frames[1:], - loop=0, - duration=1000 / fps, - save_all=True, - ) - - if _hide_panel: - self.playback_panel.show() - - return frames - - def add_animation(self, animation): - """Add Animation or list of Animations. - - Parameters - ---------- - animation: Animation or list[Animation] or tuple[Animation] - Animation/s to be added. - - """ - if isinstance(animation, (list, tuple)): - [self.add_animation(anim) for anim in animation] - elif isinstance(animation, Animation): - animation._timeline = self - self._animations.append(animation) - self.update_duration() - else: - raise TypeError("Expected an Animation, a list or a tuple.") - - @property - def animations(self) -> "list[Animation]": - """Return a list of Animations. - - Returns - ------- - list: - List of Animations controlled by the timeline. - - """ - return self._animations - - def update(self, force=False): - """Update the timeline. - - Update the Timeline and all the animations that it controls. As well as - the playback of the Timeline (if exists). - - Parameters - ---------- - force: bool, optional, default: False - If True, the timeline will update even when the timeline is paused - or stopped and hence, more resources will be used. - - """ - time = self.current_timestamp - if self.has_playback_panel: - self.playback_panel.current_time = time - if time > self.duration: - if self._loop: - self.seek(0) - else: - self.seek(self.duration) - # Doing this will pause both the timeline and the panel. - if self.has_playback_panel: - self.playback_panel.pause() - else: - self.pause() - if self.playing or force: - [anim.update_animation(time) for anim in self._animations] - - def add_to_scene(self, scene): - """Add Timeline and all of its Animations to the scene""" - self._scene = scene - if self.has_playback_panel: - self.playback_panel.add_to_scene(scene) - [animation.add_to_scene(scene) for animation in self._animations] - - def remove_from_scene(self, scene): - """Remove Timeline and all of its Animations to the scene""" - self._scene = None - if self.has_playback_panel: - scene.rm(*tuple(self.playback_panel.actors)) - [animation.remove_from_scene(scene) for animation in self._animations] +import os +from time import perf_counter + +from PIL import Image +import numpy as np + +from fury import window +from fury.animation.animation import Animation +from fury.decorators import warn_on_args_to_kwargs +from fury.lib import RenderWindow, WindowToImageFilter, numpy_support +from fury.ui.elements import PlaybackPanel + + +class Timeline: + """Keyframe animation Timeline. + + Timeline is responsible for handling the playback of keyframes animations. + It has multiple playback options which makes it easy + to control the playback, speed, state of the animation with/without a GUI + playback panel. + + Attributes + ---------- + animations : Animation or list[Animation], optional, default: None + Actor/s to be animated directly by the Timeline (main Animation). + playback_panel : bool, optional + If True, the timeline will have a playback panel set, which can be used + to control the playback of the timeline. + length : float or int, default: None, optional + the fixed length of the timeline. If set to None, the timeline will get + its length from the animations that it controls automatically. + loop : bool, optional + Whether loop playing the timeline or play once. + + """ + + @warn_on_args_to_kwargs() + def __init__( + self, *, animations=None, playback_panel=False, loop=True, length=None + ): + self._scene = None + self.playback_panel = None + self._current_timestamp = 0 + self._speed = 1.0 + self._last_started_time = 0 + self._playing = False + self._animations = [] + self._loop = loop + self._length = length + self._duration = length if length is not None else 0.0 + + if playback_panel: + + def set_loop(is_loop): + self._loop = is_loop + + def set_speed(speed): + self.speed = speed + + self.playback_panel = PlaybackPanel(loop=self._loop) + self.playback_panel.on_play = self.play + self.playback_panel.on_stop = self.stop + self.playback_panel.on_pause = self.pause + self.playback_panel.on_loop_toggle = set_loop + self.playback_panel.on_progress_bar_changed = self.seek + self.playback_panel.on_speed_changed = set_speed + + if animations is not None: + self.add_animation(animations) + + def update_duration(self): + """Update and return the duration of the Timeline. + + Returns + ------- + float + The duration of the Timeline. + + """ + if self._length is not None: + self._duration = self._length + else: + self._duration = max( + [0.0] + [anim.update_duration() for anim in self._animations] + ) + if self.has_playback_panel: + self.playback_panel.final_time = self.duration + return self.duration + + @property + def duration(self): + """Return the duration of the Timeline. + + Returns + ------- + float + The duration of the Timeline. + + """ + return self._duration + + def play(self): + """Play the animation""" + if not self.playing: + if self.current_timestamp >= self.duration: + self.current_timestamp = 0 + self._last_started_time = ( + perf_counter() - self._current_timestamp / self.speed + ) + self._playing = True + + def pause(self): + """Pause the animation""" + self._current_timestamp = self.current_timestamp + self._playing = False + + def stop(self): + """Stop the animation""" + self._current_timestamp = 0 + self._playing = False + self.update(force=True) + + def restart(self): + """Restart the animation""" + self._current_timestamp = 0 + self._playing = True + self.update(force=True) + + @property + def current_timestamp(self): + """Get current timestamp of the Timeline. + + Returns + ------- + float + The current time of the Timeline. + + """ + if self.playing: + self._current_timestamp = ( + perf_counter() - self._last_started_time + ) * self.speed + return self._current_timestamp + + @current_timestamp.setter + def current_timestamp(self, timestamp): + """Set the current timestamp of the Timeline. + + Parameters + ---------- + timestamp: float + The time to set as current time of the Timeline. + + """ + self.seek(timestamp) + + def seek(self, timestamp): + """Set the current timestamp of the Timeline. + + Parameters + ---------- + timestamp: float + The time to seek. + + """ + # assuring timestamp value is in the timeline range + if timestamp < 0: + timestamp = 0 + elif timestamp > self.duration: + timestamp = self.duration + if self.playing: + self._last_started_time = perf_counter() - timestamp / self.speed + else: + self._current_timestamp = timestamp + self.update(force=True) + + def seek_percent(self, percent): + """Seek a percentage of the Timeline's final timestamp. + + Parameters + ---------- + percent: float + Value from 1 to 100. + + """ + t = percent * self.duration / 100 + self.seek(t) + + @property + def playing(self): + """Return whether the Timeline is playing. + + Returns + ------- + bool + True if the Timeline is playing. + + """ + return self._playing + + @property + def stopped(self): + """Return whether the Timeline is stopped. + + Returns + ------- + bool + True if Timeline is stopped. + + """ + return not self.playing and not self._current_timestamp + + @property + def paused(self): + """Return whether the Timeline is paused. + + Returns + ------- + bool + True if the Timeline is paused. + + """ + return not self.playing and self._current_timestamp is not None + + @property + def speed(self): + """Return the speed of the timeline's playback. + + Returns + ------- + float + The speed of the timeline's playback. + + """ + return self._speed + + @speed.setter + def speed(self, speed): + """Set the speed of the timeline's playback. + + Parameters + ---------- + speed: float + The speed of the timeline's playback. + + """ + current = self.current_timestamp + if speed <= 0: + return + self._speed = speed + self._last_started_time = perf_counter() + self.current_timestamp = current + + @property + def loop(self): + """Get loop condition of the timeline. + + Returns + ------- + bool + Whether the playback is in loop mode (True) or play one mode + (False). + + """ + return self._loop + + @loop.setter + def loop(self, loop): + """Set the timeline's playback to loop or play once. + + Parameters + ---------- + loop: bool + The loop condition to be set. (True) to loop the playback, and + (False) to play only once. + + """ + self._loop = loop + + @property + def has_playback_panel(self): + """Return whether the `Timeline` has a playback panel. + + Returns + ------- + bool: 'True' if the `Timeline` has a playback panel. otherwise, 'False' + + """ + return self.playback_panel is not None + + @warn_on_args_to_kwargs() + def record( + self, + *, + fname=None, + fps=30, + speed=1.0, + size=(900, 768), + order_transparent=True, + multi_samples=8, + max_peels=4, + show_panel=False, + ): + """Record the animation + + Parameters + ---------- + fname : str, optional + The file name. Save a GIF file if name ends with '.gif', or mp4 + video if name ends with'.mp4'. + If None, this method will only return an array of frames. + fps : int, optional + The number of frames per second of the record. + size : (int, int) + ``(width, height)`` of the window. Default is (900, 768). + speed : float, optional, default 1.0 + The speed of the animation. + order_transparent : bool, optional + Default False. Use depth peeling to sort transparent objects. + If True also enables anti-aliasing. + multi_samples : int, optional + Number of samples for anti-aliasing (Default 8). + For no anti-aliasing use 0. + max_peels : int, optional + Maximum number of peels for depth peeling (Default 4). + show_panel : bool, optional, default False + Controls whether to show the playback (if True) panel of hide it + (if False) + + Returns + ------- + ndarray: + The recorded frames. + + Notes + ----- + It's recommended to use 50 or 30 FPS while recording to a GIF file. + + """ + ext = os.path.splitext(fname)[-1] + + mp4 = ext == ".mp4" + + if mp4: + try: + import cv2 + except ImportError as err: + raise ImportError( + "OpenCV must be installed in order to " "save as MP4 video." + ) from err + fourcc = cv2.VideoWriter.fourcc(*"mp4v") + out = cv2.VideoWriter(fname, fourcc, fps, size) + + duration = self.duration + step = speed / fps + frames = [] + t = 0 + scene = self._scene + if not scene: + scene = window.Scene() + scene.add(self) + + _hide_panel = False + if self.has_playback_panel and not show_panel: + self.playback_panel.hide() + _hide_panel = True + render_window = RenderWindow() + render_window.SetOffScreenRendering(1) + render_window.AddRenderer(scene) + render_window.SetSize(*size) + + if order_transparent: + window.antialiasing(scene, render_window, multi_samples, max_peels, 0) + + render_window = RenderWindow() + render_window.SetOffScreenRendering(1) + render_window.AddRenderer(scene) + render_window.SetSize(*size) + + if order_transparent: + window.antialiasing(scene, render_window, multi_samples, max_peels, 0) + + window_to_image_filter = WindowToImageFilter() + + print("Recording...") + while t < duration: + self.seek(t) + render_window.Render() + window_to_image_filter.SetInput(render_window) + window_to_image_filter.Update() + window_to_image_filter.Modified() + vtk_image = window_to_image_filter.GetOutput() + h, w, _ = vtk_image.GetDimensions() + vtk_array = vtk_image.GetPointData().GetScalars() + components = vtk_array.GetNumberOfComponents() + snap = numpy_support.vtk_to_numpy(vtk_array).reshape(w, h, components) + corrected_snap = np.flipud(snap) + + if mp4: + cv_img = cv2.cvtColor(corrected_snap, cv2.COLOR_RGB2BGR) + out.write(cv_img) + else: + pillow_snap = Image.fromarray(corrected_snap) + frames.append(pillow_snap) + + t += step + + print("Saving...") + + if fname is None: + return frames + + if mp4: + out.release() + else: + frames[0].save( + fname, + append_images=frames[1:], + loop=0, + duration=1000 / fps, + save_all=True, + ) + + if _hide_panel: + self.playback_panel.show() + + return frames + + def add_animation(self, animation): + """Add Animation or list of Animations. + + Parameters + ---------- + animation: Animation or list[Animation] or tuple[Animation] + Animation/s to be added. + + """ + if isinstance(animation, (list, tuple)): + [self.add_animation(anim) for anim in animation] + elif isinstance(animation, Animation): + animation._timeline = self + self._animations.append(animation) + self.update_duration() + else: + raise TypeError("Expected an Animation, a list or a tuple.") + + @property + def animations(self) -> "list[Animation]": + """Return a list of Animations. + + Returns + ------- + list: + List of Animations controlled by the timeline. + + """ + return self._animations + + @warn_on_args_to_kwargs() + def update(self, *, force=False): + """Update the timeline. + + Update the Timeline and all the animations that it controls. As well as + the playback of the Timeline (if exists). + + Parameters + ---------- + force: bool, optional, default: False + If True, the timeline will update even when the timeline is paused + or stopped and hence, more resources will be used. + + """ + time = self.current_timestamp + if self.has_playback_panel: + self.playback_panel.current_time = time + if time > self.duration: + if self._loop: + self.seek(0) + else: + self.seek(self.duration) + # Doing this will pause both the timeline and the panel. + if self.has_playback_panel: + self.playback_panel.pause() + else: + self.pause() + if self.playing or force: + [anim.update_animation(time=time) for anim in self._animations] + + def add_to_scene(self, scene): + """Add Timeline and all of its Animations to the scene""" + self._scene = scene + if self.has_playback_panel: + self.playback_panel.add_to_scene(scene) + [animation.add_to_scene(scene) for animation in self._animations] + + def remove_from_scene(self, scene): + """Remove Timeline and all of its Animations to the scene""" + self._scene = None + if self.has_playback_panel: + scene.rm(*tuple(self.playback_panel.actors)) + [animation.remove_from_scene(scene) for animation in self._animations] diff --git a/fury/tests/test_gltf.py b/fury/tests/test_gltf.py index 14e22ad9ca..4172f0bf9b 100644 --- a/fury/tests/test_gltf.py +++ b/fury/tests/test_gltf.py @@ -221,7 +221,7 @@ def test_skinning(): file = read_viz_gltf("SimpleSkin") gltf_obj = glTF(file) animation = gltf_obj.skin_animation()["anim_0"] - timeline = Timeline(animation) + timeline = Timeline(animations=animation) # checking weights and joints weights = np.array( [ diff --git a/fury/tests/test_window.py b/fury/tests/test_window.py index 3c3e11b4e4..608da38523 100644 --- a/fury/tests/test_window.py +++ b/fury/tests/test_window.py @@ -612,7 +612,7 @@ def test_add_animation_to_show_manager(): cube = actor.cube(np.array([[2, 2, 3]])) timeline = Timeline(playback_panel=True) - animation = Animation(cube) + animation = Animation(actors=cube) timeline.add_animation(animation) showm.add_animation(timeline)