递归的递归之书:第十三章到第十四章

253 阅读47分钟

十三、分形艺术生成器

原文:Chapter 13 - Fractal Art Maker

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章向您介绍了使用turtle Python 模块绘制许多著名分形的程序,但您也可以使用本章中的项目制作自己的分形艺术。分形艺术生成器程序使用 Python 的turtle模块将简单的形状转化为复杂的设计,只需很少的额外代码。

本章的项目带有九个示例分形,尽管您也可以编写新的函数来创建您自己设计的分形。修改示例分形以产生完全不同的艺术品,或者从头开始编写代码来实现您自己的创意愿景。

内置分形

您可以指示计算机创建无限数量的分形。图 13-1 显示了本章中将使用的分形艺术生成器程序中附带的九个分形。这些是通过绘制简单的正方形或等边三角形作为基本形状,然后在它们的递归配置中引入轻微差异来产生完全不同的图像。

标有九个乌龟图形截图的标签。四个角:包含复杂六边形图案的正方形。螺旋方块:由重叠的灰色和白色正方形创建的螺旋。双螺旋方块:由多组白色和灰色正方形重叠创建的螺旋。三角形螺旋:由三角形轮廓重叠创建的螺旋。康威生命游戏:由较小的灰色正方形部分填充的白色正方形。这些较小的正方形部分填充有较小的白色和深灰色正方形。谢尔宾斯基三角形:谢尔宾斯基三角形,如第一章和第九章所见。波浪:由许多较小的三角形和波形形状创建的波浪。喇叭:灰色和白色条纹螺旋形状。雪花:雪花形状。

图 13-1:分形艺术生成器程序附带的九个示例分形

您可以通过将程序顶部的DRAW_FRACTAL常量设置为从19的整数,然后运行分形艺术生成器程序来生成所有这些分形。您还可以将DRAW_FRACTAL设置为1011,以分别绘制组成这些分形的基本正方形和三角形形状,如图 13-2 所示。

两个乌龟图形截图,一个显示正方形,另一个显示等边三角形的轮廓。

图 13-2:调用drawFilledSquare()(左)和drawTriangleOutline()(右)的结果

这些形状相当简单:一个填充有白色或灰色的正方形,以及一个简单的三角形轮廓。drawFractal()函数使用这些基本形状来创建令人惊叹的分形。

分形艺术生成器算法

分形艺术生成器的算法有两个主要组成部分:一个形状绘制函数和递归的drawFractal()函数。

形状绘制函数绘制基本形状。分形艺术生成器程序配备了先前在图 13-2 中显示的两个形状绘制函数,drawFilledSquare()drawTriangleOutline(),但您也可以创建自己的形状绘制函数。我们将一个形状绘制函数作为参数传递给drawFractal()函数,就像我们在第十章中将匹配函数传递给文件查找器的walk()函数一样。

drawFractal()函数还具有一个参数,指示在对drawFractal()进行递归调用之间对形状的大小、位置和角度进行更改。我们将在本章后面介绍这些具体细节,但让我们看一个例子:分形 7,它绘制了一个波浪状的图像。

该程序通过调用drawTriangleOutline()形状绘制函数来生成波形分形,该函数创建一个单独的三角形。对drawFractal()的额外参数告诉它进行三次递归调用drawFractal()。图 13-3 显示了原始调用drawFractal()产生的三角形以及三次递归调用产生的三角形。

两个乌龟图形截图。第一个显示等边三角形的轮廓。第二个显示相同的三角形轮廓,以及另外三个较小的等边三角形:两个在第一个上方,第三个在下方并稍微向左旋转。

图 13-3:第一次调用drawFractal()产生的三角形(左)和第一组三次递归调用(右)

第一个递归调用告诉drawFractal()调用drawTriangleOutline(),但三角形的大小是上一个三角形的一半,并且位于其上一个三角形的左上方。第二个递归调用产生了一个三角形,位于其上一个三角形的右上方,大小为其 30%。第三个递归调用产生了一个三角形,位于其上一个三角形的下方,大小为其一半,并且相对于其旋转了 15 度。

这三个对drawFractal()的递归调用中的每一个都会再次对drawFractal()进行三次递归调用,从而产生九个新的三角形。新的三角形与其上一个三角形相比,其大小、位置和角度都发生了相同的变化。左上角的三角形始终是上一个三角形的一半大小,而底部三角形始终旋转 15 度。图 13-4 显示了递归的第一级和第二级产生的三角形。

两个乌龟图形的截图。第一个显示与图 13-3 中相同的四个三角形。第二个显示围绕每个三角形的三个较小的三角形以相同的模式聚集。

图 13-4:对drawFractal()的递归调用的第一级(左)和第二级递归调用的九个新三角形(右)

drawFractal()的这九个调用分别产生了九个新的三角形,每个调用再次对drawFractal()进行三次递归调用,从而在下一级递归中产生 27 个新的三角形。随着递归模式的继续,最终三角形变得如此小,以至于drawFractal()停止进行新的递归调用。这是递归drawFractal()函数的一个基本情况。另一个情况是当递归深度达到指定级别时。无论哪种情况,这些递归调用都会产生图 13-5 中的最终 Wave 分形。

Wave 分形的乌龟图形截图。

图 13-5:每个三角形递归生成三个新三角形后的最终 Wave 分形

图 13-1 中的九个示例分形是使用两个形状绘制函数和对drawFractal()参数的一些更改制作的。让我们看看分形艺术生成器的代码,以了解它是如何实现的。

完整的分形艺术制作程序

将以下代码输入到一个新文件中,并将其保存为fractalArtMaker.py。此程序依赖于 Python 内置的turtle模块,因此本章的项目不使用 JavaScript 代码:

Python

import turtle, math

DRAW_FRACTAL = 1 # Set to 1 through 11 and run the program.

turtle.tracer(5000, 0) # Increase the first argument to speed up the drawing.
turtle.hideturtle()

def drawFilledSquare(size, depth):
    size = int(size)

    # Move to the top-right corner before drawing:
    turtle.penup()
    turtle.forward(size // 2)
    turtle.left(90)
    turtle.forward(size // 2)
    turtle.left(180)
    turtle.pendown()

    # Alternate between white and gray (with black border):
    if depth % 2 == 0:
        turtle.pencolor('black')
        turtle.fillcolor('white')
    else:
        turtle.pencolor('black')
        turtle.fillcolor('gray')

    # Draw a square:
    turtle.begin_fill()
    for i in range(4): # Draw four lines.
        turtle.forward(size)
        turtle.right(90)
    turtle.end_fill()

def drawTriangleOutline(size, depth):
    size = int(size)

    # Move the turtle to the top of the equilateral triangle:
    height = size * math.sqrt(3) / 2
    turtle.penup()
    turtle.left(90) # Turn to face upward.
    turtle.forward(height * (2/3)) # Move to the top corner.
    turtle.right(150) # Turn to face the bottom-right corner.
    turtle.pendown()

    # Draw the three sides of the triangle:
    for i in range(3):
        turtle.forward(size)
        turtle.right(120)

def drawFractal(shapeDrawFunction, size, specs, maxDepth=8, depth=0):
    if depth > maxDepth or size < 1:
        return # BASE CASE

    # Save the position and heading at the start of this function call:
    initialX = turtle.xcor()
    initialY = turtle.ycor()
    initialHeading = turtle.heading()

 # Call the draw function to draw the shape:
    turtle.pendown()
    shapeDrawFunction(size, depth)
    turtle.penup()

    # RECURSIVE CASE
    for spec in specs:
        # Each dictionary in specs has keys 'sizeChange', 'xChange',
        # 'yChange', and 'angleChange'. The size, x, and y changes
        # are multiplied by the size parameter. The x change and y
        # change are added to the turtle's current position. The angle
        # change is added to the turtle's current heading.
        sizeCh = spec.get('sizeChange', 1.0)
        xCh = spec.get('xChange', 0.0)
        yCh = spec.get('yChange', 0.0)
        angleCh = spec.get('angleChange', 0.0)

        # Reset the turtle to the shape's starting point:
        turtle.goto(initialX, initialY)
        turtle.setheading(initialHeading + angleCh)
        turtle.forward(size * xCh)
        turtle.left(90)
        turtle.forward(size * yCh)
        turtle.right(90)

        # Make the recursive call:
        drawFractal(shapeDrawFunction, size * sizeCh, specs, maxDepth, 
        depth + 1)

if DRAW_FRACTAL == 1:
    # Four Corners:
    drawFractal(drawFilledSquare, 350,
        [{'sizeChange': 0.5, 'xChange': -0.5, 'yChange': 0.5},
         {'sizeChange': 0.5, 'xChange': 0.5, 'yChange': 0.5},
         {'sizeChange': 0.5, 'xChange': -0.5, 'yChange': -0.5},
         {'sizeChange': 0.5, 'xChange': 0.5, 'yChange': -0.5}], 5)
elif DRAW_FRACTAL == 2:
    # Spiral Squares:
    drawFractal(drawFilledSquare, 600, [{'sizeChange': 0.95,
        'angleChange': 7}], 50)
elif DRAW_FRACTAL == 3:
    # Double Spiral Squares:
    drawFractal(drawFilledSquare, 600,
        [{'sizeChange': 0.8, 'yChange': 0.1, 'angleChange': -10},
         {'sizeChange': 0.8, 'yChange': -0.1, 'angleChange': 10}])
elif DRAW_FRACTAL == 4:
    # Triangle Spiral:
    drawFractal(drawTriangleOutline, 20,
        [{'sizeChange': 1.05, 'angleChange': 7}], 80)
elif DRAW_FRACTAL == 5:
    # Conway's Game of Life Glider:
    third = 1 / 3
    drawFractal(drawFilledSquare, 600,
        [{'sizeChange': third, 'yChange': third},
 {'sizeChange': third, 'xChange': third},
         {'sizeChange': third, 'xChange': third, 'yChange': -third},
         {'sizeChange': third, 'yChange': -third},
         {'sizeChange': third, 'xChange': -third, 'yChange': -third}])
elif DRAW_FRACTAL == 6:
    # Sierpiński Triangle:
    toMid = math.sqrt(3) / 6
    drawFractal(drawTriangleOutline, 600,
        [{'sizeChange': 0.5, 'yChange': toMid, 'angleChange': 0},
         {'sizeChange': 0.5, 'yChange': toMid, 'angleChange': 120},
         {'sizeChange': 0.5, 'yChange': toMid, 'angleChange': 240}])
elif DRAW_FRACTAL == 7:
    # Wave:
    drawFractal(drawTriangleOutline, 280,
        [{'sizeChange': 0.5, 'xChange': -0.5, 'yChange': 0.5},
         {'sizeChange': 0.3, 'xChange': 0.5, 'yChange': 0.5},
         {'sizeChange': 0.5, 'yChange': -0.7, 'angleChange': 15}])
elif DRAW_FRACTAL == 8:
    # Horn:
    drawFractal(drawFilledSquare, 100,
        [{'sizeChange': 0.96, 'yChange': 0.5, 'angleChange': 11}], 100)
elif DRAW_FRACTAL == 9:
    # Snowflake:
    drawFractal(drawFilledSquare, 200,
        [{'xChange': math.cos(0 * math.pi / 180),
          'yChange': math.sin(0 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(72 * math.pi / 180),
          'yChange': math.sin(72 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(144 * math.pi / 180),
          'yChange': math.sin(144 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(216 * math.pi / 180),
          'yChange': math.sin(216 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(288 * math.pi / 180),
          'yChange': math.sin(288 * math.pi / 180), 'sizeChange': 0.4}])
elif DRAW_FRACTAL == 10:
    # The filled square shape:
    turtle.tracer(1, 0)
    drawFilledSquare(400, 0)
elif DRAW_FRACTAL == 11:
    # The triangle outline shape:
    turtle.tracer(1, 0)
    drawTriangleOutline(400, 0)
else:
    assert False, 'Set DRAW_FRACTAL to a number from 1 to 11.'

turtle.exitonclick() # Click the window to exit.

运行此程序时,它将显示来自图 13-1 的九个分形图像中的第一个。您可以将DRAW_FRACTAL常量更改为源代码开头的任何整数,从19,然后再次运行程序以查看新的分形。在了解程序如何工作之后,您还可以创建自己的形状绘制函数,并调用drawFractal()来生成自己设计的分形。

设置常量和乌龟配置

程序的第一行涵盖了基于乌龟的程序的基本设置步骤:

Python

import turtle, math

DRAW_FRACTAL = 1 # Set to 1 through 11 and run the program.

turtle.tracer(5000, 0) # Increase the first argument to speed up the drawing.
turtle.hideturtle()

程序导入了用于绘图的turtle模块。它还导入了math模块,用于math.sqrt()函数,Sierpiński Triangle 分形将使用该函数,以及math.cos()math.sin()函数,用于 Snowflake 分形。

DRAW_FRACTAL常量可以设置为从19的任何整数,以绘制程序生成的九个内置分形中的一个。您还可以将其设置为1011,以分别显示正方形或三角形形状绘制函数的输出。

我们还调用一些海龟函数来准备绘制。turtle.tracer(5000, 0)调用加快了分形的绘制速度。5000参数告诉turtle模块在渲染屏幕上的绘制之前等待处理 5000 个海龟绘制指令,0参数告诉它在每个绘制指令后暂停 0 毫秒。否则,如果我们只想要最终图像,turtle模块会在每个绘制指令后渲染图像,这会显著减慢程序。

如果你想要减慢绘制速度并观察生成的线条,你可以将这个调用改为turtle.tracer(1, 10)。在制作自己的分形图案时,这可能有助于调试绘制中的任何问题。

turtle.hideturtle()调用隐藏了屏幕上代表海龟当前位置和方向的三角形形状。我们调用这个函数是为了让标记不出现在最终图像中。

使用形状绘制函数

drawFractal()函数使用传递给它的形状绘制函数来绘制分形的各个部分。这通常是一个简单的形状,比如正方形或三角形。分形的美丽复杂性是由于drawFractal()递归调用这个函数来绘制整个分形的每个组件。

Fractal Art Maker 的形状绘制函数有两个参数:sizedepthsize参数是它绘制的正方形或三角形的边长。形状绘制函数应该始终使用基于size的参数来调用turtle.forward(),以便长度在每个递归级别上与size成比例。避免像turtle.forward(100)turtle.forward(200)这样的代码;而是使用基于size参数的代码,比如turtle.forward(size)turtle.forward(size * 2)。在 Python 的turtle模块中,turtle.forward(1)将海龟移动一个单位,这不一定等同于一个像素。

形状绘制函数的第二个参数是drawFractal()的递归深度。对drawFractal()的原始调用将depth参数设置为0。对drawFractal()的递归调用使用depth + 1作为depth参数。在 Wave 分形中,窗口中心的第一个三角形的深度参数为0。接下来创建的三个三角形的深度为1。围绕这三个三角形的九个三角形的深度为2,依此类推。

你的形状绘制函数可以忽略这个参数,但使用它可以导致基本形状的有趣变化。例如,drawFilledSquare()形状绘制函数使用depth来在绘制白色正方形和灰色正方形之间进行交替。如果你想为 Fractal Art Maker 程序创建自己的形状绘制函数,请记住它们必须接受sizedepth参数。

drawFilledSquare()函数

drawFilledSquare()函数绘制了一个边长为size的填充正方形。为了给正方形上色,我们使用了turtle模块的turtle.begin_fill()turtle.end_fill()函数,使正方形变成白色或灰色,带有黑色边框,具体取决于depth参数是偶数还是奇数。因为这些正方形是填充的,稍后绘制在它们上面的任何正方形都会覆盖它们。

就像 Fractal Art Maker 程序的所有形状绘制函数一样,drawFilledSquare()接受sizedepth参数:

def drawFilledSquare(size, depth):
    size = int(size)

size参数可以是带有小数部分的浮点数,这有时会导致turtle模块绘制略微不对称和不均匀的图案。为了防止这种情况,函数的第一行将size四舍五入为整数。

当函数绘制正方形时,它假设海龟位于正方形的中心。因此,海龟必须首先移动到正方形的右上角,相对于它的初始方向:

Python

 # Move to the top-right corner before drawing:
    turtle.penup()
    turtle.forward(size // 2)
    turtle.left(90)
    turtle.forward(size // 2)
    turtle.left(180)
    turtle.pendown()

drawFractal()函数在调用形状绘制函数时总是将笔放下并准备绘制,因此drawFilledSquare()必须调用turtle.penup()以避免在移动到起始位置时绘制一条线。为了找到相对于正方形中心的起始位置,海龟必须先向前移动正方形长度的一半(即size // 2),到达正方形的未来右边缘。接下来,海龟向上转 90 度,然后向前移动size // 2个单位到达右上角。现在海龟面朝错误的方向,所以它向后转了 180 度,并放下笔,这样就可以开始绘制了。

请注意,*top-right*和*up*是相对于海龟最初面对的方向。如果海龟开始面对 0 度向右,或者其朝向为 90、42 或任何其他角度,此代码同样有效。当您创建自己的形状绘制函数时,坚持使用相对海龟移动函数,如turtle.forward()turtle.left()turtle.right(),而不是绝对海龟移动函数,如turtle.goto()`。

接下来,depth参数告诉函数它应该绘制白色正方形还是灰色正方形:

Python

 # Alternate between white and gray (with black border):
    if depth % 2 == 0:
        turtle.pencolor('black')
        turtle.fillcolor('white')
    else:
        turtle.pencolor('black')
        turtle.fillcolor('gray')

如果depth是偶数,则depth % 2 == 0条件为True,正方形的填充颜色为白色。否则,代码将填充颜色设置为灰色。无论哪种情况,由笔颜色确定的正方形边框都设置为黑色。要更改这两种颜色中的任何一种,请使用常见颜色名称的字符串,如redyellow,或包含一个井号和六个十六进制数字的 HTML 颜色代码,如#24FF24表示酸橙绿,#AD7100表示棕色。

网站html-color.codes有许多 HTML 颜色代码的图表。这本黑白书中的分形缺乏颜色,但您的计算机可以以丰富的颜色范围呈现自己的分形!

颜色设置好后,我们最终可以绘制实际正方形的四条线:

Python

 # Draw a square:
    turtle.begin_fill()
    for i in range(4): # Draw four lines.
        turtle.forward(size)
        turtle.right(90)
    turtle.end_fill()

为了告诉turtle模块我们打算绘制填充形状而不仅仅是轮廓,我们调用了turtle.begin_fill()函数。接下来是一个for循环,绘制长度为size的线并将海龟向右转 90 度。for循环重复四次,以创建正方形。当函数最终调用turtle.end_fill()时,填充的正方形出现在屏幕上。

drawTriangleOutline()函数

第二个形状绘制函数绘制了边长为size的等边三角形的轮廓。该函数绘制的三角形是以一个顶点在顶部,两个顶点在底部的方向。图 13-6 说明了等边三角形的各种尺寸。

等边三角形的注解图,显示以下属性。大小:一边的长度。角度:60 度。高度:大小乘以 math.sqrt(3) / 2。还显示了高度的三分之二和三分之一。

图 13-6:边长为size的等边三角形的测量

在我们开始绘制之前,我们必须根据其边长确定三角形的高度。几何学告诉我们,对于边长为L的等边三角形,三角形的高度hL乘以根号 3 除以 2。在我们的函数中,L对应于size参数,因此我们的代码设置高度变量如下:

`height = size * math.sqrt(3) / 2`

几何学还告诉我们,三角形的中心距离底边的高度为高度的三分之一,距离顶点的高度为高度的三分之二。这为我们提供了将海龟移动到起始位置所需的信息:

Python

def drawTriangleOutline(size, depth):
    size = int(size)

    # Move the turtle to the top of the equilateral triangle:
    height = size * math.sqrt(3) / 2
    turtle.penup()
    turtle.left(90) # Turn to face upward.
    turtle.forward(height * (2/3)) # Move to the top corner.
    turtle.right(150) # Turn to face the bottom-right corner.
    turtle.pendown()

为了到达顶角,我们将乌龟左转 90 度面朝上(相对于乌龟原始朝向右转 0 度),然后向前移动height * (2/3)个单位。乌龟仍然面朝上,所以要开始在右侧绘制线条,乌龟必须右转 90 度面向右侧,然后再转 60 度面向三角形的右下角。这就是为什么我们调用turtle.right(150)

此时,乌龟已准备好开始绘制三角形,因此我们通过调用turtle.pendown()来放下画笔。for循环将处理绘制三条边:

Python

 # Draw the three sides of the triangle:
    for i in range(3):
        turtle.forward(size)
        turtle.right(120)

绘制实际三角形是向前移动size单位,然后向右转 120 度,分别进行三次。第三次和最后一次 120 度转向使乌龟面对其原始方向。您可以在图 13-7 中看到这些移动和转向。

四个等边三角形的图示。每个三角形有一条额外的加粗线,代表绘制三角形并将乌龟返回到其原始朝向所需的步骤。

图 13-7:绘制等边三角形涉及三次向前移动和三次 120 度转向。

drawTriangleOutline()函数只绘制轮廓而不是填充形状,因此不像drawFilledSquare()那样调用turtle.begin_fill()turtle.end_fill()

使用分形绘图函数

现在我们有两个样本绘图函数可以使用,让我们来看一下分形艺术制作项目中的主要函数drawFractal()。这个函数有三个必需参数和一个可选参数:shapeDrawFunctionsizespecsmaxDepth

shapeDrawFunction参数期望一个函数,比如drawFilledSquare()drawTriangleOutline()size参数期望传递给绘图函数的起始大小。通常,值在100500之间是一个不错的起始大小,尽管这取决于您的形状绘制函数中的代码,并且找到合适的值可能需要进行实验。

specs参数期望一个字典列表,指定递归调用drawFractal()时递归形状应该如何改变大小、位置和角度。这些规格稍后在本节中描述。

为了防止drawFractal()递归调用导致堆栈溢出,maxDepth参数保存了drawFractal()应该递归调用自身的次数。默认情况下,maxDepth的值为8,但如果需要更多或更少的递归形状,可以提供不同的值。

第五个参数depthdrawFractal()的递归调用处理,并默认为0。调用drawFractal()时不需要指定它。

设置函数

drawFractal()函数的第一件事是检查其两个基本情况:

Python

def drawFractal(shapeDrawFunction, size, specs, maxDepth=8, depth=0):
    if depth > maxDepth or size < 1:
        return # BASE CASE

如果depth大于maxDepth,函数将停止递归并返回。另一个基本情况是如果size小于1,此时绘制的形状将太小而无法在屏幕上看到,因此函数应该简单地返回。

我们用三个变量initialXinitialYinitialHeading来跟踪乌龟的原始位置和朝向。这样,无论形状绘制函数将乌龟定位在何处或者朝向何方,drawFractal()都可以将乌龟恢复到原始位置和朝向,以便进行下一次递归调用:

Python

 # Save the position and heading at the start of this function call:
    initialX = turtle.xcor()
    initialY = turtle.ycor()
    initialHeading = turtle.heading()

turtle.xcor()turtle.ycor()函数返回乌龟在屏幕上的绝对 x 和 y 坐标。turtle.heading()函数返回乌龟指向的方向,单位为度。

接下来的几行调用传递给shapeDrawFunction参数的形状绘制函数:

Python

 # Call the draw function to draw the shape:
    turtle.pendown()
    shapeDrawFunction(size, depth)
    turtle.penup()

由于作为shapeDrawFunction参数的值是一个函数,代码shapeDrawFunction(size, depth)调用此函数,并使用sizedepth中的值。在shapeDrawFunction()调用之前和之后分别将笔降下和抬起,以确保形状绘制函数始终可以期望在绘制开始时笔是放下的。

使用规范字典

在调用shapeDrawFunction()之后,drawFractal()的其余代码致力于根据specs列表中的规范进行递归调用drawFractal()。对于每个字典,drawFractal()都会对drawFractal()进行一次递归调用。如果specs是一个具有一个字典的列表,则每次调用drawFractal()都会导致对drawFractal()的一次递归调用。如果specs是一个具有三个字典的列表,则每次调用drawFractal()都会导致对drawFractal()的三次递归调用。

specs参数中的字典为每个递归调用提供了规范。这些字典中的每一个都具有sizeChangexChangeyChangeangleChange键。这些键规定了分形的大小、海龟的位置以及海龟的航向如何在递归的drawFractal()调用中改变。表 13-1 描述了规范中的四个键。

表 13-1:规范字典中的键

默认值描述
sizeChange1.0下一个递归形状的大小值是当前大小乘以这个值。
xChange0.0下一个递归形状的 x 坐标是当前 x 坐标加上当前大小乘以这个值。
yChange0.0下一个递归形状的 y 坐标是当前 y 坐标加上当前大小乘以这个值。
angleChange0.0下一个递归形状的起始角度是当前起始角度加上这个值。

让我们来看一下四角分形的规范字典,它产生了之前在图 13-1 中显示的左上角图像。对于四角分形的drawFractal()调用,传递了以下字典列表作为specs参数:

Python

[{'sizeChange': 0.5, 'xChange': -0.5, 'yChange': 0.5},
 {'sizeChange': 0.5, 'xChange': 0.5, 'yChange': 0.5},
 {'sizeChange': 0.5, 'xChange': -0.5, 'yChange': -0.5},
 {'sizeChange': 0.5, 'xChange': 0.5, 'yChange': -0.5}]

specs列表有四个字典,因此每次调用drawFractal()绘制一个正方形,都会递归调用drawFractal()四次,以绘制另外四个正方形。图 13-8 显示了这些正方形的进展(在白色和灰色之间交替)。

为了确定下一个要绘制的正方形的大小,sizeChange键的值乘以当前的size参数。specs列表中的第一个字典具有sizeChange值为0.5,这使得下一个递归调用具有大小参数为350 * 0.5,即175个单位。这使得下一个正方形的大小是前一个正方形的一半。例如,sizeChange值为2.0会使下一个正方形的大小加倍。如果字典没有sizeChange键,则该值默认为1.0,表示大小不变。

六个海龟图形截图。第一个显示一个白色正方形。第二个显示四个更小的灰色正方形覆盖白色正方形的每个角落。第三个显示四个更小的白色正方形覆盖这些更小的灰色正方形的每个角落。这种模式在随后的三个截图中继续。随着正方形开始重叠,它们的轮廓仍然可见。

图 13-8:四角示例的每一步从左到右,从上到下。每个正方形在其角落递归产生四个更小的正方形,颜色在白色和灰色之间交替。

要确定下一个正方形的 x 坐标,首个字典的xChange值,在这种情况下是-0.5,乘以大小。当size350时,这意味着下一个正方形相对于海龟当前位置有一个 x 坐标为-175单位。这个xChange值和yChange键的值为0.5,将下一个正方形的位置放置在当前正方形位置的左侧和上方 50%的距离。这恰好将其居中在当前正方形的左上角。

如果你看一下specs列表中的其他三个字典,你会注意到它们的sizeChange值都是0.5。它们之间的区别在于它们的xChangeyChange值将它们放置在当前正方形的其他三个角落。因此,下一个四个正方形是在当前正方形的四个角上居中绘制的。

这个例子中specs列表中的字典没有angleChange值,因此这个值默认为0.0度。正的angleChange值表示逆时针旋转,而负值表示顺时针旋转。

每个字典代表每次递归函数调用时要绘制的一个单独的正方形。如果我们从specs列表中删除第一个字典,每个drawFractal()调用将只产生三个正方形,就像图 13-9 中一样。

与图 13-8 中相同的六个截图,只是图案只在原始正方形的三个角上发展。

图 13-9:从specs列表中删除第一个字典的四个角分形

应用规范

让我们看看drawFractal()中的代码实际上是如何做我们描述的一切的:

Python

 # RECURSIVE CASE
    for spec in specs:
        # Each dictionary in specs has keys 'sizeChange', 'xChange',
        # 'yChange', and 'angleChange'. The size, x, and y changes
        # are multiplied by the size parameter. The x change and y
        # change are added to the turtle's current position. The angle
        # change is added to the turtle's current heading.
        sizeCh = spec.get('sizeChange', 1.0)
        xCh = spec.get('xChange', 0.0)
        yCh = spec.get('yChange', 0.0)
        angleCh = spec.get('angleChange', 0.0)

for循环将specs列表中的单个规范字典分配给循环变量spec的每次迭代。get()字典方法调用从这个字典中提取sizeChangexChangeyChangeangleChange键的值,并将它们分配给更短的名称sizeChxChyChangleCh变量。如果键在字典中不存在,get()方法会替换默认值。

接下来,海龟的位置和朝向被重置为首次调用drawFractal()时指示的值。这确保了来自先前循环迭代的递归调用不会使海龟停留在其他位置。然后根据angleChxChyCh变量改变朝向和位置:

Python

 # Reset the turtle to the shape's starting point:
        turtle.goto(initialX, initialY)
        turtle.setheading(initialHeading + angleCh)

        turtle.forward(size * xCh)
        turtle.left(90)
        turtle.forward(size * yCh)
        turtle.right(90)

x-change 和 y-change 位置是相对于海龟当前的朝向来表达的。如果海龟的朝向是0,海龟的相对 x 轴与屏幕上的实际 x 轴相同。然而,如果海龟的朝向是45,海龟的相对 x 轴就会倾斜 45 度。沿着海龟的相对 x 轴“向右”移动将以一个向上和向右的角度移动。

这就是为什么通过size * xCh向前移动会沿着其相对 x 轴移动。如果xCh为负,turtle.forward()会沿着海龟的相对 x 轴向左移动。turtle.left(90)调用将海龟指向其相对 y 轴,turtle.forward(size * yCh)将海龟移动到下一个形状的起始位置。然而,turtle.left(90)调用改变了海龟的朝向,所以调用turtle.right(90)将其重置回原始方向。

图 13-10 展示了这四行代码如何沿着海龟的相对 x 轴向右移动,沿着相对 y 轴向上移动,并且无论初始朝向如何,都将其保留在正确的朝向。

相同的两条垂直线的四个海龟图形截图,每次以不同的方式旋转。

图 13-10:在这四个图像中,海龟总是沿着其初始朝向的相对 x 轴和 y 轴移动 100 个单位“向右”和“向上”。

最后,当乌龟处于正确的位置和朝向下一个形状时,我们对 drawFractal()进行递归调用:

Python

 # Make the recursive call:
        drawFractal(shapeDrawFunction, size * sizeCh, specs, maxDepth, 
        depth + 1)

shapeDrawFunction,specs 和 maxDepth 参数未经修改地传递给递归 drawFractal()调用。 但是,传递 size * sizeCh 作为下一个 size 参数以反映递归形状的 size 的变化,并且传递 depth + 1 作为 depth 参数以增加下一个形状绘制函数调用的深度。

创建示例分形

既然我们已经介绍了形状绘制函数和递归 drawFractal()函数的工作原理,让我们来看看随附 Fractal Art Maker 的九个示例分形。 您可以在图 13-1 中看到这些示例。

Four Corners

第一个分形是 Four Corners,它开始作为一个大正方形。 随着函数调用自身,分形的规格导致在正方形的四个角落绘制四个较小的正方形:

Python

if DRAW_FRACTAL == 1:
    # Four Corners:
    drawFractal(drawFilledSquare, 350,
        [{'sizeChange': 0.5, 'xChange': -0.5, 'yChange': 0.5},
         {'sizeChange': 0.5, 'xChange': 0.5, 'yChange': 0.5},
         {'sizeChange': 0.5, 'xChange': -0.5, 'yChange': -0.5},
         {'sizeChange': 0.5, 'xChange': 0.5, 'yChange': -0.5}], 5)

这里对 drawFractal()的调用将最大深度限制为 5,因为再多会使分形变得如此密集,以至于细节变得难以看清。 这个分形出现在图 13-8 中。

螺旋正方形

Spiral Squares fractal也以一个大正方形开始,但每次递归调用时只创建一个新的正方形:

Python

elif DRAW_FRACTAL == 2:
    # Spiral Squares:
    drawFractal(drawFilledSquare, 600, [{'sizeChange': 0.95,
        'angleChange': 7}], 50)

这个正方形稍小,并旋转了 7 度。 所有正方形的中心都没有改变,所以不需要向规范中添加 xChange 和 yChange 键。 默认的最大深度为 8 太小,无法得到有趣的分形,因此我们将其增加到 50 以产生催眠螺旋图案。

双螺旋正方形

Double Spiral Squares fractal类似于 Spiral Squares,只是每个正方形创建两个较小的正方形。 这会产生有趣的扇形效果,因为第二个正方形稍后绘制,往往会覆盖先前绘制的正方形:

Python

elif DRAW_FRACTAL == 3:
    # Double Spiral Squares:
    drawFractal(drawFilledSquare, 600,
        [{'sizeChange': 0.8, 'yChange': 0.1, 'angleChange': -10},
         {'sizeChange': 0.8, 'yChange': -0.1, 'angleChange': 10}])

正方形的创建略高于或低于其上一个正方形,并且旋转了 10 度或-10 度。

Triangle Spiral

Triangle Spiral fractal,螺旋正方形的另一种变体,使用 drawTriangleOutline()形状绘制函数而不是 drawFilledSquare():

Python

elif DRAW_FRACTAL == 4:
    # Triangle Spiral:
    drawFractal(drawTriangleOutline, 20,
        [{'sizeChange': 1.05, 'angleChange': 7}], 80)

与螺旋正方形分形不同,Triangle Spiral 分形从 20 个单位的小 size 开始,并在每个递归级别略微增加大小。 sizeChange 键大于 1.0,因此形状始终在增大。 这意味着当递归达到深度 80 时,基本情况发生,因为 size 小于 1 的基本情况永远不会发生。

康威的生命游戏 Glider

康威的生命游戏是细胞自动机的著名例子。 游戏的简单规则导致在 2D 网格上出现有趣且极其混乱的图案。 其中一种图案是由 5 个单元格组成的 3×3 空间的Glider

Python

elif DRAW_FRACTAL == 5:
    # Conway's Game of Life Glider:
    third = 1 / 3
    drawFractal(drawFilledSquare, 600,
        [{'sizeChange': third, 'yChange': third},
         {'sizeChange': third, 'xChange': third},
         {'sizeChange': third, 'xChange': third, 'yChange': -third},
         {'sizeChange': third, 'yChange': -third},
         {'sizeChange': third, 'xChange': -third, 'yChange': -third}])

这里的 Glider 分形在其五个单元格中各有额外的 Glider 绘制。 third 变量有助于精确设置 3×3 空间中递归形状的位置。

您可以在我的书《The Big Book of Small Python Projects》(No Starch Press,2021)中找到康威的生命游戏的 Python 实现,并在inventwithpython.com/bigbookpython/project13.html上找到在线版本。 不幸的是,数学家和教授约翰·康威于 2020 年 4 月因 COVID-19 并发症去世。

谢尔宾斯基三角形

我们在第九章创建了 Sierpiński Triangle 分形,但是我们的 Fractal Art Maker 也可以使用 drawTriangleOutline()形状函数重新创建它。 毕竟,谢尔宾斯基三角形是一个内部绘制了三个较小的等边三角形的等边三角形:

Python

elif DRAW_FRACTAL == 6:
    # Sierpiński Triangle:
    toMid = math.sqrt(3) / 6
    drawFractal(drawTriangleOutline, 600,
        [{'sizeChange': 0.5, 'yChange': toMid, 'angleChange': 0},
         {'sizeChange': 0.5, 'yChange': toMid, 'angleChange': 120},
         {'sizeChange': 0.5, 'yChange': toMid, 'angleChange': 240}])

这些较小三角形的中心距离上一个三角形的中心是size * math.sqrt(3) / 6单位。这三次调用将乌龟的方向调整为0120240度,然后在乌龟的相对 y 轴上移动。

波形

我们在本章的开头讨论了波形分形,你可以在图 13-5 中看到它。这个相对简单的分形创建了三个较小且不同的递归三角形:

Python

elif DRAW_FRACTAL == 7:
    # Wave:
    drawFractal(drawTriangleOutline, 280,
        [{'sizeChange': 0.5, 'xChange': -0.5, 'yChange': 0.5},
         {'sizeChange': 0.3, 'xChange': 0.5, 'yChange': 0.5},
         {'sizeChange': 0.5, 'yChange': -0.7, 'angleChange': 15}])

角分形类似于公羊的角:

Python

elif DRAW_FRACTAL == 8:
    # Horn:
    drawFractal(drawFilledSquare, 100,
        [{'sizeChange': 0.96, 'yChange': 0.5, 'angleChange': 11}], 100)

这个简单的分形由一系列正方形组成,每个正方形都比前一个正方形稍微小一些,向上移动,并旋转11度。我们将最大递归深度增加到100,以将角延伸成紧密的螺旋。

雪花

最终的分形雪花由以五边形图案布置的正方形组成。这类似于四角分形,但它使用了五个均匀间隔的递归正方形,而不是四个:

Python

elif DRAW_FRACTAL == 9:
    # Snowflake:
    drawFractal(drawFilledSquare, 200,
        [{'xChange': math.cos(0 * math.pi / 180),
          'yChange': math.sin(0 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(72 * math.pi / 180),
          'yChange': math.sin(72 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(144 * math.pi / 180),
          'yChange': math.sin(144 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(216 * math.pi / 180),
          'yChange': math.sin(216 * math.pi / 180), 'sizeChange': 0.4},
         {'xChange': math.cos(288 * math.pi / 180),
          'yChange': math.sin(288 * math.pi / 180), 'sizeChange': 0.4}])

这个分形使用三角函数中的余弦和正弦函数,在 Python 的math.cos()math.sin()函数中实现,来确定如何沿着 x 轴和 y 轴移动正方形。一个完整的圆有 360 度,所以为了均匀地在这个圆中间放置五个递归正方形,我们将它们放置在 0、72、144、216 和 288 度的间隔处。math.cos()math.sin()函数希望角度参数是弧度而不是度数,所以我们必须将这些数字乘以math.pi / 180

最终的结果是,每个正方形都被另外五个正方形所包围,这些正方形又被另外五个正方形所包围,依此类推,形成了一个类似雪花的晶体状分形。

生成单个正方形或三角形

为了完整起见,你还可以将DRAW_FRACTAL设置为1011,以查看单次调用drawFilledSquare()drawTriangleOutline()在乌龟窗口中产生的效果。这些形状的大小为600

Python

elif DRAW_FRACTAL == 10:
    # The filled square shape:
    turtle.tracer(1, 0)
    drawFilledSquare(400, 0)
elif DRAW_FRACTAL == 11:
    # The triangle outline shape:
    turtle.tracer(1, 0)
    drawTriangleOutline(400, 0)
turtle.exitonclick() # Click the window to exit.

在根据DRAW_FRACTAL中的值绘制分形或形状之后,程序调用turtle.exitonclick(),这样乌龟窗口会一直保持打开,直到用户点击它。然后程序终止。

创建你自己的分形

你可以通过改变传递给drawFractal()函数的规范来创建自己的分形。首先考虑每次调用drawFractal()生成多少个递归调用,以及形状的大小、位置和方向应该如何改变。你可以使用现有的形状绘制函数,也可以创建自己的函数。

例如,图 13-11 展示了九个内置的分形,除了正方形和三角形函数已经交换。其中一些产生了平淡的形状,但其他一些可能会产生意想不到的美丽。

图 13-1 中的六个分形,所有的正方形和三角形都已经交换。

图 13-11:分形艺术制作器附带的九个分形,形状绘制函数已经交换

总结

分形艺术制作器项目展示了递归的无限可能性。一个简单的递归drawFractal()函数,配合一个绘制形状的函数,可以创造出各种各样的详细几何艺术。

在分形艺术制作器的核心是递归的drawFractal()函数,它接受另一个函数作为参数。这个第二个函数通过使用规范字典列表中给定的大小、位置和方向,重复绘制一个基本形状。

你可以测试无限数量的形状绘制函数和规范设置。让你的创造力驱动你的分形项目,当你在这个程序中进行实验时。

进一步阅读

有一些网站可以让您创建分形。交互式分形树在www.visnos.com/demos/fractal上有滑块可以改变二叉树分形的角度和大小参数。procedural-snowflake.glitch.me上的程序性雪花可以在您的浏览器中生成新的雪花。Nico 的分形机在sciencevsmagic.net/fractal上创建分形的动画图。您可以通过在网络上搜索分形生成器在线分形生成器来找到其他网站。

十四、Droste 生成器

原文:Chapter 14 - Droste Maker

译者:飞龙

协议:CC BY-NC-SA 4.0

Droste 效应是一种递归艺术技术,以荷兰品牌 Droste 可可的 1904 年插图命名。在图 14-1 中,这个罐子上有一个护士拿着一个托盘,托盘上有一个 Droste 可可的罐子,罐子上有这个插图。

在本章中,我们将创建一个 Droste 生成器程序,可以从您拥有的任何照片或图纸生成类似的递归图像,无论是一个在博物馆观看自己展品的参观者,一只猫在另一只猫前面的计算机显示器,还是其他任何东西。

1904 年的 Droste 可可罐。罐子上的插图是一个护士拿着一个托盘,托盘上有一杯冒着热气的杯子和一个 Droste 可可罐。这个罐子上的递归插图是同一个护士拿着一个托盘,托盘上有一杯冒着热气的杯子和一个 Droste 可可罐。

图 14-1: Droste 可可罐上的递归插图

使用诸如 Microsoft Paint 或 Adobe Photoshop 之类的图形程序,您将通过用纯品红色覆盖图像的一部分来准备图像,指示递归图像的放置位置。Python 程序使用 Pillow 图像库读取这些图像数据并生成递归图像。

首先,我们将介绍如何安装 Pillow 库以及 Droste 生成器算法的工作原理。接下来,我们将提供程序的 Python 源代码,并解释代码。

安装 Pillow Python 库

本章的项目需要 Pillow 图像库。这个库允许您的 Python 程序创建和修改图像文件,包括 PNG、JPEG 和 GIF。它有几个函数可以执行调整大小、复制、裁剪和其他常见的图像操作。

要在 Windows 上安装此库,请打开命令提示窗口并运行py -m pip install --user pillow。要在 macOS 或 Linux 上安装此库,请打开终端窗口并运行 python3 -m pip install --user pillow。此命令使 Python 使用 pip 安装程序从pypi.org官方 Python 软件包索引下载模块。

要验证安装是否成功,请打开 Python 终端并运行from PIL import Image。(虽然库的名称是 Pillow,但安装的 Python 模块名为PIL,大写字母。)如果没有出现错误,则库已正确安装。

Pillow 的官方文档可以在pillow.readthedocs.io找到。

绘制您的图像

下一步是通过将图像的一部分设置为 RGB(红色、绿色、蓝色)颜色值(255, 0, 255)来准备图像。计算机图形通常使用品红色来标记图像的哪些像素应该被渲染为透明。我们的程序将把这些品红色像素视为视频制作中的绿屏,用初始图像的调整版本替换它们。当然,这个调整后的图像将有自己更小的品红区域,程序将用另一个调整后的图像替换它。当最终图像没有更多品红像素时,基本情况发生,此时算法完成。

图 14-2 显示了随着调整大小的图像递归应用到品红色像素而创建的图像的进展。在这个例子中,一个模特站在一个被品红色像素替换的艺术博物馆展品前,将照片本身变成了展品。你可以从inventwithpython.com/museum.png下载这个基础图像。

确保在你的图像中只使用纯(255, 0, 255)品红色来绘制品红色区域。一些工具可能会产生淡化效果,产生更自然的外观。例如,Photoshop 的画笔工具会在绘制区域的轮廓上产生淡化的品红色像素,所以你需要使用铅笔工具,它只使用你选择的精确纯品红色来绘制。如果你的图形程序不允许你指定绘制的精确 RGB 颜色,你可以从inventwithpython.com/magenta.png的 PNG 图像中复制和粘贴颜色。

图像中的品红色区域可以是任意大小或形状;它不必是一个精确的、连续的矩形。你可以在图 14-2 中看到,博物馆参观者切入品红色矩形,将他们放在递归图像的前面。

如果你用 Droste Maker 制作自己的图像,你应该使用 PNG 图像文件格式而不是 JPEG。JPEG 图像使用有损压缩技术来保持文件大小小,引入了轻微的瑕疵。这些通常对人眼来说是不可察觉的,不会影响整体图像质量。然而,这种有损压缩会用稍微不同色调的品红色像素取代纯(255, 0, 255)品红色。PNG 图像的无损压缩确保这种情况不会发生。

一个女孩的四张图像,从背后看着一件艺术品。在第一张图像中,艺术品被单色矩形覆盖。在第二张图像中,单色矩形被原始女孩图像的调整大小版本替换。在第三和第四张图像中,单色矩形再次被替换,产生了女孩看着自己看着自己的效果。

图 14-2:图像递归应用到品红色像素。如果你在本书中查看黑白图像,品红色区域是博物馆参观者前面的矩形。

完整的 Droste Maker 程序

以下是drostemaker.py的源代码;因为这个程序依赖于仅限于 Python 的 Pillow 库,所以在本书中这个项目没有 JavaScript 的等价物:

from PIL import Image

def makeDroste(baseImage, stopAfter=10):
    # If baseImage is a string of an image filename, load that image:
    if isinstance(baseImage, str):
        baseImage = Image.open(baseImage)

    if stopAfter == 0:
        # BASE CASE
        return baseImage
    # The magenta color has max red/blue/alpha, zero green:
    if baseImage.mode == 'RGBA':
        magentaColor = (255, 0, 255, 255)
    elif baseImage.mode == 'RGB':
        magentaColor = (255, 0, 255)

    # Find the dimensions of the base image and its magenta area:
    baseImageWidth, baseImageHeight = baseImage.size
    magentaLeft = None
    magentaRight = None
    magentaTop = None
    magentaBottom = None

    for x in range(baseImageWidth):
        for y in range(baseImageHeight):
            if baseImage.getpixel((x, y)) == magentaColor:
                if magentaLeft is None or x < magentaLeft:
                    magentaLeft = x
                if magentaRight is None or x > magentaRight:
                    magentaRight = x
                if magentaTop is None or y < magentaTop:
                    magentaTop = y
                if magentaBottom is None or y > magentaBottom:
                    magentaBottom = y

    if magentaLeft is None:
        # BASE CASE - No magenta pixels are in the image.
        return baseImage

    # Get a resized version of the base image:
    magentaWidth = magentaRight - magentaLeft + 1
    magentaHeight = magentaBottom - magentaTop + 1
    baseImageAspectRatio = baseImageWidth / baseImageHeight
    magentaAspectRatio = magentaWidth / magentaHeight

    if baseImageAspectRatio < magentaAspectRatio:
        # Make the resized width match the width of the magenta area:
        widthRatio = magentaWidth / baseImageWidth
        resizedImage = baseImage.resize((magentaWidth, 
        int(baseImageHeight * widthRatio) + 1), Image.NEAREST)
    else:
        # Make the resized height match the height of the magenta area:
        heightRatio =  magentaHeight / baseImageHeight
 resizedImage = baseImage.resize((int(baseImageWidth * 
        heightRatio) + 1, magentaHeight), Image.NEAREST)

    # Replace the magenta pixels with the smaller, resized image:
    for x in range(magentaLeft, magentaRight + 1):
        for y in range(magentaTop, magentaBottom + 1):
            if baseImage.getpixel((x, y)) == magentaColor:
                pix = resizedImage.getpixel((x - magentaLeft, y - magentaTop))
                baseImage.putpixel((x, y), pix)

    # RECURSIVE CASE:
    return makeDroste(baseImage, stopAfter=stopAfter - 1)

recursiveImage = makeDroste('museum.png')
recursiveImage.save('museum-recursive.png')
recursiveImage.show()

在运行这个程序之前,将你的图像文件放在与drostemaker.py相同的文件夹中。程序将递归图像保存为museum-recursive.png,然后打开一个图像查看器来显示它。如果你想在你自己添加了品红色区域的图像上运行程序,用你的图像文件的名称替换源代码末尾的makeDroste('museum.png'),用你想要用来保存递归图像的名称替换save('museum-recursive.png')

设置

Droste Maker 程序只有一个函数makeDroste(),它接受一个 Pillow Image对象或一个图像文件名的字符串。该函数返回一个 Pillow Image对象,其中任何品红色像素都被同一图像的版本递归地替换:

Python

from PIL import Image

def makeDroste(baseImage, stopAfter=10):
    # If baseImage is a string of an image filename, load that image:
    if isinstance(baseImage, str):
        baseImage = Image.open(baseImage)

程序开始时从 Pillow 库(作为 Python 模块命名为PIL)导入Image类。在makeDroste()函数内部,我们检查baseImage参数是否是一个字符串,如果是,我们将其替换为从相应图像文件加载的 Pillow Image对象。

接下来,我们检查stopAfter参数是否为0。如果是,我们已经达到了算法的一个基本情况,函数将返回基础图像的 Pillow Image对象:

Python

 if stopAfter == 0:
        # BASE CASE
        return baseImage

如果函数调用没有提供stopAfter,则stopAfter参数默认为10。在此函数中稍后对makeDroste()的递归调用将stopAfter - 1作为该参数的参数传递,以便它在每次递归调用时减少,并接近0的基本情况。

例如,将0传递给stopAfter会导致函数立即返回与基本图像相同的递归图像。将1传递给stopAfter会替换品红区域为递归图像一次,进行一次递归调用,达到基本情况,并立即返回。将2传递给stopAfter会导致两次递归调用,依此类推。

该参数防止函数在品红区域特别大时递归,直到导致堆栈溢出。它还允许我们传递比10更小的参数,以限制放置在基本图像中的递归图像的数量。例如,通过为stopAfter参数传递0123,可以创建图 14-2 中的四幅图像。

接下来,我们检查基本图像的颜色模式。这可以是RGB,表示具有红绿蓝像素的图像,或者RGBA,表示具有像素 alpha 通道的图像。alpha 值表示像素的透明级别。以下是代码:

Python

 # The magenta color has max red/blue/alpha, zero green:
    if baseImage.mode == 'RGBA':
        magentaColor = (255, 0, 255, 255)
    elif baseImage.mode == 'RGB':
        magentaColor = (255, 0, 255)

Droste Maker 需要知道颜色模式,以便它可以找到品红像素。每个通道的值范围从0255,品红像素具有最大量的红色和蓝色,但没有绿色。此外,如果存在 alpha 通道,对于完全不透明的颜色,它将设置为255,对于完全透明的颜色,它将设置为0。根据baseImage.mode中给出的图像颜色模式,magentaColor变量设置为品红像素的正确元组值。

寻找品红区域

在程序可以递归地将图像插入品红区域之前,它必须找到图像中品红区域的边界。这涉及找到图像中最左、最右、最上和最下的品红像素。

虽然品红区域本身不需要是一个完美的矩形,但程序需要知道品红的矩形边界,以便正确调整图像以进行插入。例如,图 14-3 显示了蒙娜丽莎的基本图像,其中品红区域用白色轮廓标出。品红像素被替换以生成递归图像。

蒙娜丽莎的两幅图像。在第一幅图像中,女人的脸和躯干被单色形状替换,白色矩形表示该形状的边界。在第二幅图像中,单色区域已被原始图像的逐渐缩小版本替换。

图 14-3:带有白色轮廓的品红区域的基本图像(左)及其生成的递归图像(右)

为了计算调整大小和调整后图像的放置位置,程序从baseImage中 PillowImage对象的size属性中检索基本图像的宽度和高度。以下行初始化了四个变量,用于品红区域的四个边缘——magentaLeftmagentaRightmagentaTopmagentaBottom——并将其值设置为None

Python

 # Find the dimensions of the base image and its magenta area:
    baseImageWidth, baseImageHeight = baseImage.size
    magentaLeft = None
    magentaRight = None
    magentaTop = None
    magentaBottom = None

这些边缘变量的值在接下来的代码中被整数xy坐标替换:

Python

 for x in range(baseImageWidth):
        for y in range(baseImageHeight):
            if baseImage.getpixel((x, y)) == magentaColor:
                if magentaLeft is None or x < magentaLeft:
                    magentaLeft = x
                if magentaRight is None or x > magentaRight:
                    magentaRight = x
                if magentaTop is None or y < magentaTop:
                    magentaTop = y
                if magentaBottom is None or y > magentaBottom:
                    magentaBottom = y

这些嵌套的for循环在基本图像的每个可能的 x、y 坐标上迭代xy变量。我们检查每个坐标处的像素是否为存储在magentaColor中的纯品红色,然后更新magentaLeft变量,如果品红像素的坐标比magentaLeft中当前记录的更靠左,则对其他三个方向也是如此。

当嵌套的for循环完成时,magentaLeftmagentaRightmagentaTopmagentaBottom将描述基本图像中品红像素的边界。如果图像没有品红像素,这些变量将保持设置为它们最初的None值:

Python

 if magentaLeft is None:
        # BASE CASE - No magenta pixels are in the image.
        return baseImage

如果嵌套的for循环完成后magentaLeft(或者实际上是这四个变量中的任何一个)仍然设置为None,则图像中没有品红像素。这是我们递归算法的基本情况,因为随着每次对makeDroste()的递归调用,品红区域会变得越来越小。此时,函数返回baseImage中的 PillowImage对象。

调整基本图像的大小

我们需要将基本图像调整大小以完全覆盖品红区域,不多不少。图 14-4 显示了完整的调整大小后的图像透明地叠加在原始基本图像上。这个调整大小后的图像被裁剪,以便只有覆盖品红像素的部分被复制到最终图像中。

一只猫坐在计算机显示器前的四幅图像。在第一幅图像中,计算机显示器的屏幕被单色遮盖。在第二幅图像中,单色区域被原始图像的较小版本替换,但这个版本是透明的,可以看到它与较大图像的非单色部分重叠的地方。第三幅图像是完成的递归图像。

图 14-4:带有显示器中品红区域的基本图像(顶部),覆盖在基本图像上的调整大小后的图像(中部),以及替换仅品红像素的最终递归图像(底部)

我们不能简单地将基本图像调整大小到品红区域的尺寸,因为两者不太可能具有相同的长宽比,即宽度除以高度的比例。这样做会导致一个看起来被拉伸或压缩的递归图像,就像图 14-5 一样。

相反,我们必须使调整大小后的图像足够大,以完全覆盖品红区域,但仍保留图像的原始长宽比。这意味着要么将调整大小后的图像的宽度设置为品红区域的宽度,使得调整大小后的图像的高度等于或大于品红区域的高度,要么将调整大小后的图像的高度设置为品红区域的高度,使得调整大小后的图像的宽度等于或大于品红区域的宽度。

女孩看着自己的递归图像的版本,其中女孩的后续出现看起来扭曲。

图 14-5:将图像调整大小到品红区域的尺寸可能会导致不同的长宽比,使其看起来被拉伸或压缩。

为了计算正确的调整尺寸,程序需要确定基本图像和品红区域的长宽比:

Python

 # Get a resized version of the base image:
    magentaWidth = magentaRight - magentaLeft + 1
    magentaHeight = magentaBottom - magentaTop + 1
    baseImageAspectRatio = baseImageWidth / baseImageHeight
    magentaAspectRatio = magentaWidth / magentaHeight

magentaRightmagentaLeft,我们可以计算出品红区域的宽度。+1是为了一个小的必要调整:如果品红区域的右侧 x 坐标为 11,左侧为 10,宽度将为两个像素。这是通过(magentaRight - magentaLeft + 1)正确计算的,而不是(magentaRight - magentaLeft)。

因为长宽比是宽度除以高度,具有大长宽比的图像比宽度大,具有小长宽比的图像比高度大。长宽比为 1.0 描述了一个完美的正方形。接下来的行设置了基本图像和品红区域的长宽比后调整大小图像的尺寸:

 if baseImageAspectRatio < magentaAspectRatio:
        # Make the resized width match the width of the magenta area:
        widthRatio = magentaWidth / baseImageWidth
        resizedImage = baseImage.resize((magentaWidth, 
        int(baseImageHeight * widthRatio) + 1), Image.NEAREST)
 else:
        # Make the resized height match the height of the magenta area:
        heightRatio =  magentaHeight / baseImageHeight
        resizedImage = baseImage.resize((int(baseImageWidth * 
        heightRatio) + 1, magentaHeight), Image.NEAREST)

如果基础图像的宽高比小于品红色区域的宽高比,则调整大小后的图像的宽度应与品红色区域的宽度匹配。如果基础图像的宽高比大,则调整大小后的图像的高度应与品红色区域的高度匹配。然后,我们通过将基础图像的高度乘以宽度比例或将基础图像的宽度乘以高度比例来确定另一个维度。这确保了调整大小后的图像既完全覆盖品红色区域,又保持与其原始宽高比的比例。

我们调用resize()方法一次,以生成一个新的 PillowImage对象,其大小与基础图像的宽度或高度匹配。第一个参数是一个(宽度,高度)元组,用于新图像的大小。第二个参数是 Pillow 库中的Image.NEAREST常量,告诉resize()方法在调整图像大小时使用最近邻算法。这可以防止resize()方法混合像素颜色以产生平滑的图像。

我们不希望这样,因为这可能会使调整大小后的图像中的品红色像素与相邻的非品红色像素模糊在一起。我们的makeDroste()函数依赖于检测具有精确 RGB 颜色(255, 0, 255)的品红色像素,并且会忽略这些略微偏离的品红色像素。最终结果将是品红色区域周围有一个粉红色的轮廓,这将破坏我们的图像。最近邻算法不会进行这种模糊处理,使我们的品红色像素恰好保持在(255, 0, 255)的品红色。

在图像中递归放置图像

基础图像调整大小后,我们可以将调整大小后的图像放置在基础图像上。但是,调整大小后的图像的像素应该只放置在基础图像中的品红色像素上。调整大小后的图像将被放置在这样一个位置,即调整大小后的图像的左上角位于品红色区域的左上角:

Python

 # Replace the magenta pixels with the smaller, resized image:
    for x in range(magentaLeft, magentaRight + 1):
        for y in range(magentaTop, magentaBottom + 1):
            if baseImage.getpixel((x, y)) == magentaColor:
                pix = resizedImage.getpixel((x - magentaLeft, y - magentaTop))
                baseImage.putpixel((x, y), pix)

两个嵌套的for循环遍历品红色区域中的每个像素。请记住,品红色区域不一定是一个完美的矩形,因此我们要检查当前坐标处的像素是否为品红色。如果是,我们从调整大小后的图像中获取相应坐标处的像素颜色,并将其放置在基础图像上。两个嵌套的for循环完成循环后,基础图像中的品红色像素将被调整大小后的图像中的像素替换。

然而,调整大小后的图像本身可能有品红色的像素,如果是这样,这些像素现在将成为基础图像的一部分,就像图 14-2 的右上图中一样。我们需要将修改后的基础图像传递给递归的makeDroste()调用:

Python

 # RECURSIVE CASE:
    return makeDroste(baseImage, stopAfter - 1)

这一行是我们递归算法中的递归调用,也是makeDroste()函数中的最后一行代码。这种递归处理了从调整大小后的图像复制的新品红色区域。请注意,传递给stopAfter参数的值是stopAfter - 1,确保它更接近0的基本情况。

最后,Droste Maker 程序通过将′museum.png′传递给makeDroste()来开始,以获得递归图像的 PillowImage对象。我们将其保存为一个名为museum-recursive.png的新图像文件,并在新窗口中显示递归图像供用户查看:

Python

recursiveImage = makeDroste('museum.png')
recursiveImage.save('museum-recursive.png')
recursiveImage.show()

您可以将这些文件名更改为计算机上您想要与程序一起使用的任何图像。

makeDroste()函数需要使用递归实现吗?简单地说,不需要。请注意,问题中没有涉及类似树状结构,并且算法不进行回溯,这表明递归可能是对这段代码过度设计的方法。

总结

本章的项目是一个程序,可以生成递归 Droste 效应图像,就像 Droste 的 Cacao 旧罐头上的插图一样。该程序通过使用纯品红像素(RGB 值为(255, 0, 255))来标记图像中应该被较小版本替换的部分来工作。由于这个较小的版本也将有自己较小的品红区域,替换将重复进行,直到品红区域消失以生成递归图像。

我们递归算法的基本情况是当图像中没有更多品红像素可以放置较小的递归图像,或者stopAfter计数器达到0时。否则,递归情况将图像传递给makeDroste()函数,以继续用更小的递归图像替换品红区域。

您可以修改自己的照片以添加品红像素,然后通过 Droste Maker 运行它们。在一个展览中观看自己的博物馆参观者,猫坐在猫前面的计算机显示器前,以及无面孔的《蒙娜丽莎》图像只是您可以用这个递归程序创造的超现实可能性的一些例子。

进一步阅读

维基百科关于 Droste 效应的文章en.wikipedia.org/wiki/Droste_effect中有除 Droste 的 Cacao 之外使用 Droste 效应的产品的例子。荷兰艺术家 M.C. Escher 的作品《Print Gallery》是一个著名的场景,其中也包含了自身,您可以在en.wikipedia.org/wiki/Print_Gallery_(M._C._Escher)了解更多信息。

在 Numberphile YouTube 频道上名为“The Neverending Story (and Droste Effect)”的视频中,Clifford Stoll 博士讨论了递归和 Droste 的 Cacao 盒子艺术youtu.be/EeuLDnOupCI

我的书《Automate the Boring Stuff with Python》第二版(No Starch Press,2019)的第十九章提供了 Pillow 库的基本教程automatetheboringstuff.com/2e/chapter19