兔年了,利用AI风格化实现剪纸兔、年画兔、烟花兔

我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛

一、图像风格化简介

说起图像风格化(image stylization),你可能会感觉到陌生。尽管这项技术,已经深入你的生活很久了。

专业名词,有时候,沟通起来不方便。

记得高中时,我看见同桌带了一个书包。很特别。我就问他这是什么材料的。他说是PP的。我摇了摇头。他又说,就是聚丙烯。当时我很自卑,他说了两遍我依然不懂,感觉我知识太贫乏了。即便如此,我还是虚伪地点了点头

多少年之后。我才了解到,原来聚丙烯的袋子从我出生时,我就见过了,就是下图这样的:

从那一刻起,我发誓,对于专业名词,我要做到尽量不提。

但是不提,同行又以为我不专业。因此,我现在就是,说完了通俗的,再总结专业的。我会说编织袋或者蛇皮袋,文雅一点可以称为:聚丙烯可延展包装容器

而对于图像风格化,其实就类似你的照片加梵高的画作合成梵高风格的你。又或者你的照片直接转为动漫头像。

风格化需要有两个参数。一个叫 content 原内容,另一个叫 style 风格参照。两者经过模型,可以将原内容变为参照的风格。

举个例子。如果 content 是一只兔子,style 是上面的聚丙烯编织袋,那么两者融合会发生什么呢?

那肯定是一个带有编织袋风格的……兔子!

上面的风格融合,多少有点下里巴人。

我再来一个阳春白雪的。让兔子和康定斯基的抽象画做一次融合。

看着还不错,虽然没有抽象感,但是起码风格是有的。

大家想象一下,在兔年来临之际,如果兔子和年画、剪纸、烟花这类春节元素融合起来,会是怎样的效果呢?从技术上(不调用API接口)又该如何实现呢?

下面,跟随我的镜头,我们来一探究竟(我已经探完了,不然不能有上面的图)。

二、技术实现讲解

首先说啊,咱们不调用网络API。其次,我们是基于开源项目。

调用第三方API,会实时依附于服务提供商。一般来说,它处于自主产品鄙视链的底层。

我了解一些大牛,尤其是领导,声称实现了很多高级功能。结果一深究,是调用了别家的能力。这类人,把购买接口的年租费用,称为“研发投入”。把忘记续费,归咎于“服务器故障”。

那么,鄙视链再上升一层。就是拿国外的开源项目,自己部署服务用。尽管这种行为依然不露脸。但是,这在国内已经算很棒的了。因为他们会把部署好的服务,再卖给上面的大牛领导,然后还鄙视他只能调API。

今天,我要使用的,就是从开源项目本地部署这条路。

因此,你学会了也不要骄傲,这并没有什么自主的知识产权。学不会也不用自卑,你还可以试试调用API。

我们选用的开源项目就是TensorFlow Hub。地址是 github.com/tensorflow/…

2.1 TensorFlow Hub库

可以说我对 TensorFlow 很熟,而且是它的铁杆粉丝。铁到我的昵称“TF男孩”的TF指的就是 TensorFlow

TensorFlow 已经很简单和人性化了。简单到几十行代码,就可以实现数字识别的全流程(我都不好意写这个教程)。

但是,它依然不满足于此。代码调用已经够简单了。但是对于训练的样本数据、设备性能这些条件,仍然是限制普通人去涉足的门槛。

于是,TensorFlow 就推出了一个 TensorFlow Hub 来解决上面的问题。你可以利用它训练好的模型和权重,自己再做微调,以此适配成自己的成果。这节省了大量的人力和物力的投入。

Hub 是轮毂的意思

这让我们很容易就联想到“重复造轮子”这个话题。但是,它又很明确,不是轮子,是轮毂。这说明,它把最硬的部件做好了,你只需要往上放轮胎就行。到这里,我开始感觉,虽然我很讨厌有些人说一句话,又是带中文,又是带英文的。但是,这个 Hub ,很难翻译,还是叫 TensorFlow Hub 更为贴切。

2.2 加载image-stylization模型

如果你打算使用 hub 预训练好的风格化模型,自己不做任何改动的话,效果就像下面这样。这是我搞的一个梵高《星空》风格的兔子:

而代码其实很简单,也就是下面几行:

# 导入hub库
import tensorflow_hub as hub
# 加载训练好的风格化模型
hub_model = hub.load('https://tfhub.dev/google/magenta/arbitrary-image-stylization-v1-256/2')
# 将content和style传入
stylized_image = hub_model(content_image, style_image)[0]
# 获取风格化后的图片并打印出来
tensor_to_image(stylized_image)

这,看起来很简单。似乎人工智能的工作很容易干。

