263 lines
11 KiB
Python
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()
|