【以太网摄像头采集存储终端】HTTP MJPEG 推流

1. 引言

前面的文章已经分别完成了 OV5640 图像采集、LCD 显示、JPEG 硬件编解码,以及以太网、LwIP 和 HTTP 服务。这一篇把这些功能串起来:让 STM32H757 将摄像头画面编码成 JPEG,通过 HTTP 持续发送,在浏览器中直接观看。

本文基于现有工程展开,不再重复外设初始化和协议栈移植。需要的前置知识包括摄像头与 LCD、JPEG/MDMA、以太网与 LwIP、HTTP 与 OTA,以及双核启动和 FreeRTOS,相关内容可从文末博客导航查阅。

当前实现包含以下功能:

  • 双端观看:支持两个观看连接,共用一个 JPEG 编码任务。
  • 独立发送:各连接独立发送,较慢的连接会跳过旧帧。
  • 业务协作:推流期间,HTTP 主任务仍可处理状态查询和 OTA 请求。
  • 帧率显示:网页显示板端发送帧率。

2. 从摄像头到浏览器的整体链路

2.1 从打开页面到持续显示

先看整篇文章的主线:摄像头产生图像,CM7 将完整图像压缩成 JPEG,再通过 HTTP 发给浏览器。

图像处理与发送链路

下图按“采集 → 编码 → 共享 → 发送 → 显示”排列,每个方框只表达一个处理环节。

flowchart TB subgraph BOARD["STM32H757 · CM7"] direction TB CAMERA["OV5640 摄像头"] CAPTURE["图像采集<br/>DCMI / DMA / DMA2D"] RGB["完整 RGB565 帧<br/>800 × 480"] LCD["LTDC / LCD<br/>本地显示"] subgraph ENCODER["共享编码任务"] direction TB ACQUIRE["持有完整帧<br/>防止采集覆盖"] CONVERT["CPU 颜色转换<br/>RGB565 → YCbCr420"] JPEG["JPEG + MDMA<br/>硬件压缩"] RELEASE["释放 RGB565 帧<br/>发布完整 JPEG"] ACQUIRE --> CONVERT --> JPEG --> RELEASE end BUFFER["三块共享 JPEG 缓冲<br/>保存并发布最新编码结果"] SEND1["发送任务 1<br/>独立连接 · 获取最新帧"] SEND2["发送任务 2<br/>独立连接 · 获取最新帧"] CAMERA --> CAPTURE --> RGB RGB --> LCD RGB --> ACQUIRE RELEASE --> BUFFER BUFFER --> SEND1 BUFFER --> SEND2 end BROWSER1["浏览器 1<br/>JPEG 解码与画面更新"] BROWSER2["浏览器 2<br/>JPEG 解码与画面更新"] SEND1 -->|"HTTP multipart<br/>连续发送 JPEG"| BROWSER1 SEND2 -->|"HTTP multipart<br/>连续发送 JPEG"| BROWSER2 classDef capture fill:#EAF3FF,stroke:#3973AC,color:#16324F classDef encode fill:#FFF3DE,stroke:#BC8526,color:#573B12 classDef buffer fill:#EDE9FA,stroke:#8064AD,color:#382653 classDef send fill:#E5F4EB,stroke:#43865D,color:#20432E classDef display fill:#F2F4F7,stroke:#788596,color:#293544 class CAMERA,CAPTURE,RGB capture class ACQUIRE,CONVERT,JPEG,RELEASE encode class BUFFER buffer class SEND1,SEND2 send class LCD,BROWSER1,BROWSER2 display

图像从上向下依次经过采集、颜色转换、JPEG 压缩和网络发送。LCD 与编码任务使用完整的 RGB565 帧,两个观看连接共享同一套 JPEG 编码结果,由各自的发送任务独立传输。慢连接在完成当前帧后获取最新帧,跳过期间产生的中间帧。

浏览器请求与连接移交

在这条图像通路开始工作前,浏览器还需要建立观看连接。请求过程单独看会更清楚:

