Richtungsweisend (Linux-Magazin, Oktober 2019)

Meine neue Lieblingssprache Go trumpft ja von Haus aus mit Image-Processing-Routinen auf und verarbeitet die Pixel eines Fotos genauso rasant wie ordinäre Zahlen oder Texte ([2]). Da ich öfter Pfeile in digitale Bilder male, teils aus Jux und Tollerei bei lustigen Schnappschüssen oder zu Illustrationszwecken, drängt sich die Frage auf, wie einfach das zu automatisieren ist. Bislang musste ich immer den Gimp hochfahren, mit dem Path-Tool einen Pfad auswählen, um dann auf diesen, mittels eines Arrow-Plugins, den ich irgendwo aus dem Internet heruntergeladen habe, einen Pfeil aufsetzen. Geht das nicht einfacher?

Eigentlich bevorzuge ich ja Kommandozeilentools, aber manchmal ist eine traditionelle grafische Oberfläche doch von Vorteil, wie zum Beispiel um in einem Foto eine Stelle auszuwählen, an der das Programm einen roten Pfeil einzeichnen soll. Der Pfeil in Abbildung 1 illustriert zum Beispiel, dass es sich um bei dem abgelichteten Maßband aus einer verstaubten Schublade um Krimskrams der Firma Netscape handelt. Jawohl, der Browserhersteller, bei dem ich während der 90er-Jahre des letzten Jahrhunderts ein paar Jahre gearbeitet habe, kurz nachdem ich aus München abgezischt, ins Silicon-Valley ausgebüchst und nie mehr wieder gekommen bin. Dieses Maßband ist mehr als 20 Jahre alt und mein Beitrag zur aktuellen Jubiläumsausgabe, denn damals, als eine junge Dame aus Netscapes Ergo-Abteilung es mir damals überreichte, um meinen Schreibtischstuhl auf eine rücken- und handsehnenschonende Höhe einzustellen, war das Linux-Magazin gerade mal im Krabbelalter.

Abbildung 1: Der Algorithmus hat an der vom User in der UI-Applikation ausgewählten Stelle einen Pfeil ins Bild gezeichnet.

Qual der Wahl

Für mausgesteuerte Eingaben auf grafischen Oberflächen hält Go etwa ein halbes Dutzend verschiedener GUI-Frameworks vor, die das schöne Buch von Andrew Williams [3] mit lohnenden Beispielen beschreibt, und alle laufen mehr oder weniger Cross-Plattform, also auf Linux, dem Mac und sogar Windows. Alles ausgehend von einer einzigen Codebasis, und eines der Frameworks soll sogar irgendwann auf Mobiltelefonen laufen, ein wahres Wunderwerk der Technik! Eine weitere Alternative für das heute gelöste Problem wäre übrigens die Electron-GUI gewesen, die mit JavaScript läuft und erst letztes Jahr in dieser Kolumne debütierte ([4]).

Listing 1: hello.go

    01 package main
    02 
    03 import "fyne.io/fyne/app"
    04 import "fyne.io/fyne/widget"
    05 
    06 func main() {
    07   app := app.New()
    08 
    09   win := app.NewWindow("Hello World")
    10   win.SetContent(widget.NewVBox(
    11     widget.NewLabel("Hello World"),
    12     widget.NewButton("Quit", func() {
    13       app.Quit()
    14     }),
    15   ))
    16 
    17   win.ShowAndRun()
    18 }

Buchautor Williams hat selbst auch ein GUI-Paket auf Go-Basis namens "Fyne" veröffentlicht ([5]), und das sah mir grafisch sehr ansprechend aus, dewegen setzte ich es für die Pfeilsoftware ein. Ein einfaches Hello-World-Programm in "Fyne" entsteht aus wenigen Zeilen Go-Code, wie Listing 1 illustriert, das ein Textlabel und einen Button untereinandersetzt und darauf wartet, dass der User mit der Maus den Exit-Knopf drückt. Wie immer in Go kompiliert sich Listing 1 mit dem Zweisatz

    go mod init hello
    go build

denn das mit go mod init erzeugte neue Go-Modul hello veranlasst den Compiler in der Build-Anweisung, alle noch nicht installierten Pakete ihren im Code angegebenen Github-Repositories zu holen und gleich mitzuverpacken. Heraus kommt ein Binary hello, das zum Laufen nichts weiter braucht, und nach erneutem Compilieren auf jedweger Zielplattform fast genau wie in Abbildung 2 erscheint. Damit Go bei der Compilation die richtigen Libraries unter Ubuntu findet, müssen folgende Pakete installiert sein:

    $ sudo apt-get install libgl1-mesa-dev xorg-dev

Auf dem Mac funktioniert das Ganze ohne jedes Zutun.

Abbildung 2: Das Hello-World-Programm mit der Fyne-GUI.

Listing 1 erzeugt hierzu erst eine App, dann das einzige in ihr enthaltene Fenster. Die Methode SetContent() packt in Zeile 10 ein "VBox"-Widget hinein das seinerseits zwei Widgets untereinander anordnet, ein Label mit der Aufschrift "Hello World" und einen Button, der mit "Quit" zum Abbruch des Programms einlädt. Die dem Button zugeordnete Klick-Aktion nimmt der Konstruktor NewButton() als Callback-Funktion entgegen. Letztere ruft in diesem Fall nur app.Quit() auf und faltet damit die UI zusammen. Nachdem UI-typisch alle Widgets und deren potentielle Interaktionen definiert sind, bleibt Zeile 17 nur noch, mit ShowAndRun() in die Eventschleife zu springen, die auf Aktionen wartet und derweil die UI kontinuierlich aufffrischt.

Heimwerker-Widget

Was nun die Pfeilsoftware anlangt, zeigt Listing 2 zeigt die UI-Applikation, die ein Fenster aufmacht und mit NewImageFromFile die vom User auf der Kommandozeile vorgegebene JPG-Datei zum späteren Hineinladen definiert. Leider verschweigt Fyne die Dimensionen des Fotos, und deswegen lädt die ab Zeile 62 definierte Funktion imgDim() die JPG-Datei einfach nochmal von der Platte, dekodiert die Bilddaten mit Decode() und holt dann mit Bounds() die Koordinaten der linken oberen und der rechten unteren Ecke des Bildes. Diese x/y-Werte wiederum wandelt Zeile 76 mittels einfacher Subtraktion in Breite und Höhe des Bildes um und gibt das Ergebnispaar an das aufrufende Hauptprogramm zurück. Dieses passt die Größe des Applikationsfensters so an, dass das Bild genau hineinpasst. Wie vorher bei "Hello World" startet auch in Listng 2 mit ShowAndRun() in Zeile 59 die Eventschleife, die Useraktionen wie in diesem Fall Mausklicks an einer Stelle im Bild, verarbeitet und entsprechende Aktionen einleitet.

In vorliegenden Fall sollte das angezeigte Bild-Widget mitbekommen, wo der User mit der Maus hingeklickt hat, um einen Pfeil dorthin zu malen. Allerdings ist das dargestellte Bild in Fyne kein Widget, das Mausklicks verarbeiten könnte. So behilft sich Listing 2 mit einem Trick, den mir ein hilfsbereiter Teilnehmer im Slack-Kanal #fyne verriet: Zeile 15 definiert eine Struktur, die ihre Widget-Eigenschaften wegen des Eintrags widget.Box vom Allerwelts-Widget Box aus dem Fyne-Katalog erbt, inklusive der Mausverarbeitung. Zusätzlich speichert die Struktur im Element image das Bildobjekt und unter filename den Namen der geladenen JPG-Datei für später. Diesem hausgemachte Widget nun ordnen die Zeilen 21 und 28 nun die Methoden Tapped und TappedSecondary zu, die Fyne bei verschiedenen Mausklicks anspringt.

