Python3 OpenCV4 计算机视觉学习手册:6~11(4)https://developer.aliyun.com/article/1427065
了解 MNIST 手写数字数据库
可在这个页面上公开获得 MNIST 数据库(或美国国家标准混合技术研究院数据库)。该数据库包括一个包含 60,000 个手写数字图像的训练集。 其中一半是由美国人口普查局的雇员撰写的,而另一半是由美国的高中生撰写的。
该数据库还包括从同一作者那里收集的 10,000 张图像的测试集。 所有训练和测试图像均为灰度格式,尺寸为28 x 28
像素。 在黑色背景上,数字为白色(或灰色阴影)。 例如,以下是 MNIST 训练样本中的三个:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AkdubCnU-1681871605272)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/aafb1f29-c71f-488d-8696-2307830f5cea.png)]
作为使用 MNIST 的替代方法,您当然可以自己构建一个类似的数据库。 这将涉及收集大量手写数字的图像,将图像转换为灰度图像,对其进行裁剪以使每个图像在标准化位置均包含一个数字,然后缩放图像以使它们都具有相同的大小。 您还需要标记图像,以便程序可以读取正确的分类,以训练和测试分类器。
许多作者提供了有关如何将 MNIST 数据库与各种机器学习库和算法结合使用的示例-不仅是 OpenCV,还不仅仅是 ANN。 免费在线书籍《神经网络和深度学习》的作者 Michael Nielsen 在这里为 MNIST 和 ANN 专门撰写了一章。 他展示了如何仅使用 NumPy 几乎从头开始实现 ANN,如果您想加深对 OpenCV 公开的高级功能的了解,那么这是一本非常好的读物。 他的代码可在 GitHub 上免费获得。
Nielsen 提供了 MNIST 版本,为PKL.GZ
(gzip 压缩的 Pickle)文件,可以轻松地将其加载到 Python 中。 出于本书 OpenCV 示例的目的,我们(作者)采用了 Nielsen 的 MNIST 的PKL.GZ
版本,为我们的目的对其进行了重组,并将其放置在本书的chapter10/digits_data/mnist.pkl.gz
的 GitHub 存储库中。
既然我们已经了解了 MNIST 数据库,那么让我们考虑一下适合该训练集的 ANN 参数。
为 MNIST 数据库选择训练参数
每个 MNIST 样本都是一个包含 784 像素(即28 x 28
像素)的图像。 因此,我们的人工神经网络的输入层将具有 784 个节点。 输出层将有 10 个节点,因为有 10 类数字(0 到 9)。
我们可以自由选择其他参数的值,例如隐藏层中的节点数,要使用的训练样本数以及训练周期数。 与往常一样,实验可以帮助我们找到可提供可接受的训练时间和准确率的值,而不会使模型过度适合训练数据。 根据本书作者所做的一些实验,我们将使用 60 个隐藏节点,50,000 个训练样本和 10 个周期。 这些参数足以进行初步测试,将训练时间缩短至几分钟(取决于计算机的处理能力)。
实现训练 ANN 的模块
您也可能希望在未来的项目中基于 MNIST 训练 ANN。 为了使我们的代码更具可重用性,我们可以编写一个专门用于此训练过程的 Python 模块。 然后(在下一节“实现主模块”中),我们将把这个训练模块导入到主模块中,在这里我们将进行数字检测和分类的演示。
让我们在名为digits_ann.py
的文件中实现训练模块:
- 首先,我们将从 Python 标准库中导入
gzip
和pickle
模块。 和往常一样,我们还将导入 OpenCV 和 NumPy:
import gzip import pickle import cv2 import numpy as np
我们将使用gzip
和pickle
模块解压缩并从mnist.pkl.gz
文件中加载 MNIST 数据。 我们之前在“了解 MNIST 手写数字数据库”部分中简要提到了此文件。 它包含嵌套元组中的 MNIST 数据,格式如下:
((training_images, training_ids), (test_images, test_ids))
反过来,这些元组的元素具有以下格式:
- 让我们编写以下帮助函数来解压缩并加载
mnist.pkl.gz
的内容:
def load_data(): mnist = gzip.open('./digits_data/mnist.pkl.gz', 'rb') training_data, test_data = pickle.load(mnist) mnist.close() return (training_data, test_data)
注意,在前面的代码中,training_data
是一个元组,等效于(training_images, training_ids)
,test_data
也是一个元组,等效于(test_images, test_ids)
。
- 我们必须重新格式化原始数据,以匹配 OpenCV 期望的格式。 具体来说,当我们提供用于训练 ANN 的样本输出时,它必须是具有 10 个元素(用于 10 类数字)的向量,而不是单个数字 ID。 为方便起见,我们还将应用 Python 内置的
zip
函数以一种可以对匹配的输入和输出向量对(如元组)进行迭代的方式来重组数据。 让我们编写以下辅助函数来重新格式化数据:
def wrap_data(): tr_d, te_d = load_data() training_inputs = tr_d[0] training_results = [vectorized_result(y) for y in tr_d[1]] training_data = zip(training_inputs, training_results) test_data = zip(te_d[0], te_d[1]) return (training_data, test_data)
- 请注意,前面的代码调用
load_data
和另一个帮助函数vectorized_result
。 后者将 ID 转换为分类向量,如下所示:
def vectorized_result(j): e = np.zeros((10,), np.float32) e[j] = 1.0 return e
例如,将 ID 1
转换为包含值[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0\. 0.0]
的 NumPy 数组。 您可能已经猜到,这个由 10 个元素组成的数组对应于 ANN 的输出层,我们在训练 ANN 时可以将其用作正确输出的样本。
先前的函数load_data
,wrap_data
和vectorized_result
已从 Nielsen 的代码中进行了修改,以加载他的mnist.pkl.gz
版本。 有关 Nielsen 的工作的更多信息,请参阅本章的“了解 MNIST 手写数字数据库”部分。
- 到目前为止,我们已经编写了用于加载和重新格式化 MNIST 数据的函数。 现在,让我们编写一个函数来创建未经训练的 ANN:
def create_ann(hidden_nodes=60): ann = cv2.ml.ANN_MLP_create() ann.setLayerSizes(np.array([784, hidden_nodes, 10])) ann.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0) ann.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1) ann.setTermCriteria( (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS, 100, 1.0)) return ann
请注意,我们已经根据 MNIST 数据的性质对输入和输出层的大小进行了硬编码。 但是,我们允许此函数的调用者指定隐藏层中的节点数。
有关参数的进一步讨论,请参考本章“选择 MNIST 数据库的训练参数”。
- 现在,我们需要一个训练函数,允许调用者指定 MNIST 训练样本的数量和周期的数量。 我们以前的 ANN 样本应该熟悉很多训练函数,因此让我们看一下整个实现,然后再讨论一些细节:
def train(ann, samples=50000, epochs=10): tr, test = wrap_data() # Convert iterator to list so that we can iterate multiple # times in multiple epochs. tr = list(tr) for epoch in range(epochs): print("Completed %d/%d epochs" % (epoch, epochs)) counter = 0 for img in tr: if (counter > samples): break if (counter % 1000 == 0): print("Epoch %d: Trained on %d/%d samples" % \ (epoch, counter, samples)) counter += 1 sample, response = img data = cv2.ml.TrainData_create( np.array([sample], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([response], dtype=np.float32)) if ann.isTrained(): ann.train(data, cv2.ml.ANN_MLP_UPDATE_WEIGHTS | cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE) else: ann.train(data, cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE) print("Completed all epochs!") return ann, test
请注意,我们加载数据,然后通过迭代指定数量的训练周期(每个周期中都有指定数量的样本)来递增地训练 ANN。 对于我们处理的每 1,000 个训练样本,我们会打印一条有关训练进度的消息。 最后,我们同时返回经过训练的 ANN 和 MNIST 测试数据。 我们可能刚刚返回了 ANN,但是如果我们想检查 ANN 的准确率,则手头准备测试数据会很有用。
- 当然,经过训练的 ANN 的目的是进行预测,因此我们将提供以下
predict
函数,以便包装 ANN 自己的predict
方法:
def predict(ann, sample): if sample.shape != (784,): if sample.shape != (28, 28): sample = cv2.resize(sample, (28, 28), interpolation=cv2.INTER_LINEAR) sample = sample.reshape(784,) return ann.predict(np.array([sample], dtype=np.float32))
该函数获取训练有素的人工神经网络和样本图像; 它通过确保样本图像为28 x 28
并通过调整大小(如果不是)来执行最少的数据清理。 然后,它将图像数据展平为向量,然后再将其提供给 ANN 进行分类。
这就是我们支持演示应用所需的所有与 ANN 相关的函数。 但是,让我们还实现一个test
函数,该函数通过对一组给定的测试数据(例如 MNIST 测试数据)进行分类来测量经过训练的 ANN 的准确率。 以下是相关代码:
def test(ann, test_data): num_tests = 0 num_correct = 0 for img in test_data: num_tests += 1 sample, correct_digit_class = img digit_class = predict(ann, sample)[0] if digit_class == correct_digit_class: num_correct += 1 print('Accuracy: %.2f%%' % (100.0 * num_correct / num_tests))
现在,让我们走一小段弯路,编写一个利用所有前面的代码和 MNIST 数据集的最小测试。 之后,我们将继续实现演示应用的主要模块。
实现最小的测试模块
让我们创建另一个脚本test_digits_ann.py
,以测试digits_ann
模块中的功能。 测试脚本非常简单; 这里是:
from digits_ann import create_ann, train, test ann, test_data = train(create_ann()) test(ann, test_data)
请注意,我们尚未指定隐藏节点的数量,因此create_ann
将使用其默认参数值:60 个隐藏节点。 同样,train
将使用其默认参数值:50,000 个样本和 10 个周期。
当我们运行此脚本时,它应打印类似于以下内容的训练和测试信息:
Completed 0/10 epochs Epoch 0: Trained on 0/50000 samples Epoch 0: Trained on 1000/50000 samples ... [more reports on progress of training] ... Completed all epochs! Accuracy: 95.39%
在这里,我们可以看到,对 MNIST 数据集中的 10,000 个测试样本进行分类时,ANN 的准确率达到了 95.39%。 这是一个令人鼓舞的结果,但让我们看一下 ANN 的概括程度。 是否可以对来自与 MNIST 无关的完全不同来源的数据进行准确分类? 我们的主要应用会从我们自己的一张纸的图像中检测数字,这将给分类器带来这种挑战。
实现主要模块
我们的演示程序的主要脚本吸收了本章中有关 ANN 和 MNIST 的所有知识,并将其与我们在前几章中研究的一些对象检测技术相结合。 因此,从很多方面来说,这对我们来说都是一个顶点项目。
让我们在名为detect_and_classify_digits.py
的新文件中实现主脚本:
- 首先,我们将导入 OpenCV,NumPy 和我们的
digits_ann
模块:
import cv2 import numpy as np import digits_ann
- 现在,让我们编写一些辅助函数来分析和调整数字和其他轮廓的边界矩形。 如前几章所述,重叠检测是一个常见问题。 以下称为
inside
的函数将帮助我们确定一个边界矩形是否完全包含在另一个边界矩形内:
def inside(r1, r2): x1, y1, w1, h1 = r1 x2, y2, w2, h2 = r2 return (x1 > x2) and (y1 > y2) and (x1+w1 < x2+w2) and \ (y1+h1 < y2+h2)
借助inside
函数,我们将能够轻松地为每个数字选择最外面的矩形。 这很重要,因为我们不希望检测器遗漏任何手指的四肢。 这样的检测错误可能使分类器的工作变得不可能。 例如,如果我们仅检测到数字的下半部分 8,则分类器可能会合理地将该区域视为 0。
为了进一步确保边界矩形满足分类器的需求,我们将使用另一个名为wrap_digit
的辅助函数,将紧密拟合的边界矩形转换为带有围绕数字填充的正方形。 请记住,MNIST 数据包含28 x 28
像素的数字正方形图像,因此在尝试使用 MNIST 训练的 ANN 对其进行分类之前,我们必须将任何兴趣区域重新缩放至此大小。 通过使用填充的边界正方形而不是紧密拟合的边界矩形,我们确保骨感数字(例如 1)和粗体数字(例如 0)不会不同地拉伸。
- 让我们看一下
wrap_digit
的实现。 首先,我们修改矩形的较小尺寸(宽度或高度),使其等于较大尺寸,然后修改矩形的x
或y
位置,以使中心保持不变:
def wrap_digit(rect, img_w, img_h): x, y, w, h = rect x_center = x + w//2 y_center = y + h//2 if (h > w): w = h x = x_center - (w//2) else: h = w y = y_center - (h//2)
- 接下来,我们在所有侧面添加 5 像素填充:
padding = 5 x -= padding y -= padding w += 2 * padding h += 2 * padding
在这一点上,我们修改后的矩形可能会延伸到图像外部。
- 为了避免超出范围的问题,我们对矩形进行裁剪,使其完全位于图像内。 在这些边缘情况下,这可能会给我们留下非正方形的矩形,但这是可以接受的折衷方案。 我们宁愿使用感兴趣的非正方形区域,而不是仅仅因为它位于图像的边缘而完全抛弃检测到的数字。 这是用于边界检查和裁剪矩形的代码:
if x < 0: x = 0 elif x > img_w: x = img_w if y < 0: y = 0 elif y > img_h: y = img_h if x+w > img_w: w = img_w - x if y+h > img_h: h = img_h - y
- 最后,我们返回修改后的矩形的坐标:
return x, y, w, h
到此结束wrap_digit
辅助函数的实现。
- 现在,让我们进入程序的主要部分。 在这里,我们首先创建一个 ANN 并在 MNIST 数据上对其进行训练:
ann, test_data = digits_ann.train( digits_ann.create_ann(60), 50000, 10)
请注意,我们正在使用digits_ann
模块中的create_ann
和train
函数。 如前所述(“在 MNIST 数据库中选择参数”),我们正在使用 60 个隐藏节点,50,000 个训练样本和 10 个周期。 尽管这些是函数的默认参数值,但无论如何我们还是在这里指定它们,以便以后我们想尝试其他值时更易于查看和修改。*
- 现在,让我们在一张白纸上加载一个包含许多手写数字的测试图像:
img_path = "./digit_https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/digits_0.jpg" img = cv2.imread(img_path, cv2.IMREAD_COLOR)
我们使用的是乔·米尼诺(Joe Minichino)手写的以下图像(但是,当然,您可以根据需要替换其他图像):
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nGjbEM6f-1681871605272)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/5bac9c25-a8d2-4f07-9238-d7e5998374be.jpg)]
- 让我们将图像转换为灰度并使其模糊,以消除噪点并使墨水的暗度更加均匀:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) cv2.GaussianBlur(gray, (7, 7), 0, gray)
- 现在我们有了一个平滑的灰度图像,我们可以应用一个阈值和一些形态学操作,以确保数字与背景脱颖而出,并且轮廓相对没有不规则性,这可能会超出预测。 以下是相关代码:
ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV) erode_kernel = np.ones((2, 2), np.uint8) thresh = cv2.erode(thresh, erode_kernel, thresh, iterations=2)
注意阈值标志cv2.THRESH_BINARY_INV
,它是反二进制阈值。 由于 MNIST 数据库中的样本是黑底白字(而不是黑底白字),因此我们将图像转换为带有白色数字的黑色背景。 我们将阈值图像用于检测和分类。
- 进行形态学操作后,我们需要分别检测图片中的每个数字。 为此,首先,我们需要找到轮廓:
contours, hier = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
- 然后,我们遍历轮廓并找到其边界矩形。 我们丢弃任何我们认为太大或太小而无法数字化的矩形。 我们还将丢弃完全包含在其他矩形中的所有矩形。 其余的矩形将追加到一个良好的矩形列表中(我们相信),这些矩形包含单个数字。 让我们看下面的代码片段:
rectangles = [] img_h, img_w = img.shape[:2] img_area = img_w * img_h for c in contours: a = cv2.contourArea(c) if a >= 0.98 * img_area or a <= 0.0001 * img_area: continue r = cv2.boundingRect(c) is_inside = False for q in rectangles: if inside(r, q): is_inside = True break if not is_inside: rectangles.append(r)
- 现在我们有了一个好的矩形列表,可以遍历它们,使用
wrap_digit
函数对它们进行清理,并对其中的图像数据进行分类:
for r in rectangles: x, y, w, h = wrap_digit(r, img_w, img_h) roi = thresh[y:y+h, x:x+w] digit_class = int(digits_ann.predict(ann, roi)[0])
- 此外,在对每个数字进行分类之后,我们绘制了经过清理的边界矩形和分类结果:
cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2) cv2.putText(img, "%d" % digit_class, (x, y-5), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
- 处理完所有兴趣区域后,我们将保存阈值图像和带有完整标注的图像,并显示它们,直到用户按下任何键以结束程序为止:
cv2.imwrite("detected_and_classified_digits_thresh.png", thresh) cv2.imwrite("detected_and_classified_digits.png", img) cv2.imshow("thresh", thresh) cv2.imshow("detected and classified digits", img) cv2.waitKey()
脚本到此结束。 运行它时,我们应该看到阈值图像以及检测和分类结果的可视化。 (最初两个窗口可能重叠,因此您可能需要移动一个窗口才能看到另一个窗口。)这是阈值图像:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-k2Y1moA6-1681871605272)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/92d5e988-1408-45b1-922e-a5b78068deeb.png)]
这是结果的可视化:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cHubVUQr-1681871605273)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/e2a4e4cd-f574-49cb-a452-0a70c4d37ab0.png)]
该图像包含 110 个采样位:从 0 到 9 的一位数字中的 10 位,再加上从 10 到 59 的两位数字中的 100 位。在这 110 个采样中,可以正确检测到 108 个采样的边界,这意味着,探测器的准确率为 98.18%。 然后,在这 108 个正确检测的样本中,对 80 个样本的分类结果是正确的,这意味着 ANN 分类器的准确率为 74.07%。 这比随机分类器要好得多,后者只能在 10% 的时间内正确分类一个数字。
因此,ANN 显然能够学习一般地对手写数字进行分类,而不仅仅是 MNIST 训练和测试数据集中的数字。 让我们考虑一些改善学习的方法。
试图改善人工神经网络的训练
我们可以对训练 ANN 的问题进行一些潜在的改进。 我们已经提到了其中一些潜在的改进,但让我们在这里进行回顾:
- 您可以尝试训练数据集的大小,隐藏节点的数量和周期的数量,直到找到最高的准确率。
- 您可以修改
digits_ann.create_ann
函数,使其支持多个隐藏层。 - 您也可以尝试其他激活函数。 我们使用了
cv2.ml.ANN_MLP_SIGMOID_SYM
,但这不是唯一的选择。 其他包括cv2.ml.ANN_MLP_IDENTITY
,cv2.ml.ANN_MLP_GAUSSIAN
,cv2.ml.ANN_MLP_RELU
和cv2.ml.ANN_MLP_LEAKYRELU
。 - 同样,您可以尝试不同的训练方法。 我们使用了
cv2.ml.ANN_MLP_BACKPROP
。 其他选项包括cv2.ml.ANN_MLP_RPROP
和cv2.ml.ANN_MLP_ANNEAL
。
有关 OpenCV 中与 ANN 相关的参数的更多信息,请访问这个页面上的官方文档。
除了试验参数外,请仔细考虑您的应用需求。 例如,您的分类器将在哪里和由谁使用? 并非每个人都以相同的方式绘制数字。 确实,不同国家的人们倾向于以略有不同的方式得出数字。
MNIST 数据库是在美国编译的,数字 7 与手写字符 7 一样是手写的。但是,在欧洲,数字 7 通常是用数字的对角线部分中间的一条小水平线手写的。 引入此笔划是为了帮助区分手写数字 7 和手写数字 1。
有关区域手写变化的更详细概述,请查看 Wikipedia 上有关该主题的文章,这是一个很好的介绍,可在这个页面上找到。
这种变化意味着在 MNIST 数据库上训练的 ANN 在应用于欧洲手写数字的分类时可能不太准确。 为了避免这样的结果,您可以选择创建自己的训练数据集。 在几乎所有情况下,最好利用属于当前应用域的训练数据。
最后,请记住,一旦对分类器的准确率感到满意,就可以随时将其保存并稍后重新加载,这样它就可以在应用中使用,而不必每次都训练 ANN。
该界面类似于在“保存和加载受过训练的 SVM”部分中看到的接口,该部分接近第 7 章,“构建自定义对象检测器”。 具体来说,您可以使用以下代码将经过训练的 ANN 保存到 XML 文件:
ann = cv2.ml.ANN_MLP_create() data = cv2.ml.TrainData_create( training_samples, layout, training_responses) ann.train(data) ann.save('my_ann.xml')
随后,您可以使用如下代码重新加载经过训练的 ANN:
ann = cv2.ml.ANN_MLP_create() ann.load('my_ann.xml')
既然我们已经学习了如何为手写数字分类创建可重用的 ANN,让我们考虑一下这种分类器的用例。
寻找其他潜在的应用
前面的演示仅是手写识别应用的基础。 您可以轻松地将方法扩展到视频并实时检测手写数字,也可以训练 ANN 识别整个字母,以实现完整的光学字符识别(OCR)系统。
汽车牌照的检测和识别将是到目前为止我们所学课程的另一个有用的扩展。 车牌上的字符具有一致的外观(至少在给定的国家/地区内),这应该是问题的 OCR 部分的简化因素。
您也可以尝试将 ANN 应用于以前使用过 SVM 的问题,反之亦然。 这样,您可以看到它们的准确率如何与不同类型的数据进行比较。 回想一下,在第 7 章,“构建自定义对象检测器”中,我们使用 SIFT 描述符作为 SVM 的输入。 同样,人工神经网络能够处理高级描述符,而不仅仅是普通的旧像素数据。
如我们所见,cv2.ml_ANN_MLP
类用途广泛,但实际上,它仅涵盖了 ANN 设计方法的一小部分。 接下来,我们将了解 OpenCV 对更复杂的深度神经网络(DNN)的支持,这些网络可以通过其他各种框架进行训练。
在 OpenCV 中使用其他框架的 DNN
OpenCV 可以加载和使用在以下任何框架中经过训练的 DNN:
深度学习部署工具包(DLDT)是英特尔 OpenVINO 工具包的一部分。 DLDT 提供了用于优化其他框架中的 DNN 并将其转换为通用格式的工具。 兼容 DLDT 的模型的集合可在称为开放模型动物园的存储库中免费获得。 DLDT,开放模型动物园和 OpenCV 在其开发团队中拥有一些相同的人。 这三个项目均由英特尔赞助。
这些框架使用各种文件格式来存储经过训练的 DNN。 其中一些框架使用了一对文件格式的组合:一个用于描述模型参数的文本文件,以及一个用于存储模型本身的二进制文件。 以下代码段显示了与从每个框架加载模型相关的文件类型和 OpenCV 函数:
caffe_model = cv2.dnn.readNetFromCaffe( 'my_model_description.protext', 'my_model.caffemodel') tensor_flow_model = cv2.dnn.readNetFromTensorflow( 'my_model.pb', 'my_model_description.pbtxt') # Some Torch models use the .t7 extension and others use # the .net extension. torch_model_0 = cv2.dnn.readNetFromTorch('my_model.t7') torch_model_1 = cv2.dnn.readNetFromTorch('my_model.net') darknet_model = cv2.dnn.readNetFromDarket( 'my_model_description.cfg', 'my_model.weights') onnx_model = cv2.dnn.readNetFromONNX('my_model.onnx') dldt_model = cv2.dnn.readNetFromModelOptimizer( 'my_model_description.xml', 'my_model.bin')
加载模型后,我们需要预处理将用于模型的数据。 必要的预处理特定于给定 DNN 的设计和训练方式,因此,每当我们使用第三方 DNN 时,我们都必须了解该 DNN 的设计和训练方式。 OpenCV 提供了cv2.dnn.blobFromImage
函数,该函数可以执行一些常见的预处理步骤,具体取决于我们传递给它的参数。 在将数据传递给此函数之前,我们可以手动执行其他预处理步骤。
神经网络的输入向量有时称为张量或 Blob,因此称为函数名称cv2.dnn.blobFromImage
。
让我们继续来看一个实际的示例,在该示例中,我们将看到第三方 DNN 的运行。
使用第三方 DNN 检测和分类对象
对于此演示,我们将实时捕获来自网络摄像头的帧,并使用 DNN 来检测和分类任何给定帧中可能存在的 20 种对象。 是的,单个 DNN 可以在程序员可能使用的典型笔记本电脑上实时完成所有这些操作!
在深入研究代码之前,让我们介绍一下我们将使用的 DNN。 它是称为 MobileNet-SSD 的模型的 Caffe 版本,它使用 Google 的 MobileNet 框架与另一个称为单发检测器(SSD)MultiBox。 后一个框架在这个页面上有一个 GitHub 存储库。 Caffe 版本的 MobileNet-SSD 的训练技术由 GitHub 上的一个项目提供。 可以在本书的存储库中的chapter10/objects_data
文件夹中找到以下 MobileNet-SSD 文件的副本:
MobileNetSSD_deploy.caffemodel
:这是模型。MobileNetSSD_deploy.prototxt
:这是描述模型参数的文本文件。
随着我们的示例代码的进行,该模型的功能和正确用法将很快变得清晰起来:
- 与往常一样,我们首先导入 OpenCV 和 NumPy:
import cv2 import numpy as np
- 我们以上一节中介绍的相同方式继续使用 OpenCV 加载 Caffe 模型:
model = cv2.dnn.readNetFromCaffe( 'objects_data/MobileNetSSD_deploy.prototxt', 'objects_data/MobileNetSSD_deploy.caffemodel')
- 我们需要定义一些特定于该模型的预处理参数。 它期望输入图像为 300 像素高。 此外,它期望图像中的像素值在 -1.0 到 1.0 的范围内。 这意味着相对于从 0 到 255 的通常标度,有必要减去 127.5,然后除以 127.5。 我们将参数定义如下:
blob_height = 300 color_scale = 1.0/127.5 average_color = (127.5, 127.5, 127.5)
- 我们还定义了一个置信度阈值,表示为了将检测作为真实对象而需要的最低置信度得分:
confidence_threshold = 0.5
- 该模型支持 20 类对象,其 ID 为 1 到 20(而不是 0 到 19)。 这些类的标签可以定义如下:
labels = ['airplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus', 'car', 'cat', 'chair', 'cow', 'dining table', 'dog', 'horse', 'motorbike', 'person', 'potted plant', 'sheep', 'sofa', 'train', 'TV or monitor']
稍后,当我们使用类 ID 在列表中查找标签时,必须记住从 ID 中减去 1,以获得 0 到 19(而不是 1 到 20)范围内的索引。
有了模型和参数,我们准备开始捕获帧。
- 对于每一帧,我们首先计算纵横比。 请记住,此 DNN 期望输入基于 300 像素高的图像; 但是,宽度可以变化以匹配原始的宽高比。 以下代码段显示了如何捕获帧并计算适当的输入大小:
cap = cv2.VideoCapture(0) success, frame = cap.read() while success: h, w = frame.shape[:2] aspect_ratio = w/h # Detect objects in the frame. blob_width = int(blob_height * aspect_ratio) blob_size = (blob_width, blob_height)
- 此时,我们可以简单地使用
cv2.dnn.blobFromImage
函数及其几个可选参数来执行必要的预处理,包括调整帧的大小并将其像素数据转换为 -1.0 到 1.0 的比例:
blob = cv2.dnn.blobFromImage( frame, scalefactor=color_scale, size=blob_size, mean=average_color)
- 我们将生成的 Blob 馈送到 DNN 并获取模型的输出:
model.setInput(blob) results = model.forward()
结果是一个数组,其格式特定于我们使用的模型。
- 对于此对象检测 DNN(以及使用 SSD 框架训练的其他 DNN),结果包括检测到的对象的子数组,每个对象都有自己的置信度得分,矩形坐标和类 ID。 以下代码显示了如何访问它们,以及如何使用 ID 在我们先前定义的列表中查找标签:
# Iterate over the detected objects. for object in results[0, 0]: confidence = object[2] if confidence > confidence_threshold: # Get the object's coordinates. x0, y0, x1, y1 = (object[3:7] * [w, h, w, h]).astype(int) # Get the classification result. id = int(object[1]) label = labels[id - 1]
- 遍历检测到的对象时,我们绘制检测矩形,分类标签和置信度得分:
# Draw a blue rectangle around the object. cv2.rectangle(frame, (x0, y0), (x1, y1), (255, 0, 0), 2) # Draw the classification result and confidence. text = '%s (%.1f%%)' % (label, confidence * 100.0) cv2.putText(frame, text, (x0, y0 - 20), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
- 我们对框架所做的最后一件事就是展示它。 然后,如果用户按下
Esc
键,则退出; 否则,我们将捕获另一帧并继续循环的下一个迭代:
cv2.imshow('Objects', frame) k = cv2.waitKey(1) if k == 27: # Escape break success, frame = cap.read()
如果插入网络摄像头并运行脚本,则应该看到检测结果和分类结果的可视化图像,并实时更新。 这是一个截图,显示约瑟夫·豪斯和萨尼贝尔·德尔菲姆·安德洛梅达(一只强大,善良和公义的猫)在加拿大一个渔村的客厅中:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B3PHAD0g-1681871605273)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/ad4e5406-cd70-4be5-850b-8949888fe547.png)]
DNN 已正确检测并分类了人类人(置信度为 99.4%),猫(85.4%),装饰性瓶子(72.1%),并进行了分类, 沙发的一部分(61.2%),以及船的纺织品图片(52.0%)。 显然,该 DNN 能够很好地对航海环境中的客厅进行分类!
这只是 DNN 可以做的事情的第一手–实时! 接下来,让我们看看通过在一个应用中组合三个 DNN 可以实现什么。
使用第三方 DNN 检测和分类人脸
在此演示中,我们将使用一个 DNN 来检测面部,并使用另外两个 DNN 来分类每个检测到的面部的年龄和性别。 具体来说,我们将使用预先训练的 Caffe 模型,这些模型存储在本书 GitHub 存储库的chapter10/faces_data
文件夹中的以下文件中。
以下是此文件夹中文件的清单以及这些文件的来源:
detection/res10_300x300_ssd_iter_140000.caffemodel
:这是用于人脸检测的 DNN。 OpenCV 团队已在这个页面提供了此文件。 这个 Caffe 模型是使用 SSD 框架训练的。 因此,它的拓扑类似于上一节示例中使用的 MobileNet-SSD 模型。detection/deploy.prototxt
:这是文本文件,描述了用于人脸检测的先前 DNN 的参数。 OpenCV 团队在这个页面提供此文件。
chapter10/faces_data/age_gender_classification
文件夹包含以下文件,这些文件均由 Gil Levi 和 Tal Hassner 在其 GitHub 存储库中及其项目页面上提供,他们在年龄和性别分类方面的工作:
age_net.caffemodel
:这是用于年龄分类的 DNN。age_net_deploy.protext
:这是文本文件,描述了用于年龄分类的先前 DNN 的参数。gender_net.caffemodel
:这是用于性别分类的 DNN。gender_net_deploy.protext
:这是文本文件,描述了用于年龄分类的先前 DNN 的参数。average_face.npy
和average_face.png
:这些文件表示分类器训练数据集中的平均面孔。 来自 Levi 和 Hassner 的原始文件称为mean.binaryproto
,但我们已将其转换为 NumPy 可读格式和标准图像格式,这对于我们的使用更加方便。
让我们看看如何在代码中使用所有这些文件:
- 为了开始示例程序,我们加载人脸检测 DNN,定义其参数,并定义置信度阈值。 我们以与上一节样本中的对象检测 DNN 大致相同的方式执行此操作:
import cv2 import numpy as np face_model = cv2.dnn.readNetFromCaffe( 'faces_data/detection/deploy.prototxt', 'faces_data/detection/res10_300x300_ssd_iter_140000.caffemodel') face_blob_height = 300 face_average_color = (104, 177, 123) face_confidence_threshold = 0.995
我们不需要为此 DNN 定义标签,因为它不执行任何分类。 它只是预测面矩形的坐标。
- 现在,让我们加载年龄分类器并定义其分类标签:
age_model = cv2.dnn.readNetFromCaffe( 'faces_data/age_gender_classification/age_net_deploy.prototxt', 'faces_data/age_gender_classification/age_net.caffemodel') age_labels = ['0-2', '4-6', '8-12', '15-20', '25-32', '38-43', '48-53', '60+']
请注意,在此模型中,年龄标签之间存在间隙。 例如,'0-2'
后跟'4-6'
。 因此,如果一个人实际上是 3 岁,则分类器没有适合这种情况的标签; 最多可以选择'0-2'
或'4-6'
之一。 大概是,模型的作者有意选择了不连续的范围,以确保类别相对于输入而言是可分离的。 让我们考虑替代方案。 根据面部图像中的数据,是否可以将 4 岁以下的人群与每天 4 岁以下的人群分开? 当然不是。 他们看起来一样。 因此,根据连续的年龄范围来制定分类问题是错误的。 可以训练 DNN 将年龄预测为连续变量(例如,浮点数的年数),但这与分类器完全不同,分类器预测各个类别的置信度得分。
- 现在,让我们加载性别分类器并定义其标签:
gender_model = cv2.dnn.readNetFromCaffe( 'faces_data/age_gender_classification/gender_net_deploy.prototxt', 'faces_data/age_gender_classification/gender_net.caffemodel') gender_labels = ['male', 'female']
- 年龄和性别分类器使用相同的 Blob 大小和相同的平均值。 他们使用的不是平均颜色,而是平均颜色的人脸图像,我们将从
NPY
文件中加载该图像(作为浮点格式的 NumPy 数组)。 稍后,我们将在执行分类之前从实际的面部图像中减去该平均面部图像。 以下是斑点大小和平均图像的定义:
age_gender_blob_size = (256, 256) age_gender_average_image = np.load( 'faces_data/age_gender_classification/average_face.npy')
如果要查看普通脸的外观,请打开chapter10/faces_data/age_gender_classification/average_face.png
的文件,该文件包含标准图像格式的相同数据。 这里是:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3l1E2MOd-1681871605273)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/3bc621b4-edb4-4462-8520-68e5ad9014b3.png)]
当然,这只是特定训练数据集的平均面孔。 它不一定代表世界人口或任何特定国家或社区的真实平均面孔。 即使这样,在这里,我们仍可以看到一张由许多面孔组成的模糊面孔,并且没有明显的年龄或性别线索。 请注意,该图像是方形的,以鼻子的尖端为中心,并且从前额的顶部垂直延伸到颈部的底部。 为了获得准确的分类结果,我们应注意将此分类器应用于以相同方式裁剪的面部图像。
- 设置好模型及其参数后,让我们继续从相机捕获和处理帧。 对于每一帧,我们首先创建一个与帧相同的宽高比的 blob,然后将此 blob 馈送到人脸检测 DNN:
cap = cv2.VideoCapture(0) success, frame = cap.read() while success: h, w = frame.shape[:2] aspect_ratio = w/h # Detect faces in the frame. face_blob_width = int(face_blob_height * aspect_ratio) face_blob_size = (face_blob_width, face_blob_height) face_blob = cv2.dnn.blobFromImage( frame, size=face_blob_size, mean=face_average_color) face_model.setInput(face_blob) face_results = face_model.forward()
- 就像我们在上一部分示例中使用的对象检测器一样,人脸检测器提供置信度得分和矩形坐标作为结果的一部分。 对于每个检测到的面部,我们需要检查置信度得分是否可以接受地高,如果是,则将获得面部矩形的坐标:
# Iterate over the detected faces. for face in face_results[0, 0]: face_confidence = face[2] if face_confidence > face_confidence_threshold: # Get the face coordinates. x0, y0, x1, y1 = (face[3:7] * [w, h, w, h]).astype(int)
- 此人脸检测 DNN 生成的矩形长于宽度。 但是,DNN 的年龄和性别分类要求使用方形面孔。 让我们加宽检测到的脸部矩形以使其成为正方形:
# Classify the age and gender of the face based on a # square region of interest that includes the neck. y1_roi = y0 + int(1.2*(y1-y0)) x_margin = ((y1_roi-y0) - (x1-x0)) // 2 x0_roi = x0 - x_margin x1_roi = x1 + x_margin if x0_roi < 0 or x1_roi > w or y0 < 0 or y1_roi > h: # The region of interest is partly outside the # frame. Skip this face. continue
请注意,如果正方形的一部分落在图像的边界之外,我们将跳过此检测结果并继续进行下一个检测。
- 此时,我们可以选择正方形兴趣区域(ROI),其中包含将用于年龄和性别分类的图像数据。 我们将 ROI 缩放到分类器的斑点大小,将其转换为浮点格式,然后减去平均脸部。 根据生成的缩放后的标准化脸部,创建斑点:
age_gender_roi = frame[y0:y1_roi, x0_roi:x1_roi] scaled_age_gender_roi = cv2.resize( age_gender_roi, age_gender_blob_size, interpolation=cv2.INTER_LINEAR).astype(np.float32) scaled_age_gender_roi[:] -= age_gender_average_image age_gender_blob = cv2.dnn.blobFromImage( scaled_age_gender_roi, size=age_gender_blob_size)
- 我们将斑点输入年龄分类器,选择具有最高置信度得分的类 ID,然后记下该 ID 的标签和置信度得分:
age_model.setInput(age_gender_blob) age_results = age_model.forward() age_id = np.argmax(age_results) age_label = age_labels[age_id] age_confidence = age_results[0, age_id]
- 同样,我们将性别分类:
gender_model.setInput(age_gender_blob) gender_results = gender_model.forward() gender_id = np.argmax(gender_results) gender_label = gender_labels[gender_id] gender_confidence = gender_results[0, gender_id]
- 我们绘制检测到的脸部矩形,扩展的方形 ROI 和分类结果的可视化图像:
# Draw a blue rectangle around the face. cv2.rectangle(frame, (x0, y0), (x1, y1), (255, 0, 0), 2) # Draw a yellow square around the region of interest # for age and gender classification. cv2.rectangle(frame, (x0_roi, y0), (x1_roi, y1_roi), (0, 255, 255), 2) # Draw the age and gender classification results. text = '%s years (%.1f%%), %s (%.1f%%)' % ( age_label, age_confidence * 100.0, gender_label, gender_confidence * 100.0) cv2.putText(frame, text, (x0_roi, y0 - 20), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
- 最后,我们显示带标注的帧,并继续捕获更多帧,直到用户按下
Esc
键:
cv2.imshow('Faces, age, and gender', frame) k = cv2.waitKey(1) if k == 27: # Escape break success, frame = cap.read()
该程序如何报告约瑟夫·豪斯? 让我们来看看:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tD78QkCB-1681871605273)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/7061d12a-b3ed-4db8-9c5a-9ba3c0f259ba.png)]
没有虚荣心,约瑟夫·豪斯(Joseph Howse)将就此结果写几段文字。
首先,让我们考虑面部的检测和 ROI 的选择。 已正确检测到脸部。 ROI 已正确扩展到包括脖子的方形区域,或者在这种情况下为完整的胡须,这对于分类年龄和性别可能是重要的区域。
其次,让我们考虑分类。 事实是,约瑟夫·豪斯(Joseph Howse)是男性,在这张照片拍摄时大约 35.8 岁。 看到约瑟夫·豪斯的脸的其他人也能够完全自信地断定他是男性。 但是,他们对他的年龄的估计差异很大。 性别分类 DNN 满怀信心(100.0%)说约瑟夫·豪斯是男性。 年龄分类 DNN 充满信心(96.6%)表示他年龄在 25-32 岁之间。 取这个范围的中点 28.5 也许很诱人,并说该预测的误差为-7.3 年,从客观上来说,这是一个大大的低估了,它是真实年龄的-20.4%。 但是,这种评估是预测含义的延伸。
请记住,此 DNN 是年龄分类器,而不是连续年龄值的预测指标,并且 DNN 的年龄类别被标记为不连续的范围; '25-32'
之后的下一个是'38-43'
。 因此,该模型与约瑟夫·豪斯(Joseph Howse)的真实年龄之间存在差距,但至少它设法从边界上选择了两个类别之一。
该演示结束了我们对 ANN 和 DNN 的介绍。 让我们回顾一下我们学到的东西和做过的事情。
总结
本章概述了人工神经网络的广阔而迷人的世界。 我们了解了人工神经网络的结构,以及如何根据应用需求设计网络拓扑。 然后,我们专注于 OpenCV 对 MLP ANN 的实现,以及 OpenCV 对在其他框架中进行过训练的各种 DNN 的支持。
我们将神经网络应用于现实世界中的问题:特别是手写数字识别; 目标检测和分类; 以及实时的人脸识别,年龄分类和性别分类的组合。 我们看到,即使在这些入门演示中,神经网络在多功能性,准确率和速度方面也显示出很大的希望。 希望这可以鼓励您尝试各种作者的经过预先训练的模型,并学习在各种框架中训练自己的高级模型。
带着这种思想和良好的祝愿,我们现在将分开。
本书的作者希望您通过 OpenCV 4 的 Python 绑定一起经历了我们的旅程。尽管涵盖了 OpenCV 4 的所有功能及其所有绑定将涉及一系列书籍,但我们探索了许多有趣而又充满未来感的概念,并且我们鼓励您与我们以及 OpenCV 社区取得联系,让我们了解您在计算机视觉领域的下一个突破性项目!
十一、附录 A:使用“曲线”过滤器弯曲颜色空间
从第 3 章“使用 OpenCV 处理图像”开始,我们的Cameo
演示应用合并了一种称为曲线的图像处理效果,用于模拟某些物体的色偏。 摄影胶片。 本附录描述了曲线的概念及其使用 SciPy 的实现。
曲线是一种重新映射颜色的技术。 使用曲线时,目标像素处的通道值是(仅)源像素处的相同通道值的函数。 而且,我们不直接定义函数; 而是,对于每个函数,我们定义一组必须通过插值拟合的控制点。 在伪代码中,对于 BGR 图像,我们具有以下内容:
dst.b = funcB(src.b) where funcB interpolates pointsB dst.g = funcG(src.g) where funcG interpolates pointsG dst.r = funcR(src.r) where funcR interpolates pointsR
尽管应避免控制点处的不连续坡度,但会产生曲线,但这种插值方式可能会因实现方式而异。 只要控制点数量足够,我们将使用三次样条插值。
让我们先来看一下如何实现插值。
定义曲线
我们迈向基于曲线的过滤器的第一步是将控制点转换为函数。 大部分工作都是通过名为scipy.interp1d
的 SciPy 函数完成的,该函数接受两个数组(x
和y
坐标)并返回一个对点进行插值的函数。 作为scipy.interp1d
的可选参数,我们可以指定kind
插值; 支持的选项包括'linear'
,'nearest'
,'zero'
,'slinear'
(球形线性),'quadratic'
和'cubic'
。 另一个可选参数bounds_error
可以设置为False
,以允许外插和内插。
让我们编辑我们在Cameo
演示中使用的utils.py
脚本,并添加一个将scipy.interp1d
包裹起来的函数,该函数的接口稍微简单一些:
def createCurveFunc(points): """Return a function derived from control points.""" if points is None: return None numPoints = len(points) if numPoints < 2: return None xs, ys = zip(*points) if numPoints < 3: kind = 'linear' elif numPoints < 4: kind = 'quadratic' else: kind = 'cubic' return scipy.interpolate.interp1d(xs, ys, kind, bounds_error = False)
我们的函数不是使用两个单独的坐标数组,而是采用(x
,y
)对的数组,这可能是指定控制点的一种更易读的方式。 必须对数组进行排序,以使x
从一个索引增加到下一个索引。 通常,为获得自然效果,y
值也应增加,并且第一个和最后一个控制点应为(0, 0)
和(255, 255)
,以保留黑白。 注意,我们将x
视为通道的输入值,并将y
视为对应的输出值。 例如,(128, 160)
将使通道的中间色调变亮。
请注意,三次插值至少需要四个控制点。 如果只有三个控制点,则退回到二次插值;如果只有两个控制点,则退回到线性插值。 为了获得自然效果,应避免这些后备情况。
在本章的其余部分中,我们力求以有效且井井有条的方式使用由createCurveFunc
函数生成的曲线。
缓存和应用曲线
现在,我们可以获得插入任意控制点的曲线的函数。 但是,此函数可能很昂贵。 我们不希望每个通道每个像素运行一次(例如,如果应用于640 x 480
视频的三个通道,则每帧运行 921,600 次)。 幸运的是,我们通常只处理 256 个可能的输入值(每个通道 8 位),并且可以廉价地预先计算并存储许多输出值。 然后,我们的每通道每像素成本只是对缓存的输出值的查找。
让我们编辑utils.py
文件并添加一个将为给定函数创建查找数组的函数:
def createLookupArray(func, length=256): """Return a lookup for whole-number inputs to a function. The lookup values are clamped to [0, length - 1]. """ if func is None: return None lookupArray = numpy.empty(length) i = 0 while i < length: func_i = func(i) lookupArray[i] = min(max(0, func_i), length - 1) i += 1 return lookupArray
我们还添加一个函数,该函数会将查找数组(例如前一个函数的结果)应用于另一个数组(例如图像):
def applyLookupArray(lookupArray, src, dst): """Map a source to a destination using a lookup.""" if lookupArray is None: return dst[:] = lookupArray[src]
请注意,createLookupArray
中的方法仅限于输入值为整数(非负整数)的输入值,因为该输入值用作数组的索引。 applyLookupArray
函数通过使用源数组的值作为查找数组的索引来工作。 Python 的切片符号([:]
)用于将查找的值复制到目标数组中。
让我们考虑另一个优化。 如果我们要连续应用两个或更多曲线怎么办? 执行多次查找效率低下,并且可能导致精度降低。 我们可以通过在创建查找数组之前将两个曲线函数组合为一个函数来避免这些问题。 让我们再次编辑utils.py
并添加以下函数,该函数返回两个给定函数的组合:
def createCompositeFunc(func0, func1): """Return a composite of two functions.""" if func0 is None: return func1 if func1 is None: return func0 return lambda x: func0(func1(x))
createCompositeFunc
中的方法仅限于采用单个参数的输入函数。 参数必须是兼容类型。 请注意,使用 Python 的lambda
关键字创建匿名函数。
以下是最终的优化问题。 如果我们想对图像的所有通道应用相同的曲线怎么办? 在这种情况下,拆分和合并通道很浪费,因为我们不需要区分通道。 我们只需要applyLookupArray
使用的一维索引。 为此,我们可以使用numpy.ravel
函数,该函数将一维接口返回到预先存在的给定数组(可能是多维数组)。 返回类型为numpy.view
,其接口与numpy.array
几乎相同,除了numpy.view
仅拥有对数据的引用,而非副本。
NumPy 数组具有flatten
方法,但这将返回一个副本。
numpy.ravel
适用于具有任意数量通道的图像。 因此,当我们希望所有通道都相同时,它可以抽象出灰度图像和彩色图像之间的差异。
现在,我们已经解决了与曲线使用有关的几个重要的优化问题,让我们考虑如何组织代码,以便为诸如Cameo
之类的应用提供简单且可重用的界面。
设计面向对象的曲线过滤器
由于我们为每个曲线缓存了一个查找数组,因此基于曲线的过滤器具有与之关联的数据。 因此,我们将它们实现为类,而不仅仅是函数。 让我们制作一对曲线过滤器类,以及一些可以应用任何函数而不仅仅是曲线函数的相应高级类:
VFuncFilter
:这是一个用函数实例化的类,然后可以使用apply
将其应用于图像。 该函数适用于灰度图像的 V(值)通道或彩色图像的所有通道。VCurveFilter
:这是VFuncFilter
的子类。 而不是使用函数实例化,而是使用一组控制点实例化,这些控制点在内部用于创建曲线函数。BGRFuncFilter
:这是一个用最多四个函数实例化的类,然后可以使用apply
将其应用于 BGR 图像。 这些函数之一适用于所有通道,而其他三个函数均适用于单个通道。 首先应用整体函数,然后再应用每通道函数。BGRCurveFilter
:这是BGRFuncFilter
的子类。 而不是使用四个函数实例化,而是使用四组控制点实例化,这些控制点在内部用于创建曲线函数。
此外,所有这些类都接受数字类型的构造器参数,例如numpy.uint8
,每个通道 8 位。 此类型用于确定查找数组中应包含多少个条目。 数值类型应为整数类型,并且查找数组将覆盖从 0 到该类型的最大值(包括该值)的范围。
首先,让我们看一下VFuncFilter
和VCurveFilter
的实现,它们都可以添加到filters.py
中:
class VFuncFilter(object): """A filter that applies a function to V (or all of BGR).""" def __init__(self, vFunc=None, dtype=numpy.uint8): length = numpy.iinfo(dtype).max + 1 self._vLookupArray = utils.createLookupArray(vFunc, length) def apply(self, src, dst): """Apply the filter with a BGR or gray source/destination.""" srcFlatView = numpy.ravel(src) dstFlatView = numpy.ravel(dst) utils.applyLookupArray(self._vLookupArray, srcFlatView, dstFlatView) class VCurveFilter(VFuncFilter): """A filter that applies a curve to V (or all of BGR).""" def __init__(self, vPoints, dtype=numpy.uint8): VFuncFilter.__init__(self, utils.createCurveFunc(vPoints), dtype)
在这里,我们正在内部使用几个以前的函数:utils.createCurveFunc
,utils.createLookupArray
和utils.applyLookupArray
。 我们还使用numpy.iinfo
根据给定的数字类型确定相关的查找值范围。
现在,让我们看一下BGRFuncFilter
和BGRCurveFilter
的实现,它们也都可以添加到filters.py
中:
class BGRFuncFilter(object): """A filter that applies different functions to each of BGR.""" def __init__(self, vFunc=None, bFunc=None, gFunc=None, rFunc=None, dtype=numpy.uint8): length = numpy.iinfo(dtype).max + 1 self._bLookupArray = utils.createLookupArray( utils.createCompositeFunc(bFunc, vFunc), length) self._gLookupArray = utils.createLookupArray( utils.createCompositeFunc(gFunc, vFunc), length) self._rLookupArray = utils.createLookupArray( utils.createCompositeFunc(rFunc, vFunc), length) def apply(self, src, dst): """Apply the filter with a BGR source/destination.""" b, g, r = cv2.split(src) utils.applyLookupArray(self._bLookupArray, b, b) utils.applyLookupArray(self._gLookupArray, g, g) utils.applyLookupArray(self._rLookupArray, r, r) cv2.merge([b, g, r], dst) class BGRCurveFilter(BGRFuncFilter): """A filter that applies different curves to each of BGR.""" def __init__(self, vPoints=None, bPoints=None, gPoints=None, rPoints=None, dtype=numpy.uint8): BGRFuncFilter.__init__(self, utils.createCurveFunc(vPoints), utils.createCurveFunc(bPoints), utils.createCurveFunc(gPoints), utils.createCurveFunc(rPoints), dtype)
同样,我们正在内部使用几个以前的函数:utils.createCurvFunc
,utils.createCompositeFunc
,utils.createLookupArray
和utils.applyLookupArray
。 我们还使用numpy.iinfo
,cv2.split
和cv2.merge
。
这四个类可以按原样使用,在实例化时将自定义函数或控制点作为参数传递。 或者,我们可以创建其他子类,这些子类对某些功能或控制点进行硬编码。 这样的子类可以实例化而无需任何参数。
现在,让我们看一下子类的一些示例。
模拟摄影胶片
曲线的常用用法是模拟数字前摄影中常见的调色板。 每种类型的胶卷都有自己独特的颜色(或灰色)表示法,但我们可以概括一些与数字传感器的区别。 电影往往会损失细节和阴影饱和度,而数字往往会遭受高光的这些缺陷。 而且,胶片在光谱的不同部分上往往具有不均匀的饱和度,因此每张胶片都有某些弹出或跳出的颜色。
因此,当我们想到漂亮的电影照片时,我们可能会想到明亮的且具有某些主导色彩的场景(或副本)。 在另一个极端,也许我们还记得曝光不足的胶卷的暗淡外观,而实验室技术人员的努力并不能改善它。
在本节中,我们将使用曲线创建四个不同的类似于电影的过滤器。 它们受到三种胶片和冲洗技术的启发:
- 柯达波特拉(Kodak Portra),这是一系列针对肖像和婚礼进行了优化的电影。
- Fuji Provia,一个通用电影家族。
- 富士·维尔维亚(Fuji Velvia),针对风景优化的电影系列。
- 交叉处理是一种非标准的胶片处理技术,有时用于在时装和乐队摄影中产生低劣的外观。
每个电影模拟效果都实现为BGRCurveFilter
的非常简单的子类。 在这里,我们只需重写构造器即可为每个通道指定一组控制点。 控制点的选择基于摄影师 Petteri Sulonen 的建议。 有关更多信息,请参见他在这个页面上有关胶片状曲线的文章。
Portra,Provia 和 Velvia 效果应产生看起来正常的图像。 除了前后比较之外,这些效果应该不明显。
让我们从 Portra 过滤器开始,检查四个胶片仿真过滤器中每个过滤器的实现。
模拟柯达 Portra
Portra 具有宽广的高光范围,倾向于暖色(琥珀色),而阴影则较冷(蓝色)。 作为人像电影,它倾向于使人们的肤色更白皙。 而且,它会夸大某些常见的衣服颜色,例如乳白色(例如婚纱)和深蓝色(例如西装或牛仔裤)。 让我们将 Portra 过滤器的此实现添加到filters.py
:
class BGRPortraCurveFilter(BGRCurveFilter): """A filter that applies Portra-like curves to BGR.""" def __init__(self, dtype=numpy.uint8): BGRCurveFilter.__init__( self, vPoints = [(0,0),(23,20),(157,173),(255,255)], bPoints = [(0,0),(41,46),(231,228),(255,255)], gPoints = [(0,0),(52,47),(189,196),(255,255)], rPoints = [(0,0),(69,69),(213,218),(255,255)], dtype = dtype)
从柯达到富士,接下来我们将模拟 Provia。
模拟富士 Provia
普罗维亚(Provia)具有很强的对比度,并且在大多数色调中略微凉爽(蓝色)。 天空,水和阴影比太阳增强更多。 让我们将 Provia 过滤器的此实现添加到filters.py
:
class BGRProviaCurveFilter(BGRCurveFilter): """A filter that applies Provia-like curves to BGR.""" def __init__(self, dtype=numpy.uint8): BGRCurveFilter.__init__( self, bPoints = [(0,0),(35,25),(205,227),(255,255)], gPoints = [(0,0),(27,21),(196,207),(255,255)], rPoints = [(0,0),(59,54),(202,210),(255,255)], dtype = dtype)
接下来是我们的 Fuji Velvia 过滤器。
模拟富士 Velvia
Velvia 具有深阴影和鲜艳的色彩。 它通常可以在白天产生蔚蓝的天空,在日落时产生深红色的云。 这种效果很难模拟,但是这是我们可以添加到filters.py
的尝试:
class BGRVelviaCurveFilter(BGRCurveFilter): """A filter that applies Velvia-like curves to BGR.""" def __init__(self, dtype=numpy.uint8): BGRCurveFilter.__init__( self, vPoints = [(0,0),(128,118),(221,215),(255,255)], bPoints = [(0,0),(25,21),(122,153),(165,206),(255,255)], gPoints = [(0,0),(25,21),(95,102),(181,208),(255,255)], rPoints = [(0,0),(41,28),(183,209),(255,255)], dtype = dtype)
现在,让我们来看一下交叉处理的外观!
模拟交叉处理
交叉处理会在阴影中产生强烈的蓝色或绿蓝色调,在高光区域产生强烈的黄色或绿黄色。 黑色和白色不一定要保留。 而且,对比度非常高。 交叉处理的照片看起来很不舒服。 人们看起来黄疸,而无生命的物体看起来很脏。 让我们编辑filters.py
并添加以下交叉处理过滤器的实现:
class BGRCrossProcessCurveFilter(BGRCurveFilter): """A filter that applies cross-process-like curves to BGR.""" def __init__(self, dtype=numpy.uint8): BGRCurveFilter.__init__( self, bPoints = [(0,20),(255,235)], gPoints = [(0,0),(56,39),(208,226),(255,255)], rPoints = [(0,0),(56,22),(211,255),(255,255)], dtype = dtype)
现在我们已经看过一些有关如何实现胶片仿真过滤器的示例,我们将包装本附录,以便您可以回到第 3 章“使用 OpenCV 处理图像”中的Cameo
应用的主要实现。
总结
在scipy.interp1d
函数的基础上,我们实现了一系列曲线过滤器,这些过滤器高效(由于使用查找数组)并且易于扩展(由于面向对象的设计)。 我们的工作包括专用曲线过滤器,可以使数字图像看起来更像胶卷照。 这些过滤器可以很容易地集成到诸如Cameo
之类的应用中,如第 3 章,“用 OpenCV 处理图像”中使用我们的 Portra 胶片仿真过滤器所示。