sequenceDiagram autonumber participant B as 浏览器 participant H as CM7 HTTP 主任务 participant S as CM7 发送任务 B->>H: GET /,请求主页 H-->>B: 200 OK,返回 HTML B->>H: 图像标签触发 GET /stream H->>H: 检查观看名额、OTA 和停流状态 alt 无空闲名额或当前禁止推流 H-->>B: 503 Service Unavailable Note over B,H: 关闭本次连接 else 允许建立观看连接 H->>S: 移交连接并通知发送任务 Note over H: 继续接收其他请求 S-->>B: 200 OK,multipart 响应头 par 图像流连接:持续推送 loop 每次取得新的完整 JPEG S-->>B: 分隔行 + 单帧头 + JPEG 数据 + 帧尾 Note over B: 解码 JPEG,更新画面 end and 状态连接:独立查询 loop 每次查询结束后等待 1 秒 B->>H: GET /status H-->>B: JSON,包含 mjpeg_fps_x10 Note over B: 更新页面发送帧率 end end Note over B,S: 断连、发送错误、超时或停流时,由发送任务关闭并释放流连接 end

上图的 /stream 与 /status 使用不同连接,状态查询可以在持续接收图像时进行。连接退出、缓冲引用和 OTA 停流的细节放到后文对应章节,开头先把主链路建立起来。

阅读后文时可以围绕三个问题展开:

  1. 图像从哪里来?
  2. 完整 JPEG 如何在任务之间共享?
  3. 浏览器如何从同一个响应里区分每一帧?

2.2 数据流与任务分工

沿着上面的图像链路,RGB565 先转换成按 JPEG MCU 排列的 YCbCr 数据,再由硬件压缩。MCU 是 JPEG 中的最小编码单元(Minimum Coded Unit),这里指数据排列方式。

  • 摄像头任务:负责持续采集和显示。
  • Mjpeg_Thread():负责取得完整帧、调用编码接口并发布 JPEG。
  • Mjpeg_SendThread():负责各自的 HTTP 长连接。

编码任务和发送任务首次使用时通过 xTaskCreateStatic() 创建,后续用任务通知唤醒,无人观看时编码任务回到等待状态。

主要代码集中在以下文件:

文件 职责
CM7/APP/NET/HTTP/app_http_server.c 页面、路由、状态查询与连接移交
CM7/APP/NET/HTTP/app_http_mjpeg.c 共享编码、帧缓冲管理与独立发送
CM7/APP/JPEG/app_hw_jpeg.c 颜色转换、JPEG/MDMA 编码和缓存维护
CM7/BSP/CAMERA/ov5640_dcmi.c 完整帧持有与释放

2.3 安全取帧与编码衔接

不能把某个采集缓冲地址直接交给编码器:如果 DMA 正在更新这块内存,读到的图像可能混合两帧内容。当前实现通过 ov5640_dcmi_acquire_frame() 取得完整显示帧,同时保护它不被回收为采集缓冲。

下面摘录编码任务的调用链,省略计时和结果发布部分:

/* 成功取到一帧 RGB 数据 */
if (ov5640_dcmi_acquire_frame(&rgb) == 0)
{
    /* 若正在停止(s_stopping)则跳过编码,直接置 -1;
       否则用硬件 JPEG 编码器把 rgb 编码到 index 对应的 MJPEG 缓冲区,
       输出尺寸写入 size(成功返回 0) */
    int result = s_stopping ? -1 :
        jpeg_hw_encode(rgb, mjpeg_buffer((uint32_t)index),
                       MJPEG_CAPACITY, &size);
    /* 编码完立即归还帧缓冲,与 acquire 配对 */
    ov5640_dcmi_release_frame();
    /* 后续根据 result 决定是否发布 JPEG:result==0 才发布。 */
}

这是直接持有完整帧的方式,没有额外复制一份 RGB 图像。取帧函数会分块失效对应 D-Cache,使 CPU 读取 DMA 已写入的内存;持有期间允许 LCD 切换到其他完整帧。

