🚀 使用 C++/OpenCV 与 Libevent 打造实时 AI 视觉对话应用

本文将详细介绍如何构建一个高性能的后端服务,该服务能够接收来自浏览器的实时视频流,并将其与用户提问一起发送给多模态大语言模型(如 Google Gemini),最终将 AI 的回答实时返回给浏览器。这是一个融合了 Web 技术、高性能网络编程和人工智能的有趣项目。

(图片来源: Google I/O 2024)


核心架构设计 🏛️

我们的系统分为两大部分:前端(浏览器)和后端(C++ 服务器)。

  1. 前端 (Browser):

    • 使用 HTML 和 CSS 构建基本的用户界面,包括一个视频播放区、聊天记录区和输入框。
    • 通过 JavaScript 调用 navigator.mediaDevices.getUserMedia API 来访问用户的摄像头。
    • 使用 WebSocket 与后端服务器建立持久的双向连接。
    • 定期将摄像头画面绘制到 <canvas> 上,并将其编码为 Base64 格式的 JPEG 图像,通过 WebSocket 发送给后端。
    • 发送用户的文本提问,并接收和显示来自后端的 AI 回答。
  2. 后端 (C++ Server):

    • 网络层: 使用 Libevent 构建一个异步、事件驱动的 HTTP 和 WebSocket 服务器。Libevent 的高性能 I/O 模型非常适合处理大量并发连接和数据。
    • 图像处理层: 使用 OpenCV 库来解码从前端接收到的 Base64 图像数据,将其转换成标准的图像格式(cv::Mat),以便后续处理。
    • AI 通信层: 使用一个 HTTP 客户端库(如 libcurl)来调用大模型的 REST API。它负责构建请求(包含图像和文本),并解析返回的 JSON 结果。
    • 数据解析: 使用 nlohmann/json 库来方便地处理与大模型 API 交互的 JSON 数据。

数据流转路径:
浏览器摄像头 -> JS 获取帧 -> Base64 编码 -> WebSocket 发送 -> C++ Libevent 接收 -> Base64 解码 -> OpenCV 处理 -> libcurl 提交到 LLM API -> LLM 返回 JSON -> C++ 解析 -> WebSocket 发送 -> JS 接收并显示


🛠️ 环境准备与依赖

在开始编码之前,请确保你已安装以下库:

  • GCC/Clang: C++ 编译器。
  • CMake: 用于项目构建。
  • Libevent: sudo apt-get install libevent-dev 或通过源码编译。
  • OpenCV: sudo apt-get install libopencv-dev 或通过源码编译。
  • Libcurl: sudo apt-get install libcurl4-openssl-dev。
  • nlohmann/json: 这是一个头文件库,直接将其 json.hpp 文件包含到你的项目中即可。
  • libb64: 一个简单的 Base64 编解码库。

👨‍💻 后端 C++ 实现

后端代码是整个项目的核心,我们将分步讲解关键部分。

1. Libevent 服务器基础

我们首先用 Libevent 的 evhttp 模块创建一个简单的 HTTP 服务器,它能响应 WebSocket 的升级请求。

#include <iostream>
#include <string>
#include <vector>
#include <event2/event.h>
#include <event2/http.h>
#include <event2/buffer.h>
#include <event2/keyvalq_struct.h>

void generic_handler(struct evhttp_request *req, void *arg) {
    // 默认处理,比如提供 index.html
    // ...
}

void websocket_handler(struct evhttp_request *req, void *arg) {
    // 这里将处理 WebSocket 连接
    // ...
}

int main() {
    struct event_base *base = event_base_new();
    struct evhttp *http = evhttp_new(base);

    evhttp_set_cb(http, "/ws", websocket_handler, nullptr);
    evhttp_set_gencb(http, generic_handler, nullptr);

    if (evhttp_bind_socket(http, "0.0.0.0", 8088) != 0) {
        std::cerr << "Couldn't bind to port 8088. Exiting." << std::endl;
        return 1;
    }

    std::cout << "Server listening on http://0.0.0.0:8088" << std::endl;
    event_base_dispatch(base);

    evhttp_free(http);
    event_base_free(base);
    return 0;
}

2. WebSocket 握手与数据处理

Libevent 本身不直接支持 WebSocket 协议,但我们可以手动实现握手过程。当收到 /ws 请求时,检查 Upgrade 头。

