C++基于OpenCV实现实时监控和运动检测记录

基于OpenCV实现实时监控并通过运动检测记录视频

一、课程介绍

1. 课程来源

课程使用的操作系统为 Ubuntu 14.04,OpenCV 版本为OpenCV 2.4.13.1,你可以在这里查看该版本 OpenCV 的文档。官方文档中有两个例子可以帮助你理解此课程,分别是

你可以在我的 Github仓库 上找到 Windows 系统对应的 Visual Studio 工程。全部代码文件也可以在我的仓库中找到。

这里提供了完整的代码 http://labfile.oss.aliyuncs.com/courses/671/monitor-recorder.zip 。

2. 内容简介

  • 课程实验使用PC机自带的摄像头作为监视器进行实时监控。
  • 对原始图像做一定处理,使监控人员或监控软件更易发现监控中存在的问题。
  • 当摄像头捕捉到运动产生时自动记录视频。

3. 课程知识点

本课程项目完成过程中将学习:

  • 对摄像头数据的捕获
  • 对捕获到的监控帧作背景处理
  • 对监控视频做运动检测并记录视频

二、实验环境

  • 本实验需要先在实验平台安装 OpenCV ,需下载依赖的库、源代码并编译安装。安装过程建议按照教程给出的步骤,或者你可以参考官方文档中 Linux 环境下的安装步骤,但 有些选项需要变更。安装过程所需时间会比较长,这期间你可以先阅读接下来的教程,在大致了解代码原理后再亲自编写尝试。

  • 我提供了一个编译好的2.4.13-binary.tar.gz包,你可以通过下面的命令下载并安装,节省了编译的时间,通过这个包安装大概需要20~30分钟,视实验楼当前环境运转速度而定。

    $ sudo apt-get update
    $ sudo apt-get install build-essential libgtk2.0-dev libjpeg-dev libtiff5-dev libjasper-dev libopenexr-dev cmake python-dev python-numpy python-tk libtbb-dev libeigen2-dev yasm libfaac-dev libopencore-amrnb-dev libopencore-amrwb-dev libtheora-dev libvorbis-dev libxvidcore-dev libx264-dev libqt4-dev libqt4-opengl-dev sphinx-common texlive-latex-extra libv4l-dev libdc1394-22-dev libavcodec-dev libavformat-dev libswscale-dev
    $ cd ~
    $ mkdir OpenCV && cd OpenCV
    $ wget http://labfile.oss.aliyuncs.com/courses/671/2.4.13-binary.tar.gz
    $ tar -zxvf 2.4.13-binary.tar.gz
    $ cd opencv-2.4.13
    $ cd build
    $ sudo make install
    
  • 如果你想体验编译的整个过程,我也提供了一个一键安装的脚本文件,你可以通过下面的命令尝试。这个过程会非常漫长,约2小时,期间可能还需要你做一定的交互确认工作。

    $ cd ~
    $ sudo apt-get update
    $ wget http://labfile.oss.aliyuncs.com/courses/671/opencv.sh
    $ sudo chmod 777 opencv.sh
    $ ./opencv.sh
    
  • 如果你觉得有必要亲自尝试一下安装的每一步,可以按照下面的命令逐条输入执行,在实验楼的环境中大概需要两个小时。

    $ sudo apt-get update
    $ sudo apt-get install build-essential libgtk2.0-dev libjpeg-dev libtiff5-dev libjasper-dev libopenexr-dev cmake python-dev python-numpy python-tk libtbb-dev libeigen2-dev yasm libfaac-dev libopencore-amrnb-dev libopencore-amrwb-dev libtheora-dev libvorbis-dev libxvidcore-dev libx264-dev libqt4-dev libqt4-opengl-dev sphinx-common texlive-latex-extra libv4l-dev libdc1394-22-dev libavcodec-dev libavformat-dev libswscale-dev
    $ wget https://github.com/Itseez/opencv/archive/2.4.13.zip
    $ unzip 2.4.13.zip
    $ cd 2.4.13
    $ mkdir release && cd release
    $ cmake -D WITH_TBB=ON -D BUILD_NEW_PYTHON_SUPPORT=ON -D WITH_V4L=ON -D INSTALL_C_EXAMPLES=ON -D INSTALL_PYTHON_EXAMPLES=ON -D BUILD_EXAMPLES=ON -D WITH_QT=ON -D WITH_GTK=ON -D WITH_OPENGL=ON ..
    $ sudo make
    $ sudo make install
    $ sudo gedit /etc/ld.so.conf.d/opencv.conf   
    $ 输入 /usr/local/lib,按 Ctrl + X 退出,退出时询问是否保存,按 Y 确认。
    $ sudo ldconfig -v
    $ sudo gedit /etc/bash.bashrc
    $ 在文件末尾加入
    $ PKG_CONFIG_PATH=$PKG_CONFIG_PATH:/usr/local/lib/pkgconfig
    export PKG_CONFIG_PATH
    按 Ctrl + X 退出,按 Y 确认保存。
    
  • 检验配置是否成功。将 OpenCV 自带的例子(在目录PATH_TO_OPENCV/samples/C下)运行检测。如果成功,将显示 lena 的脸部照片,同时圈出其面部。

    $ cd samples/C
    $ ./build_all.sh
    $ ./facedetect --cascade="/usr/local/share/OpenCV/haarcascades/haarcascade_frontalface_alt.xml" --scale=1.5 lena.jpg
    

