YOLO Web API设计随笔

本文Notion链接:Notion

为什么FastAPI?

很显然,作为一群对yolov8尚不了解的大学生。。能够直接使用python进行detect任务对调用是一个极为便利的途径。

或者说我们其实并不清楚怎样用其他的方式使用yolo,从官方的文档中看,我们或许可以将模型转译为tensorflow.js
所需的格式,从而更加从容的使用nodejs作为后端??也许之后会尝试

至于说为什么用fastapi而不是flask,我问了chatgpt,他这么说:

基于 YOLO 模型设计出供 Web 程序调用的 API,你可能会面临处理图像数据、模型推理、API 构建等多个方面的任务。根据这些需求,我会推荐使用
FastAPI。以下是一些原因:

  1. 异步支持: YOLO 模型的推理通常是相对耗时的任务,而 FastAPI 的异步能力可以在处理多个请求时提供更好的性能和响应时间。
  2. 类型提示和数据验证: YOLO 模型的输入和输出数据通常有特定的格式,使用 FastAPI
    的类型提示和数据验证功能可以确保请求和响应的数据格式正确,并在开发过程中就能发现问题。
  3. 自动文档生成: 你可以使用 FastAPI 自动生成交互式的 API 文档,其中包括请求和响应的数据模型、参数以及示例请求和响应。这对于与其他开发者合作或测试
    API 非常有用。
  4. 依赖注入和模块化: FastAPI 的依赖注入系统可以帮助你将不同的功能模块化,比如图像处理、模型推理等,使代码更易于管理和测试。
  5. 活跃的社区和发展: FastAPI 的社区不断壮大,你可以获得来自其他开发者的支持和贡献,以及许多现成的库和工具,以满足你的需求。

总之,基于 YOLO 模型构建 Web API 需要考虑多个方面,FastAPI 是一个强大且适合现代异步 Web 开发的框架,可以帮助你满足这些需求。

说到底,fastapi的fast真的很吸引我。(起个好名字的重要性)

那就开始吧

首先来看看我希望这个api去做些什么

在上个期末完成了对模型的训练后,已经通过Gradio完成了一个简单的ui实现

Untitled

那么这个方案有什么问题吗?

有,太慢了!

这种慢并不是模型运行的耗时而是在一个垃圾云服务器上因为带宽限制导致的。没错,gradio给我们的输入和输出方式定义为了图片,也就是我们要把这张图来回上传下载两次,这无疑使愚蠢的。

一种我理解中(我以为)的工作方式

一种我理解中(我以为)的工作方式

众所周知
webui可是跑在浏览器上的,如果我们把绘制边框的操作交给浏览器(本地)进行,服务器只是回传一段表示位置的数据,那是不是我们就节省了回传一张图片的时间~~
(获得50%的性能提升)~~

另一种我理解中(我以为)的工作方式

另一种我理解中(我以为)的工作方式


从yolo中获取我们所需要的坐标数据

关于坐标数据我们一开始就知道是存在的(废话),也就是save进txt里的那些。但问题是yolov8封装的实在是太好了,他的默认方式居然就是返回图片。好在yolo项目组在假期这几个月里面确确实实有在更新项目,特别是文档,终于变的能看了。现在他们居然写出了整整一页的predict方法解析!!

Predict

在**Working with Results
一节中,我们终于看到了result的真实面目:一个对象()**

Attribute Type Description
orig_img numpy.ndarray The original image as a numpy array.
orig_shape tuple The original image shape in (height, width) format.
boxes Boxes, optional A Boxes object containing the detection bounding boxes.
masks Masks, optional A Masks object containing the detection masks.
probs Probs, optional A Probs object containing probabilities of each class for classification task.
keypoints Keypoints, optional A Keypoints object containing detected keypoints for each object.
speed dict A dictionary of preprocess, inference, and postprocess speeds in milliseconds per image.
names dict A dictionary of class names.
path str The path to the image file.
属性 类型 描述
orig_img numpy.ndarray 原始图像的numpy数组。
orig_shape tuple 以(高度,宽度)格式表示的原始图像形状。
boxes Boxes, 可选 包含检测边界框的Boxes对象。
masks Masks, 可选 包含检测掩码的Masks对象。
probs Probs, 可选 包含每个类别的概率的Probs对象,用于分类任务。
keypoints Keypoints, 可选 包含每个对象检测到的关键点的Keypoints对象。
speed dict 每张图像的预处理、推理和后处理速度的毫秒字典。
names dict 类名的字典。
path str 图像文件的路径。

注意这个numpy.ndarray 我们可以理解为这个就是图片在python中的多维数组形式

再回来看我们常用的plot命令:

Method Return Type Description
plot() numpy.ndarray Plots the detection results. Returns a numpy array of the annotated image.
方法 返回类型 描述
plot() numpy.ndarray 绘制检测结果。返回一个带注释的图像的 numpy 数组。

