SimdGcdrNet:纯 C# 的文档照片去阴影,3080 Ti 上 9.6 ms 一张,浏览器里也能跑

手机拍文档是个很日常的事:合同、书页、孩子的手抄报,拍完一看,半边是手的影子,另一边是背面透过来的字。扫描 App 里那个"增强"按钮做的就是这件事,背后一般是个神经网络。

今天发布的 SimdGcdrNet 就是这样一个网络的纯 C# 推理库。模型是 GCDRNet(Appearance Enhancement for Camera-captured Document Images in the Wild ,IEEE TAI 2023),推理引擎是我自己写的------不依赖 onnxruntime,也不依赖任何第三方推理框架,和上个月的 SimdPaddleOCR 是一个路数。仓库语言统计依然是 100% C#。

先看效果。左边是原图,一张被手挡住一半光的手抄报,右边是 SimdGcdrNet 的输出:

powershell 复制代码
dotnet add package Sdcb.SimdGcdrNet
dotnet add package Sdcb.SimdGcdrNet.Models
csharp 复制代码
using Sdcb.SimdGcdrNet;
using Sdcb.SimdGcdrNet.Models;

using GcdrEnhancer enh = GcdrEnhancer.FromBytes(GcdrModels.GcNet, GcdrModels.DrNet, GcdrBackend.Auto);
byte[] dst = enh.Enhance(bgr, width: 1600, height: 1152);   // BGR24,HWC

几个关键数字:

  • RTX 3080 Ti 上一张 1600×1152 的图,Vulkan 端到端(含上传下载和前后处理)约 9.6 ms;同一张卡上 PyTorch 纯推理 30.5 ms,加上前后处理 57.2 ms。
  • 49 张 RealDAE 真实文档语料,Vulkan 平均 28 ms/张,CPU AVX2 722 ms/张。
  • 同一套代码编译成 WebAssembly,在浏览器里多线程跑,同一语料 696 ms/张。可以直接去 sdcb.ai/simdgcdrnet/playground 试,图片不会上传。
  • 支持 Windows / Linux / macOS / Android / 浏览器,CPU 有 AVX-512、AVX2、NEON、WASM SIMD 四套手写内核,GPU 有 Vulkan 和 Metal。

从第一个提交到 1.0.0 发布,一共 80 个提交,用了不到一周。下面按"效果 → 怎么用 → 它是什么 → 性能 → 浏览器 → 示例"的顺序讲。

效果:不只是去阴影

GCDRNet 处理的是三类退化:光照不均(color)、阴影(shadow)和透印(bleed-through,背面的字透过纸张显出来)。上面那张手抄报是阴影,下面这张是透印,来自论文的 RealDAE 数据集:

透印对 OCR 的杀伤力比阴影大得多,因为背面的字是"真的字",只是镜像而且淡一点,OCR 模型很乐意去认。仓库里有一个 examples/PaddleOCR.Console 示例,先用 SimdGcdrNet 增强,再分别对原图和增强图跑 SimdPaddleOCR,两次 OCR 用的是同一块解码后的 RGBA 内存:

复制代码
ocr-example.jpg: 1056x1586
enhanced in 194 ms (first call includes plan compilation)

===== original =====
4N4998
第二篇历年真题汇编
43
3.【答案】D
【解析】(1)本题应采用直接法计算应纳税所得额;(2)免税投资收益80万元不计人,各
......(正面的 12 行)
税务机关报送预缴企业所得税纳税申报表,预缴税款。员量点业
日H7:2 XRS03 IF
羊规方粉调
VXIF研Z断G
上型解5证5款00
5颗瑞求担单易期深售圳品国额冻上创零道中
......(背面透印产生的 40 多行)
制武卡知本宝方爆印物出4U明限君喂员集不校 0
 网上课堂www.dongao.com

===== enhanced =====
第三篇历年真题汇编
43
3.【答案】D
【解析】(1)本题应采用直接法计算应纳税所得额:(2)免税投资收益80万元不计人,各
项不得扣除的项目(5万元、30万元、10万元、50万元)不减除,甲公司2014年度企业所
得税应纳税所得额 =5000 + 20+300-3500 =1820(万元)。
4.【答案】ABCD
【解析】(1)选项 AD:企业所得税按年计征,分月或者分季预缴,年终汇算清缴,多退少
补。纳税年度自公历1月1日起至12月31日止。(2)选项B:除税收法律、行政法规另有
规定外,居民企业以"企业登记注册地"为纳税地点;但登记注册地在境外的,以实际管
理机构所在地为纳税地点。(3)选项C:企业应当自月份或者季度终了之日起15日内,向
税务机关报送预缴企业所得税纳税申报表,预缴税款。
0网上课堂
www.dongao.com

             lines   chars
original        56     762
enhanced        14     360

原图识别出 56 行、762 个字符,正面那 12 行之外全是背面透过来的乱码,连第一行正面文字末尾都被粘上了"员量点业"四个字;增强之后是干干净净的 14 行。这也是我做这个库最直接的动机:给 OCR 管线前面加一级预处理。

快速上手

需要 .NET 10,两个包都只面向 net10.0:

包 内容
Sdcb.SimdGcdrNet 推理引擎(CPU / Vulkan / Metal),附带 4 个 LINQPad 示例
Sdcb.SimdGcdrNet.Models GC-Net + DR-Net 两个 ONNX 模型(约 21 MB),嵌入资源,无依赖,版本独立于引擎

模型包是独立的,GcdrModels.GcNet / GcdrModels.DrNet 返回的是程序集映像上的 ReadOnlySpan<byte>,零拷贝,不解压、不写临时文件。不想用 NuGet 上的模型,GcdrEnhancer.FromFiles(...) / FromStreams(...) 自己传。

API 只接收像素内存,不强迫你引入任何图像库。四种 8 位交错格式都支持,输出和输入同格式,alpha 原样拷回,支持任意行 stride,也支持原地增强:

csharp 复制代码
// ImageSharp:Rgba32,原地
enh.Enhance(span, w, h, srcStride: w * 4, dst: span, dstStride: w * 4, GcdrPixelFormat.Rgba32);

// OpenCvSharp:Mat 的 Step() 当 stride
enh.Enhance(src, mat.Width, mat.Height, (int)mat.Step(), dst, (int)mat.Step(), GcdrPixelFormat.Bgr24);
格式 常见来源
Bgr24 OpenCV Mat、GDI+ 24bpp
Rgb24 一般的 RGB 缓冲
Bgra32 SkiaSharp、GDI+ 32bpp、WPF
Rgba32 ImageSharp、浏览器 canvas

后端选择:GcdrBackend.Auto 在 macOS Apple Silicon 上优先 Metal,其它平台有可用 GPU 就 Vulkan,否则 CPU;也可以显式指定 Cpu(默认)/ Vulkan / Metal。GcdrEnhancer 是线程安全的,一个实例在服务里共用就行:CPU 按尺寸建 session 池,Vulkan 的 worker 与尺寸无关。

Native AOT 可以用,没有 trim / AOT 警告。x64 上记得在可执行项目里设 <IlcInstructionSet>x86-64-v3</IlcInstructionSet>,否则 ILC 按 SSE 基线编译,Avx2.IsSupported 被折叠成 false,CPU 后端会悄悄落到 128 位 Vector<float> 档。

它是什么

模型

GCDRNet 是两级串联:

  1. GC-Net 看原图 I_s,预测一张逐像素的乘性光照/阴影图 I_sm。
  2. 除法校正 :I_GC = clamp(I_s / I_sm, 0, 1),把阴影"除掉"。
  3. DR-Net 吃 6 通道的 [I_s; I_GC],输出最终的增强图。

两个网络都是 U-Net 风格的卷积网络,中间夹着 LayerNorm 和 GELU,上采样是双线性插值加 skip 相加。输入会被 pad 到 32 的倍数(上、左复制填充),输出再裁回去,和参考 Python 实现一致。

引擎

和 SimdPaddleOCR 一样,这个库自带一个"编译式"推理引擎,不是算子解释器:

  • ONNX 读取:一个最小的 protobuf 读取器,只读 ONNX 用到的那几种字段。
  • 图编译:按输入尺寸做 shape 特化、常量折叠、算子融合(conv+激活、conv+maxpool、UpHead 整行融合等),然后给所有中间张量做 arena 规划------按生存期贪心排布,达到"同时存活字节之和"的下界。GC 和 DR 两张图共用一块 arena,因为 GC 跑完 DR 才开始。
  • CPU 内核 :NHWC 布局,手写 x64 AVX-512 / AVX2+FMA、arm64 NEON、browser-wasm PackedSimd 四套,往下是 Vector<float> 和标量兜底。按 IsSupported 自动分发,所以用 DOTNET_EnableAVX2=0 这类运行时开关就能强制降档,CI 里就是这么测各档位的。3×3 卷积走 Winograd F(4,3),1×1 走 GEMM,深度卷积单独一套。
  • Vulkan 后端 :push descriptor、ReBAR 感知的 staging、整张图录成一个命令缓冲。bgr2nchw → GC → div6 → DR → nchw2bgr 是一条融合管线,中间不回 CPU。有 VK_KHR_cooperative_matrix 的显卡(NVIDIA、Intel Xe2)卷积走张量核的隐式 GEMM;没有的(Adreno)走寄存器分块的直接卷积。
  • Metal 后端:纯 C# 通过 ObjC runtime P/Invoke 调 Metal,没有 Swift / Objective-C++ 胶水。fp16 存储 + fp32 计算,simdgroup-MMA 隐式 GEMM 卷积。