// 伪代码,放在 websocket_handler 中
void websocket_handler(struct evhttp_request *req, void *arg) {
    // 1. 检查请求头是否包含 "Upgrade: websocket"
    const char* upgrade_header = evhttp_find_header(request_get_input_headers(req), "Upgrade");
    if (!upgrade_header || strsecmp(upgrade_header, "websocket") != 0) {
        evhttp_send_error(req, HTTP_BADREQUEST, "Not a websocket request");
        return;
    }

    // 2. 实现 WebSocket 握手
    //    a. 获取 Sec-WebSocket-Key
    const char* key = evhttp_find_header(evhttp_request_get_input_headers(req), "Sec-WebSocket-Key");
    //    b. 拼接 Magic String: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
    std::string accept_key = std::string(key) + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
    //    c. 计算 SHA1 哈希
    //    d. 进行 Base64 编码
    //    e. 设置响应头 Sec-WebSocket-Accept
    struct evkeyvalq* headers = evhttp_request_get_output_headers(req);
    evhttp_add_header(headers, "Upgrade", "websocket");
    evhttp_add_header(headers, "Connection", "Upgrade");
    evhttp_add_header(headers, "WebSocket-Accept", /* [Encoded Accept Key] */);

    // 3. 发送 101 Switching Protocols 响应
    evhttp_send_reply(req, HTTP_SWITCHING_PROTOCOLS, "Switching Protocols", nullptr);

    // 4. 将连接的 bufferevent 从 http 中解放出来,用于原始数据读写
    struct evhttp_connection *evcon = evhttp_request_get_connection(req);
    struct bufferevent *bev = evhttp_connection_get_bufferevent(evcon);
    // 设置读写回调函数来处理 WebSocket 数据帧
    // bufferevent_setcb(bev, websocket_read_cb, nullptr, websocket_event_cb, nullptr);
}

在 websocket_read_cb 中,你需要解析 WebSocket 数据帧(这部分比较复杂,需要处理掩码、opcode 等),提取出负载数据。

3. 解码图像并调用 LLM API

当从 WebSocket 帧中成功提取出 Base64 编码的图像数据后:

#include <opencv2/opencv.hpp>
#include <nlohmann/json.hpp>
#include "b64/decode.h" // Base64 解码库

// 假设从 WebSocket 收到 JSON: { "type": "image", "data": "base64_string...", "prompt": "user_question" }
void process_incoming_data(const std::string& jsonData) {
    using json = nlohmann::json;
    auto parsed = json::parse(jsonData);

    if (parsed["type"] == "image") {
        std::string prompt = parsed["prompt"];
        std::string b64_image = parsed["data"]; // data:image/jpeg;base64,.....
        
        // 移除 data URL 头部
        std::string b64_data = b64_image.substr(b64_image.find(",") + 1);

        // Base64 解码
        base64::decoder D;
        std::vector<char> decoded_bytes(b64_data.size());
        int len = D.decode(b64_data.c_str(), b64_data.size(), decoded_bytes.data());
        decoded_bytes.resize(len);

        // 使用 OpenCV 从内存解码
        cv::Mat frame = cv::imdecode(decoded_bytes, cv::IMREAD_COLOR);
        if (frame.empty()) {
            std::cerr << "Failed to decode image" << std::endl;
            return;
        }

        // 可以在这里对 frame 做一些预处理,如缩放
        // cv::resize(frame, frame, cv::Size(512, 512));

        // 调用大模型 API (使用 libcurl)
        std::string llm_response = call_gemini_vision_api(prompt, decoded_bytes);
        
        // 将 llm_response 通过 WebSocket 发回客户端
        // ...
    }
}

call_gemini_vision_api 函数大致如下(使用 libcurl):

#include <curl/curl.h>

static size_t WriteCallback(void *contents, size_t size, size_t nmemb, void *userp) {
    ((std::string*)userp)->append((char*)contents, size * nmemb);
    return size * nmemb;
}

std::string call_gemini_vision_api(const std::string& prompt, const std::vector<char>& image_bytes) {
    CURL *curl;
    CURLcode res;
    std::string readBuffer;
    
    std::string api_key = "YOUR_GEMINI_API_KEY";
    std::string url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=" + api_key;

    // 使用 nlohmann/json 构建请求体
    using json = nlohmann::json;
    json request_body = {
        {"contents", {{
            {"parts", {
                {{"text", prompt}},
                {{"inline_data", {
                    {"mime_type", "image/jpeg"},
                    {"data", base64::encoder::encode(image_bytes.data(), image_bytes.size())} // 重新编码
                }}}
            }}
        }}}
    };

    curl_global_init(CURL_GLOBAL_ALL);
    curl = curl_easy_init();
    if(curl) {
        struct curl_slist *headers = NULL;
        headers = curl_slist_append(headers, "Content-Type: application/json");

        curl_easy_setopt(curl, CURLOPT_URL, ur.c_str());
        curl_easy_setopt(curl, CURLOPT_HTTPHEAD, headers);
        curl_easy_setopt(curl, CURLOPT_POSTFIELDS, request_body.dump().c_str());
        curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback);
        curl_easy_setopt(curl, CURLOPT_WRITEDATA, &readBuffer);

        res = curl_easy_perform(curl);
        if(res != CURLE_OK) {
            fprintf(stderr, "curl_easy_perform() failed: %s\n", curl_easy_strerror(res));
        }
        curl_easy_cleanup(curl);
    }
    curl_global_cleanup();
    
    // 解析 readBuffer (JSON),提取模型回答的文本
    // ...
    return extracted_text;
}

