00 - 前言

欢迎学习《昇腾行业应用案例》的“基于AI图像处理的疲劳驾驶检测”实验。在本实验中,您将学习如何使用利用CV(Computer Vision)领域的AI模型来构建一个端到端的疲劳驾驶检测系统,并使用开源数据集进行效果验证。为此,我们将使用昇腾的AI硬件以及CANN等软件产品。

学习目标
在本课程中,您将学习一些与使用AI图像处理技术实现疲劳驾驶检测有关的重要概念,包括:

  • 视频数据的处理方法
  • 采用计算机视觉检测人脸的方法
  • 采用计算机视觉检测人脸特征点的方法
  • 疲劳检测算法
  • 端到端深度学习工作流

目录
本实验分为四个部分。在第一部分中,我们将介绍本案例的场景,让您了解其相关的背景。在第二部分中,我们将介绍本案例的端到端解决方案。接下来,在第三部分中,我们将指导您完成解决方案对应的代码实现。在最后一部分,我们给出了一些测试题,来帮助您更好地理解本课程。

  1. 场景介绍
  2. 解决方案
  3. 代码实战
  4. 课后测试

JupyterLab

在本实操实验中,我们将使用 JupyterLab 管理我们的环境。JupyterLab 界面是一个控制面板,可供您访问交互式 iPython Notebook、所用环境的文件夹结构,以及用于进入 Ubuntu 操作系统的终端窗口,只需要点击菜单栏的小三角就可以运行代码。

尝试执行下方单元中的一条简单的 print(打印)语句。

# DO NOT CHANGE THIS CELL
# activate this cell by selecting it with the mouse or arrow keys then use the keyboard shortcut [Shift+Enter] to execute
print('This is just a simple print statement')

01 场景介绍

在21世纪,汽车已经成为人类生活中不可或缺的工具,它给人们带来便利的同时,也会产生交通事故,交通事故通常是由驾驶员的疲劳驾驶和不规范驾驶造成的。疲劳驾驶是指驾驶员在长时间连续行车后,由于缺乏必要的休息,导致在心理技能和生理机制上发生变化,客观上表现为驾驶技能的下降。这种状态的驾驶员往往会出现打瞌睡、反应迟钝、注意力不集中等现象,极大地增加了交通事故的风险。根据世界卫生组织(WHO)的统计,全球每年约有124万人死于交通事故,而疲劳驾驶是导致这些事故的主要原因之一。在美国,国家公路交通安全管理局(NHTSA)报告指出,疲劳驾驶每年导致超过100,000起交通事故,其中约1,550人死亡。在欧洲,疲劳驾驶导致的事故占所有交通事故的20%至30%。这些触目惊心的数字凸显了疲劳驾驶检测技术的紧迫性和重要性。

随着深度学习技术,尤其是图像处理技术的高速发展,汽车厂商可以借助AI技术,来实现驾驶员状态的实时监测,及时识别风险。这些技术通过分析驾驶员的面部表情和眼睛状态,如眨眼频率、眼皮下垂和打哈欠等行为,来判断驾驶员是否疲劳。AI模型的高准确率和高性能可以使得检测过程有很高的实时性,一旦发现驾驶员疲劳驾驶,就可以立即触发警报,提醒驾驶员休息或者主动降速,避免交通事故的发生。
很好!接下来我们一起来学习一下实现疲劳检测的通用解决方案。

02 解决方案

疲劳检测已经成为各车企“智驾系统”中的必备功能。典型的疲劳驾驶检测方案流程如下:

fatigue_detection_algorithm.png

主要包含以下模块:
1) 图像处理模块:该模型主要用来处理实时视频流数据,把司机正脸的视频数据分帧,发送给CV模型处理。
2) 人脸检测模型:该模型采用的是CV领域的目标检测模型,比如本课程中的Yolo模型,他的功能是将图片中的人脸位置识别出来,返回图片左上角和右下角的坐标,并且把人脸部分抠出来传给下一模块进行处理。
3) 人脸特征点检测模型:该模型的功能是把人脸的关键轮廓识别出来,包括眉毛、眼睛、鼻子、嘴巴以及脸的轮廓位置。有了这些五官的位置和轮廓,我们就可以通过计算眼睛的开闭、嘴巴的开闭以及头部的仰角来判断司机的表情状态。
4) 疲劳检测算法:该模块的输入是第3个模块的输出,该算法根据人脸特征判读司机是否处于疲劳驾驶状态。

2.1 图像处理模块

在汽车驾驶过程中,车内的摄像头会录制司机正脸的视频。图像处理模块会持续获取视频数据,然后使用开源三方件 opencv-python(cv2) 加载视频,把视频分成多帧图片。cv2 提供了 cv2.VideoCapture(path) 接口加载视频并逐帧读取图片。

2.2 人脸检测模型

人脸检测算法包含传统机器学习方法和AI视觉方法:

  1. 传统机器学习方法
    早期的人脸检测算法主要依赖于手工设计的特征和传统机器学习技术,如基于几何特征的方法、整体方法、基于特征的方法和混合方法。这些方法通常涉及到特征脸方法(EigenFace)、线性判别分析(LDA)等技术,它们通过统计人脸图像的主要特征向量来构建特征空间,实现人脸检测。传统方法在面对光照变化、表情变化、遮挡物等复杂环境时,泛化能力不足,且对训练集和测试集场景严重依赖。
  2. AI视觉方法(深度学习)
    随着深度学习技术的发展,基于深度学习的人脸检测算法逐渐成为主流,尤其是在大规模数据集上训练的深度神经网络。深度学习算法通过训练自动学习人脸特征,提升了识别的准确性和效率。例如,卷积神经网络(CNN)已被广泛应用于人脸识别,包括目标检测和识别、分割、光学字符识别、面部表情分析等多个计算机视觉任务。一些流行的深度学习架构,如Haar Cascade算法、MTCNN(Multi-Task Cascaded Convolutional Networks)和YOLO(You Only Look Once)算法,因其高精度和鲁棒性而被广泛应用于人脸检测。深度学习模型能够处理复杂场景下的人脸检测问题,如大角度、遮挡、低光照等,并且通过多模态融合技术、三维人脸识别技术等新兴技术进一步提升了识别的准确性和安全性。

总的来说,人脸检测技术已经从依赖传统机器学习的手工特征方法,转变为主要依靠深度学习的自动化特征学习方法,这些方法在处理复杂环境下的人脸检测任务时展现出更高的准确性和鲁棒性。

Yolov8-face模型

本课程采用了 Yolov8-face 模型来检测人脸。

yolov8-face.jpg

YOLOv8-face模型是Ultralytics基于YOLOv8算法优化的人脸识别模型,它专注于人脸检测和关键点定位任务。YOLOv8-face模型继承了YOLO系列的高效性和准确性,专为人脸检测任务设计,它能够在实时视频流中快速检测人脸。YOLOv8-face模型的代码结构可以分为以下几个部分:

  1. Backbone(主干网络)
    模型的Backbone部分负责提取图像特征,通常由一系列卷积层和残差连接构成。在YOLOv8-face中,Backbone从输入图像中提取多尺度的特征图,为后续的人脸检测和关键点定位提供基础。
  2. Neck(颈部网络)
    Neck部分,也称为特征金字塔网络(FPN),负责融合不同尺度的特征图。YOLOv8-face通过FPN结构实现特征图的多尺度融合,增强模型对不同大小人脸的检测能力。
  3. Head(头部网络)
    Head部分负责最终的检测任务,包括分类和边界框回归。YOLOv8-face的Head部分接收来自Neck的特征图,输出最终的人脸检测结果和关键点定位信息。

Yolov8-face模型具有以下特点:

  • 高准确性:YOLOv8-face采用了一系列的优化策略,包括网络结构的设计、数据增强和训练技巧等,从而提高了模型的准确性。
  • 实时性能:YOLOv8-face具有较高的实时性能,可以在实时图像和视频流中快速检测人脸。
  • 多尺度检测:为了适应不同大小和尺度的人脸,YOLOv8-face使用了多尺度检测技术。
  • 数据增强:YOLOv8-face使用了各种数据增强技术,如随机裁剪、旋转和缩放等,以增加训练数据的多样性和丰富性。

2.3 人脸特征点检测模型

在深度学习之前,人脸特征点检测主要依赖于传统机器学习方法,如主动形状模型(ASM)、主动外观模型(AAM)和级联形状回归(CPR)。这些方法通过手工特征和经典机器学习算法实现,但对光照、表情和姿态变化敏感,泛化能力有限。

PFLD模型

近年来,深度学习方法在人脸特征点检测领域取得了显著进展。PFLD(A Practical Facial Landmark Detector)模型是一个代表性的AI模型,它是一个精度高、速度快、模型小的检测器,特别适合实际应用。PFLD模型由主干网络和辅助网络组成,主干网络采用轻量化的MobileNet结构,辅助网络在训练时预测人脸姿态,提高定位精度。PFLD在多个挑战性基准测试中表现出色,包括300W数据集,模型大小仅为2.1Mb。

PFLD.jpg

本实验采用的是 PFLD-106 模型,该模型对于每张人脸图片,都会输出一个形状为 (2, 106) 的数组,代表106个特征点的 (x, y) 坐标。具体的特征点和五官对应关系为:脸的轮廓:0-32,左眼:33-42,左眉:43-51,嘴巴:52-71,鼻子:72-88,右眼:88-96,右眉:97-105。

2.4 疲劳检测算法

获取到人脸五官的轮廓之后,我们可以计算眼睛的开闭程度、嘴巴的开闭程度以及头部俯仰角的变化来判断司机是否处于疲劳状态。参考目前已有的开源项目,疲劳检测算法流程如下:

    1. 获取司机在正常状态下的眼睛高宽比(Eyes aspect ratio)、嘴巴高宽比(Mouth aspect ratio)、头部仰角(Pitch);
    1. 分析每秒种内的每帧图像,计算每张图片的 EAR , MARPitch,如果这些值和正常状态下的指标差异超过设定的阈值,则认为该张图片的状态是“疲劳驾驶”状态。如果判定为“疲劳驾驶”状态的图片比较超过设定的阈值,则发出“疲劳驾驶”告警。
      很好!到这里,相信您已经对实现疲劳检测的解决方法有了一个整体的理解,接下来我们一起通过实验来实现这个端到端的过程吧!

03 动手实验

3.1 实验准备

数据集准备

本实验使用的视频数据来源于启智社区。课程目录下的 test_video/driver_video.mp4 即为本次实验使用的视频数据。

模型权重