无 LCD 时,当前实现仍保留 LTDC 时序,供摄像头三缓冲通过消隐期重载完成帧切换,但会关闭图层的帧缓冲读取,避免无用的 SDRAM 访问影响编码性能。相关故障与修复见 6.5 节。

释放原则:编码调用返回后,无论成功与否都释放帧,避免长期占用采集资源。

jpeg_hw_encode() 当前使用固定配置:

info.ImageWidth = 800U; // 图像宽度 800 像素
info.ImageHeight = 480U; // 图像高度 480 像素(800x480 分辨率)
info.ColorSpace = JPEG_YCBCR_COLORSPACE; // 色彩空间:YCbCr(JPEG 常用)
info.ChromaSubsampling = JPEG_420_SUBSAMPLING; // 色度下采样 4:2:0,压缩率高、体积小
info.ImageQuality = 80U; // 编码质量 80(0~100,越高越清晰、文件越大)

RGB565 到 YCbCr420 MCU 数据的转换由 CPU 完成,JPEG 压缩由硬件执行。转换按 MCU 行分批进行,累计运行约 4 ms 后主动让出 CPU,兼顾相机和网络任务的运行机会。

相关内存区域如下:

数据或缓冲 大小 / 地址
一帧 RGB565 800 × 480 × 2 = 768000 字节
YCbCr420 工作区 从 0xD0600000 开始
三块 JPEG 输出缓冲 0xD0700000..0xD087FFFF,每块 512 KiB

采集/显示缓冲和 JPEG 输出缓冲是两套不同用途的缓冲,不能混用。

CPU 生成的 MCU 数据在 MDMA 读取前需要清理缓存,JPEG 输出在 CPU 读取前也要完成相应缓存维护。编码接口还会检查 JPEG 起止标记,去掉 EOI 后的填充字节,返回实际长度,供后续填写单帧 Content-Length。

3. 浏览器为什么能连续显示 JPEG

3.1 MJPEG 与 HTTP 长连接

MJPEG 可以理解为连续传输的一组独立 JPEG 图像。每帧都能单独解码,不需要依赖前后帧,因此很适合在已经具备 JPEG 编码能力的项目上实现实时预览。

浏览器请求 /stream 后,服务器保持响应打开,不断在响应体中追加新的 JPEG。这里不需要网页反复请求单张图片,也不需要编写 JavaScript JPEG 解码器。

代价是每一帧都要完整传输,没有利用相邻画面的重复信息。画面复杂度、编码质量、JPEG 大小和观看端数量,都会影响带宽与发送帧率。两个观看端共享编码结果可以减少重复计算,但网络数据仍然需要分别发送。

3.2 multipart 响应格式是什么

multipart 是一种把一个消息体组织成多个部分的格式。 普通图片响应的响应体只有一张 JPEG;multipart 响应的响应体则包含多个 part,每个 part 都可以带有自己的头部和数据,并通过约定的边界标记分隔。

在本项目中,一个 part 就是一帧 JPEG。浏览器只发起一次 /stream 请求,服务器返回一次 HTTP 响应,随后在这一个响应体里持续发送多个 part。每帧的 Content-Type、Content-Length 属于 part 的头部,不是重新发送一套 HTTP 状态行和响应头。

multipart 是总类别,具体用途由子类型决定。这里使用 multipart/x-mixed-replace,表达持续提供新内容、替换先前显示内容的语义。支持这种格式的浏览器把每个新 JPEG 显示在同一个图像位置,就形成了连续画面。它与上传表单常见的 multipart/form-data 使用场景不同,也不是 HTTP 的 chunked 传输编码。

可以把响应分成两层理解:

一个 HTTP 响应
├─ 外层响应头:声明 multipart 类型及 boundary
└─ 响应体:持续追加多个 part
   ├─ part 1:分隔行 + 单帧头 + 空行 + JPEG 1 + CRLF
   ├─ part 2:分隔行 + 单帧头 + 空行 + JPEG 2 + CRLF
   └─ ……

