如何用深度学习做“前端”:基于设计模型图片生成HTML和CSS代码(上)
编者按:一个多月前,知名博主Emil Wallner授权论智编译了他的第三篇深度学习系列教程:如何用100行神经网络代码为黑白图片着色,受到了读者的广泛欢迎。这一次,我们又带来了他的新作:Turning Design Mockups Into Code With Deep Learning,即如何用深度学习模型将设计稿转化为代码,希望大家能喜欢。本文是连载文章,今天给大家带来的是上半部分,敬请关注。
我认为,在未来三年内,深度学习将被用于加快设计稿上线速度、降低软件开发门槛上,它会改变前端的发展轨迹。
从去年Tony Beltramelli在论文中介绍了pix2code——一种自动从单个输入图像(iOS、Android和Web)生成代码,准确率高达77%的模型,到后来Airbnb推出sketch2code,首次提出将人工智能辅助设计和开发用于下一代工具的愿景。自动化的前端设计已经成了一个可展望的趋势。
事实上,深度学习能依靠算法及训练数据实现前端开发的自动化,但目前它还有一个最大的障碍,就是算力。即便如此,为这个趋势进行一些初步探索也是不错的尝试。
在这篇文章中,我们将建立一个神经网络,它能基于设计稿的图片编写基本的HTML代码和CSS代码用于网站设计。以下是该过程的简要概述:
1.为训练好的神经网络提供设计稿
2.神经网络将图像转换成HTML标签
3.渲染输出
和“着色”一文类似,这里我们将具体介绍神经网络的三个迭代版本。
在第一个版本中,我们要做的是建立一个能编写可移动部件的低级版本;第二个版本是HTML版,它能自动执行代码编写的所有步骤,届时我会介绍神经网络的各个层次;第三个版本是Bootstrap,我们会建立一个模型来解析和探索LSTM层。
代码下载:
Jupyter Notebook:GitHub(https://github.com/emilwallner/Screenshot-to-code-in-Keras/blob/master/README.md)和FloydHub(https://www.floydhub.com/emilwallner/projects/picturetocode)
FloydHub notebook:FloydHub(https://www.floydhub.com/emilwallner/projects/picturetocode),floydhub目录
本地notebook:FloydHub(https://www.floydhub.com/emilwallner/projects/picturetocode),local。
参考阅读:
论文:pix2code:从用户界面图形截图生成代码(https://arxiv.org/abs/1705.07962)
博客:编码器-解码器模型的图片标题生成(https://machinelearningmastery.com/caption-generation-inject-merge-architectures-encoder-decoder-model/)
博客:用Python实现神经网络(https://blog.floydhub.com/my-first-weekend-of-deep-learning/)
博客:反向传播过程详解(https://blog.floydhub.com/coding-the-history-of-deep-learning/)
以下实现基于Python和Keras。
核心逻辑
让我们回顾一下我们的初级目标——建立一个神经网络,它能基于屏幕截图生成对应的HTML/CSS标签。
整个训练思路十分清晰。在训练时,我们提供截图和截图对应的HTML标签作为训练数据,它要通过这两个输入学会准确预测代码中的标签。当它预测下一个标签时,它的输入是截图和下一个标签之前的所有正确标签。具体可看这个在线Excel中的演示。
根据描述可以发现,这其实就是一个常见的单词预测模型,我们通过给定一个单词序列,让模型预测哪一个单词是最有可能出现的下一个单词。考虑到过程中使用了截图,一些人也提出过结合计算机视觉的方法,但我们这里仍以单词预测为准。
请注意,对于每一次预测,模型得到的截图都是相同的,所以如果它要预测20个标签的话,它其实会得到20个相同的设计模型。现在,让我们把神经网络的工作机制放在一边,先来关注它的输入和输出。
我们先来看模型的“已有标签”(previous markup)。假设我们的模型需要预测“I can code”这个句子,当它接收输入“I”时,它要预测“can”;下一次它收到“I can”时,它要预测“code”。也就是说,输入之前的所有标签后,模型只需要返回下一个标签。
神经网络的作用机制是从数据中提取特征,然后利用特征构建输入数据和输出数据的连接。为了了解它预测的每张截图中的内容和HTML语法,它必须创建表征。这也是为预测做知识积累。
当你想用一个训练好的模型做实际预测时,它的流程其实和训练模型差不多。我们的模型每次都用相同的截图逐一生成文本,它只有迄今为止预测产生的标签,而无需手动输入正确标签。之后它再预测下一个标签。整个预测从“start tag”开始,并以“end tag”或达到最大层数告终。这里是另一个表现流程的在线Excel。
第一个版本:Hello World
让我们先从建立一个hello world版本开始。我们向神经网络输入一个只显示“Hello World!”的网页截图,教它怎么生成网页标签。
首先,神经网络需要将设计模型映射成像素值列表,它有3个调色通道:红色、蓝色、绿色。颜色数值区间为0—255。
为了让神经网络理解标签的含义,我在这里用了One Hot Encoding(独热编码),即把m个状态编码为m个二元特征,它们两两互斥,在任意时候只有一个有效值(激活)。因此,“I can code”可以被映射为:
上图中包含开始和结束标签:“start tag”“end tag”。它们是神经网络开始、终止预测的线索。对于输入,我们用了整个句子,并根据每轮预测逐个输入单词。神经网络的输出始终是下一个词。
这里句子的逻辑和单词一样,都有一个固定的输入长度。比起限制具体词汇,这种限制最大句长的方法更便捷,因为如果输出短于最大句长,我们可以用空字填充它,也就是0。
如上图所示,由于单词是从右到左打印的,这就迫使模型改变单词在每轮预测中的位置。因此它就能从最终所得的排序中习得每个单词的正确位置。
下图展示了一轮正确预测,每一行代表一次预测。函数的左边是由红、绿、蓝三种颜色表示的图像,右边是之前预测的单词。函数的输出是一个接一个的预测,以红色方块结束。
#最大句长的长度
max_caption_len = 3
#Size of vocabulary
vocab_size = 3
# 为每个单词加载一副截图,并将其转换为数字
images = []
for i in range(2):
images.append(img_to_array(load_img('screenshot.jpg', target_size=(224, 224))))
images = np.array(images, dtype=float)
# 为VGG16模型做输入预处理
images = preprocess_input(images)
#对start token做one-hot encoding
html_input = np.array(
[[[0., 0., 0.], #start
[0., 0., 0.],
[1., 0., 0.]],
[[0., 0., 0.], #start <HTML>Hello World!</HTML>
[1., 0., 0.],
[0., 1., 0.]]])
#对下一个单词做one-hot encoding
next_words = np.array(
[[0., 1., 0.], # <HTML>Hello World!</HTML>
[0., 0., 1.]]) # end
# 加载在imagenet上训练的VGG16模型,并输出分类特征
VGG = VGG16(weights='imagenet', include_top=True)
# Extract the features from the image
features = VGG.predict(images)
#将特征放入神经网络,应用全连接层,重复向量
vgg_feature = Input(shape=(1000,))
vgg_feature_dense = Dense(5)(vgg_feature)
vgg_feature_repeat = RepeatVector(max_caption_len)(vgg_feature_dense)
# 从输入序列中提取信息
language_input = Input(shape=(vocab_size, vocab_size))
language_model = LSTM(5, return_sequences=True)(language_input)
# 连接来自图像和输入的信息
decoder = concatenate([vgg_feature_repeat, language_model])
# 从连接的输出中提取信息
decoder = LSTM(5, return_sequences=False)(decoder)
# 预测下一个单词
decoder_output = Dense(vocab_size, activation='softmax')(decoder)
# 编译并运行神经网络
model = Model(inputs=[vgg_feature, language_input], outputs=decoder_output)
model.compile(loss='categorical_crossentropy', optimizer='rmsprop')
# 训练神经网络
model.fit([features, html_input], next_words, batch_size=2, shuffle=False, epochs=1000)
hello world版用了3个token:“start” “Hello World!”和“end”。一个token可以是一个字符、一个单词或一句话,它可以是任何东西。在这个模型中,字符token虽然对词汇量要求较小但是会限制神经网络,所以词汇级token是一个更合适的选择。
以下是我们的预测:
# 创建一个空句并插入start token
sentence = np.zeros((1, 3, 3)) # [[0,0,0], [0,0,0], [0,0,0]]
start_token = [1., 0., 0.] # start
sentence[0][2] = start_token # place start in empty sentence
# 根据start token做第一次预测
second_word = model.predict([np.array([features[1]]), sentence])
# 把第二个单词放进句子里,做最终预测
sentence[0][1] = start_token
sentence[0][2] = np.round(second_word)
third_word = model.predict([np.array([features[1]]), sentence])
# 把start token和两个预测放进句子里
sentence[0][0] = start_token
sentence[0][1] = np.round(second_word)
sentence[0][2] = np.round(third_word)
# 把我们的one-hot预测转为final token
vocabulary = ["start", "<HTML><center><H1>Hello World!</H1></center></HTML>", "end"]
for i in sentence[0]:
print(vocabulary[np.argmax(i)], end=' ')
输出
10 epochs:
start start start
;100 epochs:
start <HTML><center><H1>Hello World!</H1></center></HTML> <HTML><center><H1>Hello World!</H1></center></HTML>
;300 epochs:
start <HTML><center><H1>Hello World!</H1></center></HTML> end
。
我得到的经验
在收集数据前构建了第一个版本。在项目初期,我设法获得了Geocities网页寄存的旧版存档,它包含3800万个网页。事实证明我低估了它的“潜力”,我忽视了在实际操作中减少100k词汇所需的巨额工作量;
处理TB级的数据需要够好的硬盘,或足够的耐心。mac崩溃了几次后,我最后还是租了一个功能强大的远程服务器,它包含8个现代GPU和1Gpbs的高速网络;
在我真正理解输入和输出前,一切都毫无意义。输入X是屏幕截图和之前预测的标签,输出Y是下一个标签。当我掌握这两点后,理解中间过程就变得容易了。尝试不同的网页架构也有助于理解神经网络;
注意狡兔三窟。这个项目涉及深度学习的多个领域,所以我一路上经常迷失在“兔子洞”(未知领域)中。一次,因为我中途突然对向量空间embedding产生了兴趣,去学了一下,付出的代价是花一星期时间重新编写RNN;
把图像转成编码的神经网络其实就是Image Caption模型。即便我掌握了这一点,我还是忽视了很多关于自动生成图像描述的论文,因为它们太无聊了。但是一旦我有了一些灵感,我就能通过它们加深对问题的理解。
在FloydHub上运行代码
FloydHub是一个深度学习云平台。当我刚开始学习深度学习的时候,我接触到了它,并开始用它来训练和管理我的实验。你可以在10分钟内完成FloydHub安装并运行自己的第一个模型,它称得上是最好的云GPU主机之一。
如果你是FloydHub的新手,你可以看看这个2分钟安装教程和5分钟实际操作演示。
复制repository
git clone https://github.com/emilwallner/Screenshot-to-code-in-Keras.git
登录并启动FloydHub命令行工具
cd Screenshot-to-code-in-Keras
floyd login
floyd init s2c
在FloydHub云GPU主机上运行Jupyter notebook
floyd run --gpu --env tensorflow-1.4 --data emilwallner/datasets/imagetocode/2:data --mode jupyter
所有notebook都放在floydhub目录中,本地存档在local下。模型一旦跑起来,你可以在floydhub/Helloworld/helloworld.ipynb里找到第一个notebook。
HTML版
这个版本将实现hello world版中多个步骤的自动执行,在这一节中,我们的关注点在于创建一个可扩展的神经网络,并让它能编写网页上的可移动部件。
需要注意的是,HTML版并不能预测随机网页的HTML标签,它的贡献在于探索动态问题。
模型概览
如果我们展开上一版的流程图,它看起来是这样的:
我们的神经网络主要由两部分构成。一是编码器,它是我们学习图像特征和之前预测的标签特征的地方,这些特征是连接设计稿和标签的重要桥梁。在编码器的最后,我们把学习到的图像特征一个个对应给之前预测的标签单词。二是解码器,当解码器接收到组合在一起的图像和标签特征后,它把这些特征导入全连接层,从而预测下一个标签。
设计稿特征
由于我们要为每一个预测单词插入一张截图,这会成为训练神经网络的瓶颈(示例)。为了解决这个问题,我们不直接使用图像,而是提取它生成标签所需的信息作为输入。
而为了把信息编码成图像特征,我们事先已经在imagenet上训练了一个的卷积神经网络(CNN),在进入最后的分类步骤前,它会完成图像的特征提取。
最终,我们得到了1536个8×8的图像,即特征。虽然它们在人眼中没有什么意义,但神经网络能理解它们,并能从中提取对象、坐标等信息。
标签特征
在hello world版本中,我们用one-hot encoding把标签编码成了二元特征,效果显著。这个版本仍将对输出使用one-hot encoding,但同时引入一种新方法——word embedding(词嵌入),来处理输入。
换句话说,就是我们仍将沿用原先句子的构造方式,但改变了映射token的方法。one-hot encoding会把每个单词视为一个独立的单元,相反地,word embedding会把输入的每个单词转换成一系列数字,它们反映的是和标签关系的远近。
在上图中,word embedding得到的矩阵是8维的,词嵌入向量空间的维度一般在50—500之间,具体取决于词汇量大小。每个单词对应的这8位数字有点类似单隐层神经网络中的权值w1、w2、w3……它们描述的是词语之间的相互关系。
以上就是我们生成标签特征的具体方法。神经网络的特点是能将输入数据和输出数据连接起来,具体的连接方法我们会在下一节中介绍。
编码器
如下图右侧所示,我们对输入的单词做word embedding后,把它们导入LSTM运行,并输出一系列标签特征。之后,这些标签特征被馈送进Time distributed全连接层(dense layer)——一个具有多个输入和输出的全连接层。
与此同时,另一侧,我们的图像特征首先通过Flatten层被“压平”。无论数据结构如何,它们都会被转换成一张巨大的数字列表。我们在这一层的基础上建立一个全连接层,形成高维特征,然后把图像特征对应给标签特征。
如果你理解不了,这里我们对它做一下分解。
标签特征
在编码器左侧,我们用LSTM运行word embedding。如下图所示,这个LSTM有256个单元,所以我们把矩阵从8维扩展到最大的256维(添加0)。
为了方便把两边特征放在一起,同时构建高维模式(连接特征),我们在标签特征上加了一个TimeDistributed全连接层,它和隔壁的全连接层是一模一样的,只不过有多个输入和多个输出(因为有多个单词输入)。
图像特征
当编码器在处理标签特征时,在另一个“平行空间”,它也在对图像特征做进一步处理。我们的Flatten层把之前得到的1536个8×8的小图像转换成了一个很长的列表。里面包含的信息没有变,只是被重组了。
同样的,为了方便把两边特征放在一起,同时构建高维模式(连接特征),我们在图像列表上加了一个全连接层。因为这边只有一张表,也就是只有一个输入,所以我们可以用正常的全连接层。最后,为了创建连接,我们根据标签特征的输出个数,复制致密层输出的图像特征。
在上图示例中,我们最终复制了3个图像特征,和之前输出的3个标签特征数量相同。
标签特征和图像特征的连接
两边特征被处理成同样数量后,由于已经有了图像特征,我们就可以为标签特征添加图像特征了。
如上图所示,我们把图像特征对应到标签特征,这时编码器结束了“使命”,输出这3个图像—标签特征。
解码器
解码器的“使命”是把编码器输出的图像—标签特征作为输入,然后预测下一个标签特征。
下图是编码器的3个输出进入解码器后的结果。值得注意的是,图中LSTM显示sequence=false,这是因为虽然它可以有多个输入,但它只能有一个输出,即预测下一个标签的特征。它只包含最终预测的信息。
最终预测
全连接层的工作机制和传统前馈神经网络一样,它把代表下一个标签特征的512个数字和4个最终预测联系在一起。这里我们假设这4个单词是:start、hello、world和end。
全连接层中的softmax激活函数的分布区间在0到1之间,所有预测的概率总和为1。这时,如果这4个单词的预测概率是[0.1,0.1,0.1,0.7],那么end就是我们的最终预测。利用one-hot encoding,我们把它转化为[0, 0, 0, 1],输出“end”。
# 加载图像并对其进行预处理
images = []
all_filenames = listdir('images/')
all_filenames.sort()
for filename in all_filenames:
images.append(img_to_array(load_img('images/'+filename, target_size=(299, 299))))
images = np.array(images, dtype=float)
images = preprocess_input(images)
# 在进入分类层之前,用已经训练好的CNN——inception-resnet提取图像特征
IR2 = InceptionResNetV2(weights='imagenet', include_top=False)
features = IR2.predict(images)
# 将每个输入序列限制为100个token
max_caption_len = 100
# 初始化生成词汇函数
tokenizer = Tokenizer(filters='', split=" ", lower=False)
# 阅读文档并返回一个字符串
def load_doc(filename):
file = open(filename, 'r')
text = file.read()
file.close()
return text
# 加载HTML文件
X = []
all_filenames = listdir('html/')
all_filenames.sort()
for filename in all_filenames:
X.append(load_doc('html/'+filename))
# 从html文件创建词汇表
tokenizer.fit_on_texts(X)
# +1为空字符预留空间
vocab_size = len(tokenizer.word_index) + 1
# 将文本文件中的每个单词转换为匹配的词汇索引
sequences = tokenizer.texts_to_sequences(X)
# HTML文件最大长度
max_length = max(len(s) for s in sequences)
# 初始化模型的最终输入
X, y, image_data = list(), list(), list()
for img_no, seq in enumerate(sequences):
for i in range(1, len(seq)):
# 添加整个序列作为输入,只输出下一个单词
in_seq, out_seq = seq[:i], seq[i]
# 如果序列长度短于max_length,用空白字符填充
in_seq = pad_sequences([in_seq], maxlen=max_length)[0]
# 用one-hot encoding编码输出
out_seq = to_categorical([out_seq], num_classes=vocab_size)[0]
# 添加和图像对应的HTML文件
image_data.append(features[img_no])
# 将输入句子分为100个token,并将其添加到输入数据中
X.append(in_seq[-100:])
y.append(out_seq)
X, y, image_data = np.array(X), np.array(y), np.array(image_data)
# 创建编码器
image_features = Input(shape=(8, 8, 1536,))
image_flat = Flatten()(image_features)
image_flat = Dense(128, activation='relu')(image_flat)
ir2_out = RepeatVector(max_caption_len)(image_flat)
language_input = Input(shape=(max_caption_len,))
language_model = Embedding(vocab_size, 200, input_length=max_caption_len)(language_input)
language_model = LSTM(256, return_sequences=True)(language_model)
language_model = LSTM(256, return_sequences=True)(language_model)
language_model = TimeDistributed(Dense(128, activation='relu'))(language_model)
# 创建解码器
decoder = concatenate([ir2_out, language_model])
decoder = LSTM(512, return_sequences=False)(decoder)
decoder_output = Dense(vocab_size, activation='softmax')(decoder)
# 编译模型
model = Model(inputs=[image_features, language_input], outputs=decoder_output)
model.compile(loss='categorical_crossentropy', optimizer='rmsprop')
# 训练神经网络
model.fit([image_data, X], y, batch_size=64, shuffle=False, epochs=2)
# 将整数映射到一个单词
def word_for_id(integer, tokenizer):
for word, index in tokenizer.word_index.items():
if index == integer:
return word
return None
# 为图像生成描述
def generate_desc(model, tokenizer, photo, max_length):
# seed the generation process
in_text = 'START'
# iterate over the whole length of the sequence
for i in range(900):
# integer encode input sequence
sequence = tokenizer.texts_to_sequences([in_text])[0][-100:]
# pad input
sequence = pad_sequences([sequence], maxlen=max_length)
# predict next word
yhat = model.predict([photo,sequence], verbose=0)
# convert probability to integer
yhat = np.argmax(yhat)
# map integer to word
word = word_for_id(yhat, tokenizer)
# stop if we cannot map the word
if word is None:
break
# append as input for generating the next word
in_text += ' ' + word
# Print the prediction
print(' ' + word, end='')
# stop if we predict the end of the sequence
if word == 'END':
break
return
# Load and image, preprocess it for IR2, extract features and generate the HTML
test_image = img_to_array(load_img('images/87.jpg', target_size=(299, 299)))
test_image = np.array(test_image, dtype=float)
test_image = preprocess_input(test_image)
test_features = IR2.predict(np.array([test_image]))
generate_desc(model, tokenizer, np.array(test_features), 100)
输出
上图网站的链接:
250 epochs
350 epochs
450 epochs
550 epochs
如果打开网址看不到,可以右键查看网页源代码。这是原版参考。
我得到的经验
和CNN相比,LSTM要更难理解一点。在我展开LSTM的架构后,我才理解它的工作机制。这里不得不提一点,Fast.ai的RNN教程很好用。另外,在你尝试理解工作机制之前,注意先弄清输入和输出到底是什么;
重新创建词库比缩小大型词库要容易得多。这包含从字体、div大小、颜色到变量名称、普通单词等方方面面;
大多数库是被创建来解析文本的。在(英文)文档中,每个单词都用空格分离,但在代码中,你需要自定义分析;
你可以用在Imagenet上训练的模型提取特征。乍一看这可能不太科学,因为Imagenet上几乎没有web图像。当然,和从头训练的pix2code模型相比,Imagenet模型的loss还是高了30%。我个人比较倾向于用基于网页截图训练的inception-resnet模型。
未完待续……