Histogram, kéo giãn tương phản, equalizeHist và CLAHE trong OpenCV
Đọc histogram để tự chẩn đoán ảnh tối, cháy sáng, tương phản thấp; kéo giãn, gamma, cân bằng toàn cục và CLAHE cho ánh sáng không đều.
Học xong bài này, bạn sẽ
- Đọc histogram để chẩn đoán ảnh
- Kéo giãn tương phản, cân bằng histogram
- Dùng CLAHE cho ảnh sáng không đều
Nên học trước: Dự án chặng 2: công cụ chụp và lưu ảnh kiểm tra
Nội dung bài viết
- 1. Histogram là gì?
- 2. Ba ảnh mẫu: tối, bình thường, cháy sáng
- 3. Đọc histogram để chẩn đoán ảnh
- 4. Hàm tự chẩn đoán ảnh
- 5. Kéo giãn tương phản và gamma
- 6. Cân bằng histogram toàn cục (equalizeHist)
- 7. CLAHE: cân bằng thích nghi cục bộ
- 8. Ảnh màu: chỉ cân bằng kênh độ sáng
- 9. Giám sát độ sáng khi sản xuất
- 10. Lỗi thường gặp
- 11. Bài tập
- Tóm tắt
Mọi thuật toán ở chặng này, từ phân ngưỡng, tìm cạnh đến đo kích thước, đều giả định ảnh đầu vào "đủ tốt": không quá tối, không cháy sáng, đủ tương phản. Ở chặng 2 (OpenCV cơ bản) bạn đã đọc, ghi và tổ chức pipeline xử lý ảnh; bài mở đầu chặng 3 này dạy bạn chẩn đoán ảnh bằng histogram trước khi xử lý, và các cách cân bằng sáng khi ảnh chưa đạt: kéo giãn tương phản, cân bằng histogram toàn cục và CLAHE. Bài tiếp theo, "Lọc nhiễu và phát hiện cạnh", sẽ dùng ảnh đã được chẩn đoán đúng cách này.
opencv-python và numpy đã cài ở chặng 1. Mọi ví dụ tự tạo ảnh mẫu bằng NumPy và OpenCV nên chạy ngay, và các con số in ra có thể kiểm chứng vì ta biết trước "đáp án" của ảnh. Nếu muốn vẽ biểu đồ đẹp hơn, bạn có thể cài thêm matplotlib, nhưng bài này vẽ histogram bằng OpenCV để không phụ thuộc thư viện ngoài.1. Histogram là gì?
Histogram của ảnh xám 8 bit là bảng đếm số điểm ảnh ở mỗi mức xám từ 0 (đen) đến 255 (trắng). Nó bỏ hết thông tin vị trí, chỉ giữ phân bố độ sáng, nên rất nhanh để tính và rất hữu ích để chẩn đoán phơi sáng. Xem trên một ảnh 4×4:
import cv2
import numpy as np
a = np.array([[10, 10, 20, 20],
[10, 10, 20, 20],
[200, 200, 200, 255],
[200, 200, 255, 255]], np.uint8)
h1 = np.bincount(a.ravel(), minlength=256) # cách NumPy
h2 = cv2.calcHist([a], [0], None, [256], [0, 256]).ravel() # cách OpenCV
print("mức 10:", h1[10], "| mức 20:", h1[20], "| mức 200:", h1[200], "| mức 255:", h1[255])
print("tổng:", h1.sum(), "| hai cách giống nhau:", np.array_equal(h1, h2.astype(int)))
print("kiểu và shape của calcHist:", h2.dtype, cv2.calcHist([a], [0], None, [256], [0, 256]).shape)
mức 10: 4 | mức 20: 4 | mức 200: 5 | mức 255: 3
tổng: 16 | hai cách giống nhau: True
kiểu và shape của calcHist: float32 (256,)
- Khác biệt phiên bản: OpenCV 4 trả về mảng
(256, 1), OpenCV 5 trả về(256,)như dòng in ở trên. Luôn gọi.ravel()để code chạy đúng trên cả hai. - Tham số của
cv2.calcHist(ảnh, kênh, mặt_nạ, số_bin, khoảng)đều là list. Khoảng[0, 256]có cận trên không tính, nên phải ghi 256 chứ không phải 255, nếu không mức 255 bị bỏ sót. - Tham số
mặt_nạcho phép tính histogram chỉ trong một vùng (ROI không phải hình chữ nhật). Trong trạm kiểm tra, histogram của ROI chứa sản phẩm có ý nghĩa hơn histogram của cả khung hình. - Tổng histogram luôn bằng số điểm ảnh. Chia cho tổng sẽ được phân bố xác suất, dùng để so sánh ảnh khác kích thước.
2. Ba ảnh mẫu: tối, bình thường, cháy sáng
Ta tạo một "cảnh" cố định (nền, nhãn giấy có chữ, gioăng cao su tối, ba chi tiết kim loại) rồi "chụp" với ba mức phơi sáng khác nhau. Hệ số gain mô phỏng việc tăng giảm thời gian phơi sáng hoặc độ khuếch đại của camera; nhiễu cảm biến cộng vào sau, giống camera thật.
import cv2
import numpy as np
def tao_canh():
"""Cảnh ở mức phơi sáng chuẩn (mức xám), dạng float để nhân hệ số sáng."""
c = np.full((240, 320), 90, np.uint8) # nền
cv2.rectangle(c, (30, 30), (170, 120), 200, -1) # nhãn giấy sáng
cv2.putText(c, "LOT 25A", (40, 90), cv2.FONT_HERSHEY_SIMPLEX, 0.9, 40, 2)
for x in (220, 260, 300):
cv2.circle(c, (x, 180), 14, 150, -1) # chi tiết kim loại
cv2.rectangle(c, (40, 160), (180, 220), 50, -1) # gioăng cao su tối
return c.astype(np.float32)
def chup(canh, gain, seed=0):
"""Mô phỏng chụp: nhân hệ số sáng, cộng nhiễu cảm biến, cắt về 0..255."""
rng = np.random.default_rng(seed)
anh = canh * gain + rng.normal(0, 3, canh.shape)
return np.clip(anh, 0, 255).astype(np.uint8)
canh = tao_canh()
for ten, gain in [("toi", 0.25), ("binh_thuong", 1.0), ("sang", 2.2)]:
cv2.imwrite(f"anh_{ten}.png", chup(canh, gain))
print("Đã tạo anh_toi.png, anh_binh_thuong.png, anh_sang.png")
Đã tạo anh_toi.png, anh_binh_thuong.png, anh_sang.png
cv2.putText chỉ nhận ảnh 8 bit (uint8). Vì vậy hàm tao_canh vẽ trên ảnh uint8 rồi mới đổi sang float32. Với OpenCV 4, vẽ chữ trên ảnh float vẫn chạy, nhưng làm như trên thì chạy được cả hai phiên bản.3. Đọc histogram để chẩn đoán ảnh
Thay vì nhìn bằng mắt, ta tính vài con số tóm tắt histogram. Đây là các chỉ số mà kỹ sư vision nhìn đầu tiên khi cài đặt camera:
| Chỉ số | Cách tính | Ý nghĩa |
|---|---|---|
| Trung bình (mean) | gray.mean() | Độ sáng chung; theo dõi theo thời gian để phát hiện đèn yếu |
| Độ lệch chuẩn (std) | gray.std() | Độ tương phản toàn cục |
| Phân vị 1% và 99% (p1, p99) | np.percentile(gray, (1, 99)) | Dải sáng thực sự được dùng, bỏ qua vài điểm nhiễu |
| Tỉ lệ cháy sáng | (gray >= 250).mean() | Phần ảnh bị bão hòa, mất thông tin vĩnh viễn |
| Tỉ lệ chìm đen | (gray <= 5).mean() | Phần ảnh bị cắt ở đáy |
import cv2
import numpy as np
print(f"{'ảnh':12s} {'mean':>6s} {'std':>6s} {'p1':>5s} {'p99':>5s} {'cháy':>6s} {'số mức':>7s}")
for ten in ("toi", "binh_thuong", "sang"):
g = cv2.imread(f"anh_{ten}.png", cv2.IMREAD_GRAYSCALE)
p1, p99 = np.percentile(g, (1, 99))
chay = (g >= 250).mean()
so_muc = len(np.unique(g)) # số mức xám thực sự xuất hiện
print(f"{ten:12s} {g.mean():6.1f} {g.std():6.1f} {p1:5.0f} {p99:5.0f} {chay:6.1%} {so_muc:7d}")
ảnh mean std p1 p99 cháy số mức
toi 25.4 11.5 8 54 0.0% 62
binh_thuong 103.1 44.6 44 204 0.0% 173
sang 197.0 39.7 103 255 18.0% 135
Đọc bảng:
- Ảnh tối: 98% điểm ảnh nằm trong 8–54, chỉ dùng 62 mức xám. Các bước xử lý sau sẽ làm việc với rất ít thông tin, và nhiễu cảm biến (độ lệch 3 mức) trở nên đáng kể so với tín hiệu.
- Ảnh bình thường: dải 44–204, không cháy, 173 mức. Đây là mục tiêu khi chỉnh camera.
- Ảnh cháy sáng: 18% ảnh bằng 255. Toàn bộ nhãn giấy và chữ trên nó đã thành một mảng trắng đồng nhất: không thuật toán nào khôi phục được chữ ở đó, vì mọi điểm ảnh đều có cùng giá trị.
Để nhìn được hình dạng histogram, ta vẽ nó bằng OpenCV (cách này cũng dùng được để hiển thị trên giao diện trạm kiểm tra):
import cv2
import numpy as np
def ve_histogram(gray, cao=150, ten=""):
"""Vẽ histogram 256 cột thành ảnh màu 256×cao, chuẩn hóa theo cột cao nhất."""
h = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
h = h / h.max() * (cao - 20)
anh = np.full((cao, 256, 3), 255, np.uint8)
for muc, gia_tri in enumerate(h):
cv2.line(anh, (muc, cao - 1), (muc, cao - 1 - int(gia_tri)), (90, 60, 20), 1)
cv2.putText(anh, ten, (5, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 200), 1)
return anh
cac_hinh = []
for ten in ("toi", "binh_thuong", "sang"):
g = cv2.imread(f"anh_{ten}.png", cv2.IMREAD_GRAYSCALE)
cac_hinh.append(ve_histogram(g, ten=ten))
cv2.imwrite("histogram_3_anh.png", np.hstack(cac_hinh))
print("Đã lưu histogram_3_anh.png", np.hstack(cac_hinh).shape)
Đã lưu histogram_3_anh.png (150, 768, 3)
Mở histogram_3_anh.png bạn sẽ thấy: ảnh tối dồn sát trái; ảnh bình thường có các đỉnh tách biệt (gioăng ~50, nền ~90, kim loại ~150, nhãn ~200); ảnh sáng có một cột rất cao ở 255. Cột dựng đứng ở mép phải (hoặc trái) là dấu hiệu cháy (hoặc chìm) cần sửa ngay ở camera. Nếu đã cài matplotlib, chỉ cần plt.plot(h) với h là kết quả calcHist.
| Hình dạng histogram | Chẩn đoán | Sửa ở đâu trước |
|---|---|---|
| Dồn về trái, p99 thấp | Thiếu sáng | Tăng thời gian phơi sáng, tăng đèn; tăng gain là cách cuối vì gain khuếch đại cả nhiễu |
| Cột cao ở 255 | Cháy sáng, mất thông tin | Giảm phơi sáng, giảm đèn, đổi góc đèn để tránh phản xạ gương |
| Hẹp, nằm giữa | Tương phản thấp | Đổi kiểu chiếu sáng, màu đèn, kính lọc; xử lý ảnh kéo giãn chỉ là bước phụ |
| Hai đỉnh tách rõ | Vật và nền khác biệt tốt | Rất hợp để phân ngưỡng Otsu (bài phân ngưỡng và contour) |
4. Hàm tự chẩn đoán ảnh
Trong trạm thật, bạn muốn phần mềm tự cảnh báo khi ảnh không đạt, thay vì chờ người vận hành nhìn thấy. Quy tắc dưới đây dùng các chỉ số ở mục 3; các ngưỡng (1% cháy, p99 < 100…) là điểm khởi đầu hợp lý, bạn chỉnh theo sản phẩm của mình:
import cv2
import numpy as np
def chan_doan(gray, ty_le_chay_max=0.01, p99_min=100, dai_min=60):
"""Trả về (kết luận, chi tiết) cho ảnh xám 8 bit."""
p1, p99 = np.percentile(gray, (1, 99))
chay = float((gray >= 250).mean())
chim = float((gray <= 5).mean())
chi_tiet = f"mean={gray.mean():.0f} p1={p1:.0f} p99={p99:.0f} cháy={chay:.1%} chìm={chim:.1%}"
if chay > ty_le_chay_max:
return "QUÁ SÁNG", chi_tiet
if p99 < p99_min or chim > ty_le_chay_max:
return "QUÁ TỐI", chi_tiet
if p99 - p1 < dai_min:
return "TƯƠNG PHẢN THẤP", chi_tiet
return "BÌNH THƯỜNG", chi_tiet
for ten in ("toi", "binh_thuong", "sang"):
g = cv2.imread(f"anh_{ten}.png", cv2.IMREAD_GRAYSCALE)
ket_luan, chi_tiet = chan_doan(g)
print(f"{ten:12s} -> {ket_luan:16s} ({chi_tiet})")
toi -> QUÁ TỐI (mean=25 p1=8 p99=54 cháy=0.0% chìm=0.2%)
binh_thuong -> BÌNH THƯỜNG (mean=103 p1=44 p99=204 cháy=0.0% chìm=0.0%)
sang -> QUÁ SÁNG (mean=197 p1=103 p99=255 cháy=18.0% chìm=0.0%)
Thứ tự kiểm tra có chủ ý: kiểm tra cháy sáng trước, vì một ảnh có thể vừa có vùng cháy vừa có vùng rất tối (chênh sáng quá lớn), và cháy là lỗi không sửa được bằng phần mềm. Hàm trả về cả chi tiết số để ghi log, giống nguyên tắc "luôn trả về lý do" của quyết định OK/NG.
chan_doan trên ROI ở bước cài đặt (setup) để chỉnh phơi sáng, và chạy định kỳ khi sản xuất (mục 9). Nếu ảnh "QUÁ SÁNG" hay "QUÁ TỐI", trạm nên báo lỗi thiết bị thay vì báo sản phẩm NG, để không gây overkill (loại nhầm hàng tốt).5. Kéo giãn tương phản và gamma
Kéo giãn tương phản (contrast stretching) là phép biến đổi tuyến tính đưa dải [thấp, cao] về [0, 255]. Có hai cách chọn "thấp" và "cao":
- Min-max (
cv2.normalize(..., cv2.NORM_MINMAX)): dùng giá trị nhỏ nhất và lớn nhất. Chỉ cần một điểm ảnh lỗi (hot pixel) là phép kéo giãn mất tác dụng. - Theo phân vị (1% và 99%): bền vững với điểm nhiễu, chấp nhận cắt bỏ 1% hai đầu.
import cv2
import numpy as np
g = cv2.imread("anh_toi.png", cv2.IMREAD_GRAYSCALE)
g[5, 5] = 255 # một điểm ảnh lỗi (hot pixel)
minmax = cv2.normalize(g, None, 0, 255, cv2.NORM_MINMAX)
lo, hi = np.percentile(g, (1, 99))
phan_vi = np.clip((g.astype(np.float32) - lo) * 255.0 / (hi - lo), 0, 255).astype(np.uint8)
nen = (slice(5, 25), slice(190, 315)) # vùng nền phẳng để đo nhiễu
for ten, x in [("gốc", g), ("min-max", minmax), ("phân vị", phan_vi)]:
p1, p99 = np.percentile(x, (1, 99))
print(f"{ten:8s} p1={p1:5.0f} p99={p99:5.0f} số mức={len(np.unique(x)):4d} "
f"nhiễu nền={x[nen].std():5.1f}")
gốc p1= 8 p99= 54 số mức= 63 nhiễu nền= 3.1
min-max p1= 7 p99= 53 số mức= 63 nhiễu nền= 3.1
phân vị p1= 0 p99= 255 số mức= 47 nhiễu nền= 17.0
Ba bài học từ ba dòng số:
- Min-max thất bại chỉ vì một điểm 255: p1 và p99 gần như không đổi (8–54 thành 7–53), ảnh vẫn tối như cũ.
- Kéo giãn theo phân vị trải dải sáng ra 0–255, nhưng số mức xám không tăng (còn giảm, do cắt hai đầu): histogram có dạng "răng lược" với các khe trống. Kéo giãn không tạo ra thông tin mới.
- Nhiễu bị khuếch đại cùng tỉ lệ (3,1 → 17,0, gấp khoảng 5,5 lần, đúng bằng hệ số kéo giãn 255/46 ≈ 5,5). Đó là lý do phải sửa phơi sáng ở camera trước: tăng thời gian phơi sáng làm tăng tín hiệu nhưng nhiễu tăng chậm hơn, còn kéo giãn bằng phần mềm thì tăng cả hai như nhau.
Hiệu chỉnh gamma là biến đổi phi tuyến out = 255·(in/255)^γ: γ < 1 làm sáng vùng tối nhiều hơn vùng sáng, γ > 1 ngược lại. Cách nhanh nhất là tính sẵn bảng tra 256 giá trị (LUT) rồi áp bằng cv2.LUT, nhanh hơn tính lũy thừa trên từng điểm ảnh:
import cv2
import numpy as np
gamma = 0.5
lut = np.array([255 * (i / 255) ** gamma for i in range(256)]).round().astype(np.uint8)
print("vào :", [0, 16, 32, 64, 128, 192, 255])
print("ra :", [int(lut[i]) for i in (0, 16, 32, 64, 128, 192, 255)])
g = cv2.imread("anh_toi.png", cv2.IMREAD_GRAYSCALE)
print("mean trước/sau:", round(float(g.mean()), 1), round(float(cv2.LUT(g, lut).mean()), 1))
vào : [0, 16, 32, 64, 128, 192, 255]
ra : [0, 64, 90, 128, 181, 221, 255]
mean trước/sau: 25.4 78.6
LUT là công cụ chung: mọi phép biến đổi "mức xám vào → mức xám ra" (kéo giãn, gamma, đảo ảnh, phân ngưỡng) đều có thể viết thành một bảng 256 phần tử.
6. Cân bằng histogram toàn cục (equalizeHist)
Cân bằng histogram tìm phép biến đổi làm histogram đầu ra trải đều nhất có thể. Bảng tra được tính từ hàm phân phối tích lũy (CDF, cumulative distribution function): mức xám đầu ra tỉ lệ với phần trăm điểm ảnh tối hơn hoặc bằng nó. Tự cài đặt để hiểu bản chất, rồi so với OpenCV:
import cv2
import numpy as np
g = cv2.imread("anh_binh_thuong.png", cv2.IMREAD_GRAYSCALE)
hist = np.bincount(g.ravel(), minlength=256)
cdf = hist.cumsum() # số điểm ảnh có mức <= i
cdf_min = cdf[cdf > 0][0]
lut = np.round((cdf - cdf_min) * 255.0 / (g.size - cdf_min)).clip(0, 255).astype(np.uint8)
tu_lam = lut[g]
cua_opencv = cv2.equalizeHist(g)
print("giống OpenCV:", np.array_equal(tu_lam, cua_opencv))
print("mức 50 (gioăng) ->", lut[50], "| mức 90 (nền) ->", lut[90],
"| mức 150 (kim loại) ->", lut[150], "| mức 200 (nhãn) ->", lut[200])
giống OpenCV: True
mức 50 (gioăng) -> 20 | mức 90 (nền) -> 143 | mức 150 (kim loại) -> 213 | mức 200 (nhãn) -> 240
Hãy để ý cách bảng tra phân bổ: nền chiếm phần lớn diện tích ảnh, nên dải mức xám quanh nền (~90) được kéo giãn mạnh (từ 50 lên 90, chỉ 40 mức vào, nhưng ra từ 20 đến 143, tức 123 mức), trong khi dải từ 150 đến 200 của kim loại và nhãn (50 mức vào) chỉ được 27 mức ra (213 → 240). Nói cách khác, equalizeHist dành nhiều tương phản cho vùng chiếm nhiều diện tích, bất kể vùng đó có quan trọng hay không. Hệ quả thực tế:
- Hiệu quả cho ảnh tương phản thấp đồng đều (sương mờ, toàn ảnh xám nhạt).
- Nền lớn và đồng nhất bị kéo giãn làm nhiễu nền nổi lên.
- Mức xám tuyệt đối bị thay đổi theo nội dung ảnh: cùng một vật, ảnh có nhiều nền hơn sẽ cho mức xám khác. Không dùng equalizeHist trước một ngưỡng cố định hay trước phép đo độ sáng, vì kết quả sẽ thay đổi theo số lượng vật trong khung.
7. CLAHE: cân bằng thích nghi cục bộ
Khi ánh sáng không đều (một góc bị bóng che, đèn lệch), cân bằng toàn cục không giúp được vùng tối, vì vùng tối chỉ chiếm phần nhỏ histogram. CLAHE (Contrast Limited Adaptive Histogram Equalization) giải quyết bằng ba ý:
- Chia ảnh thành lưới ô (
tileGridSize, mặc định 8×8) và cân bằng histogram riêng từng ô. - Giới hạn độ dốc của bảng tra bằng
clipLimit: cột histogram nào cao quá ngưỡng sẽ bị cắt bớt và phần dư rải đều cho các mức khác. Nhờ vậy vùng phẳng (chỉ có nhiễu) không bị khuếch đại vô hạn. - Nội suy song tuyến giữa các ô lân cận để không lộ đường biên ô.
Ảnh thử: tấm kim loại có 9 ký hiệu dập chìm (tối hơn bề mặt 25 mức), đèn chiếu lệch tạo một vùng bóng ở góc trên bên trái (chỉ còn 20% ánh sáng ở tâm bóng). Ta biết chính xác vị trí ký hiệu nên đo được độ tương phản cục bộ: chênh lệch giữa viền nền sát ký hiệu và chính ký hiệu.
import cv2
import numpy as np
rng = np.random.default_rng(3)
H, W = 240, 360
dau = np.zeros((H, W), np.uint8) # mặt nạ "đáp án" của ký hiệu
for r, y in enumerate((55, 135, 215)):
for c, x in enumerate((20, 140, 260)):
cv2.putText(dau, f"A{r * 3 + c + 1}", (x, y), cv2.FONT_HERSHEY_SIMPLEX, 1.2, 255, 3)
canh = 170.0 - (dau > 0) * 25.0 # ký hiệu tối hơn bề mặt 25 mức
yy, xx = np.mgrid[0:H, 0:W]
den = 1.0 - 0.8 * np.exp(-((xx - 50) ** 2 + (yy - 40) ** 2) / (2 * 70.0 ** 2)) # bóng ở góc trái trên
anh = np.clip(canh * den + rng.normal(0, 2, (H, W)), 0, 255).astype(np.uint8)
cv2.imwrite("tam_kim_loai.png", anh)
cv2.imwrite("ky_hieu_mask.png", dau)
vien = (cv2.dilate(dau, np.ones((9, 9), np.uint8)) > 0) & ~(cv2.dilate(dau, np.ones((3, 3), np.uint8)) > 0)
O_TOI = (slice(0, 80), slice(0, 120)) # ô chứa A1, nằm trong bóng
O_SANG = (slice(160, 240), slice(240, 360)) # ô chứa A9, đủ sáng
def tuong_phan(img, o):
a = img[o].astype(np.float64)
return a[vien[o]].mean() - a[dau[o] > 0].mean()
def nhieu(img, o):
a = img[o].astype(np.float32)
phang = cv2.dilate(dau, np.ones((15, 15), np.uint8))[o] == 0 # bề mặt xa ký hiệu
return float((a - cv2.blur(a, (5, 5)))[phang].std())
ket_qua = {"gốc": anh, "equalizeHist": cv2.equalizeHist(anh)}
for clip in (2.0, 4.0):
ket_qua[f"CLAHE clip={clip:g}"] = cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8)).apply(anh)
print(f"{'phương pháp':16s} {'TP vùng tối':>11s} {'TP vùng sáng':>12s} {'nhiễu tối':>9s} {'TP/nhiễu':>8s}")
for ten, img in ket_qua.items():
t1, t2, n1 = tuong_phan(img, O_TOI), tuong_phan(img, O_SANG), nhieu(img, O_TOI)
print(f"{ten:16s} {t1:11.1f} {t2:12.1f} {n1:9.2f} {t1 / n1:8.1f}")
phương pháp TP vùng tối TP vùng sáng nhiễu tối TP/nhiễu
gốc 7.4 25.1 1.98 3.7
equalizeHist 3.6 129.0 1.37 2.6
CLAHE clip=2 16.3 39.3 4.81 3.4
CLAHE clip=4 25.2 50.6 7.74 3.3
Kết quả đáng nhớ:
- Trong bóng, ký hiệu chỉ còn chênh 7,4 mức (so với 25,1 ở vùng sáng).
- equalizeHist làm vùng tối tệ hơn (7,4 → 3,6) trong khi đẩy vùng sáng lên 129: vùng tối chiếm ít diện tích nên bị "dồn" vào vài mức xám.
- CLAHE tăng tương phản vùng tối lên 16,3 (clip 2) và 25,2 (clip 4), tức là vùng bóng giờ "trông" rõ như vùng sáng ban đầu.
- Nhưng cột cuối cùng, tỉ số tương phản/nhiễu, gần như không đổi (3,7 → 3,3–3,4). CLAHE làm ảnh dễ nhìn hơn và giúp các thuật toán dùng ngưỡng cố định, nhưng không tạo thêm thông tin: nhiễu bị khuếch đại cùng với tín hiệu. Muốn tăng tỉ số này, phải sửa chiếu sáng.
| Tham số | Tăng lên thì | Gợi ý ban đầu |
|---|---|---|
clipLimit | Tương phản mạnh hơn, nhiễu nổi rõ hơn; 1 gần như không cân bằng | 2–4 |
tileGridSize | Ô nhỏ hơn, thích nghi với thay đổi sáng cục bộ hơn, nhưng dễ tạo quầng quanh vật lớn | (8, 8); ô nên lớn hơn chi tiết cần xem vài lần |
8. Ảnh màu: chỉ cân bằng kênh độ sáng
Cân bằng riêng từng kênh B, G, R sẽ làm mỗi kênh biến đổi theo một bảng tra khác nhau, nên màu bị sai. Cách đúng: đổi sang không gian có kênh độ sáng tách riêng (LAB hoặc YCrCb), chỉ xử lý kênh đó, rồi đổi lại. Kiểm chứng bằng cách đo màu sắc (Hue trong HSV, bài không gian màu ở chặng 2) của một nắp nhựa màu cam:
import cv2
import numpy as np
rng = np.random.default_rng(1)
anh = np.zeros((200, 300, 3), np.uint8)
anh[:] = (70, 75, 80) # nền xám
cv2.circle(anh, (90, 100), 50, (30, 110, 200), -1) # nắp nhựa cam (BGR)
cv2.rectangle(anh, (180, 50), (270, 150), (160, 90, 40), -1) # tem xanh dương
anh = np.clip(anh * 0.6 + rng.normal(0, 2, anh.shape), 0, 255).astype(np.uint8) # chụp hơi tối
# Sai: cân bằng từng kênh
b, g, r = cv2.split(anh)
sai = cv2.merge([cv2.equalizeHist(b), cv2.equalizeHist(g), cv2.equalizeHist(r)])
# Đúng: CLAHE trên kênh L của LAB
l, a, bb = cv2.split(cv2.cvtColor(anh, cv2.COLOR_BGR2LAB))
l = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(l)
dung = cv2.cvtColor(cv2.merge([l, a, bb]), cv2.COLOR_LAB2BGR)
nap = np.zeros((200, 300), np.uint8)
cv2.circle(nap, (90, 100), 40, 255, -1)
tem = np.zeros((200, 300), np.uint8)
cv2.rectangle(tem, (190, 60), (260, 140), 255, -1)
for ten, img in [("gốc", anh), ("từng kênh", sai), ("kênh L", dung)]:
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
print(f"{ten:10s} Hue nắp cam = {hsv[..., 0][nap > 0].mean():5.1f} | Hue tem xanh = "
f"{hsv[..., 0][tem > 0].mean():5.1f} | độ sáng V nắp = {hsv[..., 2][nap > 0].mean():5.1f}")
gốc Hue nắp cam = 14.1 | Hue tem xanh = 107.5 | độ sáng V nắp = 119.5
từng kênh Hue nắp cam = 30.0 | Hue tem xanh = 94.6 | độ sáng V nắp = 246.1
kênh L Hue nắp cam = 13.8 | Hue tem xanh = 108.6 | độ sáng V nắp = 136.9
Cân bằng từng kênh đổi nắp cam (Hue 14) thành vàng (Hue 30): một trạm kiểm tra màu nắp sẽ báo sai ngay. Xử lý trên kênh L giữ nguyên Hue (14,1 → 13,8) mà vẫn tăng độ sáng.
9. Giám sát độ sáng khi sản xuất
Đèn LED yếu dần, ống kính bám bụi, ánh nắng chiều chiếu qua cửa sổ, camera bị va lệch: tất cả đều thể hiện trên histogram của ROI trước khi gây ra hàng loạt NG giả. Cách đơn giản và hiệu quả: lưu mean và std của ROI tham chiếu lúc nghiệm thu, rồi so sánh định kỳ. Mô phỏng một đèn yếu đi 4% mỗi tuần:
import cv2
import numpy as np
g0 = cv2.imread("anh_binh_thuong.png", cv2.IMREAD_GRAYSCALE)
ROI = (slice(30, 120), slice(30, 170)) # vùng nhãn giấy, luôn có trong ảnh
chuan = float(g0[ROI].mean()) # mean lúc nghiệm thu
def kiem_tra_den(gray, chuan, canh_bao=0.08, dung_may=0.15):
lech = (float(gray[ROI].mean()) - chuan) / chuan
if abs(lech) >= dung_may:
return lech, "DỪNG: kiểm tra đèn/camera"
if abs(lech) >= canh_bao:
return lech, "CẢNH BÁO: lên lịch bảo trì"
return lech, "OK"
rng = np.random.default_rng(7)
for tuan in range(0, 6):
he_so = 0.96 ** tuan # đèn yếu 4% mỗi tuần
g = np.clip(g0 * he_so + rng.normal(0, 1, g0.shape), 0, 255).astype(np.uint8)
lech, ket_luan = kiem_tra_den(g, chuan)
print(f"tuần {tuan}: lệch {lech:+6.1%} -> {ket_luan}")
tuần 0: lệch -0.3% -> OK
tuần 1: lệch -4.3% -> OK
tuần 2: lệch -8.1% -> CẢNH BÁO: lên lịch bảo trì
tuần 3: lệch -11.8% -> CẢNH BÁO: lên lịch bảo trì
tuần 4: lệch -15.3% -> DỪNG: kiểm tra đèn/camera
tuần 5: lệch -18.7% -> DỪNG: kiểm tra đèn/camera
Tuần 0 đã lệch −0,3% vì nhiễu và phép ép kiểu astype(np.uint8) cắt bỏ phần thập phân (làm tròn xuống): mọi phép đo đều có dao động nhỏ, nên ngưỡng cảnh báo phải lớn hơn hẳn mức dao động bình thường. Hai mức ngưỡng (cảnh báo và dừng) cho bộ phận bảo trì thời gian phản ứng trước khi chất lượng kiểm tra bị ảnh hưởng. Ở chặng 9 bạn sẽ ghi các giá trị này vào cơ sở dữ liệu để vẽ xu hướng theo ca.
10. Lỗi thường gặp
| Lỗi | Hậu quả | Cách đúng |
|---|---|---|
| Dùng phần mềm để "cứu" ảnh cháy sáng | Vùng 255 không có thông tin, kết quả vô nghĩa | Giảm phơi sáng; ưu tiên ảnh hơi tối hơn là cháy |
| equalizeHist trước ngưỡng cố định | Ngưỡng "trôi" theo số vật trong khung | Dùng Otsu, ngưỡng thích nghi, hoặc không cân bằng |
| Kéo giãn min-max | Một hot pixel làm phép kéo giãn mất tác dụng | Kéo giãn theo phân vị 1%–99% |
| Cân bằng từng kênh B, G, R | Sai màu | Xử lý kênh L (LAB) hoặc Y (YCrCb) |
| Tính histogram trên cả khung hình | Băng tải, đồ gá chi phối histogram | Tính trên ROI chứa sản phẩm, dùng tham số mặt nạ |
calcHist(..., [0, 255]) | Bỏ sót mức 255, đếm cháy sáng sai | Khoảng [0, 256] |
11. Bài tập
Bài 1. Tính và vẽ histogram của 3 ảnh: quá tối, bình thường, quá sáng (mục 2); viết hàm tự phân loại 3 trường hợp. Sau đó tạo thêm ảnh thứ tư "mờ sương" (ảnh bình thường × 0,3 + 100, giống ảnh chụp qua tấm kính bị mờ hơi nước) và kiểm tra hàm của bạn trả về "TƯƠNG PHẢN THẤP". Ghép 4 histogram thành một ảnh.
Xem đáp án
import cv2
import numpy as np
def chan_doan(gray, ty_le_chay_max=0.01, p99_min=100, dai_min=60):
p1, p99 = np.percentile(gray, (1, 99))
chay, chim = float((gray >= 250).mean()), float((gray <= 5).mean())
chi_tiet = f"mean={gray.mean():.0f} p1={p1:.0f} p99={p99:.0f} cháy={chay:.1%}"
if chay > ty_le_chay_max:
return "QUÁ SÁNG", chi_tiet
if p99 < p99_min or chim > ty_le_chay_max:
return "QUÁ TỐI", chi_tiet
if p99 - p1 < dai_min:
return "TƯƠNG PHẢN THẤP", chi_tiet
return "BÌNH THƯỜNG", chi_tiet
def ve_histogram(gray, cao=150, ten=""):
h = np.bincount(gray.ravel(), minlength=256).astype(np.float64)
h = h / h.max() * (cao - 20)
anh = np.full((cao, 256, 3), 255, np.uint8)
for muc, gia_tri in enumerate(h):
cv2.line(anh, (muc, cao - 1), (muc, cao - 1 - int(gia_tri)), (90, 60, 20), 1)
cv2.putText(anh, ten, (5, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 0, 200), 1)
return anh
binh_thuong = cv2.imread("anh_binh_thuong.png", cv2.IMREAD_GRAYSCALE)
mo = np.clip(binh_thuong * 0.3 + 100, 0, 255).astype(np.uint8)
cv2.imwrite("anh_mo.png", mo)
hinh = []
for ten in ("toi", "binh_thuong", "sang", "mo"):
g = cv2.imread(f"anh_{ten}.png", cv2.IMREAD_GRAYSCALE)
ket_luan, chi_tiet = chan_doan(g)
print(f"{ten:12s} {ket_luan:16s} {chi_tiet}")
hinh.append(ve_histogram(g, ten=ten))
cv2.imwrite("histogram_4_anh.png", np.hstack(hinh))
toi QUÁ TỐI mean=25 p1=8 p99=54 cháy=0.0%
binh_thuong BÌNH THƯỜNG mean=103 p1=44 p99=204 cháy=0.0%
sang QUÁ SÁNG mean=197 p1=103 p99=255 cháy=18.0%
mo TƯƠNG PHẢN THẤP mean=130 p1=113 p99=161 cháy=0.0%
Ảnh mờ sương có độ sáng trung bình hoàn toàn bình thường nhưng dải p1–p99 rất hẹp (dưới 60 mức) nên bị xếp "TƯƠNG PHẢN THẤP". Đây là lý do không thể chẩn đoán chỉ bằng mean. Trong dự án thật, đặt chan_doan và ve_histogram vào một module chung (bài tổ chức pipeline ở chặng 2) thay vì chép lại.
Bài 2. So sánh equalizeHist và CLAHE trên ảnh có vùng tối cục bộ (tam_kim_loai.png, mục 7). Giữ clipLimit=2 và thử tileGridSize = 4, 8, 16. Lưới nào cho tương phản vùng tối tốt nhất, và tại sao lưới 16×16 kém hơn?
Xem đáp án
import cv2
import numpy as np
anh = cv2.imread("tam_kim_loai.png", cv2.IMREAD_GRAYSCALE)
dau = cv2.imread("ky_hieu_mask.png", cv2.IMREAD_GRAYSCALE) # đáp án vị trí ký hiệu
vien = (cv2.dilate(dau, np.ones((9, 9), np.uint8)) > 0) & ~(cv2.dilate(dau, np.ones((3, 3), np.uint8)) > 0)
O_TOI = (slice(0, 80), slice(0, 120))
def tuong_phan(img, o):
a = img[o].astype(np.float64)
return a[vien[o]].mean() - a[dau[o] > 0].mean()
print("equalizeHist :", round(tuong_phan(cv2.equalizeHist(anh), O_TOI), 1))
for o in (4, 8, 16):
cl = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(o, o)).apply(anh)
print(f"CLAHE lưới {o:2d}x{o:<2d}:", round(tuong_phan(cl, O_TOI), 1),
"| kích thước ô:", anh.shape[1] // o, "x", anh.shape[0] // o, "px")
equalizeHist : 3.6
CLAHE lưới 4x4 : 15.9 | kích thước ô: 90 x 60 px
CLAHE lưới 8x8 : 16.3 | kích thước ô: 45 x 30 px
CLAHE lưới 16x16: 13.5 | kích thước ô: 22 x 15 px
Lưới 8×8 (mỗi ô 45×30 px) cho kết quả tốt nhất ở đây, lưới 4×4 gần bằng. Lưu ý tileGridSize là số ô theo mỗi chiều, không phải kích thước ô. Với lưới 16×16, mỗi ô chỉ 22×15 px, nhỏ hơn cả ký hiệu (cao khoảng 30 px): nhiều ô nằm gọn trong ký hiệu hoặc gần như chỉ có nền, histogram của ô không chứa đủ cả "nền" lẫn "ký hiệu" nên bảng tra không kéo giãn đúng chỗ. Quy tắc: ô cần chứa được cả chi tiết lẫn nền xung quanh.
Bài 3. Ảnh anh_sang.png có 18% điểm ảnh bằng 255. Đồng nghiệp đề xuất giảm gamma (γ = 2) để "lấy lại chữ trên nhãn". Hãy chứng minh bằng số rằng không thể, và đề xuất cách đúng.
Xem đáp án
import cv2
import numpy as np
g = cv2.imread("anh_sang.png", cv2.IMREAD_GRAYSCALE)
nhan = g[30:120, 30:170] # vùng nhãn có chữ "LOT 25A"
lut = np.array([255 * (i / 255) ** 2.0 for i in range(256)]).round().astype(np.uint8)
print("số mức xám trên nhãn trước:", len(np.unique(nhan)), "| sau gamma:", len(np.unique(cv2.LUT(nhan, lut))))
print("tỉ lệ điểm 255 trên nhãn:", f"{(nhan == 255).mean():.1%}")
số mức xám trên nhãn trước: 123 | sau gamma: 114
tỉ lệ điểm 255 trên nhãn: 93.5%
93,5% vùng nhãn bằng đúng 255: mọi phép biến đổi mức xám (gamma, kéo giãn, CLAHE) đều ánh xạ các điểm bằng nhau thành các điểm bằng nhau, nên phần chữ nằm trong vùng cháy không thể tách ra. Số mức xám còn giảm sau gamma. Cách đúng là giảm thời gian phơi sáng (hoặc độ sáng đèn) cho đến khi tỉ lệ cháy trên ROI về gần 0, rồi mới xử lý.
Tóm tắt
- Histogram đếm số điểm ảnh theo mức xám; dùng
calcHistvới khoảng[0, 256]hoặcnp.bincount, tính trên ROI. - Chẩn đoán bằng mean, p1, p99, tỉ lệ cháy và số mức xám; viết thành hàm tự cảnh báo QUÁ TỐI / QUÁ SÁNG / TƯƠNG PHẢN THẤP.
- Cháy sáng là mất thông tin vĩnh viễn; sửa ở camera và đèn trước, xử lý ảnh sau.
- Kéo giãn theo phân vị bền hơn min-max; gamma và mọi biến đổi điểm có thể viết thành LUT.
- equalizeHist dồn tương phản cho vùng chiếm nhiều diện tích; không dùng trước ngưỡng cố định.
- CLAHE xử lý ánh sáng không đều (clipLimit 2–4, lưới 8×8) nhưng không tăng tỉ số tương phản/nhiễu.
- Ảnh màu: chỉ cân bằng kênh L (LAB). Giám sát mean của ROI theo thời gian để phát hiện đèn yếu.
Bài tiếp theo: Lọc nhiễu và phát hiện cạnh, nơi ta làm việc với nhiễu mà bài này vừa chỉ ra, và tìm đường biên vật bằng Sobel và Canny.
Bài tập thực hành
- Tính và vẽ histogram của 3 ảnh: quá tối, bình thường, quá sáng; viết hàm tự phân loại 3 trường hợp.
- So sánh equalizeHist và CLAHE trên ảnh có vùng tối cục bộ.
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.


