Concernant l’agnosticisme des espaces colorimétriques des moteurs de rendu

Ce document est la traduction du billet des développeurs de Colour : About Rendering Engines Colourspaces Agnosticism. Il date du 17 septembre 2014 et se veut une analyse des problèmes que posent les primaires RGB AP0 du standard SMPTE ST 2065-1, dit ACES (ou ACES2065-1), dans le but de permettre d’établir des primaires plus adaptées à l’industrie des effets spéciaux qui deviendront les primaires RGB AP1, définis dans le standard S-2014-004, dit ACEScg qui sortira le 19 décembre 2014.

Une fois ce document lu, vous devriez être en mesure de comprendre pourquoi ACEScg est nécessaire.

La liste des standards qui définissent ACES sont disponibles ici.

Introduction

Beaucoup de gens, y compris moi-même, assument que les moteurs de rendu sont indépendants des espaces colorimétriques, et que quelles que soient vos primaires, cela ne devrait pas faire de différence.

Un fil intéressant a été lancé par Steve Agland avec certains vétérans de l’industrie des effets visuels sur Academy Color Encoding System.

Il y décrivait des problèmes en rendant en espace colorimétriques RGB ACES où les équations de rendu pouvaient générer des couleurs très saturée, coupées, et une perte de détails. Depuis que nous avons commencé ce cahier, Steve a écrit un article illustrant ces problèmes, je vous suggère de jeter un œil à son cahier.

Pendant que nous discutions, Zap 'Master' Anderson a fait remarquer qu’il supposait que les primaires n’auraient pas d’importance mais que c’était faux.

La version TL;PL; de Rick Sayre de Pixar :

Les vecteurs de base RGB devienent non-orthogonaux1 une fois transformée en XYZ, et plus particulièrement dans ce cas. Il n’est donc pas surprenant que la multiplication par composants ne donne pas une transformation correcte entre deux espaces non-orthogonaux

L’agnosticisme des espaces colorimétriques

Nous allons démontrer le problème dans la section suivante, notez qu’il ne concerne pas seulement le rendu mais le compositing, etc. Elle Stone a par exemple écrit un article sur ce problème dans le contexte de l’édition d’images dans un espace colorimétrique sRGB non borné.

Nous choisissons des couleurs de référence dans l’espace colorimétrique CIE XYZ (dark skin et green) de ColorChecker 20052. Une couleur unique suffit à révéler le problème, mais nous en utiliserons deux afin d’être plus proche du contexte de rendu où plusieurs couleurs interagissent entre elles. On peut par exemple supposer que dark skin est une texture et green une lumière.

Nous les convertissons en espaces couleurs sRVB et Adobe RVB 1998. Nous avons choisi ces 2 espaces colorimétriques, car ils ont le même point blanc et donc ne tiennent pas compte de l’adaptation chromatique, supprimant du même coup un problème de l’équation pour qu’elle soit plus facile à comprendre :

%matplotlib inline
import numpy as np
import pylab

import colour
from colour.utilities.verbose import message_box

name, data, illuminant = colour.COLOURCHECKERS['ColorChecker 2005']

sRGB_w = colour.sRGB_COLOURSPACE.whitepoint
sRGB_XYZ_to_RGB = colour.sRGB_COLOURSPACE.XYZ_to_RGB_matrix
sRGB_RGB_to_XYZ = colour.sRGB_COLOURSPACE.RGB_to_XYZ_matrix
adobe98_w = colour.ADOBE_RGB_1998_COLOURSPACE.whitepoint
adobe98_XYZ_to_RGB = colour.ADOBE_RGB_1998_COLOURSPACE.XYZ_to_RGB_matrix
adobe98_RGB_to_XYZ = colour.ADOBE_RGB_1998_COLOURSPACE.RGB_to_XYZ_matrix

# Pépare la couleur *dark skin* en différents espaces colorimétriques.
index, name, x, y, Y = data[0]
XYZ_r1 = colour.xyY_to_XYZ((x, y, Y))

# Les valeurs 0-255 de l’espace colorimétrique *sRGB* avec l’OETF appliquée.
sRGB_rd1 = colour.XYZ_to_sRGB(XYZ_r1, illuminant)

# Les valeurs linéaires de l’espace colorimétrique *sRGB*.
sRGB_r1 = colour.XYZ_to_RGB(XYZ_r1,
                            illuminant,
                            sRGB_w,
                            sRGB_XYZ_to_RGB)

# Les valeurs linéaires de l’espace colorimétrique *Adobe RGB 1998*.
adobe98_r1 = colour.XYZ_to_RGB(XYZ_r1,
                               illuminant,
                               adobe98_w,
                               adobe98_XYZ_to_RGB)

message_box(('Reference "dark skin" "CIE XYZ" colourspace tristimulus '
             'values:\n'
             '\t{0}\n'
             '\n"sRGB" colourspace values (OETF):\n'
             '\n\t{1}\n'
             '\n"sRGB" and "Adobe RGB 1998" colourspaces (Linear):\n'
             '\tsRGB: {2}\n\tAdobe RGB 1998: {3}').format(
    XYZ_r1,
    np.around(sRGB_rd1 * 255),
    sRGB_r1,
    adobe98_r1))

