简介:这份源码包面向具备一定C语言与Linux开发基础的开发者,提供一套完整的屏幕取词翻译工具实现,可用于学习如何在Linux环境下捕获屏幕文本、集成翻译服务并构建交互界面。压缩包共141个文件,约16.7MB,以40个C源文件与25个头文件为核心,辅以Makefile构建脚本、UI布局文件、Shell脚本及PNG/GIF示意图,另含开源许可证、README与Git子模块配置,目录划分清晰。项目同时预留Bing与Google翻译接口模块,并配有配置管理与快捷键监听等组件,便于读者理解多引擎翻译的接入方式与用户输入处理流程。已有126人学习下载,适合希望深入C语言工程实践、研究Linux桌面工具与翻译API应用的开发者参考借鉴。
1. 从一次查文档的崩溃说起:这个 Linux 屏幕取词翻译项目到底解决什么
在 Linux 桌面上读英文文档,遇到不认识的词,标准动作是:选中、Ctrl+C、切浏览器、粘进翻译框、再切回来。一天下来这个动作重复几十次,手指比脑子累。更难受的是有些界面文字根本选不中——PDF 渲染层、终端输出、图片里的字,复制都复制不了。这个基于 C 语言实现的 Linux 屏幕取词翻译源码,瞄准的就是这个场景:鼠标停在哪儿,就把那一片屏幕像素抓下来,识别出文字,再送去翻译,整个过程不打断你当前的操作。它适合两类人:一是想在 Linux 桌面做工具类开发、需要一套「截屏 + OCR + 翻译」完整链路的开发者;二是想拿现成源码改出自己取词工具的高级用户。技术栈是 C 语言打底,X11 负责屏幕抓取和全局事件,OCR 和翻译走外部接口,整体是一个典型的 Linux 桌面小工具架构。
2. 技术选型与整体架构:为什么是 C + X11 + 外部 OCR
2.1 选型逻辑:C 语言在这个场景里不是情怀
桌面取词工具对延迟极其敏感。用户鼠标停下的那一刻,程序要在几百毫秒内完成「定位光标 → 截取区域 → 识别文字 → 返回译文」,任何一环拖慢都会让体验崩掉。C 语言在这里的优势不是性能数字好看,而是对系统调用的控制粒度足够细:X11 的XGetImage拿像素是零拷贝语义的,OCR 前的图像预处理(灰度化、二值化、放大)可以直接在内存 buffer 上做,不用经过一层运行时抽象。
另一个现实原因是依赖轻。一个常驻后台的取词工具如果拖进来几百 MB 的运行时,用户第一反应就是卸载。C 编译出来的二进制通常只有几十 KB 到几百 KB,链接的动态库也都是系统自带的 X11 系列,分发成本极低。
常见做法是把项目拆成三层:采集层(X11 抓屏、全局热键/鼠标事件)、处理层(图像预处理 + OCR 调用)、输出层(翻译请求 + 结果展示,通常是悬浮窗或通知)。三层之间用简单的函数调用串联,不引入消息队列之类的重设施,因为单次取词是同步短任务,异步框架反而是负担。
2.2 目录结构:一个典型的小型 C 项目长什么样
这类项目通常不会用 CMake 之外的花哨构建系统,目录也保持扁平。下面是我拆这类源码时最常见的结构,字段名按功能命名,你拿到手的包大体对得上:
screen-translate/ ├── Makefile # 编译入口,链接 X11 / Xext / png 等 ├── src/ │ ├── main.c # 程序入口,初始化 X11、注册事件、主循环 │ ├── capture.c/.h # 屏幕抓取:光标定位、区域截取、像素转图像 │ ├── preprocess.c/.h # 图像预处理:灰度、二值化、缩放 │ ├── ocr.c/.h # OCR 封装:调用外部识别引擎或本地库 │ ├── translate.c/.h # 翻译封装:HTTP 请求、JSON 解析 │ └── overlay.c/.h # 结果展示:悬浮窗绘制、自动消失 ├── include/ │ └── config.h # 全局配置:热键、区域大小、接口地址 └── assets/ └── config.ini # 运行期可改的参数这个结构的核心思想是按职责切文件,按数据流串调用。main.c只做初始化和事件分发,真正的活都在各模块里。二次开发时你改 OCR 引擎只需要动ocr.c,换翻译服务只动translate.c,互不牵连。
2.3 一次取词的完整链路
把链路摊开看,一次取词大致是这么走的:
- 用户在
main.c的主循环里等待事件,X11 的XNextEvent阻塞监听; - 命中预设的触发条件(比如按住某个修饰键 + 鼠标停留超过阈值),进入
capture; capture用XQueryPointer拿到光标坐标,按配置的区域尺寸(常见 200×60 像素)算出矩形;XGetImage抓取该矩形像素,转成 RGB buffer;preprocess做灰度化和二值化,必要时放大 2 倍提升识别率;ocr把图像交给识别引擎,拿回文本;translate把文本发出去,拿回译文;overlay在光标附近画一个悬浮窗显示结果,几秒后自动销毁。
这条链路里每一步都可能成为瓶颈,后面排查章节会细说。这里先记住一个原则:采集要快、识别要准、展示要轻,三者优先级不能乱。
3. 核心模块拆解:抓屏、预处理、OCR 三段代码怎么读
3.1 屏幕抓取:XGetImage 的正确打开方式
抓屏是整个项目的地基。X11 下抓一块区域的标准写法是XGetImage,但直接抄示例代码很容易踩坑——比如忘了处理返回的XImage结构里的字节序,或者抓完不释放导致内存泄漏。下面这段是这类项目里capture.c的核心逻辑,我按常见实现补全了注释:
#include <X11/Xlib.h> #include <X11/Xutil.h> #include <stdlib.h> /* 抓取以 (x, y) 为中心、宽 w 高 h 的屏幕区域,返回 RGB 数据 */ unsigned char *capture_region(Display *dpy, int x, int y, int w, int h) { Window root = DefaultRootWindow(dpy); /* 把中心坐标换算成左上角坐标,并做边界裁剪,防止越界 */ int left = x - w / 2; int top = y - h / 2; if (left < 0) left = 0; if (top < 0) top = 0; XImage *img = XGetImage(dpy, root, left, top, w, h, AllPlanes, ZPixmap); if (!img) return NULL; unsigned char *rgb = malloc(w * h * 3); if (!rgb) { XDestroyImage(img); return NULL; } /* 逐像素读取,XGetPixel 会处理不同色深和字节序 */ for (int row = 0; row < h; row++) { for (int col = 0; col < w; col++) { unsigned long pixel = XGetPixel(img, col, row); int idx = (row * w + col) * 3; rgb[idx] = (pixel >> 16) & 0xFF; /* R */ rgb[idx + 1] = (pixel >> 8) & 0xFF; /* G */ rgb[idx + 2] = pixel & 0xFF; /* B */ } } XDestroyImage(img); /* 必须释放,否则每次取词泄漏一块图像内存 */ return rgb; }这段代码有三个关键点值得说。第一,XGetImage的坐标是左上角而不是中心点,所以要先做x - w/2的换算,并且必须裁剪到屏幕范围内,否则在屏幕边缘取词会拿到未定义内容。第二,XGetPixel是安全但偏慢的接口,它内部处理了色深和字节序差异;如果追求极致性能,可以按img->bits_per_pixel直接读img->data,但那样就得自己处理 24 位和 32 位色深的差异,新手不建议一上来就这么干。第三,XDestroyImage不能漏,这是这类工具最常见的泄漏点——每次取词泄漏几十 KB,跑一天内存就上去了。
参数上,w和h一般从config.ini读,默认 200×60 是个折中:太小容易把单词截断,太大识别耗时上升且容易混入无关文字。
3.2 图像预处理:为什么灰度化之后还要二值化
OCR 引擎对输入图像的质量很敏感。屏幕截图直接喂进去,背景色、抗锯齿、字体粗细都会影响识别率。预处理模块干的就是「把图变干净」这件事。常见实现是两步:先灰度化,再自适应二值化。
/* 灰度化:加权平均,权重按人眼感知,绿色权重最高 */ void to_grayscale(unsigned char *rgb, unsigned char *gray, int w, int h) { int n = w * h; for (int i = 0; i < n; i++) { int r = rgb[i * 3]; int g = rgb[i * 3 + 1]; int b = rgb[i * 3 + 2]; gray[i] = (unsigned char)(0.299 * r + 0.587 * g + 0.114 * b); } } /* 简单阈值二值化:阈值可配置,默认 128 */ void binarize(unsigned char *gray, int n, int threshold) { for (int i = 0; i < n; i++) { gray[i] = (gray[i] > threshold) ? 255 : 0; } }灰度化的权重0.299 / 0.587 / 0.114是标准公式,不是随便拍的——人眼对绿色最敏感,所以绿色通道权重最高,这样转出来的灰度图更接近人看到的明暗关系。二值化则是把灰度图压成纯黑白,让 OCR 引擎只需要判断「黑还是白」,减少颜色干扰。
这里有个血泪经验:固定阈值 128 在深色主题下会翻车。Linux 桌面用户里深色主题占比不低,深色背景配浅色文字,固定阈值二值化后文字和背景可能全糊成一片。合格的做法是用自适应阈值(比如局部均值法),或者干脆根据图像整体亮度动态调整阈值。项目里如果只给了固定阈值,二次开发时这是第一个该改的地方。
另外,如果原始截图分辨率低(比如小字号),在二值化之前做一次 2 倍最近邻放大,识别率往往能明显提升。放大代码不复杂,但要注意放大后 buffer 尺寸同步变化,别把w、h传错了。
3.3 OCR 与翻译封装:把外部依赖关进一个文件里
OCR 和翻译都是外部能力,项目本身不实现识别算法,而是做封装。这种设计的好处是换引擎不动主流程。ocr.c里通常是一个函数把图像 buffer 交给识别引擎,拿回字符串:
/* 调用外部 OCR 命令,图像先落盘为临时 PPM,再读回识别结果 */ char *ocr_image(unsigned char *gray, int w, int h) { char tmp_path[] = "/tmp/st_ocr_XXXXXX"; int fd = mkstemp(tmp_path); if (fd < 0) return NULL; /* 写 PPM 头 + 像素数据,PPM 格式简单,无需额外编码库 */ FILE *fp = fdopen(fd, "wb"); fprintf(fp, "P5\n%d %d\n255\n", w, h); fwrite(gray, 1, w * h, fp); fclose(fp); /* 拼命令执行,实际项目里应做路径转义和超时控制 */ char cmd[512]; snprintf(cmd, sizeof(cmd), "ocr-engine --input %s 2>/dev/null", tmp_path); FILE *pipe = popen(cmd, "r"); if (!pipe) { unlink(tmp_path); return NULL; } char *result = malloc(1024); if (fgets(result, 1024, pipe) == NULL) { free(result); result = NULL; } pclose(pipe); unlink(tmp_path); /* 临时文件必须删,否则 /tmp 会被塞满 */ return result; }这段代码体现了一个务实的选择:用 PPM 格式落盘而不是引入 PNG 编码库。PPM 是无压缩的裸格式,写起来就几行,省掉 libpng 依赖。代价是临时文件体积大一点,但取词区域只有 200×60,几十 KB 而已,无所谓。
翻译封装同理,translate.c里一般是一个 HTTP 请求函数,把识别出的文本 POST 出去,解析返回的 JSON 取译文。这里要注意两点:一是请求要设超时,网络卡住时不能让整个取词流程挂死;二是返回的 JSON 解析别自己手写字符串查找,容易在转义字符上翻车,用个轻量 JSON 库更稳。
提示:OCR 和翻译都涉及外部进程或网络,务必给每一步加超时。取词是交互操作,用户等 3 秒没反应就会以为程序死了。
4. 编译运行与配置:从源码到能取词的五步
4.1 依赖安装与编译
这类项目在 Debian/Ubuntu 系上的依赖基本固定,X11 开发头文件和图像库是必需的:
# 安装编译依赖 sudo apt install build-essential libx11-dev libxext-dev libpng-dev # 进入源码目录编译 cd screen-translate make # 产物通常在当前目录,直接运行 ./screen-translatelibx11-dev提供Xlib.h和链接所需的.so,libxext-dev是某些扩展(比如形状扩展,用于画不规则悬浮窗)需要的。如果make报undefined reference to XGetImage,八成是 Makefile 里-lX11漏了或者顺序不对——链接库要放在源文件之后。
4.2 配置文件怎么改
运行期参数一般放在assets/config.ini,常见字段如下表:
| 字段 | 含义 | 默认值 | 调整建议 |
|---|---|---|---|
capture_width | 取词区域宽(像素) | 200 | 单词短可降到 120,整句翻译调到 400 |
capture_height | 取词区域高 | 60 | 单行文字 40 够用,多行要加 |
trigger_key | 触发修饰键 | Alt | 避开常用快捷键,防止误触 |
hover_delay_ms | 鼠标停留触发延迟 | 500 | 太低误触,太高反应慢 |
ocr_threshold | 二值化阈值 | 128 | 深色主题改自适应或调低 |
translate_api | 翻译接口地址 | 空 | 必须填自己的接口 |
改完配置不用重新编译,程序启动时读取即可。这也是把参数外置的意义——调参不该触发编译。
4.3 验证是否跑通
跑起来之后按这个顺序验证,能快速定位问题出在哪一层:
- 先确认程序能抓到图:临时在
capture_region返回前把 RGB 数据存成 PPM,用图片查看器打开,看是不是光标附近的画面。这一步过了,说明 X11 层没问题。 - 再确认预处理没把图弄坏:把二值化后的图也存一份,看文字是否还清晰可辨。糊了就是阈值问题。
- 然后单独测 OCR:拿一张清晰的截图喂给识别引擎,看返回文本对不对。不对就是引擎或参数问题,跟取词逻辑无关。
- 最后测翻译:手动构造一段文本走
translate函数,看返回是否正常。
分层验证的好处是,出问题时你能立刻知道该看哪个文件,而不是从头到尾瞎猜。
5. 避坑与排查:五条踩过的坑
5.1 取词区域偏移,抓到的总是隔壁的字
现象:鼠标明明停在某个词上,识别出来的却是旁边一行或偏上偏下的内容。
原因:X11 的坐标系原点在屏幕左上角,而XQueryPointer返回的是相对根窗口的坐标;如果程序跑在多显示器环境或者有缩放(HiDPI),坐标还需要按缩放比例换算。很多示例代码只处理单屏 100% 缩放,换个环境就偏。
解决:先打印XQueryPointer返回的坐标和实际光标位置对比,确认偏差是固定值还是比例值。固定偏差多半是区域中心换算写错,比例偏差则是缩放没处理。多屏场景下还要用XineramaQueryScreens拿到各屏几何信息,判断光标落在哪块屏。
5.2 深色主题下识别率断崖式下跌
现象:浅色主题下识别挺准,用户切到深色主题后大量识别失败或返回乱码。
原因:固定阈值二值化假设「背景亮、文字暗」,深色主题正好反过来,二值化后文字和背景的极性颠倒,OCR 引擎把背景当成了笔画。
解决:二值化前先统计图像平均亮度,如果整体偏暗,就把阈值逻辑反过来(或者对图像做反色)。更稳的做法是上自适应阈值,按局部窗口算阈值,对主题不敏感。改完记得用浅色和深色两套截图各测一遍。
5.3 跑一段时间内存持续上涨
现象:程序刚启动占几十 MB,挂一下午涨到几百 MB,最后被系统杀掉。
原因:XGetImage返回的XImage没释放,或者每次取词malloc的 RGB buffer 用完没free。这类泄漏单次很小,但取词是高频操作,累积起来很快。
解决:用valgrind --leak-check=full跑一轮取词,看泄漏点在哪。重点检查capture_region里XDestroyImage和free是否成对出现,以及异常分支(比如malloc失败提前 return)有没有漏释放。养成「谁申请谁释放、异常路径也要释放」的习惯。
5.4 翻译请求偶尔卡死整个程序
现象:大部分时候正常,偶尔取词后界面无响应好几秒,甚至要手动杀进程。
原因:HTTP 请求没设超时,网络抖动或接口慢响应时,同步调用把主线程堵死了。取词工具是单线程事件循环,主线程一堵,整个程序就假死。
解决:给 socket 设SO_RCVTIMEO,或者用带超时参数的 HTTP 库。更彻底的做法是把翻译请求放到独立线程,主线程只管展示「翻译中」状态,结果回来再更新。哪怕不做线程,至少要有超时兜底,超时后返回空结果并提示,别让用户干等。
5.5 临时文件堆积把 /tmp 塞满
现象:用久了/tmp下出现大量st_ocr_*文件,磁盘告警。
原因:OCR 走临时文件落盘,正常路径有unlink,但异常路径(比如popen失败、fgets读空)提前 return 时漏了删除。
解决:把临时文件的创建和删除包成一个「申请-使用-释放」的固定模式,所有 return 路径都走统一的清理逻辑。或者干脆用memfd_create创建内存文件描述符,不落盘,从根上避免残留。
6. 二次开发:换 OCR 引擎与加取词模式的改造思路
拿到这份源码,最值钱的改造方向有两个:换掉 OCR 引擎,以及扩展取词触发方式。这两个改动分别对应「识别质量」和「使用体验」,也是这类工具拉开差距的地方。
先说换 OCR 引擎。项目默认走外部命令,好处是解耦,坏处是每次取词都要 fork 一个进程、写一次临时文件,延迟下不来。如果你追求响应速度,可以把ocr.c改成链接本地识别库,直接在进程内调用。改造时保持函数签名不变——输入灰度 buffer 和宽高,输出字符串——这样main.c和预处理模块一行都不用动。这就是前面强调「把外部依赖关进一个文件」的回报。改完用同一批截图对比新旧引擎的识别结果,重点看小字号和深色背景两类样本,别只看正常情况。
再说加取词模式。现在多半是「按住修饰键 + 鼠标停留」触发,你可以加一个「划选取词」:按下鼠标开始记录轨迹,松开时把轨迹包围盒作为取词区域。这需要监听ButtonPress和ButtonRelease事件,在main.c的事件循环里加分支,把起止坐标传给capture_region。注意包围盒要做最小尺寸限制,防止用户手抖点一下导致区域为零。加完之后,触发逻辑和原有热键逻辑要互斥,别让两种模式同时响应同一次操作。
改造验证我一般按这个顺序走:先单元测capture(存图看对不对),再测ocr(固定输入看输出稳不稳),最后整体跑一轮真实文档取词,统计识别准确率和平均耗时。准确率低于预期就回头调预处理,耗时超标就查是不是 fork 或网络拖慢的。
| 改造点 | 涉及文件 | 验证方法 | 常见翻车 |
|---|---|---|---|
| 换本地 OCR 库 | ocr.c | 同批截图对比识别率 | 库的输入格式和灰度 buffer 不匹配 |
| 加划选取词 | main.c、capture.c | 手动划选不同尺寸区域 | 包围盒为零或越界 |
| 翻译改异步 | translate.c、main.c | 弱网下观察是否假死 | 线程间共享 buffer 没加锁 |
最后说个习惯。我早期改这类工具,图省事直接在main.c里堆逻辑,结果触发方式一多,事件循环里全是 if-else,改一处崩三处。从那以后我每次动事件循环之前,都强制先把「事件 → 处理函数」的映射抽成一张表,新增模式只加表项不碰主循环。这个习惯让后面几次加功能都顺得多。希望帮到你。
本文还有配套的精品资源,点击获取