⭐ Vision Engineer · Chặng 3Bài 1/8Bài học~60 phút

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.

Histogram, kéo giãn tương phản, equalizeHist và CLAHE trong OpenCV

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. 1. Histogram là gì?
  2. 2. Ba ảnh mẫu: tối, bình thường, cháy sáng
  3. 3. Đọc histogram để chẩn đoán ảnh
  4. 4. Hàm tự chẩn đoán ảnh
  5. 5. Kéo giãn tương phản và gamma
  6. 6. Cân bằng histogram toàn cục (equalizeHist)
  7. 7. CLAHE: cân bằng thích nghi cục bộ
  8. 8. Ảnh màu: chỉ cân bằng kênh độ sáng
  9. 9. Giám sát độ sáng khi sản xuất
  10. 10. Lỗi thường gặp
  11. 11. Bài tập
  12. 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.

Chuẩn bịChỉ cần 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
Lưu ý OpenCV 5Từ OpenCV 5, 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 histogramChẩn đoánSửa ở đâu trước
Dồn về trái, p99 thấpThiếu sángTă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 ở 255Cháy sáng, mất thông tinGiảm phơi sáng, giảm đèn, đổi góc đèn để tránh phản xạ gương
Hẹp, nằm giữaTươ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ốtRấ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.

Dùng ở đâu trong nhà máy?Chạy 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ố:

  1. 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ũ.
  2. 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.
  3. 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 ý:

  1. Chia ảnh thành lưới ô (tileGridSize, mặc định 8×8) và cân bằng histogram riêng từng ô.
  2. 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.
  3. 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
clipLimitTương phản mạnh hơn, nhiễu nổi rõ hơn; 1 gần như không cân bằng2–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
CLAHE và phép đoCLAHE thay đổi mức xám khác nhau ở từng vùng ảnh. Dùng nó để hiển thị, để tiền xử lý trước OCR hay tìm cạnh. Đừng dùng trước các phép đo dựa trên độ sáng tuyệt đối (so màu, đo độ đậm vết bẩn), vì cùng một vết bẩn sẽ có độ tương phản khác nhau tùy nằm ở ô nào.

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ỗiHậu quảCách đúng
Dùng phần mềm để "cứu" ảnh cháy sángVùng 255 không có thông tin, kết quả vô nghĩaGiảm phơi sáng; ưu tiên ảnh hơi tối hơn là cháy
equalizeHist trước ngưỡng cố địnhNgưỡng "trôi" theo số vật trong khungDùng Otsu, ngưỡng thích nghi, hoặc không cân bằng
Kéo giãn min-maxMột hot pixel làm phép kéo giãn mất tác dụngKéo giãn theo phân vị 1%–99%
Cân bằng từng kênh B, G, RSai màuXử lý kênh L (LAB) hoặc Y (YCrCb)
Tính histogram trên cả khung hìnhBăng tải, đồ gá chi phối histogramTí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 saiKhoả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 calcHist với khoảng [0, 256] hoặc np.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

  1. 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.
  2. 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.

Bài tiếp theo
Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.