
简介基于深度学习的肿瘤辅助诊断系统完整源码面向医疗影像分析开发者与研究者核心功能为肿瘤区域的自动识别与勾画并通过特征提取辅助医生决策。系统覆盖模型构建、TensorRT加速推理、Flask后端服务、Vue前端交互等完整链路适合希望学习医学图像分割与工业级部署流程的读者。压缩包共60个文件包含24个Python脚本、4个Vue组件、5个JavaScript与4个CSS样式文件、3个HTML页面以及配置说明和模型相关文件整体仅3.42MB结构清晰便于按模块查阅。已有81人学习下载源码提供了从数据处理到模型训练、再到前后端联调的全套实现可支撑复现肿瘤影像辅助诊断应用理解深度学习在医疗场景中的落地方式。从代码组织来看模型、服务端与前端分层明确便于按需裁剪和二次开发。1. 医学影像AI落地为什么切入点先是分割而不是分类读一张肺部CT切片医生要在几百帧图像里逐层勾出肿瘤边界一例病例耗掉半小时以上。分类模型能回答“有没有”却回答不了“在哪、多大、长成什么样”而临床决策恰恰需要后者。这套基于深度学习打造的肿瘤辅助诊断系统源码把整条链路串起来了PyTorch训练分割模型、Flask提供推理API、TensorRT做工业级加速、Vue渲染勾画结果。适合三类人看准备做医学影像课题的学生想用Python快速搭一套分割Demo的工程师以及正在评估推理框架选型的技术负责人。下面按模型训练、服务封装、部署加速、前端验证的顺序拆开讲。2. 肿瘤分割的模型底座从U-Net选型到PyTorch训练管线2.1 分割任务的架构选择为什么U-Net仍是首选医学图像分割的常见做法是把任务当作像素级分类输入一张512×512的灰度切片输出同尺寸的mask每个像素属于背景或肿瘤。U-Net这类编码器-解码器结构之所以被广泛使用是因为它在降采样提取高层语义时通过跳跃连接把浅层的边缘纹理信息传回解码器保证了肿瘤边界不会被“磨平”。直接搬ResNet做分割不是不行但最后一层特征图只有原图1/32分辨率上采样回来的边界会明显锯齿化对于需要精确勾画轮廓的辅助诊断场景误差不可接受。在源码的CTAI_model目录里模型构建层应当按照“编码器下采样 解码器上采样 跳跃连接”的主线组织。训练时输入不是原始的RGB图而是单通道灰度图配合归一化、翻转、随机裁剪等数据增强手段做对抗过拟合。这类项目的典型问题是标注数据少因此迁移学习和数据增强的权重会比自然图像分类要高。2.2 数据预处理OpenCV与窗宽窗位配合CT影像的原始灰度值范围是-10243071直接送进网络会让激活函数饱和。常用做法是先做窗宽窗位截断再归一化到[0,1]。所谓窗位是观察中心窗宽是显示范围比如肺部窗口常用的窗位是-600、窗宽1500超出窗口的像素截断到边界值。下面的代码演示了读取图像后如何完成这套流程。import cv2 import numpy as np def preprocess_ct_slice(image_path, window_center-600, window_width1500, target_size(512, 512)): # image_path指向读取到的CT切片先转为float32避免截断时精度丢失 img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE).astype(np.float32) # 窗宽窗位截断低于下限置为下限高于上限置为上限 lower window_center - window_width / 2.0 upper window_center window_width / 2.0 img np.clip(img, lower, upper) # 归一化到[0,1]与训练时一致 img (img - lower) / (upper - lower) # 统一尺寸插值方式选INTER_LINEAR对灰度图更平滑 img_resized cv2.resize(img, target_size, interpolationcv2.INTER_LINEAR) # 扩展成网络需要的[N, C, H, W]形状这里先返回单张 return np.expand_dims(np.expand_dims(img_resized, 0), 0)这段逻辑的关键在截断和归一化的顺序。先clip后归一化才能让有效灰度值铺满整个激活区间否则异常高亮的骨骼区域会压缩软组织的对比度。target_size必须与训练时的输入一致否则推理阶段会出现维度不匹配。OpenCV在这里只负责基础图像I/O和尺寸变换DICOM的解析一般需要pydicom配合从原始dicom文件里读出像素数组后再交给OpenCV做形态学处理例如开运算去掉细小噪声点。2.3 损失函数与训练参数Dice Loss解决前景背景失衡肿瘤区域在整张CT切片里通常只占几个百分点前景像素远少于背景直接用交叉熵会让模型偏向预测全部为背景。Dice Loss从分割结果和真实标注的重叠程度出发不关心像素总数天然缓解样本不均衡。实现上需要注意平滑项加在分子分母同时避免分母为零。import torch import torch.nn as nn class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super(DiceLoss, self).__init__() self.smooth smooth def forward(self, pred, target): # pred形状为[B, C, H, W]softmax后取前景通道 pred torch.softmax(pred, dim1)[:, 1, :, :] target target.float() intersection (pred * target).sum() # Dice系数 2*交集 / (预测面积 真实面积) dice (2.0 * intersection self.smooth) / ( pred.sum() target.sum() self.smooth ) return 1.0 - dice训练时一般取Dice Loss与交叉熵的加权组合交叉熵提供稠密的像素级梯度Dice Loss拉高整体重叠度组合系数常见为0.5/0.5。下面是一组在开源肺结节数据集上比较稳妥的初始参数实际训练时按显存大小下调batch size。参数项设置说明输入尺寸512×512过大显存吃紧过小丢失边界细节Batch Size8单卡16GB可跑显存不足时降到4优化器AdamWweight_decay设为1e-5初始学习率1e-4配合余弦退火或ReduceLROnPlateau损失权重0.5Dice 0.5CE前景比例特别低时Dice权重调到0.7训练过程中的验证指标不要只看acc因为全背景预测也能拿高acc。保存模型时按验证集Dice系数筛选而不是按loss更符合临床勾画目标。源码里的权重目录也会区分训练中途的ckpt和最终导出用的pt后者要剔除优化器状态以减小体积。3. Flask后端推理服务化把模型封装成API3.1 请求处理和推理流程拆分模型训练完要落地第一步是封装成HTTP接口。Flask的轻量特性适合这种单节点推理服务。整套推理流程分四段接收图片并校验、预处理、模型inference、后处理生成结果。后处理最常见的是把模型的概率图通过argmax转成mask再用连通域分析去丢弃面积小于阈值的孤立区域最后计算肿瘤面积、最大直径和圆形度等特征。import base64 import numpy as np import torch from flask import Flask, request, jsonify import cv2 app Flask(__name__) # 全局只加载一次模型避免每个请求重新开销 device torch.device(cuda:0 if torch.cuda.is_available() else cpu) model torch.load(ctai_model/best_model.pt, map_locationdevice) model.eval() # 预处理函数复用2.2中的逻辑这里省略展开 from preprocess import preprocess_ct_slice app.route(/api/predict, methods[POST]) def predict(): file request.files.get(image) if file is None: return jsonify({code: 400, msg: missing image}), 400 img preprocess_ct_slice(file.stream, target_size(512, 512)) img_tensor torch.from_numpy(img).to(device) with torch.no_grad(): output model(img_tensor) prob torch.softmax(output, dim1)[0, 1].cpu().numpy() mask (prob 0.5).astype(np.uint8) # 后处理去掉太小的连通区域保留真实肿瘤块 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(mask, connectivity8) mask_clean np.zeros_like(mask) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] 50: mask_clean[labels i] 1 # 计算肿瘤面积像素数与最小外接圆直径方便前端展示 area_pixel int(mask_clean.sum()) contours, _ cv2.findContours(mask_clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) diameter float(cv2.minEnclosingCircle(contours[0])[1] * 2) if len(contours) 0 else 0.0 # mask用base64传给前端前端解码后叠加到原图 _, buf cv2.imencode(.png, mask_clean * 255) mask_b64 base64.b64encode(buf.tobytes()).decode(utf-8) return jsonify({ code: 0, area_pixel: area_pixel, diameter: round(diameter, 2), mask_base64: mask_b64, shape: [512, 512] })核心在三点模型推理要包在torch.no_grad()里关闭梯度图构建这一步能省下相当可观的显存和计算时间预测概率的阈值0.5不是死的肿瘤区域较小且边缘模糊时抬到0.6能减少假阳性连通域面积阈值50像素对应实际物理面积需要乘上像素间距spacing换算源码里通常会预留一个spacing参数让前端换算成平方毫米。3.2 服务稳定性模型加载与并发控制Flask开发服务器的Werkzeug默认支持多线程但PyTorch的CUDA推理不是完全线程安全的。多人同时请求时可能出现CUDA context冲突或显存分配错乱。常见做法是给推理过程加线程锁或者启动多个worker进程配合排队。工程上更稳的方案是把Flask作为内部服务外层再挂耗时统计与失败重试但这套源码以单节点演示为主加锁足够。from threading import Lock inference_lock Lock() app.route(/api/predict, methods[POST]) def predict_with_lock(): with inference_lock: return predict()这个锁的作用是让同一时刻只有一个请求进入模型推理其余请求排队等待。代价是吞吐量下降但医学影像辅助诊断的特点是单并发、高耗时每个病例的推理本来就要几百毫秒到一秒排队反而比并发竞争更可预期。如果后续要提升吞吐优先考虑TensorRT批量推理而不是无脑堆worker。4. TensorRT工业级部署FP16量化与推理加速的取舍4.1 为什么PyTorch直接推理不够PyTorch的eager模式在算子调度上有额外开销同一份模型用TensorRT重写计算图后推理延迟通常能下降到原来的1/3左右且显存占用更小。TensorRT把网络按kernel融合、层间合并的方式编译成优化后的engine针对NVIDIA显卡做了算子级特化。CTAI_tensorRT目录就是干这件事的它和PyTorch训练结构分离训练完用PyTorch导出权重再用TensorRT构建推理引擎。4.2 基于ONNX的转换流程从PyTorch到TensorRT的标准路径是先导ONNX。导出时固定输入尺寸为512×512避免动态shape带来的额外复杂度代价是输入图片必须resize到固定尺寸边界特征会有一点损失但换来的稳定性对临床端口更值。# 第一步在Python环境里把pt导出为ONNX python -c import torch model torch.load(ctai_model/best_model.pt, map_locationcpu) model.eval() dummy torch.randn(1, 1, 512, 512) torch.onnx.export(model, dummy, ctai_model/ctai.onnx, input_names[input], output_names[output], dynamic_axesNone, opset_version17) # 第二步用TensorRT自带工具构建FP16 engine trtexec --onnxctai_model/ctai.onnx \ --saveEnginectai_model/ctai_fp16.engine \ --fp16 \ --minShapesinput:1x1x512x512 \ --optShapesinput:1x1x512x512 \ --maxShapesinput:1x1x512x512--fp16开启半精度推理对医学图像分割类网络而言大部分卷积对精度损失不敏感但最后一个softmax层的概率分布在FP16下可能出现细微变化。minShapes和maxShapes保持一致就是告诉TensorRT固定输入尺寸跑静态engine。导出ONNX时把opset_version设为17或更高避免低版本opset缺少某些算子支持。4.3 FP16/INT8精度对比与验证手段工业级部署不仅要快还要证明“改完和原模型没差多少”。常见的做法是把验证集切片逐张跑断言计算Dice系数的差值。下表是我在这类分割模型上见过的典型对照具体数值随模型和数据集浮动用来建立量级认知引擎类型推理延迟(ms)显存占用(MB)Dice相对FP32变化PyTorch动态图160~220约2400基准TensorRT FP3290~120约1500无显著变化TensorRT FP1645~70约900-0.1%~-0.5%TensorRT INT830~50约700-0.5%~-2%INT8需要校准数据集且肿瘤边界这种高频细节最容易丢精度。医疗场景宁可信延迟高一点也要优先保Dice所以工程上FP16是折中点。验证时有条件就用Dice没有真实标注就用“勾画结果与原图像的叠加图”人工抽查边界是否断线、是否出现空洞。4.4 TensorRT常见坑尺寸对齐与plugin这层最有价值的经验集中在两处。第一输入数据要对齐到TensorRT要求的内存布局NCHW转NHWC、通道数对齐到4字节边界直接扔raw numpy数组进CPU buffer容易得到全黑输出。第二U-Net里的上采样层如bilinear interpolate在部分老版本TensorRT不受支持需要替换成转置卷积或者在导出ONNX时趁机把上采样改成pytorch原生支持的nearest模式。trtexec构建报错时先检查日志里的“UNSUPPORTED”关键字google该算子加“polygraphy”就能找到替代方案。提示FP16下概率输出为0和1的极端情况比FP32更常见前端显示时不要把0.9999与1.0区分看待直接按概率阈值二分类即可。5. Vue前端联动与分割效果自检5.1 Canvas叠加渲染的接口约定Vue前端负责把用户上传的CT切片展示到界面调用后端接口拿到mask后绘制半透明轮廓。下面是前端调用并渲染的关键片段后端返回的shape字段在这里发挥作用。async function uploadAndDraw(file) { const formData new FormData(); formData.append(image, file); const resp await axios.post(/api/predict, formData); const data resp.data; if (data.code ! 0) return; // 后端返回的是PNG编码的mask用Image先解码再绘制 const maskImg new Image(); maskImg.src data:image/png;base64, data.mask_base64; maskImg.onload () { const canvas document.getElementById(overlay); canvas.width data.shape[0]; canvas.height data.shape[1]; const ctx canvas.getContext(2d); ctx.drawImage(maskImg, 0, 0); // 把mask染成半透明红色再叠到原图上面 ctx.globalCompositeOperation source-atop; ctx.fillStyle rgba(255, 0, 0, 0.4); ctx.fillRect(0, 0, canvas.width, canvas.height); }; }核心坑在canvas的宽高必须与后端shape一致否则mask会拉伸变形。source-atop的合成模式只在已有像素区域着色这样肿瘤区域被高亮背景保持原样。前端拿到area_pixel后可以结合图像的dicom spacing做物理面积换算源码里一般会预留输入框让医生填写切片层厚或像素间距。5.2 用Dice系数验证分割效果界面画得再花哨没有量化验证都不可信。院端落地时最常见的自检方法是拿若干张有专家标注的切片用下面的脚本批量计算Dice系数确认指标与训练集报告接近如果差得远基本可以判断为部署管线引入的预处理不一致。import numpy as np def dice_score(pred_mask, ground_truth_mask): # 输入均为0/1二值数组 pred pred_mask.astype(np.float32).flatten() gt ground_truth_mask.astype(np.float32).flatten() intersection (pred * gt).sum() return (2.0 * intersection 1e-6) / (pred.sum() gt.sum() 1e-6) # 示例加载同一张图的后端结果和人工标注 pred np.load(deploy_output/ct001_mask.npy) gt np.load(ground_truth/ct001_mask.npy) print(fDice: {dice_score(pred, gt):.4f})批量测试时注意numpy数组的dtype和shapemask经过base64传输再解码后有时会自动加上batch维度提前squeeze掉即可。验证通过后这套从训练到后端再到前端展示的链路才算真正闭环。本文还有配套的精品资源点击获取