发布于 2026-01-06 0 阅读
0

如何使用 YOLOv8 神经网络和 JavaScript 在网页浏览器中检测视频中的物体 目录 引言 向网页添加视频组件 捕获视频帧以进行物体检测 检测视频中的物体 在 JavaScript 中并行运行多个任务 在后台线程中运行模型 结论

如何使用 YOLOv8 神经网络和 JavaScript 在 Web 浏览器中检测视频中的物体

目录

介绍

向网页添加视频组件

捕获视频帧以进行目标检测

检测视频中的物体

在 JavaScript 中并行运行多个任务

在后台线程中运行模型

结论

目录

简介
向网页添加视频组件
捕获视频帧以进行目标检测
检测视频中的目标
    准备输入
    运行模型
    处理输出
    绘制边界框
在 JavaScript 中并行运行多个任务 在
后台线程中运行模型
结论

介绍

这是 YOLOv8 系列教程的第三部分。在前两部分中,我带领大家了解了 YOLOv8 的所有基础知识,包括数据准备、神经网络训练以及在图像上运行目标检测。最后,我们使用不同的编程语言创建了一个用于检测图像中目标的 Web 服务。

现在是时候更进一步了。如果你知道如何检测图像中的物体,那么检测视频中的物体也就易如反掌,因为视频本质上就是一个包含背景声音的图像数组。你只需要知道如何将每一帧图像捕获,然后使用我们在上一篇文章中编写的相同代码,将其输入到物体检测神经网络中即可。这就是我将在本教程中演示的内容。

在接下来的章节中,我们将创建一个 Web 应用程序,用于检测加载到 Web 浏览器中的视频中的物体。它会实时显示检测到的物体的边界框。最终的应用程序的外观和功能将如下一个视频所示。

请确保您已阅读并尝试过本系列的所有前几篇文章,特别是“如何使用 JavaScript 检测图像中的对象”部分,因为我将重用其中开发的算法和项目源代码。

在复习了如何使用 YOLOv8 神经网络在网页浏览器中检测图像中的对象之后,您就可以继续阅读以下章节了。

向网页添加视频组件

现在我们开始一个项目。创建一个新文件夹,并将index.html以下内容的文件添加到该文件夹​​中:

index.html



<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Object detector</title>    
</head>
<body>
<video src="sample.mp4" controls></video>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

我们使用 `<video>` 元素在网页上显示视频。该元素可以显示来自各种来源的视频,包括文件、网络摄像头或来自 WebRTC 的远程媒体流。本文将使用文件中的视频,但目标检测代码也适用于 `<video>` 组件支持的任何其他视频源。我使用了一段两只猫的精彩录像。您可以从这里sample.mp4下载,或者使用任何其他 MP4 视频进行测试。将视频文件放在与 `<video>` 元素相同的文件夹中index.html

video元素有很多属性。我们使用 ` src<video>` 属性指定源视频文件,并使用 `<control>`controls属性显示带有 `<button>` 和其他按钮的控制栏。您可以在这里找到标签选项play的完整列表video

打开此网页后,您将看到以下内容:

图片描述

你可以看到屏幕上显示着视频,底部还有一个面板,可以用来控制视频:播放/暂停、改变音量、全屏显示等等。

此外,您还可以通过 JavaScript 代码管理此组件。要从代码中访问视频元素,您需要获取指向视频对象的链接:



const video = document.querySelector("video");


Enter fullscreen mode Exit fullscreen mode

然后,您可以使用该video对象以编程方式控制视频。此变量是实现了HTMLMediaElement接口的HTMLVideoElement对象的一个​​实例。该对象包含一组用于控制视频元素的属性和方法。此外,它还提供了对视频生命周期事件的访问。您可以绑定事件处理程序来响应许多不同的事件,特别是:

  • loadeddata - 当视频加载完毕并显示第一帧时触发
  • 播放- 视频开始播放时触发
  • 暂停- 视频暂停时触发

你可以利用这些事件来捕获视频帧。在捕获帧之前,你需要知道视频的尺寸:宽度和高度。我们先在视频加载完成后获取这些信息。

创建一个名为 `<script>` 的 JavaScript 文件object_detector.js,并将其包含到index.html

index.html



<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Object detector</title>    
</head>
<body>
<video src="sample.mp4" controls></video>
<script src="object_detector.js" defer></script>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

并将以下内容添加到新文件中:

object_detector.js



const video = document.querySelector("video");

video.addEventListener("loadeddata", () => {
    console.log(video.videoWidth, video.videoHeight);
})


Enter fullscreen mode Exit fullscreen mode

loadeddata在这段代码片段中,你为视频元素的事件设置了事件监听器video。一旦视频文件加载到视频元素中,视频的尺寸就可用了,你将视频的尺寸打印videoWidthvideoHeight控制台。

如果你使用了sample.mp4视频文件,那么你应该会在控制台上看到以下尺寸。



960 540


Enter fullscreen mode Exit fullscreen mode

如果一切顺利,就可以开始捕捉视频帧了。

捕获视频帧以进行目标检测

