前言
"调用摄像头拍照并做图像处理"是很多桌面项目的标配需求:人脸签到、工牌识别、质检拍照。Qt 用 Qt Multimedia 模块负责跟摄像头打交道,图像处理则交给 QImage 完成。
但真正动手会发现几个绕不开的问题:Qt5 和 Qt6 的相机 API 完全不是一套 (Qt6 重写了整个多媒体栈);拍出来的照片可能是左右镜像的;QImage 的各种 Format 枚举让人眼花;在预览回调里直接处理大图会卡住界面。
本文走完"打开相机 → 实时预览 → 拍照保存 → 图像处理"全流程,重点讲清 Qt5/Qt6 差异,并给出灰度、二值化、Sobel 边缘检测的完整实现。
一、Qt Multimedia 的架构变迁
先说清这个"大分裂",否则你搜到的教程很可能跑不起来。
Qt5 以 QCamera 为中心,配 QCameraViewfinder 预览、QCameraImageCapture 拍照。Qt6 全部废弃,改用**会话(Session)**模型:
QCamera ──setCamera()──> QMediaCaptureSession
├── setVideoOutput() ← 预览
└── setImageCapture() ← 拍照
这么做是因为 Qt5 把"设备"和"功能"耦合太紧,而会话模型让"一个相机 → 多个输出"变得自然。
Qt5 与 Qt6 API 对照表
| 功能 | Qt5 | Qt6 |
|---|---|---|
| 列出摄像头 | QCameraInfo::availableCameras() |
QMediaDevices::videoInputs() |
| 默认摄像头 | QCameraInfo::defaultCamera() |
QMediaDevices::defaultVideoInput() |
| 预览控件 | QCameraViewfinder |
QVideoWidget |
| 绑定预览 | camera->setViewfinder(vf) |
session->setVideoOutput(vw) |
| 拍照控制 | QCameraImageCapture |
QImageCapture |
| 绑定拍照 | camera->setCaptureMode(...) |
session->setImageCapture(...) |
| 保存到文件 | capture->capture(path) |
capture->captureToFile(path) |
| 错误信号 | error(int,int,QString) |
errorOccurred(int,int,QString) |
重要提醒 :Qt6 里 QVideoWidget 在 Qt6::MultimediaWidgets,而 QCamera/QMediaCaptureSession/QImageCapture 在 Qt6::Multimedia。两个都要 link,否则报"未定义的符号"。
二、环境准备
cmake
cmake_minimum_required(VERSION 3.16)
project(CameraDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_AUTOMOC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets Multimedia MultimediaWidgets)
add_executable(CameraDemo main.cpp camerawidget.cpp camerawidget.h
imageprocessor.cpp imageprocessor.h)
target_link_libraries(CameraDemo PRIVATE
Qt6::Widgets Qt6::Multimedia Qt6::MultimediaWidgets)
qmake(Qt5)版本:
bash
QT += core gui widgets multimedia multimediawidgets
CONFIG += c++17
TARGET = CameraDemo
SOURCES += main.cpp camerawidget.cpp imageprocessor.cpp
HEADERS += camerawidget.h imageprocessor.h
平台依赖:Windows 用 Media Foundation 后端一般开箱即用;Linux 需要 GStreamer 的 good/bad/ugly 插件组;Android 需要 android.permission.CAMERA。
macOS 最容易踩坑 :必须在 Info.plist 里加下面这一条,否则一调用相机就被系统直接杀掉。
xml
<key>NSCameraUsageDescription</key>
<string>本应用需要访问摄像头以完成拍照</string>
三、拍照全流程:Qt6 实现
头文件
cpp
// camerawidget.h
#include <QWidget>
#include <QImage>
QT_BEGIN_NAMESPACE
class QCamera; class QMediaCaptureSession; class QImageCapture;
class QVideoWidget; class QPushButton; class QLabel;
QT_END_NAMESPACE
class CameraWidget : public QWidget
{
Q_OBJECT
public:
explicit CameraWidget(QWidget *parent = nullptr);
~CameraWidget() override;
private slots:
void startCamera();
void stopCamera();
void takePhoto();
void onImageCaptured(int id, const QImage &preview);
void onImageSaved(int id, const QString &fileName);
void onCaptureError(int id, int error, const QString &errorString);
private:
QCamera *m_camera = nullptr;
QMediaCaptureSession *m_session = nullptr;
QImageCapture *m_capture = nullptr;
QVideoWidget *m_viewfinder = nullptr;
QLabel *m_status = nullptr;
QPushButton *m_btnStart = nullptr;
QPushButton *m_btnShot = nullptr;
};
实现
cpp
// camerawidget.cpp
#include "camerawidget.h"
#include "imageprocessor.h"
#include <QCamera>
#include <QMediaCaptureSession>
#include <QImageCapture>
#include <QVideoWidget>
#include <QMediaDevices>
#include <QCameraDevice>
#include <QPushButton>
#include <QLabel>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QStandardPaths>
#include <QDateTime>
#include <QDir>
#include <QMessageBox>
#include <QDebug>
CameraWidget::CameraWidget(QWidget *parent) : QWidget(parent)
{
setWindowTitle(QStringLiteral("拍照 + 图像处理"));
m_viewfinder = new QVideoWidget(this);
m_viewfinder->setMinimumSize(640, 480);
m_status = new QLabel(QStringLiteral("相机未启动"), this);
m_btnStart = new QPushButton(QStringLiteral("启动相机"), this);
m_btnShot = new QPushButton(QStringLiteral("拍照"), this);
m_btnShot->setEnabled(false);
auto *btnLayout = new QHBoxLayout;
btnLayout->addWidget(m_btnStart);
btnLayout->addWidget(m_btnShot);
btnLayout->addWidget(m_status, 1);
auto *root = new QVBoxLayout(this);
root->addWidget(m_viewfinder, 1);
root->addLayout(btnLayout);
connect(m_btnStart, &QPushButton::clicked, this, [this]{
(m_camera && m_camera->isActive()) ? stopCamera() : startCamera();
});
connect(m_btnShot, &QPushButton::clicked, this, &CameraWidget::takePhoto);
}
CameraWidget::~CameraWidget()
{
if (m_camera && m_camera->isActive())
m_camera->stop(); // 先停,让会话退出活动状态
}
void CameraWidget::startCamera()
{
// 1. 找设备:优先默认,没有就放弃
const QCameraDevice dev = QMediaDevices::defaultVideoInput();
if (dev.isNull()) {
QMessageBox::warning(this, QStringLiteral("提示"),
QStringLiteral("未检测到可用摄像头"));
return;
}
// 2. 建对象,父子关系交给 Qt 对象树管理,避免销毁顺序出错
if (!m_camera) {
m_camera = new QCamera(dev, this);
m_session = new QMediaCaptureSession(this);
m_capture = new QImageCapture(this);
// 3. 会话把三者串起来------Qt6 的核心范式
m_session->setCamera(m_camera);
m_session->setVideoOutput(m_viewfinder);
m_session->setImageCapture(m_capture);
// 4. 连接信号
connect(m_capture, &QImageCapture::imageCaptured,
this, &CameraWidget::onImageCaptured);
connect(m_capture, &QImageCapture::imageSaved,
this, &CameraWidget::onImageSaved);
connect(m_capture, &QImageCapture::errorOccurred,
this, &CameraWidget::onCaptureError);
// 5. 相机错误必须连,否则设备被占用时用户毫无察觉
connect(m_camera, &QCamera::errorOccurred, this,
[this](QCamera::Error err, const QString &msg){
if (err != QCamera::NoError) {
m_status->setText(QStringLiteral("相机错误: ") + msg);
m_btnShot->setEnabled(false);
}
});
}
m_camera->start(); // 启动预览
m_btnStart->setText(QStringLiteral("停止相机"));
m_btnShot->setEnabled(true);
m_status->setText(QStringLiteral("相机已启动,可以拍照了"));
}
void CameraWidget::stopCamera()
{
if (m_camera) m_camera->stop();
m_btnStart->setText(QStringLiteral("启动相机"));
m_btnShot->setEnabled(false);
m_status->setText(QStringLiteral("相机已停止"));
}
void CameraWidget::takePhoto()
{
if (!m_capture || !m_camera || !m_camera->isActive())
return;
const QString dir = QStandardPaths::writableLocation(QStandardPaths::PicturesLocation);
QDir().mkpath(dir); // 目录不存在会导致保存静默失败
const QString path = QStringLiteral("%1/photo_%2.jpg")
.arg(dir, QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss_zzz"));
m_capture->captureToFile(path); // 落盘,同时发 imageCaptured 和 imageSaved
m_status->setText(QStringLiteral("正在保存到 ") + path);
}
void CameraWidget::onImageCaptured(int id, const QImage &preview)
{
Q_UNUSED(id)
qDebug() << "捕获到图像,尺寸:" << preview.size() << "格式:" << preview.format();
// 注意:这里在 UI 线程;处理 4K 大图要挪到工作线程(见坑点 4)
const QImage gray = ImageProcessor::toGray(preview);
const QImage bin = ImageProcessor::binarize(gray, 128);
const QImage edge = ImageProcessor::sobelEdge(gray);
Q_UNUSED(bin) Q_UNUSED(edge)
m_status->setText(QStringLiteral("已捕获并完成图像处理"));
}
void CameraWidget::onImageSaved(int id, const QString &fileName)
{
Q_UNUSED(id)
m_status->setText(QStringLiteral("已保存: ") + fileName);
}
void CameraWidget::onCaptureError(int id, int error, const QString &errorString)
{
Q_UNUSED(id) Q_UNUSED(error)
m_status->setText(QStringLiteral("拍照失败: ") + errorString);
}
Qt5 版本的差异
Qt5 的流程是:QCameraInfo::defaultCamera() 找设备 → new QCamera(info, this) → setViewfinder(new QCameraViewfinder) 预览 → setCaptureMode(QCamera::CaptureStillImage) → 拍照前先 searchAndLock() 对焦锁定,capture(path) 后再 unlock()。要点是没有 session 对象 ,预览和拍照都直接挂在 QCamera 上;且 QCameraViewfinder 这个类在 Qt6 里已被删除,统一用 QVideoWidget。
四、用 QImage 做图像处理
QImage::Format 常用值
| Format | 每像素字节 | 说明 | 处理友好度 |
|---|---|---|---|
Format_RGB32 |
4 | 0xffRRGGBB,不透明 | ⭐⭐⭐ 最推荐 |
Format_ARGB32 |
4 | 带 alpha | ⭐⭐⭐ |
Format_RGB888 |
3 | 无 alpha | ⭐⭐ |
Format_Grayscale8 |
1 | 8 位灰度(Qt 5.5+) | ⭐⭐⭐ 灰度首选 |
Format_Indexed8 |
1 | 调色板索引 | ⭐ 要配 colorTable |
黄金法则 :处理前先 convertToFormat() 成 Format_RGB32(彩色)或 Format_Grayscale8(灰度),再去访问像素。直接对未知格式按固定步长读,是崩溃和花屏的根源。
灰度化
灰度用 ITU-R BT.601 亮度权重,比简单的 (R+G+B)/3 更符合人眼感知:
Gray = 0.299*R + 0.587*G + 0.114*B
cpp
// imageprocessor.h
#include <QImage>
class ImageProcessor
{
public:
ImageProcessor() = delete;
static QImage toGray(const QImage &src);
static QImage binarize(const QImage &gray, int threshold);
static QImage sobelEdge(const QImage &gray);
};
cpp
// imageprocessor.cpp
#include "imageprocessor.h"
#include <QtMath>
QImage ImageProcessor::toGray(const QImage &src)
{
if (src.isNull()) return {};
const QImage img = src.convertToFormat(QImage::Format_RGB32); // 统一格式
QImage gray(img.size(), QImage::Format_Grayscale8);
for (int y = 0; y < img.height(); ++y) {
const QRgb *srcLine = reinterpret_cast<const QRgb *>(img.constScanLine(y));
uchar *dstLine = gray.scanLine(y);
for (int x = 0; x < img.width(); ++x) {
const QRgb px = srcLine[x];
// +0.5 是四舍五入,qBound 防溢出
const int v = int(0.299 * qRed(px) + 0.587 * qGreen(px)
+ 0.114 * qBlue(px) + 0.5);
dstLine[x] = uchar(qBound(0, v, 255));
}
}
return gray;
}
为什么用 scanLine() 而不是 pixel()/setPixel()? 后者是逐像素函数调用还要处理格式转换,比直接指针访问慢几十倍。1920×1080 的图,pixel() 版要几百毫秒,scanLine() 版只要几毫秒。
二值化
cpp
QImage ImageProcessor::binarize(const QImage &gray, int threshold)
{
if (gray.isNull()) return {};
const QImage img = (gray.format() == QImage::Format_Grayscale8)
? gray : toGray(gray);
QImage out(img.size(), QImage::Format_Grayscale8);
for (int y = 0; y < img.height(); ++y) {
const uchar *srcLine = img.constScanLine(y);
uchar *dstLine = out.scanLine(y);
for (int x = 0; x < img.width(); ++x)
dstLine[x] = (srcLine[x] >= threshold) ? 255 : 0;
}
return out;
}
阈值写死 128 在光照不均时效果很差。实际项目建议用**大津法(Otsu's method)**按直方图自动求阈值:统计 0~255 的直方图后,遍历每个候选阈值把像素分成前景/背景两类,计算类间方差,取方差最大者。它只需一遍直方图加一次 256 次遍历,代价极低,但比固定阈值鲁棒得多。
Sobel 边缘检测
Sobel 用两个 3×3 卷积核分别算水平和垂直梯度,再取模长:
Gx: -1 0 +1 Gy: -1 -2 -1
-2 0 +2 0 0 0
-1 0 +1 +1 +2 +1
cpp
QImage ImageProcessor::sobelEdge(const QImage &gray)
{
if (gray.isNull()) return {};
const QImage img = (gray.format() == QImage::Format_Grayscale8)
? gray : toGray(gray);
const int w = img.width(), h = img.height();
QImage out(img.size(), QImage::Format_Grayscale8);
for (int y = 0; y < h; ++y) {
uchar *dstLine = out.scanLine(y);
for (int x = 0; x < w; ++x) {
// 边界像素置黑:3x3 核在边缘会越界读
if (x == 0 || y == 0 || x == w - 1 || y == h - 1) {
dstLine[x] = 0;
continue;
}
auto at = [&](int dx, int dy) -> int {
return img.constScanLine(y + dy)[x + dx];
};
const int gx = -at(-1,-1) + at(1,-1) - 2*at(-1,0)
+ 2*at(1,0) - at(-1,1) + at(1,1);
const int gy = -at(-1,-1) - 2*at(0,-1) - at(1,-1)
+ at(-1,1) + 2*at(0,1) + at(1,1);
const int mag = int(qSqrt(double(gx * gx + gy * gy)));
dstLine[x] = uchar(qBound(0, mag, 255));
}
}
return out;
}
性能提醒 :上面 at() 在 3×3 循环里反复调 constScanLine(),每次都有边界检查和指针计算。生产代码应预取三行指针:
cpp
const uchar *rowPrev = img.constScanLine(y - 1);
const uchar *rowCurr = img.constScanLine(y);
const uchar *rowNext = img.constScanLine(y + 1);
// 若不需要精确模长,用城市距离近似更快:
const int mag = qMin(255, qAbs(gx) + qAbs(gy));
展示与亮度调节
处理完的图像用 QLabel::setPixmap(QPixmap::fromImage(img.scaled(label->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation))) 就能显示。做"阈值实时调节"时记得把灰度图缓存在成员变量里,滑块变化只重跑二值化------缓存中间结果是图像处理界面流畅的关键。
亮度调节就是在 Format_RGB32 下逐像素给 R/G/B 加同一个偏移量,关键是每个通道都要 qBound(0, v, 255) 裁剪,否则整数溢出会回绕成诡异颜色(比如 250+20 变成 14)。
常见坑点
坑点 1:图像是左右镜像的
预览常做镜像(像照镜子自然),但捕获的原始数据方向取决于设备和平台,可能刚好相反。判断方法:拍一张带文字的纸,看文字正不正,正了就别翻。
cpp
QImage fixed = captured.mirrored(true, false); // Qt5
// Qt6 里 mirrored() 已被删除!改用 flipped():
QImage fixed = captured.flipped(Qt::Horizontal); // Qt6
mirrored() → flipped() 是 Qt5→Qt6 的典型 API 变更。
坑点 2:capture() 与 captureToFile() 用混
| 方法 | 是否落盘 | 发 imageCaptured |
发 imageSaved |
|---|---|---|---|
capture() |
❌ | ✅ | ❌ |
captureToFile(path) |
✅ | ✅ | ✅ |
用 capture() 拿到图想自己存,得手动 img.save(path, "JPG", 95)。用 captureToFile() 则要记住文件路径是从 imageSaved(id, path) 拿的 。另外 path 的目录不存在会静默失败,务必先 QDir().mkpath()。
坑点 3:QImage 格式不是 RGB32 却按 RGB32 读
❌ 错误写法:
cpp
const QRgb *line = reinterpret_cast<const QRgb *>(captured.constScanLine(y));
// 若实际是 3 字节/像素,每行错位,图像呈现斜条纹
✅ 正确写法:
cpp
const QImage img = captured.convertToFormat(QImage::Format_RGB32);
// 现在可以放心按 4 字节/像素读
任何像素级操作之前,先 convertToFormat()。 这一步拷贝开销换来的是绝对安全。
坑点 4:跨线程处理大图卡界面
imageCaptured 是在主线程发出的,在里面跑 4K 图的 Sobel,界面会明显冻结。
❌ 错误写法:
cpp
void CameraWidget::onImageCaptured(int, const QImage &img)
{
const QImage edge = ImageProcessor::sobelEdge(img); // 200ms+,界面卡死
m_label->setPixmap(QPixmap::fromImage(edge));
}
✅ 正确写法,把耗时处理丢到工作线程:
cpp
class ImageWorker : public QObject {
Q_OBJECT
public slots:
void process(const QImage &img) {
emit processed(ImageProcessor::sobelEdge(ImageProcessor::toGray(img)));
}
signals:
void processed(const QImage &result);
};
auto *thread = new QThread(this);
auto *worker = new ImageWorker;
worker->moveToThread(thread); // 必须在 connect 之前
thread->start();
connect(this, &CameraWidget::imageReady, worker, &ImageWorker::process);
connect(worker, &ImageWorker::processed, this, [this](const QImage &r){
m_label->setPixmap(QPixmap::fromImage(r)); // 回到主线程,安全
});
补充:QCamera 必须在它的创建线程 里使用,跨线程调 start()/stop() 会崩。
坑点 5:相机对象销毁顺序错误
QMediaCaptureSession、QCamera、QImageCapture 互相引用,销毁顺序乱了程序退出时会崩。✅ 正确做法是给它们同一个父对象 (都以 this 为父),靠 Qt 对象树按构造逆序销毁;析构里只需先 stop()。真正会崩的是把它们 new 成无父对象的裸指针再手动删。
坑点 6:设备被占用 / 无设备时毫无提示
m_camera->start() 不会抛异常,失败只通过 errorOccurred 报告。用户可能没摄像头,或相机被会议软件独占。❌ 别 start() 之后就写"相机已启动"假装成功;✅ 必须连错误信号(QCamera::CameraError / ResourceError),把提示显示出来并禁用拍照按钮。监听热插拔用 QMediaDevices::videoInputsChanged 信号。
总结
整个流程是一条流水线:
| 阶段 | Qt6 关键类 | 要点 |
|---|---|---|
| 找设备 | QMediaDevices::defaultVideoInput() |
判 isNull() |
| 建会话 | QMediaCaptureSession |
相机/预览/拍照三者串一起 |
| 预览 | QVideoWidget |
Qt5 是 QCameraViewfinder |
| 拍照 | QImageCapture::captureToFile() |
目录要先 mkpath |
| 处理 | QImage + scanLine() |
先 convertToFormat |
| 展示 | QLabel::setPixmap |
大图丢工作线程 |
图像处理三板斧的要点:
| 算法 | 核心 | 易错点 |
|---|---|---|
| 灰度化 | 0.299R + 0.587G + 0.114B |
别用 (R+G+B)/3 |
| 二值化 | 阈值比较,或大津法自适应 | 阈值写死 128 效果差 |
| Sobel | 两个 3×3 核取梯度模长 | 边界要跳过,别越界读 |
最后划两个跨版本差异的重点:Qt6 用 QMediaCaptureSession 取代了 Qt5 的 setViewfinder/setCaptureMode 那套;QImage::mirrored() 在 Qt6 里改成了 flipped()。 另外 QVideoWidget 在 Qt6::MultimediaWidgets 模块,与 Qt6::Multimedia 要分别 link------这是编译报"未定义符号"时第一个该检查的地方。