写在前面
本项目历时一个多月,是我的第一个基于机器视觉检测的项目也是我第一个嵌入式项目,在制作这个项目过程中我遇到了很多困难,曾经一度想过放弃,但是最后还是坚持下来了,感觉对于所学的知识有了更深的理解,感觉成长了许多。
功能需求
硬件设计
ESP32介绍
ESP32是Espressif乐鑫信息科技推出的一块WiFi芯片。
- 拥有40nm工艺、双核32位MCU、2.4GHz双模Wi-Fi和蓝牙芯片、主频高达240MHz,计算能力可达600DMIPS。
- 涵盖精细分辨时钟门控、省电模式和动态电压调整等特征。
- 它集成了天线和射频巴伦,功率放大器,低噪声放大器,滤波器和电源管理模块等元器件,性能稳定,易于制造,工作温度范围从-40℃到125℃
- 支持多种通信协议,如:I2C. I2S. SPI. UART. CA
- 多种调节管理模式:Active模式、Modem-sleep模式、Light-sleep模式、Deep-sleep模式、Hibernation模式。可根据不同需求,调节所需方案。
- 性价比高,体积小。方便嵌入到任何产品,功能强大,支持LWIP协议,freertos,支持三种模式:AP,STA,AP+STA 共存模
esp32使用的Xtensa® 32位LX7处理器是一个双核的处理器,后面我会使用freeRTOS调度双核实现多线程的功能。
研发esp32的公司乐鑫提供了两种esp32开发环境,分别是esp-idf和arduino-esp32两种开发环境。使用espif开发将获得对esp32更加全面的控制,不过缺点是环境配置起来复杂(需要配置Linux环境),而且代码量大,开发效率不高。使用esp32封装的Arduino core将能提高开发效率,所以最后我使用的是基于arduino的开发模式。
ESP32-CAM 介绍
esp32-cam是安信可科技(ai-thinker)开发的一款基于esp32的摄像头模组。
这个模块使用的摄像头是OV2640,OV2640是一款200万像素的摄像头模块,配合上esp32官方的webserverWebServer案例可以轻松实现局域网的视频传输,但是在高像素下帧率感人(我查看了一下OV2640的API,它是可以最高支持的60fps的,理论上在SVGA(800*600)分辨率下也有30帧的速率),在SVGA下只有20帧左右,而且不能通过max_framerate来调节帧率,应该是官方为了限制它的发热而做了锁帧。
使用的官方的webserver历程,发现esp32CAM内部其实烧录了一个非常tiny的人脸识别网络,但是其只能在超低分辨率下运行,而且帧率极低,没什么用。官方让它出现在一个小单片机上我感觉更多的是一种营销手段,根本不能在实际项目中使用。
硬件和电路搭建
搭建(其实就是连线)电路只需要把各个模块用杜邦线连起来即可。在这之前我还设计了一个PCB板子,集成了自动下载电路,降压电路,电机驱动电路。但是自己去打出来自己贴片比直接买零件麻烦多了,所以这个PCB板子就当做练习好了。
接线
TXD:发送端,一般表示为自己的发送端,正常通信必须接另一个设备的RXD。
RXD:接收端,一般表示为自己的接收端,正常通信必须接另一个设备的TXD。
正常通信时候自身的TXD永远接设备的RXD!
摄像头小云台
为了能让摄像头旋转,我使用solidworks制作了一个小云台,之后3D打印出来安上去。
有关SG90 与 L298N的资料
SG90
- 尺寸:21.5mmX11.8mmX22.7mm
- 重量:9克 (1kg=1公斤=2斤)
- 无负载速度:0.12秒/60度(4.8V) 0.002s/度
- 堵转扭矩:1.2-1.4公斤/厘米(4.8V)
- 使用温度:-30~~+60摄氏度
- 死区设定:7us (7MHZ)
- 工作电压:4.8V-6V
- 位置等级:1024级
- 脉冲控制精度为2us
做到后期才发现我这个项目使用SG90控制角度根本不行,实际控制精度在7°左右,对于我这个项目来说是不行的,这里推荐使用更高精度的步进电机或者伺服电机
SG90通过PWM控制旋转角度,舵机需要一个20ms周期的脉冲,以0.5ms到2.5ms的高电平来控制舵机旋转的角度
0.5ms————-0度;
1.0ms————45度;
1.5ms————90度;
2.0ms———–135度;
2.5ms———–180度;
我的控制算法如下:
int calculatePWM(int degree)
{
//0-180度
//20ms周期,高电平0.5-2.5ms,对应0-180度角度
const float deadZone = 6.4;//对应0.5ms(0.5ms/(20ms/256))
const float max = 32;//对应2.5ms
if(degree < 0){degree = 0;}
if(degree > 180){degree = 180;}
return(int)((max-de
}
L298N
控制电机时的逻辑图🧐
软件设计
目标检测模块
目标检测模块我是用的是yolov5模块的第六版v6.0 – YOLOv5n ‘Nano’ models,我使用的时候这个版本的模型才发布了两个星期,网上基本没什么资料,我就只能自己通过看源码来学习。
先来看一下常见的目标检测算法:经典的目标检测算法汇总
除此之外,我还查了一下比较新的算法,比如说旷视科技提出的 YOLOX : Exceeding YOLO Series in 2021 WongKinYiu/yolor 以及最近百度飞桨发布的PaddleDetection
虽然上述算法在很多方面都超越了我使用的yolov5算法,但是因为它的论文太难读懂了,我还是用回熟悉的模型吧。
YOLOv5
YOLOv5模型更新到了第六版,新加入了针对单片机或者同级算力的Nano模型,这个新模型的权重文件史无前例地小,达到了1.9MB。此外我最看重的是改进了针对OpenCV DNN的支持。
通过上图可以看出yolov5的性能已经打到了丧心病狂的地步,就算使用m6级别的权重文件GPU时间也能进10ms。有关Yolov5的更详细介绍请看:深入浅出Yolo系列之Yolov5核心基础知识完整讲解 YOLOv5网络结构学习 yolov5理论学习笔记
因为opencv 目前只支持onnx12版本,这个onnx版本是不支持yolov5 focus结构中的slice操作的,所以需要转换,具体的操作可以参考2021.09.02更新说明 c++下使用opencv部署yolov5模型系列文章。在6.0版本之前,在prediction需要将三个尺度的输出合并成一个,模型的转换需要额外写代码转换,在6.0版本中已经集成了一个output可以直接拿来用(我之前没有仔细看源码这里卡了好久),6.0版本模型变成四个输出,我们使用第四个(最大的)就行,其他三个是用来运算不同尺度的,一般效果不怎么好。我使用一台装有GTX 1660Ti显卡在官方的数据集上进行训练,奈何coco2017数据集规模太大带不动。下面是使用coco108进行的训练结果:
对yolov5模型的检测头进行改进
yolov5 6.0版本还是沿用之前的GIOU算法,该算法是2019年发布的,在2020年的AAAI上发布了D-IOU算法:Distance-IoU Loss: Faster and Better Learning for Bounding Box Regression DIoU 该算法在检测有遮挡的物体部分被证明比GIOU更有效,所以我就使用D-IOU替换了原来的GIOU期望拥有更好的检测效果。
以D-IOU为估价函数加入到NMS算法中能够带来更好的检测效果。有关NMS算法可以参考 NMS (非极大值抑制)
使用神经网络进行目标检测的前向传播最后得到的结果往往有多个方框,NMS算法利用IOU为估价函数对这些方框进行删减,最后留下预测最准确的方框。
下面是DIOU-NMS算法的框架:
下面的我写的实现代码,仅供参考:
// nms
void nms_boxes(vector<Rect>& boxes, vector<float>& confidences, float confThreshold, float nmsThreshold, vector<int>& indices)
{
Output bbox;
vector<Output> bboxes;
int i, j;
for (i = 0; i < boxes.size(); i++)
{
bbox.box = boxes[i];
bbox.confidence = confidences[i];
bbox.id = i;
bboxes.push_back(bbox);
}
sort(bboxes.begin(), bboxes.end(), comp);
int updated_size = bboxes.size();
for (i = 0; i < updated_size; i++)
{
if (bboxes[i].confidence < confThreshold)
continue;
indices.push_back(bboxes[i].id);
for (j = i + 1; j < updated_size; j++)
{
float iou = get_iou_value(bboxes[i].box, bboxes[j].box);
if (iou > nmsThreshold)
{
bboxes.erase(bboxes.begin() + j); j = j - 1;
updated_size = bboxes.size();
}
}
}
}
// diou
static float get_iou_value(Rect rect1, Rect rect2)
{
int xx1, yy1, xx2, yy2, c, d;
xx1 = max(rect1.x, rect2.x);
yy1 = max(rect1.y, rect2.y);
xx2 = min(rect1.x + rect1.width - 1, rect2.x + rect2.width - 1);
yy2 = min(rect1.y + rect1.height - 1, rect2.y + rect2.height - 1);
int a[4] = { rect1.x, rect1.x + rect1.width, rect2.x, rect2.x + rect2.width };
int b[4] = { rect1.y, rect1.y + rect1.height, rect2.y, rect2.y + rect2.height };
c = pow(pow((bigest_smallest(a, 1) - bigest_smallest(a, 0)), 2) + pow((bigest_smallest(b, 1) - bigest_smallest(b, 0)), 2), 0.5);
int insection_width, insection_height;
insection_width = max(0, xx2 - xx1 + 1);
insection_height = max(0, yy2 - yy1 + 1);
d = Euclidean_distance(Point((rect1.x + rect1.width) / 2, (rect1.y + rect1.height) / 2), Point((rect2.x + rect2.width) / 2, (rect2.y + rect2.height) / 2));
float insection_area, union_area, iou, end_area, diou;
insection_area = float(insection_width) * insection_height;
union_area = float(rect1.width * rect1.height + rect2.width * rect2.height - insection_area);
iou = insection_area / union_area;
diou = iou - pow(d, 2) / pow(c, 2);
return diou;
}
对象跟踪
对象跟踪我使用的是FDSST算法,其主要原理是使用相关滤波器从前一张图像中提取特征,在后一张图片进行相关性分析查找相关度最大的区域作为下一次迭代的初始图像。其实YOLOv5也可以做多目标的跟踪,虽然其抗干扰能力非常好,但是性能远不及FDSST算法,所以我最终采用的是FDSST算法。有关相关滤波跟踪算法的历史,可以参考以下文章:将相关滤波跟踪算法的速度做到极致
本文不细讲其相关原理,仅给出大致的思路:
论文中主要描述了一种在视觉跟踪中精准的尺度估计的方法,基于此尺度估计方法提出了DSST(Discriminatiive Scale Space Tracker)算法。该算法分为位置滤波器(Translation Filter)和尺度滤波器(Scale Filter)。这种精准的尺度估计方法可以和任意其他的没有尺度估计的跟踪算法结合。下图是作者给出的算法的效果。DSST算法采用判别相关滤波器(discriminative correlation filters)来确定位置信息,使用文中提出的尺度估计方法确定尺度信息。
该算法相比于其他算法最大的优点是支持尺度变换检测并且尺度估计准确率高。该论文作者Martin Danelljan将算法发布到github上:fDSST_cpp 我修改了里面的尺度因子,使它对尺度变化更加敏感。
网络通信——基于MQTT协议的控制系统
MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于
发布/订阅
(publish/subscribe
)模式的“轻量级”通讯协议,构建于TCP/IP协议上,由IBM在1999年发布。
MQTT最大优点在于, 用极少的代码和有限的带宽,为连接远程设备提供实时可靠的消息服务 。
MQTT 协议的特点:
- MQTT是一个基于客户端-服务器的消息发布/订阅传输协议。
- MQTT协议是轻量、简单、开放和易于实现的,这些特点使它适用范围非常广泛。在很多情况下,包括受限的环境如:机器与机器(M2M)通信和物联网(IoT)。
- 其在,通过卫星链路通信传感器、偶尔拨号的医疗设备、智能家居、及一些小型化设备中已广泛使用。
- MQTT协议当前版本为,2014年发布的MQTT v3.1.1。除标准版外,还有一个简化版
MQTT-SN
,该协议主要针对嵌入设备,这些设备一般工作于TCP/IP网络,如:ZigBee。
- MQTT 与 HTTP 一样,MQTT 运行在传输控制协议/互联网协议 (TCP/IP) 堆栈之上。
QoS(Quality of Service levels)
服务质量是 MQTT 的一个重要特性。当我们使用 TCP/IP 时,连接已经在一定程度上受到保护。但是在无线网络中,中断和干扰很频繁,MQTT 在这里帮助避免信息丢失及其服务质量水平。这些级别在发布时使用。如果客户端发布到 MQTT 服务器,则客户端将是发送者,MQTT 服务器将是接收者。当MQTT服务器向客户端发布消息时,服务器是发送者,客户端是接收者。
QoS 0 这一级别会发生消息丢失或重复,消息发布依赖于底层TCP/IP网络。即:<=1
QoS 1 承诺消息将至少传送一次给订阅者。
QoS 2,我们保证消息仅传送到目的地一次。为此,带有唯一消息 ID 的消息会存储两次,首先来自发送者,然后是接收者。QoS 级别 2 在网络中具有最高的开销,因为在发送方和接收方之间需要两个流。
2 MQTT 数据包结构
固定头(Fixed header)
,存在于所有MQTT
数据包中,表示数据包类型及数据包的分组类标识;
可变头(Variable header)
,存在于部分MQTT
数据包中,数据包类型决定了可变头是否存在及其具体内容;
消息体(Payload)
,存在于部分MQTT
数据包中,表示客户端收到的具体内容;
在搭建MQTT服务时,先是使用了腾讯云的物联网平台来作为MQTT服务器,后来觉得用得不顺手就用自己的服务器,使用开源项目emqx/emqx作为MQTT服务器端实现通讯
在客户端方面,我的esp32cam开发板只需引进PubSubClient
这个库就可以轻松创建MQTT连接。在PC端,使用QT做开发时需要手动根据emqx/qmqtt编译安装MQTT插件才能进行通讯。在通讯过程中使用的一般是JSon作为数据交换的格式。单片机上需要引入ArduinoJson
这个库对JSon数据进行封装和解析,PC机上我使用的是QJsonObject和QJsonDocument两个库。对于我的这个项目我用到的Json数据格式如下:
// 上位机发出的
{
"Config": { // 这里的数据不一定每次都会发出,PConline只会在连接上MQTT服务器之后发出,jpeg_quality 只会在需要更改图片质量时发出
"PConline": 1,
"jpeg_quality": 12
},
"Control": {
"leftWheels": 0.00,
"rightWheels": 0.00,
"servoAngle": 90
}
}
// esp32发出的
{
"State": {
"InitState": "ESP32CAMOK!", // 这里的数据不一定每次都会发出
"IPAddress": "192.168.137.27",
"StreamUrl": "http://192.168.137.27/mjpeg/1"
}
}
下面是我的实现代码:
// MQTT发布函数
void publisher()
{
StaticJsonDocument<250> doc;
JsonObject State = doc.createNestedObject("State"); //添加一个对象节点
State["InitState"] = "ESP32CAMOK!";
State["IPAddress"] =ip.toString();
State["StreamUrl"] ="http://"+ip.toString()+"/mjpeg/1";
String msg;
serializeJson(doc, msg);
char sendmsg[250];
msg.toCharArray(sendmsg,250);
pclient.publish(TOPIC_PUBLIHER,sendmsg);
}
// MQTT 指令解析
void commandHandle(String recivemsg)
{
StaticJsonDocument<200> doc;
DeserializationError error = deserializeJson(doc, recivemsg); //反序列化JSON数据
if (!error) //检查反序列化是否成功
{
if (doc.containsKey("State"))
{
if (doc["State"]["runningState"] == 0)
{
Motor_Control(0,0,90);
}
}
if (doc.containsKey("Control"))
{
float Cnt_L_temp = doc["Control"]["leftWheels"];
float Cnt_R_temp = doc["Control"]["rightWheels"];
int SG = doc["Control"]["servoAngle"];
Motor_Control(Cnt_L_temp*1024,Cnt_R_temp*1024,SG);
}
if (doc.containsKey("Config"))
{
if (doc["Config"].containsKey("jpeg_quality"))
{
config.jpeg_quality = doc["Config"]["jpeg_quality"];
}
if (doc["Config"].containsKey("PConline"))
{
Serial.println("连接到PC!");
delay(2000);// 延迟2s之后把网关信息发给PC
publisher();
}
}
}
}
// 消息发布函数
void ControlProcessThread::published(float leftWheel,float rightWhell,int servoAngle, int imageQuality,bool sendPConline)
{
if(m_client->state() == QMqttClient::Connected)
{
leftWheel *= m_velocityC;
rightWhell *= m_velocityC;
QString L=QString::number(leftWheel,'f',2);
QString R=QString::number(rightWhell,'f',2);
QJsonObject json;
QJsonObject Config;
QJsonObject Control;
static int servo=90;
if(servoAngle!=90){servo = servoAngle;}
QJsonDocument document;
if(imageQuality!=12)
{
Config.insert("jpeg_quality",imageQuality);
document.setObject(Config);
}
if(sendPConline){Config.insert("PConline",1);json.insert("Config",QJsonValue(Config));}
Control.insert("leftWheels",L);
Control.insert("rightWheels",R);
Control.insert("servoAngle",servo);
json.insert("Control",QJsonValue(Control));
document.setObject(json);
QByteArray messagePub = document.toJson(QJsonDocument::Compact);
// 以下代码的作用是将浮点数截断成两位,QJsonDocument 对于Qt float 这种数据格式会转换成16位
messagePub.remove(messagePub.indexOf("leftWheels",0)+12,L.length()+2);
messagePub.insert(messagePub.indexOf("leftWheels",0)+12,L);
messagePub.remove(messagePub.indexOf("rightWheels",0)+13,R.length()+2);
messagePub.insert(messagePub.indexOf("rightWheels",0)+13,R);
m_client->publish(m_PublishTopic, messagePub, 0);
return;
}
emit sigSendControlInfo(QString::fromLocal8Bit("请连接后重新操作"));
}
// 消息接收函数
void ControlProcessThread::topicMessageReceived(QByteArray message, QMqttTopicName topic)
{
emit sigSendControlInfo(QString::fromLocal8Bit("收到消息"));
QString content;
QDateTime Time = QDateTime::currentDateTime();
content = Time.toString("hh:mm:ss") + QLatin1Char(' ');
content += QLatin1String(" Received Topic:\n [ ") + topic.name() +QLatin1String("]")+ QLatin1Char('\n');
content += QLatin1String(" Message: \n ") + message + QLatin1Char('\n');
emit sigSendControlInfo(content);
QJsonParseError jsonError;
QJsonDocument doucment = QJsonDocument::fromJson(message, &jsonError); // 转化为 JSON 文档
if (!doucment.isNull() && (jsonError.error == QJsonParseError::NoError))
{
QJsonObject object = doucment.object();
if(object.contains("State"))
{
QJsonValue state = object.value("State");
QJsonObject obj = state.toObject();
if(obj.value("InitState").toString()=="ESP32CAMOK!"){emit sigSendControlInfo(QString::fromLocal8Bit("与ESP32成功通信!"));}
QJsonValue IP = obj.value("IPAddress");
QJsonValue streamURL = obj.value("StreamUrl");
published(0,0,90,12);
emit sigGetStreamAddress(streamURL.toString());
emit sigSendControlInfo(QString::fromLocal8Bit("成功获得ESP32小车IP地址")+IP.toString());
}
}
}
通过以上代码就可以实现无论哪个客户端先上线,最后都可以实现两个客户端都知道对方的状态。
网络通讯——视频传输
视频传输我一开始是准备使用Socket进行传输的,但是接收到的数据需要进行图像解码,过程有些复杂,最后还是通过webserver的方式进行图片数据的传输,OpenCV的videocapture类能够直接通过串流地址解析视频流,比较省事。但是我之前还是收集了一些有关mjpeg图像解码的资料,在这里也一并记录一下。
mjpeg解码
Motion JPEG(M-JPEG 或MJPEG ,Motion Joint Photographic Experts Group ,FourCC:MJPG) 是一种影像压缩格式,其中每一帧图像都分别使用JPEG 编码 。
M-JPEG常用在数字相机和摄像头之类的图像采集设备上。MJPEG即动态JPEG,按照至少达到25帧/秒速度使用JPEG压缩算法压缩视频信号,完成动态视频的压缩。MJPEG压缩标准是由JPEG专家组制定的,其图像格式是对每一帧JPEG图像进行压缩。MJPEG是一种基于静态图像压缩技术JPEG发展起来的动态图像压缩技术,可以生成序列化的运动图像。实际上MJPEG图像数据流就是一帧一帧的JPEG 格式图片 。
M-JPEG只使用帧内压缩(区别于算法更复杂的帧间压缩),只单独的对某一帧进行压缩,而不考虑影像畫面中不同帧之间的变化。因此压缩效率比较低,一般低于1:20,而使用了帧间压缩的现代影像压缩格式(如MPEG1、MPEG2和H.264/MPEG-4 AVC)一般能超过1:50.由于各帧直接是相互独立的,M-JPEG的编解码在对运算能力和内存的要求较低。
JFIF 是 JPEG File Interchange Format 的缩写,也即 JPEG 文件交换格式。JFIF 是一个图片文件格式标准,它是一种使用 JPEG 图像压缩技术存储摄影图像的方法。JFIF 代表了一种”通用语言”文件格式,它是专门为方便用户在不同的计算机和应用程序间传输 JPEG 图像而设计的语言。 JPEG委员会在制定JPEG标准时,定义了许多标记码(marker)或标记段(marker segments)组成,用来区分和识别图像数据及其相关信息。目前,使用比较广泛的是其交换格式JFIF(Jpeg File Interchange Format)。JPEG的每个标记码都是由2 个字节组成,其前一个字节是固定值0xFF ,每个标记码之前还可以添加数目不限的0xFF填充字节。JPEG文件中的字节是按照正序排列的,即高位字节在前,低位字节在后 。
参考文章:MJPEG格式和码流分析 视频编解码类型MJPEG数据格式介绍我们解码主要是为了将mjpeg图像解码为原始的RGB\YUV数据,可以使用libjpeg,mjpegtools,nvjpeg等库来解码解码可以参考:OpenCV_Test
具体流程是在Socket中读出全部数据,之后需要找到图片开头位置并读出头信息(数据流长度等信息),之后从(byte)0xFF, (byte)0xD8 开始读一定的数据,相关的代码参考了opencv读取esp32cam摄像头视频流 这篇文章,里面讲了使用VS studio进行视频流的读取。
使用webserver 传输视频流
具体流程不在细讲,主要思路是在esp32中建立一个webserver,之后读取视频帧,在客户端通过get方法获取到视频流。客户端就是上位机,利用OpenCV的openCapture类就可以直接通过网址拿到流。esp32cam中建立webserver的代码如下:
// 视频传输
OV2640 cam;
WebServer server(80);
camera_config_t config;
const char HEADER[] = "HTTP/1.1 200 OK\r\n" \
"Access-Control-Allow-Origin: *\r\n" \
"Content-Type: multipart/x-mixed-replace; boundary=123456789000000000000987654321\r\n";
const char BOUNDARY[] = "\r\n--123456789000000000000987654321\r\n";
const char CTNTTYPE[] = "Content-Type: image/jpeg\r\nContent-Length: ";
const int hdrLen = strlen(HEADER);
const int bdrLen = strlen(BOUNDARY);
const int cntLen = strlen(CTNTTYPE);
const char JHEADER[] = "HTTP/1.1 200 OK\r\n" \
"Content-disposition: inline; filename=capture.jpg\r\n" \
"Content-type: image/jpeg\r\n\r\n";
const int jhdLen = strlen(JHEADER);
void handle_jpg_stream(void)
{
char buf[32];
int s;
WiFiClient client = server.client();
client.write(HEADER, hdrLen);
client.write(BOUNDARY, bdrLen);
while (true)
{
if (!client.connected()) break;
cam.run();
s = cam.getSize();
client.write(CTNTTYPE, cntLen);
sprintf( buf, "%d\r\n\r\n", s );
client.write(buf, strlen(buf));
client.write((char *)cam.getfb(), s);
client.write(BOUNDARY, bdrLen);
}
}
void handle_jpg(void)
{
WiFiClient client = server.client();
if (!client.connected()) return;
cam.run();
client.write(JHEADER, jhdLen);
client.write((char *)cam.getfb(), cam.getSize());
}
void handleNotFound()
{
String message = "Server is running!\n\n";
message += "URI: ";
message += server.uri();
message += "\nMethod: ";
message += (server.method() == HTTP_GET) ? "GET" : "POST";
message += "\nArguments: ";
message += server.args();
message += "\n";
server.send(200, "text / plain", message);
}
// setup 函数需要对相机参数进行初始化
// 程序主循环:核心1
void loop()
{
server.handleClient();
}
总体软件框架设计
最后放一张软件框架图结束我的笔记:
项目做得挺好啊