正确性靠 onnxruntime 生成的 golden 数据兜着:CPU 与 oracle 的差 ≤ 1e-4,GPU ≤ 5e-4,最终 u8 输出误差 ≤ 2。CI 在 win-x64 / win-arm64 / linux-x64 / linux-arm64 / osx-x64 / osx-arm64 上跑单元测试和 49 张语料的 smoke,x64 还额外跑 AVX-512 关、AVX2 关、AVX 关三档。

性能

RTX 3080 Ti:对比 PyTorch

测试机 Ryzen 7 5800X + RTX 3080 Ti,1600×1152 一张:

路径 耗时
PyTorch(CUDA)纯推理 30.5 ms
PyTorch 含前后处理 57.2 ms
SimdGcdrNet Vulkan 端到端(含上传、下载、前后处理) ~9.6 ms
SimdGcdrNet CPU AVX2,16 线程,gc+dr 推理 ~365 ms

端到端比 PyTorch 含前后处理快 6 倍左右。这里面前后处理占了不少:PyTorch 版本的 bgr→nchw、除法、拼 6 通道都在 Python 侧,SimdGcdrNet 全部融合进了同一条 GPU 管线。

49 张真实文档:各设备

语料是 RealDAE 的 color / shadow / bleed 三类各抽 17 / 17 / 16 张,0.44 到 5.24 MP 混合尺寸,按编号顺序跑(大小图交错,池的驱逐重建会被真实暴露),第一张做 warmup 不计时,剩下 49 张逐张计 Enhance 端到端,解码不计时。

设备 CPU GPU GPU vs CPU
RTX 3080 Ti(5800X) AVX2 722 ms Vulkan 28 ms 25.7×
Intel Arc B580(5950X) AVX2 414 ms Vulkan 36 ms 11.5×
Apple M4(虚拟机) NEON 689 ms Metal 87 ms 7.9×
Radeon 880M 核显(锐龙 AI 9 H 365) AVX-512 285 ms Vulkan 100 ms 2.9×
骁龙 8 Gen 3 / Adreno 750(真我 GT5 Pro) NEON 1030 ms Vulkan 486 ms 2.1×
浏览器(Edge,5950X,wasm 多线程) PackedSimd 696 ms --- ---
瑞芯微 RK3399(2× A72 + 4× A53,被动散热) NEON 9.0 s --- ---

(单位:pass1 平均 ms/张;每台机器的完整 JSON、命令和逐轮明细都在仓库 docs/ 下)

几个值得单独说的点:

  • 3080 Ti 的语料数字受 GPU 调频影响。 harness 在两张图之间要解码 webp(CPU 约 100 ms),GPU 每次只忙 20 ms 左右,NVIDIA 驱动按平均利用率把频率往下调,最低到 420 MHz,单张从 25 ms 涨到最多 100 ms。同尺寸背靠背连续跑一直是 23 ms / 5 MP。连续供图的服务场景看单尺寸稳态:1600×2304(3.7 MP)约 17 ms。
  • M4 是 Devin 的虚拟机,Metal 经 paravirt 透传,调度开销比真机高,还有宿主争抢抖动,真机只会更快。Metal 1600×1152 单点 42.3 ms,同机 CPU 316 ms,7.5 倍。
  • 骁龙上 Adreno 750 没有 cooperative matrix,一开始的共享内存卷积路径只比 CPU 快一点点,改成寄存器分块的卷积 / GEMM 内核之后快了 2.6 倍,才有现在的 486 ms。
  • RK3399 那一行放着是认真的。 2016 年的 6 核 ARM 板子,被动散热,9 秒一张,NEON 档在锁 1 GHz 时是通用 Vector<float> 路径的 2.6 倍。手写 NEON 内核在低端 ARM 上的收益比在 M4 上大。

内存

早期版本 CPU 跑完语料 working set 峰值 3.3 GB,Vulkan 显存 1.3 GB,这对一个"预处理步骤"来说太重了。arena 紧凑排布、GC/DR 共用一块、conv+maxpool 融合、UpHead 重算 skip 几步下来:

后端 指标 优化前 现在
CPU AVX2 峰值 working set 3293 MB 794 MB
Vulkan 峰值 working set 933 MB 332 MB
Vulkan 显存 1289 MB 420 MB

一张 5.1 MP 的图,CPU worker 的 arena 从 2315 MB(452 字节/像素)降到 492 MB(96 字节/像素),速度不变,CPU 输出 hash 逐张一致。

