导读 :产线上把监控画面接到工控机,经常被写成一行
VideoCapture。一路还好,多路、掉线、换品牌之后这行就不够用了。监控取流是四层决策叠在一起:选什么相机、电口还是网口、像素怎么进进程、C++ 怎么管线程和重连。本文按这四层写完,并给出一份只依赖 FFmpeg 的最小封装:配置与界面分开,一路一个线程,断线自己重连。
前言
「监控取流」不是单一 API 调用。现场要先回答四件事:
- 设备层:选什么相机。
- 链路层:电口 / 网口怎么进工控机。
- 协议层:像素怎么从相机到进程(RTSP 或其它)。
- 软件层:C++ 如何封装线程、重连、显示,以及和业务解耦。

给人看的实时画面,和给算法测距 / 定位,通常是两条链路。监控优先网络摄像机(IPC)加 RTSP;检测相机另选工业视觉标准,不要用监控 IPC 硬扛尺寸精度,也不要把 GigE 检测相机塞进监控预览页。
下面按这四层往下写。示例工程分两层:库 rtsp_link 负责拉流,界面 rtsp_demo 只消费最新一帧。
一、相机 / 摄像头怎么选
1、先定场景,再定品类
| 场景 | 推荐品类 | 不推荐 |
|---|---|---|
| 产线旁路监控、看工位全貌(本地监控) | 网络摄像机 IPC(支持 RTSP) | 消费级 USB 摄像头作主方案 |
| 近距离调试、实验室 | USB UVC 摄像头 | 当作长期现场方案 |
| 高精度尺寸 / 缺陷检测 | 工业面阵(GigE / USB3 Vision) | 用监控 IPC 硬扛检测精度 |
| 远程监控上云 | 网络摄像机 IPC(支持 RTMP) | 网络摄像机 IPC(支持 RTSP) |
监控页要的是「给人看实时画面」。USB 摄像头线短、多路扩展差,适合实验台,不适合当长期现场方案。
2、厂区本地监控预览
这台可以当室内工位全貌的采购起点:
| 项 | 选型 |
|---|---|
| 型号 | 海康威视 DS-2CD2146F(D)WDA1-IS(400 万定焦智能半球) |
| 场景 | 工位全貌、旁路监控、多路预览页(室内顶装优先) |
| 分辨率 | 约 2560×1440 @25fps(主码流);预览优先子码流约 720p@15--25fps |
| 匹配点 | 官方支持 RTSP / ONVIF、H.264/H.265、主/子码流齐全;采购选不带 D 的型号以支持 PoE |
| 外形与安装 | 半球,适合室内吸顶;相对筒机外形更收敛 |
| 接入 | 固定 IP + 只读监控账号,写入设备表的 url |
同像素备选:海螺 DS-2CD2346(F)(D)WDV3-I(S),调角度更方便。协议和取流方式一致,换机主要改 URL。
3、监控 IPC 选型清单
采购规格可以直接按这几条卡:
- 协议:官方文档明确支持 RTSP,能给出主码流、子码流 URL。
- 编码:优先 H.264,兼容最好。H.265 省带宽,解码更吃 CPU / GPU。
- 分辨率 / 帧率:预览用子码流 720p@15--25fps;要看细节再开主码流。
- 供电与安装:优先 PoE;固定支架、工业温宽。
- 网络:固定 IP,或 DHCP 预留;和 PLC、工控机同网段可达。
- 账号:独立只读监控账号。管理员口令不要写进配置文件。
4、可迁移的配置模型
URL 不要写死在代码里。用一份有序设备表:
json
{
"num": 2,
"device_list": [
{ "enable": true, "function": "cam_left", "url": "rtsp://user:pwd@192.168.1.64:554/stream1" },
{ "enable": true, "function": "cam_right", "url": "rtsp://user:pwd@192.168.1.65:554/stream1" }
]
}
使用规则就四条:
- 只拉
enable=true的项。 - 数组顺序就是画面格子顺序。
function是业务名;url才是开源栈真正打开的地址。- UI 格子数有上限时,只取前 N 路,并打日志说明截断。
二、视频进工控机,硬件接口怎么选
| 接口 | 典型设备 | 距离 | 多路扩展 | 监控推荐度 |
|---|---|---|---|---|
| 千兆以太网 + PoE 交换机 | RTSP IPC | 长 | 极易 | ★★★★★ |
| USB2 / USB3 | UVC / 部分工业相机 | 短 | 差 | ★★(调试) |
| GigE Vision | 工业相机 | 中长 | 好 | ★★★(检测) |
| HDMI / SDI + 采集卡 | 广播 / 工控显示源 | 中 | 一般 | ★★ |
| Camera Link / CXP | 高速工业相机 | 短 | 需采集卡 | ★(高速检测) |
监控默认方案是:IPC → PoE 交换机 → 工控机网口。

