From 2c84ae0f72c3106cf48137082f59e2183e29d14a Mon Sep 17 00:00:00 2001 From: re2zero Date: Sat, 10 Oct 2026 16:29:31 +0800 Subject: [PATCH] fix: move the scanner and camera work off the UI thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening and enumerating scanners, opening and capturing from a camera, processing and saving a scan and rendering the PDF all ran on the UI thread and froze the window for up to several seconds (about seven seconds for a SANE enumeration, one second for an A4 page at 300 dpi). They now run on worker threads, the fixed sleeps became waits with a timeout, the frame formats that map onto a QImage no longer go through a PNG temp file, and elapsed-time logs were added to tell the slow phase apart. The camera frame work followed: dequeuing a frame, converting it and waiting for a frame during a capture still ran on the UI thread, which froze the window for about a second on a stream that had to be started first and spent most of every preview frame on the per-pixel conversion. Both now run on a single per-device I/O thread that keeps the preview and the capture from touching the device at the same time, the frame wait is sliced so that a device that is being closed or reconfigured ends it within about 100 ms, the preview timer is only touched from the UI thread, the device-level operations are serialized, two scans that end within the same second no longer build the same file name, and the application waits for a camera reconfiguration before it releases the device. 扫描仪打开/枚举/扫描、相机打开/开流/采集/切换分辨率、扫描结果处理与保存、PDF 渲染原本都在 UI 线程执行,界面会冻结数秒(SANE 枚举约 7 秒,A4 300dpi PDF 渲染约 1 秒)。现全部移入工作线程, 固定睡眠改为带超时等待,可直接映射为 QImage 的帧格式不再经过 PNG 临时文件中转,并补充各阶段 耗时日志。 相机的取帧与转换随后也移入工作线程:预览取帧、逐像素转换和采集时等待首帧原本仍在 UI 线程执行, 需要现开流的采集会冻结界面约 1 秒,预览每帧的转换也几乎占满 UI 线程。现两者都在设备专属的单个 I/O 线程上串行执行;等待帧改成分片等待(设备关闭或重配置时约 100ms 内让出),预览定时器只在 UI 线程操作,设备级操作(打开/关闭/重配置)串行化,同一秒内结束的两次扫描不再生成同名文件,退出 应用时会等待进行中的相机重配置结束后再释放设备。 Log: 扫描与相机操作移出 UI 线程,扫描、保存、预览期间界面不再冻结 PMS: BUG-346173 Influence: 打开设备、开始扫描、保存结果、切换相机分辨率与预览期间界面保持响应;同一秒内连续两次扫描会生成不同文件名的结果;退出应用时若正在切换相机分辨率会等待该操作完成(最多数秒);默认相机分辨率由设备最大值改为 1080p(界面可改回);输出文件内容不变。 --- src/device/scannerdevice.cpp | 217 ++++++++++++----- src/device/scannerdevice.h | 21 +- src/device/webcamdevice.cpp | 458 +++++++++++++++++++++++------------ src/device/webcamdevice.h | 32 ++- src/mainwindow.cpp | 63 ++--- src/ui/loadingdialog.cpp | 4 +- src/ui/scannerswidget.cpp | 10 +- src/ui/scanwidget.cpp | 309 ++++++++++++++--------- src/ui/scanwidget.h | 42 +++- 9 files changed, 776 insertions(+), 380 deletions(-) diff --git a/src/device/scannerdevice.cpp b/src/device/scannerdevice.cpp index 2b568db..d461906 100644 --- a/src/device/scannerdevice.cpp +++ b/src/device/scannerdevice.cpp @@ -15,6 +15,7 @@ #include #include #include +#include using namespace DDLog; @@ -41,6 +42,7 @@ ScannerDevice::ScannerDevice(QObject *parent) connect(this, &ScannerDevice::triggerCloseDevice, m_worker, &ScannerWorker::doCloseDevice); connect(this, &ScannerDevice::triggerStartScan, m_worker, &ScannerWorker::doStartScan); connect(this, &ScannerDevice::triggerCancelScan, m_worker, &ScannerWorker::doCancelScan); + connect(this, &ScannerDevice::triggerGetAvailableDevices, m_worker, &ScannerWorker::doGetAvailableDevices); // --- Connect signals from worker back to this (UI) thread's ddslots --- connect(m_worker, &ScannerWorker::errorOccurred, this, &ScannerDevice::onWorkerError); @@ -51,8 +53,9 @@ ScannerDevice::ScannerDevice(QObject *parent) emit deviceUnavailable(deviceName); }); connect(m_worker, &ScannerWorker::deviceUnavailable, this, &ScannerDevice::deviceUnavailable); + connect(m_worker, &ScannerWorker::availableDevicesReady, this, &ScannerDevice::onAvailableDevicesReady); connect(m_worker, &ScannerWorker::captureCompleted, this, &ScannerDevice::onCaptureCompleted); - connect(m_worker, &ScannerWorker::scanProgress, this, &ScannerDevice::scanProgress); // Forward signal + connect(m_worker, &ScannerWorker::captureCompletedImage, this, &ScannerDevice::onCaptureCompletedImage); m_workerThread.start(); qCInfo(app) << "Scanner worker thread started."; @@ -86,42 +89,26 @@ bool ScannerDevice::initialize() QStringList ScannerDevice::getAvailableDevices() { - // This is synchronous for now, but could be moved to the worker if it proves slow. -#ifndef _WIN32 - const SANE_Device **device_list; - QStringList deviceNames; - - SANE_Status status = sane_get_devices(&device_list, SANE_FALSE); - if (status != SANE_STATUS_GOOD) { - emit errorOccurred(tr("Failed to get device list: %1").arg(sane_strstatus(status))); - return deviceNames; - } + // The SANE enumeration runs in the worker thread, see requestAvailableDevices(): it can + // block for seconds on network backends, which must not happen on the UI thread. + // Callers get the last list reported by the worker. + return m_availableDevices; +} - if (device_list) { - for (int i = 0; device_list[i] != nullptr; ++i) { - if (device_list[i]->name) { - deviceNames.append(QString::fromUtf8(device_list[i]->name)); - } - } - } - - if (deviceNames.isEmpty() && ADD_TEST_DEVICE) { - deviceNames.append("test:0"); - } +void ScannerDevice::requestAvailableDevices() +{ + emit triggerGetAvailableDevices(); +} - return deviceNames; -#else - if (ADD_TEST_DEVICE) { - return QStringList() << "test:0"; - } - return QStringList(); -#endif +void ScannerDevice::onAvailableDevicesReady(const QStringList &deviceNames) +{ + m_availableDevices = deviceNames; + emit availableDevicesReady(deviceNames); } bool ScannerDevice::openDevice(const QString &deviceName) { m_currentDeviceName = deviceName; - m_deviceOpening = true; emit triggerOpenDevice(deviceName); return true; // Asynchronous operation, success is reported via signal } @@ -263,7 +250,6 @@ QSizeF ScannerDevice::getPaperSizeDimensions(PaperSize size) void ScannerDevice::onWorkerError(const QString &errorMessage) { m_isCapturing = false; - m_deviceOpening = false; if (!m_deviceOpen) { // The error came from an open attempt: no open is in flight any more, so drop // the queued scan request together with the device name that marks it. @@ -277,7 +263,6 @@ void ScannerDevice::onWorkerError(const QString &errorMessage) void ScannerDevice::onDeviceOpened(const QList &resolutions, const QList &modes) { m_deviceOpen = true; - m_deviceOpening = false; m_supportedResolutions = resolutions; m_supportedScanModes = modes; @@ -344,6 +329,14 @@ void ScannerDevice::onCaptureCompleted(const QString &filePath) } +void ScannerDevice::onCaptureCompletedImage(const QImage &image) +{ + m_isCapturing = false; + setState(Connected); + + emit imageCaptured(image); +} + // ================================================================= // ScannerWorker Implementation (Worker Thread) // ================================================================= @@ -361,11 +354,6 @@ struct ScannerWorker::Image ScannerWorker::ScannerWorker() : QObject(nullptr) { - // Set SANE_DEBUG env vars for more info - qputenv("SANE_DEBUG_DLL", "2"); - qputenv("SANE_DEBUG_NET", "2"); - qputenv("SANE_DEBUG_HPAIO", "2"); - qputenv("SANE_DEBUG_USB", "2"); } ScannerWorker::~ScannerWorker() @@ -378,6 +366,37 @@ ScannerWorker::~ScannerWorker() } +void ScannerWorker::doGetAvailableDevices() +{ +#ifndef _WIN32 + const SANE_Device **device_list = nullptr; + QStringList deviceNames; + + SANE_Status status = sane_get_devices(&device_list, SANE_FALSE); + if (status != SANE_STATUS_GOOD) { + emit errorOccurred(tr("Failed to get device list: %1").arg(sane_strstatus(status))); + emit availableDevicesReady(deviceNames); + return; + } + + if (device_list) { + for (int i = 0; device_list[i] != nullptr; ++i) { + if (device_list[i]->name) { + deviceNames.append(QString::fromUtf8(device_list[i]->name)); + } + } + } + + if (deviceNames.isEmpty() && ADD_TEST_DEVICE) { + deviceNames.append("test:0"); + } + + emit availableDevicesReady(deviceNames); +#else + emit availableDevicesReady(ADD_TEST_DEVICE ? QStringList() << "test:0" : QStringList()); +#endif +} + void ScannerWorker::doOpenDevice(const QString &deviceName) { #ifndef _WIN32 @@ -395,8 +414,12 @@ void ScannerWorker::doOpenDevice(const QString &deviceName) m_deviceName = deviceName; + QElapsedTimer openTimer; + openTimer.start(); + QByteArray deviceNameBytes = deviceName.toUtf8(); SANE_Status status = sane_open(deviceNameBytes.constData(), &m_device); + const qint64 openMs = openTimer.elapsed(); if (status != SANE_STATUS_GOOD) { emit errorOccurred(tr("Failed to open SANE device '%1': %2").arg(deviceName).arg(sane_strstatus(status))); @@ -411,7 +434,9 @@ void ScannerWorker::doOpenDevice(const QString &deviceName) } m_deviceOpen = true; + openTimer.restart(); updateSupportedOptions(); + qCInfo(app) << "Worker: Device" << deviceName << "opened in" << openMs << "ms, options read in" << openTimer.elapsed() << "ms"; qCDebug(app) << "Worker: Device" << deviceName << "opened successfully."; #else m_usingTestDevice = true; @@ -451,16 +476,20 @@ bool ScannerWorker::reopenDevice() m_device = nullptr; m_deviceOpen = false; + QElapsedTimer openTimer; + openTimer.start(); + QByteArray deviceNameBytes = m_deviceName.toUtf8(); SANE_Status status = sane_open(deviceNameBytes.constData(), &m_device); if (status != SANE_STATUS_GOOD) { - qCWarning(app) << "Worker: Failed to re-open device" << m_deviceName << ":" << sane_strstatus(status); + qCWarning(app) << "Worker: Failed to re-open device" << m_deviceName << "after" << openTimer.elapsed() << "ms:" << sane_strstatus(status); m_device = nullptr; m_deviceOpen = false; return false; } m_deviceOpen = true; + qCInfo(app) << "Worker: Device" << m_deviceName << "re-opened in" << openTimer.elapsed() << "ms"; return true; } @@ -526,6 +555,9 @@ void ScannerWorker::doStartScan(const QString &tempOutputFilePath, int dpi, Scan return; } + QElapsedTimer scanTimer; + scanTimer.start(); + doSetScanMode(mode); doSetColorMode(colorMode); doSetResolution(dpi); @@ -537,8 +569,13 @@ void ScannerWorker::doStartScan(const QString &tempOutputFilePath, int dpi, Scan m_scanCancelled = false; + const qint64 optionsMs = scanTimer.elapsed(); + scanTimer.restart(); + // Start the scan SANE_Status status = sane_start(m_device); + const qint64 startMs = scanTimer.elapsed(); + scanTimer.restart(); if (status != SANE_STATUS_GOOD) { reportScanFailure(tr("Failed to start scan: %1").arg(sane_strstatus(status))); return; @@ -552,9 +589,13 @@ void ScannerWorker::doStartScan(const QString &tempOutputFilePath, int dpi, Scan return; } - status = scan_it(ofp); // The blocking call + QImage scannedImage; + status = scan_it(ofp, scannedImage); // The blocking call + const qint64 readMs = scanTimer.elapsed(); fclose(ofp); + qCInfo(app) << "Worker: scan timing: options" << optionsMs << "ms, sane_start" << startMs << "ms, read" << readMs << "ms"; + if (m_scanCancelled) { QFile::remove(tempOutputFilePath); // Don't emit error, as it's a user action @@ -567,6 +608,10 @@ void ScannerWorker::doStartScan(const QString &tempOutputFilePath, int dpi, Scan if (status != SANE_STATUS_GOOD && status != SANE_STATUS_EOF) { QFile::remove(tempOutputFilePath); reportScanFailure(tr("Scan failed during read: %1").arg(sane_strstatus(status))); + } else if (!scannedImage.isNull()) { + // The page is already an image, the temp file stayed empty. + QFile::remove(tempOutputFilePath); + emit captureCompletedImage(scannedImage); } else { emit captureCompleted(tempOutputFilePath); } @@ -761,7 +806,29 @@ void ScannerWorker::doSetPageSize(double widthMM, double heightMM) #ifndef _WIN32 -SANE_Status ScannerWorker::scan_it(FILE *ofp) +// The QImage format that matches the SANE frame, or Format_Invalid when the page has to go +// through the PNG temp file: lineart is bit packed and JPEG/G42D are not raw pixels. +static QImage::Format imageFormatFor(const SANE_Parameters &parm) +{ + if (parm.format == SANE_FRAME_GRAY) { + if (parm.depth == 8) { + return QImage::Format_Grayscale8; + } + if (parm.depth == 16) { + return QImage::Format_Grayscale16; + } + return QImage::Format_Invalid; + } + if (parm.format == SANE_FRAME_RGB && parm.depth == 8) { + return QImage::Format_RGB888; + } + return QImage::Format_Invalid; +} + +// Reads the page. When the frame format can be handed over as an image, `image` is filled +// and nothing is written to `ofp`; otherwise the page is written there as PNG, which is +// also what happens when the backend does not tell the line count in advance. +SANE_Status ScannerWorker::scan_it(FILE *ofp, QImage &image) { SANE_Parameters parm; SANE_Status status; @@ -776,20 +843,35 @@ SANE_Status ScannerWorker::scan_it(FILE *ofp) unsigned char *buffer = new unsigned char[parm.bytes_per_line]; - // Dummy PNG write header function. - // Replace with your actual implementation if you have one. - int bit_depth = (parm.depth == 1) ? 8 : parm.depth; - png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL); - info_ptr = png_create_info_struct(png_ptr); - png_init_io(png_ptr, ofp); - png_set_IHDR(png_ptr, info_ptr, parm.pixels_per_line, parm.lines, bit_depth, - parm.format == SANE_FRAME_RGB ? PNG_COLOR_TYPE_RGB : PNG_COLOR_TYPE_GRAY, - PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); - png_write_info(png_ptr, info_ptr); - // End dummy png header - - int total_lines = parm.lines > 0 ? parm.lines : 1000; // Guess if not provided - int lines_read = 0; + QImage::Format imageFormat = QImage::Format_Invalid; + if (parm.lines > 0) { + imageFormat = imageFormatFor(parm); + if (imageFormat != QImage::Format_Invalid) { + image = QImage(parm.pixels_per_line, parm.lines, imageFormat); + if (image.isNull()) { + imageFormat = QImage::Format_Invalid; + } + } + } + + const bool writePng = imageFormat == QImage::Format_Invalid; + if (writePng) { + // Dummy PNG write header function. + // Replace with your actual implementation if you have one. + int bit_depth = (parm.depth == 1) ? 8 : parm.depth; + png_ptr = png_create_write_struct(PNG_LIBPNG_VER_STRING, NULL, NULL, NULL); + info_ptr = png_create_info_struct(png_ptr); + png_init_io(png_ptr, ofp); + png_set_IHDR(png_ptr, info_ptr, parm.pixels_per_line, parm.lines, bit_depth, + parm.format == SANE_FRAME_RGB ? PNG_COLOR_TYPE_RGB : PNG_COLOR_TYPE_GRAY, + PNG_INTERLACE_NONE, PNG_COMPRESSION_TYPE_DEFAULT, PNG_FILTER_TYPE_DEFAULT); + png_write_info(png_ptr, info_ptr); + // End dummy png header + } + + QElapsedTimer readTimer; + readTimer.start(); + int linesRead = 0; do { if (m_scanCancelled) { @@ -797,19 +879,34 @@ SANE_Status ScannerWorker::scan_it(FILE *ofp) break; } + if (!writePng && linesRead >= parm.lines) { + break; // the image is complete + } + status = sane_read(m_device, buffer, parm.bytes_per_line, &len); if (status == SANE_STATUS_GOOD) { - png_write_row(png_ptr, buffer); - lines_read++; - if (parm.lines > 0) { - emit scanProgress((lines_read * 100) / total_lines); + if (writePng) { + png_write_row(png_ptr, buffer); + } else { + memcpy(image.scanLine(linesRead), buffer, qMin(len, image.bytesPerLine())); } + linesRead++; } } while (status == SANE_STATUS_GOOD); - png_write_end(png_ptr, info_ptr); - png_destroy_write_struct(&png_ptr, &info_ptr); + if (!writePng && linesRead > 0 && linesRead < parm.lines) { + // The backend stopped early, keep only the lines that arrived. + image = image.copy(0, 0, image.width(), linesRead); + } + + qCInfo(app) << "Worker: read" << linesRead << "of" << parm.lines << "lines in" << readTimer.elapsed() << "ms" + << (writePng ? "(via PNG temp file)" : "(direct image)"); + + if (writePng) { + png_write_end(png_ptr, info_ptr); + png_destroy_write_struct(&png_ptr, &info_ptr); + } delete[] buffer; if (status != SANE_STATUS_EOF) { diff --git a/src/device/scannerdevice.h b/src/device/scannerdevice.h index 4265acb..10b4f42 100644 --- a/src/device/scannerdevice.h +++ b/src/device/scannerdevice.h @@ -61,10 +61,11 @@ class ScannerDevice : public DeviceBase bool isCapturing() const override; DeviceType getDeviceType() const override { return DeviceType::Scanner; } QString currentDeviceName() const override { return m_currentDeviceName; } - // True while an open request has been issued but its result has not arrived yet. - bool isOpening() const { return m_deviceOpening; } // Extended scanner-specific interface + // Asks the worker to enumerate the devices, the result arrives with + // availableDevicesReady(). + void requestAvailableDevices(); void startScan(const QString &tempOutputFilePath); void cancelScan(); bool setScanMode(ScanMode mode); @@ -87,12 +88,13 @@ class ScannerDevice : public DeviceBase void triggerCloseDevice(); void triggerStartScan(const QString &filePath, int dpi, ScanMode mode, ColorMode colorMode, PaperSize paperSize); void triggerCancelScan(); + void triggerGetAvailableDevices(); // Forwarded signals from worker - void scanProgress(int percentage); void deviceOpened(); void deviceClosed(); void deviceUnavailable(const QString &deviceName); + void availableDevicesReady(const QStringList &deviceNames); private slots: // Slots to handle results from the worker thread @@ -100,13 +102,16 @@ private slots: void onDeviceOpened(const QList &resolutions, const QList &modes); void onDeviceClosed(); void onCaptureCompleted(const QString &filePath); + void onCaptureCompletedImage(const QImage &image); + void onAvailableDevicesReady(const QStringList &deviceNames); private: QString m_currentDeviceName; bool m_isCapturing = false; bool m_deviceOpen = false; - bool m_deviceOpening = false; // True while an open request is in flight bool m_scanPending = false; // True if a scan was requested before device was open + // Last device list reported by the worker, see getAvailableDevices(). + QStringList m_availableDevices; QThread m_workerThread; ScannerWorker *m_worker = nullptr; @@ -133,6 +138,7 @@ public slots: void doCloseDevice(); void doStartScan(const QString &tempOutputFilePath, int dpi, ScannerDevice::ScanMode mode, ScannerDevice::ColorMode colorMode, ScannerDevice::PaperSize paperSize); void doCancelScan(); + void doGetAvailableDevices(); signals: void errorOccurred(const QString &errorMessage); @@ -140,13 +146,16 @@ public slots: void deviceClosed(); void deviceDisconnected(const QString &deviceName); void deviceUnavailable(const QString &deviceName); + void availableDevicesReady(const QStringList &deviceNames); void captureCompleted(const QString &filePath); - void scanProgress(int percentage); + // Sent instead of captureCompleted() when the page could be handed over as an image, + // which avoids the PNG temp file (see scan_it()). + void captureCompletedImage(const QImage &image); private: #ifndef _WIN32 struct Image; - SANE_Status scan_it(FILE *ofp); + SANE_Status scan_it(FILE *ofp, QImage &image); void updateSupportedOptions(); bool reopenDevice(); void reportScanFailure(const QString &errorMessage); diff --git a/src/device/webcamdevice.cpp b/src/device/webcamdevice.cpp index 1dd68d0..712650a 100644 --- a/src/device/webcamdevice.cpp +++ b/src/device/webcamdevice.cpp @@ -14,8 +14,10 @@ #include #include #include +#include #include +#include #include #include #include @@ -35,19 +37,33 @@ using namespace DDLog; +// Waiting for a frame needs a timeout: a camera can take well over a second for the first +// frame at its highest resolution, but a stream that stopped (unplugged device) must not +// block the caller forever. +static const int FRAME_WAIT_TIMEOUT_MS = 2000; + +// The wait for a frame is done in slices, so that a capture in flight can be told to give up +// within this delay instead of holding the device for the whole timeout. +static const int FRAME_WAIT_SLICE_MS = 100; + // WebcamDevice implementation WebcamDevice::WebcamDevice(QObject *parent) - : DeviceBase(parent), m_fd(-1), m_currentBuffer(0), m_isInitialized(false), m_deviceSelected(false), m_width(640), m_height(480), m_pixelFormat(V4L2_PIX_FMT_YUYV) + : DeviceBase(parent), m_fd(-1), m_currentBuffer(0), m_deviceSelected(false), m_width(640), m_height(480), m_pixelFormat(V4L2_PIX_FMT_YUYV) { memset(m_buffers, 0, sizeof(m_buffers)); memset(m_bufferSizes, 0, sizeof(m_bufferSizes)); + // One thread, so that the preview and the capture never use the device at once. + m_ioPool.setMaxThreadCount(1); + m_previewTimer.setInterval(33); // ~30 fps connect(&m_previewTimer, &QTimer::timeout, this, &WebcamDevice::updatePreview); } WebcamDevice::~WebcamDevice() { + // The I/O thread uses the device, so nothing may be running when it is torn down. + stopIoWork(); closeDevice(); } @@ -100,6 +116,10 @@ bool WebcamDevice::initialize() bool WebcamDevice::openDevice(const QString &devicePath) { + // Device-level operations close and re-open the descriptor, and they can arrive from the + // UI thread and from worker threads at the same time. + std::lock_guard lock(m_deviceMutex); + // Make sure to close previous device first closeDevice(); qCInfo(app) << "Opening webcam device:" << devicePath; @@ -219,17 +239,28 @@ bool WebcamDevice::openDevice(const QString &devicePath) // Enumerate supported resolutions enumerateSupportedResolutions(); + // Open the stream here, at the end of the device setup: openDevice() runs on a worker + // thread, so the ~1 s STREAMON of a 1080p camera no longer freezes the UI. It has to + // stay after enumerateSupportedResolutions(), which probes formats. + startStream(); + return true; } bool WebcamDevice::setResolution(int width, int height) { + std::lock_guard lock(m_deviceMutex); + qCDebug(app) << "Attempting to set resolution:" << width << "x" << height; + // The preview and the capture use the device on the I/O thread: let them go before it + // is closed and re-opened underneath them. The caller refuses to reconfigure while a + // capture is in flight, so only the short preview work can be waiting here. + stopIoWork(); + // Save original state bool wasInitialized = m_isInitialized; bool wasDeviceSelected = m_deviceSelected; - bool wasPreviewActive = m_previewTimer.isActive(); // Save current device path QString devicePath; @@ -249,17 +280,9 @@ bool WebcamDevice::setResolution(int width, int height) // Here we can add fallback method to get device path } - // Stop preview and completely close device - if (wasPreviewActive) { - stopPreview(); - } - // Completely close device and release all resources closeDevice(); - // Give system some time to fully release device - QThread::msleep(500); - // Without device path, cannot continue if (devicePath.isEmpty()) { emit errorOccurred(tr("Cannot get device path, cannot set resolution")); @@ -268,7 +291,9 @@ bool WebcamDevice::setResolution(int width, int height) // Reopen device qCDebug(app) << "Reopening device:" << devicePath; - m_fd = open(devicePath.toUtf8().constData(), O_RDWR); + // O_NONBLOCK matters here: the preview reads frames with VIDIOC_DQBUF from the UI + // thread, and a blocking file descriptor would stall it until the next frame arrives. + m_fd = open(devicePath.toUtf8().constData(), O_RDWR | O_NONBLOCK); if (m_fd <= 0) { qCDebug(app) << "Failed to reopen device:" << strerror(errno); emit errorOccurred(tr("Failed to reopen device")); @@ -329,10 +354,10 @@ bool WebcamDevice::setResolution(int width, int height) // Re-enumerate resolutions and notify UI enumerateSupportedResolutions(); - // If preview was active before, restore it - if (wasPreviewActive) { - startPreview(); - } + // Open the stream again, here and not in the caller: this runs on a worker thread, + // while the preview timer belongs to the UI thread (see startStream()). The caller is + // expected to have stopped the preview before calling, and restarts it afterwards. + startStream(); qCDebug(app) << "Successfully completed resolution setting:" << m_width << "x" << m_height; return true; @@ -340,18 +365,31 @@ bool WebcamDevice::setResolution(int width, int height) void WebcamDevice::startCapture() { - captureImage(); + if (m_captureRunning.exchange(true)) { + return; // a capture is already in flight + } + + // captureImage() waits for a frame, which takes seconds when the stream has just been + // started: it runs on the I/O thread so that the caller, the UI thread, stays free. + // The outcome is reported through the device signals, so the future is not kept. + (void)QtConcurrent::run(&m_ioPool, [this]() { + captureImage(); + m_captureRunning = false; + }); } void WebcamDevice::stopCapture() { + std::lock_guard lock(m_deviceMutex); + + stopIoWork(); stopCapturing(); setState(Connected); } bool WebcamDevice::isCapturing() const { - return state() == Capturing; + return m_captureRunning; } bool WebcamDevice::startCapturing() @@ -365,9 +403,7 @@ bool WebcamDevice::startCapturing() // Ensure stream is stopped before enqueuing buffers v4l2_buf_type stopType = V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(m_fd, VIDIOC_STREAMOFF, &stopType); - - // Wait for device to stabilize - QThread::msleep(100); + m_streaming = false; // Debug buffer status qCDebug(app) << "Buffer status:"; @@ -419,17 +455,21 @@ bool WebcamDevice::startCapturing() buf.memory = V4L2_MEMORY_MMAP; buf.index = i; - int result = ioctl(m_fd, VIDIOC_QBUF, &buf); - if (result == -1) { - // Handle special cases in non-blocking mode - if (errno == EAGAIN) { - // In non-blocking mode, we may need multiple attempts - qCDebug(app) << "Buffer not ready yet (EAGAIN), retrying..."; - QThread::msleep(10); - i--; // Retry current buffer - continue; + int result = -1; + // In non-blocking mode a buffer may not be ready yet: retry a bounded number of + // times instead of retrying the same buffer forever. + for (int attempt = 0; attempt < 50; attempt++) { + if (m_captureAbort) { + break; // the device is going away, do not keep the caller waiting } - + result = ioctl(m_fd, VIDIOC_QBUF, &buf); + if (result != -1 || errno != EAGAIN) { + break; + } + qCDebug(app) << "Buffer not ready yet (EAGAIN), retrying..."; + QThread::msleep(10); + } + if (result == -1) { qCWarning(app) << "Failed to enqueue buffer" << i << ":" << strerror(errno) << "(errno:" << errno << ")"; // Clean up any already enqueued buffers @@ -458,9 +498,13 @@ bool WebcamDevice::startCapturing() if (errno == EAGAIN) { // In non-blocking mode, we may need multiple attempts for (int retry = 0; retry < 5; retry++) { + if (m_captureAbort) { + break; // the device is going away, do not keep the caller waiting + } QThread::msleep(50); if (ioctl(m_fd, VIDIOC_STREAMON, &type) != -1) { qCInfo(app) << "Video capture stream started successfully after retry"; + m_streaming = true; return true; } if (errno != EAGAIN) { @@ -475,6 +519,7 @@ bool WebcamDevice::startCapturing() } qCInfo(app) << "Video capture stream started successfully"; + m_streaming = true; return true; } @@ -482,20 +527,35 @@ void WebcamDevice::stopCapturing() { v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(m_fd, VIDIOC_STREAMOFF, &type); + m_streaming = false; } -void WebcamDevice::startPreview() +bool WebcamDevice::startStream() { if (!m_isInitialized) { - return; + return false; + } + + if (m_streaming) { + return true; } if (!startCapturing()) { - return; + return false; } adjustCommonCameraSettings(); setCameraAutoFocus(true); + return true; +} + +void WebcamDevice::startPreview() +{ + std::lock_guard lock(m_deviceMutex); + + if (!startStream()) { + return; + } // Clear latest frame { @@ -504,13 +564,20 @@ void WebcamDevice::startPreview() } qCDebug(app) << "Preview started, camera parameters applied"; + m_previewRunning = true; m_previewTimer.start(); setState(Capturing); } void WebcamDevice::stopPreview() { + std::lock_guard lock(m_deviceMutex); + + m_previewRunning = false; m_previewTimer.stop(); + // A capture in flight and the frame being dequeued on the I/O thread both use the + // stream: they are given up and waited for before it is turned off. + stopIoWork(); if (m_isInitialized) { stopCapturing(); } @@ -523,6 +590,42 @@ void WebcamDevice::updatePreview() return; } + // Dequeuing and converting a frame runs on the I/O thread: converting a full frame is + // far too slow for the UI thread, and the device is shared with a capture that may be + // waiting for a frame on that same thread. + if (m_frameTaskRunning.exchange(true)) { + return; // the previous frame is still being processed, skip this tick + } + + (void)QtConcurrent::run(&m_ioPool, [this]() { + const QImage image = dequeueFrame(); + + // Publishing the frame and touching the cache belongs to the UI thread, which also + // owns the signal. + QMetaObject::invokeMethod( + this, + [this, image]() { + m_frameTaskRunning = false; + if (image.isNull()) { + return; + } + + // Save adjusted image as latest frame for capture + { + QMutexLocker locker(&m_frameMutex); + m_latestFrame = image; + } + + // emit image + emit previewUpdated(image); + }, + Qt::QueuedConnection); + }); +} + +// Runs on the I/O thread: it only dequeues and converts a frame, the caller publishes it. +QImage WebcamDevice::dequeueFrame() +{ v4l2_buffer buf = {}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; @@ -531,9 +634,9 @@ void WebcamDevice::updatePreview() if (ioctl(m_fd, VIDIOC_DQBUF, &buf) == -1) { // EAGAIN means no frame is available at the moment, which is normal in non-blocking mode if (errno == EAGAIN) { - return; + return QImage(); } - + qCWarning(app) << "Preview update failed:" << strerror(errno); // The video stream is broken (e.g. device unplugged). Clear the cached // frame so captureImage() cannot serve stale data from it. @@ -546,36 +649,69 @@ void WebcamDevice::updatePreview() emit errorOccurred(tr("Device has been disconnected")); emit deviceUnavailable(m_currentDeviceName, tr("Device has been disconnected")); } - return; + return QImage(); } // Ensure buffer index is valid if (buf.index >= 4 || !m_buffers[buf.index]) { qCWarning(app) << "Invalid buffer index:" << buf.index; ioctl(m_fd, VIDIOC_QBUF, &buf); // Try to requeue buffer - return; + return QImage(); } - QImage image = frameToQImage(m_buffers[buf.index], m_width, m_height, m_pixelFormat); + const QImage image = frameToQImage(m_buffers[buf.index], m_width, m_height, m_pixelFormat); // Return buffer - must do this regardless of image conversion success if (ioctl(m_fd, VIDIOC_QBUF, &buf) == -1) { qCWarning(app) << "Failed to requeue buffer:" << strerror(errno); - return; + return QImage(); } - if (!image.isNull()) { - // Save adjusted image as latest frame for capture - QMutexLocker locker(&m_frameMutex); - m_latestFrame = image; + return image; +} - // emit image - emit previewUpdated(image); - } +void WebcamDevice::pausePreviewTimer() +{ + // The timer lives on the UI thread, so it is stopped there. + QMetaObject::invokeMethod( + this, [this]() { m_previewRunning = false; m_previewTimer.stop(); }, Qt::QueuedConnection); +} + +void WebcamDevice::resumePreviewTimer() +{ + QMetaObject::invokeMethod( + this, + [this]() { + // The stream may have been stopped while the capture was running: restarting + // the timer then would dequeue from a stopped stream and report the device + // that is merely being reconfigured as unplugged. + if (!m_streaming) { + return; + } + m_previewRunning = true; + m_previewTimer.start(); + }, + Qt::QueuedConnection); +} + +void WebcamDevice::stopIoWork() +{ + // A capture waits for a frame for up to a few seconds, so it is told to give up instead + // of being waited for. The frame being converted on the I/O thread is short and is + // simply waited for. + m_captureAbort = true; + m_ioPool.waitForDone(); + m_captureAbort = false; } void WebcamDevice::captureImage() { + // A device that is going away is not an error the user has to hear about. + if (m_captureAbort) { + qCDebug(app) << "Capture gave up: the device is being reconfigured"; + return; + } + // Check device state if (!m_isInitialized || m_fd <= 0) { emit errorOccurred(tr("Device not initialized or invalid file descriptor")); @@ -586,7 +722,7 @@ void WebcamDevice::captureImage() qCInfo(app) << "Starting image capture..."; // Check if preview is running - bool previewWasRunning = m_previewTimer.isActive(); + const bool previewWasRunning = m_previewRunning.load(); // If preview not running, start a temporary capture process if (!previewWasRunning) { @@ -594,19 +730,24 @@ void WebcamDevice::captureImage() // Ensure current state is cleaned up stopCapturing(); - QThread::msleep(100); // Start capture process if (!startCapturing()) { + if (m_captureAbort) { + qCDebug(app) << "Capture gave up: the device is being reconfigured"; + return; + } emit errorOccurred(tr("Failed to start video stream, capture failed")); return; } - - // Wait for device to be ready - QThread::msleep(300); } else { // If preview is running, pause timer but keep stream active - m_previewTimer.stop(); + pausePreviewTimer(); + } + + if (m_captureAbort) { + qCDebug(app) << "Capture gave up: the device is being reconfigured"; + return; } // Try to get frame directly from device, regardless of preview state @@ -617,13 +758,29 @@ void WebcamDevice::captureImage() qCDebug(app) << "Attempting to get frame directly from device (attempt" << (retry + 1) << ")"; - // Temporarily switch to blocking mode to ensure frame capture - int flags = fcntl(m_fd, F_GETFL, 0); - fcntl(m_fd, F_SETFL, flags & ~O_NONBLOCK); + // Wait for a frame with a timeout instead of blocking in VIDIOC_DQBUF: the file + // descriptor stays in non-blocking mode, so a stalled stream cannot hang here. The + // wait is sliced so that a device that is going away ends it without having to wait + // for the whole timeout. + int ready = 0; + for (int waited = 0; waited < FRAME_WAIT_TIMEOUT_MS; waited += FRAME_WAIT_SLICE_MS) { + struct pollfd pfd = { m_fd, POLLIN, 0 }; + ready = poll(&pfd, 1, FRAME_WAIT_SLICE_MS); + if (ready != 0 || m_captureAbort) { + break; + } + } + + if (m_captureAbort) { + // The device is being closed or reconfigured: its stream state is left to the + // caller and nothing is reported to the user. + qCDebug(app) << "Capture gave up: the device is being reconfigured"; + return; + } - if (ioctl(m_fd, VIDIOC_DQBUF, &buf) == -1) { - // Restore non-blocking mode - fcntl(m_fd, F_SETFL, flags); + const bool gotFrame = (ready > 0) && (ioctl(m_fd, VIDIOC_DQBUF, &buf) != -1); + + if (!gotFrame) { qCWarning(app) << "Failed to get frame:" << strerror(errno); if (retry == 2) { // Last attempt failed @@ -637,20 +794,15 @@ void WebcamDevice::captureImage() // Restore preview state if (previewWasRunning) { - m_previewTimer.start(); + resumePreviewTimer(); } else { stopCapturing(); } return; } - // Brief wait before retry - QThread::msleep(100); continue; } - // Restore non-blocking mode - fcntl(m_fd, F_SETFL, flags); - // Ensure buffer index is valid if (buf.index >= 4 || !m_buffers[buf.index]) { qCWarning(app) << "Invalid buffer index:" << buf.index; @@ -673,7 +825,7 @@ void WebcamDevice::captureImage() // If preview was running before, restore timer if (previewWasRunning) { - m_previewTimer.start(); + resumePreviewTimer(); } else { stopCapturing(); } @@ -684,9 +836,6 @@ void WebcamDevice::captureImage() } else { qCWarning(app) << "Image conversion failed - retrying"; } - - // Brief wait before retry - QThread::msleep(100); } // If all attempts failed @@ -694,7 +843,7 @@ void WebcamDevice::captureImage() // Restore preview state if (previewWasRunning) { - m_previewTimer.start(); + resumePreviewTimer(); } else { stopCapturing(); } @@ -714,66 +863,41 @@ QImage WebcamDevice::frameToQImage(const void *data, int width, int height, int const uint8_t *yuyv = static_cast(data); // Safety check - ensure enough memory - size_t expectedSize = width * height * 2; // YUYV格式每个像素需要2字节 - if (expectedSize > 0) { // 合理的大小上限 - try { - for (int y = 0; y < height; y++) { - for (int x = 0; x < width; x += 2) { - int i = y * width * 2 + x * 2; - - // Boundary check - if (i + 3 >= expectedSize) { - qCWarning(app) << "Frame data boundary check failed - possible incomplete data"; - continue; - } - - int y0 = yuyv[i]; - int u = yuyv[i + 1]; - int y1 = yuyv[i + 2]; - int v = yuyv[i + 3]; - - // Keep original brightness value - y0 = y0; - y1 = y1; - - int r, g, b; - - // First pixel - use more accurate YUV to RGB conversion - r = static_cast(y0 + 1.370705 * (v - 128)); - g = static_cast(y0 - 0.337633 * (u - 128) - 0.698001 * (v - 128)); - b = static_cast(y0 + 1.732446 * (u - 128)); - - r = qBound(0, r, 255); - g = qBound(0, g, 255); - b = qBound(0, b, 255); - - if (x < width) { // Safety check - image.setPixel(x, y, qRgb(r, g, b)); - } - - // Second pixel - use same conversion formula - r = static_cast(y1 + 1.370705 * (v - 128)); - g = static_cast(y1 - 0.337633 * (u - 128) - 0.698001 * (v - 128)); - b = static_cast(y1 + 1.732446 * (u - 128)); - - r = qBound(0, r, 255); - g = qBound(0, g, 255); - b = qBound(0, b, 255); + const size_t expectedSize = static_cast(width) * height * 2; // YUYV: 2 bytes per pixel + if (expectedSize == 0) { + qCWarning(app) << "Invalid frame size:" << expectedSize; + return QImage(); + } - if (x + 1 < width) { // Safety check - image.setPixel(x + 1, y, qRgb(r, g, b)); - } - } - } - return image; - } catch (const std::exception &e) { - qCCritical(app) << "Frame conversion exception:" << e.what(); - } catch (...) { - qCCritical(app) << "Unknown exception during frame conversion"; + for (int y = 0; y < height; y++) { + // Write through the scanline pointer: calling setPixel() for every pixel is far + // too slow for a full camera frame. + uchar *line = image.scanLine(y); + const uint8_t *row = yuyv + static_cast(y) * width * 2; + + for (int x = 0; x + 1 < width; x += 2) { + const int i = x * 2; + const int y0 = row[i]; + const int u = row[i + 1]; + const int y1 = row[i + 2]; + const int v = row[i + 3]; + + int r = static_cast(y0 + 1.370705 * (v - 128)); + int g = static_cast(y0 - 0.337633 * (u - 128) - 0.698001 * (v - 128)); + int b = static_cast(y0 + 1.732446 * (u - 128)); + line[3 * x] = static_cast(qBound(0, r, 255)); + line[3 * x + 1] = static_cast(qBound(0, g, 255)); + line[3 * x + 2] = static_cast(qBound(0, b, 255)); + + r = static_cast(y1 + 1.370705 * (v - 128)); + g = static_cast(y1 - 0.337633 * (u - 128) - 0.698001 * (v - 128)); + b = static_cast(y1 + 1.732446 * (u - 128)); + line[3 * x + 3] = static_cast(qBound(0, r, 255)); + line[3 * x + 4] = static_cast(qBound(0, g, 255)); + line[3 * x + 5] = static_cast(qBound(0, b, 255)); } - } else { - qCWarning(app) << "Invalid frame size:" << expectedSize; } + return image; } else if (format == V4L2_PIX_FMT_MJPEG) { // Get actual data size instead of calculated value v4l2_buffer buf; @@ -804,8 +928,22 @@ QImage WebcamDevice::frameToQImage(const void *data, int width, int height, int void WebcamDevice::closeDevice() { - // First stop preview and stream - m_previewTimer.stop(); + std::lock_guard lock(m_deviceMutex); + + // Nothing may touch the device while it is closed. + stopIoWork(); + + // First stop preview and stream. The initialized flag goes down before the descriptor is + // gone, so a preview tick that is already queued cannot report a device that is only + // being closed as unplugged. + m_isInitialized = false; + m_previewRunning = false; + // The timer belongs to the UI thread. From another thread it is left alone: queuing the + // stop could land after the next startPreview(), and the flag above already keeps its + // tick from touching the device. + if (QThread::currentThread() == thread()) { + m_previewTimer.stop(); + } stopCapturing(); // Clear latest preview frame @@ -825,7 +963,6 @@ void WebcamDevice::closeDevice() } m_deviceSelected = false; - m_isInitialized = false; setState(Disconnected); } @@ -878,41 +1015,45 @@ QSize WebcamDevice::getMaxResolution() } } - // If discrete resolutions found, get largest one (but no more than 1280x720 unless explicitly supported) + // If discrete resolutions are available, the default is a resolution that keeps the + // stream responsive: the highest resolution a device offers can be far too slow (a + // 3264x2448 frame takes about 1.7 s and the stream drops to a few frames per second), + // and every size stays selectable in the UI. if (resolutionsFound && !supportedSizes.isEmpty()) { - // Sort by resolution (total pixels) + // Sort by resolution (total pixels), largest first std::sort(supportedSizes.begin(), supportedSizes.end(), [](const QSize &a, const QSize &b) { return a.width() * a.height() > b.width() * b.height(); }); - // Select largest resolution not exceeding 720p (unless explicitly supports higher) - for (const QSize &size : supportedSizes) { - // If we find a reasonable resolution, use it - maxWidth = size.width(); - maxHeight = size.height(); - - // If at least 720p resolution found, use it directly - if (maxWidth >= 1280 && maxHeight >= 720) { - // Prefer standard resolutions like 720p or 1080p - if (maxWidth == 1280 && maxHeight == 720) { - qCInfo(app) << "Selected standard resolution: 1280x720"; - return QSize(maxWidth, maxHeight); + const auto selectExact = [&supportedSizes](int width, int height) -> QSize { + for (const QSize &size : supportedSizes) { + if (size.width() == width && size.height() == height) { + return size; } + } + return QSize(); + }; - if (maxWidth == 1920 && maxHeight == 1080) { - qCInfo(app) << "Selected standard resolution: 1920x1080"; - return QSize(maxWidth, maxHeight); + QSize selected = selectExact(1920, 1080); + if (selected.isEmpty()) { + selected = selectExact(1280, 720); + } + if (selected.isEmpty()) { + // No standard HD size: use the largest size that is not above 1920x1080. + for (const QSize &size : supportedSizes) { + if (size.width() <= 1920 && size.height() <= 1080) { + selected = size; + break; } - - // If no exact match, use the first (largest) resolution - qCInfo(app) << "Selected maximum resolution:" << supportedSizes.first().width() << "x" << supportedSizes.first().height(); - return supportedSizes.first(); } - - // If no sufficiently large resolution found, use largest in list - break; } + if (selected.isEmpty()) { + selected = supportedSizes.last(); + } + + qCInfo(app) << "Selected resolution:" << selected.width() << "x" << selected.height(); + return selected; } // If previous methods failed, try to get current format @@ -1363,7 +1504,12 @@ bool WebcamDevice::adjustCommonCameraSettings() // Debug info: List camera supported controls qCDebug(app) << "=== Starting camera optimization ==="; - listCameraControls(); + + // Walking the control ids costs a few hundred milliseconds on some cameras, so the + // listing is only done when debug logging is enabled. + if (DDLog::app().isDebugEnabled()) { + listCameraControls(); + } // Parameter configuration strategy (with smart adaptation) struct CameraControlConfig { @@ -1478,7 +1624,6 @@ bool WebcamDevice::adjustCommonCameraSettings() ioctl(m_fd, VIDIOC_S_CTRL, &fallback); } } - QThread::msleep(50 * (retry + 1)); } } @@ -1594,9 +1739,6 @@ bool WebcamDevice::initMmap() qCDebug(app) << "Failed to release existing buffers:" << strerror(errno); } - // Give device some recovery time - QThread::msleep(200); - // Initialize buffer array for (int i = 0; i < 4; i++) { m_buffers[i] = nullptr; diff --git a/src/device/webcamdevice.h b/src/device/webcamdevice.h index 3ef3689..7e43ef8 100644 --- a/src/device/webcamdevice.h +++ b/src/device/webcamdevice.h @@ -12,7 +12,10 @@ #include #include #include +#include #include +#include +#include #include "devicebase.h" class WebcamDevice : public DeviceBase @@ -35,6 +38,9 @@ class WebcamDevice : public DeviceBase QString currentDeviceName() const override { return m_currentDeviceName; } // Extended webcam-specific interface + // S_FMT/REQBUFS/STREAMON, which takes about a second on a 1080p camera. The caller + // decides on which thread it runs; it does nothing when the stream is already on. + bool startStream(); void startPreview(); void stopPreview(); @@ -74,11 +80,25 @@ private slots: void *m_buffers[4]; // Video buffers size_t m_bufferSizes[4]; // Buffer sizes int m_currentBuffer; // Currently used buffer - bool m_isInitialized; + std::atomic m_isInitialized = false; bool m_deviceSelected; + // Opening, closing and reconfiguring all close and re-open the descriptor, and they can + // be asked for from the UI thread and from worker threads at the same time. Recursive + // because openDevice() and setResolution() close the previous device as their first step. + std::recursive_mutex m_deviceMutex; + // Whether the V4L2 stream is on, so that startStream() does not restart an active one. + std::atomic m_streaming = false; // Set once a broken stream has been reported, so a device that is gone is reported // only once instead of on every preview tick. - bool m_disconnectReported = false; + std::atomic m_disconnectReported = false; + // Dequeuing a frame, waiting for one and converting it are all too slow for the UI + // thread, so they run here: a single thread that keeps the preview and the capture + // from touching the device at the same time. + QThreadPool m_ioPool; + std::atomic m_frameTaskRunning = false; // a preview frame is being processed + std::atomic m_captureRunning = false; // a capture is in flight + std::atomic m_captureAbort = false; // a capture in flight has to give up + std::atomic m_previewRunning = false; // the preview timer is running QTimer m_previewTimer; int m_width; int m_height; @@ -91,7 +111,15 @@ private slots: bool initMmap(); void uninitMmap(); QImage frameToQImage(const void *data, int width, int height, int format); + // captureImage() and dequeueFrame() run on the I/O thread, see m_ioPool. void captureImage(); + QImage dequeueFrame(); + // The preview timer belongs to the UI thread, so the I/O thread reaches it through these. + void pausePreviewTimer(); + void resumePreviewTimer(); + // Gives up the capture in flight and waits for the I/O thread to go idle, so that the + // device can be closed, re-opened or reconfigured safely. + void stopIoWork(); bool startCapturing(); void stopCapturing(); bool selectBestPixelFormat(); diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 8478dda..9a35b70 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -80,6 +80,17 @@ MainWindow::MainWindow(QWidget *parent) DMessageManager::instance()->sendMessage(this, QIcon::fromTheme("dialog-warning"), message); }); + // The device list is rebuilt once the worker has enumerated the scanners. + connect(scannerDevice.data(), &ScannerDevice::availableDevicesReady, this, + [this](const QStringList &) { + auto scanner = qSharedPointerCast(m_devices["scanner"]); + auto webcam = qSharedPointerCast(m_devices["webcam"]); + if (scanner && webcam) { + m_scannersWidget->updateDeviceList(scanner, webcam); + } + hideLoading(); + }); + // 设置标题栏logo auto titleBar = titlebar(); // titleBar->setIcon(QIcon(":/resources/logo.svg")); @@ -102,6 +113,9 @@ MainWindow::MainWindow(QWidget *parent) MainWindow::~MainWindow() { + // A camera reconfiguration started from the UI keeps using the device on a worker + // thread: let it finish before the devices are released here. + m_scanWidget->waitForPendingCameraWork(); m_devices.clear(); } @@ -110,41 +124,34 @@ void MainWindow::updateDeviceList() qDebug(app) << "Updating device list..."; showLoading(tr("Loading devices...")); - // 检查设备是否初始化 - if (!m_devices["scanner"] || !m_devices["webcam"]) { - qDebug(app) << "Error: Devices not initialized"; - return; - } - // 使用类型安全的指针转换 auto scanner = qSharedPointerCast(m_devices["scanner"]); auto webcam = qSharedPointerCast(m_devices["webcam"]); - if (scanner && webcam) { - // 给网络设备发现预留更多时间(特别是首次启动) - static bool firstRun = true; - int delay = firstRun ? 3000 : 500; // 首次启动等待3秒,后续等待0.5秒 - - if (firstRun) { - qCInfo(app) << "First device list update, allowing extra time for network device discovery..."; - firstRun = false; - } - - // 使用定时器延迟更新设备列表 - QTimer::singleShot(delay, this, [this]() { - // 重新获取设备指针,避免捕获过期指针 - auto scanner = qSharedPointerCast(m_devices["scanner"]); - auto webcam = qSharedPointerCast(m_devices["webcam"]); - - if (scanner && webcam) { - m_scannersWidget->updateDeviceList(scanner, webcam); - } - QTimer::singleShot(500, this, &MainWindow::hideLoading); - }); - } else { + if (!scanner || !webcam) { qDebug(app) << "Error: Failed to cast device pointers"; QTimer::singleShot(500, this, &MainWindow::hideLoading); + return; } + + // 给网络设备发现预留更多时间(特别是首次启动) + static bool firstRun = true; + int delay = firstRun ? 3000 : 500; // 首次启动等待3秒,后续等待0.5秒 + + if (firstRun) { + qCInfo(app) << "First device list update, allowing extra time for network device discovery..."; + firstRun = false; + } + + // The SANE enumeration happens in the worker thread, it can block for seconds on + // network backends. The list is rebuilt when the worker reports the result (see the + // availableDevicesReady() connection), so the UI stays responsive meanwhile. + QTimer::singleShot(delay, this, [this]() { + auto scanner = qSharedPointerCast(m_devices["scanner"]); + if (scanner) { + scanner->requestAvailableDevices(); + } + }); } void MainWindow::showScanView(const QString &device, bool isScanner) diff --git a/src/ui/loadingdialog.cpp b/src/ui/loadingdialog.cpp index ded5cc4..326c713 100644 --- a/src/ui/loadingdialog.cpp +++ b/src/ui/loadingdialog.cpp @@ -11,7 +11,9 @@ LoadingDialog::LoadingDialog(QWidget *parent) : QDialog(parent) { - setWindowFlags(windowFlags() | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint); + // Qt::Tool keeps the dialog out of the taskbar: it is an overlay on the main window, + // not a second application window. + setWindowFlags(windowFlags() | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::Tool); setAttribute(Qt::WA_TranslucentBackground); setModal(true); diff --git a/src/ui/scannerswidget.cpp b/src/ui/scannerswidget.cpp index 9659463..17a584d 100644 --- a/src/ui/scannerswidget.cpp +++ b/src/ui/scannerswidget.cpp @@ -64,12 +64,10 @@ void ScannersWidget::setupUI() void ScannersWidget::updateDeviceList(QSharedPointer scanner, QSharedPointer webcam) { - // Enumerating SANE devices while the worker is using an open handle can invalidate - // that handle (the backend may rebuild its device cache), so the known scanner list - // is kept until no scan is running and no open request is in flight. - if (!scanner->isCapturing() && !scanner->isOpening()) { - m_scannerNames = scanner->getAvailableDevices(); - } + // Both calls are cheap here: the scanner returns the list the worker has already + // enumerated (see ScannerDevice::requestAvailableDevices()) and the cameras are a + // local /dev scan. + m_scannerNames = scanner->getAvailableDevices(); m_webcamNames = webcam->getAvailableDevices(); rebuildList(); } diff --git a/src/ui/scanwidget.cpp b/src/ui/scanwidget.cpp index deb5a93..ba12d1c 100644 --- a/src/ui/scanwidget.cpp +++ b/src/ui/scanwidget.cpp @@ -20,6 +20,9 @@ #include #include #include +#include +#include +#include #include #include @@ -324,6 +327,19 @@ void ScanWidget::startScanning() return; } + if (m_cameraBusy) { + // The camera is still being reconfigured, a capture now would race with it. + return; + } + + if (auto *webcam = dynamic_cast(m_device)) { + if (webcam->isCapturing()) { + // The capture in flight is still waiting for its frame; a second one would only + // queue up behind it. + return; + } + } + resetPreview(); m_device->startCapture(); } @@ -376,8 +392,11 @@ void ScanWidget::startCameraPreview() auto webcam = dynamic_cast(m_device); if (webcam) { m_previewLabel->setText(tr("Initializing preview...")); - webcam->stopPreview(); - QTimer::singleShot(100, [webcam]() { + // The stream is already open: openDevice() starts it on the thread that opens + // the camera, so stopping it here would only restart it for nothing. The camera + // is the context object, so a device that went away in the meantime is not + // started by a stale timer. + QTimer::singleShot(100, webcam, [webcam]() { webcam->startPreview(); }); } else { @@ -403,6 +422,13 @@ void ScanWidget::stopCameraPreview() } } +void ScanWidget::waitForPendingCameraWork() +{ + if (m_resolutionWatcher) { + m_resolutionWatcher->waitForFinished(); + } +} + void ScanWidget::onUpdatePreview(const QImage &image) { if (image.isNull()) { @@ -411,11 +437,12 @@ void ScanWidget::onUpdatePreview(const QImage &image) } QMutexLocker locker(&m_previewMutex); - QPixmap pixmap = QPixmap::fromImage(image); - QPixmap scaled = pixmap.scaled(m_previewLabel->size(), - Qt::KeepAspectRatio, - Qt::SmoothTransformation); - m_previewLabel->setPixmap(scaled); + // Scale the image before converting it to a pixmap: converting a full camera frame + // (up to 8 MP) to a pixmap and scaling that is several times more expensive. + const QImage scaledImage = image.scaled(m_previewLabel->size(), + Qt::KeepAspectRatio, + Qt::FastTransformation); + m_previewLabel->setPixmap(QPixmap::fromImage(scaledImage)); m_previewLabel->setAlignment(Qt::AlignCenter); } @@ -458,7 +485,39 @@ void ScanWidget::onResolutionChanged(int index) QString res = m_resolutionCombo->itemText(index); QStringList parts = res.split('x'); if (parts.size() == 2) { - webcam->setResolution(parts[0].toInt(), parts[1].toInt()); + if (m_cameraBusy || webcam->isCapturing()) { + // The device is busy being reconfigured or read: a second reconfiguration + // would close it underneath the first one. + return; + } + + // Reconfiguring the camera (close, re-open, set the format, map the buffers + // and start the stream) takes about a second, so it runs off the UI thread. + // The preview is stopped here, on this thread, and restarted when the new + // resolution is up. + m_cameraBusy = true; + m_previewLabel->setText(tr("Initializing preview...")); + webcam->stopPreview(); + + const int width = parts[0].toInt(); + const int height = parts[1].toInt(); + // The watcher belongs to this widget, not to the camera: the task keeps using + // the device from a worker thread, so it has to be waited for before the + // device is released (see waitForPendingCameraWork()). + auto *watcher = new QFutureWatcher(this); + m_resolutionWatcher = watcher; + connect(watcher, &QFutureWatcher::finished, this, [this, webcam, watcher]() { + const bool changed = watcher->result(); + watcher->deleteLater(); + if (m_resolutionWatcher == watcher) { + m_resolutionWatcher = nullptr; + } + m_cameraBusy = false; + if (changed) { + webcam->startPreview(); + } + }); + watcher->setFuture(QtConcurrent::run(&WebcamDevice::setResolution, webcam, width, height)); } } } @@ -535,129 +594,160 @@ void ScanWidget::setSaveDirectory(const QString &dir) void ScanWidget::onScanFinished(const QImage &image) { - QString scanDir = getSaveDirectory(); - - // generate a file name with timestamp - QString fileName = QString("%1.%2").arg(QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss")) - .arg(FORMATS[m_imageSettings->format].toLower()); - - // handle color mode conversion - QImage processedImage = image; - // handle color mode conversion - if (m_imageSettings->colorMode == 1) { // GRAYSCALE - processedImage = image.convertToFormat(QImage::Format_Grayscale8); - } else if (m_imageSettings->colorMode == 2) { // BLACKWHITE - // 使用改进的黑白转换算法 - processedImage = convertToBlackWhite(image); - } - - // Paper size handling - ScannerDevice::PaperSize targetPaperSize = static_cast(m_imageSettings->paperSize); - - // Get current DPI for scaling - int currentDPI = 300; // Default DPI + // Processing a full page (black and white conversion, paper size detection and scaling) + // and writing the file takes seconds, so it runs on a worker thread. The widget state is + // read here, because it belongs to the UI thread. + ScanSaveRequest request; + request.image = image; + // The path is claimed here, on the UI thread, before the save starts: two scans that end + // within the same second would otherwise build the same name and overwrite each other, + // as both saves run in parallel. + request.filePath = QDir(getSaveDirectory()) + .filePath(QString("%1.%2").arg(QDateTime::currentDateTime().toString("yyyyMMdd_hhmmsszzz")) + .arg(FORMATS[m_imageSettings->format].toLower())); + request.formatIndex = m_imageSettings->format; + request.colorMode = m_imageSettings->colorMode; + request.paperSize = static_cast(m_imageSettings->paperSize); if (m_device && m_isScanner) { auto scanner = dynamic_cast(m_device); if (scanner) { - currentDPI = scanner->getResolution(); + request.dpi = scanner->getResolution(); } } - + + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher]() { + const ScanSaveResult result = watcher->result(); + watcher->deleteLater(); + + if (result.success) { + qCDebug(app) << "Scan saved to:" << result.filePath; + // add the saved file path to the top of the history box + m_historyEdit->moveCursor(QTextCursor::Start); + m_historyEdit->insertPlainText(result.filePath + "\n"); + } else { + qCWarning(app) << "Failed to save scan to:" << result.filePath; + } + }); + watcher->setFuture(QtConcurrent::run(&ScanWidget::saveScan, request)); +} + +// Runs on a worker thread: it must not touch any widget state. +ScanWidget::ScanSaveResult ScanWidget::saveScan(const ScanSaveRequest &request) +{ + ScanSaveResult result; + result.filePath = request.filePath; + + QElapsedTimer saveTimer; + saveTimer.start(); + + QImage processedImage = request.image; + if (request.colorMode == 1) { // GRAYSCALE + processedImage = processedImage.convertToFormat(QImage::Format_Grayscale8); + } else if (request.colorMode == 2) { // BLACKWHITE + // 使用改进的黑白转换算法 + processedImage = convertToBlackWhite(processedImage); + } + // Handle paper size detection and scaling - if (targetPaperSize == ScannerDevice::PAPER_SIZE_AUTO) { + if (request.paperSize == ScannerDevice::PAPER_SIZE_AUTO) { // Auto-detect paper size - ScannerDevice::PaperSize detectedSize = detectPaperSize(processedImage); + const ScannerDevice::PaperSize detectedSize = detectPaperSize(processedImage, request.dpi); qDebug() << "Auto-detected paper size:" << detectedSize; - + // Scale to detected size if needed if (detectedSize != ScannerDevice::PAPER_SIZE_AUTO) { - processedImage = scaleToPaperSize(processedImage, detectedSize, currentDPI); + processedImage = scaleToPaperSize(processedImage, detectedSize, request.dpi); } } else { // Use manually selected paper size - qDebug() << "Using selected paper size:" << targetPaperSize; - processedImage = scaleToPaperSize(processedImage, targetPaperSize, currentDPI); + qDebug() << "Using selected paper size:" << request.paperSize; + processedImage = scaleToPaperSize(processedImage, request.paperSize, request.dpi); } - QString filePath = QDir(scanDir).filePath(fileName); - bool saveSuccess = false; + const qint64 processMs = saveTimer.elapsed(); + saveTimer.restart(); - if (m_imageSettings->format < 4) { // PNG/JPG/BMP/TIFF - saveSuccess = processedImage.save(filePath, FORMATS[m_imageSettings->format].toLatin1().constData()); - } else if (m_imageSettings->format == 4) { // PDF - QPrinter printer(QPrinter::HighResolution); - printer.setOutputFormat(QPrinter::PdfFormat); - printer.setOutputFileName(filePath); - - // Set page size based on selected paper size - QPageSize::PageSizeId pageSizeId = QPageSize::A4; // Default - switch (targetPaperSize) { - case ScannerDevice::PAPER_SIZE_A3: - pageSizeId = QPageSize::A3; - break; - case ScannerDevice::PAPER_SIZE_A4: - pageSizeId = QPageSize::A4; - break; - case ScannerDevice::PAPER_SIZE_A5: - pageSizeId = QPageSize::A5; - break; - case ScannerDevice::PAPER_SIZE_A6: - pageSizeId = QPageSize::A6; - break; - case ScannerDevice::PAPER_SIZE_B4: - pageSizeId = QPageSize::B4; - break; - case ScannerDevice::PAPER_SIZE_B5: - pageSizeId = QPageSize::B5; - break; - case ScannerDevice::PAPER_SIZE_AUTO: - default: - // For auto mode, use the detected paper size or default to A4 - // Note: The image has already been scaled to the correct size - pageSizeId = QPageSize::A4; - break; - } - printer.setPageSize(QPageSize(pageSizeId)); - - QPainter painter; - if (painter.begin(&printer)) { - QRect rect = painter.viewport(); - QSize size = processedImage.size(); - size.scale(rect.size(), Qt::KeepAspectRatio); - painter.setViewport(rect.x(), rect.y(), size.width(), size.height()); - painter.setWindow(processedImage.rect()); - painter.drawImage(0, 0, processedImage); - painter.end(); - saveSuccess = true; - } + if (request.formatIndex < 4) { // PNG/JPG/BMP/TIFF + result.success = processedImage.save(result.filePath, FORMATS[request.formatIndex].toLatin1().constData()); + } else if (request.formatIndex == 4) { // PDF + result.success = renderPdf(processedImage, result.filePath, request.paperSize); } else { // Create vector of images - QVector images; + QVector images; images.append(processedImage); // Write OFD file ofd::Writer writer; - + // Get paper size dimensions in mm for OFD - QSizeF paperSizeMM = ScannerDevice::getPaperSizeDimensions(targetPaperSize); - - saveSuccess = writer.createFromImages(filePath, images, 0, 0, - paperSizeMM.width(), paperSizeMM.height()); - if (!saveSuccess) { + const QSizeF paperSizeMM = ScannerDevice::getPaperSizeDimensions(request.paperSize); + + result.success = writer.createFromImages(result.filePath, images, 0, 0, + paperSizeMM.width(), paperSizeMM.height()); + if (!result.success) { qCWarning(app) << "Failed to create OFD file"; } } - if (saveSuccess) { - qCDebug(app) << "Scan saved to:" << filePath; - // add the saved file path to the top of the history box - m_historyEdit->moveCursor(QTextCursor::Start); - m_historyEdit->insertPlainText(filePath + "\n"); - } else { - qCWarning(app) << "Failed to save scan to:" << filePath; + qCInfo(app) << "Scan save timing: process" << processMs << "ms, write" << saveTimer.elapsed() << "ms, format" << FORMATS[request.formatIndex]; + return result; +} + +// Runs on a worker thread: QPrinter is created and used there, which Qt allows for the +// PDF output format (a QPrinter must be created in the thread that paints on it). +bool ScanWidget::renderPdf(const QImage &image, const QString &filePath, ScannerDevice::PaperSize paperSize) +{ + QPrinter printer(QPrinter::HighResolution); + printer.setOutputFormat(QPrinter::PdfFormat); + printer.setOutputFileName(filePath); + + // Set page size based on selected paper size + QPageSize::PageSizeId pageSizeId = QPageSize::A4; // Default + switch (paperSize) { + case ScannerDevice::PAPER_SIZE_A3: + pageSizeId = QPageSize::A3; + break; + case ScannerDevice::PAPER_SIZE_A4: + pageSizeId = QPageSize::A4; + break; + case ScannerDevice::PAPER_SIZE_A5: + pageSizeId = QPageSize::A5; + break; + case ScannerDevice::PAPER_SIZE_A6: + pageSizeId = QPageSize::A6; + break; + case ScannerDevice::PAPER_SIZE_B4: + pageSizeId = QPageSize::B4; + break; + case ScannerDevice::PAPER_SIZE_B5: + pageSizeId = QPageSize::B5; + break; + case ScannerDevice::PAPER_SIZE_AUTO: + default: + // For auto mode, use the detected paper size or default to A4 + // Note: the image has already been scaled to the correct size + pageSizeId = QPageSize::A4; + break; + } + printer.setPageSize(QPageSize(pageSizeId)); + + QPainter painter; + if (!painter.begin(&printer)) { + return false; } + + QRect rect = painter.viewport(); + QSize size = image.size(); + size.scale(rect.size(), Qt::KeepAspectRatio); + painter.setViewport(rect.x(), rect.y(), size.width(), size.height()); + painter.setWindow(image.rect()); + painter.drawImage(0, 0, image); + painter.end(); + return true; } + void ScanWidget::onScanModeChanged(int index) { if (!m_device) return; @@ -874,24 +964,15 @@ QImage ScanWidget::convertToBlackWhite(const QImage &sourceImage) return finalResult; } -ScannerDevice::PaperSize ScanWidget::detectPaperSize(const QImage &image) +ScannerDevice::PaperSize ScanWidget::detectPaperSize(const QImage &image, int dpi) { if (image.isNull()) { return ScannerDevice::PAPER_SIZE_A4; // Default to A4 } - - // Get current DPI from scanner device - int currentDPI = 300; // Default DPI - if (m_device && m_isScanner) { - auto scanner = dynamic_cast(m_device); - if (scanner) { - currentDPI = scanner->getResolution(); - } - } - + // Calculate physical dimensions in millimeters - double widthMM = (image.width() * 25.4) / currentDPI; - double heightMM = (image.height() * 25.4) / currentDPI; + double widthMM = (image.width() * 25.4) / dpi; + double heightMM = (image.height() * 25.4) / dpi; qDebug() << "Detected image size:" << widthMM << "x" << heightMM << "mm"; diff --git a/src/ui/scanwidget.h b/src/ui/scanwidget.h index b7084cc..e1c6784 100644 --- a/src/ui/scanwidget.h +++ b/src/ui/scanwidget.h @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2025 UnionTech Software Technology Co., Ltd. +// SPDX-FileCopyrightText: 2025 - 2026 UnionTech Software Technology Co., Ltd. // // SPDX-License-Identifier: GPL-3.0-or-later @@ -11,6 +11,7 @@ #include #include #include +#include #include #include "device/devicebase.h" @@ -41,6 +42,10 @@ class ScanWidget : public QWidget void startCameraPreview(); void stopCameraPreview(); + // A camera reconfiguration runs on a worker thread and keeps using the device: the + // owner has to wait for it before releasing the device. + void waitForPendingCameraWork(); + QString getSaveDirectory(); void setSaveDirectory(const QString &dir); @@ -65,18 +70,45 @@ private slots: void onDeviceOpened(); private: + // Everything the worker needs to process and save a scan; gathered on the UI thread + // because the image settings and the device belong to it. + struct ScanSaveRequest { + QImage image; + QString filePath; + int formatIndex = 0; + int colorMode = 0; + ScannerDevice::PaperSize paperSize = ScannerDevice::PAPER_SIZE_AUTO; + int dpi = 300; + }; + + // Result of saveScan(). + struct ScanSaveResult { + QString filePath; + bool success = false; + }; + void setupUI(); void connectDeviceSignals(bool bind); void updateDeviceSettings(); void resetPreview(); - QImage convertToBlackWhite(const QImage &sourceImage); - + + // Processing and saving run on a worker thread, so these must not touch widget state. + static ScanSaveResult saveScan(const ScanSaveRequest &request); + static bool renderPdf(const QImage &image, const QString &filePath, ScannerDevice::PaperSize paperSize); + static QImage convertToBlackWhite(const QImage &sourceImage); + // Paper size handling methods - ScannerDevice::PaperSize detectPaperSize(const QImage &image); - QImage scaleToPaperSize(const QImage &image, ScannerDevice::PaperSize targetSize, int dpi); + static ScannerDevice::PaperSize detectPaperSize(const QImage &image, int dpi); + static QImage scaleToPaperSize(const QImage &image, ScannerDevice::PaperSize targetSize, int dpi); DeviceBase* m_device = nullptr; bool m_isScanner; + // True while the camera is being reconfigured off the UI thread, so a scan cannot be + // started against a device that is currently closing and re-opening. + bool m_cameraBusy = false; + // The reconfiguration task of the camera, or null when none is in flight. It has to be + // waited for before the device it works on is destroyed, see waitForPendingCameraWork(). + QFutureWatcher *m_resolutionWatcher = nullptr; QMutex m_previewMutex;