正如您在上一篇文章中读到的,要检测图像中的对象,需要将图像转换为归一化的像素颜色数组。为此,我们使用`drawImage`方法在HTML5 Canvas上绘制图像,然后使用HTML5 Canvas 上下文的`getImageData`方法来访问像素及其颜色分量。

这种方法的优点drawImage在于,你可以用它在画布上绘制视频,就像用它绘制图像一样。

我们来看看它是如何工作的。将 <canvas> 元素添加到index.html页面中:

index.html



<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Object detector</title>
</head>
<body>
<video src="sample.mp4" controls></video>
<br/>
<canvas></canvas>
<script src="object_detector.js" defer></script>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

当用户按下“播放”按钮或开发者调用对象的play()方法时,视频组件开始播放视频video。因此,要开始录制视频,您需要实现播放事件监听器。请将文件内容替换object_detector.js为以下内容:

object_detector.js



const video = document.querySelector("video");

video.addEventListener("play", () => {
    const canvas = document.querySelector("canvas");
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    const context = canvas.getContext("2d");
    context.drawImage(video,0,0);
});


Enter fullscreen mode Exit fullscreen mode

在这段代码中,当视频开始播放时:

  • “播放”事件监听器已触发。
  • 在事件处理函数中,我们设置了canvas视频元素的实际宽度和高度。
  • 接下来的代码获取对 2D HTML5 Canvas 绘图的访问权限。context
  • 然后,利用该drawImage方法,我们将视频绘制到画布上。

在浏览器中打开此index.html页面,然后点击“播放”按钮。之后,您应该会看到以下内容:

图片描述

这里顶部是视频,下方是画布,画布上显示了捕获的帧。画布上只显示了第一帧,因为您只在视频开始时捕获了一次帧。要捕获每一帧,您需要drawImage在视频播放期间不断调用该函数。您可以使用`setInterval`函数来重复调用指定的代码。让我们每 30 毫秒绘制一次视频的当前帧:

object_detector.js



let interval;
video.addEventListener("play", () => {
    const canvas = document.querySelector("canvas");
    const context = canvas.getContext("2d");
    const interval = setInterval(() => {
        context.drawImage(video,0,0);
    },30)
});


Enter fullscreen mode Exit fullscreen mode

这段代码会在视频播放时绘制当前帧。但如果视频停止播放,我们就应该停止这个过程,因为如果视频暂停或结束,一直重新绘制画布是没有意义的。为此,我将创建的间隔保存到一个变量中,该变量稍后可以在`clearInterval`interval函数中使用

要拦截视频停止播放的瞬间,你需要处理该pause事件。在你的代码中添加以下代码,即可在视频停止播放时停止捕获帧:

object_detector.js



video.addEventListener("pause", () => {
    clearInterval(interval);
});


Enter fullscreen mode Exit fullscreen mode

完成上述步骤后,您可以重新加载页面。如果一切操作正确,当您按下“播放”按钮时,您会看到视频和画布同步显示。

图片描述

该函数中的代码setInterval会捕获视频的每一帧并将其绘制到画布上,直到视频播放结束。如果您按下“暂停”按钮或视频播放完毕,pause事件处理程序将清除间隔并停止帧捕获循环。

我们不需要在网页上重复显示同一个视频,所以我们需要自定义播放器。让我们隐藏原有的video播放器,只保留画布。

index.html



<video controls style="display:none" src="sample.mp4"></video>


Enter fullscreen mode Exit fullscreen mode

但是,如果我们隐藏视频播放器,就无法访​​问“播放”和“暂停”按钮。幸运的是,这并不是什么大问题,因为我们可以video通过编程方式控制该对象。它具有play用于pause控制播放的 `play` 和 `pause` 方法。我们将在画布下方添加我们自己的“播放”和“暂停”按钮,新的用户界面将如下所示:

index.html



<video controls style="display:none" src="sample.mp4"></video><br/>
<canvas></canvas><br/>
<button id="play">Play</button>&nbsp;
<button id="pause">Pause</button>


Enter fullscreen mode Exit fullscreen mode

现在将onclick已创建按钮的事件处理程序添加到object_detector.js

object_detector.js



const playBtn = document.getElementById("play");
const pauseBtn = document.getElementById("pause");
playBtn.addEventListener("click", () => {
    video.play();
});
pauseBtn.addEventListener("click", () => {
    video.pause();
});


Enter fullscreen mode Exit fullscreen mode

修改完成后刷新页面即可查看结果:

图片描述

你应该可以通过按下“播放”按钮开始播放,通过按下“暂停”按钮停止播放。

以下是当前阶段的完整 JavaScript 代码:

object_detector.js



const video = document.querySelector("video");
let interval
video.addEventListener("play", () => {
    const canvas = document.querySelector("canvas");
    const context = canvas.getContext("2d");
    interval = setInterval(() => {
        context.drawImage(video,0,0);

    },30)
});

video.addEventListener("pause", () => {
    clearInterval(interval);
});

const playBtn = document.getElementById("play");
const pauseBtn = document.getElementById("pause");
playBtn.addEventListener("click", () => {
    video.play();
});
pauseBtn.addEventListener("click", () => {
    video.pause();
});



