Lọc ảnh (Image Filtering)

Lọc ảnh (làm mịn ảnh, làm mượt ảnh) là một bước rất quan trọng trong xử lý ảnh. Lọc ảnh thực tế có rất nhiều tác dụng như loại bỏ nhiễu, tìm biên đối tượng. Bài viết này sẽ giới thiệu nguyên tắc chung của lọc ảnh và một số phép lọc ảnh cơ bản.

A. Nguyên tắc chung của lọc ảnh

Nguyên tắc chung của các phương pháp lọc là cho ma trận ảnh nhân với một ma trận lọc (Kernel).  Ma trận lọc lọc (Kernel) còn có thể được gọi là cửa số chập (trong phép nhân chập), cửa sổ lọc, mặt nạ,… Trong bài viết này tôi sử dụng thuật ngữ ma trận lọc (Kernel).

Việc nhân ảnh với ma trận lọc giống như việc trượt ma trận lọc theo hàng trên ảnh và nhân với từng vùng của ảnh, cộng các kết quả lại tạo thành kết quả của điểm ảnh trung tâm.

same_padding_no_strides.gif
Minh họa việc nhân ma trận ảnh. Hình ảnh được lấy từ https://github.com/vdumoulin/conv_arithmetic
Kết quả hình ảnh cho image filtering
Ma trận đầu vào I được nhân với ma trận lọc (phần xám ở hình trái) để tạo thành ma trận đầu ra O.

Trên thực tế, chúng ta sẽ thấy có 2 phép lọc ảnh là tương quan (correlation) và tích chập (convolution). Với phép tương quan, ma trận lọc sẽ đượt trượt đi và nhân với từng vùng của ảnh như trên. Tuy nhiên với phép tích chập, ma trận lọc sẽ được xoay 180 độ (theo cả chiều ngang và dọc) trước khi thực hiện nhân. 2 phép toán này là tương đương khi ma trận lọc đối xứng.

Với mỗi phép lọc ta có những ma trận lọc (Kernel) khác nhau, không có một quy định cụ thể nào cho việc xác định M. Kích thước ma trận M là một số lẻ. Ví dụ: 3×3, 5×5. 

Khi nhân các phần tử tương ứng với nhau (giữa pixel, các điểm lân cận – các thành phần trong kernel), đối với các phần tử ở cạnh thì sẽ có một số pixel bị khuyết, lúc này, có nhiều cách giải quyết như bỏ qua, chèn thêm một (một số) hàng, cột mang giá trị 0 hoặc bằng giá trị gần nhất, hoặc tạo một đối xứng gương ở cạnh ảnh.

Tổng Tpt các phẩn tử trong ma trận M thường là 1.

  • Tpt > 1: Ảnh sau khi thực hiện xong phép lọc số ảnh (Idst) có độ sáng lớn hơn so với ảnh ban đầu (Isrc). 
  • Tpt < 1: Ảnh sau khi thực hiện xong phép lọc số ảnh (Idst) có độ sáng nhỏ hơn so với ảnh ban đầu (Isrc).

Ví dụ

    \[ M = \begin{bmatrix}1/9 & 1/9 & 1/9 \\ 1/9 & 1/9 & 1/9 \\ 1/9 & 1/9 & 1/9 \end{bmatrix} \]

B. Một số bộ lọc làm mịn ảnh

1. Lọc trung bình (Normalized Box Filter)

Đây là bộ lọc đơn giản nhất. Nó được xây dựng dựa trên ý tưởng tính giá trị một điểm ảnh bằng trung bình cộng các điểm ảnh xung quanh nó.

Ma trận lọc của lọc trung bình có dạng:

    \[ K = \frac{1}{K_{width} \cdot K_{height}}  \begin{bmatrix} 1 & 1 & 1 & \ldots & 1 \\ 1 & 1 & 1 & \ldots & 1 \\ \cdot & \cdot & \cdot & \ldots & 1 \\ 1 & 1 & 1 & \ldots & 1 \end{bmatrix} \]

Cách lọc này thường được áp dụng cho làm trơn ảnh vẫn muốn giữ lại biên không bị mờ.

Code với Python – OpenCV: Đoạn code sau sẽ thực hiện lọc ảnh với ma trận lọc trung bình 5 x 5. Lưu ý: toàn bộ mã nguồn và hình ảnh dùng trong bài viết có thể được tải về tại liên kết trong mục Tham khảo.

    \[ K = \frac{1}{25} \begin{bmatrix} 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 & 1 \end{bmatrix} \]