下面是实际使用的报文结构示意,<JPEG 字节数> 和二进制数据需要替换为实际内容。头部和分隔行使用 \r\n 结束,空行也不能省略;JPEG 部分是原始二进制,不需要转成 Base64。

HTTP/1.1 200 OK
Content-Type: multipart/x-mixed-replace; boundary=frame
Cache-Control: no-store, no-cache, must-revalidate
Connection: close

--frame
Content-Type: image/jpeg
Content-Length: <第一帧 JPEG 字节数>
<第一帧 JPEG 二进制数据,随后是 CRLF>

--frame
Content-Type: image/jpeg
Content-Length: <第二帧 JPEG 字节数>
<第二帧 JPEG 二进制数据,随后是 CRLF>

……

各字段的作用如下:

字段或标记 含义
外层 Content-Type 告诉浏览器按 multipart 流解析整个响应体
boundary=frame 约定边界字符串为 frame,供浏览器识别各个 part
--frame 实际边界行,由 -- 前缀和约定的边界字符串组成
单帧 Content-Type: image/jpeg 声明这一 part 的数据是一张 JPEG
单帧 Content-Length 当前 JPEG 的有效字节数,不包含帧头和帧尾 CRLF
头部后的空行 表示头部结束,后面开始传输 JPEG 二进制数据

报文结束方式还需要注意三点:

  • 总长度:整个流的长度事先未知,因此外层响应没有设置总 Content-Length。
  • 连接关闭:Connection: close 表示响应结束后关闭连接,不代表发送完第一张图就立即关闭。
  • 结束边界:规范中的 multipart 结束边界会在边界字符串后再加 --,即 --frame--;当前实现持续发送,退出时直接关闭连接,没有额外发送这个结束标记。

单片机发送任务中的单帧头部如下,发送顺序是“帧头 → JPEG 数据 → \r\n”:

int length = snprintf(header, sizeof(header),
                      "--frame\r\nContent-Type: image/jpeg\r\n"
                      "Content-Length: %lu\r\n\r\n", (unsigned long)size);
/* \r\n\r\n:最后这一组连续的换行符,暗示 HTTP 头部到此结束,紧挨着后面的就是纯二进制数据 */

网页上只需把流地址交给图像标签,支持这种响应格式的浏览器就会持续更新画面:

<img src="/stream" alt="相机画面" style="max-width:100%;height:auto">

4. HTTP 推流的关键实现

4.1 路由与连接所有权

HTTP 服务提供三个与预览直接相关的入口:

路由 返回内容
/ 包含图像标签和帧率轮询逻辑的页面
/stream 持续输出 JPEG 的 multipart 响应
/status 网络、运行槽位及发送帧率等 JSON 状态

普通请求处理完毕后,HTTP 主任务关闭并删除连接;推流需要持续发送,因此必须移交连接:

HTTP 主任务接收并解析请求
    ├─ / 或 /status → 返回响应 → 主任务关闭、删除连接
    └─ /stream
         ├─ 准入失败 → 返回 503 → 主任务关闭、删除连接
         └─ 准入成功 → 移交发送任务 → 主任务继续 accept
                          ↓
                    发送结束后关闭、删除连接

路由处理通过 s_client_transferred 标记移交结果:

if (app_http_mjpeg_start(conn) == 0)
    s_client_transferred = 1U;
else
    http_reply_text(conn, 503, "Service Unavailable", "text/plain; charset=utf-8",
                    "stream busy or OTA active\r\n");

主任务发现连接已移交便跳过清理,避免浏览器刚收到流就被关闭,也避免重复释放。每个发送任务独占自己的 netconn,推流循环不会长期占住 HTTP 主任务。不过这不等于任意请求都完全并行,主任务仍按自身流程处理其他请求。

4.2 一次编码,两个观看端共享

如果为每个浏览器单独取帧编码,会重复消耗颜色转换和 JPEG 硬件资源。当前方案只编码一次,再让两个发送任务读取共享结果。

每块 JPEG 缓冲保存三个元数据:

typedef struct
{
    uint32_t size;
    uint32_t sequence;
    uint8_t readers;
} mjpeg_frame_t;
状态字段 作用
size 当前 JPEG 的有效字节数
sequence 用于识别新帧
readers 正在发送这块缓冲的连接数
s_latest 指向最新发布缓冲的全局索引

选择可写缓冲

编码任务只选择“不是最新帧,并且没有发送者持有”的缓冲:

for (uint32_t i = 0U; i < MJPEG_BUFFERS; i++)
{
    if ((int)i != s_latest && !s_frames[i].readers)
    {
        index = (int)i;
        break;
    }
}

发布编码结果

编码成功后,在临界区内一起更新序号、长度和最新索引。这样发送任务看到的新索引,一定对应已经完成编码的 JPEG:

taskENTER_CRITICAL();
if (++s_sequence == 0U) ++s_sequence;
s_frames[index].sequence = s_sequence;
s_frames[index].size = size;
s_latest = index;
taskEXIT_CRITICAL();

发送任务取得引用

发送任务也在临界区内取得最新帧并增加引用:

taskENTER_CRITICAL();
if (s_latest >= 0 && s_frames[s_latest].sequence != sequence)
{
    index = s_latest;
    s_frames[index].readers++;
    sequence = s_frames[index].sequence;
    size = s_frames[index].size;
}
taskEXIT_CRITICAL();

缓冲复用示例

以下是可能出现的一种状态,序号仅为机制示例:

缓冲 A:第 10 帧,readers=1,慢端仍在发送 → 不可覆盖
缓冲 B:第 11 帧,readers=0,latest 指向它 → 保留最新帧
缓冲 C:旧内容, readers=0              → 可编码第 12 帧

第 12 帧完成:latest 从 B 切换到 C
慢端发完 A:readers 减为 0,下次直接获取当时的最新帧

三缓冲的边界:三块缓冲为读写交错提供了空间,但不保证始终有空闲块。如果两个连接分别持有不同的旧帧,而第三块是最新帧,就暂时没有可写缓冲,编码任务会让出 CPU,下一轮重新检查。这里优先保证不覆盖正在发送的数据。

4.3 慢连接跳帧与发送退出

每个发送任务只记录自己已经发送的序号,没有维护逐帧发送队列。发送完当前帧后重新查看 s_latest,因此慢端会跳过期间产生的中间 JPEG,避免待发旧帧持续堆积。跳过发生在完整帧之间,不会把已经开始发送的 JPEG 中途替换。

JPEG 数据按最多 512 字节一段提交给发送函数。这个长度是应用层提交大小,并不等同于实际 TCP 包大小。底层使用 netconn_write_partly(),关键是根据实际写入量推进指针:

size_t written = 0U;
err_t error = netconn_write_partly(conn, bytes, length,
                                 NETCONN_COPY, &written);
if (written > length) return -1;
bytes += written;
length -= written;
if (written) progress = HAL_GetTick();

这里的“写入成功”表示数据已交给协议栈处理,不等于浏览器已经显示。暂时不可写或超时可以重试,但若连续 3 秒没有写入进展,就结束发送;等待新帧也有 3 秒超时。

发送成功或失败后都要减少缓冲的 readers。连接退出时再执行 netconn_close()、netconn_delete(),清除客户端活动状态;静态发送任务保留,等待下次通知。断开后名额可能要等设备检测到发送失败才释放,立即重连时可能短暂收到 503。

最多两个观看连接;满员、OTA 忙或停流期间会拒绝新流。首次从无人观看恢复时还会清除最新帧索引,避免新会话先发出上次残留的图像。

5. 页面帧率与 OTA 配合

5.1 页面预览与帧率显示

网页中图像与状态查询分别使用不同连接。下面把固件中的 HTML 字符串整理为页面代码,保留核心逻辑:

<p>推流帧率:<span id="fps">--</span> FPS(板端发送)</p>
<img src="/stream" alt="相机画面"
     style="max-width:100%;height:auto">