事实,并非如此

我建议大家都来学习人工智能,利用成熟的代码或工具,解决生活中遇到的问题。

但是,我不建议你着急转行到人工智能的工作岗位中来。

因为在学习过程中,你会发现,相比于其他语言,人工智能具有更多的限制和基础学科要求。因此,作为使用体验者和制作开发者,会是两个不同的心境。

随着下面的讲解,上面的问题我们会逐个碰到。

首先,上面的 hub.load('https://tfhub.dev/……') 你就加载不下来。而这个地址,正是图像风格化的模型文件。咔,晴天霹雳啊,刚起头就是挫折

其实TensorFlow 是谷歌的开源项目。因此他们很多项目的资源是共享的。你可以替换 tfhub.devstorage.googleapis.com/tfhub-modules 。并且在末尾加上后缀 .tar.gz

下载完成之后,解压文件,然后指定加载路径。其实这一步操作,也是框架的操作。它也是先下载到本地某处,然后从本地加载。

比如,我将 .tar.gz 解压到同级目录下。然后调用 hub.load('image-stylization-v1-256_2') 即可完成 hub 的加载。

这就是我说的限制。

相比较而言,Java 或者 Php 这类情况也会有,但是频率没有这么高。

下面,我们继续。还会有其他惊喜

2.3 输入图片转为tensor格式

hub_model = hub.load(……) 是加载模型。我们是加载了图像风格化的模型。

赋值的名称随便起就行,上面我起的名是 hub_model 。之所以说这句话,是因为我发现有些人感觉改个名字,代码就会运行不起来。其实,变一变,更有利于理解代码。而项目运行不起来,向上帝祈祷不起作用,是需要看报错信息的。

如果完全不更改 hub 预置模型的话,再一行代码就完工了。

这行代码就是 stylized_images = hub_model(content_image, style_image)

这行代码是把内容图片 content_image 和风格参照图片 style_image 传给加载好的模型 hub_model 。模型就会输出风格化后的结果图片stylized_images

哇哦,瞬间感觉自己能卖API了。

但是,这个图片参数的格式,却并没有那么简单。

前面说了,TensorFlow HubTensorFlow 的轮毂,不是轮子,更不是自动驾驶。它的参数和返回值,都是一个 flow 流的形式。

TensorFlow 中的 flow 是什么?这很像《道德经》里的“道”是什么一样。它们只能在自己的语言体系里能说清楚。

但是在这里,你只需要知道调用一个 tf.constant(……) ,或者其他 tf 开头的函数,就可把一个字符、数组或者结构体,包装成为 tensor flow 的格式。

那么下面,我们就要把图片文件包装成这个格式。

先放代码:

import tensorflow as tf

# 根据路径加载图片,并缩小至512像素,转为tensor
max_dim = 512
img = tf.io.read_file(path_to_img)
img = tf.image.decode_image(img, channels=3)
img = tf.image.convert_image_dtype(img, tf.float32)
shape = tf.cast(tf.shape(img)[:-1], tf.float32)
long_dim = max(shape)
scale = max_dim / long_dim
new_shape = tf.cast(shape * scale, tf.int32)
img = tf.image.resize(img, new_shape)
img = img[tf.newaxis, :]
tf_img = tf.constant(img)

首先,模型在训练和预测时,是有固定尺寸的。比如,宽高统一是512像素。

然后,对于用户的输入,我们是不能限制的。比如,用户输入一个高度为863像素的图,这时我们不能让用户裁剪好了再上传。应该是用户上传后,我们来处理。

最后,要搞成tensorflow需要的格式。

上面的代码片段,把这三条都搞定了。

read_file 从路径读入文件。然后通过 decode_image 将文件解析成数组。

这时,如果打印img,具体如下:

shape=(434, 650, 3), dtype=uint8
array([[[219, 238, 245],
        ...,
        [219, 238, 245]],
        [[219, 238, 245],
        ...,
        [219, 238, 245]]])

shape=(434, 650, 3) 说明这是一个三维数组,看数据这是一张650×434像素且具有RGB三个通道的图片。其中的array是具体像素的数值,在某个颜色通道内,255表示纯白,0表示纯黑。

接着 convert_image_dtype(img, tf.float32) 把img转成了float形式。

此时,img的信息为:

shape=(434, 650, 3), dtype=float32
array([[[0.8588236, 0.9333334, 0.9607844],
        ...,
        [0.8588236, 0.9333334, 0.9607844]],
        [[0.8588236, 0.9333334, 0.9607844],
        ...,
        [0.8588236, 0.9333334, 0.9607844]]])

为啥要把int转为float呢?初学者往往会有这样的疑问。

