5.4. 🚀 Visualisierung der Ergebnisse der Figurenerkennung#

Dieses Jupyter Notebook visualisiert die Ergebnisse der automatischen Figurenerkennung mit InsightFace. Es baut auf dem Notebook Automatische Erkennung von Figuren mit InsightFace auf, in dem Figuren anhand von Referenzabbildungen in ausgewählten Frames eines Films erkannt und ihre Auftritte als zeitliche Annotationen gespeichert werden. Die Figurenerkennung selbst wird hier nicht erneut durchgeführt. Stattdessen werden die bereits erstellten .csv-Dateien eingelesen und für die Visualisierung aufbereitet.

Es entstehen zwei unterschiedliche Darstellungen:

  • Ein interaktives Figurennetzwerk, das die erkannten Figuren eines Films und ihr gemeinsames Auftreten in untersuchten Frames zeigt. Die Größe der Knoten veranschaulicht die geschätzte Screentime der Figuren, die Verbindungen verdeutlichen, welche Figuren gleichzeitig erkannt wurden.

  • Eine Screentime-Zeitleiste, die zeigt, in welchen Abschnitten des Films die einzelnen Figuren erkannt und zu längeren Annotationen zusammengefasst wurden.

Beide Visualisierungen ermöglichen die Auswahl zwischen mehreren Filmen und können anschließend als interaktive .html-Dateien exportiert werden. Sie bieten unterschiedliche Zugänge zu denselben Analyseergebnissen: Das Netzwerk macht Beziehungen im Sinne gemeinsamer Sichtbarkeit erkennbar, die Zeitleiste zeigt deren zeitliche Verteilung. Aus der gemeinsamen Erkennung zweier Figuren lässt sich allerdings nicht unmittelbar auf eine erzählerische Beziehung oder eine tatsächliche Interaktion schließen.

Der Arbeitsablauf gliedert sich in Festlegen der Parameter, technische Vorbereitung, Einlesen der Figurendaten und Erstellung der Visualisierungen sowie Export der Ergebnisse. Die Texte erläutern vor allem das Ziel und die Aussagekraft der einzelnen Arbeitsschritte. Ergänzende Kommentare im Python-Code beschreiben ihre technische Umsetzung.

Weitere Informationen zum Erkennungsverfahren und den erzeugten Daten finden Sie im Einführungskapitel zur Figurenerkennung.

5.4.1. Ausführung des Notebooks#

Innerhalb des Jupyter Books sind die Ergebnisse einer vorherigen Ausführung gespeichert und können betrachtet werden, ohne den Code selbst auszuführen. Um eigene Analyseergebnisse zu visualisieren oder die Einstellungen zu verändern, kann das Notebook in Google Colab oder lokal ausgeführt werden.

Cloud-Umgebung: Über das Raketen-Symbol 🚀 oben rechts im Jupyter Book lässt sich das Notebook in Google Colab öffnen. Für die Visualisierung ist keine GPU erforderlich.

Lokale Umgebung: Über das Download-Symbol ↓ lässt sich die .ipynb-Datei herunterladen und in einer geeigneten Jupyter-Umgebung öffnen.

Wir empfehlen, die Codeblöcke der Reihe nach einzeln auszuführen. So lassen sich die Zwischenschritte nachvollziehen und die für spätere Zellen benötigten Daten werden in der richtigen Reihenfolge vorbereitet.

5.4.2. Festlegen der Parameter#

Zunächst werden die grundlegenden Einstellungen für das Einlesen der Figurendaten und die Darstellung der Ergebnisse festgelegt. Die entsprechenden Variablen sind im Code großgeschrieben und können angepasst werden, ohne die nachfolgenden Codeblöcke verändern zu müssen. Für einen ersten Probelauf empfiehlt es sich, die voreingestellten Werte beizubehalten.

Mit CSV_PATH wird der Ordner festgelegt, in dem sich die Ergebnisse der Figurenerkennung befinden. Benötigt werden für jeden Film die drei zusammengehörigen .csv-Dateien mit einzelnen Erkennungstreffern, zusammengefassten Figurenannotationen und Metadaten. Diese Dateien entstehen beim Export des Notebooks zur Figurenerkennung. Sie können auch in Unterordnern von CSV_PATH liegen.

Die Parameter FRAMES_PATTERN, ANNOTATIONS_PATTERN und METADATA_PATTERN bezeichnen die Bestandteile der Dateinamen, anhand derer das Notebook die drei zusammengehörigen Tabellen erkennt. Sie entsprechen den voreingestellten Dateinamen des Analyse-Notebooks und müssen normalerweise nicht verändert werden. Falls Sie die Dateinamen allerdings umbenannt haben, können Sie die Suchmuster hier anpassen. EXPORT_PATH legt den Ordner fest, in dem die fertigen Visualisierungen gespeichert werden. Falls die Eingabe- oder Exportordner noch nicht existieren, werden sie beim Ausführen des Codeblocks erstellt.

Wie in den Analyse-Notebooks kann mit DOWNLOAD_TESTFILES der Download vorbereiteter Testdateien aktiviert werden. Voreingestellt ist True. Um eigene Daten zu verwenden, sollte dieser Parameter auf False gestellt werden. Über CUSTOM_ZIP_LINK und CUSTOM_ARCHIVE_NAME lässt sich außerdem ein eigenes .zip-Archiv über einen direkten Download-Link einbinden.