软件侧看到的是网络上的 RTSP URL,不是某个 /dev/video0,也不是采集卡设备名。
选型口诀:
- 要远、要多路、要可换品牌 → 以太网 + RTSP。
- 要触发、全局快门、低延迟检测 → GigE / USB3 Vision。这条不要硬套进监控架构。
三、什么是 RTSP
RTSP(Real Time Streaming Protocol)是应用层的流会话控制协议,负责三件事:
- 描述流(
DESCRIBE) - 建立传输通道(
SETUP) - 播放、暂停、拆除(
PLAY/TEARDOWN)
它不负责像素字节本身。媒体载荷通常由 RTP / RTCP 传输:
- UDP:延迟更低,丢包要自己处理。
- TCP(RTSP interleaved):穿透防火墙更好。工业现场网络不干净时,往往更稳。

一个典型地址:
text
rtsp://username:password@192.168.1.64:554/Streaming/Channels/101
路径因厂商而异,海康、大华、华为各不相同。所以 URL 必须可配置,不能写死在代码里。
可以把 RTSP 理解成遥控器,RTP 才是电视信号线。
四、除了 RTSP,还有哪些取流方式
1、常见方式对照
| 方式 | 延迟 | 生态 | 实现复杂度 | 典型用途 |
|---|---|---|---|---|
| RTSP + RTP | 低~中 | IPC 事实标准 | 中 | 产线监控 |
| HTTP-FLV / HLS / DASH | 中~高 | Web 友好 | 低~中 | 远程回看、大屏网页 |
| WebRTC | 很低 | 浏览器强 | 高 | 互动通话、超低延迟 Web |
| 本机 V4L2 / DirectShow / Media Foundation | 低 | 本机设备 | 中 | USB 摄像头 |
| GigE Vision / USB3 Vision | 低 | 工业视觉 | 中~高 | 检测算法取图 |
| 厂商私有 SDK | 低 | 绑定品牌 | 中 | 要 SDK 独占功能时 |
2、监控场景仍常选 RTSP
- 设备通用:绝大多数 IPC 开箱支持。换品牌主要改 URL。
- 网络化部署:天然多路、跨交换机,和产线布线一致。
- 传输可选 TCP / UDP:现场网络差时优先 TCP。
WebRTC 和 HLS 解决的是浏览器和公网回看。产线工控机要的是「固定 IP 的 IPC,进程里能拿到帧」,RTSP 仍然是这条路上的通用解。
五、用 FFmpeg 做 C++ 封装
拉流配置和业务界面分开。库不认识 Qt,界面不碰套接字。
参考示例资源链接如下:Rtsp+FFmpeg使用示例资源-CSDN下载