三、实验原理

实验通过 OpenCV 提供的 API 完成大部分任务,首先捕获摄像头数据,之后对捕获到的每一帧作背景减除处理,得出易于识别的图像,最后利用直方图做实时图像和背景图像的对比,实现运动检测并写入视频文件。

四、实验步骤

通过以下命令可下载项目源码,作为参照对比完成下面详细步骤的学习。

wget http://labfile.oss.aliyuncs.com/courses/671/monitor-recorder.zip
unzip monitor-recorder.zip

1.定义头文件

工程文件由一个头文件 monitor.hpp 和一个入口文件 main.cpp 构成。首先在头文件中定义将使用的库和相关变量。

代码中使用到的 OpenCV 头文件和 C++ 头文件在头文件 monitor.hpp 中声明如下,其中 unistd.h 包含了 Linux 下的 sleep 函数,参数为睡眠的秒数。

#ifndef __MONITOR_H_
#define __MONITOR_H_

//opencv
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
#include <opencv2/video/background_segm.hpp>
#include <opencv2/objdetect/objdetect.hpp>
#include <opencv2/imgproc/imgproc.hpp>
//C++
#include <ctime>
#include <iostream>
#include <string>
#include <cstdio>
#include <unistd.h> 
#include <sstream>

using namespace cv;
using namespace std;

// ...

#endif __MONITOR_H_

2.设计 processCamera 函数

processCamera 负责完成主要功能,包括监控数据的获取和处理。首先要了解 OpenCV 中提供的几个 API。

  • CvCapture* cvCaptureFromCAM(int device): 此函数捕获指定设备的数据并返回一个 cvCapture 类型指针,捕获失败时返回 NULL
  • cvCreateFileCapture(char * filepath): 从本地视频读入。
  • CvVideoWriter * cvCreateVideoWriter(char * filepath, , fps, size, is_color): 新建一个视频写入对象,返回其指针,filepath指定写入视频的路径,fps指定写入视频的帧速,size指定写入视频的像素大小,is_color仅在windows下有效,指定写入是否为彩色。
  • double cvGetCaptureProperty(CvCapture* capture, int property_id): 获取一个视频流的某个特性,property_id指定要获取的特性名称。
  • IplImage* cvQueryFrame(CvCapture* capture): 从视频流获取帧。
  • void cvCvtColor(const CvArr* src, CvArr* dst, int code): 按code指定的模式将src指向的帧转换后写入dst指向的地址。
  • void calcHist(const Mat* images, int nimages, const int* channels, InputArray mask, SparseMat& hist, int dims, const int* histSize, const float** ranges, bool uniform, bool accumulate): 为image指向的帧计算直方图,保存在 hist 中。
  • void normalize(const SparseMat& src, SparseMat& dst, double alpha, int normType): 按指定模式将src正常化并写入dst中。
  • double compareHist(const SparseMat& H1, const SparseMat& H2, int method):按method指定的方式比较两个直方图的相似程度。
  • int cvWriteFrame(CvVideoWriter* writer, const IplImage* image): 向视频写入流写入一帧。成功返回 1,否则返回 0。

了解这些API的基本功能后,梳理程序执行的步骤:

  1. 程序开始执行,启动摄像头并获得数据流
  2. 进入循环:
    1. 捕获一帧
    2. 是否为第一帧?是则记录该帧作为监控区域的背景
    3. 将该帧做适当的变换,输出到监视器中
    4. 分析该帧和背景帧的相似程度
    5. 相似程度是否低于阈值且当前没有在记录视频?低于阈值开始记录。
    6. 相似程度是否低于阈值且当前已经开始记录视频?低于阈值继续记录,否则停止记录。
  3. 循环中程序的停止,通过接受外部中断相应。

3.代码实现 processCamera

  1. 启动摄像头并获得数据流,调用上面提到的cvCaptureFromCAM函数,默认摄像头的 device 为 0。

    void processCamera() {
    CvCapture *capture = cvCaptureFromCAM(0);
    if (!capture){
        cerr << "Unable to open camera " << endl;
        exit(EXIT_FAILURE);
    }
    // TODO...
    }    // end processCamera
    
  2. 进入循环,循环条件中使用到一个 keyboard变量用于接收外部中断,如果 Esc 或者 q键被按下则退出循环。keyboard通过 OpenCV 提供的waitKey()函数获得外部按键情况。在下面的循环中,每次先检查视频输入流capture是否为空,防止访问违例内存。在capture不为NULL的情况下,从capture读取一帧。在退出 while循环后,要通过cvReleaseCapture释放此前申请的capture

    void processCamera() {
    // ... 
    Mat frame;                    // current frame
    while ((char)keyboard != 'q' && (char)keyboard != 27){
        if (!capture) {
            cerr << "Unable to read camera" << endl;
            cerr << "Exiting..." << endl;
            exit(EXIT_FAILURE);
        }
    
        frame = cvQueryFrame(capture);
    
        // TODO...
    
        keyboard = waitKey(30);
    }    // end While
    cvReleaseCapture(&capture);
    }    // end processCamera
    
  3. 判断当前帧是否为第一帧,通过一个bool型变量backGroundFlag来标识。若backGroundFlagtrue表示当前帧为第一帧,则记录该帧并将backGroundFlag置为False。此时代码如下。在当前帧为第一帧的情况下,我们不需要记录该帧的真实数据,只需要记录该帧对应的直方图,这里首先将RGB类型的图像转为HSV格式,之后计算该帧的直方图,保存在base中。

    void processCamera() {
    // ... 
    bool backGroundFlag = true;
    Mat frame;                    // current frame
    Mat HSV;                    // HSV format
    MatND base;                    // histogram
    while ((char)keyboard != 'q' && (char)keyboard != 27){
        if (!capture) {
            cerr << "Unable to read camera" << endl;
            cerr << "Exiting..." << endl;
            exit(EXIT_FAILURE);
        }
    
        frame = cvQueryFrame(capture);
    
        // set background
        if (backGroundFlag){
            cvtColor(frame, HSV, CV_BGR2HSV);
            calcHist(&HSV, 1, channels, Mat(), base, 2, histSize, ranges, true, false);
            normalize(base, base, 0, 1, NORM_MINMAX, -1, Mat());
            backGroundFlag = false;
        }
    
        // TODO...
    
        keyboard = waitKey(30);
    }    // end While
    cvReleaseCapture(&capture);
    }    // end processCamera
    
  4. 对当前帧做适当变换并输出到监视器。此时代码如下。我们要实现的程序可以对原始图像做两种背景减除处理,因此需要用户指定使用哪种方式,这里通过参数传给processCamera,method 为 0 代表使用 MOG2 方式减除, method为 1代表使用 MOG1 方式减除, method为0 代表不作任何变换。两种方式均可以突出背景外的变化情况,实际效果将在最终程序执行时展示。在对原始 frame 做处理并写入 fgMask 后,通过imshow函数输出到监视器中。imshow函数的第一个参数为输出的窗口名,这里先假设已经有一个名为Monitor的窗口等待接收输出,这个窗口将在最终的main函数中创建。用户应当可以指定这个监控程序是否将处理后的图像输出,因此我们传入一个showWindow 参数表明是否显示实时监控窗口。对每一帧的处理方式和上面对背景帧的处理方式相同。

    void processCamera(bool showWindow,
                   unsigned int method) {
    // ... 
    bool backGroundFlag = true;
    Mat frame;                    // current frame
    Mat HSV;                    // HSV format
    MatND base;                    // histogram
    while ((char)keyboard != 'q' && (char)keyboard != 27){
        if (!capture) {
            cerr << "Unable to read camera" << endl;
            cerr << "Exiting..." << endl;
            exit(EXIT_FAILURE);
        }
    
        frame = cvQueryFrame(capture);
    
        if (method == 0)
            pMOG2->operator()(frame, fgMask);
        else if (method == 1)
            pMOG->operator()(frame, fgMask);
        else if (method == 2)
            fgMask = frame;
    
        // set background
        if (backGroundFlag){
            cvtColor(frame, HSV, CV_BGR2HSV);
            calcHist(&HSV, 1, channels, Mat(), base, 2, histSize, ranges, true, false);
            normalize(base, base, 0, 1, NORM_MINMAX, -1, Mat());
            backGroundFlag = false;
        }
    
        cvtColor(frame, HSV, CV_BGR2HSV);
        calcHist(&HSV, 1, channels, Mat(), cur, 2, histSize, ranges, true, false);
        normalize(cur, cur, 0, 1, NORM_MINMAX, -1, Mat());
    
        // TODO ...
    
        if (showWindow && !fgMask.empty()){
            imshow("Monitor", fgMask);
        }
        keyboard = waitKey(30);
    } // end While
    cvReleaseCapture(&capture);
    }    // end processCamera
    
  5. 比较当前帧和背景帧的相似度,当出现异常时开始记录视频。这里直接调用compareHist 函数,输出一个 0 - 1 范围内的指标,越接近1 表示两个直方图代表的图像越相似。这里我设置的阈值为 0.65,这个阈值应当根据实际监控区域的光线、色彩等因素修正。我们创建了一个recorder指针用于写入视频,需要指定写入视频的帧速和大小,这里大小通过cvGetCaptureProperty 自动获取,帧速fps由用户传入参数指定。在这里为了避免监控过于敏感的情况出现,设置了一个UnnormalFrames参数,该参数记录当前已经持续出现了多少帧与背景不同的画面,也就是运动状态出现了多久。当 UnnormalFrames 达到用户指定的阈值 unnormal时,我们认为监控中确实出现了异常,因此开始记录。为了更完整的提供监控信息,一旦确认监控中有运动状态发生,在运动结束后,也就是检测到当前帧和背景重新一致后,程序将继续记录视频信息,继续记录的时长和之前运动状态持续的时长相同。代码中通过recordFlag 标识当前是否应该记录视频,在 UnnormalFrames > unnormal时,recordFlag被置位,同时UnnormalFrames随着运动帧被检测持续增加,当运动结束后,UnnormalFrames将递减,至 0 时停止记录视频。这里unnormal通过参数传入processCamera至此,processCamera函数编写完成

    void processCamera(bool showWindow, 
                   unsigned int method, 
                   unsigned int unnormal = 10, 
                   unsigned int fps = 24) {
    CvCapture *capture = cvCaptureFromCAM(0);
    if (!capture){
        cerr << "Unable to open camera " << endl;
        exit(EXIT_FAILURE);
    }
    
    bool backGroundFlag = true, recordFlag = false;
    Mat frame, fgMask;                    // current frame, fg mask
    Mat HSV;                            // HSV format
    MatND base, cur;                    // histogram
    unsigned int UnnormalFrames = 0;
    int channels[] = { 0, 1 };
    
    CvSize size = cvSize(
        (int)cvGetCaptureProperty(capture, CV_CAP_PROP_FRAME_WIDTH),
        (int)cvGetCaptureProperty(capture, CV_CAP_PROP_FRAME_HEIGHT)
        );
    
    CvVideoWriter * recorder = cvCreateVideoWriter(recordName, CV_FOURCC('D', 'I', 'V', 'X'), 32, size, 1);
    
    // ESC or 'q' for quitting
    while ((char)keyboard != 'q' && (char)keyboard != 27){
        if (!capture) {
            cerr << "Unable to read camera" << endl;
            cerr << "Exiting..." << endl;
            exit(EXIT_FAILURE);
        }
    
        frame = cvQueryFrame(capture);
        if (method == 0)
            pMOG2->operator()(frame, fgMask);
        else if (method == 1)
            pMOG->operator()(frame, fgMask);
        else if (method == 2)
            fgMask = frame;
    
        // set background
        if (backGroundFlag){
            cvtColor(frame, HSV, CV_BGR2HSV);
            calcHist(&HSV, 1, channels, Mat(), base, 2, histSize, ranges, true, false);
            normalize(base, base, 0, 1, NORM_MINMAX, -1, Mat());
            backGroundFlag = false;
        }
    
        cvtColor(frame, HSV, CV_BGR2HSV);
        calcHist(&HSV, 1, channels, Mat(), cur, 2, histSize, ranges, true, false);
        normalize(cur, cur, 0, 1, NORM_MINMAX, -1, Mat());
    
        double comp = compareHist(base, cur, 0);
        if (comp < 0.65)
            UnnormalFrames += 1;
        else if (UnnormalFrames > 0)
            UnnormalFrames--;
        if (UnnormalFrames > unnormal)
            recordFlag = true;
        else if (UnnormalFrames <= 0){
            UnnormalFrames = 0;
            recordFlag = false;
        }
        // DO SOMETHING WARNING
        // Here We Starting Recoding
        if (recordFlag){
            cvWriteFrame(recorder, &(IplImage(frame)));
        }
    
        if (showWindow && !fgMask.empty()){
            imshow("Monitor", fgMask);
        }
        keyboard = waitKey(30);
    }    // end While
    cvReleaseVideoWriter(&recorder);
    cvReleaseCapture(&capture);
    }    // end processCamera
    

