Tutoriel Distant Viewing 3 : Images animées

[diapositives] [chapitre] [site web]

Ce notebook explore la théorie et les méthodes présentées dans le livre Distant Viewing (MIT Press, 2023) pour étudier le style visuel de deux sitcoms de l’ère des grands réseaux télévisés américains. Concrètement, nous allons examiner chaque épisode diffusé des séries Bewitched (1964-1972) et I Dream of Jeannie (1965-1970). Dans le notebook, nous détaillerons d’abord la méthodologie à partir d’un court extrait vidéo de 45 secondes, en avançant pas à pas. Puis, faute de temps, et compte tenu de la taille des fichiers et des contraintes de droits d’auteur, nous chargerons un jeu d’annotations précalculées équivalent à celles de l’extrait, et nous utiliserons cet ensemble plus large pour l’analyse. Voici les objectifs d’apprentissage du tutoriel :

  1. Expliquer comment les vidéos numériques peuvent être comprises comme une séquence d’images.
  2. Appliquer les fonctions de vision par ordinateur du distant viewing toolkit à un court fichier vidéo.
  3. Calculer les ruptures de plan à l’aide d’un algorithme de vision par ordinateur de pointe, et relier ces ruptures à des questions de recherche en media studies.
  4. Classer l’identité estimée des personnages à l’aide d’algorithmes de vision par ordinateur et de plongements de visages.
  5. Montrer comment aborder des questions de recherche en sciences humaines à l’aide d’algorithmes de vision par ordinateur appliqués à un corpus de séries télévisées.

Ce notebook ne requiert aucune connaissance préalable de Python ni de la vision par ordinateur. Cependant, il avance assez rapidement sur les étapes préliminaires de manipulation des images numériques et n’explique que les aspects les plus importants du code Python à chaque étape. Pour une introduction plus approfondie à la façon dont les ordinateurs « voient » les images et à Python, nous recommandons de suivre d’abord le notebook Distant Viewing Tutorial: Movie Posters and Color Analysis, accessible ici.

3.1 Configuration

Pour commencer, nous devons installer quelques composants Python supplémentaires, télécharger les jeux de données, et indiquer à Python toutes les fonctions dont nous aurons besoin par la suite. Pour démarrer, nous utiliserons le code ci-dessous pour installer le module dvt (le distant viewing toolkit), qui contient plusieurs fonctions utiles spécialement conçues pour appliquer des algorithmes de vision par ordinateur à des collections de données en humanités. Le point d’exclamation au début de la ligne de code indique au notebook que nous voulons exécuter directement un outil en ligne de commande en dehors de Python. Ici, nous utilisons pip pour installer des fonctionnalités supplémentaires pour Python. 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, car Colab met toujours un peu de temps à se mettre en place lors de l’exécution du premier bloc de code.

View code
!pip install -q dvt
!pip install -q insightface
!pip install -q onnxruntime
[notice] A new release of pip is available: 26.0.1 -> 26.1.1

[notice] To update, run: pip install --upgrade pip



[notice] A new release of pip is available: 26.0.1 -> 26.1.1

[notice] To update, run: pip install --upgrade pip



[notice] A new release of pip is available: 26.0.1 -> 26.1.1

[notice] To update, run: pip install --upgrade pip

Ensuite, le code ci-dessous télécharge les métadonnées et les fichiers vidéo 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 portraits de personnages. 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. Comme ci-dessus, passez la souris sur le code puis cliquez sur le bouton de lecture à gauche du bloc. 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 cache
!mkdir -p /root/.cache/torch/hub/checkpoints/
!wget -q -nc -P data/ "https://distantviewing.org/atelier/ttl_sitcom_characters.csv"
!wget -q -nc -P data/ "https://distantviewing.org/atelier/ttl_sitcom_metadata.csv"
!wget -q -nc -P data/ "https://distantviewing.org/atelier/ttl_sitcom_shots.csv"
!wget -q -nc "https://distantviewing.org/atelier/faces.tar"
!wget -q -nc "https://distantviewing.org/atelier/bewitched_sample.mp4"
!wget -q -nc "https://distantviewing.org/atelier/funs.py"
!tar -xf faces.tar --warning=no-unknown-keyword

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. Plus précisément, il s’agit de os pour interagir avec le système de fichiers, numpy pour travailler avec de grands tableaux de nombres, polars pour les jeux de données tabulaires, matplotlib.pyplot pour la visualisation de base des images, plotnine pour la visualisation de données, insightface pour la détection et la reconnaissance de visages, PIL (Pillow) pour charger les fichiers image, et le module dvt décrit ci-dessus.

View code
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import polars as pl
import numpy as np
import cv2
import os
import dvt
import insightface
from insightface.app import FaceAnalysis
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, l’installation du paquet dvt et le téléchargement des données ne devraient être faits 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.

3.2 Jeu de données des sitcoms de l’ère des réseaux