因为他们发现,只要是计算,就要求搞成float类型。就算明明是9个分类,也不能用1、2、3、4来表示,也要转为一堆的小数点。

今天这个图片的像素,也是如此,255个色值多好辨认,为什么非要转为看不懂的小数呢?

别拦着我,我今天非要要解释一下。

这并不是算法没事找事,假装高级。其实,这是为了更好地对应到很多基础学科的知识。

比如,我在《详解激活函数》中讲过很多激活函数。激活函数决定算法如何做决策,可以说是算法的指导思想

你看几个就知道了。不管是sigmoid还是tanh,它的值都是以0或者1为边界的。

也就是说你的模型做数字识别的时候,计算的结果并不是1、2、3、4,而是0到1之间的小数。最后,算法根据概率得出属于哪个分类。哎,你看概率的表示也是0到1之间的数。

除此之外,计算机的二进制也是0或者1。芯片的计算需要精度,整数类型不如小数精确。

各种原因,导致还是浮点型的小数更适合算法的计算。甚至,人工智能的体系中,还具有float64类型,也就是64位的小数。

变为小数之后,后面就是将图片数组做缩放。根据数据的shape,找到最长的边。然后缩放到512像素以内。

这就到了 resize(img, new_shape) 这行代码。

到这一步时,img的数据如下:

shape=(341, 512, 3), dtype=float32
array([[[0.8588236, 0.9333334, 0.9607844],
        ...,
        [0.8588236, 0.9333334, 0.9607844]],
       [[0.8588236, 0.9333334, 0.9607844],
        ...,
        [0.8588236, 0.9333334, 0.9607844]]])

原来的 (434, 650, 3) 图片被重新定义成了 (341, 512, 3) 。依然是3通道的色彩,但是长宽尺寸经过计算,最大已经不超过512像素了。

为什么做缩放?除了模型要求,还要防止用户有可能上传一张1亿像素的图片,这时你的服务器就冒烟了。

(434, 650, 3) 代表的是一张图。但是纵观所有算法模型,不管是 model.fit(train_ds) 训练阶段,还是 model.predict(tf_imgs) 预测阶段。就没有处理单张图片的代码逻辑,全都是批量处理。

它不能处理单张图片的结构,你别说它不人性化,不用跟他杠。兄弟,模型要的只是一个数组结构,它并不关心里面图片的数量。一张图片可以是 ["a.png"] 这种形式。

说到这里,我又忍不住想谈谈关于接口设计的话题了。

我给业务方提供了一个算法接口能力,就是查询一张图上存在的特定目标信息。我也是返回多个结果的结构。尽管样本中只有一个目标。业务方非要返回一个。从长远来讲,谁也不敢保证以后场景中只有一个目标。我必须要如实返回,有一个返回一个,有两个返回两个,你可以只取第一个。但是,结构肯定是要支持多个的。

从成本和风险权衡的角度,从列表中取一条数据的成本,要远小于程序出错或者失灵的风险。但是业务方比较坚持返回一个就行。

后来,他们让我把图片的base64返回值带上 data:image/jpeg;base64, 以便于前端直接展示。那一刻,我就明白了,跟他们较这个真,是我冲动了。

而对于 TensorFlow 的要求,你必须要包装成批量的形式。我认为这很规范。

这句代码 img = img[tf.newaxis, :] 就是将维度上升一层。可以将 1 变为 [1] ,也可以将 [[1],[2]] 变为 [[[1],[2]]]

此时再打印img,它已经变为了如下结构:

shape=(1, 341, 512, 3), dtype=float32
array([[[[0.8588236, 0.9333334, 0.9607844],
        ...,
        [0.8588236, 0.9333334, 0.9607844]],
       [[0.8588236, 0.9333334, 0.9607844],
        ...,
        [0.8588236, 0.9333334, 0.9607844]]]])

shape=(1, 341, 512, 3) 表示有1张512×341的彩图。那么,这个结构它也可以承载100张这样的图,那时就是shape=(100, 341, 512, 3)。这就做到了,以不变应万变。

最后一步的 tf_img = tf.constant(img) ,作用是通过 tf.constant 把图片数据,包装成 TensorFlow 需要的格式。

这个格式,就可以传给hub_model去处理了。

经过 stylized_images = hub_model(tf_img_content, tf_img_style) 这行代码的处理。它会将处理结果放到 stylized_images 中。你马上就可以看到融合结果了。

不过,好像也没有那么简单。这个结果的呈现,实际上是图片到tensor格式的逆向过程。

我们下面就来处理它。

2.4 tensor格式结果转为图片

上一步经过 hub_model 转化,我们获取到了 stylized_images 。这是我们辛苦那么久的产物。你是否会好奇 stylized_images 到底是怎样的结构。

