OpenCV-Python实战(33)——构建实时目标检测与跟踪系统

OpenCV-Python实战(33)------构建实时目标检测与跟踪系统

    • [0. 前言](#0. 前言)
    • [1. 规划应用程序](#1. 规划应用程序)
    • [2. 准备主脚本](#2. 准备主脚本)
      • [2.1 使用 SSD 检测物体](#2.1 使用 SSD 检测物体)
      • [2.2 使用其他检测器](#2.2 使用其他检测器)
      • [2.3 目标检测器](#2.3 目标检测器)
      • [2.4 模型训练](#2.4 模型训练)
      • [2.5 跟踪检测到的物体](#2.5 跟踪检测到的物体)
    • [3. 实现 Sort 跟踪器](#3. 实现 Sort 跟踪器)
      • [3.1 卡尔曼滤波器](#3.1 卡尔曼滤波器)
    • [3.2 使用带有卡尔曼滤波器的边界框跟踪器](#3.2 使用带有卡尔曼滤波器的边界框跟踪器)
      • [3.3 将检测结果与跟踪器关联](#3.3 将检测结果与跟踪器关联)
      • [3.4 定义跟踪器的主类](#3.4 定义跟踪器的主类)
    • [4. 观察应用程序的实际运行](#4. 观察应用程序的实际运行)
    • 小结
    • 系列链接

0. 前言

我们已经动手实践了深度卷积神经网络,并使用迁移学习构建了深度分类和定位网络。已经开启了深度学习之旅,并熟悉了一系列深度学习概念,理解了深度模型是如何训练的,并且已经准备好学习更高级的深度学习概念。

在本节中,我们将继续深度学习之旅,首先使用目标检测模型在相关场景(例如包含汽车和行人的街景)的视频中检测多种不同类型的目标。之后,将学习这类模型是如何构建和训练的。

总的来说,如今鲁棒的目标检测模型有着广泛的应用。这些领域包括但不限于医学、机器人、监控等。理解它们的工作原理将使我们能够将它们用于构建自己的实际应用,以及在其基础上精炼出新的模型。

在我们介绍完目标检测之后,我们将实现简单在线实时跟踪 (Simple Online and Realtime Tracking, Sort) 算法,该算法能够在各帧之间鲁棒地跟踪检测到的目标。在实现 Sort 算法的过程中,还将熟悉卡尔曼滤波器,它通常是处理时间序列时的一个重要算法。

一个良好的检测器与跟踪器的结合在工业问题中有着多种应用。在本节中,我们将统计相关场景视频中出现的不同类型的目标总数,一旦理解了如何完成这个特定任务,就可以进行推广将其应用于自己的应用程序中。

例如,拥有一个好的目标跟踪器可以回答诸如:场景的哪个部分看起来更密集?在观测期间,物体在哪些地方移动得更慢或更快?等统计问题。在某些场景中,可能有兴趣监控特定物体的轨迹,估计它们的速度或它们在场景不同区域停留的时间。拥有一个好的跟踪器是解决所有这些问题的关键。

1. 规划应用程序

如前所述,最终的应用程序将能够检测、跟踪和统计场景中的物体。这将需要以下组件:

  • main.py:用于实时检测、跟踪和统计物体的主脚本
  • sort.py:实现跟踪算法的模块

我们将首先准备主脚本。在准备过程中,将学习如何使用检测网络,以及它们的工作原理和训练方式。在同一脚本中,我们将使用跟踪器来跟踪和统计物体。

准备完主脚本后,我们将准备跟踪算法,并能够运行应用程序。

2. 准备主脚本

主脚本将负责应用程序的完整逻辑。它将处理视频流,并使用目标检测深度卷积神经网络以及我们将在本节后面介绍的跟踪算法。

该算法用于逐帧跟踪物体,它还负责展示结果。该脚本将接受参数并具有一些内在常量,这些在脚本的以下初始化步骤中定义:

(1) 首先导入所需的模块:

python 复制代码
import argparse

import cv2
import numpy as np

from classes import CLASSES_90
from sort import Sort

我们将使用 argparse,因为我们希望脚本接受参数。我们将物体类别存储在一个单独的文件中,最后我们导入 Sort 跟踪器(我们将在本节后面构建它)。

(2) 接下来,我们创建并解析参数:

python 复制代码
parser = argparse.ArgumentParser()
parser.add_argument("-i", "--input",
                    help="Video path, stream URI, or camera ID ", default="demo.mp4")
parser.add_argument("-t", "--threshold", type=float, default=0.3,
                    help="Minimum score to consider")
parser.add_argument("-m", "--mode", choices=['detection', 'tracking'], default="tracking",
                    help="Either detection or tracking mode")

args = parser.parse_args()

我们的第一个参数是 input,它可以是视频的路径、摄像头的 ID (0 表示默认摄像头)或视频流的通用资源标识符 (Universal Resource Identifier, URI)。例如,能够使用实时传输控制协议 (Real-time Transport Control Protocol, RTCP) 将应用程序连接到远程 IP 摄像头。

我们将使用的网络能够预测物体的边界框。每个边界框将有一个分数,用于指定该边界框包含某种类型物体的概率。

下一个参数是 threshold,它指定分数的最小值。如果分数低于阈值,我们将不考虑该检测。最后一个参数是 mode,即我们想要运行脚本的模式。如果在检测模式下运行,算法的流程将在检测物体后停止,不会继续进行跟踪,物体检测的结果将在帧中展示。

(3) OpenCV 接受摄像头 ID 作为整数。如果我们指定了摄像头的 ID,输入参数将是一个字符串而不是整数。因此,如果需要,我们需要将其转换为整数:

python 复制代码
    args.input = int(args.input)

(4) 接下来,我们定义所需的常量:

python 复制代码
TRACKED_CLASSES = ["car", "person"]
BOX_COLOR = (23, 230, 210)
TEXT_COLOR = (255, 255, 255)
INPUT_SIZE = (300, 300)

在这个应用程序中,我们将跟踪汽车和行人。我们将用淡黄色绘制边界框,并用白色书写文本。我们还将定义用于检测的单次检测器 (Single Shot Detector, SSD) 模型的标准输入尺寸。

2.1 使用 SSD 检测物体

OpenCV 提供了导入使用深度学习框架构建的模型的方法。我们按如下方式加载 TensorFlow SSD 模型:

python 复制代码
config = "./ssd_mobilenet_v1_coco_2017_11_17.pbtxt.txt"
model = "frozen_inference_graph.pb"
detector = cv2.dnn.readNetFromTensorflow(model, config)

readNetFromTensorflow 方法的第一个参数接受一个文件的路径,该文件包含二进制 Protobuf (协议缓冲区)格式的 TensorFlow 模型。第二个参数是可选的,它是一个文本文件的路径,包含模型的图定义,同样也是 Protobuf 格式。

当然,模型文件本身可能包含图定义,OpenCV 可以从模型文件中读取该定义。但是,对于许多网络,可能需要创建一个单独的定义文件,因为 OpenCV 无法解释 TensorFlow 中所有可用的操作,这些操作应该被替换为 OpenCV 可以解释的操作。

现在,让我们定义一些对展示检测结果的辅助函数。第一个函数用于展示单个边界框:

python 复制代码
def illustrate_box(image: np.ndarray, box: np.ndarray, caption: str) -> None:

从上述代码中,illustrate_box 函数接受一个图像、一个归一化的边界框(作为指定框的两个对角点的四个坐标的数组),以及该边界框的标题。然后,该函数包含以下步骤:

(1) 首先提取图像的大小:

python 复制代码
    rows, cols = frame.shape[:2]

(2) 然后提取两个点,按图像尺寸缩放它们,并将它们转换为整数:

python 复制代码
    points = box.reshape((2, 2)) * np.array([cols, rows])
    p1, p2 = points.astype(np.int32)

(3) 之后,使用这两个点绘制相应的矩形:

python 复制代码
    cv2.rectangle(image, tuple(p1), tuple(p2), BOX_COLOR, thickness=4)

(4) 最后,在第一个点附近放置标题:

python 复制代码
    cv2.rectangle(image, tuple(p1), tuple(p2), BOX_COLOR, thickness=4)
    cv2.putText(
        image,
        caption,
        tuple(p1),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.75,
        TEXT_COLOR,
        2)

第二个函数将展示所有检测结果,如下所示:

python 复制代码
def illustrate_detections(dets: np.ndarray, frame: np.ndarray) -> np.ndarray:
    class_ids, scores, boxes = dets[:, 0], dets[:, 1], dets[:, 2:6]
    for class_id, score, box in zip(class_ids, scores, boxes):
        illustrate_box(frame, box, f"{CLASSES_90[int(class_id)]} {score:.2f}")
    return frame

第二个函数接受一个二维 numpy 数组形式的检测结果以及一个用于展示检测结果的帧。每个检测结果包括检测到的物体的类别 ID、指定边界框包含指定类别的物体的概率分数,以及检测结果本身的边界框。

该函数首先提取所有检测结果的上述值,然后使用 illustrate_box 方法展示每个检测结果的边界框。类别名称和分数作为边界框的标题添加。

现在,让我们连接到摄像头:

python 复制代码
cap = cv2.VideoCapture(args.input)

我们将 input 参数传递给 VideoCapture,如前所述,它可以是一个视频文件、流或摄像头 ID

现在我们已经加载了网络,定义了展示所需的函数,并打开了视频捕获,我们准备迭代处理帧、检测物体并展示结果。我们使用一个 for 循环来实现这个目的:

python 复制代码
for res, frame in iter(cap.read, (False, None)):

循环体包含以下步骤:

(1) 将当前帧设置为检测网络的输入:

python 复制代码
    detector.setInput(
        cv2.dnn.blobFromImage(
            frame,
            size=INPUT_SIZE,
            swapRB=True,
            crop=False))

blobFromImage 从提供的图像中为网络创建一个四维输入。它还将图像调整到输入尺寸,并交换图像的红色和蓝色通道,因为网络是在 RGB 图像上训练的,而 OpenCV 读取的帧是 BGR 格式的。

(2) 然后使用网络进行预测,并以所需的格式获取输出:

python 复制代码
    detections = detector.forward()[0, 0, :, 1:]

从上述代码中,forward 代表前向传播。结果是一个二维 numpy 数组。数组的第一个索引表示检测编号,第二个索引表示具体的检测结果,由物体类别、分数以及指定边界框两个角点坐标的四个值组成。

(3) 之后,从检测结果中提取分数,并过滤掉分数过低的检测结果:

python 复制代码
    scores = detections[:, 1]
    detections = detections[scores > 0.3]

(4) 在脚本以检测模式运行的情况下,立即展示检测结果:

python 复制代码
    if args.mode == "detection":
        out = illustrate_detections(detections, frame)
    else:
        out = track(detections, frame)
        illustrate_tracking_info(out)
    cv2.imshow("out", out)

(5) 然后设置终止条件:

python 复制代码
    if cv2.waitKey(1) == 27:
        cv2.waitKey(0)

现在我们已经准备好一切,可以在检测模式下运行脚本了。示例结果如下图所示:

从上图的帧中可以看到,SSD 模型已经成功检测到了场景中可见的所有汽车和行人。现在,让我们看看如何使用其他检测器。

2.2 使用其他检测器

在本节中,我们使用目标检测器来获取带有物体类型的边界框,这些边界框将由 Sort 算法进一步处理以进行跟踪。通常,获取边界框的具体方式并不重要。在本节中,我们使用了预训练的 SSD 模型。接下来,让我们了解如何将其替换为不同的模型。

首先,让我们了解如何使用 YOLOYou Only Look Once (YOLO) 也是一个单阶段检测器。原始的 YOLO 模型基于 Darknet,这是另一个开源神经网络框架,用 C++CUDA 编写。OpenCV 能够加载基于 Darknet 的网络,类似于加载 TensorFlow 模型的方式。

为了加载 YOLO 模型,需要首先下载包含网络配置和网络权重的文件。在本节中,作为示例,我们将使用 YOLOv3-tiny

下载网络配置和权重后,就可以像加载 SSD 模型一样加载它们:

python 复制代码
detector = cv2.dnn.readNetFromDarknet("yolov3-tiny.cfg", "yolov3-tiny.weights")

不同之处在于,使用 readNetFromDarknet 函数代替 readNetFromTensorflow

为了使用此检测器代替 SSD,我们需要进行以下修改:

  • 我们必须更改输入尺寸:

    python 复制代码
    INPUT_SIZE = (320, 320)

    该网络最初是以指定尺寸进行训练的。如果有高分辨率的输入视频流,并且希望网络检测场景中的小物体,可以将输入设置为不同的尺寸,例如 160 的倍数,比如尺寸 (640, 480)。输入尺寸越大,检测到的小物体就越多,但网络进行预测的速度会变慢。

  • 我们必须更改类别名称:

    python 复制代码
    with open("coco.names") as f:
        CLASSES_90 = f.read().split("\n")

    尽管 YOLO 网络是在 COCO 数据集上训练的,但物体的 ID 是不同的。在这种情况下,仍然可以使用之前的类别名称运行将会得到错误的类别名称。
    可以从darknet 资源库下载文件。

  • 我们必须稍微更改输入:

    python 复制代码
    detector.setInput(
            cv2.dnn.blobFromImage(
            frame,
            scalefactor=1 / 255.0,
            size=INPUT_SIZE,
            swapRB=True,
            crop=False))

    SSD 的输入相比,我们添加了 scalefactor,它对输入进行归一化。

现在我们已经准备好成功进行预测。不过,我们还没有完全准备好用这个检测器来显示结果。问题在于 YOLO 模型的预测具有不同的格式。

每个检测结果包括边界框中心的坐标、边界框的宽度和高度,以及一个表示边界框中每种物体概率的独热向量。为了完成集成,我们必须将检测结果转换为我们在应用程序中使用的格式,可以通过以下步骤完成:

(1) 提取边界框的中心坐标:

python 复制代码
centers = detections[:, 0:2]

(2) 然后,提取边界框的宽度和高度:

python 复制代码
sizes = detections[:, 2:4]

(3) 然后,提取分数的独热向量 scores_one_hot

python 复制代码
scores_one_hot = detections[:, 5:]

(4) 然后,找到最高分对应的类别ID class_ids

python 复制代码
class_ids = np.argmax(scores_one_hot, axis=1)

(5) 之后,我们提取最高分数:

python 复制代码
scores = np.max(scores_one_hot, axis=1)

(6) 然后,使用前面步骤中获得的结果,以应用程序所需格式构建检测结果:

python 复制代码
detections = np.concatenate(
        (class_ids[:, None], scores[:, None], centers - sizes / 2,
        centers + sizes / 2), axis=1)
detections = detections[scores > 0.3]

现在我们可以成功使用新的检测器运行应用程序了。根据实际需求、可用资源和所需的精度,可能希望使用其他检测模型,例如其他版本的 SSDYOLO

可以尝试用 OpenCV 加载你选择的模型,就像我们在本节中对 YOLOSSD 所做的那样。使用这种方法,可能会在加载模型时遇到困难。例如,我们可能需要调整网络配置,使得网络中的所有操作都能被 OpenCV 处理。

另一种可能更方便的方法是使用原始框架来运行模型,类似于我们在《分类与定位物体》一节中所学的。现在,我们了解了如何使用检测器,让我们在下一部分中介绍它们的工作方式。

2.3 目标检测器

在《分类与定位物体》一节中,我们学习了如何使用卷积神经网络某一层的特征图来预测场景中物体的边界框,在我们的例子中是头部。

我们可能会注意到,我们组成的定位网络与我们使用的检测网络之间的区别在于,检测网络预测多个边界框而不是单个边界框,并且为每个边界框分配一个类别。

现在,让我们在这两种架构之间做一个平滑的过渡,以便能够理解像 YOLOSSD 这样的目标检测网络是如何工作的。

2.3.1 单目标探测器

首先,让我们看一下如何并行预测类别和边界框。在《分类与定位物体》一节中,我们学习了如何构建分类器,我们可以将分类与定位结合在同一个网络中,这可以通过将分类和定位模块连接到基础网络的同一个特征图,并用一个损失函数(定位损失和分类损失之和)一起训练来实现的。

问题在于:如果场景中没有物体怎么办?为了解决这个问题,我们可以简单地添加一个对应于背景的类别,并在训练时为零边界框预测器的损失赋值。这样,将得到一个能检测多类物体但只能在场景中检测一个物体的检测器。现在让我们看看如何预测多个边界框,从而达成目标检测器的完整架构。

2.3.2 滑动窗口方法

滑动窗口方法是最早用于创建能够检测场景中多个物体的架构的方法之一。使用这种方法,首先为感兴趣的物体构建一个分类器。然后,选择一个比想要检测物体的图像小几倍或很多倍的矩形(窗口)。之后,将其滑动到图像中所有可能的位置,并在矩形的每个位置分类是否存在所选类别的物体。

在滑动过程中,使用介于窗口尺寸的一小部分到完整窗口尺寸之间的滑动步长。该过程使用不同大小的滑动窗口重复进行。最后,选择类别分数高于某个阈值的窗口位置,这些带有尺寸的窗口位置是所选物体类别的边界框。

这种方法的问题在于,首先,单张图像需要进行大量分类,因此检测器的架构会相当繁重。另一个问题是,物体只能以滑动步长的精度进行定位。此外,检测边界框的尺寸必须等于滑动窗口的尺寸。当然,如果减小滑动步长并增加窗口尺寸的数量,检测精度可以提高,但这将导致更高的计算成本。

一种方法是将单物体检测器与滑动窗口方法结合起来,充分利用两者的优点。例如,可以将图像分割成多个区域。例如,我们可以采用一个 5×5 的网格,并在网格的每个单元格中运行单物体检测器。

也创建更大或更小尺寸的更多网格,或者使网格单元格重叠。但使用这些方法,会使架构变得更复杂,也就是说,我们为了提髙精度而增大了网格尺寸或网格数量。

2.3.3 单次检测器

在上述思想中,我们使用了单物体分类或检测网络来实现多物体检测。在所有场景中,对于每个预定义的区域,我们将完整的图像或部分图像多次馈送到网络中。换句话说,我们进行了多次传递,导致架构变得繁重。

我们希望有一个网络,一旦输入图像,就能在一次传递中检测场景中的所有物体。一个想法是为我们的单物体检测器增加更多输出,使其能够预测多个边界框而不是一个。但问题在于,假设场景中有多只狗,它们可能出现在不同的位置,数量也可能不同。

我们应该如何在狗和输出之间建立不变的对应关系?如果我们尝试通过将边界框分配给输出来训练这样的网络,例如从左到右分配,我们最终只会得到接近所有位置平均值的预测。

SSDYOLO 这样的网络解决了这些问题,并在单次传递中实现了多尺度和多框检测。我们可以将它们的架构总结为以下三个组成部分:

  • 首先,它们有一个连接到特征图的位置感知多框检测器。我们已经讨论了将多个边界框预测器连接到完整特征图时出现的训练问题。SSDYOLO 通过使用一个连接到特征图的小区域而不是完整特征图的预测器来解决这个问题。它预测图像中与该特征图确切区域相对应的区域中的边界框。然后,同一个预测器在特征图的所有可能位置进行预测。这个操作是使用卷积层实现的。卷积核在特征图上滑动,并将坐标和类别作为它们的输出特征图。
    例如,如果回到定位模型的代码中,替换最后两层------这两层展平输出并创建四个全连接神经元来预测边界框坐标------替换为一个具有四个核的卷积层,就能获得类似的操作。此外,由于预测器在某个特定区域中运作并且只感知该区域,它们预测的是相对于该区域的坐标,而不是相对于完整图像的坐标。
  • YOLOSSD 在每个位置预测多个边界框,而不是单个。它们预测相对于几个默认边界框的偏移坐标,这些默认边界框也称为锚框。这些框选择的是与数据集或自然场景中物体接近的尺寸和形状,使得相对坐标值很小,甚至默认框就能很好地匹配物体的真实边界框。例如,汽车的边界框通常宽度较大,而人的边界框通常高度较大。多框允许我们获得更好的精度,并在同一区域进行多个预测。
    例如,如果图像中某处有一个人坐在自行车上,而我们只有一个边界框,那么我们会忽略其中一个物体。使用多个锚框,物体将对应于不同的锚框。
  • 除了多尺寸锚框之外,它们还使用不同尺寸的多个特征图来实现多尺度预测。如果预测模块连接到网络顶部尺寸较小的特征图,它负责大物体。
    如果它连接到底部的一个特征图,它负责小物体。一旦在选定的特征图中完成所有多框预测,结果将被转换为图像的绝对坐标并拼接起来。最终,我们得到的形式就是本节中使用的预测结果。

现在我们已经理解了检测器的工作原理,可能也对它们的训练原理感兴趣。然而,在我们理解这些原理之前,让我们先了解一下交并比指标,它在训练和评估这些网络以及过滤它们的预测结果时被大量使用。

我们还将实现一个计算该指标的函数,在构建 Sort 跟踪算法时会用到它。因此,理解这个指标不仅对于目标检测很重要,对于跟踪也同样重要。

2.3.4 交并比

交并比 (Intersection over Union, IoU),也称为 Jaccard 指数,定义为交集面积除以并集面积,公式如下:

J ( A , B ) = ∣ A ∩ B ∣ ∣ A ∪ B ∣ J(A,B)=\frac{|A\cap B|}{|A\cup B|} J(A,B)=∣A∪B∣∣A∩B∣

该公式等价于:

∣ A ∩ B ∣ ∣ A ∣ + ∣ B ∣ − ∣ A ∩ B ∣ \frac{|A\cap B|}{|A|+|B|-|A\cap B|} ∣A∣+∣B∣−∣A∩B∣∣A∩B∣

在下图中,我们展示了两个边界框的 IoU

在上图中,并集是整个图形的总面积,交集是两个边界框重叠的部分。IoU 的取值范围是 (0,1),只有当两个边界框完全匹配时才达到最大值。一旦两个边界框分离,IoU 变为零。

让我们定义一个接受两个边界框并返回其 IoU 值的函数:

python 复制代码
def iou(a: np.ndarray, b: np.ndarray) -> float:

为了计算 IoU 值,需要以下步骤:

(1) 首先提取两个边界框的左上角和右下角坐标:

python 复制代码
    a_tl, a_br = a[:4].reshape((2, 2))
    b_tl, b_br = b[:4].reshape((2, 2))

(2) 然后,获取两个左上角坐标的元素级最大值:

python 复制代码
    int_tl = np.maximum(a_tl, b_tl)

这两个数组按元素进行比较,结果将是一个新数组,包含数组中对应索引位置的较大值。在我们的例子中,获得最大的 xy 坐标,并存储在 int_tl 中。如果两个框相交,这就是交集的左上角。

(3) 然后,获取右下角坐标的元素级最小值:

python 复制代码
    int_br = np.minimum(a_br, b_br)

与上一种情况类似,如果两个框相交,这是交集的右下角。

(4) 然后,计算边界框的面积:

python 复制代码
    a_area = np.prod(a_br - a_tl)
    b_area = np.prod(b_br - b_tl)

一个框的右下角与左上角坐标之差是该框的宽度和高度,因此结果数组各元素的乘积就是边界框的面积。

(5) 之后,计算交集面积:

python 复制代码
    int_area = np.prod(np.maximum(0., int_br - int_tl))

如果两个框不重叠,结果数组中至少有一个元素将为负数。负数被替换为零。因此,在这种情况下,交集面积为零,符合预期。

(6) 最后,计算 IoU 并返回结果:

python 复制代码
    return int_area / (a_area + b_area - int_area)

现在,我们已经理解了什么是 IoU 并构建了计算它的函数,接下来可以学习如何训练检测网络。

2.4 模型训练

我们已经知道,像 YOLOSSD 这样的网络使用预定义的锚框来预测物体。在所有可用的锚框中,只选择一个对应于物体的锚框。在预测阶段,为该框分配物体的类别,并预测偏移量。

因此,问题在于,我们如何选择那一个框?IoU 就是用于这个目的。真实边界框与锚框之间的对应关系可以按如下方式建立:

  1. 创建一个矩阵,包含所有可能的真实边界框与锚框对的所有 IoU 值。假设行对应真实边界框,列对应锚框
  2. 找到矩阵中的最大元素,并将对应的两个框相互匹配。从矩阵中移除该最大元素所在的行和列
  3. 重复步骤 2,直到没有剩余的真实边界框,换句话说,直到矩阵的所有行都被移除

分配完成后,剩下要做的就是为每个边界框计算损失函数,将所有结果求和作为总损失,然后训练网络。对于包含物体的边界框,其偏移量的损失可以简单地定义为 IoU 的负值------IoU 越大,边界框越接近真实框,因此应当减小其负值。

不包含物体的锚框不贡献损失。物体类别的损失也很直接:没有匹配的锚框用背景类别进行训练,有匹配的锚框则用其对应的类别进行训练。

我们可以对上述每个考虑到的网络都对所描述的损失函数进行一些修改,以便在特定网络上获得更好的性能。

现在,我们已经理解了如何训练这些网络,让我们在下一节中继续完成应用程序的主脚本,并将其与 Sort 跟踪器集成。

2.5 跟踪检测到的物体

一旦我们能够在每一帧中成功检测到物体,我们就可以通过关联帧之间的检测结果来跟踪它们。如前所述,在本节中,我们使用简单在线实时跟踪 (Simple Online and Realtime Tracking, Sort) 算法进行多目标跟踪。

给定多个边界框序列,该算法关联序列元素中的边界框,并基于物理原理微调边界框坐标。其中一个原理是物理物体不能迅速改变其速度或运动方向。例如,在正常情况下,一辆行驶中的汽车不可能在两个连续帧之间反转其运动方向。

我们假设检测器正确标注了物体,并为每个要跟踪的物体类别实例化一个多目标跟踪器 (Multiple Object Tracker, mot):

python 复制代码
TRACKED_CLASSES = ["car", "person"]
mots = {CLASSES_90.index(tracked_class): Sort() for tracked_class in TRACKED_CLASSES}

我们将这些实例存储在一个字典中。字典的键设置为相应的类别 ID。我们将使用以下函数来跟踪检测到的物体:

python 复制代码
def track(dets: np.ndarray, illustration_frame: np.ndarray = None):
    for class_id, mot in mots.items():

该函数接受检测结果和一个可选的示例帧。函数的主循环遍历我们已经实例化的多目标跟踪器。然后,对于每个多目标跟踪器,执行以下步骤:

(1) 首先,从所有传入的检测结果中提取当前多目标跟踪器对应物体类型的检测结果:

python 复制代码
        class_dets = dets[dets[:, 0] == class_id]

(2) 然后,通过将当前物体类型的边界框传递给跟踪器的 update 方法来更新跟踪器:

python 复制代码
        sort_boxes = tracker.update(class_dets[:, 2:6])

update 方法返回与物体 ID 关联的跟踪物体的边界框坐标。

(3) 如果提供了示例帧,则在帧中展示这些边界框:

python 复制代码
        if illustration_frame is not None:
            for box in sort_boxes:
                illustrate_box(illustration_frame,
                               box[:4],
                               f"{CLASSES_90[class_id]} {int(box[4])}")

    return illustration_frame

对于每个返回的结果,将使用我们之前定义的 illustrate_box 函数绘制相应的边界框。每个边界框将标注类别名称和该边界框的 ID

(4) 我们还希望定义一个函数,用于在帧上打印关于跟踪的概括信息:

python 复制代码
def illustrate_tracking_info(frame: np.ndarray) -> np.ndarray:
    for num, (class_id, tracker) in enumerate(trackers.items()):
        txt = f"{CLASSES_90[class_id]}:Total:{tracker.count} Now:{len(tracker.trackers)}"
        cv2.putText(frame, txt, (0, 50 * (num + 1)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.75, TEXT_COLOR, 2)
    return frame

对于每个被跟踪的物体类别,该函数将写入被跟踪物体的总数和当前被跟踪物体的数量。

(5) 现在我们已经定义了用于跟踪和展示的函数,我们准备修改遍历帧的主循环,以便能够在跟踪模式下运行我们的应用程序:

python 复制代码
    if args.mode == "tracking":
        out = frame
        track(detections, frame)
        illustrate_tracking_info(out)

从上述片段中,如果应用程序在跟踪模式下运行,所选类别的检测到的物体将使用我们的 track 函数在各帧中被跟踪,并且跟踪信息将显示在帧上。

剩下要做的就是详细阐述跟踪算法,以完成整个应用程序。我们将在下一节中借助 Sort 跟踪器来完成这项工作。

3. 实现 Sort 跟踪器

Sort 算法是一种简单而鲁棒的实时跟踪算法,用于视频序列中检测到的多目标跟踪。该算法具有一种关联检测结果与跟踪器的机制,确保每个被跟踪物体最多只有一个检测框。

对于每个被跟踪物体,算法会创建一个单物体跟踪类的实例。基于物体不能快速改变大小或速度等物理原理,该类实例可以预测物体的特征位置,并维持逐帧跟踪。后者是借助卡尔曼滤波器实现的。

我们按如下方式导入实现该算法时将使用的模块:

python 复制代码
import numpy as np
from scipy.optimize import linear_sum_assignment
from typing import Tuple
import cv2

主要依赖项是 numpyOpenCVlinear_sum_assignment 方法将在关联检测到的物体与已跟踪物体时使用。

现在,让我们通过介绍卡尔曼滤波器来深入算法,该滤波器将在下一节的单框跟踪器实现中使用。

3.1 卡尔曼滤波器

卡尔曼滤波器是一种统计模型,在信号处理、控制理论和统计学中有着广泛的应用。卡尔曼滤波器是一个复杂的模型,但可以将其视为一种算法,用于在已知系统动力学具有一定精度的前提下,对随时间变化的、包含大量噪声的物体观测值进行去噪。

让我们看一个例子来说明卡尔曼滤波器的工作原理。假设我们想要找到在轨道上行驶的火车的位置。火车会有速度,但我们唯一的测量数据来自雷达,而雷达只显示火车的位置。

关于下午 3 点时火车的真实位置,我们能得出什么结论?嗯,火车有可能在位置 5,但既然我们知道火车很重且速度变化非常缓慢,火车很难在短时间内连续两次反转行驶方向,先到位置 5 再回来。因此,我们可以利用一些关于事物运行规律的知识以及先前的观测值,对火车的位置做出更可靠的预测。

例如,如果我们假设可以通过位置和速度来描述火车,我们将状态定义为:

s = ( x , v ) T s=(x,v)^T s=(x,v)T

其中, x x x 是火车的位置, v v v 是火车的速度。

现在我们需要一种方式来描述我们的世界模型,这被称为状态转移模型------对于火车来说,它很简单:

x t = x t − 1 + v v t = v t − 1 xt=xt-1+v\\ vt=vt-1 xt=xt−1+vvt=vt−1

我们可以使用状态变量 s 将其写成矩阵形式:

s t = F ⋅ s t − 1 = ( 1 1 0 1 ) ( x t − 1 v t − 1 ) st=F\cdot st-1=\begin{pmatrix} 1 & 1 \\ 0 & 1 \end{pmatrix}\begin{pmatrix} xt-1 \\ vt-1 \end{pmatrix} st=F⋅st−1=(1011)(xt−1vt−1)

矩阵 F F F 被称为状态转移矩阵。

因此,我们认为火车不改变速度,以恒定速度运动。这意味着观测图上应该是一条直线,但这过于严格,并且我们知道没有真实系统会这样表现,所以允许系统中存在一些噪声,即过程噪声:

s t = F ⋅ s t − 1 + w t st=F\cdot st-1+wt st=F⋅st−1+wt

一旦我们对过程噪声的性质做出统计假设,这将成为一个统计框架,这通常是实际情况。但是,这样,如果我们对自己的状态转移模型不确定,但对观测值确定,那么最好的解决方案仍然是仪器报告的值。因此,我们需要将我们的状态与观测值联系起来。注意,我们正在观测 x x x,所以观测值可以通过将状态乘以一个简单的行矩阵来恢复:

o t = H ⋅ s t = ( 1 0 ) ( x t v t ) ot=H\cdot st=\begin{pmatrix} 1 & 0 \end{pmatrix}\begin{pmatrix} xt \\ vt \end{pmatrix} ot=H⋅st=(10)(xtvt)

但是,正如我们所说,我们必须允许观测值不完美(也许雷达过于陈旧,有时会有错误的读数),也就是说,我们需要允许观测噪声;因此,最终的观测值是:

o t = H ⋅ s t + ϵ t ot=H\cdot st+\epsilon t ot=H⋅stt

现在,如果我们能够表征过程噪声和观测噪声,卡尔曼滤波器将能够仅使用该时间点之前的观测值,为我们提供每个时间点上火车位置的良好预测。参数化噪声的最佳方法是使用协方差矩阵:

w t ∼ N ( 0 , Q ) ϵ t N ( 0 , R ) wt\sim N(0,Q)\\ \epsilont N(0,R) wt∼N(0,Q)ϵtN(0,R)

卡尔曼滤波器具有递归的状态转移模型,因此我们必须提供状态的初始值。如果我们将其选为 (0,0),并且假设过程噪声和测量噪声的概率相等,卡尔曼滤波器将为每个时间点提供如下预测:

由于我们既相信观测数据,也相信速度不变的假设,因此得到了一条较为平滑的蓝色曲线,它不像之前那样极端,但仍然不够令人信服。现在,如果设定信噪比(即协方差比值的平方根)为 10,我们将得到以下结果:

可以看到,速度确实变化非常缓慢,但我们似乎低估了火车行驶的距离。

调优卡尔曼滤波器是一项非常困难的任务,有许多算法可以完成这项工作,但不幸的是,没有一种是完美的。本节不会涉及这些算法;我们将尝试选择有意义的参数,并且会看到这些参数能给出不错的结果。

现在,我们重新审视单辆汽车的跟踪模型,看看应该如何对系统动态进行建模。

3.2 使用带有卡尔曼滤波器的边界框跟踪器

首先,我们必须弄清楚如何对每辆车的状态进行建模。也许从观测模型开始更好;也就是说,关于每辆车,我们能测量到什么?

目标检测器给我们提供了边界框,但它们呈现的方式并不是最好的物理解释;类似于前面火车的例子,我们想要能够推理的变量,并且这些变量更接近交通的基础动力学。因此,我们使用以下观测模型:

o b s e r v a t i o n = ( horizontal coordinate vertical coordinate size aspect ratio ) = ( u v s r ) observation=\begin{pmatrix} \text{horizontal coordinate} \\ \text {vertical coordinate} \\ \text{size} \\ \text{aspect ratio}\end{pmatrix}=\begin{pmatrix} u \\ v \\ s \\ r\end{pmatrix} observation= horizontal coordinatevertical coordinatesizeaspect ratio = uvsr

其中, u u u 和 v v v 是目标中心的水平和垂直像素位置, s s s 和 r r r 分别表示目标边界框的尺度(面积)和长宽比。由于我们的车辆在屏幕上移动并且正在远离或靠近,边界框的坐标和大小都会随时间变化。假设人们的驾驶比较稳定,图像中车辆的速度应该或多或少保持恒定;这就是为什么我们可以将模型限制在物体的位置和速度上。因此,我们将采用的状态如下:

state = u , v , s , r , u ˙ , v ˙ , s ˙ T \text {state}=u,v,s,r,\\dot u,\\dot v,\\dot s^T state=u,v,s,r,u˙,v˙,s˙T

其中变量上方的点表示该变量的变化率。

状态转移模型将是速度和长宽比随时间保持恒定(带有一些过程噪声)。在下图中,我们可视化了所有边界框及其对应的状态(中心位置和速度向量):

可以看到,我们设置模型使得其观测到的内容与我们跟踪器接收到的内容略有不同。因此,在下一节中,我们将详细讨论将边界框转换为卡尔曼滤波器状态空间以及从中转换回来所需的变换函数。

3.2.1 将边界框转换为观测值

为了将边界框传递给卡尔曼滤波器,我们将不得不定义一个从每个边界框到观测模型的变换函数,并且为了使用预测出的边界框进行目标跟踪,我们需要定义一个从状态到边界框的函数。

我们从边界框到观测值的变换函数开始:

(1) 首先,计算边界框的中心坐标:

python 复制代码
def bbox_to_observation(bbox):
    x, y = (bbox[0:2] + bbox[2:4]) / 2

(2) 接下来,我们计算盒子的宽度和高度,我们将使用它们来计算尺寸(即面积)和比例尺:

python 复制代码
    w, h = bbox[2:4] - bbox[0:2]

(3) 然后,我们计算 bbox 的大小,即面积:

python 复制代码
    s = w * h

(4) 之后,我们计算宽高比,只需将宽度除以高度即可:

python 复制代码
    r = w / h

(5) 最后将结果作为一个 4×1 的矩阵返回:

python 复制代码
    return np.array([x, y, s, r])[:, None].astype(np.float64)

现在,由于我们知道还必须定义逆变换,接下来定义 state_to_bbox 函数:

(1) 该函数接受一个 7×1 的矩阵作为参数,并解包出构造边界框所需的所有分量:

python 复制代码
def state_to_bbox(x):

(2) 然后,根据宽高比和尺度计算出边界框的宽度和高度:

python 复制代码
    w = np.sqrt(s * r)
    h = s / w

(3) 之后,计算中心的坐标:

python 复制代码
    center = np.array([center_x, center_y])

(4) 然后,将边界框的一半尺寸计算为 numpy 元组,并用它来计算边界框对角点的坐标:

python 复制代码
    half_size = np.array([w, h]) / 2
    corners = center - half_size, center + half_size

(5) 最后,将边界框作为一维 numpy 数组返回:

python 复制代码
    return np.concatenate(corners).astype(np.float64)

掌握了这些变换函数,让我们看看如何使用 OpenCV 构建卡尔曼滤波器。

3.2.2 实现卡尔曼滤波器

现在,有了模型,我们动手编写一个处理所有这些神奇功能的类。我们将编写一个自定义类,使用 cv2.KalmanFilter 作为卡尔曼滤波器,添加一些辅助属性,以便能够跟踪每个物体。

首先,让我们看一下类的初始化部分,我们将通过传递状态模型、转移矩阵和初始参数来设置卡尔曼滤波器:

(1) 我们首先使用边界框 bbox 和标签对象的标签来初始化类:

python 复制代码
class KalmanBoxTracker:
    def __init__(self, bbox, label):

(2) 然后,我们设置一些辅助变量,以便在跟踪器中过滤出现和消失的边界框:

python 复制代码
        self.id = label
        self.time_since_update = 0
        self.hit_streak = 0

(3) 然后,使用正确的维度和数据类型初始化 cv2.KalmanFilter

python 复制代码
        self.kf = cv2.KalmanFilter(dynamParams=7, measureParams=4, type=cv2.CV_64F)

(4) 我们设置转移矩阵和相应的过程噪声协方差矩阵。该协方差矩阵是一个简单模型,涉及每个物体在水平和垂直方向上以当前恒定速度运动,并以恒定速率变大或变小:

python 复制代码
        self.kf.transitionMatrix = np.array(
            [[1, 0, 0, 0, 1, 0, 0],
             [0, 1, 0, 0, 0, 1, 0],
             [0, 0, 1, 0, 0, 0, 1],
             [0, 0, 0, 1, 0, 0, 0],
             [0, 0, 0, 0, 1, 0, 0],
             [0, 0, 0, 0, 0, 1, 0],
             [0, 0, 0, 0, 0, 0, 1]], dtype=np.float64)

(5) 我们还设置了我们对恒定速度过程的确信程度。我们选择一个对角协方差矩阵;即我们的状态变量是不相关的,我们将位置变量的方差设为 10,将速度变量的方差设为 10000。我们认为位置变化比速度变化更可预测:

python 复制代码
        self.kf.processNoiseCov = np.diag([10, 10, 10, 10, 1e4, 1e4, 1e4]).astype(np.float64)

(6) 然后,我们将观测模型设置为以下矩阵,这意味着我们只测量状态中的前四个变量,即所有位置变量:

python 复制代码
        self.kf.measurementMatrix = np.array(
            [[1, 0, 0, 0, 0, 0, 0],
             [0, 1, 0, 0, 0, 0, 0],
             [0, 0, 1, 0, 0, 0, 0],
             [0, 0, 0, 1, 0, 0, 0]], dtype=np.float64)

(7) 现在我们已经设置了观测噪声协方差,我们认为水平位置和垂直位置的测量噪声大于长宽比和缩放的测量噪声,因此我们对这两个测量方差赋予较小的值:

python 复制代码
        self.kf.measurementNoiseCov = np.diag([1, 1, 10, 10]).astype(np.float64)

(8) 最后,我们设置卡尔曼滤波器的初始位置和不确定性:

python 复制代码
        self.kf.statePost = np.vstack((convert_bbox_to_z(bbox), [[0], [0], [0]]))
        self.kf.errorCovPost = np.diag([1, 1, 1, 1, 1e-2, 1e-2, 1e-4]).astype(np.float64)

完成卡尔曼滤波器的设置后,我们需要能够实际预测物体移动时的新位置。我们将通过定义另外两个方法来实现:updatepredictupdate 方法将基于新的观测值更新卡尔曼滤波器,而 predict 方法将基于先前的证据预测新位置。现在让我们看看 update 方法:

python 复制代码
    def update(self, bbox):
        self.time_since_update = 0
        self.hit_streak += 1

        self.kf.correct(convert_bbox_to_z(bbox))

可以看到,update 方法接收新位置的边界框 bbox,将其转换为观测值,并调用 OpenCV 实现上的正确方法。我们只添加了一些变量来记录我们最后一次更新被跟踪物体之后过去了多长时间。

现在让我们看看 predict 函数;其步骤说明如下:

(1) 首先检查我们是否连续两次调用了 predict;如果是,则将 self.hit_streak 设置为 0

python 复制代码
    def predict(self):
        if self.time_since_update > 0:
            self.hit_streak = 0

(2) 然后它将 self.time_since_update 增加 1,这样我们就能记录跟踪这个物体已经有多长时间了:

python 复制代码
        self.time_since_update += 1

(3) 然后我们调用 OpenCV 实现的 predict 方法,并返回与预测结果相对应的边界框:

python 复制代码
        return state_to_bbox(self.kf.predict())

至此,我们已经实现了一个单物体跟踪器,下一步是创建一个能够将检测框与跟踪器关联起来的机制,我们将在下一小节中完成这项工作。

3.3 将检测结果与跟踪器关联

Sort 算法中,判断两个边界框是否应被视为同一个物体的决策是基于交并比 (Intersection over Union, IoU) 做出的。在本节前面,我们已经了解了这个指标并实现了计算它的函数。在这里,我们将定义一个函数,根据检测框和跟踪框的 IoU 值来关联它们:

python 复制代码
def associate_detections_to_trackers(detections: np.ndarray, trackers: np.ndarray,
                                     iou_threshold: float = 0.3) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:

该函数接收检测结果的边界框和跟踪器的预测框,以及一个 IoU 阈值。它返回匹配结果(在对应数组中相应索引对的数组)、未匹配的检测框的索引以及未匹配的跟踪框的索引。为了实现这一点,它需要以下步骤:

(1) 首先,初始化一个矩阵,用于存储每一对可能的边界框的 IoU 值:

python 复制代码
    iou_matrix = np.zeros((len(detections), len(trackers)), dtype=np.float64)

(2) 然后,我们遍历检测框和跟踪框,计算每一对的 IoU,并将结果值存储在矩阵中:

python 复制代码
    for d, det in enumerate(detections):
        for t, trk in enumerate(trackers):
            iou_matrix[d, t] = iou(det, trk)

(3) 使用 iou_matrix,我们将找到匹配对,使得这些匹配对的 IoU 值之和尽可能大:

python 复制代码
    row_ind, col_ind = linear_sum_assignment(-iou_matrix)

为此,我们使用了匈牙利算法,该算法以 linear_sum_assignment 函数的形式实现。它是一种解决分配问题的组合优化算法。

为了使用该算法,我们传递了 iou_matrix 的相反值。该算法关联索引以使总和最小。因此,当我们取矩阵的负数时,就能找到最大值。找到这些关联的直接方法是遍历所有可能的组合,并选择具有最大值的那个。后一种方法的问题在于其时间复杂度是指数级的,因此一旦我们有多个检测和跟踪器,它就会变得太慢。而匈牙利算法的时间复杂度为 O ( n 3 ) O(n³) O(n3)。

(4) 然后,我们将算法的结果格式更改为一对匹配索引的 numpy 数组:

python 复制代码
    matched_indices = np.transpose(np.array([row_ind, col_ind]))

(5) 然后,从 iou_matrix 中获取匹配项的 IoU 值:

python 复制代码
    iou_values = np.array([iou_matrix[detection, tracker]
                           for detection, tracker in matched_indices])

(6) 过滤掉 IoU 值过低的匹配项:

python 复制代码
    good_matches = matched_indices[iou_values > 0.3]

(7) 然后,找到未匹配的检测框的索引:

python 复制代码
    unmatched_detections = np.array(
        [i for i in range(len(detections)) if i not in good_matches[:, 0]])

(8) 之后,找到未匹配的跟踪框的索引:

python 复制代码
    unmatched_trackers = np.array(
        [i for i in range(len(trackers)) if i not in good_matches[:, 1]])

(9) 最后,返回匹配结果以及未匹配的检测框和跟踪框的索引:

python 复制代码
    return good_matches, unmatched_detections, unmatched_trackers

现在,我们已经有了跟踪单个物体以及将检测结果与单物体跟踪器关联的机制,剩下要做的就是创建一个类,使用这些机制在帧间跟踪多个物体。我们将在下一小节中完成这项工作,届时该算法将变得完整。

3.4 定义跟踪器的主类

该类的构造函数如下所示:

python 复制代码
class Sort:
    def __init__(self, max_age=10, min_hits=6):
        self.max_age = max_age
        self.min_hits = min_hits
        self.trackers = []
        self.count = 0

它存储两个参数:

  • 第一个参数是 max_age,它指定在某个物体的跟踪器没有关联的检测框多少连续次数后,我们认为该物体已从场景中离开并删除该跟踪器
  • 第二个参数是 min_hits,它指定跟踪器需要连续与检测框关联多少次,我们才将其视为一个确定的物体。它还为存储跟踪器和在实例生命周期内计数跟踪器总数创建属性

我们还定义了一个为跟踪器创建 ID 的方法:

python 复制代码
    def next_id(self):
        self.count += 1
        return self.count

该方法将跟踪器的计数增加 1,并返回该数字作为 ID

现在,我们准备定义 update 方法:

python 复制代码
    def update(self, dets):

update 方法接收检测框,并涵盖以下步骤:

(1) 对于所有可用的跟踪器,预测它们的新位置,并立即移除预测失败的跟踪器:

python 复制代码
        self.trackers = [
            tracker for tracker in self.trackers if not np.any(
                np.isnan(
                    tracker.predict()))]

(2) 然后,获取跟踪器的预测框:

python 复制代码
        trks = np.array([tracker.current_state for tracker in self.trackers])

(3) 接着,将跟踪器预测的框与检测框进行关联:

python 复制代码
        matched, unmatched_dets, unmatched_trks = associate_detections_to_trackers(
            dets, trks)

(5) 然后,使用关联的检测结果更新匹配的跟踪器:

python 复制代码
        for detection_num, tracker_num in matched:
            self.trackers[tracker_num].update(dets[detection_num])

(6) 对于所有未匹配的检测结果,创建用相应边界框初始化的新跟踪器:

python 复制代码
        for i in unmatched_dets:
            self.trackers.append(KalmanBoxTracker(dets[i, :], self.next_id()))

(7) 然后,将返回值构成为相关跟踪器的跟踪框和跟踪器 ID 拼接的数组:

python 复制代码
        ret = np.array([np.concatenate((trk.current_state, [trk.id + 1]))
                        for trk in self.trackers
                        if trk.time_since_update < 1 and trk.hit_streak >= self.min_hits])

在上述代码片段中,我们只考虑那些在当前帧中已用检测框更新,并且与检测框至少连续关联 hit_streak 次的跟踪器。根据算法的特定应用,我们可以更改此行为以更好地满足实际需求。

(8) 然后,通过移除那些一段时间未用新边界框更新的跟踪器来清理它们:

python 复制代码
        self.trackers = [
            tracker for tracker in self.trackers if tracker.time_since_update <= self.max_age]

(9) 最后,返回结果:

python 复制代码
        return ret

现在,我们已经完成了算法的实现,一切就绪,可以运行应用程序并观察其实际运行了。

4. 观察应用程序的实际运行

一旦我们运行应用程序,它将使用传递的视频或其他视频流,然后进行处理并展示结果:

在每个处理过的帧上,它将显示物体类型、一个边界框以及每个被跟踪物体的编号。它还会在帧的左上角显示关于跟踪的概括信息。这些概括信息包括每种被跟踪物体类型在整个视频中跟踪到的物体总数,以及当前场景中可用的被跟踪物体。

小结

在本节中,我们使用了一个目标检测网络,并将其与跟踪器结合,以随时间跟踪和计数物体。阅读完本节后,应该理解检测网络的工作原理以及它们的训练机制。

我们已经学习了如何将用其他框架构建的模型导入到 OpenCV 中,并将它们绑定到处理视频或使用其他视频流(如摄像头或远程 IP 摄像头)的应用程序中。实现了一个简单但鲁棒的跟踪算法,该算法与鲁棒的检测器网络相结合,可以回答与视频数据相关的多个统计问题。

现在,我们可以使用和训练你选择的目标检测网络,以便创建高精度应用程序,围绕目标检测和跟踪实现其功能。

系列链接

OpenCV-Python实战(1)------OpenCV简介与图像处理基础

OpenCV-Python实战(2)------图像与视频文件的处理

OpenCV-Python实战(3)------OpenCV中绘制图形与文本

OpenCV-Python实战(4)------OpenCV常见图像处理技术

OpenCV-Python实战(5)------OpenCV图像运算

OpenCV-Python实战(6)------OpenCV中的色彩空间和色彩映射

OpenCV-Python实战(7)------直方图详解

OpenCV-Python实战(8)------直方图均衡化

OpenCV-Python实战(9)------OpenCV用于图像分割的阈值技术

OpenCV-Python实战(10)------OpenCV轮廓检测

OpenCV-Python实战(11)------OpenCV轮廓检测相关应用

OpenCV-Python实战(12)------一文详解AR增强现实

OpenCV-Python实战(13)------OpenCV与机器学习的碰撞

OpenCV-Python实战(14)------人脸检测详解

OpenCV-Python实战(15)------面部特征点检测详解

OpenCV-Python实战(16)------人脸追踪详解

OpenCV-Python实战(17)------人脸识别详解

OpenCV-Python实战(18)------深度学习简介与入门示例

OpenCV-Python实战(19)------OpenCV与深度学习的碰撞

OpenCV-Python实战(20)------OpenCV计算机视觉项目在Web端的部署

OpenCV-Python实战(21)------OpenCV人脸检测项目在Web端的部署

OpenCV-Python实战(22)------使用Keras和Flask在Web端部署图像识别应用

OpenCV-Python实战(23)------将OpenCV计算机视觉项目部署到云端

OpenCV-Python实战(24)------打造实时图像滤镜系统

OpenCV-Python实战(25)------基于深度传感器与凸性分析打造实时手势识别系统

OpenCV-Python实战(26)------复杂场景下的实时物体检测与跟踪

OpenCV-Python实战(27)------基于对极几何的3D场景重建

OpenCV-Python实战(28)------OpenCV计算摄影从HDR图像融合到全景拼接

OpenCV-Python实战(29)------基于视觉显著性的自动多目标跟踪系统

OpenCV-Python实战(30)------基于支持向量机的交通标志识别模型

OpenCV-Python实战(31)------实时面部情绪检测与识别系统

OpenCV-Python实战(32)------基于深度学习的物体分类与定位

相关推荐
bill32822780433 小时前
从 0 到 1 掌握 1688API:商品详情获取技巧
python
hboot10 小时前
AI工程师第五课 - 大语言模型基础
python·llm·fastapi
AOwhisky10 小时前
Python 学习笔记(第一期与第二期)——基础语法——核心知识点自测与详解
开发语言·笔记·python·学习·云原生·运维开发
STLearner11 小时前
ICML 2026 | LLM×Graph论文总结[1]【图基础模型,文本属性图,多模态属性图,图对齐,图提示学习,关系深度学习
论文阅读·人工智能·python·深度学习·学习·机器学习·数据挖掘
习明然13 小时前
我的本地化AI项目(三)
人工智能·python·electron·c#·avalonia
喜欢的名字被抢了13 小时前
Python实战:SQLAlchemy ORM与FastAPI项目集成
开发语言·python·sql·教程·fastapi
夏季疯15 小时前
读论文:STARS 是什么结构?一个统一的歌声自动标注框架
python
光测实验室15 小时前
3种Python降噪算法实测:我把处理速度提升了20倍
python
AOwhisky15 小时前
Python 学习笔记(第三期)——流程控制核心知识点自测与详解
开发语言·笔记·python·学习·云原生·运维开发·流程控制