Avant de regarder les étapes computationnelles nécessaires pour travailler avec des images animées, il est utile de comprendre les questions de recherche en sciences humaines qui motivent ce travail. Ici, nous nous intéressons à deux sitcoms américains populaires des années 1960 et du début des années 1970 : Bewitched (1964-1972) et I Dream of Jeannie (1965-1970). Voici les métadonnées de chacun des épisodes de l’ensemble des deux séries :

View code
meta = pl.read_csv("data/ttl_sitcom_metadata.csv")
meta
shape: (393, 8)
series season number title director writer air_date description
str i64 i64 str str str str str
"Bewitched" 1 1 "I, Darrin, Take This Witch, Sa… "William Asher" "Sol Saks" "1964-09-17" "In the pilot episode, stranger…
"Bewitched" 1 2 "Be It Ever So Mortgaged" "William Asher" "Barbara Avedon" "1964-09-24" "Endora is still surprised that…
"Bewitched" 1 3 "It Shouldn't Happen to a Dog" "William Asher" "Jerry Davis" "4-10-01)[3" "Samantha is preparing dinner f…
"Bewitched" 1 4 "Mother Meets What's-His-Name" "William Asher" "Danny Arnold" "1964-10-08" "Samantha is visited by Gladys …
"Bewitched" 1 5 "Help, Help, Don't Save Me" "William Asher" "Danny Arnold" "1964-10-15" "Darrin has been spending all h…
"I Dream of Jeannie" 5 22 "Eternally Yours, Jeannie" "Joseph Goodson" "James Henerson" "1970-03-17" "Tony gets a letter from his ol…
"I Dream of Jeannie" 5 23 "An Astronaut in Sheep's Clothi… "Bruce Kessler" "James Henerson" "1970-03-24" "After Jeannie blinks Tony a dr…
"I Dream of Jeannie" 5 24 "Hurricane Jeannie" "Claudio Guzman" "James Henerson" "1970-04-28" "A hurricane traps Tony, Jeanni…
"I Dream of Jeannie" 5 25 "One Jeannie Beats Four of a Ki… "Michael Ansara" "Perry Grant, Richard Bensfield" "1970-05-19" "General Schaeffer tells Major …
"I Dream of Jeannie" 5 26 "My Master, the Chili King" "Claudio Guzman" "James Henerson" "1970-05-26" "Tony's cousin Arvel (Gabriel D…

Ces deux séries sont souvent comparées et opposées, I Dream of Jeannie étant vu comme une tentative de NBC pour reproduire le succès de Bewitched sur ABC, qui avait débuté une saison plus tôt. Bien qu’on ait beaucoup écrit sur la portée culturelle de ces deux séries, la recherche n’avait pas vraiment pris au sérieux leur style visuel. Nous nous intéressons à la façon dont le tournage et le montage des deux séries éclairent des questions comme : qui est ou qui sont les personnages principaux ? Dans quelle mesure le style visuel contribue-t-il aux relations entre les personnages ? Et comment ces aspects évoluent-ils dans le temps ? Cette dernière question est particulièrement intéressante parce que les deux séries ont débuté en noir et blanc avant de passer à la couleur pour la majorité des saisons suivantes.

3.3 Fichier vidéo d’exemple

Maintenant que nous avons quelques idées des grandes questions qui nous intéressent, regardons un court extrait de l’une des séries pour comprendre les étapes computationnelles nécessaires pour travailler avec des images animées. Lorsqu’on mène des analyses computationnelles sur des matériaux visuels et audiovisuels, il est important de revenir fréquemment à des exemples précis pour s’assurer que notre analyse à grande échelle reste connectée à l’expérience humaine du visionnage. Dans ce notebook, nous allons commencer par parcourir lentement le processus de création d’annotations qui résument un court extrait de 45 secondes tiré du premier épisode de la troisième saison de Bewitched.

Nous vous suggérons de regarder l’extrait deux ou trois fois. Essayez de prêter attention (et peut-être même de noter) au nombre de plans dans l’extrait et à leur cadrage. Dans les sections suivantes, nous utiliserons des algorithmes de vision par ordinateur pour essayer de capturer ces traits sous forme d’annotations structurées.

3.4 Travailler avec les images d’une vidéo

Les formats vidéo comme mp4, avi ou ogg stockent habituellement les images animées dans un format complexe, très optimisé pour réduire la taille des fichiers et le temps de décompression. Lorsqu’on travaille avec des images animées en Python, on décompresse en général ces fichiers pour les transformer en une suite d’images individuelles stockées comme des tableaux de pixels — une par image de la vidéo d’entrée. Le distant viewing toolkit (dvt) contient plusieurs fonctions pour travailler efficacement avec des fichiers vidéo en Python. Dans cette section, nous allons voir comment elles fonctionnent.

D’abord, on peut utiliser la fonction video_info pour accéder aux métadonnées d’un fichier vidéo. Ici, nous chargeons les métadonnées de notre extrait, qui affichent le nombre de images, la hauteur et la largeur de chaque image en pixels, et la fréquence de lecture en images par seconde (IPS ou FPS en anglais).

