Farbe

Farbe
Wahrnehmung
Visualisierung
R
Python
Paletten nach Aufgabe, Kontrast und Farbfehlsichtigkeit.

Kernideen

  • Farbe hat drei Aufgaben: unterscheiden, Mengen darstellen, hervorheben. Zu jeder gehört ein Palettentyp, und Verwechslungen führen in die Irre.
  • Ein Farbverlauf über nominale Kategorien behauptet eine Rangfolge.
  • Wie verschieden zwei Farben sind, lässt sich messen, im Lab-Raum, wo Abstände ungefähr der wahrgenommenen Verschiedenheit entsprechen.
  • Regenbogenpaletten sind nicht monoton in der Helligkeit: Sie erzeugen Grenzen, wo keine sind, und lassen weit entfernte Werte gleich aussehen.
  • Rot-Grün darf nie der einzige Unterscheidungskanal sein. Unter Deuteranopie fällt der Abstand zwischen Rot und Grün von 120 auf 8.
  • Kontrast ist nachrechenbar: Für Text braucht es mindestens 4.5 zu 1.

Erklärung

Vorwissen: Wahrnehmung und Kodierung, vor allem die Rangfolge der Kanäle: Farbton eignet sich zum Unterscheiden, nicht zum Ablesen von Mengen.

Drei Aufgaben, drei Palettentypen

Aufgabe Palettentyp Wann Beispiele
Unterscheiden qualitativ nominale Kategorien ohne Reihenfolge Okabe-Ito, ColorBrewer Set2
Menge darstellen sequenziell kontinuierliche Werte mit einer Richtung Viridis, Blues
Abweichung zeigen divergierend Werte mit sinnvollem Nullpunkt: Saldo, Veränderung RdBu, BrBG
Hervorheben Akzent auf Grau eine Gruppe soll auffallen alles grau, eine Serie farbig

Der häufigste Fehler ist die Verwechslung der ersten beiden. Ein Farbverlauf über Standorte suggeriert, dass Bern “weniger” ist als Zürich. Umgekehrt zerlegt eine qualitative Palette eine kontinuierliche Grösse in scheinbare Klassen.

Warum sich Farbe rechnen lässt

Der Hex-Wert einer Farbe sagt nichts darüber, wie verschieden zwei Farben aussehen: #FF0000 und #00FF00 unterscheiden sich in den Zahlen genauso stark wie #FF0000 und #FF0001. Der Lab-Raum ist so gebaut, dass der euklidische Abstand ungefähr der wahrgenommenen Verschiedenheit entspricht. Dieser Abstand heisst ΔE.

ΔE Bedeutung
unter 1 für das Auge nicht unterscheidbar
1 bis 10 erkennbar, wenn die Flächen aneinandergrenzen
10 bis 25 in einer Grafik sicher unterscheidbar
über 25 deutlich verschieden

Für eine Kategorienpalette gilt: Der kleinste paarweise Abstand entscheidet, denn an ihm scheitert das Ablesen. Beispiel 2 misst das nach.

Die Umrechnung braucht kein Zusatzpaket, sRGB zu linearem RGB, dann über XYZ nach Lab sind rund zehn Zeilen, und sie stehen im Setup dieser Seite.

Der Graustufentest

Die schnellste Prüfung: die Grafik in Graustufen ansehen. Ist sie dann nicht mehr lesbar, trägt der Farbton zu viel Last. Das betrifft nicht nur den Druck, sondern auch Projektoren, schlechte Bildschirme und eingeschränktes Farbsehen.

Rechnerisch ist “Graustufe” die relative Luminanz, die gewichtete Summe der linearisierten Kanäle mit 0.2126, 0.7152 und 0.0722. Eine sequenzielle Palette muss darin monoton sein, sonst kodiert sie Mengen falsch. Beispiel 3 zeigt, woran Regenbogenpaletten scheitern.

Farbfehlsichtigkeit

Etwa acht Prozent der Männer sehen Rot und Grün nur eingeschränkt getrennt. Das ist kein Randfall, sondern in jeder grösseren Runde jemand.

Drei Regeln decken das ab:

  1. Rot-Grün nie als einzigen Kanal. Zusätzlich Form, Position oder direkte Beschriftung einsetzen.
  2. Geprüfte Paletten verwenden. Viridis für Verläufe, Okabe-Ito für Kategorien sind dafür entworfen.
  3. Nicht auf Farbe allein hervorheben. Ein Akzent verträgt zusätzlich eine dickere Linie oder eine Beschriftung.

Die Okabe-Ito-Palette für Kategorien, als Werte zum Kopieren:

okabe_ito <- c("#E69F00", "#56B4E9", "#009E73", "#F0E442",
               "#0072B2", "#D55E00", "#CC79A7", "#000000")

Beispiele

Frage und Datenlage

Fünf Standorte ohne Reihenfolge, dargestellt mit einer sequenziellen Palette. Was genau behauptet diese Grafik, das die Daten nicht hergeben?

Rechnung

standorte <- c("Bern", "Chur", "Genf", "Luzern", "Zug")
umsatz <- c(41, 37, 52, 33, 45)

verlauf <- c("#440154", "#3E4A89", "#26828E", "#35B779", "#FDE725")
round(rbind(luminanz = sapply(verlauf, luminanz)), 3)
         #440154 #3E4A89 #26828E #35B779 #FDE725