本实验使用了Yolo8n-face进行人脸区域检测,权重来源于github社区,也可以在gitcode下载,下载后的文件名为 yolov8n-face.pt

本实验采用了PFLD-106模型采集人脸的106个特征点,权重来源于gitee社区,下载后的文件名为 pfld_106.onnx

很好,接下来让我们正式进入代码操作的步骤!

3.2 使用 opencv-python 处理样本视频

我们从测试视频数据 test_video/driver_video.mp4 出发,为了把视频数据转成图片,我们需要构建一个数据处理的python类,然后定义一个迭代函数把视频各帧图片读取出来。
首先,我们需要 import 涉及的三方库:

import glob
import os
from pathlib import Path
import cv2
import numpy as np

然后定义一个列表常亮,用来判断数据格式是否是目标格式:

vid_formats = ['.mov', '.avi', '.mp4', '.mpg', '.mpeg', '.m4v', '.wmv', '.mkv']

再定义这个数据处理类 LoadImages ,这个类主要包含3个函数:__init__() 函数负责初始化,返回一个可迭代的数据类;__next__() 函数是迭代器,用来不断地返回视频帧;new_video() 用来读取新的视频。

class LoadImages:
    def __init__(self, path):
        # 判断传入的 path 是否合理或者非空
        p = str(Path(path))  # os-agnostic
        p = os.path.abspath(p)  # absolute path
        if '*' in p:
            files = sorted(glob.glob(p))  # glob
        elif os.path.isdir(p):
            files = sorted(glob.glob(os.path.join(p, '*.*')))  # dir
        elif os.path.isfile(p):
            files = [p]  # files
        else:
            raise Exception('ERROR: %s does not exist' % p)

        # 获取目录下的每个视频
        videos = [x for x in files if os.path.splitext(x)[-1].lower() in vid_formats]
        self.files = videos
        self.nf = len(videos)  # number of files
        # 确保至少有一个视频
        assert self.nf > 0, 'No videos found in %s. Supported formats is:\nvideos: %s' % \
                            (p, vid_format)

        # 读取第一个视频
        if any(videos):
            self.new_video(videos[0])  # new video
        else:
            self.cap = None
    # 给 self.cont 赋初值
    def __iter__(self):
        self.count = 0
        return self
        
    # 迭代器,和for循环配合使用
    def __next__(self):
        # 处理完了所有视频
        if self.count == self.nf:
            raise StopIteration
        path = self.files[self.count]

        # 读取一帧图片
        ret_val, img0 = self.cap.read()
        # 上一个视频的帧读完了开始读取下一个视频
        if not ret_val:
            self.count += 1
            self.cap.release()
            if self.count == self.nf:  # last video
                raise StopIteration
            else:
                path = self.files[self.count]
                # 读取新的视频
                self.new_video(path)
                # 读取一帧图片
                ret_val, img0 = self.cap.read()

        # 记录当前读取的帧数
        self.frame += 1
        print('video %g/%g (%g/%g): ' % (self.count + 1, self.nf, self.frame, self.nframes), end='')

        # 拷贝一份,用于保存成在新的视频数据里面,不拷贝会有报错提示
        img = img0.copy()

        # 格式转换,和人脸识别模型的输入对齐
        img = img[:, :, ::-1].transpose(2, 0, 1)  # BGR to RGB, to 3x416x416
        # 使图片的内存连续分布,提升处理速度
        img = np.ascontiguousarray(img)
        return path, img, img0, self.cap

    # 读取新的视频数据
    def new_video(self, path):
        self.frame = 0
        # 获取视频帧
        self.cap = cv2.VideoCapture(path)
        self.nframes = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))

好的,接下来就让我们来测试一下这个类吧。为此,我们加载测试视频,观察获取的数据:
source = "./test_video/"
dataset = LoadImages(source)
for path, img, im0s, vid_cap in dataset:

print(img.shape)
print(im0s.shape)
cv2.imwrite("./read_pic.png", im0s)
break

如果上面的运行结果有下面的打印,且可以在当前目录下看到图像正常的read_pic.png,说明运行成功!

# video 1/2 (1/102): (3, 320, 640)
# (320, 640, 3)

很棒!通过这一小节,我们已经知道了如何把视频数据的多帧图片读取出来,接下来我们看看如何检测出图片中的人脸区域。

3.3 使用Yolo模型完成人脸检测

根据 2.2 章节的知识,我们知道我们可以使用 Yolo 模型来检测图片中的人脸,因此,我们使用 3.1 章节中提到的权重文件 yolov8n-face.pt 构建一个模型用于获取人脸区域:
首先,我们需要导入三方库 ultralyticsYOLO 类,这个类提供了 yolo 模型推理的完好封装,会直接返回人脸位置信息,我们来测试一下(基于CPU硬件):
from ultralytics import YOLO
face_detect_model_path = './yolov8n-face.pt'
face_detect_model = YOLO(face_detect_model_path, task='detect')
我们在开源数据集中选择一张司机正常状态的照片 ./test_input_images/day_man_001_00_1_127.png 进行测试:
可以看到,输出的 face_coordinates shape为 (2, 4),代表检测到了2个人脸,每个人脸有一个 (x1, y1, x2, y2) 坐标,代表人脸的左上角和右下角坐标。我们现在根据返回的坐标把人脸框出来,然后保存成新的图片:

img = cv2.imread("./test_input_images/day_man_001_00_1_127.png")
for coordinate in face_coordinates:
    x1, y1, x2, y2 = coordinate.astype(int)  # 获取坐标
    cv2.rectangle(img, (x1, y1), (x2, y2), (50, 50, 250), 3)  # 在原图上画矩形框
    detected_image_name = "./test_input_images/day_man_001_00_1_127_face.png"
    cv2.imwrite(detected_image_name, img)  # 保存新图片

完成运行后,我们可以看到 test_input_images 目录中生成了一张新图片 day_man_001_00_1_127_face.png ,点击查看,可以看到人脸被框出来了:

day_man_001_00_1_127_face.png

很棒!到这里,我们已经完成了人脸检测部分,是不是已经有成就感了呢?休息片刻,我们继续完成人脸特征点的检测。

3.4 使用PFLD模型检测人脸特征轮廓

想一想,人在疲劳的状态下,是不是会眯着眼睛、打哈欠(眯眼、张嘴、抬头)呢?那么为了判断司机是否有这些动作,我们需要描绘出眼睛、嘴巴、脸型的轮廓,根据这些特征去判断司机是否处于疲劳驾驶状态。
根据 2.3 章节的介绍,我们知道我们可以使用 PFLD 模型来检测人脸关键点。由于 3.1 章节下载的 PFLD 权重是 onnx 格式的,所以我们可以直接使用 onnxruntime 来导入 onnx 模型(基于CPU硬件):

import onnxruntime as ort
points_detect_model_path = "./pfld_106.onnx"
pfld_model = ort.InferenceSession(points_detect_model_path)

我们把 3.3 章节中的司机人脸区域截出来,作为 PFLD 模型的输入:

# 由于司机的位置靠右,我们如下获取司机人脸的坐标
dirver_index = np.argmax(face_coordinates[:, 2])  # max x2
x1, y1, x2, y2 = face_coordinates[dirver_index]
 
cut_image = img[int(y1):int(y2), int(x1):int(x2), :]
original_shape = cut_image.shape  # 保存图片的时候用到

然后调整截取出来的图像,满足 PFLD 模型的输入:

# 调整图片大小为 112x112,满足PFLD模型的输入shape要求
cut_image = cv2.resize(cut_image, (112, 112))
# 归一化
cut_image = cut_image / 255.0
# 转换为模型输入需要的维度 NCHW
input_data = np.transpose(cut_image, (2, 0, 1)).astype(np.float32)
input_data = np.expand_dims(input_data, axis=0)  # 添加批次维度

再获取 PFLD 模型的输入输出命名:

# 获取输入和输出名称
input_name = pfld_model.get_inputs()[0].name
output_name = pfld_model.get_outputs()[1].name

执行推理,获取人脸关键点坐标:

# 推理
results = pfld_model.run([output_name], {input_name: input_data})
# 需要转换为坐标
keypoints = results[0].reshape(-1, 2) * 112  # 取出关键点
keypoints = keypoints.astype(int)  # 转换为整数坐标

把特征点标记画到人脸图片上,保存为新图片:

cut_image_new= cut_image * 255.0  # 恢复像素
font = cv2.FONT_HERSHEY_SIMPLEX  # 标记字体
font_scale = 0.4  # 标记字号
color = (0, 255, 0)  # 绿色
thickness = 1  # 标记字体粗细
expand_scale = 5  # 把图片放大5倍,防止特征点的面积占据人脸的比例过大
cut_image_new = cv2.resize(cut_image_new, (112*expand_scale, 112*expand_scale))

for i, point in enumerate(keypoints):
    point *= expand_scale
    cv2.circle(cut_image_new, tuple(point), 1, color, -1)
    cv2.putText(cut_image_new, str(i), point, font, font_scale, color, thickness, cv2.LINE_AA)
cut_image_new = cv2.resize(cut_image_new, (original_shape[1], original_shape[0]))
point_detect_image_name =  "./test_input_images/day_man_001_00_1_127_face_points.png"
cv2.imwrite(point_detect_image_name, cut_image_new)

结果如下图所示:

points_new.PNG

通过上面的结果,可以看到:
1,特征点把人脸的五官轮廓描绘出来了,位置的偏差主要是keypoints取整、放大后引入的,不影响轮廓的相对位置;
2,特征点的序号和五官的对应关系 2.3 章节中提到的一致,即:脸的轮廓:0-32,左眼:33-42,左眉:43-51,嘴巴:52-71,鼻子:72-88,右眼:88-96,右眉:97-105。
好的,有了眼睛、嘴巴、脸型的轮廓位置,我们接下来就可以计算一些特征来判断司机是否疲劳驾驶了!

3.4.1 使用昇腾卡进行推理

在上面的内容中,我们是在CPU上加载了 onnx 模型进行推理,为了加速,我们可以使用昇腾提供的 atc 工具,把 onnx 模型转成 om 格式的模型,然后使用 acl 接口构建模型在昇腾卡上推理。
步骤一:把onnx模型转成om模型

参考atc的使用文档,我们可以在命令行如下操作:

# atc --model=pfld_106.onnx --framework=5 --output=pfld_106 --input_shape="input_1:1,3,112,112"  --soc_version=Ascend910B3

步骤二:使用acl接口构建om推理模型

参考acl模型构建 ,我们可以如下构建用于推理的类:

import acl

ACL_MEM_MALLOC_HUGE_FIRST = 0
ACL_MEMCPY_HOST_TO_DEVICE = 1
ACL_MEMCPY_DEVICE_TO_HOST = 2

class OmModel:
    def __init__(self, model_path):
     # 初始化函数
     self.device_id = 5
    
     # step1: 初始化
     ret = acl.init()
     # 指定运算的Device
     ret = acl.rt.set_device(self.device_id)
    
     # step2: 加载模型,本示例为pfld模型
     # 加载离线模型文件,返回标识模型的ID
     self.model_id, ret = acl.mdl.load_from_file(model_path)
     # 创建空白模型描述信息,获取模型描述信息的指针地址
     self.model_desc = acl.mdl.create_desc()
     # 通过模型的ID,将模型的描述信息填充到model_desc
     ret = acl.mdl.get_desc(self.model_desc, self.model_id)
    
     # step3:创建输入输出数据集
     # 创建输入数据集
     self.input_dataset, self.input_data = self.prepare_dataset('input')
     # 创建输出数据集
     self.output_dataset, self.output_data = self.prepare_dataset('output')

    def prepare_dataset(self, io_type):
     # 准备数据集
     if io_type == "input":
         # 获得模型输入的个数
         io_num = acl.mdl.get_num_inputs(self.model_desc)
         acl_mdl_get_size_by_index = acl.mdl.get_input_size_by_index
     else:
         # 获得模型输出的个数
         io_num = acl.mdl.get_num_outputs(self.model_desc)
         acl_mdl_get_size_by_index = acl.mdl.get_output_size_by_index
     # 创建aclmdlDataset类型的数据,描述模型推理的输入。
     dataset = acl.mdl.create_dataset()
     datas = []
     for i in range(io_num):
         # 获取所需的buffer内存大小
         buffer_size = acl_mdl_get_size_by_index(self.model_desc, i)
         # 申请buffer内存
         buffer, ret = acl.rt.malloc(buffer_size, ACL_MEM_MALLOC_HUGE_FIRST)
         # 从内存创建buffer数据
         data_buffer = acl.create_data_buffer(buffer, buffer_size)
         # 将buffer数据添加到数据集
         _, ret = acl.mdl.add_dataset_buffer(dataset, data_buffer)
         datas.append({"buffer": buffer, "data": data_buffer, "size": buffer_size})
     return dataset, datas

    def forward(self, inputs):
     # 执行推理任务
     # 遍历所有输入,拷贝到对应的buffer内存中
     input_num = len(inputs)
     for i in range(input_num):
         bytes_data = inputs[i].tobytes()
         bytes_ptr = acl.util.bytes_to_ptr(bytes_data)
         # 将图片数据从Host传输到Device。
         ret = acl.rt.memcpy(self.input_data[i]["buffer"],   # 目标地址 device
                             self.input_data[i]["size"],     # 目标地址大小
                             bytes_ptr,                      # 源地址 host
                             len(bytes_data),                # 源地址大小
                             ACL_MEMCPY_HOST_TO_DEVICE)      # 模式:从host到device
     # 执行模型推理。
     ret = acl.mdl.execute(self.model_id, self.input_dataset, self.output_dataset)
     # 处理模型推理的输出数据,输出top5置信度的类别编号。
     inference_result = []
     for i, item in enumerate(self.output_data):
         buffer_host, ret = acl.rt.malloc_host(self.output_data[i]["size"])
         # 将推理输出数据从Device传输到Host。
         ret = acl.rt.memcpy(buffer_host,                    # 目标地址 host
                             self.output_data[i]["size"],    # 目标地址大小
                             self.output_data[i]["buffer"],  # 源地址 device
                             self.output_data[i]["size"],    # 源地址大小
                             ACL_MEMCPY_DEVICE_TO_HOST)      # 模式:从device到host
         # 从内存地址获取bytes对象
         bytes_out = acl.util.ptr_to_bytes(buffer_host, self.output_data[i]["size"])
         # 按照float32格式将数据转为numpy数组
         data = np.frombuffer(bytes_out, dtype=np.float32)
         inference_result.append(data)
     return inference_result[1]  # 取第二个输出,也就是坐标点

    def __del__(self):
     # 析构函数 按照初始化资源的相反顺序释放资源。
     # 销毁输入输出数据集
     for dataset in [self.input_data, self.output_data]:
         while dataset:
             item = dataset.pop()
             ret = acl.destroy_data_buffer(item["data"])    # 销毁buffer数据
             ret = acl.rt.free(item["buffer"])              # 释放buffer内存
     ret = acl.mdl.destroy_dataset(self.input_dataset)      # 销毁输入数据集
     ret = acl.mdl.destroy_dataset(self.output_dataset)     # 销毁输出数据集
     # 销毁模型描述
     ret = acl.mdl.destroy_desc(self.model_desc)
     # 卸载模型
     ret = acl.mdl.unload(self.model_id)
     # 释放device
     ret = acl.rt.reset_device(self.device_id)
     # acl去初始化
     ret = acl.finalize()

步骤三:实例化OmModel进行推理

points_detect_om_model_path = "./pfld_106.om"
points_detect_om_model = OmModel(points_detect_om_model_path)
results = points_detect_om_model.forward([np.load("./pfld_input.npy")])
print(results.shape)  #(212, )
print(results)

3.5 使用疲劳检测算法评估驾驶状态

3.5.1 获取评估指标

