Tutoriel Distant Viewing 1 : Affiches de films et analyse des couleurs

[diapositives] [chapitre] [site web]

Ce notebook présente une introduction aux méthodes exposées dans le livre Distant Viewing: Computational Exploration of Digital Images (MIT Press, 2023). Nous reproduisons et étendons certaines parties de l’analyse en utilisant une collection de 5000 affiches de films présentée dans le troisième chapitre du livre. Aucune connaissance préalable de Python ou de la vision par ordinateur n’est requise. Bien qu’une introduction complète à Python dépasse le cadre de ce tutoriel, nous mettrons en avant les principales caractéristiques du langage telles qu’elles s’appliquent ici. Voici les objectifs d’apprentissage spécifiques du tutoriel :

  1. Expliquer comment les images numériques sont stockées sous forme de tableaux de pixels.
  2. Relier la structure des images numériques aux méthodes computationnelles à travers le cadre du distant viewing.
  3. Appliquer du code Python préconstruit pour étudier une collection d’images numériques.
  4. Expliquer les mesures telles que la teinte, la saturation, le chroma et la valeur à l’aide de la théorie des couleurs.
  5. Comparer la composition des affiches de films à travers le temps et les genres.

Vous trouverez plus d’informations sur la théorie du distant viewing et son application aux affiches de films dans notre livre, disponible en téléchargement gratuit sous licence libre sur notre site web, ainsi que des données et du code supplémentaires permettant de reproduire les autres études présentées dans le texte.

1.1 Configuration

Pour commencer, nous devons télécharger le jeu de données des affiches de films, et indiquer à Python toutes les fonctions dont nous aurons besoin par la suite. Le code ci-dessous télécharge les métadonnées et les affiches de films que nous utiliserons dans ce notebook. Il s’appuie sur le programme wget pour télécharger les fichiers depuis le site distant viewing, suivi de la commande tar pour décompresser le répertoire contenant les miniatures des affiches. Le point d’exclamation au début de chaque ligne de code indique au notebook que nous voulons exécuter directement un outil en ligne de commande en dehors de Python. Nous explorerons le contenu de chacun de ces éléments au fur et à mesure dans les sections suivantes. La dernière ligne utilise mkdir pour créer un répertoire vide qui sera nécessaire dans la dernière section du notebook pour exécuter les modèles d’apprentissage automatique sur la collection. Pour exécuter le code, passez votre souris sur l’arrière-plan du code. Un bouton de lecture triangulaire apparaîtra à gauche du bloc de code. Cliquez sur le bouton et attendez la fin de l’exécution, ce qui peut prendre une minute ou deux. Toutes les lignes seront exécutées les unes après les autres. Une petite flèche verte indique la ligne en cours d’exécution à tout moment, ce qui nous aide à comprendre quelles étapes prennent le plus de temps.

View code
!mkdir -p data
!mkdir -p /root/.cache/torch/hub/checkpoints/
!wget -q -nc -P data/ "https://distantviewing.org/atelier/movies_50_years_meta.csv"
!wget -q -nc -P data/ "https://distantviewing.org/atelier/movies_50_years_hue.csv"
!wget -q -nc -P data/ "https://distantviewing.org/atelier/movies_50_years_genre_fra.csv"
!wget -q -nc "https://distantviewing.org/atelier/mp_med.tar"
!wget -q -nc "https://distantviewing.org/atelier/funs.py"
!tar -xf mp_med.tar --warning=no-unknown-keyword
!mv mp_med data

Dans la dernière partie de la configuration, nous allons exécuter du code en Python (les lignes ci-dessous ne commencent pas par un point d’exclamation). Ce code utilise la commande import pour indiquer à Python quelles bibliothèques et fonctions nous allons utiliser dans le notebook. Il s’agit de matplotlib.pyplot et matplotlib.patches pour la visualisation de données, torch pour l’apprentissage automatique, polars pour les jeux de données tabulaires, numpy pour travailler avec de grands tableaux de nombres, et cv2 (la bibliothèque OpenCV pour le traitement d’images). Les dernières lignes chargent quelques composants supplémentaires : PIL pour ouvrir des images, un alias c pour les colonnes de polars, nos propres fonctions utilitaires depuis le fichier funs, et plotnine pour la création de graphiques selon la grammaire des graphiques.

View code
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import torch
import polars as pl
import numpy as np
import cv2

from PIL import Image
from polars import col as c
from funs import *
from plotnine import *

theme_set(theme_minimal())

Dans Google Colab, votre environnement de travail se réinitialise à chaque fois que vous rouvrez un notebook. Toutes les étapes ci-dessus doivent donc être ré-exécutées à chaque démarrage du notebook. Si vous exécutiez ce code sur votre propre machine, le téléchargement des données ne devrait être fait qu’une seule fois. Le chargement des modules dans le dernier bloc de code, en revanche, doit toujours être exécuté à chaque redémarrage de Python.

1.2 Jeu de données des affiches de films

Avant de plonger dans l’analyse des images d’affiches de films, il est important de prendre un moment pour examiner les métadonnées attachées à chaque affiche et comprendre la structure du jeu de données. Dans le code ci-dessous, nous utilisons la fonction read_csv du module polars (que nous avons abrégé en pl selon la convention) pour charger le fichier csv contenant une ligne par film de notre jeu de données. Nous enregistrons la sortie de la fonction dans un objet nommé posters. À la dernière ligne, nous écrivons simplement le nom de l’objet, ce qui affiche les premières lignes du jeu de données dans le notebook. Les données contiennent une ligne pour chacun des 100 films les plus rentables de chaque année, de 1970 à 2019. Pour quelques films des années 1970, nous n’avons pas pu trouver les affiches ; ceux-ci sont exclus du jeu de données. Pour chaque film, nous disposons de l’année, du titre, du nom du fichier image associé à l’affiche, et d’une description de la demi-décennie dont provient le film. Cette dernière sera utilisée dans notre analyse des évolutions au fil du temps.