Für die Darstellung des Figurennetzwerks stehen drei Einstellungen zur Verfügung:

  • GRAPH_HEIGHT bestimmt die Höhe der Netzwerkdarstellung.

  • GRAPH_ZOOM_FACTOR legt fest, wie stark das Netzwerk zu Beginn vergrößert oder verkleinert angezeigt wird.

  • GRAPH_LINK_DISTANCE beeinflusst den Abstand zwischen miteinander verbundenen Figuren.

Weitere Darstellungseinstellungen folgen später im Notebook. Diese drei Parameter sind jedoch besonders wichtig für die grundlegende Anordnung.

Für die Screentime-Zeitleiste bestimmen

  • TIMELINE_WIDTH die Breite,

  • TIMELINE_HEIGHT die Höhe,

  • und MIN_BAR_WIDTH_PX die Mindestbreite für sehr kurze Screentime-Abschnitte.

in Pixeln. Die Visualisierung skaliert immer so, dass in diesen Grenzen der gesamgte Film angezeigt wird.

Über CHARACTER_PALETTE können Sie außerdem die Farben der Figuren anpassen. Sie werden innerhalb eines Films sowohl für das Netzwerk als auch für die Zeitleiste verwendet.

# Creating the input and export directories.
import os

# Read matching frames, annotations, and headerless key-value metadata per film.
# Keep the filenames and shared export version created by the analysis notebook.
# Relative paths start in the directory where the notebook is executed.
CSV_PATH = "vis_files_characters"
ANNOTATIONS_PATTERN = "annotations"
METADATA_PATTERN = "metadata"
FRAMES_PATTERN = "frames"
EXPORT_PATH = "export_vis_characters"

# If True, download and extract the provided test files.
DOWNLOAD_TESTFILES = True
# Add your own download link inside quotes to load an additional ZIP archive.
CUSTOM_ZIP_LINK = "insert link here"
# An archive with the same name is reused instead of downloaded again.
CUSTOM_ARCHIVE_NAME = "custom_archive"

# Network visualisation parameters.
# Visualisation height in pixels.
GRAPH_HEIGHT = 500
# Initial zoom level.
GRAPH_ZOOM_FACTOR = 1.0
# Preferred distance between connected nodes in pixels. Larger values spread them out.
GRAPH_LINK_DISTANCE = 150

# Fixed timeline dimensions in pixels. The complete film fits this space.
TIMELINE_WIDTH = 500
TIMELINE_HEIGHT = 500
# Minimum width of screentime bars.
MIN_BAR_WIDTH_PX = 1.5

# Reuse each character's colour in both views of the same film.
CHARACTER_PALETTE = [
    "#4E79A7", "#F28E2B", "#E15759", "#76B7B2", "#59A14F",
    "#EDC948", "#B07AA1", "#FF9DA7", "#9C755F", "#BAB0AC",
]

# Create both directories. exist_ok=True allows them to exist already.
os.makedirs(CSV_PATH, exist_ok=True)
os.makedirs(EXPORT_PATH, exist_ok=True)

5.4.3. Technische Vorbereitung#

Bevor die Ergebnisse der Figurenerkennung visualisiert werden können, müssen die benötigten Programmbibliotheken installiert und importiert werden. Anschließend können vorbereitete Testdateien oder eigene Analyseergebnisse eingelesen werden.

Installation der Programmbibliotheken#

Der folgende Codeblock installiert die für das Figurennetzwerk benötigten Programmbibliotheken. pandas dient dem Einlesen und der Verarbeitung der tabellarischen Analyseergebnisse. Mit NetworkX wird aus diesen Daten ein Netzwerk erstellt, das anschließend mit gravis interaktiv visualisiert wird.

Außerdem wird eine passende Version von setuptools installiert, da gravis eine darin enthaltene Funktion benötigt.

Weitere Bibliotheken für die später folgende Zeitleiste werden erst im entsprechenden Abschnitt installiert. [Winter]: Sollte ich das ändern?

In einer unveränderten lokalen Python-Umgebung muss diese Installation nicht bei jeder Ausführung wiederholt werden. In Google Colab ist sie nach dem Neustart der Laufzeit in der Regel erneut notwendig.

print("Installing dependencies.")

# Install the network and table-processing dependencies once.
# Gravis uses pkg_resources, which requires a setuptools version below 82.
!pip install -q "setuptools<82" 
!pip install -q gravis==0.1.0 
!pip install -q networkx 
!pip install -q pandas

print("Dependencies installed.")
Installing dependencies.
Dependencies installed.

Import der Programmbibliotheken#

Durch den Import werden die benötigten Programmbibliotheken in der aktuellen Notebook-Sitzung verfügbar. Neben der Verarbeitung der Tabellen und der Erstellung des Netzwerks werden dabei Funktionen für Dateipfade, den Download und das Entpacken von Archiven sowie die Formatierung von Zeitangaben vorbereitet. Die einzelnen Aufgaben der Bibliotheken sind im Code kommentiert.

Zusätzlich wird geprüft, ob das Notebook in Google Colab ausgeführt wird. Diese Information wird später beim Herunterladen der exportierten Visualisierungen verwendet.

