Nên chuẩn hóa giá trị RGB bằng 255 hay 256?
(30fps.net)- Trong chuẩn hóa RGB, nếu xử lý một tệp ảnh lạ rồi lưu lại dưới dạng 8-bit như thông thường, cách chia cho 255 theo chuẩn là phù hợp
- Cách dùng 255 ánh xạ 0 thành 0.0 và 255 thành 1.0, nên dễ xử lý trực tiếp màu đen và màu trắng, đồng thời cũng khớp với cách GPU chuyển đổi UNORM sang float
- Cách dùng 256 đặt mỗi giá trị vào giữa khoảng bằng
(img + 0.5) / 256.0, giúp đơn giản hóa xử lý biên trong các tác vụ như dithering, nhưng vì 0 không còn là 0.0 nên logic xử lý bị ràng buộc với đầu vào 8-bit - Với cách dùng 255, hai khoảng ở hai đầu chỉ rộng bằng một nửa nên nếu làm tròn lại một số ngẫu nhiên đều trong
[0, 1]về 8-bit thì 0 và 255 sẽ xuất hiện với tần suất bằng một nửa các giá trị khác, nhưng chuyển đổi khứ hồi ảnh thực tế vẫn hoạt động không mất dữ liệu - Về lý thuyết, cách dùng 256 có sai số tuyệt đối trung bình
1 / 1024, nhỏ hơn1 / 1020của cách dùng 255, nhưng nếu đọc một ảnh đã được lượng tử hóa theo cách 255 bằng thang đo sai thì lại làm tăng sai số
Thiết lập vấn đề
Một chương trình xử lý ảnh chuyển ảnh 8-bit sang số thực dấu phẩy động, thực hiện xử lý rồi lưu lại thành màu 8-bit
Hai cách chuyển đổi như sau
# Chuẩn: chia cho 255
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)
# Phương án khác: cộng 0.5 rồi chia cho 256
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)
Cả hai cách đều giới hạn giá trị về 0~255 trước khi chuyển đổi cuối cùng
output_8bit = output.clip(0, 255).astype(np.uint8)
Cách chuẩn ánh xạ số nguyên 0 thành 0.0 và 255 thành 1.0, giống với cách GPU chuyển đổi UNORM sang float
Phương án khác ánh xạ 0 thành 0.5 / 256 = 0.001953125, nên để phát hiện pixel đen thì phải biết hằng số này
Đặc tính của cách chuẩn chia cho 255
Cách chuẩn khiến hai khoảng ở hai đầu trong miền [0, 1] thực tế chỉ rộng bằng một nửa các khoảng còn lại
Nếu tạo số ngẫu nhiên đều trong [0, 1] rồi làm tròn bằng trunc(result * 255 + 0.5) thì 0 và 255 sẽ xuất hiện với tần suất bằng một nửa các số nguyên khác
Tuy nhiên, ảnh 8-bit gốc vẫn khứ hồi không mất dữ liệu qua chuyển đổi uint8 → float → uint8
Ngoài ra, kể cả khi kết quả xử lý hơi vượt khỏi 0.0 hoặc 1.0, thao tác clamp và làm tròn vẫn có thể đưa nó về đúng khoảng số nguyên
Ví dụ, nếu trừ 0.005 khỏi màu dấu phẩy động thì màu đen theo cách chuẩn sẽ thành số âm, nhưng kết quả cuối cùng vẫn là số nguyên 0
trunc(255 * (-0.005) + 0.5) = 0
Độ chính xác dấu phẩy động và việc đặt ở tâm khoảng
Một số giá trị trong cách 255 không được biểu diễn chính xác
Ví dụ 128 / 255.0 ≈ 0.501961 trong khi 128 / 256.0 = 0.5
Khác biệt này chỉ là sai số làm tròn ở mức bit thấp nhất trong phần định trị 23 bit của số thực 32 bit, với độ lớn nhỏ hơn 2^-23
Vì vậy, sự không chính xác này gần như là vấn đề thẩm mỹ hơn là vấn đề kỹ thuật thực sự
Cách 256 đặt mỗi giá trị dấu phẩy động đúng vào giữa hai số nguyên
Tính chất này có thể xem là một cách dung hòa bằng việc dùng điểm trung bình giữa hai số nguyên liên tiếp khi không biết chính xác giá trị lượng tử hóa ban đầu là gì
Bài viết năm 2015 của Andrew Kesler “Converting Color Depth” cho rằng cách này giúp bớt phải lo xử lý biên khi thêm nhiễu trong dithering
Ngược lại, với cách chuẩn, hai khoảng ở biên cần được xử lý cẩn thận để giữ phân bố nhiễu nhất quán
Góc nhìn lượng tử hóa
Có thể xem cả hai cách là bộ lượng tử hóa vô hướng đều (uniform scalar quantizer)
Giải thích về quantization trên Wikipedia) chủ yếu chia bộ lượng tử hóa đều cho dữ liệu đầu vào có dấu thành mid-riser và mid-tread
mid-tread có mức tái dựng giá trị 0, còn mid-riser có ngưỡng phân loại tại giá trị 0
Công thức tương ứng như sau
| Cách | Mã hóa | Giải mã |
|---|---|---|
| mid-tread | k = trunc(x L + 0.5) |
y_k = k / L |
| mid-riser | k = trunc(x L) |
y_k = (k + 0.5) / L |
Cách chuẩn là dạng mid-tread với L=255, còn phương án khác là dạng mid-riser với L=256
Cách chuẩn đánh đổi bằng việc có được sự tiện lợi trong lập trình khi khớp hai đầu vào 0.0 và 1.0, nhưng bố trí khoảng của nó không phải là tối ưu nhất cho đầu vào 8-bit
Sai số tái dựng và xử lý ảnh thực tế
Nếu tự thiết kế một hệ thống mã hóa số thực x ∈ [0, 1] theo phân bố đều thành số nguyên 8-bit rồi tái dựng lại thành số thực, thì về lý thuyết cách 256 chính xác hơn
Miền biểu diễn của cách chuẩn là [-0.5 / 255, 255.5 / 255], khiến khoảng cách giữa các mức lớn hơn mức thật sự cần thiết cho [0, 1]
Theo phép tính của Peter Mudrievskij trên StackOverflow, sai số tuyệt đối trung bình là 1 / 1020 khi chia cho 255 và 1 / 1024 khi chia cho 256
Nhưng trong tình huống đọc một ảnh RGB 8-bit đã được lưu sẵn để xử lý, thông tin đã mất tại thời điểm lưu sẽ không thể được khôi phục
Nếu ảnh đã được lượng tử hóa bằng cách nhân 255 rồi làm tròn, thì việc chia cho 256 khi tải lên cũng không giúp lấy lại độ chính xác
Phần lớn ảnh do người khác tạo ra nhiều khả năng đã được lượng tử hóa theo cách chuẩn, nên nếu đọc bằng công thức thay thế thì về lý thuyết là đang dùng sai hệ số thang đo
Trên thực tế, màu sắc không hoạt động như các giá trị đo tuyệt đối, nên kết quả chỉ là xử lý trong một miền hơi nhỏ hơn và có một độ lệch nhỏ
Nếu trộn lẫn bước mã hóa và giải mã của hai bộ lượng tử hóa thì mã sẽ bị sai
Kết luận
Nếu xử lý ảnh do người khác cung cấp, nên chuẩn hóa giá trị RGB bằng 255
Lý do “giá trị dấu phẩy động không chính xác” hay cảm giác rằng sai số tái dựng trừu tượng lớn hơn không phải là cơ sở đủ mạnh để chọn cách 256
Nếu bạn kiểm soát cả việc lưu và tải ảnh, không cần 0 phải ánh xạ đúng về 0, và chấp nhận để mã xử lý bị ràng buộc với dải động 8-bit, thì có thể chia cho 256 để đổi lấy độ chính xác lý thuyết nhỉnh hơn một chút
1 bình luận
Ý kiến trên Lobste.rs
Nếu thấy không trực quan thì hãy nhìn vào trường hợp suy biến 2 bit. Khi các giá trị nguyên khả dĩ chỉ là 0, 1, 2, 3, nếu tính toàn bộ phép chuyển đổi từ số nguyên sang số thực dấu phẩy động thì để tránh hành vi kỳ quặc như đen/trắng không còn là đen/trắng hoặc khoảng cách rõ ràng không đều, ta sẽ có 0.0, 0.33..., 0.66..., 1.0
Vì vậy phép chuyển ngược sẽ là nhân với 3, chứ không phải 4(2^2)
Chuyển ngược cần lượng tử hóa (làm tròn), và chính điểm này mới là thứ phá vỡ tính đối xứng
Nếu tạo một dải chuyển màu số thực đều trong khoảng 0..=1 rồi lượng tử hóa về 0, 1, 2, 3, bạn sẽ thấy nhân 3 cho kết quả không đều.
round()sau khi ×3 sẽ làm 1 và 2 bị biểu diễn quá mức, cònfloorhoặcceilsau khi ×3 thì lại gộp 0 hoặc 3 thành kiểu điểm kỳ dị, khiến dải màu trông như chỉ dùng 3 trong 4 màuLogic
/3và×3có vẻ ổn khi chuyển đổi qua lại các con số chính xác, nhưng các giá trị trung gian bị ảnh hưởng mạnh bởi lựa chọn làm tròn, và điều đó trở nên quan trọng ngay khi bắt đầu xử lý dữ liệuTỷ lệ số nguyên chỉ trở nên đồng đều khi nhân với (4-ε) rồi lấy floor, tức tương đương với ×4,
floor(),clamp(). Nó có cảm giác như một lỗi kỳ quặc lệch 1 hoặc lệch ε, nhưng về mặt trực giác lại là lời giải trông hợp lý nhấtVới tôi thì đáp án luôn “rõ ràng” là [0.0..255.0], nhưng có lẽ không phải ai cũng thấy hiển nhiên như vậy
Bài viết nói rằng các vùng “cực trị” chỉ có một nửa dung lượng so với các vùng khác, và tôi cũng không nghĩ cách đặt vấn đề này là đúng
Nếu không tồn tại giá trị nào ngoài [0..1], thì việc nó trông như một vùng hẹp là sản phẩm của cách render. Nó chỉ được render hẹp hơn vì bạn đã cắt bucket dựa trên hiểu biết rằng không có giá trị nào ngoài phạm vi đó
Ngược lại, nếu có tồn tại giá trị ngoài [0..1], thì phạm vi đó là vô hạn. Bài viết thừa nhận trường hợp sau nhưng lại không thừa nhận trường hợp trước
Một khi chấp nhận điều thứ nhất thì cách hoạt động đúng có vẻ khá rõ ràng, nhưng việc bài này xuất hiện tự nó cũng có nghĩa đây không hẳn là một vấn đề “rõ ràng” theo nghĩa khách quan :D
Nếu 0..<1 đi về số nguyên 0, còn 254>..255.0 đi về số nguyên 255, thì 128 sẽ bị nuốt mất. Có lẽ bạn muốn 127.5..128.5 đi về 128, nhưng vậy thì các nửa khoảng này phải đi đâu?
Nếu dịch toàn bộ đi một chút để khớp 128, thì 0..0.99609375 sẽ ánh xạ về số nguyên 0
round()Có vẻ cách đó mang lại cảm giác khá tự nhiên cho nhiều người, nên nhờ tính đơn giản mà nó trở thành tiêu chuẩn
pngcrush. Hay ý bạn là nội dung ảnh có gì đó không đúng?