我们来打印一下:

[<tf.Tensor: shape=(1, 320, 512, 3), dtype=float32, numpy=
 array([[[[0.31562978, 0.47748038, 0.7790847 ],
          ...,
          [0.7430198 , 0.733053  , 0.6921962 ]],
         [[0.76158   , 0.6912774 , 0.5468565 ],
          ...,
          [0.69527835, 0.70888966, 0.6492392 ]]]], dtype=float32)>]

厉害了,它是一个 shape=(1, 320, 512, 3) 形状的 tf.Tensor 的数组。

不要和我说这些,我要把它转为图片看结果。

来,先上代码:

import numpy as np
import PIL.Image

tensor = stylized_images[0]
tensor = tensor*255
tensor_arr = np.array(tensor, dtype=np.uint8)
img_arr = tensor_arr[0]
img = PIL.Image.fromarray(img_arr)
img.save(n_path) 

相信有了上面图片转 tensor 的过程,这个反着转化的过程,你很容易就能理解。

  • 第1步:取结果中的第一个 stylized_images[0] ,那是 shape=(1, 320, 512, 3)
  • 第2步:小数转为255色值的整数数组 tensor*255np.array(tensor, dtype=np.uint8)
  • 第3步:取出 shape=(1, 320, 512, 3) 中的那个1,也就是512×320的那张图。
  • 第4步:通过 fromarray(img_arr) 加载图片的数组数据,保存为图片文件。

我敢保证,后面的事情,你只管享受就好了

源码在这里 github.com/hlwgy/image… 。你可以亲自运行试验下效果。

不过,多数人还是会选择看完文章再试。

三、一切皆可兔图的效果

春节就要到了,新的一年是兔年(抱歉,我好像说过了)。

下面,我就把小兔子画面和一些春节元素,做一个风格融合。

3.1 年画兔

当然,我只说我这个年龄段的春节场景。

年画,过年是必须贴的,在我老家(倒装句暴露了家乡)。而且年画种类很丰富。

有这样的:

还有这样的:

它们的制作工艺不同,作用不同,贴的位置也大不相同。

我最喜欢贴的是门神。老家的门是木头门。搞一盆浆糊,拿扫帚往门上抹。然后把年画一放,就粘上了。纸的质量不是很好,浆糊又是湿的,浆糊融合着彩纸还会把染料扩散开来。估计现在的孩子很少再见到了。

我们看一下,可爱的小兔子遇到门神年画,会发生怎样的反应:

你们知道年画是怎么制作的吗?在没有印刷机的年代,年画的制作完全靠手工。

需要先雕刻模子,其作用类似于印章。有用木头雕刻的模具,印出来的就是木板年画。

好了,雕刻完了。最终的模子是这样的。

模板里放上不同的染料,然后印在纸上,年画就出来了。

如果兔子遇到这种木板模具,会是什么风格呢?我有点好奇,我们看一下:

我想,这个图,再结合3D打印机,是不是就不用工匠雕刻了。

3.2 剪纸兔

剪纸,也是过春节的一项民俗。

我老家有一种特殊的剪纸的工艺,叫“挂门笺”。当地叫“门吊子”,意思就是吊在门下的旗子。

其实这个习俗来源于南宋。那时候过年,大户人家都挂丝绸旗帜,以示喜庆。但是普通百姓买不起啊,就改成了彩纸。

跟年画比,这个工艺现在依然活跃,农村大集还有卖的。

如果小兔子遇到剪纸,会是什么风格呢?揭晓一下效果:

3.3 烟花兔

说起烟花,就不是哪个年龄段的专利了。现如今,即便是小孩子,也很喜欢看烟花。

如果兔子遇到烟花,会产生什么样的融合呢?放图揭晓答案:

确实很美丽。

四、无限遐想

最后,我仍然意犹未尽。

我尝试自己画了个小兔子,和掘金的吉祥物们做了一个融合:

这……我感觉比较失落。

但是,转念一想,其实作为抽象画也可以,反正大多数人都看不懂。

我又融合了一张,裱起来,打上落款,好像也过得去。

上面的例子,我们是 hub.loadhttps://tfhub.dev/……image-stylization-v1-256/2 这个模型。

其实,你可以试试 https://tfhub.dev/……image-stylization-v1-256/1 这个模型,它是一个带有发光效果的模型。

嗨,技术人,不管你是前端还是后端,如果春节没事干,想跨界、想突破,试试这个人工智能的项目吧。搞个小程序,给亲友用一下,也挺好的。

我是掘金@TF男孩,一位讲代码过程中,多少带点人文气息的编程表演艺术家。