Im ersten Artikel hatten wir den SMT-Solver Z3 kennen gelernt. Wir hatten in der zugehörigen Sprache SMT-LIB-2 eine Spezifikation für die Rasterisierung von Geraden verfasst und dann einen verbesserten Algorithmus entworfen, der mit dem simplen in seiner Funktionsweise übereinstimmt. Während die Korrespondenz dieser beiden Algorithmen manchen Lesern vielleicht noch offensichtlich erschien, gehen wir in diesem Artikel einen signifikanten Schritt weiter. Wir implementieren den sog. Bresenham-Algorithmus, bei dem auf den ersten Blick ganz und gar nicht mehr klar ist, warum er überhaupt funktioniert.

Der nächste bitte

Der zweite Algorithmus aus unserem ersten Artikel ist zwar nicht mehr ganz so verschwenderisch wie stures Ausmultiplizieren, aber er führt im Algorithmenzustand immer noch eine reelle (rationale) Zahl y mit, welche in jedem Schritt gerundet werden muss. Das ist immer noch recht aufwendig und auch das ist recht unnötig. Wir haben uns am Anfang bewusst eingeschränkt auf „flache“ (dx < dy), „aufsteigende“ (dy >= 0) Linien. Bei der Rasterung solcher Linien geht‘s für jeden x-Schritt in y-Richtung entweder einen Schritt nach oben, oder wir bleiben auf der gleichen Höhe. Das klingt zum einen plausibel, ist andererseits aber auch beweisbar:1

(define-fun candidate-next-pixel-top ((st State-1))
  Int

  (+ 1 (state-1-y st)))

(define-fun candidate-next-pixel-bottom ((st State-1))
  Int

  (state-1-y st))

(push)
(declare-const st State-1)

(assert (state-1-valid st))

(assert (not (let ((next-st (step-1 st)))
               (or
                (= (candidate-next-pixel-bottom st)
                   (state-1-y next-st))
                (= (candidate-next-pixel-top st)
                   (state-1-y next-st))))))

(check-sat)
(pop)

Wir benutzen hier und im folgenden die Ausdrücke (push) und (pop), um Zwischenergebnisse zu prüfen, und zwar ohne, dass das einen Einfluss auf das Endergebnis hat. Alles, was seit dem letzten (push) deklariert oder assertet wurde, ist nach dem (pop) wieder weg.

Die Entscheidung, welchen dieser beiden Kandidatenpixel wir auswählen, treffen wir anhand der Abstände der jeweiligen Mittelpunkte zur Ideallinie. Dieser Zusammenhang lässt sich am besten anhand einer Grafik illustrieren:

Schmale Linien

Die Abstände zum oberen Pixel d-oben und zum unteren Pixel d-unten werden wir später als expliziten Teil des Zustands mitführen. Jetzt – in der Phase, wo wir uns noch klar werden müssen über alle Zusammenhänge – können wir die beiden Abstände mit Funktionen ableiten.

(define-fun state-1-d-oben ((st State-1))
  Real
  (-
   (candidate-next-pixel-top st)
   (state-1-exact-y (step-1 st))))

(define-fun state-1-d-unten ((st State-1))
  Real
  (-
   (state-1-exact-y (step-1 st))
   (candidate-next-pixel-bottom st)))

Hierbei ist wichtig, dass sich die beiden Abstände d-unten und d-oben auf den nächsten Zustand beziehen. Deshalb müssen wir zur Berechnung einmal mit step-1 weiterschalten.

Mit diesen Definitionen können wir prüfen, ob das Wissen über die beiden Abstände ausreicht, um die Entscheidung zu treffen, ob wir den oberen Kandidatenpixel auswählen müssen oder den unteren. Eine Entscheidung in SMT-LIB-2 treffen wir mit dem ite-Ausdruck, was für „if-then-else“ steht:

(push)
(declare-const st State-1)
(assert (state-1-valid st))

(assert (not
         (let ((next-y (state-1-y
                        (step-1 st)))
               (dx (line-dx (state-1-line st)))
               (dy (line-dy (state-1-line st)))
               (x (state-1-x st))
               (y (state-1-y st)))

           (ite (< (state-1-d-oben st)
                   (state-1-d-unten st))

                (= next-y
                   (candidate-next-pixel-top st))
                (= next-y
                   (candidate-next-pixel-bottom st))))))

(check-sat)
(pop)

Z3 sagt hierzu sat. Das ist schlecht; unsere Aussage gilt nicht. Um herauszufinden, was wir falsch gemacht haben, können wir einen get-value-Aufruf hinter das (check-sat) schreiben. get-value nimmt als Argument eine Liste mit Ausdrücken und druckt deren Werte aus für das gefundene Modell (sprich: die gefundene Variablebelegung). Wir lassen uns ein paar interessante Werte ausdrucken.

(get-value
 (st

  (state-1-y st)

  (state-1-y
   (step-1 st))

  (state-1-d-oben st)

  (state-1-d-unten st)

  (candidate-next-pixel-top st)

  (candidate-next-pixel-bottom st)
  ))

Das Ergebnis davon ist:

sat
((st (mk-state-1 (mk-line 4 3) 1))
 ((state-1-y st) 1)
 ((state-1-y
   (step-1 st)) 2)
 ((state-1-d-oben st) (/ 1.0 2.0))
 ((state-1-d-unten st) (/ 1.0 2.0))
 ((candidate-next-pixel-top st) 2)
 ((candidate-next-pixel-bottom st) 1))

Wir sehen, dass der Abstand zum oberen Kandidaten mit 1/2 genau gleich groß ist wie der Abstand zum unteren Kandidaten. In der Bedingung unseres ite steht ein <, also strikt kleiner, was in diesem Fall nicht gegeben ist. Dann wählen wir den unteren Pixel aus. Richtig gewesen wäre der obere, denn bei 1/2 rundet round-to-nearest auf statt ab. Ein naheliegender Lösungsversuch wäre, das < in ein <= zu ändern. Und siehe da, damit sagt Z3 unsat.

Die Bedingung für das ite ist jetzt (<= (state-1-d-oben st) (state-1-d-unten st)), was immer noch die Berechnung des exakten y-Werts erfordert. Davon wollen wir jetzt weg. Zu diesem Zwecke formulieren wir die Bedingung nach und nach um. Diese Umformungen können wir dabei jeweils von Z3 auf Korrektheit prüfen lassen.

Wir substituieren zunächst von Hand alle Funktionsaufrufe durch deren jeweilige Implementierungen und formen nebenbei die Ungleichung so um, dass links nur noch die Null steht.

(push)

(declare-const st State-1)
(assert (state-1-valid st))

(assert (not
         (=

          (<= (state-1-d-oben st)
              (state-1-d-unten st))

          (<= 0

              (-
               (* 2 (line-y (state-1-line (step-1 st))
                            (state-1-x (step-1 st))))
               (* 2 (state-1-y st))
               1)))))

(check-sat)
(pop)

Wir wissen außerdem, dass die Linie state-1-line sich nicht ändert. Darüberhinaus ist das x nach einem step-1 einfach x + 1. Wenn wir zusätzlich noch die Definition von line-y und dann die von line-slope einsetzen, erhalten wir:

(push)

(declare-const st State-1)
(assert (state-1-valid st))

(assert (not
         (let ((next-y (state-1-y
                        (step-1 st)))
               (dx (line-dx (state-1-line st)))
               (dy (line-dy (state-1-line st)))
               (x (state-1-x st))
               (y (state-1-y st)))

           (ite (<= 0

                    (-
                     (* 2 (* (/ dy dx)
                             (+ 1 x)))
                     (* 2 y)
                     1))

                (= next-y
                   (candidate-next-pixel-top st))
                (= next-y
                   (candidate-next-pixel-bottom st))))))

(check-sat)
(pop)

Die Ungleichung enthält (/ dy dx) als einzigen Bruch. Den können wir beseitigen, indem wir die Ungleichung mit dx durchmultiplizieren. Das ist zulässig, so lange dx ungleich 0 ist. Anders als auf Papier müssen wir die Zulässigkeit hier gar nicht verargumentieren, sondern wir können die Umformulierung einfach machen und Z3 sagt uns dann, ob das zulässig war. Die Ungleichung sieht jetzt so aus:

(<= 0
    (-
     (* 2 dy (+ 1 x))
     (* 2 y dx)
     dx))

Z3 sagt dazu immer noch unsat. Die Einschränkung (state-1-valid st) enthält die Einschränkung, dass die im Zustand befindliche Linie valide sein soll. In line-valid steht (> (line-dx l) 0). Wir können mal probeweise diese Einschränkung löschen und Z3 fragen, ob dann unsere Aussagen immer noch gelten. Erwartungsgemäß sagt Z3 jetzt sat.

Der Ausdruck (- (* 2 (* dy (+ 1 x))) (* 2 y dx) dx) ist jetzt eine Ganzzahl und zu deren Berechnung müssen wir auch nur Ganzzahlarithmetik betreiben. Nur die Multiplikationen stören uns noch. Aber um eine Multiplikation potenziell aufzulösen kennen wir ja schon einen Trick: Wir merken uns den jeweiligen Wert aus dem vorherigen Schritt und addieren für den nächsten Schritt was drauf (oder ziehen was ab). Das sollte hier funktionieren, denn das Produkt (* 2 dy (+ 1 x)) erhöht sich in jedem Schritt um genau 2 dy und das Produkt (* 2 y dx) erhöht sich je nachdem, ob wir den unteren Kandidatenpixel oder den oberen auswählen entweder gar nicht oder um ein konstantes (* 2 dx). Mit diesen formal verifizierten Überlegungen sind wir gewappnet für den finalen Schlag: Wir bauen einen Algorithmus, der eine Linie zeichnet und sich dabei nur einfacher Ganzzahlarithmetik bedient.

I keep my ends out for the tie that binds

Wir machen wieder das übliche Schema. Zuerst definieren wir ein Zustandsobjekt. Das enthält neben dem üblichen Kram jetzt unser d, welches unsere Entscheidung für den oberen oder unteren nächsten Pixel treibt:

(declare-datatypes ()
                   ((State-4 (mk-state-4 (state-4-dx Int)
                                         (state-4-dy Int)
                                         (state-4-x Int)
                                         (state-4-y Int)
                                         (state-4-d Int)
                                         ))))

Ein solcher Zustand ist valide, wenn die üblichen Einschränkungen gelten und zusätzlich unser d dem Wert entspricht, der uns vorschwebt:

(define-fun state-4-m ((st State-4))
  Real

  (/ (state-4-dy st)
     (state-4-dx st)))

(define-fun state-4-valid ((st State-4))
  Bool
  (let ((dx (state-4-dx st))
        (dy (state-4-dy st))
        (m (state-4-m st))
        (x (state-4-x st))
        (y (state-4-y st))
        (d (state-4-d st)))
    (and
     (< 0 dx)
     (<= 0 dy dx)

     (>= x 0)

     ;; y is actually the correct y for m and x
     (= y (round-to-nearest (* m x)))
     
     ;; d is what we want it to describe
     (= d
        (- (* 2 dy (+ 1 x))
           (* 2 y dx)
           dx))
     )))

Fehlt noch die Funktion für den Einstieg:

(define-fun into-state-4 ((st2 State-1))
  State-4

  (let ((dx (line-dx (state-1-line st2)))
        (dy (line-dy (state-1-line st2)))
        (x (state-1-x st2)))

    (let ((y (round-to-nearest (* (/ dy dx) x))))
      (mk-state-4
       dx
       dy
       x
       y
       (- (* 2 dy (+ 1 x))
          (* 2 y dx)
          dx)))))

Und die Schrittfunktion:

(define-fun step-4 ((st State-4))
  State-4

  (let ((dx (state-4-dx st))
        (dy (state-4-dy st))
        (x (state-4-x st))
        (y (state-4-y st))
        (d (state-4-d st)))

    (ite (<= 0 d)
         ;; Diagonalschritt
         (mk-state-4
          dx
          dy
          (+ x 1)
          (+ y 1)
          (+ d (- (* 2 dy)
                  (* 2 dx))))

         ;; Rechtsschritt
         (mk-state-4
          dx
          dy
          (+ x 1)
          y
          (+ d (* 2 dy))))))

Als Korrektheitsaussage würden wir gern wieder dem oben etablierten Schema entsprechen:

(declare-const st State-1)
(assert (state-1-valid st))

(assert
 (not
  (= (step-4
      (into-state-4 st))
     (into-state-4
      (step-1 st)))))

Das terminiert leider nicht. Das ist leider gar nicht schön und zeigt die Grenzen eines SMT-Solvers als Verifikationsinstrument. Wir können mit etwas Gefrickel aber eine Korrektheitsaussage hinbasteln, die äquivalent ist:

(declare-const before-1 State-1)
(assert (state-1-valid before-1))

(assert (not
         (let ((before-4 (into-state-4 before-1)))
           (let ((after-4 (step-4 before-4))
                 (after-1 (step-1 before-1)))
             (and
              (= (state-4-x after-4)
                 (state-1-x after-1))

              (state-4-valid after-4)

              (= (state-1-y after-1)
                 (state-4-y after-4))
              )))))

(check-sat)

Die Bedingungen zu x und y bewirken, dass step-4 dasselbe Ergebnis liefert wie step-1 und die Bedingung (step-4-valid after-4) bewirkt, dass wir auch nicht den Pfad der Tugend verlassen.

Because you‘re mine, I walk the line

Unsere finale Formulierung ist eine Instanz des sog. Bresenham-Algorithmus. Wir haben diesen hier nicht nur korrekt implementiert, sondern diese Korrektheit auch sichergestellt und außerdem auf dem Weg dorthin einen Einblick erhalten, warum der Algorithmus funktioniert.

Um die Korrektheit zu zeigen, formulierten wir Bedingungen mit Rückgriff auf die reellen Zahlen – nicht Float, nicht Double, sondern präzise reelle Zahlen. Das ist richtig, denn nur mit reellen Zahlen lässt sich die Rasterisierung einer Geraden akkurat beschreiben. Das bedeutet jedoch nicht, dass eine Implementierung ebenfalls mit reellen Zahlen arbeiten muss. Das wäre für einen Computer sehr aufwendig und mit dem Bresenham-Algorithmus haben wir gezeigt, dass es ja auch ohne geht.

Im nächsten Artikel zeigen wir eine Implementierung des Bresenham-Algorithmus in Haskell. Wir nutzen Liquid Haskell, um sicherzustellen, dass auch diese Haskell-Implementierung korrekt im Sinne unserer obigen Überlegungen ist.

  1. Zur Erinnerung: Mit assert (not (... st)) behaupten wir, dass unsere eigentliche Aussage ... nicht gilt. Wir fragen dann Z3, ob es für diese invertierte Aussage ein Gegenbeispiel findet. Wenn Z3 kein Gegenbeispiel findet (Ergebnis: unsat), dann gilt die Aussage für alle Zustände st. Allgemeingültigkeit einer Aussage x ist äquivalent zur Nichterfüllbarkeit von not x