轻量,再轻量:当 PicoServer 遇上 SimdPaddleOCR

我的日常工作环境很"普通":需要跑几个 Web API 接口,再挂一两个网页,剩下的就是业务逻辑本身。
坦白说,我不需要一套完整的企业级 Web 框架——我用到的只是路由、一个 POST 接口、一个静态页面。
而 OCR 也是同理:我不需要微服务、不需要 GPU 推理服务器,只想要在 .NET 进程里直接"识别一段文字"。

于是我把两者放在一起:PicoServer(几十 KB 的 Web 胶水库)+ Sdcb.SimdPaddleOCR(嵌入式轻量 OCR),
最终用一个几 MB 的控制台程序,同时做完了 Web API、静态页托管和中文 OCR。


一、我为什么不想用 ASP.NET

不是 ASP.NET 不好,而是对"几个接口 + 几个网页"这个场景,它偏重了:

  • 启动一个空 ASP.NET 项目,哪怕什么都还没写,就已经引入了大量抽象:HostKestrelIServiceCollectionMiddleware 管道……
  • 部署时要面对框架版本、运行环境(IIS / 自托管)、一堆配置。
  • 对"胶水型"需求(把 API 能力缝进一个本就存在的 .NET 程序),这些抽象反而成了负担。

我想要的其实是这样一个东西:HttpListener 一样靠近底层,但又把路由、请求对象、响应写入这些琐碎封装好
PicoServer 就是这个定位——它把 Kestrel、IIS 全部换成系统自带的 HttpListener
只有几十 KB、零第三方依赖、毫秒级启动、全异步。

看最简的 Web API:

var server = new WebAPIServer();
server.AddRoute("/", async (req, rsp) => await rsp.WriteAsync("Hello PicoServer"));
server.StartServer("127.0.0.1");   // 默认端口 8090

req / rsp 就是 HttpListenerRequest / HttpListenerResponse——
HttpListener 本就是 .NET 内置类型,这意味着没有引入任何新的请求/响应模型来学习


二、OCR 也要"轻量"

Sdcb.SimdPaddleOCR(PaddleOCR 的 .NET 移植)同样以轻量为目标:

  • 模型直接内嵌到 NuGet 包里,无需单独部署 ONNX/服务端。
  • 有 tiny / small / medium 多档模型可选,识别精度与速度按需取舍。
  • 底层用 SIMD 加速(自动适配到 AVX/VNNI),纯托管进程内完成,无需额外进程。
  • 对外接口简单:给一张图(BGR8 内存),返回识别文本与每个文本行的位置框。

初始化一个引擎:

using Sdcb.SimdPaddleOCR;
using Sdcb.SimdPaddleOCR.Models.ChineseV6Tiny;

using PaddleOcrAll ocr = await PaddleOcrAll.LoadAsync(ChineseV6TinyModels.Default);
PaddleOcrResult result = ocr.Run(bgrBytes, width, height);
foreach (var line in result.Lines)
    Console.WriteLine(line.Text);

三、两者结合:一个进程 = Web API + 静态页 + OCR

核心思路是 "领域逻辑与框架解耦"

  • OcrEngine:负责模型懒加载与缓存(不依赖任何 Web 框架,ASP.NET 和 PicoServer 都能用同一套);
  • Program.cs:只做"路由 → 调 OcrEngine → 写回 JSON"这件事。

把业务逻辑抽到一个与框架无关的类里,是我在设计里最看重的一点:

// OcrEngine.cs —— 与框架完全无关
public sealed class OcrEngine : IDisposable
{
    private readonly ConcurrentDictionary<string, Lazy<Task<PaddleOcrAll>>> _engines = new();

    public Task<PaddleOcrAll> GetAsync(string model)
        => _engines.GetOrAdd(model, key => new Lazy<Task<PaddleOcrAll>>(
            () => LoadModelAsync(key), LazyThreadSafetyMode.ExecutionAndPublication)).Value;
    // ...
}

ConcurrentDictionary + Lazy 保证并发下每个模型只初始化一次,且无锁。
这样一来,将来哪怕从 PicoServer 换成 ASP.NET,OcrEngine 一行都不用改。

Program.cs 里的路由简单到直白:

OcrEngine engine = new();
var server = new WebAPIServer();

// Web API
server.AddRoute("/api/ocr/models", ListModels);
server.AddRoute("/api/ocr", Recognize);

// 静态页(前端上传图片页)
server.AddRoute("/",         async (_, rsp) => await ServeFileAsync(rsp, "index.html", "text/html; charset=utf-8"));
server.AddRoute("/app.js",   async (_, rsp) => await ServeFileAsync(rsp, "app.js",   "text/javascript; charset=utf-8"));