import cv2 as cv
import numpy as np
from matplotlib import pyplot as plt

# Load and blur image
img = cv.imread('rose_gauss.jpg')
img2 = cv.imread('rose_salt_and_pepper.jpg')
blur = cv.blur(img,(5,5))
blur2 = cv.blur(img2,(5,5))

# Convert color from bgr (OpenCV default) to rgb
img_rgb = cv.cvtColor(img, cv.COLOR_BGR2RGB)
blur_rgb = cv.cvtColor(blur, cv.COLOR_BGR2RGB)
img_rgb2 = cv.cvtColor(img2, cv.COLOR_BGR2RGB)
blur_rgb2 = cv.cvtColor(blur2, cv.COLOR_BGR2RGB)

# Display
plt.subplot(221),plt.imshow(img_rgb),plt.title('Gauss Noise')
plt.xticks([]), plt.yticks([])
plt.subplot(222),plt.imshow(blur_rgb),plt.title('Gauss Noise - Blurred')
plt.xticks([]), plt.yticks([])
plt.subplot(223),plt.imshow(img_rgb2),plt.title('Salt&Pepper Noise')
plt.xticks([]), plt.yticks([])
plt.subplot(224),plt.imshow(blur_rgb2),plt.title('Salt&Pepper Noise - Blurred')
plt.xticks([]), plt.yticks([])
plt.show()

Sau đây là kết quả sau khi chạy đoạn code trên:

Chú ý: bạn cần cài gói matplotlib (dùng pip) và tkinter để sử dụng với Python. Với Python 3.6, việc cài đặt tkinter có thể sử dụng:

sudo apt-get install python3.6-tk

Trên thực tế,  thay vì sử dụng hàm cv2.blur() thì bộ lọc trung bình có thể được sử dụng với hàm: cv2.filter2D() với một tham số là ma trận lọc.

kernel = np.ones((5,5),np.float32)/25
blur = cv.filter2D(img,-1,kernel)

Khi thay ma trận lọc trung bình bằng một ma trận khác, chúng ta có thể có những phép lọc khác như lọc thông thấp (low-pass filters(LPF)) hoặc lọc thông cao (high-pass filters(HPF) ).

2. Lọc Gauss (Gaussian Filter)

Bộ lọc Gauss được cho là bộ lọc hữu ích nhất, được thực hiện bằng cách nhân chập ảnh đầu vào với một ma trận lọc Gauss sau đó cộng chúng lại để tạo thành ảnh đầu ra.

Ý tưởng chung là giá  trị mỗi điểm ảnh sẽ phụ thuộc nhiều vào các điểm ảnh ở gần hơn là các điểm ảnh ở xa. Trọng số của sự phụ thuộc được lấy theo hàm Gauss (cũng được sử dụng trong quy luật phân phối chuẩn).

Dưới đây là biểu diễn ma trận lọc  Gauss:

Giả sử ảnh là một chiều. Điểm ảnh ở trung tâm sẽ có trọng số lớn nhất. Các điểm ảnh ở càng xa trung tâm sẽ có trọng số giảm dần khi khoảng cách từ chúng tới điểm trung tâm tăng lên. Như vậy điểm càng gần trung tâm sẽ càng đóng góp  nhiều hơn vào giá trị điểm trung tâm.


Chú ý: Trên thực tế, việc lọc ảnh dựa trên hàm Gauss 2 chiều (ngang và dọc). Phân phối chuẩn 2 chiều có thể biểu diễn dưới dạng:

    \[G_{0}(x, y) = A e^{ \dfrac{ -(x - \mu_{x})^{2} }{ 2\sigma^{2}_{x} } + \dfrac{ -(y - \mu_{y})^{2} }{ 2\sigma^{2}_{y} } }\]

Trong đó \mu là trung bình (đỉnh),  \sigma^{2} là phương sai của các biến số xy.

Tham số \mu quyết định tác dụng của bộ lọc Gauss lên ảnh. Độ lớn của ma trận lọc (kernel) cần được lựa chọn cho đủ rộng.


Code thực tế lọc Gauss với Python – OpenCV:   Trong OpenCV chúng ta sử dụng hàm sau để lọc Gauss: cv.GaussianBlur().

dst = cv.GaussianBlur(	src, ksize, sigmaX[, dst[, sigmaY[, borderType]]]	)

Bạn cũng có thể tạo một ma trận lọc Gauss và sử dụng với hàm cv.filter2D() phía trên bằng cách sử dụng: cv.getGaussianKernel().

import cv2 as cv
import numpy as np
from matplotlib import pyplot as plt

# Load and blur image
img = cv.imread('rose_gauss.jpg')
img2 = cv.imread('rose_salt_and_pepper.jpg')
blur = cv.GaussianBlur(img,(5,5),0)
blur2 = cv.GaussianBlur(img2,(5,5),0)

# Convert color from bgr (OpenCV default) to rgb
img_rgb = cv.cvtColor(img, cv.COLOR_BGR2RGB)
blur_rgb = cv.cvtColor(blur, cv.COLOR_BGR2RGB)
img_rgb2 = cv.cvtColor(img2, cv.COLOR_BGR2RGB)
blur_rgb2 = cv.cvtColor(blur2, cv.COLOR_BGR2RGB)

# Display
plt.subplot(221),plt.imshow(img_rgb),plt.title('Gauss Noise')
plt.xticks([]), plt.yticks([])
plt.subplot(222),plt.imshow(blur_rgb),plt.title('Gauss Noise - Blurred')
plt.xticks([]), plt.yticks([])
plt.subplot(223),plt.imshow(img_rgb2),plt.title('Salt&Pepper Noise')
plt.xticks([]), plt.yticks([])
plt.subplot(224),plt.imshow(blur_rgb2),plt.title('Salt&Pepper Noise - Blurred')
plt.xticks([]), plt.yticks([])
plt.show()

Dưới đây là kết quả lọc sử dụng phép lọc Gauss. Các bạn có thể thấy kết quả lọc  (Gauss noise) rất tốt mà hình ảnh không bị nhòe nhiều như phép lọc trung bình như trên.

3. Lọc trung vị

Phép lọc trung vị cũng được thực hiện với các ma trận lọc. Tuy nhiên nó tính trung vị tất cả các giá trị điểm ảnh trong vùng ma trận lọc và sử dụng trung vị này cho giá trị điểm trung tâm. Một điều khá thú vị là với các cách lọc ở trên, giá trị điểm trung tâm được tính mới (có thể bằng hoặc khác với giá trị một điểm trong vùng ma trận lọc), còn với phép lọc trung vị, giá trị điểm trung tâm luôn được thay bằng một giá trị điểm ảnh trong bức ảnh đầu vào. Do vậy, phương pháp lọc này có khả năng loại bỏ nhiễu muối tiêu (salt-and-pepper noise )  khá tốt.

Có một điểm cũng cần được chú ý là phép lọc trung bình và lọc Gauss là phép lọc tuyến tính, nhưng phép lọc trung vị không phải là một phép lọc tuyến tính.

Để sử dụng lọc trung vị trong OpenCV, ta dùng hàm: cv.medianBlur().

Tương tự hai ví dụ ở trên, ta thay bước lọc thành:

blur = cv.medianBlur(img,5)

Kết quả thực hiện lọc trung vị với một số nhiễu:

Có thể thấy rõ, với việc lọc trung vị, nhiễu muối tiêu đã được loại bỏ tốt hơn nhiều so với lọc trung bình hay lọc Gauss.

4. Bộ lọc Bilateral (bộ lọc hai chiều)

cv.bilateralFilter() là một bộ lọc hiệu quả cao trong việc loạt bỏ nhiễu mà vẫn giữ lại được các đường viền (cạnh) trong ảnh.

Như chúng ta đã biết, bộ lọc Gauss quyết định giá trị một điểm ảnh bằng cách lấy trung bình theo hàm Gauss các giá trị điểm ảnh xung quanh điểm đó. Hàm trọng số Gauss chỉ phụ thuộc vào khoảng cách trong không gian so với điểm ảnh trung tâm, không quan tâm đến sự tương quan giữa mức xám của điểm trung tâm với các điểm xung quanh đó. Nó cũng không quan tâm rằng điểm ảnh trung tâm có nằm tại một đường biên trong ảnh không, vì thế làm nhòe luôn các đường biên trong ảnh.

