Mein Porträtfoto am Ende des letzten Snapshots war schon gut 15 Jahre alt. Neulich schoss ich deshalb ein neues. Dabei kam mir die Idee, Go auf Tauglichkeit zur Bildverarbeitung abzuklopfen. Es galt, aus dem Foto einen Schattenriss der abgebildeten Person zu generieren.
Klar, mit ein paar Gimp-Skills ließe sich das einigermaßen schnell hintricksen, aber viel interessanter ist doch die Frage, wie ein Bildverarbeitungsprogramm sich durch die Pixel von Abbildung 1 schlängelt und herausfindet, welche noch zur porträtierten Person gehören und welche zum helleren Hintergrund. Welcher Algorithmus schwärzt alle relevanten Bildpunkte in vertretbarer Zeit ein, und zwar ohne sich zu verheddern?
Schwärzen per Schwellenwert
Bei hellem Hintergrund und signifikant dunklerem Objekt im Vordergrund findet ein Programm wie in Listing 1 einfach alle Pixel, deren Helligkeit einen eingestellten Schwellenwert unterschreitet, und schwärzt sie komplett ein. Dabei nimmt die Funktion »Darken()« ab Zeile 9 eine Struktur vom Typ »draw.Image« entgegen, samt Breite und Höhe des Bildes in den Parametern »width« und »height« in Pixeln.
Listing 1
darkenthreshold.go
01 package darkenthreshold
02 import (
03 "image/color"
04 "image/draw"
05 )
06
07 var Threshold uint8 = 180
08
09 func Darken(dimg draw.Image,
10 width int, height int) {
11 for x := 0; x < width; x++ {
12 for y := 0; y < height; y++ {
13
14 red, green, blue, _ :=
15 dimg.At(x, y).RGBA()
16
17 if uint8(red >> 8) < Threshold ||
18 uint8(blue >> 8) < Threshold ||
19 uint8(green >> 8) < Threshold {
20 dimg.Set(x, y, color.Black)
21 }
22 }
23 }
24 }
Die doppelte For-Schleife fährt die Pixel zeilenweise von oben nach unten ab, und die in Zeile 15 aufgerufene Funktion »At()« liefert den Farbton des aktuellen Pixels als einen Wert vom Typ »color.Color«, den die Funktion »RGBA()« in einen Rot-, Grün- und Blau-Wert umrechnet, sowie einen Alpha-Wert (Transparenz), den der Aufrufer in Listing 1 mit einem Unterstrich ignoriert.
Die oberen 8 Bit dieser Werte geben den Farbwert des jeweiligen RGB-Kanals von 0 bis 255 an, den eine Bitshift-Operation extrahiert und mit dem vorab experimentell ermittelten Schwellenwert von 180 vergleicht. Ist einer der Kanäle darunter, das aktuelle Pixel also dunkler, setzt Zeile 20 den Pixelwert auf »color.Black«, also (0, 0, 0). Der eingestellte Schwellenwert ist das A und O des Algorithmus. Mit einem zu niedrig eingestellten Wert findet das Verfahren nicht alle Vordergrundpixel (Abbildung 2), ist er zu hoch, schwärzt er das Bild auch an Stellen ein, die zum Hintergrund gehören (Abbildung 5).

Abbildung 2: Ein zu niedriger Grenzwert für die Schwärzung verleiht dem Bild einen Warhol-haften Charakter, erzeugt aber keinen Schattenriss.
Hexenwerk
Das Hauptprogramm, das den Namen einer Bilddatei im JPG-Format entgegennimmt, zeigt Listing 2. Damit der User auch weiß, wie das Programm zu bedienen ist, analysiert das Standardmodul »flags« die Kommandozeile, schnappt sich eventuell gegebene Flags (etwa »-v«) und stellt sie sowie alle dahinterstehenden Argumente in der Struktur »flag« bereit. Die Bilddatei steht so in »flag.Arg(0)«, fehlt sie, ruft Zeile 30 die in Zeile 16 definierte Funktion »usage()« auf, die dem User die richtige Signatur des Kommandos anzeigt und das Programm abbricht.
Listing 2
thresmain.go
01 package main
02
03 import (
04 "darkenthreshold"
05 "flag"
06 "fmt"
07 "image"
08 "image/draw"
09 "image/jpeg"
10 "github.com/golang/glog"
11 "os"
12 "path/filepath"
13 "strings"
14 )
15
16 func usage() {
17 fmt.Fprintf(os.Stderr, "usage: " +
18 os.Args[0]+" image.jpg\n")
19 flag.PrintDefaults()
20 os.Exit(2)
21 }
22
23 func main() {
24 flag.Parse()
25 flag.Usage = usage
26
27 defer glog.Flush()
28
29 if len(flag.Args()) != 1 {
30 usage()
31 }
32
33 srcFileName := flag.Arg(0)
34
35 src, err := os.Open(srcFileName)
36 if err != nil {
37 glog.Fatalf("Can't read %s: %s",
38 srcFileName, err)
39 }
40
41 glog.Infof("Decoding %s\n", srcFileName)
42
43 jimg, err := jpeg.Decode(src)
44 if err != nil {
45 glog.Fatalf("Can't decode %s: %s",
46 srcFileName, err)
47 }
48
49 bounds := jimg.Bounds()
50 width, height := bounds.Max.X,
51 bounds.Max.Y
52
53 dimg := image.NewRGBA(bounds)
54 draw.Draw(dimg, dimg.Bounds(), jimg,
55 bounds.Min, draw.Src)
56
57 darkenthreshold.Darken(dimg,
58 width, height)
59
60 fileSuffix := filepath.Ext(srcFileName)
61 fileBase := strings.TrimSuffix(srcFileName,
62 fileSuffix)
63 dstFileName := fmt.Sprintf("%s-s%s",
64 fileBase, fileSuffix)
65
66 dstFile, err := os.OpenFile(dstFileName,
67 os.O_RDWR|os.O_CREATE, 0644)
68 if err != nil {
69 glog.Fatalf("Can't open output")
70 }
71
72 jpeg.Encode(dstFile, dimg,
73 &jpeg.Options{Quality: 80})
74 dstFile.Close()
75 }
Das in Zeile 10 hereingeholte Logpaket der Firma Google, »glog«, ist reines Hexenwerk. Es kommuniziert hinter den Kulissen mit dem Kommandozeilen-Parser »flag« und jubelt ihm auch noch eine Hilfeseite für »glog«-Kommandozeilen-Parameter unter, die »flag« bei falschem Aufruf anzeigt (Abbildung 3). Listing 2 meldet auch zu Informationszwecken den Namen der gerade dekodierten Jpeg-Datei in Zeile 41.

