Ảnh là mảng số: NumPy cho xử lý ảnh (BGR, ROI, mặt nạ, uint8)
Ảnh xám, ảnh màu BGR, ảnh 16 bit, cắt ROI kiểm tra có/không, mặt nạ tròn và bẫy tràn số uint8: nền tảng trước khi học OpenCV.
Học xong bài này, bạn sẽ
- Hiểu ảnh xám, ảnh màu, thứ tự BGR
- Cắt ROI và tạo mặt nạ
- Tránh bẫy tràn số uint8
Nên học trước: NumPy cơ bản: mảng, slicing, vector hóa
Nội dung bài viết
Trước khi học OpenCV hay Deep Learning, có một sự thật cần nắm thật chắc: với máy tính, một bức ảnh chỉ là một mảng số. Bài "NumPy cơ bản: mảng, slicing, vector hóa" đã dạy bạn tạo mảng, cắt mảng, view/copy, mặt nạ boolean và axis trên các dãy số đo. Bài này áp dụng đúng những công cụ đó lên ảnh: ảnh xám và ảnh màu nằm trong bộ nhớ ra sao, vì sao OpenCV dùng thứ tự BGR, cắt vùng kiểm tra (ROI), tạo mặt nạ, và cái bẫy tràn số uint8 mà gần như ai cũng dính ít nhất một lần. Sau bài này là "Dự án chặng 1: báo cáo chất lượng thư mục ảnh", nơi bạn dùng mọi thứ để tự động đánh giá cả nghìn ảnh.
Các ví dụ dùng NumPy và OpenCV (đã cài ở bài "Môi trường ảo, pip và thư viện cho AI Vision"). Ở đây OpenCV chỉ dùng để đọc/ghi file và vài hàm nhỏ; chặng 2 sẽ học OpenCV bài bản.
1. Ảnh xám: mảng 2 chiều
Ảnh xám (grayscale) là mảng 2 chiều (cao, rộng). Mỗi phần tử là độ sáng của một pixel, kiểu uint8 (số nguyên không dấu 8 bit): 0 là đen, 255 là trắng. Tự tạo một ảnh "thang xám" 100 hàng × 256 cột, sáng dần từ trái sang phải:
import cv2
import numpy as np
xam = np.tile(np.arange(256, dtype=np.uint8), (100, 1)) # lặp hàng 0..255 thành 100 hàng
print(xam.shape, xam.dtype, xam.size)
print("Trái, giữa, phải:", xam[50, 0], xam[50, 128], xam[50, 255])
cv2.imwrite("thang_xam.png", xam)
doc_lai = cv2.imread("thang_xam.png", cv2.IMREAD_GRAYSCALE)
print("Đọc lại giống hệt:", np.array_equal(doc_lai, xam))
(100, 256) uint8 25600
Trái, giữa, phải: 0 128 255
Đọc lại giống hệt: True
Một ảnh xám 2448×2048 (camera 5 MP phổ biến trong Machine Vision) chiếm đúng 2448 × 2048 = 5.013.504 byte, khoảng 5 MB trong bộ nhớ: mỗi pixel 1 byte. Ảnh màu cùng độ phân giải chiếm gấp 3.
Hệ tọa độ: hàng trước, cột sau
Gốc tọa độ ảnh ở góc trái trên, trục y hướng xuống. NumPy truy cập theo anh[hàng, cột] tức anh[y, x], nhưng các hàm vẽ của OpenCV lại nhận điểm dạng (x, y). Hai quy ước này nằm cạnh nhau trong cùng một chương trình, nên phải luôn tỉnh táo:
anh = np.zeros((100, 200), np.uint8) # cao 100, rộng 200
cv2.circle(anh, (150, 30), 5, 255, -1) # OpenCV: tâm (x=150, y=30)
print(anh.shape) # (cao, rộng)
print("anh[30, 150] =", anh[30, 150]) # NumPy: [y, x] → đúng điểm trắng
try:
print(anh[150, 30]) # viết nhầm thành [x, y]
except IndexError as loi:
print("anh[150, 30] lỗi:", loi)
(100, 200)
anh[30, 150] = 255
anh[150, 30] lỗi: index 150 is out of bounds for axis 0 with size 100
2. Ảnh màu và thứ tự BGR
Ảnh màu là mảng 3 chiều (cao, rộng, 3): mỗi pixel có 3 số. OpenCV lưu 3 số đó theo thứ tự Blue, Green, Red (BGR), ngược với RGB mà Pillow, matplotlib và hầu hết mô hình AI dùng.
mau = np.zeros((120, 160, 3), np.uint8)
mau[:, :, 2] = 255 # kênh số 2 là R → cả ảnh màu đỏ
mau[40:80, 60:100] = (255, 0, 0) # ô giữa: B=255 → xanh dương
cv2.imwrite("do_xanh.png", mau)
anh = cv2.imread("do_xanh.png") # mặc định đọc thành ảnh màu BGR
print(anh.shape, anh.dtype)
print("Pixel góc (BGR):", anh[0, 0], " pixel giữa (BGR):", anh[60, 80])
b, g, r = cv2.split(anh) # tách 3 kênh, mỗi kênh là ảnh xám
print("Trung bình B, G, R:", anh.mean(axis=(0, 1)).round(1))
xam = cv2.imread("do_xanh.png", cv2.IMREAD_GRAYSCALE)
print("Đọc dạng xám:", xam.shape, xam[0, 0], xam[60, 80])
(120, 160, 3) uint8
Pixel góc (BGR): [ 0 0 255] pixel giữa (BGR): [255 0 0]
Trung bình B, G, R: [ 21.2 0. 233.8]
Đọc dạng xám: (120, 160) 76 29
Đọc dạng xám, đỏ thuần thành 76 còn xanh dương thuần thành 29: OpenCV đổi màu sang xám theo trọng số 0,299·R + 0,587·G + 0,114·B (bài "Không gian màu BGR, HSV, Gray và tách màu" sẽ giải thích kỹ).
BGR gặp RGB: đọc cùng file bằng Pillow
from PIL import Image
rgb_pillow = np.asarray(Image.open("do_xanh.png"))
print("Pillow (RGB) pixel góc:", rgb_pillow[0, 0])
rgb = cv2.cvtColor(anh, cv2.COLOR_BGR2RGB) # cách chuẩn để đổi BGR → RGB
print("Giống Pillow:", np.array_equal(rgb, rgb_pillow))
dao_kenh = anh[:, :, ::-1] # đảo kênh bằng slicing: nhanh, là view
print("Giống Pillow:", np.array_equal(dao_kenh, rgb_pillow),
"| liền bộ nhớ:", dao_kenh.flags["C_CONTIGUOUS"])
try:
cv2.rectangle(dao_kenh, (0, 0), (10, 10), (0, 255, 0), -1)
except cv2.error as loi:
print("cv2.rectangle trên view đảo kênh báo lỗi:", type(loi).__name__)
dao_kenh = np.ascontiguousarray(dao_kenh) # sao chép thành mảng liền bộ nhớ
cv2.rectangle(dao_kenh, (0, 0), (10, 10), (0, 255, 0), -1)
print("Sau ascontiguousarray: vẽ được")
Pillow (RGB) pixel góc: [255 0 0]
Giống Pillow: True
Giống Pillow: True | liền bộ nhớ: False
cv2.rectangle trên view đảo kênh báo lỗi: error
Sau ascontiguousarray: vẽ được
anh[:, :, ::-1] là view với bước nhảy âm, không liền bộ nhớ. Nhiều hàm OpenCV từ chối ghi vào mảng như vậy (lỗi "Layout of the output array ... is incompatible with cv::Mat"). Dùng cv2.cvtColor hoặc bọc thêm np.ascontiguousarray là hết.
3. Ảnh 16 bit từ camera công nghiệp
Camera công nghiệp thường cho ảnh 10 hoặc 12 bit (giá trị 0–1023 hoặc 0–4095), lưu trong mảng uint16. Đọc sai cờ là mất dữ liệu mà không hay biết:
anh12 = np.tile(np.linspace(0, 4095, 8).astype(np.uint16), (4, 1)) # ảnh 12 bit giả lập
cv2.imwrite("anh_12bit.png", anh12) # PNG lưu được 16 bit
giu_nguyen = cv2.imread("anh_12bit.png", cv2.IMREAD_UNCHANGED)
mac_dinh = cv2.imread("anh_12bit.png") # mặc định: ép về 8 bit, 3 kênh
print("UNCHANGED:", giu_nguyen.dtype, giu_nguyen.shape, giu_nguyen[0])
print("Mặc định :", mac_dinh.dtype, mac_dinh.shape, mac_dinh[0, :, 0])
hien_thi = (giu_nguyen >> 4).astype(np.uint8) # 12 bit → 8 bit để xem: chia 16
print("Để hiển thị:", hien_thi[0])
UNCHANGED: uint16 (4, 8) [ 0 585 1170 1755 2340 2925 3510 4095]
Mặc định : uint8 (4, 8, 3) [ 0 2 4 6 9 11 13 15]
Để hiển thị: [ 0 36 73 109 146 182 219 255]
Với ảnh 16 bit, đọc mặc định sẽ chia giá trị cho 256 (dịch phải 8 bit), nên ảnh 12 bit gần như chỉ còn 16 mức xám. Muốn đo đạc chính xác trên ảnh 12 bit: đọc bằng IMREAD_UNCHANGED, tính toán trên uint16 hoặc float32, chỉ chuyển về 8 bit khi cần hiển thị. JPEG không lưu được 16 bit, hãy dùng PNG hoặc TIFF.
4. Cắt vùng quan tâm (ROI)
Trong Machine Vision, ta hầu như chỉ xử lý một vùng nhỏ của ảnh, gọi là ROI (Region of Interest): vùng có con ốc, vùng in hạn sử dụng, vị trí linh kiện trên khay. Cắt ROI là slicing anh[y1:y2, x1:x2], và thường cấu hình dưới dạng (x, y, w, h).
Ví dụ: kiểm tra khay 4 ô, ô nào có linh kiện (sáng) thì OK, ô trống (tối) thì NG. Độ sáng trung bình của ROI là một "cảm biến" đơn giản mà rất hiệu quả, dùng cho các bài toán có/không: có linh kiện chưa, nắp chai đã đậy chưa, đèn báo có sáng không.
rng = np.random.default_rng(0)
khay = rng.integers(20, 50, size=(200, 400), dtype=np.uint8) # nền khay tối, có nhiễu
for x in (20, 120, 320): # ô thứ 3 (x=220) bị trống
khay[60:140, x + 10:x + 70] = rng.integers(170, 220, size=(80, 60), dtype=np.uint8)
cv2.imwrite("khay.png", khay)
ROI = [(20, 50, 80, 100), (120, 50, 80, 100), (220, 50, 80, 100), (320, 50, 80, 100)] # (x, y, w, h)
NGUONG = 90
for i, (x, y, w, h) in enumerate(ROI, start=1):
vung = khay[y:y + h, x:x + w] # hàng y..y+h, cột x..x+w
sang = vung.mean()
print(f"Ô {i}: ROI {vung.shape}, sáng TB {sang:6.1f} → {'OK' if sang > NGUONG else 'NG (trống)'}")
Ô 1: ROI (100, 80), sáng TB 130.6 → OK
Ô 2: ROI (100, 80), sáng TB 130.6 → OK
Ô 3: ROI (100, 80), sáng TB 34.5 → NG (trống)
Ô 4: ROI (100, 80), sáng TB 130.4 → OK
Ngưỡng 90 nằm giữa nền (khoảng 35) và mức trung bình của ô có linh kiện (khoảng 130, vì linh kiện chỉ chiếm 60% diện tích ROI). Ngưỡng tốt nằm xa cả hai phía; trên dây chuyền thật bạn sẽ lấy hai giá trị này từ vài chục ảnh OK và NG.
vung dùng chung bộ nhớ với ảnh gốc: gán vung[:] = 0 là bôi đen luôn vùng đó trên ảnh gốc. Tính toán (mean, so sánh) thì không sao; định sửa thì .copy() trước. Ngược lại, chính tính chất này cho phép "vẽ" lên ảnh bằng slicing, như bài tập 1.5. Mặt nạ (mask)
Phép so sánh trên ảnh trả về mảng True/False cùng kích thước: đó là một mặt nạ, đánh dấu pixel nào thỏa điều kiện. Ví dụ: bề mặt sáng có vài vết bẩn tối, tính tỉ lệ diện tích bẩn.
be_mat = np.full((100, 100), 200, np.uint8) # bề mặt sáng
be_mat[20:26, 30:40] = 40 # vết bẩn 1: 6 x 10 = 60 pixel
be_mat[70:75, 60:64] = 55 # vết bẩn 2: 5 x 4 = 20 pixel
toi = be_mat < 100 # mặt nạ bool
print(toi.dtype, toi.shape)
print("Số pixel tối:", toi.sum(), "| tỉ lệ:", f"{toi.mean():.2%}")
print("Độ sáng TB vùng tối:", be_mat[toi].mean(), "| vùng sạch:", be_mat[~toi].mean())
mask_cv = toi.astype(np.uint8) * 255 # dạng OpenCV: 0 và 255
print(mask_cv.dtype, np.unique(mask_cv), cv2.countNonZero(mask_cv))
danh_dau = cv2.cvtColor(be_mat, cv2.COLOR_GRAY2BGR)
danh_dau[toi] = (0, 0, 255) # tô đỏ mọi pixel bẩn
cv2.imwrite("vet_ban.png", danh_dau)
bool (100, 100)
Số pixel tối: 80 | tỉ lệ: 0.80%
Độ sáng TB vùng tối: 43.75 | vùng sạch: 200.0
uint8 [ 0 255] 80
toi.sum()đếm sốTrue;toi.mean()là tỉ lệ diện tích (True = 1, False = 0).be_mat[toi]lấy ra các pixel thỏa mặt nạ (thành mảng 1 chiều);~toilà phần còn lại.- Hàm OpenCV nhận mặt nạ dạng
uint8với giá trị 0/255, không nhậnbool. Đổi bằng.astype(np.uint8) * 255; chiều ngược lại làmask_cv > 0. - Gán theo mặt nạ trên ảnh màu (
danh_dau[toi] = (0, 0, 255)) tô cả 3 kênh của mọi pixel được chọn: cách nhanh nhất để đánh dấu lỗi lên ảnh kết quả.
Mặt nạ hình tròn: chỉ kiểm tra bên trong nắp chai
Sản phẩm tròn (nắp chai, vòng đệm) thì ROI chữ nhật dính cả nền ở bốn góc. Tạo mặt nạ tròn bằng khoảng cách tới tâm, tính được bằng broadcasting:
cao, rong = 120, 120
cx, cy, ban_kinh = 60, 60, 50
yy, xx = np.ogrid[:cao, :rong] # yy: cột (120,1), xx: hàng (1,120)
trong_tron = (xx - cx) ** 2 + (yy - cy) ** 2 <= ban_kinh ** 2
print(trong_tron.shape, trong_tron.sum(), "pixel; πr² =", round(np.pi * ban_kinh ** 2))
nap = np.full((cao, rong), 30, np.uint8) # nền tối
nap[trong_tron] = 180 # nắp sáng
nap[55:60, 40:52] = 60 # một vết lõm tối trên nắp
loi = trong_tron & (nap < 100) # tối VÀ nằm trong nắp
print("Pixel lỗi trong nắp:", loi.sum(), "| nếu không có mặt nạ tròn:", (nap < 100).sum())
(120, 120) 7845 pixel; πr² = 7854
Pixel lỗi trong nắp: 60 | nếu không có mặt nạ tròn: 6615
Không dùng mặt nạ tròn, bốn góc nền tối bị đếm là "lỗi" và mọi nắp đều thành NG. Kết hợp mặt nạ bằng &, |, ~ với mỗi điều kiện trong ngoặc, đúng như bài NumPy cơ bản.
6. Bẫy tràn số uint8
uint8 chỉ chứa 0–255. Phép cộng, trừ, nhân giữa hai mảng uint8 cho kết quả uint8, giá trị vượt khoảng sẽ quay vòng (tính theo modulo 256), không báo lỗi gì:
a = np.array([[200, 250, 30]], dtype=np.uint8) # "ảnh" 1 hàng x 3 pixel
b = np.array([[100, 10, 60]], dtype=np.uint8)
print("a + b :", a + b) # 300 → 44 !
print("b - a :", b - a) # -100 → 156 !
print("(a + b) // 2:", (a + b) // 2) # trung bình hai ảnh: sai
print("Đúng, int16:", (a.astype(np.int16) + b))
print("TB đúng :", ((a.astype(np.uint16) + b) // 2).astype(np.uint8))
print("cv2.add :", cv2.add(a, b)) # OpenCV: bão hòa ở 255
print("cv2.absdiff:", cv2.absdiff(a, b)) # |a - b|, không bao giờ âm
a + b : [[44 4 90]]
b - a : [[156 16 30]]
(a + b) // 2: [[22 2 45]]
Đúng, int16: [[300 260 90]]
TB đúng : [[150 130 45]]
cv2.add : [[255 255 90]]
cv2.absdiff: [[100 240 30]]
Ở dòng TB đúng, chỉ cần một toán hạng là uint16 thì NumPy tính cả phép cộng ở uint16. Ví dụ dùng mảng 2 chiều 1×3 (một "ảnh" nhỏ) vì các hàm OpenCV làm việc với ảnh; truyền mảng 1 chiều 3–4 phần tử, OpenCV có thể hiểu nhầm nó là một bộ giá trị màu.
Ba tình huống dính bẫy hay gặp nhất:
| Tình huống | Viết sai | Viết đúng |
|---|---|---|
| Tăng sáng | anh + 60 | np.clip(anh.astype(np.int16) + 60, 0, 255).astype(np.uint8) hoặc cv2.add |
| So sánh ảnh với ảnh mẫu (tìm khác biệt) | anh - mau | cv2.absdiff(anh, mau) |
| Tăng tương phản, nhân hệ số | (anh * 1.5).astype(np.uint8) | np.clip(anh * 1.5, 0, 255).astype(np.uint8) hoặc cv2.convertScaleAbs(anh, alpha=1.5, beta=0) |
Dòng cuối là bẫy kiểu khác: anh * 1.5 cho ra float64 (không tràn), nhưng ép thẳng về uint8 mà không clip thì giá trị ngoài 0–255 cho kết quả không xác định:
f = np.array([300.0, -20.0, 128.6])
print("Ép thẳng:", f.astype(np.uint8)) # không xác định, tùy máy
print("Clip rồi ép:", np.clip(f, 0, 255).astype(np.uint8)) # 128.6 → 128: cắt phần lẻ
print("Làm tròn đúng:", np.clip(np.rint(f), 0, 255).astype(np.uint8))
Ép thẳng: [ 44 236 128]
Clip rồi ép: [255 0 128]
Làm tròn đúng: [255 0 129]
Kết quả dòng đầu là trên máy chạy thử; máy khác có thể ra số khác, đó chính là vấn đề. Cũng để ý astype cắt bỏ phần lẻ (128,6 thành 128), muốn làm tròn thì np.rint trước.
cv2.add(anh, 60) trên OpenCV 4.x hiểu số 60 là bộ (60, 0, 0, 0), nên chỉ kênh B được cộng và ảnh ngả xanh; OpenCV 5 cộng đều cả 3 kênh. Để code chạy đúng trên mọi phiên bản, truyền đủ bộ giá trị: cv2.add(anh, (60, 60, 60, 0)), hoặc dùng cách np.clip ở trên.7. Thống kê nhanh trên ảnh
Vài con số tính trong một dòng NumPy đủ để phát hiện ảnh có vấn đề trước khi chạy thuật toán chính:
anh = cv2.imread("khay.png", cv2.IMREAD_GRAYSCALE)
print("Sáng TB:", round(anh.mean(), 1), "| độ tương phản (std):", round(anh.std(), 1))
print("Min/max:", anh.min(), anh.max())
hist = np.bincount(anh.ravel(), minlength=256) # histogram 256 mức xám
print("Mức xám phổ biến nhất:", hist.argmax(), "với", hist.max(), "pixel")
chay_sang = (anh == 255).mean() # tỉ lệ pixel bão hòa
print(f"Pixel cháy sáng (=255): {chay_sang:.2%}")
Sáng TB: 63.3 | độ tương phản (std): 62.3
Min/max: 20 219
Mức xám phổ biến nhất: 39 với 2261 pixel
Pixel cháy sáng (=255): 0.00%
| Chỉ số | Dấu hiệu bất thường | Nguyên nhân thường gặp ở trạm |
|---|---|---|
| Sáng TB rất thấp | Ảnh tối | Đèn chưa bật, trigger đèn lệch thời điểm chụp, exposure quá ngắn |
| Tỉ lệ pixel = 255 lớn (vài %) | Cháy sáng, mất chi tiết | Exposure/gain quá cao, phản xạ gương từ bề mặt kim loại |
| Std rất nhỏ | Ảnh "phẳng", thiếu tương phản | Mất nét nặng, ống kính bẩn, chụp khi chưa có sản phẩm |
Tóm tắt
- Ảnh xám:
(cao, rộng); ảnh màu:(cao, rộng, 3)theo thứ tự BGR; thường làuint80–255, camera công nghiệp có thể làuint16(đọc bằngIMREAD_UNCHANGED). - NumPy:
anh[y, x]; OpenCV vẽ:(x, y). Gốc ở góc trái trên. - Đổi BGR→RGB bằng
cv2.cvtColor; view[:, :, ::-1]không liền bộ nhớ, một số hàm OpenCV từ chối. - ROI
(x, y, w, h)→anh[y:y+h, x:x+w]; độ sáng TB của ROI là cảm biến có/không đơn giản. - Mặt nạ bool:
sum()đếm,mean()tỉ lệ; OpenCV cần mặt nạuint80/255. uint8quay vòng khi tràn: đổi sang kiểu lớn hơn +np.clip, hoặc dùngcv2.add/cv2.absdiff.
Bài tập
Bài 1. Vẽ hình chữ nhật bằng slicing
Tạo ảnh xám 200×300 (cao 200, rộng 300) nền đen. Chỉ bằng slicing (không dùng cv2.rectangle), vẽ một hình chữ nhật trắng đặc có góc trái trên tại x = 50, y = 40, rộng 120, cao 80. Lưu ra chu_nhat.png, đọc lại và kiểm tra số pixel trắng.
Xem đáp án
import cv2
import numpy as np
anh = np.zeros((200, 300), dtype=np.uint8) # (cao, rộng)
x, y, w, h = 50, 40, 120, 80
anh[y:y + h, x:x + w] = 255 # hàng trước (y), cột sau (x)
cv2.imwrite("chu_nhat.png", anh)
doc = cv2.imread("chu_nhat.png", cv2.IMREAD_GRAYSCALE)
print(doc.shape, "pixel trắng:", np.count_nonzero(doc == 255), "= w*h =", w * h)
print("Góc trái trên:", doc[40, 50], "| ngay ngoài:", doc[39, 50], doc[40, 49])
(200, 300) pixel trắng: 9600 = w*h = 9600
Góc trái trên: 255 | ngay ngoài: 0 0
Muốn chỉ vẽ viền dày 3 pixel: tô cả khối trắng rồi tô đen phần bên trong anh[y+3:y+h-3, x+3:x+w-3] = 0. Với ảnh màu, gán bộ 3 số: anh[y:y+h, x:x+w] = (0, 255, 0) cho màu xanh lá.
Bài 2. Tăng sáng 60 mà không tràn số
Dùng ảnh thang_xam.png ở mục 1. Tăng độ sáng thêm 60 theo hai cách: cộng trực tiếp trên uint8, và cách đúng (đổi kiểu + clip). So sánh: đếm số pixel bị "quay vòng" ở cách sai, in giá trị của vài cột tiêu biểu, và kiểm tra cách đúng cho cùng kết quả với cv2.add.
Xem đáp án
import cv2
import numpy as np
anh = cv2.imread("thang_xam.png", cv2.IMREAD_GRAYSCALE) # 100 x 256, cột c có giá trị c
sai = anh + 60 # uint8 + int: vẫn là uint8
dung = np.clip(anh.astype(np.int16) + 60, 0, 255).astype(np.uint8)
dung_cv = cv2.add(anh, 60) # ảnh 1 kênh: an toàn mọi phiên bản
cot = [0, 100, 195, 196, 200, 255]
print("Gốc :", anh[0, cot])
print("Sai :", sai[0, cot])
print("Đúng:", dung[0, cot])
print("Pixel bị quay vòng:", np.count_nonzero(sai < anh), "trên", anh.size)
print("Giống cv2.add:", np.array_equal(dung, dung_cv))
cv2.imwrite("so_sanh_tang_sang.png", np.vstack([anh, sai, dung]))
Gốc : [ 0 100 195 196 200 255]
Sai : [ 60 160 255 0 4 59]
Đúng: [ 60 160 255 255 255 255]
Pixel bị quay vòng: 6000 trên 25600
Giống cv2.add: True
Mọi pixel có giá trị từ 196 trở lên (196 + 60 = 256) bị quay vòng về gần 0: vùng sáng nhất của ảnh trở thành vùng tối nhất. Mở so_sanh_tang_sang.png sẽ thấy dải giữa (cách sai) có một bước nhảy đột ngột từ trắng sang đen. Trong bài toán thật, lỗi này làm sản phẩm sáng bóng bị coi là "lỗ đen" và báo NG oan (overkill).
Bài 3. Kiểm tra có/không bằng ROI và mặt nạ
Dùng ảnh khay.png ở mục 4. Thay vì độ sáng trung bình, với mỗi ô tính tỉ lệ pixel sáng (giá trị > 128) trong ROI. Ô OK nếu tỉ lệ ≥ 40%. Vì sao cách này ổn định hơn độ sáng trung bình khi đèn yếu đi một chút?
Xem đáp án
import cv2
import numpy as np
khay = cv2.imread("khay.png", cv2.IMREAD_GRAYSCALE)
ROI = [(20, 50, 80, 100), (120, 50, 80, 100), (220, 50, 80, 100), (320, 50, 80, 100)]
for i, (x, y, w, h) in enumerate(ROI, start=1):
ti_le = (khay[y:y + h, x:x + w] > 128).mean()
print(f"Ô {i}: {ti_le:.0%} pixel sáng → {'OK' if ti_le >= 0.4 else 'NG'}")
Ô 1: 60% pixel sáng → OK
Ô 2: 60% pixel sáng → OK
Ô 3: 0% pixel sáng → NG
Ô 4: 60% pixel sáng → OK
Linh kiện chiếm khoảng 60% ROI (vùng 80×60 trong ROI 100×80), ô trống gần 0%. Khi đèn yếu đi 20%, pixel linh kiện giảm từ khoảng 195 xuống khoảng 156, vẫn trên ngưỡng 128, nên tỉ lệ gần như không đổi; còn độ sáng trung bình thì tụt theo đèn và tiến sát ngưỡng. Đếm pixel theo mặt nạ đo diện tích, ít nhạy với độ sáng tổng thể hơn trung bình.
Bài tiếp theo: Dự án chặng 1: báo cáo chất lượng thư mục ảnh, bạn sẽ gộp duyệt thư mục, CSV, log và các phép thống kê ảnh vừa học thành một công cụ hoàn chỉnh.
Bài tập thực hành
- Tạo ảnh 200x300 nền đen, vẽ một hình chữ nhật trắng bằng slicing rồi lưu ra file.
- Tăng độ sáng một ảnh thêm 60 mà không bị tràn số, so sánh với cách cộng trực tiếp trên uint8.
Làm xong bài tập mới chuyển sang bài tiếp theo: tự viết code là cách duy nhất để nhớ lâu.