# Handling file paths and packaging the exported results.
from pathlib import Path
import shutil

# Downloading files and extracting ZIP archives.
from urllib.request import urlretrieve
import zipfile

# Identifying Google Colab and formatting durations.
import sys
from datetime import timedelta

# Counting shared frames and generating pairs of characters.
from collections import Counter
from itertools import combinations

# Reading tables, building graphs, and displaying the network.
import pandas as pd
import networkx as nx
import gravis as gv

# Import the browser download function only within a Google Colab runtime.
colab = "google.colab" in sys.modules
if colab:
    from google.colab import files
else:
    print("Using non-Colab environment.")

# Display durations as hours, minutes, and seconds in both visualisations.
def format_duration(seconds):
    return str(timedelta(seconds=round(seconds)))

print("Dependencies imported.")
Using non-Colab environment.
Dependencies imported.

Download des Analysematerials#

Dieser Schritt ermöglicht es, die für die Visualisierung benötigten .csv-Dateien als .zip-Archiv herunterzuladen und in den zuvor festgelegten Eingabeordner zu entpacken. Anders als bei den Notebooks zur automatischen Analyse werden keine Videodateien oder Referenzabbildungen benötigt, da hier nur bereits exportierte Erkennungsergebnisse ausgewertet werden.

Wenn bei der Festlegung der Parameter DOWNLOAD_TESTFILES = True eingestellt wurde, lädt das Notebook das vorbereitete Testarchiv herunter. Ist das Archiv aus einer vorherigen Ausführung bereits vorhanden, wird es nicht erneut heruntergeladen. Anschließend werden die enthaltenen Dateien in CSV_PATH entpackt.

Bei den Testdateien handelt es sich in diesem Fall um die gesamten Ergebnisse der Figurenerkennung zu den Spielfilmen Sonnensucher, Der geteilte Himmel und Solo Sunny, bei denen Konrad Wolf Regie geführt hat.

Die Verarbeitung wurde auf einer CUDA-fähigen GPU durchgeführt. Alle weiteren Informationen zur Analyse können Sie den enthaltenen Metadaten-Dateien entnehmen.

Für eigene Analyseergebnisse kann der Download deaktiviert bleiben. Die drei zusammengehörigen .csv-Dateien pro Film müssen dann manuell im Eingabeordner oder einem seiner Unterordner abgelegt werden. Alternativ lässt sich mit CUSTOM_ZIP_LINK ein eigenes Archiv über einen Direktlink zu einer .zip-Datei herunterladen. Der Name des heruntergeladenen Archivs wird mit CUSTOM_ARCHIVE_NAME festgelegt.

# Define a reusable function for downloading and extracting ZIP archives.
def download_zip(url: str, archive_name: str, target_directory: str):
    # Store the archive in the notebook's working directory.
    archive_path = Path(f"{archive_name}.zip")

    # Download only if an archive with the same name is not already present.
    if not archive_path.is_file():
        print(f"Downloading {archive_path}...")
        urlretrieve(url, archive_path)
    else:
        print(f"{archive_name} already downloaded.")

    # Extract the files into the selected input directory.
    with zipfile.ZipFile(archive_path) as zip_ref:
        zip_ref.extractall(target_directory)
    print(f"Extracted {archive_path} to {target_directory}.")


# Download and extract the test files if the setting is enabled.
if DOWNLOAD_TESTFILES:
    download_zip(
        "https://owncloud.gwdg.de/index.php/s/ukj9jaHRzUtV9Hg/download",
        "character_visualisation_testfiles", CSV_PATH,
    )
else:
    print("No testfile download requested.")

# Download and extract the custom archive if a link is provided.
if CUSTOM_ZIP_LINK != "insert link here":
    download_zip(CUSTOM_ZIP_LINK, CUSTOM_ARCHIVE_NAME, CSV_PATH)
Downloading character_visualisation_testfiles.zip...
Extracted character_visualisation_testfiles.zip to vis_files_characters.

5.4.4. Einlesen der Figurendaten und Erstellung der Visualisierungen#

Im folgenden Abschnitt werden die exportierten Daten der Figurenerkennung eingelesen und zunächst für das Figurennetzwerk aufbereitet. Anschließend entsteht eine interaktive Darstellung, in der zwischen den eingelesenen Filmen gewechselt werden kann. Die bereits geladenen Daten werden später auch für die Screentime-Zeitleiste verwendet.

Import und Transformation der Figurendaten für das Netzwerk#

Das Notebook sucht zuerst im festgelegten Eingabeordner (CSV_PATH) nach den Metadatendateien der Figurenerkennung. Zu jeder gefundenen Datei werden anhand des gemeinsamen Dateinamens die passenden Tabellen frames.csvund annotations.csv eingelesen. Dabei werden auch Unterordner und versionierte Exporte wie _v2 berücksichtigt. Fehlen die zugehörigen Tabellen, wird der betreffende Datensatz bzw. Film komplett übersprungen.

Die drei Dateien enthalten unterschiedliche Informationen: Die frames.csv verzeichnet, welche Figur in welchem untersuchten Frame erkannt wurde. annotations.csv fasst aufeinanderfolgende Frame-Erkennungen zu längeren zeitlichen Annotationen zusammen. metadata.csv dokumentiert unter anderem die Bildrate des Videos und die Einstellungen, die bei der Figurenerkennung verwendet wurden. Eine Übersicht dieser Ausgabeformate bietet der OER-Abschnitt zur Ausgabe der Figurenerkennung.

