diff --git a/CMakeLists.txt b/CMakeLists.txt index b751811..fde18a4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(HDMIViewer VERSION 1.3.2 LANGUAGES CXX) +project(HDMIViewer VERSION 1.4.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -8,7 +8,8 @@ set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) # Qt6 – Gui enthält den JPEG-Plugin-Support (QImage::fromData) -find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) +# OpenGLWidgets für das GPU-beschleunigte Video-Rendering (Bildglättung/Retro-Optik) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets OpenGL OpenGLWidgets) # ALSA find_package(PkgConfig REQUIRED) @@ -37,6 +38,8 @@ target_link_libraries(hdmi-viewer Qt6::Core Qt6::Gui Qt6::Widgets + Qt6::OpenGL + Qt6::OpenGLWidgets ${ALSA_LIBRARIES} ) diff --git a/README.md b/README.md index 124571f..cab14f2 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,32 @@ Gedacht als Companion zu OBS Studio: **Anzeigen ohne Aufnehmen**. - Titelleiste zeigt tatsächliche Auflösung und FPS - Colorspace-Konvertierung im ThreadPool (kein Ruckeln) - FPS explizit auf 60fps gesetzt (was Grabber liefert wird in Statusbar angezeigt) +- **Bildglättung** (standardmäßig an): GPU-Shader trennt Luma/Chroma und + behandelt sie getrennt – Chroma wird stärker weichgezeichnet (killt + Dot-Crawl/Farbrauschen billiger Composite→HDMI-Scaler wie Lioncast Mini + AV2HDMI o.ä.), Luma wird leicht geglättet und dezent gegengeschärft + (nimmt harte Scaler-Kanten raus, ohne matschig zu wirken). Keine Optik- + Spielerei, sondern eine echte Bildverbesserung. Taste **S** zum Umschalten. +- **Retro-Optik** (standardmäßig aus): separater Nostalgie-Look – Scanlines, + Phosphor-/Aperture-Grille-Maske, leichte Wölbung wie bei einem alten + Röhrenfernseher. Lässt sich mit der Bildglättung kombinieren. Taste **C**. +- **Helligkeit** (Tasten **,** dunkler / **.** heller): Gamma-Anpassung, live + und persistiert. Sinnvoll als Feinjustierung, falls Farbraum-Erkennung + (siehe unten) allein nicht perfekt zum eigenen Bildschirm passt. +- **Hardware-Helligkeit**: Beim Öffnen des Geräts wird (falls vom Chip + unterstützt) der V4L2-Brightness-Regler direkt am Grabber moderat + angehoben (+20 % des Wertebereichs ab Werkseinstellung) – das wirkt + bereits VOR der JPEG-Kompression und kann so tatsächlich verlorene + Schattendetails retten, statt nur nachträglich zu strecken, was von der + Kompression übrig blieb. Diagnose-Ausgabe der verfügbaren Regler landet + beim Start im Terminal. +- **Studio-/Full-Range-Korrektur**: Das fertig dekodierte Bild wird von einem + komprimierten Kontrastumfang (Schwarzpunkt niedrig angesetzt, Weißpunkt + ≈235/255) auf den vollen 0–255-Kontrastumfang gedehnt. Ohne diese + Korrektur wirkt das Bild dumpf, kontrastarm und zu dunkel – viele billige + HDMI-Grabber liefern (oder kodieren, auch bei MJPEG) genau in diesem + komprimierten Bereich. Greift direkt im GPU-Shader auf dem fertigen + RGB-Bild, also unabhängig vom Pixelformat. ## Abhängigkeiten (Arch Linux) @@ -21,6 +47,9 @@ Gedacht als Companion zu OBS Studio: **Anzeigen ohne Aufnehmen**. sudo pacman -S qt6-base cmake alsa-lib linux-headers base-devel ``` +`qt6-base` bringt auf Arch bereits die benötigten `Qt6OpenGL`- und +`Qt6OpenGLWidgets`-Module mit. + ## Bauen ```bash @@ -40,6 +69,9 @@ make -j$(nproc) | Taste | Funktion | |--------------|---------------------------------------------| | F | Vollbild ein/aus | +| S | Bildglättung ein/aus | +| C | Retro-Optik (Scanlines/Maske) ein/aus | +| , / . | Dunkler / Heller (Helligkeit, gespeichert) | | Esc | Vollbild verlassen ODER zurück zum Dialog | ## Einstellungen diff --git a/src/CaptureThread.cpp b/src/CaptureThread.cpp index dda20fc..51f6874 100644 --- a/src/CaptureThread.cpp +++ b/src/CaptureThread.cpp @@ -1,6 +1,10 @@ #include "CaptureThread.h" #include +#include +#include +#include +#include #include #include @@ -16,6 +20,48 @@ static int xioctl(int fd, unsigned long request, void* arg) return r; } +// Listet alle vom Grabber-Chip unterstützten V4L2-Regler (Helligkeit, +// Kontrast, Gain, Belichtung, ...) mit ihrem Wertebereich und aktuellen Wert +// ins Terminal. Rein diagnostisch – zeigt, ob/welche Hardware-Verstärkung +// VOR der JPEG-Kompression überhaupt ansetzbar wäre, bevor wir dafür UI +// bauen. Manche Chips unterstützen keinen dieser Regler; dann bleibt die +// Liste leer, was ebenfalls eine wichtige Information ist. +static void logAvailableControls(int fd) +{ + qDebug().noquote() << "── V4L2-Regler des Grabbers ──────────────────────"; + + v4l2_queryctrl qc{}; + qc.id = V4L2_CTRL_FLAG_NEXT_CTRL; + bool any = false; + + while (xioctl(fd, VIDIOC_QUERYCTRL, &qc) == 0) { + if (!(qc.flags & V4L2_CTRL_FLAG_DISABLED) && + qc.type != V4L2_CTRL_TYPE_CTRL_CLASS) { + any = true; + + v4l2_control ctrl{}; + ctrl.id = qc.id; + const bool gotCurrent = (xioctl(fd, VIDIOC_G_CTRL, &ctrl) == 0); + + qDebug().noquote() << QString( + " %1 (id=0x%2) min=%3 max=%4 step=%5 default=%6 aktuell=%7") + .arg(QString::fromLatin1(reinterpret_cast(qc.name)), -28) + .arg(qc.id, 0, 16) + .arg(qc.minimum) + .arg(qc.maximum) + .arg(qc.step) + .arg(qc.default_value) + .arg(gotCurrent ? QString::number(ctrl.value) : QStringLiteral("?")); + } + qc.id |= V4L2_CTRL_FLAG_NEXT_CTRL; + } + + if (!any) + qDebug().noquote() << " (keine regelbaren Controls gemeldet)"; + + qDebug().noquote() << "───────────────────────────────────────────────────"; +} + // ── YUYV → RGB888 (BT.601 fixed-point) ────────────────────────────────────── static inline quint8 clamp255(int v) @@ -40,6 +86,11 @@ void CaptureThread::stopAndWait() wait(); } +void CaptureThread::requestFrameDump() +{ + m_dumpRequested = true; +} + void CaptureThread::run() { if (!openDevice() || !initDevice() || !setFramerate() || !startCapture()) { @@ -117,6 +168,10 @@ bool CaptureThread::openDevice() emit errorOccurred(m_device + " unterstützt kein Streaming"); return false; } + + logAvailableControls(m_fd); + applyHardwareControls(); + return true; } @@ -273,14 +328,131 @@ void CaptureThread::closeDevice() m_buffers.clear(); if (m_fd != -1) { + restoreHardwareControls(); ::close(m_fd); m_fd = -1; qDebug() << "V4L2 freigegeben:" << m_device; } } +// Setzt beim Start Helligkeit/Kontrast/Sättigung des Grabber-Chips auf die +// vom Nutzer konfigurierten Werte (persistiert in QSettings, per Tasten +// oder Startdialog gesetzt) – VOR der JPEG-Kompression, was tatsächlich +// Schattendetails vor dem verlustbehafteten Encoding retten kann. Merkt +// sich dabei den JEWEILS AKTUELLEN Wert jedes Reglers (der Ausgangszustand, +// egal von wem/wann gesetzt), um ihn beim Beenden wiederherzustellen – so +// hinterlässt hdmi-viewer den Chip nicht dauerhaft verändert für andere +// Programme. Regler ohne gespeicherten Nutzerwert (settings.contains == +// false) werden gar nicht erst angefasst. +void CaptureThread::applyHardwareControls() +{ + QSettings settings; + + auto handle = [&](quint32 id, const char* key, int& restoreOut) { + v4l2_queryctrl qc{}; + qc.id = id; + if (xioctl(m_fd, VIDIOC_QUERYCTRL, &qc) == -1 || (qc.flags & V4L2_CTRL_FLAG_DISABLED)) + return; + + v4l2_control cur{}; + cur.id = id; + if (xioctl(m_fd, VIDIOC_G_CTRL, &cur) == 0) + restoreOut = cur.value; + + if (!settings.contains(key)) + return; // kein expliziter Nutzerwert -> unangetastet lassen + + const int target = qBound(qc.minimum, settings.value(key).toInt(), qc.maximum); + v4l2_control ctrl{}; + ctrl.id = id; + ctrl.value = target; + if (xioctl(m_fd, VIDIOC_S_CTRL, &ctrl) == 0) { + qDebug().noquote() << QString("%1 gesetzt: %2 (Ausgangswert %3 gemerkt)") + .arg(key).arg(target).arg(restoreOut); + } + }; + + handle(V4L2_CID_BRIGHTNESS, "hardware/brightness", m_restoreBrightness); + handle(V4L2_CID_CONTRAST, "hardware/contrast", m_restoreContrast); + handle(V4L2_CID_SATURATION, "hardware/saturation", m_restoreSaturation); +} + +void CaptureThread::restoreHardwareControls() +{ + auto restore = [&](quint32 id, int value) { + if (value < 0) return; + v4l2_control ctrl{}; + ctrl.id = id; + ctrl.value = value; + xioctl(m_fd, VIDIOC_S_CTRL, &ctrl); + }; + restore(V4L2_CID_BRIGHTNESS, m_restoreBrightness); + restore(V4L2_CID_CONTRAST, m_restoreContrast); + restore(V4L2_CID_SATURATION, m_restoreSaturation); + qDebug().noquote() << "Hardware-Regler auf Ausgangswerte zurückgesetzt."; +} + +// Für Live-Tasten (Kontrast/Sättigung): liest den aktuellen Wert, addiert +// delta, schreibt zurück, merkt sich den neuen Wert dauerhaft und meldet +// ihn per Signal (für die Einblendung in der UI). +void CaptureThread::adjustHardwareControl(quint32 id, const char* settingsKey, + const QString& label, int delta) +{ + if (m_fd == -1) return; + + v4l2_queryctrl qc{}; + qc.id = id; + if (xioctl(m_fd, VIDIOC_QUERYCTRL, &qc) == -1 || (qc.flags & V4L2_CTRL_FLAG_DISABLED)) + return; + + v4l2_control cur{}; + cur.id = id; + if (xioctl(m_fd, VIDIOC_G_CTRL, &cur) == -1) + return; + + const int next = qBound(qc.minimum, cur.value + delta, qc.maximum); + v4l2_control ctrl{}; + ctrl.id = id; + ctrl.value = next; + + if (xioctl(m_fd, VIDIOC_S_CTRL, &ctrl) == 0) { + QSettings settings; + settings.setValue(settingsKey, next); + emit hardwareControlChanged(label, next, qc.minimum, qc.maximum); + } +} + +void CaptureThread::adjustHardwareContrast(int delta) +{ + adjustHardwareControl(V4L2_CID_CONTRAST, "hardware/contrast", "Hardware-Kontrast", delta); +} + +void CaptureThread::adjustHardwareSaturation(int delta) +{ + adjustHardwareControl(V4L2_CID_SATURATION, "hardware/saturation", "Hardware-Sättigung", delta); +} + void CaptureThread::processFrame(const void* data, size_t size) { + // Diagnose: rohes MJPEG-Frame unverändert (Byte für Byte, wie vom Chip + // geliefert) auf die Platte speichern, BEVOR irgendeine Dekodierung/ + // Verarbeitung stattfindet. Lässt sich in einem unabhängigen Bildbe- + // trachter öffnen, um zu prüfen, ob die Rohdaten selbst schon dunkel + // sind oder ob die Abweichung erst bei uns beim Dekodieren entsteht. + if (m_dumpRequested.exchange(false) && m_pixelFormat == V4L2_PIX_FMT_MJPEG) { + const QString path = QDir::homePath() + "/hdmiviewer_frame_dump_" + + QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss") + ".jpg"; + QFile f(path); + if (f.open(QIODevice::WriteOnly)) { + f.write(reinterpret_cast(data), static_cast(size)); + f.close(); + qDebug().noquote() << "Rohes Frame gespeichert:" << path; + emit frameDumped(path); + } else { + qDebug().noquote() << "Frame-Dump fehlgeschlagen: Datei konnte nicht geschrieben werden."; + } + } + QImage img; switch (m_pixelFormat) { case V4L2_PIX_FMT_MJPEG: img = decodeMJPEG(data, size); break; diff --git a/src/CaptureThread.h b/src/CaptureThread.h index 375a26b..bbd0e82 100644 --- a/src/CaptureThread.h +++ b/src/CaptureThread.h @@ -23,6 +23,9 @@ public: ~CaptureThread() override; void stopAndWait(); + void requestFrameDump(); // speichert das nächste rohe MJPEG-Frame unverändert auf die Platte + void adjustHardwareContrast(int delta); + void adjustHardwareSaturation(int delta); int captureWidth() const { return m_width; } int captureHeight() const { return m_height; } @@ -32,6 +35,8 @@ signals: void frameReady(QImage image); void errorOccurred(const QString& message); void captureInfo(int width, int height, int fps, const QString& format); + void frameDumped(QString path); + void hardwareControlChanged(QString label, int value, int minVal, int maxVal); protected: void run() override; @@ -45,6 +50,9 @@ private: void closeDevice(); bool initMmap(); void processFrame(const void* data, size_t size); + void applyHardwareControls(); // beim Start: eigene Werte setzen, Ausgangswerte merken + void restoreHardwareControls(); // beim Beenden: Ausgangswerte wiederherstellen + void adjustHardwareControl(quint32 id, const char* settingsKey, const QString& label, int delta); // Dekodierung QImage decodeYUYV(const void* data) const; @@ -60,4 +68,11 @@ private: quint32 m_pixelFormat = V4L2_PIX_FMT_MJPEG; std::vector m_buffers; std::atomic m_running{false}; + std::atomic m_dumpRequested{false}; + + // Ausgangswerte der Hardware-Regler (vor unseren Änderungen), für die + // Wiederherstellung beim Beenden. -1 = nicht gelesen/nicht verfügbar. + int m_restoreBrightness = -1; + int m_restoreContrast = -1; + int m_restoreSaturation = -1; }; diff --git a/src/DeviceDialog.cpp b/src/DeviceDialog.cpp index 8409967..11ac951 100644 --- a/src/DeviceDialog.cpp +++ b/src/DeviceDialog.cpp @@ -1,9 +1,13 @@ #include "DeviceDialog.h" +#include "VideoWidget.h" // für kBrightnessMin/Max, kSharpenMin/Max (eine Quelle der Wahrheit) #include #include #include #include +#include +#include +#include #include #include #include @@ -51,11 +55,79 @@ DeviceDialog::DeviceDialog(QWidget* parent) form->addRow("Audio-Gerät (ALSA):", m_audioCombo); form->addRow("Ziel-Framerate:", m_fpsCombo); + m_smoothCheck = new QCheckBox("Bildglättung aktivieren (empfohlen)", this); + m_smoothCheck->setChecked(true); + m_smoothCheck->setToolTip( + "Reduziert live Kompressions-/Scaler-Artefakte deines HDMI-Upscalers:\n" + "Farbrauschen/Dot-Crawl wird weicher gezeichnet, harte blockige\n" + "Pixelkanten werden geglättet und dezent gegengeschärft.\n" + "Das ist keine Optik-Spielerei, sondern eine Bildverbesserung –\n" + "empfohlen für Composite-Quellen wie PS1/PS2 an billigen Scalern.\n" + "Umschaltbar im laufenden Betrieb mit [S]."); + form->addRow("", m_smoothCheck); + + m_retroCheck = new QCheckBox("Retro-Optik (Scanlines/Maske) aktivieren", this); + m_retroCheck->setToolTip( + "Rein optischer Nostalgie-Look: Scanlines, Phosphor-Maske,\n" + "leichte Wölbung wie bei einem alten Röhrenfernseher.\n" + "Lässt sich mit der Bildglättung kombinieren.\n" + "Umschaltbar im laufenden Betrieb mit [C]."); + form->addRow("", m_retroCheck); + + m_sharpenSpin = new QDoubleSpinBox(this); + m_sharpenSpin->setRange(VideoWidget::kSharpenMin, VideoWidget::kSharpenMax); + m_sharpenSpin->setSingleStep(VideoWidget::kSharpenStep); + m_sharpenSpin->setDecimals(1); + m_sharpenSpin->setValue(0.0); + m_sharpenSpin->setSpecialValueText("aus"); + m_sharpenSpin->setToolTip( + "Unsharp Mask übers gesamte Bild, unabhängig von der Bildglättung.\n" + "0 = aus. Im laufenden Betrieb mit [X]/[+]/[-] änderbar."); + form->addRow("Nachschärfen:", m_sharpenSpin); + + m_brightnessSpin = new QDoubleSpinBox(this); + m_brightnessSpin->setRange(VideoWidget::kBrightnessMin * 100.0, VideoWidget::kBrightnessMax * 100.0); + m_brightnessSpin->setSingleStep(VideoWidget::kBrightnessStep * 100.0); + m_brightnessSpin->setDecimals(0); + m_brightnessSpin->setSuffix(" %"); + m_brightnessSpin->setValue(100.0); + m_brightnessSpin->setToolTip( + "Gamma-Helligkeit, 100 % = unverändert. Im laufenden Betrieb\n" + "mit [,]/[.] änderbar."); + form->addRow("Helligkeit:", m_brightnessSpin); + + m_hwContrastSpin = new QSpinBox(this); + m_hwContrastSpin->setRange(-1, 100); + m_hwContrastSpin->setValue(-1); + m_hwContrastSpin->setSpecialValueText("unverändert"); + m_hwContrastSpin->setToolTip( + "Hardware-Kontrast direkt am Grabber-Chip, VOR der JPEG-Kompression\n" + "(Wertebereich hängt vom jeweiligen Chip ab, hier 0-100 angenommen).\n" + "\"unverändert\" lässt den aktuellen Chip-Wert einfach in Ruhe.\n" + "Im laufenden Betrieb mit [1]/[2] änderbar. hdmi-viewer merkt sich\n" + "beim Start den Ausgangswert und stellt ihn beim Beenden wieder her."); + form->addRow("Hardware-Kontrast:", m_hwContrastSpin); + + m_hwSaturationSpin = new QSpinBox(this); + m_hwSaturationSpin->setRange(-1, 100); + m_hwSaturationSpin->setValue(-1); + m_hwSaturationSpin->setSpecialValueText("unverändert"); + m_hwSaturationSpin->setToolTip( + "Hardware-Sättigung direkt am Grabber-Chip, VOR der JPEG-Kompression.\n" + "\"unverändert\" lässt den aktuellen Chip-Wert einfach in Ruhe.\n" + "Im laufenden Betrieb mit [3]/[4] änderbar. hdmi-viewer merkt sich\n" + "beim Start den Ausgangswert und stellt ihn beim Beenden wieder her."); + form->addRow("Hardware-Sättigung:", m_hwSaturationSpin); + auto* hint = new QLabel( "Hinweis: Der Grabber liefert die gewünschte FPS-Zahl nur wenn " "genug USB-Bandbreite vorhanden ist. Die tatsächliche Rate wird in der " "Titelleiste angezeigt.
" - "F = Vollbild  ·  Esc = zurück hierher
", + "F Vollbild  ·  S Glättung  ·  " + "C Retro  ·  X/+/- Schärfen  ·  " + ",/. Helligkeit  ·  1/2 HW-Kontrast  ·  " + "3/4 HW-Sättigung  ·  V Vergleich  ·  " + "P Screenshot  ·  Esc zurück hierher", this); hint->setWordWrap(true); @@ -81,10 +153,22 @@ DeviceDialog::DeviceDialog(QWidget* parent) QString DeviceDialog::selectedVideoDevice() const { return m_videoCombo->currentData().toString(); } QString DeviceDialog::selectedAudioDevice() const { return m_audioCombo->currentData().toString(); } int DeviceDialog::selectedFps() const { return m_fpsCombo->currentData().toInt(); } +bool DeviceDialog::selectedSmoothMode() const { return m_smoothCheck->isChecked(); } +bool DeviceDialog::selectedRetroMode() const { return m_retroCheck->isChecked(); } +float DeviceDialog::selectedSharpenAmount() const { return static_cast(m_sharpenSpin->value()); } +float DeviceDialog::selectedBrightness() const { return static_cast(m_brightnessSpin->value() / 100.0); } +int DeviceDialog::selectedHwContrast() const { return m_hwContrastSpin->value(); } +int DeviceDialog::selectedHwSaturation() const { return m_hwSaturationSpin->value(); } void DeviceDialog::setPreselectedVideo(const QString& device) { selectByData(m_videoCombo, device); } void DeviceDialog::setPreselectedAudio(const QString& device) { selectByData(m_audioCombo, device); } void DeviceDialog::setPreselectedFps(int fps) { selectByData(m_fpsCombo, QString::number(fps)); } +void DeviceDialog::setPreselectedSmoothMode(bool enabled) { m_smoothCheck->setChecked(enabled); } +void DeviceDialog::setPreselectedRetroMode(bool enabled) { m_retroCheck->setChecked(enabled); } +void DeviceDialog::setPreselectedSharpenAmount(float amount) { m_sharpenSpin->setValue(amount); } +void DeviceDialog::setPreselectedBrightness(float gamma) { m_brightnessSpin->setValue(gamma * 100.0); } +void DeviceDialog::setPreselectedHwContrast(int value) { m_hwContrastSpin->setValue(value); } +void DeviceDialog::setPreselectedHwSaturation(int value) { m_hwSaturationSpin->setValue(value); } void DeviceDialog::selectByData(QComboBox* combo, const QString& data) { diff --git a/src/DeviceDialog.h b/src/DeviceDialog.h index 4a9dfb7..ac13f32 100644 --- a/src/DeviceDialog.h +++ b/src/DeviceDialog.h @@ -5,6 +5,9 @@ QT_BEGIN_NAMESPACE class QComboBox; +class QCheckBox; +class QDoubleSpinBox; +class QSpinBox; QT_END_NAMESPACE class DeviceDialog : public QDialog @@ -17,17 +20,35 @@ public: QString selectedVideoDevice() const; QString selectedAudioDevice() const; int selectedFps() const; + bool selectedSmoothMode() const; + bool selectedRetroMode() const; + float selectedSharpenAmount() const; + float selectedBrightness() const; + int selectedHwContrast() const; // -1 = unverändert lassen + int selectedHwSaturation() const; // -1 = unverändert lassen void setPreselectedVideo(const QString& device); void setPreselectedAudio(const QString& device); void setPreselectedFps(int fps); + void setPreselectedSmoothMode(bool enabled); + void setPreselectedRetroMode(bool enabled); + void setPreselectedSharpenAmount(float amount); + void setPreselectedBrightness(float gamma); + void setPreselectedHwContrast(int value); // -1 = unverändert + void setPreselectedHwSaturation(int value); // -1 = unverändert private: void populateVideoDevices(); void populateAudioDevices(); void selectByData(QComboBox* combo, const QString& data); - QComboBox* m_videoCombo = nullptr; - QComboBox* m_audioCombo = nullptr; - QComboBox* m_fpsCombo = nullptr; + QComboBox* m_videoCombo = nullptr; + QComboBox* m_audioCombo = nullptr; + QComboBox* m_fpsCombo = nullptr; + QCheckBox* m_smoothCheck = nullptr; + QCheckBox* m_retroCheck = nullptr; + QDoubleSpinBox* m_sharpenSpin = nullptr; + QDoubleSpinBox* m_brightnessSpin = nullptr; + QSpinBox* m_hwContrastSpin = nullptr; + QSpinBox* m_hwSaturationSpin = nullptr; }; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 7fe20cc..980cb73 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -9,18 +9,28 @@ #include #include #include +#include #include #include #include #include #include #include +#include +#include +#include MainWindow::MainWindow(const QString& videoDevice, const QString& audioDevice, int targetFps, + bool smoothModeEnabled, + bool retroModeEnabled, + float brightness, + float sharpenAmount, + float lastSharpenAmount, QWidget* parent) : QMainWindow(parent) + , m_lastSharpenAmount(lastSharpenAmount) , m_videoDevice(videoDevice) , m_audioDevice(audioDevice) { @@ -28,8 +38,20 @@ MainWindow::MainWindow(const QString& videoDevice, resize(1280, 720); m_video = new VideoWidget(this); + m_video->setSmoothModeEnabled(smoothModeEnabled); + m_video->setRetroModeEnabled(retroModeEnabled); + m_video->setSharpenAmount(sharpenAmount); + m_video->setBrightness(brightness); setCentralWidget(m_video); + // Kurzzeitige Einblendung unten links (funktioniert auch im Vollbild, + // wo Menü- und Statusleiste ausgeblendet sind). + m_overlayLabel = new QLabel(m_video); + m_overlayLabel->setStyleSheet( + "background-color: rgba(20, 20, 20, 200); color: white;" + "padding: 6px 12px; border-radius: 4px; font-size: 13px;"); + m_overlayLabel->hide(); + setupMenuBar(); updateStatusBar(); @@ -45,6 +67,12 @@ MainWindow::MainWindow(const QString& videoDevice, connect(m_capture, &CaptureThread::captureInfo, this, &MainWindow::onCaptureInfo, Qt::QueuedConnection); + connect(m_capture, &CaptureThread::frameDumped, + this, &MainWindow::onFrameDumped, + Qt::QueuedConnection); + connect(m_capture, &CaptureThread::hardwareControlChanged, + this, &MainWindow::onHardwareControlChanged, + Qt::QueuedConnection); m_capture->start(); @@ -65,6 +93,54 @@ MainWindow::~MainWindow() void MainWindow::setupMenuBar() { + QMenu* viewMenu = menuBar()->addMenu("&Anzeige"); + + m_smoothAction = viewMenu->addAction("Bildglättung\tS"); + m_smoothAction->setCheckable(true); + m_smoothAction->setChecked(m_video->smoothModeEnabled()); + connect(m_smoothAction, &QAction::triggered, this, &MainWindow::toggleSmoothMode); + + m_retroAction = viewMenu->addAction("Retro-Optik (Scanlines/Maske)\tC"); + m_retroAction->setCheckable(true); + m_retroAction->setChecked(m_video->retroModeEnabled()); + connect(m_retroAction, &QAction::triggered, this, &MainWindow::toggleRetroMode); + + m_sharpenAction = viewMenu->addAction("Nachschärfen\tX"); + m_sharpenAction->setCheckable(true); + m_sharpenAction->setChecked(m_video->sharpenAmount() > 0.0f); + connect(m_sharpenAction, &QAction::triggered, this, &MainWindow::toggleSharpen); + + QAction* sharpenUpAct = viewMenu->addAction("Schärfen +\t+"); + connect(sharpenUpAct, &QAction::triggered, this, &MainWindow::increaseSharpen); + + QAction* sharpenDownAct = viewMenu->addAction("Schärfen -\t-"); + connect(sharpenDownAct, &QAction::triggered, this, &MainWindow::decreaseSharpen); + + viewMenu->addSeparator(); + + QAction* brighterAct = viewMenu->addAction("Heller\t."); + connect(brighterAct, &QAction::triggered, this, &MainWindow::increaseBrightness); + + QAction* darkerAct = viewMenu->addAction("Dunkler\t,"); + connect(darkerAct, &QAction::triggered, this, &MainWindow::decreaseBrightness); + + viewMenu->addSeparator(); + + QAction* rawAct = viewMenu->addAction("Roh-Modus (Diagnose)\tR"); + rawAct->setCheckable(true); + rawAct->setChecked(m_video->rawEnabled()); + connect(rawAct, &QAction::triggered, this, &MainWindow::toggleRaw); + + QAction* compareAct = viewMenu->addAction("Live-Vergleich (roh/bearbeitet)\tV"); + compareAct->setCheckable(true); + compareAct->setChecked(m_video->compareEnabled()); + connect(compareAct, &QAction::triggered, this, &MainWindow::toggleCompare); + + viewMenu->addSeparator(); + + QAction* screenshotAct = viewMenu->addAction("Screenshot speichern\tP"); + connect(screenshotAct, &QAction::triggered, this, &MainWindow::saveScreenshot); + QMenu* helpMenu = menuBar()->addMenu("&Hilfe"); QAction* aboutAct = helpMenu->addAction("Über HDMI Viewer"); @@ -72,11 +148,155 @@ void MainWindow::setupMenuBar() connect(aboutAct, &QAction::triggered, this, &MainWindow::showAbout); } +void MainWindow::showOverlayMessage(const QString& text) +{ + if (!m_overlayLabel) return; + + m_overlayLabel->setText(text); + m_overlayLabel->adjustSize(); + + const int margin = 16; + m_overlayLabel->move(margin, m_video->height() - m_overlayLabel->height() - margin); + m_overlayLabel->show(); + m_overlayLabel->raise(); + + QTimer::singleShot(1500, m_overlayLabel, &QLabel::hide); +} + +void MainWindow::toggleSmoothMode() +{ + const bool enabled = !m_video->smoothModeEnabled(); + m_video->setSmoothModeEnabled(enabled); + if (m_smoothAction) m_smoothAction->setChecked(enabled); + + QSettings settings; + settings.setValue("display/smooth", enabled); + + statusBar()->showMessage( + enabled ? "Bildglättung aktiviert" : "Bildglättung deaktiviert", 2000); + showOverlayMessage(enabled ? "Bildglättung: an" : "Bildglättung: aus"); +} + +void MainWindow::toggleRetroMode() +{ + const bool enabled = !m_video->retroModeEnabled(); + m_video->setRetroModeEnabled(enabled); + if (m_retroAction) m_retroAction->setChecked(enabled); + + QSettings settings; + settings.setValue("display/retro", enabled); + + statusBar()->showMessage( + enabled ? "Retro-Optik aktiviert" : "Retro-Optik deaktiviert", 2000); + showOverlayMessage(enabled ? "Retro-Optik: an" : "Retro-Optik: aus"); +} + +void MainWindow::toggleSharpen() +{ + // Quick-Toggle: aus (0) ↔ zuletzt benutzter Wert (nicht der feste + // Standardwert!) – merkt sich also z. B. 7.0, nicht immer 2.5. + if (m_video->sharpenAmount() > 0.0f) { + m_lastSharpenAmount = m_video->sharpenAmount(); + applySharpen(0.0f); + } else { + applySharpen(m_lastSharpenAmount > 0.0f ? m_lastSharpenAmount : VideoWidget::kSharpenDefault); + } +} + +void MainWindow::increaseSharpen() +{ + applySharpen(m_video->sharpenAmount() + VideoWidget::kSharpenStep); +} + +void MainWindow::decreaseSharpen() +{ + applySharpen(m_video->sharpenAmount() - VideoWidget::kSharpenStep); +} + +void MainWindow::applySharpen(float value) +{ + value = qBound(VideoWidget::kSharpenMin, value, VideoWidget::kSharpenMax); + m_video->setSharpenAmount(value); + if (m_sharpenAction) m_sharpenAction->setChecked(value > 0.0f); + + QSettings settings; + settings.setValue("display/sharpen", static_cast(value)); + if (value > 0.0f) { + m_lastSharpenAmount = value; + settings.setValue("display/sharpen_last", static_cast(value)); + } + + statusBar()->showMessage(QString("Nachschärfen: %1").arg(value, 0, 'f', 1), 2000); + showOverlayMessage(QString("Nachschärfen: %1").arg(value, 0, 'f', 1)); +} + +void MainWindow::increaseBrightness() +{ + applyBrightness(m_video->brightness() + VideoWidget::kBrightnessStep); +} + +void MainWindow::decreaseBrightness() +{ + applyBrightness(m_video->brightness() - VideoWidget::kBrightnessStep); +} + +void MainWindow::toggleRaw() +{ + const bool enabled = !m_video->rawEnabled(); + m_video->setRawEnabled(enabled); + + const QString msg = enabled ? "Roh-Modus: an (alle Effekte umgangen)" : "Roh-Modus: aus"; + statusBar()->showMessage(msg, 2000); + showOverlayMessage(msg); +} + +void MainWindow::toggleCompare() +{ + const bool enabled = !m_video->compareEnabled(); + m_video->setCompareEnabled(enabled); + + const QString msg = enabled ? "Vergleich: an (links roh, rechts bearbeitet)" : "Vergleich: aus"; + statusBar()->showMessage(msg, 2000); + showOverlayMessage(msg); +} + +void MainWindow::saveScreenshot() +{ + const QImage img = m_video->grabProcessedFrame(); + if (img.isNull()) { + statusBar()->showMessage("Screenshot fehlgeschlagen (kein Bild)", 3000); + return; + } + + const QString path = QDir::homePath() + "/hdmiviewer_screenshot_" + + QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss") + ".png"; + + if (img.save(path, "PNG")) { + statusBar()->showMessage("Screenshot gespeichert: " + path, 5000); + showOverlayMessage("Screenshot gespeichert"); + } else { + statusBar()->showMessage("Screenshot konnte nicht gespeichert werden: " + path, 5000); + } +} + +void MainWindow::applyBrightness(float value) +{ + value = qBound(VideoWidget::kBrightnessMin, value, VideoWidget::kBrightnessMax); + m_video->setBrightness(value); + + QSettings settings; + settings.setValue("display/brightness", static_cast(value)); + + statusBar()->showMessage( + QString("Helligkeit: %1 %").arg(qRound(value * 100.0f)), 2000); + showOverlayMessage(QString("Helligkeit: %1 %").arg(qRound(value * 100.0f))); +} + void MainWindow::showAbout() { auto* dlg = new QDialog(this); dlg->setWindowTitle("Über HDMI Viewer"); - dlg->setFixedSize(420, 340); + dlg->setFixedSize(460, 470); dlg->setModal(true); auto* root = new QVBoxLayout(dlg); @@ -125,26 +345,37 @@ void MainWindow::showAbout() grid->addWidget(v, row, 1); }; - addRow(0, "Version", "1.3.0"); + addRow(0, "Version", "1.4.0"); addRow(1, "Entwickler", "Dany Thinnes"); addRow(2, "Projekt", "Projekt Hirnfrei"); addRow(3, "Framework", QString("Qt %1").arg(QT_VERSION_STR)); addRow(4, "Sprache", "C++17"); addRow(5, "Video-API", "V4L2 · MJPEG / YUYV / NV12"); addRow(6, "Audio-API", "ALSA"); + addRow(7, "Rendering", "OpenGL 3.3 · Bildglättung / Retro-Optik"); // ── Tastenkürzel-Hinweis ────────────────────────────────────────────────── auto* sep2 = new QFrame(body); sep2->setFrameShape(QFrame::HLine); sep2->setStyleSheet("color: #313244;"); - grid->addWidget(sep2, 7, 0, 1, 2); + grid->addWidget(sep2, 8, 0, 1, 2); auto* keysLabel = new QLabel( - "F  Vollbild ein/aus    " - "Esc  Zurück zu Einstellungen", body); + "F Vollbild    " + "S Glättung    " + "C Retro    " + "X/+/- Schärfen    " + ",/. Helligkeit
" + "1/2 HW-Kontrast    " + "3/4 HW-Sättigung    " + "R Roh-Diagnose
" + "V Vergleich    " + "P Screenshot    " + "D Frame dumpen    " + "Esc Zurück
", body); keysLabel->setStyleSheet("color: #6c7086;"); keysLabel->setAlignment(Qt::AlignCenter); - grid->addWidget(keysLabel, 8, 0, 1, 2); + grid->addWidget(keysLabel, 9, 0, 1, 2); // ── Schließen-Button ────────────────────────────────────────────────────── auto* btnRow = new QHBoxLayout; @@ -194,6 +425,52 @@ void MainWindow::keyPressEvent(QKeyEvent* event) close(); } break; + case Qt::Key_S: + toggleSmoothMode(); + break; + case Qt::Key_C: + toggleRetroMode(); + break; + case Qt::Key_X: + toggleSharpen(); + break; + case Qt::Key_Plus: + case Qt::Key_Equal: // '+' liegt auf vielen Layouts ohne Shift auf dieser Taste + increaseSharpen(); + break; + case Qt::Key_Minus: + decreaseSharpen(); + break; + case Qt::Key_Period: + increaseBrightness(); + break; + case Qt::Key_Comma: + decreaseBrightness(); + break; + case Qt::Key_R: + toggleRaw(); + break; + case Qt::Key_D: + if (m_capture) m_capture->requestFrameDump(); + break; + case Qt::Key_V: + toggleCompare(); + break; + case Qt::Key_P: + saveScreenshot(); + break; + case Qt::Key_1: + if (m_capture) m_capture->adjustHardwareContrast(-5); + break; + case Qt::Key_2: + if (m_capture) m_capture->adjustHardwareContrast(5); + break; + case Qt::Key_3: + if (m_capture) m_capture->adjustHardwareSaturation(-5); + break; + case Qt::Key_4: + if (m_capture) m_capture->adjustHardwareSaturation(5); + break; default: QMainWindow::keyPressEvent(event); } @@ -226,7 +503,7 @@ void MainWindow::updateStatusBar() info = QString("%1×%2 @ %3fps %4 ") .arg(m_capWidth).arg(m_capHeight).arg(m_capFps).arg(m_capFmt); - info += QString("Video: %1 Audio: %2 [F] Vollbild [Esc] Einstellungen") + info += QString("Video: %1 Audio: %2 [F] Vollbild [S] Glättung [C] Retro [X/+/-] Schärfen [,/.] Helligkeit [1/2] HW-Kontrast [3/4] HW-Sättigung [R] Roh [V] Vergleich [P] Screenshot [D] Frame dumpen [Esc] Einstellungen") .arg(m_videoDevice, m_audioDevice.isEmpty() ? QStringLiteral("–") : m_audioDevice); @@ -244,6 +521,20 @@ void MainWindow::onCaptureInfo(int width, int height, int fps, const QString& fo .arg(width).arg(height).arg(fps).arg(format)); } +void MainWindow::onFrameDumped(const QString& path) +{ + const QString msg = "Frame gespeichert: " + path; + statusBar()->showMessage(msg, 5000); + showOverlayMessage("Frame gespeichert (siehe Home-Ordner)"); +} + +void MainWindow::onHardwareControlChanged(const QString& label, int value, int minVal, int maxVal) +{ + const QString msg = QString("%1: %2 (%3..%4)").arg(label).arg(value).arg(minVal).arg(maxVal); + statusBar()->showMessage(msg, 2000); + showOverlayMessage(msg); +} + void MainWindow::onError(const QString& msg) { statusBar()->showMessage("⚠ " + msg, 8000); diff --git a/src/MainWindow.h b/src/MainWindow.h index 2290c16..07ca98e 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -6,6 +6,10 @@ class VideoWidget; class CaptureThread; class AudioThread; +QT_BEGIN_NAMESPACE +class QAction; +class QLabel; +QT_END_NAMESPACE class MainWindow : public QMainWindow { @@ -15,6 +19,11 @@ public: MainWindow(const QString& videoDevice, const QString& audioDevice, int targetFps, + bool smoothModeEnabled = true, + bool retroModeEnabled = false, + float brightness = 1.0f, + float sharpenAmount = 0.0f, + float lastSharpenAmount = 2.5f, QWidget* parent = nullptr); ~MainWindow() override; @@ -27,17 +36,37 @@ protected: private slots: void onError(const QString& msg); void onCaptureInfo(int width, int height, int fps, const QString& format); + void onFrameDumped(const QString& path); + void onHardwareControlChanged(const QString& label, int value, int minVal, int maxVal); void showAbout(); + void toggleSmoothMode(); + void toggleRetroMode(); + void toggleSharpen(); + void increaseSharpen(); + void decreaseSharpen(); + void increaseBrightness(); + void decreaseBrightness(); + void toggleRaw(); + void toggleCompare(); + void saveScreenshot(); private: void setupMenuBar(); void toggleFullscreen(); void stopAll(); void updateStatusBar(); + void applyBrightness(float value); + void applySharpen(float value); + void showOverlayMessage(const QString& text); - VideoWidget* m_video = nullptr; - CaptureThread* m_capture = nullptr; - AudioThread* m_audio = nullptr; + VideoWidget* m_video = nullptr; + CaptureThread* m_capture = nullptr; + AudioThread* m_audio = nullptr; + QAction* m_smoothAction = nullptr; + QAction* m_retroAction = nullptr; + QAction* m_sharpenAction = nullptr; + QLabel* m_overlayLabel = nullptr; + float m_lastSharpenAmount = 2.5f; // für Taste X: Wert vorm letzten Ausschalten QString m_videoDevice; QString m_audioDevice; diff --git a/src/VideoWidget.cpp b/src/VideoWidget.cpp index f0a9a6d..ed8f357 100644 --- a/src/VideoWidget.cpp +++ b/src/VideoWidget.cpp @@ -1,45 +1,450 @@ #include "VideoWidget.h" +#include +#include #include -#include +#include + +namespace { + +// Unit-Quad in NDC, per Vertex-Shader auf die gewünschte Letterbox-Größe +// skaliert. Texturkoordinaten so gewählt, dass QImage-Zeile 0 (oben) auf +// v=0 (oben im Bild) landet. +const float kQuadVerts[] = { + // x, y, u, v + -1.0f, 1.0f, 0.0f, 0.0f, + -1.0f, -1.0f, 0.0f, 1.0f, + 1.0f, 1.0f, 1.0f, 0.0f, + 1.0f, -1.0f, 1.0f, 1.0f, +}; + +const char* kVertexShaderSrc = R"GLSL( +#version 330 core +layout(location = 0) in vec2 a_pos; +layout(location = 1) in vec2 a_tex; + +uniform vec2 u_scale; +uniform vec2 u_offset; + +out vec2 v_tex; + +void main() +{ + vec2 pos = a_pos * u_scale + u_offset; + gl_Position = vec4(pos, 0.0, 1.0); + v_tex = a_tex; +} +)GLSL"; + +// Zwei unabhängige, kombinierbare Effekte: +// +// 1) Bildglättung (u_smooth) – die eigentliche Qualitätsverbesserung: trennt +// Luma/Chroma (wie ein Composite-Signal es tut) und behandelt sie +// unterschiedlich. Chroma wird stärker weichgezeichnet, weil billige +// Composite→HDMI-Chips dort Dot-Crawl/Farbrauschen produzieren – das Auge +// ist für Farbdetails ohnehin unempfindlicher. Luma wird nur leicht +// geglättet (nimmt harte, blockige Scaler-Kanten raus) und danach dezent +// gegengeschärft, damit das Bild nicht matschig wirkt. Kein Nostalgie- +// Effekt, sondern Rekonstruktion in Richtung dessen, was ein Röhrenbild +// ohnehin automatisch getan hätte. +// +// 2) Retro-Optik (u_retro) – der Nostalgie-Look von vorhin: Scanlines, +// Aperture-Grille-Maske, leichte Wölbung, Vignette. Rein optisch, wird +// nach der Glättung obendrauf gelegt, falls gewünscht. +const char* kFragmentShaderSrc = R"GLSL( +#version 330 core +in vec2 v_tex; +out vec4 fragColor; + +uniform sampler2D u_tex; +uniform vec2 u_texSize; +uniform int u_smooth; +uniform int u_retro; +uniform float u_sharpen; +uniform float u_gamma; +uniform int u_raw; +uniform int u_compare; + +vec3 rgb2ycbcr(vec3 c) +{ + float y = dot(c, vec3(0.299, 0.587, 0.114)); + float cb = dot(c, vec3(-0.168736, -0.331264, 0.5)) + 0.5; + float cr = dot(c, vec3(0.5, -0.418688, -0.081312)) + 0.5; + return vec3(y, cb, cr); +} + +vec3 ycbcr2rgb(vec3 c) +{ + float y = c.x; + float cb = c.y - 0.5; + float cr = c.z - 0.5; + return vec3( + y + 1.402 * cr, + y - 0.344136 * cb - 0.714136 * cr, + y + 1.772 * cb + ); +} + +// Rekonstruiert Luma und Chroma getrennt mit einem echten, breiten 2D- +// Tiefpass (Radius 4 Texel statt vorher 1-2). Bei 1920×1080 ist ein +// 1-2-Texel-Radius schlicht zu schmal, um überhaupt wahrnehmbar zu sein – +// und JPEG-Blockartefakte sitzen im 8×8-Pixel-Raster, das ein so schmaler +// Filter gar nicht berührt. Radius 4 ist die minimal sinnvolle Größe. +vec3 reconstruct(vec2 uv) +{ + vec2 tx = vec2(1.0 / max(u_texSize.x, 1.0), 0.0); + vec2 ty = vec2(0.0, 1.0 / max(u_texSize.y, 1.0)); + + vec3 center = rgb2ycbcr(texture(u_tex, uv).rgb); + + // Symmetrischer 9-Tap-Kern (Radius 4), auf 1.0 normiert + float w0 = 0.20, w1 = 0.16, w2 = 0.12, w3 = 0.08, w4 = 0.04; + + vec3 blurH = center * w0; + blurH += rgb2ycbcr(texture(u_tex, uv - 1.0*tx).rgb) * w1; + blurH += rgb2ycbcr(texture(u_tex, uv + 1.0*tx).rgb) * w1; + blurH += rgb2ycbcr(texture(u_tex, uv - 2.0*tx).rgb) * w2; + blurH += rgb2ycbcr(texture(u_tex, uv + 2.0*tx).rgb) * w2; + blurH += rgb2ycbcr(texture(u_tex, uv - 3.0*tx).rgb) * w3; + blurH += rgb2ycbcr(texture(u_tex, uv + 3.0*tx).rgb) * w3; + blurH += rgb2ycbcr(texture(u_tex, uv - 4.0*tx).rgb) * w4; + blurH += rgb2ycbcr(texture(u_tex, uv + 4.0*tx).rgb) * w4; + + vec3 blurV = center * w0; + blurV += rgb2ycbcr(texture(u_tex, uv - 1.0*ty).rgb) * w1; + blurV += rgb2ycbcr(texture(u_tex, uv + 1.0*ty).rgb) * w1; + blurV += rgb2ycbcr(texture(u_tex, uv - 2.0*ty).rgb) * w2; + blurV += rgb2ycbcr(texture(u_tex, uv + 2.0*ty).rgb) * w2; + blurV += rgb2ycbcr(texture(u_tex, uv - 3.0*ty).rgb) * w3; + blurV += rgb2ycbcr(texture(u_tex, uv + 3.0*ty).rgb) * w3; + blurV += rgb2ycbcr(texture(u_tex, uv - 4.0*ty).rgb) * w4; + blurV += rgb2ycbcr(texture(u_tex, uv + 4.0*ty).rgb) * w4; + + vec3 blur2D = (blurH + blurV) * 0.5; + + // Adaptive Stärke: dunkle Bereiche stärker (Kompressionsrauschen), aber + // die Basis liegt jetzt nahe 100% reinem Blur – KEIN interner + // Gegenschärfen-Anteil mehr (der hat vorher einen Großteil des Effekts + // wieder zurückgenommen, ohne dass es nach außen sichtbar war). Schärfen + // ist jetzt ausschließlich Aufgabe von sharpen()/Taste X. + float darkFactor = clamp(1.0 - center.x / 0.3, 0.0, 1.0); + float lumaMixAmt = mix(0.92, 0.99, darkFactor); + float chromaMixAmt = mix(0.95, 0.99, darkFactor); + + float lumaOut = mix(center.x, blur2D.x, lumaMixAmt); + vec2 chromaOut = mix(center.yz, blur2D.yz, chromaMixAmt); + + return ycbcr2rgb(vec3(lumaOut, chromaOut)); +} + +// Nachschärfen (Unsharp Mask) übers gesamte Bild, unabhängig von Glättung/ +// Retro-Optik. Stärke kommt jetzt vollständig aus u_sharpen (per Taste +/- +// live einstellbar), damit sich die Wirkschwelle experimentell finden lässt. +vec3 sharpen(vec2 uv, vec3 baseColor, float amount) +{ + vec2 tx = vec2(1.0 / max(u_texSize.x, 1.0), 0.0); + vec2 ty = vec2(0.0, 1.0 / max(u_texSize.y, 1.0)); + + vec3 raw = texture(u_tex, uv).rgb; + vec3 blur = raw * 0.20; + blur += (texture(u_tex, uv - tx).rgb + texture(u_tex, uv + tx).rgb + + texture(u_tex, uv - ty).rgb + texture(u_tex, uv + ty).rgb) * 0.10; + blur += (texture(u_tex, uv - 2.0*tx).rgb + texture(u_tex, uv + 2.0*tx).rgb + + texture(u_tex, uv - 2.0*ty).rgb + texture(u_tex, uv + 2.0*ty).rgb) * 0.10; + + return baseColor + amount * (raw - blur); +} + +void main() +{ + vec2 uv = v_tex; + + // Diagnose: komplette Umgehung aller Verarbeitung, 1:1 wie im Speicher. + if (u_raw == 1) { + fragColor = vec4(texture(u_tex, uv).rgb, 1.0); + return; + } + + float r2 = 0.0; + + if (u_retro == 1) { + vec2 c = uv * 2.0 - 1.0; + r2 = dot(c, c); + vec2 warped = c * (1.0 + 0.035 * r2); + uv = warped * 0.5 + 0.5; + if (uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { + fragColor = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + } + + vec3 color = (u_smooth == 1) ? reconstruct(uv) : texture(u_tex, uv).rgb; + + if (u_retro == 1) { + float lines = 240.0; + float linePhase = fract(uv.y * lines) * 2.0 - 1.0; + float scan = 1.0 - 0.28 * (1.0 - linePhase * linePhase); + color *= scan; + + float maskX = mod(gl_FragCoord.x, 3.0); + vec3 mask = vec3(0.82); + if (maskX < 1.0) mask.r = 1.08; + else if (maskX < 2.0) mask.g = 1.08; + else mask.b = 1.08; + color *= mask; + + float vig = clamp(1.0 - 0.22 * r2, 0.0, 1.0); + color *= vig; + color *= 1.18; + } + + // Signalbereich-Korrektur: viele billige Grabber/Scaler liefern effektiv + // nur Studio-/Broadcast-Range statt vollem 0–255-Kontrastumfang – + // unabhängig vom Pixelformat, betrifft also auch MJPEG (wo eine reine + // YUV-seitige Korrektur nicht greifen würde). Schwarzpunkt bewusst nahe + // 0, damit möglichst viel Schattendetail erhalten bleibt – das adaptive + // Rauschunterdrücken oben in reconstruct() fängt das dabei entstehende + // Rauschen in genau diesen dunklen Bereichen gezielt ab. + const float kBlack = 1.0 / 255.0; + const float kWhite = 235.0 / 255.0; + color = clamp((color - kBlack) / (kWhite - kBlack), 0.0, 1.0); + + if (u_sharpen > 0.0) { + color = sharpen(uv, color, u_sharpen); + } + + // Helligkeit: Gamma-Anpassung NUR auf Luma (nicht pro RGB-Kanal einzeln!) + // – sonst verschiebt sich bei verrauschten/dunklen Pixeln der Farbton, + // und Kompressionsrauschen wird bunt statt nur heller. + vec3 ycc = rgb2ycbcr(color); + ycc.x = pow(max(ycc.x, 0.0), 1.0 / u_gamma); + + // Kontrast-Punch: leichte S-Kurve um die Mitte, deutlich zurückhaltender + // als vorher (war 1.12) – sattere Schwarz-/Hellwerte, ohne bei dunklen + // Szenen spürbar Helligkeit zu kosten. + ycc.x = clamp((ycc.x - 0.5) * 1.05 + 0.5, 0.0, 1.0); + float targetY = ycc.x; + + // Sättigung: etwas kräftiger als roh, aber ebenfalls zurückhaltender + // (war 1.35). Wichtiger: nach der Sättigungsanhebung wird die + // tatsächliche Helligkeit der resultierenden Farbe gemessen und wieder + // auf targetY zurückskaliert. Grund: Die YCbCr→RGB-Formel zieht den + // Grün-Kanal bei der Rückrechnung von Cb/Cr ab – bei warmen/orangenen + // Szenen (starkes Cr) senkt eine Sättigungsanhebung dadurch spürbar die + // wahrgenommene Helligkeit, obwohl Y unverändert blieb. Diese + // Renormierung verhindert genau dieses szenenabhängige Abdunkeln. + ycc.y = clamp((ycc.y - 0.5) * 1.15 + 0.5, 0.0, 1.0); + ycc.z = clamp((ycc.z - 0.5) * 1.15 + 0.5, 0.0, 1.0); + + color = ycbcr2rgb(ycc); + float actualY = dot(color, vec3(0.299, 0.587, 0.114)); + if (actualY > 0.001) { + color *= targetY / actualY; + } + + // Live-Vergleich: linke Bildhälfte roh, rechte Hälfte komplett bearbeitet + // (mit allen aktuell aktiven Reglern) – zum direkten Abgleich beim + // Fein-Tunen. Nutzt bewusst v_tex (unverzerrte Original-Koordinate), + // nicht das ggf. durch Retro-Optik gewölbte uv, damit die rohe Seite + // wirklich unverändert bleibt. + if (u_compare == 1) { + vec3 rawColor = texture(u_tex, v_tex).rgb; + if (v_tex.x < 0.5) { + color = rawColor; + } + if (abs(v_tex.x - 0.5) < 0.0015) { + color = vec3(1.0, 0.9, 0.1); // gelbe Trennlinie + } + } + + fragColor = vec4(clamp(color, 0.0, 1.0), 1.0); +} +)GLSL"; + +} // namespace VideoWidget::VideoWidget(QWidget* parent) - : QWidget(parent) + : QOpenGLWidget(parent) { setMinimumSize(640, 360); - // Schwarzer Hintergrund - QPalette pal = palette(); - pal.setColor(QPalette::Window, Qt::black); - setPalette(pal); - setAutoFillBackground(true); +} + +VideoWidget::~VideoWidget() +{ + makeCurrent(); + delete m_texture; + m_texture = nullptr; + if (m_vbo) { glDeleteBuffers(1, &m_vbo); m_vbo = 0; } + if (m_vao) { glDeleteVertexArrays(1, &m_vao); m_vao = 0; } + delete m_program; + m_program = nullptr; + doneCurrent(); } void VideoWidget::setFrame(const QImage& image) { if (image.isNull()) return; - m_pixmap = QPixmap::fromImage(image); + m_pendingImage = image; + m_pendingDirty = true; update(); } -void VideoWidget::paintEvent(QPaintEvent* /*event*/) +void VideoWidget::setSmoothModeEnabled(bool enabled) +{ + if (m_smoothEnabled == enabled) return; + m_smoothEnabled = enabled; + update(); +} + +void VideoWidget::setRetroModeEnabled(bool enabled) +{ + if (m_retroEnabled == enabled) return; + m_retroEnabled = enabled; + update(); +} + +void VideoWidget::setSharpenAmount(float amount) +{ + amount = qBound(kSharpenMin, amount, kSharpenMax); + if (qFuzzyCompare(m_sharpenAmount, amount)) return; + m_sharpenAmount = amount; + update(); +} + +void VideoWidget::setRawEnabled(bool enabled) +{ + if (m_rawEnabled == enabled) return; + m_rawEnabled = enabled; + update(); +} + +void VideoWidget::setCompareEnabled(bool enabled) +{ + if (m_compareEnabled == enabled) return; + m_compareEnabled = enabled; + update(); +} + +void VideoWidget::setBrightness(float gamma) +{ + gamma = qBound(kBrightnessMin, gamma, kBrightnessMax); + if (qFuzzyCompare(m_brightness, gamma)) return; + m_brightness = gamma; + update(); +} + +void VideoWidget::initializeGL() +{ + initializeOpenGLFunctions(); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + + m_program = new QOpenGLShaderProgram(this); + m_program->addShaderFromSourceCode(QOpenGLShader::Vertex, kVertexShaderSrc); + m_program->addShaderFromSourceCode(QOpenGLShader::Fragment, kFragmentShaderSrc); + m_program->link(); + + glGenVertexArrays(1, &m_vao); + glBindVertexArray(m_vao); + + glGenBuffers(1, &m_vbo); + glBindBuffer(GL_ARRAY_BUFFER, m_vbo); + glBufferData(GL_ARRAY_BUFFER, sizeof(kQuadVerts), kQuadVerts, GL_STATIC_DRAW); + + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), reinterpret_cast(0)); + glEnableVertexAttribArray(1); + glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), + reinterpret_cast(2 * sizeof(float))); + + glBindVertexArray(0); +} + +void VideoWidget::resizeGL(int w, int h) +{ + glViewport(0, 0, w, h); +} + +void VideoWidget::uploadPendingFrame() +{ + if (!m_pendingDirty || m_pendingImage.isNull()) + return; + + const QImage rgba = m_pendingImage.convertToFormat(QImage::Format_RGBA8888); + + if (!m_texture || m_texture->width() != rgba.width() || m_texture->height() != rgba.height()) { + delete m_texture; + m_texture = new QOpenGLTexture(QOpenGLTexture::Target2D); + m_texture->create(); + m_texture->setFormat(QOpenGLTexture::RGBA8_UNorm); + m_texture->setSize(rgba.width(), rgba.height()); + m_texture->allocateStorage(QOpenGLTexture::RGBA, QOpenGLTexture::UInt8); + m_texture->setMinificationFilter(QOpenGLTexture::Linear); + m_texture->setMagnificationFilter(QOpenGLTexture::Linear); + m_texture->setWrapMode(QOpenGLTexture::ClampToEdge); + } + + m_texture->setData(QOpenGLTexture::RGBA, QOpenGLTexture::UInt8, rgba.constBits()); + + m_imageSize = rgba.size(); + m_hasFrame = true; + m_pendingDirty = false; +} + +void VideoWidget::drawNoSignal() { QPainter p(this); - p.setRenderHint(QPainter::SmoothPixmapTransform); + p.setPen(Qt::darkGray); + p.drawText(rect(), Qt::AlignCenter, "Kein Signal"); +} - if (m_pixmap.isNull()) { - p.fillRect(rect(), Qt::black); - p.setPen(Qt::darkGray); - p.drawText(rect(), Qt::AlignCenter, "Kein Signal"); +void VideoWidget::paintGL() +{ + uploadPendingFrame(); + + glClear(GL_COLOR_BUFFER_BIT); + + if (!m_hasFrame || !m_texture) { + drawNoSignal(); return; } - // Seitenverhältnis bewahren (letterbox/pillarbox) - QRect target = rect(); - QSize scaled = m_pixmap.size().scaled(target.size(), Qt::KeepAspectRatio); - QPoint topLeft( - target.x() + (target.width() - scaled.width()) / 2, - target.y() + (target.height() - scaled.height()) / 2 - ); - p.fillRect(rect(), Qt::black); - p.drawPixmap(QRect(topLeft, scaled), m_pixmap, m_pixmap.rect()); + const float viewW = static_cast(width()); + const float viewH = static_cast(height()); + const float imgW = static_cast(m_imageSize.width()); + const float imgH = static_cast(m_imageSize.height()); + + float scaleX = 1.0f; + float scaleY = 1.0f; + if (imgW > 0.0f && imgH > 0.0f && viewW > 0.0f && viewH > 0.0f) { + const float imageAspect = imgW / imgH; + const float viewAspect = viewW / viewH; + if (imageAspect > viewAspect) { + scaleX = 1.0f; + scaleY = viewAspect / imageAspect; + } else { + scaleY = 1.0f; + scaleX = imageAspect / viewAspect; + } + } + + m_program->bind(); + glBindVertexArray(m_vao); + m_texture->bind(0); + + m_program->setUniformValue("u_scale", QVector2D(scaleX, scaleY)); + m_program->setUniformValue("u_offset", QVector2D(0.0f, 0.0f)); + m_program->setUniformValue("u_tex", 0); + m_program->setUniformValue("u_texSize", QVector2D(imgW, imgH)); + m_program->setUniformValue("u_smooth", m_smoothEnabled ? 1 : 0); + m_program->setUniformValue("u_retro", m_retroEnabled ? 1 : 0); + m_program->setUniformValue("u_sharpen", m_sharpenAmount); + m_program->setUniformValue("u_gamma", m_brightness); + m_program->setUniformValue("u_raw", m_rawEnabled ? 1 : 0); + m_program->setUniformValue("u_compare", m_compareEnabled ? 1 : 0); + + glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); + + m_texture->release(); + glBindVertexArray(0); + m_program->release(); } diff --git a/src/VideoWidget.h b/src/VideoWidget.h index 83610dd..05c601c 100644 --- a/src/VideoWidget.h +++ b/src/VideoWidget.h @@ -1,22 +1,79 @@ #pragma once -#include +#include +#include #include -#include +#include -class VideoWidget : public QWidget +QT_BEGIN_NAMESPACE +class QOpenGLShaderProgram; +class QOpenGLTexture; +QT_END_NAMESPACE + +// VideoWidget zeichnet das eingehende Frame per OpenGL (statt QPainter/CPU). +// Zwei unabhängige Effekte können live per Shader zugeschaltet werden: +// Bildglättung (Chroma/Luma-Rekonstruktion gegen Scaler-Artefakte, echte +// Bildverbesserung) und Retro-Optik (Scanlines/Maske/Wölbung, Nostalgie-Look). +// QOpenGLExtraFunctions (statt nur QOpenGLFunctions) wird benötigt, weil +// Vertex-Array-Objects (glGenVertexArrays & Co.) nicht Teil des reinen +// ES2-kompatiblen QOpenGLFunctions-Subsets sind. +class VideoWidget : public QOpenGLWidget, protected QOpenGLExtraFunctions { Q_OBJECT public: explicit VideoWidget(QWidget* parent = nullptr); + ~VideoWidget() override; public slots: void setFrame(const QImage& image); + void setSmoothModeEnabled(bool enabled); + void setRetroModeEnabled(bool enabled); + void setSharpenAmount(float amount); + void setBrightness(float gamma); + void setRawEnabled(bool enabled); + void setCompareEnabled(bool enabled); + bool smoothModeEnabled() const { return m_smoothEnabled; } + bool retroModeEnabled() const { return m_retroEnabled; } + float sharpenAmount() const { return m_sharpenAmount; } + float brightness() const { return m_brightness; } + bool rawEnabled() const { return m_rawEnabled; } + bool compareEnabled() const { return m_compareEnabled; } + QImage grabProcessedFrame() { return grabFramebuffer(); } protected: - void paintEvent(QPaintEvent* event) override; + void initializeGL() override; + void resizeGL(int w, int h) override; + void paintGL() override; private: - QPixmap m_pixmap; + void uploadPendingFrame(); + void drawNoSignal(); + + QOpenGLShaderProgram* m_program = nullptr; + QOpenGLTexture* m_texture = nullptr; + + unsigned int m_vbo = 0; + unsigned int m_vao = 0; + + QImage m_pendingImage; // Wartet auf Upload im GL-Thread (paintGL) + bool m_pendingDirty = false; + + QSize m_imageSize; // Auflösung des zuletzt hochgeladenen Frames + bool m_hasFrame = false; + bool m_smoothEnabled = true; // Bildglättung: Chroma/Luma-Rekonstruktion gegen Scaler-Artefakte + bool m_retroEnabled = false; // Retro-Optik: Scanlines/Maske/Wölbung (Nostalgie-Look) + float m_sharpenAmount = 0.0f; // Nachschärfen (Unsharp Mask): 0 = aus + float m_brightness = 1.0f; // Gamma-Faktor: 1.0 = neutral, >1 heller, <1 dunkler + bool m_rawEnabled = false; // Diagnose: umgeht ALLES (Studio-Range, Kontrast, Sättigung, Gamma) + bool m_compareEnabled = false; // Live-Vergleich: links roh, rechts bearbeitet + +public: + static constexpr float kBrightnessMin = 0.4f; + static constexpr float kBrightnessMax = 3.2f; + static constexpr float kBrightnessStep = 0.1f; + static constexpr float kSharpenMin = 0.0f; + static constexpr float kSharpenMax = 20.0f; + static constexpr float kSharpenStep = 0.5f; + static constexpr float kSharpenDefault = 2.5f; // Wert für Taste X (Quick-Toggle) }; diff --git a/src/main.cpp b/src/main.cpp index 52c30b2..2c15779 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,15 +1,24 @@ #include #include #include +#include #include "DeviceDialog.h" #include "MainWindow.h" int main(int argc, char* argv[]) { + // OpenGL 3.3 Core für die Bildglättungs-/Retro-Optik-Shader in VideoWidget. + // Muss vor der QApplication gesetzt werden. + QSurfaceFormat fmt; + fmt.setVersion(3, 3); + fmt.setProfile(QSurfaceFormat::CoreProfile); + fmt.setSwapInterval(1); + QSurfaceFormat::setDefaultFormat(fmt); + QApplication app(argc, argv); app.setApplicationName("HDMIViewer"); - app.setApplicationVersion("1.2"); + app.setApplicationVersion("1.4.0"); app.setOrganizationName("hdmi-viewer"); QSettings settings; @@ -19,6 +28,12 @@ int main(int argc, char* argv[]) dlg.setPreselectedVideo(settings.value("device/video").toString()); dlg.setPreselectedAudio(settings.value("device/audio", "default").toString()); dlg.setPreselectedFps(settings.value("device/fps", 30).toInt()); + dlg.setPreselectedSmoothMode(settings.value("display/smooth", true).toBool()); + dlg.setPreselectedRetroMode(settings.value("display/retro", false).toBool()); + dlg.setPreselectedSharpenAmount(settings.value("display/sharpen", 0.0).toFloat()); + dlg.setPreselectedBrightness(settings.value("display/brightness", 1.0).toFloat()); + dlg.setPreselectedHwContrast(settings.value("hardware/contrast", -1).toInt()); + dlg.setPreselectedHwSaturation(settings.value("hardware/saturation", -1).toInt()); if (dlg.exec() != QDialog::Accepted) break; @@ -26,6 +41,18 @@ int main(int argc, char* argv[]) const QString videoDevice = dlg.selectedVideoDevice(); const QString audioDevice = dlg.selectedAudioDevice(); const int fps = dlg.selectedFps(); + const bool smoothMode = dlg.selectedSmoothMode(); + const bool retroMode = dlg.selectedRetroMode(); + const float sharpenAmt = dlg.selectedSharpenAmount(); + const float brightness = dlg.selectedBrightness(); + const int hwContrast = dlg.selectedHwContrast(); + const int hwSaturation = dlg.selectedHwSaturation(); + // Für die Taste [X] (Quick-Toggle 0 ↔ letzter Wert) zur Laufzeit: + // Falls im Dialog ein Wert >0 gesetzt wurde, ist das gleichzeitig + // der zu merkende "letzte" Wert. + const float lastSharpen = sharpenAmt > 0.0f + ? sharpenAmt + : settings.value("display/sharpen_last", 2.5).toFloat(); if (videoDevice.isEmpty()) { QMessageBox::critical(nullptr, "Fehler", @@ -36,9 +63,25 @@ int main(int argc, char* argv[]) settings.setValue("device/video", videoDevice); settings.setValue("device/audio", audioDevice); settings.setValue("device/fps", fps); + settings.setValue("display/smooth", smoothMode); + settings.setValue("display/retro", retroMode); + settings.setValue("display/sharpen", static_cast(sharpenAmt)); + settings.setValue("display/brightness", static_cast(brightness)); + if (sharpenAmt > 0.0f) + settings.setValue("display/sharpen_last", static_cast(sharpenAmt)); + // -1 = "unverändert" -> kein expliziter Nutzerwert, Regler bleibt + // beim Öffnen unangetastet (siehe CaptureThread::applyHardwareControls) + if (hwContrast >= 0) + settings.setValue("hardware/contrast", hwContrast); + else + settings.remove("hardware/contrast"); + if (hwSaturation >= 0) + settings.setValue("hardware/saturation", hwSaturation); + else + settings.remove("hardware/saturation"); settings.sync(); - MainWindow win(videoDevice, audioDevice, fps); + MainWindow win(videoDevice, audioDevice, fps, smoothMode, retroMode, brightness, sharpenAmt, lastSharpen); win.show(); app.exec();