View code
posters = pl.read_csv("data/movies_50_years_meta.csv")
posters
shape: (4_680, 4)
year title filepath period
i64 str str str
1970 "Love Story" "data/mp_med/1970_love_story.jp… "1970-1974"
1970 "Airport" "data/mp_med/1970_airport.jpg" "1970-1974"
1970 "M.A.S.H." "data/mp_med/1970_mash.jpg" "1970-1974"
1970 "Patton" "data/mp_med/1970_patton.jpg" "1970-1974"
1970 "Little Big Man" "data/mp_med/1970_little_big_ma… "1970-1974"
2019 "The Art of Self-Defense" "data/mp_med/2019_the_art_of_se… "2015-2019"
2019 "Luce" "data/mp_med/2019_luce.jpg" "2015-2019"
2019 "The Other Side of Heaven 2: Fi… "data/mp_med/2019_the_other_sid… "2015-2019"
2019 "The Aftermath" "data/mp_med/2019_the_aftermath… "2015-2019"
2019 "The Kid" "data/mp_med/2019_the_kid.jpg" "2015-2019"

Nous disposons d’un autre ensemble de métadonnées qui associe chaque film à une ou plusieurs catégories de genre. Le jeu de données contient une ligne pour chaque paire film/étiquette de genre. L’année est incluse parce que plusieurs films partagent le même titre, mais peuvent être identifiés de manière unique en connaissant à la fois le titre et l’année.

View code
genre = pl.read_csv("data/movies_50_years_genre_fra.csv")
genre
shape: (11_885, 3)
year title genre
i64 str str
1970 "Love Story" "Drame"
1970 "Love Story" "Romance"
1970 "Airport" "Action"
1970 "Airport" "Drame"
1970 "Airport" "Thriller"
2019 "The Aftermath" "Romance"
2019 "The Aftermath" "Guerre"
2019 "The Kid" "Biographie"
2019 "The Kid" "Drame"
2019 "The Kid" "Western"

En examinant les premières et dernières lignes, les étiquettes de genre semblent-elles cohérentes pour les films concernés ? Nos analyses dans la suite du notebook se concentreront sur les motifs des affiches de films à travers les périodes et les genres.

Maintenant que nous avons une idée de nos données, quelles questions pourrait-on vouloir explorer ?

1.3 Images numériques

Maintenant que nous avons vu les métadonnées des affiches, regardons comment les images numériques sont manipulées en Python en chargeant l’image d’une seule affiche. Toutes les affiches sont stockées au format JPEG (abréviation de Joint Photographic Experts Group). Il s’agit d’un format d’image courant qui peut être ouvert et compris par presque tous les programmes ou appareils qui travaillent avec des images. Si vous ouvriez un fichier JPEG sur votre ordinateur ou votre téléphone, l’image s’afficherait sans configuration particulière. La plupart des images que vous voyez sur les sites web publics sont stockées au format JPEG et sont traitées et affichées par votre navigateur.

Dans le code ci-dessous, nous utilisons la fonction Image.open de la bibliothèque PIL pour charger une image en Python. Nous enregistrons l’image dans un objet nommé img. Le chemin vers l’image de l’affiche est tiré directement des métadonnées ci-dessus. Ici, nous utilisons la formule suivante : le nom du jeu de données (posters), filtré pour ne garder que la ligne du film Chisum, suivi du nom de la colonne (filepath) et du numéro de ligne entre crochets ([0]). Nous avons choisi l’affiche du film Chisum avec John Wayne car elle présente une tonalité orange marquée qui sera intéressante à examiner. Après avoir chargé l’image en Python, nous l’affichons en l’inscrivant seule sur la dernière ligne.

View code
img = Image.open(
    posters.filter(c.title == "Chisum")["filepath"][0]
)
img

On voit qu’il est relativement facile de charger un format d’image courant en Python puis de l’afficher dans le notebook. Pour bien comprendre les analyses computationnelles qui suivent, il sera utile d’examiner la façon dont une image numérique est représentée en Python. Nous pouvons utiliser la fonction Python intégrée type pour voir le type d’objet de n’importe quel objet Python. Faisons-le ci-dessous :

View code
type(img)
PIL.JpegImagePlugin.JpegImageFile

L’objet PIL.JpegImagePlugin n’est pas adapté au traitement numérique. Nous pouvons le transférer vers un numpy.ndarray

View code
arr = np.asarray(img)
type(arr)
numpy.ndarray

Qu’est-ce que cela signifie ? Il s’agit d’un type de données générique créé par la bibliothèque numpy (la même que celle chargée dans la section de configuration) pour stocker des blocs rectangulaires de nombres.

Pour comprendre comment un tableau de nombres peut représenter une image, nous allons afficher l’attribut shape de l’objet (un attribut est une caractéristique d’un objet Python, accessible avec le nom de l’objet suivi d’un . et du nom de l’attribut). Cela nous indique comment les nombres du tableau sont organisés.

View code
arr.shape
(384, 252, 3)

On voit que la forme de l’objet a trois composantes. Le premier nombre indique combien de lignes de nombres il y a, et le second combien de colonnes. Le troisième nombre indique qu’il existe une troisième dimension de taille trois. La façon la plus simple de se le représenter est d’imaginer trois grilles rectangulaires de nombres, chacune avec 229 lignes et 150 colonnes. Pensez à un fichier Excel avec trois feuilles, chacune contenant une grille de nombres de même taille.

Une ligne et une colonne précises dans cette grille de nombres représentent un pixel (picture + element), la plus petite unité individuelle d’une image. Nous avons besoin de trois nombres pour chaque pixel afin d’indiquer la quantité de lumière rouge, verte et bleue à combiner pour créer la couleur à cet emplacement précis. La bibliothèque Python utilisée ici représente la quantité de lumière sur une échelle allant de 0 (éteint) à 255 (le plus brillant possible). En mélangeant ces trois composantes, on peut recréer presque toutes les couleurs perceptibles par l’œil humain.

Pour rendre cela plus concret, voyons un exemple des nombres qui composent l’image ci-dessus. Il y en a beaucoup trop à examiner d’un seul coup. Nous utiliserons plutôt une notation entre crochets pour sélectionner les dix premières lignes, les huit premières colonnes, et la première composante de couleur. Python adopte une convention courante en programmation qui consiste à commencer à compter à zéro : le 0 ci-dessous prend donc le premier tableau de nombres, qui correspond ici à l’intensité de la couleur rouge.

View code
arr[:10, :8, 0]
array([[203, 204, 205, 206, 207, 207, 207, 206],
       [203, 204, 205, 206, 207, 207, 206, 206],
       [204, 204, 205, 206, 207, 206, 206, 205],
       [204, 205, 206, 206, 207, 206, 205, 205],
       [205, 205, 206, 207, 206, 206, 205, 204],
       [205, 206, 206, 207, 206, 205, 204, 204],
       [206, 206, 207, 207, 206, 205, 204, 203],
       [206, 207, 207, 207, 206, 205, 204, 203],
       [206, 206, 206, 206, 206, 206, 206, 206],
       [206, 206, 206, 206, 206, 206, 206, 206]], dtype=uint8)

La portion de l’image que nous avons extraite ci-dessus est le coin supérieur gauche. On voit que pour représenter ce coin, il faut allumer une bonne quantité de lumière rouge (environ 200 sur les 255 possibles). Pourtant, en regardant l’image, on ne voit pas de rouge à cet endroit : le coin supérieur gauche semble blanc. Pour comprendre pourquoi, regardons la deuxième composante, qui correspond à la quantité de lumière verte.

View code
arr[:10, :8, 1]
array([[203, 204, 205, 206, 207, 207, 207, 206],
       [203, 204, 205, 206, 207, 207, 206, 206],
       [204, 204, 205, 206, 207, 206, 206, 205],
       [204, 205, 206, 206, 207, 206, 205, 205],
       [205, 205, 206, 207, 206, 206, 205, 204],
       [205, 206, 206, 207, 206, 205, 204, 204],
       [206, 206, 207, 207, 206, 205, 204, 203],
       [206, 207, 207, 207, 206, 205, 204, 203],
       [206, 206, 206, 206, 206, 206, 206, 206],
       [206, 206, 206, 206, 206, 206, 206, 206]], dtype=uint8)

Et tant qu’on y est, la quantité de lumière bleue aussi.

View code
arr[:10, :8, 2]
array([[205, 206, 207, 208, 209, 209, 209, 208],
       [205, 206, 207, 208, 209, 209, 208, 208],
       [206, 206, 207, 208, 209, 208, 208, 207],
       [206, 207, 208, 208, 209, 208, 207, 207],
       [207, 207, 208, 209, 208, 208, 207, 206],
       [207, 208, 208, 209, 208, 207, 206, 206],
       [208, 208, 209, 209, 208, 207, 206, 205],
       [208, 209, 209, 209, 208, 207, 206, 205],
       [208, 208, 208, 208, 208, 208, 208, 208],
       [208, 208, 208, 208, 208, 208, 208, 208]], dtype=uint8)

On voit que les lumières rouge, verte et bleue sont toutes allumées au même niveau dans le coin supérieur gauche de l’image. Lorsque l’on mélange ces trois couleurs, on obtient une nuance de gris. Plutôt noire quand les couleurs sont toutes faibles, plutôt blanche quand elles sont toutes élevées. Cela correspond à ce que l’on voit dans le coin supérieur gauche : une nuance de gris très proche du blanc.

Pour bien saisir le fonctionnement de ces composantes, examinons une autre partie de l’image, correspondant aux lignes 180-190 et aux colonnes 25-30. C’est très petit, mais en regardant attentivement on devrait pouvoir le relier à l’image ci-dessus. L’image obtenue est trop petite pour que Colab la traite automatiquement comme une image à afficher ; nous utilisons donc la fonction plt.imshow pour afficher les valeurs de pixels comme des pixels.

View code
plt.imshow(arr[180:190, 25:30, :])

Cette petite fenêtre fait partie de la zone orange en bas de l’affiche. Voyons les composantes rouge, verte et bleue qui composent cette couleur.

View code
arr[180, 25, :]
array([112,  51,   0], dtype=uint8)

L’orange foncé provient du mélange d’une bonne quantité de lumière rouge (189/255), d’un peu de vert (106/255), et de pas de bleu (0/255). Un coup d’œil à une roue chromatique permet de comprendre pourquoi l’orange résulte du mélange d’un peu de vert et de beaucoup de rouge.

Les bibliothèques de traitement d’images utilisent des conventions légèrement différentes pour représenter les images numériques. La plupart utilisent le même ordre de couleurs (rouge, vert, bleu), et certaines utilisent des fractions entre 0 et 1 plutôt que des entiers entre 0 et 255. Certains formats d’image incluent une quatrième composante, appelée canal alpha, pour représenter l’opacité. D’autres formats ne contiennent qu’un seul canal de couleur pour représenter les images en niveaux de gris. Mais tous ces formats reposent sur le même concept fondamental : représenter les images numériques par des nombres indiquant les intensités des pixels. C’est une manière très différente de penser les images de celle dont les humains traitent les signaux visuels, ce que nous explorerons dans la section suivante.

Nous savons désormais regarder de près les couleurs des affiches, et nous pouvons combiner cela avec les métadonnées.

Quelles autres questions peut-on se poser sur les affiches de films ?

1.4 Distant Viewing : théorie

