1 Commits

Author SHA1 Message Date
29a86d895a Bild weiter verbessert, Icon und .desktop hinzugefügt 2026-10-17 23:39:50 +02:00
20 changed files with 1086 additions and 14 deletions

View File

@@ -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)

View File

@@ -1,12 +1,12 @@
# Maintainer: Dany Thinnes <dany@projekt-hirnfrei.de>
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')

View File

@@ -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

54
assets/hdmi-viewer.svg Normal file
View File

@@ -0,0 +1,54 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<title>HDMI Viewer</title>
<defs>
<radialGradient id="screenGlow" cx="50%" cy="40%" r="65%">
<stop offset="0%" stop-color="#9cecf7" />
<stop offset="100%" stop-color="#62b8ea" />
</radialGradient>
<clipPath id="screenClip">
<rect x="140" y="137" width="232" height="162" rx="20" ry="20" />
</clipPath>
</defs>
<!-- Hintergrund: abgerundetes Quadrat, ausserhalb transparent -->
<rect x="32" y="32" width="448" height="448" rx="96" ry="96" fill="#1e1e2e" stroke="#cdd6f4" stroke-width="12" />
<g transform="translate(0,-8)">
<!-- HDMI-Stecker (hinter dem Monitor, sitzt direkt am Gehaeuse) -->
<g id="hdmi-plug">
<!-- Gehaeuse -->
<path d="M 198 330 L 314 330 L 314 380 L 298 398 L 214 398 L 198 380 Z"
fill="#45475a" stroke="#cdd6f4" stroke-width="10" stroke-linejoin="round" />
<!-- Magenta-Akzent -->
<rect x="203" y="362" width="106" height="14" fill="#d35fd0" />
<!-- Metall-Steckfahne: flaches Trapez mit abgeschraegten unteren Ecken -->
<path d="M 220 398 L 292 398 L 292 420 L 280 434 L 232 434 L 220 420 Z"
fill="#cdd6f4" stroke="#cdd6f4" stroke-width="6" stroke-linejoin="round" />
<rect x="238" y="408" width="36" height="9" rx="2" fill="#1e1e2e" />
</g>
<!-- Roehrenmonitor: Gehaeuse -->
<rect x="112" y="112" width="288" height="228" rx="32" ry="32" fill="#181825" stroke="#cdd6f4" stroke-width="16" />
<!-- Blende -->
<rect x="128" y="124" width="256" height="188" rx="24" ry="24" fill="#313244" stroke="#11111b" stroke-width="6" />
<!-- Bildschirm -->
<rect x="140" y="137" width="232" height="162" rx="20" ry="20" fill="url(#screenGlow)" />
<g clip-path="url(#screenClip)">
<!-- 4 dicke Scanlines -->
<line x1="120" y1="164" x2="392" y2="164" stroke="#11111b" stroke-width="9" opacity="0.28" />
<line x1="120" y1="200" x2="392" y2="200" stroke="#11111b" stroke-width="9" opacity="0.28" />
<line x1="120" y1="236" x2="392" y2="236" stroke="#11111b" stroke-width="9" opacity="0.28" />
<line x1="120" y1="272" x2="392" y2="272" stroke="#11111b" stroke-width="9" opacity="0.28" />
<!-- dezenter Glanzreflex -->
<path d="M 145 142 L 262 142 L 145 222 Z" fill="#ffffff" opacity="0.10" />
<!-- Live-Punkt mit dunklem Ring -->
<circle cx="186" cy="182" r="27" fill="#11111b" />
<circle cx="186" cy="182" r="19" fill="#ff4d5e" />
<circle cx="180" cy="176" r="5" fill="#ffffff" opacity="0.6" />
</g>
<!-- Innenkante des Bildschirms -->
<rect x="140" y="137" width="232" height="162" rx="20" ry="20" fill="none" stroke="#11111b" stroke-width="5" opacity="0.4" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

11
hdmi-viewer.desktop Normal file
View File

@@ -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

74
hdmi_viewer_icon.md Normal file
View File