浏览器:同一套代码,多线程 WASM

Sdcb.SimdGcdrNet 这个包本身就能被 browser-wasm 引用,不用改一行代码。开 WasmEnableThreads(Web Workers + SharedArrayBuffer)和 LLVM AOT 之后,5950X 上的 Edge 跑同一语料:

档位 均值/张 相对
浏览器 PackedSimd(WASM SIMD128) 696 ms 1.00×
浏览器 Vector<float> 1081 ms 1.55× 慢
同机桌面 .NET 128 位 Vector<float>(参考) 954 ms 1.37× 慢
同机桌面 .NET AVX2(参考) 420 ms 1.66× 快

浏览器里的 PackedSimd 档比同一台机器上桌面 RyuJIT 的 128 位 Vector<float> 档还快一点,是桌面 AVX2 的 60%。PackedSimd 这一档是专门写的:V8 在 x64 上最多给 v128 分 15 个 XMM 寄存器,所以卷积 micro-kernel 是 4 像素 × 8 通道的 tile;WASM SIMD 没有 FMA,乘加分开按 k 升序累加,和 Vector<float> 档逐 lane 运算顺序相同;LayerNorm 原来在非 AVX 档是单线程标量,向量化并行之后快了 10 到 14 倍。

我把它放到了个人网站上:sdcb.ai/simdgcdrnet/playground。拖一张图进去,或者 Ctrl+V 粘贴,可以拖动分割线对比原图和增强结果,也能把结果下载成 PNG。图片不会离开你的电脑,没有服务器参与推理。

这张截图是 16 线程下 1280×960 的手抄报,927 ms。.NET 运行时加模型大约 33 MB,首次打开要下载一会儿,之后浏览器会缓存。

两个要注意的地方:多线程 wasm 需要页面 crossOriginIsolated(COOP / COEP 响应头),自己部署的话记得配;wasm32 线性内存上限 2 GB,5 MP 的图峰值要 1.6 GB,所以 playground 把长边限制到了 1600。浏览器里没有 GPU 后端,Auto / Vulkan / Metal 都直接落到 CPU。

示例

仓库 examples/ 下有四个示例,覆盖了四种像素格式:

ASP.NET Core + ImageSharp (Rgba32,原地增强):上传或拖拽,前后对比滑块,auto / cpu 后端切换,显示耗时;POST /api/enhance 返回增强后的图,/scalar 有 API 文档。

状态栏那一串是我这台 5800X + 3080 Ti 上的数字:1280×960,auto 选到了 Vulkan,增强 8.1 ms,JPEG 解码 29 ms,编码 27 ms,HTTP 往返 68 ms。推理本身已经不是瓶颈,解码和编码加起来是它的 7 倍。同一尺寸第一次调用是 428 ms,里面包含了这个尺寸的计划编译和显存分配,之后同尺寸的图都走热管线。

Avalonia + SkiaSharp (Bgra32 的 SKBitmap):跨平台桌面,Windows / Linux / macOS 都能跑,并排预览,可以在 auto / cpu / vulkan(macOS 上是 metal)之间切换,保存 JPEG / PNG。

WPF + OpenCvSharp5 (Bgr24 的 Mat,任意 Step()):和 Avalonia 版同样的界面,多了拖放。

两张桌面截图状态栏里的 249 ms 和 279 ms 都是首次调用,含计划编译;再点一次"增强"就是上面 Web 示例那种个位数毫秒。

PaddleOCR.Console:就是上面 56 行 → 14 行那个。

Sdcb.SimdGcdrNet 包里还带了 4 个 LINQPad 脚本(System.Drawing、ImageSharp、OpenCvSharp5、SkiaSharp + SimdPaddleOCR),在 LINQPad 里引用这个包后 Samples 面板里能直接看到。

已知限制

  • 两个包只面向 net10.0,没有 netstandard2.0。
  • 不支持批量推理(N > 1),没有 fp16 / int8 量化。
  • GPU 用 fp16 存储,输出和 CPU 有 ≤ 2 的 u8 级差异,PSNR 相同。
  • Metal 只在 M4 虚拟机和 GitHub Actions 的 macOS 虚拟机上测过,没有真机数据。
  • 浏览器里没有 GPU 后端;多线程 wasm 需要 COOP / COEP。
  • 模型本身的能力边界:对真值的 PSNR 平均 24.65 dB,个别图只有 11 dB 左右,是模型对那张图的能力极限,不是实现问题。

开源与交流

觉得有用的话欢迎点个 Star。欢迎关注微信公众号 .NET骚操作 ,或者扫码加入 C#/.NET 计算机视觉交流群:

(如果群满了,或者微信群二维码过期,请加QQ群:.NET计算机视觉交流群 :579060605)