Welches Bild aus einer Serie ist das schärfste? Mike Schilli erforscht die Tiefen der Schärfenphysik mit selbstgeschriebenen Go-Tools.
Mein relativ neues Smartphone macht zumeist hervorragend scharfe Bilder, allerdings nicht immer. Warum bloß? In einer Smartphone-Kamera steckt nicht nur ein Fotochip, sondern auch ein leistungsfähiges KI-Gehirn, das den Autofokus der Kamera zum scharfzustellenden Bildbereich dirigiert.
Alte Fotofüchse wissen nun, dass kritische Betrachter bei Tierfotos erwarten, dass der Bereich ums Auge messerscharf ist. Der Rest des Tieres darf mangels Tiefenschärfe leicht unscharf erscheinen, ohne dass der Gesamteindruck leidet. Nun tricksen Apple und andere Telefonkamerahersteller mächtig herum, um den scharfzustellenden Bildbereich der Kamera möglichst batterieschonend zu ermitteln. Das scheint bei kaltblütigen Lurchen, wie dem in Abbildung 1 nicht zu funktionieren. So oft ich die freche kleine Eidechse auf einem Wanderweg knipste, kein einziges Foto der Serie geriet hundert prozentig scharf, sodass der Echsenkopf “poppt”, wie Fachleute sagen.
Im Auge des Betrachters
Daheim fragte ich ChatGPT, wo er denn den Autofokus ansetzen würde. Das Elektronengehirn antwortete zwar richtig, dass die Kamera dazu auf das Auge des Lurches zielen solle, versagte aber kläglich beim Auffinden desselben (Abbildung 1, links). Nun wollte ich nicht in ein voreiliges Siegesgeheul verhärmter KI-Kritiker einstimmen, denn mir ist klar, dass die aktuell verfügbaren künstlichen Gehirne noch in den Kinderschuhen stecken. Deshalb fragte ich noch Grok aus dem Hause X (vormals Twitter), und der löste die Aufgabe im Handumdrehen (Abbildung 1, rechts).
Bis das KI-Angebot aufgeholt hat, verpflichtet die hier beschriebene Go-App den User dazu, mit der Maus auf allen Bildern einer Serie jeweils eine rote Linie zu ziehen, die den kritischen Fokusbereich definiert. Entlang dieser Messlinie ermittelt die App dann die Lichtintensität jedes Pixels. Abbildung 2 zeigt das Ergebnis bei drei unterschiedlichen Fotos der Palme vor meinem Fenster in San Francisco. Das erste ist gestochen scharf, das zweite leicht unscharf und das unterste total unscharf. Die zugehörigen Messgraphen verraten, wie sich Schärfe in einem Bild manifestiert: Bei einem scharfen Bild ist der Graph zackig, denn zwischen hellen und dunklen Bereichen (zum Beispiel zwischen blauem Himmel und dunklen Palmenblättern) geht die Intensität ruckartig nach unten. Ein unscharfes Bild zeigt hingegen sanfte, harmonische Übergänge, die eher einer Sinuskurve als einem Dirac ähneln.
Die App dieser Ausgabe zeigt nun alle Fotos einer Serie in einem Kontaktabzug an (Abbildung 3), lässt den User mit der Maus auf jedem Bild eine rote Line ziehen, und ermittelt den Intensitätsverlauf entlang dieser Linie. Aus dessen Zackigkeit errechnet sie einen Score, der bei vielen abrupten Wechseln hoch und bei sanften Übergängen niedrig ausfällt. Sobald die App den Schärfegrad des Bildes errechnet hat, blendet sie den “Score” unten ein. Voran geht der Rang des Fotos in einer nach Schärfe sortierten Serie. Der Sieger bekommt außerdem einen blauen Rahmen spendiert. Den Pfad dieses Premiumbildes schreibt die App nach »grep«-Manier in die Standardausgabe, sobald ein [Q] für Abbruch gesorgt hat. So dürfen nachfolgende Tools das schärfste Bild weiterverarbeiten, und es zum Beispiel in ein Verzeichnis mit den besten Aufnahmen bugsieren.
Schwankung im Quadrat
Wie nun lässt sich die Zackigkeit einer Werteserie mathematisch bestimmen, und zum Beispiel den ersten Graph oben in Abbildung 2 höher bewerten als den zweite oder gar den dritten? Ein gängiges Verfahren ist es, die Deltas zwischen den Messpunkten zu quadrieren, diese dann zu addieren und das Ergebnis durch die Anzahl der Deltas zu teilen [1]. Heraus kommt ein Score für die Rauigkeit des Messwertgraphen. Stark schwankende Messwerte bewirken hohe Deltas, die Quadratur bewertet positive und negative Schwankungen gleich und heraus kommen stark gewichtete Score-Werte. Flachere Messgraphen produzieren kleinere Deltas, die wegen der Quadratur deutlich niedrigere Scores liefern (Abbildung 4). Listing 1 implementiert den Algorithmus in der Funktion »Roughness()« ab Zeile 6. Sie errechnet den Score aus der Summe der quadrierten Deltas, geteilt durch die Anzahl der Messwerte.
Listing 1
focus.go
package main
import (
"image"
"image/color"
)
func Roughness(img image.Image,
x1, y1, x2, y2 int) float64 {
values := SampleLine(img, x1, y1, x2, y2)
if len(values) < 2 {
return 0
}
var sum float64
for i := 0; i < len(values)-1; i++ {
d := values[i+1] - values[i]
sum += d * d
}
return sum / float64(len(values)-1)
}
func SampleLine(img image.Image, x1, y1, x2, y2 int) []float64 {
dx, sx := x2-x1, 1
if dx < 0 {
dx, sx = -dx, -1
}
dy, sy := y2-y1, 1
if dy < 0 {
dy, sy = -dy, -1
}
dy = -dy
err := dx + dy
out := []float64{}
for {
gray := color.GrayModel.Convert(img.At(x1, y1)).(color.Gray)
out = append(out, float64(gray.Y))
if x1 == x2 && y1 == y2 {
break
}
e2 := 2 * err
if e2 >= dy {
err += dy
x1 += sx
}
if e2 <= dx {
err += dx
y1 += sy
}
}
return out
}
Wie hell sind nun die Bildpunkte entlang der per Maus eingetragenen Geraden? Die Pixel in einem Farbbild liegen als Rot-, Grün- und Blauanteile vor. Alle drei tragen zur Helligkeitswahrnehmung bei, allerdings reagiert das menschliche Auge im Grün-Kanal fast fünfmal so empfindlich wie auf Blautöne und etwa doppelt so empfänglich wie auf Rotanteile. Am einfachsten errechnet sich die Helligkeit eines Pixels mit der Funktion »color.GrayModel.Convert« aus Listing 1 ab Zeile 32. Sie wandelt ihn in einen Grauton um und gewichtet dabei die drei Kanäle unterschiedlich.
Schnurstracks durchs Bild
Zieht der User mit der Maus eine Gerade durchs Bild, gilt es, die Messwerte entlang dieser Geraden zwecks Analyse aus dem Bild zu fieseln. Dabei erstreckt sich die Gerade von den Koordinaten »(x1,**y1)« nach »(x2,**y2)«. Wegen der endlichen Bildauflösung streift sie bei Steigungen, die nicht Vielfache von 45 Grad sind, manche Pixel lediglich zum Teil. Es entsteht keine ideale Linie, sondern eine stufige Annäherung nach Abbildung 5. Ein schnelles Verfahren zum Ermitteln der gestreiften Pixel entlang der Geraden ist der im Jahr 1962 von Jack Bresenham bei IBM erfundene Algorithmus [2].

