Aus Linux-Magazin 02/2019

Ein Algorithmus findet eine Person im Bild und erzeugt einen Schattenriss

© andreiuc88, 123RF

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?

Abbildung 1: Das dem Algorithmus vorgelegte Porträtfoto von Mike Schilli in der Originalversion.

Abbildung 1: Das dem Algorithmus vorgelegte Porträtfoto von Mike Schilli in der Originalversion.

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&uuml;r die Schw&auml;rzung verleiht dem Bild einen Warhol-haften Charakter, erzeugt aber keinen Schattenriss.

Abbildung 2: Ein zu niedriger Grenzwert für die Schwärzung verleiht dem Bild einen Warhol-haften Charakter, erzeugt aber keinen Schattenriss.

Abbildung 5: Ein zu hoher Schwellenwert w&auml;hlt allerdings auch Teile des Hintergrunds aus.

Abbildung 5: Ein zu hoher Schwellenwert wählt allerdings auch Teile des Hintergrunds aus.

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&uuml;ltigen Parametern aufgerufen, druckt das Programm als Hilfestellung die erlaubten Parameter des Logging-Moduls aus und bricht ab.

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.

Abbildung 4: Glog schreibt abgesetzte Meldungen in eine Logdatei.

Abbildung 4: Glog schreibt abgesetzte Meldungen in eine Logdatei.

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!

Abbildung 6: Mit dem richtigen Wert leistet der Algorithmus ganze Arbeit.

Abbildung 6: Mit dem richtigen Wert leistet der Algorithmus ganze Arbeit.

Online PLUS

Im Screencast demonstriert Michael Schilli das Beispiel: https://www.linux-magazin.de/videos/

Der Autor

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 mailto:mschilli@perlmeister.com beantwortet er gerne Fragen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 3 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben