本文记录了一个医学图像处理平台从架构设计到部署上线的完整技术实践。项目涉及 ONNX 模型推理、Windows 服务托管、Cloudflare Workers 边缘计算以及 VPC 安全网络穿透等多个技术栈,希望对从事医学影像 AI 工程化的读者有所启发。
背景与架构设计
在医学图像处理领域,AI 模型的部署往往面临两个核心挑战:如何安全地暴露内网服务,以及如何高效地处理大体积模型。本文介绍的项目是一个医学图像处理服务,提供五种图像处理能力:
| 处理类型 | 说明 | 计算资源 |
|---|---|---|
| 原图预览 | 原始图像显示 | CPU |
| 传统滤波 | 快速 CPU 处理 | CPU |
| 传统滤波 + 增强 | 快速 CPU 处理 | CPU |
| AI 窗宽窗位预测(WWWL) | 基于 ResNet 微调模型 | GPU |
| AI 滤波 | 基于轻量级模型 | GPU |
当前演示版本出于展示友好和传输效率考虑,采用 8 位深度处理。生产级部署可无缝切换回 16 位深度处理流程。
整体架构
flowchart TB
subgraph Client["🖥️ 用户端"]
Browser["浏览器<br/>(React + TypeScript SPA)"]
end
subgraph Edge["☁️ Cloudflare 边缘"]
Worker["React Router v7 应用<br/>(Cloudflare Worker 运行时)"]
Router["路由 action<br/>处理文件上传"]
VPC_Binding["VPC Service Binding"]
end
subgraph Internal["🏢 内网计算节点"]
Tunnel["Cloudflared Tunnel"]
WebAPI["图像处理服务<br/>(ASP.NET Core WebAPI)"]
CPU["CPU 处理<br/>传统滤波 / 增强"]
GPU["GPU 推理<br/>(ONNX Runtime)"]
SSE["SSE 流式响应"]
end
Browser -->|HTTPS 文件上传| Worker
Worker --> Router
Router --> VPC_Binding
VPC_Binding -->|VPC 加密隧道| Tunnel
Tunnel --> WebAPI
WebAPI --> CPU
WebAPI --> GPU
WebAPI --> SSE
SSE -->|SSE 流式推送| Browser
style Client fill:#e8f4fd,stroke:#2196F3
style Edge fill:#fff3e0,stroke:#FF9800
style Internal fill:#e8f5e9,stroke:#4CAF50
为什么选这个架构?
在方案选型时,我考虑了三种架构:
| 方案 | 优点 | 缺点 | 本项目选择 |
|---|---|---|---|
| 后端直接暴露公网 + API 网关 | 简单直接 | 内网服务需开放端口,安全风险高 | ❌ |
| 自建 VPN + 反向代理 | 完全可控 | 运维复杂,需维护 VPN 网关 | ❌ |
| Cloudflare Tunnels + Workers | 零配置穿透,无需公网 IP,内置安全防护 | 依赖 Cloudflare 生态 | ✅ |
选择 Cloudflare Tunnels 的核心原因是:内网计算节点没有公网 IP,且无法在防火墙开放端口。Tunnels 的零配置穿透特性正好解决了这个环境约束,同时 Workers 提供了边缘计算能力,让前端渲染和路由逻辑更靠近用户。
后端服务实现
ONNX 模型加载:从嵌入资源到推理会话
模型文件的管理是第一个技术挑战。为了简化部署,我们将 .onnx 模型文件嵌入到 .NET 程序集中,而非作为外部文件分发。
为什么选择嵌入资源而不是外部文件?
| 方式 | 优点 | 缺点 | 本项目选择 |
|---|---|---|---|
| 外部文件 | 更新灵活 | 部署时需额外分发模型文件,易丢失 | ❌ |
| 嵌入资源 | 单文件部署,不易丢失 | 更新模型需重新编译 | ✅ |
在演示场景下,部署的便利性优先于更新灵活性,因此选择嵌入资源。
嵌入资源(.csproj)
<ItemGroup>
<EmbeddedResource Include="Models\model_a.onnx">
<LogicalName>Models.model_a.onnx</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Models\model_b.onnx">
<LogicalName>Models.model_b.onnx</LogicalName>
</EmbeddedResource>
</ItemGroup>从嵌入资源加载模型
public static byte[] ReadResourceBytes(string resourceName)
{
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream(resourceName);
if (stream == null)
throw new FileNotFoundException($"找不到嵌入资源: {resourceName}");
using var ms = new MemoryStream();
stream.CopyTo(ms);
return ms.ToArray();
}
// 单例模式加载,避免重复初始化
private static readonly Lazy<InferenceSession> _wwwlSession = new(() =>
{
var modelBytes = ReadResourceBytes("Models.wwwl_model.onnx");
return new InferenceSession(modelBytes);
});
public static InferenceSession WWWLSession => _wwwlSession.Value;关于模型加载失败的处理:当前实现中,若模型加载失败(如文件损坏、ONNX 版本不兼容),应用会在启动时直接抛出异常,由 Windows 服务管理器记录事件日志并停止服务。这样避免了运行时才发现模型不可用的风险,但牺牲了一定的启动容错性。在更严格的场景下,可考虑在加载失败时启用“降级模式”(仅提供 CPU 处理能力),并发送告警通知。
图像格式处理:DICOM 与常规格式的统一
DICOM 文件包含 16 位深的像素数据和丰富的元数据(如窗宽窗位参数),浏览器原生不支持直接渲染。虽然前端可通过 Cornerstone.js 等库实现解析和显示,但本项目采用后端统一转换为 8 位 JPEG 的策略,以简化前端逻辑并保持所有图像处理结果(原图 + 四种处理结果)的渲染方式一致。
格式检测:根据 DICOM 标准,DICM 标记位于文件偏移量 128 处(前 128 字节为文件导言)。为兼容部分省略导言的非标准文件,实现中同时检查偏移量 0 和 128 两处。对于 DICOM 文件,使用专业库解析并应用窗宽窗位映射转换为 8 位灰度;对于常规图片格式(JPG/PNG),直接加载为 8 位灰度。
为什么选择 8 位深度?
| 深度 | 优点 | 缺点 |
|---|---|---|
| 16 位 | 保留完整的医学影像精度 | 前端无法直接渲染,需额外处理 |
| 8 位 | 前端原生支持,传输体积小 | 部分精度信息丢失(演示场景可接受) |
本项目为演示场景,前端显示友好优先,故选择 8 位。生产环境可切换回 16 位。
AI 推理的异步执行与并发控制
AI 模型推理属于计算密集型操作,若在请求线程上同步执行,会阻塞该线程,降低服务吞吐量。本项目采用 Task.Run 将推理工作移至线程池,并通过 SemaphoreSlim 限制并发推理数量,以保护 GPU 资源不被耗尽。需要说明的是,该方案主要解决“请求线程释放”和“GPU 资源保护”问题,单次请求的总耗时并未缩短;对于真正的超时控制和高并发削峰,需要配合超时配置或消息队列等机制。
public class AIService
{
// 并发数设为 2 的依据:
// 测试环境 GPU(NVIDIA T4)显存为 16GB,每个推理任务峰值占用约 4GB
// 2 个并发任务占用约 8GB,留有 8GB 余量
// 若显存更大,可适当提高此值
private static readonly SemaphoreSlim _concurrencyLimiter = new(2, 2);
public async Task<byte[]> PredictAsync(byte[] imageData, CancellationToken ct)
{
// 若等待超过 30 秒仍未获得信号量,则抛出超时异常
if (!await _concurrencyLimiter.WaitAsync(TimeSpan.FromSeconds(30), ct))
{
throw new TimeoutException("AI 推理服务繁忙,请稍后重试");
}
try
{
return await Task.Run(() => RunInference(imageData), ct);
}
finally
{
_concurrencyLimiter.Release();
}
}
}选择此方案的原因:
- 实现简单:无需引入额外的队列组件
- 实时性:推理任务直接关联请求生命周期
- 资源可控:信号量限制并发数,防止 GPU 显存溢出
适用于并发量可控的演示或内部系统。高并发生产环境建议引入消息队列进行削峰填谷。
SSE 流式响应
四个处理任务的耗时差异很大(传统滤波 < 50ms,AI 滤波可能达数秒)。采用 SSE(Server-Sent Events) 配合 Task.WhenAny 实现“谁先完成谁先推送”。
为什么选择 SSE 而不是 WebSocket?
| 协议 | 优点 | 缺点 | 本项目选择 |
|---|---|---|---|
| WebSocket | 双向通信,适合实时交互 | 协议复杂,需维护连接状态 | ❌ |
| SSE | 轻量,基于 HTTP,浏览器原生支持(EventSource API),自动重连 | 单向通信(仅服务端→客户端) | ✅ |
本项目只需要服务端向客户端推送处理结果,不需要客户端向服务端发送消息,SSE 是更简单、更合适的选择。
SSE 实现:
var tasks = new List<(Task<byte[]> Task, string EventType)>
{
(Task.Run(() => ApplyTraditional(data)), "traditional"),
(Task.Run(() => ApplyEnhanced(data)), "enhanced"),
(PredictWWWLAsync(data, ct), "wwwl"),
(ApplyAIFilterAsync(data, ct), "ai_filter")
};
while (tasks.Count > 0)
{
var completed = await Task.WhenAny(tasks.Select(t => t.Task));
var entry = tasks.First(t => t.Task == completed);
tasks.Remove(entry);
try
{
var result = await entry.Task;
await SendSseEvent(entry.EventType, result);
}
catch (Exception ex)
{
// 单个任务失败时,推送错误事件,不影响其他任务
await SendSseError(entry.EventType, ex.Message);
}
}前端通过 fetch + ReadableStream 接收并解析 SSE 事件,每收到一个结果即更新对应图片区域。
图像水印
在输出图像上叠加水印用于版权保护。使用 SixLabors.ImageSharp.Drawing 实现半透明旋转文字水印。
public static void AddTextWatermark(this Image image, string text,
float opacity = 0.4f, float angleDegrees = -45)
{
// 确保图像为支持透明度的格式
// 使用 SolidBrush 包裹带透明度的颜色
// 应用旋转变换后绘制文字
}Windows 服务托管
将 WebAPI 注册为 Windows 服务,实现开机自启和后台常驻运行:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddWindowsService(options =>
{
options.ServiceName = "ImageProcessingService";
});
// ... 注册其他服务为什么选择 Windows 服务而不是 IIS 托管?
| 方式 | 优点 | 缺点 | 本项目选择 |
|---|---|---|---|
| IIS | 管理界面完善,支持热更新 | 开销较大,配置复杂 | ❌ |
| Windows 服务 | 轻量,开机自启,资源占用小 | 需自行管理日志 | ✅ |
本项目为纯后台 API 服务,不需要 IIS 的复杂功能,Windows 服务更轻量。
安装服务
sc create "ImageProcessingService" binPath= "C:\Services\YourApp.exe" start= delayed-auto使用 delayed-auto(延迟启动)而非 auto,是因为服务依赖 GPU 驱动初始化,延迟启动可避免驱动未就绪导致的启动失败。
前端与 Worker 集成
前端文件上传与 SSE 接收
前端使用 React + TypeScript,通过 <form> 提交 + 原生 fetch 接收 SSE 流。
为什么不用 useFetcher?
React Router 的 useFetcher 虽然能方便地提交表单,但它不暴露原始的 Response 对象,因此无法访问 response.body 流。而我们需要逐块读取 SSE 数据并实时更新界面,所以必须使用原生 fetch。
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const response = await fetch('/', { method: 'POST', body: formData });
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
// 逐块读取 SSE 事件,解析后更新界面
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 解析 event: 和 data: 行
// 调用 setState 更新对应图片
}
};服务端 Action( VPC 调用)
路由的 action 在 Cloudflare Worker 中执行,通过 VPC Binding 调用内网服务:
export async function action({ request, context }: ActionFunctionArgs) {
const { env } = context.cloudflare;
// 通过 VPC Service Binding 转发请求
const response = await env.VPC_SERVICE.fetch('http://internal/api/process', {
method: 'POST',
body: request.body,
});
return response; // 透传 SSE 流
}为什么选择 Cloudflare Tunnels 而不是自建 VPN?
| 方案 | 优点 | 缺点 | 本项目选择 |
|---|---|---|---|
| 自建 VPN(如 WireGuard) | 完全可控,无第三方依赖 | 需维护 VPN 网关、证书、路由表 | ❌ |
| Cloudflare Tunnels | 零配置穿透,自带加密,无需公网 IP | 依赖 Cloudflare 服务 | ✅ |
本项目内网计算节点无公网 IP,且无法开放防火墙端口,Cloudflare Tunnels 是唯一能零配置穿透的方案。
VPC Service 配置
- 创建 Tunnel:在 Cloudflare Workers VPC 仪表盘创建 Tunnel,在内网节点安装并运行
cloudflared。 - 创建 VPC Service:注册内网服务,指定内网 IP 和端口。
- 绑定到 Worker:在
wrangler.jsonc中配置vpc_services字段。
踩坑总结
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 水印透明度不生效 | 灰度图像不支持透明度,或未使用正确的 Brush 类型 | 转换为 RGBA 格式并使用 SolidBrush |
| Windows 服务找不到配置文件 | 默认工作目录是 system32 | 设置 ContentRootPath = AppContext.BaseDirectory |
| 本地开发 VPC 调用失败 | Miniflare 对 VPC 支持有限 | 部署到生产测试,或本地环境变量绕过 |
| 图像处理库许可证错误 | 新版本需商业许可证 | 降级到社区版本 |
写在最后
本文完整记录了一个医学图像处理平台从零到一的构建过程。核心经验可以总结为三点:
- 架构分层:前端展示、路由处理、算力密集型任务各司其职,清晰分离。
- 异步优先:AI 推理等耗时操作必须异步化,通过信号量控制并发,避免阻塞。
- 安全穿透:利用 VPC 隧道,内网服务无需暴露公网 IP 即可被安全调用。
免责声明:本文内容及代码示例仅用于技术交流与思路探讨,请勿直接用于生产环境。实际部署前请根据业务场景进行充分的性能测试、安全评估和压力测试,使用本文方案所产生的一切风险由使用者自行承担。