Les ordinateurs représentent les images comme des tableaux tridimensionnels de nombres. C’est très différent de la façon dont les images sont interprétées et utilisées par les spectateurs humains. De plus, le lien entre ces deux représentations n’a rien d’évident. Il est impossible de comprendre ce qui est représenté par un petit sous-ensemble d’intensités de pixels sans voir une grande partie de l’image dans son ensemble. Même quelque chose d’aussi simple que la quantité de lumière bleue dans un pixel peut être difficile à interpréter. Beaucoup de bleu peut signifier la couleur bleue, ou seulement un mélange avec du rouge et du vert pour produire du blanc. Pour mener des analyses computationnelles sur de grandes collections d’images numériques, il faut donc d’abord convertir ces intensités de pixels brutes en représentations correspondant aux interprétations des images qui nous intéressent.

La théorie du distant viewing découle exactement de ce constat : la façon dont les images numériques sont représentées nous oblige à construire des annotations contenant des données structurées alignées sur nos questions de recherche. Ces annotations, qui peuvent être créées manuellement ou à l’aide d’algorithmes, sont à la fois destructives (il y a une perte d’information lors de leur création) et ouvertes à l’interprétation (il n’existe pas de manière neutre de créer des annotations ; des choix doivent toujours être faits).

Nous progresserons vers des annotations plus complexes, mais commençons par l’une des plus simples : la luminosité de l’image. Les intensités des pixels nous indiquent à quel point allumer les lumières rouge, verte et bleue à chaque position de l’image. Plus ces nombres sont élevés, plus l’image est lumineuse à l’affichage. Une manière de créer une annotation pertinente sur une image consiste donc à prendre la valeur moyenne de toutes les intensités de pixels. On peut le faire avec le code suivant, qui utilise la fonction mean de numpy pour calculer la moyenne de toutes les valeurs d’un tableau.

View code
np.mean(arr)
np.float64(94.47836750440918)

Il faut sans doute peu d’arguments pour convaincre que beaucoup d’informations sont perdues entre ce nombre unique et toute la richesse présente dans la miniature de l’affiche. Aucune information sur le contenu du texte de l’image, sur la dominante orange, sur la silhouette de l’homme et du cheval, sur la bordure blanche, ou sur la disposition de ces éléments dans le cadre. Le processus de création d’annotations est donc clairement destructif. La différence entre l’annotation résumée (un seul nombre) et l’information de l’image d’origine est ce que la théorie de l’information appelle un écart sémantique. Mais qu’en est-il de la seconde partie de la théorie, selon laquelle cette mesure n’est pas neutre et reflète des choix spécifiques sur la façon dont nous voulons voir à distance ? Cela peut sembler moins évident, mais il existe de nombreuses façons de mesurer la luminosité d’une image. Par exemple, on pourrait considérer la valeur médiane des intensités plutôt que leur moyenne, comme dans le code ci-dessous.

View code
np.median(arr)
np.float64(74.0)

Par ailleurs, l’œil humain étant plus sensible au vert qu’au bleu ou au rouge, on pourrait pondérer la luminosité plus fortement selon la couleur. Beaucoup d’affiches ont des bordures noires ou blanches, qui peuvent fortement influencer la luminosité globale. On pourrait peut-être ne calculer la luminosité que sur la partie centrale de l’image. Et d’ailleurs, pourquoi se soucier de la luminosité en premier lieu ? Dès lors que l’on commence à envisager toutes ces options, il devient clair qu’il n’existe pas de manière parfaite de représenter un élément d’image sous forme de données structurées. Des choix et des compromis sont toujours faits. Il faut bien finir par en faire, et voir ce qu’on peut en apprendre, tout en gardant à l’esprit la nature des annotations d’images et les écarts sémantiques qui en résultent. Autrement dit, lorsque nous analysons des images par vision par ordinateur, nous faisons du distant viewing.

1.5 Annoter la luminosité de l’image

Nous avons examiné en détail la manière dont les images numériques sont stockées, vu les implications pour la théorie du distant viewing, et présenté une façon particulière de construire une annotation à travers la luminosité. Mettons maintenant tout cela en pratique pour analyser les affiches de films selon leur luminosité globale. Première étape : répéter sur toutes les images du jeu de données le processus appliqué à la seule affiche ci-dessus. Pour cela, on utilise une boucle Python. Elle est constituée du mot-clé for suivi d’un bloc de code indenté. Chaque ligne du code indenté sera exécutée une fois pour chaque valeur de la variable d’itération ind, prise dans l’ensemble des numéros de lignes du jeu de données posters. Nous chargerons donc l’image de chaque affiche, calculerons sa luminosité, et l’enregistrerons dans une nouvelle colonne ajoutée au jeu de données. Pour coller au plus près des résultats du livre, nous diviserons la luminosité par 255 afin que les valeurs aillent de 0 (totalement noir) à 1 (totalement blanc).

View code
results = []
for row in posters.iter_rows(named=True):
    arr = np.asarray(Image.open(row["filepath"]))
    results.append(np.mean(arr) / 255)


posters = posters.with_columns(
    avg_brightness = pl.Series(results).round(4)
)

Maintenant que la luminosité a été ajoutée à chaque ligne des données d’affiches, nous pouvons les trier de la plus lumineuse à la plus sombre avec la méthode sort.

View code
posters.sort(c.avg_brightness, descending=True)
shape: (4_680, 5)
year title filepath period avg_brightness
i64 str str str f64
1999 "My Favorite Martian" "data/mp_med/1999_my_favorite_m… "1995-1999" 0.9591
2015 "Steve Jobs" "data/mp_med/2015_steve_jobs.jp… "2015-2019" 0.9569
2017 "Downsizing" "data/mp_med/2017_downsizing.jp… "2015-2019" 0.9551
1972 "Instant Replay (In Situ Instal… "data/mp_med/1972_instant_repla… "1970-1974" 0.9549
2009 "Precious" "data/mp_med/2009_precious.jpg" "2005-2009" 0.9487
1992 "Consenting Adults" "data/mp_med/1992_consenting_ad… "1990-1994" 0.0388
2010 "Predators" "data/mp_med/2010_predators.jpg" "2010-2014" 0.038
2006 "Saw III" "data/mp_med/2006_saw_iii.jpg" "2005-2009" 0.0353
1994 "Wes Craven's New Nightmare" "data/mp_med/1994_wes_cravens_n… "1990-1994" 0.0352
1983 "Zelig" "data/mp_med/1983_zelig.jpg" "1980-1984" 0.0344

