一句话总结

SGNet 通过分组卷积分离光谱与空间特征,在 4.75M 超轻量参数下实现 97.8% 的鱼肉新鲜度分类精度,比 ResNet-50 参数量小 18 倍,面向工业实时部署设计。

为什么这个问题重要?

鱼肉腐败是全球食品安全的核心挑战。传统检测依赖人工嗅觉判断或破坏性化学分析(挥发性盐基氮测定),既慢又主观。高光谱成像(HSI) 提供了一条非接触式路径:用 100~200 个波段的光谱相机拍摄鱼肉,通过生化成分在不同波长的吸收特征判断新鲜度。

问题在于,现有深度学习方法直接把 HSI 当 RGB 图像处理,忽视了两个关键特性:

  • 光谱主导性:新鲜度信息主要编码在光谱维度,空间纹理是次要的
  • 样本稀缺性:标注 HSI 数据代价高,训练集通常只有几百张

SGNet(Spectral-Grouped Network)就是为这两个约束专门设计的。


高光谱数据:不是多了通道的 RGB

数据结构

一张 HSI 图像是 $H \times W \times C$ 的数据立方体,其中 $C$ 通常有 200 个波段(RGB 只有 3 个)。关键特性:相邻波段之间高度相关——680nm 和 682nm 的光谱响应几乎相同,但 680nm 和 900nm 之间的跨带差异才包含真正有用的生化信息。

        波段维度 (200个波长)
        ←——————————————→
  ↑    ┌───────────────┐
  H    │   HSI 数据    │ ← 每个像素点是一条完整的光谱曲线
  ↓    │   立方体      │
  ↑    │               │
  W    └───────────────┘

为什么标准 CNN 不够用?

Conv2d 在空间局部感知上设计良好,但面对 HSI 有两个问题:

  1. 把所有 200 个波段当作普通”通道”,不区分相邻波段的强相关性
  2. 参数量随波段数线性增长,在小样本数据上极易过拟合

SGNet 核心架构

直觉:把光谱波段分组处理

设想 200 个波段被分成 4 组,每组 50 个相邻波段。每组内用独立卷积核提取”局部光谱模式”(比如某种蛋白质分解产物的吸收峰),然后跨组聚合信息。

输入 [B, 200, H, W]
     ↓ 光谱分组卷积(groups=4,4个子空间各自处理)
[B, 64, H, W]
     ↓ 深度可分离空间卷积(轻量化空间纹理提取)
[B, 128, H, W]
     ↓ 双重注意力(通道 SE + 空间门控)
[B, 128, H, W]
     ↓ 全局平均池化 + 全连接头
[B, num_classes]

双重注意力机制

通道 Squeeze-and-Excitation 重新标定各通道权重,选出对新鲜度判断最关键的光谱特征组合;空间门控则关注图像中哪些区域变化最显著(例如鱼肉表面的氧化区域)。两者串联,实现选正确的波段 × 看正确的位置

环境依赖

pip install torch torchvision numpy matplotlib spectral

核心实现

以下是基于论文架构描述的简化实现(非官方代码):

import torch
import torch.nn as nn
import torch.nn.functional as F


class SpectralGroupedConv(nn.Module):
    """光谱分组卷积:相邻波段分组,每组独立提取局部光谱模式"""
    def __init__(self, in_channels, out_channels, groups=4):
        super().__init__()
        # kernel_size=1:在波段间做线性组合,不混入空间邻域信息
        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1,
                              groups=groups, bias=False)
        self.bn = nn.BatchNorm2d(out_channels)

    def forward(self, x):
        return F.relu(self.bn(self.conv(x)))


class DepthwiseSpatialConv(nn.Module):
    """深度可分离空间卷积:在光谱特征图上提取局部空间纹理"""
    def __init__(self, channels):
        super().__init__()
        self.dw = nn.Conv2d(channels, channels, 3, padding=1, groups=channels, bias=False)
        self.pw = nn.Conv2d(channels, channels, 1, bias=False)
        self.bn = nn.BatchNorm2d(channels)

    def forward(self, x):
        return F.relu(self.bn(self.pw(self.dw(x))))


class DualAttention(nn.Module):
    """双重注意力:通道 SE 选关键光谱 + 空间门控选关键区域"""
    def __init__(self, channels, reduction=16):
        super().__init__()
        self.channel_se = nn.Sequential(
            nn.AdaptiveAvgPool2d(1), nn.Flatten(),
            nn.Linear(channels, channels // reduction), nn.ReLU(),
            nn.Linear(channels // reduction, channels), nn.Sigmoid()
        )
        self.spatial_gate = nn.Sequential(
            nn.Conv2d(channels, 1, kernel_size=7, padding=3), nn.Sigmoid()
        )

    def forward(self, x):
        x = x * self.channel_se(x).view(x.size(0), -1, 1, 1)
        return x * self.spatial_gate(x)


class SGNet(nn.Module):
    def __init__(self, in_bands=200, num_classes=5, groups=4):
        super().__init__()
        self.spectral = nn.Sequential(
            SpectralGroupedConv(in_bands, 64, groups=groups),
            SpectralGroupedConv(64, 128, groups=groups),
        )
        self.spatial = DepthwiseSpatialConv(128)
        self.attention = DualAttention(128)
        self.classifier = nn.Sequential(
            nn.AdaptiveAvgPool2d(1), nn.Flatten(),
            nn.Linear(128, 64), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(64, num_classes)
        )

    def forward(self, x):
        x = self.spectral(x)
        x = self.spatial(x)
        x = self.attention(x)
        return self.classifier(x)

参数量验证:

model = SGNet(in_bands=200, num_classes=5, groups=4)
total = sum(p.numel() for p in model.parameters())
print(f"参数量:{total / 1e6:.2f}M")  # 预期约 4~5M

光谱签名可视化

理解 HSI 最直观的方式是查看某个像素点的光谱曲线(Spectral Signature)。新鲜度不同的鱼肉在特定波段有明显的吸收差异:

import numpy as np
import matplotlib.pyplot as plt


def plot_spectral_signatures(hsi_cube, labels, wavelengths, freshness_days):
    """
    可视化不同新鲜度等级的平均光谱曲线
    hsi_cube: (N, C, H, W) 归一化高光谱数据
    labels:   (N,) 新鲜度类别索引
    """
    fig, ax = plt.subplots(figsize=(10, 5))
    cmap = plt.cm.RdYlGn(np.linspace(0.8, 0.1, len(freshness_days)))

    for i, (day, color) in enumerate(zip(freshness_days, cmap)):
        mask = labels == i
        mean_spec = hsi_cube[mask].mean(axis=(0, 2, 3))  # (C,) 所有像素平均
        std_spec  = hsi_cube[mask].std(axis=(0, 2, 3))
        ax.plot(wavelengths, mean_spec, color=color, label=f"Day {day}", lw=2)
        ax.fill_between(wavelengths, mean_spec - std_spec,
                        mean_spec + std_spec, alpha=0.15, color=color)

    ax.set_xlabel("Wavelength (nm)")
    ax.set_ylabel("Reflectance")
    ax.set_title("Spectral Signatures by Freshness Level")
    ax.legend(); plt.tight_layout()
    plt.savefig("spectral_signatures.png", dpi=150)

预期输出:新鲜鱼肉(绿色)在 700~800nm 区间(血红素吸收带)反射率明显低于腐败鱼肉(红色),这正是模型判断的核心光谱依据。


训练:有序标签的双任务学习

新鲜度具有序列结构(Day 1 < Day 3 < Day 7),单纯用 CrossEntropy 会忽略类别之间的距离关系。论文采用分类 + 回归双任务:

def train_epoch(model, loader, optimizer, device):
    model.train()
    ce_fn  = nn.CrossEntropyLoss()
    mae_fn = nn.L1Loss()

    for hsi, cls_label, day_label in loader:
        hsi, cls_label, day_label = hsi.to(device), cls_label.to(device), day_label.float().to(device)
        logits = model(hsi)                              # (B, num_classes)

        # 用 softmax 概率加权类别中心,得到连续天数预测
        centers   = torch.arange(logits.size(1), dtype=torch.float, device=device)
        pred_days = (logits.softmax(-1) * centers).sum(-1)

        loss = ce_fn(logits, cls_label) + 0.5 * mae_fn(pred_days, day_label)
        optimizer.zero_grad(); loss.backward(); optimizer.step()

实验结果

方法 准确率 MAE(天) 参数量 参数倍率
SGNet(本文) 97.8% 0.64 4.75M
ResNet-50 ~93% ~1.2 25M
ViT-Base ~91% ~1.5 86M 18×
1D-CNN(仅光谱) ~89% ~1.8 0.8M

ViT 在小数据集上表现不如 CNN 类方法,自注意力机制对数据量的依赖在这里成了负担。1D-CNN 仅用光谱信息,准确率下降约 9%,说明空间信息有补充价值,但不是主导。


工程实践

实时性与硬件需求

SGNet 在 RTX 3060(6GB 显存)上处理 512×512、200 波段的单张图像:

  • 推理延迟:约 15ms,接近实时
  • 显存占用:batch=1 时 <500MB
  • 无 GPU 场景:CPU 推理约 200ms/帧,可满足流水线低速传送带需求

数据采集关键点

高光谱相机对光照一致性极为敏感,每次采集必须完成两步校正:

  1. 暗电流校正(Dark Reference):遮住镜头采集纯黑帧,补偿传感器热噪声
  2. 白板校正(White Reference):拍摄标准白板,归一化反射率

校正公式:

\[R = \frac{I_{raw} - I_{dark}}{I_{white} - I_{dark}}\]

漏掉校正步骤,光谱曲线绝对值会漂移,跨设备或跨时段的模型泛化性会大幅下降。

常见坑

坑 1:分组数必须能整除通道数

groups 必须同时整除 in_channelsout_channels,否则 Conv2d 直接报错。当输入波段数不是 4 的倍数时:

# 将波段数零填充到 groups 的倍数
pad = (groups - in_bands % groups) % groups
hsi = F.pad(hsi, (0, 0, 0, 0, 0, pad))  # 在波段维度末尾填充

坑 2:小样本过拟合

数据集 <500 张时,验证集准确率会在第 10~20 个 epoch 开始下降。解法:

# 光谱增强:随机对调两个相邻波段组(不破坏光谱连续性)
def spectral_shuffle_aug(x, groups=4):
    C = x.shape[1]
    group_size = C // groups
    idx = torch.randperm(groups)
    return torch.cat([x[:, i*group_size:(i+1)*group_size] for i in idx], dim=1)

适用 vs. 不适用

适用场景 不适用场景
光照可控的工厂流水线 自然光户外检测
静态摆放的鱼肉产品 动态场景(水槽、移动目标)
有高光谱相机硬件预算 只有 RGB 相机的场景
单一鱼种、明确的新鲜度梯度 多鱼种混合检测

与其他方法对比

方法 优点 缺点 适用场景
标准 CNN(ResNet) 成熟生态,调参简单 忽视光谱结构,参数冗余 RGB 图像任务
1D-CNN + SVM 极轻量,只用光谱 完全丢失空间信息 点光谱仪数据
ViT 全局建模能力强 依赖大数据,推理慢 大规模遥感数据集
SGNet 轻量、光谱感知、实时 依赖高光谱硬件 工业质检部署

我的观点

SGNet 的设计哲学值得借鉴:不要用通用方法硬套领域数据,先问数据的结构特性是什么。高光谱数据的答案是”光谱远比空间重要”,分组卷积就是对这个答案的工程实现。

但有几个问题需要诚实面对:

硬件成本是真正的瓶颈。 高光谱相机售价通常在 5000~50000 美元,比多光谱相机(3~8 波段)贵一个量级。在大量工厂推广前,需要认真考量 ROI。对于预算有限的场景,精心挑选 8 个关键波段的多光谱方案,可能达到 80% 效果,但成本降低 10 倍。

泛化性存疑。 论文数据集只有三文鱼冷藏 16 天的场景,模型在其他鱼种(金枪鱼、带鱼)、不同冷冻方式(冷冻 vs. 冰鲜)或不同光照设备下是否需要重新训练,是未回答的关键问题。

域感知设计(Domain-Aware Design)是值得推广的范式。 类似的思路在卫星遥感(时序波段相关性)、医学光谱成像(组织类型的光谱特征)等领域都有直接的迁移价值——关键是先问”这个数据的物理结构是什么”,而不是先问”哪个 backbone 最新”。