diff --git a/CMakeLists.txt b/CMakeLists.txt index a2297c1..ce7cf71 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(HDMIViewer VERSION 1.0.0 LANGUAGES CXX) +project(HDMIViewer VERSION 1.1.0 LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -9,7 +9,8 @@ set(CMAKE_AUTOUIC ON) # Qt6 – Gui enthält den JPEG-Plugin-Support (QImage::fromData) # OpenGLWidgets für das GPU-beschleunigte Video-Rendering (Bildglättung/Retro-Optik) -find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets OpenGL OpenGLWidgets) +find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets OpenGL OpenGLWidgets Svg) +# Svg: Programm-Icon (assets/hdmi-viewer.svg) wird als SVG geladen # ALSA find_package(PkgConfig REQUIRED) @@ -27,8 +28,10 @@ set(SOURCES src/MainWindow.cpp src/VideoWidget.cpp src/DeviceDialog.cpp + src/Profile.cpp src/AudioThread.cpp src/CaptureThread.cpp + resources.qrc ) add_executable(hdmi-viewer ${SOURCES}) @@ -40,6 +43,7 @@ target_link_libraries(hdmi-viewer Qt6::Widgets Qt6::OpenGL Qt6::OpenGLWidgets + Qt6::Svg ${ALSA_LIBRARIES} ) @@ -54,3 +58,8 @@ target_compile_options(hdmi-viewer PRIVATE ) install(TARGETS hdmi-viewer DESTINATION bin) + +# Startmenü-Eintrag und Icon (Cinnamon/GNOME/KDE lesen beides aus share/) +install(FILES hdmi-viewer.desktop DESTINATION share/applications) +install(FILES assets/hdmi-viewer.svg + DESTINATION share/icons/hicolor/scalable/apps) diff --git a/PKGBUILD b/PKGBUILD index 88b7269..ff87263 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,12 +1,12 @@ # Maintainer: Dany Thinnes pkgname=hdmi-viewer -pkgver=1.0.0 +pkgver=1.1.0 pkgrel=1 pkgdesc="Schlanker Qt6/C++ Live-Viewer für HDMI-Grabber (V4L2 + ALSA), Companion zu OBS Studio" arch=('x86_64') url="https://git.projekt-hirnfrei.de/diabolus/hdmiviewer" license=('MIT') -depends=('qt6-base' 'alsa-lib') +depends=('qt6-base' 'qt6-svg' 'alsa-lib') makedepends=('cmake') source=("$pkgname-$pkgver.tar.gz::$url/archive/v$pkgver.tar.gz") sha256sums=('SKIP') diff --git a/README.md b/README.md index 262b69d..2148038 100644 --- a/README.md +++ b/README.md @@ -45,24 +45,40 @@ Gedacht als Companion zu OBS Studio: **Anzeigen ohne Aufnehmen**. unabhängig vom Pixelformat. - **Roh-Modus** (Taste **R**): Diagnose-Umschalter, der sämtliche Verarbeitung (Bildglättung, Retro-Optik, Studio-Range-Korrektur, Kontrast-/ - Sättigungs-Punch, Gamma) umgeht und das Bild 1:1 anzeigt. + Sättigungs-Punch, Gamma) umgeht und das Bild 1:1 anzeigt. Zusätzlich werden + die Hardware-Regler am Grabber-Chip auf ihren beim Start gemerkten + Ausgangswert zurückgesetzt; beim Ausschalten gelten wieder die gespeicherten + Werte bzw. das aktive Profil. - **Live-Vergleich** (Taste **V**): zeigt links das rohe und rechts das bearbeitete Bild nebeneinander – praktisch zum Fein-Tunen der Hardware-Regler. +- **Profile** (Menü „Profile“, Tasten **F1**–**F9**): speichert die aktuelle + Kombination aus Bildglättung, Retro-Optik, Nachschärfen und den drei + Hardware-Reglern unter einem Namen (z. B. „PlayStation“, „PS3“). Beim + Umschalten werden Filter und Grabber-Regler sofort gesetzt; „unverändert“ + bei einem Hardware-Regler setzt ihn auf den Ausgangswert des Chips zurück. + Speichern/Löschen geht im Menü „Profile“ und im Startdialog, dort lädt die + Auswahl die Werte in die Felder. Ein Häkchen markiert das Profil, das zu den + aktuellen Einstellungen passt. F1–F9 folgen der alphabetischen Reihenfolge + im Menü. - **Screenshot** (Taste **P**): speichert das aktuell angezeigte (bearbeitete) Frame als PNG im Home-Verzeichnis. - **Frame-Dump** (Taste **D**, nur bei MJPEG): speichert das nächste rohe, unveränderte MJPEG-Frame direkt vom Chip als JPEG – nützlich, um zu prüfen, ob Abweichungen schon in den Rohdaten liegen oder erst bei der eigenen Verarbeitung entstehen. +- **Programm-Icon und Startmenü-Eintrag**: Icon als SVG (`assets/hdmi-viewer.svg`), + dazu eine `.desktop`-Datei. Beides wird bei `cmake --install` bzw. über das + Paket installiert; das Fenster-Icon ist zusätzlich ins Programm eingebettet. ## Abhängigkeiten (Arch Linux) ```bash -sudo pacman -S qt6-base cmake alsa-lib base-devel +sudo pacman -S qt6-base qt6-svg cmake alsa-lib base-devel ``` `qt6-base` bringt auf Arch bereits die benötigten `Qt6OpenGL`- und -`Qt6OpenGLWidgets`-Module mit. `linux/videodev2.h` ist Teil von +`Qt6OpenGLWidgets`-Module mit. `qt6-svg` wird für das SVG-Programm-Icon +benötigt. `linux/videodev2.h` ist Teil von `linux-api-headers`, das über die `base`-Gruppe ohnehin installiert ist. ## Installation über AUR @@ -89,6 +105,12 @@ cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc) ``` +Optional systemweit installieren (inkl. Startmenü-Eintrag und Icon): + +```bash +sudo cmake --install . --prefix /usr/local +``` + ## Starten ```bash @@ -107,16 +129,33 @@ make -j$(nproc) | , / . | Hardware-Helligkeit dunkler / heller | | 1 / 2 | Hardware-Kontrast runter / hoch | | 3 / 4 | Hardware-Sättigung runter / hoch | -| R | Roh-Modus (Diagnose, alle Effekte umgangen) ein/aus | +| R | Roh-Modus (Diagnose, alle Filter + HW-Regler aus) ein/aus | +| F1–F9 | Profil 1–9 laden (alphabetisch, siehe Menü „Profile“) | | V | Live-Vergleich (links roh, rechts bearbeitet) ein/aus | | P | Screenshot speichern (PNG) | | D | Rohes MJPEG-Frame dumpen (Diagnose) | | Esc | Vollbild verlassen ODER zurück zum Gerätedialog | +## Hilfsprogramme + +`tools/colormatch.py` vergleicht zwei Screenshots (z. B. dasselbe Bild in einem +Referenzprogramm und in HDMI Viewer) und ermittelt pro Farbkanal Gain, Offset +und Gamma, die das Testbild an die Referenz angleichen – inklusive fertiger +GLSL-Zeile. Mit `--palette` werden zusätzlich die häufigsten Flächenfarben +beider Bilder ausgegeben. Benötigt `python-numpy` und `python-pillow`. + +```bash +python3 tools/colormatch.py referenz.png test.png --palette +``` + +Beide Bilder müssen auf demselben Weg entstanden sein (z. B. beide als +Desktop-Screenshot des laufenden Programms). + ## Einstellungen Werden gespeichert in: `~/.config/hdmi-viewer/HDMIViewer.conf` -(Standard XDG-Pfad, kein manuelles Anlegen nötig) +(Standard XDG-Pfad, kein manuelles Anlegen nötig). Profile liegen in +derselben Datei im Abschnitt `[profiles]`. ## Audio-Gerät finden @@ -131,3 +170,22 @@ aplay /tmp/test.wav Das Gerät dann im Dialog so eingeben: z.B. `hw:2,0` oder `plughw:2,0` (plughw macht automatische Sample-Rate-Konvertierung, falls nötig) + +## Änderungen + +### 1.1.0 + +- Profile: Kombinationen aus Bildglättung, Retro-Optik, Nachschärfen und + Hardware-Reglern speichern, laden und per **F1**–**F9** umschalten (Menü und + Startdialog) +- Fix: Das Bild wurde in der Anzeige auf etwa die Hälfte der Helligkeit + abgedunkelt (Weiß 255 → 128), auch im Roh-Modus. Ursache war OpenGL-Zustand + (u. a. Blending), den der `QPainter` der „Kein Signal“-Anzeige zurückließ. + Der Zustand wird jetzt vor dem Zeichnen des Videobilds zurückgesetzt. +- Roh-Modus (**R**) setzt zusätzlich die Hardware-Regler auf den Ausgangswert +- Neues Programm-Icon, Startmenü-Eintrag, neue Abhängigkeit `qt6-svg` +- Neues Hilfsprogramm `tools/colormatch.py` + +### 1.0.0 + +- Erste Version diff --git a/assets/hdmi-viewer.svg b/assets/hdmi-viewer.svg new file mode 100644 index 0000000..5bcd749 --- /dev/null +++ b/assets/hdmi-viewer.svg @@ -0,0 +1,54 @@ + + HDMI Viewer + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/hdmi-viewer.desktop b/hdmi-viewer.desktop new file mode 100644 index 0000000..9e01a0e --- /dev/null +++ b/hdmi-viewer.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Name=HDMI Viewer +GenericName=HDMI-Grabber-Vorschau +Comment=Live-Vorschau für HDMI-Grabber +Exec=hdmi-viewer +Icon=hdmi-viewer +Terminal=false +Type=Application +Categories=AudioVideo;Video; +Keywords=HDMI;Grabber;Capture;V4L2;Video; +StartupWMClass=HDMIViewer diff --git a/hdmi_viewer_icon.md b/hdmi_viewer_icon.md new file mode 100644 index 0000000..d3fd022 --- /dev/null +++ b/hdmi_viewer_icon.md @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/hdmiviewer_screenshot_2026-10-17_03-14-50.png b/hdmiviewer_screenshot_2026-10-17_03-14-50.png new file mode 100644 index 0000000..6f53f8b Binary files /dev/null and b/hdmiviewer_screenshot_2026-10-17_03-14-50.png differ diff --git a/resources.qrc b/resources.qrc new file mode 100644 index 0000000..156d14d --- /dev/null +++ b/resources.qrc @@ -0,0 +1,5 @@ + + + assets/hdmi-viewer.svg + + diff --git a/src/CaptureThread.cpp b/src/CaptureThread.cpp index 7ff4a38..1d2bdaf 100644 --- a/src/CaptureThread.cpp +++ b/src/CaptureThread.cpp @@ -422,6 +422,30 @@ void CaptureThread::adjustHardwareControl(quint32 id, const char* settingsKey, } } +void CaptureThread::setHardwareValues(int brightness, int contrast, int saturation) +{ + if (m_fd == -1) return; + + auto set = [&](quint32 id, int value, int restoreValue) { + v4l2_queryctrl qc{}; + qc.id = id; + if (xioctl(m_fd, VIDIOC_QUERYCTRL, &qc) == -1 || (qc.flags & V4L2_CTRL_FLAG_DISABLED)) + return; + + const int target = value >= 0 ? value : restoreValue; + if (target < 0) return; // weder Profilwert noch Ausgangswert bekannt + + v4l2_control ctrl{}; + ctrl.id = id; + ctrl.value = qBound(qc.minimum, target, qc.maximum); + xioctl(m_fd, VIDIOC_S_CTRL, &ctrl); + }; + + set(V4L2_CID_BRIGHTNESS, brightness, m_restoreBrightness); + set(V4L2_CID_CONTRAST, contrast, m_restoreContrast); + set(V4L2_CID_SATURATION, saturation, m_restoreSaturation); +} + void CaptureThread::adjustHardwareBrightness(int delta) { adjustHardwareControl(V4L2_CID_BRIGHTNESS, "hardware/brightness", "Hardware-Helligkeit", delta); diff --git a/src/CaptureThread.h b/src/CaptureThread.h index ade3afa..c8db17f 100644 --- a/src/CaptureThread.h +++ b/src/CaptureThread.h @@ -27,6 +27,9 @@ public: void adjustHardwareBrightness(int delta); void adjustHardwareContrast(int delta); void adjustHardwareSaturation(int delta); + // Setzt die Hardware-Regler absolut (für Profile). -1 = zurück auf den + // beim Start gemerkten Ausgangswert des Chips. + void setHardwareValues(int brightness, int contrast, int saturation); int captureWidth() const { return m_width; } int captureHeight() const { return m_height; } diff --git a/src/DeviceDialog.cpp b/src/DeviceDialog.cpp index 0d261fb..9cedb71 100644 --- a/src/DeviceDialog.cpp +++ b/src/DeviceDialog.cpp @@ -55,6 +55,22 @@ DeviceDialog::DeviceDialog(QWidget* parent) form->addRow("Audio-Gerät (ALSA):", m_audioCombo); form->addRow("Ziel-Framerate:", m_fpsCombo); + // Profile: gespeicherte Kombination aus Glättung/Retro/Schärfen/HW-Reglern + m_profileCombo = new QComboBox(this); + m_profileSaveBtn = new QPushButton("Speichern …", this); + m_profileDeleteBtn = new QPushButton("Löschen", this); + m_profileSaveBtn->setAutoDefault(false); + m_profileDeleteBtn->setAutoDefault(false); + m_profileCombo->setToolTip( + "Gespeicherte Bild-Einstellungen (Glättung, Retro, Schärfen,\n" + "Hardware-Regler) für eine Quelle, z. B. PlayStation oder PS3.\n" + "Im laufenden Betrieb mit [F1]..[F9] oder über das Menü \"Profile\" umschaltbar."); + auto* profileRow = new QHBoxLayout; + profileRow->addWidget(m_profileCombo, 1); + profileRow->addWidget(m_profileSaveBtn); + profileRow->addWidget(m_profileDeleteBtn); + form->addRow("Profil:", profileRow); + m_smoothCheck = new QCheckBox("Bildglättung aktivieren (empfohlen)", this); m_smoothCheck->setChecked(true); m_smoothCheck->setToolTip( @@ -143,6 +159,24 @@ DeviceDialog::DeviceDialog(QWidget* parent) connect(btnOk, &QPushButton::clicked, this, &QDialog::accept); connect(btnClose, &QPushButton::clicked, this, &QDialog::reject); + // Profile: Auswahl lädt die Werte in die Felder; manuelle Änderungen + // führen die Auswahl nach (passendes Profil oder "kein Profil"). + populateProfiles(QString()); + connect(m_profileCombo, QOverload::of(&QComboBox::activated), this, [this](int index) { + loadProfileIntoControls(m_profileCombo->itemData(index).toString()); + }); + connect(m_profileSaveBtn, &QPushButton::clicked, this, &DeviceDialog::saveProfile); + connect(m_profileDeleteBtn, &QPushButton::clicked, this, &DeviceDialog::deleteProfile); + + auto onControlChanged = [this]() { refreshProfileSelection(); }; + connect(m_smoothCheck, &QCheckBox::toggled, this, onControlChanged); + connect(m_retroCheck, &QCheckBox::toggled, this, onControlChanged); + connect(m_sharpenSpin, QOverload::of(&QDoubleSpinBox::valueChanged), this, onControlChanged); + connect(m_hwBrightnessSpin, QOverload::of(&QSpinBox::valueChanged), this, onControlChanged); + connect(m_hwContrastSpin, QOverload::of(&QSpinBox::valueChanged), this, onControlChanged); + connect(m_hwSaturationSpin, QOverload::of(&QSpinBox::valueChanged), this, onControlChanged); + refreshProfileSelection(); + auto* main = new QVBoxLayout(this); main->addLayout(form); main->addSpacing(8); @@ -171,6 +205,77 @@ void DeviceDialog::setPreselectedHwBrightness(int value) { m_hwBrightnessSpin->s void DeviceDialog::setPreselectedHwContrast(int value) { m_hwContrastSpin->setValue(value); } void DeviceDialog::setPreselectedHwSaturation(int value) { m_hwSaturationSpin->setValue(value); } +Profile DeviceDialog::currentProfile() const +{ + Profile p; + p.smooth = m_smoothCheck->isChecked(); + p.retro = m_retroCheck->isChecked(); + p.sharpen = static_cast(m_sharpenSpin->value()); + p.hwBrightness = m_hwBrightnessSpin->value(); + p.hwContrast = m_hwContrastSpin->value(); + p.hwSaturation = m_hwSaturationSpin->value(); + return p; +} + +void DeviceDialog::populateProfiles(const QString& select) +{ + m_profileCombo->clear(); + m_profileCombo->addItem("– kein Profil –", QString()); + for (const QString& name : Profiles::names()) + m_profileCombo->addItem(name, name); + selectByData(m_profileCombo, select); + m_profileDeleteBtn->setEnabled(m_profileCombo->currentIndex() > 0); +} + +void DeviceDialog::refreshProfileSelection() +{ + if (m_loadingProfile) return; + + const Profile cur = currentProfile(); + + // Aktuell gewähltes Profil bevorzugen, solange es noch passt + // (relevant, wenn zwei Profile identische Werte haben). + QString match = m_profileCombo->currentData().toString(); + if (match.isEmpty() || !Profiles::exists(match) || !Profiles::load(match).matches(cur)) + match = Profiles::findMatching(cur); + + selectByData(m_profileCombo, match); + m_profileDeleteBtn->setEnabled(m_profileCombo->currentIndex() > 0); +} + +void DeviceDialog::loadProfileIntoControls(const QString& name) +{ + m_profileDeleteBtn->setEnabled(!name.isEmpty()); + if (name.isEmpty() || !Profiles::exists(name)) + return; + + const Profile p = Profiles::load(name); + + m_loadingProfile = true; + m_smoothCheck->setChecked(p.smooth); + m_retroCheck->setChecked(p.retro); + m_sharpenSpin->setValue(p.sharpen); + m_hwBrightnessSpin->setValue(p.hwBrightness); + m_hwContrastSpin->setValue(p.hwContrast); + m_hwSaturationSpin->setValue(p.hwSaturation); + m_loadingProfile = false; +} + +void DeviceDialog::saveProfile() +{ + QString saved; + if (Profiles::saveWithPrompt(this, currentProfile(), &saved)) + populateProfiles(saved); +} + +void DeviceDialog::deleteProfile() +{ + const QString name = m_profileCombo->currentData().toString(); + if (name.isEmpty()) return; + if (Profiles::removeWithConfirm(this, name)) + populateProfiles(QString()); +} + void DeviceDialog::selectByData(QComboBox* combo, const QString& data) { for (int i = 0; i < combo->count(); ++i) diff --git a/src/DeviceDialog.h b/src/DeviceDialog.h index b572a8c..ee69ad4 100644 --- a/src/DeviceDialog.h +++ b/src/DeviceDialog.h @@ -3,8 +3,11 @@ #include #include +#include "Profile.h" + QT_BEGIN_NAMESPACE class QComboBox; +class QPushButton; class QCheckBox; class QDoubleSpinBox; class QSpinBox; @@ -37,10 +40,20 @@ public: void setPreselectedHwContrast(int value); // -1 = unverändert void setPreselectedHwSaturation(int value); // -1 = unverändert + // Profile: aktueller Stand der Bild-Einstellungen im Dialog, und + // Profil-Auswahl passend zu den aktuellen Werten setzen (nach den + // setPreselected*-Aufrufen verwenden). + Profile currentProfile() const; + void refreshProfileSelection(); + private: void populateVideoDevices(); void populateAudioDevices(); void selectByData(QComboBox* combo, const QString& data); + void populateProfiles(const QString& select); + void loadProfileIntoControls(const QString& name); + void saveProfile(); + void deleteProfile(); QComboBox* m_videoCombo = nullptr; QComboBox* m_audioCombo = nullptr; @@ -51,4 +64,8 @@ private: QSpinBox* m_hwBrightnessSpin = nullptr; QSpinBox* m_hwContrastSpin = nullptr; QSpinBox* m_hwSaturationSpin = nullptr; + QComboBox* m_profileCombo = nullptr; + QPushButton* m_profileSaveBtn = nullptr; + QPushButton* m_profileDeleteBtn = nullptr; + bool m_loadingProfile = false; }; diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index e8000a1..ad191ae 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -20,6 +20,8 @@ #include #include +#include "Profile.h" + MainWindow::MainWindow(const QString& videoDevice, const QString& audioDevice, int targetFps, @@ -139,6 +141,12 @@ void MainWindow::setupMenuBar() QAction* screenshotAct = viewMenu->addAction("Screenshot speichern\tP"); connect(screenshotAct, &QAction::triggered, this, &MainWindow::saveScreenshot); + // Profile werden beim Aufklappen frisch aufgebaut (Häkchen = passt zum + // aktuellen Stand), Umschalten auch per F1..F9. + m_profileMenu = menuBar()->addMenu("&Profile"); + connect(m_profileMenu, &QMenu::aboutToShow, this, &MainWindow::rebuildProfileMenu); + rebuildProfileMenu(); + QMenu* helpMenu = menuBar()->addMenu("&Hilfe"); QAction* aboutAct = helpMenu->addAction("Über HDMI Viewer"); @@ -146,6 +154,103 @@ void MainWindow::setupMenuBar() connect(aboutAct, &QAction::triggered, this, &MainWindow::showAbout); } +// ── Profile ────────────────────────────────────────────────────────────────── + +Profile MainWindow::currentProfile() const +{ + // Hardware-Werte stehen laut QSettings immer aktuell (Tasten + Dialog + // schreiben sie sofort), die Anzeige-Werte kommen direkt vom Widget. + Profile p = Profile::fromSettings(); + p.smooth = m_video->smoothModeEnabled(); + p.retro = m_video->retroModeEnabled(); + p.sharpen = m_video->sharpenAmount(); + return p; +} + +void MainWindow::rebuildProfileMenu() +{ + if (!m_profileMenu) return; + m_profileMenu->clear(); + + const QStringList names = Profiles::names(); + const Profile current = currentProfile(); + + for (int i = 0; i < names.size(); ++i) { + const QString name = names.at(i); + QString label = name; + label.replace("&", "&&"); + if (i < 9) + label += QString("\tF%1").arg(i + 1); + + QAction* act = m_profileMenu->addAction(label); + act->setCheckable(true); + act->setChecked(Profiles::load(name).matches(current)); + connect(act, &QAction::triggered, this, [this, name]() { applyProfile(name); }); + } + + if (names.isEmpty()) { + QAction* none = m_profileMenu->addAction("(noch keine Profile gespeichert)"); + none->setEnabled(false); + } + + m_profileMenu->addSeparator(); + + QAction* saveAct = m_profileMenu->addAction("Aktuelle Einstellungen als Profil speichern …"); + connect(saveAct, &QAction::triggered, this, &MainWindow::saveCurrentAsProfile); + + QMenu* delMenu = m_profileMenu->addMenu("Profil löschen"); + delMenu->setEnabled(!names.isEmpty()); + for (const QString& name : names) { + QString label = name; + label.replace("&", "&&"); + QAction* act = delMenu->addAction(label); + connect(act, &QAction::triggered, this, [this, name]() { deleteProfile(name); }); + } +} + +void MainWindow::applyProfile(const QString& name) +{ + if (!Profiles::exists(name)) return; + const Profile p = Profiles::load(name); + + m_video->setSmoothModeEnabled(p.smooth); + m_video->setRetroModeEnabled(p.retro); + + const float sharpen = qBound(VideoWidget::kSharpenMin, p.sharpen, VideoWidget::kSharpenMax); + m_video->setSharpenAmount(sharpen); + if (sharpen > 0.0f) + m_lastSharpenAmount = sharpen; + + if (m_smoothAction) m_smoothAction->setChecked(p.smooth); + if (m_retroAction) m_retroAction->setChecked(p.retro); + if (m_sharpenAction) m_sharpenAction->setChecked(sharpen > 0.0f); + + // Als aktuellen Stand persistieren (nächster Start übernimmt ihn) ... + p.applyToSettings(); + + // ... und die Hardware-Regler am Grabber sofort umstellen. + if (m_capture) + m_capture->setHardwareValues(p.hwBrightness, p.hwContrast, p.hwSaturation); + + statusBar()->showMessage("Profil geladen: " + name, 3000); + showOverlayMessage("Profil: " + name); +} + +void MainWindow::saveCurrentAsProfile() +{ + QString saved; + if (Profiles::saveWithPrompt(this, currentProfile(), &saved)) { + statusBar()->showMessage("Profil gespeichert: " + saved, 3000); + showOverlayMessage("Profil gespeichert: " + saved); + } +} + +void MainWindow::deleteProfile(const QString& name) +{ + if (Profiles::removeWithConfirm(this, name)) + statusBar()->showMessage("Profil gelöscht: " + name, 3000); +} + void MainWindow::showOverlayMessage(const QString& text) { if (!m_overlayLabel) return; @@ -249,7 +354,19 @@ 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"; + // Auch die Hardware-Regler am Grabber-Chip umgehen: An = zurück auf die + // beim Start gemerkten Ausgangswerte des Chips (-1), Aus = gespeicherte + // Nutzerwerte (QSettings bzw. aktives Profil) wieder setzen. + if (m_capture) { + if (enabled) { + m_capture->setHardwareValues(-1, -1, -1); + } else { + const Profile p = Profile::fromSettings(); + m_capture->setHardwareValues(p.hwBrightness, p.hwContrast, p.hwSaturation); + } + } + + const QString msg = enabled ? "Roh-Modus: an (alle Filter und HW-Regler aus)" : "Roh-Modus: aus"; statusBar()->showMessage(msg, 2000); showOverlayMessage(msg); } @@ -287,7 +404,7 @@ void MainWindow::showAbout() { auto* dlg = new QDialog(this); dlg->setWindowTitle("Über HDMI Viewer"); - dlg->setFixedSize(460, 470); + dlg->setFixedSize(460, 490); dlg->setModal(true); auto* root = new QVBoxLayout(dlg); @@ -336,7 +453,7 @@ void MainWindow::showAbout() grid->addWidget(v, row, 1); }; - addRow(0, "Version", "1.0"); + addRow(0, "Version", "1.1.0"); addRow(1, "Entwickler", "Dany Thinnes"); addRow(2, "Projekt", "Projekt Hirnfrei"); addRow(3, "Framework", QString("Qt %1").arg(QT_VERSION_STR)); @@ -360,6 +477,7 @@ void MainWindow::showAbout() "1/2 HW-Kontrast    " "3/4 HW-Sättigung    " "R Roh-Diagnose
" + "F1–F9 Profile
" "V Vergleich    " "P Screenshot    " "D Frame dumpen    " @@ -404,6 +522,17 @@ void MainWindow::stopAll() void MainWindow::keyPressEvent(QKeyEvent* event) { + // F1..F9: Profil 1..9 (Reihenfolge wie im Profile-Menü, alphabetisch) + if (event->key() >= Qt::Key_F1 && event->key() <= Qt::Key_F9) { + const QStringList names = Profiles::names(); + const int idx = event->key() - Qt::Key_F1; + if (idx < names.size()) + applyProfile(names.at(idx)); + else + showOverlayMessage(QString("Kein Profil auf F%1").arg(idx + 1)); + return; + } + switch (event->key()) { case Qt::Key_F: toggleFullscreen(); @@ -494,7 +623,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 [S] Glättung [C] Retro [X/+/-] Schärfen [,/.] HW-Helligkeit [1/2] HW-Kontrast [3/4] HW-Sättigung [R] Roh [V] Vergleich [P] Screenshot [D] Frame dumpen [Esc] Einstellungen") + info += QString("Video: %1 Audio: %2 [F] Vollbild [S] Glättung [C] Retro [X/+/-] Schärfen [,/.] HW-Helligkeit [1/2] HW-Kontrast [3/4] HW-Sättigung [R] Roh [F1-F9] Profile [V] Vergleich [P] Screenshot [D] Frame dumpen [Esc] Einstellungen") .arg(m_videoDevice, m_audioDevice.isEmpty() ? QStringLiteral("–") : m_audioDevice); diff --git a/src/MainWindow.h b/src/MainWindow.h index 42dfe1f..4e5f33a 100644 --- a/src/MainWindow.h +++ b/src/MainWindow.h @@ -3,12 +3,15 @@ #include #include +#include "Profile.h" + class VideoWidget; class CaptureThread; class AudioThread; QT_BEGIN_NAMESPACE class QAction; class QLabel; +class QMenu; QT_END_NAMESPACE class MainWindow : public QMainWindow @@ -57,6 +60,14 @@ private: void applySharpen(float value); void showOverlayMessage(const QString& text); + // Profile + Profile currentProfile() const; + void rebuildProfileMenu(); + void applyProfile(const QString& name); + void saveCurrentAsProfile(); + void deleteProfile(const QString& name); + + QMenu* m_profileMenu = nullptr; VideoWidget* m_video = nullptr; CaptureThread* m_capture = nullptr; AudioThread* m_audio = nullptr; diff --git a/src/Profile.cpp b/src/Profile.cpp new file mode 100644 index 0000000..800125a --- /dev/null +++ b/src/Profile.cpp @@ -0,0 +1,168 @@ +#include "Profile.h" + +#include +#include +#include +#include + +// ── Profile ────────────────────────────────────────────────────────────────── + +bool Profile::matches(const Profile& o) const +{ + return smooth == o.smooth + && retro == o.retro + && qAbs(sharpen - o.sharpen) < 0.05f // Spinbox hat 1 Nachkommastelle + && hwBrightness == o.hwBrightness + && hwContrast == o.hwContrast + && hwSaturation == o.hwSaturation; +} + +Profile Profile::fromSettings() +{ + QSettings s; + Profile p; + p.smooth = s.value("display/smooth", true).toBool(); + p.retro = s.value("display/retro", false).toBool(); + p.sharpen = s.value("display/sharpen", 0.0).toFloat(); + p.hwBrightness = s.contains("hardware/brightness") ? s.value("hardware/brightness").toInt() : -1; + p.hwContrast = s.contains("hardware/contrast") ? s.value("hardware/contrast").toInt() : -1; + p.hwSaturation = s.contains("hardware/saturation") ? s.value("hardware/saturation").toInt() : -1; + return p; +} + +void Profile::applyToSettings() const +{ + QSettings s; + s.setValue("display/smooth", smooth); + s.setValue("display/retro", retro); + s.setValue("display/sharpen", static_cast(sharpen)); + if (sharpen > 0.0f) + s.setValue("display/sharpen_last", static_cast(sharpen)); + + auto setOrRemove = [&](const char* key, int value) { + if (value >= 0) s.setValue(key, value); + else s.remove(key); + }; + setOrRemove("hardware/brightness", hwBrightness); + setOrRemove("hardware/contrast", hwContrast); + setOrRemove("hardware/saturation", hwSaturation); +} + +// ── Profiles ───────────────────────────────────────────────────────────────── + +namespace Profiles +{ + +QStringList names() +{ + QSettings s; + s.beginGroup("profiles"); + QStringList list = s.childGroups(); + s.endGroup(); + list.sort(Qt::CaseInsensitive); + return list; +} + +bool exists(const QString& name) +{ + return isValidName(name) && names().contains(name); +} + +Profile load(const QString& name) +{ + QSettings s; + s.beginGroup("profiles/" + name); + Profile p; + p.smooth = s.value("smooth", true).toBool(); + p.retro = s.value("retro", false).toBool(); + p.sharpen = s.value("sharpen", 0.0).toFloat(); + p.hwBrightness = s.value("hwBrightness", -1).toInt(); + p.hwContrast = s.value("hwContrast", -1).toInt(); + p.hwSaturation = s.value("hwSaturation", -1).toInt(); + s.endGroup(); + return p; +} + +void save(const QString& name, const Profile& p) +{ + if (!isValidName(name)) return; + QSettings s; + s.beginGroup("profiles/" + name); + s.setValue("smooth", p.smooth); + s.setValue("retro", p.retro); + s.setValue("sharpen", static_cast(p.sharpen)); + s.setValue("hwBrightness", p.hwBrightness); + s.setValue("hwContrast", p.hwContrast); + s.setValue("hwSaturation", p.hwSaturation); + s.endGroup(); + s.sync(); +} + +bool remove(const QString& name) +{ + if (!exists(name)) return false; + QSettings s; + s.remove("profiles/" + name); + s.sync(); + return true; +} + +bool isValidName(const QString& name) +{ + // '/' und '\' wären für QSettings Gruppentrenner + return !name.trimmed().isEmpty() + && name == name.trimmed() + && !name.contains('/') + && !name.contains('\\'); +} + +QString findMatching(const Profile& profile) +{ + for (const QString& n : names()) + if (load(n).matches(profile)) + return n; + return QString(); +} + +bool saveWithPrompt(QWidget* parent, const Profile& profile, QString* savedName) +{ + const QStringList existing = names(); + + bool ok = false; + const QString input = QInputDialog::getItem( + parent, "Profil speichern", + "Name des Profils (neuen Namen eintippen\noder vorhandenes zum Überschreiben wählen):", + existing, 0, true, &ok); + if (!ok) return false; + + const QString name = input.trimmed(); + if (!isValidName(name)) { + QMessageBox::warning(parent, "Profil speichern", + "Ungültiger Name. Der Name darf nicht leer sein und weder '/' noch '\\' enthalten."); + return false; + } + + if (existing.contains(name)) { + const auto answer = QMessageBox::question( + parent, "Profil überschreiben", + QString("Das Profil „%1“ existiert bereits. Überschreiben?").arg(name), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) return false; + } + + save(name, profile); + if (savedName) *savedName = name; + return true; +} + +bool removeWithConfirm(QWidget* parent, const QString& name) +{ + const auto answer = QMessageBox::question( + parent, "Profil löschen", + QString("Profil „%1“ wirklich löschen?").arg(name), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) return false; + return remove(name); +} + +} // namespace Profiles diff --git a/src/Profile.h b/src/Profile.h new file mode 100644 index 0000000..4d4e887 --- /dev/null +++ b/src/Profile.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include + +QT_BEGIN_NAMESPACE +class QWidget; +QT_END_NAMESPACE + +// Ein Profil bündelt alle Bildeinstellungen, die sich pro Konsole/Quelle +// unterscheiden (z. B. PlayStation: aufhellen + Filter, PS3: alles neutral). +// Hardware-Werte: -1 = "unverändert" (Regler am Grabber-Chip nicht anfassen +// bzw. auf den Ausgangswert des Chips zurücksetzen). +struct Profile +{ + bool smooth = true; + bool retro = false; + float sharpen = 0.0f; // 0 = aus + int hwBrightness = -1; + int hwContrast = -1; + int hwSaturation = -1; + + bool matches(const Profile& other) const; + + // Aktueller Stand laut QSettings (display/* und hardware/*). + static Profile fromSettings(); + + // Schreibt das Profil als aktuellen Stand nach display/* und hardware/*. + void applyToSettings() const; +}; + +// Profilverwaltung (gespeichert in QSettings unter profiles//...). +namespace Profiles +{ + QStringList names(); // alphabetisch sortiert + bool exists(const QString& name); + Profile load(const QString& name); + void save(const QString& name, const Profile& profile); + bool remove(const QString& name); + bool isValidName(const QString& name); + QString findMatching(const Profile& profile); // "" falls keins passt + + // Dialog: Namen eingeben bzw. vorhandenes Profil wählen (Überschreiben + // wird nachgefragt) und speichern. true = gespeichert. + bool saveWithPrompt(QWidget* parent, const Profile& profile, QString* savedName = nullptr); + + // Löschen nach Rückfrage. true = gelöscht. + bool removeWithConfirm(QWidget* parent, const QString& name); +} diff --git a/src/VideoWidget.cpp b/src/VideoWidget.cpp index ed8f357..5ed21a1 100644 --- a/src/VideoWidget.cpp +++ b/src/VideoWidget.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace { @@ -64,6 +65,7 @@ uniform float u_sharpen; uniform float u_gamma; uniform int u_raw; uniform int u_compare; +uniform int u_debug; vec3 rgb2ycbcr(vec3 c) { @@ -159,6 +161,12 @@ void main() { vec2 uv = v_tex; + // Diagnose (nur per HDMIVIEWER_DEBUG=shaderwhite): Shader gibt reines Weiss aus. + if (u_debug == 1) { + fragColor = vec4(1.0, 1.0, 1.0, 1.0); + return; + } + // Diagnose: komplette Umgehung aller Verarbeitung, 1:1 wie im Speicher. if (u_raw == 1) { fragColor = vec4(texture(u_tex, uv).rgb, 1.0); @@ -338,6 +346,14 @@ void VideoWidget::initializeGL() initializeOpenGLFunctions(); glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + // Diagnose-Ausgabe einmalig im Terminal: welche GL-Umgebung/Pixelformat aktiv ist. + qDebug().noquote() << "GL_RENDERER:" << reinterpret_cast(glGetString(GL_RENDERER)) + << " GL_VERSION:" << reinterpret_cast(glGetString(GL_VERSION)); + qDebug().noquote() << QString("Surface: R%1 G%2 B%3 A%4 samples=%5") + .arg(format().redBufferSize()).arg(format().greenBufferSize()) + .arg(format().blueBufferSize()).arg(format().alphaBufferSize()) + .arg(format().samples()); + m_program = new QOpenGLShaderProgram(this); m_program->addShaderFromSourceCode(QOpenGLShader::Vertex, kVertexShaderSrc); m_program->addShaderFromSourceCode(QOpenGLShader::Fragment, kFragmentShaderSrc); @@ -403,11 +419,81 @@ void VideoWidget::paintGL() glClear(GL_COLOR_BUFFER_BIT); + // Diagnose-Schalter per Umgebungsvariable (im Normalbetrieb ohne Wirkung): + // HDMIVIEWER_DEBUG=clearwhite -> Framebuffer nur mit Weiss loeschen, kein Shader + // HDMIVIEWER_DEBUG=shaderwhite -> Shader wird benutzt, gibt aber reines Weiss aus + static const QByteArray dbg = qgetenv("HDMIVIEWER_DEBUG"); + if (dbg == "clearwhite") { + glClearColor(1.0f, 1.0f, 1.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + return; + } + if (!m_hasFrame || !m_texture) { drawNoSignal(); return; } + // Diagnose (HDMIVIEWER_DEBUG=state oder =reset): GL-Zustand direkt VOR dem Zeichnen + // einmalig ins Terminal schreiben (nach einem moeglichen QPainter in drawNoSignal). + if (dbg == "state" || dbg == "reset") { + static bool dumped = false; + if (!dumped) { + dumped = true; + auto en = [this](GLenum cap) { return glIsEnabled(cap) ? 1 : 0; }; + GLint bsr = 0, bdr = 0, bsa = 0, bda = 0, beq = 0, fbo = 0, enc = 0, ctype = 0; + GLfloat bc[4] = {0, 0, 0, 0}; + GLboolean cm[4] = {0, 0, 0, 0}; + glGetIntegerv(0x80C9, &bsr); // GL_BLEND_SRC_RGB + glGetIntegerv(0x80C8, &bdr); // GL_BLEND_DST_RGB + glGetIntegerv(0x80CB, &bsa); // GL_BLEND_SRC_ALPHA + glGetIntegerv(0x80CA, &bda); // GL_BLEND_DST_ALPHA + glGetIntegerv(0x8009, &beq); // GL_BLEND_EQUATION_RGB + glGetIntegerv(0x8CA6, &fbo); // GL_DRAW_FRAMEBUFFER_BINDING + glGetFloatv(0x8005, bc); // GL_BLEND_COLOR + glGetBooleanv(0x0C23, cm); // GL_COLOR_WRITEMASK + glGetFramebufferAttachmentParameteriv(0x8CA9, 0x8CE0, 0x8210, &enc); // COLOR_ENCODING + glGetFramebufferAttachmentParameteriv(0x8CA9, 0x8CE0, 0x8211, &ctype); // COMPONENT_TYPE + const QStringList st = { + QString("BLEND=%1").arg(en(0x0BE2)), + QString("blendRGB src=0x%1 dst=0x%2").arg(bsr, 0, 16).arg(bdr, 0, 16), + QString("blendA src=0x%1 dst=0x%2").arg(bsa, 0, 16).arg(bda, 0, 16), + QString("eq=0x%1").arg(beq, 0, 16), + QString("blendColor=%1,%2,%3,%4").arg(bc[0]).arg(bc[1]).arg(bc[2]).arg(bc[3]), + QString("colorMask=%1%2%3%4").arg(int(cm[0])).arg(int(cm[1])).arg(int(cm[2])).arg(int(cm[3])), + QString("FB_SRGB=%1").arg(en(0x8DB9)), + QString("LOGIC_OP=%1").arg(en(0x0BF2)), + QString("A2C=%1").arg(en(0x809E)), + QString("DITHER=%1").arg(en(0x0BD0)), + QString("MULTISAMPLE=%1").arg(en(0x809D)), + QString("SCISSOR=%1").arg(en(0x0C11)), + QString("DEPTH=%1").arg(en(0x0B71)), + QString("STENCIL=%1").arg(en(0x0B90)), + QString("CULL=%1").arg(en(0x0B44)), + QString("drawFBO=%1").arg(fbo), + QString("attachEncoding=0x%1 (0x8c40=sRGB, 0x2601=linear)").arg(enc, 0, 16), + QString("attachType=0x%1 (0x8c17=UNORM, 0x8f9c=SNORM, 0x1406=FLOAT)").arg(ctype, 0, 16) + }; + qDebug().noquote() << "GL-State vor dem Zeichnen:" << st.join(" "); + } + } + + // Fester Zustands-Reset vor dem Zeichnen: drawNoSignal() nutzt QPainter, der GL-Zustand + // (u. a. Blending) zuruecklaesst. Ohne diesen Reset war das Bild live/im Screenshot + // exakt auf die Haelfte abgedunkelt (Weiss 255 -> 128), auch im Roh-Modus. + { + glDisable(0x0BE2); // GL_BLEND + glDisable(0x0B71); // GL_DEPTH_TEST + glDisable(0x0B90); // GL_STENCIL_TEST + glDisable(0x0C11); // GL_SCISSOR_TEST + glDisable(0x0B44); // GL_CULL_FACE + glDisable(0x8DB9); // GL_FRAMEBUFFER_SRGB + glDisable(0x0BF2); // GL_COLOR_LOGIC_OP + glDisable(0x809E); // GL_SAMPLE_ALPHA_TO_COVERAGE + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + } + const float viewW = static_cast(width()); const float viewH = static_cast(height()); const float imgW = static_cast(m_imageSize.width()); @@ -441,6 +527,7 @@ void VideoWidget::paintGL() 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); + m_program->setUniformValue("u_debug", dbg == "shaderwhite" ? 1 : 0); glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); diff --git a/src/main.cpp b/src/main.cpp index 5a041f9..74f1c54 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -2,6 +2,7 @@ #include #include #include +#include #include "DeviceDialog.h" #include "MainWindow.h" @@ -18,9 +19,13 @@ int main(int argc, char* argv[]) QApplication app(argc, argv); app.setApplicationName("HDMIViewer"); - app.setApplicationVersion("1.0"); + app.setApplicationVersion("1.1.0"); app.setOrganizationName("hdmi-viewer"); + // Fenster-/Taskleisten-Icon: installiertes Theme-Icon bevorzugen, sonst das + // eingebettete SVG (z. B. beim Start direkt aus dem build-Ordner). + app.setWindowIcon(QIcon::fromTheme("hdmi-viewer", QIcon(":/icons/hdmi-viewer.svg"))); + QSettings settings; while (true) { @@ -34,6 +39,7 @@ int main(int argc, char* argv[]) dlg.setPreselectedHwBrightness(settings.value("hardware/brightness", -1).toInt()); dlg.setPreselectedHwContrast(settings.value("hardware/contrast", -1).toInt()); dlg.setPreselectedHwSaturation(settings.value("hardware/saturation", -1).toInt()); + dlg.refreshProfileSelection(); // zeigt das Profil, das zu den Werten passt if (dlg.exec() != QDialog::Accepted) break; diff --git a/tools/colormatch.py b/tools/colormatch.py new file mode 100644 index 0000000..6e8c4a2 --- /dev/null +++ b/tools/colormatch.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +colormatch.py - Farbabgleich zwischen einem Referenzbild (z. B. UARTScope) +und einem Testbild (z. B. HDMIViewer), das zu dunkel/anders erscheint. + +Es wird KEINE Pixel-fuer-Pixel-Gleichheit verlangt (die Bilder duerfen +unterschiedliche Aufloesung und leicht unterschiedlichen Inhalt haben, z. B. +eine andere Animationsphase). Stattdessen werden pro Farbkanal die +Helligkeitsverteilungen (Quantile) beider Bilder zugeordnet und daraus die +Korrektur bestimmt, die das Testbild an die Referenz angleicht: + + ref ~= T(test) + +Gefittet werden drei Modelle pro Kanal (Werte normiert auf 0..1): + gain : out = g * in + linear : out = g * in + o + gamma : out = g * in ** gamma + +Ausgabe: Werte, Fehler (RMS in 0..255) und fertige GLSL-Zeilen fuer +VideoWidget.cpp. Optional LUT als CSV und das korrigierte Testbild. + +WICHTIG: Fuer eine sinnvolle Aussage muessen beide Bilder auf demselben Weg +entstanden sein, z. B. beide per Desktop-Screenshot (Spectacle o. ae.) des +LAUFENDEN Programms, jeweils auf das Videobild zugeschnitten. Ein per P +gespeicherter HDMIViewer-Screenshot enthaelt nur den Framebuffer und zeigt +Unterschiede, die erst bei der Anzeige am Bildschirm entstehen, nicht. + +Ist die Verarbeitung nicht linear (Kontrast, Studio-Range ...), liefert der +Fit nur eine Naeherung (Fehler deutlich ueber ~3). Dann hilft --palette: Die +haeufigsten Flaechenfarben zeigen direkt, was mit Weiss/Rot/... passiert. + +Aufruf: + colormatch.py REFERENZ.png TEST.png + colormatch.py ref.png test.png --palette + colormatch.py ref.png test.png --crop-ref 100,80,1280,720 --crop-test 0,0,1280,672 + colormatch.py ref.png test.png --lut lut.csv --save-corrected korrigiert.png + +Benoetigt: python3, numpy, Pillow (Arch: pacman -S python-numpy python-pillow) +""" + +import argparse +import sys + +import numpy as np +from PIL import Image + +CHANNELS = ("R", "G", "B") +N_QUANT = 4096 # Anzahl der Quantil-Stuetzstellen pro Kanal + + +def parse_crop(text): + if not text: + return None + try: + x, y, w, h = (int(v) for v in text.split(",")) + except ValueError: + raise argparse.ArgumentTypeError("Format: x,y,breite,hoehe") + return x, y, w, h + + +def load(path, crop, auto, thr): + arr = np.asarray(Image.open(path).convert("RGB")) + if crop: + x, y, w, h = crop + arr = arr[y:y + h, x:x + w] + if auto: + # Schwarze Letterbox-/Pillarbox-Raender abschneiden + mask = arr.max(axis=2) > thr + ys = np.where(mask.any(axis=1))[0] + xs = np.where(mask.any(axis=0))[0] + if len(ys) and len(xs): + arr = arr[ys[0]:ys[-1] + 1, xs[0]:xs[-1] + 1] + if arr.size == 0: + sys.exit(f"Leerer Ausschnitt bei {path}") + return arr + + +def quantile_pairs(test_ch, ref_ch): + """Ordnet Quantile des Testkanals denen des Referenzkanals zu. + Rueckgabe: (t, r, w) - eindeutige Wertepaare (0..1) mit Gewicht.""" + ts = np.sort(test_ch.ravel()) + rs = np.sort(ref_ch.ravel()) + p = (np.arange(N_QUANT) + 0.5) / N_QUANT + t = ts[(p * (len(ts) - 1)).astype(np.int64)].astype(np.float64) / 255.0 + r = rs[(p * (len(rs) - 1)).astype(np.int64)].astype(np.float64) / 255.0 + pairs = np.stack([t, r], axis=1) + uniq, cnt = np.unique(pairs, axis=0, return_counts=True) + return uniq[:, 0], uniq[:, 1], cnt.astype(np.float64) + + +def fit_gain(t, r, w): + den = np.sum(w * t * t) + g = np.sum(w * t * r) / den if den > 0 else 1.0 + return {"g": g} + + +def fit_linear(t, r, w): + sw = np.sqrt(w) + a = np.stack([t, np.ones_like(t)], axis=1) * sw[:, None] + sol, *_ = np.linalg.lstsq(a, r * sw, rcond=None) + return {"g": sol[0], "o": sol[1]} + + +def fit_gamma(t, r, w): + m = (t > 1.0 / 255.0) & (r > 1.0 / 255.0) + if m.sum() < 2: + return {"g": 1.0, "gamma": 1.0} + sw = np.sqrt(w[m]) + a = np.stack([np.ones(m.sum()), np.log(t[m])], axis=1) * sw[:, None] + sol, *_ = np.linalg.lstsq(a, np.log(r[m]) * sw, rcond=None) + return {"g": float(np.exp(sol[0])), "gamma": float(sol[1])} + + +def apply_model(name, p, x): + if name == "gain": + y = p["g"] * x + elif name == "linear": + y = p["g"] * x + p["o"] + else: + y = p["g"] * np.power(np.clip(x, 0.0, 1.0), p["gamma"]) + return np.clip(y, 0.0, 1.0) + + +def rms255(name, p, t, r, w): + d = (apply_model(name, p, t) - r) * 255.0 + return float(np.sqrt(np.sum(w * d * d) / np.sum(w))) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("ref", help="Referenzbild (so soll es aussehen, z. B. UARTScope)") + ap.add_argument("test", help="Testbild (so sieht es aus, z. B. HDMIViewer)") + ap.add_argument("--crop-ref", type=parse_crop, help="Ausschnitt x,y,b,h im Referenzbild") + ap.add_argument("--crop-test", type=parse_crop, help="Ausschnitt x,y,b,h im Testbild") + ap.add_argument("--no-autocrop", action="store_true", + help="schwarze Raender NICHT automatisch abschneiden") + ap.add_argument("--black-threshold", type=int, default=8, + help="Schwellwert fuer die Randerkennung (Standard 8)") + ap.add_argument("--palette", action="store_true", + help="zusaetzlich die haeufigsten Flaechenfarben beider Bilder ausgeben") + ap.add_argument("--lut", metavar="CSV", help="256-Werte-LUT pro Kanal als CSV speichern") + ap.add_argument("--save-corrected", metavar="PNG", + help="Testbild mit der besten Korrektur speichern") + args = ap.parse_args() + + auto = not args.no_autocrop + ref = load(args.ref, args.crop_ref, auto, args.black_threshold) + test = load(args.test, args.crop_test, auto, args.black_threshold) + + print(f"Referenz: {ref.shape[1]}x{ref.shape[0]} Test: {test.shape[1]}x{test.shape[0]}") + print() + + if args.palette: + # Haeufigste Farben (ohne fast schwarze Pixel). Bei Bildern mit flachen + # Farbflaechen (Amiga, Konsolen) zeigt das am direktesten, wie die + # Helligkeit veraendert wird, z. B. Weiss 255 -> 128 = Faktor 0,5. + for label, img in (("Referenz", ref), ("Test", test)): + px = img.reshape(-1, 3) + px = px[px.max(axis=1) > 8] + u, c = np.unique(px, axis=0, return_counts=True) + print(f"Haeufigste Farben {label}:") + for i in np.argsort(-c)[:6]: + print(f" ({u[i][0]:3d},{u[i][1]:3d},{u[i][2]:3d}) {c[i]} Pixel") + print() + + print("Mittelwerte (0..255) R G B") + mr = ref.reshape(-1, 3).mean(axis=0) + mt = test.reshape(-1, 3).mean(axis=0) + print(" Referenz " + "".join(f"{v:9.2f}" for v in mr)) + print(" Test " + "".join(f"{v:9.2f}" for v in mt)) + print(" Verhaeltnis Ref/Test" + "".join(f"{(a / b if b > 0 else float('nan')):9.3f}" + for a, b in zip(mr, mt))) + print() + + results = {} + luts = [] + for ci, cname in enumerate(CHANNELS): + t, r, w = quantile_pairs(test[..., ci], ref[..., ci]) + models = { + "gain": fit_gain(t, r, w), + "linear": fit_linear(t, r, w), + "gamma": fit_gamma(t, r, w), + } + errs = {n: rms255(n, p, t, r, w) for n, p in models.items()} + results[cname] = (models, errs) + + # LUT (monoton, per Interpolation der Quantilpaare) + order = np.argsort(t) + tt, rr = t[order], r[order] + xs = np.arange(256) / 255.0 + lut = np.interp(xs, tt, rr) + lut = np.maximum.accumulate(lut) + luts.append(np.clip(np.round(lut * 255.0), 0, 255).astype(np.uint8)) + + print("Fit pro Kanal (Fehler = RMS in 0..255, kleiner ist besser)") + for cname in CHANNELS: + models, errs = results[cname] + print(f" {cname}:") + print(f" gain : g={models['gain']['g']:.4f}" + f" RMS {errs['gain']:.2f}") + print(f" linear : g={models['linear']['g']:.4f} o={models['linear']['o']:+.4f}" + f" RMS {errs['linear']:.2f}") + print(f" gamma : g={models['gamma']['g']:.4f} gamma={models['gamma']['gamma']:.4f}" + f" RMS {errs['gamma']:.2f}") + + # Bestes gemeinsames Modell nach Summe der Fehlerquadrate waehlen, + # aber das einfachste nehmen, wenn es fast genauso gut ist. + total = {n: np.sqrt(np.mean([results[c][1][n] ** 2 for c in CHANNELS])) + for n in ("gain", "linear", "gamma")} + best = min(total, key=total.get) + if total["gain"] <= total[best] + 1.0: + best = "gain" + elif total["linear"] <= total[best] + 1.0 and best == "gamma": + best = "linear" + + print() + print(f"Gesamtfehler gain {total['gain']:.2f} linear {total['linear']:.2f}" + f" gamma {total['gamma']:.2f} -> empfohlen: {best}") + if total[best] > 4.0: + print("ACHTUNG: Fehler ist hoch - die Abweichung ist wohl nicht durch ein einfaches") + print("Modell erklaerbar (oder die Bildinhalte unterscheiden sich zu stark).") + print("Mit --palette die Flaechenfarben ansehen, bevor die Werte uebernommen werden.") + print() + print("GLSL-Zeile fuer VideoWidget.cpp (im Fragment-Shader auf 'color' anwenden):") + P = [results[c][0][best] for c in CHANNELS] + if best == "gain": + v = ", ".join(f"{p['g']:.4f}" for p in P) + print(f" color = clamp(color * vec3({v}), 0.0, 1.0);") + elif best == "linear": + g = ", ".join(f"{p['g']:.4f}" for p in P) + o = ", ".join(f"{p['o']:.4f}" for p in P) + print(f" color = clamp(color * vec3({g}) + vec3({o}), 0.0, 1.0);") + else: + g = ", ".join(f"{p['g']:.4f}" for p in P) + e = ", ".join(f"{p['gamma']:.4f}" for p in P) + print(f" color = clamp(vec3({g}) * pow(color, vec3({e})), 0.0, 1.0);") + + if args.lut: + with open(args.lut, "w", encoding="utf-8") as f: + f.write("in,R,G,B\n") + for i in range(256): + f.write(f"{i},{luts[0][i]},{luts[1][i]},{luts[2][i]}\n") + print(f"\nLUT gespeichert: {args.lut}") + + if args.save_corrected: + x = test.astype(np.float64) / 255.0 + out = np.empty_like(x) + for ci, cname in enumerate(CHANNELS): + out[..., ci] = apply_model(best, results[cname][0][best], x[..., ci]) + Image.fromarray(np.round(out * 255.0).astype(np.uint8)).save(args.save_corrected) + print(f"Korrigiertes Testbild gespeichert: {args.save_corrected}") + + print() + print("Hinweis: Weichen die Mittelwerte stark ab, obwohl beide Bilder denselben") + print("Inhalt zeigen, ist die Korrektur sinnvoll. Sind die Bilder praktisch") + print("gleich (Verhaeltnis ~1.0, Gain ~1.0), liegt der Unterschied NICHT in") + print("den Bilddaten - dann haben beide Bilder denselben Weg genommen.") + + +if __name__ == "__main__": + main() diff --git a/uartscope_20261017_021812.png b/uartscope_20261017_021812.png new file mode 100644 index 0000000..ae52f4a Binary files /dev/null and b/uartscope_20261017_021812.png differ