Abbildung 3: Mit ungültigen Parametern aufgerufen, druckt das Programm als Hilfestellung die erlaubten Parameter des Logging-Moduls aus und bricht ab.
Aber wohin loggt »glog« eigentlich? Unterbleibt der Kommandozeilen-Parameter »–stderrthresold=INFO«, der alle als “Info” gekennzeichneten Glog-Meldungen auf Stderr umleitet, finden sich die Logmeldungen in einer Logdatei im »/tmp«-Verzeichnis des Rechners. Abbildung 4 zeigt deren Namen. Wichtig ist auch noch die Flush-Methode, die Listing 2 in Zeile 27 mit dem Defer-Schlüsselwort am Programmende aufruft. Wer das vergisst, wundert sich, warum Einträge in der Logdatei fehlen.
Um »glog« im Homeverzeichnis zu installieren, genügt der Aufruf:
go get github.com/golang/glog
Künftig kompilierte Programme können die Features auch verwenden.
Eine JPG-Datei zu öffnen und deren Pixelwerte zu extrahieren ist dank des Standardpakets »image/jpeg« kein Hexenwerk. Die Funktion »jpeg.Decode()« schnappt sich ein mittels »os.Open()« erzeugtes »Reader«-Interface auf die Bilddatei und dekodiert die komprimierten Daten. Das schlägt bei korrupten Dateien fehl, deshalb prüft Zeile 44 das Resultat und bricht mit »glog.Fatalf()« aus Go’s Logging-Modul das Programm ab.
Beschreiben nach Klimmzug
Die von »jpeg.Decode()« gelesenen Bilddaten liegen aber noch nicht in einem Format vor, das sich dynamisch verändern ließe. Daher kopiert Listing 2 in Zeile 54 mit der Funktion »draw.Draw()« aus dem Paket »image/draw« die Bilddaten in eine neu erzeugte Struktur vom Typ »image.RGBA«. Aus der kann später Listing 1 nicht nur mit »At()« lesen, sondern mit »Set()« auch Pixelwerte gezielt verändern. Wie in [2] beschrieben, zielt das Interface in »image/draw« auf Transformationen am bearbeiteten Bild.
Die Funktion »draw.Draw()« aus Zeile 54, die im vorliegenden Fall nur das interne Format beschreibbar macht, überführt das Jpeg-Bild in »jimg« in das Draw-Image »dimg«. Die Konstante »draw.Src« gibt an, dass das (im vorliegenden Fall leere, weil gerade neu erzeugte) Zielbild in »dimg« überschrieben wird. Der Wert »draw.Over« hätte stattdessen eine Source-over-Destination-Overlay-Transformation durchgeführt. Die angegebenen Startkoordinaten »bounds.Min« definieren den Nullpunkt (0,0), da das gesamte Image kopiert wird.
Alles neu
Das mit »go build thresmain.go« erzeugte Hauptprogramm »thresmain« schreibt die modifizierte Bilddatei »portrait.jpg« in eine neue Datei »portrait-s.jpg«. Zum Umschreiben des Dateinamens fieselt Zeile 60 die Endung ».jpg« aus dem Namen der alten Datei heraus, schneidet ihn dann mit »TrimSuffix« ab, bevor die Funktion »Sprintf« aus dem Paket »fmt« ein »-s« einfügt und das Suffix wieder dranhängt. Die Zieldatei existiert noch nicht, also benötigt die Funktion »OpenFile()« in Zeile 66 das zum Lesen und Schreiben erforderliche Flag »os.O_RDWR« und »os.O_CREATE« zum Erzeugen einer neuen Datei.
Mit »jpeg.Encode()« und dem Qualitätswert 80 kodiert Listing 2 die modifizierten Daten ins Jpeg-Format und schreibt sie in die angegebene Datei (Abbildung 6). Wer will, kann weitere Algorithmen probieren, etwa mit dem “Flood Fill” [3] genannten Verfahren, das gleichartige Pixel einfärbt. Die Pixelwerte liegen vor, die Kreativität hat freie Bahn!
Online PLUS
Im Screencast demonstriert Michael Schilli das Beispiel: https://www.linux-magazin.de/videos/
- Listings zu diesem Artikel: https://www.linux-magazin.de/static/listings/magazin/2019/02/snapshot/
- The Go image/draw package: https://blog.golang.org/go-imagedraw-package
- “Flood Fill”: https://en.wikipedia.org/wiki/Flood_fill