# Pépare la couleur *verte* en différents espaces colorimétriques.
index, name, x, y, Y = data[13]
XYZ_r2 = colour.xyY_to_XYZ((x, y, Y))

# Les valeurs 0-255 de l’espace colorimétrique *sRGB* avec l’OETF appliquée.
sRGB_rd2 = colour.XYZ_to_sRGB(XYZ_r2, illuminant)

# Les valeurs linéaires de l’espace colorimétrique *sRGB*.
sRGB_r2 = colour.XYZ_to_RGB(XYZ_r2,
                            illuminant,
                            sRGB_w,
                            sRGB_XYZ_to_RGB)

# Les valeurs linéaires de l’espace colorimétrique *Adobe RGB 1998*.
adobe98_r2 = colour.XYZ_to_RGB(XYZ_r2,
                               illuminant,
                               adobe98_w,
                               adobe98_XYZ_to_RGB)

message_box(('Reference "green" "CIE XYZ" colourspace tristimulus '
             'values:\n'
             '\t{0}\n'
             '\n"sRGB" colourspace values (OETF):\n'
             '\t{1}\n'
             '\n"sRGB" and "Adobe RGB 1998" colourspaces (Linear):\n'
             '\tsRGB: {2}\n\tAdobe RGB 1998: {3}').format(
    XYZ_r2,
    np.around(sRGB_rd2 * 255),
    sRGB_r2,
    adobe98_r2))

===============================================================================
*                                                                             *
*   Reference "dark skin" "CIE XYZ" colourspace tristimulus values:           *
*           [ 0.11518475  0.1008      0.05089373]                             *
*                                                                             *
*   "sRGB" colourspace values (OETF):                                         *
*                                                                             *
*           [ 115.   81.   68.]                                               *
*                                                                             *
*   "sRGB" and "Adobe RGB 1998" colourspaces (Linear):                        *
*           sRGB: [ 0.172906    0.08205715  0.05711951]                       *
*           Adobe RGB 1998: [ 0.14702493  0.08205715  0.05814617]             *
*                                                                             *
===============================================================================
===============================================================================
*                                                                             *
*   Reference "green" "CIE XYZ" colourspace tristimulus values:               *
*           [ 0.14985004  0.2318      0.07900179]                             *
*                                                                             *
*   "sRGB" colourspace values (OETF):                                         *
*           [  66.  149.   76.]                                               *
*                                                                             *
*   "sRGB" and "Adobe RGB 1998" colourspaces (Linear):                        *
*           sRGB: [ 0.05440562  0.29876767  0.07183236]                       *
*           Adobe RGB 1998: [ 0.12401962  0.29876767  0.08117514]             *
*                                                                             *
===============================================================================

Tout est configuré et nous pouvons maintenant appliquer quelques transformations, mais par souci de cohérence, nous nous assurons d’abord que dark skin se reconvertit correctement en valeurs trichromatiques CIE XYZ :

XYZ_sRGB1 = colour.RGB_to_XYZ(sRGB_r1,
                              sRGB_w,
                              illuminant,
                              sRGB_RGB_to_XYZ)

XYZ_adobe981 = colour.RGB_to_XYZ(adobe98_r1,
                                 adobe98_w,
                                 illuminant,
                                 adobe98_RGB_to_XYZ)

message_box(('Converting back "dark skin" "CIE XYZ" colourspace '
             'tristimulus values from "sRGB" and "Adobe RGB 1998" '
             'colourspaces:\n'
             '\tFrom sRGB: {0}\n\tFrom Adobe RGB 1998: {1}\n'
             '\nEverything looks fine!').format(
    XYZ_sRGB1, XYZ_adobe981))

===============================================================================
*                                                                             *
*   Converting back "dark skin" "CIE XYZ" colourspace tristimulus values      *
*   from "sRGB" and "Adobe RGB 1998" colourspaces:                            *
*           From sRGB: [ 0.11518475  0.1008      0.05089373]                  *
*           From Adobe RGB 1998: [ 0.11518475  0.1008      0.05089373]        *
*                                                                             *
*   Everything looks fine!                                                    *
*                                                                             *
===============================================================================

Nous multiplions la couleur dark skin par green n fois et mettons à l’échelle le résultat pour obtenir des valeurs affichables :

sRGB_m = sRGB_r1 * sRGB_r2 * sRGB_r2 * sRGB_r2 * sRGB_k
adobe98_m = adobe98_r1 * adobe98_r2 * adobe98_r2 * adobe98_r2 * adobe98_k

Où :

sRGB_r1 = 'dark skin'
sRGB_r2 = 'green'
adobe98_r1 = 'dark skin'
adobe98_r2 = 'green'

Avec sRGB_k et adobe98_k, des facteurs d’échelle arbitraires équivalents pour chaque espace colorimétrique.

Nous reconvertissons ensuite en espace colorimétrique CIE XYZ :

k = np.array([500, 500, 500])