Abbildung 5: Zur Messung der Schärfe durchwandert der Bresenham-Algorithmus das Bild entlang der Geraden.
Bei den Startkoordinaten »(x1,**y1)« beginnend, arbeitet sich Listing 1 Pixel für Pixel in der “schnellen” X-Richtung nach rechts vor und macht hin und wieder einen Schritt in die “langsame” Y-Richtung nach oben, um der eingezeichneten Geraden zwischen Start- und Endpunkt zu folgen (Abbildung 5). Ob der Algorithmus einen Korrekturschritt in Y-Richtung einlegen muss, bestimmt der Wert einer Fehlervariablen »err«. Die bekommt Strafpunkte im Wert von »dy« abgezogen, falls der Messpunkt nur in X-Richtung marschiert.
Geht es aber in Y-Richtung nach oben, bekommt »err« den Wert »dx« aufgebrummt. Je nachdem, in welcher Richtung »err« den eingestellten Grenzwert überschreitet, folgt ein Korrekturschritt in Richtung »dx« oder »dy«. Listing 1 verwendet übrigens die Variable »e2« mit dem zweifachen Wert von »err«, aber nur deshalb, weil nach Bresenham »dx/2« beziehungsweise »dy/2« als Grenzwerte gelten und die Funktion Floating-Divisionen vermeiden will.
Auf Fotos malen
Wie stellt eine in Go geschriebene Fyne-GUI Linien dar, die der User mit der Maus aufzieht? In Listing 2 definiert dazu ein von Fynes »widget.BaseWidget« abgeleitetes Custom-Widget »Linex«, das drei zusätzliche Komponenten enthält: das darzustellende Foto in »view«, die gezogene rote Linie in »line« und einen eventuell erscheinenden blauen Rahmen »frame«, falls das Bild das schärfste aus der Serie ist.
Auf neumodischen Handys geschossene Fotos sind mit 4000 x 3000 Pixel schon relativ groß, und eine Full-Frame-Kamera wie eine Sony A7 III liefert Monster im Format 6000 x 4000 Pixel. In der GUI erscheinen die Bilder viel kleiner. Fyne würde die notwendige Skalierung zwar ohne Murren vornehmen, aber dabei den Prozessor belasten und gewaltige Datenmengen zwischen RAM und Grafikkarte hin- und herschaufeln. Deshalb erzeugt Zeile 27 in Listing 2 ein 400 Pixel breites Abbild für die mühelose Darstellung. Doch das Original lebt in »orig« fort und dient fürderhin bei der Schärfeanalyse.
Listing 2
linex.go
package main
import (
"image"
"image/color"
_ "image/jpeg"
"os"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
"github.com/disintegration/imaging"
)
type Linex struct {
widget.BaseWidget
img image.Image
view *canvas.Image
line *canvas.Line
frame *canvas.Rectangle
dragging bool
onDone func(l *Linex, x1, y1, x2, y2 int)
x1, y1, x2, y2 float32
}
func NewLinex(path string, maxW int, cb func(l *Linex, x1, y1, x2, y2 int)) *Linex {
f, _ := os.Open(path)
defer f.Close()
orig, _, _ := image.Decode(f)
small := imaging.Resize(orig, maxW, 0, imaging.Lanczos)
frame := canvas.NewRectangle(color.Transparent)
frame.StrokeColor = color.White
frame.StrokeWidth = 4
lp := &Linex{
img: orig,
view: canvas.NewImageFromImage(small),
line: canvas.NewLine(color.Transparent),
frame: frame,
onDone: cb,
}
lp.view.FillMode = canvas.ImageFillContain
lp.line.StrokeWidth = 6
lp.ExtendBaseWidget(lp)
return lp
}
type lxLayout struct {
p *Linex
}
func (l *lxLayout) Layout(_ []fyne.CanvasObject, size fyne.Size) {
p := l.p
p.view.Resize(size)
s, ox, oy := p.geom()
sw := p.frame.StrokeWidth
b := p.img.Bounds()
w := float32(b.Dx()) * s
h := float32(b.Dy()) * s
p.frame.Move(fyne.NewPos(ox-sw/2, oy-sw/2))
p.frame.Resize(fyne.NewSize(w+sw, h+sw))
p.updateLine()
}
func (*lxLayout) MinSize(_ []fyne.CanvasObject) fyne.Size {
return fyne.NewSize(300, 200)
}
func (p *Linex) CreateRenderer() fyne.WidgetRenderer {
c := container.New(
&lxLayout{p},
p.frame,
p.view,
p.line,
)
return widget.NewSimpleRenderer(c)
}
func (p *Linex) geom() (s, ox, oy float32) {
z := p.Size()
b := p.img.Bounds()
iw := float32(b.Dx())
ih := float32(b.Dy())
s = z.Width / iw
if h := z.Height / ih; h < s {
s = h
}
ox = (z.Width - iw*s) / 2
oy = (z.Height - ih*s) / 2
return s, ox, oy
}
func (p *Linex) updateLine() {
s, ox, oy := p.geom()
p.line.Position1 = fyne.NewPos(ox+p.x1*s, oy+p.y1*s)
p.line.Position2 = fyne.NewPos(ox+p.x2*s, oy+p.y2*s)
canvas.Refresh(p.line)
}
func (p *Linex) Dragged(e *fyne.DragEvent) {
s, ox, oy := p.geom()
p.line.StrokeColor = color.RGBA{255, 0, 0, 255}
x := (e.Position.X - ox) / s
y := (e.Position.Y - oy) / s
if !p.dragging {
p.dragging = true
p.x1, p.y1 = x, y
}
p.x2, p.y2 = x, y
p.updateLine()
}
func (p *Linex) DragEnd() {
p.dragging = false
p.onDone(p, int(p.x1), int(p.y1), int(p.x2), int(p.y2))
}
Jedes handgestrickte Widget in Fyne benötigt einen Renderer, also eine Komponente, die es skaliert und in die GUI pflanzt. Die Zeilen 62 bis 66 packen Rahmen, Foto und die rote Linie mit »New()« in einen Container, dessen dynamisches Abbild Fyne zentral steuert. Das klappt auch bis auf den blauen Rahmen, der das Foto luftdicht umschließen soll und nicht den umliegenden verfügbaren Platz.
Ohne Verzerrung
Der Layout-Manager einer Fyne-GUI unterscheidet zwischen Widgets, deren Größe und Lage zentral gesteuert wird, und solchen, die ihren Auftritt selbst gestalten wollen. Zieht der User zum Beispiel mit der Maus das Applikationsfenster auf, erhält ein darin enthaltener VBox-Container das Signal, dass nun mehr Platz da ist, und er breitet sich sowohl horizontal als auch vertikal bis zum Rand aus. Liegen im VBox-Container wiederum etwa zwei senkrecht übereinander gestapelte Label-Widgets, schickt er diesen ein »Resize()« Kommando, wobei er dafür sorgt, dass sie sich den vertikalen Freiraum brüderlich teilen.
Die einzelnen Kacheln der »sharpgrep«-App stellen nun jeweils Fotos mit einer bei Bedarf eingezeichneten roten Geraden dar. Diese sollte sich synchron zum Foto strecken, sobald der User das Applikationsfenster aufzieht. Allerdings müssen bei einem Foto immer die Proportionen zwischen Breite und Höhe stimmen, sonst sieht der Inhalt grotesk aus. Aus diesem Grund erhält das Image-Widget in Zeile 38 die Option »ImageFillContain«, die dafür sorgt, dass sich das Foto niemals verzerrt, sondern sich nicht weiter ausbreitet, falls Höhe oder Breite des verfügbaren Leerraums eine Beschränkung markieren. Stattdessen bleibt entweder ein vertikaler oder seitlicher Rand.
Nun muss aber zum Beispiel das Linien-Widget wissen, wo die linke obere Ecke des Fotos nach einem »Resize()«-Event zu liegen kam, damit es sich relativ dazu im Bild positionieren kann. Auch der bei Schärfetestsiegern erscheinende blaue Rahmen um das Foto muss um dessen Lage im Container wissen. Die Funktion »geom()« ab Zeile 70 in Listing 2 rechnet hierzu aus den Längen der Strecken in Abbildung 6 aus, an welchem Offset »ox« (oder »oy« im Fall von vertikalem Leerraum) der Ursprung des Fotos liegt. Der Wert »z« gibt dazu die Gesamtbreite des verfügbaren Raums für das Widget an, »iw« und »ih« die tatsächliche Breite und Höhe des darin unverzerrt hineingequetschten Fotos. Weiter rechnet »geom()« den Skalierungsfaktor »s« des Fotos aus, als Quotient aus den Maßen des dargestellten Fotos zu denen des Originalbilds.

Abbildung 6: Damit das Foto seine Proportionen behält, fügt der Layout-Manager links und rechts Leerraum ein.
Zweigleisig fahren
Von Fyne zentral verwaltete Widgets mit Layout-Funktion definieren nur eine Funktion »MinSize()«, die festlegt, wie klein das Widget minimal geraten darf. Alles andere bestimmt der zentrale Layout-Manager. Widgets ohne Layout-Funktion hingegen verlassen sich darauf, dass irgendjemand ihre »Resize()«-Methode aufruft, oder sie definieren ihr Layout selbst. Letzteres tut Listing 2 in der »Layout()«-Funktion ab Zeile 46. Die passt das Foto an die vom Layout-Manager geforderte Größe »size« an, zeichnet den luftdichten Rahmen »frame« und verschiebt die rote Linie maßgeschneidert.
Außerdem muss das »Linex«-Widget wissen, welche Pixel im Originalbild die Endpunkte der eingezeichneten Geraden referenzieren, die ja im verkleinerten Foto der Darstellung erscheint. Nach zentrischer Streckung mit Offset (»ox«, »oy«) und einem Skalierungsfaktor »s« ist dies gleich erledigt und »Dragged()« ab Zeile 89 frischt die Koordinaten (»x1«,»y1«) beziehungsweise (»x2«,»y2«) auf. Sie bestimmen die Endpunkte der Geraden als Pixelwerte im Originalbild und helfen später bei der Intensitätsmessung. Lässt der User beim Aufziehen der Geraden den Mausknopf los, springt Fyne die Funktion »DragEnd()« ab Zeile 101 an und das Widget ruft den eingangs definierten Callback »onDone« auf, mit den Pixelkoordinaten im Originalbild.
Verpackt mit Etikett
Jedes Linex-Widget bekommt nun in Listing 3 ein Label mit nützlichen Textinformationen spendiert, sowie eine Funktion zum Aktivieren des blauen Rahmens, falls es sich um das Gewinner-Widget handelt. Lustigerweise ist der Rahmen immer da, nur erscheint er eingangs in Weiß, bleibt also unsichtbar. Die »Select()«-Funktion ab Zeile 47 färbt ihn bei Bedarf blau ein und der Aufruf von »Refresh()« in Zeile 53 lässt ihn aufleuchten oder wieder verschwinden, falls ein neuer Gewinner feststeht.
Listing 3
tile.go
package main
import (
"image/color"
"os/exec"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/widget"
)
const ThumbnailWidth = 400
type Tile struct {
widget.BaseWidget
Path string
Linex *Linex
Label *widget.Label
Score float64
Rank int
con *fyne.Container
}
func NewTile(path string,
cb func(t *Tile, x1, y1, x2, y2 int)) *Tile {
label := widget.NewLabel(path)
label.Alignment = fyne.TextAlignCenter
tile := &Tile{
Path: path,
Label: label,
}
pl := NewLinex(path, ThumbnailWidth,
func(w *Linex, x1, y1, x2, y2 int) {
cb(tile, x1, y1, x2, y2)
})
con := container.NewBorder(container.NewPadded(), label, nil, nil, pl)
tile.Linex = pl
tile.con = con
tile.ExtendBaseWidget(tile)
return tile
}
func (t *Tile) TappedSecondary(*fyne.PointEvent) {
cmd := exec.Command("open", t.Path)
err := cmd.Start()
if err != nil {
panic(err)
}
}
func (t *Tile) CreateRenderer() fyne.WidgetRenderer {
return widget.NewSimpleRenderer(t.con)
}
func (t *Tile) Select(on bool) {
if on {
t.Linex.frame.StrokeColor = color.RGBA{0, 0, 255, 255}
} else {
t.Linex.frame.StrokeColor = color.White
}
t.Linex.frame.Refresh()
}
Zudem führt die »Tile«-Struktur Buch darüber, wie hoch der Schärfe-Score eines Fotos entlang der eingezeichneten Linie ist (»score«), und speichert den später im Hauptprogramm ermittelten Rang in der Hitparade aller Fotos. Drückt der User über einem Foto die rechte Maustaste, öffnet »TappedSecondary()« ab Zeile 37 das Foto in dem standardmäßig eingestellten Viewer.
In Zweierreihen
Das Hauptprogramm in Listing 4 spannt das Applikationsfenster von Abbildung 3 auf und definiert ein zweispaltiges Grid-Widget, das alle Foto-Widgets aufnimmt. Den Callback ab Zeile 21 springt die GUI an, falls der User eine Gerade in ein Bild gezeichnet und den Mausknopf losgelassen hat. Die Funktion »Roughness()« aus Listing 1 bestimmt dann die Zackigkeit des Intensitätsgraphen über die ausgewählten Pixel.
Der Aufruf von »rankPhotos()« in Zeile 22 springt die gleichnamige Funktion ab Zeile 41 an, die den ihr zugespielten Kachel-Array »tiles« durchforstet, die gewerteten Fotos nach ihrem Score sortiert und in jeder »Tile«-Struktur den Rang des Fotos in der Parade, von 1 aufsteigend, setzt. Die Reihenfolge in »tiles« bleibt dabei wegen der sonst chaotisch geratenden Darstellung gleich, statt dessen sortiert »rankPhotos()« mit einem Index-Hash. Beim Testsieger setzt Zeile 51 mit »Select« aus Listing 3 den blauen Rahmen und Zeile 53 zeigt Rang, Pfad und Score des Fotos im darunterliegenden Label-Widget an.
Listing 4
sharpgrep.go
package main
import (
"flag"
"fmt"
"sort"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/container"
)
func main() {
flag.Parse()
a := app.New()
w := a.NewWindow("Sharp Grep")
w.Resize(fyne.NewSize(1000, 700))
grid := container.NewGridWithColumns(2)
scroll := container.NewScroll(grid)
tiles := []*Tile{}
winner := ""
for _, ipath := range flag.Args() {
tile := NewTile(ipath,
func(t *Tile, x1, y1, x2, y2 int) {
t.Score = Roughness(t.Linex.img, x1, y1, x2, y2)
winner = rankPhotos(tiles)
},
)
tiles = append(tiles, tile)
grid.Add(tile)
}
w.SetContent(scroll)
w.Canvas().SetOnTypedKey(
func(ev *fyne.KeyEvent) {
key := string(ev.Name)
switch key {
case "Q":
w.Close()
fmt.Println(winner)
}
})
w.ShowAndRun()
}
func rankPhotos(tiles []*Tile) string {
idx := make([]int, len(tiles))
for i := range tiles {
idx[i] = i
}
sort.Slice(idx, func(i, j int) bool {
return tiles[idx[i]].Score > tiles[idx[j]].Score
})
for i, oidx := range idx {
tile := tiles[oidx]
tile.Select(i == 0)
tile.Rank = i + 1
tile.Label.SetText(
fmt.Sprintf("%d. %s %.1f", tile.Rank, tile.Path, tile.Score))
}
return tiles[idx[0]].Path
}
Der übliche Dreisprung mit »go mod init/tidy«, sowie ein nachfolgendes »go build« mit allen vier Listings erzeugt das Binary »sharpgrep«. Mit einer Reihe von Fotos von der Kommandozeile aus aufgerufen, stellt die Applikation diese in einer Matrix dar. Sobald der User in einem Foto die rote Gerade einzeichnet, findet die Messung statt. Bei mehreren so markierten Fotos erhält das schärfste den blauen Rahmen. Ein Druck auf [Q] bricht das Programm ab, nachdem es vorher noch schnell den Pfad zur Siegerdatei in die Standardausgabe geschrieben hat. (uba)
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 gern Fragen.
Infos
- Root Mean Square: https://en.wikipedia.org/wiki/Root_mean_square
- Bresenham-Algorithmus: https://de.wikipedia.org/wiki/Bresenham-Algorithmus










