Rysowanie odcinków
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.
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 |
|
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.
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.