detection_engine.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. """
  2. Wakeword detection engine with confidence scoring and inference optimization.
  3. This module provides the core detection engine that processes audio features
  4. and generates wakeword predictions with confidence scores and threshold management.
  5. """
  6. import time
  7. import threading
  8. from typing import Optional, Dict, List, Tuple, Callable, Union
  9. from dataclasses import dataclass
  10. from enum import Enum
  11. import warnings
  12. import torch
  13. import torch.nn as nn
  14. import torch.nn.functional as F
  15. import numpy as np
  16. from .audio_features import AudioFeatureExtractor, SpectrogramConfig
  17. from .audio_buffer import AudioChunk
  18. from .model_loader import ModelMetadata
  19. class WakewordType(Enum):
  20. """Supported wakeword types."""
  21. CUSTOM = "custom"
  22. SYSTEM_COMMAND = "system_command"
  23. NEGATIVE = "negative" # Not a wakeword
  24. @dataclass
  25. class DetectionResult:
  26. """Result of wakeword detection."""
  27. wakeword_type: WakewordType
  28. confidence: float
  29. raw_scores: Dict[str, float]
  30. processing_time_ms: float
  31. chunk_id: int
  32. timestamp: float
  33. features_shape: Tuple[int, ...]
  34. @property
  35. def is_wakeword_detected(self) -> bool:
  36. """Check if a valid wakeword was detected."""
  37. return self.wakeword_type in [WakewordType.CUSTOM, WakewordType.SYSTEM_COMMAND]
  38. def to_dict(self) -> Dict:
  39. """Convert result to dictionary."""
  40. return {
  41. 'wakeword_type': self.wakeword_type.value,
  42. 'confidence': self.confidence,
  43. 'raw_scores': self.raw_scores,
  44. 'processing_time_ms': self.processing_time_ms,
  45. 'chunk_id': self.chunk_id,
  46. 'timestamp': self.timestamp,
  47. 'features_shape': list(self.features_shape),
  48. 'is_wakeword_detected': self.is_wakeword_detected
  49. }
  50. @dataclass
  51. class DetectionConfig:
  52. """Configuration for wakeword detection."""
  53. # Confidence thresholds
  54. custom_threshold: float = 0.7
  55. system_command_threshold: float = 0.8 # Higher threshold for admin commands
  56. negative_threshold: float = 0.3
  57. # Temporal filtering
  58. use_temporal_smoothing: bool = True
  59. smoothing_window_size: int = 5
  60. min_detections_in_window: int = 3
  61. # Performance optimization
  62. use_half_precision: bool = False
  63. enable_model_optimization: bool = True
  64. batch_inference: bool = False
  65. max_batch_size: int = 8
  66. # Debugging
  67. log_all_predictions: bool = False
  68. save_detection_features: bool = False
  69. def validate(self) -> bool:
  70. """Validate configuration parameters."""
  71. if not (0.0 <= self.custom_threshold <= 1.0):
  72. return False
  73. if not (0.0 <= self.system_command_threshold <= 1.0):
  74. return False
  75. if not (0.0 <= self.negative_threshold <= 1.0):
  76. return False
  77. if self.smoothing_window_size < 1:
  78. return False
  79. if self.min_detections_in_window < 1:
  80. return False
  81. if self.max_batch_size < 1:
  82. return False
  83. return True
  84. class ConfidenceCalibrator:
  85. """
  86. Calibrate model outputs to well-calibrated probability scores.
  87. Uses temperature scaling and Platt scaling for confidence calibration.
  88. """
  89. def __init__(self):
  90. self.temperature = 1.0
  91. self.platt_a = 1.0
  92. self.platt_b = 0.0
  93. self.is_calibrated = False
  94. def calibrate_with_temperature(self, logits: torch.Tensor) -> torch.Tensor:
  95. """Apply temperature scaling to logits."""
  96. return logits / self.temperature
  97. def calibrate_with_platt(self, scores: torch.Tensor) -> torch.Tensor:
  98. """Apply Platt scaling to probability scores."""
  99. return torch.sigmoid(self.platt_a * scores + self.platt_b)
  100. def set_temperature(self, temperature: float):
  101. """Set temperature parameter."""
  102. self.temperature = max(0.1, temperature)
  103. self.is_calibrated = True
  104. def set_platt_parameters(self, a: float, b: float):
  105. """Set Platt scaling parameters."""
  106. self.platt_a = a
  107. self.platt_b = b
  108. self.is_calibrated = True
  109. class TemporalFilter:
  110. """
  111. Temporal filtering for reducing false positives.
  112. Implements various temporal smoothing strategies.
  113. """
  114. def __init__(self, window_size: int, min_detections: int):
  115. self.window_size = window_size
  116. self.min_detections = min_detections
  117. self.detection_history = []
  118. self._lock = threading.Lock()
  119. def add_detection(self, result: DetectionResult) -> bool:
  120. """
  121. Add detection result and check if temporal condition is met.
  122. Args:
  123. result: Detection result
  124. Returns:
  125. True if temporal filtering criteria are met
  126. """
  127. with self._lock:
  128. # Add current detection
  129. self.detection_history.append(result)
  130. # Keep only recent detections
  131. if len(self.detection_history) > self.window_size:
  132. self.detection_history.pop(0)
  133. # Count positive detections in window
  134. positive_detections = sum(
  135. 1 for r in self.detection_history
  136. if r.is_wakeword_detected and r.wakeword_type == result.wakeword_type
  137. )
  138. return positive_detections >= self.min_detections
  139. def clear(self):
  140. """Clear detection history."""
  141. with self._lock:
  142. self.detection_history.clear()
  143. def get_history(self) -> List[DetectionResult]:
  144. """Get detection history."""
  145. with self._lock:
  146. return self.detection_history.copy()
  147. class WakewordDetectionEngine:
  148. """
  149. Core wakeword detection engine with optimized inference and confidence scoring.
  150. This class handles the complete detection pipeline from audio features
  151. to wakeword classification with confidence estimation.
  152. """
  153. def __init__(self, model: nn.Module, metadata: ModelMetadata,
  154. feature_extractor: AudioFeatureExtractor,
  155. config: DetectionConfig,
  156. device: Optional[torch.device] = None):
  157. """
  158. Initialize wakeword detection engine.
  159. Args:
  160. model: Trained PyTorch model
  161. metadata: Model metadata
  162. feature_extractor: Audio feature extractor
  163. config: Detection configuration
  164. device: PyTorch device
  165. """
  166. self.model = model
  167. self.metadata = metadata
  168. self.feature_extractor = feature_extractor
  169. self.config = config
  170. self.device = device or torch.device("cpu")
  171. # Validate configuration
  172. if not config.validate():
  173. raise ValueError("Invalid detection configuration")
  174. # Move model to device and set to eval mode
  175. self.model.to(self.device)
  176. self.model.eval()
  177. # Optimize model if requested
  178. if config.enable_model_optimization:
  179. self._optimize_model()
  180. # Set up half precision if requested
  181. if config.use_half_precision and self.device.type == "cuda":
  182. self.model = self.model.half()
  183. self._use_half_precision = True
  184. else:
  185. self._use_half_precision = False
  186. # Initialize confidence calibrator
  187. self.confidence_calibrator = ConfidenceCalibrator()
  188. # Initialize temporal filter if enabled
  189. if config.use_temporal_smoothing:
  190. self.temporal_filter = TemporalFilter(
  191. config.smoothing_window_size,
  192. config.min_detections_in_window
  193. )
  194. else:
  195. self.temporal_filter = None
  196. # Class mapping
  197. self.class_labels = metadata.class_labels
  198. self.class_to_wakeword = {
  199. "custom": WakewordType.CUSTOM,
  200. "system_command": WakewordType.SYSTEM_COMMAND,
  201. "negative": WakewordType.NEGATIVE
  202. }
  203. # Performance tracking
  204. self._inference_stats = {
  205. 'total_inferences': 0,
  206. 'total_time_ms': 0.0,
  207. 'avg_time_ms': 0.0,
  208. 'min_time_ms': float('inf'),
  209. 'max_time_ms': 0.0,
  210. 'detections_count': {wt.value: 0 for wt in WakewordType}
  211. }
  212. self._lock = threading.Lock()
  213. def _optimize_model(self):
  214. """Apply model optimizations for inference."""
  215. try:
  216. # Try to use TorchScript if possible
  217. if hasattr(self.model, 'reparameterize'):
  218. # RepCNN-specific optimization
  219. self.model.reparameterize()
  220. # Compile model for better performance (PyTorch 2.0+)
  221. if hasattr(torch, 'compile'):
  222. self.model = torch.compile(self.model, mode="reduce-overhead")
  223. except Exception as e:
  224. warnings.warn(f"Model optimization failed: {e}")
  225. def detect_wakeword(self, audio_chunk: AudioChunk) -> DetectionResult:
  226. """
  227. Detect wakeword in audio chunk.
  228. Args:
  229. audio_chunk: Audio chunk to process
  230. Returns:
  231. Detection result with confidence scores
  232. """
  233. start_time = time.time()
  234. try:
  235. # Extract features
  236. features = self.feature_extractor.extract_features(
  237. audio_chunk.to_tensor(self.device),
  238. normalize_audio=True
  239. )
  240. # Run inference
  241. with torch.no_grad():
  242. if self._use_half_precision:
  243. features = features.half()
  244. # Forward pass
  245. logits = self.model(features)
  246. # Apply softmax to get probabilities
  247. probabilities = F.softmax(logits, dim=-1)
  248. # Apply confidence calibration if available
  249. if self.confidence_calibrator.is_calibrated:
  250. probabilities = self.confidence_calibrator.calibrate_with_platt(probabilities)
  251. # Convert to numpy for processing
  252. probs = probabilities.cpu().float().numpy().squeeze()
  253. # Create raw scores dictionary
  254. raw_scores = {}
  255. for i, label in enumerate(self.class_labels):
  256. raw_scores[label] = float(probs[i])
  257. # Determine predicted class and confidence
  258. predicted_class_idx = np.argmax(probs)
  259. predicted_label = self.class_labels[predicted_class_idx]
  260. confidence = float(probs[predicted_class_idx])
  261. # Map to wakeword type
  262. wakeword_type = self.class_to_wakeword.get(predicted_label, WakewordType.NEGATIVE)
  263. # Apply threshold-based filtering
  264. is_above_threshold = self._check_confidence_threshold(wakeword_type, confidence)
  265. if not is_above_threshold:
  266. wakeword_type = WakewordType.NEGATIVE
  267. confidence = max(raw_scores.get("negative", 0.0), 1.0 - confidence)
  268. # Calculate processing time
  269. processing_time_ms = (time.time() - start_time) * 1000
  270. # Create result
  271. result = DetectionResult(
  272. wakeword_type=wakeword_type,
  273. confidence=confidence,
  274. raw_scores=raw_scores,
  275. processing_time_ms=processing_time_ms,
  276. chunk_id=audio_chunk.chunk_id,
  277. timestamp=audio_chunk.timestamp,
  278. features_shape=features.shape
  279. )
  280. # Apply temporal filtering if enabled
  281. if self.temporal_filter:
  282. temporal_pass = self.temporal_filter.add_detection(result)
  283. if not temporal_pass and result.is_wakeword_detected:
  284. # Override detection if temporal filter doesn't pass
  285. result.wakeword_type = WakewordType.NEGATIVE
  286. result.confidence = raw_scores.get("negative", 0.5)
  287. # Update statistics
  288. self._update_stats(result)
  289. return result
  290. except Exception as e:
  291. # Return negative result on error
  292. processing_time_ms = (time.time() - start_time) * 1000
  293. return DetectionResult(
  294. wakeword_type=WakewordType.NEGATIVE,
  295. confidence=0.0,
  296. raw_scores={"error": 1.0},
  297. processing_time_ms=processing_time_ms,
  298. chunk_id=audio_chunk.chunk_id,
  299. timestamp=audio_chunk.timestamp,
  300. features_shape=(0,)
  301. )
  302. def detect_batch(self, audio_chunks: List[AudioChunk]) -> List[DetectionResult]:
  303. """
  304. Batch detection for multiple audio chunks.
  305. Args:
  306. audio_chunks: List of audio chunks
  307. Returns:
  308. List of detection results
  309. """
  310. if not self.config.batch_inference or len(audio_chunks) == 1:
  311. # Process individually
  312. return [self.detect_wakeword(chunk) for chunk in audio_chunks]
  313. # Batch processing
  314. batch_size = min(len(audio_chunks), self.config.max_batch_size)
  315. results = []
  316. for i in range(0, len(audio_chunks), batch_size):
  317. batch = audio_chunks[i:i + batch_size]
  318. batch_results = self._process_batch(batch)
  319. results.extend(batch_results)
  320. return results
  321. def _process_batch(self, audio_chunks: List[AudioChunk]) -> List[DetectionResult]:
  322. """Process a batch of audio chunks."""
  323. start_time = time.time()
  324. try:
  325. # Extract features for all chunks
  326. batch_features = []
  327. for chunk in audio_chunks:
  328. features = self.feature_extractor.extract_features(
  329. chunk.to_tensor(self.device),
  330. normalize_audio=True
  331. )
  332. batch_features.append(features)
  333. # Stack into batch tensor
  334. batch_tensor = torch.cat(batch_features, dim=0)
  335. # Run batch inference
  336. with torch.no_grad():
  337. if self._use_half_precision:
  338. batch_tensor = batch_tensor.half()
  339. batch_logits = self.model(batch_tensor)
  340. batch_probabilities = F.softmax(batch_logits, dim=-1)
  341. if self.confidence_calibrator.is_calibrated:
  342. batch_probabilities = self.confidence_calibrator.calibrate_with_platt(batch_probabilities)
  343. batch_probs = batch_probabilities.cpu().float().numpy()
  344. # Process results for each chunk
  345. results = []
  346. total_processing_time = (time.time() - start_time) * 1000
  347. processing_time_per_chunk = total_processing_time / len(audio_chunks)
  348. for i, (chunk, probs) in enumerate(zip(audio_chunks, batch_probs)):
  349. # Create raw scores
  350. raw_scores = {}
  351. for j, label in enumerate(self.class_labels):
  352. raw_scores[label] = float(probs[j])
  353. # Determine prediction
  354. predicted_class_idx = np.argmax(probs)
  355. predicted_label = self.class_labels[predicted_class_idx]
  356. confidence = float(probs[predicted_class_idx])
  357. wakeword_type = self.class_to_wakeword.get(predicted_label, WakewordType.NEGATIVE)
  358. # Apply threshold filtering
  359. if not self._check_confidence_threshold(wakeword_type, confidence):
  360. wakeword_type = WakewordType.NEGATIVE
  361. confidence = max(raw_scores.get("negative", 0.0), 1.0 - confidence)
  362. result = DetectionResult(
  363. wakeword_type=wakeword_type,
  364. confidence=confidence,
  365. raw_scores=raw_scores,
  366. processing_time_ms=processing_time_per_chunk,
  367. chunk_id=chunk.chunk_id,
  368. timestamp=chunk.timestamp,
  369. features_shape=batch_features[i].shape
  370. )
  371. results.append(result)
  372. self._update_stats(result)
  373. return results
  374. except Exception as e:
  375. # Return negative results on error
  376. processing_time_per_chunk = (time.time() - start_time) * 1000 / len(audio_chunks)
  377. return [
  378. DetectionResult(
  379. wakeword_type=WakewordType.NEGATIVE,
  380. confidence=0.0,
  381. raw_scores={"error": 1.0},
  382. processing_time_ms=processing_time_per_chunk,
  383. chunk_id=chunk.chunk_id,
  384. timestamp=chunk.timestamp,
  385. features_shape=(0,)
  386. )
  387. for chunk in audio_chunks
  388. ]
  389. def _check_confidence_threshold(self, wakeword_type: WakewordType, confidence: float) -> bool:
  390. """Check if confidence meets threshold for wakeword type."""
  391. if wakeword_type == WakewordType.CUSTOM:
  392. return confidence >= self.config.custom_threshold
  393. elif wakeword_type == WakewordType.SYSTEM_COMMAND:
  394. return confidence >= self.config.system_command_threshold
  395. elif wakeword_type == WakewordType.NEGATIVE:
  396. return confidence >= self.config.negative_threshold
  397. return False
  398. def _update_stats(self, result: DetectionResult):
  399. """Update performance statistics."""
  400. with self._lock:
  401. self._inference_stats['total_inferences'] += 1
  402. self._inference_stats['total_time_ms'] += result.processing_time_ms
  403. self._inference_stats['avg_time_ms'] = (
  404. self._inference_stats['total_time_ms'] / self._inference_stats['total_inferences']
  405. )
  406. self._inference_stats['min_time_ms'] = min(
  407. self._inference_stats['min_time_ms'], result.processing_time_ms
  408. )
  409. self._inference_stats['max_time_ms'] = max(
  410. self._inference_stats['max_time_ms'], result.processing_time_ms
  411. )
  412. self._inference_stats['detections_count'][result.wakeword_type.value] += 1
  413. def update_thresholds(self, custom_threshold: Optional[float] = None,
  414. system_command_threshold: Optional[float] = None,
  415. negative_threshold: Optional[float] = None):
  416. """Update confidence thresholds."""
  417. if custom_threshold is not None:
  418. self.config.custom_threshold = custom_threshold
  419. if system_command_threshold is not None:
  420. self.config.system_command_threshold = system_command_threshold
  421. if negative_threshold is not None:
  422. self.config.negative_threshold = negative_threshold
  423. def calibrate_confidence(self, validation_data: List[Tuple[torch.Tensor, int]]):
  424. """
  425. Calibrate confidence scores using validation data.
  426. Args:
  427. validation_data: List of (features, label) tuples
  428. """
  429. if not validation_data:
  430. return
  431. # Extract features and labels
  432. features_list = []
  433. labels_list = []
  434. for features, label in validation_data:
  435. features_list.append(features)
  436. labels_list.append(label)
  437. # Stack tensors
  438. all_features = torch.stack(features_list).to(self.device)
  439. all_labels = torch.tensor(labels_list).to(self.device)
  440. # Get model predictions
  441. with torch.no_grad():
  442. logits = self.model(all_features)
  443. probabilities = F.softmax(logits, dim=-1)
  444. # Implement temperature scaling (simplified)
  445. # This is a basic implementation - for production use, implement proper optimization
  446. temperatures = [0.5, 1.0, 1.5, 2.0, 2.5]
  447. best_temperature = 1.0
  448. best_score = float('inf')
  449. for temp in temperatures:
  450. scaled_probs = F.softmax(logits / temp, dim=-1)
  451. loss = F.cross_entropy(scaled_probs, all_labels)
  452. if loss < best_score:
  453. best_score = loss
  454. best_temperature = temp
  455. self.confidence_calibrator.set_temperature(best_temperature)
  456. def get_performance_stats(self) -> Dict:
  457. """Get performance statistics."""
  458. with self._lock:
  459. stats = self._inference_stats.copy()
  460. # Add additional metrics
  461. stats.update({
  462. 'model_name': self.metadata.model_name,
  463. 'model_type': self.metadata.model_type,
  464. 'device': str(self.device),
  465. 'use_half_precision': self._use_half_precision,
  466. 'temporal_filtering_enabled': self.temporal_filter is not None,
  467. 'confidence_calibrated': self.confidence_calibrator.is_calibrated
  468. })
  469. return stats
  470. def reset_stats(self):
  471. """Reset performance statistics."""
  472. with self._lock:
  473. self._inference_stats = {
  474. 'total_inferences': 0,
  475. 'total_time_ms': 0.0,
  476. 'avg_time_ms': 0.0,
  477. 'min_time_ms': float('inf'),
  478. 'max_time_ms': 0.0,
  479. 'detections_count': {wt.value: 0 for wt in WakewordType}
  480. }
  481. if self.temporal_filter:
  482. self.temporal_filter.clear()
  483. def warm_up(self, num_warmup_iterations: int = 5):
  484. """
  485. Warm up the model for consistent performance measurements.
  486. Args:
  487. num_warmup_iterations: Number of warmup iterations
  488. """
  489. # Create dummy input matching expected shape
  490. dummy_input = torch.randn(
  491. 1,
  492. self.metadata.input_shape[0],
  493. self.metadata.input_shape[1],
  494. self.metadata.input_shape[2]
  495. ).to(self.device)
  496. if self._use_half_precision:
  497. dummy_input = dummy_input.half()
  498. # Run warmup iterations
  499. with torch.no_grad():
  500. for _ in range(num_warmup_iterations):
  501. _ = self.model(dummy_input)
  502. # Synchronize CUDA if using GPU
  503. if self.device.type == "cuda":
  504. torch.cuda.synchronize()
  505. def create_detection_engine(model: nn.Module, metadata: ModelMetadata,
  506. feature_extractor: AudioFeatureExtractor,
  507. config: Optional[DetectionConfig] = None,
  508. device: Optional[torch.device] = None) -> WakewordDetectionEngine:
  509. """
  510. Factory function to create detection engine.
  511. Args:
  512. model: Trained PyTorch model
  513. metadata: Model metadata
  514. feature_extractor: Audio feature extractor
  515. config: Detection configuration (uses default if None)
  516. device: PyTorch device
  517. Returns:
  518. Detection engine instance
  519. """
  520. if config is None:
  521. config = DetectionConfig()
  522. return WakewordDetectionEngine(model, metadata, feature_extractor, config, device)