Mendapatkan kotak wajah itu bisa dibilang setengah tugas; aplikasi nyata hampir selalu butuh lebih: posisi mata untuk koreksi kamera, orientasi kepala untuk filter AR, atau gerakan mulut untuk analisis ekspresi. Face detection dan landmark detection bekerja berpasangan — detektor menemukan wilayah wajah, lalu model landmark menandai titik-titik kuncinya dengan koordinat sub-piksel. Chapter ini membedah konsep keduanya (anchor multi-scale, MTCNN, RetinaFace), menguji pipeline klasik pada citra sintetis buatan NumPy, dan menutup dengan bagian yang sama pentingnya dengan algoritmanya: etika dan privasi pengolahan data wajah.
Deteksi Wajah dengan Anchor Multi-Scale #
Wajah muncul di segala ukuran: menempati hampir seluruh foto potret, atau hanya beberapa piksel di kerumunan. Detektor modern mengatasinya dengan anchor — kotak referensi berbagai ukuran dan rasio yang disebar pada banyak lokasi — lalu memprediksi koreksi terhadap anchor terdekat untuk tiap wajah. Karena feature map pada resolusi berbeda (hasil pooling bertingkat) secara alami melihat konteks berbeda, anchor besar dipasang di layer dangkal dan anchor kecil di layer dalam; pendekatan ini adalah dasar arsitektur SSD dan RetinaFace. MTCNN menempuh jalur bertingkat lain: jaringan kecil pertama memindai seluruh gambar dengan jendela geser cepat, jaringan kedua memverifikasi kandidat, jaringan ketiga mempresisikan kotak sekaligus memprediksi landmark — ketiganya membentuk pipeline kasar-ke-halus. Seluruh konsep kotak, skor, dan pencocokan IoU-nya sudah kita kuasai dari chapter Object Detection .
Landmark: Titik Kunci Wajah #
Landmark adalah koordinat (x, y) titik anatomis — sudut dan pusat mata, ujung hidung, sudut mulut — yang jumlahnya baku per model: 5 titik untuk tugas ringkas, 68 titik untuk keperluan rinci. Dari lima titik standar, geometri wajah bisa dihitung langsung: sudut roll dari garis kedua mata, skala dari jarak antar mata, orientasi kepala dari posisi hidung relatif terhadap garis mulut. Inilah bahan baku berbagai aplikasi:
| Use case | Komponen yang dipakai | Contoh nyata |
|---|---|---|
| Filter dan AR | landmark + estimasi pose 3D | masker wajah, animasi avatar |
| Liveness check | urutan landmark dari video | gerakan kedipan mata untuk verifikasi hidup |
| Pra-pemrosesan wajah | landmark untuk align | normalisasi posisi sebelum face recognition |
| Ekspresi dan attention | jarak antar landmark | analisis usaha fokus pengguna |
| Fotografi otomatis | deteksi wajah + landmark | senyum untuk memicu jepretan |
Eksperimen: Pipeline Klasik pada Citra Sintetis #
Sebelum era deep learning, deteksi wajah memakai pola terang-gelap: wilayah wajah relatif seragam dan terang, fitur (mata, hidung, mulut) relatif gelap — ide yang juga dipakai Haar cascade klasik. Berkas deteksi_wajah_sintetis.py membangun citra buatan semacam itu (oval terang, fitur gelap, derau kecil), lalu menjalankan pipeline: blur untuk meredam derau, komponen terang terbesar sebagai area wajah, blob gelap di dalamnya sebagai fitur, dan lima titik landmark standar.
"""Deteksi "wajah" sintetis dan 5 titik landmark dengan NumPy.
Alur: bangun citra sintetis (oval terang + fitur gelap + derau) ->
blur 3x3 -> region terang terbesar = area wajah (lalu isi lubang) ->
piksel gelap di dalam area wajah = mata, hidung, mulut -> 5 titik
landmark standar.
"""
import numpy as np
from scipy import ndimage
rng = np.random.default_rng(42)
H, W = 18, 20 # kanvas: 18 baris x 20 kolom
# ---------- 1. Citra sintetis ----------
citra = np.full((H, W), 0.05) # latar belakang gelap
ry, rx, cy0, cx0 = 7.0, 7.0, 8.5, 11.0 # oval wajah
rr, cc = np.mgrid[0:H, 0:W]
oval = ((rr - cy0) / ry) ** 2 + ((cc - cx0) / rx) ** 2 <= 1.0
citra[oval] = 0.9 # wajah terang
citra[6, 7:10] = 0.1 # mata kiri (1 baris x 3)
citra[6, 13:16] = 0.1 # mata kanan (1 baris x 3)
citra[8:11, 10:13] = 0.25 # hidung (3x3)
citra[12:14, 9:14] = 0.25 # mulut (2x5)
citra = np.clip(citra + rng.normal(0, 0.03, citra.shape), 0.0, 1.0)
def blur3x3(a):
"""Rata-rata 3x3 pada dua sumbu terakhir, padding tepi (mode edge)."""
t = np.pad(a, [(1, 1)] * a.ndim, mode="edge")
jumlah = np.zeros_like(a)
h, w = a.shape[-2], a.shape[-1]
for dy in (-1, 0, 1):
for dx in (-1, 0, 1):
jumlah = jumlah + t[..., 1 + dy: 1 + dy + h, 1 + dx: 1 + dx + w]
return jumlah / 9.0
print("citra sintetis (0 = gelap, 9 = terang):")
print("\n".join("".join(str(v) for v in baris)
for baris in np.clip(np.round(citra * 10), 0, 9).astype(int)))
print()
# ---------- 2. Area wajah: komponen terang terbesar pada citra halus ----------
halus = blur3x3(citra)
terang, _ = ndimage.label(halus > 0.5)
id_wajah = np.argmax(np.bincount(terang.ravel())[1:]) + 1
wajah = terang == id_wajah
wajah_penuh = ndimage.binary_fill_holes(wajah) # lubang mata/hidung/mulut
ys, xs = np.where(wajah)
print(f"area wajah terdeteksi: baris {ys.min()}-{ys.max()}, "
f"kolom {xs.min()}-{xs.max()}, luas {int(wajah.sum())} piksel")
print()
# ---------- 3. Blob gelap di dalam wajah = fitur ----------
fitur, jumlah_fitur = ndimage.label((citra <= 0.48) & wajah_penuh)
print(f"blob fitur ditemukan: {jumlah_fitur} komponen")
komponen = []
for k in range(1, jumlah_fitur + 1):
sel = fitur == k
ys, xs = np.where(sel)
komponen.append(
{
"sel": sel,
"luas": len(ys),
"cy": ys.mean(),
"cx": xs.mean(),
"lebar": xs.max() - xs.min() + 1,
}
)
print(f" komponen {k}: luas {len(ys):2d}, lebar {xs.max() - xs.min() + 1}, "
f"centroid ({ys.mean():.1f}, {xs.mean():.1f})")
print()
# Dua komponen paling atas = mata; dari sisanya, terlebar = mulut
komponen.sort(key=lambda k: k["cy"])
mata = sorted(komponen[:2], key=lambda k: k["cx"])
mata_kiri, mata_kanan = mata
sisanya = komponen[2:]
mulut = max(sisanya, key=lambda k: k["lebar"])
hidung = next(k for k in sisanya if k is not mulut)
# ---------- 4. Lima titik landmark ----------
def titik(komp, kolom=None):
y = int(round(komp["cy"]))
x = int(round(komp["cx"])) if kolom is None else kolom
return (y, x)
ys_mulut, xs_mulut = np.where(mulut["sel"])
landmark = {
"mata kiri": titik(mata_kiri),
"mata kanan": titik(mata_kanan),
"ujung hidung": titik(hidung),
"sudut mulut kiri": titik(mulut, int(xs_mulut.min())),
"sudut mulut kanan": titik(mulut, int(xs_mulut.max())),
}
print("5 titik landmark (baris, kolom):")
for nama, (ly, lx) in landmark.items():
print(f" {nama:18s}: ({ly:2d}, {lx:2d})")
print()
# ---------- 5. Overlay visual ----------
simbol = {titik_landmark: str(i + 1)
for i, titik_landmark in enumerate(landmark.values())}
print("overlay (1-5 = landmark, # = blob fitur, . = area wajah):")
for r in range(H):
baris_teks = ""
for c in range(W):
if (r, c) in simbol:
baris_teks += simbol[(r, c)]
elif fitur[r, c]:
baris_teks += "#"
elif wajah_penuh[r, c]:
baris_teks += "."
else:
baris_teks += " "
print(baris_teks)Output yang Dihasilkan:
citra sintetis (0 = gelap, 9 = terang):
10110010001111101010
00100011111000110001
11011111199999000101
00111109999999990110
10000199999999899010
01010999989999999910
01111991119991119911
10010999999989999900
00101999992329999901
11011999993229999900
11110999993239999900
01101999999999998900
00001999922332999900
00101199932323999011
10110019999998991001
10100001199999101010
10111100001100100111
01111001011101111100
area wajah terdeteksi: baris 2-15, kolom 5-17, luas 142 piksel
blob fitur ditemukan: 4 komponen
komponen 1: luas 3, lebar 3, centroid (6.0, 8.0)
komponen 2: luas 3, lebar 3, centroid (6.0, 14.0)
komponen 3: luas 9, lebar 3, centroid (9.0, 11.0)
komponen 4: luas 10, lebar 5, centroid (12.5, 11.0)
5 titik landmark (baris, kolom):
mata kiri : ( 6, 8)
mata kanan : ( 6, 14)
ujung hidung : ( 9, 11)
sudut mulut kiri : (12, 9)
sudut mulut kanan : (12, 13)
overlay (1-5 = landmark, # = blob fitur, . = area wajah):
.....
.........
...........
.............
..#1#...#2#..
.............
.....###.....
.....#3#.....
.....###.....
.............
....4###5....
...#####...
.........
.....
Mari kita telaah kode di atas baris per baris:
- Baris 16-26: citra sintetis dibangun dari oval terang (
citra[oval] = 0.9) dengan mata (baris 6, gelap 0.1), hidung (3x3, 0.25), dan mulut (2x5, 0.25); derau Gaussian kecil lalu dipotong ke rentang 0-1. - Baris 31-39 (
blur3x3): rata-rata 3x3 dengan padding tepi meredam derau — tanpa langkah ini, ambang pada citra mentah akan menghasilkan banyak titik liar di latar belakang. - Baris 49-53:
ndimage.labelmenomori komponen terang;np.bincountmenghitung luas tiap komponen danargmaxmemilih yang terbesar sebagai area wajah.binary_fill_holeslalu mengisi lubang fitur supaya piksel mata dan mulut tetap dianggap “di dalam wajah”. - Baris 62-78: blob fitur = piksel yang gelap (
citra <= 0.48) dan berada di dalam area wajah. Empat komponen ditemukan: dua mata (luas 3), hidung (luas 9), mulut (luas 10). - Baris 81-87: klasifikasi geometris sederhana — dua komponen paling atas adalah mata (diurutkan lagi berdasar kolom untuk kiri/kanan), dan dari sisanya komponen terlebar adalah mulut, sisanya hidung. Aturan ini bekerja karena tata letak wajah mengikuti anatomi tetap.
- Baris 91-107: lima landmark dihitung dari centroid komponen (mata kiri
(6, 8), mata kanan(6, 14), hidung(9, 11)) dan dari kolom terkiri serta terkanan komponen mulut untuk dua sudutnya(12, 9)dan(12, 13). Overlay akhir menampilkan wajah yang tersegmentasi rapi dengan angka 1-5 pada posisi landmark.
Privasi: Wajah adalah Data Pribadi #
Dari klasifikasi, deteksi, segmentasi, hingga wajah dan landmark, seluruh chapter section ini masih bertumpu pada satu mesin: convolution. Penutup section ini justru memperkenalkan pesaingnya — Vision Transformer, arsitektur yang membuang convolution dan memakai attention murni atas potongan-potongan gambar. Detail ndimage.label yang dipakai eksperimen kita tersedia di dokumentasi scipy
.