La théorie du distant viewing nous rappelle que la création d’annotations, étape nécessaire de l’analyse computationnelle d’images, est à la fois destructive et subjective. Avant de passer à une analyse agrégée, il est donc utile de relier les annotations aux images en regardant effectivement quelques affiches. Une manière de faire est de regarder les affiches aux valeurs extrêmes. Dans le code ci-dessous, nous chargeons la première image du jeu de données trié, soit l’affiche ayant la luminosité la plus élevée. Rappelez-vous que Python commence à compter à zéro : le zéro de la première ligne correspond à la première ligne des données.

N’hésitez pas à examiner d’autres lignes particulièrement lumineuses pour avoir une vision plus complète de ce que capture l’annotation.

View code
(
    posters
    .sort(c.avg_brightness, descending=True)
    .pipe(plot_image_grid, ncol=4, limit=12, label_name="avg_brightness")
)

Il est tout aussi utile de regarder les images les plus sombres du jeu de données. Pour cela, on modifie le code pour commencer au nombre de lignes moins un (toujours à cause de la convention Python qui démarre à zéro). On peut remplacer le -1 par -n pour examiner la n-ième image la moins lumineuse.

View code
(
    posters
    .sort(c.avg_brightness, descending=False)
    .pipe(plot_image_grid, ncol=4, limit=12, label_name="avg_brightness")
)

Après avoir examiné quelques exemples, que pensez-vous de la capacité de l’annotation à capturer la luminosité des affiches ? Au moins aux extrêmes, quels éléments de l’affiche semblent le mieux expliquer/prédire sa luminosité ?

Maintenant que nous avons une idée du fonctionnement de l’annotation, et, espérons-le, une certaine confiance dans le fait qu’elle reflète une quantité significative, passons à une analyse agrégée. Le code ci-dessous regroupe notre jeu de données par period (demi-décennies de 1970 à 2019) et examine la luminosité moyenne de toutes les affiches de chaque période.

View code
(
    posters
    .group_by(c.period)
    .agg(avg_brightness = c.avg_brightness.mean())
    .sort(c.period)
)
shape: (10, 2)
period avg_brightness
str f64
"1970-1974" 0.575647
"1975-1979" 0.525793
"1980-1984" 0.471725
"1985-1989" 0.443653
"1990-1994" 0.408835
"1995-1999" 0.373252
"2000-2004" 0.417843
"2005-2009" 0.435171
"2010-2014" 0.394069
"2015-2019" 0.410042
View code
(
    posters
    .group_by(c.period)
    .agg(avg_brightness = c.avg_brightness.mean())
    .sort(c.period)
    .pipe(ggplot, aes("period", "avg_brightness"))
    + geom_point()
    + geom_line(group=1)
)

Comment décririez-vous le motif observé ici ? Y a-t-il une tendance significative ? Si oui, avez-vous des hypothèses sur ce qui pourrait l’expliquer ?

Faisons une analyse similaire en utilisant les genres associés à chaque film. Pour cela, nous utilisons d’abord la méthode join pour combiner les données des affiches avec la table des genres.

View code
(
    posters
    .join(genre, on=[c.year, c.title])
)
shape: (11_610, 6)
year title filepath period avg_brightness genre
i64 str str str f64 str
1970 "Love Story" "data/mp_med/1970_love_story.jp… "1970-1974" 0.7902 "Drame"
1970 "Love Story" "data/mp_med/1970_love_story.jp… "1970-1974" 0.7902 "Romance"
1970 "Airport" "data/mp_med/1970_airport.jpg" "1970-1974" 0.7071 "Action"
1970 "Airport" "data/mp_med/1970_airport.jpg" "1970-1974" 0.7071 "Drame"
1970 "Airport" "data/mp_med/1970_airport.jpg" "1970-1974" 0.7071 "Thriller"
2019 "The Aftermath" "data/mp_med/2019_the_aftermath… "2015-2019" 0.186 "Romance"
2019 "The Aftermath" "data/mp_med/2019_the_aftermath… "2015-2019" 0.186 "Guerre"
2019 "The Kid" "data/mp_med/2019_the_kid.jpg" "2015-2019" 0.275 "Biographie"
2019 "The Kid" "data/mp_med/2019_the_kid.jpg" "2015-2019" 0.275 "Drame"
2019 "The Kid" "data/mp_med/2019_the_kid.jpg" "2015-2019" 0.275 "Western"

Nous pouvons ensuite répéter l’analyse en regroupant par genre et en prenant la luminosité moyenne de chaque genre. Là où il était logique de classer les périodes par ordre chronologique pour faire ressortir une tendance, il sera ici préférable de laisser Python classer les genres selon leur luminosité moyenne. On le fait avec la méthode sort sur les données résumées.

View code
(
    posters
    .join(genre, on=[c.year, c.title])
    .group_by(c.genre)
    .agg(avg_brightness = c.avg_brightness.mean())
    .sort(c.avg_brightness)
    .pipe(ggplot, aes("reorder(genre, avg_brightness)", "avg_brightness"))
    + geom_col()
    + coord_flip()
    + labs(x="Brightness", y="Genre")
)

Quels motifs remarquez-vous parmi les genres ? Lesquels semblent avoir les affiches les plus sombres, lesquels les plus lumineuses ? Pouvez-vous résumer cette tendance ? Avez-vous des hypothèses sur ce qui se passe ici ?

