Bỏ qua đến nội dung chính
Học Python
Logo Python cạnh ảnh phố cổ Hội An ngày mưa

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:

Terminal: pip install PIL báo ERROR: Could not find a version that satisfies the requirement PIL và No matching distribution found for PIL, import PIL báo ModuleNotFoundError, pip install pillow cài pillow-13.0.0, in phiên bản PIL ra 13.0.0

Lệnh đúng là cài Pillow, tốt nhất trong môi trường ảo của project:

bash
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:

python
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)
Terminal chạy b1_mo_anh.py: JPEG (4989, 3326) RGB, Dung lượng file: 12.9 MB, Rộng: 4989 | Cao: 3326

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:

python
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:

So sánh hai ảnh phố cổ Hội An: bên trái resize((800, 800)) bị bóp hẹp thành hình vuông, nhà và xe đạp trông gầy hơn; bên phải thumbnail((1600, 1600)) giữ đúng tỉ lệ 3:2

resize((800, 800))

thumbnail((1600, 1600))

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 Image

None

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:

python
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
Lưới sáu ảnh phố cổ Hội An sau khi xử lý bằng Pillow: crop() cắt vuông 1067×1067 ở giữa, rotate(90, expand=True) xoay dọc 1067×1600, convert("L") thành ảnh xám, GaussianBlur(8) làm mờ, Brightness 1,4 sáng hơn, ImageOps.mirror() 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:

python
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")
Hai dải ảnh phố cổ Hội An có chữ: dòng trên dùng font mặc định Aileron, các chữ ố, ổ, ộ, đ, è, ồ hiện thành ô vuông; dòng dưới dùng Be Vietnam Pro Bold có viền đen, đủ dấu Phố cổ Hội An: đèn lồng

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:

Biểu đồ dung lượng cùng một ảnh 1600×1067 trên Pillow 13.0.0 ngày 26/10/2026: PNG 2886 KB, JPEG q95 682 KB, q85 363 KB, q75 mặc định 268 KB, WebP q80 232 KB, JPEG q60 204 KB

Cách lưu

Dung lượng

Thời gian lưu

save("a.png")

2.886 KB

633 ms

save("a.jpg", quality=95)

682 KB

12 ms

save("a.jpg", quality=85)

363 KB

10 ms

save("a.jpg") (mặc định 75)

268 KB

9 ms

save("a.webp", quality=80)

232 KB

226 ms

save("a.jpg", quality=60)

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:

Cùng vùng giàn hoa giấy và đèn lồng dưới mưa phóng to gấp đôi: bên trái JPEG quality=95 nét lá và hạt mưa, bên phải JPEG quality=60 hơi nhòe ở lá và vùng tối, khác biệt nhỏ

Ở 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:

python
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)
Terminal chạy b8_hang_loat.py: 6 ảnh từ 6,3 đến 15,9 MB còn 185 đến 387 KB mỗi ảnh, ảnh dọc LANTERNS_OF_HOI_AN_01 ra (1063, 1600), tổng 71.2 MB -> 1.90 MB, 3.1 giây

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:

text
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:

python
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")
Terminal: b6_loi_rgba.py in RGBA rồi Traceback KeyError: 'RGBA' dẫn tới OSError: cannot write mode RGBA as JPEG; b6_sua_rgba.py dán lên nền trắng in RGB

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:

text
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:

Terminal: b7_tai_anh.py in 403 text/plain 126 byte rồi PIL.UnidentifiedImageError: cannot identify image file 'anh/tai-ve.jpg'; b7_sua_tai_anh.py in nội dung file Please set a user-agent and respect our robot policy, sau khi thêm User-Agent ra 200 image/jpeg 11664035 byte, JPEG (5184, 2916)

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

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.

Tham vấn chuyên môn

Nguyễn Việt Chung

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.
Biên soạn: AIDev Team · Rà soát · Quy trình biên tập