Schattenwelt (Linux-Magazin, Februar 2019)

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.

Schwärzen per Schwellwert

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).

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 }

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 (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 glogs-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.

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 }

Beschreiben nach Klimmzug

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.

Alles Neu

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!

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2019/02/snapshot/

[2]

"The Go image/draw package", "The Go Blog", https://blog.golang.org/go-imagedraw-package

[3]

"Flood Fill Algorithm", https://en.wikipedia.org/wiki/Flood_fill

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc