Rysowanie odcinków
Grafika rastrowa
Grafika rastrowa (potocznie nazywana bitmapową) jest:
  • odwzorowaniem obrazu rzeczywistego
  • za pomocą mapy punktów
  • w postaci prostokątnej siatki
  • odpowiednio kolorowanych pikseli
  • na monitorze komputera, drukarce lub innym urządzeniu wyjściowym.
Kafelkowanie

Mozaikowanie (kafelkowanie, ang. tessellation) polega na pokryciu powierzchni, często płaszczyzny, jednym lub większą liczbą kształtów geometrycznych, zwanych kafelkami, bez ich zachodzenia na siebie i pozostawiania przerw pomiędzy nimi.

Podstawową strategią leżącą u podstaw modelu danych rastrowych jest właśnie mozaikowanie (kafelkowanie) obrazu do dwuwymiarowej tablicy kwadratów, z których każdy nazywany jest komórką lub pikselem (od angielskiego picture element).

Słowo raster pochodzi od łacińskiego rastrum (grabie), które z kolei pochodzi od radere (skrobać). Grabienie ma bliski związek z wczesnymi metodami generowania obrazu na powierzchniach lamp kineskopowych, gdzie obraz rysowany był linia po linii za pomocą skupionej i odpowiednio kierowanej wiązki elektronów.

Rysunek 1: Kolejność powstawania obrazu na powierzchni lampy kineskopowej



Bez zastosowania kompresji kolor każdego piksela jest definiowany pojedynczo za pomocą jednej lub większej liczby liczb – zależnie od wybranego trybu opisu kolorów. Na przykład obrazy z popularną głębią kolorów RGB składają się z kolorowych kwadratów (pixeli) zdefiniowanych przy pomocy trzech bajtów – jeden bajt (czyli 8 bitów) opisuje składową czerwoną koloru (jej natężenie), jeden zieloną i jeden składową niebieską; we wszystkich przypadkach wartość 255 oznacza maksymalne natężenie danej składowej a wartość 0 jej całkowity brak.

Obrazki o mniejszej liczbie kolorów potrzebują mniej informacji (bitów) na piksel, w skrajnym przypadku obraz jedynie w kolorach czarnym i białym wymaga tylko jednego bitu na każdy piksel (i dlatego czasem nazywany jest bitmapą co może kłócić się z powszechnym określaniem dowolnych obrazów rastrowych jako obrazów bitmapowych a w skrócie właśnie bitmap).

Rysunek 2: Obraz (w lewym górnym rogu) i jego rastrowe odpowiedniki (dla "kafelków" o wielkości 2, 4, 8, 16, 32, 64, 128 i 256 pixeli). Zauważ, że oryginał prezentowany na ekranie w praktyce też jest obrazem rastrowym, choć ze względu na małą wielkość rastra (pixeli) nie jest to widoczne


Odmiennym podejściem do tworzenia grafiki jest grafika wektorowa. Różni się ona od grafiki rastrowej tym, że obraz nie jest opisywany przez poszczególne punkty, lecz jest zdefiniowany matematycznie, czyli generowany jest przy pomocy obiektów geometrycznych, takich jak odcinki, krzywe czy wielokąty.

Podsumowanie
Poniżej w formie punktów podsumuję wszystkie dotychczas podane informacje:
  • Obraz tworzony jest na siatce prostokątnej o określonej liczbie wierszy i kolumn (a zatem zawsze ma ściśle określoną wysokość i szerokość).
  • Każdy element siatki (znajdujacy się na przecięciu określonego wiersza i określonej kolumny) nazywamy pixelem.
  • Każdy piksel ma jeden, ściśle określony, kolor w całym swoim obszarze.
  • Sposób określenia koloru zależy od przyjętego modelu (trybu) kolorystycznego.
  • Piksele nie mogą na siebie zachodzić.
  • Piksele pokrywają cały, określony, obszar – w tym obszarze nie może być pomiędzy nimi "dziur".
  • Punkt $(x, y)$ w ujęciu matematycznym (geometrycznym) nie "zajmuje" miejsca, pixel zaś jest kwadratem (tak zakładamy) o pewnej niezerowej powierzchni. Gdzie więc leży w tym kwadracie punkt $(x, y)$? Umawiamy się, że znajduje się w środku tego kwadratu:

    Rysunek 3: Pixel o współrzędnych $(x, y)$ oraz położenie względem niego "geometrycznego" punktu $(x, y)$


Czym jest linia (odcinek) na obrazie rastrowym
Zanim przejdziemy do zagadnienia rysowania odcinków zastanówmy się chwilę jak odcinek powinien być odwzorowywany w grafice rastrowej.
Przypadek linii poziomej i pionowej
Przypadek linii poziomej i pionowej są do siebie tak bardzo podobne, że w zasadzie nie ma sensu rozpatrywać ich osobno. Dlatego od razu przyjrzymy się im obu na przykładzie linii poziomej:

Rysunek 4: Układ pikseli tworzących linię poziomą (na górze). Układ pikseli na dole nie tworzy poprawnej lini poziomej


Program rysujący linie poziome i pionowe
Program rysujący linie poziome:


from screen_terminal import Screen

def drawHorizontalLine(screen, x1, y1, x2, y2, c):
  if y1 != y2:
    return False
  
  if x1 < x2:
    d = x2 - x1 + 1
    b = x1 
  else:
    d = x1 - x2 + 1
    b = x2
    
  for i in range(d):
    screen.setPoint(b+i, y1, c)
    
  return True


def main():
  s = Screen(31, 31)
  s.moveOrigin(-10, -10)
  drawHorizontalLine(s, 5, 1, 10, 1, "*")
  s.printScreen("plot.txt")


if __name__ == "__main__":
  main()
i efekt jego działania:


     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |    ******               
-----+-------------------------
     |                         
     |                         
     |                         
     |                         
     | 


Biblioteka do tworzenia rysunków

W programie tym wykorzystuję prostą klasę rysującą obraz rastrowy za pomocą znaków. Zaletą tego rozwiązania jest to, że możesz ten program uruchomić nawet w konsoli i będziesz mógł sprawdzić czy Twój algorytm działa. Nie musisz tworzyć żadnych prawdziwych plików graficznych i zastanawiać się w czym to obejrzeć. Na tę chwilę w zupełności Tobie to wystarczy.

Do poprawnego działania programu potrzebujesz klasy reprezentującej ekran. Możesz pobrać kod tej klasy i zapisać w pliku screen_terminal.py. Wykorzystanie klasy ilustruje poniższy program zapisany w pliku main.py:


from screen_terminal import Screen


def main():
  s = Screen(31, 31)
  s.moveOrigin(-10, -10)
  s.setPoint(4,0,"*")
  s.setPoint(0,4,"*")
  s.setPoint(4,4,"*")
  s.printScreen("plot.txt")


if __name__ == "__main__":
  main()
Jeśli oba pliki są w tym samym katalogu to po uruchomieniu pliku main.py zostanie utworzony plik plot.txt zawierający w sobie uproszczony rysunek układu współrzędnych i punktów w nim się znajdujących:


     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     *   *                     
     |                         
     |                         
     |                         
-----+---*---------------------
     |                         
     |                         
     |                         
     |                         
     | 
Jeśli wolisz tradycyjne, prawdziwe rysunki możesz pobrać odpowiednią bibliotekę przygotowaną przeze mnie, zapisać w tym samym katalogu co screen_terminal.py i zmodyfikować program zamieniając wiersze:


from screen_terminal import Screen

[...]

  s.printScreen("plot.txt")
na:


from screen_graphics import Screen

[...]

  s.printScreen("plot.png")
Rezultatem tych zmian będzie poniższy obrazek:

Rysunek 5: Testowy rysunek utworzony przy pomocy klasy Screen z pliku screen_graphics.py



Program rysujący linie pionowe:


from screen_terminal import Screen

def drawVerticalLine(screen, x1, y1, x2, y2, c):
  if x1 != x2:
    return False
  
  if y1 < y2:
    d = y2 - y1 + 1
    b = y1 
  else:
    d = y1 - y2 + 1
    b = y2
    
  for i in range(d):
    screen.setPoint(x1, b+i, c)
    
  return True


def main():
  s = Screen(31, 31)
  s.moveOrigin(-10, -10)
  drawVerticalLine(s, 1, 5, 1, 10, "*")
  s.printScreen("plot.txt")


if __name__ == "__main__":
  main()
i efekt jego działania (w zależnosci od wybranej przez Ciebie wersji – tekstowej lub graficznej):


     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |*                        
     |*                        
     |*                        
     |*                        
     |*                        
     |*                        
     |                         
     |                         
     |                         
     |                         
-----+-------------------------
     |                         
     |                         
     |                         
     |                         
     | 


Rysunek 6: Efekt działania funkcji rysującej linie pionowe


Zauważ, że programy te są do siebie bardzo podobne i wymagają kosmetycznych zmian. Dlatego połączę je teraz w jeden, który sam będzie określał jaka linia ma być rysowana (kod obu wcześniejszych funkcji został pominięty – musisz go wkleić w miejsce oznaczone [... PASTE CODE HERE ...]):


from screen_terminal import Screen


def drawHorizontalLine(screen, x1, y1, x2, y2, c):
  [... PASTE CODE HERE ...]


def drawVerticalLine(screen, x1, y1, x2, y2, c):
  [... PASTE CODE HERE ...]


def drawHorizontalVerticalLine(screen, x1, y1, x2, y2, c):  
  if drawHorizontalLine(screen, x1, y1, x2, y2, c):
    return True
  else:
    return drawVerticalLine(screen, x1, y1, x2, y2, c)
    
  return False


def main():
  s = Screen(31, 31)
  s.moveOrigin(-10, -10)
  drawHorizontalVerticalLine(s, 5, 1, 10, 1, "*")
  drawHorizontalVerticalLine(s, 1, 5, 1, 10, "*")
  s.printScreen("plot.txt")


if __name__ == "__main__":
  main()




     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |                         
     |*                        
     |*                        
     |*                        
     |*                        
     |*                        
     |*                        
     |                         
     |                         
     |                         
     |    ******               
-----+-------------------------
     |                         
     |                         
     |                         
     |                         
     | 


Rysunek 7: Efekt działania funkcji rysującej linie pionowe lub poziome


Przypadek linii ukośnej


Rysunek 8.1: Układ pikseli tworzących linie ukośne




Rysunek 8.2: Układ pikseli nie tworzących linii ukośnych


Założenia odnośnie położenia odcinka
Jak widać odcinki mogą być różnie położone w układzie współrzędnych. Zwłaszcza odcinki przebiegające ukośnie mogą w tym zakresie cechować się znaczną różnorodnością. Zauważ jednak pewną zależność jaka zachodzi w układzie współrzędnych:

Rysunek 9: Oktanty układu współrzędnych. Oktanty I, IV, V, VIII to obszary w których moduł nachylenia odcinka do osi OX jest z przedziału 0-45 stopni




Rysunek 10: Zależności pomiędzy współrzędnymi punktów rozmieszczonych w różnych oktantach


A zatem możemy ograniczyć się tylko do rozważania odcinków leżących w I oktancie, to znaczy odcinków o następujących własnościach:
  • Jeśli punkt $P_1 = (x_1, y_1)$ jest początkiem odcinka a punkt $P_2 = (x_2, y_2)$ jego końcem to:
    • $x_1 < x_2$
    • $y_1 \leq y_2$
  • Kąt nachylenia prostej (tzw. współczynnik kierunkowy) na której leży odcinek zawiera się w przedziale od 0 do 45 stopni.
  • Pewnym ułatwieniem może być założenie, że punkt $P_1$ zawsze ma współrzędne $(0,0)$.
Odcinki leżące w pozostałych oktantach można sprowadzić do przypadku odcinka leżącego w I oktancie:

Rysunek 11: Sprowadzanie przypadku odcinka leżącego w oktancie V i VI do przypadku odcinka leżącego w oktancie I


Zauważ, że przy tak przyjętych założeniach, jeśli zaczniesz rysować odcinek od lewej strony (czyli od $x_1$) to kolejne punkty tworzące odcinek będą miały współrzędną $y$ albo taką samą jak punkt poprzedzający, albo zwiększoną o 1. Nie będzie innych możliwości:

Rysunek 12: Piksele tworzące rastrowy odpowiednik rzeczywistego odcinka leżącego w I oktancie


Rysowanie odcinka, podejście pierwsze
Analizując podane do tej pory informacje, a wszczególności przyglądając się rysunkowi 12 możesz stwierdzić:
  • Dla każdego kolejnego punktu tworzącego odcinek, współrzędna $x$ zmienia się o stały przyrost niezależny od nachylenia prostej i wynoszący +1.
  • Dla każdego kolejnego punktu tworzącego odcinek, współrzędna $y$ zmienia się o pewien stały przyrost zależny od nachylenia prostej.
  • Pixelem należącym do odcinka staje się ten pixel, przez który "przechodzi" odcinek.
  • Posługując się przedstawionym rysunkiem, dla pewnego pixela o współrzędnych $(x, y)$ należącego do odcinka, możesz zauważyć następujące zależności: $$\Delta X = dx * i,$$ $$\Delta Y = dy * i,$$ gdzie $i = x-x_1$.

    Symbol $\Delta$ to grecka wielka litera delta (mała to $\delta$), której w matematyce bardzo często używamy do oznaczenia przyrostu, albo mówiąc inaczej: wartości o jaką zmienia się jakaś liczba lub też po prostu różnicy pomiędzy dwiema wartościami.

    Współczynnik kierunkowy $a$ odcinka o początku w punkcie o współrzędnych $(x_1, y_1)$ i końcu w punkcie o współrzędnych $(x_2, y_2)$ leżącego na prostej opisanej równaniem $y=ax+b$ obliczamy według wzoru: $$ a = \frac{y_2-y_1}{x_2-x_1} $$


Większość osób przyjmuje wzór na współczynnik kierunkowy $a$ jak prawdę objawioną. Nie zastanawia się dlaczego ma taką postać, ale przyjmuje, że "po prostu" tak jest i już. Jeśli jednak należysz do grona tych osób, które lubią wiedzieć, to już Ci wyjaśniam jak ten wzór wyprowadzić.

Wybierz na wykresie funkcji liniowej $y=ax+b$ dwa różne punkty $P_1$ i $P_2$, o współrzędnych $(x_1, y_1)$ i $(x_2, y_2)$. Wtedy: $$ y_1 = a \cdot x_1 + b $$ $$ y_2 = a \cdot x_2 + b $$ Zauważ, że: $$ b = y_1 - a \cdot x_1 $$ $$ b = y_2 - a \cdot x_2 $$ A zatem: $$ y_1 - a \cdot x_1 = y_2 - a \cdot x_2 $$ skąd: $$ y_1-y_2 = a \cdot x_1 - a \cdot x_2, $$ $$ y_1-y_2 = a (x_1 - x_2). $$ Ponieważ punkty $P_1$ i $P_2$ są różne i należą do wykresu funkcji, więc $x_1 \neq x_2$ i stąd $x_1 - x_2 \neq 0$. Wobec tego iloraz: $$ a = \frac{y_1-y_2}{x_1 - x_2} $$ jest ilorazem różnicy dwóch wartości funkcji liniowej przez różnicę odpowiadających im argumentów.

Patrząc na dwa różne punkty $P_1$ i $P_2$ należące do wykresu funkcji, współczynnik kierunkowy interpretujemy jako iloraz wartości przesunięcia $y_1-y_2$ wzdłuż osi OY do odpowiadającej mu wartości przesunięcia $x_1 - x_2$ wzdłuż osi OX.

Uwaga: Zauważ, że poprawne są oba następujące wzory: $$ a = \frac{y_1-y_2}{x_1 - x_2} $$ $$ a = \frac{y_2-y_1}{x_2 - x_1} $$ Wynika to z faktu, iż w równości: $$ y_1 - a \cdot x_1 = y_2 - a \cdot x_2 $$ możesz przenieść $y$-ki na lewą, a $x$-y na prawą stronę (tak jak to zostało zrobione powyżej), lub na odwrót: przenieść $y$-ki na prawą, a $x$-y na lewą stronę – w tym drugim przypadku otrzymasz drugą postać wzoru na współczynnik kierunkowy.

Z geometrii i twierdzenia Talesa wiemy, że (porównaj z rysunkiem 10): $$ a = \frac{y_2-y_1}{x_2-x_1} = \frac{\Delta Y}{\Delta X} = \frac{dy}{dx} $$ a stąd otrzymujesz: $$ dy = a \cdot dx $$ Po takiej dawce obserwacji i wiedzy matematycznej możesz napisać pierwszy program doskonujący rasteryzacji dowolnego odcinka leżącego w 1 oktancie (patrz założenia z sekcji Założenia odnośnie położenia odcinka):


def drawLine01(screen, x1, y1, x2, y2, c):
  a = (y2 - y1)/(x2 - x1)
  dx = 1
  dy = a * dx
  d = x2 + 1 - x1
  for i in range(d):
    deltaX = dx * (i)
    deltaY = dy * (i)
    xReal = x1 + deltaX
    yReal = y1 + deltaY
    xPixel = xReal
    # Find y which 'catch' your yReal
    yPixel = round(yReal)
    screen.setPoint(xPixel, yPixel, c)
Użyta w powyższym programie funkcja round(x) dla zadanego argumentu $x$ zwraca najbliższą liczbę całkowitą, np.:


round(1.0)=1
round(1.1)=1
round(1.2)=1
round(1.3)=1
round(1.4)=1
round(1.5)=2
round(1.6)=2
round(1.7)=2
round(1.8)=2
round(1.9)=2
round(2.0)=2
Kompletny program (tak jak poprzednio musisz uzupełnić fragment oznaczony [... PASTE CODE HERE ...]) przedstawiam poniżej:


from screen_terminal import Screen

def drawLine01(screen, x1, y1, x2, y2, c):
  [... PASTE CODE HERE ...]


def main():
  s = Screen(31, 31)
  s.moveOrigin(-10, -10)
  drawLine01(s, 0, 0, 25, 25, ".")
  drawLine01(s, 2, 1, 20, 2, "*")
  s.printScreen("plot.txt")


if __name__ == "__main__":
  main()
Po jego wykonaniu zobaczysz taki obrazek:


     |                        .
     |                       . 
     |                      .  
     |                     .   
     |                    .    
     |                   .     
     |                  .      
     |                 .       
     |                .        
     |               .         
     |              .          
     |             .           
     |            .            
     |           .             
     |          .              
     |         .               
     |        .                
     |       .                 
     |      .                  
     |     .                   
     |    .                    
     |   .                     
     |  .                      
     | .        **********     
     |.*********               
-----.-------------------------
     |                         
     |                         
     |                         
     |                         
     | 
Jeśli wolisz widzieć prawdziwy rysunek to pobierz odpowiednią bibliotekę przygotowaną przeze mnie i zmodyfikuj program do poniższej postaci:


from screen_image import Screen

def drawLine01(screen, x1, y1, x2, y2, c):
  [... PASTE CODE HERE ...]


def main():
  s = Screen(31, 31)
  s.moveOrigin(-10, -10)
  drawLine01(s, 0, 0, 25, 25, ".")
  drawLine01(s, 2, 1, 20, 2, "*")
  s.printScreen("plot.png")


if __name__ == "__main__":
  main()
W tym przypadku zobaczysz rysunek jaki prezentuję poniżej:

Rysunek 13: Efekt pierwszej próby implementacji algorytmu rysowania odcinka


Zaletą powyższego rozwiązania jest generowanie $i$-tego punktu zawsze względem stałego punktu odniesienia jakim jest początek odcinka czyli punkt $(x_1,y_1)$. Dzięki temu obliczenia dla jednego punktu nie wpływają na obliczenia dla innego punktu. Co więcej, można je wykonać w dowolnej kolejności a nawet wszystkie w tym samym czasie czyli równolegle co znacząco mogłoby przyspieszyć sam proces rasteryzacji odcinka.

Pomimo niewątpliwych zalet, powyższa implementacja ma też dużą wadę: złożoność czasową. Zauważ, że aby wyliczyć yPixel potrzeba wykonać dwie operacje zmiennoprzecinkowe: jedno mnożenie i jedno dodawanie. A na koniec trzeba dokonać zaokrąglenia za pomocą funkcji round(). A niestety operacje zmiennoprzecinkowe są bardzo kosztowne. Jak bardzo? Jeśli jesteś ciekaw, spójrz do zielonej ramki poniżej.

Jak bardzo kosztowne są obliczenia zmiennoprzecinkowe?

Aby zobrazować koszt wykonania instrukcji zmiennoprzecinkowej zobacz ile czasu zajmują obliczenia zmiennoprzecinkowe na układzie bez dedykowanej jednostki zmiennoprzecinkowej (8086 Emulation) i z dedykowaną jednostką zmiennoprzecinkową (8087):


Comparison of 8087 and 8086 Clock Times

                            | Approximate execution time (in us)
                            +-------------+---------------------
Instruction                 | 8087        | 8086 Emulation
                            | 8 MHz clock |
----------------------------+-------------+---------------------            
Add/Subtract                | 10          | 1000
Multiply (single precision) | 11.9        | 1000
Multiply (double precision) | 16.0        | 1312
Divide (single precision)   | 24.4        | 2000
Compare                     |  5.6        | 812
Load (double precision)     |  6.3        | 1062
Store (double precision)    | 13.1        | 750
Square root                 | 22.5        | 12250
Tangent                     | 56.3        | 8125
Exponentiation              | 62.5        | 10687


Na przykład, aby wykonać zwykłe dodawanie zmiennoprzecinkowe na jednostce nieposiadającej sprzętowego wsparcia dla tego typu działań (rozważamy procesor 8086 i koprocesor 8087, ale podobne zależności zachodzą dla wszelkich układów tego typu) należy wykonać bardzo dużo różnych operacji na które potrzeba aż 1000us. Oczywiście to samo dodwanie możesz wykonać znacznie szybciej o ile masz w swoim komputerze czy mikrokontrolerze specjalny dedykowany układ zmiennoprzecinkowy – wówczas zajmie Tobie to 100 razy mniej czasu! Weź jednak pod uwagę, że taki specjalizowany układ jest niesamowicei skomplikowany – popatrz na poniższy rysunek, gdzie zobaczysz jaką powierzchnię zajmuje część dedykowana dla instrucji zmiennoprecinkowych (Floating point / SIMD) a jaka dla instrukcji całkowitoliczbowych (ALU):

Rysunek 14: Porównanie powierzchni zajmowanej przez część dedykowaną dla instrucji zmiennoprecinkowych (Floating point / SIMD) i dla instrukcji całkowitoliczbowych (ALU) we współczesnym procesorze AMD [Algorithmica: Modern Hardware]


Nawet wówczas, gdy zdcydujesz się na specjalny układ wykonujący instrukcje zmiennoprzecinkowe to i tak będą one jednak wolniejsze od instrukcji działających na liczbach całkowitych, które w zależności od wersji zajmowały od 3 do 29 cykli pracy procesora co oznacza, że przy zegarze 8MHz (układ 8086 mógł pracować z częstotliwością 5MHz, 8MHz i 10MHz – przyjąłem wartość "średnią") zajmowało to od 0.375us do 3.625:

Przedrostek mikro oznacza mnożnik o wartości $10^{-6} = 0.000001$ czyli jedną milionową. Zatem przy zegarze 8MHz w ciągu 1us masz 8 "tyknięć" zegara. Tak więc 3 "tyknięcia" trwają 3/8us = 0.375us a 29 "tyknięć" trwa 29/8us = 3 i 5/8us = 3.625us.

Co gorsza, nie wszystkie układy potrafią wykonywać operacje zmiennoprzecinkowe (w przeciwieństwie do operacji na liczbach całkowitych) – po prostu, ze względu na koszt związany z budową i obsługą jednostki dedykowanej do operacji zmiennoprzecinkowych, są one jej pozbawione.
Rysowanie odcinka, podejście drugie
Spróbujesz teraz poprawić ten program. Jak zaznaczyłem wcześniej, jeśli przyrosty na osi OX będą stałe i równe 1 (to znaczy: dwa kolejne punkty tworzące prostą będą miały współrzędne x różniące się o 1) to także przyrosty na osi OY będą stałe. Oznacza to, że można współrzędne punktu o indeksie i+1 obliczyć w oparciu o współrzędne punktu bezpośrednio go poprzedzającego, tj. punktu o współrzędnych i:

Rysunek 15: Obliczanie współrzędnych punktu o indeksie i+1 w oparciu o współrzędne punktu bezpośrednio go poprzedzającego, tj. punktu o współrzędnych i