sRGB_k = colour.XYZ_to_RGB(k,
                           illuminant,
                           sRGB_w,
                           sRGB_XYZ_to_RGB)
adobe98_k = colour.XYZ_to_RGB(k,
                              illuminant,
                              adobe98_w,
                              adobe98_XYZ_to_RGB)

sRGB_m = sRGB_r1 * sRGB_r2 * sRGB_r2 * sRGB_r2 * sRGB_k
adobe98_m = adobe98_r1 * adobe98_r2 * adobe98_r2 * adobe98_r2 * adobe98_k

XYZ_sRGB_m1 = colour.RGB_to_XYZ(sRGB_m,
                                sRGB_w,
                                sRGB_w,
                                sRGB_RGB_to_XYZ)

XYZ_adobe98_m1 = colour.RGB_to_XYZ(adobe98_m,
                                   adobe98_w,
                                   adobe98_w,
                                   adobe98_RGB_to_XYZ)

message_box(('Multiplying "dark skin" with "green" and converting back to '
             '"CIE XYZ" colourspace tristimulus values from "sRGB" and '
             '"Adobe RGB 1998" colourspaces:\n'
             '\tFrom sRGB: {0}\n\tFrom Adobe RGB 1998: {1}\n'
             '\nHouston? We have a problem!').format(
    XYZ_sRGB_m1, XYZ_adobe98_m1))

===============================================================================
*                                                                             *
*   Multiplying "dark skin" with "green" and converting back to "CIE XYZ"     *
*   colourspace tristimulus values from "sRGB" and "Adobe RGB 1998"           *
*   colourspaces:                                                             *
*           From sRGB: [ 0.38869578  0.76482524  0.13960073]                  *
*           From Adobe RGB 1998: [ 0.28287392  0.71107141  0.0980476 ]        *
*                                                                             *
*   Houston? We have a problem!                                               *
*                                                                             *
===============================================================================

Les valeurs trichromatiques de l’espace colorimétrique CIE XYZ ne sont pas égales !

Cela veut dire que si deux rendus sont effectués en utilisant le même jeu de données, mais dans deux espaces colorimétriques différents, ils ne donneront pas la même image.

Afin de visualiser cet écart, la différence entre les deux couleurs dark skin éclairées par les lumières green est tracé :

from colour.plotting import *

sRGB_m1 = colour.XYZ_to_sRGB(XYZ_sRGB_m1)
adobe98_m1 = colour.XYZ_to_sRGB(XYZ_adobe98_m1)

sRGB_difference = sRGB_m1 - adobe98_m1

# La différence produit des valeurs négatives donc nous découpons le résultat,
# car les données restantes devraient illustrer l’effet.
sRGB_difference = np.clip(sRGB_difference, 0, 1)

single_colour_plot(colour_parameter('sRGB - Adobe RGB 1998', sRGB_difference), text_size=24)

Difference entr sRGB et Adobe RGB 1998

Les coordonnées chromatiques sont également hors des limites de leurs espaces colorimétriques respectifs :

colourspaces_CIE_1931_chromaticity_diagram_plot(
    ['sRGB', 'Adobe RGB 1998'],
standalone=False,
title='"dark skin" Colour Computation')

for name, XYZ in (('"dark skin"', XYZ_r1),
                  ('"dark skin" * "green" - sRGB', XYZ_sRGB_m1),
                  ('"dark skin" * "green" - Adobe RGB 1998', XYZ_adobe98_m1)):

    xy = colour.XYZ_to_xy(XYZ)

    pylab.plot(xy[0], xy[1], 'o', color='white')
    pylab.annotate(name,
                   xy=xy,
                   xytext=(50, 30),
                   textcoords='offset points',
                   arrowprops=dict(arrowstyle='->', connectionstyle='arc3, rad=0.2'))

display(standalone=True)

Schéma dark skin Colour Computation

Enfin, nous quantifions mathématiquement cette différence en calculant la différence de couleur deltas_E :

Lab1 = colour.XYZ_to_Lab(XYZ_sRGB_m1, illuminant)
Lab2 = colour.XYZ_to_Lab(XYZ_adobe98_m1, illuminant)

print(colour.delta_E_CIE2000(Lab1, Lab2))

4.90614603088

C’est plus de deux fois le seuil perceptible (JND=2.3).

Avec RGB ACES…

Nous faisons le même calcul, mais en comparant cette fois les espace colorimétrique sRGB avec RGB ACES en utilisant l’ensemble du nuancier :

from pprint import pprint

aces_w = colour.ACES_RGB_COLOURSPACE.whitepoint
aces_XYZ_to_RGB = colour.ACES_RGB_COLOURSPACE.XYZ_to_RGB_matrix
aces_RGB_to_XYZ = colour.ACES_RGB_COLOURSPACE.RGB_to_XYZ_matrix

aces_r2 = colour.XYZ_to_RGB(XYZ_r2,
                            illuminant,
                            aces_w,
                            aces_XYZ_to_RGB)
aces_k = colour.XYZ_to_RGB(k,
                           illuminant,
                           aces_w,
                           aces_XYZ_to_RGB)

XYZs_m = []
for index, name, x, y, Y in data:
    xyY = np.array([x, y, Y])
    sRGB_r1 = colour.XYZ_to_RGB(
        colour.xyY_to_XYZ(xyY),
        illuminant,
        sRGB_w,
        sRGB_XYZ_to_RGB)
    sRGB_m = sRGB_r1 * sRGB_r2 * sRGB_r2 * sRGB_r2 * sRGB_k

    aces_r1 = colour.XYZ_to_RGB(
        colour.xyY_to_XYZ(xyY),
        illuminant,
        aces_w,
        aces_XYZ_to_RGB)
    aces_m = aces_r1 * aces_r2 * aces_r2 * aces_r2 * aces_k

    XYZ_sRGB_m1 = colour.RGB_to_XYZ(sRGB_m,
                                    sRGB_w,
                                    sRGB_w,
                                    sRGB_RGB_to_XYZ)
    XYZ_aces_m1 = colour.RGB_to_XYZ(aces_m,
                                    aces_w,
                                    aces_w,
                                    aces_RGB_to_XYZ)
    XYZs_m.append((XYZ_sRGB_m1, XYZ_aces_m1))

pprint(XYZs_m)

[(array([ 0.38869578,  0.76482524,  0.13960073]),
  array([ 0.20268126,  0.60365236,  0.03364468])),
 (array([ 1.38456752,  2.72573459,  0.50101318]),
  array([ 0.69072112,  2.12275751,  0.12698879])),
 (array([ 0.92852793,  1.82982909,  0.37612784]),
  array([ 0.30939555,  1.31947147,  0.16922992])),
 (array([ 0.69359492,  1.3785311 ,  0.24114326]),
  array([ 0.19083716,  0.88582441,  0.0350462 ])),
 (array([ 1.03144188,  2.02284727,  0.43022108]),
  array([ 0.44387339,  1.58908042,  0.22006831])),
 (array([ 2.40152185,  4.77004663,  0.88445106]),
  array([ 0.54698139,  3.01866894,  0.22818611])),
 (array([ 0.95490787,  1.86993573,  0.31732109]),
  array([ 0.70738428,  1.66657639,  0.03354826])),
 (array([ 0.51278009,  0.99598882,  0.25222686]),
  array([ 0.23696274,  0.86242913,  0.19645941])),
 (array([ 0.41771695,  0.79872923,  0.15820317]),
  array([ 0.52233613,  0.98114033,  0.06715399])),
 (array([ 0.20837929,  0.40166426,  0.09743945]),
  array([ 0.1561374 ,  0.40618319,  0.07091007])),
 (array([ 2.3611088 ,  4.70114725,  0.79512437]),
  array([ 0.61150339,  2.93819638,  0.05968195])),
 (array([ 1.6879813 ,  3.33409446,  0.56013065]),
  array([ 0.84523956,  2.51821819,  0.04078637])),
 (array([ 0.25092775,  0.4816829 ,  0.14274428]),
  array([ 0.13600667,  0.47098151,  0.14048398])),
 (array([ 1.38992747,  2.77231474,  0.47752416]),
  array([ 0.26078266,  1.63883043,  0.05233632])),
 (array([ 0.15986689,  0.29388405,  0.05722255]),
  array([ 0.37851941,  0.55876612,  0.02558479])),
 (array([ 2.64925686,  5.25426029,  0.87775234]),
  array([ 1.02529913,  3.63311038,  0.04850628])),
 (array([ 0.44848756,  0.84955542,  0.20680862]),
  array([ 0.5579247 ,  1.10363437,  0.15522728])),
 (array([ 1.16289887,  2.30114648,  0.46972151]),
  array([ 0.25719247,  1.51339265,  0.20353625])),
 (array([ 4.31014492,  8.51455551,  1.61131629]),
  array([ 1.56833336,  6.0699454 ,  0.48769307])),
 (array([ 2.78914109,  5.50938196,  1.04588173]),
  array([ 1.01162598,  3.92908105,  0.3226246 ])),
 (array([ 1.7213125 ,  3.40015924,  0.64576132]),
  array([ 0.62258501,  2.42342331,  0.19970586])),
 (array([ 0.90371356,  1.78493521,  0.33901966]),
  array([ 0.32971348,  1.27530931,  0.10493392])),
 (array([ 0.41892695,  0.82737264,  0.15780297]),
  array([ 0.15157743,  0.59078427,  0.05004748])),
 (array([ 0.14698068,  0.29023823,  0.05542667]),
  array([ 0.05363832,  0.20784694,  0.01771646]))]

Nous pouvons alors calculer la différence de couleur deltas_E pour chaque échantillon :

deltas_E = []
for i, (XYZ1, XYZ2) in enumerate(XYZs_m):
    Lab1 = colour.XYZ_to_Lab(XYZ1, illuminant)
    Lab2 = colour.XYZ_to_Lab(XYZ2, illuminant)
    deltas_E.append((data[i][1], colour.delta_E_CIE2000(Lab1, Lab2)))

pprint(deltas_E)