1.6 Saturation et chroma

Nous avons déjà vu qu’avec une analyse soignée, on peut faire pas mal de choses avec une annotation relativement simple fondée sur la luminosité. Mais notre objectif est de comprendre plus largement l’usage de la couleur dans les affiches de films, ce qui demande de créer d’autres annotations capturant d’autres aspects de la couleur. Une façon de faire est de convertir les intensités de pixels brutes dans un autre espace colorimétrique.

La représentation RGB des pixels par les quantités de lumière rouge, verte et bleue nécessaires pour créer une couleur à un point donné vient des contraintes techniques de capture et d’affichage. Comme on l’a vu, ce n’est pas une manière particulièrement pertinente de penser notre perception des couleurs. Heureusement, il existe d’autres façons de représenter la couleur, plus proches de la perception humaine. Pour comprendre comment cela fonctionne, rechargeons l’affiche du film Chisum avec John Wayne.

View code
arr =np.asarray(Image.open(
    posters.filter(c.title == "Chisum")["filepath"][0]
))

Rappelons que nous avions repéré plus haut un pixel correspondant à l’orange brûlé de l’affiche. Sa représentation RGB est la suivante :

View code
arr[180, 25, :]
array([112,  51,   0], dtype=uint8)

On peut convertir le format RGB en format HSV avec la fonction cv2.cvtColor en spécifiant le type de transformation colorimétrique (COLOR_RGB2HSV) en second argument. Nous effectuerons quelques conversions d’échelle de sortie pour les ramener entre 0 et 1, ce qui correspond mieux aux autres sources ainsi qu’à la discussion plus détaillée de cette étude de cas au chapitre 3 de Distant Viewing.

View code
arr_hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
arr_hsv = arr_hsv.astype(np.float64)
arr_hsv[:, :, 0] = arr_hsv[:, :, 0] / 179.0
arr_hsv[:, :, 1] = arr_hsv[:, :, 1] / 255.0
arr_hsv[:, :, 2] = arr_hsv[:, :, 2] / 255.0
arr_hsv.shape
(384, 252, 3)

La forme de la sortie est exactement la même que celle de l’image originale. Les lignes et les colonnes correspondent toujours aux mêmes positions que dans le modèle RGB ; seul le triplet de nombres à cet emplacement a changé. Voyons à quoi ressemble maintenant notre pixel orange brûlé :

View code
arr_hsv[180, 25, :]
array([0.07821229, 1.        , 0.43921569])

La première composante vaut environ 0,095. Elle correspond à la teinte (hue) du pixel, et ce nombre correspond à l’orange. La teinte est un peu complexe et nous l’examinerons plus en détail dans la section suivante. Le deuxième nombre est la saturation, qui indique la richesse de la couleur. Un pastel pâle, comme un rose pâle, aura une saturation faible. Ici, la saturation est à son maximum : 1. Enfin, la valeur est une autre représentation de la luminosité, ici égale à 0,74.

Concentrons-nous maintenant sur la saturation des affiches et faisons une analyse similaire à celle menée sur la luminosité. Pour coller au plus près de l’analyse du livre, nous calculerons la quantité apparentée appelée chroma plutôt que de travailler directement avec la saturation. Elle s’obtient en multipliant la saturation par la valeur. Nous utilisons cette quantité car elle correspond mieux à l’idée de richesse de couleur que nous cherchons à capturer. Par exemple, l’orange brûlé de notre affiche Chisum a une saturation de 1, mais un chroma de seulement 0,74 (saturation fois valeur). Seul un orange « pur », comme celui d’une roue chromatique, aurait un chroma de 1.

Parcourons maintenant les affiches et ajoutons à chacune la valeur moyenne de chroma, comme nous l’avons fait pour la luminosité.

View code
results = []
for row in posters.iter_rows(named=True):
    arr = np.asarray(Image.open(row["filepath"]))
    arr_hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
    arr_hsv = arr_hsv.astype(np.float64)
    arr_hsv[:, :, 0] = arr_hsv[:, :, 0] / 179.0
    arr_hsv[:, :, 1] = arr_hsv[:, :, 1] / 255.0
    arr_hsv[:, :, 2] = arr_hsv[:, :, 2] / 255.0
    results.append(np.mean(arr_hsv[:, :, 1] * arr_hsv[:, :, 2]))


posters = posters.with_columns(
    avg_chroma = pl.Series(results).round(4)
)

Et de nouveau, nous trierons les données d’affiches du chroma le plus élevé au plus faible.

View code
posters.sort(c.avg_chroma, descending=True)
shape: (4_680, 6)
year title filepath period avg_brightness avg_chroma
i64 str str str f64 f64
2006 "The Omen" "data/mp_med/2006_the_omen.jpg" "2005-2009" 0.3294 0.9566
2007 "Daddy's Little Girls" "data/mp_med/2007_daddys_little… "2005-2009" 0.5613 0.9216
1980 "The Shining" "data/mp_med/1980_the_shining.j… "1980-1984" 0.5536 0.79
2006 "Curious George" "data/mp_med/2006_curious_georg… "2005-2009" 0.5339 0.7616
2004 "The SpongeBob SquarePants Movi… "data/mp_med/2004_the_spongebob… "2000-2004" 0.6807 0.7588
1981 "Mommie Dearest" "data/mp_med/1981_mommie_deares… "1980-1984" 0.3377 0.0
1982 "A Midsummer Night's Sex Comedy" "data/mp_med/1982_a_midsummer_n… "1980-1984" 0.9384 0.0
1983 "Love Letters" "data/mp_med/1983_love_letters.… "1980-1984" 0.437 0.0
1992 "Husbands and Wives" "data/mp_med/1992_husbands_and_… "1990-1994" 0.3448 0.0
2006 "Rocky Balboa" "data/mp_med/2006_rocky_balboa.… "2005-2009" 0.7087 0.0

