Ibtida01's picture
Deploy MicroGhost Gradio backend
8f5271b
Raw
History Blame Contribute Delete
37.5 kB
"""
MicroGhost-Thermal: Model Module (V2)
========================================
Dual-branch architecture with late gated fusion for multimodal intrusion detection.
V2 Architecture:
- Dual independent GhostNet+MobileNetV2 branches (RGB + Thermal)
- EnergyGate: learned per-location modality weighting at Scale 2
- BiFusion Neck: bidirectional weighted feature pyramid (replaces FPN)
- ReliabilityClassifier: gate-aware classification (Visible vs Camouflaged)
- AuxSegHead: training-only contrast loss head (zero deployment cost)
- 3 anchors/cell for adjacent person detection
V1 architecture preserved for backward compatibility.
"""
import math
import torch
import torch.nn as nn
import torch.nn.functional as F
from config import (
INPUT_SIZE, INPUT_CHANNELS, NUM_CLASSES, NUM_ANCHORS,
# V1 constants (backward compat)
STEM_CHANNELS, RGB_STEM_CHANNELS, THERMAL_STEM_CHANNELS,
SCALE1_CHANNELS, SCALE2_CHANNELS, SCALE3_CHANNELS,
FPN_CHANNELS, CLASSIFIER_HIDDEN_DIM, EXPAND_RATIO,
# V2 constants
V2_STEM_CHANNELS, V2_SCALE1_CHANNELS, V2_SCALE2_CHANNELS,
V2_SCALE3_CHANNELS, V2_BIFUSION_CHANNELS, V2_CLASSIFIER_HIDDEN_DIM,
V2_EXPAND_RATIO,
ESP32_S3,
)
# ============================================================================
# 1. CORE BUILDING BLOCKS (shared V1 + V2)
# ============================================================================
class GhostModule(nn.Module):
"""
Ghost Module: Generates feature maps using cheap linear operations.
Reduces computation by ~2x while maintaining representational capacity.
"""
def __init__(self, in_channels, out_channels, kernel_size=1,
ratio=2, dw_kernel=3, stride=1, relu=True):
super().__init__()
self.out_channels = out_channels
init_channels = math.ceil(out_channels / ratio)
new_channels = init_channels * (ratio - 1)
self.primary_conv = nn.Conv2d(in_channels, init_channels, kernel_size, stride,
kernel_size // 2, bias=False)
self.primary_bn = nn.BatchNorm2d(init_channels)
# RepGhost parallel 1x1 for richer gradients during training
self.rep_1x1 = nn.Sequential(
nn.Conv2d(in_channels, init_channels, 1, stride, 0, bias=False),
nn.BatchNorm2d(init_channels)
)
self.primary_act = nn.ReLU6(inplace=True) if relu else nn.Identity()
self.cheap_operation = nn.Sequential(
nn.Conv2d(init_channels, new_channels, dw_kernel, 1,
dw_kernel // 2, groups=init_channels, bias=False),
nn.BatchNorm2d(new_channels),
nn.ReLU6(inplace=True) if relu else nn.Identity(),
)
def forward(self, x):
x1 = self.primary_bn(self.primary_conv(x))
if self.training and hasattr(self, 'rep_1x1'):
x1 = x1 + self.rep_1x1(x)
x1 = self.primary_act(x1)
x2 = self.cheap_operation(x1)
out = torch.cat([x1, x2], dim=1)
return out[:, :self.out_channels, :, :]
class GhostBottleneck(nn.Module):
"""
Ghost Bottleneck: Efficient bottleneck using Ghost modules.
Structure: Ghost (expansion) → DW Conv → Ghost (projection, linear) → Residual
"""
def __init__(self, in_channels, mid_channels, out_channels,
dw_kernel=3, stride=1):
super().__init__()
self.stride = stride
self.ghost1 = GhostModule(in_channels, mid_channels, relu=True)
if stride > 1:
self.conv_dw = nn.Sequential(
nn.Conv2d(mid_channels, mid_channels, dw_kernel, stride,
dw_kernel // 2, groups=mid_channels, bias=False),
nn.BatchNorm2d(mid_channels),
)
else:
self.conv_dw = nn.Identity()
self.ghost2 = GhostModule(mid_channels, out_channels, relu=False)
if in_channels != out_channels or stride > 1:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, in_channels, dw_kernel, stride,
dw_kernel // 2, groups=in_channels, bias=False),
nn.BatchNorm2d(in_channels),
nn.Conv2d(in_channels, out_channels, 1, 1, 0, bias=False),
nn.BatchNorm2d(out_channels),
)
else:
self.shortcut = nn.Identity()
def forward(self, x):
residual = self.shortcut(x)
x = self.ghost1(x)
x = self.conv_dw(x)
x = self.ghost2(x)
return x + residual
class InvertedResidual(nn.Module):
"""
MobileNetV2 Inverted Residual Block with Linear Bottleneck.
Structure: Expand → Depthwise → Project (Linear, no ReLU at end)
"""
def __init__(self, in_channels, out_channels, stride=1,
expand_ratio=None):
super().__init__()
expand_ratio = expand_ratio or EXPAND_RATIO
self.stride = stride
self.use_residual = (stride == 1 and in_channels == out_channels)
hidden_dim = int(in_channels * expand_ratio)
layers = []
if expand_ratio != 1:
layers.extend([
nn.Conv2d(in_channels, hidden_dim, 1, 1, 0, bias=False),
nn.BatchNorm2d(hidden_dim),
nn.ReLU6(inplace=True),
])
layers.extend([
nn.Conv2d(hidden_dim, hidden_dim, 3, stride, 1,
groups=hidden_dim, bias=False),
nn.BatchNorm2d(hidden_dim),
nn.ReLU6(inplace=True),
])
layers.extend([
nn.Conv2d(hidden_dim, out_channels, 1, 1, 0, bias=False),
nn.BatchNorm2d(out_channels),
])
self.conv = nn.Sequential(*layers)
def forward(self, x):
if self.use_residual:
return x + self.conv(x)
return self.conv(x)
# ============================================================================
# 2. V1 ARCHITECTURE (kept for backward compatibility)
# ============================================================================
class LightweightFPN(nn.Module):
"""V1 Lightweight Feature Pyramid Network for multi-scale fusion."""
def __init__(self, in_channels_s2, in_channels_s3, out_channels=None):
super().__init__()
out_channels = out_channels or FPN_CHANNELS
self.lateral_s2 = nn.Conv2d(in_channels_s2, out_channels, 1,
bias=False)
self.lateral_s3 = nn.Conv2d(in_channels_s3, out_channels, 1,
bias=False)
self.smooth_s2 = nn.Sequential(
nn.Conv2d(out_channels, out_channels, 3, 1, 1,
groups=out_channels, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU6(inplace=True),
nn.Conv2d(out_channels, out_channels, 1, bias=False),
nn.BatchNorm2d(out_channels),
)
self.smooth_s3 = nn.Sequential(
nn.Conv2d(out_channels, out_channels, 3, 1, 1,
groups=out_channels, bias=False),
nn.BatchNorm2d(out_channels),
nn.ReLU6(inplace=True),
nn.Conv2d(out_channels, out_channels, 1, bias=False),
nn.BatchNorm2d(out_channels),
)
def forward(self, feat_s2, feat_s3):
lat_s2 = self.lateral_s2(feat_s2)
lat_s3 = self.lateral_s3(feat_s3)
upsampled_s3 = F.interpolate(lat_s3, size=lat_s2.shape[2:],
mode='nearest')
p2 = self.smooth_s2(lat_s2 + upsampled_s3)
p3 = self.smooth_s3(lat_s3)
return p2, p3
class SSDLiteHead(nn.Module):
"""SSDLite Detection Head using depthwise-separable convolutions."""
def __init__(self, in_channels, num_anchors=None):
super().__init__()
num_anchors = num_anchors or NUM_ANCHORS
self.feature = nn.Sequential(
nn.Conv2d(in_channels, in_channels, 3, 1, 1,
groups=in_channels, bias=False),
nn.BatchNorm2d(in_channels),
nn.ReLU6(inplace=True),
nn.Conv2d(in_channels, in_channels, 1, bias=False),
nn.BatchNorm2d(in_channels),
nn.ReLU6(inplace=True),
)
self.bbox_head = nn.Conv2d(in_channels, num_anchors * 4, 1)
self.obj_head = nn.Conv2d(in_channels, num_anchors, 1)
def forward(self, x):
feat = self.feature(x)
bbox = self.bbox_head(feat)
obj = self.obj_head(feat)
return bbox, obj
class IntrusionClassifier(nn.Module):
"""V1 Multiclass intrusion classifier with objectness-weighted attention."""
def __init__(self, in_channels, num_classes=None, hidden_dim=None):
super().__init__()
num_classes = num_classes or NUM_CLASSES
hidden_dim = hidden_dim or CLASSIFIER_HIDDEN_DIM
self.modality_attention = nn.Sequential(
nn.Linear(in_channels * 2, hidden_dim // 2),
nn.ReLU6(inplace=True),
nn.Linear(hidden_dim // 2, in_channels * 2),
nn.Sigmoid()
)
self.classifier = nn.Sequential(
nn.Linear(in_channels * 2, hidden_dim),
nn.ReLU6(inplace=True),
nn.Dropout(0.2),
nn.Linear(hidden_dim, num_classes),
)
def forward(self, feat_p2, feat_p3, obj_p2, obj_p3):
attn_p2 = torch.sigmoid(
obj_p2.max(dim=1, keepdim=True)[0]
)
attn_p3 = torch.sigmoid(
obj_p3.max(dim=1, keepdim=True)[0]
)
p2_w = (feat_p2 * attn_p2).sum(dim=[2, 3]) / \
(attn_p2.sum(dim=[2, 3]) + 1e-6)
p3_w = (feat_p3 * attn_p3).sum(dim=[2, 3]) / \
(attn_p3.sum(dim=[2, 3]) + 1e-6)
combined = torch.cat([p2_w, p3_w], dim=1)
attn_weights = self.modality_attention(combined)
attended_features = combined * attn_weights
return self.classifier(attended_features)
class MicroGhostThermal(nn.Module):
"""V1 MicroGhost-Thermal: Early fusion, shared backbone, FPN."""
def __init__(self, num_classes=None, num_anchors=None,
input_size=None, classifier_hidden_dim=None):
super().__init__()
num_classes = num_classes or NUM_CLASSES
num_anchors = num_anchors or NUM_ANCHORS
input_size = input_size or INPUT_SIZE
classifier_hidden_dim = classifier_hidden_dim or CLASSIFIER_HIDDEN_DIM
self.input_size = input_size
if isinstance(input_size, tuple):
self.input_h, self.input_w = input_size
else:
self.input_h, self.input_w = input_size, input_size
self.num_classes = num_classes
self.classifier_hidden_dim = classifier_hidden_dim
# Dual Stem
self.rgb_stem = nn.Sequential(
nn.Conv2d(3, 8, 3, 2, 1, bias=False),
nn.BatchNorm2d(8),
nn.ReLU6(inplace=True),
GhostModule(8, RGB_STEM_CHANNELS, kernel_size=1, stride=1),
)
self.thermal_stem = nn.Sequential(
nn.Conv2d(1, 8, 3, 2, 1, bias=False),
nn.BatchNorm2d(8),
nn.ReLU6(inplace=True),
GhostModule(8, THERMAL_STEM_CHANNELS, kernel_size=1, stride=1),
)
# Shared backbone
self.scale1 = nn.Sequential(
GhostBottleneck(STEM_CHANNELS, STEM_CHANNELS * 2,
SCALE1_CHANNELS, stride=2),
GhostBottleneck(SCALE1_CHANNELS, SCALE1_CHANNELS * 2,
SCALE1_CHANNELS, stride=1),
)
self.scale2 = nn.Sequential(
InvertedResidual(SCALE1_CHANNELS, SCALE2_CHANNELS,
stride=2, expand_ratio=EXPAND_RATIO),
InvertedResidual(SCALE2_CHANNELS, SCALE2_CHANNELS,
stride=1, expand_ratio=EXPAND_RATIO),
)
self.scale3 = nn.Sequential(
InvertedResidual(SCALE2_CHANNELS, SCALE3_CHANNELS,
stride=2, expand_ratio=EXPAND_RATIO),
InvertedResidual(SCALE3_CHANNELS, SCALE3_CHANNELS,
stride=1, expand_ratio=EXPAND_RATIO),
)
self.fpn = LightweightFPN(
in_channels_s2=SCALE2_CHANNELS,
in_channels_s3=SCALE3_CHANNELS,
out_channels=FPN_CHANNELS,
)
self.head_small = SSDLiteHead(FPN_CHANNELS, num_anchors=num_anchors)
self.head_large = SSDLiteHead(FPN_CHANNELS, num_anchors=num_anchors)
self.classifier = IntrusionClassifier(
in_channels=FPN_CHANNELS,
num_classes=num_classes,
hidden_dim=classifier_hidden_dim,
)
self._initialize_weights()
def _initialize_weights(self):
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out',
nonlinearity='relu')
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.BatchNorm2d):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
if m.bias is not None:
nn.init.zeros_(m.bias)
def forward(self, x):
x_rgb = x[:, :3, :, :]
x_thermal = x[:, 3:, :, :]
feat_rgb = self.rgb_stem(x_rgb)
feat_thermal = self.thermal_stem(x_thermal)
feat_fused = torch.cat([feat_rgb, feat_thermal], dim=1)
s1 = self.scale1(feat_fused)
s2 = self.scale2(s1)
s3 = self.scale3(s2)
p2, p3 = self.fpn(s2, s3)
bbox_small, obj_small = self.head_small(p2)
bbox_large, obj_large = self.head_large(p3)
label = self.classifier(p2, p3, obj_small, obj_large)
return {
'bbox_small': bbox_small,
'obj_small': obj_small,
'bbox_large': bbox_large,
'obj_large': obj_large,
'label': label,
}
# ============================================================================
# 3. V2 NEW MODULES
# ============================================================================
class EnergyGate(nn.Module):
"""
Per-location gating between RGB and Thermal at Scale 2.
Computes learned energy projections and applies softmax across the two
branches at each spatial location. This lets the network suppress
whichever branch has unreliable content (e.g., dark RGB at night,
hot car bonnet in thermal).
~30 parameters. Applied BEFORE BiFusion Neck.
"""
def __init__(self, channels):
super().__init__()
self.proj_rgb = nn.Conv2d(channels, 1, 1, bias=True)
self.proj_thm = nn.Conv2d(channels, 1, 1, bias=True)
self.temperature = nn.Parameter(torch.ones(1) * 2.0)
def forward(self, feat_rgb, feat_thm):
e_rgb = self.proj_rgb(feat_rgb) # (B, 1, H, W)
e_thm = self.proj_thm(feat_thm) # (B, 1, H, W)
temp = torch.clamp(self.temperature, min=0.5, max=5.0)
weights = torch.softmax(
torch.stack([e_rgb, e_thm], dim=1) / temp, # (B, 2, 1, H, W)
dim=1
)
w_rgb = weights[:, 0] # (B, 1, H, W)
w_thm = weights[:, 1] # (B, 1, H, W)
fused = w_rgb * feat_rgb + w_thm * feat_thm
return fused, w_rgb, w_thm
class BiFusionNeck(nn.Module):
"""
Bidirectional weighted feature pyramid (replaces LightweightFPN).
Receives S2 fused features (from EnergyGate) and S3 features from
both branches separately. Uses learned normalized weights (BiFPN-style)
for top-down and bottom-up passes.
Inputs:
fused_s2: (B, s2_ch, 16, 20) — gated EnergyGate output
feat_rgb_s3: (B, s3_ch, 8, 10)
feat_thm_s3: (B, s3_ch, 8, 10)
Outputs:
p2: (B, out_ch, 16, 20) — small/distant target features
p3: (B, out_ch, 8, 10) — large/close target features
"""
def __init__(self, s2_ch=None, s3_ch=None, out_ch=None):
super().__init__()
s2_ch = s2_ch or V2_SCALE2_CHANNELS
s3_ch = s3_ch or V2_SCALE3_CHANNELS
out_ch = out_ch or V2_BIFUSION_CHANNELS
# Lateral projections to unified channel count
self.lat_s2 = nn.Conv2d(s2_ch, out_ch, 1, bias=False)
self.lat_rgb = nn.Conv2d(s3_ch, out_ch, 1, bias=False)
self.lat_thm = nn.Conv2d(s3_ch, out_ch, 1, bias=False)
# Learned BiFPN weights (softmax-normalized)
# Top-down: P3 = w1*rgb_s3 + w2*thm_s3
self.w_td = nn.Parameter(torch.zeros(2))
# Bottom-up P2: w3*s2 + w4*P3_upsampled
self.w_bu = nn.Parameter(torch.zeros(2))
# DW-separable refinement convolutions
self.refine_p3 = self._dw_sep(out_ch, out_ch)
self.refine_p2 = self._dw_sep(out_ch, out_ch)
def _dw_sep(self, in_ch, out_ch):
return nn.Sequential(
nn.Conv2d(in_ch, in_ch, 3, 1, 1, groups=in_ch, bias=False),
nn.BatchNorm2d(in_ch),
nn.ReLU6(inplace=True),
nn.Conv2d(in_ch, out_ch, 1, bias=False),
nn.BatchNorm2d(out_ch),
)
def forward(self, fused_s2, feat_rgb_s3, feat_thm_s3):
eps = 1e-4
# Project to unified channels
lat_s2 = self.lat_s2(fused_s2) # (B, CH, 16, 20)
lat_rgb = self.lat_rgb(feat_rgb_s3) # (B, CH, 8, 10)
lat_thm = self.lat_thm(feat_thm_s3) # (B, CH, 8, 10)
# ── Top-down: fuse S3 from both branches ──
w_td = F.softplus(self.w_td) + eps
w_td = w_td / w_td.sum()
p3_td = self.refine_p3(
w_td[0] * lat_rgb + w_td[1] * lat_thm
) # (B, CH, 8, 10)
# ── Bottom-up: upsample P3 and merge with S2 ──
p3_up = F.interpolate(p3_td, size=lat_s2.shape[2:], mode='nearest')
w_bu = F.softplus(self.w_bu) + eps
w_bu = w_bu / w_bu.sum()
p2_out = self.refine_p2(
w_bu[0] * lat_s2 + w_bu[1] * p3_up
) # (B, CH, 16, 20)
return p2_out, p3_td
class ReliabilityClassifier(nn.Module):
"""
Gate-aware classifier for V2 (replaces IntrusionClassifier).
Receives the EnergyGate weights as auxiliary input, enabling
Visible vs Camouflaged classification based on which modality
was dominant at detection time.
- w_thm >> w_rgb → thermal dominant → likely Person_Camouflaged
- w_rgb ≈ w_thm → both agree → Person_Visible
"""
def __init__(self, in_channels=None, num_classes=None, hidden_dim=None):
super().__init__()
in_channels = in_channels or V2_BIFUSION_CHANNELS
num_classes = num_classes or NUM_CLASSES
hidden_dim = hidden_dim or V2_CLASSIFIER_HIDDEN_DIM
# Modality-gate-aware attention (+2 for gate weight scalars)
self.modality_gate = nn.Sequential(
nn.Linear(in_channels * 2 + 2, hidden_dim // 2),
nn.ReLU6(inplace=True),
nn.Linear(hidden_dim // 2, in_channels * 2),
nn.Sigmoid(),
)
self.classifier = nn.Sequential(
nn.Linear(in_channels * 2, hidden_dim),
nn.ReLU6(inplace=True),
nn.Dropout(0.2),
nn.Linear(hidden_dim, num_classes + 1), # +1 for IoU regression
)
def forward(self, feat_p2, feat_p3, obj_p2, obj_p3, w_rgb, w_thm):
# Objectness-weighted spatial pooling
attn_p2 = torch.sigmoid(obj_p2.max(dim=1, keepdim=True)[0])
attn_p3 = torch.sigmoid(obj_p3.max(dim=1, keepdim=True)[0])
p2_w = (feat_p2 * attn_p2).sum([2, 3]) / (attn_p2.sum([2, 3]) + 1e-6)
p3_w = (feat_p3 * attn_p3).sum([2, 3]) / (attn_p3.sum([2, 3]) + 1e-6)
combined = torch.cat([p2_w, p3_w], dim=1) # (B, 2C)
# Gate summary: mean weight across spatial dimensions → (B, 2)
gate_rgb_mean = w_rgb.mean(dim=[1, 2, 3]) # (B,)
gate_thm_mean = w_thm.mean(dim=[1, 2, 3]) # (B,)
gate_summary = torch.stack([gate_rgb_mean, gate_thm_mean], dim=1) # (B, 2)
# Attend features using gate-aware attention
gate_input = torch.cat([combined, gate_summary], dim=1) # (B, 2C+2)
attn_weights = self.modality_gate(gate_input) # (B, 2C)
attended = combined * attn_weights
return self.classifier(attended)
class AuxSegHead(nn.Module):
"""
Training-only auxiliary segmentation head for TFDet-style contrast loss.
Removed at export. Zero deployment cost.
Produces per-spatial-location person/background logits from p2 features.
"""
def __init__(self, in_ch=None):
super().__init__()
in_ch = in_ch or V2_BIFUSION_CHANNELS
self.proj = nn.Conv2d(in_ch, 1, 1)
def forward(self, feat_p2):
return self.proj(feat_p2) # (B, 1, H, W)
# ============================================================================
# 4. V2 COMPLETE MODEL: MicroGhostV2
# ============================================================================
class MicroGhostV2(nn.Module):
"""
MicroGhost-V2: Asynchronous Dual-Branch Architecture.
Key changes vs V1:
- Parallel independent RGB + Thermal branches (no shared weights)
- EnergyGate at Scale 2 for learned modality weighting
- BiFusion Neck (bidirectional weighted pyramid, replaces FPN)
- ReliabilityClassifier with gate-weight awareness
- AuxSegHead for training-only contrast loss
- 3 anchors per cell
- Graceful camera failure (either branch can operate independently)
Architecture:
┌────────────────────────────────────────────────────────────┐
│ RGB (3ch) Thermal (1ch) │
│ │ │ │
│ RGB Stem(16) Thm Stem(16) │
│ │ │ │
│ RGB Scale1(24) Thm Scale1(24) │
│ │ │ │
│ RGB Scale2(32) ──► EnergyGate ◄── Thm Scale2(32) │
│ │ fused(32) │
│ RGB Scale3(48) ──► BiFusion Neck ◄── Thm Scale3(48) │
│ ┌────┴────┐ │
│ p2(48) p3(48) │
│ SmallHead LargeHead │
│ └────┬────┘ │
│ ReliabilityClassifier │
└────────────────────────────────────────────────────────────┘
"""
def __init__(self, num_classes=None, num_anchors=None,
input_size=None, classifier_hidden_dim=None,
training_mode=True):
super().__init__()
num_classes = num_classes or NUM_CLASSES
num_anchors = num_anchors or NUM_ANCHORS
input_size = input_size or INPUT_SIZE
classifier_hidden_dim = classifier_hidden_dim or V2_CLASSIFIER_HIDDEN_DIM
self.input_size = input_size
if isinstance(input_size, tuple):
self.input_h, self.input_w = input_size
else:
self.input_h, self.input_w = input_size, input_size
self.num_classes = num_classes
self.classifier_hidden_dim = classifier_hidden_dim
self.training_mode = training_mode
S = V2_STEM_CHANNELS
S1 = V2_SCALE1_CHANNELS
S2 = V2_SCALE2_CHANNELS
S3 = V2_SCALE3_CHANNELS
E = V2_EXPAND_RATIO
# ========== RGB BRANCH (fully independent) ==========
self.rgb_stem = nn.Sequential(
nn.Conv2d(3, 8, 3, 2, 1, bias=False),
nn.BatchNorm2d(8),
nn.ReLU6(inplace=True),
GhostModule(8, S, kernel_size=1, stride=1),
)
self.rgb_scale1 = nn.Sequential(
GhostBottleneck(S, S * 2, S1, stride=2),
GhostBottleneck(S1, S1 * 2, S1, stride=1),
)
self.rgb_scale2 = nn.Sequential(
InvertedResidual(S1, S2, stride=2, expand_ratio=E),
InvertedResidual(S2, S2, stride=1, expand_ratio=E),
)
self.rgb_scale3 = nn.Sequential(
InvertedResidual(S2, S3, stride=2, expand_ratio=E),
InvertedResidual(S3, S3, stride=1, expand_ratio=E),
)
# ========== THERMAL BRANCH (fully independent) ==========
self.thm_stem = nn.Sequential(
nn.Conv2d(1, 8, 3, 2, 1, bias=False),
nn.BatchNorm2d(8),
nn.ReLU6(inplace=True),
GhostModule(8, S, kernel_size=1, stride=1),
)
self.thm_scale1 = nn.Sequential(
GhostBottleneck(S, S * 2, S1, stride=2),
GhostBottleneck(S1, S1 * 2, S1, stride=1),
)
self.thm_scale2 = nn.Sequential(
InvertedResidual(S1, S2, stride=2, expand_ratio=E),
InvertedResidual(S2, S2, stride=1, expand_ratio=E),
)
self.thm_scale3 = nn.Sequential(
InvertedResidual(S2, S3, stride=2, expand_ratio=E),
InvertedResidual(S3, S3, stride=1, expand_ratio=E),
)
# ========== ENERGY GATE (at Scale 2 output) ==========
self.energy_gate = EnergyGate(channels=S2)
# ========== BIFUSION NECK (replaces FPN) ==========
self.bifusion_neck = BiFusionNeck(
s2_ch=S2, s3_ch=S3, out_ch=V2_BIFUSION_CHANNELS,
)
# ========== DETECTION HEADS (3 anchors each) ==========
self.head_small = SSDLiteHead(V2_BIFUSION_CHANNELS, num_anchors=num_anchors)
self.head_large = SSDLiteHead(V2_BIFUSION_CHANNELS, num_anchors=num_anchors)
# ========== RELIABILITY CLASSIFIER ==========
self.classifier = ReliabilityClassifier(
in_channels=V2_BIFUSION_CHANNELS,
num_classes=num_classes,
hidden_dim=classifier_hidden_dim,
)
# ========== AUX SEGMENTATION HEAD (training only) ==========
self.aux_seg_head = AuxSegHead(in_ch=V2_BIFUSION_CHANNELS)
# Initialize weights
self._initialize_weights()
def _initialize_weights(self):
"""Kaiming initialization for better convergence."""
for m in self.modules():
if isinstance(m, nn.Conv2d):
nn.init.kaiming_normal_(m.weight, mode='fan_out',
nonlinearity='relu')
if m.bias is not None:
nn.init.zeros_(m.bias)
elif isinstance(m, nn.BatchNorm2d):
nn.init.ones_(m.weight)
nn.init.zeros_(m.bias)
elif isinstance(m, nn.Linear):
nn.init.normal_(m.weight, 0, 0.01)
if m.bias is not None:
nn.init.zeros_(m.bias)
def forward(self, x):
"""
Forward pass for V2 dual-branch architecture.
Args:
x: (B, 4, H, W) float tensor (channels 0:3 = RGB, channel 3: = Thermal)
Returns:
dict with detection outputs + gate weights + aux seg logits
"""
x_rgb = x[:, :3] # (B, 3, H, W)
x_thm = x[:, 3:] # (B, 1, H, W)
# Modality masking to prevent BatchNorm shift artifacts on empty inputs
rgb_present = (x_rgb.abs().mean(dim=[1,2,3], keepdim=True) > 1e-5).float()
thm_present = (x_thm.abs().mean(dim=[1,2,3], keepdim=True) > 1e-5).float()
# === RGB Branch (fully independent) ===
feat_rgb = self.rgb_stem(x_rgb) # (B, 16, 64, 80)
feat_rgb = self.rgb_scale1(feat_rgb) # (B, 24, 32, 40)
feat_rgb_s2 = self.rgb_scale2(feat_rgb) # (B, 32, 16, 20)
feat_rgb_s3 = self.rgb_scale3(feat_rgb_s2) # (B, 48, 8, 10)
feat_rgb_s2 = feat_rgb_s2 * rgb_present
feat_rgb_s3 = feat_rgb_s3 * rgb_present
# === Thermal Branch (fully independent) ===
feat_thm = self.thm_stem(x_thm) # (B, 16, 64, 80)
feat_thm = self.thm_scale1(feat_thm) # (B, 24, 32, 40)
feat_thm_s2 = self.thm_scale2(feat_thm) # (B, 32, 16, 20)
feat_thm_s3 = self.thm_scale3(feat_thm_s2) # (B, 48, 8, 10)
feat_thm_s2 = feat_thm_s2 * thm_present
feat_thm_s3 = feat_thm_s3 * thm_present
# === Energy Gate (learned modality weighting at S2) ===
fused_s2, w_rgb, w_thm = self.energy_gate(feat_rgb_s2, feat_thm_s2)
# === BiFusion Neck (replaces FPN) ===
p2, p3 = self.bifusion_neck(fused_s2, feat_rgb_s3, feat_thm_s3)
# === Detection Heads (3 anchors each) ===
bbox_small, obj_small = self.head_small(p2)
bbox_large, obj_large = self.head_large(p3)
# === Reliability Classifier ===
label = self.classifier(p2, p3, obj_small, obj_large, w_rgb, w_thm)
result = {
'bbox_small': bbox_small,
'obj_small': obj_small,
'bbox_large': bbox_large,
'obj_large': obj_large,
'label': label,
'w_rgb': w_rgb,
'w_thm': w_thm,
}
# Aux seg head (training only — removed at export)
if self.training_mode and self.training:
result['aux_seg'] = self.aux_seg_head(p2)
return result
def freeze_early_layers(self):
"""Freeze stems and Scale 1 for Phase 3 fine-tuning."""
for name, param in self.named_parameters():
if any(prefix in name for prefix in
['rgb_stem', 'thm_stem', 'rgb_scale1', 'thm_scale1']):
param.requires_grad = False
def unfreeze_all(self):
"""Unfreeze all parameters for Phase 4 polish."""
for param in self.parameters():
param.requires_grad = True
# ============================================================================
# 5. MODEL ANALYSIS UTILITIES
# ============================================================================
def count_parameters(model):
"""Count trainable parameters."""
return sum(p.numel() for p in model.parameters() if p.requires_grad)
def count_all_parameters(model):
"""Count all parameters (including frozen)."""
return sum(p.numel() for p in model.parameters())
def estimate_model_size(model):
"""Estimate model size in different quantization formats."""
param_count = count_all_parameters(model)
fp32_mb = param_count * 4 / (1024 * 1024)
int8_kb = param_count * 1 / 1024
return param_count, fp32_mb, int8_kb
def estimate_peak_sram(model, input_size=None, batch_size=1):
"""Estimate peak SRAM usage during inference on ESP32-S3."""
input_size = input_size or INPUT_SIZE
activations = []
def hook_fn(module, inp, output):
if isinstance(output, torch.Tensor):
activations.append(output.numel())
hooks = []
for module in model.modules():
if isinstance(module, (nn.Conv2d, nn.BatchNorm2d, nn.ReLU6)):
hooks.append(module.register_forward_hook(hook_fn))
model.eval()
with torch.no_grad():
h, w = input_size if isinstance(input_size, tuple) else (input_size, input_size)
x = torch.randn(batch_size, INPUT_CHANNELS, h, w)
_ = model(x)
for hook in hooks:
hook.remove()
input_bytes = batch_size * INPUT_CHANNELS * h * w
max_activation_int8 = max(activations) if activations else 0
return {
'input_buffer_kb': input_bytes / 1024,
'peak_activation_fp32_kb': max(activations) * 4 / 1024 if activations else 0,
'peak_activation_int8_kb': max_activation_int8 / 1024,
'total_arena_int8_kb': (input_bytes + max_activation_int8) / 1024,
'fits_esp32_s3': (input_bytes + max_activation_int8) < ESP32_S3['max_arena_sram_kb'] * 1024,
}
def print_model_analysis(model):
"""Print comprehensive model analysis for V1 or V2."""
param_count, fp32_mb, int8_kb = estimate_model_size(model)
sram = estimate_peak_sram(model)
is_v2 = isinstance(model, MicroGhostV2)
model_name = "MicroGhost-V2" if is_v2 else "MicroGhost-V1"
print(f"\n[OK] {model_name}:")
print(f" Parameters: {param_count:,}")
print(f" Trainable: {count_parameters(model):,}")
print(f" Est. Size (FP32): {param_count * 4 / 1024:,.1f} KB")
print(f" Est. Size (FP16): {param_count * 2 / 1024:,.1f} KB")
print(f" Est. Size (INT8): {int8_kb:,.1f} KB")
print()
print(f" Input buffer: {sram['input_buffer_kb']:>10.1f} KB")
print(f" Peak act (FP32): {sram['peak_activation_fp32_kb']:>10.1f} KB")
print(f" Peak act (INT8): {sram['peak_activation_int8_kb']:>10.1f} KB")
print(f" Total arena INT8: {sram['total_arena_int8_kb']:>10.1f} KB")
print(f" Fits ESP32-S3: {'OK' if sram['fits_esp32_s3'] else 'FAIL'} "
f"(limit: {ESP32_S3['max_arena_sram_kb']}KB)")
# Layer-by-layer breakdown
print(f"\n {'Layer':<35} {'Params':>12} {'Size (KB)':>10}")
print(" " + "-" * 59)
if is_v2:
components = [
('RGB Stem', model.rgb_stem),
('RGB Scale 1 (Ghost)', model.rgb_scale1),
('RGB Scale 2 (InvRes)', model.rgb_scale2),
('RGB Scale 3 (InvRes)', model.rgb_scale3),
('Thm Stem', model.thm_stem),
('Thm Scale 1 (Ghost)', model.thm_scale1),
('Thm Scale 2 (InvRes)', model.thm_scale2),
('Thm Scale 3 (InvRes)', model.thm_scale3),
('Energy Gate', model.energy_gate),
('BiFusion Neck', model.bifusion_neck),
('Head Small', model.head_small),
('Head Large', model.head_large),
('Classifier', model.classifier),
('Aux Seg Head (train only)', model.aux_seg_head),
]
else:
components = [
('RGB Stem', model.rgb_stem),
('Thermal Stem', model.thermal_stem),
('Scale 1 (Ghost)', model.scale1),
('Scale 2 (InvRes)', model.scale2),
('Scale 3 (InvRes)', model.scale3),
('FPN', model.fpn),
('Head Small', model.head_small),
('Head Large', model.head_large),
('Classifier', model.classifier),
]
total = 0
for name, module in components:
params = sum(p.numel() for p in module.parameters())
kb = params * 4 / 1024
total += params
print(f" {name:<35} {params:>12,} {kb:>8.1f} KB")
print(" " + "-" * 59)
print(f" {'TOTAL':<35} {total:>12,} {total * 4 / 1024:>8.1f} KB")
# ============================================================================
# TEST
# ============================================================================
if __name__ == '__main__':
print("=" * 60)
print(" Model Module — V2 Self Test")
print("=" * 60)
# Test V2 model
model = MicroGhostV2()
print_model_analysis(model)
# Test forward pass (training mode)
model.train()
h, w = INPUT_SIZE if isinstance(INPUT_SIZE, tuple) else (INPUT_SIZE, INPUT_SIZE)
dummy = torch.randn(2, INPUT_CHANNELS, h, w)
with torch.no_grad():
outputs = model(dummy)
print(f"\n[OK] V2 Forward Pass (training):")
for key, val in outputs.items():
print(f" {key}: {val.shape}")
# Test forward pass (eval mode — no aux_seg)
model.eval()
with torch.no_grad():
outputs_eval = model(dummy)
print(f"\n[OK] V2 Forward Pass (eval):")
for key, val in outputs_eval.items():
print(f" {key}: {val.shape}")
assert 'aux_seg' not in outputs_eval, "aux_seg should not be in eval outputs"
# Test single-modality (CMM-RXTO: thermal zeroed)
dummy_rxto = dummy.clone()
dummy_rxto[:, 3:] = 0.0
model.eval()
with torch.no_grad():
outputs_rxto = model(dummy_rxto)
print(f"\n[OK] CMM-RXTO (thermal zeroed) — forward pass OK")
# Test single-modality (CMM-ROTX: RGB zeroed)
dummy_rotx = dummy.clone()
dummy_rotx[:, :3] = 0.0
with torch.no_grad():
outputs_rotx = model(dummy_rotx)
print(f"[OK] CMM-ROTX (RGB zeroed) — forward pass OK")
# Test freeze/unfreeze
model.freeze_early_layers()
trainable_after_freeze = count_parameters(model)
model.unfreeze_all()
trainable_after_unfreeze = count_parameters(model)
print(f"\n[OK] Freeze test: {trainable_after_freeze:,} trainable (frozen) -> "
f"{trainable_after_unfreeze:,} trainable (unfrozen)")
# Verify gate weights sum to ~1
w_rgb_mean = outputs_eval['w_rgb'].mean().item()
w_thm_mean = outputs_eval['w_thm'].mean().item()
print(f"\n[OK] Gate weights: w_rgb={w_rgb_mean:.4f}, w_thm={w_thm_mean:.4f}, "
f"sum={w_rgb_mean + w_thm_mean:.4f}")
print("\n[OK] All V2 model tests passed!")