Die Angaben aus der Metadatendatei sind wichtig, weil die Figurenerkennung in der Regel nicht jeden Frame eines Videos untersucht. Aus der Bildrate (frame_rate) und der Anzahl übersprungener Frames (skip_frames) lässt sich bestimmen, welchen zeitlichen Abstand zwei untersuchte Frames haben. Dieser Abstand wird später benötigt, um aus der Anzahl der Erkennungstreffer eine ungefähre Screentime abzuleiten.

Die Daten jedes Films werden im Dictionary film_datasets gesammelt und stehen dadurch beiden Visualisierungen zur Verfügung. Bei mehreren Exportversionen werden diese als getrennte Datensätze eingelesen und in der späteren Filmauswahl entsprechend gekennzeichnet. Abschließend werden die Filme alphabetisch geordnet, um auch bei Veränderungen der Ordnerstruktur eine feste Reihenfolge an Titeln zu gewährleisten.

Weitere Informationen und Beispiele zur Funktionsweise eines Dictionaries finden Sie etwa im Dictionary-Kapitel des hier verlinkten Python-Kurses.

# Find CSVs, including subfolders and versioned exports such as _v2.

# Initialise the list of metadata files.
metadata_files = []
# Find metadata files in CSV_PATH matching CSV_PATTERN.
for csv_path in Path(CSV_PATH).rglob(f"*{METADATA_PATTERN}*.csv"):
    # Check whether the path refers to a file.
    if csv_path.is_file():
        metadata_files.append(csv_path)

# Raise an error if no metadata files are found.         
if not metadata_files:
    raise FileNotFoundError(f"No files matching {METADATA_PATTERN}.csv in {CSV_PATH}.")

# Read the three matching tables for each film once and reuse them in both views.
film_datasets = {}
for file in metadata_files:

    file_stem = file.stem
    # Match the filename prefix and version of this annotation file.
    prefix, version = file_stem.rsplit(f"{METADATA_PATTERN}")
    frames_file = file.with_name(f"{prefix}{FRAMES_PATTERN}{version}.csv")
    annotations_file = file.with_name(f"{prefix}{ANNOTATIONS_PATTERN}{version}.csv")

    # Metadata contains one key-value pair per row, without a heading.
    metadata = pd.read_csv(file, header=None, index_col=0).iloc[:, 0]
    try:
        frames = pd.read_csv(frames_file)
        annotations = pd.read_csv(annotations_file)
    except FileNotFoundError as error:
        print(f"No matching frames and annotation files found for {file_stem}.")
        continue

    # Use the recorded title. Distinguish export versions in the film dropdown.
    title = metadata["title"]
    if version:
        title = f"{title} ({version.lstrip('_')})"

    # Calculate the sample interval that each frame represents based on the analysis parameters of the metadata file.
    frame_rate = float(metadata["frame_rate"])
    skip_frames = int(metadata["skip_frames"])
    sample_interval_s = (skip_frames + 1) / frame_rate
    # An individual video frame is shorter than the interval between analysed frames.
    frame_duration_s = 1 / frame_rate
    # Use the unrounded full runtime, including time after the last detection.
    runtime_s = int(metadata["frame_count"]) / frame_rate

    # Save the collected data in a dictionary.
    film_datasets[file_stem] = {
        "title": title, 
        "metadata": metadata, 
        "frames": frames,
        "annotations": annotations, 
        "sample_interval_s": sample_interval_s,
        "frame_duration_s": frame_duration_s, 
        "runtime_s": runtime_s,
    }

    print(f"Loaded data for {title}.")

# Sort the films alphabetically by their titles.
film_datasets = dict(sorted(
film_datasets.items(),
key=lambda item: item[1]["title"].casefold()
))
Loaded data for Sonnensucher.
Loaded data for Der geteilte Himmel.
Loaded data for Solo Sunny.

Erstellung des Figurennetzwerks#

Aus den zuvor eingelesenen Erkennungstreffern wird nun für jeden Film ein Figurennetzwerk erstellt. Ein Netzwerk besteht aus nodes und edges, auf Deutsch Knoten und Verbindungen. Hier steht jede node für eine erkannte Figur. Zwei Figuren werden mittels edges verbunden, wenn sie im selben untersuchten Frame erkannt wurden. Mit der Bibliothek NetworkX werden diese Informationen zunächst als (nicht-visuelle) Netzwerkdaten erfasst.

Die Größe einer node beruht auf der Anzahl der Frames, in denen die jeweilige Figur erkannt wurde. Da bei der Analyse gegebenenfalls Frames übersprungen wurden, wird diese Anzahl mit dem zeitlichen Abstand zwischen zwei untersuchten Frames verrechnet. So entsteht eine geschätzte Screentime, die später über die Größe des Knotens und beim Überfahren mit dem Mauszeiger (hover) angezeigt werden kann.

Die Stärke oder Dicke einer Verbindung beruht darauf, wie oft zwei Figuren gemeinsam in einem untersuchten Frame erkannt wurden. Auch dafür wird aus den Erkennungstreffern eine ungefähre gemeinsame Screentime berechnet. In der späteren Darstellung erscheinen häufigere gemeinsame Erkennungen als stärkere Verbindungen.