<script>
async function updateFps() {
    const el = document.getElementById('fps');
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(), 3000);
    try {
        const r = await fetch('/status', {
            cache: 'no-store', signal: ctrl.signal
        });
        if (!r.ok) throw new Error();
        const s = await r.json();
        el.textContent = (s.mjpeg_fps_x10 / 10).toFixed(1);
    } catch (e) {
        el.textContent = '--';
    } finally {
        clearTimeout(timer);
        setTimeout(updateFps, 1000);
    }
}
updateFps();
</script>

每次查询结束后等待 1 秒再发下一次,查询超过 3 秒则取消,失败时显示 --。

发送任务在完整 JPEG 及帧尾提交成功后计数,统计窗口至少为 1 秒,计算 fps_x10 = 帧数 × 10000 / 时间毫秒数。/status 从活动连接中选出最近 3 秒仍有发送、且统计帧率最高的一个。

帧率口径:页面显示的是最快活动连接的板端发送帧率,不累加两个观看端,也不分别代表每个观看端的速度,更不等同于浏览器解码、渲染后的 FPS。

初次连接尚未形成统计窗口时,数值可能暂时为 0。

5.2 升级前停流与升级后访问

OTA 擦写前,需要让相关业务退出资源使用。工程先取得存储 OTA 占用,再执行停流和摄像头暂停,之后才允许后续擦写。这里摘录与预览直接相关的顺序:

if (app_http_mjpeg_stop(5000U) != 0)
{
    app_http_mjpeg_resume();
    return -1;
}
if (app_ov5640_pause(3000U) != 0)
{
    app_http_mjpeg_resume();
    return -1;
}

app_http_mjpeg_stop() 先设置停止标志,再等待所有观看连接退出且编码不再进行。编码任务在临界区内也检查停止状态,避免停流已返回后,被抢占过的任务又重新进入编码硬件。

失败或取消后的恢复路径会恢复摄像头并解除停流限制。解除限制只允许建立新流,不会复活已经关闭的 TCP 连接;需要复位的升级流程还会中断设备网络服务。当前页面没有自动重连图像的逻辑,连接中断或升级完成后需要刷新页面。

6. 故障处理与恢复

推流涉及采集、编码和网络发送,画面停止更新并不一定是 HTTP 出了问题。本工程在不同层分别处理故障:

  • 采集异常:尝试重启采集链路。
  • 单次编码失败:丢弃失败结果。
  • 发送异常:回收对应连接。

恢复范围:板端业务恢复与浏览器重新建立连接,是两件不同的事。

6.1 摄像头采集异常:停止后重新启动

ov5640_dcmi_stream_poll() 的主要检查项如下:

检查项 触发条件或说明
采集状态 采集错误标志置位,或存在 DCMI 错误
DMA 状态 存在未被屏蔽的 DMA 错误;检查屏蔽了 HAL_DMA_ERROR_FE
帧更新 距离最近一次帧更新超过 1000 ms
LTDC 地址重载 等待消隐期地址重载超过 100 ms,返回超时

因此,不能简单理解为任意 DMA 标志都会触发重启。

摄像头任务发现轮询失败后,先打印现场信息,再停止采集,将 streaming 清零,等待 250 ms 后重新进入启动流程。下面是该分支的关键代码:

ret = ov5640_dcmi_stream_poll();
if (ret != OV5640_DCMI_EOK)
{
    ov5640_dcmi_stream_log_error();
    LOG_ERROR("CAMERA", "CAMERA_STREAM_FAIL=%u DCMI=0x%lX DMA=0x%lX",
              ret, (unsigned long)hdcmi.ErrorCode,
              (unsigned long)hdcmi.DMA_Handle->ErrorCode);
    ov5640_dcmi_stream_stop();
    streaming = 0U;
    retries++;
    LOG_WARN("CAMERA", "CAMERA_RECOVER retry=%lu", (unsigned long)retries);
    osDelay(250U);
    continue;
}

停止函数会先拒绝新的取帧,再等待已持有的帧释放,之后调用 HAL_DCMI_Stop()。因此不能为了恢复速度,直接停止仍被编码任务使用的采集链路。