4.定义外部或全局变量

processCamera函数中使用到了一些函数中没有声明过的变量,这些变量有的是配置使用的常量如ranges等,不需要理解,下面在头文件中声明。pMOGpMOG2对应了对frame做变换的两个方式,他们将在main函数中被定义。keyboard 用于接收外部键盘输入。其余的均为常量,用于配置 OpenCV 提供的函数。

// ...
extern Ptr<BackgroundSubtractor> pMOG; //MOG Background subtractor
extern Ptr<BackgroundSubtractor> pMOG2; //MOG2 Background subtractor
extern int keyboard;

const float h_ranges[] = { 0, 256 };
const float s_ranges[] = { 0, 180 };
const float* ranges[] = { h_ranges, s_ranges };

const int h_bins = 50, s_bins = 60;
const int histSize[] = { h_bins, s_bins };

extern char recordName[128];

// ...

5.编写 main.cpp

下面编写程序入口。

  1. 首先需要告知用户程序的使用方式,编写help函数输出帮助信息。-vis选项用于指定程序显示实时监控,[MODE]参数指定使用何种方式显示监控,[FPS]指定帧速,[THRESHOLD]指定经过多少异常帧后开始记录,[OUTPUTFILE]指定输出视频记录位置。
void help(){
    cout
        << "----------------------------------------------------------------------------\n"
        << "Usage:                                                                      \n"
        << " ./MonitorRecorder.exe [VIS] [MODE] [FPS] [THRESHOLD] [OUTPUTFILE]          \n"
        << "   [VIS]  : use -vis to show the monitor window, or it will run background. \n"
        << "   [MODE] : -src   shows the original frame;                                \n"
        << "            -mog1       shows the MOG frame;                                \n"
        << "            -mog2      shows the MOG2 frame.                                \n"
        << "   [FPS]  : set the fps of record file, default is 24.                      \n"
        << "   [THRESHOLD]                                                              \n"
        << "          : set the number x that the monitor will start recording after    \n"
        << "            x unnormal frames passed.                                       \n"
        << "   [OUTPUTFILE]                                                             \n"
        << "          : assign the output recording file. It must be .avi format.       \n"
        << "                                                   designed by Forec        \n";
        << "----------------------------------------------------------------------------\n";
}
  1. 编写main函数。在main函数中我们需要用到外部声明的 pMOGpMOG2以及recordName。这里需要在函数外声明。主函数中主要部分为解析用户的命令行参数,其中stoi函数需要使用 C++ 11 标准编译。我们使用sleep(2)processCamera延时 2 秒执行,这个时间你可以离开电脑,让程序捕获你的背景,之后可以回到电脑前,观察监控程序的显示和记录情况。如果用户指定了 -vis 参数,则产生一个Monitor窗口显示实时监控,这个窗口就是此前processCamera函数中输出图像的窗口。在main函数最后,用destroyAllWindows销毁namedWindow产生的窗口。