Stosując taki ciąg zależności, tj. zależność punktu i+1 od punktu i, potem zależność punktu i+2 od punktu i+1, dalej zależności punktu i+3 od punktu i+2 itd. otrzymujesz zmodyfikowaną wersję poprzedniego programu (przedstawiam tylko kod funkcji; aby ją wykonać wklej ją do poprzedniego programu i w funkcji main() wywołanie funkcji drawLine01() zastąp wywołaniem drawLine02()):


def drawLine02(screen, x1, y1, x2, y2, c):
  a = (y2 - y1)/(x2 - x1)
  d = x2 + 1 - x1
  xReal = x1
  yReal = y1
  for i in range(d):
    xPixel = xReal
    yPixel = round(yReal)
    screen.setPoint(xPixel, yPixel, c)
    
    xReal += 1
    yReal += a
Zapewne stwierdzisz, iż w zasadzie różnica jest niewielka. A jednak różnica jest istotniejsza niż może się Tobie wydawać:
  • współrzędna x zwiększa się przy każdej iteracji o stałą wartość równą 1;
  • aby obliczyć kolejną wartości współrzędnej x wystarczy wykonać tylko jedno dodawania całkowitoliczbowe.
Teoretycznie podobnie wygląda sprawa ze współrzędną y. Także i ona zwiększana jest przy każdej iteracji o stałą wartość a – ponownie nie ma mnożenia i pozostaje jedynie dodawanie. Jednakże a nie jest liczbą całkowitą – jest liczbą rzeczywistą. I w tym momencie natrafiamy na problem, którego nie dostrzegamy w świecie rzeczywistym, a który pojawia się w świecie cyfrowym. Chodzi o zagadnienie związane z powstawaniem błędów numerycznych.

Błędy numeryczne

Mówiąc bardzo ogólnie, liczby rzeczywiste nie są reprezentowane na komputerze dokładnie, ale jedynie z pewnym przybliżeniem. W świecie rzeczywistym też bardzo często postępujemy w ten sposób. Rozważmy dwie liczby wymierne $\frac{1}{5}$ oraz $\frac{1}{3}$. Pierwsza z nich bez problemu daje się zapisać jako liczba rzeczywista: $$ \frac{1}{5} = 0.2. $$ Z drugą mieć już będziemy problem, gdyż nie posiada ona skończonego zapisu rzeczywistego. Możemy ją jedynie przybliżać tworząc co raz to dłuższe liczby, ale żadna z nich nie będzie dokładna: $$ \begin{array}{rcl} \frac{1}{3} & \approx & 0.3\\ \frac{1}{3} & \approx & 0.33\\ \frac{1}{3} & \approx & 0.333\\ \end{array} $$ Jeśli teraz policzysz błąd reprezentacji to wynosi on: $$ \begin{array}{rcl} \frac{1}{3} - 0.3 & = & \frac{1}{3} - \frac{3}{10} = \frac{10-9}{30} = \frac{1}{30}\\ \frac{1}{3} - 0.33 & = & \frac{1}{3} - \frac{33}{100} = \frac{100-99}{300} = \frac{1}{300}\\ \frac{1}{3} - 0.333 & = & \frac{1}{3} - \frac{333}{1000} = \frac{1000-999}{3000} = \frac{1}{3000}\\ \end{array} $$ A więc im dłuższa reprezentacja, tym błąd będzie mniejszy, ale nigdy nie będzie wynosił dokładnie 0.

Tym bardziej wykonywanie obliczeń na liczbach, które nie są dokładne prowadzi do jeszcze mniej dokładnych wyników.

Rozważmy następujący przykład w którym wykorzystamy liczbę $x = 2.31$ reprezentowaną z błędem $0.02$ oraz liczbę $y = 1.42$ reprezentowaną z błędem $0.03$. Tak więc do obliczeń nie bierzesz dokładnej wartości liczby 2.32 ale jakąś liczbę za pomocą której tę dokładną wartość przybliżasz. Ta "jakaś" liczba mieści się w przedziale od 2.32-0.02 do 2.32+0.02. Podobnie sprawa wygląda z liczbą $y = 1.42$ dla której "jakaś" liczba mieści się w przedziale od 1.42-0.03 do 1.42+0.03.

Rysunek 16: Reprezentacja liczb w komputerze. Dokładne są tylko liczby $x$, $y$ oraz $z$. Wszystkie liczby z czerwonego "otoczenia" punktu $y$ są "zamieniane" na $y$. A zatem liczba dokładna $y+e$, gdzie $e$ jest pewną małą liczbą w porównaniu do $y$, będzie w komputerze reprezentowana jako $y$


Jeśli teraz wykonasz na takich "jakichś" liczbach elementarną operację $x+y$ wówczas zauważysz, że:
  • najmniejsza możliwa wartość różnicy $x$ i $y$ wynosi $(2.31−0.02)+(1.42 - 0.03) = 3.68$;
  • największa możliwa wartość różnicy $x$ i $y$ wynosi $(2.31 + 0.02)+(1.42+0.03) = 3.78$.
W konsekwencji otrzymujesz, że wartość różnicy mieści się w przedziale [3.68, 3.78] a więc wynikiem jest liczba 3.73 reprezentowana z błędem 0.05 czyli większym, niż zadane liczby – w wyniku operacji błąd nam się powiększył. Nazywamy to zjawisko akumulowaniem błędów.