luminanz   0.019   0.077   0.183    0.36   0.782
round(c(verhaeltnis_hellste_zu_dunkelste =
          luminanz(verlauf[5]) / luminanz(verlauf[1])), 1)
verhaeltnis_hellste_zu_dunkelste 
                            41.3 
# Die Verlaufspalette ordnet die Standorte -- nach welchem Kriterium?
data.frame(standort = standorte, umsatz = umsatz,
           luminanz = round(sapply(verlauf, luminanz), 3), row.names = NULL)
  standort umsatz luminanz
1     Bern     41    0.019
2     Chur     37    0.077
3     Genf     52    0.183
4   Luzern     33    0.360
5      Zug     45    0.782
par(mfrow = c(1, 2), mar = c(4, 4, 3, 1))
barplot(umsatz, names.arg = standorte, col = verlauf, border = NA,
        ylab = "Umsatz", main = "Verlaufspalette")
barplot(umsatz, names.arg = standorte, col = OKABE[1:5], border = NA,
        ylab = "Umsatz", main = "qualitative Palette")
par(mfrow = c(1, 1))
Abbildung 1: Beispiel 1: oben nominale Standorte mit Verlaufspalette, unten mit qualitativer.
standorte = ["Bern", "Chur", "Genf", "Luzern", "Zug"]
umsatz = np.array([41, 37, 52, 33, 45])

verlauf = ["#440154", "#3E4A89", "#26828E", "#35B779", "#FDE725"]
print({"luminanz": [round(luminanz(h), 3) for h in verlauf]})
{'luminanz': [0.019, 0.077, 0.183, 0.36, 0.782]}
print({"verhaeltnis_hellste_zu_dunkelste":
       round(luminanz(verlauf[4]) / luminanz(verlauf[0]), 1)})
{'verhaeltnis_hellste_zu_dunkelste': 41.3}
print(pd.DataFrame({"standort": standorte, "umsatz": umsatz,
                    "luminanz": [round(luminanz(h), 3) for h in verlauf]})
      .to_string(index=False))
standort  umsatz  luminanz
    Bern      41     0.019
    Chur      37     0.077
    Genf      52     0.183
  Luzern      33     0.360
     Zug      45     0.782
fig, achsen = plt.subplots(1, 2, figsize=(7, 3.4))
_ = achsen[0].bar(standorte, umsatz, color=verlauf)
_ = achsen[0].set_title("Verlaufspalette")
_ = achsen[1].bar(standorte, umsatz, color=OKABE[:5])
_ = achsen[1].set_title("qualitative Palette")
for achse in achsen:
    _ = achse.set_ylabel("Umsatz")
plt.tight_layout()
plt.show()
Abbildung 2: Beispiel 1: dieselbe Gegenüberstellung in Python.

Output Zeile für Zeile

Standort Bern Chur Genf Luzern Zug
Umsatz 41 37 52 33 45
Luminanz der Balkenfarbe 0.019 0.077 0.183 0.360 0.782

Verhältnis der hellsten zur dunkelsten Farbe: 41.3.

Beobachtung Erklärung
Die Farben werden von links nach rechts immer heller Um den Faktor 41. Das Auge liest darin eine Steigerung, die Palette behauptet eine Reihenfolge Bern < Chur < Genf < Luzern < Zug.
Diese Reihenfolge ist alphabetisch, nicht inhaltlich Sie entstand aus der Sortierung der Namen. Mit den Umsätzen hat sie nichts zu tun: Genf hat den höchsten Umsatz (52) und bekommt eine mittlere Farbe, Zug mit 45 die hellste.
Es werden also zwei Grössen dargestellt Die Balkenhöhe zeigt den Umsatz, die Farbe zeigt die alphabetische Position und nur eine der beiden ist gemeint.
Rechts trägt die Farbe keine Information mehr Und das ist richtig so: Die Standorte sind bereits durch ihre Position unterscheidbar. Farbe, die nichts kodiert, sollte gar nicht variieren.
Die ehrlichste Variante wäre einfarbig Alle Balken grau, nur der hervorgehobene farbig. Siehe Wahrnehmung, Beispiel 3.

Die Entscheidungsfrage lautet nicht “welche Farben”, sondern “wofür”.

Kodiert die Farbe … Palettentyp
nichts (Position reicht schon) keine, einfarbig oder grau
eine nominale Kategorie qualitativ
eine geordnete Grösse mit einer Richtung sequenziell
eine Abweichung von einem Nullpunkt divergierend
genau eine hervorzuhebende Gruppe Akzent auf Grau

Die erste Zeile wird am häufigsten übersehen. Ein Balkendiagramm mit fünf verschiedenfarbigen Balken hat meistens fünf Farben zu viel.

Interpretation und Ergebnissatz

Die sequenzielle Palette ordnet die fünf Standorte über eine Luminanz von 0.019 bis 0.782, also um den Faktor 41. Diese Ordnung entspricht der alphabetischen Reihenfolge der Namen und nicht dem dargestellten Umsatz.

Frage und Datenlage

Zwei Kategorienpaletten: Okabe-Ito mit acht Farben und eine Regenbogenpalette mit zwölf. Gemessen wird der kleinste paarweise Abstand im Lab-Raum, denn an ihm entscheidet sich, ob eine Legende überhaupt zuzuordnen ist.