Enter fullscreen mode Exit fullscreen mode

现在您拥有了自定义视频播放器,并可以完全控制视频的每一帧。例如,您可以使用HTML5 Canvas 上下文 API在任何视频帧上绘制任何您想要的内容。在下面的章节中,我们将把每一帧传递给 YOLOv8 神经网络,以检测其中的所有对象并绘制它们的边界框。我们将使用与上一篇文章中编写的相同的代码来开发JavaScript 对象检测 Web 服务,以准备输入、运行模型、处理输出并绘制检测到的对象周围的边界框。

检测视频中的物体

要检测视频中的物体,需要检测视频每一帧中的物体。您已经将每一帧转换为图像并将其显示在 HTML5 Canvas 上。一切就绪,可以重用我们在上一篇文章中编写的用于检测图像中物体的代码。对于每一帧视频,您需要:

  • 准备画布上图像的输入。
  • 使用此输入运行模型
  • 处理输出
  • 在每一帧的顶部显示检测到的物体的边界框。

准备输入

我们来创建一个prepare_input函数,用于准备神经网络模型的输入。该函数将接收canvas显示帧的图像,并对其进行以下处理:

  • 创建一个临时画布并将其大小调整为 640x640,这是 YOLOv8 模型所必需的。
  • 将源图像(画布)复制到此临时画布
  • getImageData使用HTML5 canvas 上下文的方法获取像素颜色分量数组
  • 将每个像素的红色、绿色和蓝色颜色分量收集到单独的数组中。
  • 将这些数组连接成一个数组,其中红色排在最前面,绿色排在后面,蓝色排在最后。
  • 返回此数组

让我们来实现这个函数:

object_detector.js



function prepare_input(img) {  
    const canvas = document.createElement("canvas");
    canvas.width = 640;
    canvas.height = 640;
    const context = canvas.getContext("2d");
    context.drawImage(img, 0, 0, 640, 640);

    const data = context.getImageData(0,0,640,640).data;
    const red = [], green = [], blue = [];
    for (let index=0;index<data.length;index+=4) {
        red.push(data[index]/255);
        green.push(data[index+1]/255);
        blue.push(data[index+2]/255);
    }
    return [...red, ...green, ...blue];
}


Enter fullscreen mode Exit fullscreen mode

在函数的第一部分中,我们创建了一个 640x640 大小的不可见画布,并将输入的图像调整大小为 640x640 显示在画布上。

然后,我们获取了画布像素数据,收集了颜色分量并将其green转换redblue数组,将它们组合在一起并返回。这个过程在下一张图中有所展示。

图片描述

此外,我们将每个颜色分量值归一化,将其除以 255。

这个函数与上一篇文章prepare_input中创建的函数非常相似。唯一的区别在于,这里我们不需要为图像创建 HTML 元素,因为图像已经存在于输入画布中。

当此函数准备就绪后,您可以将每一帧传递给它,并接收一个数组作为 YOLOv8 模型的输入。将此函数的调用添加到循环中setInterval

object_detector.js



interval = setInterval(() => {
    context.drawImage(video,0,0);
    const input = prepare_input(canvas);
},30)


Enter fullscreen mode Exit fullscreen mode

在这里,绘制完画布上的图像后,您将该画布传递给prepare_input函数,该函数返回一个数组,其中包含此帧所有像素的红色、绿色和蓝色分量。此数组将用作 YOLOv8 神经网络模型的输入。

运行模型

输入准备就绪后,就可以将其传递给神经网络了。我们不会为此创建后端,所有操作都将在前端完成。我们将使用 ONNX 运行时库的 JavaScript 版本,直接在浏览器中运行模型预测。请将 ONNX 运行时 JavaScript 库添加到index.html文件中以加载它。

index.html



<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>


Enter fullscreen mode Exit fullscreen mode

然后,您需要获取 YOLOv8 模型并将其转换为 ONNX 格式。请按照上一篇文章此部分.onnx中的说明进行操作。将导出的文件复制到与index.html.

接下来,我们编写一个函数run_model,该函数将使用 .oonx 文件实例化一个模型,然后将上面部分准备的输入传递给模型,并返回原始预测结果:

object_detector.js



async function run_model(input) {
    const model = await ort.InferenceSession.create("yolov8n.onnx");
    input = new ort.Tensor(Float32Array.from(input),[1, 3, 640, 640]);
    const outputs = await model.run({images:input});
    return outputs["output0"].data;
}


Enter fullscreen mode Exit fullscreen mode

这段代码直接从上一篇文章的相应部分复制粘贴而来。请继续阅读以了解其工作原理。

这里我使用的yolov8n.onnx模型是基于 COCO 数据集预训练的 YOLOv8 模型的精简版。您也可以使用任何其他预训练模型或自定义模型。

最后,在setInterval循环中调用此函数以检测每一帧中的对象:

object_detector.js



interval = setInterval(async() => {
    context.drawImage(video,0,0);
    const input = prepare_input(canvas);
    const output = await run_model(input)
},30)


