平扫CT上的缺血区域检测本质上是在一堆低密度影里找边界。缺血后的脑组织因为水肿和钠水潴留在CT上会表现为局部低密度但正常脑白质、脑室周围低密度、甚至一部分陈旧病灶拍出来也是“灰暗的一块”。更麻烦的是缺血灶尺寸跨度极大有的只有几个像素有的直接累及大半侧大脑半球边界往往模糊到和周围正常组织连成片。医生在横断位上逐层手动勾画耗时长还容易漏掉微小病灶。传统分类网络只能回答“有没有问题”给不出具体范围纯分割网络又容易被背景里的正常低密度区干扰。这正是把目标检测和图像分割放到同一条管线里的理由。本文拆解 YOLO11-Seg-SPPF-LSKA 这套组合从 CT 影像特点、模型结构改进、数据准备、训练细节到推理后处理完整走一遍并给出能直接改的代码。名字看着长拆开其实就是YOLO11-Seg 提供检测加实例分割的骨架SPPF 把不同感受野的特征拉齐LSKA 在控制计算量的前提下做大感受野注意力。1. 平扫CT上的缺血目标难在哪为什么需要“先检测、再分割”的思路1.1 缺血病灶在CT上的形态特点非增强 CT 上急性缺血区域的主要表现是局部脑组织密度降低也就是低密度影。这个“低密度”不是孤立存在的大脑里天然就有不少低密度结构脑室、脑白质、部分皮层萎缩区域甚至扫描时的部分容积效应都会造成类似低密度改变。病灶的边界也很不友好。早期缺血灶密度只下降几到十几个 HU跟周围正常组织的差值非常小边界是一条缓慢过渡的“灰色带”而不是锐利边缘。等到梗死明显后形态又会变成楔形、类圆形或不规则形可能多发也可能单发。这给影像算法带来两个直接问题第一个是目标与背景极度不平衡。一张 512×512 的CT切片里缺血灶往往只占几十到几百个像素其余基本都是背景。第二个是缺乏强边界线索。普通分割模型在边界模糊的地方容易把病灶“吃大”或“吃小”因为模型很难决定灰阶过渡带到底属于病灶还是正常组织。所以这个任务不能简单套用自然图像里的分割方案必须结合目标检测的定位能力和分割的像素级刻画能力。1.2 为什么检测和分割要放在同一条管线里如果只做语义分割模型会对整个切片逐像素分类。在背景占比极高、又存在大量类似低密度结构的情况下分割头很容易在距离真正病灶很远的区域产生低置信度的假阳性。如果只做目标检测得到的是矩形框框内除了病灶还有大量正常组织没法直接计算体积和最大层面面积而这些恰恰是临床最关心的量化指标。检测头和分割头组合之后逻辑就变成先在整张图上找“哪里可能是缺血”输出候选框和类别置信度再在每个候选框对应的特征区域上做像素级掩膜预测。这样分割分支永远只在已经定位到的目标附近工作背景干扰大幅减少。而且多个散在病灶会被拆成多个实例可以分别统计数量、面积和体积。1.3 检测头对这个任务来说不是“多余”的有人会问直接用分割网络不是更简单吗我在实际测试中的体会是纯分割网络在缺血区域的敏感度可以做得很好但特异性通常很难控制。因为正常白质低密度在信号上跟缺血灶太接近纯像素分类缺少一个“全局定位”的约束。检测头相当于给分割加了一个空间先验。模型必须先确认某个区域整体上像不像一个病灶才有资格做像素级分割。对于微小病灶检测头的高分辨率特征帮助很大对于大的融合病灶检测头又能输出一个大框让分割分支看到完整的上下文而不是只在局部小窗口里判断。这也是为什么“检测分割”比单纯分割更适合缺血区域这种“背景脏、边界糊、尺寸跨度大”的目标。2. 基于YOLO11-Seg的架构改进SPPF、LSKA各自在解决什么2.1 YOLO11-Seg做了什么YOLO11-Seg 是 YOLO 系列里的实例分割变体。主干网络负责从输入图像中提取多尺度特征颈部网络把浅层细节和深层语义融合起来最后的检测分割头同时输出边界框、类别概率和掩膜系数。它属于端到端的单阶段模型不需要像传统两阶段方法那样先跑一个区域提议网络再单独分割。对 CT 缺血分割来说YOLO11-Seg 有几个天然优势。首先是 anchor-free 的检测设计对不同宽高比的病灶比较友好缺血灶不一定是规整的矩形但 anchor-free 不依赖预设框形状。其次是多尺度特征融合小病灶在高分辨率特征图上更容易被保留大病灶则依赖深层语义特征。第三是掩膜分支和检测分支共享特征训练时梯度可以互相补充检测头学到的定位能力会间接帮助分割头分割头的像素级监督也会反过来修正检测框的边界。2.2 SPPF扩大感受野又不让计算翻倍SPPF 的全称是 Spatial Pyramid Pooling - Fast空间金字塔池化的快速版本。标准的空间金字塔池化会用多个不同尺寸的池化核对同一特征图做并行池化再拼接起来感受野大但计算量也大。SPPF 换了一个更聪明的做法用几个小的池化核串行连接前一个池化结果继续送入下一个池化。串行三次 5×5 最大池化之后等效的感受野比单一 5×5 池化大得多但计算量只是线性叠加而不是并行多路同时计算。对于 CT 缺血分割SPPF 的意义很直接缺血灶周边的脑沟、脑回、白质边界会干扰判断模型需要看到更大范围的上下文才能确认某个低密度区域到底是病灶还是正常的解剖结构。在 YOLO11-Seg 的 backbone 末端插入 SPPF可以把高层特征的感受野进一步扩大让后续的检测头和分割头拿到更完整的语义信息。这个改动几乎不增加太多参数属于性价比很高的操作。2.3 LSKA注意力用两个方向的卷积代替大核注意力LSKA 是 Large Separable Kernel Attention核心思路是大感受野注意力。普通卷积注意力如果想看到更远的依赖关系最简单的方法是加大卷积核。一个 21×21 的卷积核计算量是 3×3 卷积的几十倍在每层特征图上做这种操作训练和推理都会变慢。LSKA 把一个大二维卷积核拆成两个一维卷积的组合先沿水平方向做一个 (k,1) 卷积再沿垂直方向做一个 (1,k) 卷积。这样网络实际上看到了一个约 k×k 的方形区域但计算量从 k² 量级降到了 2k 量级。这个设计对 CT 图像特别合适。CT 是单通道灰度图没有自然图像里丰富的颜色和纹理模型更依赖空间结构和相对密度来判断病灶。LSKA 的大感受野能让当前像素“看到”更大范围的灰阶分布有助于理解缺血灶和正常脑组织之间的过渡关系。在实际改造中我把 LSKA 放在 SPPF 输出之后让高层特征经过一次大范围注意力再进入分割头。代码实现如下import torch import torch.nn as nn class LSKA(nn.Module): Large Separable Kernel Attention 的简洁实现。 用两个方向的可分离卷积近似一个大卷积核降低计算量。 def __init__(self, channels, kernel_size21): super().__init__() p kernel_size // 2 # 第一次大核分解水平方向 垂直方向 self.conv_h1 nn.Conv2d( channels, channels, kernel_size(kernel_size, 1), padding(p, 0), biasFalse, groupschannels ) self.conv_v1 nn.Conv2d( channels, channels, kernel_size(1, kernel_size), padding(0, p), biasFalse, groupschannels ) # 第二次大核分解进一步扩大有效感受野 self.conv_h2 nn.Conv2d( channels, channels, kernel_size(kernel_size, 1), padding(p, 0), biasFalse, groupschannels ) self.conv_v2 nn.Conv2d( channels, channels, kernel_size(1, kernel_size), padding(0, p), biasFalse, groupschannels ) self.norm nn.BatchNorm2d(channels) self.act nn.SiLU() def forward(self, x): u self.conv_h1(x) u self.conv_v1(u) u self.conv_h2(u) u self.conv_v2(u) u self.act(self.norm(u)) return u * x注意上面是一个简化版实现重点是可分离大核注意力的基本形态。实际工程里还可以在第一次分解和第二次分解之间加入层归一化或者在最后加一个 1×1 卷积来调整通道权重这些都属于微调范围。2.4 改造后模型的变化标准 YOLO11-Seg 本身已经包含 SPPF 结构标题里的 SPPF 更多是强调“保留并充分利用这个模块”。在 YOLO11-Seg 基础上加入 LSKA 之后模型结构的变化主要体现在高层特征层。模块主要作用对CT缺血分割的帮助YOLO11-Seg 骨干多尺度特征提取同时保留小病灶细节和大病灶语义SPPF扩大感受野、融合多尺度池化特征减少边界模糊带来的误判LSKA大核注意力可分离降低计算量让模型关注真正可疑区域降低白质干扰改造后的模型在理论上的优势是检测头负责定位SPPF 负责扩大上下文感受野LSKA 负责在高层特征上做注意力筛选分割头再输出精细掩膜。这条链路对缺血区域这种低对比度目标比较匹配。3. 数据准备从DICOM/标注掩膜到YOLO分割训练集3.1 DICOM读取与HU归一化CT 图像的标准存储格式是 DICOM像素值不是灰度而是 CT 值单位是 HUHounsfield Unit。水是 0 HU空气是 -1000 HU骨组织通常在几百到一千多。不同设备的原始像素值可能不同必须先用 DICOM 里的 Rescale Slope 和 Rescale Intercept 把像素值转换成真正的 HU。读取和窗宽窗位处理的代码import numpy as np import pydicom def read_dicom_to_hu(dicom_path): ds pydicom.dcmread(dicom_path) hu ds.pixel_array.astype(np.float32) hu hu * float(ds.RescaleSlope) float(ds.RescaleIntercept) return hu def apply_hu_window(hu, width80, level40): 脑窗设置。窗宽80HU窗位40HU。 低于下限置0高于上限置255中间线性拉伸。 lower level - width / 2.0 upper level width / 2.0 normalized np.clip((hu - lower) / (upper - lower), 0.0, 1.0) return normalized为什么窗宽窗位要固定因为同一张CT用不同窗宽窗位显示病灶和正常组织的对比度完全不一样。训练时用脑窗推理时也必须是脑窗。很多模型跑出来的效果不稳定问题不在模型而在预处理阶段训练和推理不一致。还有一点值得注意不要直接把 JPEG 或 PNG 重编码后的图像当训练输入除非你已经确认图像没有经过有损压缩。CT 的 HU 信息经过 JPEG 压缩会丢失大量灰阶细节微小缺血灶的边界会变得更糊。3.2 把掩膜转成YOLO分割标注YOLO-Seg 的训练标注格式不是逐像素 PNG而是每行一个目标的多边形顶点坐标坐标基于图像宽高归一化。如果医生或标注工具提供的是二值掩膜 PNG需要先提取轮廓再把轮廓转成归一化多边形坐标。import cv2 import numpy as np def mask_to_yolo_polygon(mask, img_w, img_h, epsilon1.0): 二值mask转YOLO分割txt行。 mask: (H, W) uint80/255 contours, _ cv2.findContours( mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) lines [] for cnt in contours: cnt cnt.reshape(-1, 2).astype(np.float32) if len(cnt) 3: continue cnt cv2.approxPolyDP(cnt, epsilon, True) cnt[:, 0] / img_w cnt[:, 1] / img_h pts .join(f{x:.6f} {y:.6f} for x, y in cnt) lines.append(f0 {pts}) # 0 是类别ID return lines这里的 epsilon 参数控制多边形顶点简化程度。顶点过多会导致训练时 mask 编解码变慢顶点过少又会丢失病灶的细节形状。我的建议是先从 epsilon1.0 开始对微小病灶可以单独放宽到 0.5对大的不规则病灶可以到 2.0。不要一套参数走到底。另外如果一个病灶区域内部有正常组织岛比如缺血灶中间有未被累及的区域二值掩膜上就会产生孔洞。YOLO 的实例分割标注只能表示外轮廓内部孔洞很难精确表达。碰到这种情况要么把孔洞单独当作背景处理要么用多个轮廓把孔洞表示成独立目标。实际工程里大多数做法是优先保证主要病灶区域正确孔洞依赖模型学习。3.3 训练集划分与类别设计缺血分割的标注类别设计取决于你要解决什么场景只区分“缺血区域/背景”那类别就是 1 个。这种情况最常见模型需要把缺血灶和脑室、白质全部区分开。如果要区分不同血管供血区或者区分急性缺血和陈旧性梗死类别可以设计成多个。但类别过多会显著增加标注难度也要求样本量更大。划分训练集时有一个容易被忽略的点按病例划分而不是按切片划分。同一个病人的相邻切片高度相关如果一部分切片在训练集、另一部分在验证集模型的验证指标会虚高因为你本质上是在用同一个人的图像做评估。我个人建议的训练/验证/测试比例是 7:2:1 或者 8:1:1并且保证每个子集里都覆盖大病灶、小病灶、多发病灶三种形态。如果数据量很少宁可增加数据增强也不要让验证集跟训练集太“像”。4. 训练实现模型配置、损失函数与关键超参数4.1 自定义模块注册与模型加载在 YOLO11-Seg 里加入 LSKA需要把 LSKA 模块注册到模型解析的模块表里然后在 YAML 配置文件中引用。YAML 结构示意如下# yolo11-seg-lska.yaml 关键片段 # 这里只是结构示意实际使用请复制官方yolo11-seg.yaml后增量修改 nc: 1 # 单类别 backbone: - [-1, 1, Conv, [64, 3, 2]] - [-1, 1, Conv, [128, 3, 2]] - [-1, 1, C3k2, [128, True]] - [-1, 1, Conv, [256, 3, 2]] - [-1, 1, C3k2, [256, True]] - [-1, 1, Conv, [512, 3, 2]] - [-1, 1, C3k2, [512, True]] - [-1, 1, SPPF, [512, 5]] - [-1, 1, LSKA, [512, 21]]训练脚本可以这样启动from ultralytics import YOLO model YOLO(yolo11-seg-lska.yaml) model.load(yolo11n-seg.pt) # 用预训练权重初始化 model.train( datact_stroke.yaml, epochs150, imgsz512, batch8, device0, workers4, optimizerAdamW, lr01e-3, weight_decay5e-4, hsv_h0.0, hsv_s0.0, hsv_v0.0, # CT是灰度图关闭颜色扰动 degrees10, fliplr0.5, projectruns/ct_seg, nameexp_lska, )这段脚本里有几个细节是 CT 影像特有的。hsv_h、hsv_s、hsv_v是自然图像训练时常用的颜色增强但 CT 是单通道灰度打开这些增强等于在灰度图上乱调色彩只会破坏灰阶信息必须设成 0。fliplr0.5是水平镜像翻转对脑部 CT 来说左右翻转在解剖上通常可以接受但如果你研究的是左右半球本身就有明显不对称的特定疾病要谨慎使用。4.2 损失函数默认损失与Dice补充YOLO11-Seg 默认的损失由几部分组成边界框回归损失、类别分类损失、DFL 损失以及掩膜分支的 BCELoss。对于缺血分割BCELoss 有一个明显短板背景像素太多缺血区域只占少数即使整体 loss 很低病灶区域也可能学得不够充分。一种可行的改进是引入 Dice Loss 作为掩膜损失的补充。Dice Loss 对前景区域和背景区域的比例不敏感更适合小目标分割。简单实现def dice_loss(inputs, targets, smooth1.0): inputs: 预测掩膜范围0-1 targets: 真实掩膜0/1 inputs inputs.reshape(inputs.size(0), -1) targets targets.reshape(targets.size(0), -1) intersection (inputs * targets).sum(1) dice (2.0 * intersection smooth) / (inputs.sum(1) targets.sum(1) smooth) return 1.0 - dice.mean()要把它接入 YOLO11-Seg 的官方训练流程有两种路径第一种是修改损失文件里分割损失的权重计算在 BCELoss 之外加上 Dice Loss相当于total_mask_loss bce_loss 0.5 * dice_loss。第二种是使用自定义训练循环把检测头输出和掩膜系数经过解码后与标注掩膜算 Dice但这种方式要自己处理 mask 解码逻辑代码量会明显增加。如果只是为了快速验证模型有没有改进潜力可以先不改损失直接用官方损失训练一批对比实验跑通后再逐步加入 Dice。不要一开始就把所有改动叠在一起否则出了问题很难定位。4.3 数据增强与超参数CT和自然影像的区别CT 图像的数据增强要克制。下面是我在实际调试中采用的一组增强参数参数值说明imgsz512保持CT原始分辨率附近过小会丢细节degrees10小角度旋转模拟扫描摆位差异fliplr0.5左右翻转hsv_h/s/v0关闭颜色抖动scale0.8-1.2缩放增强但注意缩放后CT像素尺寸变化mosaic默认适合增加样本多样性mixup0灰度医学图像一般不推荐病灶重叠后极不自然训练时观察哪些指标不要只盯着总 loss。要分别看 box loss 和 mask loss 是否在稳定下降。如果 mask loss 在下降但 box loss 停滞可能是预训练权重里检测头的初始化与你的数据分布不太匹配可以调大 lr0 或适当增加训练轮数。如果 mask loss 下降缓慢优先检查数据是否预处理错了而不是急着换模型。5. 推理与后处理从边界框和掩膜到可用结果5.1 推理流程训练完成后加载 best.pt 执行推理import cv2 import numpy as np from ultralytics import YOLO model YOLO(runs/ct_seg/exp_lska/weights/best.pt) # 输入图像必须是经过窗宽窗位处理和归一化的图像 img cv2.imread(preprocessed_ct_slice.png, cv2.IMREAD_COLOR) results model.predict( img, imgsz512, conf0.25, iou0.5, retina_masksTrue, ) for res in results: if res.masks is None: continue masks res.masks.data.cpu().numpy() # [N, H, W] boxes res.boxes.xyxy.cpu().numpy() for mask in masks: mask (mask * 255).astype(np.uint8) # 下一步做后处理这里retina_masksTrue很关键。它让模型输出的是原始分辨率下的掩膜而不是先缩小再插值回来的掩膜。对缺血灶这种边缘本来就模糊的目标掩膜分辨率低一点边界就会“锯齿化”最后计算体积和面积时误差变得很大。推理时最容易犯的错是在输入图像上做 resize最后输出的掩膜忘了 resize 回 DICOM 原始尺寸。如果评价指标需要在原始像素空间计算这一步漏掉Dice 和 HD95 全都失真。我的建议是所有体积计算都必须回到 DICOM 自带的 PixelSpacing 信息用物理面积乘以层厚而不是直接用像素面积。5.2 后处理噪声抑制、连通域和孔洞处理模型输出通常会有一些零散的小块假阳性也可能在大病灶内部出现不自然的孔洞。后处理的目标不是把模型输出改得很好看而是剔除明显不合理的区域。一个简单的清洗函数def clean_mask(mask, min_area50): 去掉面积过小的连通域并填充掩膜内部的孔洞。 mask mask.astype(np.uint8) # 先用闭运算消除小孔 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations1) # 找连通域去掉面积小于阈值的碎片 contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cleaned np.zeros_like(mask) for cnt in contours: area cv2.contourArea(cnt) if area min_area: continue cv2.drawContours(cleaned, [cnt], -1, 255, -1) return cleanedmin_area 怎么设要根据像素物理尺寸来。512×512 的 CT 图像如果视野是 250mm一个像素大约 0.49mm×0.49mm50 个像素大约是 12mm² 的物理面积。你可以先看训练集里最小的病灶有多少像素把 min_area 设在它的一半左右避免后处理把真病灶也清掉了。另外可以考虑加一重“解剖范围限制”。缺血区域只会出现在脑组织内部不会出现在颅骨外或头皮区域。如果你有脑实质掩膜用它对模型输出做一次按位与能去掉一大类明显错误的假阳性。这个掩膜可以是固定模板也可以用简单的形态学方法从 CT 骨窗信息里粗略生成。6. 效果评估与落地注意事项6.1 评估指标缺血分割常用的评价指标有三个DSC、HD95 和体积误差。DSC 是 Dice Similarity Coefficient衡量预测掩膜和真实掩膜的重叠程度def dice_score(pred, gt, smooth1e-6): pred (pred 0.5).astype(np.float32) gt (gt 0.5).astype(np.float32) inter (pred * gt).sum() return 2.0 * inter / (pred.sum() gt.sum() smooth)DSC 对区域重叠敏感但对边界形状不敏感。两个掩膜面积相同但位置错开DSC 也会下降但如果一个掩膜边界整体向外扩一圈另一个缩一圈DSC 可能都不低。所以还要看 HD95。HD95 是 95% 豪斯多夫距离计算预测边界上的点到真实边界的最短距离取 95 分位再反向计算取两者中较大的值。它反映的是边界的最大偏离程度。HD95 越小说明预测边界和真实边界越贴近。这个指标对缺血灶这种关心“到底波及到哪里”的目标非常关键。体积误差则是直接计算预测体积与真实体积的百分比误差。从临床量化角度看体积误差比像素级指标更直观。不过要注意体积计算必须使用 DICOM 里的 PixelSpacing 和 SliceThickness而不是图像像素坐标。评估的时候建议按病例粒度统计而不是按切片粒度。如果按切片统计一个病灶跨 20 层的病例会把整体指标拉向大目标的水平掩盖微小病灶表现差的问题。正确做法是先把某个病人所有层面的预测掩膜拼接成一个三维体积再和真实掩膜算三维 DSC 和 HD95这样更贴近真实使用场景。6.2 容易踩的问题窗宽窗位不一致是最大的坑。有的数据集用脑窗预处理存成 PNG有的直接存原始 HU训练和推理时如果混用模型的表现会断崖式下降。解决方法是把预处理统一成一个函数训练脚本和推理脚本都调用同一个函数不要复制粘贴两段代码。另一个坑是不同 CT 机型之间的重建核差异。同样一个病灶在软组织重建和骨重建下的对比度完全不同。理想情况下训练数据应该覆盖多种重建方式如果做不到至少推理阶段要判断输入数据的拍摄参数必要时做归一化处理。部署阶段也需要注意输入尺寸。YOLO 系列通常会把输入 resize 到固定尺寸比如 512×512。如果原始 CT 是 512×512那还好如果是 1024×1024 的矩阵直接缩到 512 会丢失大量细节。更好的做法是大图先按滑动窗口切块推理再把切块掩膜拼回全分辨率这样微小病灶不会被 resize 抹掉。如果后续要上生产环境可以用半精度推理或者模型导出加速。但引用模型之前务必用小批量验证集把 Dice 指标和 float32 版本对齐误差通常可以接受但绝不能默认量化后效果不变。对于医疗影像这类对边界敏感的任务压缩和量化带来的边界漂移有时候会直接影响结果。我最后的建议是先不要急着把模型改复杂。第一次实验先用标准 YOLO11-Seg 做基线记录好每个层面的 Dice 和 HD95接着加入 SPPF 的通道调整或 LSKA再看指标有没有提升。如果提升不明显优先怀疑数据预处理和标注质量而不是继续堆模型模块。深度学习分割模型的性能上限很大程度上在数据准备阶段就定下来了模型结构的改进只是把那个上限兑现多少的问题。