Rechnung

paarabstaende <- function(farben, namen = seq_along(farben)) {
  paare <- t(combn(length(farben), 2))
  data.frame(a = namen[paare[, 1]], b = namen[paare[, 2]],
             dE = apply(paare, 1, function(p) abstand(farben[p[1]], farben[p[2]])))
}

okabe_ab <- paarabstaende(OKABE, OKABE_NAMEN)
head(okabe_ab[order(okabe_ab$dE), ], 3)
            a        b       dE
10 himmelblau     blau 26.43333
5      orange zinnober 33.42918
3      orange     gelb 35.09136
round(c(minimum = min(okabe_ab$dE), median = median(okabe_ab$dE),
        maximum = max(okabe_ab$dE), paare = nrow(okabe_ab)), 1)
minimum  median maximum   paare 
   26.4    83.2   125.0    28.0 
regen_ab <- paarabstaende(REGEN12)
head(regen_ab[order(regen_ab$dE), ], 3)
   a  b       dE
61 9 10 17.38086
31 4  5 18.71166
39 5  6 37.81341
round(c(minimum = min(regen_ab$dE), median = median(regen_ab$dE),
        paare_unter_20 = sum(regen_ab$dE < 20), paare = nrow(regen_ab)), 1)
       minimum         median paare_unter_20          paare 
          17.4          145.8            2.0           66.0 
par(mfrow = c(2, 1), mar = c(1, 1, 2.5, 1))
image(matrix(1:8, ncol = 1), col = OKABE, axes = FALSE,
      main = "Okabe-Ito (8 Farben)")
image(matrix(1:12, ncol = 1), col = REGEN12, axes = FALSE,
      main = "Regenbogen (12 Farben)")
par(mfrow = c(1, 1))
Abbildung 3: Beispiel 2: Okabe-Ito mit acht Farben, Regenbogen mit zwölf.
def paarabstaende(farben, namen=None):
    namen = namen if namen is not None else list(range(1, len(farben) + 1))
    return pd.DataFrame([{"a": namen[i], "b": namen[j],
                          "dE": abstand(farben[i], farben[j])}
                         for i, j in itertools.combinations(range(len(farben)), 2)])


okabe_ab = paarabstaende(OKABE, OKABE_NAMEN)
print(okabe_ab.nsmallest(3, "dE").to_string(index=False))
print({"minimum": round(okabe_ab["dE"].min(), 1),
       "median": round(okabe_ab["dE"].median(), 1),
       "maximum": round(okabe_ab["dE"].max(), 1),
       "paare": len(okabe_ab)})

regen_ab = paarabstaende(REGEN12)
print(regen_ab.nsmallest(3, "dE").to_string(index=False))
print({"minimum": round(regen_ab["dE"].min(), 1),
       "median": round(regen_ab["dE"].median(), 1),
       "paare_unter_20": int((regen_ab["dE"] < 20).sum()),
       "paare": len(regen_ab)})

fig, achsen = plt.subplots(2, 1, figsize=(7, 2.6))
for achse, (titel, pal) in zip(achsen, [("Okabe-Ito (8 Farben)", OKABE),
                                        ("Regenbogen (12 Farben)", REGEN12)]):
    for k, farbe in enumerate(pal):
        _ = achse.bar(k, 1, width=1, color=farbe)
    _ = achse.set_title(titel, fontsize=10)
    _ = achse.axis("off")
plt.tight_layout()
plt.show()
Abbildung 4: Beispiel 2: dieselben zwei Paletten in Python.

Output Zeile für Zeile

Okabe-Ito, die drei engsten Paare:

Paar ΔE
himmelblau, blau 26.4
orange, zinnober 33.4
orange, gelb 35.1
Palette Minimum Median Paare darunter unter ΔE 20
Okabe-Ito (8) 26.4 83.2 28 0
Regenbogen (12) 17.4 66 2
Beobachtung Erklärung
Okabe-Ito hält überall mindestens ΔE 26 ein Auch das engste Paar liegt über der Schwelle von 25, ab der Farben “deutlich verschieden” sind. Die Palette ist genau darauf hin entworfen.
Der Median liegt bei 83 Die meisten Paare sind weit auseinander. Das ist der Spielraum, der die acht Farben trägt.
Der Regenbogen mit zwölf Farben fällt auf 17.4 Und zwei Paare liegen unter 20. Das ist noch unterscheidbar, wenn die Flächen gross sind, in einer Legende mit kleinen Quadraten nicht mehr zuverlässig.
Das engste Paar ist blau und violett Position 9 und 10 der Palette, also benachbarte Farbtöne. Bei zwölf Stufen auf einem Farbkreis liegen die Nachbarn zwangsläufig eng.
Mehr Kategorien heisst immer engere Abstände Der Farbraum ist endlich. Deshalb endet Farbe als Unterscheidungskanal bei ungefähr acht, nicht wegen einer Konvention, sondern wegen der Geometrie.

Der kleinste Abstand ist das Mass, nicht der Durchschnitt. Eine Palette mit einem Median von 80 und einem Minimum von 5 ist unbrauchbar: An genau dem einen Paar scheitert jede Zuordnung, und das Publikum weiss nicht, welches Paar es war.