Listing 2: picker.go

    01 package main
    02 
    03 import (
    04   "flag"
    05   "fmt"
    06   "fyne.io/fyne"
    07   "fyne.io/fyne/app"
    08   "fyne.io/fyne/canvas"
    09   "fyne.io/fyne/widget"
    10   "image"
    11   _ "image/jpeg"
    12   _ "image/png"
    13   "log"
    14   "os"
    15 )
    16 
    17 type clickImage struct {
    18   widget.Box
    19   image *canvas.Image
    20   filename    string
    21 }
    22 
    23 func (ci *clickImage) Tapped(
    24     pe *fyne.PointEvent) {
    25   imgAddArrow(ci.filename,
    26             pe.Position.X, pe.Position.Y)
    27   canvas.Refresh(ci)
    28 }
    29 
    30 func (ci *clickImage) TappedSecondary(
    31     *fyne.PointEvent) {
    32   // empty, but required for Tapped to work
    33 }
    34 
    35 func main() {
    36   flag.Parse()
    37   flag.Usage = func() {
    38     fmt.Printf("usage: %s imgfile\n",
    39       os.Args[0])
    40   }
    41 
    42   if len(flag.Args()) != 1 {
    43     flag.Usage()
    44     os.Exit(2)
    45   }
    46 
    47   file := flag.Arg(0)
    48 
    49   os.Setenv("FYNE_SCALE", ".25")
    50   win := app.New().NewWindow("Pick Arrow")
    51   img := canvas.NewImageFromFile(file)
    52 
    53   width, height := imgDim(file)
    54   clickimg := clickImage{image: img,
    55                         filename: file}
    56   clickimg.image.SetMinSize(
    57       fyne.NewSize(width, height))
    58   clickimg.Append(clickimg.image)
    59   win.SetContent(&clickimg)
    60   win.Resize(fyne.NewSize(width, height))
    61   win.ShowAndRun()
    62 }
    63 
    64 func imgDim(file string) (int, int) {
    65   src, err := os.Open(file)
    66   if err != nil {
    67     log.Fatalf("Can't read %s", file)
    68   }
    69   defer src.Close()
    70 
    71   jimg, _, err := image.Decode(src)
    72   if err != nil {
    73     log.Fatalf("Can't decode %s: %s",
    74       file, err)
    75   }
    76 
    77   bounds := jimg.Bounds()
    78   return (bounds.Max.X - bounds.Min.X),
    79          (bounds.Max.Y - bounds.Min.Y)
    80 }

Erstaunlicherweise besteht Fyne darauf, dass das Widget nicht nur eine Methode für den Tapped-Event definiert, auch sogenannte "secondary pointer events" wie (Multi-Touch), die im vorliegenden Widget gar nicht vorkommen, müssen mit einem (einfach leer gelassenen) TappedSecondary abgehandelt werden, sonst funktioniert ersteres ebenfalls nicht. Listing 2 springt für den Tapped-Event den in Zeile 23 definierten Callback an, der die Funktion imgAddArrow() aus Listing 3 anspringt, den Pfeil ins Bild malt und die so modifizierte Datei sichert. Ein folgender Refresh() auf das Widget lädt das modifierte Foto von neuem von der Platte und zeigt es an. Der User bemerkt davon nur, dass er auf einen Punkt im Bild geklickt hat und dort nun wie durch Zauberei ein horizontaler Pfeil erscheint.

Zeile 56 hängt das Bild-Objekt mittels Append() an die von dem neuen hausgemachten Widget clickImage verwalteten Objekte an. Dies geschieht mit Hilfe des in der clickImage-Struktur in Zeile 17 gespeicherten Bildobjekts, das im Element image liegt.

Fyne ist dafür konzipiert, sich wie ein T-1000-Terminator dynamisch an das gerade verwendete Display anzupassen. Das widerspricht den Anforderungen, die die Pfeilsoftware stellt etwas, denn sich möchte die das Fenster immer so darstellen, dass die Bildpixel 1:1 erscheinen, damit sich Mausklick-Koordinaten einfach in Bildpixel umrechnen lassen. Entfiele der Aufruf von SetMinSize() in Zeile 54, ließe Fyne das Bild bis zur Unkenntlichkeit zusammenschrumpfen. Zeile 58 sorgt dafür, dass das Applikationsfenster genau die Ausmaße des Bildes annimmt. So wird nichts gestaucht und das Verhältnis von Breite und Höhe bleibt insgesamt erhalten. Damit auch ein relativ großes Bild, zum Beispiel von einem modernen Mobiltelefon, auf einem PC-Bildschirm Platz hat, verkleinert Zeile 47 mit der Environment-Variablen FYNE_SCALE das Applikationsfenster mit 0.25 noch um den Faktor vier.

