You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
532 lines
16 KiB
532 lines
16 KiB
/* |
|
* Copyright 2026 Axel Waggershauser |
|
*/ |
|
// SPDX-License-Identifier: Apache-2.0 |
|
|
|
#include "ZXingQt.h" |
|
|
|
#include <QApplication> |
|
#include <QCamera> |
|
#include <QCameraDevice> |
|
#include <QCheckBox> |
|
#include <QClipboard> |
|
#include <QComboBox> |
|
#include <QDebug> |
|
#include <QElapsedTimer> |
|
#include <QGridLayout> |
|
#include <QHBoxLayout> |
|
#include <QKeyEvent> |
|
#include <QLabel> |
|
#include <QMainWindow> |
|
#include <QMediaCaptureSession> |
|
#include <QMediaDevices> |
|
#include <QPainter> |
|
#include <QPushButton> |
|
#include <QTimer> |
|
#include <QVBoxLayout> |
|
#include <QVideoFrame> |
|
#include <QVideoSink> |
|
#include <QWidget> |
|
|
|
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) |
|
#include <QPermission> |
|
#endif |
|
|
|
using namespace ZXingQt; |
|
|
|
class VideoWidget : public QWidget |
|
{ |
|
Q_OBJECT |
|
|
|
public: |
|
VideoWidget(QWidget* parent = nullptr) : QWidget(parent) |
|
{ |
|
setAttribute(Qt::WA_OpaquePaintEvent); |
|
setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); |
|
} |
|
|
|
void setVideoFrame(const QVideoFrame& frame) |
|
{ |
|
_frame = frame; |
|
update(); |
|
} |
|
|
|
void setBarcodes(const QList<Barcode>& barcodes) |
|
{ |
|
_barcodes = barcodes; |
|
update(); |
|
} |
|
|
|
protected: |
|
void paintEvent(QPaintEvent*) override |
|
{ |
|
QPainter painter(this); |
|
painter.fillRect(rect(), Qt::black); |
|
|
|
if (!_frame.isValid()) |
|
return; |
|
|
|
auto image = _frame.toImage(); |
|
if (image.isNull()) |
|
return; |
|
|
|
// Scale image to fit widget while maintaining aspect ratio |
|
QSize imageSize = image.size(); |
|
imageSize.scale(size(), Qt::KeepAspectRatio); |
|
|
|
int x = (width() - imageSize.width()) / 2; |
|
int y = (height() - imageSize.height()) / 2; |
|
|
|
QRect targetRect(x, y, imageSize.width(), imageSize.height()); |
|
painter.drawImage(targetRect, image); |
|
|
|
// Draw all barcode positions |
|
if (!_barcodes.isEmpty() && imageSize.width() > 0) { |
|
double scaleX = static_cast<double>(imageSize.width()) / image.width(); |
|
double scaleY = static_cast<double>(imageSize.height()) / image.height(); |
|
|
|
auto mapPoint = [&](const QPoint& p) { return QPointF(x + p.x() * scaleX, y + p.y() * scaleY); }; |
|
|
|
QFont font = painter.font(); |
|
QFontMetrics fm(font); |
|
font.setPointSize(16); |
|
font.setBold(true); |
|
painter.setFont(font); |
|
|
|
int barcodeIndex = 1; |
|
for (const auto& barcode : _barcodes) { |
|
const auto& position = barcode.position(); |
|
painter.setPen(QPen(barcode.isValid() ? Qt::green : Qt::red, 3)); |
|
for (int i = 0; i < 4; ++i) |
|
painter.drawLine(mapPoint(position[i]), mapPoint(position[(i + 1) % 4])); |
|
|
|
if (_barcodes.size() == 1) |
|
break; |
|
|
|
// Draw barcode index at center |
|
QString indexText = QString::number(barcodeIndex); |
|
QRect textRect = fm.boundingRect(indexText); |
|
textRect.moveCenter(mapPoint(position.center()).toPoint()); |
|
textRect.adjust(-5, -5, 5, 5); |
|
|
|
painter.fillRect(textRect, QColor(128, 128, 128, 180)); |
|
painter.setPen(Qt::white); |
|
painter.drawText(textRect, Qt::AlignCenter, indexText); |
|
|
|
++barcodeIndex; |
|
} |
|
} |
|
} |
|
|
|
private: |
|
QVideoFrame _frame; |
|
QList<Barcode> _barcodes; |
|
}; |
|
|
|
class CameraReaderWidget : public QMainWindow |
|
{ |
|
Q_OBJECT |
|
|
|
public: |
|
CameraReaderWidget(QWidget* parent = nullptr) : QMainWindow(parent) |
|
{ |
|
setWindowTitle(QStringLiteral("ZXingQtCamReader")); |
|
resize(640, 480); |
|
|
|
setupUI(); |
|
setupCameraAndReader(); |
|
} |
|
|
|
~CameraReaderWidget() override { _camera->stop(); } |
|
|
|
private: |
|
void setInfoText(const QString& text) |
|
{ |
|
_infoLabel->setText(text); |
|
_infoLabel->adjustSize(); |
|
} |
|
|
|
void resizeEvent(QResizeEvent* event) override |
|
{ |
|
QMainWindow::resizeEvent(event); |
|
if (_infoLabel) |
|
_infoLabel->setMaximumWidth(width()); |
|
} |
|
|
|
void keyPressEvent(QKeyEvent* event) override |
|
{ |
|
if (event->key() == Qt::Key_Space || event->key() == Qt::Key_P) { |
|
togglePause(); |
|
} else if (event->key() == Qt::Key_S) { |
|
toggleSettings(); |
|
} else { |
|
QMainWindow::keyPressEvent(event); |
|
} |
|
} |
|
|
|
void setupUI() |
|
{ |
|
auto centralWidget = new QWidget(this); |
|
setCentralWidget(centralWidget); |
|
|
|
auto mainLayout = new QVBoxLayout(centralWidget); |
|
mainLayout->setContentsMargins(0, 0, 0, 0); |
|
mainLayout->setSpacing(0); |
|
|
|
// Camera selection (only shown when multiple cameras available) |
|
const auto cameras = QMediaDevices::videoInputs(); |
|
if (cameras.size() > 1) { |
|
auto cameraLayout = new QHBoxLayout(); |
|
cameraLayout->addWidget(new QLabel(tr("Camera: "))); |
|
|
|
_cameraCombo = new QComboBox(); |
|
for (const auto& camera : cameras) |
|
_cameraCombo->addItem(camera.description(), QVariant::fromValue(camera)); |
|
connect(_cameraCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CameraReaderWidget::onCameraChanged); |
|
cameraLayout->addWidget(_cameraCombo); |
|
cameraLayout->addStretch(); |
|
|
|
mainLayout->addLayout(cameraLayout); |
|
} |
|
|
|
// Video display with overlays |
|
_videoWidget = new VideoWidget(); |
|
|
|
// Create a container for video widget with overlays using a grid layout |
|
auto videoContainer = new QWidget(); |
|
auto gridLayout = new QGridLayout(videoContainer); |
|
gridLayout->setContentsMargins(0, 0, 0, 0); |
|
gridLayout->addWidget(_videoWidget, 0, 0, 1, 1); |
|
|
|
mainLayout->addWidget(videoContainer, 1); |
|
|
|
// Info label overlay (top-left aligned) |
|
_infoLabel = new QLabel(videoContainer); |
|
_infoLabel->setStyleSheet(QStringLiteral( |
|
"QLabel { color: white; background-color: rgba(40, 40, 40, 200); padding: 12px 16px; border-radius: 8px; }")); |
|
_infoLabel->setAttribute(Qt::WA_TransparentForMouseEvents); |
|
_infoLabel->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored); |
|
setInfoText(tr("Initializing camera...")); |
|
gridLayout->addWidget(_infoLabel, 0, 0, 1, 1, Qt::AlignTop | Qt::AlignLeft); |
|
|
|
// Feedback label overlay (center aligned) |
|
_feedbackLabel = new QLabel(videoContainer); |
|
_feedbackLabel->setStyleSheet(QStringLiteral( |
|
"QLabel { color: white; background-color: rgba(40, 40, 40, 200); padding: 12px 16px; border-radius: 8px; }")); |
|
_feedbackLabel->setAttribute(Qt::WA_TransparentForMouseEvents); |
|
_feedbackLabel->hide(); |
|
gridLayout->addWidget(_feedbackLabel, 0, 0, 1, 1, Qt::AlignCenter); |
|
|
|
// Control buttons container (bottom-right aligned) |
|
auto buttonContainer = new QWidget(videoContainer); |
|
auto buttonLayout = new QHBoxLayout(buttonContainer); |
|
buttonLayout->setContentsMargins(0, 0, 0, 0); |
|
buttonLayout->setSpacing(8); |
|
|
|
const QString buttonStyle = QStringLiteral("QPushButton { " |
|
" background-color: rgba(40, 40, 40, 200); " |
|
" color: white; " |
|
" border: none; " |
|
" border-radius: 24px; " |
|
" font-size: 24px; " |
|
"} " |
|
"QPushButton:hover { background-color: rgba(60, 60, 60, 220); } " |
|
"QPushButton:pressed { background-color: rgba(80, 80, 80, 240); }"); |
|
|
|
// Pause/Resume button |
|
_pauseButton = new QPushButton(QStringLiteral("⏸"), buttonContainer); |
|
_pauseButton->setFixedSize(48, 48); |
|
_pauseButton->setStyleSheet(buttonStyle); |
|
_pauseButton->setCursor(Qt::PointingHandCursor); |
|
_pauseButton->setToolTip(tr("Pause capture")); |
|
connect(_pauseButton, &QPushButton::clicked, this, &CameraReaderWidget::togglePause); |
|
buttonLayout->addWidget(_pauseButton); |
|
|
|
// Settings button |
|
_settingsButton = new QPushButton(QStringLiteral("⚙"), buttonContainer); |
|
_settingsButton->setFixedSize(48, 48); |
|
_settingsButton->setStyleSheet(buttonStyle); |
|
_settingsButton->setCursor(Qt::PointingHandCursor); |
|
_settingsButton->setToolTip(tr("Settings")); |
|
connect(_settingsButton, &QPushButton::clicked, this, &CameraReaderWidget::toggleSettings); |
|
buttonLayout->addWidget(_settingsButton); |
|
|
|
gridLayout->addWidget(buttonContainer, 0, 0, 1, 1, Qt::AlignBottom | Qt::AlignRight); |
|
|
|
// Controls panel (initially hidden) |
|
_controlsWidget = new QWidget(videoContainer); |
|
_controlsWidget->setStyleSheet(QStringLiteral("QWidget#controlsWidget { " |
|
" background-color: rgba(40, 40, 40, 230); " |
|
" border-radius: 12px; " |
|
"} " |
|
"QCheckBox, QLabel { " |
|
" color: white; " |
|
" background: transparent; " |
|
" font-size: 13px; " |
|
"}")); |
|
_controlsWidget->setObjectName(QStringLiteral("controlsWidget")); |
|
_controlsWidget->hide(); |
|
auto controlsLayout = new QVBoxLayout(_controlsWidget); |
|
controlsLayout->setContentsMargins(16, 16, 16, 16); |
|
controlsLayout->setSpacing(8); |
|
|
|
// Header with close button |
|
auto headerLayout = new QHBoxLayout(); |
|
headerLayout->addWidget(new QLabel(tr("Settings"))); |
|
headerLayout->addStretch(); |
|
|
|
auto closeButton = new QPushButton(QStringLiteral("✕")); |
|
closeButton->setFixedSize(24, 24); |
|
closeButton->setStyleSheet( |
|
QStringLiteral("QPushButton { background: transparent; border: none; color: white; font-size: 18px; }")); |
|
closeButton->setCursor(Qt::PointingHandCursor); |
|
connect(closeButton, &QPushButton::clicked, _controlsWidget, &QWidget::hide); |
|
headerLayout->addWidget(closeButton); |
|
controlsLayout->addLayout(headerLayout); |
|
|
|
auto addCheckBox = [&](QCheckBox*& checkbox, const QString& text, bool defaultValue = true) { |
|
checkbox = new QCheckBox(text); |
|
checkbox->setChecked(defaultValue); |
|
connect(checkbox, &QCheckBox::toggled, this, &CameraReaderWidget::updateReaderOptions); |
|
controlsLayout->addWidget(checkbox); |
|
}; |
|
|
|
addCheckBox(_autoPauseCheck, tr("Auto Pause"), false); |
|
addCheckBox(_tryRotateCheck, tr("Try Rotate")); |
|
addCheckBox(_tryHarderCheck, tr("Try Harder")); |
|
addCheckBox(_tryInvertCheck, tr("Try Invert")); |
|
addCheckBox(_tryDownscaleCheck, tr("Try Downscale")); |
|
addCheckBox(_returnErrorsCheck, tr("Return Errors"), false); |
|
|
|
// Format filter combobox |
|
_formatCombo = new QComboBox(); |
|
for (auto format : ListBarcodeFormats()) |
|
_formatCombo->addItem(ToString(format), static_cast<unsigned int>(format)); |
|
|
|
connect(_formatCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), this, &CameraReaderWidget::updateReaderOptions); |
|
controlsLayout->addWidget(_formatCombo); |
|
|
|
// Position controls panel above the settings button with some margin |
|
gridLayout->addWidget(_controlsWidget, 0, 0, 1, 1, Qt::AlignBottom | Qt::AlignRight); |
|
} |
|
|
|
void toggleSettings() |
|
{ |
|
if (_controlsWidget->isVisible()) { |
|
_controlsWidget->hide(); |
|
} else { |
|
_controlsWidget->show(); |
|
_controlsWidget->adjustSize(); |
|
} |
|
} |
|
|
|
void togglePause() { _isPaused ? resumeScan() : pauseScan(); } |
|
|
|
void pauseScan() |
|
{ |
|
_isPaused = true; |
|
_barcodeReader->setVideoSink(nullptr); |
|
disconnect(_videoSink, &QVideoSink::videoFrameChanged, _videoWidget, &VideoWidget::setVideoFrame); |
|
_pauseButton->setText(QStringLiteral("▶")); |
|
_pauseButton->setToolTip(tr("Resume capture")); |
|
_resetTimer->stop(); |
|
|
|
if (!_lastBarcodeText.isEmpty()) { |
|
QApplication::beep(); |
|
QApplication::clipboard()->setText(_lastBarcodeText); |
|
showFeedback(tr("Copied content of (first) barcode to clipboard.")); |
|
} |
|
} |
|
|
|
void resumeScan() |
|
{ |
|
_isPaused = false; |
|
_barcodeReader->setVideoSink(_videoSink); |
|
connect(_videoSink, &QVideoSink::videoFrameChanged, _videoWidget, &VideoWidget::setVideoFrame); |
|
_pauseButton->setText(QStringLiteral("⏸")); |
|
_pauseButton->setToolTip(tr("Pause capture")); |
|
_resetTimer->start(); |
|
} |
|
|
|
void setupCameraAndReader() |
|
{ |
|
// Create camera and video pipeline |
|
_camera = new QCamera(this); |
|
_camera->setFocusMode(QCamera::FocusModeAutoNear); |
|
|
|
connect(_camera, &QCamera::errorOccurred, this, [this](QCamera::Error error, const QString& errorString) { |
|
qWarning() << "Camera error:" << error << errorString; |
|
setInfoText(tr("Camera error: %1").arg(errorString)); |
|
}); |
|
|
|
connect(_camera, &QCamera::activeChanged, this, [](bool active) { qDebug() << "Camera active state changed:" << active; }); |
|
|
|
_captureSession = new QMediaCaptureSession(this); |
|
_captureSession->setCamera(_camera); |
|
|
|
_videoSink = new QVideoSink(this); |
|
_captureSession->setVideoOutput(_videoSink); |
|
|
|
connect(_videoSink, &QVideoSink::videoFrameChanged, _videoWidget, &VideoWidget::setVideoFrame); |
|
|
|
if (QMediaDevices::videoInputs().isEmpty()) { |
|
setInfoText(tr("No camera found")); |
|
return; |
|
} |
|
|
|
_camera->setCameraDevice(QMediaDevices::videoInputs().first()); |
|
|
|
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) |
|
// Request camera permission for Qt 6.6+ |
|
QCameraPermission cameraPermission; |
|
switch (qApp->checkPermission(cameraPermission)) { |
|
case Qt::PermissionStatus::Undetermined: |
|
qApp->requestPermission(cameraPermission, this, [this](const QPermission& permission) { |
|
if (permission.status() == Qt::PermissionStatus::Granted) { |
|
setInfoText(tr("Starting camera...")); |
|
_camera->start(); |
|
} else { |
|
setInfoText(tr("Camera permission denied")); |
|
} |
|
}); |
|
break; |
|
case Qt::PermissionStatus::Granted: |
|
setInfoText(tr("Starting camera...")); |
|
_camera->start(); |
|
break; |
|
case Qt::PermissionStatus::Denied: setInfoText(tr("Camera permission denied")); break; |
|
} |
|
#else |
|
_camera->start(); |
|
#endif |
|
|
|
// Create barcode reader and configure |
|
_barcodeReader = new BarcodeReader(this); |
|
_barcodeReader->setVideoSink(_videoSink); |
|
|
|
connect(_barcodeReader, &BarcodeReader::foundBarcodes, this, &CameraReaderWidget::onBarcodesFound); |
|
connect(_barcodeReader, &BarcodeReader::foundNoBarcodes, this, &CameraReaderWidget::onFoundNoBarcodes); |
|
|
|
updateReaderOptions(); |
|
|
|
_resetTimer = new QTimer(this); |
|
_resetTimer->setSingleShot(true); |
|
_resetTimer->setInterval(1000); |
|
connect(_resetTimer, &QTimer::timeout, [this]() { _infoLabel->clear(); }); |
|
|
|
_feedbackTimer = new QTimer(this); |
|
_feedbackTimer->setSingleShot(true); |
|
_feedbackTimer->setInterval(2000); |
|
connect(_feedbackTimer, &QTimer::timeout, [this]() { _feedbackLabel->hide(); }); |
|
} |
|
|
|
void updateReaderOptions() |
|
{ |
|
_barcodeReader->setFormats({static_cast<BarcodeFormat>(_formatCombo->currentData().toUInt())}); |
|
_barcodeReader->setTryRotate(_tryRotateCheck->isChecked()); |
|
_barcodeReader->setTryHarder(_tryHarderCheck->isChecked()); |
|
_barcodeReader->setTryInvert(_tryInvertCheck->isChecked()); |
|
_barcodeReader->setTryDownscale(_tryDownscaleCheck->isChecked()); |
|
_barcodeReader->setReturnErrors(_returnErrorsCheck->isChecked()); |
|
} |
|
|
|
private Q_SLOTS: |
|
|
|
void onBarcodesFound(const QList<Barcode>& barcodes) |
|
{ |
|
_videoWidget->setBarcodes(barcodes); |
|
|
|
_lastBarcodeText = barcodes.isEmpty() ? QString() : barcodes.first().text(); |
|
|
|
// Build info text for all barcodes |
|
QStringList infoParts; |
|
for (int i = 0; i < barcodes.size(); ++i) { |
|
const auto& barcode = barcodes[i]; |
|
if (barcodes.size() > 1) |
|
infoParts.append(tr("[%1]").arg(i + 1)); |
|
infoParts.append(tr("Format: %1").arg(ToString(barcode.format()))); |
|
if (barcode.isValid()) |
|
infoParts.append(tr("Text: %1").arg(barcode.text())); |
|
else |
|
infoParts.append(tr("Error: %1").arg(ToString(barcode.error()))); |
|
infoParts.append(tr("Type: %1").arg(ToString(barcode.contentType()))); |
|
infoParts.append(QStringLiteral("")); |
|
} |
|
infoParts.append(tr("Time: %1 ms").arg(_barcodeReader->runTime.loadRelaxed())); |
|
|
|
setInfoText(infoParts.join(QStringLiteral("\n"))); |
|
if (!_isPaused) |
|
_resetTimer->start(); |
|
|
|
if (_autoPauseCheck->isChecked() && !_isPaused) |
|
pauseScan(); |
|
} |
|
|
|
void onFoundNoBarcodes() |
|
{ |
|
_videoWidget->setBarcodes({}); |
|
|
|
if (!_resetTimer->isActive() && !_isPaused) { |
|
setInfoText(tr("No barcode found (in %1 ms)").arg(_barcodeReader->runTime.loadRelaxed())); |
|
} |
|
} |
|
|
|
void onCameraChanged(int index) |
|
{ |
|
if (index < 0) |
|
return; |
|
|
|
auto cameraDevice = _cameraCombo->itemData(index).value<QCameraDevice>(); |
|
_camera->stop(); |
|
_camera->setCameraDevice(cameraDevice); |
|
_camera->start(); |
|
} |
|
|
|
void showFeedback(const QString& message) |
|
{ |
|
_feedbackLabel->setText(message); |
|
_feedbackLabel->adjustSize(); |
|
_feedbackLabel->show(); |
|
_feedbackTimer->start(); |
|
} |
|
|
|
private: |
|
VideoWidget* _videoWidget = nullptr; |
|
QLabel* _infoLabel = nullptr; |
|
QLabel* _feedbackLabel = nullptr; |
|
QPushButton* _pauseButton = nullptr; |
|
QPushButton* _settingsButton = nullptr; |
|
QWidget* _controlsWidget = nullptr; |
|
QComboBox* _cameraCombo = nullptr; |
|
QComboBox* _formatCombo = nullptr; |
|
QCheckBox* _tryRotateCheck = nullptr; |
|
QCheckBox* _tryHarderCheck = nullptr; |
|
QCheckBox* _tryInvertCheck = nullptr; |
|
QCheckBox* _tryDownscaleCheck = nullptr; |
|
QCheckBox* _returnErrorsCheck = nullptr; |
|
QCheckBox* _autoPauseCheck = nullptr; |
|
|
|
QCamera* _camera = nullptr; |
|
QMediaCaptureSession* _captureSession = nullptr; |
|
QVideoSink* _videoSink = nullptr; |
|
BarcodeReader* _barcodeReader = nullptr; |
|
QTimer* _resetTimer = nullptr; |
|
QTimer* _feedbackTimer = nullptr; |
|
|
|
bool _isPaused = false; |
|
QString _lastBarcodeText; |
|
}; |
|
|
|
int main(int argc, char* argv[]) |
|
{ |
|
QApplication app(argc, argv); |
|
app.setApplicationName(QStringLiteral("ZXingQtCamReader")); |
|
|
|
CameraReaderWidget window; |
|
window.show(); |
|
|
|
return app.exec(); |
|
} |
|
|
|
#include "ZXingQtCamReader.moc" |
|
#include "moc_ZXingQt.cpp"
|
|
|