Ich werde ja leider auch nicht jünger, und das Portrait-Foto am Ende jeder Snapshotausgabe war bis zur vorigen Ausgabe gute 15 Jahre alt. Neulich schoss ich deshalb kurzerhand ein neues mit dem Handy und polierte mit Gimp den Hintergrund auf, auf dem ein Stück leicht knittriger Stoff störte. Dabei kam mir die Idee, meine neue Lieblingssprache Go mal auf ihre Tauglichkeit zum Verarbeiten von digitalen Bildern abzuklopfen und fragte mich, wie schwierig es wohl wäre, aus einem Portraitfoto wie in Abbildung 1 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 schlängelt und herausfindet, welche noch zur portraitierten Person gehören und welche zum helleren Hintergrund. Welcher Algorithmus schwärzt alle relevanten Bildpunkte in vertretbarer Zeit ein, und ohne sich zu verheddern?
Abbildung 1: Das Portrait im Original |
Abbildung 2: Ein zu niedriger Grenzwert für die Schwärzung verleiht dem Bild einen Warhol-haften Charakter, erzeugt aber keinen Schattenriss. |
Bei hellem Hintergrund und signifikant dunklerem Objekt im Vordergrund findet ein Programm wie in Listing 1 einfach alle Pixel, deren Helligkeit einen eingestellten Schwellwert unterschreitet, und schwärzt sie komplett ein. Dabei nimmt die Funktion Darken()
ab Zeile 9 eine Struktur vom Typ draw.Image
entgegen, samt der Breite und Höhe des Bildes in den Parametern width
und height
in Pixeln. Die doppelte For-Schleife fährt alle Pixel des Bildes 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 Blauwert 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 Schwellwert von 180 vergleicht. Ist einer der Kanäle drunter, der aktuelle Pixel also dunkler, setzt Zeile 20 den Pixelwert auf color.Black
, also (0, 0, 0). Der eingestellte Schwellwert ist das A und O des Algorithmus. Mit einem zu niedrig eingestellten Wert findet das Verfahren nicht alle Vordergrundpixel (Abbildung 2), ist er hingegen zu hoch, schwärzt er das Bild auch an Stellen ein, die zum Hintergrund gehören (Abbildung 6).
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 }
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 (z.B. -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.
Abbildung 3: Mit ungültigen Paramtern aufgerufen, zeigt das Programm die |
Abbildung 4: Glog schreibt abgesetzte Meldungen in eine Logdatei. |
Das in Zeile 10 hereingeholte Logpaket der Firma Google, glog
, ist reines Hexenwerk. Es kommuniziert hinter den Kulissen mit dem Kommandozeilenparser flag
und jubelt ihm auch noch eine Hilfeseite für glog
s-Kommandozeilenparameter unter, die flag
bei falschem Aufruf anzeigt (Abbildung 3). Listing 2 meldet außerdem zu Informationszwecken den Namen der gerade dekodierten Jpeg-Datei in Zeile 41. Aber wohin loggt glog
eigentlich? Unterbleibt der Kommandozeilenparameter
--stderrthreshold=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 hinterher, warum Einträge in der Logdatei fehlen. Um glog
im Home-Verzeicnis zu installieren, genügt der Aufruf
go get github.com/golang/glog
und zukünftig kompilierte Programme können die nützlichen Features verwenden.
Abbildung 5: Mit dem richtigen Schwellwert leistet der Algorithmus ganze Arbeit. |
Eine JPG-Datei zu öffnen und die darin enthaltenen Pixelwerte zu extrahieren ist dank des Standardpakets image/jpeg
kein Hexenwerk. Die Funktion jpeg.Decode()
schnappt sich ein zuvor 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 Gos Logging-Modul das Programm ab.
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 }
Die von jpeg.Decode()
gelesenen Bilddaten liegen allerdings noch nicht in einem Format vor, das sich dynamisch verändern ließe. Deswegen kopiert Listing 2 in Zeile 54 mittels 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 ja 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
einfach überschrieben wird. Der Wert draw.Over
hätte statt dessen eine Source-over-Destination Overlay-Transformation durchgeführt. Die angegebenen Startkoordinaten bounds.Min
definieren den Nullpunkt (0,0), da ja das gesamte Image kopiert wird und kein Teil ausgeschnitten wird.
Abbildung 6: Ein zu hoher Schwellenwert wählt allerdings auch Teile des Hintergrunds aus. |
Das mit go build thresmain.go
erzeugte Hauptprogramm thresmain
schreibt die modifizierte Bilddatei portrait.jpg
in eine neu erzeugte Datei portrait-s.jpg
. Zum Umschreiben des Dateinamens fieselt Zeile 60 erst einmal 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 den Suffix wieder dranhängt. Die Zieldatei existiert normalerweise noch nicht, also benötigt die Funktion OpenFile()
in Zeile 66 nicht nur das zum Lesen/Schreiben erforderliche Flag os.O_RDWR
sondern auch noch os.O_CREATE
zum Erzeugen einer neuen Datei.
Mit jpeg.Encode()
und dem Qualitätswert 80 kodiert Listing 2 dann die modifizierten Bilddaten wieder ins Jpeg-Format und schreibt sie in die angegebene Datei. Wer will, kann noch mit weiteren Algorithmen zur Analyse des Bildes herumspielen. Ein berühmtes Beispiel ist das "Flood Fill" oder auch "Bucket Fill" ([3]) genannte Verfahren, das im Bild herumwandert, nach gleichartigen Pixeln sucht, und diese entsprechend einfärbt. Die Pixelwerte liegen vor, der Kreativität sind keine Grenzen gesetzt!
Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/02/snapshot/
"The Go image/draw package", "The Go Blog", https://blog.golang.org/go-imagedraw-package
"Flood Fill Algorithm", https://en.wikipedia.org/wiki/Flood_fill
Hey! The above document had some coding errors, which are explained below:
Unknown directive: =desc