SteveHawk's Blog

SE 验证码识别



目标

在爬取瑞典专利和注册局(PRV,代码 SE)的专利数据库数据的时候,存在输入验证码的步骤,需要编写程序来识别。以下是几张示例:

captcha-example-1

captcha-example-2

captcha-example-3

难点

这次要识别的验证码的特点:

  1. 背景特别干净,没有任何噪声
  2. 字符本身的扭曲变形非常厉害
  3. 存在多种字体,包括多种衬线和非衬线字体
  4. 所有英文字母都是小写
  5. 字符旋转的角度不会超过 ±90°

没有噪声是好事,这意味着不需要做任何降噪相关的处理。但是字符的变形扭曲要远远比一般见到的验证码厉害得多,甚至有的例子中人眼也难以分辨到底是什么字母。

扭曲的特别厉害的一张:

captcha-example-bad

容易看出,这一批验证码的生成规则都是把对应的文字从左到右排列,然后按照一个随机的曲线和宽度进行扭曲。在曲线曲率较大的地方,字符就容易出现非常大的扭曲变形,从而难以辨认。因此,如何进行形态矫正是一个非常大的考验。

多种字体也是一个挑战。在一些衬线字体中,1l 的样子完全一致,仅仅是有微小的高度差别,这为识别造成了很大的麻烦。幸亏只有小写,不然会有更多容易混淆的情况。

另外,还有一个隐藏的特点。仔细看的话,你会发现之前第一张示例图片和这一张示例图片其实是同一个验证码,都是 t3st1n。写爬虫的小哥告诉我,同一个 IP 貌似返回的是同样的验证码文字,也就是说验证码文字的生成不是完全随机,而是包含 IP 的某种哈希。反向推导这个生成算法可能不太现实,但是至少可以通过多张同样内容的验证码一起识别交叉验证,提高准确性。可惜的是爬虫使用的是代理池,IP 会不受控制的变化,因此这条路就难以进行下去,只能尽量一发入魂了。

传统方法

首先,试一试传统的数字图像处理方法。能够想到的方法,无非就是先对每个字符的图像进行校准,然后去试着识别是哪个字符,最后再拼起来。以下是我想要采取的流程:

  1. 把排列成曲线的字符矫正回直线
  2. 提取字符,做必要的矫正和处理
  3. 匹配内容

曲线矫正

首先尝试做曲线矫正。这一步我在实际开发的时候并没有做,原因在于难以对字符扭曲形成的曲线进行建模并矫正。项目结束以后我才想到,其实可以试试用 numpy 或者 scipy 去做曲线拟合,获得曲线方程以后大概就可以生成变换矩阵矫正形变了。不过项目已经结束了,以后如果有类似的需求可以再试一试。

字符提取

于是我上来就直接对单个字符进行提取了。下面我用于在一张图片中提取字符的函数,参数列表:img 是 cv2.imread() 返回的图片对象;reverse_color 是一个布尔值,代表是否反转颜色;iter 是一个整数(默认为 2),代表第一次膨胀的迭代次数。

 1import cv2
 2import numpy as np
 3
 4
 5def extract_char(img, reverse_color, iter=2):
 6    # Binary, dilation
 7    if reverse_color:
 8        _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV)
 9    else:
10        _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY)
11    kernel = np.ones((3, 3), np.uint8)
12    dilation = cv2.dilate(binary, kernel, iterations=iter)
13    
14    # Find contours, sort them
15    contours, hierarchy = cv2.findContours(dilation, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
16    contours = sorted(contours, key=(lambda x: (x[0][0][0], x[0][0][1])))
17    
18    chars = list()
19    
20    for cnt in contours:
21        # Contour approximation
22        rect = None
23        for i in range(100):
24            epsilon = (0.01 + 0.01 * i) * cv2.arcLength(cnt, True)
25            box = cv2.approxPolyDP(cnt, epsilon, True)
26            box = np.float32(box)
27            if len(box) <= 4:
28                break
29        if len(box) != 4:
30            print("NOOOOOO! Fall Back!")
31            rect = cv2.minAreaRect(cnt)
32            box = cv2.boxPoints(rect)
33            box = np.float32(box)
34        
35        # Transform to a single image
36        points = np.float32([[25, 25], [25, 125], [125, 125]])
37        M = cv2.getAffineTransform(box[0:3], points)
38        processed = cv2.warpAffine(binary, M, (150, 150))
39        if rect and rect[2] <= -80.0:
40            processed = np.rot90(processed, 1)
41        
42        # Refind countour
43        kernel = np.ones((11, 11), np.uint8)
44        _dilation = cv2.dilate(processed, kernel, iterations=5)
45        _contours, _hierarchy = cv2.findContours(_dilation, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
46        
47        if len(_contours) > 1:
48            print("HELPPPPP More Than ONE Contours")
49            print(_contours)
50        
51        cnt = _contours[0]
52        
53        # Find rectangle
54        rect = cv2.minAreaRect(cnt)
55        box = cv2.boxPoints(rect)
56        box = np.float32(box)
57        
58        # Transform to a single image
59        points = np.float32([[0, 100], [0, 0], [100, 0]])
60        M = cv2.getAffineTransform(box[0:3], points)
61        processed = cv2.warpAffine(processed, M, (100, 100))
62        if rect[2] <= -80.0:
63            processed = np.rot90(processed, 1)
64        
65        chars.append(processed)
66    
67    return chars

该函数按顺序做了这些事情:

  1. 将原图做二值化,并转化为黑底白字的形式。

  2. 对字符做膨胀,让字符分离的部分(例如 i, j 的点)连接便于寻找轮廓。

  3. 使用 cv2.findContours() 查找所有的轮廓。

  4. 遍历所有的轮廓:

    a. 使用 cv2.approxPolyDP() 对当前轮廓做四边形近似。

    b. 如果无法取得四边形的近似,则更改方案使用 cv2.minAreaRect() 获取最小外接矩形。

    c. 使用 cv2.warpAffine() 将获得的四边形轮廓映射成一个 150 × 150 的正方形图片。如 minAreaRect 返回的旋转角小于 -80°,则对图片做逆时针旋转 90°。

    d. 对这张正方形图片做膨胀,让字符连接。

    e. 再次使用 cv2.findContours() 查找轮廓,如果有多于一个轮廓则报错。无论如何选择列表中的 0 号轮廓。

    f. 使用 cv2.minAreaRect() 获取最小外接矩形。

    g. 使用 cv2.warpAffine() 将这个矩形映射为最终的 100 × 100 大小的一张正方形图片。如 minAreaRect 返回的旋转角小于 -80°,则对图片做逆时针旋转 90°。

最终函数返回一个列表,包含了每个字符矫正后的独立图像。提取出来的字符大概是下面这种诡异画风:

traditional-extract-example

生成模板

提取出了字符,下面就要做匹配了。我准备采用简单粗暴的模板匹配,也就是 cv2.matchTemplate()。做模板匹配,首先需要生成模板。这里借鉴了 Python PIL创建文字图片 这篇博客的代码,魔改成了我的模板生成代码。

 1# Learn from: https://www.cnblogs.com/mmhx/p/3819776.html
 2from PIL import Image,ImageDraw,ImageFont,ImageOps
 3
 4
 5class LetterImage():
 6    def __init__(self, fontFile='', imgSize=(0, 0), imgMode='RGB', bg_color=(0, 0, 0), fg_color=(255, 255, 255), fontsize=20):
 7        self.imgSize = imgSize
 8        self.imgMode = imgMode
 9        self.fontsize = fontsize
10        self.bg_color = bg_color
11        self.fg_color = fg_color
12        if '' == fontFile:
13            self.font = ImageFont.truetype('times.ttf', fontsize)
14        else:
15            self.font = ImageFont.truetype(fontFile, fontsize)
16
17    def GenLetterImage(self, letters):
18        # Generate the Image of letters
19        self.letters = letters
20        self.letterWidth, self.letterHeight = self.font.getsize(letters)
21        if self.imgSize == (0, 0):
22            self.imgSize = (self.letterWidth + 2, self.letterHeight + 2)
23        self.imgWidth, self.imgHeight = self.imgSize
24        self.img = Image.new(self.imgMode, self.imgSize, self.bg_color)
25        self.drawBrush = ImageDraw.Draw(self.img)
26        textY0 = (self.imgHeight - self.letterHeight + 1) / 2
27        textY0 = int(textY0)
28        textX0 = int((self.imgWidth - self.letterWidth + 1) / 2)
29        print('text location:',(textX0, textY0))
30        print('text size (width,height):', self.letterWidth, self.letterHeight)
31        print('img size(width,height):', self.imgSize)
32        self.drawBrush.text((textX0, textY0), self.letters, fill=self.fg_color, font=self.font)
33    
34    def SaveImg(self, saveName=''):
35        if '' == saveName.strip():
36            saveName = str(self.letters.encode('utf8')) + '.png'
37        fileName, file_format = saveName.split('.')
38        # fileName += '_' + str(self.fontsize) + '.' + file_format
39        print(fileName, file_format)
40        self.img.save(saveName, file_format)
41    
42    def Show(self):
43        self.img.show()
44
45
46def generate(fontFile, font_type):
47    letterList = LetterImage(fontFile=fontFile, imgSize=(100, 100), bg_color=(0, 0, 0), fontsize=50)
48    for i in range(10):
49        letter = chr(ord('0') + i)
50        letterList.GenLetterImage(letter)
51        letterList.SaveImg(f"template/{font_type}_{letter}.png")
52    for i in range(26):
53        letter = chr(ord('a') + i)
54        letterList.GenLetterImage(letter)
55        letterList.SaveImg(f"template/{font_type}_{letter}.png")
56    # for i in range(26):
57    #     letter = chr(ord('A') + i)
58    #     letterList.GenLetterImage(letter)
59    #     letterList.SaveImg(f"template/{font_type}_{letter}_cap.png")
60
61
62if __name__=='__main__':
63    generate("times.ttf", "serif")
64    generate("msyh.ttc", "sans-serif")

生成出来的字符模板是这样子的:

template-example

模板匹配

提取好字符,生成好模板,下面就可以开心的做匹配了!

但是又遇到这么几个问题:

  1. 这个模板和提取的字符长得完全不像啊!
  2. cv2.matchTemplate() 做模板匹配的时候,并不会做任何大小的改变,也就是说我需要尽量保证大小一致,但是这个大小怎么控制呢?
  3. 旋转也是要考虑的问题,大部分字符都会存在一定的旋转角,这个怎么解决?

对于这几个问题,我采取的解决方法是:

  1. 匹配之前,模板图片也使用同样的 extract_char() 函数进行一遍处理。由于分辨率原因,第一次膨胀需要采用和字符提取时不同的参数。这个需要摸索一下找到最佳参数,尽量使得处理后的模板和处理后的字符大小相近。
  2. 自适应大小。从缩小 12% 到放大 12%,以 1.5% 为间隔进行缩放,分别匹配。
  3. 自适应旋转角度。从 -100° 到 100° 以 20° 为间隔进行旋转,分别匹配。

这里的旋转角和缩放大小都可以看情况调节。需要权衡的是:旋转角度和缩放大小的数量更多能保证更精确的匹配,但是一味的增加角度和大小的数量又会导致性能下降。

下面是用于模板匹配的代码。

 1import cv2
 2import numpy as np
 3import glob
 4from extract_char import extract_char
 5
 6
 7def rotate(img, angle):
 8    # Rotation helper function
 9    h, w = img.shape
10    M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1)
11    return cv2.warpAffine(img, M, (w, h))
12
13
14def tempTrans(raw_img):
15    # Do rotation and extract_char to templates
16    ret = list()
17    for i in range(-100, 100, 20):
18        if i < 0:
19            i += 360
20        img = rotate(raw_img.copy(), i)
21        ret.append(extract_char(img, False, 7)[0])
22    return ret
23
24
25def translate(name):
26    # Get the corresponding character based on filename
27    parts = name.split("_")
28    return parts[-1]
29
30
31def match(img, templates):
32    anslist = dict()
33    # Iter throught templates
34    for template in templates:
35        # Rotate templates
36        for rot_template in tempTrans(template[0]):
37            h, w = rot_template.shape
38            for i in range(-8, 8):
39                # Change template size
40                temp = cv2.resize(rot_template.copy(), (int(h*(1 - 0.015*i)), int(w*(1 - 0.015*i))))
41                
42                # Match it
43                result = cv2.matchTemplate(img, temp, cv2.TM_CCOEFF_NORMED)
44                
45                # Find numbers above threshold
46                threshold = 0.65
47                loc = np.where(result >= threshold)
48                ans = translate(template[1])
49                if ans in anslist.keys():
50                    anslist[ans] += loc[0].size
51                else:
52                    anslist[ans] = loc[0].size
53    anslist = sorted(anslist.items(), key=lambda x: x[1], reverse=True)
54    return anslist[0][0]
55
56
57def recognize(imgList):
58    # Prepare templates
59    template_paths = glob.glob("./template/*.png")
60    templates = list()
61    for path in template_paths:
62        templates.append((cv2.imread(path, 0), path.split("\\")[-1].split(".")[0]))
63    
64    # Recognize captchas
65    ansList = list()
66    for img, path in imgList:
67        print(path)
68        chars = extract_char(img, True)
69        
70        ansStr = str()
71        for char in chars:
72            ans = match(char, templates)
73            ansStr += ans
74        ansList.append(ansStr)
75        
76    return ansList
77
78
79if __name__ == "__main__":
80    # Get captcha images
81    imgList = list()
82    imgPaths = glob.glob("./captcha/*.jpg")
83    for path in imgPaths:
84        imgList.append((cv2.imread(path, 0), path))
85    
86    # Recognize them
87    ans = recognize(imgList)
88    print(ans)

结果

由于模板匹配的粗糙特性,这种方法的效果非常差劲。存在几个问题:

  1. 有些字符因为难以矫正的巨大扭曲,而完全无法与模板匹配上。
  2. 有很多字符会与其他字符匹配度更高。例如非常讨厌的 l (Lima),凡是中间会有一竖条的字符,识别结果里常常会有它,而且极可能名列榜首。其实我拿到的数据集中并不存在 l 这个字符,但是光靠一个很小的数据集我没法确定到底有没有它,无法直接排除 l
  3. 有些字符旋转以后会和别的字符完全一致。这里要点名批评 q``b6``9u``n。一旦旋转矫正出现问题,他们就会直接变成另一个字符。而由于原图中每个字符都有不同的变形和旋转,我现有的程序难以辨认一个字符的正确朝向并予以矫正。
  4. 矫正过程中的拉伸会导致一些字符无法区分。0``o1``7 在拉伸变换到正方形后,会变得完全一致无法区分。部分情况下,h``n 也会同样难以区分。
  5. 性能差劲。由于自适应匹配需要进行大量的尝试,因此会消耗大量的时间。
  6. 几乎每一张验证码都会至少有那么一两个字符扭曲非常厉害。而验证码这种东西,要么全对,要么一个字符错就算全错。这导致单字符的识别准确率提升并没有什么大作用,全图识别的准确率几乎为 0。对某些参数的精细调整也许可以强行使一张特定的图片识别准确,但是对其他图片将仍然难以识别全对。

由于这一系列难以解决的问题,模板匹配这条路宣告失败。

机器学习

既然 9102 年了,在这个炼丹当道的世代,当然是要试试机器学习的。

思路

用机器学习做验证码有两种思路:

  1. 直接把整张图扔进去,输出一整串结果向量。
  2. 分别抠出字符,然后做字符的分类识别。

如果验证码是属于那种有干扰噪声,但是字符本身较为规整变形不多的情况,可以考虑采用第一种情况。然而现在我这个情况实属变形得可怕,在数据集不足的情况下我决定还是走第二条路。毕竟抠字的代码现成可用,所以并不用费什么功夫。

数据集

不过需要做一点小魔改。之前 extract_char() 函数里,我在第一次四边形变换矫正的时候,用的是四边形近似的方法。这个方法非常激进,在一些情况下(近似的四边形接触到字符的像素的时候)会造成字符矫正过度反而难以辨认,但是在总体上能够提升模板匹配的准确率。既然现在换成神经网络,那就不需要这么过度的矫正了,第一步的四边形变换就可以换成简单的最小外接矩形。下面是修改后的函数。

 1def extract_char(img, reverse_color, iter=2):
 2    # Binary, dilation
 3    if reverse_color:
 4        _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY_INV)
 5    else:
 6        _, binary = cv2.threshold(img, 0, 255, cv2.THRESH_OTSU + cv2.THRESH_BINARY)
 7    kernel = np.ones((3, 3), np.uint8)
 8    dilation = cv2.dilate(binary, kernel, iterations=iter)
 9    
10    # Find contours, sort them
11    contours, hierarchy = cv2.findContours(dilation, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
12    contours = sorted(contours, key=(lambda x: (x[0][0][0], x[0][0][1])))
13    
14    chars = list()
15    
16    for cnt in contours:
17        # Find rectangle
18        rect = cv2.minAreaRect(cnt)
19        box = cv2.boxPoints(rect)
20        box = np.float32(box)
21        
22        # Transform to a single image
23        points = np.float32([[25, 125], [25, 25], [125, 25]])
24        M = cv2.getAffineTransform(box[0:3], points)
25        processed = cv2.warpAffine(binary, M, (150, 150))
26        if rect[2] <= -80.0:
27            processed = np.rot90(processed, 1)
28        
29        # Refind countour
30        kernel = np.ones((11, 11), np.uint8)
31        _dilation = cv2.dilate(processed, kernel, iterations=5)
32        _contours, _hierarchy = cv2.findContours(_dilation, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
33        
34        if len(_contours) > 1:
35            print("HELPPPPP More Than ONE Contours")
36            print(_contours)
37        
38        cnt = _contours[0]
39        
40        # Find rectangle
41        rect = cv2.minAreaRect(cnt)
42        box = cv2.boxPoints(rect)
43        box = np.float32(box)
44        
45        # Transform to a single image
46        points = np.float32([[0, 100], [0, 0], [100, 0]])
47        M = cv2.getAffineTransform(box[0:3], points)
48        processed = cv2.warpAffine(processed, M, (100, 100))
49        if rect[2] <= -80.0:
50            processed = np.rot90(processed, 1)
51        
52        chars.append(processed)
53    
54    return chars

最后生成的字符是这个样子:

train-example

前期我把拿到的所有验证码图片都先做了标注,然后用一个脚本批量把每个字符归类放到各自的文件夹里,于是获得了这样的画风:

train-1

train-3

train-t

(没错,就是 t3st1n 最多)

用一个简单脚本可以计算数据集均值。

 1import numpy as np
 2import cv2
 3import glob
 4
 5
 6def calculate_mean(path: str) -> float:
 7    means = list()
 8    imglist = glob.glob(path)
 9    
10    for path in imglist:
11        # Only for gray images
12        img = cv2.imread(path, 0)
13        img_mean = np.mean(img)
14        means.append(img_mean)
15    
16    return np.mean(means) / 255
17
18
19if __name__ == "__main__":
20    print(calculate_mean("./dataset/*/*.png"))

计算得出,该数据集的均值 mean 为 0.13。

于是,数据集就准备完毕了。

模型

 1class View(nn.Module):
 2    def __init__(self):
 3        super(View, self).__init__()
 4
 5    def forward(self, x):
 6        return x.view(x.size(0), -1)
 7
 8model = nn.Sequential(
 9    nn.Conv2d(1, 69, kernel_size=3, stride=1, padding=1, dilation=1),
10    nn.BatchNorm2d(69),
11    nn.ReLU(),
12    nn.Conv2d(69, 69, kernel_size=3, stride=1, padding=2, dilation=2),
13    nn.AvgPool2d(2),
14    nn.BatchNorm2d(69),
15    nn.ReLU(),
16    nn.Conv2d(69, 69, kernel_size=3, stride=1, padding=4, dilation=4),
17    nn.AvgPool2d(2),
18    nn.BatchNorm2d(69),
19    nn.ReLU(),
20    nn.Conv2d(69, 69, kernel_size=3, stride=1, padding=8, dilation=8),
21    nn.AvgPool2d(2),
22    nn.BatchNorm2d(69),
23    nn.ReLU(),
24    nn.Conv2d(69, 50, kernel_size=3, stride=1, padding=1, dilation=1),
25    nn.BatchNorm2d(50),
26    nn.ReLU(),
27    nn.Conv2d(50, 34, kernel_size=3, stride=1, padding=1, dilation=1),
28    nn.BatchNorm2d(34),
29    nn.ReLU(),
30    nn.AdaptiveAvgPool2d(1),
31    View(),
32)

依然是 Good old 全卷积 + 空洞卷积 + 全局池化。优点分析可以参见上一篇博客 基于全卷积网络的图像分类

需要特别说明的是最后一层输出的是 34 类而非 36 类,原因是数据集里缺失了 6l 这两个字符… 如果数据集包含了完整的 36 个字符,则需要把最后一个卷积层的深度改为 36,全局池化层的输出也需要改为 36。

结果

使用 0.1 的学习率,128 的 Batch size,在 40 - 50 个 epoch 后收敛。

训练集 acc 约为 96.2%,验证集 acc 约为 93.3%。

在实际的验证码图片测试中,整体准确率上升到 80%。其中,t3st1n 这一条验证码的识别准确率为 99%(毕竟好几百条这哥们儿),其余验证码的准确率约 50%。由于其他验证码数据中,有部分字符十分稀缺,所以识别准确率不出意外的比较差。

因此可以看出,只要拥有足够的数据量(每个字符 100 张以上),神经网络可以获得相当优秀的识别准确率。

尾声

在花费了一个礼拜的功夫之后,这网站把验证码系统换成了 reCAPTCHA项目终结。😕


关于验证码的一些思考

验证码,即 CAPTCHA,全称 Completely Automated Public Turing test to tell Computers and Humans Apart(全自动区分计算机和人类的图灵测试)。

顾名思义,验证码的初衷在于区分人和机器人,需要使用人能够简单完成,但是程序难以完成的任务。最初的传统验证码使用加入噪声或者扭曲的文字图像,人能够轻易辨认,程序难以识别。但是随着计算机视觉和深度学习的发展,现在的机器人已经有能力解决 99% 的文字验证码了。为了抵抗机器人,验证码的字符越来越扭曲,噪声越来越多,人越来越难以看清,反而是机器人依旧能够很准确的识别。就看我正在解决的这个验证码,一个普普通通的学生都可以随便做到很高的识别率,反而是人眼不一定能轻松辨认出是什么字符。从这个角度来说,这个验证码不就是本末倒置了么。

厂商们都清楚这个问题。为了解决这个问题,新一代的验证码开始流行于市场上。我常常见到的有这么几种:

  1. 在文字图片上点击指定的文字
  2. 拖动一个滑块,完成拼图 / 旋转图片到指定角度
  3. 在几张图片中选中指定内容的图片

更为高级的,就是谷歌的 reCAPTCHA。从第二代 reCAPTCHA 开始,谷歌就会综合各种数据给每个用户打分,低于某个阈值的就可以认为是机器人。到第三代,甚至在前端完全不提供任何用户界面,只在网站后台提供打分系统。

但是这些验证码都不可破解吗?显然不是。上面我提到的这三种形式里的前两个无非都只是需要鼠标操作,写一个控制鼠标操作的脚本何其容易,剩下的判定利用神经网络解决也不会是难事。至于第三个,如果是 12306 那种物体类别判断,想想我们现在连 ImageNet 的 1000 类都可以训练下来,破解下这个不就是小儿科;如果是谷歌那种一整张图切成九宫格选物体的类型,大不了也就是上个语义分割网络,再或者也可以选择走提供给视障患者的语音挑战来绕过这个。

就连用机器学习去判断用户的谷歌 reCAPTCHA,也不是不能破解。这篇论文:Hacking Google reCAPTCHA v3 using Reinforcement Learning 就介绍了一种用强化学习绕过 reCAPTCHA v3 的方法。

扯了这么多有的没的,我想表达的观点很简单。道高一尺魔高一丈,研发验证码的人不断创造出新的验证码把机器人挡在外面,而开发爬虫的人又不断更新自己的程序来绕过限制,这不就是妥妥的零和博弈么。生成式对抗网络里生成器和判别器的博弈训练过程和这样的螺旋上升过程不就是完全一回事吗。如此精妙,不得不赞叹隐藏在世界表面下的奇妙社会规律,也不得不赞叹 Goodfellow 强大的洞察和想象力。(GAN 吹是我了)

那把眼光放长放科幻,这个运行在世界上的大型 GAN 最后能给我们带来什么样的结果呢?会是人类一败涂地还是机器大获胜利?Let’s see.


#tech notes
本文总字数 5773
本文阅读量
本站访客量

↪ reply by email