Enter fullscreen mode Exit fullscreen mode

请注意,我async在函数内部添加了关键字setInterval,并await在调用时添加了关键字run_model,因为这是一个异步函数,需要一些时间才能完成执行。

要使其正常工作,您需要index.html在某个 HTTP 服务器中运行它,例如在 VS Code 的嵌入式 Web 服务器中,因为 run_model 函数需要yolov8n.onnx使用 HTTP 将文件下载到浏览器。

现在,是时候将原始的 YOLOv8 模型输出转换为检测到的对象的边界框了。

处理输出

你可以直接process_output上一篇文章的相应部分复制该函数。

object_detector.js



function process_output(output, img_width, img_height) {
    let boxes = [];
    for (let index=0;index<8400;index++) {
        const [class_id,prob] = [...Array(80).keys()]
            .map(col => [col, output[8400*(col+4)+index]])
            .reduce((accum, item) => item[1]>accum[1] ? item : accum,[0,0]);
        if (prob < 0.5) {
            continue;
        }
        const label = yolo_classes[class_id];
        const xc = output[index];
        const yc = output[8400+index];
        const w = output[2*8400+index];
        const h = output[3*8400+index];
        const x1 = (xc-w/2)/640*img_width;
        const y1 = (yc-h/2)/640*img_height;
        const x2 = (xc+w/2)/640*img_width;
        const y2 = (yc+h/2)/640*img_height;
        boxes.push([x1,y1,x2,y2,label,prob]);
    }

    boxes = boxes.sort((box1,box2) => box2[5]-box1[5])
    const result = [];
    while (boxes.length>0) {
        result.push(boxes[0]);
        boxes = boxes.filter(box => iou(boxes[0],box)<0.7);
    }
    return result;
}


Enter fullscreen mode Exit fullscreen mode

这段代码是为在 COCO 数据集上预训练的 YOLOv8 模型编写的,该模型包含 80 个对象类别。如果您使用类别数量不同的自定义模型,则需要将代码行中的“80”替换[...Array(80).keys()]为您模型检测到的类别数量。

此外,还复制了用于实现“交并比”算法的辅助函数和 COCO 对象类标签数组:

object_detector.js



function iou(box1,box2) {
    return intersection(box1,box2)/union(box1,box2);
}

function union(box1,box2) {
    const [box1_x1,box1_y1,box1_x2,box1_y2] = box1;
    const [box2_x1,box2_y1,box2_x2,box2_y2] = box2;
    const box1_area = (box1_x2-box1_x1)*(box1_y2-box1_y1)
    const box2_area = (box2_x2-box2_x1)*(box2_y2-box2_y1)
    return box1_area + box2_area - intersection(box1,box2)
}

function intersection(box1,box2) {
    const [box1_x1,box1_y1,box1_x2,box1_y2] = box1;
    const [box2_x1,box2_y1,box2_x2,box2_y2] = box2;
    const x1 = Math.max(box1_x1,box2_x1);
    const y1 = Math.max(box1_y1,box2_y1);
    const x2 = Math.min(box1_x2,box2_x2);
    const y2 = Math.min(box1_y2,box2_y2);
    return (x2-x1)*(y2-y1)
}

const yolo_classes = [
    'person', 'bicycle', 'car', 'motorcycle', 'airplane', 'bus', 'train', 'truck', 'boat',
    'traffic light', 'fire hydrant', 'stop sign', 'parking meter', 'bench', 'bird', 'cat', 'dog', 'horse',
    'sheep', 'cow', 'elephant', 'bear', 'zebra', 'giraffe', 'backpack', 'umbrella', 'handbag', 'tie', 'suitcase',
    'frisbee', 'skis', 'snowboard', 'sports ball', 'kite', 'baseball bat', 'baseball glove', 'skateboard',
    'surfboard', 'tennis racket', 'bottle', 'wine glass', 'cup', 'fork', 'knife', 'spoon', 'bowl', 'banana', 'apple',
    'sandwich', 'orange', 'broccoli', 'carrot', 'hot dog', 'pizza', 'donut', 'cake', 'chair', 'couch', 'potted plant',
    'bed', 'dining table', 'toilet', 'tv', 'laptop', 'mouse', 'remote', 'keyboard', 'cell phone', 'microwave', 'oven',
    'toaster', 'sink', 'refrigerator', 'book', 'clock', 'vase', 'scissors', 'teddy bear', 'hair drier', 'toothbrush'
];


Enter fullscreen mode Exit fullscreen mode

这里我使用了在 COCO 数据集上预训练模型的标签数组。如果您使用不同的模型,标签显然也应该不同。

最后,在循环中对每一帧视频调用此函数setInterval

object_detector.js



interval = setInterval(async() => {
        context.drawImage(video,0,0);
        const input = prepare_input(canvas);
        const output = await run_model(input);
        const boxes = process_output(output, canvas.width, canvas.height);
    },30)


Enter fullscreen mode Exit fullscreen mode

process_output函数接收模型的原始输出和画布尺寸,以将边界框缩放到原始图像大小。(请记住,该模型适用于 640x640 的图像)。

