TIF_E41253336/facerecognition_presensi/ModelBaru/enhanced_augmentation.py

985 lines
37 KiB
Python

"""
Enhanced Data Augmentation Module
=================================
Enhanced augmentation methods for face recognition:
1. CLAHE (Contrast Limited Adaptive Histogram Equalization)
2. Gamma Correction
3. Sharpening
4. Histogram Equalization
5. Original methods (brightness, contrast, rotation, noise)
These augmentations are moderate - not too extreme to preserve embedding quality.
Author: Skripsi 2026
"""
import cv2
import numpy as np
from typing import Tuple, List, Optional
import logging
from config import AUGMENTATION_CONFIG
log = logging.getLogger(__name__)
class EnhancedAugmentation:
"""
Enhanced face augmentation with multiple transformation methods.
Unlike standard augmentations, these are designed to be moderate
and preserve the essential face features for recognition.
"""
def __init__(self):
self.config = AUGMENTATION_CONFIG
self.methods = self.config['methods']
def apply_clahe(self, image: np.ndarray) -> np.ndarray:
"""
Apply CLAHE (Contrast Limited Adaptive Histogram Equalization).
CLAHE is particularly useful for faces with poor lighting
as it enhances local contrast without over-amplifying noise.
Args:
image: BGR or grayscale image
Returns:
CLAHE-enhanced image
"""
cfg = self.methods['clahe']
if not cfg.get('enabled', True):
return image
# Convert to grayscale if needed (for LBPH)
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
# Random clip limit
clip_limit = np.random.uniform(*cfg['clip_limit'])
# Create CLAHE
clahe = cv2.createCLAHE(
clipLimit=clip_limit,
tileGridSize=cfg['tile_size']
)
# Apply
enhanced = clahe.apply(gray)
# Convert back to BGR if input was BGR
if len(image.shape) == 3:
enhanced = cv2.cvtColor(enhanced, cv2.COLOR_GRAY2BGR)
return enhanced
def apply_gamma(self, image: np.ndarray) -> np.ndarray:
"""
Apply Gamma correction.
Gamma < 1: Brighten dark areas
Gamma > 1: Darken bright areas
Args:
image: BGR or grayscale image
Returns:
Gamma-corrected image
"""
cfg = self.methods['gamma']
if not cfg.get('enabled', True):
return image
gamma = np.random.uniform(*cfg['gamma_range'])
# Build lookup table
inv_gamma = 1.0 / gamma
table = np.array([
((i / 255.0) ** inv_gamma) * 255
for i in np.arange(0, 256)
]).astype("uint8")
# Apply
return cv2.LUT(image, table)
def apply_sharpen(self, image: np.ndarray) -> np.ndarray:
"""
Apply sharpening filter.
Uses an unsharp masking approach which enhances edges
without introducing artifacts.
Args:
image: BGR or grayscale image
Returns:
Sharpened image
"""
cfg = self.methods['sharpen']
if not cfg.get('enabled', True):
return image
strength = np.random.uniform(*cfg['kernel_range'])
# Create sharpening kernel
# Standard sharpening kernel with adjustable strength
kernel = np.array([
[0, -1, 0],
[-1, 4 + strength, -1],
[0, -1, 0]
]) / (strength + 1)
# Apply
if len(image.shape) == 3:
sharpened = cv2.filter2D(image, -1, kernel)
else:
sharpened = cv2.filter2D(image, -1, kernel)
# Blend with original (0.3 = 30% sharpened, 70% original)
# This keeps the augmentation moderate
alpha = 0.3
result = cv2.addWeighted(
image, 1 - alpha,
sharpened, alpha,
0
)
return result
def apply_histogram_equalization(self, image: np.ndarray) -> np.ndarray:
"""
Apply global histogram equalization.
This stretches the histogram to use the full dynamic range,
improving overall contrast.
Note: CLAHE is generally preferred for faces as it preserves
local structure better.
Args:
image: BGR or grayscale image
Returns:
Histogram-equalized image
"""
cfg = self.methods['hist_eq']
if not cfg.get('enabled', True):
return image
if len(image.shape) == 3:
# Convert to YCrCb, equalize Y channel
ycrcb = cv2.cvtColor(image, cv2.COLOR_BGR2YCrCb)
ycrcb[:, :, 0] = cv2.equalizeHist(ycrcb[:, :, 0])
return cv2.cvtColor(ycrcb, cv2.COLOR_YCrCb2BGR)
else:
return cv2.equalizeHist(image)
def apply_brightness(self, image: np.ndarray) -> np.ndarray:
"""
Adjust brightness.
Args:
image: BGR or grayscale image
Returns:
Brightness-adjusted image
"""
cfg = self.methods['brightness']
if not cfg.get('enabled', True):
return image
factor = np.random.uniform(*cfg['factor_range'])
if len(image.shape) == 3:
# For BGR, adjust each channel
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
hsv[:, :, 2] = np.clip(hsv[:, :, 2].astype(np.float32) * factor, 0, 255).astype(np.uint8)
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
else:
return np.clip(image.astype(np.float32) * factor, 0, 255).astype(np.uint8)
def apply_rotation(self, image: np.ndarray) -> np.ndarray:
"""
Apply slight rotation.
Rotation is kept small (±8°) to avoid drastic changes
to the embedding.
Args:
image: BGR or grayscale image
Returns:
Rotated image
"""
cfg = self.methods['rotation']
if not cfg.get('enabled', True):
return image
angle = np.random.uniform(-cfg['max_angle'], cfg['max_angle'])
h, w = image.shape[:2]
# Get rotation matrix
M = cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0)
# Apply rotation
return cv2.warpAffine(
image, M, (w, h),
borderMode=cv2.BORDER_REFLECT,
borderValue=0
)
def apply_noise(self, image: np.ndarray) -> np.ndarray:
"""
Add Gaussian noise.
Args:
image: BGR or grayscale image
Returns:
Noisy image
"""
cfg = self.methods.get('noise', {})
if not cfg.get('enabled', True):
return image
std = np.random.uniform(*cfg.get('std_range', (3.0, 12.0)))
# Gaussian noise
noise = np.random.normal(0, std, image.shape)
noisy = image.astype(np.float32) + noise
return np.clip(noisy, 0, 255).astype(np.uint8)
def apply_shadows(self, image: np.ndarray) -> np.ndarray:
"""
Simulasi shadow / pencahayaan tidak merata (vignette satu sisi).
Ini mensimulasi kondisi di gedung jurusan di mana cahaya datang dari
satu arah (jendela atau lampu overhead), menghasilkan bayangan gradien
pada separuh wajah. Augmentasi ini sangat penting untuk membuat gallery
embedding robust terhadap kondisi pencahayaan tidak merata.
Args:
image: BGR or grayscale image
Returns:
Image dengan shadow gradient
"""
cfg = self.methods.get('shadows', {})
if not cfg.get('enabled', True):
return image
intensity_range = cfg.get('intensity_range', (0.3, 0.7))
intensity = np.random.uniform(*intensity_range)
h, w = image.shape[:2]
# Pilih arah shadow secara random:
# 0 = dari kiri, 1 = dari kanan, 2 = dari atas, 3 = dari bawah
direction = np.random.randint(0, 4)
# Buat gradient mask (0.0 = shadow gelap, 1.0 = normal)
mask = np.ones((h, w), dtype=np.float32)
shadow_strength = 1.0 - intensity # Misal intensity=0.5 → shadow=0.5
if direction == 0: # Shadow dari kiri
for col in range(w // 2):
alpha = shadow_strength + (1.0 - shadow_strength) * (col / (w / 2))
mask[:, col] = alpha
elif direction == 1: # Shadow dari kanan
for col in range(w // 2, w):
alpha = shadow_strength + (1.0 - shadow_strength) * ((w - col) / (w / 2))
mask[:, col] = alpha
elif direction == 2: # Shadow dari atas
for row in range(h // 2):
alpha = shadow_strength + (1.0 - shadow_strength) * (row / (h / 2))
mask[row, :] = alpha
else: # Shadow dari bawah
for row in range(h // 2, h):
alpha = shadow_strength + (1.0 - shadow_strength) * ((h - row) / (h / 2))
mask[row, :] = alpha
# Apply mask
if len(image.shape) == 3:
mask_3ch = np.stack([mask, mask, mask], axis=2)
result = (image.astype(np.float32) * mask_3ch)
else:
result = (image.astype(np.float32) * mask)
return np.clip(result, 0, 255).astype(np.uint8)
def apply_color_temperature(self, image: np.ndarray,
temp: str = None) -> np.ndarray:
"""
Simulasi perbedaan color temperature pencahayaan.
Kamera Android sering menghasilkan color cast yang berbeda
tergantung sumber cahaya:
- Warm (tungsten/incandescent): kekuningan, boost Red, reduce Blue
- Cool (neon/fluorescent/TL): kebiruan, boost Blue, reduce Red
Args:
image: BGR image
temp: 'warm', 'cool', atau None (random)
Returns:
Image dengan color temperature shift
"""
cfg = self.methods.get('color_temp', {})
if not cfg.get('enabled', True):
return image
if len(image.shape) != 3:
return image # Hanya untuk BGR
if temp is None:
temp = np.random.choice(['warm', 'cool'])
result = image.astype(np.float32)
if temp == 'warm':
shift = np.random.uniform(*cfg.get('warm_shift', (10, 30)))
# BGR: boost Red (channel 2), reduce Blue (channel 0)
result[:, :, 2] = np.clip(result[:, :, 2] + shift, 0, 255)
result[:, :, 0] = np.clip(result[:, :, 0] - shift * 0.5, 0, 255)
else: # cool
shift = np.random.uniform(*cfg.get('cool_shift', (10, 30)))
# BGR: boost Blue (channel 0), reduce Red (channel 2)
result[:, :, 0] = np.clip(result[:, :, 0] + shift, 0, 255)
result[:, :, 2] = np.clip(result[:, :, 2] - shift * 0.5, 0, 255)
return result.astype(np.uint8)
def apply_jpeg_compression(self, image: np.ndarray,
quality: int = None) -> np.ndarray:
"""
Simulasi artefak kompresi JPEG dari kamera Android & WhatsApp.
Foto dari HP sering di-compress beberapa kali:
1. Kamera HP compress saat save (quality ~85)
2. App/WhatsApp compress ulang saat kirim (quality ~60)
3. Server bisa compress lagi saat simpan
Ini menyebabkan artefak blok yang mengubah distribusi pixel.
Args:
image: BGR image
quality: JPEG quality (1-100), lower = more artifacts
Returns:
Image dengan JPEG compression artifacts
"""
cfg = self.methods.get('jpeg_compression', {})
if not cfg.get('enabled', True):
return image
if quality is None:
quality = int(np.random.uniform(*cfg.get('quality_range', (30, 70))))
# Encode ke JPEG buffer dan decode kembali
encode_param = [int(cv2.IMWRITE_JPEG_QUALITY), quality]
_, encoded = cv2.imencode('.jpg', image, encode_param)
decoded = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
if decoded is None:
return image
# Pastikan ukuran sama (seharusnya selalu sama)
if decoded.shape != image.shape:
decoded = cv2.resize(decoded, (image.shape[1], image.shape[0]))
return decoded
def apply_gaussian_blur(self, image: np.ndarray,
kernel_size: int = None) -> np.ndarray:
"""
Simulasi sedikit out-of-focus dari kamera HP.
Kamera Android (terutama front camera) sering sedikit tidak fokus
saat autofocus gagal atau objek bergerak.
Args:
image: BGR or grayscale image
kernel_size: Kernel size (harus ganjil: 3, 5)
Returns:
Slightly blurred image
"""
cfg = self.methods.get('gaussian_blur', {})
if not cfg.get('enabled', True):
return image
if kernel_size is None:
k_range = cfg.get('kernel_range', (3, 5))
kernel_size = np.random.choice(
[k for k in range(k_range[0], k_range[1] + 1) if k % 2 == 1]
)
return cv2.GaussianBlur(image, (kernel_size, kernel_size), 0)
def apply_exposure_simulation(self, image: np.ndarray,
mode: str = None) -> np.ndarray:
"""
Simulasi auto-exposure kamera Android yang realistis.
Berbeda dari simple brightness adjustment, ini mengkombinasikan:
- Brightness adjustment
- Gamma correction
- Sedikit saturation shift
Ini lebih mendekati bagaimana kamera HP sebenarnya menghandle
exposure otomatis di berbagai kondisi.
Args:
image: BGR image
mode: 'under' (indoor gelap), 'over' (outdoor terang), atau None (random)
Returns:
Exposure-adjusted image
"""
cfg = self.methods.get('exposure_simulation', {})
if not cfg.get('enabled', True):
return image
if len(image.shape) != 3:
return image
if mode is None:
mode = np.random.choice(['under', 'over'])
result = image.copy()
if mode == 'under':
# Underexposed: gelap + gamma tinggi + saturation turun
factor = np.random.uniform(*cfg.get('underexpose_range', (0.4, 0.7)))
gamma = np.random.uniform(1.2, 1.6)
# Apply brightness
hsv = cv2.cvtColor(result, cv2.COLOR_BGR2HSV).astype(np.float32)
hsv[:, :, 2] *= factor
hsv[:, :, 1] *= 0.85 # Saturation turun sedikit saat gelap
hsv = np.clip(hsv, 0, 255).astype(np.uint8)
result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
# Apply gamma
inv_gamma = 1.0 / gamma
table = np.array([(i / 255.0) ** inv_gamma * 255
for i in range(256)]).astype(np.uint8)
result = cv2.LUT(result, table)
else:
# Overexposed: terang + gamma rendah + saturation naik sedikit
factor = np.random.uniform(*cfg.get('overexpose_range', (1.3, 1.8)))
gamma = np.random.uniform(0.6, 0.85)
# Apply brightness
hsv = cv2.cvtColor(result, cv2.COLOR_BGR2HSV).astype(np.float32)
hsv[:, :, 2] *= factor
hsv[:, :, 1] *= 1.1 # Saturation naik sedikit saat overexposed
hsv = np.clip(hsv, 0, 255).astype(np.uint8)
result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
# Apply gamma
inv_gamma = 1.0 / gamma
table = np.array([(i / 255.0) ** inv_gamma * 255
for i in range(256)]).astype(np.uint8)
result = cv2.LUT(result, table)
return result
def augment(
self,
image: np.ndarray,
max_methods: int = None,
methods: List[str] = None
) -> np.ndarray:
"""
Apply random augmentation combination.
Args:
image: Input image (BGR or grayscale)
max_methods: Maximum number of methods to apply (default: config)
methods: Specific methods to apply (default: random selection)
Returns:
Augmented image
"""
if not self.config.get('enabled', True):
return image
max_methods = max_methods or self.config.get('max_combined', 3)
# Select methods based on probability
if methods is None:
available_methods = []
# Daftar semua augmentasi yang tersedia (termasuk yang baru)
all_aug_names = ['clahe', 'gamma', 'sharpen', 'hist_eq',
'brightness', 'rotation', 'noise', 'shadows',
'color_temp', 'jpeg_compression',
'gaussian_blur', 'exposure_simulation']
for name in all_aug_names:
cfg = self.methods.get(name, {})
if cfg.get('enabled', True) and np.random.random() < cfg.get('probability', 0.5):
available_methods.append(name)
# Randomly select up to max_methods
if len(available_methods) > max_methods:
methods = list(np.random.choice(available_methods, max_methods, replace=False))
else:
methods = available_methods
# Apply selected methods
augmented = image.copy()
for method in methods:
if method == 'clahe':
augmented = self.apply_clahe(augmented)
elif method == 'gamma':
augmented = self.apply_gamma(augmented)
elif method == 'sharpen':
augmented = self.apply_sharpen(augmented)
elif method == 'hist_eq':
augmented = self.apply_histogram_equalization(augmented)
elif method == 'brightness':
augmented = self.apply_brightness(augmented)
elif method == 'rotation':
augmented = self.apply_rotation(augmented)
elif method == 'noise':
augmented = self.apply_noise(augmented)
elif method == 'shadows':
augmented = self.apply_shadows(augmented)
elif method == 'color_temp':
augmented = self.apply_color_temperature(augmented)
elif method == 'jpeg_compression':
augmented = self.apply_jpeg_compression(augmented)
elif method == 'gaussian_blur':
augmented = self.apply_gaussian_blur(augmented)
elif method == 'exposure_simulation':
augmented = self.apply_exposure_simulation(augmented)
return augmented
def generate_versions(
self,
image: np.ndarray,
n_versions: int = None,
include_original: bool = True
) -> List[np.ndarray]:
"""
Generate multiple augmented versions.
Args:
image: Original image
n_versions: Number of augmented versions (default: config)
include_original: Include original in output
Returns:
List of image versions (original + augmentations)
"""
n_versions = n_versions or self.config.get('enrollment_copies', 3)
versions = []
if include_original:
versions.append(image)
for _ in range(n_versions):
versions.append(self.augment(image))
return versions
def generate_lighting_variants(
self,
image: np.ndarray,
include_original: bool = False
) -> List[np.ndarray]:
"""
Generate 7 kondisi cahaya deterministik untuk enrollment.
Berbeda dari generate_versions() yang random, method ini
MENJAMIN gallery mencakup 7 skenario cahaya nyata:
1. Terang (gamma 0.6, brightness 1.25) — outdoor / lampu kuat
2. Gelap (gamma 1.5, brightness 0.6) — indoor sore
3. Sangat gelap + CLAHE (brightness 0.4) — indoor malam
4. Overexposed (brightness 1.5, sedikit saturasi) — flash HP
5. Shadow kiri (cahaya dari kanan) — dekat jendela
6. Shadow kanan (cahaya dari kiri) — dekat jendela sisi lain
7. CLAHE agresif (normalisasi histogram) — kamera kualitas rendah
Args:
image: Original BGR face crop
include_original: Apakah menyertakan gambar original
Returns:
List of augmented images (7 atau 8 item)
"""
variants = []
if include_original:
variants.append(image.copy())
h, w = image.shape[:2]
# ── Variant 1: TERANG — Outdoor / lampu studio ──────────────────────
# Simulasi: foto enrollment diambil di luar ruangan atau dekat jendela
bright = image.astype(np.float32)
# Gamma < 1 → mencerahkan (inverse gamma correction)
gamma_table = np.array([(i / 255.0) ** (1.0 / 0.6) * 255
for i in range(256)]).astype(np.uint8)
v1 = cv2.LUT(image, gamma_table)
v1 = cv2.convertScaleAbs(v1, alpha=1.0, beta=10) # sedikit offset
variants.append(np.clip(v1, 0, 255).astype(np.uint8))
# ── Variant 2: GELAP — Indoor sore ──────────────────────────────────
# Simulasi: ruang kelas di sore hari tanpa lampu cukup
gamma_table_dark = np.array([(i / 255.0) ** 1.5 * 255
for i in range(256)]).astype(np.uint8)
v2 = cv2.LUT(image, gamma_table_dark)
hsv = cv2.cvtColor(v2, cv2.COLOR_BGR2HSV)
hsv[:, :, 2] = np.clip(hsv[:, :, 2].astype(np.float32) * 0.65, 0, 255).astype(np.uint8)
variants.append(cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR))
# ── Variant 3: SANGAT GELAP + CLAHE ─────────────────────────────────
# Simulasi: indoor malam, kamera HP dengan auto-brightness
hsv3 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
hsv3[:, :, 2] = np.clip(hsv3[:, :, 2].astype(np.float32) * 0.42, 0, 255).astype(np.uint8)
dark_face = cv2.cvtColor(hsv3, cv2.COLOR_HSV2BGR)
lab3 = cv2.cvtColor(dark_face, cv2.COLOR_BGR2LAB)
clahe_strong = cv2.createCLAHE(clipLimit=3.5, tileGridSize=(8, 8))
lab3[:, :, 0] = clahe_strong.apply(lab3[:, :, 0])
variants.append(cv2.cvtColor(lab3, cv2.COLOR_LAB2BGR))
# ── Variant 4: OVEREXPOSED ───────────────────────────────────────────
# Simulasi: flash HP langsung, atau foto di luar ruangan tengah hari
hsv4 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
hsv4[:, :, 2] = np.clip(hsv4[:, :, 2].astype(np.float32) * 1.55, 0, 255).astype(np.uint8)
v4 = cv2.cvtColor(hsv4, cv2.COLOR_HSV2BGR)
variants.append(np.clip(v4, 0, 255).astype(np.uint8))
# ── Variant 5: SHADOW KIRI ───────────────────────────────────────────
# Simulasi: cahaya dari kanan (jendela sisi kanan), wajah kiri gelap
mask_left = np.ones((h, w), dtype=np.float32)
for col in range(w // 2):
alpha = 0.35 + 0.65 * (col / (w / 2))
mask_left[:, col] = alpha
mask_left_3ch = np.stack([mask_left] * 3, axis=2)
v5 = np.clip(image.astype(np.float32) * mask_left_3ch, 0, 255).astype(np.uint8)
variants.append(v5)
# ── Variant 6: SHADOW KANAN ──────────────────────────────────────────
# Simulasi: cahaya dari kiri (jendela sisi kiri), wajah kanan gelap
mask_right = np.ones((h, w), dtype=np.float32)
for col in range(w // 2, w):
alpha = 0.35 + 0.65 * ((w - col) / (w / 2))
mask_right[:, col] = alpha
mask_right_3ch = np.stack([mask_right] * 3, axis=2)
v6 = np.clip(image.astype(np.float32) * mask_right_3ch, 0, 255).astype(np.uint8)
variants.append(v6)
# ── Variant 7: CLAHE AGRESIF ─────────────────────────────────────────
# Simulasi: kamera kualitas rendah / foto lama — histogram sangat normalized
lab7 = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
clahe_agg = cv2.createCLAHE(clipLimit=4.5, tileGridSize=(4, 4))
lab7[:, :, 0] = clahe_agg.apply(lab7[:, :, 0])
v7 = cv2.cvtColor(lab7, cv2.COLOR_LAB2BGR)
variants.append(v7)
return variants
def generate_android_variants(
self,
image: np.ndarray,
include_original: bool = False
) -> List[np.ndarray]:
"""
Generate 10 kondisi deterministik yang mensimulasi kamera Android.
Method ini fokus pada kondisi yang TIDAK tercakup oleh
generate_lighting_variants, khususnya:
- Color temperature shift (warm/cool)
- JPEG compression artifacts
- Noise sensor di kondisi gelap
- Kombinasi realistis dari beberapa faktor sekaligus
10 varian:
1. Indoor lampu neon (cool temp + gelap ringan)
2. Indoor lampu tungsten (warm temp + shadow ringan)
3. Outdoor teduh (gamma ringan + noise ringan + JPEG)
4. Outdoor terang langsung (overexposed + slight warm + JPEG)
5. Malam/gelap total (very dark + noise tinggi + CLAHE agresif)
6. Backlight (shadow atas kuat + terang bawah)
7. HP dekat jendela kiri (shadow kanan kuat + warm)
8. HP dekat jendela kanan (shadow kiri kuat + cool)
9. Foto terkompresi (JPEG q=25 + slight blur + noise)
10. Kamera depan HP (flip + slight warm + noise + JPEG)
Args:
image: Original BGR face crop
include_original: Apakah menyertakan gambar original
Returns:
List of augmented images (10 atau 11 item)
"""
variants = []
if include_original:
variants.append(image.copy())
h, w = image.shape[:2]
# ── Variant 1: INDOOR LAMPU NEON ─────────────────────────────────────
# Lampu TL/neon menghasilkan cahaya kebiruan + sedikit gelap
v1 = image.astype(np.float32)
v1[:, :, 0] = np.clip(v1[:, :, 0] + 20, 0, 255) # Blue boost
v1[:, :, 2] = np.clip(v1[:, :, 2] - 10, 0, 255) # Red reduce
hsv1 = cv2.cvtColor(v1.astype(np.uint8), cv2.COLOR_BGR2HSV).astype(np.float32)
hsv1[:, :, 2] *= 0.80 # Gelap ringan
hsv1 = np.clip(hsv1, 0, 255).astype(np.uint8)
variants.append(cv2.cvtColor(hsv1, cv2.COLOR_HSV2BGR))
# ── Variant 2: INDOOR LAMPU TUNGSTEN ─────────────────────────────────
# Lampu pijar/tungsten = kekuningan + shadow ringan dari satu sisi
v2 = image.astype(np.float32)
v2[:, :, 2] = np.clip(v2[:, :, 2] + 25, 0, 255) # Red boost
v2[:, :, 0] = np.clip(v2[:, :, 0] - 15, 0, 255) # Blue reduce
v2[:, :, 1] = np.clip(v2[:, :, 1] + 5, 0, 255) # Green sedikit naik
# Shadow ringan dari kiri
mask2 = np.ones((h, w), dtype=np.float32)
for col in range(w // 3):
mask2[:, col] = 0.6 + 0.4 * (col / (w / 3))
mask2_3ch = np.stack([mask2] * 3, axis=2)
v2 = np.clip(v2 * mask2_3ch, 0, 255).astype(np.uint8)
variants.append(v2)
# ── Variant 3: OUTDOOR TEDUH ─────────────────────────────────────────
# Teduh = cahaya diffuse, sedikit noise, JPEG dari share HP
gamma_table = np.array([(i / 255.0) ** 0.85 * 255
for i in range(256)]).astype(np.uint8)
v3 = cv2.LUT(image, gamma_table)
# Noise ringan
noise3 = np.random.normal(0, 5, v3.shape).astype(np.float32)
v3 = np.clip(v3.astype(np.float32) + noise3, 0, 255).astype(np.uint8)
# JPEG compression
_, enc3 = cv2.imencode('.jpg', v3, [cv2.IMWRITE_JPEG_QUALITY, 55])
v3 = cv2.imdecode(enc3, cv2.IMREAD_COLOR)
if v3 is not None:
variants.append(v3)
else:
variants.append(image.copy())
# ── Variant 4: OUTDOOR TERANG LANGSUNG ───────────────────────────────
# Sinar matahari langsung = overexposed + slight warm + JPEG
hsv4 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV).astype(np.float32)
hsv4[:, :, 2] = np.clip(hsv4[:, :, 2] * 1.6, 0, 255)
hsv4[:, :, 1] = np.clip(hsv4[:, :, 1] * 1.1, 0, 255) # Sedikit saturasi
v4 = cv2.cvtColor(np.clip(hsv4, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR)
# Slight warm (matahari = warm)
v4 = v4.astype(np.float32)
v4[:, :, 2] = np.clip(v4[:, :, 2] + 10, 0, 255)
v4 = v4.astype(np.uint8)
# JPEG
_, enc4 = cv2.imencode('.jpg', v4, [cv2.IMWRITE_JPEG_QUALITY, 50])
v4 = cv2.imdecode(enc4, cv2.IMREAD_COLOR)
if v4 is not None:
variants.append(v4)
else:
variants.append(image.copy())
# ── Variant 5: MALAM / GELAP TOTAL ───────────────────────────────────
# Kamera HP di malam hari: sangat gelap + noise tinggi + auto-CLAHE
hsv5 = cv2.cvtColor(image, cv2.COLOR_BGR2HSV).astype(np.float32)
hsv5[:, :, 2] *= 0.30 # Sangat gelap
hsv5[:, :, 1] *= 0.7 # Saturation turun
v5 = cv2.cvtColor(np.clip(hsv5, 0, 255).astype(np.uint8), cv2.COLOR_HSV2BGR)
# Noise tinggi (sensor noise di low light)
noise5 = np.random.normal(0, 15, v5.shape).astype(np.float32)
v5 = np.clip(v5.astype(np.float32) + noise5, 0, 255).astype(np.uint8)
# CLAHE agresif (simulasi auto-brightness HP)
lab5 = cv2.cvtColor(v5, cv2.COLOR_BGR2LAB)
clahe5 = cv2.createCLAHE(clipLimit=4.0, tileGridSize=(8, 8))
lab5[:, :, 0] = clahe5.apply(lab5[:, :, 0])
variants.append(cv2.cvtColor(lab5, cv2.COLOR_LAB2BGR))
# ── Variant 6: BACKLIGHT ─────────────────────────────────────────────
# Cahaya dari belakang subjek: bagian atas terang, bawah gelap
mask6 = np.ones((h, w), dtype=np.float32)
for row in range(h):
# Gradien: atas terang (1.3), tengah normal (1.0), bawah gelap (0.5)
if row < h // 3:
mask6[row, :] = 1.3 - 0.3 * (row / (h / 3))
elif row > 2 * h // 3:
mask6[row, :] = 1.0 - 0.5 * ((row - 2 * h // 3) / (h / 3))
mask6_3ch = np.stack([mask6] * 3, axis=2)
v6 = np.clip(image.astype(np.float32) * mask6_3ch, 0, 255).astype(np.uint8)
variants.append(v6)
# ── Variant 7: HP DEKAT JENDELA KIRI ─────────────────────────────────
# Cahaya kuat dari kiri: wajah kanan sangat gelap + warm tint
mask7 = np.ones((h, w), dtype=np.float32)
for col in range(w):
if col > w // 3:
mask7[:, col] = 1.0 - 0.6 * ((col - w // 3) / (2 * w / 3))
mask7_3ch = np.stack([mask7] * 3, axis=2)
v7 = np.clip(image.astype(np.float32) * mask7_3ch, 0, 255)
v7[:, :, 2] = np.clip(v7[:, :, 2] + 10, 0, 255) # Warm tint
variants.append(v7.astype(np.uint8))
# ── Variant 8: HP DEKAT JENDELA KANAN ────────────────────────────────
# Cahaya kuat dari kanan: wajah kiri sangat gelap + cool tint
mask8 = np.ones((h, w), dtype=np.float32)
for col in range(w):
if col < 2 * w // 3:
mask8[:, col] = 0.4 + 0.6 * (col / (2 * w / 3))
mask8_3ch = np.stack([mask8] * 3, axis=2)
v8 = np.clip(image.astype(np.float32) * mask8_3ch, 0, 255)
v8[:, :, 0] = np.clip(v8[:, :, 0] + 10, 0, 255) # Cool tint
variants.append(v8.astype(np.uint8))
# ── Variant 9: FOTO TERKOMPRESI ──────────────────────────────────────
# Foto yang sudah di-share berkali-kali: heavy JPEG + blur + noise
v9 = cv2.GaussianBlur(image, (3, 3), 0)
noise9 = np.random.normal(0, 8, v9.shape).astype(np.float32)
v9 = np.clip(v9.astype(np.float32) + noise9, 0, 255).astype(np.uint8)
_, enc9 = cv2.imencode('.jpg', v9, [cv2.IMWRITE_JPEG_QUALITY, 25])
v9 = cv2.imdecode(enc9, cv2.IMREAD_COLOR)
if v9 is not None:
variants.append(v9)
else:
variants.append(image.copy())
# ── Variant 10: KAMERA DEPAN HP ──────────────────────────────────────
# Front camera = mirrored + slight warm + noise + JPEG
v10 = cv2.flip(image, 1) # Horizontal flip (mirror)
v10 = v10.astype(np.float32)
v10[:, :, 2] = np.clip(v10[:, :, 2] + 8, 0, 255) # Slight warm
v10[:, :, 0] = np.clip(v10[:, :, 0] - 5, 0, 255)
noise10 = np.random.normal(0, 6, v10.shape).astype(np.float32)
v10 = np.clip(v10 + noise10, 0, 255).astype(np.uint8)
_, enc10 = cv2.imencode('.jpg', v10, [cv2.IMWRITE_JPEG_QUALITY, 60])
v10 = cv2.imdecode(enc10, cv2.IMREAD_COLOR)
if v10 is not None:
variants.append(v10)
else:
variants.append(image.copy())
return variants
# Backward compatibility with old Augmentation class
class Augmentation(EnhancedAugmentation):
"""
Backward compatibility wrapper.
Uses the new EnhancedAugmentation class.
"""
def __init__(self):
super().__init__()
@staticmethod
def random_brightness(image, factor_range=(0.8, 1.2)):
"""Legacy method - use enhanced version."""
cfg = {'factor_range': factor_range, 'enabled': True}
result = image.copy()
factor = np.random.uniform(*factor_range)
if len(image.shape) == 3:
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
hsv[:, :, 2] = np.clip(hsv[:, :, 2].astype(np.float32) * factor, 0, 255).astype(np.uint8)
result = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
else:
result = np.clip(image.astype(np.float32) * factor, 0, 255).astype(np.uint8)
return result
@staticmethod
def random_contrast(image, factor_range=(0.8, 1.2)):
"""Legacy method."""
factor = np.random.uniform(*factor_range)
mean = image.mean()
return np.clip((image - mean) * factor + mean, 0, 255).astype(np.uint8)
@staticmethod
def random_rotation(image, max_angle=10):
"""Legacy method."""
angle = np.random.uniform(-max_angle, max_angle)
h, w = image.shape[:2]
M = cv2.getRotationMatrix2D((w/2, h/2), angle, 1.0)
return cv2.warpAffine(image, M, (w, h), borderMode=cv2.BORDER_REPLICATE)
@staticmethod
def random_noise(image, std=5.0):
"""Legacy method."""
noise = np.random.normal(0, std, image.shape)
return np.clip(image.astype(np.float32) + noise, 0, 255).astype(np.uint8)
@classmethod
def augment(cls, image):
"""Legacy method."""
aug = cls()
return aug.augment(image)
@classmethod
def generate_versions(cls, image, n=4):
"""Legacy method."""
aug = cls()
return aug.generate_versions(image, n_versions=n, include_original=True)
def augment_face(
face: np.ndarray,
method: str = 'random',
seed: Optional[int] = None
) -> np.ndarray:
"""
Convenience function for face augmentation.
Args:
face: Face crop
method: Augmentation method ('clahe', 'gamma', 'sharpen', 'hist_eq', 'random')
seed: Random seed for reproducibility
Returns:
Augmented face
"""
if seed is not None:
np.random.seed(seed)
aug = EnhancedAugmentation()
if method == 'clahe':
return aug.apply_clahe(face)
elif method == 'gamma':
return aug.apply_gamma(face)
elif method == 'sharpen':
return aug.apply_sharpen(face)
elif method == 'hist_eq':
return aug.apply_histogram_equalization(face)
else: # 'random'
return aug.augment(face)
# Test function
if __name__ == "__main__":
import sys
log.info("Testing EnhancedAugmentation...")
# Test with sample image
if len(sys.argv) > 1:
image = cv2.imread(sys.argv[1])
if image is not None:
aug = EnhancedAugmentation()
# Generate versions
versions = aug.generate_versions(image, n_versions=4)
log.info(f"Generated {len(versions)} versions")
# Save examples
for i, v in enumerate(versions):
cv2.imwrite(f"aug_test_{i}.jpg", v)
log.info("Saved augmented versions")
else:
# Synthetic test
image = np.random.randint(0, 255, (112, 112, 3), dtype=np.uint8)
aug = EnhancedAugmentation()
versions = aug.generate_versions(image, n_versions=3)
log.info(f"Generated {len(versions)} versions")