Aus Linux-Magazin 01/2025

GUI-Tool Photogrep filtert Fotos im Stil von Grep

© jotavisual / 123RF.com

So wie das Tool Grep Dateinamen aus einer Pipe filtert, soll das mit Go und Fyne gebaute GUI-Tool Photogrep Fotos filtern.

Zu den mächtigsten und vielfältigsten Funktionen der Unix-Kommandozeile zählt das Pipe-Konzept. Eine Pipe pumpt die Ausgabe eines Werkzeugs in die Eingabe des nächsten, das wiederum Teile ausfiltert, die dann durchs nächste Rohrstück zu einem weiteren Verarbeitungsschritt wandern. Dass dieses Konzept niemals den Nobelpreis erhalten hat, ist schlichtweg ein Skandal.

Das Kommandozeilenwerkzeug Grep spielt im Pipe-Mechanismus oft eine tragende Rolle, denn es filtert unerwünschte Einträge zwischen zwei verbundenen Rohrenden aus: Die Guten ins Töpfchen, die schlechten ins Kröpfchen. Geht es darum, Strings zu filtern, helfen Textersatz oder reguläre Ausdrücke, doch nach welchen Kriterien soll man Fotos aussortieren? Welche sind schön, welche unscharf, farbstichig oder einfach schlecht komponiert?

Solche Entscheidungen trifft am besten (noch) der Mensch. Das im Folgenden vorgestellte Werkzeug Photogrep bringt die Fotos dazu aus der Stdin-Pipe in ein Bildschirmfenster und lässt Sie mit der Maus eines oder mehrere auswählen. Sobald Sie den Submit-Button drücken, schreibt Photogrep die Namen der auserwählten Bilder in die Standardausgabe – Grep mit Fotos eben.

Abbildung 1 zeigt Photogrep in Aktion, mit einer Pipe, deren erstes Kommando alle Fotodateien im Verzeichnis »photos/« auflistet. Das Tool schnappt sich die Bilder über die Standardeingabe und zeigt sie als Thumbnails in einer dreispaltigen Matrix an. Sie dürfen nun auf die Bildchen klicken, um sie zu verlesen. Bei jedem ausgewählten Foto erscheint ein blaues Kästchen mit weißem Haken.

Abbildung 1: Das Tool Photogrep lässt Sie Bilder auswählen.

Abbildung 1: Das Tool Photogrep lässt Sie Bilder auswählen.

Um Details eines der Fotos zu inspizieren, klicken Sie mit der rechten Maustaste auf das Bildchen. Daraufhin erscheint ein neues Inspektorfenster mit der Detailansicht (Abbildung 2), die sich wie eine Landkarte auf Google Maps bei gedrückter Maustaste herumschubsen lässt. Die Ansicht verschwindet nach einem Mausklick auf das Schließsymbol des Fensters.

Abbildung 2: Ein Rechtsklick auf ein Foto zeigt einen beweglichen Ausschnitt aus dem vergrößerten Originalbild.

Abbildung 2: Ein Rechtsklick auf ein Foto zeigt einen beweglichen Ausschnitt aus dem vergrößerten Originalbild.

Haben Sie die gewünschten Fotos ausgewählt, schließt ein Klick auf Submit im Hauptfenster das Tool. Kurz vor Programmende gibt der Fotogrepper noch die Pfade der ausgewählten Bilder auf seiner Standardausgabe aus, von wo das nächste Rohrstück der Pipe sie einsaugt. In Abbildung 3 ist das »xargs echo«, aber es könnten beliebige Kommandos folgen, die Dateien automatisch bearbeiten oder irgendwohin kopieren.

Abbildung 3: Ein Klick auf <span class="ui-element">Submit</span> schlie&szlig;t das Tool und gibt die ausgew&auml;hlten Dateien aus.

Abbildung 3: Ein Klick auf Submit schließt das Tool und gibt die ausgewählten Dateien aus.

Zweierlei Eingaben

Eine GUI auf den Desktop zu zaubern, mit Knöpfchen zum Drücken und Fotos zum Bestaunen, das ist mit Go ein klarer Fall für das Framework Fyne. Der Code lässt sich damit für allerlei Plattformen kompilieren, nicht nur für Windows und Linux-Derivate, sondern auch für MacOS, Android und iOS.

Listing 1 zeigt das Hauptprogramm »main«, das zuerst einmal die Eingabe untersucht. Kommen die Dateien per Pipe zeilenweise über die Standardeingabe herein, oder hat der User die Dateipfade dem Binary als Argumente übergeben? Genau wie Grep verarbeitet Photogrep beide Eingabeformen.

