309 lines
12 KiB
Python
309 lines
12 KiB
Python
import cv2
|
||
import numpy as np
|
||
from PIL import Image
|
||
|
||
|
||
def validate_cattle_image(image_path, confidence_threshold=0.75):
|
||
"""
|
||
Validasi apakah gambar adalah sapi atau bukan.
|
||
Bisa deteksi wajah sapi (dengan mata) atau bagian tubuh sapi lainnya.
|
||
|
||
Returns: (is_cattle, confidence, reason)
|
||
"""
|
||
try:
|
||
# Baca gambar
|
||
img = cv2.imread(image_path)
|
||
if img is None:
|
||
try:
|
||
pil_img = Image.open(image_path).convert('RGB')
|
||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||
except:
|
||
return False, 0.0, "Format gambar tidak didukung"
|
||
|
||
if img is None:
|
||
return False, 0.0, "Gagal membaca gambar"
|
||
|
||
h, w = img.shape[:2]
|
||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||
|
||
# ===== LAYER 1: DETEKSI MATA (OPTIONAL - hanya bonus jika ada) =====
|
||
circles = cv2.HoughCircles(
|
||
gray, cv2.HOUGH_GRADIENT,
|
||
dp=1, minDist=30,
|
||
param1=70, param2=35,
|
||
minRadius=5, maxRadius=70
|
||
)
|
||
|
||
valid_eyes = []
|
||
eye_score = 0.0
|
||
|
||
if circles is not None and len(circles[0]) > 0:
|
||
circles = np.uint16(np.around(circles))
|
||
for circle in circles[0]:
|
||
x, y, r = int(circle[0]), int(circle[1]), int(circle[2])
|
||
roi = gray[max(0, y-r):min(h, y+r), max(0, x-r):min(w, x+r)]
|
||
if roi.size > 0:
|
||
roi_mean = np.mean(roi)
|
||
roi_std = np.std(roi)
|
||
if roi_mean < 105 and roi_std > 12:
|
||
valid_eyes.append((x, y, r))
|
||
|
||
# Validasi mata jika terdeteksi (untuk bonus score)
|
||
if len(valid_eyes) >= 2:
|
||
eye_positions = [(x, y) for x, y, r in valid_eyes[:2]]
|
||
eye_x = sorted([x for x, y in eye_positions])
|
||
eye_y = [valid_eyes[i][1] for i in range(2)]
|
||
eye_distances = [abs(eye_x[1] - eye_x[0]), abs(eye_y[0] - eye_y[1])]
|
||
|
||
# Mata harus horizontal (left-right)
|
||
if eye_distances[0] >= 20 and eye_distances[1] <= eye_distances[0] * 0.3:
|
||
eye_score = 1.0
|
||
print(f"[VALIDATE] Mata terdeteksi | Spacing: h={eye_distances[0]:.0f}, v={eye_distances[1]:.0f}")
|
||
|
||
|
||
# ===== LAYER 2: VALIDASI WARNA SAPI (FLEKSIBEL) =====
|
||
img_hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
||
h_channel = img_hsv[:, :, 0].astype(np.float32)
|
||
s_channel = img_hsv[:, :, 1].astype(np.float32)
|
||
v_channel = img_hsv[:, :, 2].astype(np.float32)
|
||
|
||
# Warna sapi: coklat, merah, hitam, putih (bisa partial)
|
||
brown_mask = ((h_channel >= 8) & (h_channel <= 30) & (s_channel >= 30) & (v_channel >= 60))
|
||
red_mask = (((h_channel <= 3) | (h_channel >= 175)) & (s_channel >= 35) & (v_channel >= 50))
|
||
black_mask = ((v_channel < 90) & (s_channel >= 15))
|
||
white_mask = ((v_channel >= 210) & (s_channel <= 50))
|
||
|
||
cattle_color = brown_mask | red_mask | black_mask | white_mask
|
||
color_ratio = np.sum(cattle_color) / (h * w)
|
||
|
||
# Minimum warna sapi harus 30% (ketat untuk menolak bukan sapi)
|
||
if color_ratio < 0.30:
|
||
return False, 0.0, "Gambar tidak menunjukkan sapi"
|
||
|
||
# Score color: ketat untuk matching sapi
|
||
if color_ratio >= 0.50:
|
||
color_score = 1.0
|
||
elif color_ratio >= 0.40:
|
||
color_score = 0.95
|
||
elif color_ratio >= 0.30:
|
||
color_score = 0.80
|
||
else:
|
||
color_score = 0.60
|
||
print(f"[VALIDATE] Warna sapi: {color_ratio*100:.0f}%")
|
||
|
||
# ===== LAYER 3: VALIDASI TEKSTUR KULIT =====
|
||
laplacian = cv2.Laplacian(gray, cv2.CV_64F)
|
||
texture_variance = np.var(laplacian)
|
||
|
||
# Texture sapi harus dalam range spesifik 80-2200 (ketat)
|
||
if texture_variance < 80 or texture_variance > 2200:
|
||
return False, 0.0, "Gambar tidak menunjukkan tekstur sapi"
|
||
|
||
# Score texture: ketat untuk matching sapi texture
|
||
if 120 <= texture_variance <= 1800:
|
||
texture_score = 1.0
|
||
elif 90 <= texture_variance <= 2100:
|
||
texture_score = 0.90
|
||
else:
|
||
texture_score = 0.75
|
||
print(f"[VALIDATE] Tekstur variance: {texture_variance:.0f}")
|
||
|
||
# ===== LAYER 4: EDGE DETECTION (Struktur) =====
|
||
edges = cv2.Canny(gray, 50, 150)
|
||
edge_density = np.sum(edges > 0) / (h * w)
|
||
|
||
# Edge density: 2-25% ketat untuk sapi
|
||
if edge_density < 0.02 or edge_density > 0.25:
|
||
return False, 0.0, "Gambar tidak memiliki struktur sapi"
|
||
|
||
# Score edge: ketat untuk matching
|
||
if 0.05 <= edge_density <= 0.15:
|
||
edge_score = 1.0
|
||
elif 0.03 <= edge_density <= 0.22:
|
||
edge_score = 0.90
|
||
else:
|
||
edge_score = 0.70
|
||
print(f"[VALIDATE] Edge density: {edge_density*100:.1f}%")
|
||
|
||
# ===== HARD REQUIREMENTS =====
|
||
# REQUIREMENT 1: Warna harus minimal decent (tidak boleh terlalu rendah)
|
||
if color_score < 0.75:
|
||
return False, 0.0, "Warna gambar tidak sesuai sapi"
|
||
|
||
# REQUIREMENT 2: Texture harus dalam range sapi
|
||
if texture_score < 0.70:
|
||
return False, 0.0, "Tekstur gambar tidak sesuai sapi"
|
||
|
||
# REQUIREMENT 3: Edge harus terdeteksi
|
||
if edge_score < 0.65:
|
||
return False, 0.0, "Struktur gambar tidak terlihat jelas"
|
||
|
||
# ===== FINAL CALCULATION =====
|
||
# Ketat: semua feature harus bagus untuk lolos
|
||
final_confidence = (
|
||
eye_score * 0.15 + # Mata (15% - bonus)
|
||
color_score * 0.40 + # Warna (40% - paling penting)
|
||
texture_score * 0.30 + # Tekstur (30% - penting)
|
||
edge_score * 0.15 # Edge (15% - supporting)
|
||
)
|
||
|
||
print(f"[VALIDATE] Final Score: {final_confidence*100:.1f}% (threshold: {confidence_threshold*100:.0f}%)")
|
||
|
||
if final_confidence >= confidence_threshold:
|
||
return True, final_confidence, None
|
||
else:
|
||
return False, final_confidence, "Bukan sapi atau gambar kurang jelas"
|
||
|
||
except Exception as e:
|
||
print(f"[VALIDATE] ERROR: {str(e)}")
|
||
return False, 0.0, f"Error: {str(e)}"
|
||
|
||
|
||
def preprocess_image(
|
||
image_path,
|
||
target_size=(256, 256),
|
||
apply_threshold=False,
|
||
thresh_method='otsu',
|
||
thresh_val=127,
|
||
morph_ops=None,
|
||
kernel_size=3,
|
||
morph_iterations=1
|
||
):
|
||
"""
|
||
Preprocess image and optionally produce a binary mask using thresholding
|
||
and morphological operations.
|
||
|
||
Returns: (img_normalized_rgb, img_resized_bgr, mask)
|
||
- img_normalized_rgb: RGB image normalized to 0..1 (numpy float32)
|
||
- img_resized_bgr: resized BGR image as read by OpenCV (uint8)
|
||
- mask: binary mask (uint8 0/255) or None if not requested
|
||
|
||
Parameters:
|
||
- apply_threshold: if True, compute a binary mask from grayscale image
|
||
- thresh_method: 'otsu' or 'binary'
|
||
- thresh_val: threshold value used when thresh_method=='binary'
|
||
- morph_ops: list of operations to apply in order. Supported: 'erode',
|
||
'dilate', 'opening', 'closing'
|
||
- kernel_size: size of structuring element (odd integer)
|
||
- morph_iterations: number of iterations for erosion/dilation
|
||
"""
|
||
try:
|
||
# Read image using OpenCV
|
||
img = cv2.imread(image_path)
|
||
if img is None:
|
||
# Try with PIL if OpenCV fails
|
||
pil_img = Image.open(image_path).convert('RGB')
|
||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||
|
||
# Resize image (uses width, height tuple)
|
||
img_resized = cv2.resize(img, target_size)
|
||
|
||
# Convert to RGB and normalize
|
||
img_rgb = cv2.cvtColor(img_resized, cv2.COLOR_BGR2RGB).astype(np.float32)
|
||
img_normalized = img_rgb / 255.0
|
||
|
||
mask = None
|
||
if apply_threshold or (morph_ops is not None and len(morph_ops) > 0):
|
||
# Convert to grayscale for thresholding/morphology
|
||
gray = cv2.cvtColor(img_resized, cv2.COLOR_BGR2GRAY)
|
||
|
||
# Optionally apply threshold
|
||
if apply_threshold:
|
||
if thresh_method == 'otsu':
|
||
blur = cv2.GaussianBlur(gray, (5, 5), 0)
|
||
_, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||
else:
|
||
_, mask = cv2.threshold(gray, int(thresh_val), 255, cv2.THRESH_BINARY)
|
||
else:
|
||
# start from simple binary of gray (non-thresholded) if morph ops requested
|
||
_, mask = cv2.threshold(gray, int(thresh_val), 255, cv2.THRESH_BINARY)
|
||
|
||
# Apply morphological operations if requested
|
||
if morph_ops:
|
||
# Ensure kernel size is odd and >=1
|
||
ks = int(kernel_size)
|
||
if ks <= 0:
|
||
ks = 1
|
||
if ks % 2 == 0:
|
||
ks += 1
|
||
|
||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ks, ks))
|
||
|
||
for op in morph_ops:
|
||
op_lower = op.lower()
|
||
if op_lower == 'erode':
|
||
mask = cv2.erode(mask, kernel, iterations=morph_iterations)
|
||
elif op_lower == 'dilate':
|
||
mask = cv2.dilate(mask, kernel, iterations=morph_iterations)
|
||
elif op_lower == 'opening':
|
||
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=morph_iterations)
|
||
elif op_lower == 'closing':
|
||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=morph_iterations)
|
||
else:
|
||
# ignore unknown ops but continue
|
||
continue
|
||
|
||
return img_normalized, img_resized, mask
|
||
|
||
except Exception as e:
|
||
raise ValueError(f"Error preprocessing image {image_path}: {str(e)}")
|
||
|
||
|
||
def preprocess_pipeline(
|
||
image_path,
|
||
target_size=(128, 128),
|
||
apply_threshold=True,
|
||
thresh_method='otsu',
|
||
thresh_val=127,
|
||
):
|
||
"""
|
||
Simple preprocessing pipeline:
|
||
1. Resize gambar
|
||
2. Threshold / binarisasi
|
||
3. Ekstraksi fitur RGB dan GLCM
|
||
|
||
Returns: (img_resized_rgb, gray_processed)
|
||
- img_resized_rgb: RGB image untuk fitur RGB (uint8)
|
||
- gray_processed: grayscale hasil threshold untuk fitur GLCM (uint8)
|
||
"""
|
||
try:
|
||
# Step 1: Baca image
|
||
img = cv2.imread(image_path)
|
||
if img is None:
|
||
# Try with PIL if OpenCV fails
|
||
pil_img = Image.open(image_path).convert('RGB')
|
||
img = cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)
|
||
|
||
if img is None:
|
||
raise ValueError(f"Gagal membaca image dari {image_path}")
|
||
|
||
# Step 2: Resize image ke target size (default 128×128)
|
||
img_resized = cv2.resize(img, target_size)
|
||
|
||
# Step 3: Threshold pada grayscale untuk memisahkan objek dari background
|
||
gray = cv2.cvtColor(img_resized, cv2.COLOR_BGR2GRAY)
|
||
|
||
if apply_threshold:
|
||
if thresh_method == 'otsu':
|
||
_, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||
else:
|
||
_, mask = cv2.threshold(gray, int(thresh_val), 255, cv2.THRESH_BINARY)
|
||
else:
|
||
mask = np.full(gray.shape, 255, dtype=np.uint8)
|
||
|
||
# Terapkan mask ke RGB dan grayscale supaya fitur hanya membaca area relevan
|
||
masked_bgr = cv2.bitwise_and(img_resized, img_resized, mask=mask)
|
||
img_resized_rgb = cv2.cvtColor(masked_bgr, cv2.COLOR_BGR2RGB)
|
||
gray_processed = cv2.bitwise_and(gray, gray, mask=mask)
|
||
|
||
# Validate outputs
|
||
if img_resized_rgb is None:
|
||
raise ValueError("img_resized_rgb is None after conversion")
|
||
if gray_processed is None:
|
||
raise ValueError("gray_processed is None after thresholding")
|
||
|
||
return img_resized_rgb, gray_processed
|
||
|
||
except Exception as e:
|
||
print(f"[ERROR] preprocess_pipeline failed for {image_path}: {str(e)}")
|
||
raise ValueError(f"Error in preprocessing pipeline {image_path}: {str(e)}") |