下一轮调用 ov5640_dcmi_stream_start(),通过日志区分恢复进度:

日志 含义与后续行为
CAMERA_RESTART_FAIL 启动失败,停止后等待 250 ms 再试
CAMERA_RESTART_STARTED 启动调用成功
CAMERA_STREAM_FRAME_PASS 首次完成显示换帧,已有完整帧进入显示流程

启动调用成功不等于已经恢复出图,需要继续观察完整帧日志和实际画面。

这里重启的是采集流,不是重新执行整套 OV5640 初始化。运行中的采集故障会进入重试,但前面的相机参数配置如果直接失败返回,摄像头任务会打印 CAMERA_THREAD_FAIL 并退出,不能把这两类情况都描述成自动恢复。

6.2 取帧或编码失败:不发布无效 JPEG

取帧失败时,MJPEG 编码任务不会使用一个未经保护的地址凑合编码,而是让出 CPU,下一轮再尝试。没有可写 JPEG 缓冲时,也采用同样的重试方式,不覆盖正在发送的数据。

编码接口在硬件阶段最多等待约 2000 ms;这个时间只约束启动硬件编码后的等待,并不是整个颜色转换和编码调用的总超时。硬件错误、输出缓冲异常或 JPEG 起止标记检查失败,会进入失败清理路径,调用 HAL_JPEG_Abort(),释放内部占用并返回失败。

上层仍会释放持有的 RGB565 帧,但不会更新 s_latest,因此半成品 JPEG 不会被发送任务当作新帧读取。未处于停流状态时,会记录 MJPEG_ENCODE_FAIL;只要还有观看连接,编码任务后续仍会尝试编码。

如果迟迟没有新的 JPEG,发送任务在等待新帧超过约 3 秒后关闭连接。所以短暂故障可能在原连接上继续出图,较长故障即使板端后来恢复,也需要浏览器重新请求 /stream。

6.3 网络中断与连接回收

发送函数允许 ERR_WOULDBLOCK、ERR_TIMEOUT 等暂时性结果重试,但连续约 3 秒没有写入进展,或遇到其他发送错误,就退出当前连接。退出前释放已持有 JPEG 的引用,随后关闭、删除 netconn,清除客户端活动标志,打印 MJPEG_STREAM_STOP。

这条路径回收的是当前观看端的资源,不会主动停止另一个观看端。网络恢复后,需要新的 /stream 请求重新接入;原有 TCP 连接不会由固件自动重建。当前页面只会自动重试 /status 查询,图像流断开后仍需刷新页面。

另外,503 是准入失败,不一定代表摄像头或网络故障。以下情况都会使启动接口返回失败:

  • 两路观看名额已占满。
  • OTA 正忙,或停流限制未解除。
  • 首次创建相关任务失败。

应先检查其他标签页和设备是否仍在观看,关闭多余连接后等待设备回收,再刷新;升级期间则等待业务恢复。

6.4 按现象定位与确认恢复

现象 优先查看 恢复判断与操作
LCD 和网页画面都不再更新 CAMERA_STREAM_FAIL、CAMERA_RECOVER、CAMERA_RESTART_FAIL 先判断采集是否重启,并观察 CAMERA_STREAM_FRAME_PASS;若网页连接已超时,恢复后刷新
LCD 正常,网页停止更新 MJPEG_ENCODE_FAIL、MJPEG_STREAM_STOP,以及 /status 是否可访问 区分编码失败与发送退出;确认持续产生 JPEG 后重新建立流连接
拔掉 LCD 并重新上电后,网页仍能出图但帧率明显下降 CAMERA_LTDC、ENCODE_PERF convert,以及无屏图层关闭路径 检查 LTDC 是否仍在读取 SDRAM;设置帧地址后也要保持无屏图层关闭,详见 6.5 节
新页面无法出图,流请求返回 503 活动观看端数量、OTA/停流状态、任务创建路径 释放观看名额或等待升级结束,再刷新;持续失败时继续排查任务创建与资源状态
页面 FPS 显示 -- /status 请求是否失败或超时 状态查询会自动重试;单凭 -- 不能判断图像流是否已断开
画面卡住,FPS 随后变为 0 是否还在发送、是否有新 JPEG、连接是否已被回收 无近期活动发送时统计值会归零,需要结合日志判断,不能把 0 直接等同于摄像头故障
OTA 结束或取消后页面仍停在旧画面 摄像头是否恢复、停流限制是否解除 板端恢复后刷新页面;已关闭的流连接不会自动恢复

