model_loader.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563
  1. """
  2. Model loading and management system with password protection.
  3. This module provides secure loading of PyTorch models from password-protected
  4. ZIP files (.pth) with metadata validation and model verification.
  5. """
  6. import json
  7. import zipfile
  8. import tempfile
  9. import hashlib
  10. import io
  11. import warnings
  12. from pathlib import Path
  13. from typing import Dict, Any, Optional, Union, List, Tuple
  14. from dataclasses import dataclass, asdict
  15. from datetime import datetime
  16. import torch
  17. import torch.nn as nn
  18. @dataclass
  19. class ModelMetadata:
  20. """Metadata structure for wakeword models."""
  21. # Model identification
  22. model_name: str
  23. model_version: str
  24. model_type: str # "wakeword", "voice_recognition"
  25. architecture: str # "RepCNN", "ImprovedRepCNN", etc.
  26. # Training information
  27. training_date: str
  28. author: str
  29. description: str
  30. computer_name: str
  31. # Model configuration
  32. num_classes: int
  33. input_shape: List[int] # [channels, height, width]
  34. class_labels: List[str] # ["custom", "system_command", "negative"]
  35. # Training results
  36. final_accuracy: float
  37. final_loss: float
  38. num_epochs: int
  39. training_duration_hours: float
  40. # Audio configuration
  41. sample_rate: int
  42. n_mels: int
  43. time_frames: int
  44. chunk_duration_ms: float
  45. # Model performance
  46. inference_time_ms: float
  47. model_size_mb: float
  48. flops_estimate: int
  49. # Security and validation
  50. model_hash: str
  51. created_timestamp: str
  52. @classmethod
  53. def from_dict(cls, data: Dict[str, Any]) -> 'ModelMetadata':
  54. """Create ModelMetadata from dictionary."""
  55. return cls(**data)
  56. def to_dict(self) -> Dict[str, Any]:
  57. """Convert ModelMetadata to dictionary."""
  58. return asdict(self)
  59. def validate(self) -> bool:
  60. """Validate metadata completeness and consistency."""
  61. try:
  62. # Check required fields
  63. required_fields = [
  64. 'model_name', 'model_version', 'model_type', 'architecture',
  65. 'num_classes', 'input_shape', 'class_labels'
  66. ]
  67. for field in required_fields:
  68. value = getattr(self, field)
  69. if value is None or (isinstance(value, (str, list)) and len(value) == 0):
  70. return False
  71. # Validate consistency
  72. if len(self.class_labels) != self.num_classes:
  73. return False
  74. if len(self.input_shape) != 3: # Should be [C, H, W]
  75. return False
  76. # Check for wakeword-specific requirements
  77. if self.model_type == "wakeword":
  78. expected_labels = ["custom", "system_command", "negative"]
  79. if not all(label in self.class_labels for label in expected_labels):
  80. return False
  81. return True
  82. except Exception:
  83. return False
  84. class ModelLoadError(Exception):
  85. """Exception raised when model loading fails."""
  86. pass
  87. class SecureModelLoader:
  88. """
  89. Secure model loader for password-protected PyTorch models.
  90. Handles loading of .pth files that are actually ZIP archives containing:
  91. - model.pth: The actual PyTorch model state dict
  92. - metadata.json: Model metadata and configuration
  93. - Optional additional files (training logs, etc.)
  94. """
  95. def __init__(self, device: Optional[torch.device] = None):
  96. """
  97. Initialize the secure model loader.
  98. Args:
  99. device: PyTorch device for loading models
  100. """
  101. self.device = device or torch.device("cpu")
  102. self._loaded_models: Dict[str, Dict[str, Any]] = {}
  103. def load_model(self, model_path: Union[str, Path], password: str,
  104. model_class: Optional[type] = None,
  105. verify_hash: bool = True) -> Tuple[nn.Module, ModelMetadata]:
  106. """
  107. Load a password-protected PyTorch model.
  108. Args:
  109. model_path: Path to the .pth file (ZIP archive)
  110. password: Password for decrypting the model
  111. model_class: Model class to instantiate (if None, attempts auto-detection)
  112. verify_hash: Whether to verify model integrity using hash
  113. Returns:
  114. Tuple of (model, metadata)
  115. Raises:
  116. ModelLoadError: If loading fails
  117. """
  118. model_path = Path(model_path)
  119. if not model_path.exists():
  120. raise ModelLoadError(f"Model file not found: {model_path}")
  121. try:
  122. # Load from ZIP archive
  123. with zipfile.ZipFile(model_path, 'r') as zip_file:
  124. # Try to extract with password
  125. try:
  126. zip_file.setpassword(password.encode('utf-8'))
  127. # Load metadata
  128. metadata = self._load_metadata(zip_file)
  129. # Load model state dict
  130. model_state = self._load_model_state(zip_file)
  131. # Verify hash if requested
  132. if verify_hash and metadata.model_hash:
  133. if not self._verify_model_hash(model_state, metadata.model_hash):
  134. raise ModelLoadError("Model hash verification failed")
  135. # Create model instance
  136. model = self._create_model_instance(metadata, model_class)
  137. # Reparameterize model if it has the method (RepCNN models)
  138. # This converts the model structure to match the saved state dict
  139. if hasattr(model, 'reparameterize'):
  140. model.reparameterize()
  141. # Load state dict
  142. model.load_state_dict(model_state, strict=True)
  143. model.to(self.device)
  144. model.eval()
  145. # Cache the loaded model
  146. cache_key = str(model_path)
  147. self._loaded_models[cache_key] = {
  148. 'model': model,
  149. 'metadata': metadata,
  150. 'load_time': datetime.now()
  151. }
  152. return model, metadata
  153. except RuntimeError as e:
  154. if "Bad password" in str(e) or "Bad CRC" in str(e):
  155. raise ModelLoadError("Invalid password for model file")
  156. else:
  157. raise ModelLoadError(f"Failed to extract model: {e}")
  158. except zipfile.BadZipFile:
  159. raise ModelLoadError("Invalid ZIP file format")
  160. except Exception as e:
  161. raise ModelLoadError(f"Failed to load model: {e}")
  162. def _load_metadata(self, zip_file: zipfile.ZipFile) -> ModelMetadata:
  163. """Load and validate metadata from ZIP file."""
  164. try:
  165. # Try metafile.json first (used by trainer), then metadata.json (legacy)
  166. try:
  167. with zip_file.open('metafile.json') as meta_file:
  168. raw_metadata = json.load(meta_file)
  169. # Convert trainer format to ModelMetadata format
  170. metadata_dict = self._convert_trainer_metadata(raw_metadata)
  171. except KeyError:
  172. with zip_file.open('metadata.json') as meta_file:
  173. metadata_dict = json.load(meta_file)
  174. metadata = ModelMetadata.from_dict(metadata_dict)
  175. if not metadata.validate():
  176. raise ModelLoadError("Invalid metadata structure")
  177. return metadata
  178. except KeyError:
  179. raise ModelLoadError("metadata.json/metafile.json not found in model file")
  180. except json.JSONDecodeError as e:
  181. raise ModelLoadError(f"Invalid JSON in metadata: {e}")
  182. def _convert_trainer_metadata(self, trainer_meta: Dict[str, Any]) -> Dict[str, Any]:
  183. """Convert trainer metafile.json format to ModelMetadata format."""
  184. # Extract nested data
  185. training_stats = trainer_meta.get('training_statistics', {})
  186. audio_cfg = trainer_meta.get('audio_config', {})
  187. arch_info = trainer_meta.get('architecture_info', {})
  188. hardware = trainer_meta.get('hardware_info', {})
  189. model_specific = trainer_meta.get('model_specific', {})
  190. # Calculate time_frames from audio config
  191. audio_length_sec = audio_cfg.get('audio_length_seconds', 1.5)
  192. hop_length = audio_cfg.get('hop_length', 160)
  193. sample_rate = audio_cfg.get('sample_rate', 16000)
  194. time_frames = int((audio_length_sec * sample_rate) / hop_length) + 1
  195. # Get class labels from model_specific.wakewords or fallback
  196. class_labels = model_specific.get('wakewords', ['custom', 'system_command', 'negative'])
  197. num_classes = len(class_labels)
  198. # Build input_shape
  199. n_mels = audio_cfg.get('n_mels', 40)
  200. input_shape = [1, n_mels, time_frames]
  201. # Map to ModelMetadata fields
  202. return {
  203. 'model_name': trainer_meta.get('model_name', 'unknown'),
  204. 'model_version': trainer_meta.get('version', '1.0.0'),
  205. 'model_type': trainer_meta.get('model_type', 'wakeword'),
  206. 'architecture': arch_info.get('architecture_name', 'RepCNN'),
  207. 'training_date': trainer_meta.get('creation_timestamp', ''),
  208. 'author': trainer_meta.get('author', 'Unknown'),
  209. 'description': trainer_meta.get('description', ''),
  210. 'computer_name': hardware.get('hostname', 'Unknown'),
  211. 'num_classes': num_classes,
  212. 'input_shape': input_shape,
  213. 'class_labels': class_labels,
  214. 'final_accuracy': training_stats.get('best_val_accuracy', 0.0),
  215. 'final_loss': training_stats.get('best_val_loss', 0.0),
  216. 'num_epochs': training_stats.get('total_epochs', 0),
  217. 'training_duration_hours': training_stats.get('training_duration_seconds', 0.0) / 3600.0,
  218. 'sample_rate': sample_rate,
  219. 'n_mels': n_mels,
  220. 'time_frames': time_frames,
  221. 'chunk_duration_ms': audio_length_sec * 1000,
  222. 'inference_time_ms': 0.0,
  223. 'model_size_mb': training_stats.get('total_parameters', 0) * 4 / (1024 * 1024),
  224. 'flops_estimate': 0,
  225. 'model_hash': '', # Not available in trainer format
  226. 'created_timestamp': trainer_meta.get('creation_timestamp', ''),
  227. }
  228. def _load_model_state(self, zip_file: zipfile.ZipFile) -> Dict[str, torch.Tensor]:
  229. """Load model state dict from ZIP file."""
  230. try:
  231. with zip_file.open('model.pth') as model_file:
  232. # Load state dict directly from bytes
  233. model_bytes = model_file.read()
  234. state_dict = torch.load(
  235. io.BytesIO(model_bytes),
  236. map_location=self.device,
  237. weights_only=True
  238. )
  239. return state_dict
  240. except KeyError:
  241. raise ModelLoadError("model.pth not found in model file")
  242. except Exception as e:
  243. raise ModelLoadError(f"Failed to load model state dict: {e}")
  244. def _verify_model_hash(self, model_state: Dict[str, torch.Tensor],
  245. expected_hash: str) -> bool:
  246. """Verify model integrity using hash."""
  247. try:
  248. # Create hash of model parameters
  249. hasher = hashlib.sha256()
  250. # Sort keys for consistent hashing
  251. for key in sorted(model_state.keys()):
  252. tensor = model_state[key]
  253. hasher.update(key.encode('utf-8'))
  254. hasher.update(tensor.detach().cpu().numpy().tobytes())
  255. calculated_hash = hasher.hexdigest()
  256. return calculated_hash == expected_hash
  257. except Exception:
  258. return False
  259. def _create_model_instance(self, metadata: ModelMetadata,
  260. model_class: Optional[type] = None) -> nn.Module:
  261. """Create model instance based on metadata."""
  262. if model_class is not None:
  263. # Use provided model class
  264. return self._instantiate_model_class(model_class, metadata)
  265. # Attempt auto-detection based on metadata
  266. architecture = metadata.architecture.lower()
  267. if architecture in ["repcnn", "standard"]:
  268. from trainer.wakeword.models import RepCNN
  269. return self._instantiate_repcnn(RepCNN, metadata)
  270. elif architecture in ["improvedrepcnn", "improved"]:
  271. from trainer.wakeword.models import ImprovedRepCNN
  272. return self._instantiate_repcnn(ImprovedRepCNN, metadata)
  273. elif architecture in ["lightweightrepcnn", "lightweight"]:
  274. from trainer.wakeword.models import LightweightRepCNN
  275. return self._instantiate_lightweight_repcnn(metadata)
  276. else:
  277. raise ModelLoadError(f"Unknown architecture: {metadata.architecture}")
  278. def _instantiate_repcnn(self, model_class: type, metadata: ModelMetadata) -> nn.Module:
  279. """Instantiate RepCNN-based models."""
  280. return model_class(
  281. num_classes=metadata.num_classes,
  282. input_channels=metadata.input_shape[0],
  283. num_mels=metadata.n_mels,
  284. time_frames=metadata.time_frames
  285. )
  286. def _instantiate_lightweight_repcnn(self, metadata: ModelMetadata) -> nn.Module:
  287. """Instantiate LightweightRepCNN."""
  288. from trainer.wakeword.models import LightweightRepCNN
  289. return LightweightRepCNN(
  290. num_classes=metadata.num_classes,
  291. input_channels=metadata.input_shape[0],
  292. num_mels=metadata.n_mels,
  293. time_frames=metadata.time_frames
  294. )
  295. def _instantiate_model_class(self, model_class: type, metadata: ModelMetadata) -> nn.Module:
  296. """Instantiate arbitrary model class with metadata parameters."""
  297. try:
  298. # Try different initialization patterns
  299. init_params = {
  300. 'num_classes': metadata.num_classes,
  301. 'input_channels': metadata.input_shape[0],
  302. 'num_mels': metadata.n_mels,
  303. 'time_frames': metadata.time_frames
  304. }
  305. return model_class(**init_params)
  306. except Exception as e:
  307. raise ModelLoadError(f"Failed to instantiate model class {model_class.__name__}: {e}")
  308. def get_loaded_models(self) -> List[str]:
  309. """Get list of currently loaded model paths."""
  310. return list(self._loaded_models.keys())
  311. def unload_model(self, model_path: str) -> bool:
  312. """
  313. Unload a model from cache.
  314. Args:
  315. model_path: Path of model to unload
  316. Returns:
  317. True if model was unloaded, False if not found
  318. """
  319. if model_path in self._loaded_models:
  320. del self._loaded_models[model_path]
  321. return True
  322. return False
  323. def clear_cache(self):
  324. """Clear all cached models."""
  325. self._loaded_models.clear()
  326. def get_model_info(self, model_path: str) -> Optional[Dict[str, Any]]:
  327. """Get information about a loaded model."""
  328. return self._loaded_models.get(model_path)
  329. class ModelLoader:
  330. """
  331. Standard model loader for unprotected PyTorch models.
  332. This is a simpler loader for development and testing purposes.
  333. """
  334. def __init__(self, device: Optional[torch.device] = None):
  335. """Initialize the model loader."""
  336. self.device = device or torch.device("cpu")
  337. def load_model(self, model_path: Union[str, Path],
  338. model_class: type, **model_kwargs) -> nn.Module:
  339. """
  340. Load a standard PyTorch model.
  341. Args:
  342. model_path: Path to the .pth file
  343. model_class: Model class to instantiate
  344. **model_kwargs: Arguments for model initialization
  345. Returns:
  346. Loaded PyTorch model
  347. """
  348. model_path = Path(model_path)
  349. if not model_path.exists():
  350. raise ModelLoadError(f"Model file not found: {model_path}")
  351. try:
  352. # Create model instance
  353. model = model_class(**model_kwargs)
  354. # Load state dict
  355. state_dict = torch.load(model_path, map_location=self.device, weights_only=True)
  356. model.load_state_dict(state_dict, strict=True)
  357. model.to(self.device)
  358. model.eval()
  359. return model
  360. except Exception as e:
  361. raise ModelLoadError(f"Failed to load model: {e}")
  362. def validate_model_file(model_path: Union[str, Path], password: Optional[str] = None) -> bool:
  363. """
  364. Validate a model file without fully loading it.
  365. Args:
  366. model_path: Path to model file
  367. password: Password for encrypted models (optional)
  368. Returns:
  369. True if model file is valid, False otherwise
  370. """
  371. model_path = Path(model_path)
  372. if not model_path.exists():
  373. return False
  374. try:
  375. with zipfile.ZipFile(model_path, 'r') as zip_file:
  376. if password:
  377. zip_file.setpassword(password.encode('utf-8'))
  378. # Check for required files
  379. file_list = zip_file.namelist()
  380. # Check for model.pth
  381. if 'model.pth' not in file_list:
  382. return False
  383. # Check for metadata file (metafile.json or metadata.json)
  384. has_metadata = 'metafile.json' in file_list or 'metadata.json' in file_list
  385. if not has_metadata:
  386. return False
  387. # Try to load metadata
  388. try:
  389. meta_file_name = 'metafile.json' if 'metafile.json' in file_list else 'metadata.json'
  390. with zip_file.open(meta_file_name) as meta_file:
  391. metadata_dict = json.load(meta_file)
  392. metadata = ModelMetadata.from_dict(metadata_dict)
  393. return metadata.validate()
  394. except KeyError:
  395. return False
  396. except (zipfile.BadZipFile, json.JSONDecodeError, RuntimeError):
  397. return False
  398. except Exception:
  399. return False
  400. def create_model_metadata(model: nn.Module, training_config: Dict[str, Any],
  401. training_results: Dict[str, Any],
  402. audio_config: Dict[str, Any]) -> ModelMetadata:
  403. """
  404. Create model metadata from training information.
  405. Args:
  406. model: Trained PyTorch model
  407. training_config: Training configuration
  408. training_results: Training results and metrics
  409. audio_config: Audio processing configuration
  410. Returns:
  411. ModelMetadata instance
  412. """
  413. # Calculate model hash
  414. hasher = hashlib.sha256()
  415. for name, param in model.named_parameters():
  416. hasher.update(name.encode('utf-8'))
  417. hasher.update(param.detach().cpu().numpy().tobytes())
  418. model_hash = hasher.hexdigest()
  419. # Estimate model size
  420. param_count = sum(p.numel() for p in model.parameters())
  421. model_size_mb = param_count * 4 / (1024 * 1024) # Assuming float32
  422. return ModelMetadata(
  423. model_name=training_config.get('model_name', 'wakeword_model'),
  424. model_version=training_config.get('model_version', '1.0.0'),
  425. model_type="wakeword",
  426. architecture=model.__class__.__name__,
  427. training_date=datetime.now().isoformat(),
  428. author=training_config.get('author', 'Unknown'),
  429. description=training_config.get('description', ''),
  430. computer_name=training_config.get('computer_name', 'Unknown'),
  431. num_classes=training_config.get('num_classes', 3),
  432. input_shape=[1, audio_config.get('n_mels', 40), audio_config.get('time_frames', 151)],
  433. class_labels=training_config.get('class_labels', ['custom', 'system_command', 'negative']),
  434. final_accuracy=training_results.get('accuracy', 0.0),
  435. final_loss=training_results.get('loss', 0.0),
  436. num_epochs=training_results.get('epochs', 0),
  437. training_duration_hours=training_results.get('duration_hours', 0.0),
  438. sample_rate=audio_config.get('sample_rate', 16000),
  439. n_mels=audio_config.get('n_mels', 40),
  440. time_frames=audio_config.get('time_frames', 151),
  441. chunk_duration_ms=audio_config.get('chunk_duration_ms', 1000),
  442. inference_time_ms=0.0, # To be measured during deployment
  443. model_size_mb=model_size_mb,
  444. flops_estimate=0, # To be calculated if needed
  445. model_hash=model_hash,
  446. created_timestamp=datetime.now().isoformat()
  447. )