Python cho kỹ sư Vision: xử lý thư mục ảnh, CSV và log kết quả
Lab: duyệt hàng nghìn ảnh bằng pathlib, bắt lỗi gán nhãn, ghi CSV mở được bằng Excel, chia train/val tái lập được và ghi log xoay vòng cho trạm 24/7.
Học xong bài này, bạn sẽ
- Duyệt hàng nghìn ảnh bằng pathlib
- Ghi kết quả ra CSV mở được bằng Excel
- Ghi log thay cho print
Nên học trước: Môi trường ảo, pip và thư viện cho AI Vision
Nội dung bài viết
Phần lớn công việc hằng ngày của kỹ sư Vision không phải là thuật toán, mà là xử lý hàng nghìn file ảnh: dây chuyền chạy một ca đã sinh ra vài nghìn ảnh, khách hàng gửi một ổ cứng ảnh lỗi, bạn cần đếm, lọc, chia tập train/val, ghi kết quả ra file Excel đọc được và để lại log khi có sự cố. Bài lab này dùng môi trường đã tạo ở bài "Môi trường ảo, pip và thư viện cho AI Vision" và kiến thức pathlib, try/except từ bài "Đọc ghi file, pathlib và xử lý lỗi". Cuối bài bạn có một script quét thư mục ảnh hoàn chỉnh, đúng kiểu công cụ dùng thật ở nhà máy.
1. Tạo thư mục ảnh giả lập dây chuyền
Để mọi người chạy ra cùng kết quả, ta tự tạo một thư mục ảnh giống ảnh lưu từ trạm kiểm tra: 3 ngày, mỗi ngày 2 lô (LOT-A, LOT-B), mỗi lô có thư mục OK và NG. Tên file theo quy ước <trạm>_<ngày>_<số thứ tự>_<kết quả>. Thêm vào đó vài thứ "bẩn" bạn chắc chắn sẽ gặp ngoài đời: đuôi file viết hoa, file Thumbs.db của Windows, file ghi chú, file tạm, ảnh hỏng do mất điện lúc đang lưu, ảnh chụp tay đặt tên tùy tiện, và một ảnh NG bị kéo nhầm vào thư mục OK.
from pathlib import Path
import cv2
import numpy as np
GOC = Path("anh_day_chuyen")
rng = np.random.default_rng(7)
DUOI = [".png"] * 7 + [".jpg", ".JPG", ".bmp"] # đa số PNG, lẫn JPG/BMP, có cả đuôi viết hoa
stt = 0
for ngay in ["20261005", "20261006", "20261007"]:
for lo in ["LOT-A", "LOT-B"]:
for nhan, so_luong in [("OK", 180), ("NG", 20)]:
thu_muc = GOC / ngay / lo / nhan
thu_muc.mkdir(parents=True, exist_ok=True)
for _ in range(so_luong):
stt += 1
anh = rng.integers(60, 200, size=(48, 64), dtype=np.uint8) # ảnh nhỏ cho nhanh
duoi = DUOI[stt % len(DUOI)]
cv2.imwrite(str(thu_muc / f"ST1_{ngay}_{stt:05d}_{nhan}{duoi}"), anh)
# Những thứ "bẩn" thường gặp trong thư mục ảnh thật
(GOC / "20261005" / "Thumbs.db").write_bytes(b"\x00" * 64)
(GOC / "20261006" / "ghi_chu_ca_dem.txt").write_text("Đèn ring bị lệch lúc 2h", encoding="utf-8")
(GOC / "20261006" / "LOT-A" / "OK" / "ST1_20261006_00999_NG.png").write_bytes(
cv2.imencode(".png", anh)[1].tobytes()) # ảnh NG nằm nhầm thư mục OK
thu_muc_cuoi = GOC / "20261007" / "LOT-B" / "OK"
(thu_muc_cuoi / ".~tam.png").write_bytes(b"") # file tạm, rỗng
(thu_muc_cuoi / "ST1_20261007_01201_OK.png").write_bytes(b"mat dien khi dang luu") # ảnh hỏng
(thu_muc_cuoi / "anh chup tay.jpg").write_bytes(cv2.imencode(".jpg", anh)[1].tobytes())
print("Đã tạo", stt, "ảnh dây chuyền và vài file lạ trong", GOC)
Đã tạo 1200 ảnh dây chuyền và vài file lạ trong anh_day_chuyen
cv2.imencode mã hóa ảnh thành bytes trong bộ nhớ, ta ghi bytes đó ra file bằng write_bytes. Bài OpenCV đầu tiên của chặng 2 sẽ giải thích vì sao cách này hữu ích với đường dẫn tiếng Việt trên Windows.
2. Duyệt hàng nghìn ảnh bằng pathlib
Bài trước bạn đã biết glob và rglob. Với thư mục ảnh thật, cần thêm bốn chi tiết: lọc theo đuôi không phân biệt hoa thường, bỏ file ẩn/file tạm, chỉ lấy file (không lấy thư mục), và sắp xếp kết quả.
from collections import Counter
from pathlib import Path
GOC = Path("anh_day_chuyen")
DUOI_ANH = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
def liet_ke_anh(goc: Path) -> list[Path]:
"""Mọi file ảnh trong goc và các thư mục con, đã sắp xếp."""
ket_qua = []
for p in goc.rglob("*"):
if not p.is_file():
continue
if p.name.startswith((".", "~")): # file ẩn, file tạm
continue
if p.suffix.lower() in DUOI_ANH: # .JPG cũng được tính
ket_qua.append(p)
return sorted(ket_qua)
ds_anh = liet_ke_anh(GOC)
print("Số ảnh:", len(ds_anh))
print("Đầu tiên:", ds_anh[0].relative_to(GOC).as_posix())
print("Cuối cùng:", ds_anh[-1].relative_to(GOC).as_posix())
theo_thu_muc = Counter(p.parent.name for p in ds_anh) # OK / NG
theo_ngay = Counter(p.relative_to(GOC).parts[0] for p in ds_anh) # phần đầu đường dẫn
print(theo_thu_muc)
print(dict(sorted(theo_ngay.items())))
Số ảnh: 1203
Đầu tiên: 20261005/LOT-A/NG/ST1_20261005_00181_NG.png
Cuối cùng: 20261007/LOT-B/OK/anh chup tay.jpg
Counter({'OK': 1083, 'NG': 120})
dict({'20261005': 400, '20261006': 401, '20261007': 402})
Vài điểm đáng học:
p.relative_to(GOC)bỏ phần gốc,.partstách đường dẫn thành tuple('20261005', 'LOT-A', 'NG', 'ST1_...png'): cách gọn nhất để lấy ngày, lô, nhãn từ cấu trúc thư mục..as_posix()luôn dùng dấu/, tiện để ghi vào CSV dùng chung giữa Windows và Linux.Counter(modulecollections) đếm số lần xuất hiện: đúng thứ bạn cần để thống kê ảnh theo nhãn, ngày, lô.sorted()không phải để đẹp: thứ tựrglobtrả về phụ thuộc hệ điều hành và hệ thống file. Không sắp xếp thì cùng một thư mục, chạy trên hai máy có thể ra hai thứ tự khác nhau, và phép chia train/val ở mục 5 sẽ không tái lập được.
rglob("*.jpg") bỏ sót file .JPG vì phân biệt hoa thường. Trên Windows thì ngược lại, tìm cả .jpg lẫn .JPG; nếu bạn "chữa" bằng cách gọi thêm rglob("*.JPG") thì trên Windows mỗi ảnh bị đếm hai lần. Cách chạy đúng ở mọi nơi là duyệt rglob("*") rồi so p.suffix.lower() như trên.Với vài chục nghìn ảnh, rglob chạy trong vài giây là đủ nhanh. Khi thư mục lên tới hàng trăm nghìn hay hàng triệu file (ổ lưu ảnh của trạm sau nhiều tháng), os.scandir/os.walk nhanh hơn vì đọc sẵn thông tin file khi duyệt; và nên tổ chức thư mục theo ngày để mỗi lần chỉ quét một phần.
3. Lấy thông tin từ tên file và kiểm tra dữ liệu
Quy ước đặt tên tốt giúp tên file tự mang thông tin. Viết một hàm tách tên, trả về None khi tên sai quy ước, rồi dùng nó để tìm dữ liệu "lạ":
def doc_ten_file(p: Path) -> dict | None:
"""Tách 'ST1_20261005_00012_OK' thành dict; sai quy ước thì trả về None."""
phan = p.stem.split("_")
if len(phan) != 4:
return None
tram, ngay, stt, nhan = phan
if not (ngay.isdigit() and len(ngay) == 8 and stt.isdigit() and nhan in ("OK", "NG")):
return None
return {"tram": tram, "ngay": ngay, "stt": int(stt), "nhan": nhan}
print(doc_ten_file(Path("ST1_20261005_00012_OK.png")))
print(doc_ten_file(Path("anh chup tay.jpg")))
sai_ten = [p.name for p in ds_anh if doc_ten_file(p) is None]
lech_nhan = [p.relative_to(GOC).as_posix() for p in ds_anh
if (t := doc_ten_file(p)) and t["nhan"] != p.parent.name]
print("Sai quy ước tên:", sai_ten)
print("Nhãn trong tên khác thư mục:", lech_nhan)
{'tram': 'ST1', 'ngay': '20261005', 'stt': 12, 'nhan': 'OK'}
None
Sai quy ước tên: ['anh chup tay.jpg']
Nhãn trong tên khác thư mục: ['20261006/LOT-A/OK/ST1_20261006_00999_NG.png']
Biểu thức (t := doc_ten_file(p)) (toán tử "walrus", Python 3.8+) vừa gán vừa kiểm tra, để không phải gọi hàm hai lần. Viết dài hơn bằng vòng for thường cũng được.
Ảnh NG nằm trong thư mục OK là lỗi gán nhãn: nếu đưa vào huấn luyện AI, mô hình sẽ học rằng một sản phẩm lỗi là "OK", góp phần gây ra lọt lỗi (escape). Kiểm tra chéo như trên mất vài dòng code nhưng cứu được cả một đợt huấn luyện.
4. Ghi kết quả ra CSV mở được bằng Excel
CSV (Comma-Separated Values) là file văn bản, mỗi dòng một bản ghi, các cột ngăn bằng dấu phẩy. Mọi công cụ đều đọc được: Excel, pandas, phần mềm MES của nhà máy. Dùng module csv có sẵn, đừng tự nối chuỗi bằng dấu phẩy: tên file có dấu phẩy hay dấu nháy sẽ làm vỡ cột, còn csv tự xử lý.
import csv
COT = ["duong_dan", "ngay", "lo", "nhan_thu_muc", "nhan_ten_file", "hop_le"]
with open("danh_sach_anh.csv", "w", newline="", encoding="utf-8-sig") as f:
ghi = csv.DictWriter(f, fieldnames=COT)
ghi.writeheader()
for p in ds_anh:
phan = p.relative_to(GOC).parts # (ngày, lô, OK/NG, tên file)
t = doc_ten_file(p)
ghi.writerow({
"duong_dan": p.relative_to(GOC).as_posix(),
"ngay": phan[0],
"lo": phan[1],
"nhan_thu_muc": phan[2],
"nhan_ten_file": t["nhan"] if t else "",
"hop_le": "có" if t and t["nhan"] == phan[2] else "không",
})
dong = Path("danh_sach_anh.csv").read_text(encoding="utf-8-sig").splitlines()
print(len(dong), "dòng (kể cả dòng tiêu đề)")
print("\n".join(dong[:3]))
1204 dòng (kể cả dòng tiêu đề)
duong_dan,ngay,lo,nhan_thu_muc,nhan_ten_file,hop_le
20261005/LOT-A/NG/ST1_20261005_00181_NG.png,20261005,LOT-A,NG,NG,có
20261005/LOT-A/NG/ST1_20261005_00182_NG.png,20261005,LOT-A,NG,NG,có
Ba chi tiết bắt buộc khi mở file CSV để ghi:
newline="": thiếu nó, trên Windows mỗi dòng bị chèn thêm một dòng trống.encoding="utf-8-sig": thêm 3 byte đánh dấu (BOM) ở đầu file để Excel nhận ra UTF-8. Ghi bằngutf-8thường, Excel sẽ hiện "có" thay cho "có".DictWritervới danh sách cột cố định: thêm cột mới không làm lệch thứ tự cột cũ, quên một khóa thì ô đó để trống.
Đọc lại để thống kê, dùng DictReader. Mọi giá trị đọc từ CSV đều là chuỗi, cần ép kiểu nếu là số:
with open("danh_sach_anh.csv", newline="", encoding="utf-8-sig") as f:
ban_ghi = list(csv.DictReader(f))
khong_hop_le = [r["duong_dan"] for r in ban_ghi if r["hop_le"] == "không"]
print(len(ban_ghi), "bản ghi;", len(khong_hop_le), "không hợp lệ:")
for d in khong_hop_le:
print(" ", d)
1203 bản ghi; 2 không hợp lệ:
20261006/LOT-A/OK/ST1_20261006_00999_NG.png
20261007/LOT-B/OK/anh chup tay.jpg
| Vấn đề khi mở bằng Excel | Nguyên nhân | Cách xử lý |
|---|---|---|
| Tiếng Việt thành ký tự lạ | File thiếu BOM | Ghi bằng encoding="utf-8-sig" |
| Mọi thứ dồn vào cột A | Máy cài định dạng vùng dùng dấu phẩy thập phân (nhiều máy đặt định dạng Việt Nam), Excel chờ dấu ; làm ký tự ngăn cột | Mở bằng Data → From Text/CSV và chọn dấu phẩy, hoặc thống nhất với nhóm dùng csv.writer(f, delimiter=";") |
| Số 12.48 biến thành ngày tháng hoặc chữ | Excel hiểu dấu chấm theo định dạng vùng | Như trên: nhập qua Data → From Text/CSV và chọn kiểu cột |
Mã 00123 mất số 0 đầu | Excel tự đổi thành số | Nhập cột đó dạng Text, hoặc thêm tiền tố chữ vào mã (SP00123) |
Python báo PermissionError khi ghi | File đang mở trong Excel (Windows khóa file) | Đóng Excel, hoặc ghi ra tên file có thời gian |
5. Chia train/val có thể tái lập
Trước khi huấn luyện AI (chặng 10–11), bạn chia ảnh thành tập train (để học) và val (để kiểm tra). Yêu cầu quan trọng nhất: chạy lại lần nào cũng ra đúng cách chia đó, nếu không bạn không thể so sánh hai lần huấn luyện. Ba nguyên tắc: sắp xếp trước, xáo trộn với seed cố định, và ghi kết quả chia ra file.
import random
def chia_train_val(ds, ti_le_val=0.2, seed=42):
"""Trả về (train, val). Không sửa list đầu vào."""
ds = sorted(ds) # thứ tự đầu vào không ảnh hưởng kết quả
random.Random(seed).shuffle(ds) # bộ sinh số ngẫu nhiên riêng, có seed
n_val = round(len(ds) * ti_le_val)
return ds[n_val:], ds[:n_val]
ten = [f"img_{i:02d}.png" for i in range(10)]
train, val = chia_train_val(ten)
print("val:", val)
print("Chạy lại giống hệt:", chia_train_val(ten) == (train, val))
print("Đảo thứ tự đầu vào vẫn giống:", chia_train_val(ten[::-1]) == (train, val))
print("Seed khác:", chia_train_val(ten, seed=7)[1])
val: ['img_07.png', 'img_03.png']
Chạy lại giống hệt: True
Đảo thứ tự đầu vào vẫn giống: True
Seed khác: ['img_08.png', 'img_03.png']
random.Random(seed)tạo bộ sinh số ngẫu nhiên riêng. Gọirandom.seed(42)toàn cục cũng được, nhưng bất kỳ đoạn code nào khác gọirandomở giữa sẽ làm kết quả thay đổi.- Ghi danh sách train/val ra CSV thay vì copy ảnh sang thư mục mới: không tốn gấp đôi ổ cứng, và file CSV chính là "biên bản" của lần chia, lưu cùng mô hình. Khi công cụ huấn luyện bắt buộc cấu trúc thư mục, mới copy bằng
shutil.copy2(giữ cả thời gian tạo file).
6. Ghi log thay cho print
print hiện ra màn hình rồi mất. Trạm kiểm tra chạy 24/7 cần log: ghi ra file, có thời gian, có mức độ nghiêm trọng, tự giới hạn dung lượng. Khi có sự cố lúc 3 giờ sáng, file log là thứ duy nhất cho biết chuyện gì đã xảy ra. Module logging có sẵn trong Python.
| Mức | Dùng khi | Ví dụ ở trạm vision |
|---|---|---|
DEBUG | Chi tiết để gỡ lỗi, bình thường tắt | Thời gian từng bước xử lý, giá trị trung gian |
INFO | Sự kiện bình thường | Bắt đầu ca, xử lý xong lô, tổng kết |
WARNING | Bất thường nhưng vẫn chạy tiếp được | Ảnh quá tối, tên file sai quy ước, xử lý chậm |
ERROR | Một việc thất bại | Không đọc được ảnh, ghi file lỗi |
CRITICAL | Hệ thống không thể tiếp tục | Mất kết nối camera, ổ cứng đầy |
import logging
import sys
from logging.handlers import RotatingFileHandler
from pathlib import Path
def tao_logger(ten="tram1", file_log="vision.log"):
"""Logger ghi ra file (đủ chi tiết) và ra màn hình (gọn)."""
log = logging.getLogger(ten)
log.setLevel(logging.DEBUG)
for h in list(log.handlers): # gọi lại hàm không bị nhân đôi dòng log
h.close()
log.removeHandler(h)
ghi_file = RotatingFileHandler(file_log, maxBytes=5_000_000, backupCount=5, encoding="utf-8")
ghi_file.setLevel(logging.DEBUG)
ghi_file.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s"))
man_hinh = logging.StreamHandler(sys.stdout)
man_hinh.setLevel(logging.INFO) # màn hình chỉ hiện từ INFO trở lên
man_hinh.setFormatter(logging.Formatter("%(levelname)-8s %(message)s"))
log.addHandler(ghi_file)
log.addHandler(man_hinh)
log.propagate = False
return log
log = tao_logger()
log.info("Bắt đầu ca, ngưỡng sáng = %d", 128)
log.debug("Chỉ có trong file: thời gian xử lý %.1f ms", 23.4)
log.warning("Ảnh %s quá tối, độ sáng TB = %.1f", "ST1_00012_OK.png", 18.4)
try:
1 / 0
except ZeroDivisionError:
log.exception("Lỗi khi tính tỉ lệ NG") # tự kèm traceback vào log
print("--- nội dung vision.log ---")
print(Path("vision.log").read_text(encoding="utf-8"))
INFO Bắt đầu ca, ngưỡng sáng = 128
WARNING Ảnh ST1_00012_OK.png quá tối, độ sáng TB = 18.4
ERROR Lỗi khi tính tỉ lệ NG
Traceback (most recent call last):
File "...\vidu_log.py", line 34, in <module>
1 / 0
~~^~~
ZeroDivisionError: division by zero
--- nội dung vision.log ---
2026-10-09 09:12:03,417 INFO tram1: Bắt đầu ca, ngưỡng sáng = 128
2026-10-09 09:12:03,417 DEBUG tram1: Chỉ có trong file: thời gian xử lý 23.4 ms
2026-10-09 09:12:03,417 WARNING tram1: Ảnh ST1_00012_OK.png quá tối, độ sáng TB = 18.4
2026-10-09 09:12:03,418 ERROR tram1: Lỗi khi tính tỉ lệ NG
Traceback (most recent call last):
File "...\vidu_log.py", line 34, in <module>
1 / 0
~~^~~
ZeroDivisionError: division by zero
Kết quả trên khi lưu khối code thành file vidu_log.py rồi chạy. Thời gian trong file log và đường dẫn file trong traceback sẽ khác trên máy bạn. Những điều cần học từ ví dụ:
- Hai "đầu ra" (handler) với hai mức khác nhau: file giữ cả
DEBUGđể điều tra sau, màn hình chỉ hiện từINFOcho gọn. RotatingFileHandler: khi file đạt 5 MB thì đổi tên thànhvision.log.1và mở file mới, giữ tối đa 5 file cũ. Không có nó, sau vài tháng file log có thể lớn tới hàng GB và làm đầy ổ. Muốn mỗi ngày một file thì dùngTimedRotatingFileHandler(..., when="midnight").- Truyền tham số kiểu
log.info("... %d", 128)thay vì f-string: chuỗi chỉ được định dạng khi dòng log thực sự được ghi (dòngDEBUGbị tắt thì không tốn công). log.exception(...)chỉ gọi trong khốiexcept: ghi mứcERRORkèm toàn bộ traceback, đúng thứ bạn cần khi đọc lại log.logging.basicConfig(...)là cách cấu hình nhanh trong script nhỏ, nhưng nó không làm gì nếu logging đã được cấu hình trước đó (ví dụ bởi một thư viện khác), một nguồn gây bối rối. Tự tạo logger có tên như trên rõ ràng hơn.
7. Ghép lại: script quét thư mục ảnh
Script dưới đây gộp mọi thứ: duyệt ảnh, đọc từng ảnh bằng OpenCV, tính độ sáng trung bình, ghi CSV, ghi log, và không dừng khi gặp ảnh hỏng. Đây là khung bạn sẽ mở rộng ở "Dự án chặng 1: báo cáo chất lượng thư mục ảnh".
"""quet_anh.py: quét thư mục ảnh, ghi ket_qua_quet.csv và quet_anh.log."""
import csv
import logging
import sys
import time
from collections import Counter
from logging.handlers import RotatingFileHandler
from pathlib import Path
import cv2
DUOI_ANH = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
NGUONG_TOI = 40 # độ sáng trung bình dưới mức này thì cảnh báo
def tao_logger(ten, file_log):
log = logging.getLogger(ten)
log.setLevel(logging.DEBUG)
for h in list(log.handlers):
h.close()
log.removeHandler(h)
fh = RotatingFileHandler(file_log, maxBytes=5_000_000, backupCount=5, encoding="utf-8")
fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(message)s"))
sh = logging.StreamHandler(sys.stdout)
sh.setLevel(logging.INFO)
sh.setFormatter(logging.Formatter("%(levelname)-8s %(message)s"))
log.addHandler(fh)
log.addHandler(sh)
log.propagate = False
return log
def liet_ke_anh(goc):
return sorted(p for p in goc.rglob("*")
if p.is_file() and not p.name.startswith((".", "~"))
and p.suffix.lower() in DUOI_ANH)
def quet(goc: Path, file_csv: Path, log) -> Counter:
dem = Counter()
ds = liet_ke_anh(goc)
log.info("Bắt đầu quét %s: %d ảnh", goc, len(ds))
with open(file_csv, "w", newline="", encoding="utf-8-sig") as f:
ghi = csv.writer(f)
ghi.writerow(["duong_dan", "rong", "cao", "do_sang_tb", "trang_thai"])
for p in ds:
ten = p.relative_to(goc).as_posix()
try:
anh = cv2.imread(str(p), cv2.IMREAD_GRAYSCALE)
if anh is None:
raise ValueError("OpenCV không giải mã được file")
sang = float(anh.mean())
trang_thai = "toi" if sang < NGUONG_TOI else "tot"
if trang_thai == "toi":
log.warning("Ảnh tối: %s (%.1f)", ten, sang)
ghi.writerow([ten, anh.shape[1], anh.shape[0], f"{sang:.1f}", trang_thai])
except Exception as loi: # một ảnh hỏng không làm dừng cả lô
trang_thai = "hong"
log.error("Bỏ qua %s: %s", ten, loi)
ghi.writerow([ten, "", "", "", trang_thai])
dem[trang_thai] += 1
log.debug("%s -> %s", ten, trang_thai)
return dem
log = tao_logger("quet_anh", "quet_anh.log")
t0 = time.perf_counter()
dem = quet(Path("anh_day_chuyen"), Path("ket_qua_quet.csv"), log)
log.info("Xong: %s", dict(sorted(dem.items())))
log.info("Thời gian: %.2f s", time.perf_counter() - t0)
INFO Bắt đầu quét anh_day_chuyen: 1203 ảnh
ERROR Bỏ qua 20261007/LOT-B/OK/ST1_20261007_01201_OK.png: OpenCV không giải mã được file
INFO Xong: {'hong': 1, 'tot': 1202}
INFO Thời gian: 0.31 s
Thời gian chạy tùy máy. OpenCV còn in thêm một dòng cảnh báo riêng của nó ([ WARN:...] imread_(...)) khi gặp file hỏng; dòng đó đi ra luồng lỗi chuẩn, không nằm trong log của bạn. File quet_anh.log chứa thêm 1203 dòng DEBUG, mỗi ảnh một dòng: rất hữu ích khi cần tra "ảnh X lúc đó bị đánh giá thế nào".
Để biến thành công cụ dòng lệnh (python quet_anh.py D:\anh), đọc đường dẫn từ sys.argv[1] hoặc dùng module argparse, và đặt ba dòng cuối vào khối if __name__ == "__main__": như bài "Hàm và module".
Lỗi thường gặp
| Lỗi | Hậu quả | Cách đúng |
|---|---|---|
Không sorted() danh sách file trước khi chia | Máy khác chia ra train/val khác | Luôn sắp xếp, rồi mới xáo trộn có seed |
Lọc đuôi bằng rglob("*.jpg") | Sót .JPG trên Linux, đếm đôi trên Windows nếu thêm mẫu viết hoa | p.suffix.lower() in DUOI_ANH |
Ghép đường dẫn bằng chuỗi "a" + "\\" + ten | Chạy sai trên Linux | Path và toán tử / |
Ghi CSV thiếu newline="" hoặc dùng utf-8 thường | Dòng trống xen kẽ; Excel lỗi font tiếng Việt | open(..., "w", newline="", encoding="utf-8-sig") |
except: pass trong vòng lặp ảnh | Ảnh hỏng biến mất không dấu vết | Bắt lỗi, log.error/log.exception, ghi trạng thái "hong" vào CSV |
| Log không giới hạn dung lượng | Sau vài tháng đầy ổ cứng, trạm dừng | RotatingFileHandler hoặc TimedRotatingFileHandler |
Tóm tắt
- Duyệt ảnh:
rglob("*")+ lọcsuffix.lower()+ bỏ file ẩn/tạm +sorted();relative_tovàpartsđể lấy ngày/lô/nhãn. - Kiểm tra quy ước tên và nhãn chéo giữa tên file với thư mục để bắt lỗi gán nhãn.
- CSV: module
csv,newline="",utf-8-sig; đọc lại bằngDictReader(mọi giá trị là chuỗi). - Chia train/val: sắp xếp →
random.Random(seed).shuffle→ ghi CSV; cân nhắc chia theo lô và chia phân tầng OK/NG. - Log thay print: logger có tên, file + màn hình,
RotatingFileHandler,log.exceptiontrongexcept.
Bài tập
Bài 1. Đếm ảnh theo phần mở rộng
Viết script đếm số ảnh theo phần mở rộng (.jpg, .png, .bmp…) trong thư mục anh_day_chuyen và mọi thư mục con. Đuôi viết hoa và viết thường tính chung; bỏ qua file ẩn. In thêm số file không phải ảnh để biết thư mục có "rác" gì.
Xem đáp án
from collections import Counter
from pathlib import Path
DUOI_ANH = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
goc = Path("anh_day_chuyen")
dem_anh, khong_phai_anh = Counter(), []
for p in goc.rglob("*"):
if not p.is_file() or p.name.startswith((".", "~")):
continue
duoi = p.suffix.lower()
if duoi in DUOI_ANH:
dem_anh[duoi] += 1
else:
khong_phai_anh.append(p.name)
for duoi, so in dem_anh.most_common():
print(f"{duoi:6s} {so:5d}")
print("Tổng ảnh:", sum(dem_anh.values()))
print("Không phải ảnh:", sorted(khong_phai_anh))
.png 842
.jpg 241
.bmp 120
Tổng ảnh: 1203
Không phải ảnh: ['Thumbs.db', 'ghi_chu_ca_dem.txt']
241 file .jpg gồm 120 file .jpg, 120 file .JPG và "anh chup tay.jpg". most_common() trả về các cặp (đuôi, số lượng) xếp từ nhiều đến ít.
Bài 2. Chia train/val 80/20 và ghi CSV
Chia ngẫu nhiên toàn bộ ảnh trong anh_day_chuyen thành train/val theo tỉ lệ 80/20 với seed cố định 42. Ghi ra chia_tap.csv gồm các cột duong_dan, nhan (tên thư mục OK/NG), tap (train/val). In số ảnh mỗi tập và kiểm tra chạy hai lần cho cùng kết quả.
Xem đáp án
import csv
import random
from pathlib import Path
DUOI_ANH = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
goc = Path("anh_day_chuyen")
def chia(goc, ti_le_val=0.2, seed=42):
ds = sorted(p.relative_to(goc).as_posix() for p in goc.rglob("*")
if p.is_file() and not p.name.startswith((".", "~"))
and p.suffix.lower() in DUOI_ANH)
random.Random(seed).shuffle(ds)
n_val = round(len(ds) * ti_le_val)
return ds[n_val:], ds[:n_val]
train, val = chia(goc)
with open("chia_tap.csv", "w", newline="", encoding="utf-8-sig") as f:
ghi = csv.writer(f)
ghi.writerow(["duong_dan", "nhan", "tap"])
for tap, ds in (("train", train), ("val", val)):
for d in sorted(ds):
ghi.writerow([d, d.split("/")[-2], tap])
print("train:", len(train), "val:", len(val))
print("NG trong val:", sum(d.split("/")[-2] == "NG" for d in val))
print("Chạy lại giống hệt:", chia(goc) == (train, val))
train: 962 val: 241
NG trong val: 29
Chạy lại giống hệt: True
Val có 29 ảnh NG trên 241 (12,0%), trong khi toàn bộ dữ liệu chỉ có 120/1203 (10%) là NG. Chia ngẫu nhiên từng ảnh làm tỉ lệ NG trong val lệch theo may rủi, với seed khác có thể lệch nhiều hơn: đó là lý do của bài 3. Dùng đường dẫn tương đối dạng / trong CSV để file dùng được trên cả Windows lẫn Linux.
Bài 3. Chia phân tầng OK/NG
Sửa bài 2 để chia riêng ảnh OK và ảnh NG, mỗi nhóm 80/20, rồi gộp lại. Kiểm tra tỉ lệ NG trong val đúng bằng tỉ lệ NG chung.
Xem đáp án
import random
from collections import defaultdict
from pathlib import Path
DUOI_ANH = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"}
goc = Path("anh_day_chuyen")
theo_nhan = defaultdict(list)
for p in goc.rglob("*"):
if p.is_file() and not p.name.startswith((".", "~")) and p.suffix.lower() in DUOI_ANH:
theo_nhan[p.parent.name].append(p.relative_to(goc).as_posix())
rng = random.Random(42)
train, val = [], []
for nhan in sorted(theo_nhan): # sắp xếp cả tên nhóm để tái lập được
ds = sorted(theo_nhan[nhan])
rng.shuffle(ds)
n_val = round(len(ds) * 0.2)
val += ds[:n_val]
train += ds[n_val:]
ng_val = sum(d.split("/")[-2] == "NG" for d in val)
print("train:", len(train), "val:", len(val))
print(f"NG trong val: {ng_val}/{len(val)} = {ng_val / len(val):.1%}")
print(f"NG toàn bộ: {len(theo_nhan['NG'])}/{len(train) + len(val)}")
train: 962 val: 241
NG trong val: 24/241 = 10.0%
NG toàn bộ: 120/1203
defaultdict(list) tự tạo list rỗng cho khóa mới, nên không cần kiểm tra khóa đã có chưa. Cùng ý tưởng này áp dụng cho chia theo lô: thay nhóm "OK/NG" bằng nhóm "ngày + lô" và đưa trọn nhóm vào một tập.
Bài tiếp theo: NumPy cơ bản: mảng, slicing, vector hóa, công cụ để xử lý chính nội dung bên trong những file ảnh bạn vừa duyệt.
Bài tập thực hành
- Viết script đếm số ảnh theo phần mở rộng (.jpg, .png, .bmp) trong một thư mục và các thư mục con.
- Chia ngẫu nhiên thư mục ảnh thành train/val theo tỉ lệ 80/20 với seed cố định, ghi danh sách ra CSV.
Làm xong bài tập mới chuyển sang bài tiếp theo: tự viết code là cách duy nhất để nhớ lâu.