最后,boxes 数组包含每个检测到的对象的边界框,格式为:[x1,y1,x2,y2,label,prob]。

剩下的工作就是在画布上的图像上方画出这些方框。

绘制边界框

现在你需要编写一个函数,使用 HTML5 Canvas 上下文 API 为每个边界框绘制带有对象类标签的矩形。你可以重用draw_image_and_boxes我们在上一篇文章中每个项目中编写的函数。以下是原始函数的示例:



function draw_image_and_boxes(file,boxes) {
    const img = new Image()
    img.src = URL.createObjectURL(file);
    img.onload = () => {
        const canvas = document.querySelector("canvas");
        canvas.width = img.width;
        canvas.height = img.height;
        const ctx = canvas.getContext("2d");
        ctx.drawImage(img,0,0);
        ctx.strokeStyle = "#00FF00";
        ctx.lineWidth = 3;
        ctx.font = "18px serif";
        boxes.forEach(([x1,y1,x2,y2,label]) => {
            ctx.strokeRect(x1,y1,x2-x1,y2-y1);
            ctx.fillStyle = "#00ff00";
            const width = ctx.measureText(label).width;
            ctx.fillRect(x1,y1,width+10,25);
            ctx.fillStyle = "#000000";
            ctx.fillText(label, x1, y1+18);
        });
    }
}


Enter fullscreen mode Exit fullscreen mode

不过,你可以简化一下,因为在这种情况下,你不需要从文件中加载图像并将其显示在画布上,图像已经显示在画布上了。你只需要将画布传递给这个函数,然后在画布上绘制方框即可。另外,draw_boxes由于图像已经绘制在输入画布上,所以需要将函数名更改为 `drawable`。以下是修改方法:

object_detector.js



function draw_boxes(canvas,boxes) {
    const ctx = canvas.getContext("2d");
    ctx.strokeStyle = "#00FF00";
    ctx.lineWidth = 3;
    ctx.font = "18px serif";
    boxes.forEach(([x1,y1,x2,y2,label]) => {
        ctx.strokeRect(x1,y1,x2-x1,y2-y1);
        ctx.fillStyle = "#00ff00";
        const width = ctx.measureText(label).width;
        ctx.fillRect(x1,y1,width+10,25);
        ctx.fillStyle = "#000000";
        ctx.fillText(label, x1, y1+18);
    });
}


Enter fullscreen mode Exit fullscreen mode
  • 该函数接收canvas当前帧及其boxes上检测到的对象数组。
  • 该函数可设置填充、描边和字体样式。
  • 然后它遍历boxes数组,在每个检测到的对象周围绘制绿色边界矩形,并标注类别标签。类别标签采用黑色文本和绿色背景显示。

setInterval现在你可以像这样在循环中对每一帧调用这个函数:

object_detector.js



interval = setInterval(async() => {
     context.drawImage(video,0,0);
     const input = prepare_input(canvas);
     const output = await run_model(input);
     const boxes = process_output(output, canvas.width, canvas.height);
     draw_boxes(canvas,boxes)
 },30)


Enter fullscreen mode Exit fullscreen mode

然而,这样写的代码无法正常工作。这draw_boxes是循环中的最后一行,所以紧接着这一行之后,下一次迭代就会开始,并逐行覆盖已显示的方框context.drawImage(video, 0,0, canvas.width, canvas.height)。因此,你将永远看不到已显示的方框。你需要drawImage先执行 `for` 循环,然后draw_boxes再执行 `for` 循环,但当前代码的执行顺序正好相反。我们将使用以下技巧来解决这个问题:

object_detector.js



let interval
let boxes = [];
video.addEventListener("play", async() => {
    const canvas = document.querySelector("canvas");
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    const context = canvas.getContext("2d");
    interval = setInterval(async() => {
        context.drawImage(video,0,0);
        draw_boxes(canvas, boxes);
        const input = prepare_input(canvas);
        const output = await run_model(input);
        boxes = process_output(output, canvas.width, canvas.height);
    },30)
});


Enter fullscreen mode Exit fullscreen mode

在这段代码中,我boxes在“播放”事件处理程序之前声明了一个全局变量。它默认是一个空数组。这样,你就可以在draw_boxes用函数将视频帧绘制到画布上之后立即运行该函数drawImage。第一次迭代时,它不会在图像上绘制任何内容,但随后会运行模型并将boxes检测到的对象覆盖到数组中。然后在下一次迭代开始时,它会绘制检测到的对象的边界框。假设你每 30 毫秒进行一次迭代,那么前一帧和当前帧之间的差异不会很大。

最后,如果一切操作正确,您将在视频中看到检测到的物体周围出现边界框。

图片描述

运行此程序时,您可能会遇到视频播放延迟的问题。这是因为该run_model函数中的机器学习模型推理是一项 CPU 密集型操作,所需时间可能超过 30 毫秒。因此,它会导致视频播放中断。延迟时间的长短取决于您的 CPU 性能。幸运的是,我们有办法解决这个问题,我们将在下文中介绍。