Anatomie eines Pfeils

Wie malt der Algorithmus nun den Pfeil? Der schlanke Anstrich lässt sich noch einfach als horizontales Rechteck malen. Die nach rechts weisende Spitze des Pfeils hingegen ist ein Dreieck mit den Koordinaten T1, T2 und T3 (jeweils als X- und Y-Werte angegeben), das es auch noch farbig auszufüllen gilt (Abbildung 3). Das ist gar nicht mal so trivial, wie man auf den ersten Blick denken würde. Zerlegt man die Dreiecksform jedoch in schmale vertikale Rechtecke absteigender Höhe, wird ein Schuh daraus. Diese Rechteckshöhe ist bei X-Werten auf der Höhe der Koordinaten T1 und T2 maximal, und nimmt linear ab, bis sie bei T3 bei Null anlangt.

Abbildung 3: Simpler Algorithmus für einen gefüllten Pfeil

Die Funktion imgAddArrow() ab Zeile 12 in Listing 3 implementiert die notwendigen Schritte. Zeile 19 dekodiert das geladene JPG-Bild und ruft die interne Funktion arrowDraw() auf, die den Pfeil einzeichnet, um dann das Bild wieder ins JPG-Format zu konvertieren und unter dem gleichen Namen auf die Platte zurückzuspeichern.

Listing 3: arrow-draw.go

    01 package main
    02 
    03 import (
    04   "image"
    05   "image/color"
    06   "image/draw"
    07   "image/jpeg"
    08   "log"
    09   "os"
    10 )
    11 
    12 func imgAddArrow(file string, x, y int) {
    13   src, err := os.Open(file)
    14   if err != nil {
    15     log.Fatalf("Can't read %s", file)
    16   }
    17   defer src.Close()
    18 
    19   jimg, err := jpeg.Decode(src)
    20   if err != nil {
    21     log.Fatalf("Can't decode %s: %s",
    22       file, err)
    23   }
    24 
    25   bounds := jimg.Bounds()
    26   dimg := image.NewRGBA(bounds)
    27   draw.Draw(dimg, dimg.Bounds(), jimg,
    28     bounds.Min, draw.Src)
    29   arrowDraw(dimg, image.Point{X: x, Y: y})
    30 
    31   dstFileName := file
    32   dstFile, err := os.OpenFile(dstFileName,
    33     os.O_RDWR|os.O_CREATE, 0644)
    34   if err != nil {
    35     log.Fatalf("Can't open output")
    36   }
    37 
    38   jpeg.Encode(dstFile, dimg,
    39     &jpeg.Options{Quality: 80})
    40   dstFile.Close()
    41 }
    42 
    43 func arrowDraw(dimg draw.Image,
    44                start image.Point) {
    45   length := 300
    46   width := 20
    47   tiplength := 80
    48   tipwidth := 90
    49 
    50   stem := image.Rectangle{
    51     Min: image.Point{X: start.X,
    52                      Y: start.Y},
    53     Max: image.Point{X: start.X + length,
    54                      Y: start.Y + width},
    55   }
    56   rectDraw(dimg, stem)
    57 
    58   triDraw(dimg,
    59     image.Point{X: start.X + length,
    60       Y: start.Y + width/2 - tipwidth/2},
    61     image.Point{X: start.X + length,
    62       Y: start.Y + width/2 + tipwidth/2},
    63     image.Point{
    64       X: start.X + length + tiplength,
    65       Y: start.Y + tipwidth/2},
    66   )
    67 }
    68 
    69 func rectDraw(dimg draw.Image,
    70               bounds image.Rectangle) {
    71   red := color.RGBA{255, 0, 0, 255}
    72   draw.Draw(dimg, bounds,
    73     &image.Uniform{red},
    74     bounds.Min, draw.Src)
    75 }
    76 
    77 func triDraw(dimg draw.Image,
    78              t1, t2, t3 image.Point) {
    79   ymiddle := t1.Y + (t2.Y-t1.Y)/2
    80 
    81   for x := t1.X; x < t3.X; x++ {
    82     height := int(float64(t2.Y-t1.Y) *
    83       (float64(t3.X-x) /
    84        float64(t3.X-t2.X)))
    85     rect := image.Rectangle{
    86       Min: image.Point{X: x,
    87         Y: ymiddle - height/2},
    88       Max: image.Point{X: x + 1,
    89         Y: ymiddle + height/2}}
    90 
    91     rectDraw(dimg, rect)
    92   }
    93 }

