☰
实时语义分割利器 BiSeNet:双分支网络架构解析与实战调优
2026/10/16 2:05:51 网站建设 项目流程

1. 为什么需要 BiSeNet:实时语义分割面临的核心矛盾

BiSeNet(Bilateral Segmentation Network,双边分割网络)是我做实时语义分割时最早认真读透的结构之一,后来在自己的项目里复现、换骨干、调精度,前前后后折腾了好几轮,越用越觉得它把“实时”这件事想得很清楚。语义分割本身不难理解——给图像里每个像素都打上一个类别标签,但一旦加上“实时”两个字,难度立刻翻倍。车载辅助、巡检无人机、机器人避障、直播抠图,这些场景里模型不止要分得准,还得在单帧几毫秒到几十毫秒内出结果,而这偏偏是语义分割最不擅长的事。

先看一个基本事实:普通分割网络精度高,是因为它有足够大的感受野,能“看懂”整张图的上下文——判定眼前这块是路面还是草地,很多时候依赖的是周围环境而不是局部纹理。但这类网络往往又深又重,特征图一路下采样,细节被磨掉大半,边缘和细小物体自然就糊了。反过来,走轻量路线的网络速度快,但普遍存在感受野不足的问题,局部判断容易出错,一个大类别里混进几个小碎块是常事。

这两条路看似只能二选一,BiSeNet 的做法却是“我全都要”:把网络拆成两条并行的分支,一条负责用浅层卷积保住空间细节,一条负责用深层骨干提取全局上下文,最后在融合点把两路线索合起来。这就是“双边”(Bilateral)这个名字的由来。我第一次看到这个结构时的感受是,它没有发明什么高深的新算子,而是把语义分割里最本质的一组矛盾——细节与上下文、浅层与深层、速度与精度——拆开摆到台面上,让每个分支只专注干一件事。

1.1 精度与速度的天然冲突

做实时分割,第一个绕不开的问题是感受野与分辨率打架。浅层特征图分辨率高、边缘清晰,但每层只能看到很小一块局部区域,很难区分“树”和“灌木”这类需要全局信息的类别;深层特征图感受野大,但经过多轮下采样后空间位置信息已经丢得七七八八,分割结果边界往往像油画一样又肥又圆。常用缓解手段是空洞卷积,它能在不下采样的前提下扩大感受野,但代价是特征图始终很大,计算量随分辨率快速增长,实时场景下基本吃不消。

我自己第一次做实时分割项目时,先用了一个轻量骨干加双线性上采样的简单结构,结果边缘和细小目标(比如远处的人、车道分隔线)几乎全军覆没,指标惨不忍睹。后来才明白,问题不是模型不够“聪明”,而是结构上根本没给细节信息留通道。BiSeNet 的思路正好踩在这个痛点上:浅层特征天然适合保持细节,深层特征天然适合理解语义,那就不要再强迫某一个中间层两头兼顾,直接一条路走到黑反而更高效。

1.2 双路设计解决问题的基本思路

BiSeNet 的整体思路用一句话概括:两路并行,一路快而细,一路深而粗,最后融合。空间分支(Spatial Path)只堆三个带步长的卷积,输入图像经过这三层后输出分辨率是原来的 1/8,通道数依次是 64、128、256,这一路计算量非常小,专门保存边缘、颜色、纹理这些高频信息。上下文分支(Context Path)则直接挂一个通用的预训练骨干网络,常见选择是 ResNet18、ResNet101 这类现成的分类网络,一路下采样到很小分辨率,再补一个全局平均池化,把整张图的上下文浓缩成一份摘要,保证每个像素做分类决策时都能“看到”全图。

两条分支跑完,各自得到一张特征图,分辨率相差好几倍。上下文分支会把高层特征上采样回 1/8,然后通过特征融合模块(FFM)把两路特征合在一起,最后再上采样到原图大小输出分割结果。这个设计里有个很巧的细节:上下文分支不直接使用预训练骨干的最后一层输出,而是取倒数两层特征,各接一个“注意力精炼模块”(ARM),相当于在汇合前先把有效信息筛一遍。后文我会逐个模块展开讲,这里先记住整个网络的心脏,是“如何把两种异质特征融合成一张可用的高分辨率预测”。

2. BiSeNet 核心模块拆解:从设计原理到实现细节

下面把每个模块单独拿出来讲。我在复现时习惯把每个模块都写成独立的小类,方便单独验证输入输出,也方便后续替换成自己改的版本。代码片段基于常见深度学习框架的结构示意,实际使用时按你的框架规范微调即可。

2.1 Spatial Path:用浅层卷积保住细节特征

Spatial Path 的设计简单到看起来有点“敷衍”——就三个卷积层,每层卷积核 3×3、步长 2,中间夹 BN 和 ReLU。三层走完,特征图宽高变成输入的 1/8。为什么是 1/8?这是个折中:再往下采到 1/16,细节损失就明显了;保持在 1/4 的话特征图太大,计算量翻好几倍。1/8 是速度和空间信息的平衡点。

为什么这么浅的网络就够用?因为细节提取本质上是相对低层的任务——边缘、纹理这些信号在第一层卷积的输出里就已经大量存在,不需要几十层去“理解”,只需要保证它在下采样过程里不丢。我实测下来,把 Spatial Path 的通道数从 64/128/256 加到 128/256/512,边缘质量确实有改善,但推理耗时跟着明显上涨,实时要求紧的项目里不建议轻易加。

一个常见误区是给 Spatial Path 也准备预训练权重。完全没必要,这一路的参数量和计算量都很小,随机初始化从头训练,收敛没有压力。我拿通用图像预训练权重初始化试过,最终精度几乎看不出差别,白费了不少加载时间。

class SpatialPath(nn.Module): def __init__(self, in_channels=3): super().__init__() self.stage = nn.Sequential( ConvBNReLU(in_channels, 64, 3, stride=2), ConvBNReLU(64, 128, 3, stride=2), ConvBNReLU(128, 256, 3, stride=2), ) def forward(self, x): return self.stage(x) # 输出尺寸为输入的 1/8

2.2 Context Path:用预训练骨干捕获全局上下文

Context Path 是 BiSeNet 里决定“语义准确度”的那条腿。它使用一个在大型通用图像分类数据集上预训练好的骨干网络,比如 ResNet18 或 ResNet101,一路下采样。这里有两个关键设计点。

第一,取特征不止取最后一层。论文的默认做法是取骨干最后两个阶段的特征(通常对应输入尺寸的 1/16 和 1/32),两个层次各接一个 ARM,最后再接一个全局平均池化。为什么取两层而不是只取最后一层?因为最后一层经过太多下采样,语义最强但空间分布信息已经很稀薄;倒数第二层还保留了一些可供恢复的位置信息,对分割输出更友好。全局平均池化则把整图上下文压成一个“图级别”的描述,相当于给分类决策兜底——局部看不清这块是什么,那就参考整张图里最常见的东西是什么。

第二,ARM(Attention Refinement Module)做的是“筛选”而不是“变换”。它先用一个 1×1 卷积把通道数压到目标值,然后接全局平均池化、sigmoid,得到一组通道权重,用权重对特征做注意力加权。这个设计的直觉是:经过深层下采样后,特征里真正有效的信息占比不高,先通过注意力把有效通道放大、无效通道抑制,能在不增加太多计算量的前提下显著提升后续融合效果。我实际观察过 ARM 前后的特征分布,加注意力之后,特征图里类别相关的响应明显更集中,背景区域的响应则被压下去。

class AttentionRefinementModule(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv = ConvBNReLU(in_channels, out_channels, 1) self.global_pool = nn.AdaptiveAvgPool2d(1) def forward(self, x): x = self.conv(x) w = torch.sigmoid(self.global_pool(x)) return x * w

2.3 FFM 特征融合模块:把异质特征合成统一表达

空间特征和上下文特征本质上是两种完全不同的东西:一个分辨率高、语义浅,一个分辨率低、语义深。直接把两路特征拼接在一起,模型很难学会正确的组合方式。FFM(Feature Fusion Module)解决这个问题分两步。

第一步,拼接之后用一个 1×1 卷积把通道数压缩到目标值,这一步相当于强制模型把“两套语言”翻译成“同一套语言”。第二步,对压缩后的特征再做一次全局平均池化、两层全连接、sigmoid,得到一组通道级权重,然后做加权残差:输出 = 权重 × 特征 + 特征。这个结构很轻,但作用是把两路融合后的重要通道再放大一遍。有人把 FFM 理解成“融合版的注意力模块”,其实很贴切——它就是把 ARM 的注意力思路复用到了融合阶段。

class FeatureFusionModule(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv = ConvBNReLU(in_channels, out_channels, 1) self.pool = nn.AdaptiveAvgPool2d(1) self.fc = nn.Sequential( nn.Linear(out_channels, out_channels), nn.ReLU(inplace=True), nn.Linear(out_channels, out_channels), ) def forward(self, spatial_feat, context_feat): x = torch.cat([spatial_feat, context_feat], dim=1) x = self.conv(x) w = torch.sigmoid(self.fc(self.pool(x).flatten(1))) w = w.unsqueeze(-1).unsqueeze(-1) return x * w + x

注意一个细节:两路特征在拼接之前必须把分辨率对齐。实际操作中,上下文分支的特征会先上采样到与空间分支一致(通常都是输入的 1/8),再做融合。上采样用双线性插值就够了,没必要在这里引入可学习的转置卷积——FFM 已经承担了信息整合的职责,把上采样做得太复杂只会拖慢速度。

2.4 损失函数:主损失加辅助损失的搭配

BiSeNet 的训练损失不是单一份。除了最终输出的主交叉熵损失,上下文分支的两个 ARM 输出端各接了一个辅助交叉熵损失,总损失是三者加权相加。常见复现配置里辅助损失权重在 0.4~1.0 之间,我个人实践下来 0.4 左右更稳,一开始从 1.0 起步反而容易让辅助梯度喧宾夺主。这个设计有两个明确目的。

一是梯度传导。上下文分支用的是预训练骨干,但新结构和分类任务不完全一致,辅助损失从旁路直接把梯度送到中间层,让“高层语义应该关注什么”这个信号传递得更充分,训练早期收敛明显更快。二是防过拟合。多个监督信号等于是对中间表示加了约束,减少自由漂移空间。我复现时在是否加辅助损失之间做过对比,同样训练轮数,加辅助损失的版本最终精度能高 1~2 个百分点,而且前期 loss 下降更稳,这条经验在换骨干之后依然成立。

类别不均衡在分割任务里几乎必现,街景数据里“道路”和“建筑”占了大面积,“骑车人”“交通标志”这类类别可能只占极少像素。直接用普通交叉熵,小目标类别几乎学不动。解决办法常用两种:给类别加权重,或者用难例挖掘。我个人偏好后者,因为权重设起来要反复调,难例挖掘反而省心,在 BiSeNet 这种双路结构上兼容性也更好。

3. 实操:完整搭建、训练配置与调优思路

结构原理讲完,进入可复现的实操环节。这一节给出完整的组装方式、训练超参数,以及我在调优过程中觉得最关键的几个经验。

3.1 整个网络怎么组装

把上一节的模块拼起来,BiSeNet 的前向流程是:输入图像进入 Spatial Path 得到 1/8 的细节特征;同时进入 Context Path,骨干输出倒数两层特征,各过 ARM,再经过全局平均池化,得到上下文特征;上下文特征上采样到 1/8,与空间特征一起进 FFM,得到融合特征;再上采样到原图分辨率,接分类头输出每个像素的类别概率。一次前向里,计算量分布大致是:上下文分支占大头、空间分支占小头、融合与上采样几乎可以忽略。

组装时有一个容易踩的坑:骨干网络的“倒数两层”具体是哪个输出,不同框架、不同骨干版本的命名不一样。建议先把输出尺寸打印出来确认,不要照抄别人的索引。我一开始在某骨干实现上取错了层,特征倒是出来了,但分辨率完全不对,微调之后精度怎么都上不去,最后一步一步查输出尺寸才定位到问题。

class BiSeNet(nn.Module): def __init__(self, num_classes, backbone): super().__init__() self.spatial = SpatialPath() self.context = ContextPath(backbone) # 返回骨干倒数两层特征 self.arm1 = AttentionRefinementModule(256, 128) self.arm2 = AttentionRefinementModule(512, 128) self.ffm = FeatureFusionModule(256 + 128, num_classes) def forward(self, x): size = x.size()[2:] spatial_feat = self.spatial(x) # 1/8 feat_16, feat_32 = self.context(x) # 1/16 与 1/32 context_feat = self.arm1(feat_16) + self.global_avg_pool(self.arm2(feat_32)) context_feat = F.interpolate(context_feat, scale_factor=2, mode='bilinear') out = self.ffm(spatial_feat, context_feat) # 融合后仍为 1/8 out = F.interpolate(out, size=size, mode='bilinear') return out

3.2 训练超参数参考

以常见的城市街景分割数据集为例,训练输入通常裁剪到 512×1024 左右,我给一组经过验证的参考配置:

  • 优化器:SGD,初始学习率 0.01(换大骨干时可降到 0.005),momentum 0.9,权重衰减 5e-4
  • 学习率策略:poly 衰减,lr = base_lr * (1 - iter / max_iter)^0.9
  • Batch size:8~16(视显存而定),多卡训练时同步 BN 可选
  • 数据增强:随机水平翻转、随机缩放(0.5~2.0)、随机裁剪、颜色抖动
  • 总损失:主损失 + 0.4 × 辅助损失1 + 0.4 × 辅助损失2

其中随机缩放值得多说一句。语义分割里目标尺度差异非常大,同样是“人”,近处的人和远处的人像素量可能差几个数量级。随机缩放相当于免费的多尺度训练,让模型对不同尺度的目标都更敏感。我试过把缩放范围从 0.5~2.0 扩大到 0.25~3.0,小目标的精度有明显提升,代价是训练时间变长、显存峰值更紧张。实时分割的部署尺度是固定的,训练时多尺度、推理时单尺度,这条组合策略几乎不会出错。

3.3 精度与速度的权衡点

BiSeNet 最大的价值在于它是一个“精度-速度可控可调”的框架。论文报告的指标大致在 60~105 FPS 区间浮动,精度在 68%~75% 范围,差别主要来自三处:骨干选择、输入分辨率、空间分支通道数。

配置项轻量档均衡档高精度档
骨干网络ResNet18ResNet18/34ResNet101 或更强骨干
输入分辨率512×1024768×15361024×2048
空间分支通道64/128/25664/128/256128/256/512
参考速度100 FPS 左右60 FPS 左右30 FPS 以下
参考精度较低中高高

提示:速度数字必须结合具体硬件和推理框架实测,任何公开数值只能作为相对量级参考。同一模型在不同显卡上、在训练框架和推理框架上的耗时可能差出好几倍。

我自己的调优顺序是:先定算力上限(允许的推理耗时),再反推输入分辨率和骨干档位,最后才微调空间分支通道数。反过来做很容易陷入“差一点就想加一点复杂度”的循环,最后速度失守,还得推倒重来。先锁速度再提精度,每一步改动都能快速验证,可控很多。

4. 训练与部署中的常见问题排查

这一节是我在实际项目中踩坑最密集的地方,整理成几个高频问题,附上排查思路。

4.1 精度上不去,先查这些

症状一:边缘糊成一大片,小目标识别不出来。优先怀疑空间分支太弱或输入分辨率太低。把输入宽度从 512 提升到 768,边缘质量通常立竿见影,副作用是显存和耗时都涨,此时再考虑压缩空间分支通道数把计算量省回来。

症状二:大类别互相混淆,比如“道路”和“人行道”分不清。这是上下文信息不足的典型表现,优先换更强的骨干,或者检查训练时全局平均池化那一路是否正常工作。我遇到过因为池化配置问题导致全局特征退化成零的情况,模型直接退化成纯局部判断,精度崩得很快。

症状三:损失下降正常但指标停滞。多半是类别不均衡或难例挖掘没开。先看各类别的单项精度明细,肯定有几个类别接近 0,对它们单独上权重或开难例挖掘,比盲目加训练轮数有效得多。

4.2 速度不达标,瓶颈在哪里

实时分割项目里“速度不达标”是常态,但排查方向其实很固定。第一步把耗时拆开——前向里卷积、BN、激活、上采样各自占多少,用逐层计时工具打出来,不要凭感觉猜。常见罪魁祸首有三个:

  • 双线性上采样在某些设备上意外昂贵,尤其从 1/8 一路插值回原图那一下。优化方式是改成两段上采样,或者用可学习的转置卷积替代并在导出前与相邻卷积合并。
  • BN 在推理阶段如果没有折叠进卷积,每过一层都多一次内存读写和算术操作。部署前必须做 BN 折叠,这是白捡的速度。
  • 双路分支的并行度问题。Spatial Path 和 Context Path 是两条独立分支,但部分框架默认串行执行,实测中把它们放到并行的执行流里可以省一部分延迟。

另外,测速度的方法要统一。用 batch=1 的单张前向耗时作为延迟指标,先 warmup 几十次再取平均。很多人直接在训练循环里用“每秒多少张”换算,训练时多线程、梯度计算、数据加载全混在一起,数字完全不可信。我习惯把吞吐和延迟分开记:吞吐看大 batch 下每秒处理张数,延迟看单张耗时,这两个指标在真实部署里的意义完全不同。

4.3 部署落地的一些工程经验

把 BiSeNet 导出到推理格式时,要注意输入分辨率是否被写死。上采样操作通常需要指定插值到目标尺寸,很容易把训练时的 512×1024 硬编码进模型。导出前一定要用动态尺寸或者显式声明可配置的插值目标,否则换个分辨率就得重新导出。

量化方面,BiSeNet 的两条分支对量化的敏感度不一样。空间分支特征幅度小、信息密度高,量化更容易掉点;上下文分支受全局池化和注意力影响,对量化相对不敏感。做低精度量化时建议逐层观察精度变化,如果空间分支的卷积层掉点严重,可以保留高精度而只量化上下文分支。我在一个项目里就这样做“混合精度量化”,整体模型体积缩小接近 4 倍,单帧耗时降低大约 40%,精度损失控制在 1% 以内。

5. BiSeNet 的价值、后续演进与选型建议

5.1 从 BiSeNet 到 BiSeNet v2:思路的延伸

BiSeNet 之后,原作者团队很快推出了 v2,核心变化是把“两条分支”升级成“极快的细节分支 + 极深但很少跑的语义分支”:细节分支全程保持高分辨率、计算量极小,语义分支每隔若干帧才推理一次,靠帧间语义池缓存结果。这相当于把“双路并行”进一步做成了“双路并行 + 时间复用”,实时性又上了一个台阶。理解 v1 再看 v2 会非常顺畅,因为 v2 里 ARM、FFM 这些模块的继承关系很明显——花时间吃透 v1 是值得的。

5.2 与其他实时分割方案的对比

实时语义分割的解法不止 BiSeNet 一条路。早期的 ENet 靠极小的骨干硬压计算量,速度快但精度天花板低。Fast-SCNN 用“学习下采样”模块做浅层特征提取,思路和 BiSeNet 的空间分支相似,但上下文路径设计得更简单。DeepLab 系列这类大模型虽然能通过剪枝、蒸馏得到实时版本,但其设计本身并不以实时性为目标,工程改造量大。

方案核心思路优点短板
ENet极轻量骨干速度极快精度上限低
Fast-SCNN下采样模块 + 浅层语义结构简单上下文提取偏弱
BiSeNet双路并行、兼顾细节与语义精度速度平衡好,模块职责清晰双路融合有额外开销
DeepLab 系列压缩版大模型瘦身精度潜力大工程复杂

我个人的选型建议很直接:如果项目从零开始,需要一个“架构清晰、好调参、好部署”的实时分割模型,BiSeNet 是第一梯队的选择。它的好处是每个模块职责单一,出问题能快速定位;坏处是如果需要极致速度,双路结构始终有一点额外的融合开销,这时更极端的方案(比如 v2 或纯轻量单支结构)可能更合适。

5.3 什么场景适合直接用 BiSeNet

实际选型时我会问三个问题:可用推理算力是多少?目标类别有多少?输入分辨率最多能接受多少?如果算力紧张到只能跑极轻模型,BiSeNet 可以砍空间分支通道、用小骨干、降分辨率,结构依然完整;如果类别多且相互之间极度依赖上下文,BiSeNet 的上下文分支优势就很明显。它不是一个“包打天下”的模型,但绝对是值得放在工具箱最上层、随时能拿出来改造的底子。

最后说个我自己的体会。BiSeNet 对我最大的启发不是双分支本身,而是它示范了一种处理矛盾的思路:当两个需求在同一个模块里打架时,与其硬塞进一个网络让它自己权衡,不如拆成两条专门的路,再在汇合点做精心的融合。后来我做的几个非分割方向的内容,包括检测里的多尺度融合、图像增强里的内容与风格解耦,都借鉴了这个思想。如果你正在做实时视觉任务,我建议你亲自搭一遍 BiSeNet,哪怕只在公开数据集上跑通一次,都会对“精度从哪里来、速度从哪里省”建立起非常具体的体感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询