Bộ lọc Bilateral cũng sử dụng một bộ lọc Gauss với khoảng cách đến điểm trung tâm, đảm bảo chỉ có các điểm ở gần tham gia vào giá trị của điểm ảnh trung tâm. Tuy vậy nó sử dụng thêm một hàm Gauss cho mức xám, đảm bảo chỉ các điểm ảnh có mức xám tương đồng với điểm ảnh trung tâm tham gia vào quá trình làm mịn. Vì thế bộ lọc Bilateral bảo toàn được các đường biên trong ảnh bởi vì điểm ảnh ở biên có sự thay đổi về mức xám rất rõ ràng. Hơn nữa, thay vì hoạt động trên các kênh màu một cách riêng rẽ như bộ lọc trung bình hay bộ lọc Gauss, bộ lọc Bilateral thi hành việc đo đạc màu sắc có chủ đích trong không gian màu CIE-Lab , làm mượt màu và bảo toàn các biên theo hướng phù hợp hơn với nhận thức con người.

Tuy vậy, bộ lọc Bilateral cũng có nhược điểm là chậm hơn các bộ lọc khác.

Sau đây là cách sử dụng bộ lọc Bilateral trong OpenCV:

blur = cv.bilateralFilter(img,9,75,75)

Kết quả: Hình 1 sử dụng phép lọc Bilateral, hình 2 sử dụng phéo lọc Gauss. Hãy thử chạy với các phép lọc khác nhé.

Như chúng ta có thể thấy, các texture được lọc rất tốt trong khi các đường biên trong ảnh vẫn được bảo toàn, không bị mờ đi.

Hình 1: Phép lọc Bilateral
Hình 2: Phép lọc Gauss

Các bạn có thể tìm hiểu thêm về bộ lọc Bilateral tại: http://people.csail.mit.edu/sparis/bf_course/.

C. Một số bộ lọc tìm biên ảnh

Ngoài việc làm mịn ảnh, một số bộ lọc còn có tác dụng tìm biên của ảnh.

1. Liên hệ giữa đạo hàm và biên ảnh

Xét vị dụ sau: Ta có một hình ảnh (1) với 2 biên đã được làm mờ. Hình (2) cho thấy mức xám tại đường quét màu đỏ của ảnh. Dễ dàng nhận thấy các đường biên ảnh chính là 2 vùng có sự thay đổi đột ngột về mức xám. Để xác định những sự thay đổi này, ta sử dụng đạo hàm của dải mức xám và tìm các cực trị (địa phương) trên đó. Có thể thấy rõ mối liên hệ giữa các cực trị địa phương của đạo hàm với các biên trong ảnh.

Liên hệ giữa đạo hàm và biên ảnh

2. Gradient của bức ảnh

Vậy là các biên của ảnh sẽ có quan hệ với đạo hàm theo chiều x và đạo hàm theo chiều y của mức xám. Gradient của ảnh là một đại lượng véc tơ hình thành từ 2 đạo hàm này và sẽ được sử dụng để lọc biên trong ảnh.

Công thức của Gradient là:

    \[\triangledown f = \begin{bmatrix} \frac{\partial f}{\partial x} , \frac{\partial f}{\partial y} \end{bmatrix}\]

3. Sobel và Scharr

Phép Sobel là sự kết hợp giữa làm mịn Gauss và phép vi phân, do vậy nó ít bị ảnh hưởng bởi nhiễu.

Việc kết hợp này không hẳn là việc lọc nhiễu bằng phép Gauss trước, rồi thực hiện Sobel để tìm biên mà phép Gauss và Sobel sẽ được kết hợp để tạo ra một ma trân lọc (kernel) rồi sau đó nhân chập ma trận này với ảnh. Hãy cùng xem tại sao có thể làm được như vậy:

Xét một hàm mức xám f , ma trận lọc Gauss h, ta có công thức:

    \[\frac{\partial}{\partial x}\left( h * f \right) =\left( \frac{\partial}{\partial x} h \right) * f\]

Như vậy, thay vì áp dụng bộ lọc Gauss lên ảnh (kích thước khá lớn) rồi áp dụng lọc Sobel để tìm biên, ta có thể áp dụng phép Sobel lên ma trận Gauss (kích thước nhỏ) rồi sau đó nhân chập ma trận thu được với ảnh để cho ra kết quả tương tự. Việc này sẽ giảm đáng kể chi phí tính toán. 