[(u'dark skin', 8.3613623420756102),
 (u'light skin', 8.9556903598189113),
 (u'blue sky', 13.432983122505807),
 (u'foliage', 14.044848895924677),
 (u'blue flower', 11.093053002998042),
 (u'bluish green', 16.406382114232013),
 (u'orange', 9.6603921006793581),
 (u'purplish blue', 11.67133903883059),
 (u'moderate red', 10.023064455433264),
 (u'purple', 5.9334057826699187),
 (u'yellow green', 14.805099712548397),
 (u'orange yellow', 10.551947850196745),
 (u'blue', 11.278210364179239),
 (u'green', 17.082603183950241),
 (u'red', 21.33752766059369),
 (u'yellow', 12.190174742826901),
 (u'magenta', 8.2445114939752369),
 (u'cyan', 17.17140261974528),
 (u'white 9.5 (.05 D)', 12.371645422300608),
 (u'neutral 8 (.23 D)', 12.322635558756119),
 (u'neutral 6.5 (.44 D)', 12.222855188415945),
 (u'neutral 5 (.70 D)', 11.938433138820654),
 (u'neutral 3.5 (1.05 D)', 11.766891985163152),
 (u'black 2 (1.5 D)', 11.295535127310735)]

Comme vous pouvez le voir, selon les échantillons, la différence de couleur deltas_E peut atteindre des valeurs dramatiques. La différence de couleur moyenne sur l’ensemble du nuancier est la suivante :

np.average([delta_E[1] for delta_E in deltas_E])

12.256749802664634

Avec Rec. 2020…

Une fois de plus, les mêmes calculs, mais cette fois en comparant les espace colorimétriques sRGB avec Rec. 2020.

from pprint import pprint

rec2020_w = colour.REC_2020_COLOURSPACE.whitepoint
rec2020_XYZ_to_RGB = colour.REC_2020_COLOURSPACE.XYZ_to_RGB_matrix
rec2020_RGB_to_XYZ = colour.REC_2020_COLOURSPACE.RGB_to_XYZ_matrix

rec2020_r2 = colour.XYZ_to_RGB(XYZ_r2,
                               illuminant,
                               rec2020_w,
                               rec2020_XYZ_to_RGB)
rec2020_k = colour.XYZ_to_RGB(k,
                              illuminant,
                              aces_w,
                              aces_XYZ_to_RGB)
XYZs_m = []
for index, name, x, y, Y in data:
    xyY = np.array([x, y, Y])
    sRGB_r1 = colour.XYZ_to_RGB(
        colour.xyY_to_XYZ(xyY),
        illuminant,
        sRGB_w,
        sRGB_XYZ_to_RGB)
    sRGB_m = sRGB_r1 * sRGB_r2 * sRGB_r2 * sRGB_r2 * sRGB_k

    rec2020_r1 = colour.XYZ_to_RGB(
        colour.xyY_to_XYZ(xyY),
        illuminant,
        rec2020_w,
        rec2020_XYZ_to_RGB)
    rec2020_m = rec2020_r1 * rec2020_r2 * rec2020_r2 * rec2020_r2 * rec2020_k

    XYZ_sRGB_m1 = colour.RGB_to_XYZ(sRGB_m,
                                    sRGB_w,
                                    sRGB_w,
                                    sRGB_RGB_to_XYZ)
    XYZ_rec2020_m1 = colour.RGB_to_XYZ(rec2020_m,
                                       rec2020_w,
                                       rec2020_w,
                                       rec2020_RGB_to_XYZ)
    XYZs_m.append((XYZ_sRGB_m1, XYZ_rec2020_m1))

pprint(XYZs_m)

[(array([ 0.38869578,  0.76482524,  0.13960073]),
  array([ 0.25853752,  0.70148175,  0.05716381])),
 (array([ 1.38456752,  2.72573459,  0.50101318]),
  array([ 0.89307458,  2.46736894,  0.20938258])),
 (array([ 0.92852793,  1.82982909,  0.37612784]),
  array([ 0.4476039 ,  1.47882622,  0.21349428])),
 (array([ 0.69359492,  1.3785311 ,  0.24114326]),
  array([ 0.32760172,  1.10831102,  0.07492719])),
 (array([ 1.03144188,  2.02284727,  0.43022108]),
  array([ 0.57059404,  1.71920717,  0.26897265])),
 (array([ 2.40152185,  4.77004663,  0.88445106]),
  array([ 1.00942471,  3.68779206,  0.35432283])),
 (array([ 0.95490787,  1.86993573,  0.31732109]),
  array([ 0.82224442,  1.93413951,  0.10070503])),
 (array([ 0.51278009,  0.99598882,  0.25222686]),
  array([ 0.27187771,  0.83126526,  0.21373168])),
 (array([ 0.41771695,  0.79872923,  0.15820317]),
  array([ 0.52016768,  1.01546347,  0.09730777])),
 (array([ 0.20837929,  0.40166426,  0.09743945]),
  array([ 0.16245725,  0.3957943 ,  0.08017173])),
 (array([ 2.3611088 ,  4.70114725,  0.79512437]),
  array([ 1.10102029,  3.76453958,  0.19959814])),
 (array([ 1.6879813 ,  3.33409446,  0.56013065]),
  array([ 1.12444424,  3.06090261,  0.15106385])),
 (array([ 0.25092775,  0.4816829 ,  0.14274428]),
  array([ 0.13673238,  0.40545703,  0.14564003])),
 (array([ 1.38992747,  2.77231474,  0.47752416]),
  array([ 0.56129417,  2.11891082,  0.13077818])),
 (array([ 0.15986689,  0.29388405,  0.05722255]),
  array([ 0.34651438,  0.55009629,  0.04155109])),
 (array([ 2.64925686,  5.25426029,  0.87775234]),
  array([ 1.52064548,  4.53855693,  0.21546166])),
 (array([ 0.44848756,  0.84955542,  0.20680862]),
  array([ 0.53200208,  1.05121778,  0.18042333])),
 (array([ 1.16289887,  2.30114648,  0.46972151]),
  array([ 0.45549085,  1.73803537,  0.25647096])),
 (array([ 4.31014492,  8.51455551,  1.61131629]),
  array([ 2.27314638,  7.11456531,  0.72189921])),
 (array([ 2.78914109,  5.50938196,  1.04588173]),
  array([ 1.46624858,  4.59788321,  0.47349996])),
 (array([ 1.7213125 ,  3.40015924,  0.64576132]),
  array([ 0.90328444,  2.83574094,  0.29272411])),
 (array([ 0.90371356,  1.78493521,  0.33901966]),
  array([ 0.47656235,  1.49134012,  0.15383292])),
 (array([ 0.41892695,  0.82737264,  0.15780297]),
  array([ 0.21946491,  0.68956933,  0.07256384])),
 (array([ 0.14698068,  0.29023823,  0.05542667]),
  array([ 0.07733116,  0.24227999,  0.0256142 ]))]

Nous calculons à nouveau la différence de couleur delta_E :

deltas_E = []
for i, (XYZ1, XYZ2) in enumerate(XYZs_m):
    Lab1 = colour.XYZ_to_Lab(XYZ1, illuminant)
    Lab2 = colour.XYZ_to_Lab(XYZ2, illuminant)
    deltas_E.append((data[i][1], colour.delta_E_CIE2000(Lab1, Lab2, l=1)))

pprint(deltas_E)

[(u'dark skin', 6.186862353654381),
 (u'light skin', 6.6841065817906804),
 (u'blue sky', 9.9232992371789344),
 (u'foliage', 9.3146056130510342),
 (u'blue flower', 8.5710337656737838),
 (u'bluish green', 11.091417794697131),
 (u'orange', 6.856998379754125),
 (u'purplish blue', 9.9593838336339058),
 (u'moderate red', 8.4147777495143163),
 (u'purple', 4.7878515344863057),
 (u'yellow green', 9.7069670435799722),
 (u'orange yellow', 7.1359690495715338),
 (u'blue', 10.297704619280728),
 (u'green', 10.948839961415668),
 (u'red', 17.995992699335542),
 (u'yellow', 8.0907740434874338),
 (u'magenta', 6.2573732490286149),
 (u'cyan', 12.272493214747699),
 (u'white 9.5 (.05 D)', 8.8933966206553361),
 (u'neutral 8 (.23 D)', 8.8473904596447657),
 (u'neutral 6.5 (.44 D)', 8.7498743874243026),
 (u'neutral 5 (.70 D)', 8.5177561630589675),
 (u'neutral 3.5 (1.05 D)', 8.3485958287626083),
 (u'black 2 (1.5 D)', 7.9149962914638188)]

Enfin, nous calculons la différence de couleur moyenne :

np.average(np.average([delta_E[1] for delta_E in deltas_E]))

8.9903525197871481

Explication analytique

La conversion d’un espace colorimétrique RGB_1 vers un second espace colorimétrique RGB_2 se fait en appliquant une série de transformations linéaires (matrices) comme suit :

|R_2|                               |R_1|
|G_2| = matrix_rgb_2 * matrix_xyz_1 |G_1|
|B_2|                               |B_1|

matrix_rgb_2 est la matrice 3×3 transformant de l’espace colorimétrique CIE XYZ vers espace colorimétrique RGB_2 et matrix_xyz_1 est la matrice 3×3 transformant de l’espace colorimétrique RGB_1 vers l’espace colorimétrique CIE XYZ.

Remarque : Nous faisons l’impasse sur l’adaptation chromatique pour faciliter les choses.

Voici les matrices matrix_xyz_1 des espaces colorimétriques respectifs sRGB et ACES RGB :

                  |0.41238656 0.35759149 0.18045049|
matrix_xyz_srgb = |0.21263682 0.71518298 0.0721802 |
                  |0.01933062 0.11919716 0.95037259|
                  |0.9525524  0.0        0.00009368|
matrix_xyz_aces = |0.34396645 0.7281661  −0.07213255|
                  |0.0        0.0        1.00882518|

Les matrices ne sont en réalité qu’un système d’équations linéaires multiples, nous pouvons donc exprimer matrix_xyz_srgb sous la forme :

X = 0.41238656*R + 0.35759149*G + 0.18045049*B
Y = 0.21263682*R + 0.71518298*G + 0.0721802*B
Z = 0.01933062*R + 0.11919716*G + 0.95037259*B

Nous simplifierons à nouveau le problème en considérant simplement la première équation de chaque système, c.à.d, la première ligne de chaque matrice :

X_sRGB = 0.41238656*R + 0.35759149*G + 0.18045049*B
X_ACES = 0.9525524*R + 0.0*G + 0.00009368*B

Nous traçons les fonctions en utilisant une variable incrémentielle k uniformément espacée à la place de RGB :

k = np.linspace(0, 1, 10)
X_sRGB = lambda x: 0.41238656 * x + 0.35759149 * x + 0.18045049 * x
X_ACES = lambda x: 0.9525524 * x + 0.00009368 * x

pylab.plot(k, tuple(map(X_sRGB, k)), 'o-', color='red', label='X - sRGB')
pylab.plot(k, tuple(map(X_ACES, k)), 'o-', color='green', label='X - ACES RGB')

settings = {'title': 'sRGB - ACES RGB - Uniformly Spaced Incrementing RGB',
            'x_label': 'k',
            'y_label': 'X',
            'x_tighten': True,
            'legend': True,
            'legend_location': 'upper left',
            'x_ticker': True,
            'y_ticker': True}
decorate(**settings)
display(**settings)

sRGB - ACES RGB - RGB incrémenté uniformément

Les courbes correspondent et c’est ce qui est voulu, maintenant nous re-traçons les fonctions en incrémentant uniquement le composant R :

k = np.linspace(0, 1, 10)
X_sRGB = lambda x: 0.41238656 * x
X_ACES = lambda x: 0.9525524 * x

pylab.plot(k, tuple(map(X_sRGB, k)), 'o-', color='red', label='X - sRGB')
pylab.plot(k, tuple(map(X_ACES, k)), 'o-', color='green', label='X - ACES RGB')

settings = {'title': 'sRGB - ACES RGB - Uniformly Spaced Incrementing R',
            'x_label': 'k',
            'y_label': 'X',
            'x_tighten': True,
            'legend': True,
            'legend_location': 'upper left',
            'x_ticker': True,
            'y_ticker': True}
decorate(**settings)
display(**settings)

sRGB - ACES RGB - R incrémenté uniformément

Les courbes sont maintenant très différentes, ACES RGB est plus raide que sRGB. Ceci vient du fait que les vecteurs de base de ces espaces colorimétriques respectifs ne sont pas orthogonaux. Dans ce cas, cela conduit à des couleurs s’approchant plus rapidement des limites du locus spectral en ACES RGB qu’en sRGB. C’est la raison pour laquelle certains rebonds secondaires conduisent rapidement à des couleurs très saturées.

Cela peut à nouveau être démontré en multipliant nos couleurs dark skin et green via les calculs précédents et en les traçant :

pylab.plot(X_sRGB(0.172906) * X_sRGB(0.05440562), 0, 'o', color='red', label='X - "dark skin" * "green" sRGB')
pylab.plot(X_ACES(0.11758989) * X_ACES(0.15129818), 0, 'o', color='green', label='X - "dark skin" * "green" ACES RGB')

settings = {'title': 'sRGB - ACES RGB - X Value - "dark skin" * "green"',
            'x_label': 'X Value',
            'x_tighten': True,
            'legend': True,
            'legend_location': 'upper left',
            'x_ticker': True,
            'y_ticker': True}
decorate(**settings)
display(**settings)

sRGB - ACES RGB - Valeur X étant dark skin multiplié par green

Conclusion

colourspaces_CIE_1931_chromaticity_diagram_plot(
    ['Pointer Gamut', 'sRGB', 'Rec. 2020', 'ACES RGB'])

Gamut de Pointer, sRGB, Rec. 2020, ACES RGB - CIE 1931 2 Degree Standard Observer

Bien que la plupart de ces éléments ne soient liés qu’à de l’algèbre linéaire de base, la plupart des vétérans de l’industrie des effets visuels l’ont négligé. Nous supposions, à tort, que les moteurs de rendu sont indépendants des espaces colorimétriques.

D’un point de vue strictement technique, c’est effectivement le cas. Ils se contentent de mâcher toutes les données que vous leur lancez sans prendre en compte l’espace colorimétrique dans lequel les données sont stockées.

Cependant le choix de l’espace colorimétrique et de ses primaires est primordial pour obtenir un rendu fidèle. Dans le contexte du rendu, les espaces colorimétriques à large gamme (wide gamut color spaces) sont susceptibles de créer des couleurs avec une chrominance très élevée, ce qui peut être un problème majeur lorsque vous essayez d’obtenir une sortie photo-réaliste.

