Hướng dẫn sử dụng Pillow trong Python: Nén ảnh WebP và đóng watermark hàng loạt

Python tutorial - IT technology blog
Python tutorial - IT technology blog

1. Bài học thực tế: Server sập vì ảnh 15MB từ điện thoại

Khoảng 6 tháng trước, sàn thương mại điện tử bên mình chạy chiến dịch khuyến mãi lớn. Traffic tăng gấp 4 lần ngày thường. Ngay sau đó, ác mộng upload file bắt đầu.

Người bán hàng chụp sản phẩm trực tiếp bằng iPhone 14 Pro hoặc máy ảnh cơ rồi tải thẳng lên web. Mỗi tấm ảnh nặng từ 12MB đến gần 18MB, độ phân giải lên tới 6000×4000 pixel.

Hệ thống không trụ nổi. Vào khung giờ vàng tối Chủ nhật, RAM của cụm 4 Celery worker chạm trần 100%. Cơ chế OOM Killer của Linux lập tức can thiệp và ép tắt tiến trình. Người dùng nhận lỗi 502 Bad Gateway liên tục.

Tệ hơn, hóa đơn AWS S3 tháng đó tăng gần gấp đôi do lưu trữ hàng trăm gigabyte ảnh gốc chưa qua xử lý. Khách mua hàng dùng mạng 4G phải chờ 5 đến 7 giây mới thấy hình ảnh sản phẩm tải xong. Chưa kể, website đối thủ ung dung cào toàn bộ kho ảnh gốc của bên mình về đăng lại.

2. Vì sao render ảnh lại ngốn RAM khủng khiếp?

Lúc đầu, nhiều bạn trong team nghĩ đơn giản: file ảnh lưu trên đĩa 5MB thì khi mở ra cũng chỉ tốn chừng 5MB RAM. Suy nghĩ này hoàn toàn sai.

Khi ứng dụng nạp ảnh để xử lý, thư viện phải giải nén toàn bộ ma trận điểm ảnh thô (bitmap) vào bộ nhớ. Công thức tính lượng RAM thực tế như sau:

RAM tiêu thụ ≈ Chiều rộng (px) × Chiều cao (px) × Số kênh màu (Bytes)

Xét một bức ảnh 6000x4000px với 4 kênh màu RGBA (8-bit mỗi kênh). Bộ nhớ cần nạp sẽ là:

6000 × 4000 × 4 = 96.000.000 bytes (~91.5 MB RAM)

Con số 91.5MB này mới chỉ tính riêng cho một bức ảnh duy nhất. Nếu 10 worker đồng thời xử lý ảnh, gần 1GB RAM sẽ bốc hơi trong chớp mắt.

Ảnh ngược chiều do metadata EXIF

Ảnh chụp từ smartphone luôn lưu góc chụp vào thẻ Orientation trong metadata EXIF. Trình duyệt trên máy tính thường không tự xoay ảnh theo thẻ này. Kết quả là ảnh chụp dọc hiển thị trên web bị xoay ngang hoặc lộn ngược.

Định dạng JPEG, PNG đã lỗi thời trên môi trường web

File PNG giữ nguyên chất lượng lossless nhưng kích thước quá nặng. File JPEG thì nén vừa phải nhưng kích thước vẫn lớn hơn WebP từ 30% đến 40% ở cùng chất lượng hiển thị thị giác. Đã đến lúc hệ thống cần chuyển đổi toàn bộ sang WebP.

3. So sánh 4 giải pháp xử lý ảnh

Chúng mình đã cân nhắc các lựa chọn sau trước khi viết code:

  • Cloudinary / Imgix: Tích hợp rất nhanh, chỉ cần đổi URL. Nhưng khi website đạt hơn 3 triệu lượt xem ảnh mỗi tháng, chi phí API đội lên hàng trăm USD. Hệ thống cũng bị phụ thuộc chặt vào nhà cung cấp bên ngoài.
  • ImageMagick (chạy qua CLI): Xử lý rất mạnh. Dù vậy, việc gọi subprocess từ Python tiềm ẩn nguy cơ Command Injection nếu không lọc kỹ tên file. Ngoài ra, việc cài đặt thư viện C trên Docker base image khá cồng kềnh.
  • OpenCV (cv2): Phù hợp cho computer vision và AI. Mang OpenCV vào chỉ để resize ảnh sẽ khiến kích thước Docker image phình thêm 300MB đến 400MB. Lựa chọn này lãng phí tài nguyên.
  • Pillow (PIL Fork): Thư viện chuẩn mực của Python. Pillow nhẹ, viết bằng C extensions tốc độ cao, hỗ trợ sẵn WebP và xử lý tốt bộ nhớ nếu code đúng cách.

4. Xây dựng pipeline tự động hóa với Pillow

Dưới đây là pipeline thực tế mình đang vận hành trên production. Luồng xử lý gồm 4 bước: chuẩn hóa EXIF, resize theo tỉ lệ, đóng dấu logo bán trong suốt và nén sang chuẩn WebP.

Bước 1: Cài đặt thư viện

pip install Pillow

Bước 2: Chuẩn hóa tên file an toàn

Đừng bao giờ tin tưởng tên file do người dùng tải lên. Chúng ta cần loại bỏ ký tự lạ và dấu cách để tránh lỗi đường dẫn trên Linux. Bạn có thể kiểm tra nhanh pattern regex tại toolcraft.app/vi/tools/developer/regex-tester trước khi gắn vào mã nguồn.

Bước 3: Pipeline xử lý ảnh chi tiết

Mã nguồn hoàn chỉnh cho module xử lý ảnh:

import os
import re
from PIL import Image, ImageOps, ImageEnhance

def sanitize_filename(filename: str) -> str:
    # Chỉ giữ lại chữ cái, số, gạch nối và gạch dưới
    name, _ = os.path.splitext(filename)
    clean_name = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
    return f"{clean_name}.webp"

def add_watermark(base_image: Image.Image, watermark_path: str, opacity: float = 0.35) -> Image.Image:
    if not os.path.exists(watermark_path):
        return base_image
    
    watermark = Image.open(watermark_path).convert("RGBA")
    
    # Giới hạn chiều rộng watermark tối đa bằng 20% ảnh gốc
    max_wm_width = int(base_image.width * 0.2)
    if watermark.width > max_wm_width:
        scale = max_wm_width / watermark.width
        new_size = (max_wm_width, int(watermark.height * scale))
        watermark = watermark.resize(new_size, Image.Resampling.LANCZOS)
    
    # Giảm độ đục (opacity) của watermark
    alpha = watermark.split()[3]
    alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
    watermark.putalpha(alpha)
    
    # Đặt watermark ở góc dưới bên phải, cách lề 20px
    margin = 20
    position = (
        base_image.width - watermark.width - margin,
        base_image.height - watermark.height - margin
    )
    
    # Dán đè watermark lên ảnh nền bằng alpha composite
    watermark_layer = Image.new("RGBA", base_image.size, (0, 0, 0, 0))
    watermark_layer.paste(watermark, position, mask=watermark)
    
    base_rgba = base_image.convert("RGBA")
    combined = Image.alpha_composite(base_rgba, watermark_layer)
    
    return combined.convert("RGB")

def process_image(
    input_path: str,
    output_dir: str,
    watermark_path: str = None,
    max_width: int = 1600,
    quality: int = 82
) -> str:
    os.makedirs(output_dir, exist_ok=True)
    
    with Image.open(input_path) as img:
        # 1. Tự động xoay ảnh đúng chiều theo cờ EXIF
        img = ImageOps.exif_transpose(img)
        
        # 2. Resize giữ nguyên tỷ lệ khung hình nếu ảnh quá lớn
        if img.width > max_width:
            scale = max_width / float(img.width)
            new_height = int(float(img.height) * scale)
            img = img.resize((max_width, new_height), Image.Resampling.LANCZOS)
        
        # 3. Đóng dấu watermark bản quyền
        if watermark_path:
            img = add_watermark(img, watermark_path, opacity=0.35)
        else:
            img = img.convert("RGB")
            
        # 4. Xuất file WebP tối ưu dung lượng
        filename = os.path.basename(input_path)
        output_filename = sanitize_filename(filename)
        output_path = os.path.join(output_dir, output_filename)
        
        img.save(
            output_path,
            format="WEBP",
            quality=quality,
            method=6  # Thuật toán nén sâu nhất của WebP encoder
        )
        
    return output_path

Bước 4: Xử lý theo thư mục và tích hợp Celery

Đoạn script dưới đây duyệt qua toàn bộ ảnh trong một thư mục đầu vào và chạy tối ưu hàng loạt:

def batch_process(source_folder: str, destination_folder: str, watermark_file: str):
    supported_exts = (".jpg", ".jpeg", ".png", ".bmp")
    files = [f for f in os.listdir(source_folder) if f.lower().endswith(supported_exts)]
    
    print(f"Tìm thấy {len(files)} ảnh cần xử lý.")
    for idx, filename in enumerate(files, start=1):
        src = os.path.join(source_folder, filename)
        try:
            dst = process_image(src, destination_folder, watermark_path=watermark_file)
            print(f"[{idx}/{len(files)}] Hoàn thành: {filename} -> {os.path.basename(dst)}")
        except Exception as e:
            print(f"Thất bại khi xử lý {filename}: {e}")

if __name__ == "__main__":
    batch_process(
        source_folder="./raw_uploads",
        destination_folder="./optimized_images",
        watermark_file="./assets/logo_watermark.png"
    )

5. Ba kinh nghiệm xương máu khi vận hành production

  • Bắt buộc dùng with Image.open(...): Đừng bao giờ mở file mà quên đóng. Context manager giúp giải phóng file descriptor ngay lập tức, tránh lỗi sập server vì Too many open files.
  • Không tự parse thẻ EXIF bằng tay: Luôn dùng ImageOps.exif_transpose. Hàm này được cộng đồng Pillow hoàn thiện qua nhiều năm, xử lý tốt cả những file ảnh bị lỗi header.
  • Thiết lập Quality ở mức 80 đến 85: Đây là điểm cân bằng lý tưởng. Một tấm ảnh gốc 14MB từ iPhone sau khi qua pipeline giảm xuống còn khoảng 180KB – 240KB. Ảnh giảm 80% đến 85% dung lượng nhưng mắt thường hoàn toàn không nhận ra khác biệt.

Sau khi đưa pipeline này vào production, hiện tượng tràn RAM biến mất hoàn toàn. Tốc độ tải trang chi tiết sản phẩm tăng từ 5 giây xuống dưới 0.9 giây trên kết nối 4G.

Share: