用 Elixir 生成像素风格图片 -- Elixir 极简入门

总算决定开通微信公众号了!

注册完后首先犯难的就是用什么头像。用自拍照片不够严肃,用网络图片不够专业,自己设计又不够自信。忽然间想到,既然我自己是程序员,干脆头像弄 geek 一点。GitHub 的默认像素风头像给了我灵感,于是决定自己用程序生成一个。

一番 Google 之后,发现原理其实很简单。用 Elixir 来实现主要是想感受下 Elixir 清爽的编程体验。

一,步骤分解

GitHub 默认头像,被称作 identicon,具体生成原理如下:

  1. 将输入文本字符串经过 MD5 哈希转换成 16 进制文本。
  2. 将 16 进制文本转换成列表。
  3. 从列表中取三个元素,分别对应 RGB 颜色值,用来决定像素图色块的颜色。
  4. 基于列表,生成栅格数据,栅格数据沿垂直中线对称。
  5. 颜色和栅格数据都有了,最后一部调用底层库来画图即可。

从以上步骤中可以看出最终生成的图完全是由输入字符串决定的,每一个不同的输入都生成独特的像素图。

二,生成项目

先按官方文档安装 Erlang 和 Elixir。

在终端运行以下命令,生成项目:

mix new identicon

以上操作生成的项目文件如下:

├── README.md
├── _build
├── deps
├── lib
├── mix.exs
├── mix.lock
└── test

mix.exs 是用来配置项目全局行为的,这里我们只用管 deps 包依赖部分。我们的业务代码放在 lib 文件夹下。

三,Elixir 的数据模型

在使用 Java 或者 JavaScript 等语言时,我们会用 Class 来封装数据模型,这样做的目的是将数据和相关行为联结在一起,以此为基础实现面向对象程序抽象。Elixir 作为一个函数式语言,采用了十分不同的方案。在 Elixir 里面,数据和行为是分离的。(这里指的是作为模型的数据。在函数式编程里面,函数也可以作为数据,这里不讨论)在构建数据模型时,我们采用一个叫 Struct 的数据类型,你可以把他近似理解为 JavaScript 里面的对象。不同的是,Struct 是不可变(immutable)的,它也不提供方法。

我们的 identicon 项目需要一个数据模型来代表图像数据,我们使用 Struct(下称“结构体”)来构建这个模型。

lib 文件夹下新建文件 image.ex,写入以下代码:

defmodule Identicon.Image do
   defstruct hex: nil, color: nil, grid: nil, pixel_map: nil
end

我们新建了一个模块 Image,这个模块存放着我们需要的数据模型。在这个结构体模型中,我们依次声明了 16 进制数,RGB 颜色值,栅格数据,像素图这些数据,并都初始化为 nil

四,分步骤实现功能

下面我们要实现本文第一部分展示的步骤。这些步骤都将一一对应为函数。实现了这些函数之后,我们把函数组合起来即完成了目标功能。

我们把所有函数放到主模块里面。在 lib 文件夹里面新建文件 identicon.ex,然后在其中定义主模块:

defmodule Identicon do
   # ...
end

在这个模块里面我们依步骤编写功能函数。

1. 字符串转 16 进制文本

def hash_input(input) do
   hex = :crypto.hash(:md5, input)
   |> :binary.bin_to_list

   %Identicon.Image{hex: hex}
end

:crypto.hash 是在调用 Erlang 的加密模块。:crypto:md5 这种以冒号开头的字符,是 Atom 数据类型,你可以把它近似理解为 JavaScript 或者 Ruby 里面的 Symbol

这个函数有三个步骤。

  1. 将输入文本转换成 16 进制文本。
  2. 将此文本转换成列表。例如,"hello" 会被转换成 [93, 65, 64, 42, 188, 75, 42, 118, 185, 113, 157, 145, 16, 23, 197, 146]
  3. 将转换结果存到 Image 数据模块。注意,%Identicon.Image{hex: hex} 这段代码是在更新 hex 的值,不是直接改动,类似 JS 里面的 {...obj, a: 'xx'}

hash_input 函数用到了我认为 Elixir 里面最性感的特性,那就是管道操作符 |>,如果你对管道操作的实现原理感兴趣,推荐阅读我的这篇博客文章《从链式调用到管道组合》

2. 挑选颜色

这一步很简单

def pick_color(%Identicon.Image{hex: [r, g, b | _tail]} = image) do
   %Identicon.Image{image | color: {r, g, b}}
end

如果你了解 JS 里面的解构赋值的话,上面这段代码很容易理解。不同的是,这里和 JS 的解构赋值完全不同,而是函数式编程里面强大的模式匹配(pattern matching)。函数式语言里面没有赋值行为,取而代之的是模式匹配。我们从 Image 结构体中取到 hex 列表,取列表前三位,分别匹配到 r, g, b 三个值。注意到 _tail,变量名(Erlang 和 Elixir 里面没有变量,这里依惯例称变量)前面加下划线_,用来标记不会使用到的变量。

3. 构建栅格

def build_grid(%Identicon.Image{hex: hex} = image) do
   grid = hex
   |> Enum.chunk(3)
   |> Enum.map(&mirror_row/1)
   |> List.flatten
   |> Enum.with_index

   %Identicon.Image{image | grid: grid}
end

def mirror_row(row) do
   [first, second | _tail] = row
   row ++ [second, first]
end

build_grid 函数以代表 16 进制数字的列表为输入。依次做了这些事情:

  1. Enum.chunk(3) 将列表每 3 个分组,生成二维列表。凑不齐 3 个的余数被丢弃,这样就生成了 5 组。
  2. 把上一步生成的二维列表的里层列表,转换成中心对称列表。例如 [1, 2, 3] 转换后就是 [1, 2, 3, 2, 1]。这是 mirror_row 函数实现的功能。mirror_row 函数有两个值得注意的地方。一是我们在引用它时,在前面加了 &,这是 Elixir 里面的函数捕获(function capturing)。当我们把函数当入参传递时,需要函数捕获。二是在 Elixir 里面引用函数时,还要带上函数的入参数量(arity),这就是为什么要加尾巴 /1
  3. 把上面转换完的二维列表抹平成一维列表,此时列表有 25 个元素。
  4. 由于 Elixir 的列表不带下标(index),我们稍后要用到下标,就给它加上。用 Enum.with_index 来实现。

4. 把栅格数据中的偶数筛选出来

我们只需要将栅格中的部分着色,我们可以根据栅格数据特征选择任意的挑选规则,这里我用了最简单的规则,把偶数挑选出来即可。

def filter_odd_squares(%Identicon.Image{grid: grid} = image) do
   grid = Enum.filter grid, fn({code, _index}) ->
      rem(code, 2) == 0
   end

   %Identicon.Image{image | grid: grid}
end

5. 构建色块渲染图

def build_pixel_map(%Identicon.Image{grid: grid} = image) do
   pixel_map = Enum.map grid, fn({_, index}) ->
      horizontal = rem(index, 5) * 50
      vertical = div(index, 5) * 50

      top_left = {horizontal, vertical}
      bottom_right = {horizontal + 50, vertical + 50}

      {top_left, bottom_right}
   end

   %Identicon.Image{image | pixel_map: pixel_map}
end

注意函数名中的 pixel 并非真正意义上的像素,把它描述成独立的最基本的色块更合适。把筛选出来的栅格数据进行 map 映射,每个栅格数据点代表一个色块。这里的重点是算出每个色块的左上角和右下角的位置。由于栅格是5x5,我们可以对栅格点(色块)下标分别对 5 取模和取余得到色块左上顶点坐标。

grid

如上图所示,橙色色块对应的栅格点下标是 7,算出左上顶点坐标为 (2, 1)

我们规定每个色块长宽 50 像素。拿到左上顶点坐标后,各坐标乘以 50 得到当前色块左上顶点离栅格图渲染原点(origin)的横向和纵向距离。左上角顶点各加 50 即得到了右下顶点到 origin 的距离。

6,画图

def draw_image(%Identicon.Image{color: color, pixel_map: pixel_map}) do
   image = :egd.create(250, 250)
   fill = :egd.color(color)

   Enum.each pixel_map, fn({start, stop}) ->
      :egd.filledRectangle(image, start, stop, fill)
   end

   :egd.render(image)
end

def save_image(image, input) do
   File.write("#{input}.png", image)
end

数据都准备好之后,剩下的就是调用 Erlang 底层图形库来画图了。这里我们用到了 :egd 这个模块。在 mix.exs 文件里找到 deps 模块,加入这个依赖:{:egd, github: "erlang/egd"}. 然后在终端运行 mix deps.get 安装依赖。

draw_image 首先创建一张 250x250 的图片(你应该能算出来),然后把我们前面几步算出来的颜色和色块图扔给 :egd 绘制就行了。绘制完成之后调用文件系统,写入硬盘。

7,串起来!

def main(input) do
   input
   |> hash_input
   |> pick_color
   |> build_grid
   |> filter_odd_squares
   |> build_pixel_map
   |> draw_image
   |> save_image(input)
end

这么清爽的代码不用解释了吧 😄️

五,生成图片

在终端运行 iex -S mix,运行程序。此时进入可交互模式,即 REPL 界面。现在就把公众号 “fugitive_stone” 输入程序,生成 identicon 图片:

Identicon.main "fugitive_stone"

运行完后你会在项目文件理找到 fugitive_stone.png 文件,它应该长这样:

identicon

欢迎关注!扫描以下二维码,或者搜索“石头落地前”。

qr

完整项目代码见 GitHub