Deshalb lohnt sich diese Rechnung vor jeder selbstgebauten Palette, sie kostet fünf Zeilen und beantwortet die Frage, die man sonst nur raten kann.

Interpretation und Ergebnissatz

Die Okabe-Ito-Palette hält bei acht Farben einen kleinsten paarweisen Lab-Abstand von 26.4 ein, bei einem Median von 83.2. Eine Regenbogenpalette mit zwölf Farben fällt auf 17.4, und zwei ihrer 66 Paare liegen unter ΔE 20.

Frage und Datenlage

Zwei sequenzielle Paletten mit je zehn Stufen: Viridis und Regenbogen. Eine sequenzielle Palette muss in der Helligkeit monoton sein, sonst stehen verschiedene Werte für dieselbe Graustufe.

Rechnung

for (nm in c("VIRIDIS10", "REGEN10")) {
  L <- sapply(get(nm), luminanz)
  d <- diff(L)
  cat(sprintf("%-10s Luminanz %s\n", nm,
              paste(sprintf("%.3f", L), collapse = " ")))
  cat(sprintf("           %s, Richtungswechsel %d, Spanne %.3f bis %.3f\n",
              if (all(d > 0)) "monoton steigend" else "NICHT monoton",
              sum(sign(d[-length(d)]) * sign(d[-1]) < 0), min(L), max(L)))
}
VIRIDIS10  Luminanz 0.019 0.043 0.077 0.125 0.183 0.266 0.360 0.476 0.621 0.782
           monoton steigend, Richtungswechsel 0, Spanne 0.019 bis 0.782
REGEN10    Luminanz 0.213 0.440 0.844 0.722 0.725 0.787 0.167 0.079 0.201 0.236
           NICHT monoton, Richtungswechsel 4, Spanne 0.079 bis 0.844
# Welche Stufen des Regenbogens werden in Graustufen ununterscheidbar?
L <- sapply(REGEN10, luminanz)
paare <- t(combn(10, 2))
koll <- data.frame(a = paare[, 1], b = paare[, 2],
                   unterschied = abs(L[paare[, 1]] - L[paare[, 2]]))
head(koll[order(koll$unterschied), ], 3)
   a  b unterschied
25 4  5 0.002555019
8  1  9 0.012026308
9  1 10 0.022999077
c(paare_unter_0.02 = sum(koll$unterschied < 0.02), paare = nrow(koll))
paare_unter_0.02            paare 
               2               45 
grau <- function(farben) {
  sapply(farben, function(h) {
    g <- luminanz(h)^(1 / 2.2)
    rgb(g, g, g)
  })
}
par(mfrow = c(2, 2), mar = c(1, 1, 2.5, 1))
image(matrix(1:10, ncol = 1), col = VIRIDIS10, axes = FALSE, main = "Viridis")
image(matrix(1:10, ncol = 1), col = REGEN10, axes = FALSE, main = "Regenbogen")
image(matrix(1:10, ncol = 1), col = grau(VIRIDIS10), axes = FALSE,
      main = "Viridis in Graustufen")
image(matrix(1:10, ncol = 1), col = grau(REGEN10), axes = FALSE,
      main = "Regenbogen in Graustufen")
par(mfrow = c(1, 1))
Abbildung 5: Beispiel 3: beide Paletten in Farbe und als Graustufe.
for nm, pal in [("VIRIDIS10", VIRIDIS10), ("REGEN10", REGEN10)]:
    L = np.array([luminanz(h) for h in pal])
    d = np.diff(L)
    print(f"{nm:<10} Luminanz " + " ".join(f"{v:.3f}" for v in L))
    print(f"           {'monoton steigend' if (d > 0).all() else 'NICHT monoton'},"
          f" Richtungswechsel {int(np.sum(np.sign(d[:-1]) * np.sign(d[1:]) < 0))},"
          f" Spanne {L.min():.3f} bis {L.max():.3f}")
VIRIDIS10  Luminanz 0.019 0.043 0.077 0.125 0.183 0.266 0.360 0.476 0.621 0.782
           monoton steigend, Richtungswechsel 0, Spanne 0.019 bis 0.782
REGEN10    Luminanz 0.213 0.440 0.844 0.722 0.725 0.787 0.167 0.079 0.201 0.236
           NICHT monoton, Richtungswechsel 4, Spanne 0.079 bis 0.844
L = np.array([luminanz(h) for h in REGEN10])
koll = pd.DataFrame([{"a": i + 1, "b": j + 1, "unterschied": abs(L[i] - L[j])}
                     for i, j in itertools.combinations(range(10), 2)])
print(koll.nsmallest(3, "unterschied").to_string(index=False))
 a  b  unterschied
 4  5     0.002555
 1  9     0.012026
 1 10     0.022999
print({"paare_unter_0.02": int((koll["unterschied"] < 0.02).sum()),
       "paare": len(koll)})
{'paare_unter_0.02': 2, 'paare': 45}
def grau(farben):
    return [str(luminanz(h) ** (1 / 2.2)) for h in farben]


fig, achsen = plt.subplots(2, 2, figsize=(7, 3.4))
for achse, (titel, pal) in zip(achsen.ravel(),
                               [("Viridis", VIRIDIS10),
                                ("Regenbogen", REGEN10),
                                ("Viridis in Graustufen", grau(VIRIDIS10)),
                                ("Regenbogen in Graustufen", grau(REGEN10))]):
    for k, farbe in enumerate(pal):
        _ = achse.bar(k, 1, width=1, color=farbe)
    _ = achse.set_title(titel, fontsize=9)
    _ = achse.axis("off")