是不是一切都解释的通了


综上,我们只需要从result对象中提取出我们所需要的信息发送给前端,忽略掉冗长的ndarray 就实现我们前面提到的目标了。

还有一个好消息,除了plot,yolo也提供了另一个方法:

Method Return Type Description
tojson() None Convert the object to JSON format.
方法 返回类型 描述
tojson() 将对象转换为 JSON 格式。

我们来看一下效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from ultralytics import YOLO

ROOT = 'D:/Code/ultralytics'
# Load a model
model = YOLO(ROOT + '/ultralytics/trainresult/v8_170best.pt') # pretrained YOLOv8n model
testimgset = [
'images/cube1.jpg',
]
# Run batched inference on a list of images
results = model.predict(testimgset) # return a generator of Results objects

# Process results generator
for result in results:
result_tojson = result.tojson()
print(result_tojson)

JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
[
{
"name": "cube",
"class": 1,
"confidence": 0.7532966136932373,
"box": {
"x1": 85.58421325683594,
"y1": 220.336669921875,
"x2": 379.03240966796875,
"y2": 566.9713134765625
}
}
]

刚刚好,我们需要的信息都有了,接下来我们要做的就是把这些信息发给我们的浏览器


尝试把JSON信息发送给前端

编写API

我们以最简单的形式入手,当我们访问到某个特定的节点时,我们显示出上面提到的json数据

也就是我们通过浏览器访http://127.0.0.1:8000/detect_results/时,获取到上面提到的json信息。

为了处理这个请求[1],需要在FastAPI应用中创建一个路由[2],使用 @app.get() 装饰器,从而定义一个处理 GET 请求的API端点。

1
2
3
4
5
6
# 修改路由定义,使用 @app.get()
@app.get("/detect_results/")
async def detect_results():
results = run_inference(testimgset) # Run inference on test images
return JSONResponse(content=results)

[3]

这样,当你在浏览器中访问http://127.0.0.1:8000/detect_results/ 时,FastAPI应用将会处理 GET 请求,并将响应的 JSON
信息返回给浏览器。可以在浏览器中看到 JSON 数据。

1
"[\n  {\n    \"name\": \"cube\",\n    \"class\": 1,\n    \"confidence\": 0.7532966136932373,\n    \"box\": {\n      \"x1\": 85.58421325683594,\n      \"y1\": 220.336669921875,\n      \"x2\": 379.03240966796875,\n      \"y2\": 566.9713134765625\n    }\n  }\n]"

对上面的一点解释

  1. 浏览器访问网址的原理是使用 GET 请求来获取网页内容,所以我们需要编写一个处理GET请求的API

  2. 在Web开发中,路由(Route)是一种将URL与特定功能或处理程序相关联的机制。它指定了在访问特定URL时应该执行的代码块或处理逻辑。路由充当了Web应用中不同页面、功能或操作之间的映射关系。每个URL路径都对应一个特定的路由,当访问该URL时,路由会调用与之相关联的处理程序或功能。

  3. 这段代码包含以下几个步骤

    • 步骤 1:导入 FastAPI
    1
    from fastapi import FastAPI

    FastAPI 是一个为你的 API 提供了所有功能的 Python 类。

    • 步骤 2:创建一个 FastAPI「实例」
    1
    app = FastAPI()

    这里的变量 app 会是 FastAPI 类的一个「实例」。

    这个实例将是创建你所有 API 的主要交互对象。

    这个 app 同样在如下命令中被 uvicorn 所引用:

    1
    uvicorn main:app --reload
    • 步骤 3:创建一个路径(路由)操作

    路径

    这里的「路径」指的是 URL 中从第一个 / 起的后半部分。

    所以,在一个这样的 URL 中https://example.com/items/foo路径会是:/items/foo

    操作

    这里的「操作」指的是一种 HTTP「方法」。下列之一:POSTGETPUTDELETE

    …以及更少见的几种:OPTIONSHEADPATCHTRACE

    在开发 API 时,你通常使用特定的 HTTP 方法去执行特定的行为。

    通常使用:

    • POST:创建数据。
    • GET:读取数据。
    • PUT:更新数据。
    • DELETE:删除数据。

    定义路径操作装饰器

    1
    @app.get("/")

    @app.get("/") 告诉 FastAPI 在它下方的函数负责处理如下访问请求:

    • 请求路径为 /
    • 使用get 操作

    @something 语法在 Python
    中被称为「装饰器」。像一顶漂亮的装饰帽一样,将它放在一个函数的上方(我猜测这个术语的命名就是这么来的)。装饰器接收位于其下方的函数并且用它完成一些工作。在我们的例子中,这个装饰器告诉
    FastAPI 位于其下方的函数对应着路径 / 加上 get 操作。它是一个「路径操作装饰器」。

    • 步骤 4:定义路径操作函数

    这是一个 Python 函数。每当 FastAPI 接收一个使用 GET 方法访问 URL「/」的请求时这个函数会被调用。

    1
    2
    3
    @app.get("/")
    async def root():
    return {"message": "Hello World"}

    关于是否使用异步函数参考:

    Concurrency and async / await - FastAPI

    • 步骤 5:返回内容
    1
    2
    3
    @app.get("/")
    async def root():
    return {"message": "Hello World"}

    💡 待补充

引入参数

在上面的例子里,我们将图片的路径写死了(存储在服务器的变量中)这显然是不合理的,毕竟我们不希望我们的API只能识别一张图片。

路径参数

可以使用与 Python 格式化字符串相同的语法来声明路径"参数"或"变量":

1
2
3
@app.get("/items/{item_id}")
async def read_item(item_id):
return {"item_id": item_id}

不难理解,就是在路径中生命了函数的参数

有类型的路径参数

1
2
3
@app.get("/items/{item_id}")
async def read_item(item_id: int):
return {"item_id": item_id}

在这个例子中,item_id 被声明为 int 类型。

设定合法路径值(枚举)

路径参数 - FastAPI

查询参数

声明不属于路径参数的其他函数参数时,它们将被自动解释为"查询字符串"参数

查询参数 - FastAPI


搓一个简单的前端页面

我们直接告诉chatgpt:模仿gradio框架的样式,生成一个图像识别的前端页面,要求包含标题栏,二级标题栏,图像输入上传与展示区,图像结果生成区,置信度调节条,日志输出区

接着,我们稍加整理,就得到了这些

  • index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
<!doctype html>
<html>

<head>
<title>图像识别前端页面</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="style.css" />
</head>

<body>
<div class="header">
<h1>图像识别前端页面</h1>
</div>
<div class="sub-header">
<h2>图像上传与展示</h2>
</div>
<div class="container">
<div class="image-upload">
<input type="file" id="imageInput" accept="image/*" />
</div>
<div class="image-display">
<img id="uploadedImage" src="#" alt="Uploaded Image" style="max-width: 100%" />
</div>
</div>
<div class="sub-header">
<h2>图像结果与绘制</h2>
</div>
<div class="container">
<div class="result-area">
<h3>识别结果:</h3>
<div class="image-display">
<img id="resultImage" src="#" alt="Result Image" style="max-width: 100%" />
<canvas id="boundingCanvas"></canvas>
</div>
</div>
</div>
<div class="sub-header">
<h2>置信度调节</h2>
</div>
<div class="container">
<input type="range" id="confidenceSlider" class="confidence-slider" min="0" max="100" value="50" />
</div>
<div class="sub-header">
<h2>日志输出</h2>
</div>
<div class="container">
<div class="log-area" id="logOutput"></div>
</div>
<!-- JavaScript 代码 -->
<script src="main.js"></script>
</body>

</html>
  • style.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
/* 样式设置 */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
}

.header {
background-color: #333;
color: white;
text-align: center;
padding: 10px;
}

.sub-header {
background-color: #555;
color: white;
padding: 5px;
}

.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}

.image-upload {
margin-bottom: 20px;
}

.image-display {
text-align: center;
margin-bottom: 20px;
}

.result-area {
border: 1px solid #ccc;
padding: 10px;
margin-bottom: 20px;
}

.confidence-slider {
width: 100%;
margin-bottom: 10px;
}

.log-area {
border: 1px solid #ccc;
padding: 10px;
background-color: #f5f5f5;
max-height: 150px;
overflow: auto;
}

#boundingCanvas {
position: absolute;
/* 绝对定位,使canvas元素覆盖在img元素上方 */
/* 防止canvas元素干扰鼠标事件 */
/* pointer-events: none; */
z-index: 1;
border: 2px solid rgb(223, 124, 37);
border-radius: 10px;
}
  • main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const imageInput = document.getElementById("imageInput");
const uploadedImage = document.getElementById("uploadedImage");
const resultImage = document.getElementById("resultImage");
const boundingCanvas = document.getElementById("boundingCanvas");

var bounding = { x: 100, y: 200, width: 180, height: 200 };

// 在窗口大小变化时更新canvas位置
window.addEventListener("resize", updateWithDraw(bounding));

// 初始加载时调用一次
updateCanvasPosition();

imageInput.addEventListener("change", function () {
const file = imageInput.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
uploadedImage.src = e.target.result;
};
reader.readAsDataURL(file);

// Simulate recognition result
// You would need to replace this with actual logic
setTimeout(function () {
resultImage.src = uploadedImage.src;

resultImage.onload = function () {
updateWithDraw(bounding);
};
}, 500);
}
});