Listing 1

photogrep.go

package main
import (
  "bufio"
  "flag"
  "fmt"
  "os"
  "strings"
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/app"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
)
func main() {
  flag.Parse()
  var fileNames []string
  if flag.NArg() != 0 {
    fileNames = flag.Args()
  } else {
    scanner := bufio.NewScanner(os.Stdin)
    for scanner.Scan() {
      fileNames = append(fileNames, strings.TrimSpace(scanner.Text()))
    }
  }
  app := app.New()
  w := app.NewWindow("photogrep")
  cs := NewCsheet(app)
  grid := cs.MakeGrid(fileNames)
  grid.SetMinSize(fyne.NewSize(800, 600))
  submitButton := widget.NewButton("Submit", func() {
    for fileName := range cs.selected {
      fmt.Println(fileName)
    }
    w.Close()
  })
  content := container.NewVBox(grid, submitButton)
  w.SetContent(content)
  w.ShowAndRun()
}

Anschließend öffnet Zeile 24 mit »app.New()« eine neue App des Fyne-Frameworks, danach kümmert sich »NewWindow()« um das zugehörige GUI-Fenster. Die Fotos im oberen Teil sind Teil eines Contact Sheets (Kontaktabzugs), wie das in der analogen Fotografie heißt. Dessen Konstruktor »NewCsheet()« liefert in Zeile 26 eine Referenz auf das neue Widget.

Der Aufruf der Funktion »MakeGrid()« mit den auszuwählenden Dateipfaden als Argument in Zeile 27 zeigt die Fotos in einer dreispaltigen Matrix in einem scrollenden Widget an. Das Fenster muss schließlich auf dem räumlich begrenzten Desktop Platz finden und hat deswegen eine fixe Größe. Falls mehr Fotos hereinkommen, als das Kontaktabzug-Widget darstellen kann, fügt der Window-Manager eine vertikale Bildlaufleiste hinzu, die sich verschieben lässt, um weiter unten liegende Fotos zu sichten.

Dass Zeile 28 die Minimalgröße des Kontaktabzug-Widgets auf eine fixe Größe setzt, ist essenziell. Sonst würde das scrollende Guckloch auf die geringstmögliche Größe schrumpfen. Das löst in Fyne oft Verwirrung aus, wenn plötzlich ein 0 mal 0 Pixel großes Widget unsichtbar über dem Desktop schwebt.

Das Hauptfenster der Applikation besteht nun in erster Ebene aus zwei Komponenten: der Bilderliste oben und der Schaltfläche Submit unten. Letztere erzeugt der Konstruktor »NewButton()« in Zeile 29 und weist ihr eine Funktion als Callback zu. Die listet mit »cs.selected()« alle ausgewählten Fotos des Kontaktabzugs auf, iteriert mit einer For-Schleife über die Pfade und pumpt sie zeilenweise mit »Println()« in die Standardausgabe des Tools.

Kontaktabzug

Der Konstruktor des Kontaktabzug-Widgets, »NewCsheet()«, steht ab Zeile 15 in Listing 2. Er gibt nur einen Pointer auf eine Struktur zurück, in der er eine Referenz auf die App ablegt, damit der Code später ein auf höchster Ebene liegendes Inspektor-Fenster öffnen kann. Außerdem liegt im Feld »selected« eine Hashmap, die zu jedem ausgewählten Foto dem Dateipfad einen wahren »bool«-Eintrag zuweist.

Listing 2

csheet.go

package main
import (
  "github.com/disintegration/imaging"
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  con "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
)
const ThumbSize = float32(200)
const ViewSize = float32(800)
type csheet struct {
  selected map[string]bool
  app      fyne.App
}
func NewCsheet(app fyne.App) *csheet {
  return &csheet{
    selected: map[string]bool{},
    app:      app,
  }
}
func (cs *csheet) MakeGrid(fileNames []string) *con.Scroll {
  grid := con.NewGridWithColumns(3)
  scroll := con.NewScroll(grid)
  go func() {
    for _, fileName := range fileNames {
      pick := cs.newPick(fileName)
      grid.Add(pick)
    }
  }()
  return scroll
}
func (cs *csheet) newPick(fileName string) *fyne.Container {
  img, err := imaging.Open(fileName, imaging.AutoOrientation(true))
  if err != nil {
    panic(err)
  }
  thumbnail := imaging.Thumbnail(img, int(ThumbSize), int(ThumbSize), imaging.Lanczos)
  image := canvas.NewImageFromImage(thumbnail)
  image.FillMode = canvas.ImageFillContain
  image.SetMinSize(fyne.NewSize(ThumbSize, ThumbSize))
  check := widget.NewCheck("", func(checked bool) {
    if checked {
      cs.selected[fileName] = true
    } else {
      delete(cs.selected, fileName)
    }
  })
  ci := newClickImage(image, func() {
    // toggle
    check.SetChecked(!check.Checked)
  },
    func() {
      fullView := cs.app.NewWindow("Full Image View")
      img, err := imaging.Open(fileName, imaging.AutoOrientation(true))
      if err != nil {
        panic(err)
      }
      fullImage := canvas.NewImageFromImage(img)
      fullImage.FillMode = canvas.ImageFillOriginal
      inspector := NewPan(fullImage)
      fullView.SetContent(inspector.scroll)
      fullView.Resize(fyne.NewSize(ViewSize, ViewSize))
      inspector.Center()
      fullView.Show()
    })
  image.Refresh()
  return con.NewVBox(ci, check)
}

