从截图到原位替换:一个 Windows 截屏翻译工具的技术沉淀
本文整理自 TranslateUtil 这个 Windows 小工具的真实开发过程。
核心需求:快捷键框选屏幕区域,OCR 识别英文,AI 翻译成中文,再把译文覆盖到原文所在位置,尽量保留原文字号、颜色和排版。
软件使用
需要先配置deepseek的api key 通过这个网址配置。
DeepSeek开发平台
使用的时候,只需要按住 Ctrl+Alt+空格键,就可以截图翻译。当然这个快捷键也是支持设置的。
支持对翻译的内容追问。
2026.9.1更新
可以翻译选中的文字。选中文字后,还是用翻译快捷键。会先判断是否有选中的内容。如果没有的话。会再启动截图翻译。
还可以调整上面翻译的样式
优化了翻译后的定位。
原图是这样的
使用
下载绑定的资源,运行这里面的exe文件就行了
一、这个工具最终长什么样
TranslateUtil 是一个 Windows 桌面 exe,最终形态非常简单:
- 用户按
Ctrl + Alt + Space框选屏幕区域。 - 选区中央显示一个半透明圆形进度提示。
- 系统 OCR 精确定位每一行文字。
- DeepSeek 多模态模型理解截图上下文并翻译。
- 程序生成一张“替换图”,把英文说明替换成中文,代码标识符保留原样。
- 选区位置弹出一个无边框覆盖层,可切换原文/译文、滚动查看、追问模型。
这个工具经过多次需求变化,最终放弃了本地小模型,统一使用 DeepSeek 多模态接口;本地不打包 OCR 模型,只调用 Windows 自带的 OCR 做定位。这样 exe 体积较小,翻译质量也更稳定。
二、整体架构
技术栈很轻:
- 语言:Python
- GUI / 覆盖层:Tkinter
- 截图:mss
- 系统 OCR:Windows.Media.Ocr / WinRT
- AI 翻译:DeepSeek 多模态接口
- 图像合成:Pillow
- 全局快捷键:Windows 低层键盘钩子
- 打包:PyInstaller onefile
三、核心设计 1:让 OCR 负责定位,AI 只负责翻译
早期版本让 DeepSeek 自己同时返回原文、译文和坐标:
{"items":[{"source_text":"...","translated_text":"...","box":[left,top,right,bottom]}]}看起来省事,实际有两个严重问题:
- 坐标不准确。模型预测的框经常偏大、偏小或错位,导致覆盖层盖不住原文。
- 容易漏行。截图较长时,模型会合并、跳过下半段内容,出现“翻译没翻译全”。
后来改成:
- Windows OCR 负责逐行识别,直接使用 OCR 返回的
bounding_rect。 - 把 OCR 行按
1..N编号,和截图一起交给 DeepSeek。 - DeepSeek 只返回每行对应的
line_index、修正后的source_text和translated_text,不再返回坐标。
核心提示词约束大致是:
items 数量必须等于上述行数, line_index 必须从 1 到 N 连续完整, 不能跳过、合并或新增。 代码声明、标识符、API 名保留原样。请求结构类似:
messages=[{"role":"user","content":[{"type":"text","text":prompt},{"type":"image_url","image_url":{"url":encode_image_data_url(image),},},],}]这样定位精度直接来自系统 OCR,翻译质量来自多模态模型,两者职责清晰。即使模型偶尔漏行,还可以用一次纯文本补译请求兜底。
四、核心设计 2:先生成替换图,再显示覆盖层
覆盖层不是直接画很多小窗口,而是用 Pillow 合成一张完整图片,再交给 TkinterCanvas显示。
生成替换图的关键顺序是:
- 复制原截图。
- 先擦除每个 OCR 原文区域,用该区域的背景色填充。
- 再绘制所有中文译文块。
# 1. 擦原文forpatchinsource_patches:bg_patch=Image.new("RGBA",(w,h),bg+(255,))canvas.alpha_composite(bg_patch,(left,top))# 2. 画译文forlayoutinlayouts:bg_patch=Image.new("RGBA",(w,h),bg+(255,))canvas.alpha_composite(bg_patch,(left,top))draw.multiline_text((left+padding,top+padding),"\n".join(lines),font=font,fill=fg+(255,),)这里最重要的经验是:先擦原文,再画译文。如果直接画译文块,遇到译文比原文长、或者后续行被向下推挤时,原英文很容易从缝隙里露出来。
同时做了几个细节:
- 根据原文高度估算目标字号,但设置下限,避免字太小无法识别。
- 根据原文背景色估算前景色,并做对比度修正。
- 代码标识符、类名、方法名、常量名保留原样。
- 中文行数变多时允许覆盖图高于原始选区,覆盖层里用滚动条查看。
五、核心设计 3:覆盖层的按需滚动
覆盖层是一个无边框Toplevel,内部只有Canvas和一个垂直滚动条。
最初滚动条一直显示,即使内容正好等于选区高度。后来改成按需显示:
def_sync_scrollbar(self):ifself.display_image.height>viewport_height:self.scrollbar.grid()self.canvas.configure(yscrollcommand=self.scrollbar.set)else:self.scrollbar.grid_remove()self.canvas.configure(yscrollcommand="")self.canvas.yview_moveto(0)鼠标滚轮也做了保护:只有内容真的溢出、滚动条可见时,才滚动:
def_on_mousewheel(self,event):ifnotself.scrollbar.winfo_ismapped():return"break"self.canvas.yview_scroll(...)return"break"覆盖层还提供看原文 / 看译文、追问、关闭三个控制按钮,切换视图时重新计算滚动区域。
六、核心设计 4:拦截全局快捷键
pynput.keyboard.GlobalHotKeys能监听快捷键,但不能阻止其他应用也收到这个按键。所以后来换成了 Windows 低层键盘钩子WH_KEYBOARD_LL。
基本做法:
hook=user32.SetWindowsHookExW(WH_KEYBOARD_LL,hook_proc,kernel32.GetModuleHandleW(None),0,)在钩子回调里判断当前按下的虚拟键是否组成目标快捷键。命中后返回1,表示拦截这次事件,不再传给其他应用:
ifsuppress:return1returnuser32.CallNextHookEx(0,n_code,w_param,l_param)需要同时处理WM_KEYDOWN、WM_SYSKEYDOWN、WM_KEYUP、WM_SYSKEYUP,并维护当前按下的按键集合。触发后还要吞掉这个组合键后续的keyup,避免出现“只吞了一半”的奇怪状态。
一个诚实的结论是:Windows 没有绝对意义上的“全局最高优先级热键”。低层钩子已经能挡住普通应用,但如果另一个程序也安装了更晚加载的低层钩子,它仍可能先看到事件。要做到绝对优先,只能上驱动级方案,普通桌面工具通常没有必要。
七、核心设计 5:Tkinter 的线程模型
AI 翻译和截图处理不能阻塞主线程,否则界面会卡死。项目采用了一个很经典的模式:
- 工作线程只负责截图、OCR、AI 请求。
- 工作线程通过
queue.Queue把事件发给主线程。 - Tkinter 主线程用
after周期性消费队列。
def_process_region(self,region):try:image=capture_region(region)items=self.deepseek.translate_image(image,ocr_lines)self._post_ui("show_result",(region,image,items))exceptExceptionasexc:self._post_ui("error",exc)def_drain_ui_queue(self):whileTrue:action,payload=self.ui_queue.get_nowait()ifaction=="show_result":self._show_result(...)elifaction=="show_progress":self._show_progress(...)self.root.after(50,self._drain_ui_queue)这样避免了“在工作线程里直接操作 Tk 控件”导致的各种随机崩溃。进度弹窗也通过这个队列关闭,翻译完成后强制销毁。
八、追问窗口与 Markdown 显示
翻译结果旁边有一个“追问”按钮,点击后打开聊天式窗口。这里没有引入复杂前端,而是直接用tk.Text做轻量 Markdown 渲染。
支持了:
- 标题
- 加粗、斜体
- 行内代码和代码块
- 引用
- 无序列表
- 链接
快捷提问按钮:
- 解释这段文字
- 通俗易懂解释
- 总结内容
这部分的经验是:小工具不需要上 WebView,tk.Text加标签就能完成够用的富文本显示。
九、PyInstaller 打包
最终产物是一个 onefile exe:
pyinstaller--noconfirm--clean--onefile--windowed--name TranslateUtil translate_util.py依赖包括:
mss Pillow pynput requests pystray winrt-Windows.Foundation winrt-Windows.Foundation.Collections winrt-Windows.Globalization winrt-Windows.Graphics.Imaging winrt-Windows.Media.Ocr winrt-Windows.Storage.Streams winrt-runtime pyinstaller打包时最容易踩的坑是:旧的 exe 还在运行,PyInstaller 覆盖文件时会出现PermissionError。因此构建前要先停掉所有TranslateUtil进程。
十、踩坑清单
- 模型自己返回坐标不靠谱:定位交给系统 OCR,AI 只翻译。
- 翻译漏行:用 OCR 行号约束模型必须逐行返回,并加补译兜底。
- 覆盖层露出原文:先擦除原文区域,再绘制译文。
- 滚动条常驻很丑:按内容高度动态显示/隐藏。
- 全局热键冲突:从
GlobalHotKeys换成低层键盘钩子。 - Tkinter 多线程崩溃:所有 UI 操作回到主线程,通过队列通信。
- 翻译过程中用户不知道状态:在选区中央显示半透明圆形进度动画。
- PyInstaller 覆盖失败:先检查并停止旧进程再打包。
十一、可以复用的结论
这个项目最终沉淀下来的核心思路只有三句话:
- OCR 负责“在哪里”,AI 负责“翻译成什么”。
- 覆盖层先生成完整替换图,再显示。
- 所有系统级能力都通过最小、可替换的边界接入。
这套结构不只适合截屏翻译,也适合任何“识别屏幕内容,然后原位替换或增强显示”的桌面工具,例如截图转表格、图片公式识别、UI 文本替换预览等。