查看原文
其他

如何用深度学习做“前端”:基于设计模型图片生成HTML和CSS代码(上)

Emil Wallner 论智 2022-08-24
作者:Emil Wallner编译:Bot


编者按:一个多月前,知名博主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/)

  • 博客:卷积神经网络的应用——如何用100行神经网络代码为黑白图片着色(中文版)

以下实现基于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。

如上图所示,由于单词是从右到左打印的,这就迫使模型改变单词在每轮预测中的位置。因此它就能从最终所得的排序中习得每个单词的正确位置。

下图展示了一轮正确预测,每一行代表一次预测。函数的左边是由红、绿、蓝三种颜色表示的图像,右边是之前预测的单词。函数的输出是一个接一个的预测,以红色方块结束。

  1. #最大句长的长度

  2.    max_caption_len = 3

  3.    #Size of vocabulary

  4.    vocab_size = 3

  5.    # 为每个单词加载一副截图,并将其转换为数字

  6.    images = []

  7.    for i in range(2):

  8.        images.append(img_to_array(load_img('screenshot.jpg', target_size=(224, 224))))

  9.    images = np.array(images, dtype=float)

  10.    # 为VGG16模型做输入预处理

  11.    images = preprocess_input(images)

  12.    #对start token做one-hot encoding

  13.    html_input = np.array(

  14.                [[[0., 0., 0.], #start

  15.                 [0., 0., 0.],

  16.                 [1., 0., 0.]],

  17.                 [[0., 0., 0.], #start <HTML>Hello World!</HTML>

  18.                 [1., 0., 0.],

  19.                 [0., 1., 0.]]])

  20.    #对下一个单词做one-hot encoding

  21.    next_words = np.array(

  22.                [[0., 1., 0.], # <HTML>Hello World!</HTML>

  23.                 [0., 0., 1.]]) # end

  24.    # 加载在imagenet上训练的VGG16模型,并输出分类特征

  25.    VGG = VGG16(weights='imagenet', include_top=True)

  26.    # Extract the features from the image

  27.    features = VGG.predict(images)

  28.    #将特征放入神经网络,应用全连接层,重复向量

  29.    vgg_feature = Input(shape=(1000,))

  30.    vgg_feature_dense = Dense(5)(vgg_feature)

  31.    vgg_feature_repeat = RepeatVector(max_caption_len)(vgg_feature_dense)

  32.    # 从输入序列中提取信息

  33.    language_input = Input(shape=(vocab_size, vocab_size))

  34.    language_model = LSTM(5, return_sequences=True)(language_input)

  35.    # 连接来自图像和输入的信息

  36.    decoder = concatenate([vgg_feature_repeat, language_model])

  37.    # 从连接的输出中提取信息

  38.    decoder = LSTM(5, return_sequences=False)(decoder)

  39.    # 预测下一个单词

  40.    decoder_output = Dense(vocab_size, activation='softmax')(decoder)

  41.    # 编译并运行神经网络

  42.    model = Model(inputs=[vgg_feature, language_input], outputs=decoder_output)

  43.    model.compile(loss='categorical_crossentropy', optimizer='rmsprop')

  44.    # 训练神经网络

  45.    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是一个更合适的选择。

以下是我们的预测:

  1. # 创建一个空句并插入start token

  2.    sentence = np.zeros((1, 3, 3)) # [[0,0,0], [0,0,0], [0,0,0]]

  3.    start_token = [1., 0., 0.] # start

  4.    sentence[0][2] = start_token # place start in empty sentence

  5.    # 根据start token做第一次预测

  6.    second_word = model.predict([np.array([features[1]]), sentence])

  7.    # 把第二个单词放进句子里,做最终预测

  8.    sentence[0][1] = start_token

  9.    sentence[0][2] = np.round(second_word)

  10.    third_word = model.predict([np.array([features[1]]), sentence])

  11.    # 把start token和两个预测放进句子里

  12.    sentence[0][0] = start_token

  13.    sentence[0][1] = np.round(second_word)

  14.    sentence[0][2] = np.round(third_word)

  15.    # 把我们的one-hot预测转为final token

  16.    vocabulary = ["start", "<HTML><center><H1>Hello World!</H1></center></HTML>", "end"]

  17.    for i in sentence[0]:

  18.        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

  1. git clone https://github.com/emilwallner/Screenshot-to-code-in-Keras.git

登录并启动FloydHub命令行工具

  1. cd Screenshot-to-code-in-Keras

  2. floyd login

  3. floyd init s2c