Ein Dutzend Smartphone-Fotos mit jeweils 4000 mal 3000 Pixeln einzulesen und als Thumbnails darzustellen, dauert selbst auf einem schnellen Rechner ein paar Sekunden. »MakeGrid()« macht deswegen in Zeile 24 mit »go func« eine parallel laufende Goroutine auf. Sie liest die darzustellenden Fotos in aller Ruhe ein, skaliert sie herunter und malt sie in die GUI. Derweil kehrt der Haupt-Thread in Zeile 30 bereits zum Hauptprogramm zurück, das die GUI schon mal mit leerem Kontaktabzug auf den Schirm bringt.

Aktiv bleiben

Falls es ein ungeschriebenes Gesetz für ansprechende GUI-Applikationen gibt, dann, dass sie niemals tot erscheinen dürfen. Der User muss immer in der Lage sein, mittels Mausklicks Aktionen auszulösen. Hängt eine App, weil die CPU andere Dinge erledigt, als Events zu bearbeiten, folgt die Ein-Stern-Bewertung im App Store auf dem Fuß. Da die Go-Routine weiterläuft, auch wenn »MakeGrid()« bereits beendet ist, fügt sie mit »grid.Add()« nach und nach fertig bearbeitete Dateien in das Sichtfenster ein. Der Event Handler der GUI reagiert währenddessen ganz normal weiter auf Benutzeranfragen.

Ein Smartphone-Foto hat schnell einmal 25 MByte, und es wäre extrem verschwenderisch, solch ein Riesenbild in der GUI in einem kleinen Guckloch von 200 mal 200 Pixeln darzustellen. Die CPU würde das Bild in voller Auflösung in ein Raster verwandeln und an die GPU des Rechners (sofern vorhanden) übermitteln. Die müsste es dann erst einmal auf das darzustellende Maß herunterskalieren. Das zwingt den schnellsten Rechner in die Knie.

Stattdessen zieht Listing 2 das Paket imaging von Github herein, das mit der Funktion »Thumbnail()« den leistungsfähigen Lanczos-Algorithmus anbietet, um Bilder zu verkleinern. Die Fyne-Funktion »NewImageFromImage()« wandelt die Pixel des Thumbnails dann flink ins GUI-Format um. Mit »newClickImage()« in Zeile 48 entsteht daraus ein klickbares Bild. Die Funktion nimmt zwei Callbacks entgegen. Sie definieren, was passiert, wenn der User die linke oder die rechte Maustaste klickt, während der Zeiger über dem Bild schwebt.

Bequemer geht es nicht

Denkt man an die ersten Desktop-Designs zurück, war deren Bedienung mit der Maus verglichen mit dem intuitiven Tippen und Wischen auf heutigen Tablets oder Smartphones geradezu umständlich. Die Checkbox unter einem Foto mit der Maus anzusteuern, nur um sie anzuklicken, fühlt sich nach 1995 an. Darum erlaubt es Zeile 50 in Listing 2, das Bild einfach per Linksklick anzuknipsen, um die Checkbox für die Auswahl zu aktivieren oder deaktivieren.

Der zweite Callback ab Zeile 52 definiert die Reaktion auf einen rechten Mausklick über einem Foto. Dabei öffnet Zeile 53 ein neues Fenster, und »Open()« aus der Imaging-Bibliothek zeigt mit gesetzter Option »AutoOrientation« das Bild in korrigierter Orientierung. Viele Smartphones speichern ja Bilder mit gesetztem EXIF-Header rotiert ab, was diverse Applikationen ignorieren und das Bild falsch gedreht darstellen. Die 12 Millionen Pixel der vollen Auflösung schickt Zeile 60 anschließend an das mit »NewPan()« initiierte Inspektor-Fenster.