Wichtig: Das Netzwerk zeigt gemeinsame Erkennungen, nicht unmittelbar Figurenbeziehungen innerhalb der Handlung. Eine Verbindung belegt weder, dass die Figuren miteinander sprechen, noch, dass sie in derselben Einstellung über deren gesamte Dauer hinweg auftreten. Außerdem können übersehene Gesichter und die Auswahl einzelner Frames das Ergebnis beeinflussen. Die dargestellten Zeiten sind deshalb Näherungswerte und keine exakten Messungen der Screentime. Weitere Informationen zu den Grenzen der Figurenerkennung finden Sie im Abschnitt zur Datensichtung der Figurenerkennung aus dem vorherigen Kapitel.

# Build one network per film.
for dataset in film_datasets.values():
    frames = dataset["frames"]
    # Convert sampled detections using this film's recorded analysis settings.
    sample_interval_s = dataset["sample_interval_s"]
    # Create networx graph. 
    graph = nx.Graph()

    # Count sampled frames for each character.
    character_counts = frames.groupby("char_name").size()
    for name, count in character_counts.items():
        # Convert the sampled frames to screentime.
        screen_time_s = count * sample_interval_s
        # Remove underscores (_) in character names like "Herr_Herrfurth".
        label = name.replace("_", " ")

        # Add a node for the character to the graph.
        graph.add_node(
            name, label=label, size=screen_time_s,
            # Add the tooltip.
            hover=f"<b>{label}</b><br>Erkannte Screentime: {format_duration(screen_time_s)}<br>Erkannte Frames: {count}",
        )
        
    # Create an empty counter for character pairs.
    pair_counts = Counter()

    # Group detections by timestamp.
    # time_s is the timestamp; names contains the characters detected at that time.
    for time_s, names in frames.groupby("time_s")["char_name"]:

        # Sort the names alphabetically so each pair always has the same order.
        # Create every possible pair of two different characters.
        for pair in combinations(sorted(names), 2):
            # Add one shared frame to this pair's count.
            pair_counts[pair] += 1

    # Add connections between the character nodes. 
    # Iterate through the character pairs. 
    for (source_character, target_character), count in pair_counts.items():
        # Calculate the shared screentime
        shared_time = count * sample_interval_s
        # Create the label from the character names. Remove once again underscores (_) from the names. 
        label = source_character.replace("_", " ") + " ↔ " + target_character.replace("_", " ")

        # Add the connections to the graph. 
        graph.add_edge(
            source_character, target_character, size=shared_time,
            hover=f"<b>{label}</b><br>Gemeinsame Screentime: {format_duration(shared_time)}<br>Gemeinsame Frames: {count}",
        )
    # Save the graph in the film_datasets dictionary.  
    dataset["graph"] = graph

Farbcodierung und Konvertierung des Formats#

Damit sich die Figuren in beiden Visualisierungen leichter wiedererkennen lassen, erhält jede Figur innerhalb eines Films eine Farbe aus der zuvor festgelegten CHARACTER_PALETTE. Dieselbe Farbe wird später auch in der Screentime-Zeitleiste verwendet. Die Farben dienen der Orientierung innerhalb eines Films. Eine bestimmte Farbe bezeichnet nicht zwangsläufig dieselbe Figur in unterschiedlichen Filmen. Wenn Sie möchten, können Sie beim Festlegen der Parameter eigene Farben wählen.

Anschließend werden die mit NetworkX erstellten Netzwerke in das gjgf-Format übertragen, das die Visualisierungsbibliothek gravis verarbeiten kann. Für jeden Film entsteht ein eigener Eintrag im späteren Auswahlmenü zur Filmauswahl. Als Bezeichnung wird der in den Metadaten gespeicherte Filmtitel verwendet. Falls mehrere Exportversionen eines Films vorliegen, werden sie anhand ihres Versionszusatzes unterschieden.

# Assign each character the same colour in the network and timeline.
# Initialise the graphs dictionary
graphs = {"graphs": []}
#Iterate through the film datasets.
for dataset in film_datasets.values():
    graph = dataset["graph"]
    # Collect unique character names from the annotations.
    character_names = set(dataset["annotations"]["character"])
    # Add any characters that appear only in the network.
    character_names.update(graph.nodes)

    # Initialise the colors dictionary.
    character_colors = {}
    # Iterate through the character names.
    for index, name in enumerate(sorted(character_names)):
        # Assign each character a color. 
        character_colors[name] = CHARACTER_PALETTE[index % len(CHARACTER_PALETTE)]
    # Save the character colors for each film in the film_datasets dictionary.
    dataset["character_colors"] = character_colors

    # Assign each character's colour to its network node.
    nx.set_node_attributes(graph, character_colors, "color")

    # Convert the network to Gravis format. Each graph becomes one dropdown entry.
    film_graph = gv.convert.any_to_gjgf(graph)["graph"]
    film_graph["label"] = dataset["title"]
    graphs["graphs"].append(film_graph)

Interaktive Visualisierung des Figurennetzwerks#

Mit der Python-Bibliothek gravis werden die vorbereiteten Figurennetzwerke nun interaktiv dargestellt. Im Auswahlmenü Data-selection → Graph kann zwischen den eingelesenen Filmen gewechselt werden. Jede node steht für eine Figur und ist mit ihrem Namen beschriftet. Größere nodes weisen auf eine höhere geschätzte Screentime hin. Verbindungen zwischen den Figuren zeigen, welche Figuren im selben untersuchten Frame erkannt wurden. Stärkere Verbindungen stehen für eine höhere Zahl gemeinsamer Erkennungen.

Beim Bewegen des Mauszeigers über einen node oder einen edge erscheinen zusätzliche Angaben: die Anzahl der erkannten Frames und die daraus berechnete Screentime. Die Figuren können außerdem mit der Maus verschoben werden. Ihre Position bleibt nach dem Verschieben bestehen. Beim Überfahren einer Figur werden deren Verbindungen und benachbarte Figuren hervorgehoben. Größe, Abstände und Anordnung des Netzwerks lassen sich über die Einstellungen im Code und das Seitenmenü verändern – probieren Sie hier ruhig andere Einstellungen aus. Weitere Informationen zu den Darstellungsmöglichkeiten finden Sie in der Gravis-Dokumentation zur Funktion d3().

Beim Verschieben der Figuren und Ändern der Einstellungen werden Sie merken, dass sich die räumliche Anordnung der nodes stark ändern kann. Wie nah zwei Knoten beieinanderliegen, ist daher kein eigenständiges Maß für eine inhaltliche Beziehung zwischen Figuren. Auch die node-Größe und Stärke der Verbindungen werden durch die Einstellungen beeinflusst und sollten deshalb nicht als direkt vergleichbare absolute Größen zwischen verschiedenen Filmen verstanden werden.

Der folgende Codeblock gibt das Netzwerk direkt im Notebook aus. Der Export als interaktive HTML-Datei erfolgt später im Abschnitt Export der Ergebnisse.

# Create an interactive network visualisation using Gravis.
fig = gv.d3(
    # Provide the film networks and their node and edge attributes.
    graphs,

    # Set the height of the network display in pixels.
    graph_height=GRAPH_HEIGHT,

    # Set the initial zoom level.
    zoom_factor=GRAPH_ZOOM_FACTOR,

    # Show the settings menu, including the film selection dropdown.
    show_menu=True,

    # Hide the button for opening and closing the menu.
    show_menu_toggle_button=False,

    # Rescale node sizes within each film to the range defined below.
    use_node_size_normalization=True,

    # Set the smallest and largest displayed node sizes.
    node_size_normalization_min=20,
    node_size_normalization_max=100,

    # Use each node's 'label' attribute as its displayed character name.
    node_label_data_source="label",

    # Rescale edge thicknesses using Gravis's default minimum and maximum.
    use_edge_size_normalization=True,

    # Fix a node's position after dragging it. It can still be dragged again.
    node_drag_fix=True,

    # Highlight a character's connections and neighbours when hovering over it.
    node_hover_neighborhood=True,

    # Apply a collision force to keep nodes apart.
    use_collision_force=True,

    # Treat each node as a circle with this radius when calculating collisions.
    # This radius is independent of the displayed node size.
    collision_force_radius=30,

    # Make nodes repel one another.
    # More negative values produce stronger repulsion.
    many_body_force_strength=-500,

    # Set the preferred distance between connected nodes.
    # Other forces also influence their final positions.
    links_force_distance=GRAPH_LINK_DISTANCE,
)

# Display the interactive visualisation in the notebook.
fig
Details for selected element
General
App state
Display mode
Export
Data selection
Graph
Node label text
Edge label text
Node size
Minimum
Maximum
Edge size
Minimum
Maximum
Nodes
Visibility
Size
Scaling factor
Position
Drag behavior
Hover behavior
Node images
Visibility
Size
Scaling factor
Node labels
Visibility
Size
Scaling factor
Rotation
Angle
Edges
Visibility
Size
Scaling factor
Form
Curvature
Hover behavior
Edge labels
Visibility
Size
Scaling factor
Rotation
Angle
Layout algorithm
Simulation
Many-body force
Strength
Theta
Min
Max
Links force
Collision force
Radius
Strength
x-positioning force
Strength
y-positioning force
Strength
Centering force

Technische Vorbereitung der Screentime-Zeitleiste#

Nach dem Figurennetzwerk wird aus denselben Analyseergebnissen eine zweite Visualisierung erstellt. Die Screentime-Zeitleiste zeigt, wann im Verlauf eines Films welche Figuren erkannt wurden. Dafür werden die bereits eingelesenen Figurenannotationen verwendet.

Für die interaktive Darstellung wird die Python-Bibliothek Altair eingesetzt, die bereits zur Visualisierung der Ergebnisse der Einstellungserkennung verwendet wurde. Für die Erstellung der Diagramme wird die Python-Bibliothek Altair verwendet. Anstatt die einzelnen Balken und Achsen selbst durch Code zu zeichnen, beschreibt man mit Altair, welche Daten dargestellt werden sollen, welche grafische Form sie erhalten und wie diese Form auf Eingaben reagieren soll.

Eine Einführung bietet die offizielle Altair-Dokumentation zur Erstellung von Diagrammen.

Installation der Programmbibliotheken#

Der folgende Codeblock installiert Altair. Die zuvor installierten Bibliotheken für die Verarbeitung der Figurendaten können erneut verwendet werden.

print("Installing additional dependencies.")

# Install only Altair. The shared dependencies were installed for the network.
!pip install -q altair==6.0.0

print("Additional dependencies installed.")
Installing additional dependencies.
Additional dependencies installed.

Import der Programmbibliotheken#

Anschließend wird Altair importiert. Da die Annotationen mehrerer Filme sehr umfangreich sein können, wird außerdem die voreingestellte Begrenzung der Datenmenge aufgehoben, die Altair auf einmal verarbeiten kann.

# Import only the additional modules needed for the timeline.
import altair as alt

# Allow multiple films whose combined interval tables exceed 5,000 rows.
alt.data_transformers.disable_max_rows()

print("Additional dependencies imported.")
Additional dependencies imported.

Import und Transformation der Figurendaten für die Zeitleiste#

Für die Zeitleiste werden die bereits eingelesenen Figurenannotationen verwendet. Anders als die einzelnen Erkennungstreffer enthalten sie zusammenhängende Zeitabschnitte mit einem Start- (start) und Endpunkt (end). Das Notebook bereitet die Annotationen für jeden Film auf und übernimmt für die Figuren die selben Farben, die ihnen zuvor im Netzwerk zugeordnet wurden.

Die Figuren werden nach der Summe ihrer Screentime angeordnet. Figuren mit längerer erfasster Screentime stehen also weiter oben. Sehr kurze Abschnitte erhalten für die Darstellung eine Mindestbreite, damit sie auch bei längeren Filmen erkennbar bleiben. Die ursprünglichen Start- und Endzeiten werden dabei nicht verändert und können im Tooltip abgelesen werden.

# Prepare the intervals and background colour bands separately for each film.
timeline_tables = []
timeline_row_tables = []
movie_titles = []

for dataset in film_datasets.values():
    # Reuse the annotation table and film title loaded earlier.
    df = dataset["annotations"]
    title = dataset["title"]
    movie_titles.append(title)
    df["film"] = title

    # Include the final VIDEO frame, matching the analysis notebook's EAF export.
    # This is 1 / frame_rate, not the interval between analysed frames.
    df["duration_s"] = df["end_s"] - df["start_s"] + dataset["frame_duration_s"]
    df["duration"] = df["duration_s"].map(format_duration)
    # Extend the background bands to the full film duration from the metadata.
    timeline_end_s = dataset["runtime_s"]

    # Display character names with spaces and reuse their network colours.
    df["character_label"] = df["character"].str.replace("_", " ", regex=False)
    df["character_color"] = df["character"].map(dataset["character_colors"])
    # Sort characters by their total annotated screentime, longest first.
    df["total_s"] = df.groupby("character")["duration_s"].transform("sum")

    # Keep very short appearances visible at the fixed chart width.
    minimum_width_s = MIN_BAR_WIDTH_PX * timeline_end_s / TIMELINE_WIDTH
    df["end_display_s"] = (
        df["start_s"] + df["duration_s"].clip(lower=minimum_width_s)
    ).clip(upper=timeline_end_s)

    # Create one full-width background band per character.
    rows = df[["film", "character_label", "character_color", "total_s"]].drop_duplicates()
    rows = rows.assign(lane_start_s=0, lane_end_s=timeline_end_s)
    timeline_tables.append(df)
    timeline_row_tables.append(rows)
    print(f"Prepared timeline data for {title}.")

# Combine the tables so one dropdown can select the visible film.
timeline_data = pd.concat(timeline_tables, ignore_index=True)
timeline_rows = pd.concat(timeline_row_tables, ignore_index=True)
Prepared timeline data for Der geteilte Himmel.
Prepared timeline data for Solo Sunny.
Prepared timeline data for Sonnensucher.

Visualisierung der Screentime#

Mit Altair werden die vorbereiteten Annotationen nun als interaktive Zeitleiste dargestellt. Über das Auswahlmenü lässt sich zwischen den eingelesenen Filmen wechseln. Jede Zeile gehört zu einer Figur, die farbigen Balken markieren die Filmabschnitte, in denen sie erkannt wurde. Die Figuren stehen in absteigender Reihenfolge ihrer insgesamt erfassten Screentime.

Die horizontale Achse zeigt die Zeit innerhalb des ausgewählten Films in Stunden, Minuten und Sekunden. Beim Bewegen des Mauszeigers über einen Balken erscheinen der Name der Figur sowie Beginn, Ende und Dauer des jeweiligen Abschnitts.

Wichtig: Die Screentime basiert auf der automatischen Figurenerkennung und ist deshalb, wie im Zuge der Erstellung der Figurennetzwerke erläutert nur eine Annäherung. Sehr kurze Balken werden außerdem, wie zuvor angemerkt, für eine bessere Sichtbarkeit etwas breiter dargestellt.

Die Zeitleiste wird direkt im Notebook ausgegeben und kann im anschließenden Abschnitt als interaktive HTML-Datei gespeichert werden.

# Use SVG for responsive sizing and hide the chart action menu.
embed_options = {"actions": False, "renderer": "svg"}