🌐 前端 HTML 与 JavaScript 实现

前端部分相对直接。我们创建一个 index.html。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>AI Vision Chat</title>
    <style>
        body { font-family: sans-serif; display: flex; flex-direction: column; align-items: center; }
        #container { display: flex; gap: 20px; }
        #video-container { border: 1px solid black; }
        #chat-box { width: 400px; height: 480px; border: 1px solid #ccc; overflow-y: scroll; padding: 10px; }
        #input-area { display: flex; width: 100%; margin-top: 10px; }
        #prompt { flex-grow: 1; }
    </style>
</head>
<body>
    <h1>C++ AI Vision Chat 🤖</h1>
    <div id="container">
        <div id="video-container">
            <video id="video" width="640" height="480" autoplay playsinline></video>
        </div>
        <div id="chat-box"></div>
    </div>
    <div id="input-area">
        <input type="text" id="prompt" placeholder="Ask something about the video...">
        <button id="send-btn">Send</button>
    </div>
    <canvas id="canvas" width="640" height="480" style="display:none;"></canvas>

    <script>
        const canvas = document.getElementById('canvas');
        const context = canvas.getContext('2d');
        const chatBox = document.getElementById('chat-box');
        const promptInput = document.getElementById('prompt');
        const sendBtn = document.getElementById('send-btn');

        const ws = new WebSocket('ws://' + window.location.host + '/ws');

        ws.onopen = () => {
            console.log('Connected to server.');
        };

        ws.onmessage = (event) => {
            appendMessage('AI', event.data);
        };

        ws.onclose = () => {
            console.log('Disconnected from server.');
        };

        // 获取摄像头权限
        navigator.mediaDevices.getUserMedia({ video: true })
            .then(stream => {
                video.srcObject = stream;
            }).catch(err => {
                console.error("Error accessing camera: ", err);
            });

        function appendMessage(sender, message) {
            msgElem.innerHTML = `<strong>${sender}:</strong> ${message}`;
            chatBox.appendChild(msgElem);
            chatBox.scrollTop = chatBox.scrollHeight;
        }

        function sendData() {
            if (ws.readyState !== WebSocket.OPEN) return;
            
            const prompt = promptInput.value;
            if (!prompt) {
                alert('Please enter a prompt.');
                return;
            }
            appendMessage('You', prompt);

            // 绘制当前视频帧到 canvas
            context.drawImage(video, 0, 0, canvas.width, canvas.height);
            // 获取 Base64 编码的图像数据
            const imageData = canvas.toDataURL('image/jpeg', 0.7);

            const payload = JSON.stringify({
                type: 'image',
                prompt: prompt,
                data: imageData
            });

            ws.send(payload);
            promptInput.value = '';
        }

        promptInput.addEventListener('keydown', (e) => {
            if (e.key === 'Enter') sendData();
        });

    </script>
</body>
</html>

🚀 整合与运行

  1. 编译后端:

    g++ main.cpp -o server -std=c++17 -levent -lopencv_core -lopencv_imgcodecs -lcurl -ljsoncpp # (或链接你的json库)
    

    (注意:实际编译命令可能更复杂,建议使用 CMake)

  2. 运行服务器:
    ./server

  3. 访问前端:
    在浏览器中打开 http://localhost:8088。你需要一个提供 index.html 的处理逻辑(在上面的 generic_handler 中实现)。

现在,你应该能看到自己的摄像头画面了。在输入框中提问(例如,“What do you see?”),点击发送,稍等片刻,AI 的回答就会出现在聊天框中。

总结与展望

我们成功地使用 C++、Libevent 和 OpenCV 构建了一个高性能的 AI 视觉应用后端。这种技术栈虽然比 Node.js 或 Python 实现起来更复杂,但它提供了无与伦比的性能和对内存、线程的精细控制能力,非常适合对性能要求极高的生产环境。

未来的改进方向:

  • 安全性: 使用 WSS (WebSocket Secure) 和 HTTPS。
  • 错误处理: 增加更健壮的错误处理和重连机制。
  • 流式响应: 对接支持流式输出的 LLM API,实现打字机效果,提升用户体验。
  • 视频流: 替换单帧发送为真正的视频流处理(例如 WebRTC),但这将大大增加复杂性。

更多推荐