恢复验证要看完整链路:

  1. 采集重新启动后,是否产生完整帧。
  2. 新的观看连接是否出现 MJPEG_FIRST_FRAME。
  3. 画面是否继续变化,另一个观看端是否仍正常。

6.5 无 LCD 启动时的帧率下降

拔掉 LCD 排线后重新上电,LTDC 图层仍可能读取 SDRAM,与摄像头搬运和 JPEG 颜色转换争用带宽,导致网页能出图但帧率明显下降。

修复时,在 lcd_init() 未检测到面板的分支关闭图层;换帧调用 HAL_LTDC_SetAddress_NoReload() 后也要再次关闭,因为当前 HAL 会重新使能图层。保留 LTDC 时序,让现有消隐期换帧机制继续工作。

实机修复后,颜色转换日志样本由 114–127 ms 降至 47 ms,现场确认推流明显恢复、画面正常。这是短时验证结果,未进行长期稳定性和接屏显示回归。

7. 浏览器访问与效果验证

7.1 访问方法与效果展示

板卡与电脑处于可互通的网络中,确认设备实际 IP 后,可使用以下地址:

访问目的 地址
打开预览页面 http://<设备IP>/
直接查看图像流 http://<设备IP>/stream
查看状态 JSON http://<设备IP>/status

http://<设备IP>/
image

http://<设备IP>/stream
image

http://<设备IP>/status
image

两个页面各自建立 /stream 连接即可验证双端观看,测试第三个连接前应确认已有两个流仍在工作。调试时注意其他浏览器标签页也可能占用观看名额。

1452e3ba94d7bd7a5ec9c38a5b295b36

7.2 验证项目与性能观察

可以结合网页和串口日志:

观察项 来源与含义
板端发送 FPS 页面或 /status,按前述最快活动连接口径统计
MJPEG_PERF acquire 取帧调用耗时,包含相应缓存处理
MJPEG_PERF encode 编码调用及释放帧等后续操作的近似耗时,不含网络发送
ENCODE_PERF convert RGB565 到 YCbCr MCU 数据的转换耗时
ENCODE_PERF dma 从启动硬件编码到结果检查阶段的耗时,包含等待和后处理
ENCODE_PERF bytes 当前 JPEG 实际字节数,随画面内容变化

串口打印日志:

[I][MJPEG] MJPEG_STREAM_START
[I][MJPEG] MJPEG_FIRST_FRAME bytes=69349
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69821
[I][MJPEG] MJPEG_PERF acquire=0 encode=53 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=40 dma=6 ms bytes=70162
[I][MJPEG] MJPEG_PERF acquire=1 encode=53 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69828
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69350
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=70236
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69522
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=7 ms bytes=69099
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69772
[I][MJPEG] MJPEG_PERF acquire=0 encode=53 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=6 ms bytes=69924
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][JPEG] ENCODE_PERF convert=39 dma=7 ms bytes=70167
[I][MJPEG] MJPEG_PERF acquire=1 encode=52 ms/frame clients=1
[I][MJPEG] MJPEG_STREAM_STOP frames=315

这些性能日志每隔一定帧数输出一次,并不是连续采样平均值。比较单双端结果时,应尽量保持画面、质量参数和网络环境一致;所需图像负载可粗略按“平均 JPEG 字节数 × 每秒帧数 × 观看端数量”估算,实际链路还包含协议开销。

博客导航

博客导航

posted @ 2026-10-26 15:46  膝盖中箭卫兵  阅读(8)  评论(0)    收藏  举报
ORCID iD icon https://orcid.org/0000-0001-5102-772X