1、两层各管什么
| 职责 | 库 rtsp_link |
界面 rtsp_demo |
|---|---|---|
| 依赖 | FFmpeg,无 Qt | Qt Widgets + rtsp::rtsp_link |
| 配置 | RtspConfig 只读、校验、解析多路地址 |
CameraConfig 调库来读;保存仍由界面写回 JSON |
| 拉流 | RtspPuller:一个地址、一条线程、断线自己重连 |
不碰套接字。地址合法就 start(),按钮不决定连不断 |
| 图像 | 只保留最新一帧 RGB24 | FrameCache 收成 QImage;「开始拉流」才画到窗口 |
调用关系是:
text
rtsp.json
│
▼
rtsp_link(纯 C++)
RtspConfig::load 文件是否存在、JSON 是否合法,失败给出中文原因
RtspPuller × N 调用方对每个启用设备各建一个;Open → Read → Decode
失败则关闭,隔 1 秒再连;只换掉最新 RGB 帧
│ install/ 里的头文件和 DLL
▼
上位机(Qt + C++)
CameraConfig 当前窗口只用第一个 enable=true 的设备
FrameCache 显示缓存线程,把 latestFrame() 转成 QImage
MainWindow 「开始拉流」只决定要不要把这张图画到 VideoWidget
2、依赖与链接边界
FFmpeg 只链进 rtsp_link.dll。上位机不要再链 avformat 等导入库。运行时把对应配置的 FFmpeg DLL 和 rtsp_link.dll(Debug 为 rtsp_linkd.dll)放到 exe 旁边即可。
| 库 | 用途 | 链到哪 |
|---|---|---|
| libavformat | 打开 RTSP、解复用 | 仅 rtsp_link |
| libavcodec | H.264 / H.265 解码 | 仅 rtsp_link |
| libavutil | 时间、错误码、地址拆分 | 仅 rtsp_link |
| libswscale | YUV → RGB24 | 仅 rtsp_link |
| Qt Widgets | 窗口、按钮、把 RGB 收成 QImage | 仅上位机 |
FFmpeg 来自仓库根目录 ffmpeg_install_7.1.5(7.1.5,用 v142 编译)。Debug 用其中的 debug,其余配置用 release。
上位机只导入已经安装好的库(rtsp_demo/CMakeLists.txt):
cpp
find_package(rtsp_link REQUIRED CONFIG
PATHS "${CMAKE_CURRENT_SOURCE_DIR}/rtsp/install/cmake"
NO_DEFAULT_PATH)
target_link_libraries(rtsp_demo PRIVATE
rtsp::rtsp_link
Qt5::Widgets
)
3、库:rtsp_link
3.1、读配置 RtspConfig
RtspConfig::load 只读文件,不访问网络,不启动拉流。路径按 UTF-8 传入。程序实际读的是 exe 同目录的 rtsp.json,模板在 rtsp_demo/config/rtsp.json。
json
{
"num": 2,
"device_list": [
{
"enable": true,
"function": "cam_right",
"url": "rtsp://user:pwd@192.168.1.65:554/stream1",
"transport": "tcp"
}
]
}
对外接口在 rtsp_demo/rtsp/include/rtsp_config.h。失败时设备列表为空,loadConfigMessage() 是中文原因:
cpp
class RtspConfig {
public:
bool load(const std::string& utf8Path);
ConfigLoadStatus status() const;
std::string loadConfigMessage() const; // 失败原因,或成功时的路数统计
std::vector<DeviceEntry> devices() const; // 文件原顺序,含 enable=false
std::vector<DeviceEntry> enabledDevices() const;
private:
class Impl;
std::unique_ptr<Impl> impl_;
};
实现藏在 Impl 里,头文件只暴露上面这几个调用。解析核心在 rtsp_config.cpp。结构不合法就整份失败;某一路地址为空或格式不对,只记在该路的 addressStatus 上,整份文件仍然成功。enable 缺省为 true;transport 缺省、为空或不是 udp 时为 TCP。
cpp
const nlohmann::json root = nlohmann::json::parse(bytes);
if (!root.is_object()) {
fail(ConfigLoadStatus::RootNotObject, "配置根节点必须是 JSON 对象");
return false;
}
const nlohmann::json& list = root.at("device_list");
if (!list.is_array()) {
fail(ConfigLoadStatus::DeviceListNotArray, "device_list 必须是数组");
return false;
}
for (std::size_t index = 0; index < list.size(); ++index) {
if (!list.at(index).is_object()) {
fail(ConfigLoadStatus::DeviceItemInvalid,
"device_list[" + std::to_string(index) + "] 必须是对象");
return false;
}
const nlohmann::json& item = list.at(index);
DeviceEntry entry;
entry.enable = parseEnable(item); // 缺省 true
entry.function = parseString(item, "function");
entry.url = trimCopy(parseString(item, "url"));
entry.transport = parseTransport(item.contains("transport") ? item.at("transport")
: nlohmann::json());
entry.addressStatus = RtspPuller::checkAddress(entry.url); // 不访问网络
devices.push_back(std::move(entry));
}
文件不存在、打不开、空文件、JSON 语法错误,都在进入这个循环之前失败,并把原因写进 message。
3.2、一路一线程 RtspPuller
一个实例对应一个地址和一条线程。控制接口从同一线程调用;state、latestFrame、hasFreshFrame 可以在别的线程读。头文件在 rtsp_demo/rtsp/include/rtsp_puller.h:
cpp
enum class PullState { Stopped, Connecting, Streaming, Reconnecting };
class RtspPuller {
public:
void setTransport(Transport transport); // 默认 TCP,下次连接或重连生效
bool start(const std::string& url); // true 只表示线程已启动
void stop();
PullState state() const;
std::shared_ptr<const VideoFrame> latestFrame() const;
bool hasFreshFrame(int timeoutMs = 2000) const;
static AddressStatus checkAddress(const std::string& url); // 不访问网络
private:
class Impl;
std::unique_ptr<Impl> impl_;
};

