Hôm nay, tiếp tục chuỗi bài viết Tự học và phát triển ứng dụng thực tế AI, ML, DL, DS mình xin giới thiệu bài viết về Computer Vision với chủ đề được ứng dụng rất nhiều trong thực tế đó là Phát hiện làn đường sử dụng Python và OpenCV.
Nội dung chính
Phát hiện làn đường là một nhiệm vụ quan trọng đối với việc phát triển hệ thống ôtô tự lái nói riêng và đối với thị giác máy tính nói chung.
Để nhận biết các vạch trắng trên làn đường, trước tiên chúng ta cần che phần còn lại của frame, chúng ta làm điều này bằng cách sử dụng frame masking. Frame là một mảng numpy các giá trị pixel của hình ảnh và để che đi các pixel không cần thiết chúng ta chỉ cần cập nhật các pixel đó bằng 0 trong mảng numpy.
Sau khi thực hiện, chúng ta cần phải phát hiện các vạch đường. Kỹ thuật được sử dụng để phát hiện các hình dạng toán học như thế này được gọi là Hough Transform. Hough Transform có thể phát hiện các hình dạng như hình chữ nhật, hình tròn, hình tam giác và đường thẳng.
Thực hiện
Đầu tiên, chúng ta cần import một số thư viện cần thiết
1 2 3 4 5 6 7 |
import matplotlib.pyplot as plt import matplotlib.image as mpimg import numpy as np import cv2 from moviepy.editor import VideoFileClip import math import os |
Tiếp theo, áp dụng Frame masking và tìm các khu vực cần quan tâm
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
def region_of_interest(img, vertices): """ Applies an image mask. Only keeps the region of the image defined by the polygon formed from `vertices`. The rest of the image is set to black. """ #defining a blank mask to start with mask = np.zeros_like(img) #defining a 3 channel or 1 channel color to fill the mask with depending on the input image if len(img.shape) > 2: channel_count = img.shape[2] # i.e. 3 or 4 depending on your image ignore_mask_color = (255,) * channel_count else: ignore_mask_color = 255 #filling pixels inside the polygon defined by "vertices" with the fill color cv2.fillPoly(mask, vertices, ignore_mask_color) #returning the image only where mask pixels are nonzero masked_image = cv2.bitwise_and(img, mask) return masked_image |
Kế tiếp, chuyển đổi các pixel thành 1 line trong Hough Transform space
1 2 3 4 5 6 7 8 9 10 |
def hough_lines(img, rho, theta, threshold, min_line_len, max_line_gap): """ `img` should be the output of a Canny transform. Returns an image with hough lines drawn. """ lines = cv2.HoughLinesP(img, rho, theta, threshold, np.array([]), minLineLength=min_line_len, maxLineGap=max_line_gap) line_img = np.zeros((*img.shape, 3), dtype=np.uint8) # 3-channel RGB image draw_lines(line_img, lines) return line_img |
Vẽ 2 lines trong mỗi frame sau khi Hough transform
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 |
def draw_lines(img, lines, color=[255, 0, 0], thickness=10): """ NOTE: this is the function you might want to use as a starting point once you want to average/extrapolate the line segments you detect to map out the full extent of the lane (going from the result shown in raw-lines-example.mp4 to that shown in P1_example.mp4). Think about things like separating line segments by their slope ((y2-y1)/(x2-x1)) to decide which segments are part of the left line vs. the right line. Then, you can average the position of each of the lines and extrapolate to the top and bottom of the lane. This function draws `lines` with `color` and `thickness`. Lines are drawn on the image inplace (mutates the image). If you want to make the lines semi-transparent, think about combining this function with the weighted_img() function below """ # In case of error, don't draw the line(s) if lines is None: return if len(lines) == 0: return draw_right = True draw_left = True # Find slopes of all lines # But only care about lines where abs(slope) > slope_threshold slope_threshold = 0.5 slopes = [] new_lines = [] for line in lines: x1, y1, x2, y2 = line[0] # line = [[x1, y1, x2, y2]] # Calculate slope if x2 - x1 == 0.: # corner case, avoiding division by 0 slope = 999. # practically infinite slope else: slope = (y2 - y1) / (x2 - x1) # Filter lines based on slope if abs(slope) > slope_threshold: slopes.append(slope) new_lines.append(line) lines = new_lines # Split lines into right_lines and left_lines, representing the right and left lane lines # Right/left lane lines must have positive/negative slope, and be on the right/left half of the image right_lines = [] left_lines = [] for i, line in enumerate(lines): x1, y1, x2, y2 = line[0] img_x_center = img.shape[1] / 2 # x coordinate of center of image if slopes[i] > 0 and x1 > img_x_center and x2 > img_x_center: right_lines.append(line) elif slopes[i] < 0 and x1 < img_x_center and x2 < img_x_center: left_lines.append(line) # Run linear regression to find best fit line for right and left lane lines # Right lane lines right_lines_x = [] right_lines_y = [] for line in right_lines: x1, y1, x2, y2 = line[0] right_lines_x.append(x1) right_lines_x.append(x2) right_lines_y.append(y1) right_lines_y.append(y2) if len(right_lines_x) > 0: right_m, right_b = np.polyfit(right_lines_x, right_lines_y, 1) # y = m*x + b else: right_m, right_b = 1, 1 draw_right = False # Left lane lines left_lines_x = [] left_lines_y = [] for line in left_lines: x1, y1, x2, y2 = line[0] left_lines_x.append(x1) left_lines_x.append(x2) left_lines_y.append(y1) left_lines_y.append(y2) if len(left_lines_x) > 0: left_m, left_b = np.polyfit(left_lines_x, left_lines_y, 1) # y = m*x + b else: left_m, left_b = 1, 1 draw_left = False # Find 2 end points for right and left lines, used for drawing the line # y = m*x + b --> x = (y - b)/m y1 = img.shape[0] y2 = img.shape[0] * (1 - trap_height) right_x1 = (y1 - right_b) / right_m right_x2 = (y2 - right_b) / right_m left_x1 = (y1 - left_b) / left_m left_x2 = (y2 - left_b) / left_m # Convert calculated end points from float to int y1 = int(y1) y2 = int(y2) right_x1 = int(right_x1) right_x2 = int(right_x2) left_x1 = int(left_x1) left_x2 = int(left_x2) # Draw the right and left lines on image if draw_right: cv2.line(img, (right_x1, y1), (right_x2, y2), color, thickness) if draw_left: cv2.line(img, (left_x1, y1), (left_x2, y2), color, thickness) |
Và cuối cùng, xử lý từng frame của video để nhận biết làn đường
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
def annotate_image_array(image_in): """ Given an image Numpy array, return the annotated image as a Numpy array """ # Only keep white and yellow pixels in the image, all other pixels become black image = filter_colors(image_in) # Read in and grayscale the image gray = grayscale(image) # Apply Gaussian smoothing blur_gray = gaussian_blur(gray, kernel_size) # Apply Canny Edge Detector edges = canny(blur_gray, low_threshold, high_threshold) # Create masked edges using trapezoid-shaped region-of-interest imshape = image.shape vertices = np.array([[\ ((imshape[1] * (1 - trap_bottom_width)) // 2, imshape[0]),\ ((imshape[1] * (1 - trap_top_width)) // 2, imshape[0] - imshape[0] * trap_height),\ (imshape[1] - (imshape[1] * (1 - trap_top_width)) // 2, imshape[0] - imshape[0] * trap_height),\ (imshape[1] - (imshape[1] * (1 - trap_bottom_width)) // 2, imshape[0])]]\ , dtype=np.int32) masked_edges = region_of_interest(edges, vertices) # Run Hough on edge detected image line_image = hough_lines(masked_edges, rho, theta, threshold, min_line_length, max_line_gap) # Draw lane lines on the original image initial_image = image_in.astype('uint8') annotated_image = weighted_img(line_image, initial_image) return annotated_image |
Một số hàm hỗ trợ:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
# Helper functions def grayscale(img): """Applies the Grayscale transform This will return an image with only one color channel but NOTE: to see the returned image as grayscale you should call plt.imshow(gray, cmap='gray')""" return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) def canny(img, low_threshold, high_threshold): """Applies the Canny transform""" return cv2.Canny(img, low_threshold, high_threshold) def gaussian_blur(img, kernel_size): """Applies a Gaussian Noise kernel""" return cv2.GaussianBlur(img, (kernel_size, kernel_size), 0) def weighted_img(img, initial_img, α=0.8, β=1., λ=0.): """ `img` is the output of the hough_lines(), An image with lines drawn on it. Should be a blank image (all black) with lines drawn on it. `initial_img` should be the image before any processing. The result image is computed as follows: initial_img * α + img * β + λ NOTE: initial_img and img must be the same shape! """ return cv2.addWeighted(initial_img, α, img, β, λ) def filter_colors(image): """ Filter the image to include only yellow and white pixels """ # Filter white pixels white_threshold = 200 #130 lower_white = np.array([white_threshold, white_threshold, white_threshold]) upper_white = np.array([255, 255, 255]) white_mask = cv2.inRange(image, lower_white, upper_white) white_image = cv2.bitwise_and(image, image, mask=white_mask) # Filter yellow pixels hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) lower_yellow = np.array([90,100,100]) upper_yellow = np.array([110,255,255]) yellow_mask = cv2.inRange(hsv, lower_yellow, upper_yellow) yellow_image = cv2.bitwise_and(image, image, mask=yellow_mask) # Combine the two above images image2 = cv2.addWeighted(white_image, 1., yellow_image, 1., 0.) return image2 |
Full source code
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 |
''' blah ''' import matplotlib.pyplot as plt import matplotlib.image as mpimg import numpy as np import cv2 from moviepy.editor import VideoFileClip import math import os # Global parameters # Gaussian smoothing kernel_size = 3 # Canny Edge Detector low_threshold = 50 high_threshold = 150 # Region-of-interest vertices # We want a trapezoid shape, with bottom edge at the bottom of the image trap_bottom_width = 0.85 # width of bottom edge of trapezoid, expressed as percentage of image width trap_top_width = 0.07 # ditto for top edge of trapezoid trap_height = 0.4 # height of the trapezoid expressed as percentage of image height # Hough Transform rho = 2 # distance resolution in pixels of the Hough grid theta = 1 * np.pi/180 # angular resolution in radians of the Hough grid threshold = 15 # minimum number of votes (intersections in Hough grid cell) min_line_length = 10 #minimum number of pixels making up a line max_line_gap = 20 # maximum gap in pixels between connectable line segments # Helper functions def grayscale(img): """Applies the Grayscale transform This will return an image with only one color channel but NOTE: to see the returned image as grayscale you should call plt.imshow(gray, cmap='gray')""" return cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) def canny(img, low_threshold, high_threshold): """Applies the Canny transform""" return cv2.Canny(img, low_threshold, high_threshold) def gaussian_blur(img, kernel_size): """Applies a Gaussian Noise kernel""" return cv2.GaussianBlur(img, (kernel_size, kernel_size), 0) def region_of_interest(img, vertices): """ Applies an image mask. Only keeps the region of the image defined by the polygon formed from `vertices`. The rest of the image is set to black. """ #defining a blank mask to start with mask = np.zeros_like(img) #defining a 3 channel or 1 channel color to fill the mask with depending on the input image if len(img.shape) > 2: channel_count = img.shape[2] # i.e. 3 or 4 depending on your image ignore_mask_color = (255,) * channel_count else: ignore_mask_color = 255 #filling pixels inside the polygon defined by "vertices" with the fill color cv2.fillPoly(mask, vertices, ignore_mask_color) #returning the image only where mask pixels are nonzero masked_image = cv2.bitwise_and(img, mask) return masked_image def draw_lines(img, lines, color=[255, 0, 0], thickness=10): """ NOTE: this is the function you might want to use as a starting point once you want to average/extrapolate the line segments you detect to map out the full extent of the lane (going from the result shown in raw-lines-example.mp4 to that shown in P1_example.mp4). Think about things like separating line segments by their slope ((y2-y1)/(x2-x1)) to decide which segments are part of the left line vs. the right line. Then, you can average the position of each of the lines and extrapolate to the top and bottom of the lane. This function draws `lines` with `color` and `thickness`. Lines are drawn on the image inplace (mutates the image). If you want to make the lines semi-transparent, think about combining this function with the weighted_img() function below """ # In case of error, don't draw the line(s) if lines is None: return if len(lines) == 0: return draw_right = True draw_left = True # Find slopes of all lines # But only care about lines where abs(slope) > slope_threshold slope_threshold = 0.5 slopes = [] new_lines = [] for line in lines: x1, y1, x2, y2 = line[0] # line = [[x1, y1, x2, y2]] # Calculate slope if x2 - x1 == 0.: # corner case, avoiding division by 0 slope = 999. # practically infinite slope else: slope = (y2 - y1) / (x2 - x1) # Filter lines based on slope if abs(slope) > slope_threshold: slopes.append(slope) new_lines.append(line) lines = new_lines # Split lines into right_lines and left_lines, representing the right and left lane lines # Right/left lane lines must have positive/negative slope, and be on the right/left half of the image right_lines = [] left_lines = [] for i, line in enumerate(lines): x1, y1, x2, y2 = line[0] img_x_center = img.shape[1] / 2 # x coordinate of center of image if slopes[i] > 0 and x1 > img_x_center and x2 > img_x_center: right_lines.append(line) elif slopes[i] < 0 and x1 < img_x_center and x2 < img_x_center: left_lines.append(line) # Run linear regression to find best fit line for right and left lane lines # Right lane lines right_lines_x = [] right_lines_y = [] for line in right_lines: x1, y1, x2, y2 = line[0] right_lines_x.append(x1) right_lines_x.append(x2) right_lines_y.append(y1) right_lines_y.append(y2) if len(right_lines_x) > 0: right_m, right_b = np.polyfit(right_lines_x, right_lines_y, 1) # y = m*x + b else: right_m, right_b = 1, 1 draw_right = False # Left lane lines left_lines_x = [] left_lines_y = [] for line in left_lines: x1, y1, x2, y2 = line[0] left_lines_x.append(x1) left_lines_x.append(x2) left_lines_y.append(y1) left_lines_y.append(y2) if len(left_lines_x) > 0: left_m, left_b = np.polyfit(left_lines_x, left_lines_y, 1) # y = m*x + b else: left_m, left_b = 1, 1 draw_left = False # Find 2 end points for right and left lines, used for drawing the line # y = m*x + b --> x = (y - b)/m y1 = img.shape[0] y2 = img.shape[0] * (1 - trap_height) right_x1 = (y1 - right_b) / right_m right_x2 = (y2 - right_b) / right_m left_x1 = (y1 - left_b) / left_m left_x2 = (y2 - left_b) / left_m # Convert calculated end points from float to int y1 = int(y1) y2 = int(y2) right_x1 = int(right_x1) right_x2 = int(right_x2) left_x1 = int(left_x1) left_x2 = int(left_x2) # Draw the right and left lines on image if draw_right: cv2.line(img, (right_x1, y1), (right_x2, y2), color, thickness) if draw_left: cv2.line(img, (left_x1, y1), (left_x2, y2), color, thickness) def hough_lines(img, rho, theta, threshold, min_line_len, max_line_gap): """ `img` should be the output of a Canny transform. Returns an image with hough lines drawn. """ lines = cv2.HoughLinesP(img, rho, theta, threshold, np.array([]), minLineLength=min_line_len, maxLineGap=max_line_gap) line_img = np.zeros((*img.shape, 3), dtype=np.uint8) # 3-channel RGB image draw_lines(line_img, lines) return line_img # Python 3 has support for cool math symbols. def weighted_img(img, initial_img, α=0.8, β=1., λ=0.): """ `img` is the output of the hough_lines(), An image with lines drawn on it. Should be a blank image (all black) with lines drawn on it. `initial_img` should be the image before any processing. The result image is computed as follows: initial_img * α + img * β + λ NOTE: initial_img and img must be the same shape! """ return cv2.addWeighted(initial_img, α, img, β, λ) def filter_colors(image): """ Filter the image to include only yellow and white pixels """ # Filter white pixels white_threshold = 200 #130 lower_white = np.array([white_threshold, white_threshold, white_threshold]) upper_white = np.array([255, 255, 255]) white_mask = cv2.inRange(image, lower_white, upper_white) white_image = cv2.bitwise_and(image, image, mask=white_mask) # Filter yellow pixels hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) lower_yellow = np.array([90,100,100]) upper_yellow = np.array([110,255,255]) yellow_mask = cv2.inRange(hsv, lower_yellow, upper_yellow) yellow_image = cv2.bitwise_and(image, image, mask=yellow_mask) # Combine the two above images image2 = cv2.addWeighted(white_image, 1., yellow_image, 1., 0.) return image2 def annotate_image_array(image_in): """ Given an image Numpy array, return the annotated image as a Numpy array """ # Only keep white and yellow pixels in the image, all other pixels become black image = filter_colors(image_in) # Read in and grayscale the image gray = grayscale(image) # Apply Gaussian smoothing blur_gray = gaussian_blur(gray, kernel_size) # Apply Canny Edge Detector edges = canny(blur_gray, low_threshold, high_threshold) # Create masked edges using trapezoid-shaped region-of-interest imshape = image.shape vertices = np.array([[\ ((imshape[1] * (1 - trap_bottom_width)) // 2, imshape[0]),\ ((imshape[1] * (1 - trap_top_width)) // 2, imshape[0] - imshape[0] * trap_height),\ (imshape[1] - (imshape[1] * (1 - trap_top_width)) // 2, imshape[0] - imshape[0] * trap_height),\ (imshape[1] - (imshape[1] * (1 - trap_bottom_width)) // 2, imshape[0])]]\ , dtype=np.int32) masked_edges = region_of_interest(edges, vertices) # Run Hough on edge detected image line_image = hough_lines(masked_edges, rho, theta, threshold, min_line_length, max_line_gap) # Draw lane lines on the original image initial_image = image_in.astype('uint8') annotated_image = weighted_img(line_image, initial_image) return annotated_image def annotate_image(input_file, output_file): """ Given input_file image, save annotated image to output_file """ annotated_image = annotate_image_array(mpimg.imread(input_file)) plt.imsave(output_file, annotated_image) def annotate_video(input_file, output_file): """ Given input_file video, save annotated video to output_file """ video = VideoFileClip(input_file) annotated_video = video.fl_image(annotate_image_array) annotated_video.write_videofile(output_file, audio=False) # End helper functions # Main script if __name__ == '__main__': from optparse import OptionParser # Configure command line options parser = OptionParser() parser.add_option("-i", "--input_file", dest="input_file", help="Input video/image file") parser.add_option("-o", "--output_file", dest="output_file", help="Output (destination) video/image file") parser.add_option("-I", "--image_only", action="store_true", dest="image_only", default=False, help="Annotate image (defaults to annotating video)") # Get and parse command line options options, args = parser.parse_args() input_file = options.input_file output_file = options.output_file image_only = options.image_only if image_only: annotate_image(input_file, output_file) else: annotate_video(input_file, output_file) |
Giao diện chương trình Phát hiện làn đường
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 |
import tkinter as tk from tkinter import * import cv2 from PIL import Image, ImageTk import os import numpy as np global last_frame1 #creating global variable last_frame1 = np.zeros((480, 640, 3), dtype=np.uint8) global last_frame2 #creating global variable last_frame2 = np.zeros((480, 640, 3), dtype=np.uint8) global cap1 global cap2 cap1 = cv2.VideoCapture("./input2.mp4") cap2 = cv2.VideoCapture("./output2.mp4") def show_vid(): if not cap1.isOpened(): print("cant open the camera1") flag1, frame1 = cap1.read() frame1 = cv2.resize(frame1,(600,500)) if flag1 is None: print ("Major error!") elif flag1: global last_frame1 last_frame1 = frame1.copy() pic = cv2.cvtColor(last_frame1, cv2.COLOR_BGR2RGB) img = Image.fromarray(pic) imgtk = ImageTk.PhotoImage(image=img) lmain.imgtk = imgtk lmain.configure(image=imgtk) lmain.after(10, show_vid) def show_vid2(): if not cap2.isOpened(): print("cant open the camera2") flag2, frame2 = cap2.read() frame2 = cv2.resize(frame2,(600,500)) if flag2 is None: print ("Major error2!") elif flag2: global last_frame2 last_frame2 = frame2.copy() pic2 = cv2.cvtColor(last_frame2, cv2.COLOR_BGR2RGB) img2 = Image.fromarray(pic2) img2tk = ImageTk.PhotoImage(image=img2) lmain2.img2tk = img2tk lmain2.configure(image=img2tk) lmain2.after(10, show_vid2) if __name__ == '__main__': root=tk.Tk() img = ImageTk.PhotoImage(Image.open("logo.png")) heading = Label(root,image=img, text="Lane-Line Detection") # heading.configure(background='#CDCDCD',foreground='#364156') heading.pack() heading2=Label(root,text="Lane-Line Detection",pady=20, font=('arial',45,'bold')) heading2.configure(foreground='#364156') heading2.pack() lmain = tk.Label(master=root) lmain2 = tk.Label(master=root) lmain.pack(side = LEFT) lmain2.pack(side = RIGHT) root.title("Lane-line detection") root.geometry("1250x900+100+10") exitbutton = Button(root, text='Quit',fg="red",command= root.destroy).pack(side = BOTTOM,) show_vid() show_vid2() root.mainloop() cap.release() |
Xem thêm
Sắp tới, mình có viết 1 ứng dụng Android với chức năng phát hiện làn đường và cảnh báo tốc độ cho lái xe ô tô. Ứng dụng đang được phát triển và sẽ được tiến hành thử nghiệm thực tế trong thời gian tới.
Mọi người tiếp tục đón xem các bài viết tiếp theo của Ngô Tôn .IT nhé.
Leave a Reply