0
点赞
收藏
分享

微信扫一扫

深入分析用 Manimgl 绘制参数方程的图像

hoohack 2022-03-31 阅读 106
PythonManim

目录

一、基本数学概念

二、绘制函数图形

三、绘制参数曲线

四、绘制双曲线


本文介绍 Manimgl 开发库中的 FunctionGraph 函数图形类和 ParametricCurve(曾用名:ParametricFunction,参数方程)参数曲线类进行图形绘制。这些图形分别对应于某个曲线的函数或参数方程。这些图像任然是 VMobject 对象,支持动画,也可以作为 MoveAlongPath 类的 path 参数,让其他 VMobject 沿着路径移动。Manimgl 的最新版中还增加了 ImplicitFunction 隐函数类,埋个坑,关于如何绘制隐函数图像下次单独介绍。

一、基本数学概念

先复习函数和参数方程的基本概念。

函数的定义:设AB是非空的数集,如果按照某个确定的对应关系F,使对于集合A中的任意一个数 x,在集合B中都有唯一确定的数 F(x) 和它对应,那么就称 FA为从集合A到集合B的一个函数。记作:y=F(x),xA。其中,叫做自变量,的取值范围A叫做函数的定义域;与 的值相对应的 值叫做函数值,函数值的集合 { F(x) | xA } 叫做函数的值域。通过若干个这样的 x,F(x) 值对,Manimgl 就能在平面坐标系中绘制出函数所对应的图形。

参数方程是在取定的平面直角坐标系中,如果曲线上任意一点的坐标 x, y 都是某个变数 t 的函数,并且对于 t 的每个允许值,由图中方程组所确定的点 M(x, y) 都在这条曲线上,那么这个方程组就叫做这条曲线的参数方程。联系 x, y 之间的关系的变数叫做参变数,简称参数。参数方程中的参数可以是有物理或几何意义的变数,也可以是没有明显意义的变数。

相对于参数方程来说,直接给出曲线上点的坐标关系的方程 F(x, y)=0,叫做曲线的普通方程。一般情况下,可以通过消去参数方程中的参数,得到直接表示 x, y 之间关系的普通方程;也可以选择一个参数,将普通方程化成参数方程。同一个普通方程,如果选择的参数不同,可以化成不同的参数方程。概念部分就介绍这么多,具体问题可参考平面解析几何方面的资料。

二、绘制函数图形

通过 FunctionGraph 类,能快速绘制函数图形。该类是 ParametricCurve 的派生类,其主要变化是在构造函数中定义了一个参数方程函数 parametric_function,以 x_range 中的每个值作为参数 x,以该参数方程函数的返回值作为 y 值,将 z 值置0后,作为点坐标向量。事实上是初始化了一个特殊的 ParametricCurve 对象,FunctionGraph 的构造函数代码如下:

def __init__(self, function, x_range=None, **kwargs):
    digest_config(self, kwargs)
    self.function = function

    if x_range is not None:
        self.x_range[:len(x_range)] = x_range

    def parametric_function(t):
        return [t, function(t), 0]

    super().__init__(parametric_function, self.x_range, **kwargs)

从以上代码可见,初始化 FunctionGraph 对象时,需要为构造函数提供两个参数,分别是 function 参数,即函数图形所对应的数学函数;x_range 参数,即自变量 x 的取值范围。对 function 参数指向的函数有以下具体要求,首先只能接受一个值类型的参数,代表 x,且只能返回一个值类型的值,代表 y。这符合数学函数的定义,即 x 与 y 一一对应。

以正弦函数为例,绘制其函数图形,先看看代码和最终输出效果:

完整程序代码如下:

class FunctionGraphSin(Scene):
    def construct(self):
        axes = Axes(
            x_range=[-1, 7, 1],
            y_range=[-2, 2, 1],
            width=8,
            height=4,
            axis_config={
                "include_tip": True,
            }
        )

        sin_graph = FunctionGraph(
            function=lambda x: np.sin(x),
            x_range=[0, 2 * np.pi, 0.1]
        )
        sin_graph.move_to(axes.get_origin(), aligned_edge=LEFT)

        self.play(Write(axes), run_time=1)
        self.play(Write(sin_graph), run_time=1)
        self.wait()