plt.tight_layout()
plt.show()
Abbildung 6: Beispiel 3: dieselbe Gegenüberstellung in Python.

Output Zeile für Zeile

Palette Luminanz der zehn Stufen
Viridis 0.019 · 0.043 · 0.077 · 0.125 · 0.183 · 0.266 · 0.360 · 0.476 · 0.621 · 0.782
Regenbogen 0.213 · 0.440 · 0.844 · 0.722 · 0.725 · 0.787 · 0.167 · 0.079 · 0.201 · 0.236
Palette monoton? Richtungswechsel Spanne
Viridis ja 0 0.019 bis 0.782
Regenbogen nein 4 0.079 bis 0.844

Die engsten Graustufen-Paare im Regenbogen:

Stufen Luminanzunterschied Werte
4 und 5 0.0026 0.722 / 0.725
1 und 9 0.0120 0.213 / 0.201
1 und 10 0.0230 0.213 / 0.236
Beobachtung Erklärung
Viridis steigt durchgehend Zehn Stufen, kein Richtungswechsel. In Graustufen bleibt die Reihenfolge vollständig erhalten, der Farbverlauf ist Zierde, die Information steckt in der Helligkeit.
Der Regenbogen wechselt viermal die Richtung Er wird hell, dunkel, wieder hell. Eine Menge, die gleichmässig wächst, erscheint darin als Auf und Ab.
Die Stufen 4 und 5 trennen 0.0026 Luminanz Das ist in Graustufen dieselbe Farbe. Zwei verschiedene Werte, ein Grauton.
Stufe 1 und 9 sind fast gleich hell 0.213 gegen 0.201, also Rot und Violett, die beiden Enden der Skala. Wer die Grafik in Graustufen sieht, hält Minimum und Maximum für denselben Wert.
Das hellste Feld liegt in der Mitte Stufe 3 mit 0.844, Gelb. Ein Regenbogen hat seinen Helligkeitsgipfel dort, wo inhaltlich nichts Besonderes ist und erzeugt so eine Grenze, die in den Daten nicht existiert.

Warum Regenbogenpaletten trotzdem überall sind: Sie sehen bunt und “vollständig” aus, und sie waren jahrzehntelang die Voreinstellung. Ihre drei Probleme sind aber alle messbar:

Problem Messung auf dieser Seite
nicht monoton in der Helligkeit 4 Richtungswechsel
erzeugt scheinbare Grenzen Helligkeitsgipfel bei Gelb, Stufe 3 von 10
Enden sehen gleich aus Stufe 1 und 9 unterscheiden sich um 0.012 Luminanz

Viridis, Cividis, Magma und Inferno sind gegen alle drei gebaut. In R über hcl.colors(n, "Viridis"), in Python über cmap="viridis".

Interpretation und Ergebnissatz

Viridis steigt über zehn Stufen monoton von einer Luminanz 0.019 auf 0.782 ohne Richtungswechsel. Die Regenbogenpalette wechselt viermal die Richtung; ihre Stufen 4 und 5 unterscheiden sich um 0.0026 Luminanz und ihre Stufen 1 und 9, die beiden Enden, um 0.0120.

Frage und Datenlage

Drei Farbpaare, jedes für sich gut unterscheidbar. Simuliert wird Deuteranopie über die Transformation im LMS-Raum, danach wird derselbe Lab-Abstand noch einmal gemessen.

Rechnung

paare <- list(c("#D62728", "#2CA02C", "rot / gruen"),
              c("#E69F00", "#56B4E9", "Okabe orange / himmelblau"),
              c("#009E73", "#D55E00", "Okabe gruen / zinnober"))

for (p in paare) {
  cat(sprintf("%-28s normal %6.1f   deuteranop %6.1f\n",
              p[3], abstand(p[1], p[2]), abstand_deuteranop(p[1], p[2])))
}
rot / gruen                  normal  119.8   deuteranop    8.2
Okabe orange / himmelblau    normal  113.1   deuteranop  116.2
Okabe gruen / zinnober       normal  102.4   deuteranop   53.1
# Ganze Paletten im Vergleich
vergleich <- function(farben) {
  idx <- t(combn(length(farben), 2))
  d <- apply(idx, 1, function(p) abstand_deuteranop(farben[p[1]], farben[p[2]]))
  c(minimum = min(d), paare_unter_10 = sum(d < 10), paare = length(d))
}
round(rbind(Okabe = vergleich(OKABE), Regenbogen12 = vergleich(REGEN12)), 1)
             minimum paare_unter_10 paare
Okabe           17.2              0    28
Regenbogen12     4.9              1    66
als_hex <- function(h) {
  v <- deuteranop(h)
  rgb(v[1]^(1 / 2.2), v[2]^(1 / 2.2), v[3]^(1 / 2.2))
}
par(mfrow = c(2, 1), mar = c(1, 1, 2.5, 1))
image(matrix(1:8, ncol = 1), col = OKABE, axes = FALSE, main = "Okabe-Ito normal")
image(matrix(1:8, ncol = 1), col = sapply(OKABE, als_hex), axes = FALSE,
      main = "Okabe-Ito bei Deuteranopie")
par(mfrow = c(1, 1))
Abbildung 7: Beispiel 4: oben die Originalfarben, unten wie sie bei Deuteranopie erscheinen.
paare = [("#D62728", "#2CA02C", "rot / gruen"),
         ("#E69F00", "#56B4E9", "Okabe orange / himmelblau"),
         ("#009E73", "#D55E00", "Okabe gruen / zinnober")]

for a, b, name in paare:
    print(f"{name:<28} normal {abstand(a, b):6.1f}"
          f"   deuteranop {abstand_deuteranop(a, b):6.1f}")
rot / gruen                  normal  119.8   deuteranop    8.2
Okabe orange / himmelblau    normal  113.1   deuteranop  116.2
Okabe gruen / zinnober       normal  102.4   deuteranop   53.1
def vergleich(farben):
    d = [abstand_deuteranop(farben[i], farben[j])
         for i, j in itertools.combinations(range(len(farben)), 2)]
    return {"minimum": min(d), "paare_unter_10": sum(1 for x in d if x < 10),
            "paare": len(d)}


print(pd.DataFrame({"Okabe": vergleich(OKABE),
                    "Regenbogen12": vergleich(REGEN12)}).round(1).T)
              minimum  paare_unter_10  paare
Okabe            17.2             0.0   28.0
Regenbogen12      4.9             1.0   66.0
def als_hex(h):
    v = deuteranop(h) ** (1 / 2.2)
    return "#%02X%02X%02X" % tuple(int(round(x * 255)) for x in v)


fig, achsen = plt.subplots(2, 1, figsize=(7, 3))
for achse, (titel, pal) in zip(achsen,
                               [("Okabe-Ito normal", OKABE),
                                ("Okabe-Ito bei Deuteranopie",
                                 [als_hex(h) for h in OKABE])]):
    for k, farbe in enumerate(pal):
        _ = achse.bar(k, 1, width=1, color=farbe)
    _ = achse.set_title(titel, fontsize=10)
    _ = achse.axis("off")
plt.tight_layout()
plt.show()
Abbildung 8: Beispiel 4: dieselbe Simulation in Python.

Output Zeile für Zeile

Farbpaar ΔE normal ΔE bei Deuteranopie
rot / grün (matplotlib-Standardfarben) 119.8 8.2
Okabe orange / himmelblau 113.1 116.2
Okabe grün / zinnober 102.4 53.1
Palette kleinster Abstand bei Deuteranopie Paare unter ΔE 10
Okabe-Ito (28 Paare) 17.2 0
Regenbogen 12 (66 Paare) 4.9 1
Beobachtung Erklärung
Rot und Grün fallen von 119.8 auf 8.2 Ein Abstand unter 10 heisst: nur bei direkt aneinandergrenzenden Flächen erkennbar. In einer Grafik mit getrennten Linien sind die beiden Serien nicht mehr auseinanderzuhalten.
Das sind die Standardfarben vieler Werkzeuge #D62728 und #2CA02C sind Farbe 4 und 3 der matplotlib-Standardpalette. Wer nichts einstellt, bekommt genau dieses Paar.
Okabe orange / himmelblau bleibt bei 116 Praktisch unverändert. Die Palette ist so entworfen, dass sie sich in der Blau-Gelb-Richtung unterscheidet, die von der Rot-Grün-Schwäche nicht betroffen ist.
Okabe grün / zinnober halbiert sich auf 53.1 Auch die geprüfte Palette verliert Abstand, aber sie bleibt weit über der Erkennbarkeitsschwelle.
Über die ganze Palette: kein einziges Okabe-Paar unter ΔE 10 Gegen ein Paar beim Regenbogen mit zwölf Farben, dessen kleinster Abstand auf 4.9 fällt.

Was daraus folgt, ist nicht “keine roten und grünen Farben mehr”. Rot und Grün dürfen vorkommen, sie dürfen nur nicht allein die Unterscheidung tragen.

Situation Lösung
zwei Linien verschiedene Linienarten plus direkte Beschriftung
Ampelfarben in einer Tabelle zusätzlich ein Symbol (▲ ▼) oder Text
Kategorien in einem Streudiagramm zusätzlich die Punktform
Verlauf Viridis statt Rot-Grün-Skala

Die Prüfung kostet wenig: Diese zehn Zeilen Code auf die eigene Palette anwenden und schauen, ob ein Paar unter ΔE 10 fällt.

Interpretation und Ergebnissatz

Unter simulierter Deuteranopie fällt der Lab-Abstand zwischen dem Standard-Rot und -Grün von 119.8 auf 8.2 und damit unter die Erkennbarkeitsschwelle. Die Okabe-Ito-Palette behält über alle 28 Paare mindestens 17.2, während eine Regenbogenpalette mit zwölf Farben auf 4.9 fällt.

Frage und Datenlage

Welche der verwendeten Farben taugen für Text, welche nur für Flächen? Das Kontrastverhältnis nach WCAG beantwortet das mit einer Zahl: mindestens 4.5 zu 1 für normalen Text, 3 zu 1 für grosse Schrift.

Rechnung

farben <- c(Akzentrot = akzent, "Grau hell" = "#c7c7c7", "Grau mittel" = "#767676",
            "Okabe gelb" = "#F0E442", "Okabe blau" = "#0072B2",
            "Okabe himmelblau" = "#56B4E9", Schwarz = "#000000")

urteil <- function(k) {
  if (k >= 4.5) "Text ok" else if (k >= 3) "nur grosse Schrift" else "nur Fläche"
}
for (nm in names(farben)) {
  k <- kontrast(farben[[nm]], "#FFFFFF")
  cat(sprintf("%-18s %5.2f : 1   %s\n", nm, k, urteil(k)))
}
Akzentrot           6.87 : 1   Text ok
Grau hell           1.69 : 1   nur Fläche
Grau mittel         4.54 : 1   Text ok
Okabe gelb          1.32 : 1   nur Fläche
Okabe blau          5.19 : 1   Text ok
Okabe himmelblau    2.31 : 1   nur Fläche
Schwarz            21.00 : 1   Text ok
round(c(akzent_auf_hellgrau = kontrast(akzent, "#c7c7c7"),
        akzent_auf_mittelgrau = kontrast(akzent, "#767676")), 2)
  akzent_auf_hellgrau akzent_auf_mittelgrau 
                 4.06                  1.51 
par(mar = c(1, 1, 2, 1))
plot(NA, xlim = c(0, 10), ylim = c(0, 8), axes = FALSE, xlab = "", ylab = "",
     main = "Textfarben auf weissem Grund")
for (i in seq_along(farben)) {
  k <- kontrast(farben[[i]], "#FFFFFF")
  text(1, 8 - i, sprintf("%s, Kontrast %.2f : 1", names(farben)[i], k),
       col = farben[[i]], adj = 0, cex = 1.1)
}
Abbildung 9: Beispiel 5: dieselben Farben als Textfarbe auf Weiss.
farben = {"Akzentrot": AKZENT, "Grau hell": "#c7c7c7", "Grau mittel": "#767676",
          "Okabe gelb": "#F0E442", "Okabe blau": "#0072B2",
          "Okabe himmelblau": "#56B4E9", "Schwarz": "#000000"}


def urteil(k):
    if k >= 4.5:
        return "Text ok"
    return "nur grosse Schrift" if k >= 3 else "nur Fläche"


for nm, h in farben.items():
    k = kontrast(h, "#FFFFFF")
    print(f"{nm:<18} {k:5.2f} : 1   {urteil(k)}")
Akzentrot           6.87 : 1   Text ok
Grau hell           1.69 : 1   nur Fläche
Grau mittel         4.54 : 1   Text ok
Okabe gelb          1.32 : 1   nur Fläche
Okabe blau          5.19 : 1   Text ok
Okabe himmelblau    2.31 : 1   nur Fläche
Schwarz            21.00 : 1   Text ok
print({"akzent_auf_hellgrau": round(kontrast(AKZENT, "#c7c7c7"), 2),
       "akzent_auf_mittelgrau": round(kontrast(AKZENT, "#767676"), 2)})
{'akzent_auf_hellgrau': 4.06, 'akzent_auf_mittelgrau': 1.51}
fig, achse = plt.subplots(figsize=(7, 3))
for i, (nm, h) in enumerate(farben.items()):
    k = kontrast(h, "#FFFFFF")
    _ = achse.text(0.02, 0.92 - i * 0.13, f"{nm}, Kontrast {k:.2f} : 1",
                   color=h, fontsize=11, transform=achse.transAxes)
_ = achse.set_title("Textfarben auf weissem Grund")
_ = achse.axis("off")
plt.tight_layout()
plt.show()
Abbildung 10: Beispiel 5: dieselbe Darstellung in Python.

Output Zeile für Zeile

Farbe Kontrast gegen Weiss Urteil
Schwarz 21.00 : 1 Text ok
Akzentrot #b2182b 6.87 : 1 Text ok
Okabe blau #0072B2 5.19 : 1 Text ok
Grau mittel #767676 4.54 : 1 Text ok, knapp
Okabe himmelblau #56B4E9 2.31 : 1 nur Fläche
Grau hell #c7c7c7 1.69 : 1 nur Fläche
Okabe gelb #F0E442 1.32 : 1 nur Fläche
Kombination Kontrast
Akzentrot auf hellem Grau #c7c7c7 4.06 : 1
Akzentrot auf mittlerem Grau #767676 1.51 : 1
Beobachtung Erklärung
Okabe gelb erreicht nur 1.32 zu 1 Als Balkenfläche gut, als Beschriftung unlesbar. Eine Palette für Flächen ist keine Palette für Text.
Das mittlere Grau liegt mit 4.54 knapp über der Schwelle #767676 ist das hellste Grau, das noch als Text durchgeht. Alles Hellere gehört in Flächen oder Hilfslinien.
Das Akzentrot taugt für beides 6.87 zu 1 gegen Weiss. Deshalb funktioniert es sowohl für einen hervorgehobenen Balken als auch für die zugehörige Beschriftung.
Akzentrot auf mittlerem Grau ist mit 1.51 unbrauchbar Der Kontrast gilt immer gegen den tatsächlichen Hintergrund. Eine Farbe, die auf Weiss funktioniert, kann auf grauem Grund verschwinden.
Die Schwelle 4.5 ist keine Geschmacksfrage Sie stammt aus den WCAG-Richtlinien und ist der Wert, ab dem Text auch bei eingeschränktem Sehvermögen lesbar bleibt.