View code
info = dvt.video_info('bewitched_sample.mp4')
info
{'frame_count': 1319.0,
 'height': 480.0,
 'width': 710.0,
 'fps': 29.97002997002997}

Comme on le voit dans les métadonnées, même ce court extrait contient 1319 images individuelles. Sur un épisode complet de série télévisée, sans parler d’un long métrage, charger toutes les images d’une vidéo en une seule fois en Python devient vite difficile. La taille des images, en particulier en haute définition, est tout simplement trop importante pour qu’elles tiennent toutes en même temps dans la mémoire de l’ordinateur.

Le distant viewing toolkit (dvt) propose une approche alternative avec la fonction yield_video. Elle permet d’écrire une boucle qui charge chaque image de la vidéo une par une. À chaque appel, la fonction yield renvoie la image suivante sous forme de tableau de pixels, un entier décrivant le numéro de la image depuis le début de la vidéo, et un code temporel donnant le nombre de secondes écoulées.

Le code ci-dessous montre un exemple d’utilisation de yield_video. On parcourt chaque image. Pour chacune, on stocke le numéro de image, le code temporel, et la valeur moyenne des pixels (une mesure de la luminosité de la image). Puis on transforme l’ensemble en une table de données avec une ligne par image de la vidéo.

View code
output = {'frame': [], 'time': [], 'brightness': []}
for img, frame, msec in dvt.yield_video("bewitched_sample.mp4"):
    output['frame'].append(frame)
    output['time'].append(msec)
    output['brightness'].append(np.mean(img))

output = pl.DataFrame(output)
output
shape: (1_319, 3)
frame time brightness
i64 f64 f64
0 0.0 87.132526
1 0.033367 87.22107
2 0.066733 87.496499
3 0.1001 87.620548
4 0.133467 87.445358
1314 43.8438 68.011383
1315 43.877167 68.054915
1316 43.910533 68.043498
1317 43.9439 68.028847
1318 43.977267 67.969112

La luminosité est l’une des annotations les plus simples qu’on puisse utiliser pour résumer une image. Mais même cette mesure élémentaire permet déjà d’avoir une représentation grossière de notre extrait. Pour le voir, traçons la luminosité de la image au cours du temps.

View code
(
    output
    .pipe(ggplot, aes("time", "brightness"))
    + geom_line()
)

En regardant la luminosité, et en particulier les grands sauts de valeurs, parvenez-vous à identifier les ruptures de plan que vous aviez repérées en regardant la vidéo ? Il y a une montée régulière de la luminosité entre 15 et 20 secondes environ. À quoi cela correspond-il dans la vidéo ?

3.5 Détection des ruptures de plan

L’une des premières tâches qu’on cherche souvent à effectuer sur un fichier vidéo est d’identifier les coupures entre les plans. Ce processus s’appelle la détection des ruptures de plan (shot boundary detection). Comme on l’a vu sur le graphique de luminosité ci-dessus, des heuristiques simples permettent déjà d’identifier approximativement de nombreux types de ruptures. Pour obtenir des prédictions plus précises — qui ne confondent pas un mouvement rapide avec une rupture de plan et qui ne ratent pas les transitions plus subtiles comme les fondus enchaînés — il faut faire appel à un algorithme plus complexe. Le distant viewing toolkit (dvt) inclut un algorithme de détection de ruptures de plan fondé sur les réseaux de neurones, qui fonctionne bien sur de nombreux genres et types de sources.

Pour exécuter l’algorithme intégré, nous créons d’abord un annotateur avec la fonction AnnoShotBreaks. Puis nous le faisons tourner sur le fichier vidéo. C’est le seul annotateur du toolkit qui travaille directement sur un fichier vidéo plutôt que sur des images ou des images individuelles. Le code Python affichera sa progression dans le fichier au fur et à mesure, et renverra un dictionnaire contenant les ruptures prédites.

View code
anno_breaks = dvt.AnnoShotBreaks()
out_breaks = anno_breaks.run("bewitched_sample.mp4")

Processing video frames 50/1319
Processing video frames 100/1319
Processing video frames 150/1319
Processing video frames 200/1319
Processing video frames 250/1319
Processing video frames 300/1319
Processing video frames 350/1319
Processing video frames 400/1319
Processing video frames 450/1319
Processing video frames 500/1319
Processing video frames 550/1319
Processing video frames 600/1319
Processing video frames 650/1319
Processing video frames 700/1319
Processing video frames 750/1319
Processing video frames 800/1319
Processing video frames 850/1319
Processing video frames 900/1319
Processing video frames 950/1319
Processing video frames 1000/1319
Processing video frames 1050/1319
Processing video frames 1100/1319
Processing video frames 1150/1319
Processing video frames 1200/1319
Processing video frames 1250/1319
Processing video frames 1300/1319
Processing video frames 1319/1319

Nous allons convertir la sortie de la détection en jeu de données tabulaire, et ajouter l’heure de début et l’heure de fin à l’aide des métadonnées récupérées avec video_info. Voici ce que l’algorithme a trouvé pour cet extrait :

View code
shot = (
    pl.DataFrame(out_breaks['scenes'])
    .with_columns(
        mid=((c.start + c.end) // 2),
        start_t=(c.start / info['fps']),
        end_t=(c.end / info['fps']),
    )
)
shot
shape: (6, 5)
start end mid start_t end_t
i32 i32 i32 f64 f64
0 98 49 0.0 3.269933
99 184 141 3.3033 6.139467
185 427 306 6.172833 14.247567
428 1006 717 14.280933 33.566867
1007 1213 1110 33.600233 40.473767
1214 1318 1266 40.507133 43.977267

Prenez quelques minutes pour vérifier que ces ruptures correspondent bien à celles que vous aviez repérées en regardant la vidéo. Vous pouvez même revoir la vidéo (en mettant en pause à chaque coupure) et constater qu’elle coïncide avec les ruptures identifiées automatiquement.

Dans la section suivante, nous nous intéresserons à la détection et à l’identification de visages. C’est une tâche courante lorsqu’on travaille avec des images animées, mais qui finalement s’applique sur des images individuelles. Pour simplifier le travail au début, nous allons utiliser le code suivant pour construire un jeu de données contenant une image par plan détecté dans l’extrait. Plus précisément, nous prenons l’image centrale de chaque plan, telle que repérée dans la table shot ci-dessus.

View code
img_list = []
for img, frame, msec in dvt.yield_video("bewitched_sample.mp4"):
    if frame in shot['mid'].to_list():
        img_list.append(img)

Pour avoir une idée de ce à quoi elles ressemblent, on peut combiner ces images et les convertir en vignettes afin de voir les six plans de notre extrait.

View code
img_comb = np.hstack(img_list)
img_comb = cv2.resize(img_comb, (img_comb.shape[1] // 5, img_comb.shape[0] // 5))
fig, ax = plt.subplots(1, 1, figsize=(12, 12))
ax.imshow(img_comb)
ax.axis("off")
plt.tight_layout()
plt.show()

Nous utiliserons les versions complètes de ces six images dans la section suivante pour identifier la position, la taille et l’identité des personnages dans chaque plan.

3.6 Détection et reconnaissance de visages

Localiser et identifier les visages présents dans une image donnée d’une vidéo est une manière courante de comprendre la structure narrative et le style visuel d’une source. Dans cette section, nous verrons comment travailler avec les visages à l’aide de la bibliothèque InsightFace, appliquée aux six images de l’extrait extraites dans la section précédente. Après avoir vu comment procéder sur un ensemble statique d’images, nous verrons dans la section suivante comment assembler le tout pour qu’il puisse passer à l’échelle sur des fichiers vidéo plus longs.

Pour commencer, on initialise un objet FaceAnalysis d’InsightFace en précisant le pack de modèles buffalo_l. Au premier lancement, la fonction téléchargera automatiquement les fichiers de modèles nécessaires et les enregistrera localement.

View code
face_app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])
face_app.prepare(ctx_id=0, det_size=(640, 640))
Applied providers: ['CPUExecutionProvider'], with options: {'CPUExecutionProvider': {}}
find model: /Users/admin/.insightface/models/buffalo_l/1k3d68.onnx landmark_3d_68 ['None', 3, 192, 192] 0.0 1.0
Applied providers: ['CPUExecutionProvider'], with options: {'CPUExecutionProvider': {}}
find model: /Users/admin/.insightface/models/buffalo_l/2d106det.onnx landmark_2d_106 ['None', 3, 192, 192] 0.0 1.0
Applied providers: ['CPUExecutionProvider'], with options: {'CPUExecutionProvider': {}}
find model: /Users/admin/.insightface/models/buffalo_l/det_10g.onnx detection [1, 3, '?', '?'] 127.5 128.0
Applied providers: ['CPUExecutionProvider'], with options: {'CPUExecutionProvider': {}}
find model: /Users/admin/.insightface/models/buffalo_l/genderage.onnx genderage ['None', 3, 96, 96] 0.0 1.0
Applied providers: ['CPUExecutionProvider'], with options: {'CPUExecutionProvider': {}}
find model: /Users/admin/.insightface/models/buffalo_l/w600k_r50.onnx recognition ['None', 3, 112, 112] 127.5 127.5
set det-size: (640, 640)

Pour détecter les visages, on convertit l’image du format RGB au format BGR (celui qu’attend InsightFace) et on la passe à la méthode get de l’objet face_app. Lançons la détection sur la troisième image (en se rappelant que Python commence à compter à zéro : la troisième image est donc à la position 2).

View code
image_bgr = img_list[2][:, :, ::-1]
faces = face_app.get(image_bgr)

On dessine ensuite les boîtes englobantes détectées sur l’image à l’aide de matplotlib. La boîte de chaque visage détecté est stockée dans l’attribut bbox de l’objet renvoyé. Ici, on voit que l’algorithme a détecté un seul visage — ce qui correspond très probablement au nombre de visages qu’un annotateur humain aurait trouvés sur l’image.

View code
colors = plt.cm.tab10.colors
fig, ax = plt.subplots(1, 1, figsize=(5, 7))
ax.imshow(img_list[2])
for i, face in enumerate(faces):
    x1, y1, x2, y2 = [int(v) for v in face.bbox]
    color = colors[i % len(colors)]
    rect = patches.Rectangle((x1, y1), x2 - x1, y2 - y1, linewidth=2.5, edgecolor=color, facecolor="none")
    ax.add_patch(rect)

ax.axis("off")
plt.tight_layout()
plt.show()

Pour pouvoir utiliser cette détection dans une analyse computationnelle, il faut représenter la position du visage détecté dans un format structuré. On construit une table polars à partir des attributs bbox (coordonnées de la boîte englobante) et det_score (score de confiance de la détection) de chaque visage. Voici toutes les informations structurées sur le visage détecté dans l’image précédente :

View code
if len(faces) > 0:
    boxes = np.array([f.bbox for f in faces])
    df = pl.DataFrame({
        "face_id": list(range(len(faces))),
        "xmin": boxes[:, 0].tolist(),
        "ymin": boxes[:, 1].tolist(),
        "xmax": boxes[:, 2].tolist(),
        "ymax": boxes[:, 3].tolist(),
        "prob": [float(f.det_score) for f in faces],
    })
else:
    df = pl.DataFrame(schema={"face_id": pl.Int32, "xmin": pl.Float32, "ymin": pl.Float32, "xmax": pl.Float32, "ymax": pl.Float32, "prob": pl.Float32})
df
shape: (1, 6)
face_id xmin ymin xmax ymax prob
i64 f64 f64 f64 f64 f64
0 277.771545 120.524673 411.078156 302.229797 0.855546

Si vous avez suivi le notebook d’introduction sur les affiches de films, c’est la même information que celle utilisée pour détecter le nombre de visages présents sur chaque affiche. Ce qui change avec les images animées, c’est qu’on retrouve souvent les mêmes personnes d’une image ou d’un plan à l’autre. Étiqueter l’identité des personnes présentes dans chaque plan est une étape essentielle pour comprendre la structure narrative du matériau. InsightFace prend en charge la reconnaissance faciale en plus de la détection. Cela demande un peu plus de précautions : il faut commencer par identifier les personnages que l’on souhaite reconnaître.

Par défaut, InsightFace associe également à chaque visage détecté une séquence de 512 nombres appelée plongement normalisé (accessible via l’attribut normed_embedding). Les nombres individuels n’ont pas de signification directe : ils sont définis de manière relative, de sorte que deux images d’une même personne aient des plongements plus proches l’un de l’autre que des plongements de visages différents. À titre d’exemple, voici la forme du tableau des plongements pour les visages détectés sur notre image.

View code
np.array([f.normed_embedding for f in faces]).shape
(1, 512)

Une façon d’utiliser ces plongements consiste à identifier d’abord les portraits des acteurs qui nous intéressent dans la vidéo, puis à associer chacun à un nom de personnage. On calcule ensuite les plongements de ces visages et on les stocke. À chaque visage détecté dans une image, on compare ensuite la similarité entre son plongement et ceux de notre ensemble de référence. Si l’un des personnages de référence est suffisamment proche, on suppose que c’est lui qu’on a trouvé.

L’un des éléments téléchargés lors de la configuration du notebook était un dossier contenant les portraits des quatre acteurs principaux de Bewitched, nommés d’après leur personnage dans la série. Dans le code ci-dessous, on parcourt ces images, on charge chacune avec PIL, on lance InsightFace pour extraire le plongement, et on enregistre le nom du personnage. On stocke aussi une vignette du portrait pour l’affichage.

View code
face_embed = []
face_name = []
face_img = []
for path in sorted(os.listdir("faces")):
    img = np.array(Image.open("faces/" + path).convert("RGB"))
    img_bgr = img[:, :, ::-1]
    ref_faces = face_app.get(img_bgr)
    face_embed.append(ref_faces[0].normed_embedding)
    face_name.append(path[:-4])
    face_img.append(cv2.resize(img, (200, 250)))

face_embed = np.vstack(face_embed)
face_name = np.array(face_name)
face_img = np.hstack(face_img)

Voici les portraits des personnages. Ils sont rangés par ordre alphabétique : Darrin, Endora, Larry et Sam.

View code
fig, ax = plt.subplots(1, 1, figsize=(12, 12))
ax.imshow(face_img)
ax.axis("off")
plt.tight_layout()
plt.show()

Pour mesurer la similarité entre deux plongements, on utilise une technique mathématique appelée produit scalaire. Ce nombre vaut 1 si deux plongements sont exactement identiques, et -1 s’ils sont exactement opposés. En général, deux visages de personnes différentes auront un score de similarité proche de zéro. Commençons par regarder les scores de similarité entre les quatre portraits.

View code
np.dot(face_embed, face_embed.T)
array([[ 0.99999994, -0.02011934,  0.01973391,  0.1016839 ],
       [-0.02011934,  0.99999994,  0.03591111, -0.07137492],
       [ 0.01973391,  0.03591111,  1.0000001 ,  0.01680149],
       [ 0.1016839 , -0.07137492,  0.01680149,  0.9999999 ]],
      dtype=float32)

La première colonne correspond à Darrin, et compare Darrin à chacune des quatre images. Comme c’est la première image, on lit .99999 parce que la photo est comparée à elle-même. L’image de Darrin est ensuite comparée à Endora (.06135), Larry (-.19993) et Samantha (.16960).

Les valeurs sur la diagonale valent toutes 1 (ou presque) parce qu’on y compare un plongement à lui-même. Tous les autres scores se situent entre environ -0,2 et +0,17, comme attendu, puisque chaque portrait représente un acteur différent.

Comparons maintenant ces visages de référence au plongement de l’image avec laquelle nous avons commencé cette section : l’image centrale du troisième plan, qui montre Samantha dans une chambre rose (img_list[2]).

View code
image_bgr = img_list[2][:, :, ::-1]
frame_faces = face_app.get(image_bgr)
embeddings = np.array([f.normed_embedding for f in frame_faces])
dist = np.dot(face_embed, embeddings.T)
dist
array([[0.0760799 ],
       [0.03828314],
       [0.04655101],
       [0.54651666]], dtype=float32)

On constate ici un score de similarité bien plus élevé (0,59) entre ce visage et le dernier personnage de l’ensemble. Et en regardant l’image, on voit en effet qu’il s’agit du même personnage : Samantha.

On peut associer le visage au nom du personnage de manière algorithmique avec le code suivant.

View code
face_name[np.argmax(dist, axis = 0)]
array(['sam'], dtype='<U6')

Le code ci-dessus associe le visage au personnage dont la similarité est la plus élevée. Il serait également judicieux de conserver le score de similarité et de ne faire confiance à la correspondance que si ce score est suffisamment grand. On peut l’obtenir avec le code suivant.

View code
np.max(dist, axis = 0)
array([0.54651666], dtype=float32)

Maintenant que nous comprenons comment ce processus fonctionne sur une seule image, appliquons-le à chacun des plans de l’extrait.

3.7 Construire des annotations à partir d’un fichier vidéo

On peut combiner les techniques précédentes avec la fonction yield_video de dvt pour identifier les visages dans chacun des plans. Il est possible de faire tourner l’algorithme de détection sur chaque image, ce qui a certains avantages. Par souci de temps et de simplicité, nous l’appliquerons uniquement à l’image centrale de chaque plan détecté.

View code
output = []
for img, frame, msec in dvt.yield_video("bewitched_sample.mp4"):
    if frame in shot['mid'].to_list():
        img_bgr = img[:, :, ::-1]
        faces = face_app.get(img_bgr)
        if len(faces) > 0:
            boxes = np.array([f.bbox for f in faces])
            embeddings = np.array([f.normed_embedding for f in faces])
            dist = np.dot(face_embed, embeddings.T)
            n = len(faces)
            output.append(pl.DataFrame({
                "frame": [frame] * n,
                "time": [msec] * n,
                "xmin": boxes[:, 0].tolist(),
                "ymin": boxes[:, 1].tolist(),
                "xmax": boxes[:, 2].tolist(),
                "ymax": boxes[:, 3].tolist(),
                "prob": [float(f.det_score) for f in faces],
                "character": face_name[np.argmax(dist, axis=0)].tolist(),
                "confidence": np.max(dist, axis=0).tolist(),
            }))

output = pl.concat(output)
output = output.filter(c.prob > 0.7)
output
shape: (7, 9)
frame time xmin ymin xmax ymax prob character confidence
i64 f64 f64 f64 f64 f64 f64 str f64
49 1.634967 306.684021 87.512268 421.865662 260.002594 0.825149 "sam" 0.515615
141 4.7047 235.363098 169.322952 454.847229 444.766724 0.784464 "endora" 0.775891
306 10.2102 277.771545 120.524673 411.078156 302.229797 0.855546 "sam" 0.546517
717 23.9239 110.789764 162.596512 177.945328 241.057404 0.892581 "darrin" 0.472997
717 23.9239 409.637756 97.097603 483.493835 194.613052 0.856874 "larry" 0.401394
1110 37.037 207.959839 85.849342 442.111298 387.700195 0.872365 "darrin" 0.600953
1266 42.2422 220.426376 133.566284 445.793121 412.730194 0.825137 "larry" 0.690578

Prenez quelques minutes pour revenir aux vignettes de ces visages détectés et regarder dans quelle mesure elles correspondent aux personnages effectivement présents (si vous ne connaissez pas Bewitched, servez-vous des portraits pour apprendre les noms). La correspondance devrait être bonne, même si le quatrième plan a une confiance assez faible pour Darrin parce que son visage est assez petit sur l’image centrale. On obtiendrait de meilleurs résultats en ajoutant la première image du plan, plus centrée sur Darrin. Nous recommandons toujours de revenir aux résultats et de les inspecter au fur et à mesure. On peut alors ajuster son approche en fonction des données audiovisuelles avec lesquelles on travaille et des questions qui animent l’analyse.

3.8 Analyse du corpus complet

Nous disposons à présent de toutes les méthodes et de tout le code nécessaires pour identifier les plans et les visages dans un fichier d’images animées. Si nous avions un épisode entier de Bewitched, nous pourrions exécuter la même séquence d’étapes sur l’épisode complet sans rien changer. La seule différence serait que les résultats prendraient bien plus de temps à arriver. Si nous avions un dossier contenant chaque épisode de la série, il suffirait d’ajouter une boucle supplémentaire pour parcourir chaque fichier vidéo, en veillant à inclure le nom du fichier dans chaque ligne de la sortie. Enfin, pour étendre tout cela à une autre série, il suffirait d’ajouter dans notre dossier d’images de référence les portraits des personnages que l’on souhaite identifier.

Nous revenons aux deux séries — Bewitched et I Dream of Jeannie — qui sont au cœur du chapitre 5 de Distant Viewing. Le chapitre est construit autour d’une comparaison de ces deux sitcoms magiques, comparaison rendue possible par la combinaison de la détection des plans, de la détection des visages, et de la reconnaissance des visages.

L’ensemble complet des fichiers vidéo des deux séries est assez volumineux et soumis au droit d’auteur. Le traitement de tous ces fichiers prend aussi beaucoup de temps, en particulier dans une session Colab gratuite. Puisqu’il n’est pas possible d’exécuter directement les annotations ici sur le corpus complet, nous travaillerons à la place avec les annotations précalculées téléchargées lors de la configuration. Le jeu de données comporte une ligne par plan, avec les informations de timing et le nombre de visages.

View code
shots = pl.read_csv("data/ttl_sitcom_shots.csv")
shots
shape: (102_333, 7)
series video sid frame_start frame_stop time num_face
str str i64 i64 i64 f64 i64
"Bewitched" "bw_s01_e01" 0 0 331 13.84 2
"Bewitched" "bw_s01_e01" 1 332 508 7.38 2
"Bewitched" "bw_s01_e01" 2 509 586 3.25 2
"Bewitched" "bw_s01_e01" 3 587 657 2.96 2
"Bewitched" "bw_s01_e01" 4 658 785 5.34 2
"I Dream of Jeannie" "idoj_s05_e26" 161 32340 32474 5.63 2
"I Dream of Jeannie" "idoj_s05_e26" 162 32475 32575 4.21 7
"I Dream of Jeannie" "idoj_s05_e26" 163 32576 33620 43.58 4
"I Dream of Jeannie" "idoj_s05_e26" 164 33621 33639 0.79 3
"I Dream of Jeannie" "idoj_s05_e26" 165 33640 33711 3.0 1

Un indicateur rapide à calculer est la durée moyenne d’un plan pour chacune des deux séries. C’est une mesure couramment utilisée pour comprendre le rythme d’un film ou d’une série. Ici, on voit que Bewitched est légèrement plus rapide, avec une durée moyenne de plan de 5,3 secondes contre 6,2 secondes pour I Dream of Jeannie.

View code
(
    shots
    .group_by(c.series)
    .agg(time=c.time.mean())
    .sort(c.time)
    .pipe(ggplot, aes("series", "time"))
    + geom_col()
    + labs(x="Series", y="Mean shot length (s)")
)

Une mesure proche est la durée médiane des plans, qui montre ici que le plan médian est légèrement plus long dans Bewitched que dans Jeannie. Autrement dit, même si la durée moyenne d’un plan est un peu plus longue dans Bewitched, il pourrait y avoir dans Jeannie un ensemble de plans particulièrement longs qui tirent la moyenne vers le haut.

View code
(
    shots
    .group_by(c.series)
    .agg(time=c.time.median())
    .sort(c.time)
    .pipe(ggplot, aes("series", "time"))
    + geom_col()
    + labs(x="Series", y="Median shot length (s)")
)

L’un des motifs intéressants que nous avons remarqués en rédigeant le chapitre consacré à ces deux séries est que la durée moyenne d’un plan est étroitement liée au nombre de visages présents à l’écran. Nous avons choisi de plafonner le nombre de visages à trois (toute valeur supérieure à 3 devient 3), au vu de notre connaissance des deux séries. On voit comment la durée moyenne du plan augmente avec le nombre de visages à l’écran, dans les deux séries.

View code
(
    shots
    .with_columns(num_face=pl.when(c.num_face > 3).then(3).otherwise(c.num_face))
    .group_by([c.series, c.num_face])
    .agg(time=c.time.mean())
    .sort([c.series, c.num_face])
    .pipe(ggplot, aes("num_face", "time", fill="series"))
    + geom_col(position="dodge")
    + labs(x="Number of faces", y="Mean shot length (s)", fill="Series")
)

Nous avons déjà vu un exemple de ce motif dans notre extrait : le seul plan avec deux personnages était bien plus long que le plan avec un seul personnage. Cela permet de confirmer que la durée des plans est liée au style visuel. Pour ces deux séries, au moins, elle se rapporte à la fréquence avec laquelle on voit un gros plan sur un personnage seul (en train de parler ou d’agir) plutôt que plusieurs personnages interagissant dans un plan plus long.

Nous disposons aussi d’un jeu de données indiquant les personnages précis détectés dans les plans des deux séries. Il s’appuie sur un algorithme de détection de visages similaire à celui utilisé dans notre extrait. On a ici une ligne par personnage détecté dans un plan. Il y a quatre personnages principaux dans chacune des séries.

View code
characters = pl.read_csv("data/ttl_sitcom_characters.csv")
characters
shape: (59_803, 5)
series video sid time character
str str i64 f64 str
"Bewitched" "bw_s01_e01" 1 7.38 "Darrin"
"Bewitched" "bw_s01_e01" 11 3.33 "Darrin"
"Bewitched" "bw_s01_e01" 49 2.58 "Darrin"
"Bewitched" "bw_s01_e01" 52 1.04 "Darrin"
"Bewitched" "bw_s01_e01" 53 4.33 "Darrin"
"I Dream of Jeannie" "idoj_s05_e26" 143 17.22 "Roger"
"I Dream of Jeannie" "idoj_s05_e26" 146 7.13 "Roger"
"I Dream of Jeannie" "idoj_s05_e26" 151 4.84 "Roger"
"I Dream of Jeannie" "idoj_s05_e26" 161 5.63 "Roger"
"I Dream of Jeannie" "idoj_s05_e26" 163 43.58 "Roger"

On peut utiliser le temps d’écran moyen d’un personnage dans un épisode comme une mesure approximative de son importance visuelle dans la série. On calcule cette mesure pour chacun des personnages, puis on trie par ordre décroissant à l’intérieur de chaque série.

View code
(
    characters
    .group_by([c.series, c.video, c.character])
    .agg(time=c.time.sum())
    .group_by([c.series, c.character])
    .agg(time=c.time.mean())
    .with_columns(time=c.time / 60)
    .sort([c.series, c.time], descending=[True, True])
    .pipe(ggplot, aes("reorder(character, time)", "time", fill="series"))
    + geom_col(position="dodge")
    + coord_flip()
    + labs(x="Character", y="Mean screen time (min)", fill="Series")
)

On voit déjà apparaître l’une des principales conclusions de l’analyse de ces séries : bien qu’elles soient perçues comme très similaires, leurs structures narratives sont en réalité assez différentes. Jeannie est en fait centrée sur le personnage masculin Tony ; le personnage éponyme, Jeannie, n’est souvent guère plus qu’un ressort narratif qui met l’action en mouvement. À l’inverse, Bewitched se concentre surtout sur la relation entre Samantha et Darrin, chacun bénéficiant d’un temps d’écran à peu près équivalent.

Nos annotations — détection de plans, détection et reconnaissance de visages — peuvent maintenant être analysées sous de nombreux angles différents pour explorer divers aspects des séries, ce que nous approfondissons dans le chapitre 5. L’important ici est de voir que trois annotations suffisent à ouvrir de nombreuses possibilités analytiques, et permettent d’aborder des questions de sciences humaines posées à des données audiovisuelles.

3.9 Conclusion et prochaines étapes

Ce notebook a proposé une introduction à l’annotation des données d’images animées à partir d’un fichier vidéo numérique, en s’appuyant sur deux bibliothèques complémentaires : le distant viewing toolkit (dvt) pour les utilitaires vidéo et la détection des ruptures de plan, et InsightFace pour la détection et la reconnaissance des visages. Nous avons vu comment obtenir des métadonnées de base sur un fichier vidéo depuis Python, comment parcourir les image une par une, comment détecter les ruptures de plan, et comment localiser et identifier des visages à l’aide de plongements. Enfin, nous avons chargé un jeu de données plus large rassemblant l’ensemble des plans et des personnages détectés sur l’intégralité de deux sitcoms américains de l’ère des grands réseaux, et nous avons effectué plusieurs exemples d’analyses sur ces données.

Pour les lecteurs intéressés par les détails de l’étude de cas consacrée à ces deux séries, nous suggérons de lire le cinquième chapitre du livre Distant Viewing, disponible sous licence libre. Les deux premiers chapitres du livre peuvent également présenter de l’intérêt, car ils proposent une approche théorique et méthodologique plus générale de l’analyse computationnelle des images numériques.