| Autor: | Wojciech Muła |
|---|---|
| Dodany: | 5.02.2002 |
| Aktualizacja: | 7.03.2016 (link do githuba, wyniki z Core i5), 31.05.2008 |
Contents
Rozmycie obrazu (w najprostszej wersji) realizuje się jako sumę ośmiu sąsiednich pikseli 'o' piksela 'x', lub żeby być precyzyjnym: blur(x) = (a+b+c + d+x+e + f+g+h)/9.
o o o a b c o x o d x e o o o f g h
Rozmycie przeprowadza się w podwójnej pętli:
for y := y_min to y_max do
for x := x_min to x_max do
begin
a = getpixel(x-1, y-1);
b = getpixel(x , y-1);
c = getpixel(x+1, y-1);
d = getpixel(x-1, y);
x = getpixel(x , y);
e = getpixel(x+1, y);
f = getpixel(x-1, y+1);
g = getpixel(x , y+1);
h = getpixel(x+1, y+1);
avg = (a+b+c + d+x+e + f+g+h)/9;
putpixel(x, y, avg); # zapis do *nowego* obrazu
end;
Jak łatwo policzyć na każdy rozmywany piksel należy pobrać (getpixel) aż 9 pikseli, co dla całego obrazu daje 9 ⋅ (ymax − ymin) ⋅ (xmax − xmin). Np. dla obrazu o rozdzielczości 320x200 daje to aż 576000 odwołań do obrazu.
Ponadto należy, dla każdego piksela, wykonać 8 operacji dodawania.
W artykule pokażę jak te liczby zmniejszyć — i w efekcie przyspieszyć algorytm — oraz jak użyć rozkazów MMX/SSE.
Uwaga 1: Ze względu na wydajność należy rozmywać obraz obcięty z każdej strony po jednym pikselu, tj. np. zamiast (0,0)-(320,20) rozmywać (1,1)-(319,199). Problem sprawiają bowiem piksele brzegowe, np. w punkcie (0,0) musielibyśmy pobrać 5 nieistniejących w obrazie pikseli. Pół biedy z przyjęciem ich wartości (czarne, białe, powtórzone, losowe, itp.), największe zmartwienie to właśnie sprawdzenie czy piksel „istnieje” czy nie.
Dopisek 14.07.2007: jednak w przedstawionej tutaj modyfikacji, dość łatwo poradzić sobie z tymi „brakującymi” pikselami. W przykładowym programie zostało to rozwiązane, można ustalić kolor obrzeża.
Uwaga 2: W przykładowym kodzie wykorzystuję funkcje getpixel/putpixel, które stawiają, bądź pobierają piksel na zadanym, domyślnym obrazie. Faktycznie te operacje wykonuje się na wskaźnikach, ale dla czytelności użyłem postaci funkcyjnej.
Rozpatrzmy rozmycie dwóch kolejnych pikseli w jednej linii, tzn. najpierw rozmywamy piksel (x,y) ozn. X, a następnie (x+1,y) ozn. Y.
a b c d e X Y f g h i j
blur(X) = (a+b+c + e+X+Y + g+h+i)/9 blur(Y) = (b+c+d + X+Y+f + h+i+j)/9
Zobaczmy, że w wyrażeniu na blur(Y) część pikseli powtarza się, doszły zaledwie trzy nowe: d, f oraz j. Teraz jeszcze jedna rzecz: celowo grupuję w wyrażeniach po 3 piksele, bowiem każda „grupa” (suma cząstkowa) pochodzi z innej linii. Proszę teraz zobaczyć jak wyglądałby wzór na rozmycie piksela h, znajdującego się pod pikselem X, tj. (x,y+1):
blur(h) = (e+X+Y + g+h+i + .+.+.)/9
Aż dwie „grupy” występujące w wyrażeniu na blur(X) powtarzają się w blur(h). Zatem wystarczy zapamiętać wszystkie sumy cząstkowe z dwóch wcześniejszych linii y, co oszczędzi 4 dodawania i 6 odczytów pikseli. Do efektywnego liczenia sum poszczególnych grup wykorzystamy odnotowany wcześniej fakt, że przejście do następnego piksela w jednej linii y powoduje pojawienie się 3 nowych pikseli w wyrażeniu: po jednym na każdą grupę.
Na początku kod w C który dla zadanej linii y liczy wszystkie sumy w obrazie grayscale. Ponieważ na sumę cząstkową składają się wartości trzech pikseli (bajtów), więc musi być ona przechowywana w słowie.
#define SIZE_X ... // rozmiary
#define SIZE_Y ... // obrazu XxY
typedef byte Image_line[SIZE_X]; // linia obrazu
typedef Image_line Image[SIZE_Y]; // obraz
typedef word Sum[SIZE_X-2]; // tablica sum
/*
* image - wskaźnik do początku y-owej linii
* count - szerokość obrazu
* sum - tablica sum (rozmiar count-2)
*/
void calc_sum(Image_line L, Sum S) {
byte a, b, c;
int x, i;
a = L[0]; // pobranie dwóch początkowych
b = L[1]; // pikseli
i = 0;
for (x=2; x<count; x++) {
c = L[x]; // w każdej pętli pobierany
sum[i++] = a+b+c; // jest jeden nowy piksel
a = b; // natomiast dwa pozostają
b = c; // po wcześniejszej iteracji
}
}
Funkcja rozmywająca obraz.
/*
* image_in - obraz rozmywany
* image_out - wynik rozmycia
*/
void blur_image(Image image_in, Image image_out) {
Sum a, b, c;
Sum *A, *B, *C, *T;
word s;
int x, y;
A = &a; // używane są wskaźniki do tablic sum
B = &b; // żeby uniknąć przesłań pamięci;
C = &c; // wystarczy "rotacja" wskaźników
calc_sum(image_in[0], A);
calc_sum(image_on[1], B);
for (y=1; y < SIZE_Y-1; y++) {
// w każdej pętli liczona są wyłącznie sumy cząstkowe
// dla linii y+1; sumy cząstkowe z linii y i y-1
//pochodzą z wcześniejszej iteracji
calc_sum(image_in[y+1], C);
// rozmycie
for (x=0; x < SIZE_X-2; x++) {
s = (A[x] + B[x] + C[x])/9;
putpixel(image_out, x, y, s);
}
// "rotacja" wskaźników
T = A;
A = B;
B = C;
C = T;
}
}
W implementacji asemblerowej powyższej funkcji problemem może być przechowywanie trzech wskaźników, będą bowiem potrzebne 3 rejestry. Można połączyć tablice A, B i C w jedną i operować na przesunięciu względem jej początku.
void blur_image(Image image_in, Image image_out) {
#define SUM_SIZE (SIZE_X - 2)
word abc[SUM_SIZE*3];
int offset = 0;
word s;
int x, y;
calc_sum(image_in[0], (Sum*)abc[0*SUM_SIZE]);
calc_sum(image_in[1], (Sum*)abc[1*SUM_SIZE]);
for (y=1; y<SIZE_Y-1; y++) {
calc_sum(image[y+1], (Sum*)t[offset]);
// rozmycie
for (x=0; x < SIZE_X-2; x++) {
s = (abc[0*SUM_SIZE] +
abc[1*SUM_SIZE] +
abc[2*SUM_SIZE])/9;
putpixel(image_out, x, y, s);
}
// rotacja bufora
if (offset == 2*SUM_SIZE)
offset = 0;
else
offset += SUM_SIZE;
}
#undef SUM_SIZE
}
calc_sum:
; edi - tablica 'ImageLine'
; esi - tablica 'Sum'
mov ecx, SIZE_X-2 ; liczba iteracji
xor ah, ah
xor bh, bh
xor dh, dh
mov al, [edi ] ; pobierz a (=[edi-2])
mov bl, [edi+1] ; pobierz b (=[edi-1])
; c (=[edi])
add edi, 2
_sum:
mov dl, [edi] ; pobierz c, tylko jedną nowa wartość
add ax, bx ; a jest tracone w następnej iteracji
add ax, dx ; więc zostanie użyte do obliczeń
mov [esi], ax ; zapisz sumę do tablicy 'Sum'
mov ax, bx ; a=b
mov bx, dx ; b=c
inc edi
add esi, byte 2
loop _sum
ret
Rozkazy MMX o wiele lepiej nadaje się do takich obliczeń.
calc_sum:
mov ecx, SIZE_X/8
pxor mm7, mm7
_sum:
; edi -> |h|g|f|e|d|c|b|a|...
movq mm0, [edi+0] ; mm0 = |h|g|f|e|d|c|b|a|
movq mm1, [edi+1] ; mm1 = |i|h|g|f|e|d|c|b|
movq mm2, [edi+2] ; mm2 = |j|i|h|g|f|e|d|c|
movq mm3, mm0
movq mm4, mm1
movq mm5, mm2
punpcklbw mm0, mm7 ; mm0 = | d | c | b | a |
punpcklbw mm1, mm7 ; mm1 = | e | d | c | b |
punpcklbw mm2, mm7 ; mm2 = | f | e | d | c |
punpckhbw mm3, mm7 ; mm3 = | h | g | f | e |
punpckhbw mm4, mm7 ; mm3 = | i | h | g | f |
punpckhbw mm5, mm7 ; mm3 = | j | i | h | g |
paddw mm0, mm1 ;
paddw mm0, mm2 ; mm0 = |def|cde|bcd|abc|
paddw mm3, mm4 ;
paddw mm3, mm5 ; mm3 = |hij|ghi|fgh|efg|
movq [esi+0], mm0
movq [esi+8], mm1
add edi, 8
add esi, 16
loop _sum
ret
13.04.2008: Zobacz niżej nt optymalizacji tej funkcji dla procesorów Pentium II.
Nieporuszanym jeszcze problemem jest dzielenie przez 9. W przypadku kodu x86 (a także kodu w C) w zupełności wystarczy tablica z prekalkulowanymi wartościami.
byte div_tbl[9*255];
...
for (i=0; i<9*255; i++)
div_tbl[i] = i/9;
...
void blur(Image image)
...
s = div_tbl[A[x] + B[x] + C[x]];
...
Natomiast MMX dostarcza tylko rozkazów mnożenia, wykorzystamy jednak pewien trik. Jak być może wiesz, przesunięcie bitowe w prawo, to dzielenie przez pewną potęgę dwójki. Można tę własność wykorzystać — wzór na dzielenie całkowitoliczbowe przez dowolną liczbę k jest następujący:
x/k = (x*(2^n/k)) >> n
Czynnik 2^n/k jest liczony w trakcie kompilacji; w rozważanym przypadku (k=9, n=16) wyniesie on d = 65536/9 = 7281 = 0x1c71. Dlaczego n=16? Rozkazy MMX pmulhw oraz pmuluhw obliczają starsze słowo z mnożenia, a więc faktycznie, przesunięty w prawo o 16 bitów, (32-bitowy) wynik mnożenia.
Dopisek 14.07.2007: żeby naprawdę mnożenie, a potem przesunięcie dało identyczny wynik co dzielenie, należy przed przesunięciem dodać do wyniku mnożenia wartość d, czyli (x * d + d) >> 16. Bez dodawania różnica wartości pomiędzy (x * d) >> 16 a zwykłym dzieleniem całkowitoliczbowym jest co najwyżej 1. Dlatego wolałem zostać przy tym prostszym wyrażeniu, bo z jednej strony różnica jest niezauważalna, z drugiej odpada jedno działanie.
Co prawda rozkaz pmulhw mnoży liczby ze znakiem, lecz 0x1c71 w zapisie U2 jest liczbą dodatnią, więc nie będzie problemu z jego wykorzystaniem.
segment .data
mmx_coef dd 0x1c711c71, 0x1c711c71
segment .code
calc_average_mmx:
; edi - A
; esi - B
; ebx - C
; edx - linia obrazu do której zapisywany jest wynik
movq mm2, [mmx_coef]
mov ecx, SIZE_X-2
_sum:
movq mm0, [edi]
addw mm0, [esi]
addw mm0, [ebx] ; sumuj 4 młodsze
movq mm1, [edi+8]
addw mm1, [esi+8]
addw mm1, [ebx+8] ; i 4 starsze grupy pikseli
pmulhw mm0, mm2 ; mm0/9, mm1/9
pmulhw mm1, mm2 ; packed_word będą w zakresie 0..255
packuswb mm0, mm1 ; połącz wyniki
movq [edx], mm0 ; zapisz
add edi, 16 ; przesuń wskaźniki
add esi, 16 ;
add ebx, 16 ;
add edx, 8 ;
loop _sum
ret
Przy założeniu, że przedstawiona powyżej funkcja calc_average_mmx, została zadeklarowana w C:
calc_average_mmx(Image_line, Sum, Sum, Sum);
można ją wykorzystać w sposób następujący:
void blur_image(Image image_in, Image image_out) {
Sum a, b, c;
Sum *A, *B, *C, *T;
word s;
int x, y;
A = &a;
B = &b;
C = &c;
calc_sum(image_in[0], A);
calc_sum(image_out[1], B);
for (y=1; y<SIZE_Y-1; y++) {
calc_sum(image[y+1], C);
calc_average_mmx(image_out[y], A, B, C);
T = A;
A = B;
B = C;
C = T;
}
}
W przykładowym programie zamieściłem również implementację SSE2. Poza wyrównaniem wskaźników i użyciu rozkazu PALIGNR do pobierania niewyrównanych danych w calc_sum cały kod zasadniczo został przełożony z wersji MMX automatycznie.
Natomiast w procedurze nazwanej umownie MMX2 zamiast odwołań do niewyrównanych adresów wyciąga się odpowiednie dane za pomocą dwóch przesunięć bitowych oraz jednej opracji sumy bitowe. Zamiast:
0:
movq 0(%esi), %mm0
movq 1(%esi), %mm1
movq 2(%esi), %mm2
// ...
// dalsza część pętli [wykorzystywane dodatkowo mm3, mm4]
// ...
jnz 0b
Można czytać w jednej iteracji wyrównane do granicy 8 bajtów dane (a przetwarzać 16 kolejnych bajtów) i tworzyć podzakresy sekwencją dwóch przesunięć bitowych i operacji sumy:
;
movq 0(%esi), %mm6
0:
// mm0:mm3 -- 16 kolejnych bajtów
movq %mm6, %mm0
movq 8(%esi), %mm3
// zapamiętanie mm3 dla następnej iteracji
movq %mm3, %mm6
movq %mm0, %mm1
movq %mm0, %mm2
movq %mm3, %mm4
// utworzenie mm1
pslrq $8, %mm1
psllq $64-8, %mm3
por %mm3, %mm1
// utworzenie mm2
pslrq $16, %mm2
psllq $64-16, %mm4
por %mm4, %mm2
// ...
// dalsza część pętli [wykorzystywane dodatkowo mm3, mm4]
// ...
jnz 0b
Na Pentium II, pomimo sporego rozrostu kodu, to rozwiązanie pozwala przyspieszyć kod o ok. 30 procent; przyspieszenie w stosunku do analogicznego kodu x86 wzrasta z ok. 3.8 do ok. 4.7. Na Celeronie M praktycznie bez zmian, chyba nawet wolniej działała druga wersja.
Celem opisanych wyżej algorytmów, jest minimalizacja odwołań do pamięci i liczby dodawań. W przypadku bezpośredniej implementacji wzoru liczba odwołań do pamięci obrazu wynosiła 9*(SIZE_X-2)*(SIZE_Y-2), natomiast liczba operacji dodawania 8*(SIZE_X-2)*(SIZE_Y-2).
Po optymalizacji liczba odwołań do pamięci obrazu zmaleje, wyniesie tylko SIZE_X*SIZE_X. Co prawda liczba odwołań do tablic sum a, b i c (lub abc) nie będzie mniejsza, ale z racji ich relatywnie małego rozmiaru, będzie mniej nietrafień w cache.
Liczba operacji sumowania wynosie natomiast 2*(SIZE_X-2)*(SIZE_Y) + 2*(SIZE_Y)*(SIZE_X-2).
Porównanie metody wejściowej i zoptymalizowanego algorytmu dla obrazu o wielkości 320x200 pikseli
| liczba operacji dodawania | liczba odwołań do pamięci obrazu | |
|---|---|---|
| metoda podstawowa | 503 712 | 566 676 |
| po optymalizacji | 253 920 (50,4%) | 64 000 (11,3%) |
Przy użyciu kodów MMX, zaprezentowanych w tym artykule, przyrost prędkości był naprawdę imponujący — lekko licząc ok. 2-3 razy w stosunku do kodu x86 używającego tej samej, zoptymalizowanej metody.
Poniżej wyniki testów przykładowego programu przeprowadzane na dwóch maszynach działających pod kontrolą Linuksa.
| procedura | czas [user] | przyspieszenie | |
|---|---|---|---|
| x86 | 14.973 | 1.0 | █████ |
| MMX | 3.996 | 3.7 | ██████████████████ |
| MMX2 | 3.156 | 4.7 | ███████████████████████ |
CPU: Core 2 Duo E8200 @ 2.6GHz
| procedura | czas [user] | przyspieszenie | |
|---|---|---|---|
| x86 | 8.221 | 1.0 | █████ |
| MMX | 2.852 | 2.8 | ██████████████ |
| MMX2 | 2.516 | 3.2 | ████████████████ |
| SSE2 | 1.276 | 6.4 | ████████████████████████████████ |
CPU: Core i5 M 540 @ 2.53GHz
Uwaga: kod 32-bitowy
| procedura | czas [user] | przyspieszenie | |
|---|---|---|---|
| x86 | 0:08.27 | 1.00 | █████ |
| mmx | 0:01.82 | 4.52 | ██████████████████████ |
| mmx2 | 0:02.35 | 3.52 | █████████████████ |
| sse2 | 0:01.04 | 7.95 | ████████████████████████████████████████ |