Examinons à nouveau quelques affiches aux valeurs extrêmes. C’est toujours une étape importante, lorsqu’on travaille avec de nouvelles annotations, de revenir aux images originales et de les regarder pour voir comment la représentation numérique correspond à notre propre perception et interprétation. Comme avant, n’hésitez pas à explorer d’autres affiches aux valeurs particulièrement élevées.

View code
(
    posters
    .sort(c.avg_chroma, descending=True)
    .pipe(plot_image_grid, ncol=4, limit=12, label_name="avg_chroma")
)

On peut faire de même avec les affiches au chroma moyen le plus faible. Beaucoup d’affiches ont un chroma moyen égal à zéro. Pouvez- vous deviner ce qu’elles ont toutes en commun avant de regarder les exemples ?

View code
(
    posters
    .sort(c.avg_chroma, descending=False)
    .pipe(plot_image_grid, ncol=4, limit=12, label_name="avg_chroma")
)

Voyons comment le chroma moyen se rapporte aux genres associés à chaque affiche. Étant donné qu’il y a un changement marqué de luminosité durant les vingt premières années des données (beaucoup étaient en noir et blanc), nous filtrerons les données après la fusion avec les genres pour ne garder que les années à partir de 1990.

View code
(
    posters
    .filter(c.year >= 1990)
    .join(genre, on=[c.year, c.title])
    .group_by(c.genre)
    .agg(avg_chroma = c.avg_chroma.mean())
    .sort(c.avg_chroma)
    .pipe(ggplot, aes("reorder(genre, avg_chroma)", "avg_chroma"))
    + geom_col()
    + coord_flip()
    + labs(x="Chroma", y="Genre")
)

Prenez un moment pour examiner les résultats. Quels motifs remarquez- vous ? Quelque chose vous paraît-il (i) particulièrement surprenant ou (ii) particulièrement attendu ? Les deux types d’observations sont utiles pour comprendre le lien entre les messages véhiculés par la couleur de l’affiche et les genres associés.

1.7 Couleur dominante

Après avoir examiné la luminosité/valeur et la saturation/chroma des affiches, nous passons au troisième élément de la couleur : la teinte (hue). Pour commencer, prenons une autre affiche. Nous allons charger celle du film Take the Lead (2006), qui contient plusieurs teintes différentes.

View code
img = Image.open(
    posters.filter(c.title == "Take the Lead")["filepath"][0]
)
img

Calculons maintenant les coordonnées HSV de cette image, comme dans la section précédente. Nous réorganiserons aussi les données de pixels pour que le tableau ait une ligne par pixel et seulement trois colonnes. C’est une simple réorganisation qui simplifiera la suite du code.

View code
arr = np.asarray(img)
arr_hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
arr_hsv = arr_hsv.astype(np.float64)
arr_hsv[:, :, 0] = arr_hsv[:, :, 0] / 179.0
arr_hsv[:, :, 1] = arr_hsv[:, :, 1] / 255.0
arr_hsv[:, :, 2] = arr_hsv[:, :, 2] / 255.0
arr_hsv = arr_hsv.reshape((-1, 3), order = "F")
arr_hsv.shape
(99456, 3)

La teinte est un nombre entre 0 et 1 qui désigne ce que l’on appelle familièrement la « couleur ». Contrairement à la luminosité, à la saturation, au chroma et à la valeur, ces nombres se conçoivent mieux disposés en cercle (voir les diapositives associées pour une visualisation). Une valeur de 0 correspond au rouge, 0,33 au vert, 0,5 au cyan, et 0,66 au bleu. Les valeurs proches de 1 rebouclent par le violet et reviennent vers le rouge. Ainsi, les teintes 0,01 et 0,99 sont en réalité très proches.

Pour mieux comprendre, regardons un histogramme de la distribution des teintes dans l’image. Attention toutefois : l’interprétation de la teinte n’est valable que si le chroma est suffisamment élevé. Si le chroma est faible, il y a peu de couleur de toute façon, et les différences entre teintes peuvent être difficiles, voire impossibles, à distinguer. Dans le code ci-dessous, nous affichons la distribution des teintes pour un chroma supérieur à 0,3.

View code
plt.hist(arr_hsv[arr_hsv[:,1] * arr_hsv[:,2] > 0.3, 0], bins=100)
plt.show()

On devrait voir beaucoup de valeurs autour de 0,3 ; elles correspondent au vert de l’affiche, qui occupe beaucoup de place dans l’image. Le pic près de 0,66 est associé au bleu, principalement sur les silhouettes des deux personnages. La plus petite zone près de 1, et qui reboucle à 1, correspond au orange/rouge du titre du film.

Calculer la moyenne des teintes ne fournit généralement pas de résumés significatifs. Exemple extrême : la moyenne de deux nuances de rouge ayant des teintes de 0,99 et 0,01 donnerait 0,5, soit du cyan, une couleur située directement entre le vert et le bleu. À la place, on peut créer une annotation de teinte en découpant l’éventail des teintes en noms de couleurs standard, puis en comptant la proportion de chaque affiche correspondant à chaque couleur. Pour cela, nous chargeons un autre jeu de données contenant les seuils que nous avons définis pour chaque teinte.

View code
hue = pl.read_csv("data/movies_50_years_hue.csv")
hue
shape: (8, 4)
cnom start end mid
str f64 f64 f64
"rouge" 0.0 0.015625 0.0078125
"orange" 0.015625 0.109375 0.0625
"jaune" 0.109375 0.203125 0.15625
"vert" 0.203125 0.453125 0.328125
"cyan" 0.453125 0.546875 0.5
"bleu" 0.546875 0.765625 0.65625
"violet" 0.765625 0.953125 0.859375
"rouge" 0.953125 1.0 0.9765625

