| """
|
| SpiceFusionNet β Multi-Modal Fusion CNN
|
| β’ CNN Branch : EfficientNet-B4 (ImageNet pretrained)
|
| β’ Texture Branch: LBP + GLCM features β MLP (β 256-d)
|
| β’ Color Branch : HSV histogram β MLP (β 128-d)
|
| β’ Fusion : learned attention weights across three branches
|
| β’ Head : FC β BN β ReLU β Dropout(0.4) β Softmax
|
| """
|
| import torch
|
| import torch.nn as nn
|
| import torch.nn.functional as F
|
| import timm
|
|
|
| import config
|
|
|
|
|
| class _MLP(nn.Module):
|
| def __init__(self, in_dim: int, hidden: int, out_dim: int, drop: float = 0.3):
|
| super().__init__()
|
| self.net = nn.Sequential(
|
| nn.Linear(in_dim, hidden),
|
| nn.BatchNorm1d(hidden),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(drop),
|
| nn.Linear(hidden, out_dim),
|
| nn.BatchNorm1d(out_dim),
|
| nn.ReLU(inplace=True),
|
| )
|
|
|
| def forward(self, x):
|
| return self.net(x)
|
|
|
|
|
| class AttentionFusion(nn.Module):
|
| """Gate each branch with learned scalar weights (sum-to-1 via softmax)."""
|
| def __init__(self, cnn_dim: int, tex_dim: int, col_dim: int):
|
| super().__init__()
|
| self.total = cnn_dim + tex_dim + col_dim
|
| self.gate = nn.Sequential(
|
| nn.Linear(self.total, 3),
|
| nn.Softmax(dim=1),
|
| )
|
|
|
| def forward(self, f_cnn, f_tex, f_col):
|
| cat = torch.cat([f_cnn, f_tex, f_col], dim=1)
|
| g = self.gate(cat)
|
| out = torch.cat([
|
| g[:, 0:1] * f_cnn,
|
| g[:, 1:2] * f_tex,
|
| g[:, 2:3] * f_col,
|
| ], dim=1)
|
| return out
|
|
|
|
|
| class SpiceFusionNet(nn.Module):
|
| def __init__(
|
| self,
|
| num_classes: int = config.NUM_CLASSES,
|
| pretrained: bool = config.PRETRAINED,
|
| drop_rate: float = config.DROP_RATE,
|
| cnn_dim: int = config.CNN_DIM,
|
| tex_dim: int = config.TEX_DIM,
|
| col_dim: int = config.COL_DIM,
|
| proj_dim: int = config.PROJ_DIM,
|
| tex_in: int = config.TEX_INPUT_DIM,
|
| col_in: int = config.COL_INPUT_DIM,
|
| ):
|
| super().__init__()
|
|
|
| self.backbone = timm.create_model(
|
| config.BACKBONE,
|
| pretrained=pretrained,
|
| num_classes=0,
|
| global_pool="avg",
|
| drop_rate=drop_rate,
|
| )
|
|
|
|
|
| self.tex_branch = _MLP(tex_in, 128, tex_dim, drop=0.3)
|
|
|
|
|
| self.col_branch = _MLP(col_in, 64, col_dim, drop=0.3)
|
|
|
|
|
| self.fusion = AttentionFusion(cnn_dim, tex_dim, col_dim)
|
| total_dim = cnn_dim + tex_dim + col_dim
|
|
|
|
|
| self.proj_head = nn.Sequential(
|
| nn.Linear(cnn_dim, 512),
|
| nn.ReLU(inplace=True),
|
| nn.Linear(512, proj_dim),
|
| )
|
|
|
|
|
| self.img_head = nn.Sequential(
|
| nn.Linear(cnn_dim, 512),
|
| nn.BatchNorm1d(512),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(drop_rate),
|
| nn.Linear(512, num_classes),
|
| )
|
|
|
|
|
| self.fusion_head = nn.Sequential(
|
| nn.Linear(total_dim, 512),
|
| nn.BatchNorm1d(512),
|
| nn.ReLU(inplace=True),
|
| nn.Dropout(drop_rate),
|
| nn.Linear(512, num_classes),
|
| )
|
|
|
|
|
|
|
| def forward_image(self, x: torch.Tensor) -> torch.Tensor:
|
| """Phase 1: image β logits."""
|
| return self.img_head(self.backbone(x))
|
|
|
| def forward_contrastive(self, x: torch.Tensor) -> torch.Tensor:
|
| """Phase 2: image β L2-normalized projection features (for SupCon)."""
|
| feats = self.backbone(x)
|
| proj = self.proj_head(feats)
|
| return F.normalize(proj, dim=1)
|
|
|
| def forward_fusion(
|
| self,
|
| x: torch.Tensor,
|
| tex: torch.Tensor,
|
| col: torch.Tensor,
|
| ):
|
| """Phase 3: image + texture + color β (logits, proj_feats)."""
|
| f_cnn = self.backbone(x)
|
| f_tex = self.tex_branch(tex)
|
| f_col = self.col_branch(col)
|
| fused = self.fusion(f_cnn, f_tex, f_col)
|
| logits = self.fusion_head(fused)
|
| proj = F.normalize(self.proj_head(f_cnn), dim=1)
|
| return logits, proj
|
|
|
| def forward(self, x, tex=None, col=None):
|
| """Default forward β uses fusion if tex/col provided, else image-only."""
|
| if tex is not None and col is not None:
|
| logits, _ = self.forward_fusion(x, tex, col)
|
| return logits
|
| return self.forward_image(x)
|
|
|
|
|
| def save_checkpoint(path, model, optimizer, epoch, best_val_acc, history):
|
| torch.save({
|
| "epoch": epoch,
|
| "model_state": model.state_dict(),
|
| "optimizer_state": optimizer.state_dict(),
|
| "best_val_acc": best_val_acc,
|
| "history": history,
|
| }, path)
|
|
|
|
|
| def load_checkpoint(path: str, device: torch.device):
|
| ckpt = torch.load(path, map_location=device)
|
| model = SpiceFusionNet()
|
| model.load_state_dict(ckpt["model_state"])
|
| model.to(device)
|
| return model, ckpt.get("epoch", 0), ckpt.get("best_val_acc", 0.0), ckpt.get("history", {})
|
|
|