@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="100%" height="100%">
<defs>
<!-- Screen Gradient Glow -->
<radialGradient id="screenGlow" cx="50%" cy="40%" r="60%">
<stop offset="0%" stop-color="#a6e3a1" stop-opacity="0.1" />
<stop offset="0%" stop-color="#89dceb" />
<stop offset="100%" stop-color="#74c7ec" />
</radialGradient>
<!-- Red Dot Glow -->
<radialGradient id="redGlow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#f38ba8" />
<stop offset="100%" stop-color="#f38ba8" stop-opacity="0" />
</radialGradient>
<!-- Clip path for the screen interior (rounded CRT shape) -->
<clipPath id="screenClip">
<rect x="140" y="145" width="232" height="162" rx="20" ry="20" />
</clipPath>
</defs>
<!-- 1. Rounded Square Background (Squircle) -->
<rect x="32" y="32" width="448" height="448" rx="96" ry="96" fill="#1e1e2e" stroke="#cdd6f4" stroke-width="12" stroke-linecap="round" stroke-linejoin="round" />
<!-- 2. HDMI Plug (Positioned below the monitor frame) -->
<g id="hdmi-plug">
<!-- Outer thick lavender outline border for the plug -->
<path d="M 200 328 L 312 328 L 312 375 L 292 405 L 220 405 L 200 375 Z" fill="#cdd6f4" stroke="#cdd6f4" stroke-width="12" stroke-linejoin="round" />
<!-- Plug Body -->
<path d="M 206 334 L 306 334 L 306 372 L 288 399 L 224 399 L 206 372 Z" fill="#313244" />
<!-- HDMI Metal Connector Tip -->
<path d="M 226 399 L 286 399 L 286 422 L 278 428 L 234 428 L 226 422 Z" fill="#cdd6f4" stroke="#cdd6f4" stroke-width="4" stroke-linejoin="round" />
<path d="M 230 403 L 282 403 L 282 420 L 276 424 L 236 424 L 230 420 Z" fill="#585b70" />
<!-- Connector Details / Pins region -->
<rect x="242" y="410" width="28" height="8" rx="2" fill="#181825" />
<!-- Plug Grip Lines -->
<line x1="240" y1="352" x2="272" y2="352" stroke="#45475a" stroke-width="5" stroke-linecap="round" />
<line x1="240" y1="364" x2="272" y2="364" stroke="#45475a" stroke-width="5" stroke-linecap="round" />
<line x1="240" y1="376" x2="272" y2="376" stroke="#45475a" stroke-width="5" stroke-linecap="round" />
</g>
<!-- 3. CRT Monitor Outer Shell -->
<rect x="112" y="112" width="288" height="228" rx="32" ry="32" fill="#181825" stroke="#cdd6f4" stroke-width="14" stroke-linecap="round" stroke-linejoin="round" />
<!-- 4. Inner Bezel -->
<rect x="128" y="132" width="256" height="188" rx="24" ry="24" fill="#313244" stroke="#11111b" stroke-width="6" />
<!-- 5. CRT Display Screen -->
<rect x="140" y="145" width="232" height="162" rx="20" ry="20" fill="url(#screenGlow)" />
<!-- 6. Screen Content (Clipped to CRT screen bounds) -->
<g clip-path="url(#screenClip)">
<!-- 4 Thick Scanlines -->
<line x1="120" y1="172" x2="392" y2="172" stroke="#11111b" stroke-width="8" opacity="0.25" />
<line x1="120" y1="208" x2="392" y2="208" stroke="#11111b" stroke-width="8" opacity="0.25" />
<line x1="120" y1="244" x2="392" y2="244" stroke="#11111b" stroke-width="8" opacity="0.25" />
<line x1="120" y1="280" x2="392" y2="280" stroke="#11111b" stroke-width="8" opacity="0.25" />
<!-- Subtle Screen Reflection / Highlight Accent -->
<path d="M 145 150 L 260 150 L 145 230 Z" fill="#ffffff" opacity="0.08" />
<!-- Red Live Indicator Dot with Dark Outer Ring -->
<!-- Outer Dark Ring -->
<circle cx="182" cy="186" r="22" fill="#11111b" opacity="0.9" />
<circle cx="182" cy="186" r="22" stroke="#181825" stroke-width="3" fill="none" />
<!-- Red Inner Dot -->
<circle cx="182" cy="186" r="14" fill="#f38ba8" />
<!-- Inner Highlight on Red Dot -->
<circle cx="178" cy="182" r="4" fill="#ffffff" opacity="0.6" />
</g>
<!-- Screen Frame Inner Edge Shadow/Stroke for depth -->
<rect x="140" y="145" width="232" height="162" rx="20" ry="20" fill="none" stroke="#11111b" stroke-width="5" opacity="0.4" />
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

5
resources.qrc Normal file
View File

@@ -0,0 +1,5 @@
<RCC>
<qresource prefix="/icons">
<file alias="hdmi-viewer.svg">assets/hdmi-viewer.svg</file>
</qresource>
</RCC>

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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<int>::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<double>::of(&QDoubleSpinBox::valueChanged), this, onControlChanged);
connect(m_hwBrightnessSpin, QOverload<int>::of(&QSpinBox::valueChanged), this, onControlChanged);
connect(m_hwContrastSpin, QOverload<int>::of(&QSpinBox::valueChanged), this, onControlChanged);
connect(m_hwSaturationSpin, QOverload<int>::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<float>(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)

View File

@@ -3,8 +3,11 @@
#include <QDialog>
#include <QString>
#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;
};

View File

@@ -20,6 +20,8 @@
#include <QDir>
#include <QDateTime>
#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()
"<b>1</b>/<b>2</b> HW-Kontrast &nbsp;&nbsp; "
"<b>3</b>/<b>4</b> HW-Sättigung &nbsp;&nbsp; "
"<b>R</b> Roh-Diagnose<br>"
"<b>F1</b>–<b>F9</b> Profile<br>"
"<b>V</b> Vergleich &nbsp;&nbsp; "
"<b>P</b> Screenshot &nbsp;&nbsp; "
"<b>D</b> Frame dumpen &nbsp;&nbsp; "
@@ -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);

View File

@@ -3,12 +3,15 @@
#include <QMainWindow>
#include <QString>
#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;

168
src/Profile.cpp Normal file
View File

@@ -0,0 +1,168 @@
#include "Profile.h"
#include <QSettings>
#include <QInputDialog>
#include <QMessageBox>
#include <QtGlobal>
// ── 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<double>(sharpen));
if (sharpen > 0.0f)
s.setValue("display/sharpen_last", static_cast<double>(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<double>(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

49
src/Profile.h Normal file
View File

@@ -0,0 +1,49 @@
#pragma once
#include <QString>
#include <QStringList>
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/<Name>/...).
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);
}

View File

@@ -4,6 +4,7 @@
#include <QOpenGLTexture>
#include <QPainter>
#include <QVector2D>
#include <QDebug>
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<const char*>(glGetString(GL_RENDERER))
<< " GL_VERSION:" << reinterpret_cast<const char*>(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<float>(width());
const float viewH = static_cast<float>(height());
const float imgW = static_cast<float>(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);

View File

@@ -2,6 +2,7 @@
#include <QSettings>
#include <QMessageBox>
#include <QSurfaceFormat>
#include <QIcon>
#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;

262
tools/colormatch.py Normal file
View File

@@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB