Chuẩn hóa RGB: Nên chia cho 255 hay 256 trong xử lý ảnh?

Công nghệ01 tháng 6, 2026·8 phút đọc

Bài viết phân tích cuộc tranh luận về việc nên chia giá trị màu 8-bit cho 255 hay 256 khi chuyển đổi sang số thực dấu chấm động. Tác giả so sánh hai phương pháp này về mặt độ chính xác, phạm vi giá trị và lý thuyết lượng tử hóa, kết luận rằng phương pháp chuẩn (chia 255) thường an toàn hơn cho dữ liệu đầu vào chung, trong khi chia 256 có lợi thế về độ chính xác trong các hệ thống khép kín.

Chuẩn hóa RGB: Nên chia cho 255 hay 256 trong xử lý ảnh?

Giả sử bạn đang viết một chương trình xử lý ảnh. Chương trình này nhận vào một hình ảnh, chuyển đổi nó sang định dạng dấu chấm động (floating point), thực hiện một số xử lý và cuối cùng lưu các điểm ảnh đã sửa đổi xuống đĩa dưới dạng màu 8-bit.

Câu hỏi đặt ra hôm nay là: chính xác thì việc chuyển đổi từ số nguyên sang số thực nên được thực hiện như thế nào? Có hai cách tiếp cận phổ biến, được viết bằng Python và NumPy như sau:

Cách tiếp cận tiêu chuẩn (Chia cho 255):

float_pixel = int_pixel / 255.0

Cách tiếp cận thay thế (Chia cho 256):

float_pixel = (int_pixel + 0.5) / 256.0

Tôi giả định rằng trong cả hai trường hợp, các giá trị đầu ra đều được giới hạn (clamped) trước khi ép kiểu cuối cùng.

Cách tiếp cận tiêu chuẩn ánh xạ số nguyên 0 thành 0.0 và 255 thành 1.0. Nó hoạt động hoàn toàn tốt và đây là cách mà GPU thực hiện. Ngược lại, cách thay thế thêm một độ lệch 0.5 và chia cho 256, do đó số nguyên 0 được ánh xạ thành 0.5/256 = 0.001953125.

Điều này khá bất tiện vì mã xử lý ảnh của bạn không thể phát hiện các điểm ảnh đen (ví dụ: giá trị 0.0) mà không cần biết hằng số trên. Hậu quả là bạn gắn chặt logic của mình vào đầu vào 8-bit, ngay cả khi bạn tính toán trên dấu chấm động. Với cách tiếp cận tiêu chuẩn, bạn luôn có thể giả định rằng màu đen là 0.0.

Tuy nhiên, một số lập trình viên vẫn cảm thấy bị lôi cuốn bởi cách tiếp cận thay thế. Tại sao lại như vậy? Họ nhìn thấy điều gì ở nó?

Sự khác biệt trên trục sốSự khác biệt trên trục số

Cách tiếp cận tiêu chuẩn trông khá kỳ lạ khi được vẽ trên trục số. Hình trên cho thấy phiên bản phóng đại với các số nguyên 3-bit trong phạm vi [0..7] được ánh xạ sang [0,1]. Trục hoành là trục số và vị trí của các vòng tròn nâu đại diện cho các giá trị dấu chấm động đã giải mã.

Vấn đề đầu tiên rõ ràng trong sơ đồ là cách các "thùng" (bins) cực đoan của công thức tiêu chuẩn nhô ra ngoài phạm vi [0,1]. Phạm vi bị kéo giãn này rộng hơn phạm vi hoạt động giả định [0, 1] trong xử lý ảnh. Điều này có nghĩa là khi chuyển đổi các giá trị dấu chấm động trong phạm vi [0, 1] trở lại số nguyên, các thùng cực đoan có chiều rộng hiệu quả chỉ bằng một nửa so với các thùng khác.

Hậu quả là, sẽ "khó hơn" để thuật toán của bạn tạo ra các giá trị cực đoan. Ví dụ, nếu bạn tạo nhiễu đồng nhất [0,1] và làm tròn nó bằng công thức tiêu chuẩn, các giá trị 0 và 255 sẽ chỉ xuất hiện với tần suất bằng một nửa so với các số nguyên khác.

Chúng ta có thể xác minh nhận định này thực nghiệm bằng cách tạo ra một triệu số ngẫu nhiên đồng nhất, vẽ chúng dưới dạng biểu đồ và quan sát rằng cả hai thùng 0 và 255 thực sự chỉ cao bằng một nửa các thùng khác:

Biểu đồ phân phối tần suấtBiểu đồ phân phối tần suất

Tuy nhiên, tôi vẫn khó tìm ra một tình huống ví dụ mà sự thiên lệch này khỏi các giá trị cực đoan gây ra vấn đề. Chắc chắn, các giá trị dấu chấm động của cách tiếp cận tiêu chuẩn được trải ra trên một phạm vi rộng hơn, nhưng hình ảnh gốc vẫn sẽ chuyển đổi vòng tròn (round-trip) không mất dữ liệu (uint8 → float → uint8). Ngoài ra, bất kỳ giá trị kết quả nào chỉ vượt quá 0.0 hoặc 1.0 một chút vẫn sẽ được làm tròn về đúng thùng, giúp cân bằng lại phân phối đầu ra.

