Praxisbeispiel
QuickDraw mit PyTorch trainieren
Für Skizzenerkennung braucht es kein großes Modell. Das Netz, das in DrawLa jede Zeichnung live errät, hat rund zwei Millionen Parameter und passt in acht Megabyte. Dieser Artikel beschreibt die komplette Pipeline mit den tatsächlich verwendeten Werten — von der Rasterung über das Training bis zur Inferenz im laufenden Spiel.
1. Vom Strich zum Bild
Quick Draw speichert Zeichnungen als Strichfolgen: Listen von x- und y-Koordinaten, unterbrochen durch Stiftheben. Für ein CNN muss daraus ein Bild fester Größe werden. Dieser Schritt entscheidet mehr über die Erkennungsqualität als die Wahl der Architektur, und er wird oft zu beiläufig behandelt.
Entscheidend ist die Normalisierung. Aus allen Punkten der Zeichnung wird die Bounding Box bestimmt, die Zeichnung auf die Bildmitte geschoben und so skaliert, dass sie mit acht Prozent Rand hineinpasst. Der Maßstab ist für beide Achsen gleich — sonst würde ein breit gezeichnetes Haus zu einem quadratischen verzerrt und wäre nicht mehr dasselbe Objekt:
span_x = max(max_x - min_x, 1e-6)
span_y = max(max_y - min_y, 1e-6)
pad = max(1.0, img_size * 0.08)
avail = max(2.0, (img_size - 1) - 2.0 * pad)
scale = min(avail / span_x, avail / span_y) # ein Maßstab für beide Achsen
Das Ergebnis: Wo auf der Leinwand jemand zeichnet und wie groß, spielt keine Rolle mehr. Nur die Form zählt. Anschließend werden die Segmente mit einer Linienbreite von zwei Pixeln gezeichnet, wobei jeder Punkt auf einer geraden Linie zwischen zwei Stützstellen gesetzt wird.
Wichtig ist, dass Training und Inferenz exakt denselben Code
benutzen. In DrawLa liegt die Rasterung deshalb in einem einzigen Modul
(server/preprocess.py), das die Trainingsskripte
importieren. Weichen beide Pfade auch nur um einen Pixel Rand
voneinander ab, sinkt die Genauigkeit spürbar, ohne dass im Training
etwas auffällt — ein Fehler, der sich hartnäckig verstecken kann.
2. Warum 96 × 96
Die Bildgröße ist ein Kompromiss. Kleiner heißt schneller und weniger Speicher, aber feine Unterschiede verschwinden: Bei 28 × 28 — der MNIST-Größe, zu der viele greifen — verschmelzen die Speichen eines Fahrrads zu einem grauen Fleck. Größer heißt mehr Rechenzeit pro Vorhersage, was bei achtmal Erkennen pro Sekunde direkt spürbar wird.
96 × 96 hat sich als brauchbarer Mittelweg erwiesen: genug Auflösung für Details wie Fenster, Räder oder Beine, und klein genug, dass eine Vorhersage auf einer gewöhnlichen Server-CPU im einstelligen Millisekundenbereich bleibt. Ein Graustufenkanal genügt, denn Farbe trägt bei Strichzeichnungen keine Information.
3. Die Daten: eine NPZ-Datei je Kategorie
Der Datensatz umfasst 345 Kategorien. Bei 5 000 Zeichnungen pro Klasse ergibt das rund 1,7 Millionen Bilder. Die Rasterung passiert einmalig vorab, nicht bei jedem Trainingsschritt — sie in den Dataloader zu legen wäre der sichere Weg, die GPU verhungern zu lassen, während die CPU Linien zeichnet.
Gespeichert wird pro Kategorie eine komprimierte NPZ-Datei mit einem
uint8-Array der Form (N, 96, 96). Ein Byte pro
Pixel statt vier: Der gesamte Satz bleibt so handhabbar, und der
Dataloader wandelt erst beim Zugriff in Float um.
def __getitem__(self, i):
img_u8, label_idx = self._samples[i]
x = torch.from_numpy(img_u8.astype(np.float32) / 255.0).unsqueeze(0)
if self.augment:
x = self._augment_image(x)
return x, label_idx
Die Aufteilung in Training und Validierung erfolgt mit 90 zu 10 innerhalb jeder Kategorie. Ein globaler Zufallsschnitt würde bei ungleich großen Kategorien einzelne Klassen in der Validierung über- oder unterrepräsentieren.
4. Augmentierung: was echte Skizzen ausmacht
Menschen zeichnen krumm, schief und unterschiedlich groß. Genau das muss die Augmentierung nachbilden. Verwendet werden je Bild eine zufällige Rotation von ±15°, eine Skalierung zwischen 0,9 und 1,1, eine getrennte Stauchung beider Achsen im selben Bereich sowie eine Verschiebung um bis zu 16 Prozent der Bildkante.
Zwei Details sind spezifisch für Strichzeichnungen. Erstens der Linienbreiten-Jitter: Mit je 20 Prozent Wahrscheinlichkeit wird die Linie um einen Pixel verdickt oder verdünnt, umgesetzt als Max-Pooling auf dem Bild beziehungsweise auf seinem Negativ. Das bildet nach, dass Finger auf einem Touchscreen dickere Striche erzeugen als ein Mauszeiger.
width_jitter = int(self.rng.choice([-1, 0, 1], p=[0.2, 0.6, 0.2]))
if width_jitter > 0: # dicker: Linien wachsen
image = F.max_pool2d(image.unsqueeze(0), 3, stride=1, padding=1).squeeze(0)
elif width_jitter < 0: # dünner: auf dem Negativ dilatieren
inv = 1.0 - image
inv = F.max_pool2d(inv.unsqueeze(0), 3, stride=1, padding=1).squeeze(0)
image = 1.0 - inv
Zweitens die Zurückhaltung beim Rauschen: nur in einem Viertel der Fälle, und dann mit einer Standardabweichung von höchstens 0,02. Strichzeichnungen sind fast überall leer; kräftiges Rauschen füllt diese Leere mit Struktur, die es in echten Eingaben nie gibt, und verschlechtert das Ergebnis. Spiegelungen fehlen bewusst ganz — sie würden aus einem „b" ein „d" machen und richtungsabhängige Objekte zerstören.
5. Das Modell: 2,0 Millionen Parameter
Die Architektur ist ein verkleinertes ResNet. Ein Stem halbiert sofort die Auflösung, danach folgen vier Stufen mit je zwei Residual-Blöcken; die Kanalzahl steigt über 32, 64, 128 auf 192, während die Auflösung von 96 auf 6 Pixel fällt. Den Abschluss bilden Global Average Pooling, Dropout und eine lineare Schicht auf 345 Klassen.
Stem : Conv(1→32, 3×3, stride 2) + BatchNorm + SiLU
Stage1: 2 × ResidualBlock, 32 Kanäle 48×48
Stage2: 2 × ResidualBlock, 64 Kanäle 24×24
Stage3: 2 × ResidualBlock, 128 Kanäle 12×12
Stage4: 2 × ResidualBlock, 192 Kanäle 6×6
Head : GlobalAvgPool → Dropout(0.2) → Linear(192→345)
Das ergibt 2 004 345 Parameter, also exakt 8,0 MB in fp32. Zum Vergleich: Ein ResNet-50 hat etwa das Zwölffache und wäre für diese Aufgabe deutlich überdimensioniert — Strichzeichnungen haben keine Texturen, keine Beleuchtung und keinen Hintergrund, sondern nur Form.
Global Average Pooling statt einer großen Fully-Connected-Schicht ist hier besonders wirksam: Es reduziert 6 × 6 × 192 Werte auf 192 und spart damit den Löwenanteil der Parameter, die ein klassischer Klassifikator an dieser Stelle verbrauchen würde. SiLU statt ReLU liefert etwas glattere Gradienten, Batch Normalization hält das Training auch bei Lernrate 10⁻³ stabil.
6. Training und was dabei herauskommt
Trainiert wird mit AdamW bei Lernrate 10⁻³ und Weight Decay 10⁻⁴, über 40 Epochen mit Batch-Größe 256. Die Lernrate folgt einem Cosine-Schedule bis hinunter auf 10⁻⁵. Als Verlustfunktion dient Cross-Entropy mit Label Smoothing 0,1 — sinnvoll, weil der Datensatz selbst fehlerhaft beschriftet ist: Viele Zeichnungen sind abgebrochen oder schlicht misslungen, und ein Modell, das auf solche Labels mit voller Sicherheit trainiert wird, wird überheblich.
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)
optimizer = AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4)
scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-5)
nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)
Das beste Ergebnis lag bei 76,3 Prozent Top-1-Genauigkeit auf dem Validierungsteil, erreicht in Epoche 39 von 40. Diese Zahl wirkt zunächst bescheiden — bis man sie einordnet: Bei 345 gleich wahrscheinlichen Klassen liegt Raten bei 0,29 Prozent. Das Modell ist also rund 260-mal besser als Zufall, und der Rest ist zu einem guten Teil keine Schwäche des Netzes, sondern Mehrdeutigkeit der Vorlage. Eine schnelle Kritzelei von „Schnecke" und eine von „Muschel" sind manchmal dasselbe Bild.
Im Spiel zählt ohnehin die Top-5-Liste, und dort liegt die Trefferquote erheblich höher. Gespeichert wird nur der jeweils beste Checkpoint, zusammen mit Labels und Bildgröße — so kann der Export später nicht versehentlich mit einer anderen Klassenreihenfolge arbeiten.
7. Export nach ONNX
Für den Betrieb wird das Modell nach ONNX exportiert, mit Opset 18 und einer dynamischen Batch-Achse. Damit ist die Inferenz von PyTorch entkoppelt: Auf dem Server läuft kein Torch mehr, was das Container-Image erheblich verkleinert und den Kaltstart verkürzt.
torch.onnx.export(
model, torch.zeros(1, 1, 96, 96), "models/quickdraw_345cls.onnx",
opset_version=18,
input_names=["image"], output_names=["logits"],
dynamic_axes={"image": {0: "batch"}, "logits": {0: "batch"}},
)
onnx.checker.check_model(onnx.load(out_path)) # nie ungeprüft ausliefern
Die Labels werden im selben Schritt als JSON neben das Modell gelegt. Klassenreihenfolge und Gewichte gehören zusammen; sie getrennt zu pflegen ist eine zuverlässige Quelle für Fehler, die sich als „das Modell erkennt plötzlich alles falsch" äußern.
8. Inferenz im laufenden Spiel
Im Betrieb läuft ONNX Runtime auf der CPU, mit zwei Threads pro Anfrage und voll aktivierter Graph-Optimierung. Eine GPU wäre für ein Modell dieser Größe unwirtschaftlich — der Aufwand, die Daten hin- und zurückzuschieben, überwiegt.
Der Client schickt den aktuellen Strichstand alle 120 Millisekunden,
also gut achtmal pro Sekunde. Auf die Logits folgt ein Softmax, der
vorher das Maximum abzieht — sonst kann exp() überlaufen:
logits_shifted = logits - logits.max() # numerisch stabil
probs = np.exp(logits_shifted)
probs /= probs.sum()
Ein letzter Kniff macht den Unterschied für das Spielgefühl: exponentielle Glättung über aufeinanderfolgende Vorhersagen mit α = 0,6. Ohne sie flackert die Anzeige bei jedem neuen Strich zwischen konkurrierenden Begriffen, was sich anfühlt, als würde das Modell raten. Mit ihr entsteht der Eindruck, dass die Erkennung eine Vermutung entwickelt und ihr treu bleibt, bis genug Gegenbeweise vorliegen. Die Glättung ist reine Darstellung — an der Genauigkeit ändert sie nichts, am wahrgenommenen Verhalten sehr viel.
Was wir anders machen würden
Rückblickend wäre mehr Rechenzeit in die Datenqualität statt in die Architektur besser investiert gewesen. Der Quick-Draw-Datensatz enthält einen spürbaren Anteil abgebrochener und falsch beschrifteter Zeichnungen; ein Aussortieren der offensichtlichsten Fälle hätte vermutlich mehr gebracht als jede weitere Residual-Stufe.
Ebenfalls lohnend wäre eine Gewichtung nach Verwechselbarkeit. Einige Klassenpaare sind praktisch nicht trennbar, und das Modell verschwendet Kapazität darauf, sie doch zu trennen, statt bei den eindeutigen Kategorien sicherer zu werden.
Wie sich dieser bildbasierte Ansatz zur sequenzbasierten Alternative verhält, steht im Vergleich RNN gegen CNN. Die Datenbasis erklärt der Artikel zum Quick-Draw-Datensatz, und warum ein so kleines Modell ein guter Kandidat für Geräte-Inferenz ist, behandelt Mobile & Edge AI.