Aus Linux-Magazin 10/2019

Go-Applikation nutzt Image-Processing-Routinen

© Przemyslaw Koch, 123RF

Mit dem Fyne-Framework bietet Go eine einfach zu handhabende grafische Oberfläche für alle gängigen Plattformen. Eine Beispielapplikation hilft dabei, Pfeile zu Illustrationszwecken auf Fotos zu platzieren.

Meine neue Lieblingssprache Go trumpft ja von Haus aus mit ihren 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, mit einem Arrow-Plugin, das ich irgendwo aus dem Internet heruntergeladen habe, einen Pfeil aufsetzen. Aber geht das nicht viel einfacher?

GUI bevorzugt

Eigentlich bevorzuge ich ja Kommandozeilentools, aber manchmal ist eine traditionelle grafische Oberfläche doch von Vorteil, etwa 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 bei dem abgelichteten Maßband aus einer verstaubten Schublade um Krimskrams der Firma Netscape handelt. Jawohl, des Browserherstellers, bei dem ich während der 90er Jahre des vorigen Jahrhunderts ein paar Jahre gearbeitet habe, kurz nachdem ich aus München abgezischt, ins Silicon Valley ausgebüxt und nie mehr wieder zurückgekommen bin.

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

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

Dieses Maßband ist also mehr als 20 Jahre alt und sozusagen mein antiquarischer Beitrag zur aktuellen Jubiläumsausgabe des Linux-Magazins. Denn als eine junge Dame aus Netscapes hauseigener Ergo-Abteilung es mir damals überreichte, damit ich meinen Schreibtischstuhl auf eine Rücken und Handsehnen schonende Höhe einstellen konnte, war das Linux-Magazin gerade mal im Krabbelalter angelangt.

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 das Electron-GUI gewesen, das mit Javascript läuft und erst letztes Jahr in dieser Kolumne debütierte [4].

Buchautor Williams hat selbst auch ein GUI-Paket auf Go-Basis namens Fyne veröffentlicht [5], und das sah mir grafisch sehr ansprechend aus, deswegen 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. Es setzt ein Textlabel und einen Button untereinander und wartet dann darauf, dass der User mit der Maus den Exit-Knopf drückt. Wie immer in Go kompiliert sich Listing 1 mit dem Zweisatz:

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 }

go mod init hello
go build

Das mit »go mod init« erzeugte neue Go-Modul »hello« veranlasst den Compiler in der Build-Anweisung, alle noch nicht installierten Pakete aus 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 Kompilieren auf jeder Zielplattform fast genau gleich erscheint. Damit Go bei der Kompilation auch die richtigen Libraries unter Ubuntu vorfindet, müssen folgende Pakete installiert sein:

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

Auf dem Mac funktioniert das Ganze ohne weiteres Zutun.

Hello World

Das 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, erst ein Label mit der Aufschrift “Hello World”, dann einen Button, der mit »Quit« zum Abbruch des Programms einlädt.

Die dem Button zugeordnete Klick-Aktion nimmt der Konstruktor »NewButton()« dann als Callback-Funktion entgegen. Letztere ruft in diesem Fall einfach nur »app.Quit()« auf und faltet damit das Nutzerinterface zusammen.

Nachdem nun Interface-typisch alle Widgets und deren potenzielle Interaktionen definiert sind, bleibt es Zeile 17 vorbehalten, mit einem »ShowAndRun()« in die Eventschleife zu springen, die auf Aktionen wartet und derweil das UI kontinuierlich auffrischt.

Was nun die Pfeilsoftware anbelangt, zeigt Listing 2 die UI-Applikation, die ein Fenster aufmacht und mit »NewImageFromFile()« die vom User auf der Kommandozeile vorgegebene JPG-Datei zum späteren Hineinladen definiert.

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

Heimwerker- Widget

Aber Fyne verschweigt die Dimensionen des Fotos, daher lädt die ab Zeile 62 definierte Funktion »imgDim()« die JPG-Datei einfach nochmals von der Platte, dekodiert die Bilddaten mit »Decode()« und holt 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. Dies passt die Größe des Applikationsfensters so an, dass das Bild genau hineinpasst.

Wie vorher bei “Hello World” startet auch in Listing 2 mit »ShowAndRun()« in Zeile 59 die Eventschleife, die User-Aktionen verarbeitet – im vorliegenden Fall Mausklicks an einer Stelle im Bild – und entsprechende Aktionen einleitet.

Trickserei

In dem Beispiel 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 hausgemachten Widget ordnen die Zeilen 21 und 28 nun die Methoden »Tapped()« und »TappedSecondary()« zu, die Fyne bei verschiedenen Mausklicks anspringt.

Erstaunlicherweise besteht Fyne darauf, dass das Widget nicht nur eine Methode für das »Tapped«-Event definiert, sondern auch so genannte 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 das »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 modifizierte 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 Bildobjekt mittels »Append()« an die von dem neuen hausgemachten Widget »clickImage« verwalteten Objekte an. Das geschieht mit Hilfe des in der »clickImage«-Struktur in Zeile 17 gespeicherten Bildobjekts, das im Element »image« liegt.

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 }

Fyne ist dafür konzipiert, sich wie ein T-1000-Terminator dynamisch ans gerade verwendete Display anzupassen. Das widerspricht den Anforderungen, die die Pfeilsoftware stellt. Denn ich möchte 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, das Verhältnis von Breite und Höhe bleibt insgesamt erhalten.

Damit auch ein relativ großes Bild, etwa 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 4.

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 2). Das ist gar nicht mal so trivial, wie man auf den ersten Blick denken würde. Zerlege ich 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 0 anlangt.

Abbildung 2: Simpler Algorithmus f&uuml;r einen gef&uuml;llten Pfeil.

Abbildung 2: 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.

Der Anstrich des Pfeils besteht aus einem schlanken horizontalen Rechteck und einem seitlich liegenden Dreieck an dessem 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 jedweder 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 unten nach oben 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 zeichnet mit Hilfe von Go’s »images/draw«-Bibliothek das hereingereichte Rechteck in blendendem Rot ins Bild ein. 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«. 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.

Die Listings 2 und 3 teilen das Programm der Übersichtlichkeit halber in zwei Teile auf, jedoch definieren beide das Paket »main«. Daher erzeugt

go build picker.go arrow-draw.go

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

Online PLUS

Im Screencast demonstriert Michael Schilli das Programmierbeispiel: http://www.linux-magazin.de/videos/

Infos

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

  2. Michael Schilli, “Schattenwelt”: Linux-Magazin, 02/19, Seite 90, 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/18, Seite 88, https://www.linux-magazin.de/ausgaben/2018/08/snapshot-5/

  5. GUI-Paket Fyne: https://fyne.io

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: 4 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