数字病理切片分析这几年在医学影像圈子里热度一直不低而Hovernet算是其中绕不开的一个经典工作。它做的事情说白了就一件把一张病理切片图像里密密麻麻、互相粘连的细胞核逐个分离出来并分类。听起来简单但真正上手复现的时候从环境配置、数据准备到推理后处理每一步都有坑。我前后花了大概两周时间把整套流程跑通中间踩过的雷不少这里把完整的复现思路和实操细节整理出来给准备入坑或者正在卡壳的朋友一个参考。这篇内容适合有一定深度学习基础、想复现病理图像分割模型的人也适合做医学图像相关项目、需要理解实例分割在密集目标场景下怎么落地的同学。下面我按设计思路、核心细节、实操流程、问题排查几个部分展开尽量把每个为什么讲清楚。1. 整体设计思路与方案拆解1.1 Hovernet 到底解决的是什么问题病理切片里的细胞核分割难点不在于分割本身而在于实例分离。一张 HE 染色的组织切片细胞核挨得极近边界模糊传统语义分割出来的是一整片连通区域根本分不清哪个像素属于哪个细胞。Hovernet 的核心洞察很巧妙与其直接预测每个像素属于哪个实例不如先预测两个辅助图——水平距离图horizontal map和垂直距离图vertical map再加上一个核像素二值图nuclei map最后通过后处理把距离图推成一个个独立的实例。这个思路的好处在于网络不需要学习实例编号这种离散且不稳定的目标而是学习连续的像素到核边界的距离训练更稳定泛化也更好。分类分支则是在分割的基础上对每个检测到的核预测类别比如上皮细胞、淋巴细胞、间质细胞等。所以整个模型是多任务学习一个共享编码器三个解码分支核像素、水平距离、垂直距离外加一个分类分支。我选择复现这个方案主要是因为它对密集小目标的处理确实比 Mask R-CNN 那类检测式实例分割更自然——病理核太小太密检测框回归很容易糊在一起而像素级距离图对粘连边界的刻画更细腻。1.2 为什么采用距离图 后处理而不是端到端实例分割这里值得多说两句。端到端实例分割比如基于 query 的方案在自然图像上很成熟但迁移到病理图像会遇到两个现实问题一是标注成本高实例级标注在病理场景极其昂贵二是细胞核尺度差异大从几个像素到几十个像素都有固定数量的 query 很难覆盖。而 Hovernet 的距离图方案本质上是把实例分离这个难题从网络学习转移到了确定性的后处理算法上网络只负责学相对容易的距离回归后处理用分水岭或者连通域分析来收尾。这种网络学简单目标 算法做复杂决策的分工在实际工程里非常实用。因为后处理是可解释、可调试的出问题时你能明确知道是网络预测不准还是后处理参数没调好而不是面对一个黑盒端到端模型无从下手。这也是我推荐新手从这个方案入门实例分割的原因——它的每个环节都看得见。1.3 复现的整体技术栈选型我用的技术栈比较常规PyTorch做训练框架OpenCV scikit-image做后处理和可视化数据加载用标准的 Dataset/DataLoader。之所以不用 TensorFlow主要是社区里 PyTorch 版本的参考实现更多遇到问题好查。后处理部分强烈建议用 scikit-image它的watershed、label、regionprops这些函数封装得很成熟自己手写连通域分析容易在边界情况上翻车。硬件方面单卡显存 11GB 左右就能跑起来batch size 开到 4~8 没问题。如果显存紧张可以把输入裁剪到 256×256 而不是原论文的 270×270实测精度损失很小但显存占用明显下降。这个取舍在后面实操部分会详细说。2. 核心细节解析与实操要点2.1 数据准备图像与标注的格式对齐Hovernet 的训练数据需要三类信息原始切片图像、核像素掩码、以及每个核的实例标注用于生成距离图和分类标签。公开数据集里比较常用的是 Kumar 和 CPM17 这类但不管用哪个第一步都是把标注格式统一。我遇到的第一大坑就是标注格式不一致。有的数据集给的是 instance mask每个核一个整数 ID有的给的是 contour 轮廓点还有的给的是 JSON 格式的多边形。Hovernet 需要的是 instance mask所以如果拿到的是多边形得先用cv2.fillPoly转成掩码。转换时要注意坐标顺序OpenCV 的 polygon 是 (x, y) 而 numpy 数组索引是 (row, col)搞反了会得到镜像的结果而且这种错误很隐蔽训练 loss 看着正常但分割结果全歪。生成距离图是另一个关键步骤。水平距离图和垂直距离图的定义是对每个核内像素计算它到核左右边界水平方向和上下边界垂直方向的距离然后归一化。具体做法是对每个实例分别沿行和列扫描找到该像素所在连续段的左右/上下端点取到较近端点的距离再除以该方向上的最大距离做归一化。这里有个细节归一化是按每个核单独做的不是全图统一这样不同大小的核在距离图上尺度一致网络学起来更稳。注意距离图归一化时如果某个核在某个方向上只有一个像素宽分母会变成 0必须加一个极小值 eps 防止除零。我第一次跑的时候没处理loss 直接变 NaN排查了半天才发现是这个原因。2.2 网络结构的关键改动点Hovernet 的编码器用的是 Preact-ResNet50解码器是三个并行的分支。复现时最容易出问题的是跳跃连接的对齐。因为解码器要做上采样特征图尺寸必须和编码器对应层严格一致如果 padding 或者 stride 设置有偏差concat 的时候尺寸对不上直接报错。我的经验是把每个 block 的输入输出尺寸打印出来逐层核对别等报错了再回头找。分类分支的设计也值得注意。它不是对每个像素分类而是对每个检测到的实例分类——具体做法是在实例的质心位置取特征然后过一个全连接层输出类别。这意味着分类分支的输入依赖于分割结果训练时用 ground truth 的质心推理时用后处理得到的质心。这种设计让分类和分割解耦但也带来一个问题如果分割漏检了某个核分类分支就完全没机会纠正。所以分割分支的召回率要尽量高宁可多检一点靠后处理过滤也别漏。损失函数方面分割用交叉熵核像素二值 MSE两个距离图分类用交叉熵。三个分割损失的权重我按论文设的核像素 1.0、水平距离 1.0、垂直距离 1.0分类 1.0。实测下来这个配比比较均衡如果发现距离图学得不好可以适当调高距离损失的权重但别调太高否则核像素分割会退化。2.3 后处理把距离图推成实例的核心算法后处理是整个流程里最需要调参、也最影响最终效果的部分。核心逻辑是先用核像素图做阈值化得到前景然后对前景区域用距离图做分水岭分割。具体来说把水平距离图和垂直距离图组合成一个能量图在能量图的局部极小值处放置种子点然后从种子点向外泛洪遇到能量脊线就停止从而把粘连的核分开。这里的关键参数有三个核像素阈值、种子点检测的最小距离、分水岭的泛洪阈值。核像素阈值一般设 0.5但如果模型欠拟合可以降到 0.4 提高召回。种子点最小距离决定了能分开多近的两个核设太小会把一个核切成两半设太大会把两个核合并我实测在 270×270 输入下设 7~10 像素比较合适。泛洪阈值控制分水岭的停止条件一般用能量图的梯度做判断。我踩过的一个坑是直接用skimage.segmentation.watershed时如果不指定markers它会自动用局部极小值做种子结果在噪声区域产生大量碎片。正确做法是先自己检测种子点比如用peak_local_max再传给 watershed。另外分水岭之后一定要做一次小区域过滤把面积小于某个阈值的实例丢掉这些基本都是噪声。3. 实操过程与核心环节实现3.1 环境搭建与依赖安装环境这块我建议用 conda 建独立环境避免和系统里的包冲突。核心依赖就几个PyTorch带 CUDA、OpenCV、scikit-image、numpy、pandas、tqdm。版本上 PyTorch 1.10 以上都行scikit-image 建议 0.19 以上因为老版本的 watershed 接口有差异。conda create -n hovernet python3.9 -y conda activate hovernet pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python scikit-image numpy pandas tqdm matplotlib装完之后先跑一个最小验证import torch; print(torch.cuda.is_available())确认 GPU 能用。如果返回 False大概率是 CUDA 版本和 PyTorch 不匹配去官网查对应关系重装。这一步别偷懒后面训练卡在 CPU 上会慢到怀疑人生。3.2 数据预处理流水线搭建数据预处理我写成了一个独立的脚本输入是原始图像和 instance mask输出是训练用的.npy文件包含图像、核像素图、水平距离图、垂直距离图、分类标签。这样做的好处是训练时直接读 npy不用每次在线生成距离图能省不少时间。核心的距离图生成函数逻辑如下这是整个预处理里最需要仔细写的部分import numpy as np from scipy import ndimage def gen_distance_maps(inst_mask): h, w inst_mask.shape h_map np.zeros((h, w), dtypenp.float32) v_map np.zeros((h, w), dtypenp.float32) inst_ids np.unique(inst_mask) inst_ids inst_ids[inst_ids ! 0] for iid in inst_ids: bin_mask (inst_mask iid) # 水平方向逐行扫描 for r in range(h): row bin_mask[r] if not row.any(): continue cols np.where(row)[0] # 找连续段 splits np.split(cols, np.where(np.diff(cols) ! 1)[0] 1) for seg in splits: left, right seg[0], seg[-1] max_dist (right - left) / 2.0 1e-6 for c in seg: d min(c - left, right - c) h_map[r, c] d / max_dist # 垂直方向同理逐列扫描 for c in range(w): col bin_mask[:, c] if not col.any(): continue rows np.where(col)[0] splits np.split(rows, np.where(np.diff(rows) ! 1)[0] 1) for seg in splits: top, bottom seg[0], seg[-1] max_dist (bottom - top) / 2.0 1e-6 for r in seg: d min(r - top, bottom - r) v_map[r, c] d / max_dist return h_map, v_map这段代码是纯 Python 循环跑大图会慢但胜在逻辑清晰、容易调试。如果数据量大可以用向量化改写或者用ndimage.distance_transform_edt配合方向掩码加速。我一开始追求速度用了向量化版本结果边界情况处理错了还是退回循环版本先保证正确性再逐步优化。提示生成完距离图后务必随机抽几张可视化检查。我见过有人把水平和垂直搞反了训练 loss 正常但推理结果一塌糊涂就是因为没做可视化验证。3.3 训练流程与关键参数设置训练用标准的 Adam 优化器学习率 1e-4weight decay 1e-5batch size 8训练 50 个 epoch 左右收敛。学习率调度用 cosine annealing比 step decay 更平滑。输入尺寸我用的 270×270和论文一致但如果你显存不够改成 256×256 也完全可行。训练循环里有个细节要注意多任务损失的平衡。四个损失项核像素、水平距离、垂直距离、分类直接相加如果某一项数值量级差太多会主导梯度。我的做法是每次打印各项 loss 的数值观察量级是否接近。实测下来核像素交叉熵在 0.1 量级距离 MSE 在 0.01 量级分类交叉熵在 0.5 量级量级差异不算大直接相加没问题。但如果你的数据类别极不均衡分类 loss 可能飙到很高这时候需要给分类损失降权。数据增强方面我用了随机翻转、90 度旋转、颜色抖动。病理图像对颜色敏感HE 染色的深浅变化大所以颜色抖动是必须的但幅度别太大否则会把不同类别的颜色特征搞混。翻转和旋转对细胞核这种近似各向同性的目标很安全可以放心用。3.4 推理与后处理完整实现推理阶段模型输出三个图先对核像素图做 sigmoid 再阈值化得到前景然后对前景区域做分水岭。完整后处理流程如下import cv2 import numpy as np from skimage.feature import peak_local_max from skimage.segmentation import watershed from scipy import ndimage def post_process(np_map, hv_map, thresh0.5, min_distance7, min_area10): # np_map: 核像素概率图, hv_map: (2, H, W) 距离图 binary (np_map thresh).astype(np.uint8) # 能量图距离图越靠近边界值越小取负作为能量 energy -(hv_map[0] hv_map[1]) # 检测种子点 coords peak_local_max(energy, min_distancemin_distance, labelsbinary, exclude_borderFalse) markers np.zeros_like(binary, dtypenp.int32) for idx, (r, c) in enumerate(coords, start1): markers[r, c] idx # 分水岭 labels watershed(energy, markers, maskbinary) # 小区域过滤 for lid in np.unique(labels): if lid 0: continue if (labels lid).sum() min_area: labels[labels lid] 0 return labels这段代码里min_distance和min_area是两个最需要调的参数。min_distance太小会导致过分割一个核被切成多个太大导致欠分割多个核合并。我的经验是从 7 开始试如果发现小核被合并就降到 5如果发现大核被切碎就升到 10。min_area用来过滤噪声一般设 10~20 像素具体看你的输入分辨率。分类部分对每个检测到的实例取质心坐标从分类分支的特征图上采样得到类别概率取 argmax 作为该核的类别。这里要注意质心可能落在实例外部对于环形或不规则形状的核稳妥的做法是取实例内距离变换的最大值点作为采样点而不是简单的几何质心。4. 常见问题与排查技巧实录4.1 训练 loss 不下降或震荡这是最常见的问题原因通常有三类。第一类是学习率太大表现为 loss 前期剧烈震荡解决方法是降到 1e-5 再试。第二类是数据标注有问题比如距离图生成错误、类别标签越界这种问题 loss 可能看起来在降但精度上不去必须回头检查预处理。第三类是损失权重失衡某一项 loss 主导了梯度表现为其他分支的输出全是常数。排查顺序我建议先可视化几个 batch 的输入和标签确认数据没问题再把学习率调小跑几个 epoch 看 loss 是否平稳下降最后检查各分支输出的数值范围是否合理比如距离图应该在 0~1 之间如果出现负值或大于 1 说明归一化有问题。4.2 分割结果粘连严重、实例分不开如果推理出来的实例大量粘连说明后处理的分水岭没起作用。可能的原因种子点检测太少min_distance太大、能量图太平坦距离图预测不准、或者核像素阈值太高导致前景断裂。我的排查方法是把中间结果全部可视化核像素二值图、能量图、种子点位置、最终实例图一层层看。如果能量图在核边界处没有明显的脊线说明距离图分支学得不好得回头加强距离损失的权重或者检查距离图标签。如果种子点明显偏少调小min_distance。如果前景本身就断裂降低核像素阈值。4.3 分类准确率低分类分支准确率低首先要排除分割漏检的影响——如果某个核根本没被检测到分类再准也没用。所以先看分割的召回率召回率低的话分类指标没有参考意义。如果分割没问题但分类差常见原因是类别不均衡。病理数据里某些类别比如淋巴细胞数量远多于其他类别模型会偏向多数类。解决办法是给分类损失加类别权重权重按类别频率的倒数设置。另外质心采样点如果落在背景上会取到错误的特征这也是分类差的一个隐蔽原因改用距离变换最大值点能缓解。4.4 常见问题速查表问题现象可能原因排查与解决loss 变 NaN距离图归一化除零分母加 eps检查单像素宽核训练慢、GPU 利用率低数据在线生成距离图预处理成 npy 离线加载实例大量粘连种子点太少或能量图平坦调小 min_distance检查距离图预测一个核被切成多个min_distance 太小增大 min_distance 到 10 左右分类全预测成同一类类别不均衡或采样点错误加类别权重改用距离变换峰值点推理结果整体偏移坐标顺序 (x,y) 与 (row,col) 混淆检查 fillPoly 和数组索引顺序显存溢出输入尺寸或 batch 太大降到 256×256batch 减半4.5 几个容易被忽略的实操心得第一个心得是先小数据过拟合。正式训练前拿 10 张图训练 100 个 epoch看能不能把 loss 压到接近 0、分割结果完美。如果小数据都过拟合不了说明模型或数据管道有 bug别急着上全量数据。这个习惯帮我省了大量时间很多问题在小数据阶段就能暴露。第二个心得是后处理参数按数据集调不要一套参数走天下。不同数据集的细胞核大小、密度差异很大min_distance和min_area必须根据实际图像分辨率重新标定。我的做法是拿几张验证图手动标注期望的实例数然后网格搜索参数使预测实例数最接近。第三个心得是保存中间可视化结果。训练和推理过程中把每个 epoch 的预测图、后处理的各个中间步骤都存下来出问题时能快速定位是哪一环出的错。这个习惯在调试多阶段流程时尤其重要比盯着 loss 曲线有用得多。第四个心得是注意图像位深和归一化。病理图像有的是 8 位有的是 16 位读进来如果不做归一化直接送网络数值范围差几百倍训练必然崩。统一转成 float32 并除以最大值或者用 ImageNet 的均值和方差做标准化二选一但要全流程一致。整套流程跑通之后我最大的感受是Hovernet 这类工作的复现难点不在网络结构本身结构其实很清晰而在数据预处理和后处理的细节。距离图的生成、种子点的检测、参数的标定这些脏活累活才是决定最终效果的关键。网络结构照着论文搭基本不会错但后处理参数调不好再好的模型也出不来干净的结果。所以如果你正在复现建议把至少一半的精力放在预处理和后处理上多可视化、多对比、多调参别指望一次跑通。后续如果想进一步提升可以尝试在距离图分支上加注意力机制或者用更精细的实例级损失来替代纯像素损失这些方向我都试过一些效果因数据集而异值得折腾。