
Pillow (PIL): xử lý ảnh bằng Python cho người mới
AIDev TeamTham vấn chuyên môn: Nguyễn Việt Chung
Đăng 10 phút đọc
Mục lục · 20
PIL trong Python ngày nay là thư viện Pillow: cài bằng pip install pillow, import bằng from PIL import Image. Mình chạy Pillow 13 trên ảnh phố cổ Hội An: resize, crop, xoay, viết chữ có dấu, so dung lượng JPEG, WebP, PNG và thu nhỏ 6 ảnh từ 71,2 MB còn 1,90 MB trong 3,1 giây.
PIL (Python Imaging Library) là thư viện xử lý ảnh kinh điển của Python. Bản PIL gốc đã ngừng phát triển từ lâu, và thứ mọi người dùng ngày nay là Pillow, bản fork (nhánh phát triển tiếp) giữ nguyên cách gọi cũ. Vì vậy bạn cài bằng pip install pillow nhưng import bằng from PIL import Image. Với PIL Python, vài dòng code là mở được ảnh, đổi kích thước, cắt, xoay, viết chữ và lưu sang định dạng khác.
Mình chạy mọi ví dụ ngày 26/10/2026 trên một máy AIDev Cloud chạy Ubuntu 24.04, Python 3.15.1 và Pillow 13.0.0. Ảnh mẫu là ảnh một con phố cổ Hội An sau mưa trên Wikimedia Commons, phát hành theo giấy phép CC0 (cho dùng tự do, không cần xin phép). Mấy kết quả đáng chú ý:
Ảnh gốc 4989×3326 nặng 12,9 MB. Mình thu nhỏ về cạnh dài 1600 pixel và lưu JPEG chất lượng 85 thì còn 363 KB.
Font mặc định của Pillow không có chữ tiếng Việt có dấu: các chữ có dấu trong "Phố cổ Hội An" hiện thành ô vuông.
Thu nhỏ 6 ảnh chụp tổng cộng 71,2 MB xuống còn 1,90 MB mất 3,1 giây.
Cài PIL trong Python thế nào?
Lệnh đầu tiên nhiều người gõ là pip install PIL, và pip báo không tìm thấy, vì trên PyPI không có package nào tên PIL:

Lệnh đúng là cài Pillow, tốt nhất trong môi trường ảo của project:
pip install pillow
Sau khi cài, tên module vẫn là PIL. Đây là chỗ dễ nhầm nhất: tên để cài (pillow) khác tên để import (PIL).
Mở ảnh và xem thông tin
Image.open đọc ảnh và trả về một object Image. Các thuộc tính format, size và mode cho biết định dạng file, kích thước (rộng, cao) và kiểu màu:
import os
from PIL import Image
duong_dan = "anh/pho-hoi-an.jpg"
anh = Image.open(duong_dan)
print(anh.format, anh.size, anh.mode)
print(f"Dung lượng file: {os.path.getsize(duong_dan) / 1e6:.1f} MB")
print("Rộng:", anh.width, "| Cao:", anh.height)

RGB là ảnh màu ba kênh đỏ, xanh lá, xanh dương. Bạn sẽ còn gặp L (ảnh xám) và RGBA (thêm kênh trong suốt), và kiểu màu này quyết định bạn được lưu ảnh ra định dạng nào, như lỗi cannot write mode RGBA as JPEG ở mục lỗi thường gặp cuối bài.
Đổi kích thước ảnh: resize hay thumbnail?
Pillow có hai cách đổi kích thước, và chọn nhầm là ảnh bị méo:
meo = anh.resize((800, 800)) # ép đúng 800×800, không giữ tỉ lệ
meo.save("ra/resize-800x800.jpg")
thu_nho = anh.copy()
thu_nho.thumbnail((1600, 1600)) # giữ tỉ lệ, cạnh dài nhất 1600
thu_nho.save("ra/thumbnail-1600.jpg", quality=85)
resize((800, 800)) ép ảnh 3:2 vào khung vuông, nên dãy nhà và xe đạp bị bóp hẹp. thumbnail((1600, 1600)) thu nhỏ sao cho vừa khung 1600×1600 mà giữ tỉ lệ, kết quả là ảnh 1600×1067:

|
| |
|---|---|---|
Giữ tỉ lệ | không | có |
Kết quả | ảnh mới, 800×800 | sửa ảnh đang có, 1600×1067 |
Giá trị trả về | object |
|
Phóng to ảnh nhỏ | có | không |
Dung lượng file trong lần chạy | 115 KB (chất lượng 75) | 363 KB (chất lượng 85) |
Hai điểm cần nhớ. resize trả về ảnh mới, còn thumbnail sửa thẳng trên ảnh đang có và trả về None, nên đừng viết anh = anh.thumbnail(...). Ngoài ra thumbnail chỉ thu nhỏ, không bao giờ phóng to ảnh nhỏ hơn khung.
Cắt, xoay, đổi màu, làm mờ ảnh
Các thao tác thường dùng đều là một dòng. Mình chạy cả sáu trên ảnh đã thu nhỏ còn 1600×1067:
from PIL import Image, ImageFilter, ImageEnhance, ImageOps
w, h = anh.size # 1600, 1067 sau thumbnail
canh = min(w, h)
trai, tren = (w - canh) // 2, (h - canh) // 2
vuong = anh.crop((trai, tren, trai + canh, tren + canh)) # cắt vuông ở giữa
anh.rotate(90, expand=True) # xoay 90 độ ngược chiều kim đồng hồ
anh.convert("L") # đổi sang ảnh xám
anh.filter(ImageFilter.GaussianBlur(8)) # làm mờ
ImageEnhance.Brightness(anh).enhance(1.4) # sáng hơn 40%
ImageOps.mirror(anh) # lật ngang

crop nhận một bộ bốn số (trái, trên, phải, dưới) tính bằng pixel từ góc trên bên trái. Với rotate, nếu thiếu expand=True thì ảnh xoay vẫn giữ khung cũ 1600×1067, phần thừa bị cắt và phần thiếu tô đen.
Viết chữ tiếng Việt lên ảnh
ImageDraw vẽ chữ, hình lên ảnh. Mình viết cùng một dòng "Phố cổ Hội An: đèn lồng" hai lần, lần đầu bằng font mặc định của Pillow, lần sau bằng file font Be Vietnam Pro tải từ Google Fonts:
from PIL import ImageDraw, ImageFont
ve = ImageDraw.Draw(anh)
font = ImageFont.truetype("font/BeVietnamPro-Bold.ttf", 64)
ve.text((40, 40), "Phố cổ Hội An: đèn lồng", font=font, fill="white",
stroke_width=3, stroke_fill="black")

Font mặc định của Pillow 13 là Aileron, font này không có các ký tự tiếng Việt có dấu, nên chữ hiện thành ô vuông mà Python không báo lỗi gì. Với chữ tiếng Việt, bạn luôn truyền một file font .ttf có hỗ trợ tiếng Việt vào ImageFont.truetype. Tham số stroke_width vẽ viền quanh chữ để đọc được trên nền ảnh nhiều màu.
Lưu ảnh: JPEG, WebP hay PNG nhẹ hơn?
Mình lưu cùng ảnh 1600×1067 ở sáu kiểu, mỗi kiểu 5 lần, lấy trung vị thời gian lưu:

Cách lưu | Dung lượng | Thời gian lưu |
|---|---|---|
| 2.886 KB | 633 ms |
| 682 KB | 12 ms |
| 363 KB | 10 ms |
| 268 KB | 9 ms |
| 232 KB | 226 ms |
| 204 KB | 8 ms |
Với ảnh chụp, PNG nặng gần gấp 8 lần JPEG chất lượng 85 mà mắt thường khó thấy khác biệt. Pillow tự chọn định dạng theo đuôi file bạn đặt. Không truyền quality thì JPEG lưu ở mức 75, theo tài liệu định dạng ảnh của Pillow.
Muốn thấy chất lượng 95 và 60 khác nhau ở đâu, mình cắt cùng một vùng 400×250 trên hai ảnh rồi phóng to gấp đôi:

Ở mức phóng to gấp đôi, bản 60 chỉ nhòe nhẹ ở lá cây và vùng tối, trong khi file chưa bằng một phần ba bản 95. Ảnh để đăng web, mình dùng 80 đến 85. Lưu ý ảnh trong bài cũng đã qua nén thêm một lần khi đăng, nên khác biệt bạn thấy trên màn hình có thể nhỏ hơn so với file gốc.
Thu nhỏ hàng loạt ảnh trong một thư mục
Việc thật hay gặp nhất là có một thư mục ảnh chụp nặng hàng chục MB cần thu nhỏ để gửi đi hoặc đăng web. Mình tải 6 ảnh Hội An CC0 trên Commons về thư mục anh/lo rồi chạy:
from pathlib import Path
from PIL import Image, ImageOps
vao, ra = Path("anh/lo"), Path("ra/lo")
ra.mkdir(parents=True, exist_ok=True)
for f in sorted(vao.glob("*.jpg")):
with Image.open(f) as anh:
anh = ImageOps.exif_transpose(anh) # xoay đúng chiều theo EXIF của máy ảnh
anh.thumbnail((1600, 1600))
anh.save(ra / f.name, quality=85, optimize=True)

Tổng 71,2 MB còn 1,90 MB trong 3,1 giây. Dòng ImageOps.exif_transpose xử lý chuyện ảnh chụp dọc bằng điện thoại: nhiều máy lưu ảnh nằm ngang kèm một thẻ EXIF (thông tin máy ảnh ghi trong file) báo "xoay 90 độ khi hiển thị". Pillow không tự xoay ảnh theo thẻ này, nên thiếu dòng đó thì ảnh dọc có thể ra nằm ngang. Sáu ảnh mình dùng đều đã được xoay sẵn nên không ảnh nào đổi chiều, nhưng với ảnh chụp từ điện thoại, bạn cứ giữ dòng này. Cách sắp danh sách file bằng sorted có trong bài sort và sorted trong Python.
Lỗi thường gặp khi dùng PIL Python
cannot write mode RGBA as JPEG
Lỗi này xảy ra khi bạn lưu ảnh có kênh trong suốt (thường là file PNG logo) sang JPEG, vì JPEG không có kênh trong suốt:
Traceback (most recent call last):
File "/root/anh/b6_loi_rgba.py", line 5, in <module>
logo.save("ra/logo.jpg")
~~~~~~~~~^^^^^^^^^^^^^^^
...
OSError: cannot write mode RGBA as JPEG
Bạn chọn một trong hai cách: logo.convert("RGB") nếu không quan tâm phần trong suốt thành màu gì, hoặc dán logo lên nền trắng rồi mới lưu:
nen_trang = Image.new("RGB", logo.size, "white")
nen_trang.paste(logo, mask=logo) # kênh trong suốt (alpha) làm mặt nạ
nen_trang.save("ra/logo.jpg")

UnidentifiedImageError khi mở ảnh vừa tải về
Mình tải ảnh từ Wikimedia bằng requests mà không đặt User-Agent (header cho server biết chương trình nào đang gửi yêu cầu), ghi ra file .jpg rồi mở bằng Pillow:
403 text/plain 126 byte
PIL.UnidentifiedImageError: cannot identify image file 'anh/tai-ve.jpg'
Dòng đầu đã cho biết nguyên nhân: server trả mã 403 kèm một đoạn chữ 126 byte, và file .jpg vừa ghi chỉ chứa đoạn chữ này. Đó là lời nhắn của Wikimedia yêu cầu đặt User-Agent. Thêm header User-Agent có tên chương trình thì nhận về đúng ảnh JPEG 11,7 MB:

Gặp UnidentifiedImageError, bạn mở file bằng trình soạn thảo văn bản hoặc in vài trăm ký tự đầu. Thường file đó chứa trang lỗi HTML hay một đoạn chữ, còn ảnh thật thì chưa bao giờ được tải về. Cách gọi requests với header và kiểm tra mã trạng thái có trong bài request Python gọi API.
No matching distribution found for PIL
Đây là lỗi ở mục cài đặt đầu bài. Tên package trên PyPI là pillow, nên lệnh đúng là pip install pillow.
Khi nào không nên dùng Pillow?
Pillow hợp với ảnh tĩnh và các việc cơ bản như đổi kích thước, cắt, viết chữ, đổi định dạng. Cần nhận diện khuôn mặt, tách vật thể hay xử lý video thì bạn cần OpenCV hoặc các model học sâu, vì Pillow không có những chức năng này. Còn nếu chỉ thu nhỏ vài ảnh một lần, phần mềm chỉnh ảnh có sẵn trên máy làm nhanh hơn viết code. Pillow đáng dùng khi việc đó lặp lại: hàng trăm ảnh sản phẩm, ảnh đăng web mỗi tuần, hoặc bước chuẩn bị ảnh cho model AI.
Câu hỏi thường gặp
PIL và Pillow khác nhau thế nào?
PIL là thư viện gốc đã ngừng cập nhật và không cài được trên Python 3 hiện nay. Pillow là bản phát triển tiếp, giữ tên module PIL để code cũ chạy được. Khi người ta nói "PIL Python" ngày nay, gần như luôn là Pillow.
Pillow có xử lý được ảnh HEIC của iPhone không?
Tài liệu định dạng ảnh của Pillow dẫn ở mục lưu ảnh không có HEIC trong danh sách định dạng đọc được, nên bạn đừng trông vào Pillow đọc thẳng file HEIC. Lần này mình không chạy thử HEIC. Cách đơn giản nhất là chỉnh iPhone lưu ảnh dạng JPEG (mục Camera, Formats, Most Compatible) hoặc đổi sang JPEG trước khi xử lý.
Làm sao đổi ảnh sang mảng số để đưa vào model AI?
Dùng NumPy: numpy.array(anh) trả về một mảng kích thước (cao, rộng, 3) với ảnh RGB. Model xử lý ảnh trong PyTorch hay TensorFlow đều nhận dữ liệu dạng này, và Pillow thường là bước đầu tiên mở ảnh trước khi đổi sang mảng. Bài NumPy là gì giải thích mảng NumPy cho người mới.
Ảnh mẫu trong bài lấy ở đâu?
Ảnh minh họa chính là "Hoi An - HoiAn1457" của lumoplank trên Wikimedia Commons. Ba ảnh phố Hội An khác của lumoplank và hai ảnh "LANTERNS OF HOI AN" của DEBA PRASAD ROY chỉ dùng trong phần thu nhỏ hàng loạt và phần tải ảnh. Tất cả đều theo giấy phép CC0.
Đọc tiếp
Học Python từ số 0: lộ trình 8 chặng, Pillow là một library để luyện ở chặng module và library.
Request Python: gọi API, đọc JSON: tải ảnh và dữ liệu từ web về đúng cách.
Môi trường ảo Python (venv): cài Pillow cho riêng từng project.
Xử lý ảnh bằng Python là bước chuẩn bị dữ liệu quen thuộc khi bạn đi tiếp sang Deep Learning với ảnh. Lộ trình AI Engineer từ số 0 đi từ Python cơ bản đến xây ứng dụng AI, trong đó có phần huấn luyện model nhận diện ảnh.
Mình chạy ngày 26/10/2026 trên máy AIDev Cloud: Ubuntu 24.04.4 LTS, Intel Xeon E5-2680 v4 8 nhân, 16 GB RAM, Python 3.15.1 trong môi trường ảo venv, Pillow 13.0.0, requests 2.34.2.
Chuyên mục
Học PythonTham vấn chuyên môn
Nhà sáng lập AIDev và VietPlatform · Thạc sĩ AI MSE
- Tốt nghiệp thủ khoa xuất sắc Thạc sĩ AI MSE.
- Đồng sáng lập, CTO E-University, SVOnline: số hóa 10+ đại học, 40.000+ sinh viên.
- Xây hệ thống AI cho doanh nghiệp tại VietPlatform.


