s444380-wko/wko-05.ipynb
2023-01-22 20:08:13 +01:00

7.2 MiB

Logo 1

Widzenie komputerowe

05. Transformacje geometryczne i cechy obrazów [laboratoria]

Andrzej Wójtowicz (2021)

Logo 2

W poniższych materiałach zobaczymy jak przy pomocy biblitoeki OpenCV realizować transformacje geometryczne obrazu, wyszukiwać "ciekawe" elementy obrazu oraz jak łączyć je z innymi podobnymi elementami na innych obrazach.

Na początku załadujmy niezbędne biblioteki.

import cv2 as cv
import numpy as np
import matplotlib.pyplot as plt
%matplotlib inline

Transformacje obrazu

Zacznijmy od podstawowych przekształceń geometrycznych. Aby efekt poszczególnych operacji był widoczny, dodamy pustą przestrzeń wokół testowego obrazu, na którym będziemy pracować:

lena = cv.imread("img/lena.png", cv.IMREAD_COLOR)
image = np.zeros((1024, 1024, 3), np.uint8)

for i in range(3):
    image[256:768, 256:768, i] = lena[:,:, i];

plt.figure(figsize=(5,5))
plt.imshow(image[:,:,::-1]);

W OpenCV transformacje afiniczne są wykonywane przy pomocy definicji macierzy 2x3 (zamiast bardziej klasycznej 3x3), która przekazywana jest do funkcji cv.warpAffine(). Poniżej mamy przykład translacji (przesunięcia) o 100 pikseli w prawo wzdłuż osi _x i o 150 pikseli wzdłuż osi y:

mat = np.float32([
    [1.0, 0.0, 100],
    [0.0, 1.0, 150]
])

image_translated = cv.warpAffine(image, mat, image.shape[0:2])

plt.figure(figsize=(5,5))
plt.imshow(image_translated[:,:,::-1]);

Zobaczmy co się stanie po przeskalowaniu dwukrotnym wzdłuż osi _x:

mat = np.float32([
    [2.0, 0.0, 0],
    [0.0, 1.0, 0]
])

image_scaled = cv.warpAffine(image, mat, image.shape[0:2])

plt.figure(figsize=(5,5))
plt.imshow(image_scaled[:,:,::-1]);

Nasz wyjściowy obraz "wyszedł" poza wymiary wejściowe, więc musimy podać w funkcji trochę większe rozmiary wynikowego obrazu (w tym wypadku chodzi o szerokość):

mat = np.float32([
    [2.0, 0.0, 0],
    [0.0, 1.0, 0]
])

image_scaled = cv.warpAffine(image, mat, (2*image.shape[1], image.shape[0]))

plt.figure(figsize=(10,10))
plt.imshow(image_scaled[:,:,::-1]);

Poniżej mamy dwukrotne przeskalowanie w obu kierunkach (efekt jest zasadniczo widoczny patrząc na wynikowe skale obu osi):

mat = np.float32([
    [2.0, 0.0, 0],
    [0.0, 2.0, 0]
])

image_scaled = cv.warpAffine(image, mat, (2*image.shape[1], 2*image.shape[0]))

plt.figure(figsize=(5,5))
plt.imshow(image_scaled[:,:,::-1]);

W przypadku obrotu musimy pamiętać, że mamy inaczej zdefiniowany układ współrzędnych (oś _y), więc uzyskujemy trochę inną formę macierzy obrotu:

angle = 30 * np.pi / 180.0

cos_theta = np.cos(angle)
sin_theta = np.sin(angle)

mat = np.float32([
    [ cos_theta, sin_theta, 0],
    [-sin_theta, cos_theta, 0]
])

image_rotated = cv.warpAffine(image, mat, image.shape[0:2])

plt.figure(figsize=(5,5))
plt.imshow(image_rotated[:,:,::-1]);

Powyżej mieliśmy obrót wokół środka układu, ale możemy też uzyskać obrót wokół wskazanego punktu, np. środka obrazu:

angle = 30 * np.pi / 180.0

cos_theta = np.cos(angle)
sin_theta = np.sin(angle)

center_x = image.shape[0] / 2
center_y = image.shape[1] / 2

t_x = (1 - cos_theta) * center_x - sin_theta * center_y
t_y = sin_theta * center_x + (1 - cos_theta) * center_y

mat = np.float32([
    [ cos_theta, sin_theta, t_x],
    [-sin_theta, cos_theta, t_y]
])

image_rotated = cv.warpAffine(image, mat, image.shape[0:2])

plt.figure(figsize=(5,5))
plt.imshow(image_rotated[:,:,::-1]);

Na szczęście zamiast samodzielnego liczenia macierzy obrotu można użyć funkcji cv.getRotationMatrix2D() (możemy też jej użyć do skalowania):

mat = cv.getRotationMatrix2D((center_x, center_y), 30, 1)

image_rotated = cv.warpAffine(image, mat, image.shape[0:2])

plt.figure(figsize=(5,5))
plt.imshow(image_rotated[:,:,::-1]);

Poniżej mamy przykład pochylenia:

mat = np.float32([
    [1.0, 0.1, 0],
    [0.0, 1.0, 0]
])

image_sheared = cv.warpAffine(image, mat, image.shape[0:2])

plt.figure(figsize=(5,5))
plt.imshow(image_sheared[:,:,::-1]);

Jeżeli mamy kilka transformacji do wykonania, to bardziej efektywnym rozwiązaniem będzie uprzednie wymnożenie macierzy odpowiedzialnych za poszczególne transformacje (zamiast sukcesywnego wykonywania pojedynczych, izolowanych operacji):

m_scale = np.float32([
    [0.5, 0.0],
    [0.0, 1.0]
])

m_shear = np.float32([
    [1.0, 0.1],
    [0.0, 1.0]
])

m_rotation = np.float32([
    [ cos_theta, sin_theta],
    [-sin_theta, cos_theta]
])

v_translation = np.float32([
    [150],
    [90]
])

mat_tmp = m_rotation @ m_shear @ m_scale

mat_transformation = np.append(mat_tmp, v_translation, 1)

image_transformed = cv.warpAffine(image, mat_transformation, image.shape[0:2])

plt.figure(figsize=(5,5))
plt.imshow(image_transformed[:,:,::-1]);

Dla punktów o konkretnych współrzędnych możemy też obliczyć ich docelowe współrzędne po transformacjach:

src_points = np.float32([[50, 50], [50, 249], [249, 50], [249, 249]])
dst_points = (mat_tmp @ src_points.T + v_translation).T

print(dst_points)
[[200.98076 118.30127]
 [317.71466 280.6903 ]
 [287.15027  68.55127]
 [403.88422 230.94032]]

Mając co najmniej 3 punkty wejściowe i ich odpowiedniki wyjściowe, możemy oszacować macierz transformacji przy pomocy funkcji cv.estimateAffine2D():

mat_estimated_1 = cv.estimateAffine2D(src_points[:3], dst_points[:3])[0]
print("Explicit matrix:\n\n", mat_transformation)
print("\nEstimated matrix:\n\n", mat_estimated_1)
Explicit matrix:

 [[  0.4330127    0.58660257 150.        ]
 [ -0.25         0.8160254   90.        ]]

Estimated matrix:

 [[  0.43301261   0.58660252 150.00000192]
 [ -0.25         0.81602532  90.00000368]]

Mając więcej punktów możemy spróbować uzyskać dokładniejsze oszacowanie:

mat_estimated_2 = cv.estimateAffine2D(src_points, dst_points)[0]
print("Explicit matrix:\n\n", mat_transformation)
print("\nEstimated matrix:\n\n", mat_estimated_2)
Explicit matrix:

 [[  0.4330127    0.58660257 150.        ]
 [ -0.25         0.8160254   90.        ]]

Estimated matrix:

 [[  0.43301273   0.58660264 149.99997897]
 [ -0.24999996   0.81602536  89.99999603]]

Załóżmy, że chcielibyśmy teraz uzyskać zmianę perspektywy, co w efekcie spowodowałoby, że nasz kwadratowy obraz zamieniłby się w trapez:

image_to_transform = np.zeros(image.shape, dtype=np.float32)
dst_points = np.float32([[356, 256], [667, 256], [767, 767], [256, 767]])
cv.fillConvexPoly(image_to_transform, np.int32(dst_points), (0.8, 0.8, 0.5), cv.LINE_AA);

plt.figure(figsize=[10,10])
plt.subplot(121)
plt.imshow(image[:,:,::-1])
plt.title('Original image')

plt.subplot(122)
plt.imshow(image_to_transform[:,:,::-1])
plt.title('Destination plane');

Niestety przy pomocy transformacji afinicznych nie jesteśmy w stanie uzyskać zakładanego efektu:

src_points = np.float32([[256, 256], [767, 256], [767, 767], [256, 767]])
mat_estimated = cv.estimateAffine2D(src_points, dst_points)[0]

image_transformed_a = cv.warpAffine(image, mat_estimated, image.shape[0:2])

plt.figure(figsize=[15,10])
plt.subplot(131)
plt.imshow(image[:,:,::-1])
plt.title('Original image')
plt.subplot(132)
plt.imshow(image_to_transform[:,:,::-1])
plt.title('Destination plane')
plt.subplot(133)
plt.imshow(image_transformed_a[:,:,::-1])
plt.title('Transformed image (affine)');

Do uzyskania zakładanego efektu musimy znaleźć macierz homografii 3x3 przy pomocy co najmniej 4 punktów i funkcji cv.findHomography():

mat_h = cv.findHomography(src_points, dst_points)[0]
print(mat_h)
[[ 5.08838663e-01 -3.27547619e-01  2.51229024e+02]
 [ 0.00000000e+00  3.44904761e-01  1.25737302e+02]
 [ 0.00000000e+00 -6.40366802e-04  1.00000000e+00]]

Przy pomocy funkcji cv.warpPerspective() możemy dokonać teraz zmiany perspektywy:

image_transformed_h = cv.warpPerspective(image, mat_h, image.shape[0:2])

plt.figure(figsize=[20,10])
plt.subplot(141)
plt.imshow(image[:,:,::-1])
plt.title('Original image')
plt.subplot(142)
plt.imshow(image_to_transform[:,:,::-1])
plt.title('Destination plane')
plt.subplot(143)
plt.imshow(image_transformed_a[:,:,::-1])
plt.title('Transformed image (affine)')
plt.subplot(144)
plt.imshow(image_transformed_h[:,:,::-1])
plt.title('Transformed image (homography)');

Poprzez znalezienie macierzy homografii i zmianę perspektywy możemy np. wyrównywać zdjęcia dokumentów w celu ich dalszej analizy. Poniżej mamy przykład z oznaczeniem okładki zeszytu:

book = cv.imread("img/caribou.jpg", cv.IMREAD_COLOR)
image_h, image_w = book.shape[0:2]

src_points = np.array([[210, 190], [380, 280], [290, 500], [85, 390]], dtype=float)
dst_points = np.array([[0, 0],[image_w-1, 0], [image_w-1, image_h-1], [0, image_h-1]], dtype=float)

colors = [(0,0,255), (0,255,0), (255,0,0), (90,200,200)]
for (x,y), i in(zip(src_points, colors)):
    book = cv.circle(book, (int(x),int(y)), 10, i, -1)

mat = cv.findHomography(src_points, dst_points)[0]

book_aligned = cv.warpPerspective(book, mat, (image_w, image_h))

plt.figure(figsize=[10,10])
plt.subplot(121)
plt.imshow(book[:,:,::-1])
plt.title('Original image')
plt.subplot(122)
plt.imshow(book_aligned[:,:,::-1])
plt.title('Aligned image');

W przypadku dokumentów, możemy np. dysponować wzorem dokumentu do uzupełnienia oraz zdjęciem wypełnionego dokumentu. Po wyrówaniu zdjęcia moglibyśmy łatwiej analizować wypełnione pola:

Przykładowe wyrówanie dokumentu. Źródło: learnopencv.org

Cztery niezbędne punkty moglibyśmy wybierać ręcznie, jednak w przypadku masowego przetwarzania lepszym rozwiązaniem jest automatyczne znalezienie odpowiadających sobie punktów (niekoniecznie rogów dokumentu) na wzorcu i przetwarzanym zdjęciu. Poniżej zobaczymy jak można znaleźć i dopasować takie punkty.

Punkty kluczowe