Jeśli poświęciłeś czas na zapoznanie się z powyższą zieloną ramką to właśnie z przedstawionym tam problemem masz do czynienia w zmodyfikowanym programie.
  • Po pierwsze obliczona wartość a nie będzie na komputerze reprezentowana dokładnie – a więc masz pierwszy błąd.
  • Po drugie, wielokrotne sumowanie w instrukcji y += a będzie akumulowało błędy – każda kolejna wartość y będzie co raz bardziej "odbiegała" od teoretycznej wartości dokładnej. Dla przykładu, wynikiem działania poniższego kodu:

    
    e = 0.1
    v = e + e + e
    if v == 0.3:
        print("equal")
    else:
        print(f"different: {v}")
    
    będzie:

    
    different: 0.30000000000000004
    
Akumulacja błędów jest faktem, choć trudno nam będzie to dostrzec. W języku Python bowiem (a w tym języku pisane są wszystkie przykłady) używany jest typ zmiennoprzecinkowy podwójnej precyzji, tzw. double przez co obliczenia na nim prowadzone znacznie później doprowadzaj do widocznych błędów. Jeśli jednak używasz języka gdzie możesz zdcydować o wyborze typu zmiennoprzecinkowego i wybierzesz typ pojedynczej precyzji, tzw. float, wówczas jeśli za pomocą tego algorytmu spróbujesz narysować odcinek $(0,100) - (400,101)$, to otrzymasz (niepoprawny) rysunek jak poniżej:

Rysunek 17: Niepoprawny obraz odcinka $(0,100) - (400,101)$ w przypadku użycia typu zmiennoprzecinkowego pojedynczej precyzji


Rysowanie odcinka, podejście trzecie
Z powodów podanych w poprzedniej sekcji modyfikujemy program aby uwzględniać możliwe błędy numeryczne. Posłużymy się nowa zmienną error, której interpretację przedstawiam na rysunkach poniżej:

Rysunek 18: Interpretacja zmiennej error


Jak widzisz error jest błędem określającym jak daleko jest piksel od rzeczywistej wartości reprezentowanej przez prostą.
  • Jeśli error jest poniżej 0.5 (a więc error < 0.5) wówczas nowy pixel $A$ ma taką samą współrzędną y jak pixel poprzedni $P$.
  • Jeśli error jest równy lub większy niż 0.5 (a więc error >= 0.5) wówczas nowy pixel $B$ ma współrzędną y powiększoną o 1 względem pixela poprzedniego $P$.
Zauważ, że przy wyborze punktu $B$ rzeczywisty przebieg prostej jest poniżej punktu $(x+1, y+1)$ a więc błąd jaki w tym momencie otrzymujesz wynosi error - 1. Odpowiednio zmodyfikowany program przedstawiam poniżej (podobnie jak poprzednio przedstawiam tylko kod funkcji; aby ją wykonać wklej ją do poprzedniego programu i w funkcji main() wywołanie funkcji drawLine02() zastąp wywołaniem drawLine03()):


def drawLine03(screen, x1, y1, x2, y2, c):
  a = (y2 - y1)/(x2 - x1)
  d = x2 + 1 - x1
  xPixel = x1
  yPixel = y1
  error = 0.0
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    error += a
    if error >= 0.5:
      yPixel += 1
      error -= 1.0
Tym razem otrzymane wyniki są zgodne z naszą intuicją i to niezależnie od wybranego typu zmiennoprzecinkowego:

Rysunek 19: Poprawny obraz odcinka $(0,100) - (400,101)$ otrzymany przez zmodyfikowany program


Rysowanie odcinka, podejście czwarte
Program podany w poprzedniej sekcji, choć bardziej poprawny to jednak nadal zawiera w sobie element, którego musimy (chcemy) się pozbyć – otóż cały czas część obliczeń wykonywana jest na liczbach rzeczywistych, których z powodów wymienionych wcześniej, warto się pozbyć.

Poniżej pokażę ciąg przekształceń, które program w obecnej wersji doprowadzą do wersji działającej tylko na liczbach całkowitych.


def drawLine03(screen, x1, y1, x2, y2, c):
  a = (y2 - y1)/(x2 - x1)
  d = x2 + 1 - x1
  xPixel = x1
  yPixel = y1
  error = 0.0
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    error += a
    if error >= 0.5:
      yPixel += 1
      error -= 1.0
Dla uproszczenia przekształceń uzyjesz dwóch nowych zmiennych: dx = x2 - x1 oraz dy = y2 - y1:


def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  a = dy/dx
  d = dx + 1
  xPixel = x1
  yPixel = y1
  error = 0.0
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    error += a
    if error >= 0.5:
      yPixel += 1
      error -= 1.0
Pozbędziesz się teraz liczb rzeczywistych wprowadzonych przez współczynnik $a$ mnożąc obie strony wzoru dla tego współczynnika przez dx:


def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  dx*a = dy   <-- 1: Tutaj mnożysz
  d = dx + 1
  xPixel = x1
  yPixel = y1
  dx*error = 0.0   <-- 5: Podobnie jak w przypadku 4
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    dx*error += dx*a       <-- 2: Ponieważ pomnożyłeś 'a' w 1, więc musisz także i tutaj (w konsekwencji mnożysz obie strony)
    if dx*error >= dx*0.5: <-- 3: Ze względu na 2 'error' mnożysz także i tutaj (w konsekwencji mnożysz obie strony)
      yPixel += 1
      dx*error -= dx*1.0   <-- 4: Podobnie jak w przypadku 3
Aby w instrukcji warunkowej pozbyć się liczby rzeczywistej (obecnie masz tam mnożenie przez 0.5) należy wykonać mnożenie przez 2:


def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  2*dx*a = 2*dy   <-- 4: Podobnie jak w przypadku 3
  d = dx + 1
  xPixel = x1
  yPixel = y1
  2*dx*error = 0.0   <-- 5: Podobnie jak w przypadku 4
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    2*dx*error += 2*dx*a       <-- 3: W konsekwencji musisz pomnożyć też tutaj
    if 2*dx*error >= 2*dx*0.5: <-- 1: Tutaj mnożysz przez 2
      yPixel += 1
      2*dx*error -= 2*dx*1.0   <-- 2: W konsekwencji muszisz pomnożyć tutaj
Dokonaj uproszczenia w instrukcji warunkowej:


def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  2*dx*a = 2*dy
  d = dx + 1
  xPixel = x1
  yPixel = y1
  2*dx*error = 0.0
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    2*dx*error += 2*dx*a
    if 2*dx*error >= dx: <-- 1: Prawa strona ulegnie uproszczeniu
      yPixel += 1
      2*dx*error -= 2*dx*1.0
Dokonaj podstawienia za 2*dx*a wyrażenia 2*dy:


def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  2*dx*a = 2*dy   <-- 2: Tego już nie potrzebujesz
  d = dx + 1
  xPixel = x1
  yPixel = y1
  2*dx*error = 0.0
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    2*dx*error += 2*dy   <-- 1: Tutaj podstawiłeś
    if 2*dx*error >= dx:
      yPixel += 1
      2*dx*error -= 2*dx*1.0  <-- 3: Usuń mnożenie przez 1.0 gdyż nie zmienia ono wartości wyrażenia
Od obu stron nierówności odejmij dx:


def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  d = dx + 1
  xPixel = x1
  yPixel = y1
  2*dx*error-dx = 0.0-dx   <-- 4: Odjąłeś 'dx'
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    2*dx*error-dx += 2*dy   <-- 3: Odjąłeś 'dx'
    if 2*dx*error-dx >= 0:  <-- 1: Odjąłeś 'dx'
      yPixel += 1
      2*dx*error-dx -= 2*dx*1.0  <-- 2: Odjąłeś 'dx'
Analizując powyższe zmiany zwróć uwagę na poniższą zależność:


x = x + y

Od obu stron odejmuję 'a':

x - a = x + y - a
x - a = x - a + y

a więc:

x-a += y

A zatem gdy używasz operatora += możesz mieć wrażenie,
że odejmujesz tylko od jednej strony.
Teraz przez zmienną E oznacz wyrażenie 2*dx*error-dx:


def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  d = dx + 1
  xPixel = x1
  yPixel = y1
  E = -dx  <-- Użyj E
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    E += 2*dy
    if E >= 0: <-- Użyj E
      yPixel += 1
      E -= 2*dx  <-- Użyj E
To już koniec. Ostateczną wersję programu przedstawiam poniżej:


from screen_image import Screen

def drawLine04(screen, x1, y1, x2, y2, c):
  dx = x2 - x1
  dy = y2 - y1
  d = dx + 1
  xPixel = x1
  yPixel = y1
  E = -dx
  
  for i in range(d):
    screen.setPoint(xPixel, yPixel, c)
    xPixel += 1
    E += 2*dy
    if E >= 0:
      yPixel += 1
      E -= 2*dx


def main():
  s = Screen(500, 160)
  s.moveOrigin(-200, -70)
  drawLine04(s, 0, 100, 400, 101, "*")
  drawLine04(s, 0, 0, 400, 10, "*")
  drawLine04(s, 0, 0, 400, 50, "*")
  drawLine04(s, 0, 0, 400, 100, "*")
  s.printScreen("plot.png")


if __name__ == "__main__":
  main()
Gdy go uruchomisz zobaczysz rysunek jak poniżej:

Rysunek 20: Obrazy odcinków otrzymanych przez zmodyfikowany program działający tylko i wyłącznie na liczbach całkowitych


W ten oto sposób ostatecznie otrzymujemy algorytm nazywany algorytmem Bresenham-a (Jack Elton Bresenham wymyślił go w 1962 pracując w labolatoriach firmy IBM w San Jose). W tym przypadku działa on dla odcinków. Jeśli jednak rozumiesz zagadnienia jakie opisywałem powyżej, nie będziesz miał problemów z jego bardziej uniwersalną wersją działającą dla okręgów, elips a w ogólności dla dowolnych krzywych.