根据 2.4 章节的说明,我们需要获取3个面部特征进行疲劳度评估:眼睛开合度 ear 、嘴巴开合度 mar、头部仰角 pitch
ear 计算的是两个眼睛的高宽比的平均值。对于106个特征点的场景,眼睛的高度可以用上面中间的3个特征点的纵坐标平均值减去下面中间的3个特征点的纵坐标平均值得到;眼睛的宽度可以用右边特征点的横坐标减去左边横坐标的值得到,代码实现如下:

def calculate_eye_aspect_ratio(keypoints):
    # 计算左眼的ear
    top_points = np.array([keypoints[41], keypoints[40], keypoints[42]])
    bottom_points = np.array([keypoints[36], keypoints[33], keypoints[37]])
    left_eye_height = np.mean(bottom_points[:, 1]) - np.mean(top_points[:, 1])
    left_eye_width = keypoints[39, 0] - keypoints[35, 0]
    left_ear = left_eye_height / (1e-6 + np.abs(left_eye_width))
    # 计算右眼的ear
    top_points = np.array([keypoints[95], keypoints[94], keypoints[96]])
    bottom_points = np.array([keypoints[90], keypoints[87], keypoints[91]])
    right_eye_height = np.mean(bottom_points[:, 1]) - np.mean(top_points[:, 1])
    right_eye_width = keypoints[89, 0] - keypoints[93, 0]
    right_ear = right_eye_height / (1e-6 + np.abs(right_eye_width))
    return (left_ear + right_ear) / 2

mar 计算的是嘴巴的高宽比。对于106个特征点的场景,嘴巴的高度可以用上面中间的3个特征点的纵坐标平均值减去下面中间的3个特征点的纵坐标平均值得到;嘴巴的宽度可以用右边特征点的横坐标减去左边横坐标的值得到,代码实现如下:

def calculate_mouth_aspect_ratio(keypoints):
    top_points = np.array([keypoints[63], keypoints[71], keypoints[67]])
    bottom_points = np.array([keypoints[56], keypoints[53], keypoints[59]])
    mouth_height = np.mean(bottom_points[:, 1]) - np.mean(top_points[:, 1])
    mouth_width = keypoints[52, 0] - keypoints[61, 0]
    mouth_ear = mouth_height / (1e-6 + np.abs(mouth_width))
    return mouth_ear

头部仰角的计算可以参考开源项目的代码进行实现。首先需要定义一些坐标系相关的常量:

# 世界坐标系(UVW):填写3D参考点
object_pts = np.float32([[6.825897, 6.760612, 4.402142],  # 左眉左上角
                         [1.330353, 7.122144, 6.903745],  # 左眉右角
                         [-1.330353, 7.122144, 6.903745],  # 右眉左角
                         [-6.825897, 6.760612, 4.402142],  # 右眉右上角
                         [5.311432, 5.485328, 3.987654],  # 左眼左上角
                         [1.789930, 5.393625, 4.413414],  # 左眼右上角
                         [-1.789930, 5.393625, 4.413414],  # 右眼左上角
                         [-5.311432, 5.485328, 3.987654],  # 右眼右上角
                         [2.005628, 1.409845, 6.165652],  # 鼻子左上角
                         [-2.005628, 1.409845, 6.165652],  # 鼻子右上角
                         [2.774015, -2.080775, 5.048531],  # 嘴左上角
                         [-2.774015, -2.080775, 5.048531],  # 嘴右上角
                         [0.000000, -3.116408, 6.097667],  # 嘴中央下角
                         [0.000000, -7.415691, 4.070434]])  # 下巴角

# 定义相机内参,用于相机坐标系(XYZ)。
K = [6.5308391993466671e+002, 0.0, 3.1950000000000000e+002,
     0.0, 6.5308391993466671e+002, 2.3950000000000000e+002,
     0.0, 0.0, 1.0]  # 等价于矩阵[fx, 0, cx; 0, fy, cy; 0, 0, 1]

# 定义相机畸变参数,用于图像中心坐标系(uv)。
D = [7.0834633684407095e-002, 6.9140193737175351e-002, 0.0, 0.0, -1.3073460323689292e+000]

# 像素坐标系
# 将相机内参转换为相机矩阵
cam_matrix = np.array(K).reshape(3, 3).astype(np.float32)
# 将相机畸变参数转换为畸变系数。
dist_coeffs = np.array(D).reshape(5, 1).astype(np.float32)

# 定义重新投影的3D点的世界坐标轴,用于验证结果的姿态
reprojectsrc = np.float32([[10.0, 10.0, 10.0],
                           [10.0, 10.0, -10.0],
                           [10.0, -10.0, -10.0],
                           [10.0, -10.0, 10.0],
                           [-10.0, 10.0, 10.0],
                           [-10.0, 10.0, -10.0],
                           [-10.0, -10.0, -10.0],
                           [-10.0, -10.0, 10.0]])

然后是头部仰角的计算:

def get_head_pose(keypoints):
    # (像素坐标集合)填写2D参考点
    # 43左眉左上角/50左眉右角/102右眉左上角/101右眉右上角/35左眼左上角/39左眼右上角/89右眼左上角/
    # 93右眼右上角/77鼻子左上角/83鼻子右上角/52嘴左上角/61嘴右上角/53嘴中央下角/0下巴角
    image_pts = np.float32([keypoints[43], keypoints[50], keypoints[102], keypoints[101], keypoints[35],
                            keypoints[39], keypoints[89], keypoints[93], keypoints[77], keypoints[83],
                            keypoints[52], keypoints[61], keypoints[53], keypoints[0]])

    # solvePnP计算姿势——求解旋转和平移矩阵:
    # rotation_vec表示旋转矩阵,translation_vec表示平移矩阵,cam_matrix与K矩阵对应,dist_coeffs与D矩阵对应。
    _, rotation_vec, translation_vec = cv2.solvePnP(object_pts, image_pts, cam_matrix, dist_coeffs)

    # 计算欧拉角calc euler angle
    rotation_mat, _ = cv2.Rodrigues(rotation_vec)  # 罗德里格斯公式(将旋转矩阵转换为旋转向量)
    pose_mat = cv2.hconcat((rotation_mat, translation_vec))  # 水平拼接,vconcat垂直拼接

    # decomposeProjectionMatrix将投影矩阵分解为旋转矩阵和相机矩阵
    _, _, _, _, _, _, euler_angle = cv2.decomposeProjectionMatrix(pose_mat)
    return euler_angle  # 投影误差,欧拉角

好的,有了 earmarpitch 的计算函数之后,我们可以把他们和人脸检测函数、特征点检测函数结合在一起,形成一个完整的函数,可以端到端获取一张图片中司机的3个特征:

def get_face_features(yolo_model, pfld_model, input_image):
    # 获取人脸区域
    detect_results = yolo_model(input_image, conf=0.5)
    face_coordinates = detect_results[0].boxes.xyxy.numpy()
    if len(face_coordinates) == 0:
        return -1, -1, -1, []
    # 横坐标最大的是司机
    dirver_index = np.argmax(face_coordinates[:, 2])  # max x2
    driver_coordinate = face_coordinates[dirver_index]
    x1, y1, x2, y2 = driver_coordinate[:4]

    # 获取人脸特征点
    cut_image = input_image[int(y1):int(y2), int(x1):int(x2), :]
    original_shape = cut_image.shape
    # 调整图片大小为 112x112
    cut_image = cv2.resize(cut_image, (112, 112))
    # 归一化
    cut_image = cut_image / 255.0
    # 转换为模型输入需要的维度 NCHW
    input_data = np.transpose(cut_image, (2, 0, 1)).astype(np.float32)
    input_data = np.expand_dims(input_data, axis=0)  # 添加批次维度
    # 推理
    results = pfld_model.forward([input_data])
    # 转换为坐标
    keypoints = results.reshape(-1, 2) * 112  # 取出关键点
    # 计算ear和mar
    ear = calculate_eye_aspect_ratio(keypoints)
    mar = calculate_mouth_aspect_ratio(keypoints)
    # 获取头部姿态
    euler_angle = get_head_pose(keypoints)
    # 取pitch旋转角度
    pitch = euler_angle[0, 0]
    return ear, mar, pitch, np.array([[x1, y1, x2, y2]])

接下来,我们就用开源数据集中的一张正常状态的照片和打哈欠状态的照片进行计算,看看他们的特征值分别是多少:

# 正常状态
normal_pic = cv2.imread("./test_input_images/day_man_001_00_1_127.png")
sleepy_pic = cv2.imread("./test_input_images/day_man_001_20_1_105.png")
ear1, mar1, pitch1, _ = get_face_features(face_detect_model, points_detect_om_model, normal_pic)
ear2, mar2, pitch2, _ = get_face_features(face_detect_model, points_detect_om_model, sleepy_pic)
print("normal features: %s %s %s " % (ear1, mar1, pitch1))  # normal features: 0.2412939016619589 0.24392606759474458 48.88846840034163  
print("sleepy features: %s %s %s " % (ear2, mar2, pitch2))  # sleepy features: 0.2014741085459678 0.6596043956970754 31.664830326153115 

可以看到打哈欠状态下,ear 的值低于正常状态下的值(眯眼),mar 明显高于正常状态下的值(张嘴),头部仰角也和正常状态下不同(抬头)。

3.5.2 实现完整的疲劳判断算法

获取到疲劳判断指标后,我们就可以实现完整的疲劳驾驶判定算法了。为了防止司机偶尔张嘴、闭眼、抬头、低头造成的误判,我们需要计算一段时间内司机各项指标异常所占的比例。参考开源项目的代码的实现,我们可以计算某个时间段内判定为“疲劳”的帧数所占比例,超过我们设定的阈值(比如0.4)后,我们认定是异常状态。端到端的算法实现如下:
首先确定正确状态下的 earmarpitch作为参考值:

normal_pic = cv2.imread("./test_input_images/day_man_001_00_1_127.png")
ear, mar, pitch, face_box = get_face_features(face_detect_model, points_detect_om_model, normal_pic)
EAR_THRESH = ear
MAR_THRESH = mar
PITCH_THRESH = pitch

然后导入测试视频,并设置阈值:

source = "./test_video/"
# 设置检测后的视频保存位置,运行结束后我们可以查看视频的检测结果,确定是否和实际情况一致
out = "./detect_video/"
dataset = LoadImages(source)
save_video = True
# 用来创建一个新的视频
vid_path, vid_writer = None, None
# 阈值和超参
# 测试视频的帧数为5,我们每3帧进行一次指标是否异常的判定
DETECTED_TIME_LIMIT = 3
detected_time_limit = DETECTED_TIME_LIMIT
# 记录闭眼、张嘴、抬头/低头的次数
closed_eyes_times = 0
yawning_times = 0
pitch_times = 0
# 记录触发警告的次数
warning_time = 0
# 异常帧率所占比例的阈值
FATIGUE_THRESH = 0.4
# 是否进行疲劳驾驶告警
is_warning = False

