EndOfVideo: ein selbst trainiertes Bildmodell erkennt das Bandende bei der Videodigitalisierung
Projekt-Übersicht
In der industriellen Videodigitalisierung laufen VHS-Kassetten und andere analoge Videoformate über eigens entwickelte Maschinen. Ein Band läuft dabei in Echtzeit durch einen Rekorder, eine Aufnahmestation zeichnet das Signal auf. Eine wiederkehrende Frage in diesem Ablauf lautet: Wann ist auf dem Band nichts mehr drauf? Nach dem letzten echten Inhalt folgen oft Minuten oder Stunden mit Bildrauschen oder einem gleichförmigen Graubild, die niemand im fertigen Video haben will.
EndOfVideo ist das Modell, das diese Frage beantwortet. Es ist ein Bildklassifikator auf Basis von ResNet18, den ich mit einem eigenen Datensatz aus Videoframes auf drei Klassen trainiert habe: snow (Bildrauschen, „Schneegestöber"), gray (gleichförmiges Graubild) und normal (echter Bildinhalt). Das Modell läuft als eigenständiger Dienst hinter einer RabbitMQ-Queue. Unsere Produktionssoftware, ein .NET-Server, löst das Modell gezielt für eine Aufnahme aus, schickt ihm einzelne JPEG-Frames und bekommt Klasse und Konfidenz zurück. Darauf setzen zwei Anwendungen auf: eine Bandende-Analyse für fertige Aufnahmen, die einen Schnittpunkt ermittelt und das Video per FFmpeg kürzt, und eine Statusanzeige für laufende Aufnahmen.
Ausgangslage
Bevor es das Modell gab, war das Erkennen des Bandendes ein manueller Arbeitsschritt. In der Produktionssteuerung sammelt jeder Aufnahmekanal regelmäßig Vorschaubilder. Wer eine Aufnahme betreut, sucht darin das letzte gültige Bild aus; aus dessen Zeitpunkt berechnet das System einen Offset in Sekunden, der im Dateinamen landet. Ein nachgelagerter Schneidedienst kürzt das Video anschließend um diesen Offset. Das funktioniert, setzt aber voraus, dass jemand jede einzelne Aufnahme ansieht und den richtigen Moment wählt.
Das Problem eignet sich gut für ein kleines, spezialisiertes Modell. Die Unterscheidung ist visuell eindeutig: Ein Mensch erkennt Bildrauschen oder ein Graubild auf einen Blick. Gleichzeitig ist sie mit einfachen Schwellwerten auf Helligkeit oder Varianz schwer robust zu lösen, weil auch echter Inhalt dunkel, verrauscht oder einfarbig sein kann – eine Abblende, ein Nachtbild, eine schlecht erhaltene Kassette. Ein großes, allgemeines Vision-Modell wäre für diese eine Entscheidung überdimensioniert und für die vielen Einzelabfragen zu teuer. Ein vortrainiertes CNN, das auf genau diese drei Fälle feinjustiert wird, passt dagegen.
Daten
Den Datensatz habe ich selbst aus Videomaterial aufgebaut. Ausgangspunkt waren Videodateien (MP4 und MOV), die vorab nach ihrem Inhalt in drei Ordner sortiert wurden: normal, snow und gray. Die Ordnerstruktur ist gleichzeitig das Label – es gibt keine separate Annotationsdatei.
Aus jedem Video extrahiert das Skript split_videos.py mit OpenCV genau 100 Frames, gleichmäßig über die gesamte Länge verteilt. So trägt ein langes Video nicht mehr Bilder bei als ein kurzes, und die Frames decken den ganzen Clip ab statt nur dessen Anfang. Jeder Frame wird auf 224 × 224 Pixel skaliert und als JPEG gespeichert, der Dateiname enthält den Videonamen und die laufende Nummer. Pro Durchlauf verarbeitet das Skript bis zu 1.000 Videos.
step = frame_count / 100
frame_number, frames_saved = 0, 0
while frames_saved < 100:
video.set(cv2.CAP_PROP_POS_FRAMES, int(frame_number))
ret, frame = video.read()
if not ret:
break
resized_frame = cv2.resize(frame, (224, 224))
cv2.imwrite(os.path.join(final_output_path,
f"{video_name}_{frames_saved:03d}.jpg"), resized_frame)
frames_saved += 1
frame_number += stepEine frühere Version des Skripts hat stattdessen einen Frame pro Sekunde gespeichert. Das habe ich verworfen, weil lange Videos den Datensatz dominiert hätten und eine Klasse leicht viel größer geworden wäre als die anderen.
Beim Training begrenzt prepare_data die Bildanzahl auf höchstens 10.000 Bilder pro Klasse. Liegen mehr Frames vor, wird vor dem Abschneiden zufällig gemischt. Die Begrenzung hält die Klassen ungefähr im Gleichgewicht, sofern jede Klasse genug Material hat. Anschließend werden die Daten mit train_test_split aus scikit-learn im Verhältnis 80/20 in Trainings- und Testdaten aufgeteilt, mit festem Seed 42.
Modell und Training
flowchart LR
A["Videos sortiert nach normal / snow / gray"] --> B["split_videos.py: 100 Frames pro Video, 224x224"]
B --> C["prepare_data: max. 10.000 Bilder pro Klasse"]
C --> D["80/20-Split"]
D --> E["ResNet18 Fine-Tuning, 10 Epochen"]
E --> F["Checkpoint image_classifier_model.pth"]
F --> G["Docker-Image mit RabbitMQ-Worker"]
G --> H["Queue endofvideo"]
H --> I["Produktionssteuerung: Bandende-Analyse und Schnitt"]
Architekturwahl und Begründung
Als Basis dient ResNet18 aus torchvision mit den auf ImageNet vortrainierten Gewichten. Den letzten vollverbundenen Layer, der ursprünglich 1.000 ImageNet-Klassen ausgibt, ersetze ich durch einen neuen linearen Kopf mit drei Ausgängen.
class ImageClassifier(nn.Module):
def __init__(self, num_classes=3):
super(ImageClassifier, self).__init__()
self.resnet = models.resnet18(pretrained=True)
num_features = self.resnet.fc.in_features
self.resnet.fc = nn.Linear(num_features, num_classes)
def forward(self, x):
return self.resnet(x)Für ResNet18 sprachen drei Gründe. Erstens ist es klein: rund elf Millionen Parameter, schnell zu trainieren und auch ohne GPU in vertretbarer Zeit auszuführen. Zweitens bringt das ImageNet-Vortraining Merkmale für Kanten, Texturen und Flächen mit – genau die Eigenschaften, in denen sich Rauschen, Graubild und echter Inhalt unterscheiden. Drittens ist die Architektur gut dokumentiert und in torchvision mit einer Zeile verfügbar, was das Deployment einfach hält. Der Optimizer bekommt alle Parameter des Netzes; es werden also nicht nur der neue Kopf, sondern auch die vortrainierten Schichten nachjustiert.
Hyperparameter
Die Konfiguration ist bewusst schlicht gehalten:
- Eingabegröße 224 × 224, Normalisierung mit den ImageNet-Mittelwerten und -Standardabweichungen
- Verlustfunktion: Cross-Entropy
- Optimizer: Adam, Lernrate 1e-3, ohne Scheduler
- Batch-Größe 32, Trainingsdaten gemischt
- 10 Epochen
- Keine Datenaugmentation
Die Vorverarbeitung im Training und in der späteren Inferenz ist identisch: dieselbe transforms.Compose-Kette aus Resize, ToTensor und Normalize. Das ist wichtig, weil die Frames im Live-Betrieb nicht aus split_videos.py stammen, sondern aus den Vorschaubildern der Aufnahmestationen oder einer FFmpeg-Extraktion.
Trainingsablauf
Das Skript image_classifier.py erledigt alles in einem Durchgang: Daten sammeln, splitten, DataLoader bauen, Modell initialisieren, trainieren, auf dem Testsplit auswerten, speichern. Nach jeder Epoche gibt es Loss, Trainingsgenauigkeit und Laufzeit aus; nach dem Training berechnet es die Genauigkeit auf den 20 Prozent Testdaten. Gespeichert wird ein Checkpoint mit den Modellgewichten, dem Optimizer-Zustand und der gemessenen Testgenauigkeit. Trainiert wird auf CUDA, wenn eine GPU vorhanden ist, sonst auf der CPU.
Für schnelle Stichproben gibt es zusätzlich predict.py. Die Klasse ImagePredictor lädt den Checkpoint, bereitet ein Bild vor und liefert Vorhersage, Konfidenz (Softmax-Wahrscheinlichkeit der gewählten Klasse) sowie getrennte Zeiten für Vorverarbeitung und Inferenz. Diese Klasse ist zugleich der Kern des produktiven Dienstes.
Einsatz
Der Dienst hinter der Queue
Das trainierte Modell läuft als RabbitMQ-Worker in einem Docker-Container, betrieben in einem Kubernetes-Cluster. Beim Start lädt der Worker den Checkpoint einmal und lauscht dann auf der Queue endofvideo. Die Kommunikation folgt dem RPC-Muster: Der Aufrufer schickt eine Nachricht mit einer reply_to-Queue und einer Correlation-ID, der Worker antwortet genau dorthin. Nachrichten sind mit MessagePack serialisiert; das ist kompakter als JSON und auf beiden Seiten – Python und C# – mit festen Feldpositionen leicht abzubilden.
def on_request(ch, method, props, body):
data_in = msgpack.unpackb(body)
with open(temp_filename, "wb") as f:
f.write(data_in[0])
result = predictor.predict(temp_filename)
response = [True, result.prediction, result.confidence,
int((time.time() - start_time) * 1000),
args.hostname, ""]
ch.basic_publish(exchange='', routing_key=props.reply_to,
properties=pika.BasicProperties(
correlation_id=props.correlation_id),
body=msgpack.packb(response, use_bin_type=True))
ch.basic_ack(delivery_tag=method.delivery_tag)Die Antwort enthält neben Klasse und Konfidenz auch die Verarbeitungszeit und den Namen des Hosts, der die Anfrage bearbeitet hat. Mit prefetch_count=1 nimmt jeder Worker immer nur eine Nachricht gleichzeitig an; eine Consumer-Priorität lässt sich per Parameter setzen. Damit kann ich weitere Worker hinzufügen, ohne am Aufrufer etwas zu ändern. Fehler werden nicht verschluckt, sondern als Antwort mit success = false und Fehlermeldung zurückgegeben.
Auf der .NET-Seite kapselt die Klasse EndOfVideoClient den Aufruf. Sie serialisiert die JPEG-Bytes, ruft über einen generischen RabbitMQ-RPC-Client die Queue auf und deserialisiert die Antwort in ein typisiertes Objekt mit denselben sechs Feldern.
Bandende-Analyse und Schnitt
Die erste Anwendung arbeitet mit fertigen Aufnahmen. Die Produktionssteuerung extrahiert mit FFmpeg eine wählbare Anzahl von Frames aus einem Video – entweder gleichmäßig verteilt oder in festem Zeitabstand – und schickt sie in kleinen Paketen parallel an das Modell. Ergebnisse werden pro Frame zwischengespeichert, damit eine erneute Analyse nicht wieder dieselben Bilder klassifiziert.
Auf die Liste der Vorhersagen setzt ein Schnittalgorithmus auf. Er läuft vom Ende des Videos rückwärts und sucht den längsten zusammenhängenden Bereich, in dem mindestens ein bestimmter Anteil der Frames nicht normal ist. Beide Grenzen sind einstellbar, voreingestellt sind mindestens 10 Frames und mindestens 90 Prozent nicht-normale Frames. Einzelne Fehlklassifikationen am Rand werden so toleriert, ein kurzer dunkler Moment mitten im Film löst aber keinen Schnitt aus. Führende normale Frames am Anfang des gefundenen Bereichs werden abgeschnitten, der erste nicht-normale Frame wird zum Schnittpunkt. Findet der Algorithmus keinen ausreichend großen Bereich, empfiehlt er keinen Schnitt.
Den Schnitt selbst führt FFmpeg mit -t <Zeitpunkt> -c copy aus. Das Video wird nicht neu kodiert, sondern nur bis zum Schnittpunkt kopiert; das Original bleibt unverändert, das Ergebnis bekommt ein eigenes Suffix. In der aktuellen Form ist das ein Werkzeug in der Administrationsoberfläche: Frames extrahieren, Vorhersagen anzeigen, Schnittempfehlung prüfen, Schnitt auslösen.
Statusanzeige für laufende Aufnahmen
Die zweite Anwendung betrifft laufende Aufnahmen. Für jeden Aufnahmekanal kann die Produktionssteuerung das aktuelle Vorschaubild klassifizieren lassen und speichert Klasse, Konfidenz und Zeitpunkt am Kanal. Die Oberfläche markiert ein Gerät als Warnung, wenn ein Kanal aufnimmt, das Modell kein normales Bild sieht und die Konfidenz über 0,95 liegt. Zusätzlich legt der Server jedes klassifizierte Vorschaubild in einem Ordner ab, der nach der vorhergesagten Klasse benannt ist. Damit entsteht nebenbei Material, das sich prüfen und für ein späteres Nachtraining verwenden lässt.
Heute wird die Klassifikation gezielt für eine Aufnahme ausgelöst. Als nächster Ausbau ist ein Rundlauf über alle Kanäle vorbereitet: Ein Timer wählt jeweils den aktiven Kanal mit der ältesten Vorhersage und lässt ihn klassifizieren, mit einer Obergrenze für gleichzeitige Anfragen.
Ergebnis
Entstanden ist eine vollständige Kette von den Rohdaten bis zum produktiven Dienst: ein eigener, nach Klassen sortierter Datensatz aus Videoframes, ein feinjustiertes ResNet18 mit drei Klassen, ein containerisierter Inferenzdienst hinter einer RabbitMQ-Queue und die Anbindung an die Produktionssteuerung. Das Modell beantwortet für ein einzelnes Bild die Frage „Inhalt, Rauschen oder Graubild?" mit einer Konfidenz. Erst die Logik darum herum macht daraus eine Entscheidung über das Bandende.
Die Bandende-Analyse übernimmt damit einen Arbeitsschritt, der vorher manuell erledigt wurde: das Durchsehen der Vorschaubilder und das Festlegen des letzten gültigen Bildes. Statt jedes Video selbst zu prüfen, bekommt man einen vorgeschlagenen Schnittpunkt samt Zeitleiste der Vorhersagen und kann ihn bestätigen.
Die Testgenauigkeit wird bei jedem Training gemessen und im Checkpoint gespeichert. Einen Wert nenne ich hier bewusst nicht: Aussagekräftig ist für mich erst eine Auswertung auf separatem Bandmaterial, das beim Training keine Rolle gespielt hat.
Was ich dabei gelernt habe
Das Modell ist der kleinere Teil. Das Training selbst umfasst ein Skript mit gut 200 Zeilen. Der größere Aufwand steckte in der Datengewinnung, im RPC-Protokoll zwischen Python und C# und vor allem in der Frage, wie aus Einzelbild-Vorhersagen eine belastbare Entscheidung über ein ganzes Video wird. Der Schnittalgorithmus mit Mindestlänge und Anteilsschwelle ist das, was einzelne Fehlklassifikationen abfängt.
Beim Split auf Video-Ebene teilen. Frames aus demselben Band ähneln sich stark. Damit die Testgenauigkeit etwas über neues Material aussagt, gehören ganze Videos entweder ins Training oder in den Test, sodass Frames eines Bandes nie in beiden Teilen landen.
Gleichmäßig verteilte Frames sind besser als Frames pro Sekunde. Die feste Zahl von 100 Bildern pro Video hat verhindert, dass einzelne lange Aufnahmen den Datensatz dominieren. In Kombination mit der Obergrenze pro Klasse ergab das einen deutlich ausgewogeneren Datensatz als der erste Ansatz.
Training und Inferenz müssen dieselbe Vorverarbeitung und dieselbe Label-Reihenfolge haben. Gerät die Zuordnung von Index zu Klasse an einer Stelle durcheinander, liefert das Modell weiterhin plausible Konfidenzen, nur für die falsche Klasse. Solche Kopplungen gehören deshalb an eine einzige Stelle, am besten in den Checkpoint, aus dem Training und Inferenz gleichermaßen lesen.
Ein Queue-basierter Dienst entkoppelt sauber. Durch RabbitMQ mit RPC-Muster muss die Produktionssteuerung nichts über PyTorch, GPU oder Python wissen. Der Worker lässt sich unabhängig neu bauen und austauschen, und weitere Instanzen lassen sich ohne Änderung am Aufrufer hinzufügen.
Technologien
- Python, PyTorch, torchvision: Training und Inferenz, ResNet18 mit ImageNet-Gewichten
- OpenCV: Frame-Extraktion aus MP4- und MOV-Dateien
- scikit-learn: Aufteilung in Trainings- und Testdaten
- RabbitMQ (pika) und MessagePack: RPC-Kommunikation zwischen Produktionssteuerung und Modell
- Docker und Kubernetes: Betrieb des Inferenzdienstes
- C#/.NET: Client in der Produktionssteuerung, Frame-Extraktion und Schnitt
- FFmpeg: Frame-Extraktion aus fertigen Aufnahmen und verlustfreier Schnitt
- Angular: Administrationsoberfläche für Analyse und Schnittempfehlung