Die Formel steht in drei Zeilen: relative Luminanz beider Farben bestimmen, heller und dunkler sortieren, dann

\[\text{Kontrast} = \frac{L_{\text{hell}} + 0.05}{L_{\text{dunkel}} + 0.05}\]

Der Wertebereich reicht von 1 zu 1 (identisch) bis 21 zu 1 (Schwarz auf Weiss).

Verwendung Mindestkontrast
normaler Text 4.5 : 1
grosse Schrift ab etwa 18 pt 3 : 1
Bedienelemente, Diagrammlinien 3 : 1
reine Dekoration keine Anforderung

Interpretation und Ergebnissatz

Von den sieben geprüften Farben erreichen vier den für Text nötigen Kontrast von 4.5 zu 1 gegen Weiss; Okabe gelb liegt mit 1.32 zu 1 weit darunter und taugt nur für Flächen. Das Akzentrot erreicht 6.87 zu 1 gegen Weiss, aber nur 1.51 zu 1 gegen mittleres Grau, der Kontrast gilt immer gegen den tatsächlichen Hintergrund.

Verständnisfragen

Eine Karte färbt Kantone nach Produktgruppe mit einer Verlaufsfarbskala von Hellblau bis Dunkelblau. Was stimmt daran nicht?

Ein Verlauf behauptet eine Reihenfolge, die es bei Produktgruppen nicht gibt
Richtig. Nominale Kategorien brauchen eine qualitative Palette; der Verlauf legt nahe, dass dunkleres Blau “mehr” bedeutet.
Blau ist für Karten ungeeignet
Die Farbwahl selbst ist unproblematisch, der Palettentyp ist es nicht.
Es fehlt eine Legende
Auch mit Legende bliebe die falsche Suggestion.

Woran misst man, ob eine Kategorienpalette genug Farben hergibt?

Am kleinsten paarweisen Abstand im Lab-Raum
Richtig. Am engsten Paar scheitert die Zuordnung. Okabe-Ito hält bei acht Farben mindestens ΔE 26.4, eine Regenbogenpalette mit zwölf fällt auf 17.4.
Am durchschnittlichen Abstand
Ein hoher Durchschnitt nützt nichts, wenn ein einzelnes Paar ununterscheidbar ist.
An der Zahl der Farben allein
Entscheidend ist, wie weit sie auseinanderliegen, nicht wie viele es sind.

Warum taugt eine Regenbogenpalette nicht für eine kontinuierliche Grösse?

Ihre Helligkeit ist nicht monoton, sie wechselt viermal die Richtung
Richtig. Der Helligkeitsgipfel liegt bei Gelb in der Mitte und erzeugt dort eine Grenze, die in den Daten nicht existiert. Zusätzlich sind die Stufen 1 und 9 fast gleich hell.
Weil sie zu bunt aussieht
Das ist Geschmack; die Probleme sind messbar.
Weil sie zu wenige Stufen hat
Die Zahl der Stufen ist frei wählbar und nicht das Problem.

Der Lab-Abstand zwischen dem Standard-Rot und -Grün fällt unter simulierter Deuteranopie von 119.8 auf 8.2. Was folgt daraus?

Farbe darf nicht der einzige Unterscheidungskanal sein
Richtig. Ein Abstand unter 10 ist nur bei aneinandergrenzenden Flächen erkennbar. Zusätzlich braucht es Linienart, Form oder direkte Beschriftung.
Rot und Grün dürfen nie zusammen vorkommen
Sie dürfen, solange ein zweiter Kanal die Unterscheidung trägt.
Man sollte grundsätzlich in Graustufen arbeiten
Das wäre eine Überreaktion; geprüfte Paletten wie Okabe-Ito bleiben auch unter Deuteranopie über ΔE 17.

Okabe gelb hat gegen Weiss einen Kontrast von 1.32 zu 1. Was heisst das?

Als Fläche brauchbar, als Textfarbe unlesbar
Richtig. Für Text verlangen die WCAG-Richtlinien mindestens 4.5 zu 1. Eine Palette für Flächen ist keine Palette für Beschriftungen.
Die Farbe ist grundsätzlich unbrauchbar
Als Balkenfläche mit danebenstehender schwarzer Beschriftung funktioniert sie gut.
Der Wert ist zu klein, also ist der Kontrast hoch
Umgekehrt: 1 zu 1 heisst identisch, 21 zu 1 ist Schwarz auf Weiss.

Das Akzentrot erreicht 6.87 zu 1 gegen Weiss, aber nur 1.51 zu 1 gegen mittleres Grau. Was ist die Lehre?

Kontrast gilt immer gegen den tatsächlichen Hintergrund
Richtig. Eine Farbe ist nicht für sich lesbar oder unlesbar, sondern nur im Paar. Wer den Hintergrund ändert, muss neu rechnen.
Das Akzentrot ist schlecht gewählt
Gegen Weiss ist es gut geeignet; auf mittelgrauem Grund wäre jede mittelhelle Farbe problematisch.
Graue Hintergründe sind zu vermeiden
Sie sind brauchbar, solange die Vordergrundfarben dazu passend gewählt werden.

Verlinkte Ressourcen