Spaces:
Sleeping
Sleeping
| """ | |
| Stress Detection Model | |
| ======================= | |
| Deep learning model for crop stress detection using spatial-temporal encoding. | |
| Architecture: Spatial CNN → Temporal LSTM → Clustering → Anomaly Detection | |
| """ | |
| import numpy as np | |
| import tensorflow as tf | |
| from tensorflow import keras | |
| from tensorflow.keras import layers | |
| from sklearn.cluster import KMeans | |
| from sklearn.ensemble import IsolationForest | |
| from sklearn.preprocessing import StandardScaler | |
| from sklearn.metrics import silhouette_score | |
| from typing import Tuple, Dict, List | |
| import warnings | |
| warnings.filterwarnings('ignore') | |
| class SpatialEncoder(keras.Model): | |
| """ | |
| CNN-based spatial feature extractor. | |
| Processes each timestamp independently to extract spatial features. | |
| """ | |
| def __init__(self, embedding_dim=128): | |
| super(SpatialEncoder, self).__init__() | |
| # Convolutional layers | |
| self.conv1 = layers.Conv2D(32, (3, 3), activation='relu', padding='same') | |
| self.bn1 = layers.BatchNormalization() | |
| self.pool1 = layers.MaxPooling2D((2, 2)) | |
| self.dropout1 = layers.Dropout(0.25) | |
| self.conv2 = layers.Conv2D(64, (3, 3), activation='relu', padding='same') | |
| self.bn2 = layers.BatchNormalization() | |
| self.pool2 = layers.MaxPooling2D((2, 2)) | |
| self.dropout2 = layers.Dropout(0.25) | |
| self.conv3 = layers.Conv2D(128, (3, 3), activation='relu', padding='same') | |
| self.bn3 = layers.BatchNormalization() | |
| # Global pooling and dense layers | |
| self.global_pool = layers.GlobalAveragePooling2D() | |
| self.dense1 = layers.Dense(256, activation='relu') | |
| self.dropout3 = layers.Dropout(0.3) | |
| self.dense2 = layers.Dense(embedding_dim, activation='relu') | |
| def call(self, x, training=False): | |
| # x shape: (batch, height, width, channels) | |
| x = self.conv1(x) | |
| x = self.bn1(x, training=training) | |
| x = self.pool1(x) | |
| x = self.dropout1(x, training=training) | |
| x = self.conv2(x) | |
| x = self.bn2(x, training=training) | |
| x = self.pool2(x) | |
| x = self.dropout2(x, training=training) | |
| x = self.conv3(x) | |
| x = self.bn3(x, training=training) | |
| x = self.global_pool(x) | |
| x = self.dense1(x) | |
| x = self.dropout3(x, training=training) | |
| x = self.dense2(x) | |
| return x # (batch, embedding_dim) | |
| class TemporalEncoder(keras.Model): | |
| """ | |
| LSTM-based temporal feature extractor. | |
| Processes sequence of spatial embeddings to capture temporal patterns. | |
| """ | |
| def __init__(self, embedding_dim=128, lstm_units=64): | |
| super(TemporalEncoder, self).__init__() | |
| self.lstm = layers.Bidirectional( | |
| layers.LSTM(lstm_units, return_sequences=False, dropout=0.2) | |
| ) | |
| self.dense = layers.Dense(embedding_dim, activation='relu') | |
| def call(self, x, training=False): | |
| # x shape: (batch, time, spatial_embedding_dim) | |
| x = self.lstm(x, training=training) | |
| x = self.dense(x) | |
| return x # (batch, embedding_dim) | |
| class StressDetectionModel: | |
| """ | |
| Complete stress detection pipeline with spatial-temporal encoding, | |
| clustering, and anomaly detection. | |
| """ | |
| def __init__(self, patch_size=16, num_bands=8, num_timestamps=10, | |
| spatial_embedding_dim=128, temporal_embedding_dim=128): | |
| self.patch_size = patch_size | |
| self.num_bands = num_bands | |
| self.num_timestamps = num_timestamps | |
| self.spatial_embedding_dim = spatial_embedding_dim | |
| self.temporal_embedding_dim = temporal_embedding_dim | |
| # Build encoders | |
| self.spatial_encoder = SpatialEncoder(embedding_dim=spatial_embedding_dim) | |
| self.temporal_encoder = TemporalEncoder( | |
| embedding_dim=temporal_embedding_dim, | |
| lstm_units=64 | |
| ) | |
| # Build spatial encoder input | |
| self.spatial_encoder.build((None, patch_size, patch_size, num_bands)) | |
| # Clustering and anomaly detection (fitted during inference) | |
| self.kmeans = None | |
| self.anomaly_detector = None | |
| self.scaler = StandardScaler() | |
| def encode_spatial_features(self, patches: np.ndarray) -> np.ndarray: | |
| """ | |
| Extract spatial features from all patches and timestamps. | |
| Args: | |
| patches: Array of shape (num_patches, time, height, width, bands) | |
| Returns: | |
| spatial_embeddings: Array of shape (num_patches, time, spatial_embedding_dim) | |
| """ | |
| num_patches, time, height, width, bands = patches.shape | |
| # Reshape to process all patches and timestamps together | |
| # (num_patches * time, height, width, bands) | |
| reshaped = patches.reshape(-1, height, width, bands) | |
| # Extract spatial features | |
| spatial_features = self.spatial_encoder(reshaped, training=False).numpy() | |
| # Reshape back to (num_patches, time, embedding_dim) | |
| spatial_embeddings = spatial_features.reshape( | |
| num_patches, time, self.spatial_embedding_dim | |
| ) | |
| return spatial_embeddings | |
| def encode_temporal_features(self, spatial_embeddings: np.ndarray) -> np.ndarray: | |
| """ | |
| Extract temporal features from spatial embeddings. | |
| Args: | |
| spatial_embeddings: Array of shape (num_patches, time, spatial_embedding_dim) | |
| Returns: | |
| temporal_embeddings: Array of shape (num_patches, temporal_embedding_dim) | |
| """ | |
| temporal_embeddings = self.temporal_encoder( | |
| spatial_embeddings, training=False | |
| ).numpy() | |
| return temporal_embeddings | |
| def cluster_stress_patterns(self, embeddings: np.ndarray, n_clusters=4) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| Cluster embeddings into stress categories and compute stress scores. | |
| Args: | |
| embeddings: Array of shape (num_patches, embedding_dim) | |
| n_clusters: Number of clusters (4: high, moderate, low, noise) | |
| Returns: | |
| cluster_labels: Cluster assignment for each patch | |
| stress_scores: Normalized stress scores in [0, 1] | |
| """ | |
| # Standardize embeddings | |
| embeddings_scaled = self.scaler.fit_transform(embeddings) | |
| # K-Means clustering | |
| self.kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10) | |
| cluster_labels = self.kmeans.fit_predict(embeddings_scaled) | |
| # Compute stress scores based on distance to cluster centers | |
| distances = self.kmeans.transform(embeddings_scaled) | |
| # For each patch, compute stress score as weighted distance to all clusters | |
| # Normalize to [0, 1] range | |
| stress_scores = np.min(distances, axis=1) # Distance to nearest cluster | |
| stress_scores = 1 - (stress_scores - stress_scores.min()) / (stress_scores.max() - stress_scores.min() + 1e-10) | |
| # Alternative: Use cluster centers to assign stress levels | |
| # Identify which cluster represents highest stress (largest distance from origin) | |
| cluster_stress_levels = np.linalg.norm(self.kmeans.cluster_centers_, axis=1) | |
| cluster_stress_levels = (cluster_stress_levels - cluster_stress_levels.min()) / \ | |
| (cluster_stress_levels.max() - cluster_stress_levels.min() + 1e-10) | |
| # Assign stress score based on cluster membership | |
| stress_scores = cluster_stress_levels[cluster_labels] | |
| return cluster_labels, stress_scores | |
| def detect_anomalies(self, embeddings: np.ndarray, contamination=0.1) -> Tuple[np.ndarray, np.ndarray]: | |
| """ | |
| Detect anomalous stress patterns using Isolation Forest. | |
| Args: | |
| embeddings: Array of shape (num_patches, embedding_dim) | |
| contamination: Expected proportion of anomalies | |
| Returns: | |
| anomaly_labels: 1 for normal, -1 for anomaly | |
| anomaly_scores: Anomaly scores (lower = more anomalous) | |
| """ | |
| self.anomaly_detector = IsolationForest( | |
| contamination=contamination, | |
| random_state=42 | |
| ) | |
| anomaly_labels = self.anomaly_detector.fit_predict(embeddings) | |
| anomaly_scores = self.anomaly_detector.score_samples(embeddings) | |
| return anomaly_labels, anomaly_scores | |
| def predict(self, patches: np.ndarray, n_clusters=4, contamination=0.1) -> Dict: | |
| """ | |
| Complete stress detection pipeline. | |
| Args: | |
| patches: Array of shape (num_patches, time, height, width, bands) | |
| n_clusters: Number of stress clusters | |
| contamination: Expected proportion of anomalies | |
| Returns: | |
| results: Dictionary with all predictions and embeddings | |
| """ | |
| # Step 1: Spatial encoding | |
| spatial_embeddings = self.encode_spatial_features(patches) | |
| # Step 2: Temporal encoding | |
| temporal_embeddings = self.encode_temporal_features(spatial_embeddings) | |
| # Step 3: Clustering | |
| cluster_labels, stress_scores = self.cluster_stress_patterns( | |
| temporal_embeddings, n_clusters=n_clusters | |
| ) | |
| # Step 4: Anomaly detection | |
| anomaly_labels, anomaly_scores = self.detect_anomalies(temporal_embeddings, contamination=contamination) | |
| return { | |
| 'spatial_embeddings': spatial_embeddings, | |
| 'temporal_embeddings': temporal_embeddings, | |
| 'cluster_labels': cluster_labels, | |
| 'stress_scores': stress_scores, | |
| 'anomaly_labels': anomaly_labels, | |
| 'anomaly_scores': anomaly_scores, | |
| 'cluster_centers': self.kmeans.cluster_centers_, | |
| 'n_clusters': n_clusters | |
| } | |
| def get_stress_category(stress_score: float) -> str: | |
| """Convert stress score to category label.""" | |
| if stress_score < 0.25: | |
| return "Low Stress" | |
| elif stress_score < 0.5: | |
| return "Moderate Stress" | |
| elif stress_score < 0.75: | |
| return "High Stress" | |
| else: | |
| return "Severe Stress" | |
| def find_optimal_clusters(embeddings: np.ndarray, | |
| min_clusters: int = 2, | |
| max_clusters: int = 10) -> Tuple[int, Dict]: | |
| """ | |
| Find optimal number of clusters using elbow method and silhouette score. | |
| Args: | |
| embeddings: Array of shape (num_samples, embedding_dim) | |
| min_clusters: Minimum number of clusters to test | |
| max_clusters: Maximum number of clusters to test | |
| Returns: | |
| optimal_k: Optimal number of clusters | |
| metrics: Dictionary with inertia and silhouette scores | |
| """ | |
| print("\nFinding optimal number of clusters...") | |
| scaler = StandardScaler() | |
| embeddings_scaled = scaler.fit_transform(embeddings) | |
| inertias = [] | |
| silhouette_scores = [] | |
| k_range = range(min_clusters, max_clusters + 1) | |
| for k in k_range: | |
| kmeans = KMeans(n_clusters=k, random_state=42, n_init=10) | |
| labels = kmeans.fit_predict(embeddings_scaled) | |
| inertias.append(kmeans.inertia_) | |
| # Calculate silhouette score (higher is better) | |
| if k > 1: | |
| sil_score = silhouette_score(embeddings_scaled, labels) | |
| silhouette_scores.append(sil_score) | |
| else: | |
| silhouette_scores.append(0) | |
| print(f" k={k}: Inertia={kmeans.inertia_:.2f}, Silhouette={silhouette_scores[-1]:.3f}") | |
| # Find elbow using rate of change | |
| inertia_diffs = np.diff(inertias) | |
| inertia_diffs_2 = np.diff(inertia_diffs) | |
| # Optimal k is where second derivative is maximum (elbow point) | |
| elbow_k = min_clusters + np.argmax(np.abs(inertia_diffs_2)) + 1 | |
| # Also consider silhouette score | |
| best_silhouette_k = min_clusters + np.argmax(silhouette_scores) | |
| # Use silhouette score as primary metric, elbow as secondary | |
| optimal_k = best_silhouette_k | |
| print(f"\n[OK] Optimal clusters: {optimal_k} (Elbow: {elbow_k}, Best Silhouette: {best_silhouette_k})") | |
| metrics = { | |
| 'k_range': list(k_range), | |
| 'inertias': inertias, | |
| 'silhouette_scores': silhouette_scores, | |
| 'optimal_k': optimal_k, | |
| 'elbow_k': elbow_k, | |
| 'best_silhouette_k': best_silhouette_k | |
| } | |
| return optimal_k, metrics | |
| def prepare_llm_context(results: Dict, | |
| patch_coords: List, | |
| patches: np.ndarray, | |
| metadata: Dict) -> Dict: | |
| """ | |
| Prepare comprehensive context for LLM including cluster statistics and anomaly information. | |
| Args: | |
| results: Dictionary from model.predict() | |
| patch_coords: List of (h, w) coordinates for each patch | |
| patches: Original patches array | |
| metadata: Preprocessing metadata | |
| Returns: | |
| context: Dictionary with cluster-wise and anomaly statistics | |
| """ | |
| cluster_labels = results['cluster_labels'] | |
| stress_scores = results['stress_scores'] | |
| anomaly_labels = results['anomaly_labels'] | |
| temporal_embeddings = results['temporal_embeddings'] | |
| # Get anomaly scores (distance from decision boundary) | |
| anomaly_scores = results.get('anomaly_scores', | |
| results['anomaly_labels'].astype(float)) | |
| # Cluster-wise statistics | |
| cluster_stats = [] | |
| for cluster_id in range(results['n_clusters']): | |
| cluster_mask = cluster_labels == cluster_id | |
| cluster_patches = patches[cluster_mask] | |
| cluster_stress = stress_scores[cluster_mask] | |
| cluster_embeddings = temporal_embeddings[cluster_mask] | |
| # Calculate statistics for this cluster | |
| stats = { | |
| 'cluster_id': int(cluster_id), | |
| 'num_patches': int(np.sum(cluster_mask)), | |
| 'percentage': float(100 * np.sum(cluster_mask) / len(cluster_labels)), | |
| 'stress_score': { | |
| 'mean': float(cluster_stress.mean()), | |
| 'std': float(cluster_stress.std()), | |
| 'min': float(cluster_stress.min()), | |
| 'max': float(cluster_stress.max()) | |
| }, | |
| 'embedding_stats': { | |
| 'mean_norm': float(np.linalg.norm(cluster_embeddings.mean(axis=0))), | |
| 'std_norm': float(np.linalg.norm(cluster_embeddings.std(axis=0))) | |
| }, | |
| 'band_statistics': {} | |
| } | |
| # Calculate per-band statistics for this cluster | |
| for band_idx, band_name in enumerate(metadata['selected_bands']): | |
| band_data = cluster_patches[:, :, :, :, band_idx] # (patches, time, h, w) | |
| stats['band_statistics'][band_name] = { | |
| 'mean': float(np.nanmean(band_data)), | |
| 'std': float(np.nanstd(band_data)), | |
| 'min': float(np.nanmin(band_data)), | |
| 'max': float(np.nanmax(band_data)) | |
| } | |
| cluster_stats.append(stats) | |
| # Calculate temporal trends for this cluster | |
| # Shape: (num_patches, time, h, w, bands) -> (time, bands) | |
| if cluster_patches.shape[0] > 0: | |
| cluster_time_series = np.nanmean(cluster_patches, axis=(0, 2, 3)) | |
| stats['temporal_trends'] = {} | |
| for band_idx, band_name in enumerate(metadata['selected_bands']): | |
| series = cluster_time_series[:, band_idx] | |
| if len(series) > 1: | |
| change = float(series[-1] - series[0]) | |
| trend_direction = "stable" | |
| if change > 0.05: trend_direction = "increasing" | |
| elif change < -0.05: trend_direction = "decreasing" | |
| stats['temporal_trends'][band_name] = { | |
| 'change': change, | |
| 'trend_direction': trend_direction, | |
| 'latest_value': float(series[-1]), | |
| 'earliest_value': float(series[0]) | |
| } | |
| # Anomaly information | |
| anomaly_mask = anomaly_labels == -1 | |
| anomaly_indices = np.where(anomaly_mask)[0] | |
| anomaly_info = { | |
| 'total_anomalies': int(np.sum(anomaly_mask)), | |
| 'anomaly_percentage': float(100 * np.sum(anomaly_mask) / len(anomaly_labels)), | |
| 'anomaly_patches': [] | |
| } | |
| # Detailed info for each anomaly patch | |
| for idx in anomaly_indices[:20]: # Limit to first 20 anomalies | |
| patch_info = { | |
| 'patch_id': int(idx), | |
| 'coordinates': patch_coords[idx], | |
| 'stress_score': float(stress_scores[idx]), | |
| 'stress_category': get_stress_category(stress_scores[idx]), | |
| 'cluster_id': int(cluster_labels[idx]), | |
| 'anomaly_score': float(anomaly_scores[idx]) if hasattr(anomaly_scores, '__getitem__') else -1.0, | |
| 'embedding_norm': float(np.linalg.norm(temporal_embeddings[idx])) | |
| } | |
| anomaly_info['anomaly_patches'].append(patch_info) | |
| # Overall field statistics | |
| field_stats = { | |
| 'total_patches': len(cluster_labels), | |
| 'patch_size': metadata['patch_size'], | |
| 'num_bands': metadata['num_bands'], | |
| 'selected_bands': metadata['selected_bands'], | |
| 'overall_stress': { | |
| 'mean': float(stress_scores.mean()), | |
| 'std': float(stress_scores.std()), | |
| 'min': float(stress_scores.min()), | |
| 'max': float(stress_scores.max()) | |
| }, | |
| 'stress_distribution': { | |
| 'low': int(np.sum(stress_scores < 0.25)), | |
| 'moderate': int(np.sum((stress_scores >= 0.25) & (stress_scores < 0.5))), | |
| 'high': int(np.sum((stress_scores >= 0.5) & (stress_scores < 0.75))), | |
| 'severe': int(np.sum(stress_scores >= 0.75)) | |
| } | |
| } | |
| context = { | |
| 'field_statistics': field_stats, | |
| 'cluster_statistics': cluster_stats, | |
| 'anomaly_information': anomaly_info | |
| } | |
| return context | |