打开、解码、重连都在 RtspPuller::Impl(rtsp_puller.cpp)。打开时强制低延迟,并挂上中断回调,这样 stop() 不用干等套接字超时:
cpp
const char* transportName = transport_.load() == Transport::Udp ? "udp" : "tcp";
av_dict_set(&options, "rtsp_transport", transportName, 0);
av_dict_set(&options, "timeout", "2000000", 0); // 套接字超时,微秒
av_dict_set(&options, "fflags", "nobuffer", 0);
av_dict_set(&options, "max_delay", "500000", 0);
av_dict_set(&options, "reorder_queue_size", "0", 0);
format->interrupt_callback.callback = [](void* opaque) -> int {
const auto* self = static_cast<const Impl*>(opaque);
return self->stopRequested_.load() ? 1 : 0;
};
format->interrupt_callback.opaque = this;
// 随后 avformat_open_input → find_stream_info → 打开视频解码器
读失败或打开失败都不退出线程。解出的帧转成 RGB24 后直接换掉上一帧,不排队:
cpp
void publishFrame(const AVFrame* frame) {
// sws_scale 转到 AV_PIX_FMT_RGB24,写入新建的 VideoFrame
image->stride = frame->width * 3;
image->timestampMs = RtspPuller::nowMs();
std::lock_guard<std::mutex> lock(frameMutex_);
latest_ = std::move(image); // 调用方拿到的 shared_ptr 不再被改写
state_.store(PullState::Streaming);
}
void run() {
state_.store(PullState::Connecting);
while (!stopRequested_.load()) {
if (!openInput()) {
state_.store(PullState::Reconnecting);
if (!sleepUnlessStopped(1000)) break; // 停止请求会立刻打断这 1 秒
continue;
}
while (!stopRequested_.load()) {
const int readError = av_read_frame(fmt_, packet);
if (readError == AVERROR(EAGAIN)) continue;
if (readError < 0) break; // 中断,下面关闭后重连
if (packet->stream_index == videoIndex_)
decodePacket(packet, frame); // 内部 publishFrame()
av_packet_unref(packet);
}
closeInput();
if (stopRequested_.load()) break;
state_.store(PullState::Reconnecting);
if (!sleepUnlessStopped(1000)) break;
}
state_.store(PullState::Stopped);
}
不带界面时,多路就是多份 RtspPuller:
cpp
rtsp::RtspConfig config;
if (!config.load("rtsp.json")) {
// config.loadConfigMessage() 即失败原因
return;
}
for (const rtsp::DeviceEntry& device : config.enabledDevices()) {
if (device.addressStatus != rtsp::AddressStatus::Ok) continue;
auto puller = std::make_unique<rtsp::RtspPuller>();
puller->setTransport(device.transport);
puller->start(device.url); // 一路一个实例、一条线程
}
4、界面只消费库
界面不自己判断 JSON 结构是否合法,读配置直接问库。当前窗口记下全部设备,但只用第一个 enable=true 的地址(cameraconfig.cpp):
cpp
void CameraConfig::load() {
path_ = QCoreApplication::applicationDirPath() + "/rtsp.json";
rtsp::RtspConfig config;
if (!config.load(toUtf8(path_))) {
loadMessage_ = fromUtf8(config.loadConfigMessage()); // 顶部红字用这句,不启动拉流
return;
}
for (const rtsp::DeviceEntry& entry : config.devices()) {
devices_.push_back(/* enable、function、url、transport */);
}
// 取第一个 enable == true 作为当前地址
}
点「保存」只改这台的 url,再用 Qt 把整个列表写回 rtsp.json。写文件留在界面,不进库。
窗口构造完就按地址启动库,不等按钮(mainwindow.cpp)。地址格式不合法时不 start(),拉流线程保持停止:
cpp
void MainWindow::applyBackend(const QString& url) {
cache_->stop();
puller_->stop();
cache_->clear();
ui_->videoWidget->clearFrame();
if (rtsp::RtspPuller::checkAddress(toStdUtf8(savedUrl_)) == rtsp::AddressStatus::Ok) {
puller_->setTransport(useUdp ? rtsp::Transport::Udp : rtsp::Transport::Tcp);
puller_->start(toStdUtf8(savedUrl_)); // 后台开始连,与按钮无关
}
cache_->start();
}
FrameCache 在自己的线程里取库的最新帧。时间戳没变就不动缓存,避免同一帧被反复拷贝(framecache.cpp):
cpp
void FrameCache::storeLatest() {
const auto frame = puller_->latestFrame(); // RGB24;空指针表示还没有图像
if (!frame || frame->rgb.empty()) return;
if (hasImage_ && timestampMs_ == frame->timestampMs) return;
QImage detached(frame->width, frame->height, QImage::Format_RGB888);
for (int y = 0; y < frame->height; ++y) {
const auto* src = frame->rgb.data() + static_cast<size_t>(y) * frame->stride;
std::memcpy(detached.scanLine(y), src, static_cast<size_t>(frame->stride));
}
image_ = std::move(detached);
hasImage_ = true;
timestampMs_ = frame->timestampMs;
}
「开始拉流」能点,仅当最近 2 秒内有新图像。正在显示时若超过 2 秒没有新帧,按钮回到灰色的「开始拉流」,画面留下最后画上去的那一帧,顶部红字「相机掉线」。重连后再出图,空闲提示变回绿色「相机已经就绪」。
cpp
const bool fresh = puller_ && puller_->hasFreshFrame(2000);
if (displaying_) {
if (!fresh) {
displaying_ = false; // 只停上屏,库继续重连
updateStreamButton();
} else {
const FrameCache::Snapshot snap = cache_->snapshot();
if (snap.hasImage) ui_->videoWidget->setFrame(snap.image);
ui_->videoWidget->setHint(QString(), QColor());
return;
}
}
showHint(fresh); // 配置失败 / 空地址 / 地址错误 / 就绪 / 掉线
按钮管的是「要不要把图画到窗口」,不管「套接字还连不连」。掉线之后库仍在 Reconnecting,画面停在最后一帧,避免预览区闪成空白。
一句话结论
- 相机:监控选支持 RTSP 的 IPC,H.264,优先 PoE。
- 接口:千兆以太网。现场不要靠 USB 撑多路。
- 协议:RTSP 控会话,RTP 传媒体。现场优先 TCP。
- 替代方案很多,IPC 监控里 RTSP 仍是通用解。
- C++:这份封装只基于 FFmpeg 自建会话、状态和多路管理。配置可迁移,链接边界清楚,断线重连不把界面线程拖死。