Package: keyframed
KeyframedSimple, Expressive DatatypesFor Manipulating Parameter CurvesThis library implements a suite of pythonic datatypes for specifying and manipulating curves parameterized by keyframes and interpolators.# generates the image above!pipinstallkeyframedfromkeyframedimportComposition,Curve,ParameterGroup,SmoothCurveimportmathimportmatplotlib.pyplotaspltlow,high=0,0.3step1=50step2=2*step1# Define some curves, related through shared parameters.# Each curve "bounces" back and forth between two keyframes, smoothly interpolating the intermediate values.curves=ParameterGroup((SmoothCurve({0:low,step1:high},bounce=True),SmoothCurve({0:high,step1:low},bounce=True),SmoothCurve({0:low,step2:high},bounce=True),SmoothCurve({0:high,step2:low},bounce=True)))# Define another curve implicitly, extrapolating from a functionfancy=Curve.from_function(lambdak:high+math.sin(2*k/(step1+step2)))# arithmetic on curvescurves_plus_fancy=curves+fancy+1curves_summed_by_frame=Composition(curves_plus_fancy,reduction='sum')really_fancy=curves_plus_fancy/curves_summed_by_frame# isolate a single curve from a ParameterGroupred_channel=list(really_fancy.parameters.values())[-1]# built-in plottingn=1000really_fancy.plot(n=n)red_channel.plot(n=n,linewidth=3,linestyle='-',color='#d62728')plt.gca().axis('off')plt.tight_layout()plt.savefig('static/images/fancy.png')plt.show()SummaryThe motivation for this library is to facilitate object-oriented parameterization of generative animations, specifically working towards a more expressive successor to the keyframing DSL developed byChigozie Nrifor parameterizing AI art animations (i.e. the keyframing syntax used by tools such as Disco Diffusion and Deforum).The main purpose of this library is to implement theCurveclass. "Keyframes" are special indices where the value of aCurveis defined. You can access data in aCurveusing container indexing syntax, as if it were a list or dict. ACurvecan be queried for values that aren't among its parameterizing keyframes: the result will be computed on the fly based on the interpolation method attached to the preceding keyframe and values on the surrounding keyframes.The default method of interpolation is "previous", which will simply return the value of the closest preceding keyframe (i.e. the default curve is a step function). Several other interpolation methods are provided, and interpolation/extrapolation with user-defined functions is supported. Curves can also be modified via easing functions, which are essentially special interpolators.Curve objects also support basic arithmetic operations like addition and multiplication, producingCompositions of curves (which also support arithmetic). Compositions also support several reducing operators over arbitrarily many curves, e.g. average, min, max, etc.Installationpip install keyframedCurve ConstructionTo create a newCurveobject, you can pass in any of the following arguments to theCurveconstructor:An integer or float: this creates aCurvewith a single keyframe at t=0 with the given value.A dictionary: this creates aCurvewith keyframes at the keys of the dictionary with the corresponding values, which can either be numeric values orKeyframeobjects.A list/tuple of lists/tuples: this creates aCurvewith keyframes at the keys in the tuple with the corresponding values. The tuple should be in the format((k0,v0), (k1,v1), ...).fromkeyframedimportCurve# create a curve with a single keyframe at t=0 with value 0curve1=Curve()# create a curve with a single keyframe at t=0 with value 10curve2=Curve(10)# create a curve with keyframes at t=0 and t=2 with values 0 and 2, respectivelycurve3=Curve({0:0,2:2})# create a curve with keyframes at t=0 and t=2 with values 0 and 2, respectivelycurve4=Curve([(0,0),(2,2)])To facilitate compatibility with existing generative art tooling for AI animation, curve objects can also
be initialized from the "Chigozie Nri string format" used by tools like Disco Diffusion and Deforum.fromkeyframed.dslimportcurve_from_cn_stringcurve5=curve_from_cn_string("1:(1), 10:(10)")# Adding an arbitrary label for equivalence evaluationcurve5.label="foobar"assertcurve5==Curve({0:1,1:1,10:10},default_interpolation='linear',label="foobar")By defaultCurveobjects behave as step functions. This can be modified by specifying different interpolation methods, which will be discussed at length further below. A versatile alternative default is provided via theSmoothCurveclass, which simply has a different setting fordefault_interpolation(see more on interpolation methods and API below).To visualize a curve, just call its.plot()method. Curves carry alabelattribute: if this is populated, it will be used to label the curve in the plot.fromkeyframedimportCurve,SmoothCurveimportmatplotlib.pyplotaspltkfs={0:0,1:1,10:10}c=Curve(kfs,label='stepfunc')sc=SmoothCurve(kfs,label='smoothfunc')c.plot()sc.plot(linestyle='dashed')plt.legend()plt.show()Curve Propertieskeyframes: returns a list of the keyframes in the curve.values: returns a list of the values at the corresponding keyframes.label: if not specified when initialized, a label will be auto-generated. labels can be modified any time by changing the.labelattribute directlycurve=Curve({0:0,2:2})print(curve.keyframes)# prints [0, 2]print(curve.values)# prints [0, 2]print(curve.label)# prints something like "curve_SiF86DCurve IndexingYou can access the value of a keyframe in the curve by indexing the curve object with the key. If the key is not in the curve, the curve will use interpolation (defaults to 'previous') to return a value.fromkeyframedimportCurvecurve=Curve({0:0,2:2})print(curve[0])# prints 0print(curve[1])# prints 0print(curve[2])# prints 2Curve SlicingCurves also support slice indexing. The result of a slice will be re-indexed such that the beginning of the slice corresponds to the 0th keyframe index (t=0). NB: The terminal index of the slice isinclusive, which is a little unpythonic but makes sense when considering that these are actually time indices rather than discrete container indices.curve=Curve({i:iforiinrange(10)})print(curve[0],curve[3]curve[5])# 0, 3, 5sliced=curve[3:5]print(sliced[0],sliced[2],sliced[5])# 3, 5, 5Curve AssignmentYou can set the value of a keyframe in the curve by assigning to the curve object with the key. If the key is not in the curve, a newKeyframewill be created (see bottom for details).fromkeyframedimportCurvecurve=Curve()# equivalent to Curve({0:0})curve[0]=10curve[1]=20curve[2]=30print(curve)# prints "Curve({0: 10, 1: 20, 2: 30})"Curve ArithmeticAll classes inheriting fromCurveBase(Curve,ParameterGroup,Composition) support basic arithmetic with numeric values and with otherCurveBasechildclasses.fromkeyframedimportCurvecurve=Curve({0:0,2:2})curve1=curve+1print(curve1[0])# 1print(curve1[1])# 1print(curve1[2])# 3curve2=curve*2print(curve1[0])# 0print(curve1[1])# 0print(curve1[2])# 4curve3=curve+Curve((1,1))print(curve3[0])# 0print(curve3[1])# 1print(curve3[2])# 3InterpolationThe Curve class defaults to "previous" interpolation, which returns the value of the keyframe to the left of the given key if the given key is not already assigned a value. Other provided interpolation methods include "linear", "next" and "eased_lerp" (the default interpolator forSmoothCurve).fromkeyframedimportCurvecurve=Curve({0:0,2:2})print(curve[0])# 0print(curve[1])# 0print(curve[2])# 2# yes, setting t in the Keyframe object is redundant. working on it.curve[0]=Keyframe(t=0,value=0,interpolation_method='linear')print(curve[0])# 0print(curve[1])# 1print(curve[2])# 2Custom interpolation with user-defined functionsYou can also define custom interpolation methods. The call signature should take the a frame index as the first argument (k) and the Curve object itself (curve) as the second. You can then specify the custom method inside aKeyframe, assign the callable to the key directly, or register it as a named interpolation method,fromkeyframedimport(bisect_left_keyframe,bisect_right_keyframe,Curve,Keyframe,register_interpolation_method,)defmy_linear(k,curve):# get the leftmost and rightmost keyframe objectsleft=bisect_left_keyframe(k,curve)right=bisect_right_keyframe(k,curve)# Get the coordinates between which we'll be interpolatingx0,x1=left.t,right.ty0,y1=left.value,right.value# Convert the input keyframe index to a "progress" value -- `t` -- in [0,1]d=x1-x0t=(x1-k)/d# Calculate the interpolation# (this fomula is called "lerp", short for Linear intERPolation)outv=t*y0+(1-t)*y1returnoutvcurve=Curve({0:0,2:2})print(curve[0])# 0print(curve[1])# 0print(curve[2])# 2curve.plot(label='stepfunc')# the interpolation method at frame 0 is still 'previous'.curve[1]=Keyframe(t=1,value=1,interpolation_method=my_linear)print(curve[0])# 0print(curve[1])# 1print(curve[2])# 2curve.plot(label='my_linear interpolated',linestyle='dashed')plt.legend()Alternatively, you can accomplish the same thing by assigning the callable to the keyframe index directly.# shorthand: assign the callable directlycurve=Curve({0:0,2:2})curve[0]=my_linearprint(curve[0])# 0print(curve[1])# 1print(curve[2])# 2Both of the above methods will work, however: you'll need to use something likepicklefor serialization. To facilitate safer serialization, you can "register" your custom interpolation method into the library's registry of available interpolation methods and then reference it by name as a string.# register the function to a named interpolatorregister_interpolation_method('my_interpolator',my_linear)# invoke interpolator by namecurve=Curve({0:0,2:2})curve[0]=Keyframe(t=0,value=0,interpolation_method='my_interpolator)print(curve[0])# 0print(curve[1])# 1print(curve[2])# 2Interpolation with extended context windowsfromkeyframed.interpolationimportget_context_left,get_context_rightimportnumpyasnpfromscipy.interpolateimportinterp1dimportrandomrandom.seed(123)# just for reproducibilitydefuser_defined_quadratic_interp(k,curve,n=2):xs=get_context_left(k,curve,n)xs+=get_context_right(k,curve,n)ys=[curve[x]forxinxs]f=interp1d(xs,ys,kind='quadratic')returnf(k)# sample some random points that are linearly correlated with some jitterd={i:i+2*random.random()foriinrange(10)}curve=Curve(d,default_interpolation=user_defined_quadratic_interp)xs=np.linspace(0,9,100)curve.plot(xs=xs)LoopingThe Curve class has aloopattribute that can be set toTrue(either as an attribute or as an initialization argument) to make the curve loop indefinitely.curve=Curve({0:0,1:1},loop=True)print(curve[0])# prints 0print(curve[1])# prints 1print(curve[2])# prints 0print(curve[3])# prints 1curve.plot(n=5)Alternatively, you can use thebouncesetting to play every other loop in reverse. Only one of "bounce" or "loop" should be set to True.SerializationAll classes that inherit fromCurveBasecan be serialized to and from yaml
viakeyframed.serialization.to_yamlandkeyframed.serialization.from_yaml.Defaultto_yamlserialization generates a "simplified" output by default, which
should be reasonably intuitive to manipulate in yaml form. For a more verbose, explicit
yaml output, just setsimplify=False.# just play with to_yaml, you'll get the ideafromkeyframedimportSmoothCurve,ParameterGroup,serializationlow,high=0.0001,0.3step1=50curves=ParameterGroup({'foo':SmoothCurve({0:low,(step1-1):high,(2*step1-1):low},loop=True),'bar':SmoothCurve({0:high,(step1-1):low,(2*step1-1):high},loop=True)})txt=serialization.to_yaml(curves,simplify=True)asserttxt=="""parameters:foo:curve:- - 0- 0.0001- eased_lerp- - 49- 0.3- - 99- 0.0001loop: truebar:curve:- - 0- 0.3- eased_lerp- - 49- 0.0001- - 99- 0.3loop: true""".strip()curves=serialization.from_yaml(txt)Breaking down the yaml syntax:curve:--<time0>-<value0>-<interpolation method>-<interpolator arguments>--<time1>-<value1>-<another interpolation method, this one doesn't take arguments >--<time2>-<value2># because no interpolator is specified, time2 falls back to the most recent keyframe where an interpolator was# specified and re-uses the interpolation method from time1. To include redundant information like an# interpolator that is implied by a previous frame (because it's the same), set `simplify=False` when calling# the serialization method (usually `to_yaml`).Yaml serialization as just demonstrated will probably be the preferred approach for most use cases.
The yaml serialization machinery is built around an intermediarydictstructure
that might be more appropriate for certain situations, such as users who want to serialize keyframed objects to JSON.d=curves.to_dict(simplify=True,for_yaml=False)## returned dict looks like this:# {'parameters': {'foo': {'curve': {0: {'value': 0.0001,#      'interpolation_method': 'eased_lerp'},#     49: {'value': 0.3},#     99: {'value': 0.0001}},#    'loop': True},#   'bar': {'curve': {0: {'value': 0.3, 'interpolation_method': 'eased_lerp'},#     49: {'value': 0.0001},#     99: {'value': 0.3}},#    'loop': True}}}curves=serialization.from_dict(d)If you're using customization features like user-defined interpolators, use the "interpolator registration" functionality
for compatibility with these serialization tools. The "registration" step will need to be repeated in the deserialization
environment to ensure the named interpolator is available when evaluating the deserialized curve objects.Advanced: Peeking under the hoodThe following sections provide implementation details for advanced usersHowCurvesworkCurveobjects are built on top of asortedcontainer.SortedDictthat lives on theCurve._dataattribute (which you generally should not access directly). When you assign values to time indices on the curve, a key is written into_dataand associated with aKeyframeobject, which is basically just a named tuple that carries the attributest,value, andinterpolation_method. If the user queries aCurvefor an index that is already assigned to_data, the correspondingKeyframe.valueis returned directly. Otherwise, theKeyframeobject associated with the leftmost populated index in_datais used to infer the appropriate interpolation method to use.How arithmetic operations onCurvesworksThe return value of airthmetic on aCurveBasechild class is aCompositionobject, which is a special kind ofParameterGroupthat also has areductionattribute. TheCompositionencapsulates the computation of the arithmetic. When a user queries the value for a key, the normal return value from querying aParameterGroupis passed tofunctools.reduce, which applies the appropriateoperatorfunction based on the namedComposition.reduction. The result of this reduction operation is then returned.Although they inherit from theParameterGroupclass,Compositionobjects should generally be treated more likeCurveobjects. That being said, arithmetic on aParameterGroupalso returns aCompositionobject (which you may see referred to as a "compositional parameter group"), but one which will behave more like aParameterGroupobject. Normally, theParameterGroup.parametersattribute can be used to isolate "channels" from theParameterGroup, but at this timeComposition.parameterscan't be used this way: this attribute carries the operands of the arithmetic operation instead. Forthcoming changes will facilitate isolating/extracting/compiling specific"parameter channel"Curveobjects from compositional pgroups.