Der Anstrich des Pfeils besteht aus einem schlanken horizontalen Rechteck und einem seitlich liegenden Dreieck an dessen rechten Ende. Die Spitze zeigt nach rechts. Die numerischen Werte der Zeilen 45 bis 48 definieren Form und Größe des Pfeils. Grafik-Libraries wie image/draw aus dem Go-Core-Paket können Rechtecke jedweger Art malen, und füllen sie sogar farblich ansprechend aus. Dabei spezifizieren sie die Eckkoordinaten nicht etwa als vier x/y-Werte, sondern, wie die Methode Bounds() offenbart, mit nur zweien: dem Min-, und dem Max-Punkt, also die linke obere Ecke und die rechte untere, da X-Koordinaten von links nach rechts und Y-Koordinaten von oben nach unten laufen. Beide Punkte liegen wiederum als x/y-Paare vor. Die Logik zwischen den Zeilen 51 und 54 berechnet die Bounds()-Werte des gewünschten Rechtecks aus den x/y-Koordinaten eines Startpunkts, sowie der gewünschten Länge und Dicke des Pfeil-Anstrichs.

Rechteck leicht, Dreieck schwer

Die Funktion rectDraw() ab Zeile 69 malt mit Hilfe von Gos images/draw-Bibliothek das hereingereichte Rechteck in blendendem Rot ins Bild. Die dreieckige Spitze des Pfeils übernimmt triDraw() ab Zeile 77. Neben dem Handle auf das Draw-Image akzeptiert sie die Koordinaten der Punkte T1, T2 und T3 aus Abbildung 3. Die Formel für die Höhe des Dreiecks an unterschiedlichen X-Werten bestimmt Zeile 82. Sie dividiert den Abstand zwischen dem aktuellen x-Wert und dem Endpunkt T3 durch den Gesamt-X-Abstand zwischen T2 und T3, meldet also nahe T2 die maximale Höhe des Dreiecks und nahe T3 eine Höhe von null Pixeln. Diese vielen Dreiecksteile bestehen ihrerseits wiederum aus schmalen Rechtecken mit einer Breite von einem Pixel. Das Ganze sieht für so einen einfachen Algorithmus überraschend überzeugend aus.

Listing 2 und 3 teilen das Programm der Übersichtlichkeit halber in zwei Teile auf, jedoch definieren beide das Paket "main". Deswegen erzeugt

    go build picker.go arrow-draw.go

ein Binary picker, das die UI mit dem angegebenen Bild hochfährt, den User einen Punkt mit der Maus auswählen lässt und dort einen Pfeil einzeichnet. Damit wäre mal wieder bewiesen: Alles keine Hexerei, alle Algorithmen kochen letztendlich mit Wasser. Bis zur nächsten Ausgabe, Glückwunsch ans Linux-Magazin zum 25sten!

Infos

[1]

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

[2]

Michael Schilli, "Schattenwelt", Linux-Magazin, 02/2019, https://www.linux-magazin.de/ausgaben/2019/02/snapshot-11/

[3]

Andrew Williams, "Hands-On GUI Application Development in Go", Packt Publishing 2019

[4]

Michael Schilli, "Automatisch Ausmustern", Linux-Magazin 08/2018, Seite XXX, https://www.linux-magazin.de/ausgaben/2018/08/snapshot-5/

[5]

"Your beautiful new open source desktop", https://fyne.io

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