一个 Windows 桌面实时翻译工具:把指定窗口的画面实时 OCR 识别出来,经大模型翻译后,以半透明覆盖层把译文直接盖回原文位置,让原本看不懂的外语界面 / 文档 / 游戏瞬间变成中文。
适用于阅读外语文档、看外语视频 / 直播、玩外语游戏等场景。
本文档是项目唯一主文档,同时承担「项目介绍」与「交接说明」两个职责:接手本项目的 AI / 开发者读完本文即可开工,无需重新调查。原
WGC报错排查交接文档.md已并入本文第 11 章。
- 项目定位
- 功能特性
- 运行环境
- 编译与运行
- 使用步骤
- 配置说明
- 项目结构与文件职责
- 技术栈与依赖
- 主链路与线程模型
- 关键机制详解
- 屏幕捕获:WGC 与 GDI 降级(含完整故障排查史)
- 已知问题与坑
- 关键常量速查(IID / 标志位)
- 可复用规律(CsWinRT / COM interop 踩坑总结)
- 回归禁区(改动时不要破坏的既有行为)
- 待优化项
- 代码位置速查表
- 编译常见报错
- 许可证
| 项 | 值 |
|---|---|
| 项目名 | ScreenTranslator |
| 类型 | Windows 桌面应用(.NET 8 + WPF) |
| 根目录 | 仓库根目录(即 ScreenTranslator.csproj 所在目录) |
| 目标框架 | net8.0-windows10.0.26100.0(远超 WGC 所需的 19041) |
| 一句话 | 截屏 → OCR → 大模型翻译 → 译文原地盖回 |
| 核心难点 | 屏幕捕获(WGC / GDI)、译文覆盖层的「看得见但抓不到」、变化检测降负载 |
- 实时覆盖式翻译:译文原地覆盖在原文上,而不是另开一个副窗口
- 两种翻译模式
- 云端大模型 API(任何 OpenAI 兼容接口,如 DeepSeek)
- 本地大模型 Ollama(
qwen2.5:3b等),完全离线、无需 API Key
- 两种屏幕捕获方式:Windows Graphics Capture(WGC)优先,失败时自动降级 GDI
- 中文 OCR 开箱可用:内置 PP-OCRv5 中文识别模型(中英混排一个模型搞定),首次启动自动下载 + SHA256 校验
- 识别区域(ROI):可手动框选只识别屏幕某一块区域,减少无效 OCR
- 变化检测:画面没变时用灰度指纹直接跳过 OCR,静止画面(看文档 / 看代码)几乎零开销
- 译文缓存:相同原文不重复调用 API,省 token
- 覆盖层防截屏:覆盖层不会出现在截图 / 录屏 / 远程画面中,避免「译文被自己抓回去再翻译」的死循环
- 译文样式可调:字号、背景大小、内边距、圆角、不透明度,设置界面带实时预览
- 位置微调:译文 X / Y 方向像素级偏移
- 全局热键:默认
Alt+T一键开关翻译 - 调试工具:仅显示识别框 / 只显示原文 / 显示 OCR 检测框 / 强制 GDI 捕获 / WGC 仅计数不取像素(二分诊断) / 一键导出诊断串
- 全局异常兜底:崩溃信息写入
logs/error.log,不再「窗口一闪就没了」
| 项 | 要求 |
|---|---|
| 操作系统 | Windows 10 1803+ / Windows 11(WGC 需 DWM 正常合成) |
| 运行时 | .NET 8(SDK 用于编译;仅运行可装 Desktop Runtime) |
| 权限 | 需管理员权限运行(见 app.manifest)——以便捕获以管理员身份运行的窗口 |
| 网络 | 使用云端翻译或首次下载 OCR 模型时需要(本地模式 + 模型已就绪时可完全离线) |
| 硬件 | 有 GPU 时优先走 DirectML,失败回退 CPU |
git clone https://github.com/xkmlk123/ScreenTranslator.git
cd ScreenTranslator
dotnet restore
dotnet build -c Release
dotnet run -c Release产物:bin\Release\net8.0-windows10.0.26100.0\ScreenTranslator.exe
也可以直接用 Visual Studio 2022 打开
ScreenTranslator.csproj(选择 Release 配置)运行。 编译前必须先关闭正在运行的程序,详见第 18 章。
首次启动若检测到中文识别模型缺失,程序会在设置界面提示并引导下载(约 16MB),下载带 SHA256 校验与备用镜像。
- 以管理员身份启动程序(每次启动弹一次 UAC 属预期行为)
- 在「翻译模式」里选择 云端大模型 API(填 API 地址 / Key / 模型名)或 本地大模型(Ollama)
- 本地模式需先安装并启动 Ollama,执行
ollama pull qwen2.5:3b
- 本地模式需先安装并启动 Ollama,执行
- 点「刷新」→ 在「目标窗口」下拉里选中你要翻译的窗口
- (可选)点「⬚ 选择识别区域」,在屏幕上框选只识别的那一块;不框则识别整个目标窗口
- 点「▶ 开启翻译」(或按全局热键
Alt+T)开始,再按一次停止 - 译文出现位置 / 大小不满意 → 点「⚙ 设置」调整样式与偏移(带实时预览)
- 排查捕获问题时 → 看状态栏的「帧:到达 N / 转图 N」两个数字,或点「导出诊断」拿到完整诊断串
配置保存在程序同目录的 config.json(首次运行自动生成),字段全表:
| 字段 | 说明 |
|---|---|
TranslationMode |
Cloud(云端 API)/ Local(Ollama) |
ApiBaseUrl / ApiKey / Model |
云端 OpenAI 兼容接口的地址、密钥、模型名 |
LocalApiUrl / LocalModel / LocalConcurrency |
本地 Ollama 地址、模型名、并发数(1~8) |
CaptureIntervalMs |
捕获间隔(毫秒),建议 800~3000 |
OcrScale |
OCR 识别前放大倍数(约 1.5~2 倍),小字调大 |
OcrThreads |
OCR 推理线程数,0 = 自动(推荐,实测并非越多越快) |
MinConfidence |
OCR 最低置信度(0~1) |
UseChangeDetection / ChangeMinCells |
变化检测开关与灵敏度(判据是绝对变化格数,不是比例) |
UseRoi / RoiX/Y/W/H |
识别区域(归一化坐标,随窗口缩放自动跟随) |
FontScale / BgScale / BgPadding / BgCornerRadius / BgOpacity |
译文样式 |
OffsetX / OffsetY / OverlayOpacity |
译文位置与不透明度 |
ToggleHotkey |
全局热键,如 Alt+T、Ctrl+Shift+S |
OverlayExcludeFromCapture |
覆盖层排除出屏幕捕获(推荐保持 true) |
注意:
config.json可能包含你的 API Key,请勿提交到 Git 仓库(已列入.gitignore)。
ScreenTranslator/
├── App.xaml(.cs) 程序入口 + 全局异常兜底(崩溃写 logs/error.log)
├── MainWindow.xaml(.cs) 主窗口:模式选择、窗口选择、开关、状态栏、单帧调度
├── app.manifest 清单:requireAdministrator、Per-Monitor V2 DPI
├── ScreenTranslator.csproj 项目与 NuGet 依赖声明
├── Models/
│ ├── AppConfig.cs 应用配置(字段见第 6 章)
│ ├── OcrBlock.cs 单个 OCR 结果块(文本 + 框 + 置信度)
│ ├── TranslationSegment.cs 待翻译段落(合并后的文本 + 各行的框)
│ ├── WindowInfo.cs 可捕获窗口信息(句柄 + 标题)
│ └── v5/ OCR 模型目录(首次启动自动下载,不入库)
├── Services/
│ ├── AppLog.cs 日志:目录 / 按运行会话命名 / 体积滚动 / 导出 / 保留策略
│ ├── WgcCaptureService.cs WGC 捕获(SoftwareBitmap 路径 + 自建 D3D 路径 + 诊断计数)
│ ├── GdiCaptureService.cs GDI 降级捕获(PrintWindow,失败回退 BitBlt + 临时隐藏覆盖层)
│ ├── OcrService.cs OCR 推理封装(中文模型、上采样、置信度过滤、结果合并)
│ ├── OcrRuntime.cs ONNX Runtime 环境与线程数推荐(DirectML 优先,CPU 兜底)
│ ├── OcrModelManager.cs 中文模型下载 + SHA256 校验 + 镜像回退
│ ├── TextSegmenter.cs 按版面把 OCR 碎片合并成完整句子 / 段落
│ ├── TextNormalizer.cs 文本归一化(去重与缓存键的基础)
│ ├── TranslationService.cs 大模型翻译(云端批量 / 本地单条并发)
│ ├── TranslationCache.cs 译文缓存
│ ├── FrameFingerprint.cs 灰度指纹(变化检测)
│ ├── RoiGeometry.cs 识别区域几何换算(归一化 ↔ 像素)
│ ├── OcrBoxFitter.cs 识别框大小自适应(字号 clamp、避免切字)
│ ├── WindowEnumerator.cs 枚举可捕获窗口
│ ├── HotkeyManager.cs 全局热键注册
│ └── ConfigService.cs 配置读写(config.json)
└── Windows/
├── OverlayWindow.xaml(.cs) 译文覆盖层(透明穿透 + 防截屏 + 30Hz 跟随)
├── SettingsWindow.xaml(.cs) 设置界面(含实时预览)
└── RegionSelectWindow.xaml(.cs) 识别区域框选(Esc 取消)
运行期产生的文件全部已列入 .gitignore。日志统一落在程序目录下的 logs/:
| 文件 | 生成者 | 说明 |
|---|---|---|
logs/诊断日志_<启动时刻>.txt |
AppLog.Info |
运行时日志。文件名 = 本次进程启动时刻(如 诊断日志_20261014_152103.txt),一次运行一个文件;每次启动会删掉上一次运行的日志,所以目录里始终只有本次运行这一份;单文件超过 1MB 时从头部丢弃最旧内容(保留尾部 768KB),不是整文件清空 |
logs/导出诊断_<导出时刻>.txt |
「导出诊断」按钮 | 每次导出新建一个文件,文件名 = 导出时刻;写完后再删掉上一次的导出,所以始终只保留最新一份。内容 = 环境头 + 状态栏快照 + 帧错误历史 + 本次运行日志全文 |
logs/error.log |
App.xaml.cs 全局异常兜底 |
崩溃堆栈(原生 AV 类闪退抓不到,见第 14 章);跨运行累积、不随启动清除,超过 512KB 丢弃最旧内容 |
logs/debug_frame.png |
诊断路径 | 首帧调试截图,只在需要时落盘一次 |
config.json |
ConfigService |
用户配置,含 API Key,切勿提交 |
logs/里正常情况下只有 一个诊断日志 + 一个导出诊断 +error.log—— 运行时日志在每次启动时被本次取代,导出在每次导出时被本次取代(见第 12 章)。另有一条兜底规则:每类文件最多保留 20 个,超出按修改时间删最旧(只有删除失败导致堆积时才会触发);删除只匹配本程序自己的命名形状(前缀 +YYYYMMDD_HHMMSS+.txt),不碰其他文件。 想查「上一次运行出了什么事」,只有error.log留得下跨运行的历史(崩溃堆栈);诊断日志的定位是「当下这次运行」。 「按时间命名」和「导出不覆盖既有内容、只留最新一份」是硬约束,原因见第 12 章;相关代码见第 17 章。
- UI:.NET 8 + WPF(
net8.0-windows10.0.26100.0) - 捕获:Windows.Graphics.Capture(CsWinRT 投影,
Microsoft.Windows.SDK.NET.dll+WinRT.Runtime.dll)+ SharpDX.Direct3D11 / DXGI + GDI 回退 - OCR:RapidOcrNet(ONNX Runtime,PP-OCRv5 中文模型)
- 图像:SkiaSharp
- 翻译:任何 OpenAI 兼容 Chat Completions 接口(云端)或 Ollama(本地)
- 配置:Newtonsoft.Json
第三方组件的许可证(均为宽松型,与本项目的 MIT 许可兼容):
| 组件 | 版本 | 许可证 |
|---|---|---|
| RapidOcrNet | 4.2.0 | Apache-2.0 |
| SharpDX.Direct3D11 | 4.2.0 | MIT |
| SkiaSharp / SkiaSharp.NativeAssets.Win32 | 3.119.1 | MIT |
| Newtonsoft.Json | 13.0.3 | MIT |
| PP-OCRv5 中文识别模型 | — | Apache-2.0(来自 PaddleOCR) |
二次分发时请一并保留上述组件的版权与许可声明。
用户点「开启翻译」
→ MainWindow.StartTranslation()
校验配置 → 准备 OCR 模型 → 选择捕获后端(WGC 优先,或强制 GDI)
→ 创建并显示 OverlayWindow
→ 启动定时器,周期调用 ProcessFrame()
→ ProcessFrame()(捕获帧 → 变化检测 → OCR → 分段 → 翻译 → 绘制)
1. 捕获:WgcCaptureService.TryGetFrame() / GdiCaptureService.Capture()
2. 变化检测:FrameFingerprint.Compute() 与上一帧指纹比对
└─ 未变化 → 直接跳过本帧剩余流程(零 OCR 开销)
3. ROI 裁剪:RoiGeometry.ToSkRect()
4. OCR:OcrService.Recognize() → 返回 List<OcrBlock>
5. 分行分段:TextSegmenter.BuildSegments() → List<TranslationSegment>
6. 翻译:TranslationService.TranslateAsync()
├─ 先查 TranslationCache,命中直接复用
└─ Cloud → TranslateBatchAsync()(批量协议,严格回填)
Local → TranslateIndividuallyAsync()(有限并发 + 指数退避重试)
7. 绘制:OverlayWindow 渲染译文与识别框
| 环节 | 所在线程 |
|---|---|
| 定时器触发、UI 状态更新、覆盖层绘制 | WPF UI 线程 |
WGC 回调 OnFrameArrived |
MTA 线程池线程(CreateFreeThreaded 保证回调串行) |
CopyResource + Map 像素回读 |
同上(ImmediateContext 非线程安全,因此严禁改成 async void 并发调用) |
| SoftwareBitmap 异步转换 | 线程池(await 续体,用 Interlocked 重入守卫 + 5 秒卡死自愈) |
| OCR 推理 | 后台线程(OcrService 内部) |
| 翻译请求 | 异步(云端批量 / 本地并发受限) |
| 覆盖层渲染循环 | 30Hz 定时器,快照未变化时只做平移,不重建视觉树 |
两条路:
- WGC(首选):
Direct3D11CaptureFramePool.CreateFreeThreaded(...)+GraphicsCaptureSession。可捕获被遮挡窗口,且与防截屏配合良好。内部又有两条取像素路径:UseSoftwareBitmapPath = true(当前默认):SoftwareBitmap.CreateCopyFromSurfaceAsync(surface)→CopyToBuffer→DataReader.ReadBytes→SKBitmap。全程只用 CsWinRT 投影 API,刻意不做原生指针 interop。UseSoftwareBitmapPath = false(保留回退):自建 D3D11 设备 →CopyResource到 staging 纹理(缓存复用,不每帧新建)→MapSubresource回读像素。
- GDI(降级):
PrintWindow(PW_CLIENTONLY)优先;若全黑 / 全白则判定无效,回退BitBlt从屏幕 DC 抓图(此时临时隐藏覆盖层,避免把译文抓回去)。
- 模型:PP-OCRv5,含检测模型、识别模型(中文)、方向分类模型、词典;路径在
Models/v5/,缺失时由OcrModelManager下载(SHA256 校验 + 备用镜像)。 - 关键选项(
RapidOcrOptions):LimitSideLen、DoAngle = false、UnClipRatio = 1.1。 - 流程:空图检查 → 按
OcrScale上采样(约 1.5~2 倍,小字识别率的关键)→Detect()→ 置信度过滤(MinConfidence)→ 坐标还原回原图尺度 →MergeFragments()合并碎片。 - 线程数:
OcrThreads = 0为自动推荐(实测 ORT 线程数是 U 型曲线,不是越多越快,建议 clamp 4~8)。
FrameFingerprint 计算 ROI 范围内的灰度分块指纹(约 256 字节),与上一帧比对,变化格数低于 ChangeMinCells 即判定「画面没变」,整个 OCR + 翻译链路直接跳过。静止画面(看文档)几乎零开销。
- 透明穿透:铺满虚拟桌面、
Topmost、WS_EX_LAYERED、不抢焦点、不进任务栏。 - 防截屏:调用
SetWindowDisplayAffinity(WDA_EXCLUDEFROMCAPTURE),失败则降级WDA_MONITOR,并读回校验。校验时WDA_MONITOR(0x01)与0x11都算成功。- 注意:
WS_EX_TRANSPARENT不防截屏,它只影响鼠标命中测试。
- 注意:
- 坐标换算:OCR 框坐标(目标窗口客户区)→ 屏幕坐标。偏移基准用
DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)而非GetWindowRect,以排除 Win10+ 不可见边框的差异。 - 渲染循环:30Hz 跟随目标窗口移动;快照未变时只做整体平移,不重建视觉树。
本章由原
WGC报错排查交接文档.md合并而来,并已修正部分已被实测推翻的旧结论。带「已修正」标记的段落是纠正后的版本。
MainWindow.xaml.cs 的 SetStatus() 常驻输出:
状态:正在翻译(WGC模式) ← 或「GDI(WGC 失败)模式」
窗口:xxx
<本帧详情>
帧:到达 N / 转图 N|【错误】<LastFrameError>
若降级 GDI,则改为:
抓帧:PrintWindow N / BitBlt N(防截屏已生效 | 防截屏未生效)
WGC 失败原因:[WGC 步骤N] xxx
三个数的含义(诊断体系的核心):
| 组合 | 含义 |
|---|---|
| 到达 0 | 会话压根没投递帧(窗口最小化 / 不可见、受保护内容) |
| 到达 N / 转图 0 | 帧到了,但 Surface → Bitmap 转换没成功 |
| 到达 N / 转图 N | 正常 |
对应代码:Services/WgcCaptureService.cs(FrameArrivedCount / FrameTakenCount / FrameConvertedCount / LastFrameError),消费端 MainWindow.xaml.cs 的 ProcessFrame() 与 SetStatus()。
注意:勾选「WGC 仅计数不取像素(诊断)」时,每帧取到后立刻 return、不做任何转换,转图必然恒为 0。看到「转图 0」先确认这个复选框是不是被勾上了。
| # | 症状 | 根因 | 状态 |
|---|---|---|---|
| 1 | 译文越用越大(阶梯式) | GDI 降级 → BitBlt 从屏幕 DC 抓图 → 把自己刚画的译文抓回去再翻译 → OCR 框变大 → 字号 ∝ 框高 | 已修复(防截屏 + 变化检测 + 去重) |
| 2 | WGC 启动即失败 | UIPI 权限不对等:程序 asInvoker vs 目标窗口管理员 |
已修复(manifest 改 requireAdministrator) |
| 3 | [WGC 步骤6] Specified cast is not valid |
GraphicsCaptureItem.As<IGraphicsCaptureItemInterop>() 在新版 CsWinRT 做的是普通 cast 而非 QI;且 typeof(GraphicsCaptureItem).GUID 是 CLSID 不是接口 IID |
已修复(RoGetActivationFactory + AsInterface) |
| 4 | [WGC 步骤7] Failed to create a CCW ... System.__ComObject |
步骤 5 用 Marshal.GetObjectForIUnknown 造的是经典 COM RCW,CsWinRT 的 Create() 要的是投影对象 |
已修复(MarshalInterface<T>.FromAbi) |
| 5 | QueryInterface(IDXGISurface) 失败,HRESULT=0x80004002 |
WinRT IDirect3DSurface 是包装器,本身不实现 IDXGISurface |
已修复(改走 IDirect3DDxgiInterfaceAccess) |
| 6 | QueryInterface(IDirect3DDxgiInterfaceAccess) 失败,HRESULT=0x80004002(7000+ 帧全灭) |
已修正:旧文档归因于「手工指针身份问题」,实为三处缺陷叠加——① IDirect3DDxgiInterfaceAccess 的 IID 被误写成 A37624AB-...(那是 IDirect3DDevice 的 IID),错值必然 E_NOINTERFACE,是 5 条路径全灭的直接原因;② IInspectable IID 写成伪造值 ECD4B446-...,使诊断代码自身出错、把排查方向误导向「指针身份不可靠」;③ 帧池用 Create 而非 CreateFreeThreaded,跨 apartment 拿到代理对象,本地 interop 接口无 proxy/stub,QI 同样失败 |
已修复(IID 改 A9B3D012-...;IInspectable 改 AF86E2E0-...;帧池改 CreateFreeThreaded) |
| 7 | 帧到达但取像素时 0x887A0005 DXGI_ERROR_DEVICE_REMOVED / 0x887A0006 DXGI_ERROR_DEVICE_HUNG(TDR),第 1~2 帧必崩 |
已修正:旧文档归因于「跨适配器拷贝」,已被二分实验证伪。真正触发点是自建 D3D context 路径的像素回读动作(CopyResource + MapSubresource) |
已规避(改走 SoftwareBitmap 路径后 0x887A0006 消失),见 11.3 / 11.4 |
「磨损式排查」全部无效(详见 11.5),最终用二分法定位:
| 实验 | 操作 | 结果 | 结论 |
|---|---|---|---|
| A | 在 OnFrameArrived 加时间戳丢帧节流(每秒最多 5 帧) |
现象完全不变(到达=2 取帧=1 转图=1 即崩) | 「抓帧循环没节流导致 GPU 打满」被证伪 |
| B | staging 纹理缓存复用(不再每帧 new Texture2D) |
现象无改善 | 「帧池 / 纹理每帧重建」被证伪 |
| C | 勾选「WGC 仅计数不取像素(诊断)」= 每帧只计数、完全不做 CopyResource+Map |
到达 154 帧、转图 0、无任何帧错误 | 触发点就是像素回读动作本身,与帧率、资源重建、COM 接口都无关 |
由此得出的关键判断:0x887A0006(DEVICE_HUNG,TDR)是自建 D3D11 设备的回读操作触发的,而不是帧率或资源创建频率导致的。
| 方案 | 内容 | 结果 |
|---|---|---|
| 方案 A(已落地,构成稳定基线) | 删除 texture.Device 诊断块(它引入了 0xc0000005 原生访问违规闪退)+ Map 回退 MapFlags.None |
不再闪退;WGC 报错时自动降级 GDI,基线稳定可用 |
| 方案 C(已落地,当前默认) | 彻底改走 WinRT 官方 SoftwareBitmap 路径,绕开自建 D3D context |
0x887A0006 消失,帧错误历史为空,WGC 可稳定存活 150~216 帧 |
方案 C 的附加防护(已实现):
Interlocked重入守卫:同一时刻只允许一个 SoftwareBitmap 转换在跑;- 5 秒卡死自愈:转换超时未完成则复位守卫并记录错误(避免守卫永久卡死);
- SB 诊断日志:只记前 3 次
SB 已发起转换/SB 转换完成/SB 已转为 SKBitmap。
关于
0xc0000005的教训:托管try/catch抓不到原生访问违规(事件日志显示coreclr.dll+0xc0000005+.NET Runtime id=1023 exit code 0x80131506)。在 CsWinRT interop 里「手工挖指针」属于高危操作。
| 已排除 | 依据 |
|---|---|
| 权限不足 | 权限不足在**创建捕获项(步骤 6)**就抛,走不到收数千帧 |
| 目标窗口最小化 / 不可见 | 帧一直在到达(数千帧) |
| 硬件加速 / 双 GPU / HAGS | 只影响「帧内容」,影响不了 COM 接口查询与回读崩溃 |
| 注册表 DCI 超时 | 产生黑帧,不会产生 E_NOINTERFACE / DEVICE_HUNG 异常 |
| 抓帧循环没节流 | 见 11.3 实验 A,加了节流现象不变 |
| 帧池 / 纹理每帧重建 | 见 11.3 实验 B,复用后现象不变 |
| 字号 / 渲染层累积放大 | 已逐行确认 Draw / DrawOcrBoxes 对 blocks 零写回,OcrBoxFitter.ApplyTo 幂等 |
| 翻译去重缺失 | 已有 FrameFingerprint 变化检测 + 文本标准化去重 + 翻译缓存 |
| 跨适配器拷贝 | 见 11.2 第 7 行「已修正」:旧结论,已被二分实验证伪 |
注:网络上流传的「通用 WGC 排查清单」(改注册表、关硬件加速、重启等)针对的是「无报错 + 纯黑屏」场景,对本问题无效——本问题有明确报错与证据链。
WGC 路径已完整跑通:状态栏显示 WGC模式,「帧:到达 N / 转图 N」两个数字同步上涨,不再触发降级;GDI 退居备用模式。
拿下这一串问题靠的是下列改动的叠加生效,缺任何一条都走不通:
| # | 改动 | 不做的后果 |
|---|---|---|
| 1 | IDirect3DDxgiInterfaceAccess IID 改为 A9B3D012-... |
5 条取指针路径全部 E_NOINTERFACE |
| 2 | IInspectable IID 改为 AF86E2E0-...(原为伪造值 ECD4B446-...) |
诊断自身的身份体检永远失败,把排查方向误导到「指针身份不可靠」 |
| 3 | 帧池改用 CreateFreeThreaded |
回调落到别的 apartment,拿到的是代理对象;本地 interop 接口没有 proxy/stub,QI 同样失败 |
| 4 | 改走 SoftwareBitmap 路径(UseSoftwareBitmapPath = true) |
自建 D3D context 的像素回读动作会触发 0x887A0006(DEVICE_HUNG) |
| 5 | 取消「WGC 仅计数不取像素(诊断)」的勾选 | 该开关让 OnFrameArrived 每帧立即 return,转图恒为 0 |
第 5 条是最容易把自己坑进去的一条:该复选框默认不勾选(
MainWindow.xaml的SkipReadbackCheck没有设IsChecked),但做 11.3 的二分实验时一定会勾上它。实验做完忘了取消,就会看到「WGC 不报错、但转图恒为 0」这种假故障——这恰好是当时最后一份日志的形态。排查 WGC 前,先确认这个框没被勾上。
历史记录(问题未解决时的推断链,保留备查):当时最后的日志特征是「转图 = 0」+「帧错误历史为空」+「无任何报错」。逐一排除所有会写 LastFrameError 的代码路径(转换异常、返回 null、守卫 5 秒自愈、设备移除)后,指向了「诊断复选框被勾着」这一解释,与最终现象吻合。若将来再遇到「转图 0」,11.8 的决策树仍然适用;另有一种独立成因:能看到 SB 已发起转换 但始终没有 SB 转换完成,说明 SoftwareBitmap.CreateCopyFromSurfaceAsync(surface) 永久挂起,根因在环境层(见 11.7)。
本机环境的异常证据:
GameViewer Virtual Display Adapter(虚拟显示适配器)+ 系统事件 4107SetDisplayConfig + SDC_FORCE_MODE_ENUMERATION;nvlddmkm事件 153 与Display事件 4101(GPU 侧的 TDR 痕迹);BasicRender(WARP 软件设备)反复 4101;- 本机存在多块 DXGI 适配器。
远程控制 / 远程桌面会显著加剧(甚至直接造成)这类问题——它可能引入虚拟显示适配器、改变 DWM 合成路径与 GPU 调度。若问题持续,建议:
- 在物理机前本地操作(不通过远程控制)复现验证;
- 临时禁用
GameViewer Virtual Display Adapter后重测; - 用「仅计数不取像素」开关做二分,确认崩溃是发生在捕获侧还是回读侧。
运行后看状态栏
├─ 转图 N 开始上涨 ─────────────► WGC 通了。做 11.9 的收尾验证
│
└─ 转图 0
├─ 先确认「WGC 仅计数不取像素」复选框是否勾着 ──► 勾着就先取消重测
├─ 帧错误历史非空,且含 0x887A0005 / 0x887A0006
│ ──► 回读侧触发 TDR,走 SoftwareBitmap 路径(UseSoftwareBitmapPath=true)
├─ 帧错误历史为空,且无「SB 已发起转换」日志
│ ──► 回调根本没进来或提前 return,检查 SkipPixelReadback / 节流逻辑
├─ 有「SB 已发起转换」但无「SB 转换完成」
│ ──► CreateCopyFromSurfaceAsync 永久挂起 ⇒ 环境层问题(见 11.7)
└─ 全部失败 ──► 点「导出诊断」把完整诊断串交给下一个环节
- 状态栏显示 WGC 模式,「帧:到达 N / 转图 N」两个数字同步上涨
- 译文正常显示,字号不再阶梯式变大(原 bug 症状消失)
- 按
PrtScn截图 → 粘贴到画图:看不到译文与白底块(防截屏生效) - 换管理员权限的目标窗口、多显示器不同 DPI 各验证一次
dotnet build -c Release保证 0 warning 0 error
- GDI 降级是安全网,不是主路径:WGC 已跑通(见 11.6),但在虚拟显示适配器、远程桌面 / 远程控制等环境下仍可能触发
DXGI_ERROR_DEVICE_HUNG,此时程序自动降级 GDI,功能不受影响。GDI 要求目标窗口可见、未被完全遮挡,且防截屏可能失效(会启用临时隐藏覆盖层兜底,代价是抓帧瞬间闪一下)。详见第 11 章。 - 目标窗口以管理员身份运行时,本程序也必须以管理员身份运行,否则系统会拒绝捕获。
- 日志一律「按时间命名」,且「只留最新一份」(设计约定,勿改成固定文件名覆盖写):运行时日志以本次进程启动时刻命名(
logs/诊断日志_YYYYMMDD_HHMMSS.txt,一次运行一个文件),导出以导出时刻命名(logs/导出诊断_YYYYMMDD_HHMMSS.txt,每次新建)。两类文件都收敛到只剩最新一份 —— 运行时日志在启动时删掉上一次的(DeletePreviousDiagLogs),导出在导出时删掉上一次的(DeletePreviousExports)。由此得到五条硬约束:- 绝不「就地覆盖」既有文件。早期版本固定写
导出诊断.txt,第二次导出直接把第一次覆盖掉;更早还一度让导出直接覆盖写运行日志文件,会在用户最需要证据的那一刻(出问题时点导出)把整条证据链清空。保留「新建 + 删旧」而不是「固定名覆盖写」,还因为覆盖写要求目标文件可写 —— 上一份若正被记事本打开,整个导出会失败;「新建 + 删旧」在同样情况下只是静默留着旧文件,导出照常成功,且文件名仍带着时刻。 - 顺序必须是「先落新文件 → 再删旧文件」:导出写完新文件后才调用
DeletePreviousExports清理。反过来的话,一旦新文件写入失败(磁盘满、路径不可写),用户手上就一份都不剩了。任何时刻磁盘上至少有一份导出。 - 诊断日志每次启动即取代上一次,代价要清楚:上一次运行的日志会就此消失,事后翻不回来。需要跨运行留存的只有崩溃堆栈 —— 那是单独累积的
error.log,不随启动清除,本程序不删它。改动日志策略时别把这条兜底也一起删了。 - 因为日志文件本身就是一次运行,不再需要「本次运行开始」标记去切分 —— 该标记是「单文件跨进程累积」时期的产物,改为按运行命名后它就成了多余机制。
- 保留策略删除文件时只认「本程序生成的命名形状」(前缀 +
yyyyMMdd_HHmmss,可选-N,扩展名.txt),不得扩大成通配。光凭前缀不够:用户手工建的导出诊断_备份.txt、诊断日志_重要.txt前缀一模一样,见前缀就删会误伤;形状只要有一点不符(位数不对、含横线、扩展名不同)就判为「不是我们的」。宁可漏删,不可误删。另:要删的旧文件若正被记事本打开(持有写锁),删除会失败,此时静默保留、下次再删 —— 清理不能反过来把流程搞失败。
已知取舍:
logs/下每类文件最多保留 20 个的规则,实际只在「删除失败导致堆积」时才会生效;且同时开两个实例时,后启动的那个会把先启动的日志删掉(本程序按单实例使用设计)。 - 绝不「就地覆盖」既有文件。早期版本固定写
- 给日志做体积限制必须按字节比较,不能用
string.Length(已修复的坑):中文在 UTF-8 下 1 字符 = 3 字节。曾用字符数去和字节阈值比较,结果文件涨到 2.2MB 时字符数才约 750K,裁剪条件始终不成立,日志无限增长。正确做法是ReadAllBytes+ 字节级截断(见AppLog.TrimLocked)。另:截断点必须跳过以空格开头的续行,否则两行式条目(帧错误:…加到达=…)会被截成只剩续行的孤儿行。 - 原生访问违规闪退无法被托管捕获:在 CsWinRT interop 中手工取指针可能导致
0xc0000005,try/catch抓不到,表现为「点开启翻译直接闪退」。 ImmediateContext非线程安全:不要为了「不阻塞回调」而把帧处理改成并发执行。
| 名称 | 值 | 备注 |
|---|---|---|
IDXGISurface IID |
CAFCB56C-6AC3-4889-BF47-9E23BBD260EC |
反射比对 typeof(SharpDX.DXGI.Surface).GUID 一致 |
IDirect3DDxgiInterfaceAccess IID |
A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1 |
正确值(与代码一致)。四源交叉验证:FFmpeg vsrc_gfxcapture_winrt.hpp、ScreenRecorderLib、robmikh 官方 C# sample Direct3D11Helpers.cs、kennykerr gist |
IDirect3DDevice IID |
A37624AB-8D5F-4650-9D3E-9EAE3D9BC670 |
极易混淆,勿用错:这是 IDirect3DDevice 的 IID,不是 IDirect3DDxgiInterfaceAccess。它曾因误读报错信息(那次报错的对象是 device)被错当成后者写进代码,导致 5 条取指针路径全部 E_NOINTERFACE(见 11.2 第 6 行) |
IInspectable IID |
AF86E2E0-B12D-4C6A-9C5A-D7AA65101E90 |
已修正:旧文档写的 ECD4B446-17B9-4E77-B0D9-B9BD4B1C2D90 是错的,直接导致旧诊断逻辑失效、误导排查方向 |
IGraphicsCaptureItemInterop IID |
3628E81B-3CAC-4C60-B7F4-23CE0E0C3356 |
官方头文件 |
IGraphicsCaptureItem 接口 IID |
79C3F95B-31F7-4EC2-A464-632EF5D30760 |
不是 CLSID(e79f1dd1-...) |
IActivationFactory IID |
00000035-0000-0000-C000-000000000046 |
标准 |
WDA_EXCLUDEFROMCAPTURE |
0x00000011 |
读回校验时 WDA_MONITOR(0x01) 也算成功 |
DXGI_ERROR_DEVICE_REMOVED |
0x887A0005 |
设备被移除 |
DXGI_ERROR_DEVICE_HUNG |
0x887A0006 |
TDR 超时 |
DXGI_ERROR_WAS_STILL_DRAWING |
0x887A000A |
非阻塞 Map 可能返回 |
E_NOINTERFACE |
0x80004002 |
QI 失败 |
改代码前务必读本章。本文件的 interop 部分先后踩坑 5 次,全属下列类型。
- CsWinRT 投影对象不要用经典 COM interop 取指针。
Marshal.GetIUnknownForObject(投影对象)返回的是 CCW 身份指针,不是底层 WinRT 对象的 IUnknown。 官方 ABI 出口是MarshalInspectable<T>/MarshalInterface<T>.FromManaged。 typeof(运行时类).GUID是 CLSID,不是接口 IID。 传ref Guid iid必须传接口 IID。- SharpDX 等 wrapper 的
(IntPtr)构造函数不做 QI,传错接口指针会静默调错方法(比崩更难查)。 - 运行时类的静态
As<T>()对[ComImport]接口会抛InvalidCastException(做的是托管 cast,不是 QI)。 catch { }是 bug 放大器:异步回调里空 catch +return null,会把丰富异常压缩成无法诊断的上层症状。 凡吞异常处必须留LastError属性给上层读。- 异步管道必须有分段进度计数(到达 / 取帧 / 转图),否则断在哪一环无法二分定位。
- 用「QI IInspectable」给任何 WinRT 指针做身份体检:真对象必过,假货立现。(前提是 IID 正确,见第 13 章)
SetWindowDisplayAffinity才防截屏,WS_EX_TRANSPARENT只影响鼠标命中测试。- 托管
try/catch抓不到原生 AV(0xc0000005),闪退类问题要去事件日志找coreclr.dll+.NET Runtime id=1023。 - 节流要用「时间戳丢帧」而非
await Task.Delay;回调里不要引入新的并发(ImmediateContext非线程安全)。
- 译文偏移(
DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)vsGetWindowRect的边框差异) - 盖不住原文(背景块尺寸与
BgOpacity) - 图标被切字(字号 clamp 与
availWidth收缩) - ROI 归一化比例存法(窗口缩放自动跟随)
- 渲染 30Hz 解耦(快照未变只做平移,不重建视觉树)
- 变化检测(
FrameFingerprint,判据是绝对变化格数而非比例) - OCR 线程数 clamp(4~8,ORT 线程数 U 型曲线)
- 帧池必须用
CreateFreeThreaded(改回Create会让IDirect3DDxgiInterfaceAccess的 QI 全灭)
| 项 | 说明 |
|---|---|
| 旧 D3D 回读路径的去留 | UseSoftwareBitmapPath = false 的自建 D3D 路径已证明会触发 0x887A0006,目前仅作回退保留;确认 SoftwareBitmap 路径稳定后可考虑删除,减少维护面 |
| 30 帧兜底阈值 | 「到达 ≥ 30 且 转图 == 0 → 降级 GDI」在 WGC 真能用时也会误伤(诊断开关被勾着就是典型)。建议改为「连续 N 秒转图无增长」 |
| 环境层健壮性 | WGC 已通(见 11.6)。若在别的机器上再出现 DEVICE_HUNG,先在物理机前本地操作、并尝试禁用虚拟显示适配器后复现(见 11.7) |
| 日志落盘仍在调用线程上同步执行 | AppLog.Info 目前是「调用方线程直接 lock → open/write/close → 超限时读 1MB 重写 768KB」。生产者已经被节流(_lastLoggedError 等值去重、_sbDiagCount <= 3),稳态下几乎不写日志,所以尚未成为问题;但若出现两种不同错误交替(等值去重失效)→ 可达 60 行/秒 → 约 170 秒撞上 1MB → 此后每约 42 秒在帧回调线程上同步读写约 1.8MB。这与第 9 章「不让 GPU 工作阻塞回调」的原则不一致。建议改为生产者只入队、单个后台线程批量落盘(届时 AppLog.Gate 可去掉) |
| 日志无级别 | 目前所有日志无条件写入,log = seq <= 3 这类魔法常量散在调用点,本质是硬编码的级别。建议收敛成统一的 Verbose/Info/Error 三档 |
| GDI 路径不写日志 | GdiCaptureService 对 AppLog 的调用数为 0,PrintWindowOkCount / BitBltFallbackCount 只经状态栏 → 导出。GDI 是第 12 章明确的「安全网」,这条路径目前没有独立可观测性 |
括注的行号会随代码改动而漂移,定位请以「文件 + 方法名」为准,行号只作快速跳转参考。
| 想改什么 | 看哪里 |
|---|---|
| 开启 / 停止翻译、配置校验、捕获后端选择 | MainWindow.xaml.cs StartTranslation()(约 L209-369) |
| 单帧处理链路(捕获 → OCR → 翻译 → 绘制) | MainWindow.xaml.cs ProcessFrame()(L417) |
| 状态栏输出(诊断仪表盘) | MainWindow.xaml.cs SetStatus()(L1108) |
| 主界面 XAML(含各种诊断复选框、按钮) | MainWindow.xaml |
| 「导出诊断」按钮(每次新建文件 + 环境头 + 删掉上一次导出) | MainWindow.xaml.cs CopyDiag_Click / BuildCaptureBackendLine |
| WGC 回调(计数 / 节流 / 重入守卫 / 触发转换) | Services/WgcCaptureService.cs OnFrameArrived(L509) |
WGC 帧池创建(必须 CreateFreeThreaded) |
Services/WgcCaptureService.cs L215 |
| SoftwareBitmap → SKBitmap 转换 | Services/WgcCaptureService.cs ConvertViaSoftwareBitmapAsync / SoftwareBitmapToSkBitmap |
| 自建 D3D 回读路径(回退用) | Services/WgcCaptureService.cs ConvertSurfaceToSkBitmap(L1006) |
| WGC 资源释放 | Services/WgcCaptureService.cs Dispose(L1165) |
| 日志(目录 / 按运行会话命名 / 启动即取代上次 / 体积滚动 / 导出 / 保留策略) | Services/AppLog.cs —— LogDir / DiagFileName / NewExportPath / DeletePreviousDiagLogs / DeletePreviousExports / IsGeneratedLogName / Info / ReadDiagLog / Error / TrimLocked / RetainRecentFiles / Initialize |
| 设置窗口的「打开日志文件夹」 | Windows/SettingsWindow.xaml(.cs) OpenLogDir_Click |
| GDI 抓帧(PrintWindow / BitBlt 回退) | Services/GdiCaptureService.cs Capture() |
| OCR 服务与选项 | Services/OcrService.cs(约 L23-82 选项、L84-154 初始化、L156-213 Recognize()) |
| OCR 线程数与 provider | Services/OcrRuntime.cs(约 L69-108) |
| 中文模型下载与校验 | Services/OcrModelManager.cs(约 L76-169) |
| 分行分段 | Services/TextSegmenter.cs BuildSegments()(约 L21-116) |
| 翻译入口与云端 / 本地区分 | Services/TranslationService.cs TranslateAsync()(约 L80-108);本地实现约 L114-200 |
| 译文缓存 | Services/TranslationCache.cs |
| 变化检测(指纹) | Services/FrameFingerprint.cs |
| ROI 换算 | Services/RoiGeometry.cs |
| 识别框自适应 | Services/OcrBoxFitter.cs |
| 覆盖层防截屏 | Windows/OverlayWindow.xaml.cs(约 L49-70 设计说明、L218-287 实现) |
| 覆盖层渲染循环(30Hz 跟随) | Windows/OverlayWindow.xaml.cs(约 L319-426) |
| 设置界面与实时预览 | Windows/SettingsWindow.xaml(.cs) |
| 识别区域框选 | Windows/RegionSelectWindow.xaml(.cs) |
| 全局热键 | Services/HotkeyManager.cs |
| 配置读写 | Services/ConfigService.cs + Models/AppConfig.cs |
程序是管理员权限(manifest 提权),普通终端杀不掉;且运行中的实例会锁住 exe,导致 dotnet build 报 MSB3027 / MSB3021(文件被占用)。
操作顺序:关闭翻译器窗口 → 用任务管理器确认无 ScreenTranslator 进程 → 再 dotnet build -c Release。
若必须在程序运行时验证代码能否编译(不覆盖 exe):
dotnet build -c Release -o <临时目录>代码全在 dll 里(exe 只是 apphost 启动器,通常不会被重写)。可用字节检索确认(在仓库根目录下执行):
d = open(r'bin\Release\net8.0-windows10.0.26100.0\ScreenTranslator.dll','rb').read()
print(b'abiIIns=0x'.decode().encode('utf-16-le') in d or b'abiIIns=0x' in d)本项目采用 MIT License,版权归 xkmlk123 所有。完整条款见仓库根目录的 LICENSE。
一句话概括:你可以自由使用、修改、分发本项目(包括商用与闭源再发布),唯一条件是保留版权声明与许可声明。
本项目依赖的第三方组件均为宽松型许可(Apache-2.0 / MIT),与本项目的 MIT 许可兼容,清单见第 8 章。