简介:这份资源是面向医疗影像分析与深度学习入门者的息肉目标检测实战项目,基于Python与Keras-YOLOv3实现,适合具备一定神经网络基础、希望将目标检测落地到医学图像场景的开发者。压缩包共41个文件,约149KB,以25个Python脚本为核心,辅以10个txt标签与锚点配置、2个cfg网络结构文件、2个md说明文档及字体等辅助资源,覆盖数据预处理、模型构建、训练、评估到推理部署的完整链路。项目围绕YOLOv3多尺度预测与Darknet架构展开,包含数据标注转换、损失函数定义、mAP评估、锚点聚类及可视化等模块,目录按数据、模型、工具函数分层组织,便于按需查阅与二次开发。目前已有378人学习,可作为医学图像目标检测的练手模板,帮助读者理解从数据准备到模型微调的全流程,并在此基础上迁移到其他检测任务。
1. 息肉检测为什么值得用 Keras-YOLOv3 跑一遍
肠镜画面里,息肉往往只占几百个像素,边缘还和肠壁黏膜糊在一起,医生盯着屏幕找,漏检率一直下不来。把目标检测模型塞进这套流程,核心诉求就一个:在近实时帧率下,把可疑区域框出来,给医生当第二双眼睛。python + keras + yolov3 这个组合,是很多人入门医学图像检测的第一站——Keras 把网络搭建和训练循环压到几十行,YOLOv3 本身又是单阶段检测里结构清晰、改起来不心疼的经典骨架。这个标题对应的,就是一套能跑通「数据标注→训练→推理→看框」全链路的工程包,适合有 python 基础、想拿真实医学图像练手的工程师,也适合做内镜辅助诊断方向、需要快速验证想法的人。它不解决临床级精度问题,但能让你在一周内看到模型在自己数据上出框,这个反馈速度才是它真正的价值。
2. 把 Keras-YOLOv3 拆开看:骨干、先验框与损失怎么配合
2.1 Darknet-53 骨干在 Keras 里长什么样
YOLOv3 的骨干是 Darknet-53,53 层卷积堆叠,靠残差连接把梯度稳住。在 Keras 里复现时,常见做法是用Conv2D + BatchNormalization + LeakyReLU组成一个conv_block,再按[1,2,8,8,4]的重复次数堆残差块。这里有个容易忽略的点:Darknet 的 LeakyReLU 斜率是 0.1,不是 Keras 默认的 0.3,改错这个值,前期 loss 下降会明显变慢。
from tensorflow.keras.layers import Conv2D, BatchNormalization, LeakyReLU, Add def conv_block(x, filters, kernel_size, strides=1): # Darknet 标准卷积单元:卷积 + BN + LeakyReLU(0.1) x = Conv2D(filters, kernel_size, strides=strides, padding='same', use_bias=False)(x) x = BatchNormalization()(x) x = LeakyReLU(alpha=0.1)(x) # 斜率必须是 0.1 return x def residual_block(x, filters): # 残差块:1x1 降维 + 3x3 升维,再与输入相加 shortcut = x x = conv_block(x, filters // 2, 1) x = conv_block(x, filters, 3) return Add()([shortcut, x])逻辑说明:conv_block把卷积、归一化、激活绑成一个可复用单元,use_bias=False是因为 BN 自带偏置,再加一层是浪费。residual_block里先 1x1 再 3x3,这是 Darknet 的标准瓶颈结构,能压参数量。参数上,filters按[32,64,128,256,512,1024]逐级翻倍,strides=2只在下采样时用。如果你把alpha写成默认值,训练初期 loss 曲线会抖得厉害,这是血泪经验。
2.2 三个尺度的特征图与先验框分配
YOLOv3 在三个尺度上出预测:13×13、26×26、52×52。小特征图管大目标,大特征图管小目标。息肉在画面里通常偏小,所以 52×52 这一路的召回最关键。每个尺度上,每个网格预测 3 个先验框,COCO 预训练用的是 9 个聚类框,但息肉尺寸分布和 COCO 差得远,直接套用会明显偏。
| 特征图尺度 | 感受野 | 适合目标 | 先验框(宽,高)示例 |
|---|---|---|---|
| 13×13 | 大 | 大息肉/近景 | (116,90) (156,198) (373,326) |
| 26×26 | 中 | 中等息肉 | (30,61) (62,45) (59,119) |
| 52×52 | 小 | 小息肉/远景 | (10,13) (16,30) (33,23) |
先验框不是随便填的,我一般会用自己的标注框跑一遍 k-means,把 9 个聚类中心按面积排序后分配到三个尺度。如果懒得聚类,至少把 52×52 那一路的框改小,否则小息肉根本匹配不上正样本,训练时正样本数少得可怜,模型学不到东西。
2.3 损失函数里三个部分的权重
YOLOv3 的 loss 由三块组成:框回归、置信度、分类。框回归用 CIOU 或原始 MSE,置信度和分类用二值交叉熵。Keras 实现时,常见做法是把三路输出拉平后统一算 loss,再按lambda_coord、lambda_noobj加权。
import tensorflow as tf def yolo_loss(y_true, y_pred, lambda_coord=5.0, lambda_noobj=0.5): # y_true/y_pred 形状: (batch, grid, grid, anchors, 5+num_classes) obj_mask = y_true[..., 4:5] # 有目标的位置 noobj_mask = 1.0 - obj_mask # 背景位置 # 框回归损失:只算有目标的位置 xy_loss = tf.reduce_sum(obj_mask * tf.square(y_true[..., :2] - y_pred[..., :2])) wh_loss = tf.reduce_sum(obj_mask * tf.square( tf.sqrt(y_true[..., 2:4] + 1e-6) - tf.sqrt(y_pred[..., 2:4] + 1e-6))) # 置信度损失:有目标 + 背景分开加权 obj_loss = tf.reduce_sum(obj_mask * tf.square(y_true[..., 4:5] - y_pred[..., 4:5])) noobj_loss = tf.reduce_sum(lambda_noobj * noobj_mask * tf.square(y_true[..., 4:5] - y_pred[..., 4:5])) # 分类损失 cls_loss = tf.reduce_sum(obj_mask * tf.square(y_true[..., 5:] - y_pred[..., 5:])) return lambda_coord * (xy_loss + wh_loss) + obj_loss + noobj_loss + cls_loss逻辑说明:obj_mask把有目标的位置挑出来,背景位置只算置信度损失,且乘lambda_noobj压低权重,防止背景样本淹没正样本。wh_loss里对宽高开根号,是为了让大框和小框的误差尺度接近。参数上,lambda_coord=5.0是原论文的推荐值,lambda_noobj=0.5可以按你数据里正负样本比例微调——息肉数据正样本极少,这个值可以再降到 0.3 左右。注意1e-6是防止开根号出现 NaN,别省。
3. 从标注到训练:息肉数据跑通的最小闭环
3.1 标注格式与目录结构
息肉数据一般来自肠镜视频抽帧,标注用 LabelImg 画框,导出 YOLO 格式的 txt:每行class_id cx cy w h,坐标都归一化到 0~1。目录我习惯这样放:
dataset/ images/ train/ val/ labels/ train/ val/ classes.txt # 一行一个类名,息肉就写 polyp train.txt # 每行一个图片绝对路径 val.txttrain.txt和val.txt里只写图片路径,代码根据路径替换images为labels再找同名 txt。这个约定能省掉大量路径拼接的麻烦,后面换数据集也不用改代码。
3.2 用 Keras 的 Sequence 写数据生成器
息肉数据量通常不大,几百到几千张,一次性读进内存也行,但为了后续能加增强,用Sequence更稳。核心是把图片 resize 到 416×416,同时把标注框按同样比例缩放。
import cv2, numpy as np from tensorflow.keras.utils import Sequence class PolypGenerator(Sequence): def __init__(self, img_paths, input_shape=(416,416), batch_size=8): self.img_paths = img_paths self.input_shape = input_shape self.batch_size = batch_size def __len__(self): return len(self.img_paths) // self.batch_size def __getitem__(self, idx): batch = self.img_paths[idx*self.batch_size:(idx+1)*self.batch_size] images, labels = [], [] for p in batch: img = cv2.imread(p) h, w = img.shape[:2] img = cv2.resize(img, self.input_shape) / 255.0 images.append(img) # 读取同名 label 文件,按缩放比例调整框 label_path = p.replace('images', 'labels').replace('.jpg', '.txt') boxes = [] if os.path.exists(label_path): for line in open(label_path): c, cx, cy, bw, bh = map(float, line.split()) boxes.append([c, cx, cy, bw, bh]) labels.append(boxes) return np.array(images), labels # labels 后续在 loss 里编码逻辑说明:__getitem__里读图、归一化、读标注,返回原始框,编码成网格格式的活交给 loss 前的处理函数,这样生成器职责单一。参数上,input_shape用 416×416 是 YOLOv3 的经典尺寸,显存不够就降到 320×320,但小息肉会更难检。batch_size=8在 8G 显存上比较稳,爆显存就减半。注意cv2.imread读进来是 BGR,如果你用预训练权重,要确认权重训练时的通道顺序,不一致就cv2.cvtColor转一下。
3.3 加载预训练权重与冻结策略
从零训 Darknet-53 在几千张图上基本没戏,常见做法是加载 COCO 预训练权重,冻结骨干前几十层,只训检测头。Keras 里可以按层名匹配加载,或者用model.load_weights(..., by_name=True)。
# 假设 model 已经搭好,权重文件是 darknet53 的 h5 model.load_weights('yolov3_weights.h5', by_name=True, skip_mismatch=True) # 冻结前 100 层,只训后面的检测头 for layer in model.layers[:100]: layer.trainable = False model.compile(optimizer=tf.keras.optimizers.Adam(1e-4), loss=yolo_loss)逻辑说明:by_name=True按层名匹配,skip_mismatch=True跳过形状不匹配的层(比如分类数从 80 改成 1 的那几层)。冻结前 100 层是经验值,Darknet-53 前 100 层基本是底层纹理特征,通用性强,冻住能防过拟合。1e-4的学习率适合微调,训 20 个 epoch 后可以解冻全部层,降到1e-5再训几轮。如果 loss 一直不降,先检查权重有没有真的加载进去——打印几个层的权值范数看看,别问我怎么知道的。
4. 推理与评估:框出来了,怎么判断它靠不靠谱
4.1 非极大值抑制的阈值怎么定
模型输出一堆框,要去重。NMS 的iou_threshold直接决定留下多少框。息肉检测里,同一个息肉被重复框住很常见,阈值太低会误删相邻息肉,太高会留一堆重叠框。
def nms(boxes, scores, iou_threshold=0.45, score_threshold=0.3): # boxes: (N,4) xywh, scores: (N,) keep = [] idx = scores.argsort()[::-1] while len(idx) > 0: i = idx[0] keep.append(i) if len(idx) == 1: break # 计算当前框与剩余框的 IoU ious = compute_iou(boxes[i], boxes[idx[1:]]) idx = idx[1:][ious < iou_threshold] return [k for k in keep if scores[k] > score_threshold]逻辑说明:按分数降序,每次取最高分框,删掉和它 IoU 超阈值的框。参数上,iou_threshold=0.45是通用起点,息肉密集时调到 0.5 以上,避免把相邻息肉删掉。score_threshold=0.3是显示阈值,评估时可以降到 0.05 看召回。注意 NMS 是在每个类别内做的,多类时别混在一起算。
4.2 mAP 计算与验证集抽查
评估不能只看 loss,要看 mAP。息肉通常只有一个类,mAP@0.5 就够用。计算时把验证集所有图的预测框和真实框按类别收集,逐张算 precision-recall 曲线,再积分。
| 指标 | 含义 | 息肉场景参考值 |
|---|---|---|
| mAP@0.5 | IoU=0.5 时的平均精度 | 0.6~0.8 算可用 |
| Recall | 召回率 | 优先保证,漏检代价高 |
| Precision | 精确率 | 0.5 以上可接受 |
我一般会额外抽 20 张验证图,把预测框画出来肉眼过一遍。mAP 高但框歪的情况太常见了,尤其是小息肉,框可能偏到黏膜褶皱上。这一步不能省,指标是黑匣子,眼睛才是后悔药。
4.3 推理脚本与单张图测试
训练完导出模型,写个推理脚本,输入单张图或视频帧,输出带框的图。
def detect_image(model, img_path, input_shape=(416,416), score_th=0.3): img = cv2.imread(img_path) h, w = img.shape[:2] inp = cv2.resize(img, input_shape) / 255.0 inp = np.expand_dims(inp, 0) preds = model.predict(inp) # 三路输出 boxes = decode_predictions(preds, input_shape, (h, w)) boxes = nms(boxes['boxes'], boxes['scores'], score_threshold=score_th) for b in boxes: cv2.rectangle(img, (b[0],b[1]), (b[2],b[3]), (0,255,0), 2) cv2.imwrite('result.jpg', img)逻辑说明:decode_predictions把网格偏移、先验框、sigmoid 后的值还原成原图坐标,这一步最容易写错,建议单独写单元测试,拿一个已知框反推验证。score_th=0.3是显示阈值,实际部署时可以调低到 0.2 提高召回,让医生自己判断。注意 resize 回原图时坐标要乘回缩放比例,别直接用 416 的坐标画框。
5. 避坑与排查:息肉训练里最容易翻车的五件事
5.1 现象:loss 降到某个值就不动了,框全是乱的
原因:先验框和你的数据尺寸不匹配,正样本匹配不上,模型只能学背景。解决:用 k-means 重新聚类标注框,或者至少把 52×52 那路的先验框改小到 (8,8)(12,16)(20,30) 这类。改完重新训,loss 会明显往下走。
5.2 现象:验证集 mAP 还行,但实际图片上框偏移严重
原因:坐标解码时忘了乘回原图缩放比例,或者先验框的宽高顺序写反了。解决:写一个解码测试,构造一个已知网格和先验框,手动算一遍期望坐标,和代码输出对比。宽高顺序在 YOLO 里是 (w,h),别和 (h,w) 搞混。
5.3 现象:训练时显存爆了,batch_size 降到 1 还是爆
原因:输入尺寸 416×416 下,Darknet-53 的中间特征图占显存很大,加上三路输出,8G 卡确实吃紧。解决:把输入降到 320×320,或者用混合精度tf.keras.mixed_precision.set_global_policy('mixed_float16')。再不行就冻结更多层,减少反向传播的显存占用。
5.4 现象:小息肉几乎检不出来,大息肉还行
原因:小目标在 52×52 特征图上只有几个像素,经过多次下采样后信息丢失严重。解决:除了调小先验框,可以在 52×52 那路加一个额外的上采样融合,把更浅层的特征引过来。或者简单点,把输入尺寸提到 608×608,代价是显存和速度。
5.5 现象:模型把反光、气泡也框成息肉
原因:训练集里负样本太少,模型没见过这些干扰。解决:收集一批不含息肉的肠镜帧,作为背景图加入训练,让模型学会区分。另外可以在 loss 里提高lambda_noobj,加大对背景误检的惩罚。数据层面的问题,调参只能缓解,加数据才是根治。
6. 把 Keras-YOLOv3 推到能用的边缘:几个我常做的微调
训练跑通只是起点,要让息肉检测在实际画面里稳一点,我一般会做三件事。第一件是换 CIOU loss 替代原始 MSE 框回归,CIOU 把重叠面积、中心距离、长宽比都考虑进去,小框的回归会稳不少,代码上把wh_loss那段换成 CIOU 计算即可,收敛速度通常快 3~5 个 epoch。第二件是加 Mosaic 增强,把四张图拼成一张,让模型见到更多尺度的息肉组合,这个增强对小目标尤其友好,但要注意拼完的框别超出边界,越界框要裁掉。第三件是推理时用 TTA,水平翻转一次,两次预测做 NMS 融合,召回能提一两个点,代价是推理时间翻倍,看你的帧率要求。
验证模型有没有真的学到东西,我有个笨办法:把验证集里检出的框和真实框的 IoU 分布画出来,如果大部分落在 0.5 以下,说明框的位置学得不好,回去查解码和先验框;如果 IoU 高但漏检多,说明置信度阈值或正样本分配有问题。这个分布图比 mAP 一个数字信息量大得多。
最后说个习惯:每次改完先验框或 loss 权重,我都会先拿 50 张图训 5 个 epoch,看 loss 有没有正常下降,再决定要不要全量跑。全量训一次几小时,用 50 张图 10 分钟就能排除大部分低级错误。息肉检测这个方向,数据质量比模型结构重要得多,标注框画准了,Keras-YOLOv3 这种经典结构完全够用。希望帮到你。
本文还有配套的精品资源,点击获取