在FloydHub云GPU主机上运行Jupyter notebook

  1. 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”。

  1. # 加载图像并对其进行预处理

  2.    images = []

  3.    all_filenames = listdir('images/')

  4.    all_filenames.sort()

  5.    for filename in all_filenames:

  6.        images.append(img_to_array(load_img('images/'+filename, target_size=(299, 299))))

  7.    images = np.array(images, dtype=float)

  8.    images = preprocess_input(images)

  9.    # 在进入分类层之前,用已经训练好的CNN——inception-resnet提取图像特征

  10.    IR2 = InceptionResNetV2(weights='imagenet', include_top=False)

  11.    features = IR2.predict(images)

  12.    # 将每个输入序列限制为100个token

  13.    max_caption_len = 100

  14.    # 初始化生成词汇函数

  15.    tokenizer = Tokenizer(filters='', split=" ", lower=False)

  16.    # 阅读文档并返回一个字符串

  17.    def load_doc(filename):

  18.        file = open(filename, 'r')

  19.        text = file.read()

  20.        file.close()

  21.        return text

  22.    # 加载HTML文件

  23.    X = []

  24.    all_filenames = listdir('html/')

  25.    all_filenames.sort()

  26.    for filename in all_filenames:

  27.        X.append(load_doc('html/'+filename))

  28.    # 从html文件创建词汇表

  29.    tokenizer.fit_on_texts(X)

  30.    # +1为空字符预留空间

  31.    vocab_size = len(tokenizer.word_index) + 1

  32.    # 将文本文件中的每个单词转换为匹配的词汇索引

  33.    sequences = tokenizer.texts_to_sequences(X)

  34.    # HTML文件最大长度

  35.    max_length = max(len(s) for s in sequences)

  36.    # 初始化模型的最终输入

  37.    X, y, image_data = list(), list(), list()

  38.    for img_no, seq in enumerate(sequences):

  39.        for i in range(1, len(seq)):

  40.            # 添加整个序列作为输入,只输出下一个单词

  41.            in_seq, out_seq = seq[:i], seq[i]

  42.            # 如果序列长度短于max_length,用空白字符填充

  43.            in_seq = pad_sequences([in_seq], maxlen=max_length)[0]

  44.            # 用one-hot encoding编码输出

  45.            out_seq = to_categorical([out_seq], num_classes=vocab_size)[0]

  46.            # 添加和图像对应的HTML文件

  47.            image_data.append(features[img_no])

  48.            # 将输入句子分为100个token,并将其添加到输入数据中

  49.            X.append(in_seq[-100:])

  50.            y.append(out_seq)

  51.    X, y, image_data = np.array(X), np.array(y), np.array(image_data)

  52.    # 创建编码器

  53.    image_features = Input(shape=(8, 8, 1536,))

  54.    image_flat = Flatten()(image_features)

  55.    image_flat = Dense(128, activation='relu')(image_flat)

  56.    ir2_out = RepeatVector(max_caption_len)(image_flat)

  57.    language_input = Input(shape=(max_caption_len,))

  58.    language_model = Embedding(vocab_size, 200, input_length=max_caption_len)(language_input)

  59.    language_model = LSTM(256, return_sequences=True)(language_model)

  60.    language_model = LSTM(256, return_sequences=True)(language_model)

  61.    language_model = TimeDistributed(Dense(128, activation='relu'))(language_model)

  62.    # 创建解码器

  63.    decoder = concatenate([ir2_out, language_model])

  64.    decoder = LSTM(512, return_sequences=False)(decoder)

  65.    decoder_output = Dense(vocab_size, activation='softmax')(decoder)

  66.    # 编译模型

  67.    model = Model(inputs=[image_features, language_input], outputs=decoder_output)

  68.    model.compile(loss='categorical_crossentropy', optimizer='rmsprop')

  69.    # 训练神经网络

  70.    model.fit([image_data, X], y, batch_size=64, shuffle=False, epochs=2)

  71.    # 将整数映射到一个单词

  72.    def word_for_id(integer, tokenizer):

  73.        for word, index in tokenizer.word_index.items():

  74.            if index == integer:

  75.                return word

  76.        return None

  77.    # 为图像生成描述

  78.    def generate_desc(model, tokenizer, photo, max_length):

  79.        # seed the generation process

  80.        in_text = 'START'

  81.        # iterate over the whole length of the sequence

  82.        for i in range(900):

  83.            # integer encode input sequence

  84.            sequence = tokenizer.texts_to_sequences([in_text])[0][-100:]

  85.            # pad input

  86.            sequence = pad_sequences([sequence], maxlen=max_length)

  87.            # predict next word

  88.            yhat = model.predict([photo,sequence], verbose=0)

  89.            # convert probability to integer

  90.            yhat = np.argmax(yhat)

  91.            # map integer to word

  92.            word = word_for_id(yhat, tokenizer)

  93.            # stop if we cannot map the word

  94.            if word is None:

  95.                break

  96.            # append as input for generating the next word

  97.            in_text += ' ' + word

  98.            # Print the prediction

  99.            print(' ' + word, end='')

  100.            # stop if we predict the end of the sequence

  101.            if word == 'END':

  102.                break

  103.        return

  104.    # Load and image, preprocess it for IR2, extract features and generate the HTML

  105.    test_image = img_to_array(load_img('images/87.jpg', target_size=(299, 299)))

  106.    test_image = np.array(test_image, dtype=float)

  107.    test_image = preprocess_input(test_image)

  108.    test_features = IR2.predict(np.array([test_image]))

  109.    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模型。

未完待续……

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存