Ensuite, nous reprenons les teintes de arr_hsv et comptons les pixels présents dans chacune de ces tranches, après filtrage pour ne garder qu’un chroma suffisamment élevé pour avoir une teinte significative.

View code
bins = np.append(0, hue['end'].to_numpy())
cnt, _ = np.histogram(
    arr_hsv[(arr_hsv[:,1] * arr_hsv[:,2] > 0.3), 0], bins = bins
)
cnt[0] = cnt[0] + cnt[7]
cnt = cnt[:7]
cnt
array([ 1106,  2638,  1244, 41648,  2167, 15090,   139])

Il y a plusieurs façons de résumer ces décomptes. Nous en tirerons deux annotations. D’abord, nous associons à chaque affiche une couleur dominante, correspondant à la teinte la plus représentée. On le fait avec le code suivant. On voit que le code associe l’affiche de Take the Lead à la couleur verte, comme l’histogramme le laissait prévoir.

View code
hue["cnom"].to_numpy()[np.argmax(cnt)]
'vert'

L’autre quantité utile à conserver est la proportion de l’affiche correspondant à cette couleur dominante. Avec le code ci-dessous, on voit que plus de 42 % de cette affiche correspond aux teintes classées comme « vertes ».

View code
color_percent = np.max(cnt) / arr_hsv.shape[0] * 100
color_percent
np.float64(41.87580437580438)

Maintenant que nous savons faire cela sur une seule image, parcourons toutes les affiches et calculons le nom de la couleur dominante ainsi que le pourcentage de l’affiche correspondant à chaque couleur pour chacune d’elles.

View code
results = []
results_percent = []
for row in posters.iter_rows(named=True):
    arr = np.asarray(Image.open(row["filepath"]))
    arr_hsv = cv2.cvtColor(arr, cv2.COLOR_RGB2HSV)
    arr_hsv = arr_hsv.astype(np.float64)
    arr_hsv[:, :, 0] = arr_hsv[:, :, 0] / 179.0
    arr_hsv[:, :, 1] = arr_hsv[:, :, 1] / 255.0
    arr_hsv[:, :, 2] = arr_hsv[:, :, 2] / 255.0
    arr_hsv = arr_hsv.reshape((-1, 3), order = "F")
    bins = np.append(0, hue["end"].to_numpy())
    cnt, _ = np.histogram(
        arr_hsv[(arr_hsv[:,1] * arr_hsv[:,2] > 0.3), 0], bins = bins
    )
    cnt[0] = cnt[0] + cnt[7]
    cnt = cnt[:7]
    results.append(hue["cnom"].to_numpy()[np.argmax(cnt)])
    results_percent.append(np.max(cnt) / arr_hsv.shape[0] * 100)


posters = posters.with_columns(
    dom_color = pl.Series(results),
    dom_color_percent = pl.Series(results_percent).round(4)
)

Comme pour les deux autres annotations, on peut commencer par regarder les affiches qui ont la plus grande proportion de couleur dominante pour chaque teinte. Voici par exemple le code pour afficher l’image avec la plus grande quantité de bleu. Essayez de modifier le code pour voir d’autres couleurs comme « rouge », « jaune » ou « vert ».

View code
(
    posters
    .filter(c.dom_color == "bleu")
    .sort(c.dom_color_percent, descending=True)
    .pipe(plot_image_grid, ncol=4, limit=12, label_name="avg_chroma")
)

Faisons maintenant un peu d’analyse avec ces annotations. Nous fusionnerons à nouveau les données de genre avec les annotations. Nous ne considérerons aussi que les affiches dont la couleur dominante sélectionnée représente au moins 5 %. On peut alors calculer la proportion d’affiches de chaque genre ayant le rouge comme couleur dominante avec le code suivant.

View code
(
    posters
    .join(genre, on=[c.year, c.title])
    .filter(c.dom_color_percent > 5)
    .group_by(c.genre)
    .agg(prop_color = (c.dom_color == "rouge").mean())
    .sort(c.prop_color)
    .pipe(ggplot, aes("reorder(genre, prop_color)", "prop_color"))
    + geom_col()
    + coord_flip()
    + labs(x="Proportion", y="Genre")
)

Voyez-vous des motifs intéressants dans les données ci-dessus ? Après les avoir examinées de près, essayez de changer la couleur d’intérêt pour voir si d’autres motifs apparaissent.

1.8 Conclusion et prochaines étapes

Ce tutoriel a couvert beaucoup de matière. Nous avons présenté les bases de Python ainsi qu’une compréhension de la façon dont les images numériques sont représentées comme des tableaux d’intensités de pixels. À partir de là, nous avons posé les enjeux théoriques du distant viewing. Puis nous l’avons mis en pratique en générant des annotations de plus en plus complexes fondées sur notre compréhension de la couleur, et enfin en utilisant un algorithme d’apprentissage automatique à base de réseaux de neurones pour détecter les visages présents sur chaque affiche. Tout au long, nous avons cherché à relier chacune de nos analyses à des questions de recherche portant sur les cultures visuelles autour des affiches de films, à travers différents genres et sur une période de 50 ans.

Il y a de nombreuses directions à explorer après ce notebook. Si vous souhaitez voir comment mener à bien l’analyse présentée ici, y compris l’étape importante de la mise en relation des observations avec les sources d’archives et savantes existantes, nous vous suggérons de consulter le chapitre 3 de notre livre Distant Viewing. Un lien est disponible en haut de ce notebook. Si vous débutez en programmation, apprendre un peu plus de Python à partir des principes de base est un bon point de départ. Si les images animées vous intéressent, nous proposons un notebook de suivi qui parcourt un exemple à partir d’un ensemble d’émissions de télévision américaines des années 1960 et 1970. Sinon, nous vous invitons à explorer les autres annotations disponibles dans le distant viewing toolkit et à les appliquer à vos propres collections.

Merci de prendre le temps de nous lire, et n’hésitez pas à nous faire part de vos retours !