Zum zügigen Scrollen durch ein übergroßes Foto offeriert Fyne mit »container.Scroll« Bereiche mit seitlichen Rollbalken. Doch die mit dem Mauszeiger zu suchen, anzuklicken und dann zu verschieben, geht im Jahr 2024 beim besten Willen nicht mehr. Stattdessen erwarten User heute, dass sie das Foto wie eine Google-Maps-Landkarte mit der Maus im flexiblen Rahmen herumschubsen können, das nennt sich Panning. Die zugehörige Maus-Reaktion heißt neuhochdeutsch draggen (ziehen, schleifen).

Zunächst aber zu den klickbaren Fotos im Kontaktabzug. Fyne definiert eine Reihe von primitiven grafischen Elementen wie »canvas.Image«, die aus Performance-Gründen keine Mausklicks verarbeiten. Wenn Sie aber ein etwas aus dem Rahmen fallendes Widget brauchen, zum Beispiel ein klickbares Foto, können Sie das wie in Listing 3 leicht erledigen. Dazu dient ein auf »widget.BaseWidget« (Zeile 9) aufbauendes Konstrukt, das einen Pointer auf das darzustellende Foto enthält. Außerdem definiert es die Callbacks »cbleft()« und »cbright()« für Aktionen mit der linken und rechten Maustaste.

Listing 3

clickimg.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/widget"
)
type clickImage struct {
  widget.BaseWidget
  image   *canvas.Image
  cbleft  func()
  cbright func()
}
func newClickImage(img *canvas.Image, cbleft func(), cbright func()) *clickImage {
  ci := &clickImage{}
  ci.ExtendBaseWidget(ci)
  ci.image = img
  ci.cbleft = cbleft
  ci.cbright = cbright
  return ci
}
func (t *clickImage) CreateRenderer() fyne.WidgetRenderer {
  return widget.NewSimpleRenderer(t.image)
}
func (t *clickImage) Tapped(_ *fyne.PointEvent) {
  t.cbleft()
}
func (t *clickImage) TappedSecondary(_ *fyne.PointEvent) {
  t.cbright()
}

Linksklick, Rechtsklick, marsch!

Fyne hat sich auf die Fahne geschrieben, nicht nur GUIs auf dem Desktop zu steuern, sondern auch Apps auf mobilen Endgeräten. Deshalb ist es gar nicht so einfach zu bestimmen, was nach einem Links- oder Rechtsklick mit der linken oder rechten Maustaste passiert. Auf einem Mobiltelefon gibt es bekanntlich keine Maus, sondern der User tippt, hält oder wischt. Fyne kategorisiert die ersten beiden Events als »Tapped()« und »TappedSecondary()«. Listing 3 definiert für beide einen Callback, die der Aufrufer später der exportierten Funktion »newClickImage()« mitgibt.

Für das komplette Custom-Widget fehlt noch die Anweisung, wie denn das Widget darzustellen ist. Zeile 22 lässt dazu die im Interface vorgeschriebene Funktion »CreateRenderer()« einfach den »NewSimpleRenderer« der Fyne-Library für Images zurückgeben. Im Inspektorfenster, das nach rechten Mausklicks hochschnellt, können Sie mit Maus-Drags wie in Google Maps durch die verschiedenen Bereiche eines hochauflösenden Bilds fahren. Dazu erzeugt Listing 4 ein weiteres Custom-Widget »Pan« und zieht dabei alle Register.

Listing 4

pan.go

package main
import (
  "fyne.io/fyne/v2"
  "fyne.io/fyne/v2/canvas"
  "fyne.io/fyne/v2/container"
  "fyne.io/fyne/v2/widget"
  "image/color"
)
type Pan struct {
  widget.BaseWidget
  image        *canvas.Image
  scroll       *container.Scroll
  scrollOffset fyne.Position
}
func NewPan(img *canvas.Image) *Pan {
  di := &Pan{image: img}
  di.ExtendBaseWidget(di)
  scrollContent := container.NewMax(di)
  scroll := container.NewScroll(scrollContent)
  di.scroll = scroll
  return di
}
func (di *Pan) CreateRenderer() fyne.WidgetRenderer {
  return &panRenderer{di}
}
func (di *Pan) Dragged(e *fyne.DragEvent) {
  di.scroll.Offset = di.scroll.Offset.SubtractXY(e.Dragged.DX, e.Dragged.DY)
  di.scroll.Refresh()
}
func (di *Pan) Center() {
  w, h := di.image.Size().Width, di.image.Size().Height
  di.scroll.Offset = fyne.NewPos(float32(w)/2.0, float32(h)/2.0)
  di.scroll.Refresh()
}
func (di *Pan) DragEnd() {
}
type panRenderer struct {
  di *Pan
}
func (r *panRenderer) Layout(size fyne.Size) {
  r.di.image.Resize(size)
}
func (r *panRenderer) MinSize() fyne.Size {
  return r.di.image.MinSize()
}
func (r *panRenderer) Refresh() {
  canvas.Refresh(r.di.image)
}
func (r *panRenderer) BackgroundColor() color.Color {
  return color.Transparent
}
func (r *panRenderer) Objects() []fyne.CanvasObject {
  return []fyne.CanvasObject{r.di.image}
}
func (r *panRenderer) Destroy() {}

