Files
hdmiviewer/tools/colormatch.py

263 lines
11 KiB
Python

#!/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()