import os
import json
import tempfile
import time
import threading
from typing import Any
import requests
from dotenv import load_dotenv
current_dir = os.path.dirname(os.path.abspath(__file__))
dotenv_path = os.path.join(current_dir, ".env")
load_dotenv(dotenv_path=dotenv_path)
import cv2
import numpy as np
from deepface import DeepFace
from fastapi import FastAPI, HTTPException, Query, Request
from starlette.requests import ClientDisconnect
from fastapi.responses import HTMLResponse, JSONResponse, Response
from face_match import is_recognized_match
from db import (
ensure_data_table,
get_connection_from_env,
load_all_faces,
list_attendance_log,
list_all_persons,
log_attendance,
save_or_update_person,
upsert_person_rfid,
get_person_by_rfid,
claim_rfid_by_name,
get_or_create_data_row,
get_person_by_name,
delete_person_by_rfid,
delete_person_by_name,
delete_unused_persons,
delete_old_attendance_logs,
)
CASCADE_PATH = "src/face.xml"
DEFAULT_MODEL_NAME = os.environ.get("FACE_MODEL", "Facenet")
app = FastAPI(title="ESP32 Face Backend", version="2.0")
_latest_jpeg: bytes | None = None
_latest_ts: float | None = None
_recognize_hits: int = 0
_job_seq: int = 0
_pending_jobs: list[dict[str, Any]] = []
_last_result: dict[str, Any] | None = None
_armed_enroll: dict[str, Any] | None = None
_SUPABASE_URL = os.environ.get("SUPABASE_URL", "").rstrip("/")
_SUPABASE_ANON_KEY = os.environ.get("SUPABASE_ANON_KEY", "")
def _push_to_supabase(nama_user: str) -> None:
if not _SUPABASE_URL or not _SUPABASE_ANON_KEY:
return
try:
headers = {
"apikey": _SUPABASE_ANON_KEY,
"Authorization": f"Bearer {_SUPABASE_ANON_KEY}",
"Content-Type": "application/json",
"Prefer": "return=minimal",
}
resp = requests.post(f"{_SUPABASE_URL}/rest/v1/output_alat", headers=headers,
json={"nama_user": nama_user}, timeout=10)
print(f"[SUPABASE] sent '{nama_user}' status={resp.status_code}")
except Exception as exc:
print(f"[SUPABASE] exception: {exc}")
def _push_to_supabase_async(nama_user: str) -> None:
threading.Thread(target=_push_to_supabase, args=(nama_user,), daemon=True).start()
def _looks_like_rfid_uid(value: str) -> bool:
val = value.strip()
if not val:
return False
parts = val.split(":")
if len(parts) > 1:
if all(len(p) == 2 and all(c in "0123456789ABCDEFabcdef" for c in p) for p in parts):
return 4 <= len(parts) <= 16
return False
if all(c in "0123456789ABCDEFabcdef" for c in val) and 8 <= len(val) <= 32:
return True
return False
def _enqueue_job(job_type: str, name: str | None = None) -> dict[str, Any]:
global _job_seq, _pending_jobs
_job_seq += 1
job = {"id": _job_seq, "type": job_type, "name": name, "created_at": time.time()}
_pending_jobs.append(job)
print(f"[JOB] enqueue id={job['id']} type={job_type} name={name}")
return job
# ============================================================
# WEB UI HTML/CSS
# ============================================================
_CSS = """
:root{--bg:#080c12;--surface:#0e1520;--card:#111b28;--border:#1e2d40;--accent:#3b82f6;--green:#10b981;--red:#ef4444;--yellow:#f59e0b;--text:#e2e8f0;--muted:#64748b}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"Inter",sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
a{color:var(--accent);text-decoration:none}
.topbar{background:linear-gradient(90deg,#0d1b2e,#0e1f35);border-bottom:1px solid var(--border);padding:12px 24px;display:flex;align-items:center;justify-content:space-between}
.topbar h1{font-size:18px;font-weight:700;background:linear-gradient(90deg,#60a5fa,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent}
.topbar .badge{font-size:11px;padding:3px 10px;border-radius:20px;background:rgba(59,130,246,.15);border:1px solid rgba(59,130,246,.3);color:#93c5fd}
.tabs{display:flex;gap:4px;padding:16px 24px 0;border-bottom:1px solid var(--border);background:var(--surface)}
.tab{padding:10px 18px;border-radius:8px 8px 0 0;cursor:pointer;font-size:13px;font-weight:500;color:var(--muted);border:1px solid transparent;border-bottom:none;transition:all .2s}
.tab:hover{color:var(--text);background:rgba(255,255,255,.04)}
.tab.active{background:var(--bg);border-color:var(--border);color:var(--accent)}
.content{display:none;padding:24px}
.content.active{display:block}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}
.card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:18px;transition:border-color .2s}
.card:hover{border-color:rgba(59,130,246,.4)}
.card h3{font-size:14px;font-weight:600;margin-bottom:4px;color:#93c5fd}
.card p{font-size:12px;color:var(--muted);margin-bottom:12px;line-height:1.5}
label{display:block;font-size:12px;font-weight:500;color:var(--muted);margin:10px 0 4px}
input[type=text],input[type=number]{width:100%;padding:9px 12px;border-radius:8px;border:1px solid var(--border);background:#0b1220;color:var(--text);font-size:13px;outline:none;transition:border-color .2s}
input:focus{border-color:var(--accent)}
.btn{display:inline-flex;align-items:center;gap:6px;padding:9px 16px;border-radius:8px;border:none;font-size:13px;font-weight:500;cursor:pointer;transition:all .2s}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:#2563eb;transform:translateY(-1px)}
.btn-success{background:var(--green);color:#fff}.btn-success:hover{background:#059669}
.btn-danger{background:rgba(239,68,68,.15);color:var(--red);border:1px solid rgba(239,68,68,.3)}.btn-danger:hover{background:rgba(239,68,68,.25)}
.btn-warn{background:rgba(245,158,11,.15);color:var(--yellow);border:1px solid rgba(245,158,11,.3)}.btn-warn:hover{background:rgba(245,158,11,.25)}
.btn-ghost{background:rgba(255,255,255,.06);color:var(--text);border:1px solid var(--border)}.btn-ghost:hover{background:rgba(255,255,255,.1)}
.btn-sm{padding:5px 10px;font-size:12px}
pre{white-space:pre-wrap;background:#0b1220;border:1px solid var(--border);padding:10px 12px;border-radius:10px;font-size:12px;margin-top:8px;max-height:160px;overflow-y:auto;color:#94a3b8}
.tbl-wrap{overflow-x:auto;border-radius:12px;border:1px solid var(--border)}
table{width:100%;border-collapse:collapse;font-size:13px}
thead{background:#0d1828}
th{padding:10px 14px;text-align:left;font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);border-bottom:1px solid var(--border)}
td{padding:10px 14px;border-bottom:1px solid rgba(30,45,64,.6);vertical-align:middle}
tr:last-child td{border-bottom:none}
tr:hover td{background:rgba(59,130,246,.04)}
.chip{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500}
.chip-green{background:rgba(16,185,129,.15);color:#34d399;border:1px solid rgba(16,185,129,.3)}
.chip-red{background:rgba(239,68,68,.15);color:#fca5a5;border:1px solid rgba(239,68,68,.3)}
.chip-blue{background:rgba(59,130,246,.15);color:#93c5fd;border:1px solid rgba(59,130,246,.3)}
.chip-gray{background:rgba(100,116,139,.15);color:var(--muted);border:1px solid var(--border)}
.stats{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px}
.stat{flex:1;min-width:120px;background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 16px}
.stat .val{font-size:26px;font-weight:700;color:var(--accent)}
.stat .lbl{font-size:11px;color:var(--muted);margin-top:2px}
.row{display:flex;gap:10px;flex-wrap:wrap;align-items:center}
.sep{height:1px;background:var(--border);margin:16px 0}
.mt{margin-top:12px}
"""
def _html_page(title, body):
return f"""
{title}
🔧 Dashboard
👤 Data Pengguna
📋 Log Absen
🗑 Cleanup
⚙ Config ESP32
{body}
"""
_DASHBOARD_BODY = """
🔓 Step 1 — Set Identitas
Isi nama atau UID RFID untuk dikunci sebelum capture wajah.
-
📷 Step 2 — Jepret & Daftar Wajah
Setelah identitas di-set, klik untuk capture dan simpan embedding wajah.
-
👁 Deteksi / Absen
Trigger ESP32 untuk ambil foto dan kenali wajah.
-
📈 Status Terakhir ESP32
Hasil terakhir yang dikirim ESP32 setelah eksekusi job.
-
Data Pengguna
Tabel data_fcgntion
| ID | Nama | RFID UID | Wajah | Terdaftar | Aksi |
| Loading... |
Log Absensi
Riwayat tap kartu + hasil rekognisi
| Waktu | RFID UID | Nama Dikenali | Wajah OK |
| Loading... |
Cleanup Data
Hapus data tidak terpakai atau sudah lama
🗑 Hapus Data Kosong
Hapus baris yang tidak punya wajah (fc=NULL) dan tidak punya RFID (rfid_uid=NULL). Baris ini tidak berfungsi untuk system.
-
Config ESP32
Atur koneksi WiFi dan Server untuk modul ESP32
⚙ ESP32-S3 CAMERA CONFIG
Pastikan laptop/PC terhubung ke WiFi ESP32 (AP Mode) atau masukkan IP ESP32 jika sudah terhubung ke jaringan.
↻ Reset Server Connection
Kirim perintah ke ESP32 untuk mereset status koneksi server jika ESP32 berhenti merespon (API FAIL).
-
"""
@app.get("/", response_class=HTMLResponse)
def dashboard():
return _html_page("ESP32 Face System", _DASHBOARD_BODY)
@app.get("/debug/recognize_hits")
def debug_recognize_hits():
return {"ok": True, "hits": _recognize_hits}
@app.get("/job/queue")
def job_queue():
return {"ok": True, "pending": _pending_jobs, "count": len(_pending_jobs)}
@app.post("/arm/enroll")
def arm_enroll(
name: str = Query(..., min_length=1, max_length=255),
uid: str | None = Query(None, min_length=1, max_length=64),
):
"""Set identity for the next capture-enroll."""
global _armed_enroll
val = name.strip()
uid_norm = uid.strip().upper() if uid else None
is_uid = _looks_like_rfid_uid(val)
parsed_uid = uid_norm or (val.upper() if is_uid else None)
parsed_name = None if (is_uid and parsed_uid == val.upper() and not uid_norm) else val
row_id = None
conn = get_connection_from_env()
ensure_data_table(conn)
try:
if parsed_name:
row_id = get_or_create_data_row(conn, parsed_name)
if parsed_uid:
upsert_person_rfid(conn, parsed_uid, parsed_name)
except Exception:
pass
finally:
conn.close()
_armed_enroll = {
"uid": parsed_uid,
"name": parsed_name if parsed_name else parsed_uid,
"row_id": row_id,
"armed_at": time.time(),
}
return {"ok": True, "armed": _armed_enroll}
@app.delete("/arm/enroll")
def clear_arm_enroll():
global _armed_enroll
_armed_enroll = None
return {"ok": True}
@app.get("/arm/enroll")
def get_arm_enroll():
if not _armed_enroll:
return {"ok": True, "armed": False, "name": None}
return {"ok": True, "armed": True,
"name": _armed_enroll.get("name"),
"armed_at": _armed_enroll.get("armed_at")}
@app.post("/job/enroll")
def job_enroll(name: str = Query(..., min_length=1, max_length=255)):
job = _enqueue_job("ENROLL", name=name.strip())
return {"ok": True, "job": job}
@app.post("/job/capture_enroll")
def job_capture_enroll():
if not _armed_enroll or not _armed_enroll.get("name"):
raise HTTPException(status_code=409, detail="No armed enroll identity. Call /arm/enroll first")
job = _enqueue_job("ENROLL", name=str(_armed_enroll["name"]))
if _armed_enroll.get("uid"):
job["uid"] = str(_armed_enroll["uid"])
if _armed_enroll.get("row_id"):
job["row_id"] = int(_armed_enroll["row_id"])
job["armed"] = True
return {"ok": True, "job": job, "armed": _armed_enroll}
@app.post("/job/recog")
def job_recog(
threshold: float = Query(0.25, ge=0.0, le=1.0),
uid: str | None = Query(None, min_length=1, max_length=64),
):
job = _enqueue_job("RECOG", name=None)
job["threshold"] = float(threshold)
if uid and uid.strip():
job["uid"] = uid.strip().upper()
return {"ok": True, "job": job}
@app.get("/job/next")
def job_next(device: str | None = Query(None)):
global _pending_jobs
if not _pending_jobs:
return {"ok": True, "job": None}
job = _pending_jobs.pop(0)
job["taken_at"] = time.time()
if device:
job["device"] = device
print(f"[JOB] device={device} take id={job.get('id')} type={job.get('type')}")
return {"ok": True, "job": job}
@app.post("/job/result")
async def job_result(request: Request):
global _last_result
try:
data = await request.json()
except Exception:
raise HTTPException(status_code=400, detail="Invalid JSON")
data["received_at"] = time.time()
_last_result = data
print(f"[JOB] result from={data.get('from')} type={data.get('type')} name={data.get('name')}")
return {"ok": True}
@app.get("/job/last")
def job_last():
return {"ok": True, "result": _last_result}
# ============================================================
# FACE HELPERS
# ============================================================
def _parse_embedding(obj) -> np.ndarray:
if obj is None:
raise ValueError("embedding is None")
emb = obj
if isinstance(emb, dict) and "embedding" in emb:
emb = emb["embedding"]
if isinstance(emb, list) and len(emb) > 0 and isinstance(emb[0], dict) and "embedding" in emb[0]:
emb = emb[0]["embedding"]
if isinstance(emb, dict) and "embedding" in emb:
emb = emb["embedding"]
arr = np.array(emb).astype(float).reshape(-1)
if arr.size == 0:
raise ValueError("empty embedding")
return arr
def _cosine_distance(a: np.ndarray, b: np.ndarray) -> float:
a_n = a / (np.linalg.norm(a) + 1e-10)
b_n = b / (np.linalg.norm(b) + 1e-10)
return 1.0 - float(np.dot(a_n, b_n))
def _crop_first_face_bgr(img_bgr: np.ndarray):
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
face_cascade = cv2.CascadeClassifier(CASCADE_PATH)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60))
if len(faces) == 0:
return None
x, y, w, h = faces[0]
crop = img_bgr[y:y+h, x:x+w]
pad = int(0.25 * max(w, h))
padded = cv2.copyMakeBorder(crop, pad, pad, pad, pad, borderType=cv2.BORDER_CONSTANT, value=[0,0,0])
return cv2.resize(padded, (224, 224), interpolation=cv2.INTER_AREA)
def _represent_from_bgr_face(face_bgr_224: np.ndarray) -> np.ndarray:
ts = int(time.time() * 1000)
tmp = os.path.join(tempfile.gettempdir(), f"esp32_face_{ts}.jpg")
cv2.imwrite(tmp, face_bgr_224)
try:
try:
rep = DeepFace.represent(tmp, model_name=DEFAULT_MODEL_NAME, enforce_detection=True)
except Exception:
rep = DeepFace.represent(tmp, model_name=DEFAULT_MODEL_NAME,
enforce_detection=False, detector_backend="opencv")
return _parse_embedding(rep)
finally:
try:
os.remove(tmp)
except Exception:
pass
def _load_gallery_from_db() -> dict:
"""Load all face embeddings from data_fcgntion into memory."""
conn = get_connection_from_env()
ensure_data_table(conn)
rows = load_all_faces(conn)
conn.close()
gallery = {}
for row in rows:
name = row.get("name")
emb_raw = row.get("embedding")
if isinstance(emb_raw, str):
try:
emb_raw = json.loads(emb_raw)
except Exception:
pass
try:
gallery[name] = _parse_embedding(emb_raw)
except Exception:
continue
return gallery
def _build_recognition_response(best_name, best_score, threshold, uid=None, second_score=None) -> dict:
match = is_recognized_match(best_score, threshold, second_score=second_score)
recognized_name = best_name if match else "Unknown"
result = {
"ok": True,
"name": recognized_name,
"score": float(best_score) if best_score is not None else None,
"threshold": float(threshold),
"match": bool(match),
"best": best_name,
}
if uid and uid.strip() and match:
uid_norm = uid.strip().upper()
result["uid"] = uid_norm
conn = get_connection_from_env()
ensure_data_table(conn)
try:
log_attendance(conn, uid=uid_norm, recognized_name=recognized_name, face_ok=True)
finally:
conn.close()
if match and best_name:
print(f"[SUPABASE] Wajah dikenali: '{best_name}' - push ke Supabase...")
_push_to_supabase_async(best_name)
return result
def _recognize_embedding(q: np.ndarray, threshold: float, uid=None) -> dict:
gallery = _load_gallery_from_db()
if not gallery:
uid_norm = uid.strip().upper() if uid and uid.strip() else None
resp = {"ok": True, "name": "Unknown", "score": None,
"reason": "empty_gallery", "threshold": float(threshold)}
if uid_norm:
resp["uid"] = uid_norm
conn = get_connection_from_env()
ensure_data_table(conn)
try:
log_attendance(conn, uid=uid_norm, recognized_name="Unknown", face_ok=False)
finally:
conn.close()
return resp
best_name = None
best_score = float("inf")
second_best_score = float("inf")
for gname, gvec in gallery.items():
d = _cosine_distance(q, gvec)
if d < best_score:
second_best_score = best_score
best_score = d
best_name = gname
elif d < second_best_score:
second_best_score = d
return _build_recognition_response(best_name, best_score, threshold, uid, second_score=second_best_score)
# ============================================================
# RFID ENDPOINTS
# ============================================================
@app.post("/rfid/register")
def rfid_register(
request: Request,
uid: str = Query(..., min_length=1, max_length=64),
name: str | None = Query(None, min_length=1, max_length=128),
):
"""Register/upsert RFID UID in data_fcgntion."""
try:
print(f"[RFID_REGISTER] url={request.url} query={request.url.query}")
except Exception:
pass
uid_norm = uid.strip().upper()
if not uid_norm:
raise HTTPException(status_code=422, detail="uid empty")
raw_name = name.strip() if name else ""
conn = get_connection_from_env()
ensure_data_table(conn)
try:
claimed = False
if raw_name:
claimed = claim_rfid_by_name(conn, raw_name, uid_norm)
if not claimed:
upsert_person_rfid(conn, uid_norm, raw_name)
elif _armed_enroll and _armed_enroll.get("name"):
armed_name = _armed_enroll["name"]
claimed = claim_rfid_by_name(conn, armed_name, uid_norm)
if claimed and _armed_enroll:
_armed_enroll["uid"] = uid_norm
else:
upsert_person_rfid(conn, uid_norm, armed_name)
else:
upsert_person_rfid(conn, uid_norm, None)
row = get_person_by_rfid(conn, uid_norm)
finally:
conn.close()
return {"ok": True, "uid": uid_norm, "name": raw_name or uid_norm, "row": row}
@app.get("/rfid/lookup")
def rfid_lookup(
request: Request,
uid: str = Query(..., min_length=1, max_length=64),
debug: int = Query(0, ge=0, le=1),
):
"""Lookup RFID UID -> person name from data_fcgntion."""
uid_norm = uid.strip().upper()
if not uid_norm:
raise HTTPException(status_code=422, detail="uid empty")
try:
print(f"[RFID_LOOKUP] url={request.url} query={request.url.query}")
except Exception:
pass
conn = get_connection_from_env()
ensure_data_table(conn)
row = get_person_by_rfid(conn, uid_norm)
if not row:
# Cek apakah ada person yang BELUM memiliki RFID
cursor = conn.cursor()
cursor.execute("SELECT id_tabel, person, fc FROM data_fcgntion WHERE rfid_uid IS NULL OR rfid_uid = '' ORDER BY create_at ASC LIMIT 1")
unassigned_row = cursor.fetchone()
if unassigned_row:
person_id = int(unassigned_row[0])
person_name = unassigned_row[1]
has_face = unassigned_row[2] is not None
cursor.execute("UPDATE data_fcgntion SET rfid_uid=%s WHERE id_tabel=%s", (uid_norm, person_id))
conn.commit()
conn.close()
resp = {
"ok": True,
"uid": uid_norm,
"registered": True,
"name": person_name,
"has_face": has_face,
"auto_assigned": True
}
print(f"[RFID_LOOKUP] auto-assigned result={resp}")
return resp
conn.close()
if not row:
resp = {"ok": True, "uid": uid_norm, "registered": False, "name": None}
print(f"[RFID_LOOKUP] result={resp}")
return resp
resp = {
"ok": True,
"uid": row.get("rfid_uid"),
"registered": True,
"name": row.get("person"),
"has_face": row.get("has_face", False),
}
if debug == 1:
resp["create_at"] = str(row.get("create_at"))
print(f"[RFID_LOOKUP] result={resp}")
return resp
# ============================================================
# FACE ENROLLMENT & RECOGNITION ENDPOINTS
# ============================================================
@app.post("/enroll")
async def enroll(
request: Request,
name: str = Query(..., min_length=1),
uid: str | None = Query(None),
):
"""ESP32 sends raw JPEG bytes. Extract face, compute embedding, save to data_fcgntion."""
data = await request.body()
if not data:
raise HTTPException(status_code=422, detail="Missing image body")
img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is None:
raise HTTPException(status_code=400, detail="Invalid image")
face = _crop_first_face_bgr(img)
if face is None:
raise HTTPException(status_code=400, detail="No face detected")
vec = _represent_from_bgr_face(face)
uid_norm = uid.strip().upper() if uid else None
conn = get_connection_from_env()
ensure_data_table(conn)
row_id = save_or_update_person(conn, name, json.dumps(vec.tolist()), rfid_uid=uid_norm)
conn.close()
return {"ok": True, "name": name, "dim": int(vec.size), "row_id": row_id}
@app.post("/recognize")
async def recognize(
request: Request,
threshold: float = Query(0.25, ge=0.0, le=1.0),
uid: str | None = Query(None, min_length=1, max_length=64),
):
global _recognize_hits
_recognize_hits += 1
print(f"[RECOGNIZE] uid={uid} threshold={threshold}")
try:
data = await request.body()
except ClientDisconnect:
print("[RECOGNIZE] client disconnected before body received")
raise HTTPException(status_code=400, detail="Client disconnected")
if not data:
raise HTTPException(status_code=422, detail="Missing image body")
img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is None:
raise HTTPException(status_code=400, detail="Invalid image")
face = _crop_first_face_bgr(img)
if face is None:
raise HTTPException(status_code=400, detail="No face detected")
q = _represent_from_bgr_face(face)
return _recognize_embedding(q, float(threshold), uid)
@app.post("/rfid/attendance")
async def rfid_attendance(
request: Request,
uid: str = Query(..., min_length=1, max_length=64),
threshold: float = Query(0.25, ge=0.0, le=1.0),
):
print(f"[RFID_ATTENDANCE] uid={uid} threshold={threshold}")
try:
data = await request.body()
except ClientDisconnect:
print(f"[RFID_ATTENDANCE] client disconnected before body received (uid={uid})")
raise HTTPException(status_code=400, detail="Client disconnected")
if not data:
raise HTTPException(status_code=422, detail="Missing image body")
img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is None:
raise HTTPException(status_code=400, detail="Invalid image")
face = _crop_first_face_bgr(img)
if face is None:
raise HTTPException(status_code=400, detail="No face detected")
q = _represent_from_bgr_face(face)
uid_norm = uid.strip().upper()
gallery = _load_gallery_from_db()
if not gallery:
return {"ok": True, "match": False, "name": "Unknown", "reason": "empty_gallery"}
conn = get_connection_from_env()
ensure_data_table(conn)
row = get_person_by_rfid(conn, uid_norm)
conn.close()
if not row or not row.get("person"):
return {"ok": True, "match": False, "name": "Unknown", "reason": "uid_not_found"}
target_name = row.get("person")
if target_name not in gallery:
return {"ok": True, "match": False, "name": target_name, "reason": "target_not_loaded"}
target_vec = gallery[target_name]
target_distance = _cosine_distance(q, target_vec)
best_name = None
best_score = float("inf")
second_best_score = float("inf")
for gname, gvec in gallery.items():
d = _cosine_distance(q, gvec)
if d < best_score:
second_best_score = best_score
best_score = d
best_name = gname
elif d < second_best_score:
second_best_score = d
match = (best_name == target_name and
is_recognized_match(target_distance, threshold, second_score=second_best_score, min_confidence=0.35, margin=0.05))
result = {
"ok": True,
"name": target_name if match else "Unknown",
"score": float(target_distance),
"threshold": float(threshold),
"match": bool(match),
"best": best_name if best_name is not None else "Unknown",
"uid": uid_norm
}
# Hanya catat ke database JIKA COCOK. Ini mencegah spam DB saat ESP32 melakukan auto-retry!
if match:
conn = get_connection_from_env()
try:
log_attendance(conn, uid=uid_norm, recognized_name=target_name, face_ok=True)
finally:
conn.close()
print(f"[SUPABASE] Wajah dikenali: '{target_name}' - push ke Supabase...")
_push_to_supabase_async(target_name)
print(f'[RFID_ATTENDANCE] result={result}')
return result
# ============================================================
# PERSON / CLEANUP ENDPOINTS
# ============================================================
@app.delete("/person/delete")
def person_delete(
name: str | None = Query(None),
uid: str | None = Query(None),
):
"""Delete a person from data_fcgntion by name or rfid_uid."""
if not name and not uid:
raise HTTPException(status_code=422, detail="Provide name or uid")
conn = get_connection_from_env()
ensure_data_table(conn)
try:
if uid:
delete_person_by_rfid(conn, uid.strip().upper())
return {"ok": True, "deleted_by": "uid", "uid": uid.strip().upper()}
else:
delete_person_by_name(conn, name.strip())
return {"ok": True, "deleted_by": "name", "name": name.strip()}
finally:
conn.close()
@app.post("/cleanup/unused")
def cleanup_unused():
"""Delete data_fcgntion rows where fc IS NULL AND rfid_uid IS NULL."""
conn = get_connection_from_env()
ensure_data_table(conn)
try:
count = delete_unused_persons(conn)
return {"ok": True, "deleted": count, "msg": f"Dihapus {count} baris data kosong"}
finally:
conn.close()
@app.post("/cleanup/old_logs")
def cleanup_old_logs(days: int = Query(30, ge=1, le=3650)):
"""Delete attendance_log entries older than `days` days."""
conn = get_connection_from_env()
ensure_data_table(conn)
try:
count = delete_old_attendance_logs(conn, days=days)
return {"ok": True, "deleted": count, "msg": f"Dihapus {count} log absen > {days} hari"}
finally:
conn.close()
# ============================================================
# API DATA ENDPOINTS
# ============================================================
@app.get("/api/persons")
def api_list_persons(limit: int = Query(200, ge=1, le=1000)):
conn = get_connection_from_env()
ensure_data_table(conn)
items = list_all_persons(conn, limit=limit)
conn.close()
for item in items:
if item.get("create_at") and not isinstance(item["create_at"], str):
item["create_at"] = str(item["create_at"])
return {"ok": True, "items": items}
@app.get("/api/attendance")
def api_list_attendance(limit: int = Query(200, ge=1, le=1000)):
conn = get_connection_from_env()
ensure_data_table(conn)
items = list_attendance_log(conn, limit=limit)
conn.close()
for item in items:
if item.get("created_at") and not isinstance(item["created_at"], str):
item["created_at"] = str(item["created_at"])
return {"ok": True, "items": items}
# ============================================================
# FRAME / LIVE PREVIEW
# ============================================================
@app.get("/health")
def health():
return {"ok": True}
@app.get("/live", response_class=HTMLResponse)
def live_page():
return """
ESP32 Live
ESP32 Live Preview
ESP32 POST JPEG ke /frame.
"""
@app.post("/frame")
async def ingest_frame(request: Request):
global _latest_jpeg, _latest_ts
data = await request.body()
if not data:
raise HTTPException(status_code=422, detail="Missing image body")
if len(data) < 3 or not (data[0] == 0xFF and data[1] == 0xD8 and data[2] == 0xFF):
raise HTTPException(status_code=400, detail="Not a JPEG")
_latest_jpeg = data
_latest_ts = time.time()
return {"ok": True}
@app.get("/latest.jpg")
def latest_jpg(annotate: int = Query(0, ge=0, le=1)):
if _latest_jpeg is None:
raise HTTPException(status_code=404, detail="No frame yet")
if annotate != 1:
return Response(content=_latest_jpeg, media_type="image/jpeg")
img = cv2.imdecode(np.frombuffer(_latest_jpeg, dtype=np.uint8), cv2.IMREAD_COLOR)
if img is None:
return Response(content=_latest_jpeg, media_type="image/jpeg")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
face_cascade = cv2.CascadeClassifier(CASCADE_PATH)
faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5, minSize=(60, 60))
for (x, y, w, h) in faces:
cv2.rectangle(img, (x, y), (x+w, y+h), (0, 255, 0), 2)
ret, buf = cv2.imencode(".jpg", img, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
if not ret:
return Response(content=_latest_jpeg, media_type="image/jpeg")
return Response(content=buf.tobytes(), media_type="image/jpeg")
if __name__ == "__main__":
host = os.environ.get("HOST", "0.0.0.0")
port = int(os.environ.get("PORT", "8000"))
import uvicorn
ngrok_url = os.environ.get("NGROK_URL", "").strip()
if ngrok_url:
print("\n" + "="*70)
print("NGROK TUNNEL ACTIVE")
print("="*70)
print(f"Public URL: {ngrok_url}")
print(f"Local URL: http://{host}:{port}")
print("="*70 + "\n")
uvicorn.run(app, host=host, port=port, reload=False)