diff --git a/.gradio/certificate.pem b/.gradio/certificate.pem new file mode 100644 index 00000000..b85c8037 --- /dev/null +++ b/.gradio/certificate.pem @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIRAIIQz7DSQONZRGPgu2OCiwAwDQYJKoZIhvcNAQELBQAw +TzELMAkGA1UEBhMCVVMxKTAnBgNVBAoTIEludGVybmV0IFNlY3VyaXR5IFJlc2Vh +cmNoIEdyb3VwMRUwEwYDVQQDEwxJU1JHIFJvb3QgWDEwHhcNMTUwNjA0MTEwNDM4 +WhcNMzUwNjA0MTEwNDM4WjBPMQswCQYDVQQGEwJVUzEpMCcGA1UEChMgSW50ZXJu +ZXQgU2VjdXJpdHkgUmVzZWFyY2ggR3JvdXAxFTATBgNVBAMTDElTUkcgUm9vdCBY +MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAK3oJHP0FDfzm54rVygc +h77ct984kIxuPOZXoHj3dcKi/vVqbvYATyjb3miGbESTtrFj/RQSa78f0uoxmyF+ +0TM8ukj13Xnfs7j/EvEhmkvBioZxaUpmZmyPfjxwv60pIgbz5MDmgK7iS4+3mX6U +A5/TR5d8mUgjU+g4rk8Kb4Mu0UlXjIB0ttov0DiNewNwIRt18jA8+o+u3dpjq+sW +T8KOEUt+zwvo/7V3LvSye0rgTBIlDHCNAymg4VMk7BPZ7hm/ELNKjD+Jo2FR3qyH +B5T0Y3HsLuJvW5iB4YlcNHlsdu87kGJ55tukmi8mxdAQ4Q7e2RCOFvu396j3x+UC +B5iPNgiV5+I3lg02dZ77DnKxHZu8A/lJBdiB3QW0KtZB6awBdpUKD9jf1b0SHzUv +KBds0pjBqAlkd25HN7rOrFleaJ1/ctaJxQZBKT5ZPt0m9STJEadao0xAH0ahmbWn +OlFuhjuefXKnEgV4We0+UXgVCwOPjdAvBbI+e0ocS3MFEvzG6uBQE3xDk3SzynTn +jh8BCNAw1FtxNrQHusEwMFxIt4I7mKZ9YIqioymCzLq9gwQbooMDQaHWBfEbwrbw +qHyGO0aoSCqI3Haadr8faqU9GY/rOPNk3sgrDQoo//fb4hVC1CLQJ13hef4Y53CI +rU7m2Ys6xt0nUW7/vGT1M0NPAgMBAAGjQjBAMA4GA1UdDwEB/wQEAwIBBjAPBgNV +HRMBAf8EBTADAQH/MB0GA1UdDgQWBBR5tFnme7bl5AFzgAiIyBpY9umbbjANBgkq +hkiG9w0BAQsFAAOCAgEAVR9YqbyyqFDQDLHYGmkgJykIrGF1XIpu+ILlaS/V9lZL +ubhzEFnTIZd+50xx+7LSYK05qAvqFyFWhfFQDlnrzuBZ6brJFe+GnY+EgPbk6ZGQ +3BebYhtF8GaV0nxvwuo77x/Py9auJ/GpsMiu/X1+mvoiBOv/2X/qkSsisRcOj/KK +NFtY2PwByVS5uCbMiogziUwthDyC3+6WVwW6LLv3xLfHTjuCvjHIInNzktHCgKQ5 +ORAzI4JMPJ+GslWYHb4phowim57iaztXOoJwTdwJx4nLCgdNbOhdjsnvzqvHu7Ur +TkXWStAmzOVyyghqpZXjFaH3pO3JLF+l+/+sKAIuvtd7u+Nxe5AW0wdeRlN8NwdC +jNPElpzVmbUq4JUagEiuTDkHzsxHpFKVK7q4+63SM1N95R1NbdWhscdCb+ZAJzVc +oyi3B43njTOQ5yOf+1CceWxG1bQVs5ZufpsMljq4Ui0/1lvh+wjChP4kqKOJ2qxq +4RgqsahDYVvTH9w7jXbyLeiNdd8XM2w9U/t7y0Ff/9yi0GE44Za4rF2LN9d11TPA +mRGunUHBcnWEvgJBQl9nJEiU0Zsnvgc/ubhPgXRR4Xq37Z0j4r7g1SgEEzwxA57d +emyPxgcYxn/eR44/KJ4EBs+lVDR3veyJm+kXQ99b21/+jh5Xos1AnX5iItreGCc= +-----END CERTIFICATE----- diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..b7716010 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/c:\\Users\\Khanh Nguyen\\AppData\\Local\\Temp\\8885d465-fe98-47c8-8908-4666859b3524_TechWriteRP.zip.524\\main.aux": true, + "**/c:\\Users\\Khanh Nguyen\\AppData\\Local\\Temp\\8885d465-fe98-47c8-8908-4666859b3524_TechWriteRP.zip.524\\main.log": true, + "**/c:\\Users\\Khanh Nguyen\\AppData\\Local\\Temp\\8885d465-fe98-47c8-8908-4666859b3524_TechWriteRP.zip.524\\main.gz": true, + "**/c:\\Users\\Khanh Nguyen\\AppData\\Local\\Temp\\8885d465-fe98-47c8-8908-4666859b3524_TechWriteRP.zip.524\\main.out": true + } +} \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 00000000..731267bf --- /dev/null +++ b/app.py @@ -0,0 +1,149 @@ +""" +app.py +====== +Giao diện demo (Gradio) cho hệ thống nhận dạng khuôn mặt. + +Tính năng: + - Tải ảnh lên -> phát hiện + nhận dạng khuôn mặt. + - SO SÁNH SONG SONG 3 classifier trên CÙNG embedding: Cosine / KNN / SVM. + Embedding cố định (MTCNN -> align -> ResNet50), chỉ đổi bước phân loại. + - Hiển thị ảnh có vẽ khung + bảng kết quả từng classifier. + +Chạy: + python app.py + (mở đường link http://127.0.0.1:7860 hiện ra trên terminal) + +Yêu cầu: pip install gradio scikit-learn +Gallery phải có dữ liệu trước (dùng register.py). Nên có >= 2 người để SVM chạy. +""" + +import os +import sys +import numpy as np +import cv2 +from PIL import Image + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +import gradio as gr + +from inference import FacePipeline +from gallery import Gallery +from classifiers import (CosineClassifier, KNNClassifier, + SVMClassifier, RandomForestFaceClassifier) + +# ── Tải một lần khi khởi động ──────────────────────────────────────────────── +print("Đang khởi tạo pipeline và gallery...") +PIPELINE = FacePipeline() +GALLERY = Gallery() + +# Fit cả 3 classifier trên embedding của gallery (per-image). +# Cosine không thực sự cần train nhưng vẫn gọi fit() cho đồng nhất giao diện. +CLASSIFIERS = { + "Cosine (baseline)": CosineClassifier(threshold=0.40), + "KNN (k=3)": KNNClassifier(k=3, threshold=0.40), + "SVM (RBF)": SVMClassifier(prob_threshold=0.50), + "RandomForest": RandomForestFaceClassifier(prob_threshold=0.50), +} + +_GALLERY_READY = len(GALLERY.labels) > 0 +if _GALLERY_READY: + for clf in CLASSIFIERS.values(): + clf.fit(GALLERY.embeddings, GALLERY.labels) + print(f"Đã fit {len(CLASSIFIERS)} classifier trên " + f"{GALLERY.num_people()} người / {len(GALLERY.labels)} ảnh.") +else: + print("[!] Gallery trống — hãy đăng ký người trước bằng register.py.") + + +# ── Hàm xử lý chính ────────────────────────────────────────────────────────── + +def _draw(image_bgr, box, label, ok): + color = (0, 200, 0) if ok else (0, 0, 220) + x1, y1, x2, y2 = box + cv2.rectangle(image_bgr, (x1, y1), (x2, y2), color, 2) + (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 1) + cv2.rectangle(image_bgr, (x1, y1 - th - 8), (x1 + tw + 4, y1), color, -1) + cv2.putText(image_bgr, label, (x1 + 2, y1 - 4), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 255), 1) + + +def identify(image: Image.Image): + """ + Nhận PIL image -> trả (ảnh đã vẽ, bảng so sánh). + Bảng: mỗi hàng là một khuôn mặt, các cột là kết quả từng classifier. + """ + if image is None: + return None, [["—", "Hãy tải ảnh lên", "", ""]] + if not _GALLERY_READY: + return image, [["—", "Gallery trống — đăng ký người trước", "", ""]] + + faces = PIPELINE.run(image) + if not faces: + return image, [["—", "Không phát hiện khuôn mặt", "", ""]] + + # Vẽ khung dựa trên classifier baseline (cosine) để ảnh trực quan; + # bảng bên dưới hiển thị đủ cả 3. + img_bgr = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR) + + rows = [] + clf_names = list(CLASSIFIERS.keys()) + for fi, face in enumerate(faces): + emb = face["embedding"] + results = {} + for cname, clf in CLASSIFIERS.items(): + name, score = clf.predict(emb) + results[cname] = (name, score) + + # Dòng bảng: [Khuôn mặt, Cosine, KNN, SVM] + row = [f"#{fi} (conf {face['confidence']:.2f})"] + for cname in clf_names: + name, score = results[cname] + row.append(f"{name} ({score:.2f})") + rows.append(row) + + # Vẽ theo cosine + cos_name, cos_score = results[clf_names[0]] + _draw(img_bgr, face["box"], f"{cos_name} ({cos_score:.2f})", + ok=(cos_name != "Unknown")) + + out_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) + return Image.fromarray(out_rgb), rows + + +# ── Giao diện ──────────────────────────────────────────────────────────────── + +HEADERS = ["Khuôn mặt"] + list(CLASSIFIERS.keys()) + +with gr.Blocks(title="Nhận dạng khuôn mặt — So sánh classifier") as demo: + gr.Markdown( + "## Nhận dạng khuôn mặt — So sánh Cosine / KNN / SVM\n" + "Cùng một embedding (MTCNN → align → ResNet50), chỉ thay bước phân " + "loại cuối. Tải ảnh lên để xem ba classifier quyết định thế nào trên " + "cùng khuôn mặt." + ) + if not _GALLERY_READY: + gr.Markdown( + "> ⚠️ **Gallery đang trống.** Hãy đăng ký người trước:\n" + "> `python register.py --name \"Tên\" --images a1.jpg a2.jpg ...`" + ) + + with gr.Row(): + with gr.Column(): + inp = gr.Image(type="pil", label="Ảnh đầu vào") + btn = gr.Button("Nhận dạng", variant="primary") + with gr.Column(): + out_img = gr.Image(type="pil", label="Kết quả (khung theo Cosine)") + + out_table = gr.Dataframe( + headers=HEADERS, + label="So sánh từng classifier (tên kèm điểm tin cậy)", + wrap=True, + ) + + btn.click(identify, inputs=inp, outputs=[out_img, out_table]) + inp.change(identify, inputs=inp, outputs=[out_img, out_table]) + + +if __name__ == "__main__": + demo.launch() diff --git a/classifiers.py b/classifiers.py new file mode 100644 index 00000000..d31743b0 --- /dev/null +++ b/classifiers.py @@ -0,0 +1,278 @@ +""" +classifiers.py +============== +Các bộ phân loại (classifier) dùng CHUNG embedding 512-D từ pipeline. + +Ý tưởng so sánh: + - Embedding được giữ CỐ ĐỊNH (MTCNN -> align -> ResNet50 -> 512-D). + - Chỉ thay đổi bước phân loại cuối cùng -> cô lập đúng một biến số. + +Ba classifier: + 1. CosineClassifier — so khớp với embedding TRUNG BÌNH mỗi người (baseline, + không cần "train", có ngưỡng -> trả về 'Unknown'). + 2. KNNClassifier — k láng giềng gần nhất theo cosine distance, bỏ phiếu. + 3. SVMClassifier — SVM one-vs-rest (RBF) trên toàn bộ embedding mỗi ảnh. + +Giao diện chung: + clf.fit(embeddings, labels) # embeddings (N,512), labels list[str] dài N + name, score = clf.predict(emb) # emb (512,) đã L2-normalize +""" + +from __future__ import annotations +import os +import sys +import numpy as np + +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +try: + from config import EMBEDDING_DIM +except Exception: + EMBEDDING_DIM = 2048 + + +class BaseClassifier: + """Giao diện chung cho mọi classifier.""" + + name = "base" + + def fit(self, embeddings: np.ndarray, labels: list): + raise NotImplementedError + + def predict(self, embedding: np.ndarray): + """Trả về (name: str, score: float). score càng cao càng tự tin.""" + raise NotImplementedError + + +# --------------------------------------------------------------------------- +# 1. Cosine + threshold (baseline — giữ nguyên hành vi gallery cũ) +# --------------------------------------------------------------------------- + +class CosineClassifier(BaseClassifier): + """ + So khớp embedding truy vấn với embedding TRUNG BÌNH của mỗi người. + + Đây chính là logic cũ trong Gallery.find(): gom K ảnh của một người thành + một vector trung bình (đã normalize) rồi lấy cosine lớn nhất. Vì vậy mở + rộng gallery để lưu toàn bộ embedding KHÔNG làm đổi kết quả cosine — ta + chỉ tính lại mean ở đây. + """ + + name = "cosine" + + def __init__(self, threshold: float = 0.40): + self.threshold = threshold + self.person_names: list[str] = [] + self.person_means: np.ndarray = np.empty((0, EMBEDDING_DIM), dtype=np.float32) + + def fit(self, embeddings: np.ndarray, labels: list): + self.person_names = [] + means = [] + for name in sorted(set(labels)): + rows = embeddings[[i for i, l in enumerate(labels) if l == name]] + mean = rows.mean(axis=0) + n = np.linalg.norm(mean) + if n > 0: + mean = mean / n + self.person_names.append(name) + means.append(mean) + self.person_means = (np.stack(means).astype(np.float32) + if means else np.empty((0, EMBEDDING_DIM), dtype=np.float32)) + return self + + def predict(self, embedding: np.ndarray): + if len(self.person_names) == 0: + return "Unknown", 0.0 + sims = self.person_means @ embedding # (P,) — đều đã normalize + best = int(np.argmax(sims)) + best_sim = float(sims[best]) + if best_sim >= self.threshold: + return self.person_names[best], best_sim + return "Unknown", best_sim + + +# --------------------------------------------------------------------------- +# 2. KNN (k láng giềng gần nhất theo cosine) +# --------------------------------------------------------------------------- + +class KNNClassifier(BaseClassifier): + """ + Bỏ phiếu theo k embedding gần nhất (cosine). Có ngưỡng để trả 'Unknown': + nếu cosine của láng giềng gần nhất < threshold -> Unknown. + + Lưu ý: KNN cần NHIỀU embedding mỗi người mới có ý nghĩa -> đây là lý do + ta mở rộng gallery để lưu toàn bộ embedding theo từng ảnh. + """ + + name = "knn" + + def __init__(self, k: int = 3, threshold: float = 0.40): + self.k = k + self.threshold = threshold + self.embeddings = np.empty((0, EMBEDDING_DIM), dtype=np.float32) + self.labels: list[str] = [] + + def fit(self, embeddings: np.ndarray, labels: list): + self.embeddings = embeddings.astype(np.float32) + self.labels = list(labels) + return self + + def predict(self, embedding: np.ndarray): + if len(self.labels) == 0: + return "Unknown", 0.0 + sims = self.embeddings @ embedding # cosine (đã normalize) + k = min(self.k, len(self.labels)) + top = np.argsort(sims)[::-1][:k] + top_sims = sims[top] + top_labels = [self.labels[i] for i in top] + + if float(top_sims[0]) < self.threshold: + return "Unknown", float(top_sims[0]) + + # Bỏ phiếu, hoà thì ưu tiên láng giềng gần hơn (tổng similarity) + scores: dict[str, float] = {} + for lab, s in zip(top_labels, top_sims): + scores[lab] = scores.get(lab, 0.0) + float(s) + winner = max(scores, key=scores.get) + # score báo cáo: cosine cao nhất của lớp thắng + win_sim = max(float(s) for lab, s in zip(top_labels, top_sims) if lab == winner) + return winner, win_sim + + +# --------------------------------------------------------------------------- +# 3. SVM (one-vs-rest, RBF) +# --------------------------------------------------------------------------- + +class SVMClassifier(BaseClassifier): + """ + SVM one-vs-rest trên embedding. Thường chính xác nhất trên dữ liệu sạch. + + ĐIỂM CẦN BIẾT KHI BẢO VỆ: SVM thuần KHÔNG có lớp 'Unknown' tự nhiên — nó + luôn gán vào một người đã biết. Ở đây ta dùng xác suất (predict_proba) và + đặt ngưỡng prob_threshold để từ chối (trả 'Unknown') khi độ tự tin thấp. + + Cần >= 2 người để fit. SVM/KNN phải fit LẠI mỗi khi gallery đổi (đăng ký + người mới) — khác với cosine vốn không cần train. + """ + + name = "svm" + + def __init__(self, prob_threshold: float = 0.50, C: float = 10.0, + kernel: str = "rbf"): + self.prob_threshold = prob_threshold + self.C = C + self.kernel = kernel + self.clf = None + self.classes_: list[str] = [] + self._single_label = None # trường hợp chỉ có 1 người + + def fit(self, embeddings: np.ndarray, labels: list): + from sklearn.svm import SVC + + uniq = sorted(set(labels)) + if len(uniq) < 2: + # SVM không fit được với 1 lớp — ghi nhớ để predict trả về thẳng + self._single_label = uniq[0] if uniq else None + self.clf = None + self.classes_ = uniq + return self + + self._single_label = None + # KHÔNG dùng probability=True: nó chạy cross-validation nội bộ -> rất chậm + # và có thể treo trên Windows. Thay vào đó dùng decision_function để lấy + # điểm tin cậy (margin) cho việc từ chối 'Unknown'. + self.clf = SVC(C=self.C, kernel=self.kernel, gamma="scale", + decision_function_shape="ovr") + self.clf.fit(embeddings.astype(np.float32), labels) + self.classes_ = list(self.clf.classes_) + return self + + def predict(self, embedding: np.ndarray): + if self._single_label is not None: + return self._single_label, 1.0 + if self.clf is None: + return "Unknown", 0.0 + emb = embedding.reshape(1, -1).astype(np.float32) + # decision_function trả về điểm (margin) cho mỗi lớp theo kiểu ovr. + scores = self.clf.decision_function(emb)[0] + best = int(np.argmax(scores)) + best_score = float(scores[best]) + # Chuẩn hoá margin về [0,1] bằng sigmoid để so với prob_threshold. + conf = 1.0 / (1.0 + np.exp(-best_score)) + if conf < self.prob_threshold: + return "Unknown", conf + return str(self.classes_[best]), conf + + +# --------------------------------------------------------------------------- +# 4. Random Forest (L4 — bagging cây quyết định) +# --------------------------------------------------------------------------- + +class RandomForestFaceClassifier(BaseClassifier): + """ + Random Forest trên embedding (đúng nội dung L4 của môn học). + + Giống SVM: cần >= 2 người để fit, phải fit lại khi gallery đổi, và không + có 'Unknown' tự nhiên -> dùng xác suất (predict_proba) + ngưỡng để từ chối. + """ + + name = "rf" + + def __init__(self, prob_threshold: float = 0.50, + n_estimators: int = 200, max_depth=30, n_jobs: int = 2): + self.prob_threshold = prob_threshold + self.n_estimators = n_estimators + self.max_depth = max_depth + self.n_jobs = n_jobs + self.clf = None + self.classes_: list[str] = [] + self._single_label = None + + def fit(self, embeddings: np.ndarray, labels: list): + from sklearn.ensemble import RandomForestClassifier + + uniq = sorted(set(labels)) + if len(uniq) < 2: + self._single_label = uniq[0] if uniq else None + self.clf = None + self.classes_ = uniq + return self + + self._single_label = None + self.clf = RandomForestClassifier( + n_estimators=self.n_estimators, + max_depth=self.max_depth, + n_jobs=self.n_jobs, + random_state=42, + ) + self.clf.fit(embeddings.astype(np.float32), labels) + self.classes_ = list(self.clf.classes_) + return self + + def predict(self, embedding: np.ndarray): + if self._single_label is not None: + return self._single_label, 1.0 + if self.clf is None: + return "Unknown", 0.0 + probs = self.clf.predict_proba(embedding.reshape(1, -1).astype(np.float32))[0] + best = int(np.argmax(probs)) + best_prob = float(probs[best]) + if best_prob < self.prob_threshold: + return "Unknown", best_prob + return str(self.classes_[best]), best_prob + + +# --------------------------------------------------------------------------- +# Factory tiện dụng cho UI +# --------------------------------------------------------------------------- + +def build_classifier(kind: str, **kwargs) -> BaseClassifier: + kind = kind.lower() + if kind == "cosine": + return CosineClassifier(**kwargs) + if kind == "knn": + return KNNClassifier(**kwargs) + if kind == "svm": + return SVMClassifier(**kwargs) + if kind == "rf": + return RandomForestFaceClassifier(**kwargs) + raise ValueError(f"Unknown classifier: {kind}") diff --git a/config.py b/config.py index fedd5eed..c093c5fc 100644 --- a/config.py +++ b/config.py @@ -5,20 +5,24 @@ DATASET_DIR = os.path.join(BASE_DIR, 'data') MODEL_PATH = os.path.join(BASE_DIR, 'recognition', 'model', 'best_model.pth') +# Extractor mặc định là FaceNet/InceptionResnetV1 (VGGFace2) -> 512 chiều. +# (Nếu đổi sang backend 'imagenet' ResNet50 thì là 2048 — pipeline tự lấy +# đúng số chiều từ extractor, hằng số này chỉ dùng làm shape khởi tạo rỗng.) EMBEDDING_DIM = 512 -IMG_SIZE = 112 -NUM_CLASSES = 423 +IMG_SIZE = 160 +# ── KHÔNG còn dùng trong Option A (extractor đóng băng) ────────────────────── +# Các hằng số dưới đây thuộc về train.py (fine-tune ResNet + ArcFace). Giữ lại +# để train.py vẫn chạy được nếu cần tham khảo, nhưng pipeline hiện tại KHÔNG +# dùng tới chúng. +NUM_CLASSES = 423 BATCH_SIZE = 64 EPOCHS = 30 LR = 0.01 MOMENTUM = 0.9 WEIGHT_DECAY = 5e-4 - MARGIN = 0.5 SCALE = 64 - - LR_STEP_SIZE = 10 LR_GAMMA = 0.1 diff --git a/data/test/person_00/img_00.jpg b/data/test/person_00/img_00.jpg deleted file mode 100644 index 1568fe0f..00000000 Binary files a/data/test/person_00/img_00.jpg and /dev/null differ diff --git a/data/test/person_00/img_01.jpg b/data/test/person_00/img_01.jpg deleted file mode 100644 index 21442a9b..00000000 Binary files a/data/test/person_00/img_01.jpg and /dev/null differ diff --git a/data/test/person_01/img_00.jpg b/data/test/person_01/img_00.jpg deleted file mode 100644 index f8c2c547..00000000 Binary files a/data/test/person_01/img_00.jpg and /dev/null differ diff --git a/data/test/person_01/img_01.jpg b/data/test/person_01/img_01.jpg deleted file mode 100644 index fe6a7610..00000000 Binary files a/data/test/person_01/img_01.jpg and /dev/null differ diff --git a/data/test/person_02/img_00.jpg b/data/test/person_02/img_00.jpg deleted file mode 100644 index 6122a71e..00000000 Binary files a/data/test/person_02/img_00.jpg and /dev/null differ diff --git a/data/test/person_02/img_01.jpg b/data/test/person_02/img_01.jpg deleted file mode 100644 index 6bb8ccfd..00000000 Binary files a/data/test/person_02/img_01.jpg and /dev/null differ diff --git a/data/test/person_03/img_00.jpg b/data/test/person_03/img_00.jpg deleted file mode 100644 index 0e0610c5..00000000 Binary files a/data/test/person_03/img_00.jpg and /dev/null differ diff --git a/data/test/person_03/img_01.jpg b/data/test/person_03/img_01.jpg deleted file mode 100644 index 70a31e7b..00000000 Binary files a/data/test/person_03/img_01.jpg and /dev/null differ diff --git a/data/test/person_04/img_00.jpg b/data/test/person_04/img_00.jpg deleted file mode 100644 index 0fcc1d24..00000000 Binary files a/data/test/person_04/img_00.jpg and /dev/null differ diff --git a/data/test/person_04/img_01.jpg b/data/test/person_04/img_01.jpg deleted file mode 100644 index 71c8af3a..00000000 Binary files a/data/test/person_04/img_01.jpg and /dev/null differ diff --git a/data/train/person_00/img_00.jpg b/data/train/person_00/img_00.jpg deleted file mode 100644 index d4941f79..00000000 Binary files a/data/train/person_00/img_00.jpg and /dev/null differ diff --git a/data/train/person_00/img_01.jpg b/data/train/person_00/img_01.jpg deleted file mode 100644 index d186dd6c..00000000 Binary files a/data/train/person_00/img_01.jpg and /dev/null differ diff --git a/data/train/person_00/img_02.jpg b/data/train/person_00/img_02.jpg deleted file mode 100644 index 90047f7d..00000000 Binary files a/data/train/person_00/img_02.jpg and /dev/null differ diff --git a/data/train/person_00/img_03.jpg b/data/train/person_00/img_03.jpg deleted file mode 100644 index 6e7f0ad2..00000000 Binary files a/data/train/person_00/img_03.jpg and /dev/null differ diff --git a/data/train/person_00/img_04.jpg b/data/train/person_00/img_04.jpg deleted file mode 100644 index 360bd3a0..00000000 Binary files a/data/train/person_00/img_04.jpg and /dev/null differ diff --git a/data/train/person_00/img_05.jpg b/data/train/person_00/img_05.jpg deleted file mode 100644 index 2738b0c9..00000000 Binary files a/data/train/person_00/img_05.jpg and /dev/null differ diff --git a/data/train/person_00/img_06.jpg b/data/train/person_00/img_06.jpg deleted file mode 100644 index f1100aec..00000000 Binary files a/data/train/person_00/img_06.jpg and /dev/null differ diff --git a/data/train/person_00/img_07.jpg b/data/train/person_00/img_07.jpg deleted file mode 100644 index 272ebd0e..00000000 Binary files a/data/train/person_00/img_07.jpg and /dev/null differ diff --git a/data/train/person_01/img_00.jpg b/data/train/person_01/img_00.jpg deleted file mode 100644 index 36642518..00000000 Binary files a/data/train/person_01/img_00.jpg and /dev/null differ diff --git a/data/train/person_01/img_01.jpg b/data/train/person_01/img_01.jpg deleted file mode 100644 index dba16c09..00000000 Binary files a/data/train/person_01/img_01.jpg and /dev/null differ diff --git a/data/train/person_01/img_02.jpg b/data/train/person_01/img_02.jpg deleted file mode 100644 index 1afcdf44..00000000 Binary files a/data/train/person_01/img_02.jpg and /dev/null differ diff --git a/data/train/person_01/img_03.jpg b/data/train/person_01/img_03.jpg deleted file mode 100644 index 6f27e9ae..00000000 Binary files a/data/train/person_01/img_03.jpg and /dev/null differ diff --git a/data/train/person_01/img_04.jpg b/data/train/person_01/img_04.jpg deleted file mode 100644 index 884807ae..00000000 Binary files a/data/train/person_01/img_04.jpg and /dev/null differ diff --git a/data/train/person_01/img_05.jpg b/data/train/person_01/img_05.jpg deleted file mode 100644 index 4510a32d..00000000 Binary files a/data/train/person_01/img_05.jpg and /dev/null differ diff --git a/data/train/person_01/img_06.jpg b/data/train/person_01/img_06.jpg deleted file mode 100644 index 2b5f73f8..00000000 Binary files a/data/train/person_01/img_06.jpg and /dev/null differ diff --git a/data/train/person_01/img_07.jpg b/data/train/person_01/img_07.jpg deleted file mode 100644 index c560be11..00000000 Binary files a/data/train/person_01/img_07.jpg and /dev/null differ diff --git a/data/train/person_02/img_00.jpg b/data/train/person_02/img_00.jpg deleted file mode 100644 index ad2773a9..00000000 Binary files a/data/train/person_02/img_00.jpg and /dev/null differ diff --git a/data/train/person_02/img_01.jpg b/data/train/person_02/img_01.jpg deleted file mode 100644 index 08c8d0aa..00000000 Binary files a/data/train/person_02/img_01.jpg and /dev/null differ diff --git a/data/train/person_02/img_02.jpg b/data/train/person_02/img_02.jpg deleted file mode 100644 index d6b5fdd5..00000000 Binary files a/data/train/person_02/img_02.jpg and /dev/null differ diff --git a/data/train/person_02/img_03.jpg b/data/train/person_02/img_03.jpg deleted file mode 100644 index 198ce66e..00000000 Binary files a/data/train/person_02/img_03.jpg and /dev/null differ diff --git a/data/train/person_02/img_04.jpg b/data/train/person_02/img_04.jpg deleted file mode 100644 index 841b73af..00000000 Binary files a/data/train/person_02/img_04.jpg and /dev/null differ diff --git a/data/train/person_02/img_05.jpg b/data/train/person_02/img_05.jpg deleted file mode 100644 index 990c31ca..00000000 Binary files a/data/train/person_02/img_05.jpg and /dev/null differ diff --git a/data/train/person_02/img_06.jpg b/data/train/person_02/img_06.jpg deleted file mode 100644 index ccbf060f..00000000 Binary files a/data/train/person_02/img_06.jpg and /dev/null differ diff --git a/data/train/person_02/img_07.jpg b/data/train/person_02/img_07.jpg deleted file mode 100644 index 3d781864..00000000 Binary files a/data/train/person_02/img_07.jpg and /dev/null differ diff --git a/data/train/person_03/img_00.jpg b/data/train/person_03/img_00.jpg deleted file mode 100644 index 7f37206e..00000000 Binary files a/data/train/person_03/img_00.jpg and /dev/null differ diff --git a/data/train/person_03/img_01.jpg b/data/train/person_03/img_01.jpg deleted file mode 100644 index 029451b8..00000000 Binary files a/data/train/person_03/img_01.jpg and /dev/null differ diff --git a/data/train/person_03/img_02.jpg b/data/train/person_03/img_02.jpg deleted file mode 100644 index b5a36429..00000000 Binary files a/data/train/person_03/img_02.jpg and /dev/null differ diff --git a/data/train/person_03/img_03.jpg b/data/train/person_03/img_03.jpg deleted file mode 100644 index 75398c9e..00000000 Binary files a/data/train/person_03/img_03.jpg and /dev/null differ diff --git a/data/train/person_03/img_04.jpg b/data/train/person_03/img_04.jpg deleted file mode 100644 index 5df00631..00000000 Binary files a/data/train/person_03/img_04.jpg and /dev/null differ diff --git a/data/train/person_03/img_05.jpg b/data/train/person_03/img_05.jpg deleted file mode 100644 index a454fe88..00000000 Binary files a/data/train/person_03/img_05.jpg and /dev/null differ diff --git a/data/train/person_03/img_06.jpg b/data/train/person_03/img_06.jpg deleted file mode 100644 index 9bef7a78..00000000 Binary files a/data/train/person_03/img_06.jpg and /dev/null differ diff --git a/data/train/person_03/img_07.jpg b/data/train/person_03/img_07.jpg deleted file mode 100644 index a836f525..00000000 Binary files a/data/train/person_03/img_07.jpg and /dev/null differ diff --git a/data/train/person_04/img_00.jpg b/data/train/person_04/img_00.jpg deleted file mode 100644 index d366084c..00000000 Binary files a/data/train/person_04/img_00.jpg and /dev/null differ diff --git a/data/train/person_04/img_01.jpg b/data/train/person_04/img_01.jpg deleted file mode 100644 index 34405afe..00000000 Binary files a/data/train/person_04/img_01.jpg and /dev/null differ diff --git a/data/train/person_04/img_02.jpg b/data/train/person_04/img_02.jpg deleted file mode 100644 index ab3afda8..00000000 Binary files a/data/train/person_04/img_02.jpg and /dev/null differ diff --git a/data/train/person_04/img_03.jpg b/data/train/person_04/img_03.jpg deleted file mode 100644 index d73ed4f7..00000000 Binary files a/data/train/person_04/img_03.jpg and /dev/null differ diff --git a/data/train/person_04/img_04.jpg b/data/train/person_04/img_04.jpg deleted file mode 100644 index 316e73cb..00000000 Binary files a/data/train/person_04/img_04.jpg and /dev/null differ diff --git a/data/train/person_04/img_05.jpg b/data/train/person_04/img_05.jpg deleted file mode 100644 index 45a8d272..00000000 Binary files a/data/train/person_04/img_05.jpg and /dev/null differ diff --git a/data/train/person_04/img_06.jpg b/data/train/person_04/img_06.jpg deleted file mode 100644 index ba756624..00000000 Binary files a/data/train/person_04/img_06.jpg and /dev/null differ diff --git a/data/train/person_04/img_07.jpg b/data/train/person_04/img_07.jpg deleted file mode 100644 index 99f7649e..00000000 Binary files a/data/train/person_04/img_07.jpg and /dev/null differ diff --git a/data/val/person_00/img_00.jpg b/data/val/person_00/img_00.jpg deleted file mode 100644 index a3543eb1..00000000 Binary files a/data/val/person_00/img_00.jpg and /dev/null differ diff --git a/data/val/person_00/img_01.jpg b/data/val/person_00/img_01.jpg deleted file mode 100644 index ac10ee28..00000000 Binary files a/data/val/person_00/img_01.jpg and /dev/null differ diff --git a/data/val/person_01/img_00.jpg b/data/val/person_01/img_00.jpg deleted file mode 100644 index bf7631bd..00000000 Binary files a/data/val/person_01/img_00.jpg and /dev/null differ diff --git a/data/val/person_01/img_01.jpg b/data/val/person_01/img_01.jpg deleted file mode 100644 index 01e0e27e..00000000 Binary files a/data/val/person_01/img_01.jpg and /dev/null differ diff --git a/data/val/person_02/img_00.jpg b/data/val/person_02/img_00.jpg deleted file mode 100644 index 687832d0..00000000 Binary files a/data/val/person_02/img_00.jpg and /dev/null differ diff --git a/data/val/person_02/img_01.jpg b/data/val/person_02/img_01.jpg deleted file mode 100644 index e8361fe1..00000000 Binary files a/data/val/person_02/img_01.jpg and /dev/null differ diff --git a/data/val/person_03/img_00.jpg b/data/val/person_03/img_00.jpg deleted file mode 100644 index 9adba937..00000000 Binary files a/data/val/person_03/img_00.jpg and /dev/null differ diff --git a/data/val/person_03/img_01.jpg b/data/val/person_03/img_01.jpg deleted file mode 100644 index c20fc0f9..00000000 Binary files a/data/val/person_03/img_01.jpg and /dev/null differ diff --git a/data/val/person_04/img_00.jpg b/data/val/person_04/img_00.jpg deleted file mode 100644 index a42937bd..00000000 Binary files a/data/val/person_04/img_00.jpg and /dev/null differ diff --git a/data/val/person_04/img_01.jpg b/data/val/person_04/img_01.jpg deleted file mode 100644 index e4318225..00000000 Binary files a/data/val/person_04/img_01.jpg and /dev/null differ diff --git a/gallery.py b/gallery.py index 103f95d2..7a70496d 100644 --- a/gallery.py +++ b/gallery.py @@ -1,128 +1,171 @@ """ gallery.py ========== -Quản lý database embedding của những người đã đăng ký. +Database embedding của những người đã đăng ký (lưu theo từng ảnh). -Lưu dưới dạng file .npz: +THAY ĐỔI so với bản cũ: + - Trước: lưu MỘT embedding trung bình mỗi người -> đủ cho cosine, nhưng + KNN/SVM/RF không có ý nghĩa (chỉ 1 mẫu/lớp). + - Giờ : lưu TOÀN BỘ embedding theo từng ảnh (embeddings (N,D) + labels (N,)). + Cosine vẫn chạy trên embedding TRUNG BÌNH (tính từ các ảnh) nên kết quả + cosine KHÔNG đổi so với trước. KNN/SVM/RF dùng được dữ liệu đầy đủ. + (D = EMBEDDING_DIM = 2048 với ResNet50 đóng băng.) + +Định dạng .npz mới: { - 'names' : list[str] — tên từng người - 'embeddings' : np.ndarray (N, 512) — embedding trung bình mỗi người + 'embeddings' : np.ndarray (N, D) — một dòng mỗi ẢNH + 'labels' : np.ndarray (N,) str — tên người tương ứng mỗi dòng } + +Tự động đọc được file cũ (chứa 'names' + 'embeddings' dạng 1 dòng/người). """ import os +import sys import numpy as np +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +try: + from config import EMBEDDING_DIM +except Exception: + EMBEDDING_DIM = 2048 + GALLERY_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), - 'recognition', 'model', 'gallery.npz') + 'recognition', 'model', 'gallery.npz') class Gallery: """ - Database lưu embedding của người đã đăng ký. + Lưu embedding theo từng ảnh. Attributes ---------- - names : list[str] - embeddings : np.ndarray (N, 512) + embeddings : np.ndarray (N, 512) — một dòng mỗi ảnh (đã L2-normalize) + labels : list[str] — tên người của từng dòng """ def __init__(self, gallery_path: str = GALLERY_PATH): self.gallery_path = gallery_path - self.names = [] - self.embeddings = np.empty((0, 512), dtype=np.float32) + self.embeddings = np.empty((0, EMBEDDING_DIM), dtype=np.float32) + self.labels: list[str] = [] + self._mean_cache: dict[str, np.ndarray] = {} self._load() # ── Public API ──────────────────────────────────────────────────────────── def register(self, name: str, embeddings: np.ndarray): """ - Đăng ký một người mới hoặc cập nhật nếu đã tồn tại. + Đăng ký/cập nhật một người từ K embedding (K ảnh). - Parameters - ---------- - name : str - embeddings : np.ndarray (K, 512) — K ảnh của người đó + Lưu TẤT CẢ K embedding (không gộp trung bình khi lưu) để KNN/SVM dùng. + Nếu người đã tồn tại -> thay thế toàn bộ embedding cũ của họ. """ - # Tính embedding trung bình rồi normalize lại - mean_emb = embeddings.mean(axis=0) - norm = np.linalg.norm(mean_emb) - if norm > 0: - mean_emb /= norm - - if name in self.names: - idx = self.names.index(name) - self.embeddings[idx] = mean_emb + embeddings = np.atleast_2d(embeddings.astype(np.float32)) + # L2-normalize từng dòng cho chắc + norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + norms[norms == 0] = 1.0 + embeddings = embeddings / norms + + if name in self.labels: + keep = [i for i, l in enumerate(self.labels) if l != name] + self.embeddings = self.embeddings[keep] + self.labels = [self.labels[i] for i in keep] print(f"[Gallery] Cập nhật: '{name}'") else: - self.names.append(name) - self.embeddings = np.vstack([self.embeddings, mean_emb[np.newaxis]]) - print(f"[Gallery] Đăng ký mới: '{name}' (tổng: {len(self.names)} người)") + print(f"[Gallery] Đăng ký mới: '{name}'") + self.embeddings = np.vstack([self.embeddings, embeddings]) + self.labels.extend([name] * len(embeddings)) + self._rebuild_means() self._save() + print(f"[Gallery] Tổng: {self.num_people()} người, {len(self.labels)} ảnh.") def remove(self, name: str): - """Xoá một người khỏi gallery.""" - if name not in self.names: + if name not in self.labels: print(f"[Gallery] Không tìm thấy: '{name}'") return - idx = self.names.index(name) - self.names.pop(idx) - self.embeddings = np.delete(self.embeddings, idx, axis=0) + keep = [i for i, l in enumerate(self.labels) if l != name] + self.embeddings = self.embeddings[keep] + self.labels = [self.labels[i] for i in keep] + self._rebuild_means() self._save() print(f"[Gallery] Đã xoá: '{name}'") - def find(self, embedding: np.ndarray, threshold: float = 0.4): + def find(self, embedding: np.ndarray, threshold: float = 0.40): """ - Tìm người giống nhất với embedding đầu vào. - - Parameters - ---------- - embedding : np.ndarray (512,) — L2 normalized - threshold : float — Ngưỡng tối thiểu để chấp nhận + Cosine baseline — GIỮ NGUYÊN hành vi cũ: so khớp với embedding TRUNG + BÌNH của mỗi người (không phải từng ảnh). Returns ------- - name : str — Tên người giống nhất, hoặc 'Unknown' - similarity : float — Cosine similarity [-1, 1] + name : str, similarity : float """ - if len(self.names) == 0: + if not self._mean_cache: return 'Unknown', 0.0 - - # Cosine similarity với tất cả (embeddings đã normalized) - sims = self.embeddings @ embedding # (N,) - best_idx = int(np.argmax(sims)) - best_sim = float(sims[best_idx]) - + names = list(self._mean_cache.keys()) + means = np.stack([self._mean_cache[n] for n in names]) # (P, 512) + sims = means @ embedding + best = int(np.argmax(sims)) + best_sim = float(sims[best]) if best_sim >= threshold: - return self.names[best_idx], best_sim + return names[best], best_sim return 'Unknown', best_sim + def people(self) -> list[str]: + """Danh sách tên người (duy nhất, đã sắp xếp).""" + return sorted(set(self.labels)) + + def num_people(self) -> int: + return len(set(self.labels)) + def list_people(self): - """In danh sách người đã đăng ký.""" - if not self.names: + ppl = self.people() + if not ppl: print("[Gallery] Chưa có ai được đăng ký.") return - print(f"[Gallery] {len(self.names)} người đã đăng ký:") - for i, name in enumerate(self.names): - print(f" [{i}] {name}") + print(f"[Gallery] {len(ppl)} người đã đăng ký:") + for i, name in enumerate(ppl): + cnt = self.labels.count(name) + print(f" [{i}] {name} ({cnt} ảnh)") def __len__(self): - return len(self.names) + return self.num_people() # ── Internal ────────────────────────────────────────────────────────────── + def _rebuild_means(self): + self._mean_cache = {} + for name in set(self.labels): + rows = self.embeddings[[i for i, l in enumerate(self.labels) if l == name]] + mean = rows.mean(axis=0) + n = np.linalg.norm(mean) + if n > 0: + mean = mean / n + self._mean_cache[name] = mean.astype(np.float32) + def _save(self): os.makedirs(os.path.dirname(self.gallery_path), exist_ok=True) np.savez(self.gallery_path, - names=np.array(self.names), - embeddings=self.embeddings) + embeddings=self.embeddings, + labels=np.array(self.labels)) def _load(self): if not os.path.isfile(self.gallery_path): return data = np.load(self.gallery_path, allow_pickle=True) - self.names = list(data['names']) - self.embeddings = data['embeddings'].astype(np.float32) - print(f"[Gallery] Đã load {len(self.names)} người từ gallery.") + + if 'labels' in data: + # Định dạng mới (1 dòng/ảnh) + self.embeddings = data['embeddings'].astype(np.float32) + self.labels = [str(l) for l in data['labels']] + elif 'names' in data: + # Định dạng cũ (1 dòng/người) — mỗi người coi như có 1 "ảnh" + names = [str(n) for n in data['names']] + self.embeddings = data['embeddings'].astype(np.float32) + self.labels = names + print("[Gallery] Đã đọc định dạng cũ và chuyển sang định dạng mới.") + + self._rebuild_means() + print(f"[Gallery] Đã load {self.num_people()} người " + f"({len(self.labels)} ảnh) từ gallery.") diff --git a/inference.py b/inference.py index afcf2bf9..5c540905 100644 --- a/inference.py +++ b/inference.py @@ -1,14 +1,20 @@ """ -Inference Pipeline -================== -Kết hợp cả 3 module: - Detection → MTCNN pretrained (facenet-pytorch) - Preprocessing → tự viết (align, resize, normalize) - Recognition → FaceEmbeddingModel (ResNet50 + ArcFace, tự viết) +Inference Pipeline (Option A — frozen pretrained extractor) +=========================================================== +Kết hợp 3 module: + Detection → MTCNN pretrained (facenet-pytorch) — KHÔNG train + Preprocessing → tự viết (align, resize, normalize) — code của nhóm + Embedding → ResNet50 pretrained ImageNet, ĐÓNG BĂNG — KHÔNG train + +Khác với bản cũ: KHÔNG dùng FaceEmbeddingModel + ArcFace + best_model.pth. +ResNet50 ở đây chỉ là CÔNG CỤ trích đặc trưng (feature extractor) đã được +huấn luyện sẵn trên ImageNet; ta bỏ lớp phân loại cuối (fc) để lấy vector +đặc trưng 2048 chiều. Toàn bộ "học" của đồ án nằm ở bước phân loại +(KNN/SVM/RandomForest/Cosine) — xem recognition/extract_and_compare.py. Ví dụ sử dụng: - pipeline = FacePipeline(model_path='recognition/model/best_model.pth') - result = pipeline.run('path/to/image.jpg') + pipeline = FacePipeline() + faces = pipeline.run('path/to/image.jpg') embeddings = pipeline.get_embeddings('path/to/image.jpg') """ @@ -16,34 +22,70 @@ import sys import numpy as np import torch +import torch.nn as nn import torch.nn.functional as F from PIL import Image +from torchvision import models sys.path.append(os.path.dirname(os.path.abspath(__file__))) -from config import MODEL_PATH, DEVICE, IMG_SIZE, EMBEDDING_DIM +from config import DEVICE, IMG_SIZE, EMBEDDING_DIM from detection.detector import FaceDetector from preprocessing.preprocess import preprocess_face -from recognition.train import FaceEmbeddingModel + + +def build_feature_extractor(device: str, backend: str = "facenet"): + """ + Tạo feature extractor ĐÓNG BĂNG (frozen). Trả về (model, input_size, dim). + + backend = "facenet" (MẶC ĐỊNH, khuyến nghị): + InceptionResnetV1 pretrained trên VGGFace2 (3.31 triệu ảnh, 9131 người). + Đây là extractor CHUYÊN cho khuôn mặt -> embedding tách biệt danh tính + rất tốt. Output 512-D. Ảnh đầu vào 160x160. + => Vẫn chỉ là CÔNG CỤ pretrained (không tự train); phần "học" của đồ án + vẫn nằm ở KNN/SVM/RF. + + backend = "imagenet": + ResNet50 pretrained ImageNet, bỏ lớp fc -> 2048-D, ảnh 112/224. + Đặc trưng tổng quát (không chuyên khuôn mặt) -> kém tách danh tính hơn. + Giữ lại để SO SÁNH "đặc trưng tổng quát vs đặc trưng khuôn mặt". + + Cả hai đều eval() + requires_grad=False -> không cập nhật trọng số. + """ + backend = backend.lower() + if backend == "facenet": + from facenet_pytorch import InceptionResnetV1 + net = InceptionResnetV1(pretrained="vggface2") + for p in net.parameters(): + p.requires_grad = False + net.eval().to(device) + return net, 160, 512 + + if backend == "imagenet": + weights = models.ResNet50_Weights.IMAGENET1K_V2 + net = models.resnet50(weights=weights) + net.fc = nn.Identity() + for p in net.parameters(): + p.requires_grad = False + net.eval().to(device) + return net, 112, 2048 + + raise ValueError(f"Unknown extractor backend: {backend}") class FacePipeline: """ - Pipeline nhận dạng khuôn mặt end-to-end. + Pipeline nhận dạng khuôn mặt end-to-end (trích đặc trưng bằng extractor đóng băng). Parameters ---------- - model_path : str — Đường dẫn tới file .pth đã train. - device : str — 'cuda' hoặc 'cpu'. - det_threshold: float — Ngưỡng confidence tối thiểu cho detection. + device : str — 'cuda' hoặc 'cpu'. + det_threshold : float — Ngưỡng confidence tối thiểu cho detection. """ - def __init__( - self, - model_path: str = MODEL_PATH, - device: str = None, - det_threshold: float = 0.90, - ): + def __init__(self, device: str = None, det_threshold: float = 0.90, + backend: str = "facenet", **_ignored): + # **_ignored: nuốt các tham số cũ như model_path để không vỡ code gọi cũ. self.device = device or ("cuda" if torch.cuda.is_available() else "cpu") self.det_threshold = det_threshold @@ -55,21 +97,14 @@ def __init__( device=self.device, ) - # ── 2. Embedding Model ───────────────────────────────────────────── - self.model = FaceEmbeddingModel(embedding_dim=EMBEDDING_DIM).to(self.device) - - if os.path.isfile(model_path): - state = torch.load(model_path, map_location=self.device) - self.model.load_state_dict(state) - print(f"[FacePipeline] Model loaded from: {model_path}") - else: - print( - f"[FacePipeline] WARNING: model file not found at '{model_path}'.\n" - " Embeddings sẽ được tạo từ model chưa train (random weights).\n" - " Hãy train model trước bằng cách chạy: python recognition/train.py" - ) - - self.model.eval() + # ── 2. Feature extractor (pretrained, frozen) ────────────────────── + self.model, self.input_size, self.embedding_dim = build_feature_extractor( + self.device, backend=backend) + label = ("FaceNet/InceptionResnetV1 (VGGFace2, frozen)" + if backend == "facenet" + else "ResNet50 (ImageNet, frozen)") + print(f"[FacePipeline] Feature extractor: {label}. " + f"Embedding dim = {self.embedding_dim}, input = {self.input_size}px.") # ------------------------------------------------------------------ # Main API @@ -77,40 +112,26 @@ def __init__( def run(self, image, apply_equalize: bool = False): """ - Xử lý một ảnh và trả về thông tin khuôn mặt + embedding. - - Parameters - ---------- - image : str | PIL.Image | np.ndarray - apply_equalize : bool — Bật cân bằng histogram nếu ảnh có ánh sáng kém. + Xử lý một ảnh -> danh sách khuôn mặt kèm embedding. Returns ------- - faces : list[dict] - Mỗi khuôn mặt: - { - 'box' : [x1, y1, x2, y2], - 'confidence' : float, - 'landmarks' : np.ndarray (5, 2), - 'embedding' : np.ndarray (512,) — L2-normalized - } + faces : list[dict] với mỗi phần tử: + {'box','confidence','landmarks','embedding'(2048,) đã L2-normalize} """ pil_img = self._load_image(image) - # Step 1: Detect detections = self.detector.detect(pil_img) detections = [d for d in detections if d["confidence"] >= self.det_threshold] - if not detections: return [] - # Step 2: Preprocess + Embed (batch) tensors = [] for det in detections: tensor = preprocess_face( pil_img, landmarks=det.get("landmarks"), - size=IMG_SIZE, + size=self.input_size, apply_equalize=apply_equalize, ) # (1, C, H, W) tensors.append(tensor) @@ -118,46 +139,28 @@ def run(self, image, apply_equalize: bool = False): batch = torch.cat(tensors, dim=0).to(self.device) # (N, C, H, W) with torch.no_grad(): - embeddings = self.model(batch) # (N, 512) + embeddings = self.model(batch) # (N, 2048) embeddings = F.normalize(embeddings, dim=1) # L2 normalize embeddings_np = embeddings.cpu().numpy() - - # Step 3: Đính kèm embedding vào kết quả for i, det in enumerate(detections): det["embedding"] = embeddings_np[i] - return detections - def get_embeddings(self, image, apply_equalize: bool = False) -> np.ndarray: - """ - Shortcut: chỉ trả về ma trận embeddings (N, 512) cho tất cả khuôn mặt. - Trả về None nếu không tìm thấy khuôn mặt. - """ + def get_embeddings(self, image, apply_equalize: bool = False): + """Chỉ trả về ma trận embeddings (N, 2048). None nếu không có khuôn mặt.""" faces = self.run(image, apply_equalize=apply_equalize) if not faces: return None return np.stack([f["embedding"] for f in faces]) def compare(self, image_a, image_b) -> float: - """ - So sánh 2 ảnh khuôn mặt (1 khuôn mặt mỗi ảnh). - Trả về cosine similarity [-1, 1]. Giá trị càng cao → càng giống nhau. - - Ngưỡng tham khảo: > 0.4 thường được coi là cùng người. - """ + """Cosine similarity giữa khuôn mặt đầu tiên của 2 ảnh ([-1,1]).""" emb_a = self.get_embeddings(image_a) emb_b = self.get_embeddings(image_b) - if emb_a is None or emb_b is None: raise ValueError("Không phát hiện được khuôn mặt trong một hoặc cả hai ảnh.") - - # Dùng khuôn mặt đầu tiên nếu có nhiều khuôn mặt - a = emb_a[0] - b = emb_b[0] - - cosine = float(np.dot(a, b)) # Đã L2-normalize nên dot = cosine - return cosine + return float(np.dot(emb_a[0], emb_b[0])) # ------------------------------------------------------------------ # Helpers @@ -178,14 +181,8 @@ def _load_image(image) -> Image.Image: raise TypeError(f"Unsupported image type: {type(image)}") -# --------------------------------------------------------------------------- -# Demo nhanh khi chạy trực tiếp -# --------------------------------------------------------------------------- if __name__ == "__main__": - import sys - pipeline = FacePipeline() - if len(sys.argv) >= 2: img_path = sys.argv[1] faces = pipeline.run(img_path) @@ -195,4 +192,3 @@ def _load_image(image) -> Image.Image: f" embedding_norm={np.linalg.norm(f['embedding']):.4f}") else: print("Sử dụng: python inference.py <đường_dẫn_ảnh>") - print("Hoặc import FacePipeline và dùng trong code của bạn.") diff --git a/phong (1).jpg b/phong (1).jpg new file mode 100644 index 00000000..1f68b562 Binary files /dev/null and b/phong (1).jpg differ diff --git a/phong (10).jpg b/phong (10).jpg new file mode 100644 index 00000000..75847735 Binary files /dev/null and b/phong (10).jpg differ diff --git a/phong (2).jpg b/phong (2).jpg new file mode 100644 index 00000000..7d4e8e30 Binary files /dev/null and b/phong (2).jpg differ diff --git a/phong (3).jpg b/phong (3).jpg new file mode 100644 index 00000000..0da11575 Binary files /dev/null and b/phong (3).jpg differ diff --git a/phong (4).jpg b/phong (4).jpg new file mode 100644 index 00000000..7a427c82 Binary files /dev/null and b/phong (4).jpg differ diff --git a/phong (5).jpg b/phong (5).jpg new file mode 100644 index 00000000..62a5c9f0 Binary files /dev/null and b/phong (5).jpg differ diff --git a/phong (6).jpg b/phong (6).jpg new file mode 100644 index 00000000..7ea7d506 Binary files /dev/null and b/phong (6).jpg differ diff --git a/phong (7).jpg b/phong (7).jpg new file mode 100644 index 00000000..60082966 Binary files /dev/null and b/phong (7).jpg differ diff --git a/phong (8).jpg b/phong (8).jpg new file mode 100644 index 00000000..27c1441e Binary files /dev/null and b/phong (8).jpg differ diff --git a/phong (9).jpg b/phong (9).jpg new file mode 100644 index 00000000..47c4a073 Binary files /dev/null and b/phong (9).jpg differ diff --git a/phong_test.jpg b/phong_test.jpg new file mode 100644 index 00000000..d2809fdc Binary files /dev/null and b/phong_test.jpg differ diff --git a/recognition/eval_only.py b/recognition/eval_only.py deleted file mode 100644 index 1bcf583a..00000000 --- a/recognition/eval_only.py +++ /dev/null @@ -1,49 +0,0 @@ -# eval_only.py — đặt cùng thư mục với train.py -import os -import sys -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - -import torch -from train import FaceEmbeddingModel, ArcFaceHead, get_dataloaders -from config import MODEL_PATH, DEVICE - -device = torch.device(DEVICE if torch.cuda.is_available() else 'cpu') -print(f"Using device: {device}") - -# Load model -model = FaceEmbeddingModel().to(device) -model.load_state_dict(torch.load(MODEL_PATH, map_location=device)) -model.eval() - -# ArcFaceHead cần labels để forward, dùng wrapper đơn giản hơn -import torch.nn.functional as F - -def predict(model, loader): - correct = 0 - total = 0 - with torch.no_grad(): - for imgs, labels in loader: - imgs, labels = imgs.to(device), labels.to(device) - embeddings = model(imgs) - # Dùng embedding trực tiếp để classify - preds = embeddings.argmax(dim=1) - correct += (preds == labels).sum().item() - total += labels.size(0) - return correct / total - -_, val_loader, test_loader = get_dataloaders() - -# Cách đúng — dùng lại hàm evaluate từ train.py -from train import evaluate, ArcFaceHead -from config import EMBEDDING_DIM, NUM_CLASSES, SCALE, MARGIN - -head = ArcFaceHead().to(device) - -# Load head nếu bạn đã save, nếu không thì dùng random head chỉ để test embedding -# Quan trọng: val_acc trong train dùng head, nên ở đây cũng phải dùng head - -print("\nKiểm tra với random head (chỉ để xem embedding quality):") -val_acc = evaluate(model, head, val_loader, device) -test_acc = evaluate(model, head, test_loader, device) -print(f"Val accuracy : {val_acc:.4f}") -print(f"Test accuracy : {test_acc:.4f}") \ No newline at end of file diff --git a/recognition/extract_and_compare.py b/recognition/extract_and_compare.py new file mode 100644 index 00000000..2e05bd46 --- /dev/null +++ b/recognition/extract_and_compare.py @@ -0,0 +1,203 @@ +""" +recognition/extract_and_compare.py +================================== +ĐÂY LÀ PHẦN ĐÓNG GÓP CHÍNH (machine learning) CỦA ĐỒ ÁN. + +Quy trình: + 1. Trích đặc trưng (embedding) MỘT LẦN cho data/train, data/val, data/test + bằng FacePipeline (MTCNN + ResNet50 đóng băng). Cache ra .npz để khỏi + chạy lại. + 2. Huấn luyện & so sánh các bộ phân loại theo đúng nội dung môn học: + - Cosine (baseline, không train) + - KNN (L3) + - SVM (L6) + - Random Forest (L4) + 3. Đánh giá CÔNG BẰNG: cùng embedding, cùng split. Báo cáo accuracy + + macro precision/recall/F1 (L9 - Model assessment). + 4. Khảo sát SIÊU THAM SỐ: k (KNN), C+kernel (SVM), n_estimators (RF). + +Cách dùng: + python recognition/extract_and_compare.py # trích đặc trưng rồi so sánh + python recognition/extract_and_compare.py --no-extract # dùng cache có sẵn + +Ghi chú phòng vệ (defense): ResNet50 chỉ là CÔNG CỤ trích đặc trưng (pretrained, +đóng băng, có trích dẫn). Việc "học" của đồ án nằm ở các bộ phân loại bên dưới — +tất cả đều thuộc chương trình môn học và nhóm tự huấn luyện + tinh chỉnh. +""" + +import os +import sys +import argparse +import warnings +import numpy as np + +# Ẩn cảnh báo FutureWarning của sklearn về probability=True (API vẫn chạy bình thường) +warnings.filterwarnings("ignore", category=FutureWarning) + +BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(BASE) + +from config import EMBEDDING_DIM +from classifiers import (CosineClassifier, KNNClassifier, + SVMClassifier, RandomForestFaceClassifier) + +DATA_DIR = os.path.join(BASE, "data") +CACHE_DIR = os.path.join(BASE, "recognition", "model") +os.makedirs(CACHE_DIR, exist_ok=True) + + +# --------------------------------------------------------------------------- +# 1. Trích đặc trưng +# --------------------------------------------------------------------------- + +def extract_split(pipeline, split_dir): + """ + Duyệt thư mục split (data/train, ...) dạng ImageFolder: + split_dir/person_name/img.jpg + Trả về (embeddings (N,D), labels list[str]). + Mỗi ảnh -> 1 embedding (lấy khuôn mặt confidence cao nhất). + """ + embs, labels = [], [] + if not os.path.isdir(split_dir): + print(f" [!] Bỏ qua (không tồn tại): {split_dir}") + return np.empty((0, EMBEDDING_DIM), np.float32), [] + + people = sorted(d for d in os.listdir(split_dir) + if os.path.isdir(os.path.join(split_dir, d))) + for person in people: + pdir = os.path.join(split_dir, person) + for fn in os.listdir(pdir): + if not fn.lower().endswith((".jpg", ".jpeg", ".png")): + continue + faces = pipeline.run(os.path.join(pdir, fn)) + if not faces: + continue + embs.append(faces[0]["embedding"]) # detect đã sort theo confidence + labels.append(person) + if embs: + return np.stack(embs).astype(np.float32), labels + return np.empty((0, EMBEDDING_DIM), np.float32), [] + + +def build_cache(force=True): + from inference import FacePipeline + pipeline = FacePipeline() + data = {} + for split in ("train", "val", "test"): + print(f"[extract] {split} ...") + X, y = extract_split(pipeline, os.path.join(DATA_DIR, split)) + data[split] = (X, y) + print(f" -> {len(y)} ảnh, {len(set(y))} người") + np.savez(os.path.join(CACHE_DIR, "embeddings_cache.npz"), + Xtr=data["train"][0], ytr=np.array(data["train"][1]), + Xva=data["val"][0], yva=np.array(data["val"][1]), + Xte=data["test"][0], yte=np.array(data["test"][1])) + return data + + +def load_cache(): + path = os.path.join(CACHE_DIR, "embeddings_cache.npz") + if not os.path.isfile(path): + return None + d = np.load(path, allow_pickle=True) + return { + "train": (d["Xtr"], [str(s) for s in d["ytr"]]), + "val": (d["Xva"], [str(s) for s in d["yva"]]), + "test": (d["Xte"], [str(s) for s in d["yte"]]), + } + + +# --------------------------------------------------------------------------- +# 2. Đánh giá +# --------------------------------------------------------------------------- + +def evaluate(clf, X, y_true): + """Trả về dict gồm accuracy + macro precision/recall/F1.""" + from sklearn.metrics import (accuracy_score, precision_score, + recall_score, f1_score) + y_pred = [clf.predict(x)[0] for x in X] + return { + "acc": accuracy_score(y_true, y_pred), + "prec": precision_score(y_true, y_pred, average="macro", zero_division=0), + "rec": recall_score(y_true, y_pred, average="macro", zero_division=0), + "f1": f1_score(y_true, y_pred, average="macro", zero_division=0), + } + + +def row(name, m): + return f"{name:<18} {m['acc']:.4f} {m['prec']:.4f} {m['rec']:.4f} {m['f1']:.4f}" + + +# --------------------------------------------------------------------------- +# 3. Main +# --------------------------------------------------------------------------- + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--no-extract", action="store_true", + help="Dùng cache embeddings có sẵn thay vì trích lại") + args = ap.parse_args() + + data = None if args.no_extract else build_cache() + if data is None: + data = load_cache() + if data is None: + print("Không có cache. Chạy lại KHÔNG kèm --no-extract để trích đặc trưng.") + return + + Xtr, ytr = data["train"] + Xva, yva = data["val"] + Xte, yte = data["test"] + print(f"\nTrain {len(ytr)} | Val {len(yva)} | Test {len(yte)} " + f"| dim {Xtr.shape[1] if len(Xtr) else EMBEDDING_DIM}\n") + + if len(ytr) == 0: + print("Train rỗng — kiểm tra data/. (Dữ liệu thật đã bị xoá trong bản nộp.)") + return + + # ── So sánh chính (siêu tham số mặc định) ─────────────────────────────── + # Closed-set: mọi người trong test cũng có trong train -> đặt threshold=0 + # để mô hình LUÔN đoán người gần nhất (không trả 'Unknown'). Đây là accuracy + # nhận dạng đúng nghĩa. (Khả năng từ chối 'Unknown' được bàn riêng bên dưới.) + models = { + "Cosine": CosineClassifier(threshold=0.0), + "KNN(k=3)": KNNClassifier(k=3, threshold=0.0), + "SVM(RBF,C=10)":SVMClassifier(prob_threshold=0.0, C=10.0), + "RandomForest": RandomForestFaceClassifier(prob_threshold=0.0, + n_estimators=200), + } + print("=" * 64) + print("SO SÁNH CHÍNH (test set)") + print(f"{'Model':<18} {'Acc':<8} {'Prec':<8} {'Rec':<7} {'F1'}") + print("-" * 64) + for name, clf in models.items(): + clf.fit(Xtr, ytr) + print(row(name, evaluate(clf, Xte, yte))) + print("=" * 64) + + # ── Khảo sát siêu tham số (chọn trên VAL, đúng tinh thần L9) ───────────── + if len(yva) > 0: + print("\n[Hyper-param] KNN — k (đánh giá trên VAL):") + for k in (1, 3, 5, 7, 9): + clf = KNNClassifier(k=k, threshold=0.0).fit(Xtr, ytr) + print(f" k={k:<2} val_acc={evaluate(clf, Xva, yva)['acc']:.4f}") + + print("\n[Hyper-param] SVM — C & kernel (VAL):") + for kernel in ("linear", "rbf"): + for C in (1.0, 10.0, 100.0): + clf = SVMClassifier(prob_threshold=0.0, C=C, kernel=kernel) + clf.fit(Xtr, ytr) + print(f" kernel={kernel:<6} C={C:<5} " + f"val_acc={evaluate(clf, Xva, yva)['acc']:.4f}") + + print("\n[Hyper-param] RandomForest — n_estimators (VAL):") + for n in (50, 100, 200): + clf = RandomForestFaceClassifier(prob_threshold=0.0, + n_estimators=n).fit(Xtr, ytr) + print(f" n_estimators={n:<4} val_acc={evaluate(clf, Xva, yva)['acc']:.4f}") + + print("\nXong. Dùng các bảng trên cho phần 'Results & Evaluation' của báo cáo.") + + +if __name__ == "__main__": + main() diff --git a/recognition/model/best_model.pth b/recognition/model/best_model.pth new file mode 100644 index 00000000..97d44b60 Binary files /dev/null and b/recognition/model/best_model.pth differ diff --git a/recognition/model/embeddings_cache.npz b/recognition/model/embeddings_cache.npz new file mode 100644 index 00000000..10c5be3f Binary files /dev/null and b/recognition/model/embeddings_cache.npz differ diff --git a/recognition/model/gallery.npz b/recognition/model/gallery.npz new file mode 100644 index 00000000..54cd514d Binary files /dev/null and b/recognition/model/gallery.npz differ diff --git a/recognition/model/gallery_lfw.npz b/recognition/model/gallery_lfw.npz deleted file mode 100644 index ecf8effc..00000000 Binary files a/recognition/model/gallery_lfw.npz and /dev/null differ