server.StartServer("127.0.0.1");   // 端口 8090

POST /api/ocr 的请求体就是图片字节,指定 ?model=tiny|small|medium 选择模型:

async Task Recognize(HttpListenerRequest request, HttpListenerResponse response)
{
    byte[] body = await ReadBodyAsync(request);          // 读请求体(图片)
    Image<Bgr24> image = Image.Load<Bgr24>(body);        // 解码 -> BGR8
    byte[] bgr = new byte[checked(image.Width * image.Height * 3)];
    image.CopyPixelDataTo(bgr);

    using PaddleOcrAll ocr = await engine.GetAsync(name);
    PaddleOcrResult result = ocr.Run(bgr, image.Width, image.Height);

    await WriteJsonAsync(response, new OcrResponse(      // 写回 JSON
        result.Text, result.DetectedCount, name, /* ... */));
}

四、实测与体感

演示图(500×500,来自 SimdPaddleOCR 仓库自带的 examples\sample.jpg,已复制到本示例 images\ocr-demo.jpg):

OCR 演示图

实际页面效果:左侧是标出检测框的预览图,右侧是识别文本与 API 调用示例,底部实时显示各阶段耗时:

OCR 网页效果

用示例图 sample.jpg(一张中文产品说明)实跑 tiny 模型:

结果
/api/ocr/models {"Models":["tiny","small","medium"],"Default":"tiny"}
识别文本行数 16 行(中文正常识别)
解码耗时 ~130 ms
OCR 核心耗时 ~588 ms
静态页 / /app.js 200 正常返回

作为对比,同样逻辑若走 ASP.NET,光是项目骨架、管道和宿主启动,代码量和心智负担都会明显上升。
而这里从 dotnet run 到接口可用,几乎是毫秒级。

体感最大的差别:我不必为了两个接口去理解 Program.cs 里那一整套 AddControllers / MapControllers /
依赖注入的服务注册流程。路由即方法,方法即返回,仅此而已。


五、什么时候适合这么干

适合:内部工具、边缘小服务、嵌入式/桌面程序里的 Web 能力、需要"几个接口 + 少量页面"的场景。
不适合:大型商用站点、需要复杂认证授权/中间件生态/完备日志追踪的生产级 Web 应用——这些场景 ASP.NET 的成熟度无可替代。

一句话总结:框架的重量应当与需求的重量匹配。 需求只是一个 OCR API + 一个网页时,
没有理由背上整套 ASP.NET;而 PicoServer 与 SimdPaddleOCR 各自在"恰到好处的轻量"这件事上,配合得很舒服。

完整可运行示例见同一目录下 PicoServer.csprojProgram.csOcrEngine.cs
依赖:PicoServerSdcb.SimdPaddleOCRSdcb.SimdPaddleOCR.Models.ChineseV6TinySixLabors.ImageSharp,全部为 NuGet 包,无需引入源码。


附:两个真实踩过的坑(别忘了)

写这个示例时遇到过两个很典型的问题,记录一下,希望你别再踩:

1. 共享的 OCR 引擎实例千万别 using 释放

一开始我在每个请求里这样写:

using PaddleOcrAll ocr = await engine.GetAsync(name);   // ❌
PaddleOcrResult result = ocr.Run(bgr, width, height);

using 会在请求结束时 Dispose() 这个引擎。而 OcrEngine进程级缓存、被多个请求共享同一个实例——
第一个请求把引擎释放了,第二个请求再 Run() 就抛:

System.ObjectDisposedException: Cannot access a disposed object.
Object name: 'PaddleOcrAll'.

浏览器第一次点没问题,第二次就 500,非常隐蔽。正确做法是复用、不释放,只在进程退出时统一释放:

PaddleOcrAll ocr = await engine.GetAsync(name);          // ✅ 复用缓存实例
PaddleOcrResult result = ocr.Run(bgr, width, height);    // Dispose 交给进程退出时

2. 前端读数据时注意 JSON 字段大小写

前端 JS 写的是 data.elapsedMsdata.detectedCount(小驼峰),但 System.Text.Json
默认序列化出的字段名跟 C# 属性名一致(ElapsedMsDetectedCount,PascalCase)。
字段对不齐,前端就会读到 undefined,然后报 reading 'total' 之类的错误。

WriteJsonAsync 加上 camelCase 命名策略即可:

JsonSerializer.SerializeToUtf8Bytes(value, new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});

两个坑都和数据/租约长的"共享与复用边界"有关——恰好是"模型/实例与请求生命周期解耦"的体现。

posted @ 2026-09-07 11:48  桔子雨  阅读(86)  评论(3)    收藏  举报