Alle Register gezogen

Wieder baut das »Pan«-Widget auf »widget.BaseWidget« auf (Zeile 10). Es definiert ein Attribut namens »image«, das auf das darzustellende Foto zeigt. Weiter braucht so ein Google-Maps-Klon einen Scroll-Container (»scroll« in Zeile 12) sowie einen Merker für die aktuelle Scroll-Position (»scrollOffset«). Wie gesagt, kann ein Scroll-Widget in Fyne bereits übergroße Fotos laden und darstellen, aber die Bewegung durch die Bereiche des Bilds laufen über klick- und schiebbare Bildlaufleisten, die rechts und unterhalb des dargestellten Bereichs liegen.

Der Trick an »Pan« ist nun, dass es erlaubt, mit gedrückter linker Maustaste und einer schleifenden Bewegung durch die Details des Fotos zu navigieren. Dazu bekommt »Dragged()« ab Zeile 26 mehrmals pro Sekunde die Koordinaten der Maus zugeschickt, falls der User sie bei gedrückter linker Maustaste durchs Bild zieht. Diese Delta-Werte subtrahiert Zeile 27 vom gespeicherten Offset des Scroll-Widgets und veranlasst das Guckloch per »Refresh()«, den Bildausschnitt an den neuen Koordinaten anzuzeigen.

Schicken oder schubsen?

Warum subtrahieren und nicht addieren? Die ersten Map-Implementierungen in der Steinzeit des Internets funktionierten tatsächlich so, dass der User mit der schleifenden Maus die Richtung angab, in die eine App den Mauszeiger auf der Landkarte vorwärtszuschieben hatte. Mit Google Maps drehte sich die Richtung um. Heute wäre es undenkbar, etwas anderes zu implementieren als mit der gedrückten und geschlenzten Maus die Richtung anzugeben, in der die darunter liegende Landkarte (oder das Foto) zu schubsen ist.

Ruft nun der User den Inspektor zu einem Thumbnail auf, ist höchstwahrscheinlich nicht die linke obere Ecke des Fotos sein Begehr, sondern vielleicht eher die Mitte. Die Funktion »Center()« ab Zeile 30 positioniert das Guckloch dazu auf Koordinaten, die genau die Hälfte der Bildbreite und -höhe ausmachen.

Wegen der komplexeren Funktion nutzt das »Pan«-Widget in Listing 4 nicht den simplen Image-Renderer wie Listing 3, sondern definiert ab Zeile 37 seinen eigenen »PanRenderer«. Der muss dem Fyne-Widget-Verwalter auf Anfrage mitteilen, wie er sich vergrößert, falls Platz frei wird (»Layout()«), was seine Mindestgröße ist (»MinSize()«) oder wie die neueste Darstellung auf den Schirm kommt (»Refresh()«). Das Custom-Widget fällt hier lediglich auf das dargestellte Foto zurück, dessen »canvas.Image«-Implementierung bereits alles Notwendige bietet.

Dieses Panning funktioniert am Ende tatsächlich erstaunlich flott. Dabei ist über den Scroll-Container Hardwareunterstützung im Spiel: Keine CPU der Welt könnte so schnell einen Ausschnitt aus einem Bild berechnen.

Werkzeug schmieden

Der übliche Dreisatz aus Listing 5 kompiliert alle vorgestellten Listings zum Binary »photogrep«, das die auszuwählenden Fotos entweder über die Standardeingabe oder als Kommandozeilenargumente entgegennimmt. Haben Sie noch nie mit Fyne unter Linux gearbeitet, sollten Sie vorher golang-go, build-essential, libgl1-mesa-dev und xorg-dev installieren. Das neue praktische Werkzeug Photogrep sollte wie Grep in keiner Toolbox fehlen. (uba)

Listing 5

build.sh

$ go mod init photogrep
$ go mod tidy
$ go build photogrep.go clickimg.go csheet.go pan.go
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 6 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
Nach oben