#include "monitor.hpp"

Ptr<BackgroundSubtractor> pMOG; //MOG Background subtractor
Ptr<BackgroundSubtractor> pMOG2; //MOG2 Background subtractor
int keyboard;

char recordName[128];

void help();

int main(int argc, char* argv[]){
    bool showWindow = false;
    unsigned int method = 0, unnormal = 10, fps = 24;
    if (argc > 6){
        cerr << "Invalid Parameters, Exiting..." << endl;
        exit(EXIT_FAILURE);
    }
    if (argc >= 2){
        if (strcmp(argv[1], "-vis") == 0)
            showWindow = true;
        if (strcmp(argv[1], "-h") == 0 ||
            strcmp(argv[1], "--help") == 0){
            help();
            exit(EXIT_SUCCESS);
        }
    }
    if (argc >= 3){
        if (strcmp(argv[2], "-mog2") == 0)
            method = 0;
        if (strcmp(argv[2], "-mog1") == 0)
            method = 1;
        if (strcmp(argv[2], "-src") == 0)
            method = 2;
    }
    if (argc >= 4){
        int param = stoi(argv[3], nullptr, 10);
        if (param <= 10)
            fps = 24;
        else
            fps = param;
    }
    if (argc >= 5){
        int param = stoi(argv[4], nullptr, 10);
        if (param <= 0)
            unnormal = 10;
        else
            unnormal = param;
    }
    if (argc >= 6){
        strcpy(recordName, argv[5]);
    }
    else{
        // set record video file name
        time_t t = time(NULL);
        sprintf(recordName, "%d.avi", int(t));
    }

    cout << "Starts After 2s..." << endl;
    sleep(2);
    if (showWindow)
        namedWindow("Monitor");

    pMOG = new BackgroundSubtractorMOG(); //MOG approach
    pMOG2 = new BackgroundSubtractorMOG2(); //MOG2 approach

    processCamera(showWindow, method, unnormal, fps);

    destroyAllWindows();
    return EXIT_SUCCESS;
}