在 JavaScript 中并行运行多个任务

JavaScript 默认是单线程的。它有一个主线程,有时也称为 UI 线程。所有代码都在这个主线程中运行。然而,让 CPU 密集型任务(例如机器学习模型执行)中断 UI 并不是一个好的做法。你应该将 CPU 密集型任务移到单独的线程中,以免阻塞用户界面。

在 JavaScript 中创建线程的常用方法是使用WebWorkers API。使用此 API,您可以创建一个 Worker 对象并将 JavaScript 文件传递​​给它,如下面的代码所示:



const worker = new Worker("worker.js");


Enter fullscreen mode Exit fullscreen mode

worker对象将在单独的线程中运行该worker.js文件。该文件中的所有代码将与用户界面并行运行。

以这种方式创建的工作线程无法访问网页元素或其中定义的任何代码。同样,主线程也无法访问工作线程的内容。WebWorkers API 使用消息进行线程间通信。您可以向线程发送包含数据的消息,也可以监听来自该线程的消息。

工作线程也可以执行相同的操作:它可以向主线程发送消息,也可以监听来自主线程的消息。以这种方式定义的通信是异步的。

例如,要向之前创建的工作线程发送消息,您应该运行:



worker.postMessage(data)


Enter fullscreen mode Exit fullscreen mode

参数data可以是任何 JavaScript 对象。

要监听来自工作线程的消息,您需要定义onmessage事件处理程序:



worker.onmessage = (event) => {
    console.log(event.data);
};


Enter fullscreen mode Exit fullscreen mode

当工作线程收到消息时,它会触发该函数并将传入的消息作为event参数传递。该属性包含线程使用该函数发送的event.data数据workerpostMessage

您可以在文档中阅读更多关于 WebWorkers 的理论知识,然后我们将进行实践。

让我们来解决视频延迟问题。目前最耗时耗力的函数是 `get_video_freeze() run_model`。因此,我们将它移到一个新的工作线程中。然后,我们将 `get_video_freeze()` 发送input到这个线程,并从中接收响应output。它将在后台运行,处理视频播放期间的每一帧。

在后台线程中运行模型

让我们创建一个worker.js文件,并将运行模型所需的代码移动到这个文件中:

worker.js



importScripts("https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js");

async function run_model(input) {
    const model = await ort.InferenceSession.create("./yolov8n.onnx");
    input = new ort.Tensor(Float32Array.from(input),[1, 3, 640, 640]);
    const outputs = await model.run({images:input});
    return outputs["output0"].data;
}


Enter fullscreen mode Exit fullscreen mode

第一行代码导入了 ONNX 运行时 JavaScript API 库,因为如前所述,工作线程无法访问网页及其中导入的任何内容。该importScripts函数用于将外部脚本导入到工作线程。

这里导入的 JavaScript ONNX API 库只包含高级 JavaScript 函数,但不包含 ONNX 运行时库本身。JavaScript 的 ONNX 运行时库是原始 ONNX 运行时库(用 C 语言编写)的 WebAssembly 编译版本。当你导入该ort.min.js文件并打开一个包含该文件的网页时,它会检查项目文件夹中是否存在真正的 ONNX 库,如果不存在,则会自动将该ort-wasm-simd.wasm文件下载到你的浏览器中。我遇到了一个问题。如果从 Web Worker 运行此程序,则不会下载该文件。我认为,目前最好的快速解决方法是从代码仓库手动下载ort-wasm-simd.wasm文件并将其放入项目文件夹中。

随后,我run_model从……复制/粘贴了该函数object_detector.js

现在我们需要input从主 UI 线程(所有其他代码运行的线程)向此脚本发送请求。为此,我们需要在主线程中创建一个新的 worker object_detector.js。您可以在程序开始时执行此操作:

object_detector.js



const worker = new Worker("worker.js")


Enter fullscreen mode Exit fullscreen mode

run_model然后,您需要向该工作线程发送一条包含输入的消息,而不是发起调用。

object_detector.js



interval = setInterval(() => {
        context.drawImage(video,0,0, canvas.width, canvas.height);
        draw_boxes(canvas, boxes);
        const input = prepare_input(canvas);
        worker.postMessage(input);
//        boxes = process_output(output, canvas.width, canvas.height);
    },30)


Enter fullscreen mode Exit fullscreen mode

这里我使用函数将消息发送input给了工作线程postMessage,并注释掉了它之后的所有代码,因为我们应该在工作线程处理完消息input并返回结果后才运行它output。您可以直接删除这一行,因为它稍后会在另一个函数中使用,该函数将处理来自工作线程的消息。

现在让我们回到工作进程。它应该会收到你发送的输入。要接收消息,你需要定义一个onmessage处理程序。让我们把它添加到worker.js

worker.js



onmessage = async(event) => {
    const input = event.data;
    const output = await run_model(input);
    postMessage(output);
}


Enter fullscreen mode Exit fullscreen mode

这是在工作线程中实现主线程消息事件处理程序的方式。该处理程序被定义为一个异步函数。当消息到达时,它会从消息中提取数据并存储到一个input变量中。然后,它会run_model使用该输入调用另一个函数。最后,它会output使用该函数将模型中的数据作为消息发送到主线程postMessage