Trong OpenCV, bạn có thể chỉ định được hướng đạo hàm (theo chiều ngang hay chiều dọc). Bạn cũng có thể chỉ định kích thước ma trận lọc với tham số ksize. Nếu ksize = -1, bộ lọc Scharr 3×3 sẽ được sử dụng thay vì Sobel 3×3 để có kết quả tốt hơn.

4. Laplacian

Laplacian được tính theo công thức:

    \[ \Delta src = \frac{\partial ^2{src}}{\partial x^2} + \frac{\partial ^2{src}}{\partial y^2} \]

Với ksize = 1, ma trận lọc sẽ được sử dụng là:

    \[kernel = \begin{bmatrix} 0 & 1 & 0 \\ 1 & -4 & 1 \\ 0 & 1 & 0 \end{bmatrix}\]

Cài đặt Sobel và Laplace trong OpenCV:

import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt
img = cv.imread('dave.jpg',0)
laplacian = cv.Laplacian(img,cv.CV_64F)
sobelx = cv.Sobel(img,cv.CV_64F,1,0,ksize=5)
sobely = cv.Sobel(img,cv.CV_64F,0,1,ksize=5)
plt.subplot(2,2,1),plt.imshow(img,cmap = 'gray')
plt.title('Original'), plt.xticks([]), plt.yticks([])
plt.subplot(2,2,2),plt.imshow(laplacian,cmap = 'gray')
plt.title('Laplacian'), plt.xticks([]), plt.yticks([])
plt.subplot(2,2,3),plt.imshow(sobelx,cmap = 'gray')
plt.title('Sobel X'), plt.xticks([]), plt.yticks([])
plt.subplot(2,2,4),plt.imshow(sobely,cmap = 'gray')
plt.title('Sobel Y'), plt.xticks([]), plt.yticks([])
plt.show()

Kết quả:

Vấn đề quan trọng khi lập trình với Python – OpenCV:

Trong ví dụ cuối, kiểu dữ liệu đầu ra là cv.CV_8U hay np.uint8. Có một vấn đề với nó. Các chuyển dịch từ đen sang trắng (sự chuyển màu trên ảnh) có hệ số góc dương, các chuyển đổi từ trắng sang đen lại có hệ số góc âm. Do vậy, khi bạn chuyển dữ liệu sang np.uint8, các hệ số góc âm sẽ được chuyển thành 0. Do vậy bạn mất các cạnh ở chỗ màu sắc chuyển từ đen sang trắng.

Để nhận tất cả các đường biên, bạn phải chuyển kết quả sang kiểu cv.CV_16S, cv.CV_64F, hoặc một kiểu khác lưu trữ lớn hơn np.uint8, lấy giá trị tuyệt đối và chuyển lại về np.uint8.

Đoạn code dưới đây sẽ mô tả quá trình thực hiện. Ảnh đầu vào là một hình chữ nhật trắng trên nền đen. Ta thực hiện việc tìm cạnh theo chiều ngang (lấy các cạnh dọc). Nếu sử dụng kiểu dữ liệu np.uint8, cạnh bên phải bị mất (do cạnh đó được hình thành bởi sự chuyển dịch màu trắng -> đen). Để có cả 2 cạnh, ta phải làm như cách đã nêu trên.

import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt
img = cv.imread('box.png',0)
# Output dtype = cv.CV_8U
sobelx8u = cv.Sobel(img,cv.CV_8U,1,0,ksize=5)
# Output dtype = cv.CV_64F. Then take its absolute and convert to cv.CV_8U
sobelx64f = cv.Sobel(img,cv.CV_64F,1,0,ksize=5)
abs_sobel64f = np.absolute(sobelx64f)
sobel_8u = np.uint8(abs_sobel64f)
plt.subplot(1,3,1),plt.imshow(img,cmap = 'gray')
plt.title('Original'), plt.xticks([]), plt.yticks([])
plt.subplot(1,3,2),plt.imshow(sobelx8u,cmap = 'gray')
plt.title('Sobel CV_8U'), plt.xticks([]), plt.yticks([])
plt.subplot(1,3,3),plt.imshow(sobel_8u,cmap = 'gray')
plt.title('Sobel abs(CV_64F)'), plt.xticks([]), plt.yticks([])
plt.show()

Tham khảo

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.