6.编译运行

因为实验楼的环境不提供摄像头,因此我们将程序捕获摄像头的部分修改为程序从一个本地的监控视频中读取,模拟读取摄像头的情况。这需要修改monitor.hpp中的第 38 行,将CvCapture *capture = cvCaptureFromCAM(0); 改为 CvCapture *capture = cvCreateFileCapture("test.mp4");,假设将要读入的本地视频文件名为test.mp4。在修改完你的代码后,你可以通过以下命令下载test.mp4(该视频文件是周杰伦《浪漫手机》的MV),并检验代码。

$ wget http://labfile.oss.aliyuncs.com/courses/671/test.mp4

请确认你已经修改了代码,将读取摄像头改为读取test.mp4,并且将test.mp4已经拷贝到代码目录下。在代码目录下输入如下命令编译。请检查自己是否已经按照教程开始的环境配置方案配置成功。编译可能产生两个warning。

g++ -ggdb `pkg-config --cflags opencv` -std=c++11 -fpermissive -o `basename main` main.cpp `pkg-config --libs opencv`

编译成功后目录下将产生一个名为 main的可执行文件,在终端键入如下命令,将使程序输出MOG1处理后的实时监控画面,且画面连续异常10帧后开始记录视频,视频记录到当前文件夹下的out.avi中,帧速 24。

sudo vim /etc/ld.so.conf.d/opencv.conf  
export LD_LIBRARY_PATH=$LD_LIBRARY_PATH:/usr/local/lib  
sudo ldconfig -v
./main -vis -mog1 24 10 out.avi

程序按上述命令的运行截图如下。
运行截图

以下几张分别是程序在不同显示模式下的显示情况,你可以通过切换-mog1-mog2-src来自己观察对应的效果。
运行截图

运行截图

运行截图

运行截图

运行截图

五、代码获取

你可以在我的 Github仓库 中获取到完整的代码。里面提供了Windows 版本和 Linux版本的配置、运行方案。如果你有建议或想法,欢迎提 PR 沟通。

六、参考资料

  • 7
    点赞
  • 66
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值