| """
|
| InsightUX GazeCNN v4 β EfficientNet-B0 backbone
|
| ================================================
|
| WHY EfficientNet-B0 over ResNet-18:
|
| - ResNet-18 : 11.7M params, designed for 224x224 images
|
| - EfficientNet-B0: 5.3M params, designed for 224x224 but scales
|
| much better to small inputs like our 36x60 eye patches.
|
| - EfficientNet uses depthwise separable convolutions which are
|
| better at capturing fine-grained texture (iris patterns) with
|
| fewer parameters β less overfitting risk.
|
| - MobileNetV3 was also considered but EfficientNet-B0 consistently
|
| outperforms it on small image regression tasks.
|
| - ~4.5 deg expected val error (vs 5.89 in v2, ~8.3 stuck in v3).
|
|
|
| Architecture:
|
| Stream A (binocular eye patches):
|
| Input : (B, 2, 36, 60) <- left & right stacked as 2-channel
|
| EfficientNet-B0 (pretrained, first conv replaced for 2ch)
|
| AdaptiveAvgPool -> (B, 1280)
|
| FC(1280->256), BN, ReLU, Dropout(0.4) -> (B, 256)
|
|
|
| Stream B (head pose):
|
| Input : (B, 3)
|
| Linear(3->64), BN, ReLU, Dropout(0.1) -> (B, 64)
|
|
|
| Fusion:
|
| Concat -> (B, 320)
|
| BN(320)
|
| FC(320->128), ReLU, Dropout(0.3)
|
| FC(128->64), ReLU, Dropout(0.2)
|
| FC(64->2) -> [pitch_rad, yaw_rad]
|
| """
|
|
|
| import math
|
| import torch
|
| import torch.nn as nn
|
| import torchvision.models as tv_models
|
|
|
|
|
| class GazeCNNv4(nn.Module):
|
|
|
| def __init__(self):
|
| super().__init__()
|
|
|
|
|
| eff = tv_models.efficientnet_b0(
|
| weights=tv_models.EfficientNet_B0_Weights.IMAGENET1K_V1
|
| )
|
|
|
|
|
| orig_w = eff.features[0][0].weight.data
|
| new_w = orig_w[:, :2, :, :].clone()
|
| new_w = new_w * (3.0 / 2.0)
|
|
|
| eff.features[0][0] = nn.Conv2d(
|
| 2, 32, kernel_size=3, stride=2, padding=1, bias=False
|
| )
|
| eff.features[0][0].weight.data = new_w
|
|
|
|
|
| self.backbone = eff.features
|
| self.pool = nn.AdaptiveAvgPool2d(1)
|
|
|
| self.fc_a = nn.Sequential(
|
| nn.Linear(1280, 256),
|
| nn.BatchNorm1d(256),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(p=0.4),
|
| )
|
|
|
|
|
| self.stream_b = nn.Sequential(
|
| nn.Linear(3, 64),
|
| nn.BatchNorm1d(64),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(p=0.1),
|
| )
|
|
|
|
|
| self.fusion = nn.Sequential(
|
| nn.BatchNorm1d(256 + 64),
|
| nn.Linear(320, 128),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(p=0.3),
|
| nn.Linear(128, 64),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(p=0.2),
|
| nn.Linear(64, 2),
|
| )
|
|
|
| self._init_new_layers()
|
|
|
| def _init_new_layers(self):
|
| """Initialise only the layers we added (backbone weights kept from ImageNet)."""
|
| for m in [self.fc_a, self.stream_b, self.fusion]:
|
| for layer in m.modules():
|
| if isinstance(layer, nn.Linear):
|
| nn.init.xavier_uniform_(layer.weight)
|
| nn.init.zeros_(layer.bias)
|
| elif isinstance(layer, nn.BatchNorm1d):
|
| nn.init.ones_(layer.weight)
|
| nn.init.zeros_(layer.bias)
|
|
|
| def forward(self, patch: torch.Tensor,
|
| head_pose: torch.Tensor) -> torch.Tensor:
|
|
|
| feat_a = self.pool(self.backbone(patch)).flatten(1)
|
| feat_a = self.fc_a(feat_a)
|
|
|
|
|
| feat_b = self.stream_b(head_pose)
|
|
|
|
|
| fused = torch.cat([feat_a, feat_b], dim=1)
|
| return self.fusion(fused)
|
|
|
| def count_parameters(self) -> int:
|
| return sum(p.numel() for p in self.parameters() if p.requires_grad)
|
|
|
| def freeze_backbone_early(self):
|
| """Phase 1: freeze only the first 4 blocks (generic edge detectors)."""
|
|
|
|
|
| for i, block in enumerate(self.backbone):
|
| freeze = i < 4
|
| for p in block.parameters():
|
| p.requires_grad = not freeze
|
|
|
| def unfreeze_all(self):
|
| """Phase 2: unfreeze everything."""
|
| for p in self.parameters():
|
| p.requires_grad = True
|
|
|
|
|
| def angular_loss(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
| """
|
| Geodesic (cosine) loss β directly matches the angular error metric.
|
| Range [0, 2]. Lower is better.
|
| """
|
| def to_unit_vec(a):
|
| pitch, yaw = a[:, 0], a[:, 1]
|
| x = torch.cos(pitch) * torch.sin(yaw)
|
| y = torch.sin(pitch)
|
| z = torch.cos(pitch) * torch.cos(yaw)
|
| v = torch.stack([x, y, z], dim=1)
|
| return v / (v.norm(dim=1, keepdim=True) + 1e-8)
|
|
|
| cos_sim = (to_unit_vec(pred) * to_unit_vec(target)).sum(dim=1).clamp(-1 + 1e-7, 1 - 1e-7)
|
| return (1.0 - cos_sim).mean()
|
|
|
|
|
| def angular_error_deg(pred: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
| """Evaluation metric: mean angular error in degrees."""
|
| def to_unit_vec(a):
|
| pitch, yaw = a[:, 0], a[:, 1]
|
| x = torch.cos(pitch) * torch.sin(yaw)
|
| y = torch.sin(pitch)
|
| z = torch.cos(pitch) * torch.cos(yaw)
|
| v = torch.stack([x, y, z], dim=1)
|
| return v / (v.norm(dim=1, keepdim=True) + 1e-8)
|
|
|
| cos_sim = (to_unit_vec(pred) * to_unit_vec(target)).sum(dim=1).clamp(-1, 1)
|
| return torch.acos(cos_sim) * (180.0 / math.pi)
|
|
|
|
|
| if __name__ == "__main__":
|
| model = GazeCNNv4()
|
| print(f"Total parameters: {model.count_parameters():,}")
|
|
|
| model.freeze_backbone_early()
|
| trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
|
| print(f"Trainable (Phase 1, partial freeze): {trainable:,}")
|
|
|
| model.unfreeze_all()
|
| print(f"Trainable (Phase 2, all): {model.count_parameters():,}")
|
|
|
| patch = torch.randn(4, 2, 36, 60)
|
| pose = torch.randn(4, 3)
|
| out = model(patch, pose)
|
| print(f"Output shape: {out.shape}")
|
|
|