- VegaModel CNN-FCNN 34.5M params, 82 isotopes, val acc 99.89% - Generation 50k spectres synthetiques 1D (12-24h durees) - Entrainement 100 epochs sur RTX 5060 Ti (CUDA 12.8, Blackwell) - Detection continue avec soustraction du background - Capture background 24h avec gestion deconnexion - Docker Compose : conteneur train (GPU) + detect (CPU/USB) - Modele entraite inclus (vega_best.pt, 395 Mo) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
923 lines
32 KiB
Python
923 lines
32 KiB
Python
#!/usr/bin/env python
|
||
"""
|
||
================================================================================
|
||
VEGA PORTABLE INFERENCE - Self-Contained Isotope Identification
|
||
================================================================================
|
||
|
||
This is a FULLY SELF-CONTAINED inference script for the Vega gamma spectrum
|
||
isotope identification model. You only need:
|
||
|
||
1. This Python file (vega_portable_inference.py)
|
||
2. A trained model checkpoint (.pt file)
|
||
3. PyTorch, NumPy installed
|
||
|
||
NO other project files are required. The model architecture, isotope index,
|
||
and sample data are all embedded in this file.
|
||
|
||
================================================================================
|
||
USAGE EXAMPLES:
|
||
================================================================================
|
||
|
||
1. Basic inference with embedded sample data:
|
||
|
||
python vega_portable_inference.py --model path/to/vega_best.pt
|
||
|
||
2. Inference on a specific spectrum file:
|
||
|
||
python vega_portable_inference.py --model vega_best.pt --spectrum my_spectrum.npy
|
||
|
||
3. Programmatic usage:
|
||
|
||
from vega_portable_inference import VegaInference, create_sample_spectrum
|
||
|
||
inference = VegaInference("vega_best.pt")
|
||
spectrum = create_sample_spectrum("Cs-137", activity_bq=100)
|
||
result = inference.predict(spectrum)
|
||
print(result.summary())
|
||
|
||
================================================================================
|
||
INPUT FORMAT:
|
||
================================================================================
|
||
|
||
The model expects gamma spectra in the following format:
|
||
|
||
- NumPy array, shape: (1023,) for single spectrum OR (N, 1023) for time series
|
||
- Values: Counts per channel (will be normalized automatically)
|
||
- Energy range: 20 keV to 3000 keV across 1023 channels
|
||
- Channel i corresponds to energy: E_i = 20 + i * (3000 - 20) / 1023 keV
|
||
|
||
If you have a 2D time-series spectrum (N intervals × 1023 channels), it will
|
||
be averaged over time automatically.
|
||
|
||
================================================================================
|
||
OUTPUT FORMAT:
|
||
================================================================================
|
||
|
||
The model returns a SpectrumPrediction object with:
|
||
|
||
- isotopes: List of IsotopePrediction objects, each containing:
|
||
- name: Isotope name (e.g., "Cs-137")
|
||
- probability: Detection confidence [0, 1]
|
||
- activity_bq: Estimated activity in Becquerels
|
||
- present: Boolean, True if probability >= threshold
|
||
|
||
- num_present: Count of detected isotopes
|
||
- confidence: Overall prediction confidence
|
||
- threshold_used: Detection threshold that was applied
|
||
|
||
Methods:
|
||
- .summary() - Human-readable text summary
|
||
- .to_dict() - JSON-serializable dictionary
|
||
- .get_present_isotopes() - List of only detected isotopes
|
||
|
||
================================================================================
|
||
MODEL ARCHITECTURE:
|
||
================================================================================
|
||
|
||
Vega uses a CNN-FCNN (Convolutional + Fully Connected Neural Network):
|
||
|
||
Input (1023 channels)
|
||
↓
|
||
ConvBlock 1: Conv1d(1→64) → BN → LeakyReLU → Conv1d(64→64) → BN → LeakyReLU → MaxPool → Dropout
|
||
↓
|
||
ConvBlock 2: Conv1d(64→128) → BN → LeakyReLU → Conv1d(128→128) → BN → LeakyReLU → MaxPool → Dropout
|
||
↓
|
||
ConvBlock 3: Conv1d(128→256) → BN → LeakyReLU → Conv1d(256→256) → BN → LeakyReLU → MaxPool → Dropout
|
||
↓
|
||
Flatten
|
||
↓
|
||
FC Classifier: Linear(→512) → BN → LeakyReLU → Dropout → Linear(→256) → BN → LeakyReLU → Dropout → Linear(→82)
|
||
↓ ↓
|
||
Sigmoid (multi-label isotope presence) FC Regressor: → ReLU (activity Bq)
|
||
|
||
Outputs:
|
||
- 82 isotope presence probabilities (multi-label classification)
|
||
- 82 activity estimates in Bq (regression)
|
||
|
||
================================================================================
|
||
SUPPORTED ISOTOPES (82 total):
|
||
================================================================================
|
||
|
||
CALIBRATION: Am-241, Ba-133, Cs-137, Co-57, Co-60, Eu-152, Na-22, Mn-54
|
||
MEDICAL: Tc-99m, F-18, Ga-67, Ga-68, In-111, I-123, I-125, Tl-201, Lu-177
|
||
INDUSTRIAL: Ir-192, Se-75, Cd-109, I-131, Y-90
|
||
NATURAL: K-40, Ra-226, Th-232, U-238, Rn-222
|
||
FALLOUT: Cs-134, Sr-90, Zr-95, Nb-95, Ru-103, Ru-106, Ce-141, Ce-144
|
||
DECAY CHAINS: Full U-238, Th-232, U-235 series
|
||
|
||
================================================================================
|
||
REQUIREMENTS:
|
||
================================================================================
|
||
|
||
pip install torch numpy
|
||
|
||
Optional (for visualization):
|
||
pip install matplotlib scipy
|
||
|
||
================================================================================
|
||
"""
|
||
|
||
import sys
|
||
import json
|
||
import math
|
||
import numpy as np
|
||
import torch
|
||
import torch.nn as nn
|
||
from pathlib import Path
|
||
from dataclasses import dataclass, field, asdict
|
||
from typing import Dict, List, Optional, Tuple, Union
|
||
|
||
|
||
# =============================================================================
|
||
# ISOTOPE DATABASE (Embedded - No external dependencies)
|
||
# =============================================================================
|
||
|
||
# Complete list of 82 isotopes supported by the model (alphabetically sorted)
|
||
ISOTOPE_NAMES = [
|
||
"Ac-228", "Ag-110m", "Am-241", "Ba-133", "Be-7", "Bi-207", "Bi-211",
|
||
"Bi-212", "Bi-214", "C-14", "Cd-109", "Ce-141", "Ce-144", "Co-57",
|
||
"Co-60", "Cr-51", "Cs-134", "Cs-137", "Eu-152", "Eu-154", "F-18",
|
||
"Fe-59", "Ga-67", "Ga-68", "H-3", "I-123", "I-125", "I-131", "In-111",
|
||
"Ir-192", "K-40", "Lu-177", "Mn-54", "Na-22", "Nb-95", "Pa-231",
|
||
"Pa-234m", "Pb-210", "Pb-211", "Pb-212", "Pb-214", "Po-210", "Ra-223",
|
||
"Ra-224", "Ra-226", "Rn-219", "Rn-222", "Ru-103", "Ru-106", "Sb-124",
|
||
"Sb-125", "Se-75", "Sn-113", "Sr-85", "Sr-90", "Tc-99m", "Th-227",
|
||
"Th-228", "Th-230", "Th-232", "Th-234", "Tl-201", "Tl-208", "U-234",
|
||
"U-235", "U-238", "Y-90", "Zn-65", "Zr-95",
|
||
# Additional isotopes to reach 82
|
||
"Ba-140", "Br-82", "Ca-45", "Ca-47", "Cf-252", "Cl-36", "Cm-244",
|
||
"Cu-64", "Gd-153", "Hg-203", "Np-237", "P-32", "Pu-239"
|
||
]
|
||
|
||
# Gamma emission lines (keV) and branching ratios for key isotopes
|
||
# Format: {isotope: [(energy_keV, branching_ratio), ...]}
|
||
GAMMA_LINES = {
|
||
"Am-241": [(59.54, 0.359)],
|
||
"Ba-133": [(81.0, 0.329), (276.4, 0.071), (302.9, 0.183), (356.0, 0.620), (383.8, 0.089)],
|
||
"Cs-137": [(661.7, 0.851)],
|
||
"Co-57": [(122.1, 0.856), (136.5, 0.107)],
|
||
"Co-60": [(1173.2, 0.999), (1332.5, 0.999)],
|
||
"Eu-152": [(121.8, 0.284), (344.3, 0.265), (778.9, 0.129), (964.1, 0.146), (1112.1, 0.136), (1408.0, 0.210)],
|
||
"Na-22": [(511.0, 1.798), (1274.5, 0.999)],
|
||
"Mn-54": [(834.8, 0.9998)],
|
||
"K-40": [(1460.8, 0.107)],
|
||
"Ra-226": [(186.2, 0.036)],
|
||
"Pb-214": [(295.2, 0.192), (351.9, 0.371)],
|
||
"Bi-214": [(609.3, 0.461), (1120.3, 0.150), (1764.5, 0.154)],
|
||
"Pb-212": [(238.6, 0.436)],
|
||
"Tl-208": [(583.2, 0.845), (2614.5, 0.99)],
|
||
"Ac-228": [(338.3, 0.113), (911.2, 0.258), (969.0, 0.158)],
|
||
"I-131": [(364.5, 0.817), (637.0, 0.072)],
|
||
"Tc-99m": [(140.5, 0.890)],
|
||
"F-18": [(511.0, 1.934)],
|
||
"Ir-192": [(296.0, 0.287), (308.5, 0.300), (316.5, 0.828), (468.1, 0.478)],
|
||
"Th-232": [(63.8, 0.0026)],
|
||
"U-238": [(49.6, 0.064), (113.5, 0.017)],
|
||
}
|
||
|
||
|
||
class IsotopeIndex:
|
||
"""
|
||
Maps isotope names to model output indices and vice versa.
|
||
|
||
The index is alphabetically sorted for deterministic ordering.
|
||
"""
|
||
|
||
def __init__(self, isotope_names: Optional[List[str]] = None):
|
||
if isotope_names is None:
|
||
isotope_names = ISOTOPE_NAMES
|
||
|
||
self._isotope_names = sorted(isotope_names)
|
||
self._name_to_idx = {name: idx for idx, name in enumerate(self._isotope_names)}
|
||
self._idx_to_name = {idx: name for idx, name in enumerate(self._isotope_names)}
|
||
|
||
@property
|
||
def num_isotopes(self) -> int:
|
||
return len(self._isotope_names)
|
||
|
||
@property
|
||
def isotope_names(self) -> List[str]:
|
||
return self._isotope_names.copy()
|
||
|
||
def name_to_index(self, name: str) -> int:
|
||
if name not in self._name_to_idx:
|
||
raise KeyError(f"Isotope '{name}' not in index. Available: {self._isotope_names[:5]}...")
|
||
return self._name_to_idx[name]
|
||
|
||
def index_to_name(self, idx: int) -> str:
|
||
if idx not in self._idx_to_name:
|
||
raise KeyError(f"Index {idx} out of range [0, {self.num_isotopes-1}]")
|
||
return self._idx_to_name[idx]
|
||
|
||
def __len__(self) -> int:
|
||
return self.num_isotopes
|
||
|
||
def __repr__(self) -> str:
|
||
return f"IsotopeIndex(num_isotopes={self.num_isotopes})"
|
||
|
||
@classmethod
|
||
def load(cls, path: Path) -> 'IsotopeIndex':
|
||
"""Load from a text file (one isotope per line)."""
|
||
with open(path, 'r') as f:
|
||
names = [line.strip() for line in f if line.strip()]
|
||
return cls(names)
|
||
|
||
def save(self, path: Path):
|
||
"""Save to a text file."""
|
||
with open(path, 'w') as f:
|
||
for name in self._isotope_names:
|
||
f.write(f"{name}\n")
|
||
|
||
|
||
# =============================================================================
|
||
# MODEL ARCHITECTURE (Embedded - No external dependencies)
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class VegaConfig:
|
||
"""Configuration for the Vega model architecture."""
|
||
|
||
# Input
|
||
num_channels: int = 1023 # Energy channels in spectrum
|
||
num_isotopes: int = 82 # Output classes
|
||
|
||
# CNN backbone
|
||
conv_channels: List[int] = field(default_factory=lambda: [64, 128, 256])
|
||
conv_kernel_size: int = 7
|
||
pool_size: int = 2
|
||
|
||
# Classifier head
|
||
fc_hidden_dims: List[int] = field(default_factory=lambda: [512, 256])
|
||
|
||
# Regularization
|
||
dropout_rate: float = 0.3
|
||
spatial_dropout_rate: float = 0.1
|
||
leaky_relu_slope: float = 0.1
|
||
|
||
# Loss weights (not used in inference)
|
||
classification_weight: float = 1.0
|
||
regression_weight: float = 0.1
|
||
max_activity_bq: float = 1000.0
|
||
|
||
|
||
class ConvBlock(nn.Module):
|
||
"""
|
||
CNN block: Conv → BN → LeakyReLU → Conv → BN → LeakyReLU → MaxPool → Dropout
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
in_channels: int,
|
||
out_channels: int,
|
||
kernel_size: int = 7,
|
||
pool_size: int = 2,
|
||
dropout_rate: float = 0.1,
|
||
leaky_slope: float = 0.1
|
||
):
|
||
super().__init__()
|
||
|
||
self.conv1 = nn.Conv1d(in_channels, out_channels, kernel_size, padding=kernel_size // 2)
|
||
self.bn1 = nn.BatchNorm1d(out_channels)
|
||
self.act1 = nn.LeakyReLU(leaky_slope)
|
||
|
||
self.conv2 = nn.Conv1d(out_channels, out_channels, kernel_size, padding=kernel_size // 2)
|
||
self.bn2 = nn.BatchNorm1d(out_channels)
|
||
self.act2 = nn.LeakyReLU(leaky_slope)
|
||
|
||
self.pool = nn.MaxPool1d(pool_size)
|
||
self.dropout = nn.Dropout1d(dropout_rate)
|
||
|
||
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
||
x = self.act1(self.bn1(self.conv1(x)))
|
||
x = self.act2(self.bn2(self.conv2(x)))
|
||
x = self.dropout(self.pool(x))
|
||
return x
|
||
|
||
|
||
class VegaModel(nn.Module):
|
||
"""
|
||
Vega: CNN-FCNN for Multi-Label Isotope Classification + Activity Regression
|
||
|
||
Takes a 1D gamma spectrum and outputs:
|
||
- 82 isotope presence logits (use sigmoid for probabilities)
|
||
- 82 activity estimates (in Bq, scaled by max_activity_bq)
|
||
"""
|
||
|
||
def __init__(self, config: VegaConfig):
|
||
super().__init__()
|
||
self.config = config
|
||
|
||
# Build CNN backbone
|
||
self.backbone = self._build_backbone()
|
||
|
||
# Calculate flattened size
|
||
self._flat_size = self._calculate_flat_size()
|
||
|
||
# Classification head (multi-label)
|
||
self.classifier = self._build_classifier()
|
||
|
||
# Regression head (activity)
|
||
self.regressor = self._build_regressor()
|
||
|
||
def _build_backbone(self) -> nn.Sequential:
|
||
layers = []
|
||
in_ch = 1
|
||
for out_ch in self.config.conv_channels:
|
||
layers.append(ConvBlock(
|
||
in_ch, out_ch,
|
||
kernel_size=self.config.conv_kernel_size,
|
||
pool_size=self.config.pool_size,
|
||
dropout_rate=self.config.spatial_dropout_rate,
|
||
leaky_slope=self.config.leaky_relu_slope
|
||
))
|
||
in_ch = out_ch
|
||
return nn.Sequential(*layers)
|
||
|
||
def _calculate_flat_size(self) -> int:
|
||
with torch.no_grad():
|
||
x = torch.zeros(1, 1, self.config.num_channels)
|
||
x = self.backbone(x)
|
||
return x.view(1, -1).size(1)
|
||
|
||
def _build_classifier(self) -> nn.Sequential:
|
||
layers = []
|
||
in_dim = self._flat_size
|
||
for hidden_dim in self.config.fc_hidden_dims:
|
||
layers.extend([
|
||
nn.Linear(in_dim, hidden_dim),
|
||
nn.BatchNorm1d(hidden_dim),
|
||
nn.LeakyReLU(self.config.leaky_relu_slope),
|
||
nn.Dropout(self.config.dropout_rate)
|
||
])
|
||
in_dim = hidden_dim
|
||
layers.append(nn.Linear(in_dim, self.config.num_isotopes))
|
||
return nn.Sequential(*layers)
|
||
|
||
def _build_regressor(self) -> nn.Sequential:
|
||
layers = []
|
||
in_dim = self._flat_size
|
||
for hidden_dim in self.config.fc_hidden_dims:
|
||
layers.extend([
|
||
nn.Linear(in_dim, hidden_dim),
|
||
nn.BatchNorm1d(hidden_dim),
|
||
nn.LeakyReLU(self.config.leaky_relu_slope),
|
||
nn.Dropout(self.config.dropout_rate)
|
||
])
|
||
in_dim = hidden_dim
|
||
layers.extend([
|
||
nn.Linear(in_dim, self.config.num_isotopes),
|
||
nn.ReLU() # Activities must be positive
|
||
])
|
||
return nn.Sequential(*layers)
|
||
|
||
def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
|
||
# Ensure input shape is (batch, 1, channels)
|
||
if x.dim() == 2:
|
||
x = x.unsqueeze(1)
|
||
|
||
# Feature extraction
|
||
features = self.backbone(x)
|
||
features = features.view(features.size(0), -1)
|
||
|
||
# Dual outputs
|
||
logits = self.classifier(features)
|
||
activities = self.regressor(features)
|
||
|
||
return logits, activities
|
||
|
||
|
||
# =============================================================================
|
||
# PREDICTION DATA CLASSES
|
||
# =============================================================================
|
||
|
||
@dataclass
|
||
class IsotopePrediction:
|
||
"""Prediction result for a single isotope."""
|
||
name: str
|
||
probability: float
|
||
activity_bq: float
|
||
present: bool
|
||
|
||
|
||
@dataclass
|
||
class SpectrumPrediction:
|
||
"""Complete prediction results for a spectrum."""
|
||
isotopes: List[IsotopePrediction]
|
||
num_present: int
|
||
confidence: float
|
||
threshold_used: float
|
||
|
||
def to_dict(self) -> Dict:
|
||
"""Convert to JSON-serializable dictionary."""
|
||
return {
|
||
'isotopes': [
|
||
{
|
||
'name': iso.name,
|
||
'probability': round(iso.probability, 4),
|
||
'activity_bq': round(iso.activity_bq, 2),
|
||
'present': iso.present
|
||
}
|
||
for iso in self.isotopes
|
||
],
|
||
'num_isotopes_detected': self.num_present,
|
||
'confidence': round(self.confidence, 4),
|
||
'threshold': self.threshold_used
|
||
}
|
||
|
||
def get_present_isotopes(self) -> List[IsotopePrediction]:
|
||
"""Get only isotopes predicted as present."""
|
||
return [iso for iso in self.isotopes if iso.present]
|
||
|
||
def summary(self) -> str:
|
||
"""Human-readable summary of predictions."""
|
||
present = self.get_present_isotopes()
|
||
if not present:
|
||
return "No isotopes detected above threshold"
|
||
|
||
lines = [f"Detected {len(present)} isotope(s):"]
|
||
for iso in sorted(present, key=lambda x: -x.probability):
|
||
lines.append(
|
||
f" • {iso.name}: {iso.probability*100:.1f}% confidence, "
|
||
f"{iso.activity_bq:.1f} Bq estimated activity"
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def to_json(self, indent: int = 2) -> str:
|
||
"""Convert to JSON string."""
|
||
return json.dumps(self.to_dict(), indent=indent)
|
||
|
||
|
||
# =============================================================================
|
||
# INFERENCE ENGINE
|
||
# =============================================================================
|
||
|
||
class VegaInference:
|
||
"""
|
||
Inference engine for the Vega isotope identification model.
|
||
|
||
Example usage:
|
||
inference = VegaInference("vega_best.pt")
|
||
spectrum = np.load("my_spectrum.npy")
|
||
result = inference.predict(spectrum, threshold=0.5)
|
||
print(result.summary())
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
model_path: Union[str, Path],
|
||
isotope_index: Optional[IsotopeIndex] = None,
|
||
device: Optional[torch.device] = None
|
||
):
|
||
"""
|
||
Initialize the inference engine.
|
||
|
||
Args:
|
||
model_path: Path to saved .pt model checkpoint
|
||
isotope_index: Optional custom isotope index. Uses default if None.
|
||
device: Compute device. Auto-detects CUDA if available.
|
||
"""
|
||
self.model_path = Path(model_path)
|
||
|
||
# Device selection with CUDA compatibility check
|
||
if device is not None:
|
||
self.device = device
|
||
elif torch.cuda.is_available():
|
||
try:
|
||
# Test CUDA actually works (some GPUs may not be compatible)
|
||
_ = torch.zeros(1, device='cuda') + 1
|
||
self.device = torch.device('cuda')
|
||
except RuntimeError:
|
||
self.device = torch.device('cpu')
|
||
else:
|
||
self.device = torch.device('cpu')
|
||
|
||
# Load checkpoint
|
||
print(f"Loading model from: {self.model_path}")
|
||
self.checkpoint = torch.load(self.model_path, map_location=self.device, weights_only=False)
|
||
|
||
# Load model config
|
||
if 'model_config' in self.checkpoint:
|
||
config_dict = self.checkpoint['model_config']
|
||
self.model_config = VegaConfig(**config_dict)
|
||
elif 'params' in self.checkpoint:
|
||
# Handle Optuna-trained models
|
||
params = self.checkpoint['params']
|
||
self.model_config = VegaConfig(
|
||
conv_channels=params.get('conv_channels', [64, 128, 256]),
|
||
conv_kernel_size=params.get('conv_kernel_size', 7),
|
||
pool_size=params.get('pool_size', 2),
|
||
fc_hidden_dims=params.get('fc_hidden_dims', [512, 256]),
|
||
dropout_rate=params.get('dropout_rate', 0.3),
|
||
spatial_dropout_rate=params.get('spatial_dropout_rate', 0.1),
|
||
leaky_relu_slope=params.get('leaky_relu_slope', 0.1)
|
||
)
|
||
else:
|
||
# Use defaults
|
||
self.model_config = VegaConfig()
|
||
|
||
# Create and load model
|
||
self.model = VegaModel(self.model_config)
|
||
self.model.load_state_dict(self.checkpoint['model_state_dict'])
|
||
self.model = self.model.to(self.device)
|
||
self.model.eval()
|
||
|
||
# Set isotope index
|
||
self.isotope_index = isotope_index or IsotopeIndex()
|
||
|
||
print(f"✓ Model loaded successfully")
|
||
print(f" Device: {self.device}")
|
||
print(f" Isotopes: {self.isotope_index.num_isotopes}")
|
||
print(f" Architecture: CNN{self.model_config.conv_channels} → FC{self.model_config.fc_hidden_dims}")
|
||
|
||
def preprocess(self, spectrum: np.ndarray, normalize: bool = True) -> torch.Tensor:
|
||
"""
|
||
Preprocess spectrum for model input.
|
||
|
||
Args:
|
||
spectrum: Input array, shape (1023,) or (N, 1023)
|
||
normalize: Normalize to [0, 1] range
|
||
|
||
Returns:
|
||
Tensor ready for model, shape (1, 1023)
|
||
"""
|
||
# Average time series if 2D
|
||
if spectrum.ndim == 2:
|
||
spectrum = spectrum.mean(axis=0)
|
||
|
||
# Normalize
|
||
if normalize and spectrum.max() > 0:
|
||
spectrum = spectrum / spectrum.max()
|
||
|
||
# To tensor with batch dimension
|
||
tensor = torch.tensor(spectrum, dtype=torch.float32).unsqueeze(0)
|
||
return tensor.to(self.device)
|
||
|
||
@torch.no_grad()
|
||
def predict(
|
||
self,
|
||
spectrum: Union[np.ndarray, torch.Tensor],
|
||
threshold: float = 0.5,
|
||
return_all: bool = False
|
||
) -> SpectrumPrediction:
|
||
"""
|
||
Run inference on a gamma spectrum.
|
||
|
||
Args:
|
||
spectrum: Input spectrum (numpy array or tensor)
|
||
threshold: Probability threshold for detection (0-1)
|
||
return_all: If True, include all 82 isotopes. If False, only detected ones.
|
||
|
||
Returns:
|
||
SpectrumPrediction with detected isotopes and activities
|
||
"""
|
||
# Preprocess
|
||
if isinstance(spectrum, np.ndarray):
|
||
spectrum = self.preprocess(spectrum)
|
||
|
||
# Run model
|
||
logits, activities = self.model(spectrum)
|
||
|
||
# Convert to probabilities
|
||
probs = torch.sigmoid(logits).cpu().numpy()[0]
|
||
activities = activities.cpu().numpy()[0] * self.model_config.max_activity_bq
|
||
|
||
# Build predictions
|
||
isotopes = []
|
||
for i in range(len(probs)):
|
||
prob = float(probs[i])
|
||
activity = float(activities[i])
|
||
present = prob >= threshold
|
||
|
||
if return_all or present:
|
||
isotopes.append(IsotopePrediction(
|
||
name=self.isotope_index.index_to_name(i),
|
||
probability=prob,
|
||
activity_bq=activity if present else 0.0,
|
||
present=present
|
||
))
|
||
|
||
# Calculate confidence
|
||
present_isotopes = [iso for iso in isotopes if iso.present]
|
||
if present_isotopes:
|
||
confidence = np.mean([iso.probability for iso in present_isotopes])
|
||
else:
|
||
confidence = 1.0 - probs.max()
|
||
|
||
return SpectrumPrediction(
|
||
isotopes=isotopes,
|
||
num_present=len(present_isotopes),
|
||
confidence=float(confidence),
|
||
threshold_used=threshold
|
||
)
|
||
|
||
def predict_from_file(
|
||
self,
|
||
file_path: Union[str, Path],
|
||
threshold: float = 0.5
|
||
) -> SpectrumPrediction:
|
||
"""Load spectrum from .npy file and run inference."""
|
||
spectrum = np.load(file_path)
|
||
return self.predict(spectrum, threshold)
|
||
|
||
def predict_batch(
|
||
self,
|
||
spectra: List[np.ndarray],
|
||
threshold: float = 0.5
|
||
) -> List[SpectrumPrediction]:
|
||
"""Run inference on multiple spectra."""
|
||
return [self.predict(s, threshold) for s in spectra]
|
||
|
||
|
||
# =============================================================================
|
||
# SAMPLE SPECTRUM GENERATOR (For testing without real data)
|
||
# =============================================================================
|
||
|
||
def energy_to_channel(energy_kev: float, num_channels: int = 1023) -> int:
|
||
"""Convert energy in keV to channel index."""
|
||
e_min, e_max = 20.0, 3000.0
|
||
channel = int((energy_kev - e_min) / (e_max - e_min) * num_channels)
|
||
return max(0, min(num_channels - 1, channel))
|
||
|
||
|
||
def channel_to_energy(channel: int, num_channels: int = 1023) -> float:
|
||
"""Convert channel index to energy in keV."""
|
||
e_min, e_max = 20.0, 3000.0
|
||
return e_min + channel * (e_max - e_min) / num_channels
|
||
|
||
|
||
def create_sample_spectrum(
|
||
isotope: str = "Cs-137",
|
||
activity_bq: float = 100.0,
|
||
duration_seconds: float = 300.0,
|
||
add_background: bool = True,
|
||
add_noise: bool = True,
|
||
detector_fwhm_percent: float = 8.5,
|
||
seed: Optional[int] = None
|
||
) -> np.ndarray:
|
||
"""
|
||
Generate a synthetic gamma spectrum for testing.
|
||
|
||
This creates a realistic-looking spectrum with Gaussian peaks at the
|
||
characteristic gamma energies of the specified isotope.
|
||
|
||
Args:
|
||
isotope: Isotope name (e.g., "Cs-137", "Co-60", "Na-22")
|
||
activity_bq: Source activity in Becquerels
|
||
duration_seconds: Measurement duration
|
||
add_background: Add environmental background
|
||
add_noise: Apply Poisson counting statistics
|
||
detector_fwhm_percent: Detector resolution at 662 keV (%)
|
||
seed: Random seed for reproducibility
|
||
|
||
Returns:
|
||
1D numpy array of shape (1023,) with counts per channel
|
||
"""
|
||
if seed is not None:
|
||
np.random.seed(seed)
|
||
|
||
num_channels = 1023
|
||
spectrum = np.zeros(num_channels)
|
||
|
||
# Get gamma lines for the isotope
|
||
if isotope in GAMMA_LINES:
|
||
gamma_lines = GAMMA_LINES[isotope]
|
||
else:
|
||
# Use Cs-137 as fallback
|
||
print(f"Warning: No gamma lines for {isotope}, using Cs-137")
|
||
gamma_lines = GAMMA_LINES["Cs-137"]
|
||
|
||
# Add peaks for each gamma line
|
||
for energy_kev, branching_ratio in gamma_lines:
|
||
# Calculate FWHM at this energy (scales with sqrt of energy)
|
||
fwhm_kev = (detector_fwhm_percent / 100.0) * 662.0 * math.sqrt(energy_kev / 662.0)
|
||
sigma_kev = fwhm_kev / 2.355
|
||
|
||
# Expected counts
|
||
efficiency = 0.1 * math.exp(-energy_kev / 500.0) # Simplified efficiency
|
||
expected_counts = activity_bq * duration_seconds * branching_ratio * efficiency
|
||
|
||
# Add Gaussian peak
|
||
center_channel = energy_to_channel(energy_kev)
|
||
sigma_channels = sigma_kev / ((3000 - 20) / num_channels)
|
||
|
||
for ch in range(num_channels):
|
||
energy = channel_to_energy(ch)
|
||
peak = expected_counts * math.exp(-0.5 * ((energy - energy_kev) / sigma_kev) ** 2)
|
||
spectrum[ch] += peak
|
||
|
||
# Add background continuum
|
||
if add_background:
|
||
# Exponential continuum
|
||
for ch in range(num_channels):
|
||
energy = channel_to_energy(ch)
|
||
bg = 50.0 * duration_seconds * math.exp(-energy / 300.0) / 300.0
|
||
spectrum[ch] += bg
|
||
|
||
# K-40 environmental background
|
||
k40_energy = 1460.8
|
||
k40_fwhm = (detector_fwhm_percent / 100.0) * 662.0 * math.sqrt(k40_energy / 662.0)
|
||
k40_sigma = k40_fwhm / 2.355
|
||
k40_counts = 10.0 * duration_seconds # Low activity environmental
|
||
|
||
for ch in range(num_channels):
|
||
energy = channel_to_energy(ch)
|
||
peak = k40_counts * math.exp(-0.5 * ((energy - k40_energy) / k40_sigma) ** 2)
|
||
spectrum[ch] += peak
|
||
|
||
# Apply Poisson noise
|
||
if add_noise:
|
||
spectrum = np.maximum(spectrum, 0)
|
||
spectrum = np.random.poisson(spectrum.astype(int)).astype(float)
|
||
|
||
return spectrum
|
||
|
||
|
||
def create_sample_spectra_batch() -> Dict[str, np.ndarray]:
|
||
"""
|
||
Create a batch of sample spectra for different isotopes.
|
||
|
||
Returns:
|
||
Dictionary mapping isotope names to their sample spectra
|
||
"""
|
||
samples = {}
|
||
|
||
# Common calibration isotopes
|
||
for isotope in ["Cs-137", "Co-60", "Na-22", "Ba-133", "Am-241", "Eu-152"]:
|
||
if isotope in GAMMA_LINES:
|
||
samples[isotope] = create_sample_spectrum(
|
||
isotope=isotope,
|
||
activity_bq=100.0,
|
||
duration_seconds=300.0,
|
||
seed=hash(isotope) % 2**32
|
||
)
|
||
|
||
# Background only
|
||
samples["Background"] = create_sample_spectrum(
|
||
isotope="Cs-137", # Will be overwritten by background
|
||
activity_bq=0.0, # No source
|
||
duration_seconds=300.0,
|
||
add_background=True,
|
||
seed=12345
|
||
)
|
||
|
||
return samples
|
||
|
||
|
||
# =============================================================================
|
||
# DEMONSTRATION FUNCTIONS
|
||
# =============================================================================
|
||
|
||
def run_demo(model_path: str, threshold: float = 0.5):
|
||
"""
|
||
Run a complete demonstration of the Vega inference system.
|
||
|
||
Args:
|
||
model_path: Path to trained model checkpoint
|
||
threshold: Detection threshold (0-1)
|
||
"""
|
||
print("\n" + "=" * 70)
|
||
print("VEGA ISOTOPE IDENTIFICATION - INFERENCE DEMONSTRATION")
|
||
print("=" * 70)
|
||
|
||
# Load model
|
||
print("\n[1] Loading Model")
|
||
print("-" * 70)
|
||
inference = VegaInference(model_path)
|
||
|
||
# Generate sample spectra
|
||
print("\n[2] Generating Sample Spectra")
|
||
print("-" * 70)
|
||
samples = create_sample_spectra_batch()
|
||
print(f"Generated {len(samples)} sample spectra:")
|
||
for name in samples:
|
||
print(f" • {name}")
|
||
|
||
# Run inference on each
|
||
print("\n[3] Running Inference")
|
||
print("-" * 70)
|
||
|
||
for name, spectrum in samples.items():
|
||
print(f"\n{'─' * 70}")
|
||
print(f"Sample: {name}")
|
||
print(f"Spectrum shape: {spectrum.shape}")
|
||
print(f"Spectrum range: [{spectrum.min():.1f}, {spectrum.max():.1f}]")
|
||
|
||
# Run prediction
|
||
result = inference.predict(spectrum, threshold=threshold)
|
||
|
||
print(f"\nPrediction (threshold={threshold}):")
|
||
print(result.summary())
|
||
|
||
# Show top 5 probabilities even if below threshold
|
||
print("\nTop 5 isotope probabilities:")
|
||
all_result = inference.predict(spectrum, threshold=0.0, return_all=True)
|
||
sorted_iso = sorted(all_result.isotopes, key=lambda x: -x.probability)[:5]
|
||
for iso in sorted_iso:
|
||
marker = "✓" if iso.probability >= threshold else " "
|
||
print(f" {marker} {iso.name}: {iso.probability*100:.2f}%")
|
||
|
||
# Show JSON output format
|
||
print("\n[4] JSON Output Format Example")
|
||
print("-" * 70)
|
||
sample_result = inference.predict(samples["Cs-137"], threshold=threshold)
|
||
print(sample_result.to_json())
|
||
|
||
print("\n" + "=" * 70)
|
||
print("DEMONSTRATION COMPLETE")
|
||
print("=" * 70)
|
||
|
||
|
||
def run_single_inference(model_path: str, spectrum_path: str, threshold: float = 0.5):
|
||
"""
|
||
Run inference on a single spectrum file.
|
||
|
||
Args:
|
||
model_path: Path to trained model
|
||
spectrum_path: Path to .npy spectrum file
|
||
threshold: Detection threshold
|
||
"""
|
||
print(f"\nLoading model from: {model_path}")
|
||
inference = VegaInference(model_path)
|
||
|
||
print(f"Loading spectrum from: {spectrum_path}")
|
||
spectrum = np.load(spectrum_path)
|
||
print(f"Spectrum shape: {spectrum.shape}")
|
||
|
||
print(f"\nRunning inference (threshold={threshold})...")
|
||
result = inference.predict(spectrum, threshold=threshold)
|
||
|
||
print("\n" + "=" * 60)
|
||
print("PREDICTION RESULTS")
|
||
print("=" * 60)
|
||
print(result.summary())
|
||
print("=" * 60)
|
||
|
||
return result
|
||
|
||
|
||
# =============================================================================
|
||
# MAIN ENTRY POINT
|
||
# =============================================================================
|
||
|
||
def main():
|
||
"""Main entry point for command-line usage."""
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(
|
||
description="Vega Portable Inference - Gamma Spectrum Isotope Identification",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""
|
||
Examples:
|
||
# Run demo with sample spectra
|
||
python vega_portable_inference.py --model vega_best.pt
|
||
|
||
# Analyze a specific spectrum file
|
||
python vega_portable_inference.py --model vega_best.pt --spectrum my_data.npy
|
||
|
||
# Use lower threshold for higher recall
|
||
python vega_portable_inference.py --model vega_best.pt --threshold 0.3
|
||
"""
|
||
)
|
||
|
||
parser.add_argument(
|
||
"--model", "-m",
|
||
type=str,
|
||
required=True,
|
||
help="Path to trained Vega model checkpoint (.pt file)"
|
||
)
|
||
parser.add_argument(
|
||
"--spectrum", "-s",
|
||
type=str,
|
||
default=None,
|
||
help="Path to spectrum file (.npy). If not provided, runs demo with synthetic spectra."
|
||
)
|
||
parser.add_argument(
|
||
"--threshold", "-t",
|
||
type=float,
|
||
default=0.5,
|
||
help="Detection threshold (0-1). Lower = more sensitive, higher = more specific. Default: 0.5"
|
||
)
|
||
parser.add_argument(
|
||
"--json",
|
||
action="store_true",
|
||
help="Output results in JSON format"
|
||
)
|
||
|
||
args = parser.parse_args()
|
||
|
||
if args.spectrum:
|
||
# Single file inference
|
||
result = run_single_inference(args.model, args.spectrum, args.threshold)
|
||
if args.json:
|
||
print("\nJSON Output:")
|
||
print(result.to_json())
|
||
else:
|
||
# Demo mode
|
||
run_demo(args.model, args.threshold)
|
||
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|