Python3 OpenCV4 计算机视觉学习手册:6~11(3)https://developer.aliyun.com/article/1427064
执行 2D 到 3D 空间的转换
请记住,我们有一个参考图像reference_image.png
,并且我们希望 AR 应用跟踪该图像的打印副本。 出于 3D 跟踪的目的,我们可以将此打印图像表示为 3D 空间中的平面。 让我们定义局部坐标系的方式是,通常(当 6DOF 姿势的元素全部为 0 时),此平面对象像悬挂在墙上的图片一样竖立; 它的正面是上面有图像的一侧,其原点是图像的中心。
现在,让我们假设我们想将参考图像中的给定像素映射到此 3D 平面上。 给定 2D 像素坐标,图像的像素尺寸以及将像素转换为我们要在 3D 空间中使用的度量单位的比例因子,我们可以使用以下辅助函数将像素映射到平面上:
def map_point_onto_plane(point_2D, image_size, image_scale): x, y = point_2D w, h = image_size return (image_scale * (x - 0.5 * w), image_scale * (y - 0.5 * h), 0.0)
比例因子取决于打印图像的实际大小和我们选择的单位。 例如,我们可能知道我们的打印图像高 20 厘米–或我们可能不在乎绝对比例,在这种情况下,我们可以定义一个任意单位,以使打印图像高一个单位。 无论如何,只要以任何单位(绝对或相对)给出 2D 像素坐标,参考图像的尺寸以及参考图像的实际高度的列表,我们就可以使用以下帮助器函数在列表上获取相应 3D 坐标的列表。 飞机:
def map_points_to_plane(points_2D, image_size, image_real_height): w, h = image_size image_scale = image_real_height / h points_3D = [map_point_onto_plane( point_2D, image_size, image_scale) for point_2D in points_2D] return numpy.array(points_3D, numpy.float32)
请注意,我们为多个点map_points_to_plane
提供了一个辅助函数,并且为每个点map_point_to_plane
都调用了一个辅助函数。
稍后,在“初始化跟踪器”部分中,我们将为参考图像生成 ORB 关键点描述符,并且我们将使用我们的map_points_to_plane
辅助函数将关键点坐标从 2D 转换为 3D。 我们还将转换图像的四个 2D 顶点(即其左上角,右上角,右下角和左下角),以获得平面的四个 3D 顶点。 在执行 AR 绘制时,我们将使用这些顶点-特别是在“绘制跟踪结果”部分中。 与绘图相关的功能(在 OpenCV 和许多其他框架中)期望为 3D 形状的每个面按顺时针顺序(从正面角度)指定顶点。 为了满足此要求,让我们实现另一个专用于映射顶点的辅助函数。 这里是:
def map_vertices_to_plane(image_size, image_real_height): w, h = image_size vertices_2D = [(0, 0), (w, 0), (w, h), (0, h)] vertex_indices_by_face = [[0, 1, 2, 3]] vertices_3D = map_points_to_plane( vertices_2D, image_size, image_real_height) return vertices_3D, vertex_indices_by_face
请注意,我们的顶点映射帮助函数map_vertices_to_plane
调用了map_points_to_plane
帮助函数,该函数又调用了map_point_to_plane
。 因此,我们所有的映射函数都有一个共同的核心。
当然,除了平面外,2D 到 3D 关键点映射和顶点映射也可以应用于其他 3D 形状。 若要了解我们的方法如何扩展到 3D 长方体和 3D 圆柱体,请参阅 Joseph Howse 的《可视化不可视》demo 项目,该项目可在这个页面。
我们已经完成了辅助函数的实现。 现在,让我们继续进行代码的面向对象部分。
实现应用类
我们将在名为ImageTrackingDemo
的类中实现我们的应用,该类将具有以下方法:
__init__(self, capture, diagonal_fov_degrees, target_fps, reference_image_path, reference_image_real_height)
:初始化器将为参考图像设置捕获设备,相机矩阵,卡尔曼过滤器以及 2D 和 3D 关键点。run(self)
:此方法将运行应用的主循环,该循环捕获,处理和显示帧,直到用户通过按Esc
键退出。 在其他方法的帮助下执行每个帧的处理,这些方法将在此列表中接下来提到。_track_object(self)
:此方法将执行 6DOF 跟踪并绘制跟踪结果的 AR 可视化图像。_init_kalman_transition_matrix(self, fps)
:此方法将配置卡尔曼过滤器,以确保针对指定的帧速率正确模拟加速度和速度。_apply_kalman(self)
:此方法将通过应用卡尔曼过滤器来稳定 6DOF 跟踪结果。
让我们从__init__
开始一步一步地介绍方法的实现。
初始化追踪器
__init__
方法涉及许多步骤来初始化相机矩阵,ORB 描述符提取器,卡尔曼过滤器,参考图像的 2D 和 3D 关键点以及与我们的跟踪算法相关的其他变量:
- 首先,让我们看一下
__init__
接受的参数。 其中包括一个称为capture
的cv2.VideoCapture
对象(相机); 摄像机的对角 FOV,以度为单位; 每秒帧(FPS)中的预期帧速率; 包含参考图像的文件的路径; 以及参考图像实际高度的度量(以任何单位):
class ImageTrackingDemo(): def __init__(self, capture, diagonal_fov_degrees=70.0, target_fps=25.0, reference_image_path='reference_image.png', reference_image_real_height=1.0):
- 我们尝试从相机捕获一帧以确定其像素尺寸。 否则,我们将从相机的属性中获取尺寸:
self._capture = capture success, trial_image = capture.read() if success: # Use the actual image dimensions. h, w = trial_image.shape[:2] else: # Use the nominal image dimensions. w = capture.get(cv2.CAP_PROP_FRAME_WIDTH) h = capture.get(cv2.CAP_PROP_FRAME_HEIGHT) self._image_size = (w, h)
- 现在,给定帧的尺寸(以像素为单位)以及相机和镜头的 FOV,我们可以使用三角函数以像素等效单位计算焦距。 (该公式是我们在本章前面的“了解相机和镜头参数”部分中得出的公式。)此外,利用焦距和镜框的中心点,我们可以构建相机矩阵。 以下是相关代码:
diagonal_image_size = (w ** 2.0 + h ** 2.0) ** 0.5 diagonal_fov_radians = \ diagonal_fov_degrees * math.pi / 180.0 focal_length = 0.5 * diagonal_image_size / math.tan( 0.5 * diagonal_fov_radians) self._camera_matrix = numpy.array( [[focal_length, 0.0, 0.5 * w], [0.0, focal_length, 0.5 * h], [0.0, 0.0, 1.0]], numpy.float32)
- 为了简单起见,我们假定镜头不会遭受任何扭曲:
self._distortion_coefficients = None
- 最初,我们不跟踪对象,因此我们无法估计其旋转和位置。 我们只将相关变量定义为
None
:
self._rotation_vector = None self._translation_vector = None
- 现在,让我们设置一个卡尔曼过滤器:
self._kalman = cv2.KalmanFilter(18, 6) self._kalman.processNoiseCov = numpy.identity( 18, numpy.float32) * 1e-5 self._kalman.measurementNoiseCov = numpy.identity( 6, numpy.float32) * 1e-2 self._kalman.errorCovPost = numpy.identity( 18, numpy.float32) self._kalman.measurementMatrix = numpy.array( [[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]], numpy.float32) self._init_kalman_transition_matrix(target_fps)
如前面的代码cv2.KalmanFilter(18, 6)
所示,该卡尔曼过滤器将基于 6 个输入变量(或测量值)跟踪 18 个输出变量(或预测)。 具体来说,输入变量是 6DOF 跟踪结果的元素:t[x]
,t[y]
,t[z]
,r[x]
,r[y]
和r[z]
。 输出变量是稳定的 6DOF 跟踪结果的元素,以及它们的一阶导数(速度)和二阶导数(加速度),其顺序如下:t[x]
,t[y]
,t[z]
, t[x]'
,t[y]'
,t[z]'
,t[x]'
,t[y]'
,t[z]'
,r[x]
,r[y]
,r[z]
,r[x]'
,r[y]'
,r[z]'
,r[x]'
,r[y]'
和r[z]'
。 卡尔曼过滤器的测量矩阵有 18 列(代表输出变量)和 6 行(代表输入变量)。 在每一行中,我们在与匹配的输出变量相对应的索引中放入 1.0; 在其他地方,我们放 0.0。 我们还初始化了一个转换矩阵,该矩阵定义了输出变量之间随时间的关系。 初始化的这一部分由辅助方法_init_kalman_transition_matrix(target_fps)
处理,我们将在稍后的“初始化和应用卡尔曼过滤器”部分中进行检查。
并非我们的__init__
方法都会初始化所有的卡尔曼过滤器矩阵。 由于实际帧速率(以及时间步长)可能会发生变化,因此在跟踪过程中每帧更新过渡矩阵。 每次我们开始跟踪对象时,都会初始化状态矩阵。 我们将在适当的时候在“初始化和应用卡尔曼过滤器”部分中介绍卡尔曼过滤器使用的这些方面。
- 我们需要一个布尔变量(最初是
False
)来指示我们是否成功跟踪了前一帧中的对象:
self._was_tracking = False
- 我们需要定义一些 3D 图形的顶点,作为 AR 可视化的一部分,我们将绘制每一帧。 具体而言,图形将是代表对象的
X
,Y
和Z
轴的一组箭头。 这些图形的比例将与实际对象的比例有关,即我们要跟踪的打印图像。 请记住,作为其参数之一,__init__
方法采用图像的比例尺-特别是其高度-并且此度量单位可以是任何单位。 让我们将 3D 轴箭头的长度定义为打印图像高度的一半:
self._reference_image_real_height = \ reference_image_real_height reference_axis_length = 0.5 * reference_image_real_height
- 使用我们刚刚定义的长度,让我们定义相对于打印图像中心
[0.0, 0.0, 0.0]
的轴箭头的顶点:
self._reference_axis_points_3D = numpy.array( [[0.0, 0.0, 0.0], [-reference_axis_length, 0.0, 0.0], [0.0, -reference_axis_length, 0.0], [0.0, 0.0, -reference_axis_length]], numpy.float32)
请注意,OpenCV 的坐标系具有非标准轴方向,如下所示:
X
(正 X 方向)是对象的左手方向,或者是在对象正视图中查看者的右手方向。Y
是向下。Z
是对象的后向方向,即在对象的正面视图中观察者的向前方向。
为了获得以下标准右手坐标系,我们必须取反所有上述方向,就像在许多 3D 图形框架(例如 OpenGL)中使用的那样:
X
是对象正面的方向,即观看者的左手方向。Y
是向上。Z
是对象的正面方向,或者是查看者在对象的正面视图中的向后方向。
出于本书的目的,我们使用 OpenCV 绘制 3D 图形,因此即使在绘制可视化效果时,我们也可以简单地遵循 OpenCV 的非标准轴方向。 但是,如果将来要进行进一步的 AR 工作,则可能需要使用右手坐标系将计算机视觉代码与 OpenGL 和其他 3D 图形框架集成在一起。 为了更好地为您做好准备,我们将在以 OpenCV 为中心的演示中转换轴方向。
- 我们将使用三个数组来保存三种图像:BGR 视频帧(将在其中进行 AR 绘制),帧的灰度版本(将用于关键点匹配)和遮罩(在其中进行绘制) 被跟踪对象的轮廓)。 最初,这些数组都是
None
:
self._bgr_image = None self._gray_image = None self._mask = None
- 我们将使用
cv2.ORB
对象来检测关键点,并为参考图像以及随后的相机帧计算描述符。 我们按以下方式初始化cv2.ORB
对象:
# Create and configure the feature detector. patchSize = 31 self._feature_detector = cv2.ORB_create( nfeatures=250, scaleFactor=1.2, nlevels=16, edgeThreshold=patchSize, patchSize=patchSize)
有关 ORB 算法及其在 OpenCV 中的用法的更新,请参考第 6 章,“检索图像并使用图像描述符进行搜索”,特别是“将 ORB 与 FAST 特征和 BERIEF 描述符一起使用”部分。
在这里,我们为cv2.ORB
的构造器指定了几个可选参数。 描述符覆盖的直径为 31 个像素,我们的图像金字塔有 16 个级别,连续级别之间的缩放系数为 1.2,并且每次检测尝试最多需要 250 个关键点和描述符。
- 现在,我们从文件中加载参考图像,调整其大小,将其转换为灰度,并为其创建一个空的遮罩:
bgr_reference_image = cv2.imread( reference_image_path, cv2.IMREAD_COLOR) reference_image_h, reference_image_w = \ bgr_reference_image.shape[:2] reference_image_resize_factor = \ (2.0 * h) / reference_image_h bgr_reference_image = cv2.resize( bgr_reference_image, (0, 0), None, reference_image_resize_factor, reference_image_resize_factor, cv2.INTER_CUBIC) gray_reference_image = convert_to_gray(bgr_reference_image) reference_mask = numpy.empty_like(gray_reference_image)
调整参考图像的大小时,我们选择使其比相机框高两倍。 确切的数字是任意的; 但是,我们的想法是我们要使用覆盖了有用放大倍率的图像金字塔来执行关键点检测和描述。 金字塔的底面(即调整大小后的参考图像)应大于摄像头框架,以便即使目标对象离摄像头非常近,以致无法完全适合框架,我们也可以以适当的比例匹配关键点 。 相反,金字塔的顶层应该小于摄影机框架,这样即使目标物体距离无法填满整个框架,我们也可以以适当的比例匹配关键点。
让我们考虑一个例子。 假设我们的原始参考图像为4000 x 3000
像素,而我们的相机帧为4000 x 3000
像素。 我们将参考图像的尺寸调整为4000 x 3000
像素(帧高度的两倍,并且纵横比与原始参考图像相同)。 因此,我们的图像金字塔的底面也是4000 x 3000
像素。 由于我们的cv2.ORB
对象配置为使用 16 个金字塔等级且比例因子为 1.2,因此图像金字塔的顶部宽度为1920 / (1.2^(16-1)) = 124
像素,高度为1440 / (1.2^(16-1)) = 93
像素; 换句话说,它是4000 x 3000
像素。 因此,即使物体相距太远,以至于它仅占框架宽度或高度的 10%,我们也可以匹配关键点并跟踪该物体。 实际上,要在此级别上执行有用的关键点匹配,我们需要一个好的镜头,该物体需要聚焦,并且照明也必须很好。
- 在此阶段,我们有一个大小适当的 BGR 颜色和灰度参考图像,并且对此图像有一个空遮罩。 我们将图像划分为 36 个大小相等的兴趣区域(在
6 x 6
网格中),并且对于每个区域,我们将尝试生成多达 250 个关键点和描述符(因为已使用最大数量的关键点和描述符配置cv2.ORB
对象)。 这种分区方案有助于确保我们在每个区域中都有一些关键点和描述符,因此即使对象的大多数部分在给定帧中不可见,我们也可以潜在地匹配关键点并跟踪对象。 以下代码块显示了我们如何在兴趣区域上进行迭代,并为每个区域创建掩码,执行关键点检测和描述符提取,以及将关键点和描述符附加到主列表中:
# Find keypoints and descriptors for multiple segments of # the reference image. reference_keypoints = [] self._reference_descriptors = numpy.empty( (0, 32), numpy.uint8) num_segments_y = 6 num_segments_x = 6 for segment_y, segment_x in numpy.ndindex( (num_segments_y, num_segments_x)): y0 = reference_image_h * \ segment_y // num_segments_y - patchSize x0 = reference_image_w * \ segment_x // num_segments_x - patchSize y1 = reference_image_h * \ (segment_y + 1) // num_segments_y + patchSize x1 = reference_image_w * \ (segment_x + 1) // num_segments_x + patchSize reference_mask.fill(0) cv2.rectangle( reference_mask, (x0, y0), (x1, y1), 255, cv2.FILLED) more_reference_keypoints, more_reference_descriptors = \ self._feature_detector.detectAndCompute( gray_reference_image, reference_mask) if more_reference_descriptors is None: # No keypoints were found for this segment. continue reference_keypoints += more_reference_keypoints self._reference_descriptors = numpy.vstack( (self._reference_descriptors, more_reference_descriptors))
- 现在,我们在灰度参考图像上方绘制关键点的可视化效果:
cv2.drawKeypoints( gray_reference_image, reference_keypoints, bgr_reference_image, flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
- 接下来,我们将可视化文件保存到名称后附加
_keypoints
的文件中。 例如,如果参考图像的文件名是reference_image.png
,则将可视化文件另存为reference_image_keypoints.png
。 以下是相关代码:
ext_i = reference_image_path.rfind('.') reference_image_keypoints_path = \ reference_image_path[:ext_i] + '_keypoints' + \ reference_image_path[ext_i:] cv2.imwrite( reference_image_keypoints_path, bgr_reference_image)
- 我们继续使用自定义参数初始化基于 FLANN 的匹配器:
FLANN_INDEX_LSH = 6 index_params = dict(algorithm=FLANN_INDEX_LSH, table_number=6, key_size=12, multi_probe_level=1) search_params = dict() self._descriptor_matcher = cv2.FlannBasedMatcher( index_params, search_params)
这些参数指定我们正在使用具有 6 个哈希表,12 位哈希键大小和 1 个多探针级别的多探针 LSH(位置敏感哈希)索引算法。
有关多探针 LSH 算法的说明,请参阅论文《多探针 LSH:高维相似性搜索的有效索引》(VLDB,2007 年),由 Qin Lv,William Josephson,Zhe Wang, 摩西·查里卡尔(Moses Charikar)和李凯(Kai Li)。 可在这个页面获得电子版本。
- 我们通过向其提供参考描述符来训练匹配器:
self._descriptor_matcher.add([self._reference_descriptors])
- 我们获取关键点的 2D 坐标,并将它们馈送到
map_points_to_plane
辅助函数中,以便在对象平面的表面上获得等效的 3D 坐标:
reference_points_2D = [keypoint.pt for keypoint in reference_keypoints] self._reference_points_3D = map_points_to_plane( reference_points_2D, gray_reference_image.shape[::-1], reference_image_real_height)
- 类似地,我们调用
map_vertices_to_plane
函数以获得平面的 3D 顶点和 3D 面:
(self._reference_vertices_3D, self._reference_vertex_indices_by_face) = \ map_vertices_to_plane( gray_reference_image.shape[::-1], reference_image_real_height)
到此结束__init__
方法的实现。 接下来,让我们看一下run
方法,它表示应用的主循环。
实现主循环
像往常一样,我们的主循环的主要作用是捕获和处理帧,直到用户按下Esc
键。 每个帧的处理(包括 3D 跟踪和 AR 绘制)都委托给称为_track_object
的辅助方法,稍后将在《跟踪 3D 图像》部分中进行探讨。 主循环还具有辅助作用:即通过测量帧速率并相应地更新卡尔曼过滤器的转换矩阵来执行计时。 此更新委托给另一种辅助方法_init_kalman_transition_matrix
,我们将在“初始化和应用卡尔曼过滤器”部分中进行研究。 考虑到这些角色,我们可以在run
方法中实现main
循环,如下所示:
def run(self): num_images_captured = 0 start_time = timeit.default_timer() while cv2.waitKey(1) != 27: # Escape success, self._bgr_image = self._capture.read( self._bgr_image) if success: num_images_captured += 1 self._track_object() cv2.imshow('Image Tracking', self._bgr_image) delta_time = timeit.default_timer() - start_time if delta_time > 0.0: fps = num_images_captured / delta_time self._init_kalman_transition_matrix(fps)
请注意 Python 标准库中timeit.default_timer
函数的使用。 此函数提供了以秒为单位的当前系统时间的精确测量值(作为浮点数,因此可以表示秒的分数)。 就像名称timeit
所暗示的那样,此模块包含有用的功能,适用于以下情况:您具有时间敏感的代码,并且想要为其计时。
让我们继续进行_track_object
的实现,因为此助手代表run
执行了应用工作的最大部分。
在 3D 中追踪图像
_track_object
方法直接负责关键点匹配,关键点可视化和解决 PnP 问题。 此外,它调用其他方法来处理卡尔曼滤波,AR 绘制和掩盖被跟踪的对象:
- 为了开始
_track_object
的实现,我们调用convert_to_gray
辅助函数将帧转换为灰度:
def _track_object(self): self._gray_image = convert_to_gray( self._bgr_image, self._gray_image)
- 现在,我们使用
cv2.ORB
对象检测灰度图像的遮罩区域中的关键点并计算描述符:
if self._mask is None: self._mask = numpy.full_like(self._gray_image, 255) keypoints, descriptors = \ self._feature_detector.detectAndCompute( self._gray_image, self._mask)
如果我们已经在前一帧中跟踪了对象,则遮罩将覆盖我们先前找到该对象的区域。 否则,遮罩会覆盖整个框架,因为我们不知道对象可能在哪里。 稍后,我们将在“绘制跟踪结果并屏蔽被跟踪的对象”部分中了解如何创建遮罩。
- 接下来,我们使用 FLANN 匹配器查找参考图像的关键点与帧的关键点之间的匹配项,并根据比率测试过滤这些匹配项:
# Find the 2 best matches for each descriptor. matches = self._descriptor_matcher.knnMatch(descriptors, 2) # Filter the matches based on the distance ratio test. good_matches = [ match[0] for match in matches if len(match) > 1 and \ match[0].distance < 0.6 * match[1].distance ]
有关 FLANN 匹配和比率测试的详细信息,请参考第 6 章,“检索图像并使用图像描述符进行搜索”。
- 在此阶段,我们列出了通过比率测试的良好匹配项。 让我们选择与这些良好匹配相对应的框架关键点的子集,然后在框架上绘制红色圆圈以可视化这些关键点:
# Select the good keypoints and draw them in red. good_keypoints = [keypoints[match.queryIdx] for match in good_matches] cv2.drawKeypoints(self._gray_image, good_keypoints, self._bgr_image, (0, 0, 255))
- 找到了不错的比赛之后,我们显然知道其中有多少人。 如果计数很小,那么总的来说,这组匹配项可能会令人怀疑且不足以进行跟踪。 我们为良好匹配的最小数量定义了两个不同的阈值:较高的阈值(如果我们只是开始跟踪(即,我们没有在前一帧中跟踪对象))和较低的阈值(如果我们正在继续跟踪) 跟踪前一帧中的对象):
min_good_matches_to_start_tracking = 8 min_good_matches_to_continue_tracking = 6 num_good_matches = len(good_matches)
- 如果我们甚至没有达到下限阈值,那么我们会注意到我们没有在该帧中跟踪对象,因此我们将遮罩重置为覆盖整个帧:
if num_good_matches < min_good_matches_to_continue_tracking: self._was_tracking = False self._mask.fill(255)
- 另一方面,如果我们有足够的匹配项来满足适用的阈值,那么我们将继续尝试跟踪对象。 第一步是在框架中选择良好匹配的 2D 坐标,并在
reference
对象的模型中选择其 3D 坐标:
elif num_good_matches >= \ min_good_matches_to_start_tracking or \ self._was_tracking: # Select the 2D coordinates of the good matches. # They must be in an array of shape (N, 1, 2). good_points_2D = numpy.array( [[keypoint.pt] for keypoint in good_keypoints], numpy.float32) # Select the 3D coordinates of the good matches. # They must be in an array of shape (N, 1, 3). good_points_3D = numpy.array( [[self._reference_points_3D[match.trainIdx]] for match in good_matches], numpy.float32)
- 现在,我们准备使用本章开头在“了解
cv2.solvePnPRansac
”部分中介绍的各种参数来调用cv2.solvePnPRansac
。 值得注意的是,我们仅从良好匹配中使用 3D 参考关键点和 2D 场景关键点:
# Solve for the pose and find the inlier indices. (success, self._rotation_vector, self._translation_vector, inlier_indices) = \ cv2.solvePnPRansac(good_points_3D, good_points_2D, self._camera_matrix, self._distortion_coefficients, self._rotation_vector, self._translation_vector, useExtrinsicGuess=False, iterationsCount=100, reprojectionError=8.0, confidence=0.99, flags=cv2.SOLVEPNP_ITERATIVE)
- 解算器可能收敛或未收敛于 PnP 问题的解决方案。 如果没有收敛,则此方法将不再做任何事情。 如果收敛,则下一步是检查是否已在上一帧中跟踪对象。 如果我们尚未跟踪它(换句话说,如果我们开始在此帧中重新跟踪对象),则可以通过调用辅助方法
_init_kalman_state_matrices
重新初始化卡尔曼过滤器:
if success: if not self._was_tracking: self._init_kalman_state_matrices()
- 现在,无论如何,我们都在该帧中跟踪对象,因此我们可以通过调用另一个辅助方法
_apply_kalman
来应用卡尔曼过滤器:
self._was_tracking = True self._apply_kalman()
- 在这一阶段,我们有一个经过卡尔曼滤波的 6DOF 姿态。 我们还列出了
cv2.solvePnPRansac
中的内部关键点。 为了帮助用户可视化结果,让我们以绿色绘制内部关键点:
# Select the inlier keypoints. inlier_keypoints = [good_keypoints[i] for i in inlier_indices.flat] # Draw the inlier keypoints in green. cv2.drawKeypoints(self._bgr_image, inlier_keypoints, self._bgr_image, (0, 255, 0))
请记住,在此方法的前面,我们用红色绘制了所有关键点。 现在,我们以绿色绘制了内部关键点,只有外部关键点仍然是红色。
- 最后,我们再调用两个辅助方法:
self._draw_object
轴绘制被跟踪对象的 3D 轴,self._make_and_draw_object_mask
绘制并绘制包含对象的区域的遮罩:
# Draw the axes of the tracked object. self._draw_object_axes() # Make and draw a mask around the tracked object. self._make_and_draw_object_mask()
结束我们的_track_object
方法的实现。 到目前为止,我们已经大致了解了跟踪算法的实现,但是我们仍然需要实现与卡尔曼过滤器有关的辅助方法(在下一节“初始化和应用卡尔曼过滤器”中)以及遮罩和 AR 绘制(在其后的“绘制跟踪结果并遮盖跟踪的对象”部分中)。
初始化和应用卡尔曼过滤器
我们在“初始化跟踪器”部分中介绍了卡尔曼过滤器初始化的某些方面。 但是,在该部分中,我们注意到,随着应用运行在各种帧以及跟踪或不跟踪的各种状态下,卡尔曼过滤器的某些矩阵需要多次初始化或重新初始化。 具体来说,以下矩阵将发生变化:
- 转换矩阵:此矩阵表示所有输出变量之间的时间关系。 例如,该矩阵可以模拟加速度对速度的影响以及速度对位置的影响。 我们将每帧重新初始化转换矩阵,因为帧速率(以及帧之间的时间步长)是可变的。 有效地,这是缩放先前的加速度和速度预测以匹配新时间步长的一种方法。
- 校正前和校正后状态矩阵:这些矩阵包含输出变量的预测。 预校正矩阵中的预测仅考虑先前状态和转换矩阵。 校正后矩阵中的预测还考虑了新的输入和卡尔曼过滤器的其他矩阵。 每当我们从非跟踪状态变为跟踪状态时,换句话说,当我们无法在前一帧中跟踪对象但现在我们成功地在当前帧中跟踪对象时,我们将重新初始化状态矩阵。 实际上,这是一种清除过时的预测并从新的测量重新开始的方法。
让我们先看一下转换矩阵。 其初始化方法将使用一个参数fps
,即每秒帧数。 我们可以通过三个步骤来实现该方法:
- 我们首先验证
fps
参数。 如果不是正数,我们将立即返回而不会更新过渡矩阵:
def _init_kalman_transition_matrix(self, fps): if fps <= 0.0: return
- 确定
fps
为正后,我们继续计算速度和加速度的过渡速率。 我们希望速度转换速率与时间步长(即每帧的时间)成比例。 因为fps
(每秒帧数)是时间步长的倒数(即每帧秒数),所以速度转换率与fps
成反比。 加速度变化率与速度变化率的平方成正比(因此,加速度变化率与fps
的平方成反比)。 选择 1.0 作为速度转换率的基本比例,选择 0.5 作为加速度转换率的基本比例,我们可以在代码中进行如下计算:
# Velocity transition rate vel = 1.0 / fps # Acceleration transition rate acc = 0.5 * (vel ** 2.0)
- 接下来,我们填充转换矩阵。 由于我们有 18 个输出变量,因此转换矩阵具有 18 行和 18 列。 首先,让我们看一下矩阵的内容,然后,我们将考虑如何解释它:
self._kalman.transitionMatrix = numpy.array( [[1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]], numpy.float32)
每行表示一个公式,用于根据前一帧的输出值来计算新的输出值。 让我们以第一行为例。 我们可以将其解释如下:
新的t[x]
值取决于旧的t[x]
,t[x]'
和t[x]'
值,以及速度转换率v
和加速度转换率a
。 正如我们之前在此函数中看到的那样,这些过渡速率可能会有所变化,因为时间步长可能会有所不同。
到此结束了用于初始化或更新转换矩阵的辅助方法的实现。 请记住,由于帧速率(以及时间步长)可能已更改,因此我们每帧都调用此函数。
我们还需要一个辅助函数来初始化状态矩阵。 请记住,每当我们从非跟踪状态过渡到跟踪状态时,我们都会调用此方法。 此过渡是清除以前所有预测的适当时间; 相反,我们重新开始时就相信对象的 6DOF 姿势正是 PnP 求解器所说的。 此外,我们假设物体是静止的,速度为零,加速度为零。 这是辅助方法的实现:
def _init_kalman_state_matrices(self): t_x, t_y, t_z = self._translation_vector.flat r_x, r_y, r_z = self._rotation_vector.flat self._kalman.statePre = numpy.array( [[t_x], [t_y], [t_z], [0.0], [0.0], [0.0], [0.0], [0.0], [0.0], [r_x], [r_y], [r_z], [0.0], [0.0], [0.0], [0.0], [0.0], [0.0]], numpy.float32) self._kalman.statePost = numpy.array( [[t_x], [t_y], [t_z], [0.0], [0.0], [0.0], [0.0], [0.0], [0.0], [r_x], [r_y], [r_z], [0.0], [0.0], [0.0], [0.0], [0.0], [0.0]], numpy.float32)
注意,由于我们有 18 个输出变量,因此状态矩阵有 1 行和 18 列。
现在我们已经介绍了初始化和重新初始化卡尔曼过滤器的矩阵的过程,让我们看一下如何应用过滤器。 正如我们之前在第 8 章,“跟踪对象”中所看到的,我们可以要求卡尔曼过滤器估计对象的新姿态(输出变量的校正前状态),然后我们可以告诉它考虑最新的不稳定跟踪结果(输入变量)以调整其估计值(从而产生校正后的状态),最后,我们可以从调整后的估计值中提取变量以用作稳定后的跟踪结果。 与我们以前的工作相比,这次的唯一区别是我们有更多的输入和输出变量。 以下代码显示了我们如何实现在 6DOF 跟踪器的上下文中应用卡尔曼过滤器的方法:
def _apply_kalman(self): self._kalman.predict() t_x, t_y, t_z = self._translation_vector.flat r_x, r_y, r_z = self._rotation_vector.flat estimate = self._kalman.correct(numpy.array( [[t_x], [t_y], [t_z], [r_x], [r_y], [r_z]], numpy.float32)) self._translation_vector = estimate[0:3] self._rotation_vector = estimate[9:12]
这里,请注意,estimate[0:3]
对应于t[x]
,t[y]
和t[z]
,而estimate[9:12]
对应于r[x]
,r[y]
和r[z]
。 estimate
数组的其余部分对应于一阶导数(速度)和二阶导数(加速度)。
至此,我们几乎完全探索了 3D 跟踪算法的实现,包括使用卡尔曼过滤器来稳定 6DOF 姿态以及速度和加速度。 现在,让我们将注意力转向ImageTrackingDemo
类的两个最终实现细节:AR 绘制方法和基于跟踪结果创建遮罩。
绘制跟踪结果并遮盖被跟踪对象
我们将实现一个辅助方法_draw_object_axes
,以绘制跟踪对象的X
,Y
和Z
轴的可视化图像。 我们还将实现另一种辅助方法_make_and_draw_object_mask
,以将对象的顶点从 3D 投影到 2D,基于对象的轮廓创建遮罩,并将该遮罩的区域染成黄色以显示。
让我们从_draw_object_axes
的实现开始。 我们可以分三个阶段来考虑:
- 首先,我们要获取一组沿轴放置的 3D 点,并将这些点投影到 2D 图像空间。 请记住,我们在“初始化跟踪器”部分的
__init__
方法中定义了 3D 轴点。 它们将仅用作我们将绘制的轴箭头的端点。 使用cv2.projectPoints
函数,6DOF 跟踪结果和相机矩阵,我们可以找到 2D 投影点,如下所示:
def _draw_object_axes(self): points_2D, jacobian = cv2.projectPoints( self._reference_axis_points_3D, self._rotation_vector, self._translation_vector, self._camera_matrix, self._distortion_coefficients)
除了返回投影的 2D 点之外,cv2.projectPoints
还返回雅可比矩阵,该矩阵表示用于计算 2D 点的函数的偏导数(相对于输入参数)。 此信息可能对相机校准很有用,但在本示例中不使用它。
- 投影点采用浮点格式,但是我们需要整数才能传递给 OpenCV 的绘图函数。 因此,我们将以下转换为整数格式:
origin = (int(points_2D[0, 0, 0]), int(points_2D[0, 0, 1])) right = (int(points_2D[1, 0, 0]), int(points_2D[1, 0, 1])) up = (int(points_2D[2, 0, 0]), int(points_2D[2, 0, 1])) forward = (int(points_2D[3, 0, 0]), int(points_2D[3, 0, 1]))
- 在计算了端点之后,我们现在可以绘制三个箭头线来表示 X,Y 和 Z 轴:
# Draw the X axis in red. cv2.arrowedLine(self._bgr_image, origin, right, (0, 0, 255)) # Draw the Y axis in green. cv2.arrowedLine(self._bgr_image, origin, up, (0, 255, 0)) # Draw the Z axis in blue. cv2.arrowedLine( self._bgr_image, origin, forward, (255, 0, 0))
我们已经完成了_draw_object_axes
的实现。 现在,让我们将注意力转移到_make_and_draw_object_mask
上,我们也可以从三个步骤来考虑:
- 像以前的函数一样,该函数从将点从 3D 投影到 2D 开始。 这次,我们在“初始化跟踪器”部分的
__init__
方法中定义了参考对象的顶点。 这是投影代码:
def _make_and_draw_object_mask(self): # Project the object's vertices into the scene. vertices_2D, jacobian = cv2.projectPoints( self._reference_vertices_3D, self._rotation_vector, self._translation_vector, self._camera_matrix, self._distortion_coefficients)
- 同样,我们将投影点从浮点格式转换为整数格式(因为 OpenCV 的绘图函数需要整数):
vertices_2D = vertices_2D.astype(numpy.int32)
- 投影的顶点形成凸多边形。 我们可以将遮罩涂成黑色(作为背景),然后以白色绘制此凸多边形:
# Make a mask based on the projected vertices. self._mask.fill(0) for vertex_indices in \ self._reference_vertex_indices_by_face: cv2.fillConvexPoly( self._mask, vertices_2D[vertex_indices], 255)
请记住,我们的_track_object
方法在处理下一帧时将使用此掩码。 具体来说,_track_object
将仅在遮罩区域中查找关键点。 因此,它将尝试在我们最近找到它的区域中找到该对象。
潜在地,我们可以通过应用形态学扩张操作来扩展遮罩区域来改进此技术。 这样,我们不仅可以在最近找到它的区域中搜索对象,而且可以在周围区域中搜索。
- 现在,在 BGR 框架中,让我们以黄色突出显示被遮罩的区域,以可视化被跟踪对象的形状。 为了使区域更黄,我们可以从蓝色通道中减去一个值。
cv2.subtract
函数适合我们的目的,因为它接受可选的mask
参数。 这是我们的用法:
# Draw the mask in semi-transparent yellow. cv2.subtract( self._bgr_image, 48, self._bgr_image, self._mask)
当我们告诉cv2.subtract
从图像中减去单个标量值(例如 48)时,它仅从图像的第一个通道(在这种情况下(大多数情况下)是 BGR 图像的蓝色通道)中减去该值。 可以说这是一个错误,但可以方便地将其着色为黄色!
那是ImageTrackingDemo
类中的最后一个方法。 现在,让我们通过实例化该类并调用其run
方法来使演示栩栩如生!
运行和测试应用
为了完成ImageTrackingDemo.py
的实现,让我们编写一个main
函数,该函数以指定的捕获设备,FOV 和目标帧速率启动应用:
def main(): capture = cv2.VideoCapture(0) capture.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) diagonal_fov_degrees = 70.0 target_fps = 25.0 demo = ImageTrackingDemo( capture, diagonal_fov_degrees, target_fps) demo.run() if __name__ == '__main__': main()
在这里,我们使用的捕获分辨率为 1280 x 720,对角 FOV 为 70 度,目标帧速率为 25 FPS。 您应该选择适合您的相机和镜头以及系统速度的参数。
假设我们运行该应用,并从reference_image.png
加载以下图像:
当然,这是约瑟夫·霍斯(Joseph Howse)所著的《OpenCV 4 for Secret Agents》(Packt Publishing,2019)的封面。 它不仅是秘密知识的库,而且还是图像跟踪的良好目标。 您应该购买印刷本!
在初始化期间,应用将参考关键点的以下可视化保存到名为reference_image_keypoints.png
的新文件中:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2XeWuXO8-1681871605271)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/ec7a39dc-9e79-4bce-a24d-ece3ca277e6d.png)]
在第 6 章,“检索图像和使用图像描述符进行搜索”之前,我们已经看到了这种类型的可视化。 大圆圈表示可以在小范围内匹配的关键点(例如,当我们从远距离或使用低分辨率相机查看打印的图像时)。 小圆圈代表可以大规模匹配的关键点(例如,当我们近距离观看打印的图像或使用高分辨率相机时)。 最好的关键点是许多同心圆标记的,因为它们可以在不同的比例下匹配。 在每个圆圈内,径向线表示关键点的法线方向。
通过研究此可视化,我们可以推断出该图像的最佳关键点集中在图像顶部的高对比度文本(白色对深灰色)中。 在许多区域中还可以找到其他有用的关键点,包括图像底部的高对比度线(黑与饱和色)。
接下来,我们看到一个相机供稿。 将参考图像打印在相机前面时,我们会看到跟踪结果的 AR 可视化效果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mUkex5SM-1681871605271)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/617090ff-841d-45e9-864b-f06adfbf1017.png)]
当然,前面的屏幕快照显示了书封面的近正面视图。 轴方向按预期绘制。 X 轴(红色)指向书套的右侧(查看者的左侧)。 Y 轴(绿色)指向上方。 Z 轴(蓝色)从书的封面指向前方(朝着查看者)。 作为增强现实效果,在跟踪的书的封面(包括由 Joseph Howse 的食指和中指覆盖的部分)上叠加了半透明的黄色高光。 绿色和红色小点的位置表明,在此帧中,良好的关键点匹配集中在书名的区域中,而这些良好的匹配中的大多数都是cv2.solvePnPRansac
的整数。
如果您正在阅读本书的印刷版,则屏幕截图将以灰度复制。 为了使 X,Y 和 Z 轴在灰度打印中更容易区分,已将文本标签手动添加到屏幕截图中。 这些文本标签不属于程序输出的一部分。
因为我们努力在整个图像的多个区域中找到良好的关键点,所以即使被跟踪图像的很大一部分处于阴影,被遮盖或在框架外时,跟踪也可以成功。 例如,在下面的屏幕截图中,即使大部分书的封面(包括几乎所有具有最佳关键点的书名)都在框架之外,轴方向和突出显示的区域也是正确的:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xoiQsCvJ-1681871605271)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/13ce7249-4049-43e4-a447-959288040e24.png)]
继续并使用各种参考图像,照相机和观看条件进行自己的实验。 为参考图像和相机尝试各种分辨率。 切记要测量相机的 FOV 并相应地调整 FOV 参数。 研究关键点的可视化效果和跟踪结果。 在我们的演示中,哪种输入产生良好(或不良)的跟踪结果?
如果您发现使用打印的图像进行跟踪不方便,则可以将相机对准要显示要跟踪的图像的屏幕(例如智能手机屏幕)。 由于屏幕是背光的(也可能是光滑的),因此它可能无法忠实地表示打印图像在任何给定场景中的外观,但通常可以很好地用于跟踪器的目的。
对心脏的内容进行实验后,让我们考虑一些 3D 跟踪器可以改进的方法。
改进 3D 跟踪算法
本质上,我们的 3D 跟踪算法结合了三种方法:
- 使用 PnP 求解器查找 6DOF 姿势,该姿势的输入取决于基于 FLANN 的 ORB 描述符匹配。
- 使用卡尔曼过滤器来稳定 6DOF 跟踪结果。
- 如果在前一帧中跟踪到对象,请使用遮罩将搜索限制到现在最有可能找到该对象的区域。
3D 跟踪的商业解决方案通常涉及其他方法。 我们依靠成功地为每个帧使用描述符匹配器和 PnP 解算器。 但是,更复杂的算法可能会提供一些替代方案,如后备或交叉检查机制。 这是因为描述符匹配器和 PnP 求解器在某些帧中错过了对象,或者它们在计算上过于昂贵而无法用于每个帧。 广泛使用以下替代方法:
- 根据光流更新先前的关键点匹配,并根据关键点的旧位置和新位置之间的单应性更新前一个 6DOF 姿势(根据光流)。
- 根据陀螺仪和磁力计(罗盘)更新 6DOF 姿势的旋转分量。 通常,即使在消费类设备中,这些传感器也可以成功地测量旋转的大小变化。
- 根据气压计和 GPS 更新 6DOF 姿态的位置分量。 通常,在消费类设备中,气压计可以以大约 10cm 的精度测量高度变化,而 GPS 可以以大约 10m 的精度测量经度和纬度的变化。 根据使用情况,这些精度可能是有用的,也可能不是。 如果我们试图在大而远的景观特征上进行增强现实(例如),如果我们想绘制一条栖息在真实山顶上的虚拟巨龙,那么 10m 的精度可能会更好。 对于详细的工作(例如,如果我们想在真实的手指上画一个虚拟的戒指),则无法使用 10 厘米的精度。
- 根据加速度计更新卡尔曼过滤器的位置加速度分量。 通常,在消费类设备中,加速度计会产生漂移(误差会在一个方向或另一个方向上显示失控的趋势),因此应谨慎使用此选项。
这些替代技术不在本书的讨论范围之内,实际上,其中一些不是计算机视觉技术,因此我们将其留给您进行独立研究。
最后一句话:有时,通过更改预处理算法而不是跟踪算法本身,可以显着改善跟踪结果。 在本章前面的“执行灰度转换”部分中,我们提到了 Macêdo,Melo 和 Kelner 关于灰度转换算法和 SIFT 描述符的论文。 您可能希望阅读该论文并进行自己的实验,以确定在使用 ORB 描述符或其他类型的描述符时,灰度转换算法的选择如何影响跟踪内线的数量。
总结
本章介绍了 AR,以及一组针对 3D 空间中图像跟踪问题的可靠方法。
我们首先学习了 6DOF 跟踪的概念。 我们认识到,熟悉的工具(例如 ORB 描述符,基于 FLANN 的匹配和卡尔曼滤波)在这种跟踪中很有用,但是我们还需要使用相机和镜头参数来解决 PnP 问题。
接下来,我们讨论了如何以灰度图像,一组 2D 关键点和一组 3D 关键点的形式最好地表示参考对象(例如书的封面或照片)的实际考虑。
我们着手实现了一个类,该类封装了 3D 空间中的图像跟踪演示,并以 3D 高亮效果作为 AR 的基本形式。 我们的实现涉及实时考虑,例如需要根据帧速率的波动来更新卡尔曼过滤器的转换矩阵。
最后,我们考虑了使用其他计算机视觉技术或其他基于传感器的技术来潜在改善 3D 跟踪算法的方法。
现在,我们正在接近本书的最后一章,该章对到目前为止我们已经解决的许多问题提供了不同的观点。 我们可以暂时搁置相机和几何学的思想,而开始以统计学家的身份思考,因为我们将通过研究人工神经网络(ANN)。
十、使用 OpenCV 的神经网络简介
本章介绍了一系列称为人工神经网络(ANNs)或有时仅称为神经网络的机器学习模型。 这些模型的主要特征是它们试图以多层的方式学习变量之间的关系。 在将这些结果合并为一个函数以预测有意义的内容(例如对象的类别)之前,他们学习了多种特征来预测中间结果。 OpenCV 的最新版本包含越来越多的与 ANN 相关的功能-尤其是具有多层的 ANN,称为深度神经网络(DNN)。 在本章中,我们将对较浅的 ANN 和 DNN 进行试验。
在其他各章中,我们已经对机器学习有所了解,尤其是在第 7 章,“构建自定义对象检测器”中,我们使用 SURF 描述符开发了汽车/非汽车分类器, BoW 和一个 SVM。 以此为基础进行比较,您可能会想知道,人工神经网络有什么特别之处? 我们为什么将本书的最后一章专门介绍给他们?
人工神经网络旨在在以下情况下提供卓越的准确率:
- 输入变量很多,它们之间可能具有复杂的非线性关系。
- 有许多输出变量,这些变量可能与输入变量具有复杂的非线性关系。 (通常,分类问题中的输出变量是类的置信度得分,因此,如果有很多类,那么会有很多输出变量。)
- 有许多隐藏的(未指定)变量可能与输入和输出变量具有复杂的非线性关系。 DNN 甚至旨在建模多个隐变量层,这些隐层主要彼此相关,而不是主要与输入或输出变量相关。
这些情况存在于许多(也许是大多数)现实世界中的问题中。 因此,人工神经网络和 DNN 的预期优势是诱人的。 另一方面,众所周知,人工神经网络(尤其是 DNN)是不透明的模型,因为它们通过预测是否存在可能与其他所有事物有关的任意数量的无名,隐藏变量而起作用。
在本章中,我们将涵盖以下主题:
- 将人工神经网络理解为统计模型和有监督的机器学习工具。
- 了解 ANN 拓扑,或者将 ANN 组织到相互连接的神经元层中。 特别地,我们将考虑使 ANN 能够用作一种分类器的拓扑,称为多层感知器(MLP)。
- 在 OpenCV 中训练和使用人工神经网络作为分类器。
- 生成检测和识别手写数字(0 到 9)的应用。 为此,我们将基于被广泛使用的称为 MNIST 的数据集训练 ANN,该数据集包含手写数字的样本。
- 在 OpenCV 中加载和使用经过预训练的 DNN。 我们将介绍 DNN 的对象分类,人脸检测和性别分类的示例。
到本章结束时,您将很容易在 OpenCV 中训练和使用 ANN,可以使用来自各种来源的经过预先训练的 DNN,并可以开始探索其他可用来训练自己的 DNN 的库。
技术要求
本章使用 Python,OpenCV 和 NumPy。 有关安装说明,请参阅第 1 章,“设置 OpenCV”的。
本章的完整代码和示例视频可以在本书的 GitHub 存储库中找到,位于chapter10
文件夹中。
了解人工神经网络
让我们根据其基本角色和组成部分来定义 ANN。 尽管有关人工神经网络的许多文献都强调它们是通过神经元在大脑中的连接方式受到生物学启发,但我们并不需要是生物学家或神经科学家来了解人工神经网络的基本概念。
首先,人工神经网络是统计模型。 什么是统计模型? 统计模型是一对元素,即空间S
(一组观察值)和概率P
,其中P
是近似于S
的分布(换句话说,一个函数,它生成一组与S
非常相似的观察结果。
这是思考P
的两种不同方法:
P
是复杂场景的简化。P
是首先生成S
或至少与S
非常相似的一组观察结果的函数。
因此,人工神经网络是一个模型,它采用一个复杂的现实,对其进行简化,并推导一个函数以(近似)以数学形式表示我们期望从该现实中获得的统计观察结果。
与其他类型的机器学习模型一样,人工神经网络可以通过以下方式之一从观察中学习:
- 监督学习:在这种方法下,我们希望模型的训练过程产生一个函数,该函数将一组已知的输入变量映射到一组已知的输出变量。 我们知道,先验是预测问题的性质,我们将找到解决该问题的函数的过程委托给了 ANN。 要训练模型,我们必须提供输入样本以及正确的相应输出。 对于分类问题,输出变量可以是一个或多个类别的置信度得分。
- 无监督学习:在这种方法下,先验不知道输出变量的集合。 模型的训练过程必须产生一组输出变量,以及将输入变量映射到这些输出变量的函数。 对于分类问题,无监督学习可能导致发现先前未知的类别,例如医学数据中的先前未知的疾病。 无监督学习可以使用包括(但不限于)聚类的技术,我们在第 7 章,“构建自定义对象检测器”的 BoW 模型的上下文中对此进行了探讨。
- 强化学习:这种方法可以颠倒典型的预测问题。 在训练模型之前,我们已经有一个系统,当我们为一组已知的输入变量输入值时,该系统会为一组已知的输出变量产生值。 我们知道,先验是一种基于输出的优劣(合意性)或缺乏而对输出序列进行评分的方法。 但是,我们可能不知道将输入映射到输出的实际函数,或者,即使我们知道它,也是如此复杂,以至于无法为最佳输入求解。 因此,我们希望模型的训练过程能够产生一个函数,该函数根据最后的输出来预测序列中的下一个最优输入。 在训练过程中,模型从分数中学习,该分数最终是由其动作(所选输入)产生的。 从本质上讲,该模型必须学会在特定的奖惩系统中成为优秀的决策者。
在本章的其余部分中,我们将讨论仅限于监督学习,因为这是在计算机视觉环境下进行机器学习的最常用方法。
理解 ANN 的下一步是了解 ANN 如何在简单的统计模型和其他类型的机器学习方面进行改进。
如果生成数据集的函数可能需要大量(未知)输入怎么办?
人工神经网络采用的策略是将工作委托给多个神经元,节点或单元,每个单元都可以近似于创建神经元的功能。 输入。 在数学中,逼近是定义一个更简单的函数的过程,至少对于某些输入范围,其输出类似于更复杂的函数的输出。
近似函数的输出与原始函数的输出之间的差异称为误差。 神经网络的定义特征是神经元必须能够逼近非线性函数。
让我们仔细看看神经元。
了解神经元和感知器
通常,为了解决分类问题,将 ANN 设计为多层感知器(MLP),其中每个神经元都充当一种称为感知器的二分类器。 感知器的概念可以追溯到 1950 年代。 简而言之,感知器是一种需要大量输入并产生单个值的函数。 每个输入具有关联的权重,该权重表示其在激活函数中的重要性。 激活函数应具有非线性响应; 例如,Sigmoid 函数(有时称为 S 曲线)是常见的选择。 将阈值函数判别式应用于激活函数的输出,以将其转换为 0 或 1 的二分类。这是此序列的可视化图,左边是输入,激活函数在中间,右边是阈值函数:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zwDYeK2D-1681871605271)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/46e3e3ef-46bf-4f05-b4d9-8316b98fea3f.png)]
输入权重代表什么,如何确定?
在一个神经元的输出可以作为许多其他神经元的输入的范围内,神经元是相互关联的。 每个输入权重定义了两个神经元之间连接的强度。 这些权重是自适应的,这意味着它们会根据学习算法随时间变化。
由于神经元的互连性,网络具有层次。 现在,让我们检查一下通常如何组织这些层。
了解神经网络的各层
这是神经网络的直观表示:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eDEr94uj-1681871605272)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/e0bfde8f-89b0-484d-ae27-2b847fcdcb99.png)]
如上图所示,神经网络中至少有三个不同的层:输入层,隐藏层和输出层。 可以有多个隐藏层。 但是,一个隐藏层足以解决许多现实生活中的问题。 具有多个隐藏层的神经网络有时称为深度神经网络(DNN)。
如果我们将 ANN 用作分类器,则每个输出节点的输出值是一个类的置信度得分。 对于给定的样本(即给定的一组输入值),我们想知道哪个输出节点产生最高的输出值。 该得分最高的输出节点对应于预测的类别。
我们如何确定网络的拓扑结构,以及我们需要为每个层创建多少个神经元? 让我们逐层进行此确定。
选择输入层的大小
根据定义,输入层中的节点数是网络的输入数。 例如,假设您要创建一个人工神经网络,以帮助您根据动物的物理属性确定动物的种类。 原则上,我们可以选择任何可测量的属性。 如果我们选择根据重量,长度和牙齿数量对动物进行分类,那就是三个属性的集合,因此我们的网络需要包含三个输入节点。
这三个输入节点是否是物种分类的充分基础? 好吧,对于现实生活中的问题,当然不是-但是在玩具问题中,这取决于我们试图实现的输出,这是我们接下来要考虑的问题。
选择输出层的大小
对于分类器,根据定义,输出层中的节点数就是网络可以区分的分类数。 继续前面的动物分类网络示例,如果我们知道要处理以下动物,则可以使用四个节点的输出层:狗,秃鹰,海豚和龙(!)。 如果我们尝试对不在这些类别之一中的动物的数据进行分类,则网络将预测最有可能与这种无代表性动物相似的类别。
现在,我们遇到了一个困难的问题-隐藏层的大小。
选择隐藏层的大小
选择隐藏层的大小没有公认的经验法则。 必须根据实验进行选择。 对于要在其上应用 ANN 的每个实际问题,都需要对 ANN 进行训练,测试和重新训练,直到找到许多可以接受的准确率的隐藏节点。
当然,即使通过实验选择参数值,您也可能希望专家为您的测试建议一个起始值或一系列值。 不幸的是,在这些方面也没有专家共识。 一些专家根据以下广泛建议提供经验法则(这些建议应加盐):
- 如果输入层很大,则隐藏神经元的数量应在输入层的大小和输出层的大小之间,并且通常应更接近输出层的大小。
- 另一方面,如果输入和输出层都较小,则隐藏层应为最大层。
- 如果输入层较小,但输出层较大,则隐藏层应更接近输入层的大小。
其他专家建议,还应考虑训练样本的数量; 大量的训练样本意味着更多的隐藏节点可能有用。
要记住的一个关键因素是过拟合。 与训练数据实际提供的信息相比,当隐藏层中包含如此大量的伪信息时,就会发生过拟合,因此分类不太有意义。 隐藏层越大,为了正确学习而需要的训练数据就越多。 当然,随着训练数据集的大小增加,训练时间也会增加。
对于本章中的一些 ANN 示例项目,我们将使用 60 的隐藏层大小作为起点。 给定一个庞大的训练集,对于各种分类问题,60 个隐藏节点可以产生不错的准确率。
现在,我们对什么是人工神经网络有了一个大致的了解,让我们看看 OpenCV 如何实现它们,以及如何充分利用它们。 我们将从一个最小的代码示例开始。 然后,我们将充实我们在前两节中讨论的以动物为主题的分类器。 最后,我们将努力开发更现实的应用,在该应用中,我们将基于图像数据对手写数字进行分类。
在 OpenCV 中训练基本的 ANN
OpenCV 提供了cv2.ml_ANN_MLP
类,该类将 ANN 实现为多层感知器(MLP)。 这正是我们之前在“了解神经元和感知器”部分中描述的模型。
要创建cv2.ml_ANN_MLP
的实例并为该 ANN 的训练和使用格式化数据,我们依赖于 OpenCV 的机器学习模块cv2.ml
中的功能。 您可能还记得过,这与我们在第 7 章,“构建自定义对象检测器”中用于 SVM 相关功能的模块相同。 此外,cv2.ml_ANN_MLP
和cv2.ml_SVM
共享一个称为cv2.ml_StatModel
的公共基类。 因此,您会发现 OpenCV 为 ANN 和 SVM 提供了类似的 API。
让我们来看一个虚拟的例子,作为对 ANN 的简要介绍。 该示例将使用完全无意义的数据,但它将向我们展示用于在 OpenCV 中训练和使用 ANN 的基本 API:
- 首先,我们照常导入 OpenCV 和 NumPy:
import cv2 import numpy as np
- 现在,我们创建一个未经训练的人工神经网络:
ann = cv2.ml.ANN_MLP_create()
- 创建 ANN 后,我们需要配置其层数和节点数:
ann.setLayerSizes(np.array([9, 15, 9], np.uint8))
层大小由传递给setLayerSizes
方法的 NumPy 数组定义。 第一个元素是输入层的大小,最后一个元素是输出层的大小,所有中间元素定义隐藏层的大小。 例如,[9, 15, 9]
指定 9 个输入节点,9 个输出节点以及具有 15 个节点的单个隐藏层。 如果将其更改为[9, 15, 13, 9]
,它将指定两个分别具有 15 和 13 个节点的隐藏层。
- 我们还可以配置激活函数,训练方法和训练终止标准,如下所示:
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))
在这里,我们使用对称的 Sigmoid 激活函数(cv2.ml.ANN_MLP_SIGMOID_SYM
)和反向传播训练方法(cv2.ml.ANN_MLP_BACKPROP
)。 反向传播是一种算法,用于计算输出层的预测误差,从先前的层向后追溯误差的来源,并更新权重以减少误差。
- 让我们训练 ANN。 我们需要指定训练输入(或 OpenCV 术语中的
samples
),相应的正确输出(或responses
),以及数据的格式(或layout
)是每个样本一行还是每个样本一行。 这是一个如何使用单个样本训练模型的示例:
training_samples = np.array( [[1.2, 1.3, 1.9, 2.2, 2.3, 2.9, 3.0, 3.2, 3.3]], np.float32) layout = cv2.ml.ROW_SAMPLE training_responses = np.array( [[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]], np.float32) data = cv2.ml.TrainData_create( training_samples, layout, training_responses) ann.train(data)
实际上,我们希望使用包含一个以上样本的更大数据集来训练任何 ANN。 我们可以通过扩展training_samples
和training_responses
使其包含多个行来表示多个样本及其相应的响应,从而做到这一点。 或者,我们可以多次调用 ANN 的train
方法,每次都使用新数据。 后一种方法需要train
方法使用一些其他参数,下一节“在多个周期中训练 ANN 分类器”将对此进行演示。
请注意,在这种情况下,我们正在训练 ANN 作为分类器。 每个响应都是一个类的置信度得分,在这种情况下,有 9 个类。 我们将通过基于 0 的索引将它们称为 0 到 8 类。在这种情况下,我们的训练样本的响应为[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]
,这意味着它是 5 类的实例(置信度 1.0),并且它绝对不是任何其他类的实例(因为其他所有类的置信度为 0.0)。
- 为了完成对 ANN API 的最小介绍,让我们制作另一个示例,对其进行分类并打印结果:
test_samples = np.array( [[1.4, 1.5, 1.2, 2.0, 2.5, 2.8, 3.0, 3.1, 3.8]], np.float32) prediction = ann.predict(test_samples) print(prediction)
这将打印以下结果:
(5.0, array([[-0.08763029, -0.01616517, 0.13196233, 0.0402631 , 0.05711843, 1.1642447 , 0.18130444, 0.1857026 , -0.07486832]], dtype=float32))
这意味着所提供的输入被归类为第 5 类。再次,这只是一个虚拟示例,该分类是毫无意义的。 但是,网络行为正常。 在前面的代码中,我们仅提供了一个训练记录,该训练记录是第 5 类的样本,因此网络将新输入归为第 5 类。(据我们有限的训练数据集显示,除 5 以外的其他类可能永远不会发生。)
您可能已经猜到了,预测的输出是一个元组,第一个值是类,第二个值是包含每个类的概率的数组。 预测的类别将具有最高的值。
让我们继续一个更可信的例子-动物分类。
在多个周期中训练 ANN 分类器
让我们创建一个 ANN,尝试根据三种度量对动物进行分类:体重,长度和牙齿数量。 当然,这是一个模拟场景。 实际上,没有人会只用这三个统计数据来描述动物。 但是,我们的目的是在将 ANN 应用于图像数据之前,加深对 ANN 的理解。
与上一节中的最小示例相比,我们的动物分类模型将通过以下方式更加复杂:
- 我们将增加隐藏层中神经元的数量。
- 我们将使用更大的训练数据集。 为方便起见,我们将随机生成此数据集。
- 我们将在多个周期训练 ANN,这意味着我们将使用相同的数据集每次对其进行多次训练和重新训练。
隐藏层中神经元的数量是重要的参数,需要进行测试才能优化任何 ANN 的准确率。 您会发现,较大的隐藏层可以在一定程度上提高准确率,然后过拟合,除非您开始使用庞大的训练数据集进行补偿。 同样,在一定程度上,更多的周期可能会提高准确率,但过多的周期会导致过拟合。
让我们逐步执行一下实现:
- 首先,我们照例导入 OpenCV 和 NumPy。 然后,从 Python 标准库中,导入
randint
函数以生成伪随机整数,并导入uniform
函数以生成伪随机浮点数:
import cv2 import numpy as np from random import randint, uniform
- 接下来,我们创建并配置 ANN。 这次,我们使用三个神经元输入层,一个 50 神经元隐藏层和一个四个神经元输出层,如以下代码中以粗体突出显示:
animals_net = cv2.ml.ANN_MLP_create() animals_net.setLayerSizes(np.array([3, 50, 4])) animals_net.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0) animals_net.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1) animals_net.setTermCriteria( (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS, 100, 1.0))
- 现在,我们需要一些数据。 我们对准确地代表动物并不感兴趣。 我们只需要一堆记录作为训练数据即可。 因此,我们定义四个函数以生成不同类别的随机样本,另外定义四个函数以生成正确的分类结果以进行训练:
"""Input arrays weight, length, teeth """ """Output arrays dog, condor, dolphin, dragon """ def dog_sample(): return [uniform(10.0, 20.0), uniform(1.0, 1.5), randint(38, 42)] def dog_class(): return [1, 0, 0, 0] def condor_sample(): return [uniform(3.0, 10.0), randint(3.0, 5.0), 0] def condor_class(): return [0, 1, 0, 0] def dolphin_sample(): return [uniform(30.0, 190.0), uniform(5.0, 15.0), randint(80, 100)] def dolphin_class(): return [0, 0, 1, 0] def dragon_sample(): return [uniform(1200.0, 1800.0), uniform(30.0, 40.0), randint(160, 180)] def dragon_class(): return [0, 0, 0, 1]
- 我们还定义了以下辅助函数,以便将样本和分类转换为一对 NumPy 数组:
def record(sample, classification): return (np.array([sample], np.float32), np.array([classification], np.float32))
- 让我们继续创建假动物数据。 我们将为每个类创建 20,000 个样本:
RECORDS = 20000 records = [] for x in range(0, RECORDS): records.append(record(dog_sample(), dog_class())) records.append(record(condor_sample(), condor_class())) records.append(record(dolphin_sample(), dolphin_class())) records.append(record(dragon_sample(), dragon_class()))
- 现在,让我们训练 ANN。 正如我们在本节开头所讨论的,我们将使用多个训练周期。 每个周期都是循环的迭代,如以下代码所示:
EPOCHS = 10 for e in range(0, EPOCHS): print("epoch: %d" % e) for t, c in records: data = cv2.ml.TrainData_create(t, cv2.ml.ROW_SAMPLE, c) if animals_net.isTrained(): animals_net.train(data, cv2.ml.ANN_MLP_UPDATE_WEIGHTS | cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE) else: animals_net.train(data, cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)
对于具有庞大且多样化的训练数据集的实际问题,ANN 可能会受益于数百个训练周期。 为了获得最佳结果,您可能希望继续训练和测试 ANN,直到达到收敛为止,这意味着进一步的周期将不再对结果的准确率产生明显的改善。
请注意,我们必须将cv2.ml.ANN_MLP_UPDATE_WEIGHTS
标志传递给 ANN 的train
函数,以更新以前训练的模型,而不是从头开始训练新的模型。 这是每当您逐步训练模型时都必须记住的关键点,就像我们在这里所做的那样。
- 训练完我们的人工神经网络后,我们应该进行测试。 对于每个类别,让我们生成 100 个新的随机样本,使用 ANN 对其进行分类,并跟踪正确分类的数量:
TESTS = 100 dog_results = 0 for x in range(0, TESTS): clas = int(animals_net.predict( np.array([dog_sample()], np.float32))[0]) print("class: %d" % clas) if clas == 0: dog_results += 1 condor_results = 0 for x in range(0, TESTS): clas = int(animals_net.predict( np.array([condor_sample()], np.float32))[0]) print("class: %d" % clas) if clas == 1: condor_results += 1 dolphin_results = 0 for x in range(0, TESTS): clas = int(animals_net.predict( np.array([dolphin_sample()], np.float32))[0]) print("class: %d" % clas) if clas == 2: dolphin_results += 1 dragon_results = 0 for x in range(0, TESTS): clas = int(animals_net.predict( np.array([dragon_sample()], np.float32))[0]) print("class: %d" % clas) if clas == 3: dragon_results += 1
- 最后,让我们打印准确率统计信息:
print("dog accuracy: %.2f%%" % (100.0 * dog_results / TESTS)) print("condor accuracy: %.2f%%" % (100.0 * condor_results / TESTS)) print("dolphin accuracy: %.2f%%" % \ (100.0 * dolphin_results / TESTS)) print("dragon accuracy: %.2f%%" % (100.0 * dragon_results / TESTS))
当我们运行脚本时,前面的代码块应产生以下输出:
dog accuracy: 100.00% condor accuracy: 100.00% dolphin accuracy: 100.00% dragon accuracy: 100.00%
由于我们正在处理随机数据,因此每次您运行脚本时,结果可能会有所不同。 通常,由于我们已经建立了一个简单的分类问题,即输入数据的范围不重叠,因此准确率应该很高甚至是完美的。 (狗的随机权重值的范围与龙的范围不重叠,依此类推。)
您可能需要花一些时间来尝试以下修改(一次进行一次),以便了解 ANN 的准确率如何受到影响:
- 通过修改
RECORDS
变量的值来更改训练样本的数量。 - 通过修改
EPOCHS
变量的值来更改训练周期的数量。 - 通过在
dog_sample
,condor_sample
,dolphin_sample
和dragon_sample
函数中编辑uniform
和randint
函数调用的参数,使输入数据的范围部分重叠。
准备就绪后,我们将继续一个包含真实图像数据的示例。 这样,我们将训练 ANN 来识别手写数字。
用人工神经网络识别手写数字
手写数字是 10 个阿拉伯数字(0 到 9)中的任何一个,用笔或铅笔手动书写,而不是用机器打印。 手写数字的外观可能会有很大差异。 不同的人有不同的笔迹,并且-一个熟练的书法家可能会例外-一个人每次书写都不会产生相同的数字。 这种可变性意味着手写数字的视觉识别对于机器学习来说是一个不小的问题。 确实,机器学习的学生和研究人员经常通过尝试训练手写数字的准确识别器来测试他们的技能和新算法。 我们将通过以下方式应对这一挑战:
- 从 MNIST 数据库的 Python 友好版本加载数据。 这是一个广泛使用的数据库,其中包含手写数字的图像。
- 使用 MNIST 数据,在多个周期训练 ANN。
- 加载一张纸上有许多手写数字的图像。
- 基于轮廓分析,检测纸张上的各个数字。
- 使用我们的人工神经网络对检测到的数字进行分类。
- 查看结果,以确定我们的探测器和基于 ANN 的分类器的准确率。
在深入研究实现之前,让我们回顾一下有关 MNIST 数据库的信息。
Python3 OpenCV4 计算机视觉学习手册:6~11(5)https://developer.aliyun.com/article/1427066