重点是以下两行:

sin_graph = FunctionGraph(function=lambda x: np.sin(x), x_range=[0, 2 * np.pi, 0.1])
sin_graph.move_to(axes.get_origin(), aligned_edge=LEFT)

首先创建 FunctionGraph 对象,参数 function 指向一个函数,本文写成 lambda 形式,存粹是为了看到自变量 x,事实上只要写 np.sin 即可(没有括弧),Manimgl 会自动将 x_range 列表中的每一个值都作为参数传入该函数执行,并读取返回值。x_range 参数是列表类型的对象,其含义为 [min, max, step],最小值,最大值,步长。代码的完整含义是:以 0.1 为步长取 0~2π 范围内所有数值作为 x 值,并交由 sin 函数计算出所有的 y 值,将这些值对作为平面坐标系上的点坐标,绘制图形。

其次是移动正弦图形到坐标系的原点,原因是范例中坐标系的原点并不在 0,0 位置,这是由坐标系的 x_range 和 y_range 构造参数决定的。为保持正弦图形在坐标系中处于正确的位置,要把正弦图形的起点位置(范例中 x 值是从0开始的)移动到坐标系的原点。

对于其他函数的图形,可参考以上方式编写。代码中的 v_group 仅为了同步缩小和移动坐标系和函数图形。

三、绘制参数曲线

参数方程是通过传入参数,计算获得一系列的 x,y 值,这种方式对程序代码是很友好的,程序将这些值作为曲线上点的坐标绘制出来,就得到了参数方程的图形。以圆为例,圆的标准方程是:

其对于用计算机程序绘制图形是不太友好的,若将其转换为参数方程,则能更容易地绘制图形,将标准方程转换为参数方程的过程如下:

Manimgl 库中,ParametricCurve 即参数曲线类,正是利用了参数方程的这一特点编写的,其构造函数如下:

def __init__(self, t_func, t_range=None, **kwargs):
    digest_config(self, kwargs)
    if t_range is not None:
        self.t_range[:len(t_range)] = t_range
    # To be backward compatible with all the scenes specifying t_min, t_max, step_size
    self.t_range = [
        kwargs.get("t_min", self.t_range[0]),
        kwargs.get("t_max", self.t_range[1]),
        kwargs.get("step_size", self.t_range[2]),
    ]
    self.t_func = t_func
    VMobject.__init__(self, **kwargs)

要创建 ParametricCurve 的对象,至少提供 t_func 参数,这个参数是指向一个代表参数方程的函数,该函数根据 t_range 范围内的所有值,计算每个 x,y,增加 z 值(该值为0)后,作为点坐标返回。若 t_func 指向的函数不需要参数,可不提供 t_range 参数,一般情况下参数方程都需要传入参数。以上文提到的圆的参数方程为例,以原点为圆心,半径为2绘制圆,输出内容和演示代码如下:

 完整代码如下:

class ParametricCircleDemo(Scene):
    @staticmethod
    def circle(t):
        x = 2 * np.cos(t)
        y = 2 * np.sin(t)
        return np.array([x, y, 0.0])

    def construct(self):
        axes = Axes(
            x_range=[-4, 4, 1],
            y_range=[-3, 3, 1],
            width=8,
            height=6,
            axis_config={
                "include_tip": True,
            }
        )

        param_curve = ParametricCurve(
            t_func=self.circle,
            t_range=[0, 2 * np.pi, 0.05],
            color=GREEN,
        )

        self.play(Write(axes), run_time=1)
        self.play(Write(param_curve))
        self.wait()

从基本使用上看,参数曲线类的使用是比较方便的,本文通过绘制其他参数方程的图像,继续全面深入地展示 ParametricCurve 类的更多特性。

四、绘制双曲线

参考用参数曲线类绘制圆的代码,可以很容易的编写绘制双曲线图像的程序(关于如何推导双曲线的参数方程,可网上查询),对比可发现代码变化很小:

class ParametricCurveDemo(Scene):
    @staticmethod
    def hyperbolic_curve(t):
        x = 1/np.cos(t)
        y = np.tan(t)
        return np.array([x, y, 0.0])

    def construct(self):
        axes = Axes(
            x_range=[-3, 3, 1],
            y_range=[-3, 3, 1],
            width=6,
            height=6,
            axis_config={
                "include_tip": True,
            }
        )

        param_curve = ParametricCurve(
            t_func=self.hyperbolic_curve,
            t_range=[0, 2 * np.pi, 0.001],
            color=GREEN
        )

        v_group = Group(axes, param_curve)
        v_group.scale(0.8, about_point=ORIGIN)

        self.play(Write(axes), run_time=1)
        self.play(Write(param_curve))
        self.wait()

仅仅把圆参数方程,改为双曲线的参数方程,为了能更多地看到图像,把坐标和图像缩小了 20%。代码输出图像如下:

显然,图像存在明显问题,代码并没有要求绘制渐近线!事实上,多出来的线也并非是渐近线,而是 ParametricCurve 在绘制图形时没有正确间断,将所有的点作为一个 path 绘制而导致的!通过阅读 ParametricCurve 的 init_points 方法代码可以看到,参数曲线类能按照 discontinuities 列表设置的间断点对 path 进行分段,也就是只要正确设置间断点,就能得到正确的双曲线图像。

所谓间断点,以双曲线为例,就是告诉 ParametricCurve 在4个发散角处断开 path 的绘制,重新创建新的 path 对象,而这4个角对应的是2个 θ 角参数 0.5π 和 1.5π,即90度角和270度角。代码中设置的 t_range 是 0~2π,需要以 0.5π 和 1.5π 作为间断,修改 ParametricCurve 对象的创建代码为:

param_curve = ParametricCurve(
    t_func=self.hyperbolic_curve,
    t_range=[0, 2 * np.pi, 0.001],
    color=GREEN,
    discontinuities=[
        0.5*np.pi, 1.5*np.pi
    ],
)

再次执行后,程序能较好地输出双曲线,但是任然有多余的点存在,并且在移动图像时,可以隐约看到还是有多余的线存在!再次分析 ParametricCurve 的 init_points 方法的以下代码:

jumps = np.array(self.discontinuities)
jumps = jumps[(jumps > t_min) & (jumps < t_max)]
boundary_times = [t_min, t_max, *(jumps - self.epsilon), *(jumps + self.epsilon)]
boundary_times.sort()
for t1, t2 in zip(boundary_times[0::2], boundary_times[1::2]):
    t_range = [*np.arange(t1, t2, step), t2]
    points = np.array([self.t_func(t) for t in t_range])
    self.start_new_path(points[0])
    self.add_points_as_corners(points[1:])

发现,在计算 boundary_times 时,用到了 self.epsilon 属性。而这部分代码的含义是以 t_range 中的最小值、最大值,以及所有中间值减去和加上 self.epsilon 属性的结果组成一个列表,以这个列表中的值两两为对,分割 t_range 创建 path 并用参数方程计算获得的点集进行填充。

通过分析 self.epsilon 属性的值发现,Manimgl 给的默认值是 1e-8,即10的-8次方,而再看程序中用到的 π 是由 Numpy 库提供的,Numpy 库为 π 保留到小数点后15位。也就是说,尽管有间断分割,但是没有分在 t_range 中的正确值上,这导致了输出图像的问题。

至此,继续修改 ParametricCurve 对象的创建代码为:

param_curve = ParametricCurve(
    t_func=self.hyperbolic_curve,
    t_range=[0, 2 * np.pi, 0.001],
    color=GREEN,
    discontinuities=[
        0.5*np.pi, 1.5*np.pi
    ],
    epsilon=1e-15,
)

设置 epsilon 属性为 1e-15,再次执行,得到如下图形:

 这样,得到了比较正确的双曲线图形,同时也说明了 Manimgl 中参数曲线类的详细使用方法。

举报

相关推荐

0 条评论