worker.js



onmessage = async(event) => {
    const input = event.data;
    const output = await run_model(input);
    postMessage(output);
}


Enter fullscreen mode Exit fullscreen mode

当模型返回结果output并将其作为消息发送到主线程时,主线程应该接收到该消息并处理模型的输出。为此,您需要在线程中定义onmessage处理程序workerobject_detector.js

object_detector.js



worker.onmessage = (event) => {
    const output = event.data;
    const canvas = document.querySelector("canvas");
    boxes =  process_output(output, canvas.width, canvas.height);
};


Enter fullscreen mode Exit fullscreen mode

在这里,当模型输出来自工作线程时,您需要使用该process_output函数对其进行处理,并将其保存到boxes全局变量中。这样,新的方框就可以用于绘制了。

差不多完成了,但还有一件重要的事情需要处理。主线程和工作线程之间的消息流是异步的,因此,主线程不会等待run_model工作线程完成,而是会每 30 毫秒向工作线程发送新的帧。这可能会导致请求队列过长,尤其是在用户 CPU 性能较慢的情况下。我建议在当前工作线程处理完所有请求之前,不要将所有新请求都发送到工作线程。这可以通过以下方式实现:

这里我定义了一个busy用作信号量的变量。当主线程收到消息时,它会将该busy变量置为 true,以true表明消息处理已开始。之后,所有后续请求都将被忽略,直到前一个请求被处理并返回。此时,该busy变量的值将重置为 false。

我们定义的流程将与主视频循环播放并行运行。以下是完整的源代码object_detector.js

object_detector.js



const video = document.querySelector("video");

const worker = new Worker("worker.js");
let boxes = [];
let interval
let busy = false;
video.addEventListener("play", () => {
    const canvas = document.querySelector("canvas");
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;
    const context = canvas.getContext("2d");
    interval = setInterval(() => {
        context.drawImage(video,0,0);
        draw_boxes(canvas, boxes);
        const input = prepare_input(canvas);
        if (!busy) {
            worker.postMessage(input);
            busy = true;
        }
    },30)
});

worker.onmessage = (event) => {
    const output = event.data;
    const canvas = document.querySelector("canvas");
    boxes =  process_output(output, canvas.width, canvas.height);
    busy = false;
};

video.addEventListener("pause", () => {
    clearInterval(interval);
});

const playBtn = document.getElementById("play");
const pauseBtn = document.getElementById("pause");
playBtn.addEventListener("click", () => {
    video.play();
});
pauseBtn.addEventListener("click", () => {
    video.pause();
});

function prepare_input(img) {
    const canvas = document.createElement("canvas");
    canvas.width = 640;
    canvas.height = 640;
    const context = canvas.getContext("2d");
    context.drawImage(img, 0, 0, 640, 640);
    const data = context.getImageData(0,0,640,640).data;
    const red = [], green = [], blue = [];
    for (let index=0;index<data.length;index+=4) {
        red.push(data[index]/255);
        green.push(data[index+1]/255);
        blue.push(data[index+2]/255);
    }
    return [...red, ...green, ...blue];
}

function process_output(output, img_width, img_height) {
    let boxes = [];
    for (let index=0;index<8400;index++) {
        const [class_id,prob] = [...Array(yolo_classes.length).keys()]
            .map(col => [col, output[8400*(col+4)+index]])
            .reduce((accum, item) => item[1]>accum[1] ? item : accum,[0,0]);
        if (prob < 0.5) {
            continue;
        }
        const label = yolo_classes[class_id];
        const xc = output[index];
        const yc = output[8400+index];
        const w = output[2*8400+index];
        const h = output[3*8400+index];
        const x1 = (xc-w/2)/640*img_width;
        const y1 = (yc-h/2)/640*img_height;
        const x2 = (xc+w/2)/640*img_width;
        const y2 = (yc+h/2)/640*img_height;
        boxes.push([x1,y1,x2,y2,label,prob]);
    }
    boxes = boxes.sort((box1,box2) => box2[5]-box1[5])
    const result = [];
    while (boxes.length>0) {
        result.push(boxes[0]);
        boxes = boxes.filter(box => iou(boxes[0],box)<0.7 || boxes[0][4] !== box[4]);
    }
    return result;
}

function iou(box1,box2) {
    return intersection(box1,box2)/union(box1,box2);
}

function union(box1,box2) {
    const [box1_x1,box1_y1,box1_x2,box1_y2] = box1;
    const [box2_x1,box2_y1,box2_x2,box2_y2] = box2;
    const box1_area = (box1_x2-box1_x1)*(box1_y2-box1_y1)
    const box2_area = (box2_x2-box2_x1)*(box2_y2-box2_y1)
    return box1_area + box2_area - intersection(box1,box2)
}

