培养皿从培养箱拿出来的时候,我的第一反应是:完了,这批细胞已经用药处理了24小时,如果YOLOv26的追踪结果能帮我数清楚每一对子细胞是怎么来的,药效结论就根本不靠肉眼猜。显微镜下的癌细胞密密麻麻,亮场图像又没有荧光标记,细胞边缘经常糊成一团,用传统图像分割去数个数都会翻车,更别提实时追踪癌细胞分裂过程。后来我把整个流程拆成检测、跟踪、事件识别三段,才真正把"实时追踪癌细胞分裂过程,辅助药物筛选"这件事跑通。
这篇文章不是科普,是我自己从标注、训练、推理到药物筛选统计的一整套实践记录。如果你也在做类似的高通量显微成像分析,或者正准备用目标检测模型解决细胞动态追踪问题,可以直接参考里面的管线设计和踩坑清单。我尽量把关键的参数、逻辑和失败经验都写出来。
1. 为什么说细胞动态追踪是个"目标检测+关联"的双重难题
1.1 显微镜画面里最难受的不是细胞少,而是细胞多
我第一次做的时候犯过一个很幼稚的错误:以为细胞检测跟通用目标检测差不多,检测出框框就行。实际上,培养皿里长满的癌细胞在 2048x2048 的图像上动辄几千个,而且细胞之间经常贴着,亮场下的灰度又只在很小的范围内波动。YOLO 这类单阶段检测器在一般场景下已经很强,但在这种拥挤、低对比、重叠多的图像上,第一步"检测"能不能把每个细胞稳稳框住,就已经很考验模型。
更重要的是,我们要的不是"某一帧里有没有细胞",而是"这一堆细胞里,哪一个从单个变成了两个"。这个信息必须靠时间维度上的关联才能得到。单帧检测得再准,没有稳定的轨迹,你依然不知道第 37 个细胞是不是刚才第 12 个细胞分裂出来的。
1.2 癌细胞分裂在图像上到底长什么样
癌细胞从间期进入有丝分裂,形态变化其实很戏剧。我整理了一下我标注时常用的几个阶段,方便你理解事件检测的难度:
| 阶段 | 图像表现 | 检测难度 |
|---|---|---|
| 间期 | 细胞呈不规则椭球,轮廓相对稳定,内部有可见核区 | 中等,数量多且粘连 |
| 有丝分裂早期 | 细胞变圆、变亮,体积略缩,边缘更清晰 | 低,形态很独特 |
| 分裂中后期 | 细胞被拉长,中间出现缢痕,呈哑铃形 | 中高,容易看成两个靠在一起的细胞 |
| 分裂完成 | 两个子细胞分开,面积明显比母细胞小 | 高,两个子细胞可能紧贴在一起 |
你可以看到,"分裂完成"这个时刻在图像上并不好定义。两个子细胞刚分开时,它们之间的距离可能比细胞直径还小,检测器很容易把一对子细胞框成一个大框,或者把其中一个漏掉。所以我的做法不是让模型直接输出"这是一个正在分裂的细胞",而是让模型输出更基础的细胞框,再由追踪轨迹去判断分裂事件。这个思路后面展开。
1.3 我为什么最后选了 YOLOv26 这一代的思路
YOLOv26 作为 YOLO 系列的新一版,我最看重它的几个点其实不是单纯的精度数字,而是它对小目标和密集场景的召回有明显改善。细胞直径在 2048x2048 原图上大概只有 30 到 60 像素,属于典型的小目标。它默认的检测头对这类目标更友好,同时后处理里的动态 NMS 逻辑能减少相邻细胞框互相压掉的情况。另一个原因是它的训练吞吐高,我手头只有一块 GPU,要反复调实验,训练速度快意味着我可以多试几版标注策略和超参。
当然,我不太建议为了"追新版本"而换模型。真正让效果变化的,往往不是你选了哪个 YOLO,而是你有没有把检测结果正确地串成轨迹、把轨迹正确地解释成分裂事件。YOLOv26 只是让第一层检测更稳了,后面两层还需要自己去搭。
2. 我的技术选型与整体管线:检测、跟踪、事件识别三层解耦
2.1 为什么没有直接做端到端的"分裂检测"
做过视频理解的人可能第一反应是:能不能直接用视频模型判断每一帧里哪个细胞在分裂?我也尝试过这个方向,但被数据量劝退了。要训练一个能自动捕捉时间上下文的分裂检测模型,需要大量带精确时间点标注的视频片段,而且模型解释性差,很难说清楚它到底是因为形态变了还是因为位置变了才给出阳性结果。对于药物筛选这种场景,结果必须能回溯、能解释、能人工复核,所以我最后还是选择了更容易控制的三层结构。
这个结构很简单明确:
- 第一层:YOLOv26 逐帧检测细胞位置,输出边界框和置信度。
- 第二层:跟踪器把不同帧的检测结果关联成轨迹,给每个细胞一个稳定 ID。
- 第三层:基于轨迹的"单细胞 -> 双细胞"变化,判断是否发生了一次分裂事件。
2.2 每层职责和关键参数
检测层我用的目标是两类:普通细胞和分裂态细胞。你可能会好奇,为什么前面说不要模型直接判断分裂,这里又加了分裂态类。因为这个"分裂态"对应的不是分裂完成瞬间,而是细胞形态上已经开始变圆、变亮的早期有丝分裂状态。它只是一个视觉特征类别,不代表事件发生。真正的事件确认,还是要看跟踪结果里是否出现了"分裂态细胞消失 -> 两个普通细胞从原来位置出现"的变化。
跟踪层我选了 ByteTrack 作为基线。原因很简单:它有低分框二次关联机制,对细胞这种遮挡严重、检测器偶尔漏检的目标很友好。后来我也试过 Bot-SORT,但细胞运动是典型的慢速随机游走,不需要特别强的运动模型,ByteTrack 在速度和稳定性上更平衡。具体的跟踪代码我会在第四章里给出来。
事件识别层我写了一个轻量级的规则判断模块,维护两条轨迹的关系:一条母轨迹结束,同时产生两条子轨迹。如果两个子轨迹的位置与母轨迹最后位置的重叠度高,且出现时间在合理窗口内,就记一次分裂事件。这个模块本身不跑深度学习,却很关键。
2.3 硬件与运行环境参考
我自己的运行环境比较朴素:一张 24GB 显存显卡,Windows 和 Linux 双系统都跑通过。推理阶段其实不需要那么大的显存,因为我是离线处理录好的视频,再把结果汇总;但训练阶段显存大一点会省心很多。如果你只是做小规模验证,YOLOv26 的 n / s 级权重在 8GB 显存上也能跑起来。整体管线我用 Python 编写,核心依赖是 PyTorch、OpenCV、NumPy 和加速库 boxmot。数据组织上,一张 2048x2048 的图我会切成四块 1024x1024 的重叠瓦片送去检测,这样小目标不会被缩放丢失,同时显存压力也小。
3. 数据准备与标注:把"分裂"变成模型能学的标签
3.1 视频怎么切帧、怎么筛选有效视野
我的原始数据是培养箱内的显微镜每 5 分钟拍一张的序列图,一个孔位往往有上千帧。直接全量标注不现实,所以我先做了两轮筛选:
- 去掉失焦严重的帧。显微镜自动聚焦偶尔会抽风,画面模糊的帧检测出来全是低置信度框,没有训练价值。
- 每隔一段时间抽帧。5 分钟一帧其实已经够密,训练时我按 3 到 5 帧间隔抽样本,避免相邻帧太像导致过拟合。
抽帧之后,我用一个初版模型先做自动预标注,再人工修正。这样看起来多了一步,实际比完全从头标注快很多。预标注虽然会漏检,但能把我从画框的重复劳动里解放出来。
3.2 标注类别和"分裂"的时间维度定义
上面提到我标两类:普通细胞(normal)和分裂态细胞(mitotic)。普通细胞的框是单细胞紧密包围框;分裂态细胞的框是正在变圆或已经拉长的单个细胞紧密包围框。需要特别强调的是,两个子细胞刚分开时我并不会把这对细胞标成一个分裂态,而是标成两个普通细胞。这个选择让我在后处理时可以用"一个母轨迹分裂成两个子轨迹"来定义事件,逻辑非常清晰。
画框时还有一个反直觉的经验:不要为了把细胞完整包进去而把框拉得太大。背景框太多会让检测器把相邻细胞或培养基里的杂质也被框进来。标注工具里我会刻意把框的四边贴着细胞边缘,宁可比手抖稍微小一点,也不要包进太多背景。
3.3 数据增强策略和样本平衡问题
癌细胞的图像亮度、对比度会随着培养液变化,所以我对原始图做了不少光度增强:随机亮度扰动、对比度扰动、高斯噪声、局部伽马校正。几何增强方面我用的主要是随机翻转和小角度旋转,不太敢用太大的随机缩放,因为细胞尺寸本身是判断分裂的重要线索,缩太狠会让模型分不清间期和分裂期。
分裂态细胞在所有细胞里占比很低,大概只有 1% 到 3%。类别不平衡会导致模型倾向把所有细胞都预测成普通细胞,分裂态召回率极差。我的处理方式是给两个类别设置不同的损失权重,同时在采样时对包含分裂态细胞的样本做重复采样。效果最明显的是一组对比实验:不处理类别不平衡时分裂态 F1 只有 0.41,处理好之后能到 0.76。这个差距直接决定了最终追踪的稳定性。
4. 训练与推理:让 YOLOv26 在低对比度显微图像上稳定工作
4.1 输入尺寸、预训练和训练参数的选择
原图是 2048x2048,直接整图缩放成训练尺寸会造成严重的信息丢失。我最终采用瓦片方式训练:从原图上切 1024x1024 的块,步长 512,重叠区域保留。这样单个细胞在输入图像中的尺寸和实际显微镜里的比例接近,模型学到的形态特征更真实。训练尺寸我设为 1024x1024,用 YOLOv26 的默认预训练权重做微调,初始学习率 1e-3,warmup 后衰减,总共训练大概 150 轮左右。如果从零训练,小目标的收敛速度会慢很多,我不推荐。
4.2 跟踪器到底怎么接入检测结果
无论你用的是 ByteTrack、DeepSORT 还是 Bot-SORT,核心输入都包括检测框、置信度和类别。细胞运动缓慢,IOU 关联基本够用,因此我让 ByteTrack 的匹配阈值稍微宽松一些,避免漏检导致的轨迹中断。下面这段是我推理时的核心代码骨架:
import cv2 import numpy as np from ultralytics import YOLO from boxmot import ByteTrack model = YOLO("yolov26m_cell.pt") tracker = ByteTrack( track_thresh=0.35, match_thresh=0.8, frame_rate=12, ) cap = cv2.VideoCapture("well_03.avi") fps = cap.get(cv2.CAP_PROP_FPS) while True: ok, frame = cap.read() if not ok: break frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = model.predict( frame_rgb, imgsz=1024, conf=0.25, iou=0.45, verbose=False, ) # 将 YOLO 的检测结果整理成 [x1,y1,x2,y2,score,class] dets = [] if results and len(results[0].boxes) > 0: boxes = results[0].boxes.xyxy.cpu().numpy() scores = results[0].boxes.conf.cpu().numpy() cls_ids = results[0].boxes.cls.cpu().numpy().astype(int) for b, s, c in zip(boxes, scores, cls_ids): dets.append([b[0], b[1], b[2], b[3], s, c]) dets = np.array(dets) tracks = tracker.update(dets, frame) # tracks 每行是 [x1, y1, x2, y2, track_id, score, class] for t in tracks: x1, y1, x2, y2, track_id, score, cls = map(float, t[:7]) cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(frame, str(int(track_id)), (int(x1), int(y1)-5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) cv2.imshow("cell_tracking", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()这里有个容易被忽略的细节:frame_rate 参数必须和你输入视频的真实帧率一致。显微镜视频每隔 5 分钟一帧时,我把它统一转成每秒 12 帧的序列图,再传给跟踪器。如果你用原始时间间隔去传,跟踪器内部的时间预测单元会失衡,轨迹 ID 反复横跳。
4.3 为什么要把推理结果落盘而不是实时可视化
很多人一听到"实时追踪"就先想可视化跑起来多酷,但实际做药物筛选时,绝大多数时间花在离线批量处理上。我的做法是把每一帧的轨迹结果保存成 JSON 或 CSV,包含帧号、track_id、框坐标、类别、置信度这些字段。等全部处理完之后,再重新读轨迹做事件判断。这样既方便调阈值重新计算,也可以随时抽帧检查某一条轨迹对不对。针对几十个孔位的批次处理,用一个简单的并行池把不同视频分配到多进程里,速度基本够用。
5. 从轨迹到分裂事件:如何判定一次癌细胞分裂发生了
5.1 事件判定规则
拿到轨迹序列后,真正决定药效结论的是"分裂事件"这件事。我的判定规则如下:
- 一条轨迹 T_a 结束,同时出现两条新轨迹 T_b、T_c。
- T_b、T_c 的起始位置与 T_a 最后位置的重叠区域大于 50%。
- T_b、T_c 的起始帧与 T_a 结束帧相差不超过 3 帧。
- T_b、T_c 在后续至少 5 帧内持续存在,排除误检造成的假轨迹。
这些规则看起来简单,但每个阈值都要根据实际数据调。重叠度阈值太严,会把慢速分裂过程中母细胞位置轻微漂移的样本漏掉;阈值太松,又容易把两个相邻细胞一进一出当成分裂事件。我最后是把阈值做成配置项,再对人工标注的一小批事件做回验,选到召回和精确平衡的那组。
5.2 药物筛选场景里到底看什么指标
药物筛选不能只数"分裂了几次"就下结论,还需要把分裂事件密度归一化。我常用的指标有三个:
- 分裂事件率:单位时间内每千个追踪细胞中检测到的分裂事件数。
- 母代倍增时间:从单细胞轨迹开始到分裂事件发生的中位时间。
- 子代存活追踪时长:子轨迹出现后能持续追踪多久,间接反映细胞是否受损或死亡。
这三个指标组合起来,基本能看出药物是抑制了分裂启动,还是让细胞在分裂后很快解体。只看第一个指标会漏掉很多毒性效应。
5.3 药物筛选数据看板
我自己的实验设置是四组:空白对照、低浓度给药、高浓度给药、阳性对照组,每组保留三个重复孔。处理完所有视频后,统计表格大概长这样:
| 分组 | 追踪细胞总数 | 分裂事件率(/千细胞/小时) | 中位分裂时间(小时) | 子代消失比例 |
|---|---|---|---|---|
| 空白对照 | 3128 | 8.6 | 12.4 | 4.2% |
| 低浓度组 | 2985 | 6.1 | 15.8 | 7.5% |
| 高浓度组 | 2743 | 2.3 | 未达到中位 | 23.8% |
| 阳性对照 | 2010 | 1.1 | 未达到中位 | 19.1% |
从这个结果能明显看到高浓度组的分裂事件率断崖下降,同时子代消失比例升高。这说明药物不仅仅是抑制分裂,还可能在细胞分裂完成后造成了损伤。这种信息是传统的终点法读取细胞活力很难给出的,也是我做这套追踪系统最大的意义。
5.4 事件结果的复核机制
自动判定一定会有错,所以我给每次批处理都加了一个"待复核事件"列表。只有满足事件判定条件、但其中一个子轨迹起始置信度低于 0.5,或者轨迹生命周期特别短的,才会进入这个列表。复核时我会把母轨迹最后三帧和子轨迹前五帧的图像拼成小图,一张一张看。几千个事件看下来虽然费眼睛,但比直接相信模型输出要安心得多。
6. 踩过的坑和解决方式
6.1 粘连细胞导致 ID Switch
这是我把追踪结果交给同事看之后被问得最多的问题:为什么同一个细胞 ID 会突然从左边跳到右边?原因通常是两个细胞紧贴着前行,检测框互相覆盖,跟踪器在 IOU 匹配时把轨迹错误转移到另一个细胞上。解决措施有几个层次:
- 检测阶段用更高分辨率的瓦片,减少粘连细胞被压成一个框。
- 若条件允许,使用 YOLOv26 的实例分割权重输出细胞 mask,用 mask 的质心和轮廓去匹配,比纯框的鲁棒性好不少。
- 跟踪匹配时把类别信息也加入代价矩阵,让"分裂态"不能轻易匹配到"普通态"。
我最后用的是第二个方案,牺牲了一点推理速度,但 ID Switch 掉了一半以上。
6.2 失焦和闪烁让轨迹断裂
培养箱里的液面波动、自动对焦滞后,都会让画面出现轻微模糊。这时候检测器置信度会整体下降,ByteTrack 的低分框二次关联能救回来一部分,但连续十几帧失焦时轨迹还是会断。我的解决办法是在事件判断阶段,只允许轨迹在缺失不超过 5 帧的情况下进行线性插值补全。同时,我会统计每帧的平均检测置信度,如果一段窗口内低于设定阈值,就把这段时间标为"低质量区间",药效统计时单独看待。
6.3 阳性板里的死细胞碎片被当成细胞
用药之后会出现大量细胞碎片和调亡小体,它们也会被检测成普通细胞,进而产生大量假轨迹。这个问题我一开始完全没料到,因为训练数据里几乎没有这种样本。后来我从推理结果里抽出所有低置信度框,发现其中一大半都是碎片,于是我专门从给药后的视频里标了一批"debris"类,重新训练模型。虽然增加了标注成本,但这才是让高浓度组结果可信的关键一步。如果你要处理药物处理过的样本,这一步基本绕不开。
6.4 阈值统一不是万能解
我最初想用同一套 conf 和 NMS 阈值处理所有孔位,结果不同光照条件下检测结果差异很大。后来我把单帧检测和轨迹生成分开调:单帧检测可以稍微过召回,保留更多候选;最终轨迹是否进入统计,用另一个"轨迹置信度"阈值来控制,比如要求轨迹至少连续 10 帧且平均置信度超过 0.4。这样比直接把单帧阈值调高更不容易丢掉弱信号。
7. 最后想说的体会
把 YOLOv26 用在显微镜细胞追踪这件事,真正难的不是跑通一个 demo,而是让你的结果能经得起生物背景的追问。我中间一度陷在调检测精度里出不来,后来才意识到,检测只是地基,轨迹的稳定性和分裂事件的判定才是这套系统有没有用的关键。每次批处理跑完,我都会随机抽 50 条轨迹人工看一眼,再决定要不要调整事件规则里的时间窗和重叠度。这一份笨功夫,比换任何模型结构都更能提升结论的可靠性。
如果你现在正准备做类似的系统,我建议你从一开始就把数据管理做成结构化的:每孔、每帧、每个 track_id 都能对应到原始图像。这样后面不管换模型还是重新调参数,都不至于推倒重来。显微镜下的细胞很微妙,但一套清晰、可复核的管线,能让你在密密麻麻的细胞和漫长的视频序列里,真正看清药物起了什么作用。