La cause principale de ces problèmes est liée au modèle d’illumination RGB actuel utilisé par la plupart des moteurs de rendu. Il y a de nombreuses raisons pratiques d’utiliser le modèle d’illumination RGB au lieu d’un modèle spectral complet, en particulier lorsque notre représentation des données est basée sur un triplet RGB. Il est en effet très difficile de mesurer les données de réflectance dans de grands volumes et avec un contrôle et une résolution suffisants pour le travail de production. Les applications pour manipuler ces données de réflectance n’existent pas ou sont des outils dédiés aux scientifiques, donc pas vraiment utiles pour la création de contenu. À ce jour, il est impossible de lancer Spectral Photoshop pour appliquer un flou gaussien spectral sur des textures spectrales.

Les plates et les photographies sont souvent prises avec une configuration d’éclairage qui ne correspond à aucun illuminant connu ou pire, mélange plusieurs sources de lumière avec différentes distributions de puissance spectrale. Les images HDRI utilisées pour l’éclairage ne font pas exception et ne peuvent transmettre correctement l’éclairage d’une scène.

On pourrait être tentés de considérer les photographies comme de véritables données de réflectance, mais ce sont en fait des spectres réfléchis : Elles ont déjà été multipliées par les spectres de plusieurs sources lumineuses différentes et par les spectres réfléchis des surfaces voisines. Elles ne devraient pas être injectées en quantités importantes dans des calculs de colorimétrie vraiment précis.

Un autre problème vient du fait que les graphistes peuvent utiliser des couleurs de l’intégralité de l’espace colorimétrique sans prendre en compte le fait que leur chromaticité puisse être en dehors du gamut des couleurs d’objets réels, tel que le gamut de Pointer, ISO 12640-3 ou le gamut de Xun Li, entraînant quasi systématiquement des couleurs improbables. Il semble nécessaire d’avoir des outils pour s’assurer que :

Le modèle d’illumination RGB ne permet pas le calcul de valeurs colorimétriques précises, mais nous pouvons toujours produire des images convaincantes et agréables grâce au sens artistique de nos graphistes. Nous l’avons fait pendant des années et c’est sans doutes la chose la plus importante pour l’industrie du divertissement.

Au final, la solution sera de passer au rendu spectral, ce qui arrivera. La question est de savoir quand. Il existe déjà de nombreux moteurs de rendu spectraux. Weta Digital utilise Manuka depuis quelques films. Nous devons encore résoudre les problèmes liés aux données de réflectance avant qu’ils ne deviennent courants. Bien qu’il soit théoriquement impossible de reconstruire une distribution de puissance spectrale à partir d’un seul triplet RGB, parce qu’une infinité de distributions de puissance spectrale donne les mêmes valeurs trichromatiques, des recherches ont été effectuées sur le problème et quelques méthodes existent. Par exemple, Brian Smits (1999) décrit une méthode qui conduit à de très bons résultats. Les données de réflectance récupérées ne sont pas utiles pour une colorimétrie précise, mais elles donnent de très bons résultats dans le contexte de l’imagerie numérique.

En l’état, nous discutons toujours du meilleur espace colorimétrique pour les calculs dans le modèle d’illumination RGB et le problème a également été soulevé sur la liste de diffusion ACES.

Erratum du 28 novembre 2014

Elle Stone m’a envoyé un e-mail avec diverses questions concernant le document, notamment ses inquiétudes sur le fait que les chromaticités dans les calculs tombaient en dehors de leur gamut d’espaces colorimétriques respectifs dans la figure "dark skin" Colour Computation.

C’est en effet un problème et ne devrait jamais se produire dans le contexte de ces calculs. Étant donné les valeurs RGB positives et la conversion précise en coordonnées de chromaticités xy, ces dernières doivent être situées dans les limites de l’espace colorimétrique de référence.

J’ai remonté le problème jusqu’à une adaptation erronée du point blanc : Lors d’un premier contrôle de cohérence, nous étions en train de reconvertir les valeurs trichromatiques CIE XYZ, garantissant que l’API fonctionnait correctement :

XYZ_sRGB1 = colour.RGB_to_XYZ(sRGB_r1,
                              sRGB_w,
                              illuminant,
                              sRGB_RGB_to_XYZ)

Malheureusement, le même code a également été utilisé pour l’opération de post-multiplication de conversion des valeurs trichromatiques :

XYZ_sRGB_m1 = colour.RGB_to_XYZ(sRGB_m,
                                sRGB_w,
                                illuminant,
                                sRGB_RGB_to_XYZ)

En conséquence, nous adaptions chromatiquement à CIE Illuminant C au lieu de l’espace colorimétrique CIE Illuminant D Series D65. Dans le contexte actuel, la conversion correcte en valeurs trichromatiques devrait contourner totalement l’adaptation chromatique puisque les points blancs source et cible sont les mêmes.

Les instances incorrectes du code ont été corrigées et les chiffres mis à jour en conséquence.


  1. NdT : Non-orthogonaux veut dire « sans angles droits ». 

  2. Ndt : Il s’agit d’un nuancier de référence. dark skin et green (respectivement peau foncée et vert) font ici spécifiquement référence à deux couleurs de ce nuancier. Voir la page Wikipédia : ColorChecker

Dernière mise à jour : mar. 11 avril 2023