function intersection(box1,box2) {
    const [box1_x1,box1_y1,box1_x2,box1_y2] = box1;
    const [box2_x1,box2_y1,box2_x2,box2_y2] = box2;
    const x1 = Math.max(box1_x1,box2_x1);
    const y1 = Math.max(box1_y1,box2_y1);
    const x2 = Math.min(box1_x2,box2_x2);
    const y2 = Math.min(box1_y2,box2_y2);
    return (x2-x1)*(y2-y1)
}

function draw_boxes(canvas,boxes) {
    const ctx = canvas.getContext("2d");
    ctx.strokeStyle = "#00FF00";
    ctx.lineWidth = 3;
    ctx.font = "18px serif";
    boxes.forEach(([x1,y1,x2,y2,label]) => {
        ctx.strokeRect(x1,y1,x2-x1,y2-y1);
        ctx.fillStyle = "#00ff00";
        const width = ctx.measureText(label).width;
        ctx.fillRect(x1,y1,width+10,25);
        ctx.fillStyle = "#000000";
        ctx.fillText(label, x1, y1+18);
    });
}

const yolo_classes = [
    'person', 'bicycle', 'car', 'motorcycle', 'airplane', 'bus', 'train', 'truck', 'boat',
    'traffic light', 'fire hydrant', 'stop sign', 'parking meter', 'bench', 'bird', 'cat', 'dog', 'horse',
    'sheep', 'cow', 'elephant', 'bear', 'zebra', 'giraffe', 'backpack', 'umbrella', 'handbag', 'tie', 'suitcase',
    'frisbee', 'skis', 'snowboard', 'sports ball', 'kite', 'baseball bat', 'baseball glove', 'skateboard',
    'surfboard', 'tennis racket', 'bottle', 'wine glass', 'cup', 'fork', 'knife', 'spoon', 'bowl', 'banana', 'apple',
    'sandwich', 'orange', 'broccoli', 'carrot', 'hot dog', 'pizza', 'donut', 'cake', 'chair', 'couch', 'potted plant',
    'bed', 'dining table', 'toilet', 'tv', 'laptop', 'mouse', 'remote', 'keyboard', 'cell phone', 'microwave', 'oven',
    'toaster', 'sink', 'refrigerator', 'book', 'clock', 'vase', 'scissors', 'teddy bear', 'hair drier', 'toothbrush'
];


Enter fullscreen mode Exit fullscreen mode

这是工作线程的代码:

worker.js



importScripts("https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js");

onmessage = async(event) => {
    const input = event.data;
    const output = await run_model(input);
    postMessage(output);
}

async function run_model(input) {
    const model = await ort.InferenceSession.create("./yolov8n.onnx");
    input = new ort.Tensor(Float32Array.from(input),[1, 3, 640, 640]);
    const outputs = await model.run({images:input});
    return outputs["output0"].data;
}


Enter fullscreen mode Exit fullscreen mode

此外,您可以从文件中移除 ONNX 运行时库的导入index.html,因为它已在文件中导入worker.js。这是最终的index.html文件:

index.html



<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Object detector</title>
</head>
<body>
<video controls style="display:none" src="sample.mp4"></video>
<br/>
<canvas></canvas><br/>
<button id="play">Play</button>&nbsp;
<button id="pause">Pause</button>
<script src="object_detector.js" defer></script>
</body>
</html>


Enter fullscreen mode Exit fullscreen mode

如果现在在 Web 服务器上运行该index.html文件,您应该会看到以下结果。

结论

本文展示了如何在网页浏览器中直接使用 YOLOv8 神经网络检测视频中的物体,无需任何后端支持。我们使用 `<video>` HTML 元素加载视频。然后,我们使用 HTML5 Canvas 捕获视频的每一帧,并将其转换为 YOLOv8 模型的输入张量。最后,我们将此输入张量发送给模型,并获得了检测到的物体数组。

此外,我们还发现了如何使用 Web Worker 在 JavaScript 中并行运行多个任务。这样,我们就将机器学习模型的执行代码移到了后台线程,避免因这项 CPU 密集型任务而中断用户界面。

本文的完整源代码可以在此仓库中找到。

本文介绍的算法不仅可以检测视频文件中的物体,还可以检测其他视频源中的物体,例如网络摄像头拍摄的视频。您只需将网络摄像头设置为 `<video>` 元素的视频源即可。其他所有设置保持不变。这只需要几行代码。您可以阅读这篇文章了解如何将网络摄像头连接到 `<video> `video元素

本文创建的项目并非一个完整的、可用于生产环境的解决方案,还有很多需要改进的地方。例如,使用速度更快的目标跟踪算法可以提高速度和精度。与其在每一帧都运行神经网络来检测相同的目标,不如只在第一帧运行神经网络以获取初始目标位置,然后使用目标跟踪算法跟踪后续帧中检测到的边界框。点击此处了解更多关于目标跟踪方法的信息。我将在后续文章中对此进行更深入的探讨。

请在LinkedInTwitterFacebook上关注我,以便第一时间了解此类新文章和其他软件开发新闻。

享受编程的乐趣,永不止步地学习!

文章来源:https://dev.to/andreygermanov/how-to-detect-objects-in-videos-in-a-web-browser-using-yolov8-neural-network-and-javascript-lfb