import cv2 import numpy as np from PIL import Image def _detect_human_face(gray_image): """Return (detected, face_area_ratio) for human face detection.""" try: cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' face_cascade = cv2.CascadeClassifier(cascade_path) if face_cascade.empty(): return False, 0.0 faces = face_cascade.detectMultiScale( gray_image, scaleFactor=1.1, minNeighbors=5, minSize=(50, 50), ) if len(faces) == 0: return False, 0.0 image_area = float(gray_image.shape[0] * gray_image.shape[1]) largest_face_area = max((w * h for (_, _, w, h) in faces), default=0) face_area_ratio = (largest_face_area / image_area) if image_area > 0 else 0.0 return face_area_ratio >= 0.03, face_area_ratio except Exception: return False, 0.0 def validate_cattle_image(image_path, confidence_threshold=0.65): """ 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) # Tolak foto wajah manusia lebih awal karena sering lolos pada heuristik warna/tekstur. human_face_detected, face_area_ratio = _detect_human_face(gray) if human_face_detected: print(f"[VALIDATE] Wajah manusia terdeteksi | area={face_area_ratio*100:.1f}%") return False, 0.0, "Gambar tidak menunjukkan sapi" # ===== 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 dibuat lebih longgar supaya foto sapi yang crop/lighting-nya beragam tetap lolos if color_ratio < 0.18: return False, 0.0, "Gambar tidak menunjukkan sapi" # Score color: ketat untuk matching sapi if color_ratio >= 0.45: color_score = 1.0 elif color_ratio >= 0.30: color_score = 0.90 elif color_ratio >= 0.18: color_score = 0.75 else: color_score = 0.55 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) # Range texture dibuat lebih fleksibel karena foto asli bisa sangat bervariasi if texture_variance < 45 or texture_variance > 3500: return False, 0.0, "Gambar tidak menunjukkan tekstur sapi" # Score texture: ketat untuk matching sapi texture if 100 <= texture_variance <= 2200: texture_score = 1.0 elif 70 <= texture_variance <= 3000: texture_score = 0.85 else: texture_score = 0.70 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 dibuat lebih longgar supaya gambar close-up atau background polos tidak langsung gagal if edge_density < 0.01 or edge_density > 0.35: return False, 0.0, "Gambar tidak memiliki struktur sapi" # Score edge: ketat untuk matching if 0.04 <= edge_density <= 0.18: edge_score = 1.0 elif 0.02 <= edge_density <= 0.28: edge_score = 0.85 else: edge_score = 0.65 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.55: return False, 0.0, "Warna gambar tidak sesuai sapi" # REQUIREMENT 2: Texture harus dalam range sapi if texture_score < 0.60: return False, 0.0, "Tekstur gambar tidak sesuai sapi" # REQUIREMENT 3: Edge harus terdeteksi if edge_score < 0.55: 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=(256, 256), 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)}")