
简介面向第七届泰迪杯数据挖掘挑战赛B题“直肠癌肿瘤分割”的完整参赛资料包。适合备战数据挖掘、医学影像处理类竞赛的高校学生与科研人员。内容覆盖数据预处理、ROI区域提取、纹理特征构建、SVM分类与U-Net深度学习分割等多个环节提供DataPretreat.py、Extract_ROI.py、SVM_Texture.py、Unet_2gpu.py等可直接运行的Python脚本并附有临床数据表、预测结果图、最优模型权重HDF5文件以及README说明文档。压缩包共24个文件主要类型为Python脚本、PNG分割结果图、CSV临床数据、Markdown说明等整体约62.01MB目录结构清晰方便按流程学习。已有170人学习下载。借助该方案可直观了解两种主流分割建模路线掌握竞赛项目的数据组织与模型评估方法适合对照复现、二次改进或作为课题设计的基础框架。1. 泰迪杯B题直肠癌肿瘤分割考的不只是U-Net拿到第七届泰迪杯B题这套代码时我第一反应是“终于见到一份不靠单一神经网络硬撑的医学影像方案了”。它把U-Net分割和SVM纹理分类放在同一个项目里再搭配HDF5数据管道解决内存瓶颈明显是冲着比赛里“数据挖掘”四个字去的。对准备参加数据挖掘挑战赛的大学生来说这份代码可以拆成三条主线预处理如何提ROI、HDF5如何喂数据、双GPU模型如何训练与预测。适合想复现医学分割实验、又不想只看理论的人。如果只是跑通FinalModel.h5那你会发现真正的比赛难点全在数据管线和后处理上。2. 数据预处理与ROI提取从原始临床数据到干净训练集2.1 数据挖掘的第一步DataPretreat.py 和 DataExtraction.py 的分工这份代码包里没有把原始DICOM目录放进来但通过 DataPretreat.py 和 DataExtraction.py 可以反推当时的流程前者负责把原始影像读取出来做窗宽窗位归一化同时把临床数据.csv里的病人年龄、性别、肿瘤分期等字段合并进来后者则负责从归一化后的三维体数据里按切片抽取有标注的2D图像生成image和mask配对。这里有一个关键点直肠癌CT图像的HU值范围通常落在-150到250之间直接使用原始像素值会让网络训练时梯度抖动。我一般会用上下界截断再加min-max归一化而不是简单除以255。下面这段代码是DataPretreat.py里最常见的处理方式import numpy as np def normalize_ct(volume, lower-150, upper250): # 截断到直肠癌区域常见的HU范围 volume np.clip(volume, lower, upper) # 线性映射到[0, 1] volume (volume - lower) / (upper - lower) return volume.astype(np.float32)逻辑说明clip操作在训练阶段很关键它会把骨骼和金属伪影这类高亮区域直接压掉避免网络注意力被无关的高密度结构带偏。astype换成float32而不是float64是因为后续要写进HDF5float32能在保持精度的同时让文件体积减半。DataExtraction.py 的作用则是做切片级过滤。一个患者的CT可能有两三百张切片但只有几十张包含肿瘤标注。如果全部送进训练背景切片会占据绝大多数模型很容易退化成“全都预测为背景”。常见做法是计算每张mask的肿瘤面积低于某个阈值比如10像素的切片直接丢弃或者单独放入难例集。2.2 Extract_ROI.py用mask反推肿瘤包围盒Extract_ROI.py 在管道里属于中间一步。它的目的是把512x512或更大分辨率的CT切片裁剪到以肿瘤中心为原点的小尺寸区域。这样做有两个直接收益训练时显存占用更稳定因为传入U-Net的输入尺寸是固定的推理时减少背景干扰让分割结果更聚焦。我常见的做法是根据mask的非零区域计算包围盒然后外扩一圈margin再把图像和mask同步裁剪。注意同步这两个字很多人只裁剪了image没裁剪mask导致标签错位import numpy as np def extract_roi(image, mask, margin20): image: (H, W) 已归一化CT切片 mask: (H, W) 0/1 标签 margin: 肿瘤包围盒外扩像素数防止肿瘤紧贴边缘 ys, xs np.where(mask 0) if len(xs) 0: return None, None x_min, x_max int(xs.min()), int(xs.max()) y_min, y_max int(ys.min()), int(ys.max()) # 外扩并夹紧到图像边界 x_min max(0, x_min - margin) x_max min(image.shape[1], x_max margin) y_min max(0, y_min - margin) y_max min(image.shape[0], y_max margin) image_roi image[y_min:y_max, x_min:x_max] mask_roi mask[y_min:y_max, x_min:x_max] return image_roi, mask_roi参数说明margin大小直接影响模型。调得太小肿瘤可能被切破调得太大又会让背景比例反弹。比赛中常见的是15到25之间你可以用验证集Dice分数去扫一遍。另外如果肿瘤区域有多个连通域比如直肠壁两侧都有病灶上面这个简单的min-max包围盒会包含中间的大片背景这时候我一般会先用skimage.measure.label把连通域拆开只保留面积最大的那个区域来算包围盒。2.3 数据增强与样本均衡这份代码里没有单独列出增强文件通常在训练时通过HDF5DatasetGenerator在线做。医学图像增强要谨慎不是所有augmentation都适合肿瘤分割。比如随机裁剪很危险可能把肿瘤完全裁掉而水平翻转和90度旋转是安全的因为直肠癌在CT横断位上没有固定的左右方向。我一般会加上小角度旋转±15度和弹性形变但弹性形变对mask要使用相同变换参数。增强方式是否推荐原因水平翻转推荐解剖结构左右对称标签无歧义90度旋转推荐横断位图像各向同性旋转后仍是有效CT视图小角度旋转谨慎角度过大采样点偏移肿瘤边缘会模糊随机裁剪不推荐容易裁掉小肿瘤导致训练样本失效亮度对比度扰动谨慎归一化后扰动范围要控制在0.9~1.1过大影响HU语义还有一个容易被忽略的问题mask的插值和image不一样。对image用random_brightness直接改像素没问题但裁剪和旋转时如果用了cv2.INTER_LINEAR去处理mask二值标签会变成带中间灰度的浮点图再喂给模型时损失函数会算错。正确做法是mask统一用cv2.INTER_NEAREST并且最后围成0.5阈值再二值化。3. HDF5数据管道别再用ImageGenerator加载医学图像了3.1 为什么选HDF5而不把图片放在目录里很多做自然图像的朋友习惯直接建一个train/和mask/目录然后用ImageDataGenerator.flow_from_directory读图。但在直肠癌分割这个场景里同一病人的切片彼此相似如果每次epoch都从磁盘随机读文件单张图读取加解码的时间会轻松超过GPU一个step的计算时间训练完全在等IO。另外图片数量动辄上万张文件系统inode压力和随机寻址时间都成了瓶颈。这份代码用HDF5方案就是为了解决这个问题。HDF5把大量数组存进一个二进制文件里训练时通过offset直接读取切片数据不需要经过文件系统目录查找。HDF5DatasetWriter.py负责把预处理后的image和mask写入.h5文件HDF5DatasetGenerator.py则负责按batch读取并做在线增强。这套思路其实就是把“文件级随机访问”换成“内存级随机访问”。3.2 HDF5DatasetWriter.py把训练集打包成单一数据库写入端只需要做一件事将已经抽好的图像块写进h5py的Dataset。这里需要预先知道总样本数所以代码里一般会传入dims参数。下面是一个简化的写入器import h5py import numpy as np class HDF5DatasetWriter: def __init__(self, dims, output_path, keyimages, compressionlzf): self.db h5py.File(output_path, w) self.data self.db.create_dataset( key, dims, dtypefloat32, compressioncompression ) self.index 0 def add(self, rows): # rows: (batch, H, W) 已经归一化的图像 n rows.shape[0] self.data[self.index:self.index n] rows[:] self.index n def close(self): self.db.close()逻辑说明这里的关键是compressionlzf。lzf压缩比不高但解压速度极快适合训练时随机读取如果你选gzip文件会小一些但每次random access都要解压一个chunkGPU等待时间明显上升。写入时建议按batch添加而不是一行一行写因为h5py在连续地址写入大块数据时吞吐更高。另外image和mask可以放在同一个文件里分别用images和masks两个key也可以像代码包中一样把mask单独存成labelskeydtype用uint8而不是float32能省四倍空间。3.3 HDF5DatasetGenerator.py在线生成批量数据有了HDF5文件接下来就是如何把它喂给模型。Keras的fit_generator已经被model.fit替换但生成器逻辑不变。核心是维护一个索引列表每个epoch重新打乱然后按batch用numpy切片读取数据。下面这段是生成器的核心部分import numpy as np class HDF5DatasetGenerator: def __init__(self, db_path, batch_size32, preprocessNone): import h5py self.db h5py.File(db_path, r) self.images self.db[images] self.masks self.db[masks] self.batch_size batch_size self.num_images len(self.images) self.preprocess preprocess self.indexes np.arange(self.num_images) def generate(self, epochs50): while True: np.random.shuffle(self.indexes) for i in range(0, self.num_images, self.batch_size): batch_idx self.indexes[i:i self.batch_size] batch_images self.images[batch_idx] batch_masks self.masks[batch_idx] # 在线增强随机水平翻转 for j in range(len(batch_images)): if np.random.rand() 0.5: batch_images[j] np.fliplr(batch_images[j]) batch_masks[j] np.fliplr(batch_masks[j]) yield batch_images, batch_masks参数说明db_path是写入端生成的文件路径batch_size建议设成模型输入batch的整数倍如果用了双GPU训练这里要设为单GPU batch_size的2倍。preprocess是预留的钩子用来在每个batch里做标准化或数据增强。注意生成器是死循环epochs参数实际是外部控制的不需要在循环里break。另一个细节是np.random.shuffle直接作用于indexes不要复制否则打乱的是副本每个epoch读取顺序一样模型会记住数据顺序。这里还要提一个踩坑点HDF5文件打开后默认是读锁如果训练时同时有验证集生成器和训练集生成器打开同一个文件会出现并发读冲突。常见解决方法是把训练集和验证集写成两个独立的h5文件不要共用。代码包里的HDF5DatasetWriter和Generator设计正好是“一写一读”没有考虑跨进程所以你在复现时要注意这一点。4. 模型与训练Unet_2gpu.py 和 SVM_Texture.py 双路线怎么配合4.1 U-Net架构与双GPU训练参数Unet_2gpu.py 这个文件名直白地告诉你它是用双GPU训练的分割模型。医学图像分割里的U-Net已经算是标准结构编码器做下采样提取语义解码器通过skip connection恢复空间细节。这份代码里的U-Net大概率是基于Keras官方的Unet实现改造的输入尺寸是256x256或128x128输出是单通道的sigmoid概率图。双GPU训练在Keras中一般用multi_gpu_model包装但这里有个容易出错的batch_size问题。单卡批量是4双卡并行时总batch_size要变成8否则每个GPU只拿到4个样本的一半梯度更新反而比单卡更不稳定。下面这段是Unet_2gpu.py中常见的配置逻辑from keras.models import Model from keras.utils import multi_gpu_model # 原始单卡模型 unet build_unet(input_shape(256, 256, 1)) # 双卡并行模型 parallel_model multi_gpu_model(unet, gpus2) parallel_model.compile( optimizeradam, lossbinary_crossentropy, metrics[accuracy] )逻辑说明multi_gpu_model把batch切分成2份每个GPU计算一份梯度然后同步合并。所以优化器参数的学习率可以保持单卡的标准值不需要像分布式数据并行那样线性放大但batch_size一定要跟着GPU数翻倍。这里用的loss是binary_crossentropy如果你发现肿瘤区域总是漏检可以换成dice_loss。但换loss之前要确认HDF5里的mask已经严格二值化否则dice计算时会把灰边界当成误差。4.2 SVM纹理特征为什么分割之外还要做分类这套方案里单独出现了SVM_Texture.py这在很多深度学习队伍中是看不到的。结合比赛题目中“数据挖掘”这个定位SVM纹理分类应该是作为一个辅助验证分支存在的。常见的做法是先用U-Net分割出肿瘤区域然后对每个区域计算纹理特征比如灰度共生矩阵GLCM的对比度、能量、熵最后用支持向量机判断这个区域是真肿瘤还是假阳性。用SVM的另一个原因在于U-Net的分割结果往往是连续的区域而临床定量分析需要区分肿瘤的离散等级或分期这个时候小样本下SVM比重新训练一个分类CNN更稳。SVM_Texture.py里大概率是先把ROI区域归一化到固定大小然后提取特征标准化后用RBF核SVM训练。代码如下from sklearn import svm from sklearn.preprocessing import StandardScaler from skimage import feature def extract_texture_features(roi_image): # 使用局部二值模式LBP提取纹理直方图 lbp feature.local_binary_pattern(roi_image, P8, R1, methoduniform) hist, _ np.histogram(lbp, bins10, range(0, 10), densityTrue) return hist # 特征标准化 RBF SVM scaler StandardScaler() X_train scaler.fit_transform(features_train) X_test scaler.transform(features_test) clf svm.SVC(kernelrbf, C1.0, class_weightbalanced, probabilityTrue) clf.fit(X_train, y_train)逻辑说明local_binary_pattern把每个像素的局部邻域编码成数值再统计直方图能捕捉CT纹理的粗糙程度。与U-Net不同SVM需要的是区域级特征不是逐像素输出所以它更适合做“这个区域是不是肿瘤”的二分类而不是“每个像素是不是肿瘤”的分割任务。在比赛中通常把U-Net预测出的连通域一个个裁出来再用SVM过滤掉面积过小或纹理异常的噪点整体Dice会提高两到三个点。4.3 Predict.py 预测流程与后处理Predict.py 负责加载FinalModel.h5对新病例做推理并输出分割mask。由于训练时做了ROI裁剪推理时也必须执行同样的裁剪流程只在模型输入处做一次resize然后把输出resize回原图尺寸。很多人预测效果差不是模型问题而是推理时的预处理和训练时不一致。下面是Predict.py里常见的预测代码import cv2 import numpy as np from keras.models import load_model model load_model(FinalModel.h5, compileFalse) def predict_slice(image): # image: 原始512x512已归一化CT切片 roi, bbox extract_roi_with_padding(image) roi_resized cv2.resize(roi, (256, 256), interpolationcv2.INTER_LINEAR) # 增加batch和channel维度 input_tensor roi_resized[np.newaxis, :, :, np.newaxis].astype(float32) pred model.predict(input_tensor)[0, :, :, 0] pred_resized cv2.resize(pred, (bbox[2]-bbox[0], bbox[3]-bbox[1]), interpolationcv2.INTER_LINEAR) # 全图mask初始为0把ROI区域的预测贴回去 full_mask np.zeros((image.shape[0], image.shape[1]), dtypeuint8) full_mask[bbox[1]:bbox[3], bbox[0]:bbox[2]] (pred_resized 0.5).astype(uint8) return full_mask参数说明extract_roi_with_padding在代码包里可能就是Extract_ROI.py的逻辑但它加了padding处理边界情况。这里有一个我在比赛中踩过的坑预测时如果用cv2.resize把ROI从128x128拉伸到256x256相当于改变了肿瘤的实际尺寸导致预测结果边缘出现锯齿。如果训练时ROI尺寸不统一那么预测时应把所有ROI先以短边为基准做零填充再统一resize而不是直接拉伸。代码中的bbox是原图坐标最后把预测mask贴回去时用的就是它。另外注意load_model(compileFalse)如果FinalModel.h5里保存了自定义损失函数或自定义层直接load会报错。这时需要在加载前手动传入自定义对象比如custom_objects{dice_loss: dice_loss}。我一般倾向于用compileFalse加载后手动compile这样能避免版本兼容问题。5. 结果对比与实用技巧从ResultComp目录里找突破口5.1 ResultComp里的结果图怎么读ResultComp目录下放了1001.png到1011.png这些文件从比赛背景看每个数字对应一个病例编号图里通常拼接了原图、金标准mask和预测mask方便肉眼评估。我自己做医学影像项目时会把三张图拼成横向长图用不同颜色叠加预测和真实标签这样能快速看到漏提区域和过分割区域。如果你打开看到的是单张灰度图那可能是单独输出的预测mask需要自己原图对比。5.2 用Dice和IoU而不是accuracy来评估医学分割里背景像素占比很大通常超过90%所以accuracy指标几乎没有参考价值。比赛打分一般用Dice系数和IoU。代码包里没有单独列出评估脚本建议自己补一段快速计算Dice的代码def dice_coef(y_true, y_pred, smooth1e-5): y_true y_true.flatten() y_pred y_pred.flatten() intersection np.sum(y_true * y_pred) return (2.0 * intersection smooth) / (np.sum(y_true) np.sum(y_pred) smooth)参数说明smooth是平滑项防止两个mask全零时除零。注意这里要求y_true和y_pred都是二值数组。如果你直接拿sigmoid输出的概率值和0/1标签算加了y_true * y_pred会因为概率多在小数范围而得到非零但明显偏小的Dice所以一定要先threshold。5.3 我调模型时一定会做的两件事第一件事是固定一个病例做debug。每次训练前从验证集里挑一个肿瘤面积居中的病例单独存成一组原始图和mask。训练过程中每隔几个epoch跑一次Predict.py把输出贴进去看。这样比盯着loss曲线更直观能发现像“所有mask都偏左”这种loss看不出来的结构性问题。第二件事是检查数据管道有没有shuffle。HDF5DatasetGenerator如果忘记每个epoch重置indexes或者在generate开头只shuffle了一次那么整个训练过程大约等价于先学所有含肿瘤的切片再学所有背景切片最终模型会不断震荡。排查方法很简单在生成器里记录每次输出的label均值如果每个batch的肿瘤占比变化很不均匀就说明shuffle逻辑有问题。另外FinalModel.h5并不是唯一的选择。如果你的服务器只有单卡Unet_2gpu.py里的模型结构完全可以单卡训练稍微降低batch_size即可。代码包里的SVM_Texture.py更多是锦上添花如果时间不够优先保证U-Net分割结果能正确生成肿瘤区域再考虑用SVM做假阳性的过滤。我个人的经验是这两条路线的结合点应该在ROI级别而不是像素级别——先分割再逐个区域做纹理分类能兼顾精度和可解释性。本文还有配套的精品资源点击获取