Vấn đề thứ hai là các giá trị dấu chấm động của cách tiếp cận tiêu chuẩn không chính xác về mặt số học. Ví dụ, 128/255.0 ≈ 0.501961 nhưng 128/256.0 = 0.5. Do lỗi làm tròn này, khoảng cách giữa các giá trị dấu chấm động thay đổi một chút rất nhỏ. Nhưng đây không phải là vấn đề thực sự vì lỗi thực sự rất nhỏ. Một số dấu chấm động 32-bit có 23 bit phân số (significand). Chúng ta đang nói về lỗi làm tròn ở bit ít quan trọng nhất; độ nhiễu với độ lớn nhỏ hơn 2^-23.

Trong trường hợp này, sự không chính xác là một câu hỏi thẩm mỹ, không phải kỹ thuật. Cách tiếp cận thay thế luôn đặt mỗi giá trị dấu chấm động chính xác ở giữa hai số nguyên. Vị trí giữa có thể được coi là một sự thỏa hiệp; chúng ta không biết giá trị lượng tử hóa gốc chính xác là gì, và do đó điểm trung bình giữa hai số nguyên liên tiếp là một phỏng đoán tốt.

Một cách khác để nghĩ về câu hỏi này là phóng to ra một chút và xem hai cách tiếp cận này như hai bộ lượng tử vô hướng đồng nhất khác nhau. Nếu chúng ta kiểm tra trang Wikipedia về lượng tử hóa, chúng ta sẽ nhanh chóng biết rằng có hai loại chính: mid-riser (giữa bậc thang) và mid-tread (giữa mặt bậc thang).

Mid-riser so với Mid-treadMid-riser so với Mid-tread

Khi được vẽ trên đồ thị, bộ lượng tử mid-riser và mid-tread khác nhau ở nơi chúng cắt qua số 0. Mid-tread ánh xạ số 0 thành 0 (giống cách tiếp cận tiêu chuẩn của chúng ta với L=255), trong khi mid-riser ánh xạ 0 vào giữa hai số nguyên (giống cách tiếp cận thay thế với L=256).

Từ góc độ này, chúng ta có thể nói cách tiếp cận tiêu chuẩn là một sự kết hợp kỳ lạ của bộ lượng tử mid-riser cho đầu vào không dấu và sự lựa chọn L=255 mã số nguyên. Rõ ràng, điều này không tối ưu cho đầu vào 8-bit. Một lần nữa, tất cả là vì sự tiện lợi khi lập trình để các giá trị cực đoan ánh xạ tới 0.0 và 1.0.

Điều này dẫn đến chỉ trích cuối cùng về công thức tiêu chuẩn. Nếu chúng ta đang thiết kế một hệ thống nhận một số thực phân phối đồng nhất x ∈ [0,1], mã hóa nó thành số nguyên 8-bit k, và cuối cùng tái tạo nó thành một số thực khác y_k, công thức tiêu chuẩn sẽ lãng phí băng thông.

Nhớ cách các thùng 0 và 255 nhô ra một chút ngoài các cạnh của phạm vi [0,1] không? Trong cách tiếp cận tiêu chuẩn, phạm vi các giá trị có thể biểu diễn thực sự là [-0.5/255, 255.5/255], nghĩa là các thùng được cách nhau xa hơn mức cần thiết nghiêm ngặt cho đầu vào [0, 1], dẫn đến sai số tái tạo cao hơn.

Sự gia tăng lỗi này là nhỏ, tuy nhiên. Theo tính toán của người dùng StackOverflow Peter Mudrievskij, các sai số tuyệt đối trung bình lần lượt là 1/1020 và 1/1024 cho số chia 255 và 256. Do đó, chia cho 256 về mặt lý thuyết chính xác hơn.

Phần tinh tế ở đây là loại tái tạo này không phải là những gì chúng ta đang làm. Giả định là chúng ta đang tải hình ảnh RGB 8-bit, xử lý chúng và lưu lại. Chúng ta không kiểm soát cách chúng được lượng tử hóa khi lưu; mọi thông tin bị mất đã biến mất vĩnh viễn. Nói cách khác, nếu màu của hình ảnh được nhân với 255 và làm tròn, việc chia chúng cho 256 tại thời điểm tải không mang lại lại bất kỳ độ chính xác nào.

Chỉ khi chúng ta kiểm soát cả việc lưu và tải, việc kêu gọi sai số tái tạo thấp hơn mới có ý nghĩa. Trên thực tế, sử dụng công thức thay thế để tải hình ảnh của người khác sẽ giới thiệu nhiều lỗi hơn. Rất có thể các hình ảnh đó đã được lượng tử hóa thông qua công thức tiêu chuẩn, do đó giải mã chúng với hệ số tỷ lệ sai là không chính xác về mặt lý thuyết.

Cuối cùng, không bao giờ được trộn lẫn các bước mã hóa và giải mã của hai bộ lượng tử này. Đó chỉ là mã bị hỏng. Tuy nhiên, đây là một sai lầm dễ mắc phải.

Để trả lời câu hỏi trong tiêu đề: nếu bạn đang xử lý hình ảnh được người lạ đưa cho, bạn nên chuẩn hóa giá trị RGB bằng 255. Cả các giá trị dấu chấm động không chính xác hay một cảm giác trừu tượng về sai số tái tạo cao hơn đều không phải là lý do tốt để chọn phương án thay thế.

Nhưng nếu bạn kiểm soát cả việc lưu và tải hình ảnh, không cần số 0 ánh xạ tới 0, và cảm thấy ổn khi gắn mã xử lý của mình vào phạm vi động 8-bit, thì bạn có thể cân nhắc chia cho 256 để tận dụng một chút độ chính xác thêm. Chỉ đừng trách tôi khi đồng nghiệp của bạn tải hình ảnh của bạn bằng công thức tiêu chuẩn anyway, làm hỏng kế hoạch vĩ đại của bạn.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