Punkty kluczowe (ang. _keypoints) są punktami na obrazie, o których możemy myśleć w kategoriach dobrze zdefiniowanych narożników, relatywnie odpornych na przekształcenia obrazu. Każdy punkt kluczowy ma przypisany descryptor tj. wektor cech.

Wczytajmy przykładowy obraz, na którym postaramy się znaleźć takie punkty kluczowe:

python_book = cv.imread("img/book-python-cover.jpg", cv.IMREAD_COLOR)
python_book_gray = cv.cvtColor(python_book, cv.COLOR_BGR2GRAY)

plt.figure(figsize=[10,10])
plt.subplot(121)
plt.imshow(python_book[:,:,::-1])
plt.title('Original image')
plt.subplot(122)
plt.imshow(python_book_gray, cmap='gray')
plt.title('Grayscale image');

Jednym z najpopularniejszych (i nieopatentowanych) algorytmów wykrywania i opisywania punktów kluczowych jest ORB (dokumentacja). Poniżej znajduje się wizualizacja wykrytych punktów kluczowych:

orb_alg = cv.ORB_create()

keypoints_1 = orb_alg.detect(python_book_gray, None)

keypoints_1, descriptors_1 = orb_alg.compute(python_book_gray, keypoints_1)

python_book_keypoints = cv.drawKeypoints(python_book, keypoints_1, None, color=(0,255,0), 
                                         flags=cv.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

plt.figure(figsize=(10,10))
plt.imshow(python_book_keypoints[:,:,::-1])
plt.title('Image keypoints');

Łączenie punktów kluczowych

Załóżmy, że chcemy sprawdzić czy na danym zdjęciu znajduje się okładka powyższej książki:

book_in_hands = cv.imread("img/book-python-in-hands.jpg", cv.IMREAD_COLOR)
book_in_hands_gray = cv.cvtColor(book_in_hands, cv.COLOR_BGR2GRAY)

plt.figure(figsize=[10,10])
plt.subplot(121)
plt.imshow(book_in_hands[:,:,::-1])
plt.title('Original image')
plt.subplot(122)
plt.imshow(book_in_hands_gray, cmap='gray')
plt.title('Grayscale image');

Na początku znajdźmy i opiszmy punkty kluczowe. Oba kroki możemy wykonać jednocześnie przy pomocy metody detectAndCompute():

orb_alg = cv.ORB_create(nfeatures=1000)

keypoints_1, descriptors_1 = orb_alg.detectAndCompute(python_book_gray, None)
keypoints_2, descriptors_2 = orb_alg.detectAndCompute(book_in_hands_gray, None)

Jeśli uda nam się połączyć odpowiadające sobie punkty kluczowe i wyliczyć macierz homografii, to z dość dużą dozą pewności będziemy mogli przyjąć, że na zdjęciu znajduje się szukany obiekt i będziemy mogli z grubsza wskazać gdzie on się znajduje. Punkty kluczowe są opisane przez wektory, więc np. moglibyśmy _brute-forcem i odległością Hamminga poszukać najlepszych odpowiedników. W poniższym przypadku pozostawimy 5% najlepszych dopasowań i zwizualizujemy te dopasowania:

matcher = cv.DescriptorMatcher_create(cv.DESCRIPTOR_MATCHER_BRUTEFORCE_HAMMING)
matches = matcher.match(descriptors_1, descriptors_2, None)

matches.sort(key=lambda x: x.distance, reverse=False) # sort by distance

num_good_matches = int(len(matches) * 0.05) # save 5%
matches = matches[:num_good_matches]

image_matched = cv.drawMatches(python_book, keypoints_1, book_in_hands, keypoints_2, matches, None)
plt.figure(figsize=(10,10))
plt.imshow(image_matched[...,::-1]);

Jak widać część punktów jest niepoprawnie dopasowana. Na szczęście obliczenie macierzy homografii odbywa się przy pomocy algorytmu RANSAC (ang. _random sample consensus), co pozwala wziąć pod uwagę, że pewne odzwierciedlenia są złe. Aby oznaczyć miejsce występowania obiektu, transformujemy współrzędne narożników źródłowego zdjęcia przy użyciu otrzymanej macierzy i funkcji cv.perspectiveTransform():

src_pts = np.float32([keypoints_1[m.queryIdx].pt for m in matches]).reshape(-1,1,2)
dst_pts = np.float32([keypoints_2[m.trainIdx].pt for m in matches]).reshape(-1,1,2)

mat, mask = cv.findHomography(src_pts, dst_pts, cv.RANSAC, 5.0)
matches_mask = mask.ravel().tolist()
height, width, _ = python_book.shape

pts = np.float32([[0,0], [0,height-1], [width-1,height-1], [width-1,0]]).reshape(-1,1,2)

dst = cv.perspectiveTransform(pts, mat)

book_in_hands_poly = cv.polylines(book_in_hands,
                                  [np.int32(dst)], True, (0,0,255), 
                                  10, cv.LINE_AA)
    
draw_params = dict(matchColor=(0,255,0),
                   matchesMask=matches_mask, # draw only inliers
                   flags=2) # NOT_DRAW_SINGLE_POINTS
image_matched = cv.drawMatches(python_book, keypoints_1, 
                               book_in_hands_poly, keypoints_2, 
                               matches, None, **draw_params)

plt.figure(figsize=(10,10))
plt.imshow(image_matched[...,::-1]);

Zadanie 1

Napisz interaktywną aplikację przy pomocy HighGUI, która pozwoli na obrazie img/billboards.jpg wybrać 4 punkty i umieścić w zaznaczonym obszarze wybrany obraz, np. img/bakery.jpg.

Aplikacja

import cv2 as cv
import numpy as np
class CloseApp(Exception): pass

def mouse_handler(event, x, y, flags, data) :
    
    if event == cv.EVENT_LBUTTONDOWN :
        cv.circle(data['im'], (x,y),3, (0,0,255), 5, 16);
        cv.imshow("Image", data['im']);
        if len(data['points']) < 4 :
            data['points'].append([x,y])

def get_four_points(im):
    
    # Set up data to send to mouse handler
    data = {}
    data['im'] = im.copy()
    data['points'] = []
    
    #Set the callback function for any mouse event
    cv.imshow("Image",im)
    cv.setMouseCallback("Image", mouse_handler, data)
    k = cv.waitKey(0) & 0xFF
    if k == 27:
        cv.destroyAllWindows()
        cv.waitKey(1)
        raise CloseApp
    
    # Convert array to np.array
    points = np.vstack(data['points']).astype(float)
    
    return points


try:    
    while(True):

        # Read source image.
        im_src = cv.imread("img/bakery.jpg")
        size = im_src.shape

        # Create a vector of source points.
        pts_src = np.array([[0,0], [size[1] - 1, 0], [size[1] - 1, size[0] -1], [0, size[0] - 1 ]],dtype=float);

        # Read destination image
        im_dst = cv.imread("img/billboards.jpg")

        # Get four corners of the billboard
        pts_dst = get_four_points(im_dst)

        # Calculate Homography between source and destination points
        h, status = cv.findHomography(pts_src, pts_dst)

        # Warp source image
        im_temp = cv.warpPerspective(im_src, h, (im_dst.shape[1],im_dst.shape[0]))

        # Black out polygonal area in destination image.
        cv.fillConvexPoly(im_dst, pts_dst.astype(int), 0, 16)

        # Add warped source image to destination image.
        im_dst = im_dst + im_temp

        # Display image.
        cv.imshow("Image", im_dst)
        cv.waitKey(0)
        
except CloseApp:
    pass

Zadanie 2

Załóżmy, że mamy dwa zdjęcia, które chcielibyśmy połączyć w panoramę:

boat_1 = cv.imread("img/boat_1.jpg", cv.IMREAD_COLOR)
boat_2 = cv.imread("img/boat_2.jpg", cv.IMREAD_COLOR)

plt.figure(figsize=[10,10])
plt.subplot(121)
plt.imshow(boat_1[:,:,::-1])
plt.title('Image 1')
plt.subplot(122)
plt.imshow(boat_2[:,:,::-1])
plt.title('Image 2');

Standardowo w OpenCV panoramę tworzy się przy pomocy klasy Stitcher, której można podać listę zdjęć do połączenia:

stitcher_alg = cv.Stitcher_create()
_, panorama = stitcher_alg.stitch([boat_1, boat_2])
plt.figure(figsize=[20,10]) 
plt.imshow(panorama[:,:,::-1])
plt.title('Panorama');

Spróbuj zaimplementować samodzielne stworzenie panoramy. Poniższy efekt będzie wystarczający, aczkolwiek możesz również spróbować zniwelować widoczną różnicę w intensywnościach na granicy zdjęć.

Panorama

# miejsce na eksperymenty
class Image_Stitching():
    def __init__(self) :
        self.ratio=0.85
        self.min_match=10
        self.sift=cv.xfeatures2d.SIFT_create()
        self.smoothing_window_size=800

    def registration(self,img1,img2):
        kp1, des1 = self.sift.detectAndCompute(img1, None)
        kp2, des2 = self.sift.detectAndCompute(img2, None)
        matcher = cv.BFMatcher()
        raw_matches = matcher.knnMatch(des1, des2, k=2)
        good_points = []
        good_matches=[]
        for m1, m2 in raw_matches:
            if m1.distance < self.ratio * m2.distance:
                good_points.append((m1.trainIdx, m1.queryIdx))
                good_matches.append([m1])
        img3 = cv.drawMatchesKnn(img1, kp1, img2, kp2, good_matches, None, flags=2)
        cv.imwrite('matching.jpg', img3)
        if len(good_points) > self.min_match:
            image1_kp = np.float32(
                [kp1[i].pt for (_, i) in good_points])
            image2_kp = np.float32(
                [kp2[i].pt for (i, _) in good_points])
            H, status = cv.findHomography(image2_kp, image1_kp, cv.RANSAC,5.0)
        return H

    def create_mask(self,img1,img2,version):
        height_img1 = img1.shape[0]
        width_img1 = img1.shape[1]
        width_img2 = img2.shape[1]
        height_panorama = height_img1
        width_panorama = width_img1 +width_img2
        offset = int(self.smoothing_window_size / 2)
        barrier = img1.shape[1] - int(self.smoothing_window_size / 2)
        mask = np.zeros((height_panorama, width_panorama))
        if version== 'left_image':
            mask[:, barrier - offset:barrier + offset ] = np.tile(np.linspace(1, 0, 2 * offset ).T, (height_panorama, 1))
            mask[:, :barrier - offset] = 1
        else:
            mask[:, barrier - offset :barrier + offset ] = np.tile(np.linspace(0, 1, 2 * offset ).T, (height_panorama, 1))
            mask[:, barrier + offset:] = 1
        return cv.merge([mask, mask, mask])

    def blending(self,img1,img2):
        H = self.registration(img1,img2)
        height_img1 = img1.shape[0]
        width_img1 = img1.shape[1]
        width_img2 = img2.shape[1]
        height_panorama = height_img1
        width_panorama = width_img1 +width_img2

        panorama1 = np.zeros((height_panorama, width_panorama, 3))
        mask1 = self.create_mask(img1,img2,version='left_image')
        panorama1[0:img1.shape[0], 0:img1.shape[1], :] = img1
        panorama1 *= mask1
        mask2 = self.create_mask(img1,img2,version='right_image')
        panorama2 = cv.warpPerspective(img2, H, (width_panorama, height_panorama))*mask2
        result=panorama1+panorama2

        rows, cols = np.where(result[:, :, 0] != 0)
        min_row, max_row = min(rows), max(rows) + 1
        min_col, max_col = min(cols), max(cols) + 1
        final_result = result[min_row:max_row, min_col:max_col, :]
        return final_result
    
def main(img1,img2):
    final=Image_Stitching().blending(img1,img2)
    cv.imwrite('panorama.jpg', final)
    
main(boat_1, boat_2)
panorama_jpg = cv.imread("panorama.jpg", cv.IMREAD_COLOR)
plt.figure(figsize=[10,10])
plt.imshow(panorama_jpg[:,:,::-1])
plt.title('Panorama')
[ WARN:0] global /tmp/pip-req-build-agffqapq/opencv_contrib/modules/xfeatures2d/misc/python/shadow_sift.hpp (13) SIFT_create DEPRECATED: cv.xfeatures2d.SIFT_create() is deprecated due SIFT tranfer to the main repository. https://github.com/opencv/opencv/issues/16736
Text(0.5, 1.0, 'Panorama')