接下来就是遍历视频的所有帧,进行疲劳检测:

for path, img, im0s, vid_cap in dataset:
    image = np.transpose(img, (1, 2, 0))
    # 获取当前图片的评估指标以及人脸框
    ear, mar, pitch, face_box = get_face_features(face_detect_model, points_detect_om_model, image)
    # 如果检测到人脸
    if face_box != []:
        # 获取检测视频的完整地址命名
        p, s, im0 = path, '', im0s
        save_path = str(Path(out) / Path(p).name)

        # rescale坐标
        x1, y1, x2, y2 = face_box[0].astype(int)
        # 在新视频的图片中框出人脸
        cv2.rectangle(im0, (x1, y1), (x2, y2), (50, 50, 250), 3)

        # 开始时间周期内的检测
        if detected_time_limit > 0:
            detected_time_limit -= 1
            # ear 的值超过了异常阈值
            if ear < 0.75 * EAR_THRESH:
                closed_eyes_times += 1
            # mar 的值超过了异常阈值
            if mar > 1.6 * MAR_THRESH:
                yawning_times += 1
            # pitch 的值超过了异常阈值
            if abs(pitch - PITCH_THRESH) > 6:
                pitch_times += 1
                
        # 判定已经结束的周期内是否存在疲劳驾驶
        else:
            # 重置Detected_TIME_LIMIT
            detected_time_limit = DETECTED_TIME_LIMIT
            isEyeTired = False
            isYawnTired = False
            isHeadTired = False

            # 判断是否疲劳,大于阈值则疲劳
            if closed_eyes_times / detected_time_limit > FATIGUE_THRESH:
                print("闭眼时长较长")
                isEyeTired = True

            if yawning_times / detected_time_limit > FATIGUE_THRESH:
                print("张嘴时长较长")
                isYawnTired = True

            if pitch_times / detected_time_limit > FATIGUE_THRESH:
                print("抬头/低头时长较长")
                isHeadTired = True

            # 重置次数
            closed_eyes_times = 0
            yawning_times = 0
            pitch_times = 0

            # 疲劳状态判断
            if isEyeTired:
                warning_time += 2
            if isYawnTired:
                warning_time += 1
            if isHeadTired:
                warning_time += 1
            if warning_time >= 3:
                warning_time = 0
                is_warning = True
            else:
                is_warning = False
        # 疲劳驾驶
        if is_warning:
            print("您已经疲劳,请注意休息!")
            # 在图片中写上疲劳驾驶标签
            font = cv2.FONT_HERSHEY_SIMPLEX
            font_scale = 1
            color = (0, 0, 255)
            thickness = 1
            cv2.putText(im0, "Fatigue driving!", (x1, y1), font, font_scale, color, thickness, cv2.LINE_AA)

    # 保存检测后的图片到新的视频里面,只有保存每个视频的第一帧图片才会走到if里面
    if vid_path != save_path:  # new video
        vid_path = save_path
        if isinstance(vid_writer, cv2.VideoWriter):
            vid_writer.release()  # release previous video writer

        fourcc = 'mp4v' 
        fps = vid_cap.get(cv2.CAP_PROP_FPS)
        w = int(vid_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        h = int(vid_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        vid_writer = cv2.VideoWriter(save_path, cv2.VideoWriter_fourcc(*fourcc), fps, (w, h))
    # 保存每一帧图像
    vid_writer.write(im0)
    print("write success")

运行结束后,可以看到在 ./detect_video/ 目录下生成了 driver_video.mp4 ,点击观察,可以看到成功地检测出了司机疲劳驾驶(fatigue driving)的状态!截图如下所示:

detect_screenshoot.png

恭喜你!到这里,你完整地完成了疲劳驾驶检测的全部实验。接下来完成一些测试来巩固这门课程的知识吧!

3.6 软件依赖

本实验的依赖软件版本信息如下:

  1. Python:为了方便开发者进行学习,本课程采用Python代码实现,您可以在服务器上安装一个Conda,用于创建Python环境,本实验使用的是 python 3.10
  2. ultralytics:AI视觉模型三方库,提供了多种CV模型的调用接口,内置了模型的前后处理,方便用户调用模型,本实验使用的是 8.3.24 版本;
  3. opencv-python:opencv-python 是 OpenCV 库的 Python 接口,它提供了对 OpenCV 功能的访问,包括图像处理、视频分析、计算机视觉算法和实时图像处理等,使得开发者能够在 Python 环境中轻松实现复杂的视觉任务,本实验使用的是 4.10.0.84 版本;
  4. numpy: 开源的Python科学计算库,用于进行大规模数值和矩阵运算,本实验使用的是 1.23.0 版本;
  5. onnxruntime: 开源的机器学习推理和训练加速引擎,本实验使用的是 1.19.2 版本;
  6. CANN(Compute Architecture for Neural Networks):Ascend芯片的使能软件,本实验使用的是 8.0.rc2 版本

04 课后测试

请参考3.4的方式,把3.3中的YOLO模型用昇腾卡运行。


AI布道Mr_jin
1 声望0 粉丝