# Select a film.
selected_movie = alt.param(
    name="selected_movie",
    value=movie_titles[0],
    bind=alt.binding_select(options=movie_titles, name="Film: "),
)

# Show character names in order of total annotated screentime.
y_encoding = alt.Y(
    "character_label:N", title=None,
    sort=alt.EncodingSortField("total_s", order="descending"),
    axis=alt.Axis(labelLimit=0),
)
# Use the stored colour codes directly and omit a colour legend.
color_encoding = alt.Color("character_color:N", scale=None, legend=None)

# Display seconds as hours, minutes, and seconds on the horizontal axis.
x_axis = alt.Axis(
    title="Zeit im Film",
    # Limit tick density so the time labels remain readable.
    tickCount=8,
    labelExpr="utcFormat(toDate(datum.value * 1000), '%H:%M:%S')",
)
# Keep the horizontal range at the selected film's full runtime.
x_scale = alt.Scale(nice=False)

# Draw the faint full-width background bands.
row_bands = alt.Chart(timeline_rows).transform_filter(
    alt.datum.film == selected_movie
).mark_bar(opacity=0.6).encode(
    x=alt.X("lane_start_s:Q", scale=x_scale, axis=x_axis),
    x2="lane_end_s:Q", y=y_encoding, color=color_encoding,
)

# Draw screentime intervals at 60% of each row's height.
# Their true start, end, and duration remain available in the tooltip.
screentime_bars = alt.Chart(timeline_data).transform_filter(
    alt.datum.film == selected_movie
).mark_bar(height={"band": 0.6}).encode(
    x=alt.X("start_s:Q", scale=x_scale, axis=x_axis),
    x2="end_display_s:Q", y=y_encoding, color=color_encoding,
    tooltip=[
        alt.Tooltip("film:N", title="Film"),
        alt.Tooltip("character_label:N", title="Figur"),
        alt.Tooltip("start_hh_mm_ss:N", title="Start"),
        alt.Tooltip("end_hh_mm_ss:N", title="Ende"),
        alt.Tooltip("duration:N", title="Dauer"),
    ],
)

# Use the same dimensions for every film. The time range adapts to the selection.
chart = (row_bands + screentime_bars).add_params(selected_movie).properties(
    width=TIMELINE_WIDTH, height=TIMELINE_HEIGHT,
    title=alt.TitleParams(text=alt.ExprRef(expr="selected_movie")),
)

# Display the interactive character timeline.
chart

5.4.5. Export der Ergebnisse#

Die beiden interaktiven Visualisierungen können nun getrennt als .html-Dateien gespeichert werden. So lassen sich sowohl das Figurennetzwerk als auch die Screentime-Zeitleiste unabhängig vom Notebook in einem Webbrowser öffnen und weiter untersuchen. Beide Dateien werden im zuvor festgelegten Exportordner (EXPORT_PATH) abgelegt.

Export des Figurennetzwerks als .html-Datei#

Mit der Exportfunktion von gravis wird das zuvor erstellte Figurennetzwerk unter dem Namen character_network.html gespeichert. Alle Funktionen stehen auch in dieser Version zur Verfügung. Eine bereits vorhandene Datei mit demselben Namen wird bei erneutem Ausführen ersetzt.

# Combine the export directory and filename.
export_directory = Path(EXPORT_PATH)
network_html = export_directory / "character_network.html"

# Export the network with its film dropdown and interactive controls.
fig.export_html(str(network_html), overwrite=True)
print(f"Saved HTML network to: {network_html}")
Saved HTML network to: export_vis_characters/character_network.html

Export der Screentime-Zeitleiste als .html-Datei#

Anschließend wird die Screentime-Zeitleiste lässt sich direkt mit der Funktion chart.save() von Altair als character_timeline_altair.html speichern.

Die Datei kann anschließend im Browser geöffnet werden. Zum Laden der benötigten JavaScript-Bibliotheken ist dabei in der Regel eine Internetverbindung erforderlich. Wird die Zelle erneut ausgeführt, wird eine bestehende gleichnamige Datei ersetzt.

# Combine the export directory and filename.
timeline_html = export_directory / "character_timeline_altair.html"

# Save the complete interactive timeline as an HTML page.
chart.save(timeline_html, embed_options=embed_options)

print(f"Saved HTML timeline to: {timeline_html}")
Saved HTML timeline to: export_vis_characters/character_timeline_altair.html

Herunterladen des Google Colab Export-Ordners#

Dieser optionale Schritt ist für die Ausführung in Google Colab vorgesehen. Das Notebook fasst die beiden zuvor exportierten HTML-Dateien in einem .zip-Archiv zusammen und lädt es auf Ihren Computer herunter.

Wenn Sie das Notebook lokal ausführen, ist kein zusätzlicher Download notwendig. Die Dateien befinden sich bereits im Ordner EXPORT_PATH und können dort direkt geöffnet werden.

# Package the export directory as a ZIP archive and download it in Google Colab.
# Local users can open both HTML files directly from the export directory.
if colab:
    archive_base = export_directory.parent / f"{export_directory.name}_results"
    zip_path = shutil.make_archive(str(archive_base), "zip", root_dir=str(export_directory))
    files.download(zip_path)
    print(f"Downloaded {Path(zip_path).name}.")
else:
    print("Download only works in Google Colab.")
Download only works in Google Colab.