// 绘制bounding box
function drawBoundingBox(bounding) {
const canvasContext = boundingCanvas.getContext("2d");

// 设置绘制样式
canvasContext.strokeStyle = "red";
canvasContext.lineWidth = 2;

// 绘制bounding box
canvasContext.beginPath();
canvasContext.rect(bounding.x, bounding.y, bounding.width, bounding.height);
canvasContext.stroke();
canvasContext.closePath();
console.log(bounding);
}

//清除Canvas
function clearCanvas() {
const canvasContext = boundingCanvas.getContext("2d");
canvasContext.clearRect(0, 0, boundingCanvas.width, boundingCanvas.height);
}

// 更新canvas位置函数
function updateCanvasPosition() {
const resultImageRect = resultImage.getBoundingClientRect();
const scrollTop = document.documentElement.scrollTop;
console.log(resultImageRect);
boundingCanvas.style.position = "absolute";
boundingCanvas.style.left = resultImageRect.left + "px";
boundingCanvas.style.top = resultImageRect.top + scrollTop + "px";
boundingCanvas.width = resultImageRect.width;
boundingCanvas.height = resultImageRect.height;
}

function updateWithDraw(bounding) {
updateCanvasPosition();
drawBoundingBox(bounding);
}

这边采用了canvas来绘制图像的表示框,为了将canvas覆盖到输出图片上,我们在css中声明定位为绝对位置,z轴层级为1(保持在最上层)并且定义了一个更新canvas位置的函数来响应图片尺寸的变化和窗口可能存在的缩放。

现在我们只需要将绘制的坐标更新为实际的位置,并且将触发绘制函数更改为接收到结果时触发即可完成真正的识别展示逻辑。


页面大概长这个样子,使用npm+vite构建,如果你搞到了我这份代码你应该这样运行:

首先来到vite的目录下(长这样):

1
2
3
4
5
6
---xxx/xxx/vite
|-public
|-index.html
|-main.js
|-style.css
|-package.json

然后打开Terminal,输入 npm i 安装依赖包(前提是你装好了node.js(和npm npm正常情况下随node一同安装))之后会看到出现了一个node_modules文件夹,这就是依赖包

之后可以使用 npm run dev 来开启开发服务器,这个具体的执行命令会参照package.json"scripts"的部分。正常情况下dev命令只调用 vite,我添加了—host参数来进行局域网内的访问(而不是localhost)。

1
2
3
4
5
"scripts": {
"dev": "vite --host",
"build": "vite build",
"preview": "vite preview"
},

之后就可以在默认端口5173看到部署的网页了

Untitled

绘制框的效果如下

Untitled


把图片传给存储服务器

考虑到图像的采集基本上是由单片机(esp32)编码发送,我们暂且使用js在前端模拟(也就是上面提到的上传功能)以mqtt协议发送jepg图片到服务器。

对于MQTT服务器(broker)的选择,这里采用EMQX开源方案作为MQTT代理服务器(MQTT Broker),当然也可以使用MQTT.js自行构建一个server。

  • 关于EMQX:

产品概览

MQTT通信架构

我们先了解一下MQTT,MQTT采用一种发布-订阅的模式进行通信,具体的示意可看下图:

Untitled


让我们通过对 mqtt.js 的操作来解释这些概念。

首先,在作为发布者(publisher)客户端时,最主要的操作就是发布主题。这可以通过以下方法实现:

1
publish(topic: string, message: string | Buffer, callback?: DoneCallback): MqttClient;

从这个方法中,我们可以明确需要提供两个参数。一个是待发布的主题,另一个则是要发布的内容。这个过程就类似于我们在群聊中发送消息,我们需要知道要发送到 哪个群 以及要 发送什么内容

同时,在消息的接收端,也就是订阅者(subscriber)客户端,主要的操作是订阅主题。这可以通过以下方法完成:

1
subscribe(topicObject: string | string[] | ISubscriptionMap, callback?: ClientSubscribeCallback): MqttClient;

随后,您将能够接收到与所订阅主题相关的消息。通过使用.on 方法监听 message 事件,您可以进一步对接收到的负载(消息内容)进行操作(回调函数)。

我们会在后面的内容中详细说明mqtt.js 的具体操作

MQTT Broker搭建

参考官方文档:

Docker 部署指南

既然官方推荐我们使用docker进行部署那就不妨试试,顺便练一下docker怎么用

【GeekHour】30分钟Docker入门教程_哔哩哔哩_bilibili


部署成功后,将端口映射到物理机相同端口,访问 localhost:18083进入默认管理界面

MQTT 客户端(Publisher)编写

我们暂时使用javascript来代替硬件作为发布端

使用库:MQTT.js

MQTT.js 入门教程