chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
name: Bug 错误
|
||||
description: Report bugs 报告错误
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please first search the [Issues](https://github.com/hiroi-sora/Umi-OCR/issues) to make sure that no one else has already raised a similar issue. If there is an existing issue, you can reply under that Issue to provide more details. The more the developer knows, the easier it is to fix the problem.
|
||||
|
||||
请先浏览或搜索 [Issues](https://github.com/hiroi-sora/Umi-OCR/issues) ,确保没有其他人曾经提出过类似问题。如果已有类似问题,你可以在该Issue下回复,提供更多细节。开发者知道得越多,修复问题越容易。
|
||||
|
||||
Please ensure that you have read the [FAQ](https://github.com/hiroi-sora/Umi-OCR/issues/447) before raising a new issue.
|
||||
|
||||
请确保在提问之前已经阅读过 [常见问题解答](https://github.com/hiroi-sora/Umi-OCR/issues/447)。
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Issues
|
||||
options:
|
||||
- label: I have browsed through the Issues. 我已浏览过Issues,确定没有重复提问。
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Umi-OCR version 程序版本
|
||||
placeholder: 2.1.0
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Windows version 系统版本
|
||||
placeholder: win10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: OCR plugins Used 使用的OCR插件
|
||||
description: If the bug is related to OCR, please select the plugins you were using. 如果Bug与OCR相关,请选择您使用的插件。可多选。
|
||||
multiple: true
|
||||
options:
|
||||
- PaddleOCR
|
||||
- RapidOCR
|
||||
- TesseractOCR
|
||||
- Pix2Text
|
||||
- ChineseOCR
|
||||
- Other 其他插件/自开发插件
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Reproduction steps 复现步骤
|
||||
description: Describe the reproduction steps precisely if possible. 尽可能详细描述错误产生的细节。
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem screenshots or related files (optional) 问题截图或相关文件(可选)
|
||||
description:
|
||||
If there are pop-up errors, interface anomalies, or obviously incorrect results, please provide complete screenshots.
|
||||
If a specific image or document can reliably trigger a bug, please upload that file.
|
||||
|
||||
如果存在弹窗报错、界面异常、结果明显错误等问题,请提供完整截图。
|
||||
如果特定的图片或文档能稳定触发Bug,请上传该文件。
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Feature Request 功能请求
|
||||
description: Create the request for a new feature 提出一个增加新功能的请求
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please first browse the [Issues](https://github.com/hiroi-sora/Umi-OCR/issues) to ensure that no one else has previously raised a similar request. If there is a similar request, you can post a reply under the Issue; the number of replies may affect the priority decision of development. The developer has limited time and energy, and may not be able to fulfill all requests. Your understanding is appreciated.
|
||||
|
||||
When proposing a new feature, please clearly state the applicable scenarios, operation methods, and the desired effects.
|
||||
|
||||
---
|
||||
|
||||
请先浏览 [Issues](https://github.com/hiroi-sora/Umi-OCR/issues) ,确保没有其他人曾经提出过类似请求。如果已有类似的请求,你可以在Issue下回帖;回帖人数可能影响开发的优先级决策。开发者时间精力有限,不一定能实现所有的请求,敬请谅解。
|
||||
|
||||
提出新功能时,请明确说明适用场景、操作方式、希望达到的效果。
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Issues
|
||||
options:
|
||||
- label: I have browsed through the Issues. 我已浏览过Issues,确定没有重复的建议。
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior 预期的功能
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Approximate reference (optional) 近似的参考(可选)
|
||||
description:
|
||||
If another software already has a similar feature, you can provide a description or screenshots of that software for reference.
|
||||
|
||||
如果别的软件已有近似功能,可以提供该软件的描述或截图,以供参考。
|
||||
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: Discussions 讨论
|
||||
url: https://github.com/hiroi-sora/Umi-OCR/discussions
|
||||
about: |
|
||||
If you encounter issues with secondary development, wish to implement niche features, have suggestions for the future development of the project, or want to share your usage experiences, tips, or insights about the project, please post them here.
|
||||
|
||||
二次开发遇到问题,想要实现小众功能,对项目未来发展的建议,使用经验、技巧或者对项目的见解,请在这里发表。
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
开发工具
|
||||
命名规范.md
|
||||
Temp
|
||||
build
|
||||
PyStand_for_UmiOCR/build
|
||||
.vscode
|
||||
*.exe
|
||||
*.txt
|
||||
|
||||
# 发布
|
||||
release
|
||||
release.py
|
||||
|
||||
# 开发工具
|
||||
!dev-tools/i18n/release
|
||||
!dev-tools/i18n/*.exe
|
||||
dev-tools/7z
|
||||
dev-tools/*.zip
|
||||
|
||||
# 排除一级目录
|
||||
UmiOCR-data/*
|
||||
!UmiOCR-data/main.py
|
||||
!UmiOCR-data/version.py
|
||||
!UmiOCR-data/Help 帮助.txt
|
||||
# 允许二级目录
|
||||
!UmiOCR-data/**/
|
||||
# 排除运行环境目录
|
||||
UmiOCR-data/site-packages*
|
||||
UmiOCR-data/runtime
|
||||
UmiOCR-data/lib
|
||||
UmiOCR-data/plugins/*/
|
||||
UmiOCR-data/temp
|
||||
UmiOCR-data/temp_doc
|
||||
# 允许运行环境压缩包
|
||||
!UmiOCR-data/*.7z
|
||||
!UmiOCR-data/*.zip
|
||||
# 排除缓存
|
||||
**__pycache__**
|
||||
UmiOCR-data/i18n/_
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
# 分支使用说明
|
||||
|
||||
点击后续列表的版本号链接,可前往对应备份分支页面。
|
||||
|
||||
部分备份分支含有体积较大的二进制库,会让你花费长时间下载。因此,建议只下载你需要用的分支。
|
||||
|
||||
方法1:将所需的分支,fork到你自己的账号下,然后clone你自己仓库。
|
||||
|
||||
方法2:使用以下命令手动clone指定分支:
|
||||
|
||||
```
|
||||
git clone --single-branch --branch [分支名] https://github.com/hiroi-sora/Umi-OCR.git
|
||||
```
|
||||
|
||||
方法3:在本仓库手动下载指定分支的zip源码包。
|
||||
|
||||
`[分支名]` 可以是 `main` 、`release/2.0.0` 等,详见下方列表。
|
||||
|
||||
`main`、`dev` 等分支,可能含有开发中的不稳定的新功能。如果用于研究学习或二次开发,建议选择 `release` 开头的分支。
|
||||
|
||||
# 更新日志 CHANGE LOG
|
||||
|
||||
### v2.1.5 `2025.3.26`
|
||||
|
||||
分支名:`main`
|
||||
|
||||
- 新增:日志机制。在命令行中启动 Umi-OCR 可查看实时日志。指定级别以上(默认为ERROR)的日志被保存到 `Umi-OCR/UmiOCR-data/logs` 目录中,保存级别可以在全局设置标签页中更改。
|
||||
- 新增:大部分标签页能手动切换左右/上下双栏模式。 (#789)
|
||||
- 新增:Esc键隐藏主窗口。 (#652)
|
||||
- 新增:调整二维码生成相关参数后,自动刷新二维码生成。 (#690)
|
||||
- 新增:命令行指令 `--reload` ,用于重新加载配置文件。 👉 [文档](https://github.com/hiroi-sora/Umi-OCR/blob/main/docs/README_CLI.md#软件操控指令)
|
||||
- 修复:文档识别提取PDF自带的文本内容时,未考虑页面旋转的影响。 (#785)
|
||||
- 修复:文档识别生成单层PDF时,未写入原PDF自带的文本内容。
|
||||
- 修复:OCR结果展示列表的一些显示Bug和鼠标划选Bug。
|
||||
- 修复:调整标签页顺序或删除标签页后,未及时保存顺序信息。
|
||||
- 修复:HTTP接口 `/api/doc/download` 参数 `ignore_blank` 的错误。
|
||||
- 修复:Linux版本截图时,系统任务栏推移顶层窗口,导致截图位置偏移。 (#778)
|
||||
- 修复:Linux版本截图后,主窗口的位置与操作前不一致。
|
||||
- 优化:图片/文档的异步加载机制。现在可以流畅地加载含有数万个子文件的文件夹,且能预览加载进度。 (#710)
|
||||
- Windows 版本更新第三方依赖库:`PyMuPDF 1.24.11` ,`fontTools 4.56.0` ,`Pillow 10.4.0` ,`psutil 10.4.0` ,`pynput 1.8.0` ,`zxing-cpp 2.3.0`
|
||||
- 新增UI语言:俄语 `Русский` ,译者:[Вячеслав Анатольевич Малышев](https://hosted.weblate.org/user/1969)、[Muhammadyusuf Kurbonov](https://hosted.weblate.org/user/muhammadyusuf.kurbonov2002)。泰米尔语 `தமிழ்` ,译者:[தமிழ்நேரம்](https://hosted.weblate.org/user/TamilNeram/)。
|
||||
|
||||
|
||||
### [v2.1.4](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.1.4) `2024.8.31`
|
||||
|
||||
分支名:`release/2.1.4`
|
||||
|
||||
- 修复:引擎原始输出项的"text"为空时,导致文本分析越界的错误。 (#625)
|
||||
- 新增UI语言:葡萄牙语 `Português` 。译者:[@杨鹏](https://hosted.weblate.org/user/ypf)
|
||||
- **PaddleOCR 插件相关**:
|
||||
- 修复:布尔类型选项不生效的问题。
|
||||
- 修复: `繁体中文` 配置文件错误的问题。
|
||||
- 优化:调整默认线程/内存限制,默认内存占用不超过系统总内存的一半。
|
||||
- 优化:调整识别语言库结构,精简不必要的文件。
|
||||
- **Linux 版本相关**:
|
||||
- 修复:[Docker 部署](https://github.com/hiroi-sora/Umi-OCR_runtime_linux/blob/main/README-docker.md) 在部分旧系统中报错 `'code': 803` 的问题。(#621)
|
||||
- 优化:`glibc` 依赖降级至 `2.31` ,兼容 Debian-11 、 Ubuntu-20 等发行版。
|
||||
|
||||
### [v2.1.3](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.1.3) `2024.7.23`
|
||||
|
||||
分支名:`release/2.1.3`
|
||||
|
||||
- 新兼容 Linux 平台。
|
||||
- 支持:[Docker 部署](https://github.com/hiroi-sora/Umi-OCR_runtime_linux/blob/main/README-docker.md) 。
|
||||
- 新增:HTTP 文档识别接口。👉 [文档](https://github.com/hiroi-sora/Umi-OCR/blob/main/docs/http/api_doc.md#/api/doc)
|
||||
- 修复:文档中宽高为0的异常图片会导致任务卡住。
|
||||
- 优化:排版解析的 **单栏-单行** 方案,对于间隔较大的两个相邻文本块,会添加空格作为间隔符。
|
||||
|
||||
### [v2.1.2](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.1.2) `2024.6.5`
|
||||
|
||||
分支名:`release/2.1.2`
|
||||
|
||||
- 新增:批量任务(图片、文档)支持 **暂停任务** 。只要不退出软件,在待机/休眠后可以恢复任务。 (#469)
|
||||
- 新增:批量文档任务,支持保存为 **单层纯文本PDF** 。
|
||||
- 新增:HTTP OCR接口 允许设置 **忽略区域** 参数。 (#525)
|
||||
- 新增:HTTP 二维码接口 允许设置 **图像预处理** 参数。 (#512)
|
||||
- 新增:命令行`--screenshot`允许指定屏幕范围,自动进行截图。 (#452)
|
||||
- 修复:文档内容提取和写入时的坐标旋转、比例适配问题。
|
||||
- 修复:截图预览 & 记录面板中,跨区块复制文本时,尾块的复制字数不正确。
|
||||
- 优化:批量文档标签页,拖入大量文件、提交大批任务时的性能和流畅度。 (#476)
|
||||
- UI:优化一些布局,美化一些视觉效果。
|
||||
|
||||
### [v2.1.1](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.1.1) `2024.3.29`
|
||||
|
||||
分支名:`release/2.1.1`
|
||||
|
||||
- 新增:批量文档识别允许指定忽略区域的页数范围。
|
||||
- 新增:命令行`--path`允许传入多个图片或文件夹。
|
||||
- 新增:新的命令行、HTTP接口:识别/生成二维码图像。
|
||||
- 新增:命令行新指令 `--output`, `--output_append`, `--clip` 。
|
||||
- 新增:清空全部识别记录的快捷键。 (#420)
|
||||
- 新增:截图预览 删除图片显示的选项和快捷键。 (#427)
|
||||
- 修复:Esc取消截图的消息传递错误。 (#402)
|
||||
- 修复:文档`整页强制OCR`模式下,提高小尺寸页面的渲染精度。
|
||||
- 修复:Windows7及低端CPU无法进行文档识别的Bug。
|
||||
- 优化:开始菜单/开机自启快捷方式,默认放置于用户目录下,避免请求管理员权限。
|
||||
- 优化:保存双层PDF,在没有新文本写入时的处理逻辑。 (#416)
|
||||
- 优化:PDF解析时,提取原有文本行的逻辑。
|
||||
- 优化:双栏面板会记忆左右折叠状态。
|
||||
- UI:优化一些提示,修复一些渲染问题,美化一些视觉效果。
|
||||
|
||||
### [v2.1.0](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.1.0) `2024.2.29`
|
||||
|
||||
分支名:`release/2.1.0`
|
||||
|
||||
- 新增:批量文档识别功能。支持 `pdf, epub, mobi` 等格式。
|
||||
- 新增:允许隐藏托盘图标。 (#338)
|
||||
- 新增:重复上一次截图区域的快捷键。 (#357)
|
||||
- 新增:用本地图片浏览器打开图片的快捷按钮。 (#335)
|
||||
- 更新:更强大的排版解析器。
|
||||
- 修复:避免系统环境变量`QMLSCENE_DEVICE`的影响。 (#270)
|
||||
|
||||
### [v2.0.2](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.0.2) `2024.1.15`
|
||||
|
||||
分支名:`release/2.0.2`
|
||||
|
||||
- 更新:全局设置可调节 图片文字叠加层`开启/关闭`默认显示状态。 (#264)
|
||||
- 优化:输出为`txt 单独文件`时,将应用`指定路径`参数。 (#269)
|
||||
- 优化:`段落合并-多行-代码段` 去除结尾多余换行符。 (#292)
|
||||
- 优化:渲染器不兼容时,减少渲染层级错误的影响。 (#259)
|
||||
- 修复:清理图片缓存前进行检查,避免空图错误。 (#279)
|
||||
- 修复:记录面板中,光标无法移到第1个字符前面。 (#264)
|
||||
- 修复:系统语言非简体中文时,软件启动异常。 (#274) (#306)
|
||||
- 修复:csv输出的字符编码兼容性问题。 (#284)
|
||||
- 修复:`段落合并参数不存在` 的误报。
|
||||
- 修复:组件`DefaultTips`不生效。
|
||||
|
||||
### [v2.0.1](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.0.1) `2023.12.8`
|
||||
|
||||
分支名:`release/2.0.1`
|
||||
|
||||
- 更新:重新设计了OCR HTTP接口,允许省略参数,允许指定段落合并。
|
||||
- 更新:命令行增加指令`-->`和`-->>`,将结果输出到文件。
|
||||
- 优化:调整图像数据的内部编码,减少英文空格丢失的几率。
|
||||
- 优化:调整部分UI文本和布局。
|
||||
- 优化:csv默认保存为ansi编码,以兼容Office Excel。 (#237)
|
||||
- 修复:开启“禁用美化效果”后,外部通知弹窗无法关闭。 (#234)
|
||||
- 修复:别的程序通过命令行调用Umi-OCR时,无法获取stdout输出。
|
||||
|
||||
### [v2.0.0](https://github.com/hiroi-sora/Umi-OCR/tree/release/2.0.0) `2023.11.19`
|
||||
|
||||
分支名:`release/2.0.0`
|
||||
|
||||
- 优化:插件UI翻译机制。
|
||||
- 优化:下拉框UI。
|
||||
- 修复:段落合并-自然段合并的bug。
|
||||
|
||||
### v2.0.0 dev `2023.11.14`
|
||||
|
||||
- 新增:生成二维码功能。
|
||||
- 新增:所有图片预览窗口(如截图、二维码页),允许保存图片到指定路径。
|
||||
- 优化:二维码解析库改用性能更好、功能更丰富的zxingcpp。 ([v2 #47](https://github.com/hiroi-sora/Umi-OCR_v2/issues/47)) (感谢:@Byxs20)
|
||||
- 优化:截图预览面板中,文本框的位置更准确。
|
||||
- 修复:图片预览窗口,无法复制本地图片的Bug。
|
||||
- 修复:tbpu合并自然段时,垂直距离不准确的Bug。
|
||||
- 修复:HTTP API 的跨域问题。 [v2 #52](https://github.com/hiroi-sora/Umi-OCR_v2/issues/52)
|
||||
- 修复:HTTP API 传base64的大小限制问题。 [v2 #49](https://github.com/hiroi-sora/Umi-OCR_v2/issues/49)
|
||||
- 修复:其它少量报错。
|
||||
- 翻译:人工校对`繁体中文`和`英语`。 (贡献:@QZGao)
|
||||
|
||||
### v2.0.0 dev `2023.11.5`
|
||||
|
||||
- 新增:记忆窗口位置。 [v2 #44](https://github.com/hiroi-sora/Umi-OCR_v2/issues/44)
|
||||
- 新增:批量识图页增加图片预览窗口,单击图片条目打开。 [v2 #2](https://github.com/hiroi-sora/Umi-OCR_v2/issues/2)
|
||||
- 新增:检查软件是否有权限读写配置文件。 [v2 #30](https://github.com/hiroi-sora/Umi-OCR_v2/issues/30)
|
||||
- 新增:报错弹窗提供一键复制及打开issues的功能。
|
||||
- 新增:全局设置页添加左侧目录栏。
|
||||
- 新增:插件的多国语言UI机制。
|
||||
- 优化:截图预览面板中,文本框的位置更准确。
|
||||
- 优化:调整部分UI布置。
|
||||
- 修复:扫码模块添加导入异常检查。 [v2 #33](https://github.com/hiroi-sora/Umi-OCR_v2/issues/33)
|
||||
- 修复:补充扫码页的拖入图片功能。 [v2 #43](https://github.com/hiroi-sora/Umi-OCR_v2/issues/43)
|
||||
- 修复:输出到单独文件txt时,文件名去除原后缀。 [v2 #36](https://github.com/hiroi-sora/Umi-OCR_v2/issues/36)
|
||||
- 修复:一些小Bug。
|
||||
|
||||
### v2.0.0 dev `2023.10.25`
|
||||
- 新增:命令行支持传入图片路径。 [v2 #28](https://github.com/hiroi-sora/Umi-OCR_v2/issues/28)
|
||||
- 新增:HTTP接口支持Base64传输图片。 [v2 #28](https://github.com/hiroi-sora/Umi-OCR_v2/issues/28)
|
||||
- 新增:忽略区域功能。
|
||||
- 新增:二维码识别页。支持识别多种格式的二维码、条形码。 ([Umi-OCR #95](https://github.com/hiroi-sora/Umi-OCR/issues/95))
|
||||
- 新增:提供备选启动器`UmiOCR-data/RUN_GUI.bat`,供`Umi-OCR.exe`不兼容时使用。 [v2 #21](https://github.com/hiroi-sora/Umi-OCR_v2/issues/21)
|
||||
- 优化:图片预览窗口,支持用`Tab`切换显示/隐藏文本。
|
||||
- 优化:记录面板,每条记录顶部添加复制按钮。 [v2 #32](https://github.com/hiroi-sora/Umi-OCR_v2/issues/32)
|
||||
- 优化:记录面板,拖拽过程中允许指针移出文本框区域。 [v2 #32](https://github.com/hiroi-sora/Umi-OCR_v2/issues/32)
|
||||
- 优化:重新设计截图缓存机制,避免Image组件销毁时的内存泄露。
|
||||
- 优化:标签页应用动态解析机制,小幅提高加载速度。
|
||||
- 优化:运行环境转为64位包。(计划不再提供对32位的兼容)
|
||||
- 修正:配置项中布尔值解析不正确的问题。 [v2 #30](https://github.com/hiroi-sora/Umi-OCR_v2/issues/30)
|
||||
- 修正:拖入非图片文件可能导致卡顿几秒的问题。
|
||||
- 修正:PaddleOCR插件的兼容性问题。 ([Umi-OCR #209](https://github.com/hiroi-sora/Umi-OCR/issues/209))
|
||||
|
||||
### v2.0.0 dev `2023.10.18`
|
||||
- 新增:截图前自动隐藏窗口。 [v2 #26](https://github.com/hiroi-sora/Umi-OCR_v2/issues/26)
|
||||
- 新增:更改字体功能。 [v2 #25](https://github.com/hiroi-sora/Umi-OCR_v2/issues/25)
|
||||
- 新增:可爱的加载动画。
|
||||
- 新增:截图预览面板 支持显示结果文本、划选文本。
|
||||
- 新增:截图预览面板 支持将图片复制到剪贴板。
|
||||
- 新增:结果记录面板 支持跨文本框划选文本。 [v2 #18](https://github.com/hiroi-sora/Umi-OCR_v2/issues/18)
|
||||
- 新增:结果记录面板 支持删除一条或多条记录。 [v2 #10](https://github.com/hiroi-sora/Umi-OCR_v2/issues/10)
|
||||
- 新增:支持用Esc或右键中断截图。
|
||||
- 优化:更改插件目录结构和导入机制。
|
||||
- 修正:文件重复导致无法添加开机自启。 [v2 #27](https://github.com/hiroi-sora/Umi-OCR_v2/issues/27)
|
||||
|
||||
|
||||
### v2.0.0 dev `2023.10.10`
|
||||
- 新功能:第一次启动软件时,根据系统情况,选择最恰当的渲染器。解决截图闪烁问题。 [v2 #7](https://github.com/hiroi-sora/Umi-OCR_v2/issues/7)
|
||||
- 新功能:初步实现插件机制,切换引擎等组件更加便捷。
|
||||
- 新功能:支持调整界面比例(文字大小)。
|
||||
- 优化:调整截图页UI,提高屏占比。优化标签栏阴影。 [v2 #8](https://github.com/hiroi-sora/Umi-OCR_v2/issues/8)
|
||||
- 优化:双击通知弹窗可打开主窗口。 [v2 #10](https://github.com/hiroi-sora/Umi-OCR_v2/issues/10)
|
||||
- 优化:截图完成后,如果主窗口在前台,则不弹出成功提示。 [v2 #10](https://github.com/hiroi-sora/Umi-OCR_v2/issues/10)
|
||||
- 优化:禁用美化效果时,外部弹窗将不会渲染阴影区域。 [v2 #14](https://github.com/hiroi-sora/Umi-OCR_v2/issues/14)
|
||||
- 优化:Paddle引擎也支持win7系统了。
|
||||
|
||||
### v2.0.0 dev `2023.9.25`
|
||||
|
||||
### v2.0.0 dev `2023.9.8`
|
||||
- 支持多种界面语言(实验性)
|
||||
|
||||
### v2.0.0 dev `2023.9.7`
|
||||
|
||||
### v2.0.0 dev `2023.8.9`
|
||||
- 截图OCR
|
||||
- 兼容高分辨率屏幕和多屏幕系统
|
||||
- 更准确、智能的段落合并
|
||||
|
||||
### v2.0.0 dev `2023.7.26`
|
||||
- 批量OCR
|
||||
- 现代化UI风格
|
||||
- 自定义标签页系统
|
||||
- 主题切换:明亮/深色
|
||||
|
||||
---
|
||||
|
||||
### [v1.3.7](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.3.7) `2023.10.10`
|
||||
- Paddle引擎兼容Win7 x64 。
|
||||
|
||||
### v1.3.6 `2023.9.26`
|
||||
- 新功能:更强大的段落合并方案-`单行/多行自然段/多行代码段`。支持自动判断中/英文段落,采取对应的合并规则。
|
||||
- 移除一些过时的段落合并方案。
|
||||
- 功能调整:`截图联动` 划分为独立的功能,不受常规截图OCR影响。
|
||||
|
||||
### [v1.3.5](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.3.5) `2023.6.20`
|
||||
<!-- 6.5k★ 撒花~ -->
|
||||
- 新功能:复制识别结果后,可发送指定按键,以便联动唤起翻译器等工具。
|
||||
- 新功能:命令行增加切换识别语言的指令。
|
||||
- 修Bug:低配置机器上有概率误报`OCR init timeout: 5s` 。[#154](https://github.com/hiroi-sora/Umi-OCR/issues/154) , [#156](https://github.com/hiroi-sora/Umi-OCR/issues/156)。
|
||||
- 调整:默认停止任务30秒后释放一次内存。
|
||||
|
||||
### [v1.3.4](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.3.4) `2023.4.26`
|
||||
<!-- 一周年纪念! -->
|
||||
- 新功能:截图预览窗口。
|
||||
- 新功能:可用方向键微调截图框位置。
|
||||
- 修Bug:拖入图片时有几率卡退主窗口 [issue #126](https://github.com/hiroi-sora/Umi-OCR/issues/126) 。
|
||||
- 优化了一些处理流程。
|
||||
|
||||
### [v1.3.3](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.3.3) `2023.3.19`
|
||||
<!-- 4.5k★ 撒花~ -->
|
||||
- 新功能:命令行模式。
|
||||
- 新功能:识图完成的通知悬浮窗。
|
||||
- 新功能:自动清理引擎内存。
|
||||
- 修复了一些BUG,优化了一些UI表现。
|
||||
|
||||
### [v1.3.2](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.3.2) `2022.12.1`
|
||||
<!-- 3k★ 撒花~ -->
|
||||
- 新功能:创建开机启动项时,可选`不显示主窗口`。
|
||||
- 新功能:OCR结果输出到每个图片同名的单独txt文件。
|
||||
- 新功能:增加独立的设置语言窗口,可在多处点开,便于切换语言。
|
||||
- 新功能:合并段落添加`合并自然段-西文模式`,可在英文段落换行时补充空格。
|
||||
- 新功能:快捷识图可选`自动清空面板`,只显示本次识别结果,且隐藏时间信息。
|
||||
- 修复了一些BUG。
|
||||
|
||||
### [v1.3.1](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.3.1) `2022.11.4`
|
||||
<!-- 2k★ 撒花~ -->
|
||||
- 修Bug:快捷键模块重写,引入pynput库,舍弃keyboard库,解决几率失效、录制不正确等Bug。
|
||||
- 新功能:添加开机自启,桌面快捷方式,开始菜单快捷方式。
|
||||
- 新功能:多开软件时提示。
|
||||
- 新功能:截图时隐藏窗口。
|
||||
- 调整UI:使用频率极低的设置项设为隐藏的高级选项。
|
||||
- 优化:检查引擎组件是否存在。
|
||||
- 优化:`横排-合并多行-自然段` 优化逻辑,支持0~2全角空格首行缩进。
|
||||
|
||||
### [v1.3.0](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.3.0) `2022.9.29`
|
||||
- 新功能:框选截屏。
|
||||
- 新功能:系统托盘图标。
|
||||
- 新功能:引擎进程常驻。
|
||||
- 新功能:文本块后处理模块。
|
||||
- 新功能:自定义主输出栏字体。
|
||||
- 新功能:设置窗口弹出模式(保持置顶)。
|
||||
- 调整UI:自适应Win风格组件。
|
||||
- 修正了Bug:系统语言兼容性问题 [issue #16](https://github.com/hiroi-sora/Umi-OCR/issues/16) 。
|
||||
- 修正了Bug:微信图片粘贴问题 [issue #22](https://github.com/hiroi-sora/Umi-OCR/issues/22) 。
|
||||
- 更新PaddleOCR-json模块至`v1.2.1`,提供剪贴板支持。快捷识图通过剪贴板中转,无需再保存临时文件到硬盘。
|
||||
|
||||
### [v1.2.6](https://github.com/hiroi-sora/Umi-OCR/tree/release/1.2.6) `2022.9.1`
|
||||
<!-- Takahara Umi酱生日快乐~ -->
|
||||
- 更新PaddleOCR-json模块至`v1.2.0`,提高识别速度、准确度。
|
||||
- 调整UI:更方便地用下拉框切换识别语言。
|
||||
- 调整UI:可以从主窗口任意位置/任意选项卡拖入图片。
|
||||
- 修正了Bug:提高程序健壮性,增加启动子进程时的更多异常处理情况。
|
||||
- 修正了Bug:彻底解决了对边缘过窄的图片,识别结果不准确的问题 [issue #7](https://github.com/hiroi-sora/Umi-OCR/issues/7) 。
|
||||
- 优化适配PP-OCRv3模型,彻底解决了v3版模型比v2慢、不准的问题 [issue #4](https://github.com/hiroi-sora/Umi-OCR/issues/4#issuecomment-1141735773) 。
|
||||
|
||||
### v1.2.5 `2022.7.22`
|
||||
- 新功能:计划任务。识图完成后执行自动关机等任务。
|
||||
- 新功能:可选拖入文件夹时递归导入子文件夹中所有图片。
|
||||
- 调整UI:添加一些配置文件的快捷入口。
|
||||
|
||||
### v1.2.4 `2022.6.4`
|
||||
- 新功能:可选识别剪贴板图片后自动复制识别的文本。
|
||||
- 补充功能:快捷键调用剪贴板识图时,若程序窗口被最小化,则恢复前台状态并挪到最前位置。
|
||||
|
||||
### v1.2.3 `2022.5.31`
|
||||
- 新功能:读取剪贴板图片。配置全局快捷键调用该功能。
|
||||
|
||||
### v1.2.2 `2022.4.30`
|
||||
- 新功能:可选任务完成后自动打开输出文件或目录。
|
||||
|
||||
### v1.2.1 `2022.4.16`
|
||||
- 更新PaddleOCR-json模块至`v1.1.1`,修正了可能得到错误包围盒的漏洞。
|
||||
|
||||
### v1.2.0 `2022.4.8`
|
||||
- 可选生成图文链接.md文件,作为索引使用有更佳的观感。
|
||||
- 修改设置面板的样式,改为滚动面板以容纳更多设置选项。
|
||||
- 用户修改配置项后可自动保存。
|
||||
|
||||
### v1.1.1 `2022.3.30`
|
||||
- 修正了Bug:退出忽略区域窗口时,OCR子进程未关闭。
|
||||
|
||||
### v1.1.0 `2022.3.30`
|
||||
- 新功能:忽略区域窗口以虚线框 展示识别出的文字块。
|
||||
|
||||
### v1.0.0 `2022.3.28`
|
||||
- “梦开始的地方”
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 hiroi-sora
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,409 @@
|
||||
<p align="left">
|
||||
<span>
|
||||
<b>中文</b>
|
||||
</span>
|
||||
<span> • </span>
|
||||
<a href="README_en.md">
|
||||
English
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="README_ja.md">
|
||||
日本語
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR">
|
||||
<img width="200" height="128" src="https://tupian.li/images/2022/10/27/icon---256.png" alt="Umi-OCR">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Umi-OCR 文字识别工具</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/hiroi-sora/Umi-OCR?style=flat-square" alt="Umi-OCR">
|
||||
</a>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/hiroi-sora/Umi-OCR?style=flat-square" alt="LICENSE">
|
||||
</a>
|
||||
<a href="#下载发行版">
|
||||
<img src="https://img.shields.io/github/downloads/hiroi-sora/Umi-OCR/total?style=flat-square" alt="forks">
|
||||
</a>
|
||||
<a href="https://star-history.com/#hiroi-sora/Umi-OCR">
|
||||
<img src="https://img.shields.io/github/stars/hiroi-sora/Umi-OCR?style=flat-square" alt="stars">
|
||||
</a>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/forks">
|
||||
<img src="https://img.shields.io/github/forks/hiroi-sora/Umi-OCR?style=flat-square" alt="forks">
|
||||
</a>
|
||||
<a href="https://hosted.weblate.org/engage/umi-ocr/">
|
||||
<img src="https://hosted.weblate.org/widget/umi-ocr/svg-badge.svg" alt="翻译状态">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="#目录">
|
||||
使用说明
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="#下载发行版">
|
||||
下载地址
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="CHANGE_LOG.md">
|
||||
更新日志
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/issues">
|
||||
提交Bug
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
<strong>免费,开源,可批量的离线OCR软件</strong><br>
|
||||
<sub>适用于 Windows7 x64 、Linux x64
|
||||
</div><br>
|
||||
|
||||
- **免费**:本项目所有代码开源,完全免费。
|
||||
- **方便**:解压即用,离线运行,无需网络。
|
||||
- **高效**:自带高效率的离线OCR引擎,内置多种语言识别库。
|
||||
- **灵活**:支持命令行、HTTP接口等外部调用方式。
|
||||
- **功能**:截图OCR / 批量OCR / PDF识别 / 二维码 / 公式识别
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/65599097ab5f4.png" alt="1-标题-1.png" style="width: 80%;"></p>
|
||||
|
||||

|
||||
|
||||
## 目录
|
||||
|
||||
- [截图识别](#截图OCR)
|
||||
- [排版解析](#文本后处理) - 识别不同排版,按正确顺序输出文字
|
||||
- [批量识别](#批量OCR)
|
||||
- [忽略区域](#忽略区域) - 排除截图水印处的文字
|
||||
- [二维码](#二维码) 支持扫码或生成二维码图片
|
||||
- [文档识别](#文档识别) 从PDF扫描件中提取文本,或转为双层可搜索PDF
|
||||
- [全局设置](#全局设置)
|
||||
- [命令行调用](docs/README_CLI.md)
|
||||
- [HTTP接口](docs/http/README.md)
|
||||
- [构建项目(Windows、Linux)](#构建项目)
|
||||
|
||||
## 使用源码
|
||||
|
||||
开发者请务必阅读 [构建项目](#构建项目) 。
|
||||
|
||||
## 下载发行版
|
||||
|
||||
以下发布链接均长期维护,提供稳定版本的下载。
|
||||
|
||||
- **蓝奏云** https://hiroi-sora.lanzoul.com/s/umi-ocr (国内推荐,免注册/无限速)
|
||||
- **GitHub** https://github.com/hiroi-sora/Umi-OCR/releases/latest
|
||||
- **Source Forge** https://sourceforge.net/projects/umi-ocr
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>• Scoop Installer</b>(点击展开)</summary>
|
||||
|
||||
[Scoop](https://scoop.sh/) 是一款Windows下的命令行安装程序,可方便地管理多个应用。您可以先安装 Scoop ,再使用以下指令安装 `Umi-OCR` :
|
||||
|
||||
- 添加 `extras` 桶:
|
||||
```
|
||||
scoop bucket add extras
|
||||
```
|
||||
|
||||
- (可选1)安装 Umi-OCR(自带 `Rapid-OCR` 引擎,兼容性好):
|
||||
```
|
||||
scoop install extras/umi-ocr
|
||||
```
|
||||
|
||||
- (可选2)安装 Umi-OCR(自带 `Paddle-OCR` 引擎,速度稍快):
|
||||
```
|
||||
scoop install extras/umi-ocr-paddle
|
||||
```
|
||||
|
||||
- 不要同时安装二者,快捷方式可能会被覆盖。但您可以额外导入 [插件](https://github.com/hiroi-sora/Umi-OCR_plugins) ,随时切换不同OCR引擎。
|
||||
|
||||
</details>
|
||||
</br>
|
||||
|
||||
## 开始使用
|
||||
|
||||
软件发布包下载为 `.7z` 压缩包或 `.7z.exe` 自解压包。自解压包可在没有安装压缩软件的电脑上,解压文件。
|
||||
|
||||
本软件无需安装。解压后,点击 `Umi-OCR.exe` 即可启动程序。
|
||||
|
||||
遇到任何问题,请提 [Issue](https://github.com/hiroi-sora/Umi-OCR/issues) ,我会尽可能帮助你。
|
||||
|
||||
## 界面语言
|
||||
|
||||
Umi-OCR 支持的界面多国语言。在第一次打开软件时,将会按照你的电脑的系统设置,自动切换语言。
|
||||
|
||||
如果需要手动切换语言,请参考下图,`全局设置`→`语言/Language` 。
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/65599c3f9e600.png" alt="1-标题-1.png" style="width: 80%;"></p>
|
||||
|
||||
## 标签页
|
||||
|
||||
Umi-OCR v2 由一系列灵活好用的**标签页**组成。您可按照自己的喜好,打开需要的标签页。
|
||||
|
||||
标签栏左上角可以切换**窗口置顶**。右上角能够**锁定标签页**,以防止日常使用中误触关闭标签页。
|
||||
|
||||
### 截图OCR
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/65599097aba8e.png" alt="2-截图-1.png" style="width: 80%;"></p>
|
||||
|
||||
**截图OCR**:打开这一页后,就可以用快捷键唤起截图,识别图中的文字。
|
||||
- 左侧的图片预览栏,可直接用鼠标划选复制。
|
||||
- 右侧的识别记录栏,可以编辑文字,允许划选多个记录复制。
|
||||
- 也支持在别处复制图片,粘贴到Umi-OCR进行识别。
|
||||
- 关于 [公式识别](https://github.com/hiroi-sora/Umi-OCR/issues/254) 功能
|
||||
|
||||
#### 文本后处理
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/6559909f3e378.png" alt="2-截图-2.png" style="width: 80%;"></p>
|
||||
|
||||
关于 **OCR文本后处理 - 排版解析方案**: 可以整理OCR结果的排版和顺序,使文本更适合阅读和使用。预设方案:
|
||||
- `多栏-按自然段换行`:适合大部分情景,自动识别多栏布局,按自然段规则进行换行。
|
||||
- `多栏-总是换行`:每段语句都进行换行。
|
||||
- `多栏-无换行`:强制将所有语句合并到同一行。
|
||||
- `单栏-按自然段换行`/`总是换行`/`无换行`:与上述类似,不过 不区分多栏布局。
|
||||
- `单栏-保留缩进`:适用于解析代码截图,保留行首缩进和行中空格。
|
||||
- `不做处理`:OCR引擎的原始输出,默认每段语句都进行换行。
|
||||
|
||||
上述方案,均能自动处理横排和竖排(从右到左)的排版。(竖排文字还需要OCR引擎本身支持)
|
||||
|
||||
---
|
||||
|
||||
### 批量OCR
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/655990a2511e0.png" alt="3-批量-1.png" style="width: 80%;"></p>
|
||||
|
||||
**批量OCR**:这一页用于批量导入本地图片进行识别。
|
||||
- 支持格式:`jpg, jpe, jpeg, jfif, png, webp, bmp, tif, tiff`。
|
||||
- 保存识别结果的支持格式:`txt, jsonl, md, csv(Excel)`。
|
||||
- 与截图OCR一样,支持`文本后处理`功能,整理OCR文本的排版和顺序。
|
||||
- 没有数量上限,可一次性导入几百张图片进行任务。
|
||||
- 支持任务完成后自动关机/待机。
|
||||
- 如果要识别像素超大的长图或大图,请调整:**页面的设置→文字识别→限制图像边长→【调高数值】**。
|
||||
- 拥有特殊功能 `忽略区域` 。
|
||||
|
||||
#### 忽略区域
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/6559911d28be7.png" alt="3-批量-2.png" style="width: 80%;"></p>
|
||||
|
||||
关于 **OCR文本后处理 - 忽略区域**: 批量OCR中的一种特殊功能,适用于排除图片中的不想要的文字。
|
||||
- 在批量识别页的右栏设置中可进入忽略区域编辑器。
|
||||
- 如上方样例,图片顶部和右下角存在多个水印 / LOGO。如果批量识别这类图片,水印会对识别结果造成干扰。
|
||||
- 按住右键,绘制多个矩形框。这些区域内的文字将在任务中被忽略。
|
||||
- 请尽量将矩形框画得大一些,完全包裹住水印所有可能出现的位置。
|
||||
- 注意,只有处于忽略区域框内部的整个文本块(而不是单个字符)会被忽略。如下图所示,黄色边框的深色矩形是一个忽略区域。那么只有`key_mouse`才会被忽略。`pubsub_connector.py`、`pubsub_service.py` 这两个文本块得以保留。
|
||||
<p align="center"><img src="https://tupian.li/images/2024/05/30/66587bf03ae15.png" alt="忽略区域范围示例.png" style="width: 80%;"></p>
|
||||
|
||||
---
|
||||
|
||||
### 文档识别
|
||||
|
||||
<p align="center"><img src="https://github.com/hiroi-sora/Umi-OCR/assets/56373419/fc2266ee-b9b7-4079-8b10-6610e6da6cf5" alt="" style="width: 80%;"></p>
|
||||
|
||||
**文档识别**:
|
||||
- 支持格式:`pdf, xps, epub, mobi, fb2, cbz`。
|
||||
- 对扫描件进行OCR,或提取原有文本。可输出为 **双层可搜索PDF** 。
|
||||
- 支持设定 **忽略区域** ,可用于排除页眉页脚的文字。
|
||||
- 可设置任务完成后 **自动关机/休眠** 。
|
||||
|
||||
---
|
||||
|
||||
### 二维码
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/655991268d6b1.png" alt="4-二维码-1.png" style="width: 80%;"></p>
|
||||
|
||||
**扫码**:
|
||||
- 截图/粘贴/拖入本地图片,读取其中的二维码、条形码。
|
||||
- 支持一图多码。
|
||||
- 支持19种协议,如下:
|
||||
|
||||
`Aztec`,`Codabar`,`Code128`,`Code39`,`Code93`,`DataBar`,`DataBarExpanded`,`DataMatrix`,`EAN13`,`EAN8`,`ITF`,`LinearCodes`,`MatrixCodes`,`MaxiCode`,`MicroQRCode`,`PDF417`,`QRCode`,`UPCA`,`UPCE`
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/6559911cda737.png" alt="4-二维码-2.png" style="width: 80%;"></p>
|
||||
|
||||
**生成码**:
|
||||
- 输入文本,生成二维码图片。
|
||||
- 支持19种协议和**纠错等级**等参数。
|
||||
|
||||
---
|
||||
|
||||
### 全局设置
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/655991252e780.png" alt="5-全局设置-1.png" style="width: 80%;"></p>
|
||||
|
||||
**全局设置**:在这里可以调整软件的全局参数。常用功能如下:
|
||||
- 一键添加快捷方式或设置开机自启。
|
||||
- 更改界面**语言**。Umi支持繁中、英语、日语等语言。
|
||||
- 切换界面**主题**。Umi拥有多个亮/暗主题。
|
||||
- 调整界面**文字的大小**和**字体**。
|
||||
- 切换OCR插件。
|
||||
- **渲染器**:软件界面默认支持显卡加速渲染。如果在你的机器上出现截屏闪烁、UI错位的情况,请调整`界面和外观` → `渲染器` ,尝试切换到不同渲染方案,或关闭硬件加速。
|
||||
|
||||
## 调用接口:
|
||||
|
||||
- [命令行手册](docs/README_CLI.md)
|
||||
- [HTTP接口手册](docs/http/README.md)
|
||||
|
||||
---
|
||||
|
||||
## 关于项目结构
|
||||
|
||||
### 各仓库:
|
||||
|
||||
- [主仓库](https://github.com/hiroi-sora/Umi-OCR) 👈
|
||||
- [插件库](https://github.com/hiroi-sora/Umi-OCR_plugins)
|
||||
- [Windows 运行库](https://github.com/hiroi-sora/Umi-OCR_runtime_windows)
|
||||
- [Linux 运行库](https://github.com/hiroi-sora/Umi-OCR_runtime_linux)
|
||||
|
||||
### 工程结构:
|
||||
|
||||
`**` 后缀表示本仓库(`主仓库`)包含的内容。
|
||||
|
||||
```
|
||||
Umi-OCR
|
||||
├─ Umi-OCR.exe
|
||||
├─ umi-ocr.sh
|
||||
└─ UmiOCR-data
|
||||
├─ main.py **
|
||||
├─ version.py **
|
||||
├─ qt_res **
|
||||
│ └─ 项目qt资源,包括图标和qml源码
|
||||
├─ py_src **
|
||||
│ └─ 项目python源码
|
||||
├─ plugins
|
||||
│ └─ 插件
|
||||
└─ i18n **
|
||||
└─ 翻译文件
|
||||
```
|
||||
|
||||
支持的离线OCR引擎:
|
||||
|
||||
- [PaddleOCR-json](https://github.com/hiroi-sora/PaddleOCR-json)
|
||||
- [RapidOCR-json](https://github.com/hiroi-sora/RapidOCR-json)
|
||||
|
||||
运行环境框架:
|
||||
|
||||
- [PyStand](https://github.com/skywind3000/PyStand) 定制版
|
||||
|
||||
## 构建项目
|
||||
|
||||
请跳转下述仓库,完成对应平台的开发/运行环境部署。
|
||||
|
||||
- [Windows](https://github.com/hiroi-sora/Umi-OCR_runtime_windows)
|
||||
- [Linux](https://github.com/hiroi-sora/Umi-OCR_runtime_linux)
|
||||
|
||||
---
|
||||
|
||||
## 软件本地化翻译:
|
||||
|
||||
本项目使用 Weblate 平台进行UI界面的本地化翻译协作。我们欢迎任何译者参与翻译工作,您可进入此链接 [Weblate: Umi-OCR](https://hosted.weblate.org/engage/umi-ocr/) ,在线校对、补充现有语言,或添加新语言。
|
||||
|
||||
感谢以下译者,为 Umi-OCR 贡献了本地化翻译工作:
|
||||
|
||||
| 译者 | 贡献语言 |
|
||||
| ------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| [bob](https://hosted.weblate.org/user/q021) | English, 繁體中文, 日本語 |
|
||||
| [Qingzheng Gao](https://github.com/QZGao) | English, 繁體中文 |
|
||||
| [Weng, Chia-Ling](https://hosted.weblate.org/user/ChiaLingWeng) | English, 繁體中文 |
|
||||
| [linzow](https://hosted.weblate.org/user/linzow) | English, 繁體中文 |
|
||||
| [Marcos i](https://hosted.weblate.org/user/ultramarkorj9) | English, Português |
|
||||
| [Eric Guo](https://hosted.weblate.org/user/qwedc001) | English |
|
||||
| [steven0081](https://hosted.weblate.org/user/steven0081) | English |
|
||||
| [Brandon Cagle](https://hosted.weblate.org/user/random4t4x14) | English |
|
||||
| [plum7x](https://hosted.weblate.org/user/plum7x) | 繁體中文 |
|
||||
| [hugoalh](https://hosted.weblate.org/user/hugoalh) | 繁體中文 |
|
||||
| [Anarkiisto](https://hosted.weblate.org/user/Anarkiisto) | 繁體中文 |
|
||||
| [ドコモ光](https://hosted.weblate.org/user/umren190402) | 日本語 |
|
||||
| [杨鹏](https://hosted.weblate.org/user/ypf) | Português |
|
||||
| [Вячеслав Анатольевич Малышев](https://hosted.weblate.org/user/1969) | Русский |
|
||||
| [Muhammadyusuf Kurbonov](https://hosted.weblate.org/user/muhammadyusuf.kurbonov2002) | Русский |
|
||||
| [தமிழ்நேரம்](https://hosted.weblate.org/user/TamilNeram/) | தமிழ் |
|
||||
|
||||
如果有信息错误或人员缺漏,请在 [这个讨论](https://github.com/hiroi-sora/Umi-OCR/discussions/449) 中回复。
|
||||
|
||||
---
|
||||
|
||||
## 赞助
|
||||
|
||||
Umi-OCR 项目主要由作者 [hiroi-sora](https://github.com/hiroi-sora) 用业余时间在开发和维护。如果您喜欢这款软件,欢迎赞助。
|
||||
|
||||
- 国内用户可通过 [爱发电](https://afdian.com/a/hiroi-sora) 赞助作者。
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#hiroi-sora/Umi-OCR&Date)
|
||||
|
||||
## [更新日志](CHANGE_LOG.md)
|
||||
|
||||
## 开发计划
|
||||
|
||||
<details>
|
||||
<summary>已完成的工作</summary>
|
||||
|
||||
- 标签页框架。
|
||||
- OCR API控制器。
|
||||
- OCR 任务控制器。
|
||||
- 主题管理器,支持切换浅色/深色主题主题。
|
||||
- 实现 **批量OCR**。
|
||||
- 实现 **截图OCR**。
|
||||
- 快捷键机制。
|
||||
- 系统托盘菜单。
|
||||
- 文本块后处理(排版优化)。
|
||||
- 引擎内存清理。
|
||||
- 软件界面多国语言。
|
||||
- 命令行模式。
|
||||
- Win7兼容。
|
||||
- Excel(csv)输出格式。
|
||||
- `Esc`中断截图操作
|
||||
- 外置主题文件
|
||||
- 字体切换
|
||||
- 加载动画
|
||||
- 忽略区域。
|
||||
- 二维码识别。
|
||||
- 批量识别页面的图片预览窗口。
|
||||
- PDF识别。
|
||||
- 调用本地图片浏览器打开图片。 [#335](https://github.com/hiroi-sora/Umi-OCR/issues/335)
|
||||
- 重复上一次截图。 [#357](https://github.com/hiroi-sora/Umi-OCR/issues/357)
|
||||
- 修Bug:文档识别在Windows7系统的兼容性问题。
|
||||
- HTTP/命令行接口添加二维码识别/生成功能。 (#423)
|
||||
- 二维码接口的文档。
|
||||
- Linux 平台移植。
|
||||
- HTTP 文档识别接口。
|
||||
|
||||
</details>
|
||||
|
||||
<!-- ##### 正在进行的工作 -->
|
||||
|
||||
##### 远期计划
|
||||
|
||||
<details>
|
||||
<summary>展开</summary>
|
||||
|
||||
这些是预想中的功能,在开发初期已预留好接口,将在远期慢慢实现。
|
||||
|
||||
但开发途中受限于实际情况,可能更改功能设计、新增及取消功能。
|
||||
|
||||
- [ ] 重构底层插件机制。
|
||||
- [ ] 在线 OCR API 插件。
|
||||
- [ ] 独立的数学公式识别插件。
|
||||
- [ ] “数学公式”标签页,提供独立的数学公式识别/Latex渲染。
|
||||
- [ ] 检查更新机制。
|
||||
- [ ] 排版解析之外的文本后处理模块(如保留数字、半全角字符转换、文本纠错)。
|
||||
- [ ] 关键接口函数添加事件触发方式。
|
||||
|
||||
- 基于GPU的离线OCR。
|
||||
- 图片翻译
|
||||
- 离线翻译。
|
||||
- 固定区域识别。
|
||||
- 识别表格图片,输出为Excel。
|
||||
- 历史记录系统。
|
||||
- 兼容 MacOS / Ubuntu 等平台。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`hiroi-sora/Umi-OCR`
|
||||
- 原始仓库:https://github.com/hiroi-sora/Umi-OCR
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
<p align="left">
|
||||
<a href="README.md">
|
||||
中文
|
||||
</a>
|
||||
<span> • </span>
|
||||
<span>
|
||||
<b>English</b>
|
||||
</span>
|
||||
<span> • </span>
|
||||
<a href="README_ja.md">
|
||||
日本語
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR">
|
||||
<img width="200" height="128" src="https://tupian.li/images/2022/10/27/icon---256.png" alt="Umi-OCR">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Umi-OCR</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/hiroi-sora/Umi-OCR?style=flat-square" alt="Umi-OCR">
|
||||
</a>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/hiroi-sora/Umi-OCR?style=flat-square" alt="LICENSE">
|
||||
</a>
|
||||
<a href="#download-releases">
|
||||
<img src="https://img.shields.io/github/downloads/hiroi-sora/Umi-OCR/total?style=flat-square" alt="forks">
|
||||
</a>
|
||||
<a href="https://star-history.com/#hiroi-sora/Umi-OCR">
|
||||
<img src="https://img.shields.io/github/stars/hiroi-sora/Umi-OCR?style=flat-square" alt="stars">
|
||||
</a>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/forks">
|
||||
<img src="https://img.shields.io/github/forks/hiroi-sora/Umi-OCR?style=flat-square" alt="forks">
|
||||
</a>
|
||||
<a href="https://hosted.weblate.org/engage/umi-ocr/">
|
||||
<img src="https://hosted.weblate.org/widget/umi-ocr/svg-badge.svg" alt="翻译状态">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<h3>
|
||||
<a href="#usage">
|
||||
Usage
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="#download-releases">
|
||||
Download Releases
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="CHANGE_LOG.md">
|
||||
Changelog
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/issues">
|
||||
Issue a problem
|
||||
</a>
|
||||
</h3>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div align="center">
|
||||
<strong>Free, Open-source, Batch Offline OCR Software</strong><br>
|
||||
<sub>Compatible with Windows7 x64 and above</sub>
|
||||
</div><br>
|
||||
|
||||
- **Free**: All the code of this project is open-source and completely free.
|
||||
- **Convenient**: Unzip and use, run offline, no need for network.
|
||||
- **Efficient**: Comes with a highly efficient offline OCR engine. As long as the computer performance is sufficient, it can be faster than online OCR services.
|
||||
- **Flexible**: Supports customizable interface, and supports multiple calling methods such as command-line and HTTP API.
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ec301b55.png" alt="1-标题-1.png" style="width: 80%;"></p>
|
||||
|
||||

|
||||
|
||||
## Using the Source Code:
|
||||
|
||||
Developers should read [Building the Project](#Build-the-Project) before proceeding.
|
||||
|
||||
## Download Releases:
|
||||
|
||||
- **GitHub** https://github.com/hiroi-sora/Umi-OCR/releases/latest
|
||||
- **Source Forge** https://sourceforge.net/projects/umi-ocr
|
||||
- **Lanzou** https://hiroi-sora.lanzoul.com/s/umi-ocr
|
||||
|
||||
<details>
|
||||
<summary><b>• Scoop Installer</b> (Click to expand)</summary>
|
||||
|
||||
[Scoop](https://scoop.sh/) is a command-line installer for Windows that facilitates the management of multiple applications. You can install Scoop first and then use the following commands to install `Umi-OCR`:
|
||||
|
||||
- Add the `extras` bucket:
|
||||
```
|
||||
scoop bucket add extras
|
||||
```
|
||||
|
||||
- (Optional 1) Install Umi-OCR (comes with the `Rapid-OCR` engine, better compatibility):
|
||||
```
|
||||
scoop install extras/umi-ocr
|
||||
```
|
||||
|
||||
- (Optional 2) Install Umi-OCR (comes with the `Paddle-OCR` engine, slightly faster):
|
||||
```
|
||||
scoop install extras/umi-ocr-paddle
|
||||
```
|
||||
|
||||
- Do not install both, as the shortcuts may be overwritten. However, you can import additional [plugins](https://github.com/hiroi-sora/Umi-OCR_plugins) to switch between different OCR engines at any time.
|
||||
|
||||
</details>
|
||||
|
||||
## Getting Started
|
||||
|
||||
The software release package is available in `.7z` compressed format or as a self-extracting `.7z.exe` package. The self-extracting package can be used to extract files on a computer without compression software installed.
|
||||
|
||||
This software does not require installation. After extraction, simply click on `Umi-OCR.exe` to start the program.
|
||||
|
||||
If you encounter any problems, please submit an [Issue](https://github.com/hiroi-sora/Umi-OCR/issues) and I will do my best to assist you.
|
||||
|
||||
## Interface Language
|
||||
|
||||
Umi-OCR supports multiple languages for its interface. When you open the software for the first time, it will automatically switch to the language based on your computer's system settings.
|
||||
|
||||
If you need to manually switch languages, please refer to the following figure, `全局设置`→`语言/Language` .
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/65599c3f9e600.png" alt="1-标题-1.png" style="width: 80%;"></p>
|
||||
|
||||
## Tabbed Interface
|
||||
|
||||
Umi-OCR v2 is composed of a series of flexible and easy-to-use **tabbed interfaces**. You can open the required tabbed interface according to your preferences.
|
||||
|
||||
The top left corner of the tab bar can be used to switch **window always on top**. The top right corner can be used to **lock the tabbed interface** to prevent accidental closure during daily use.
|
||||
|
||||
### Screenshot OCR
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecb4dfb4.png" alt="2-截图-1.png" style="width: 80%;"></p>
|
||||
|
||||
**Screenshot OCR**: After opening this page, you can use a keyboard shortcut to capture a screenshot and recognize the text in the image.
|
||||
|
||||
- The left-side image preview panel allows you to select and copy text with your mouse.
|
||||
- The right-side recognition record panel allows you to edit text and select and copy multiple records.
|
||||
- It also supports copying images from elsewhere and pasting them into Umi-OCR for recognition.
|
||||
|
||||
#### Paragraph Merge
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecc62ac9.png" alt="2-截图-2.png" style="width: 80%;"></p>
|
||||
|
||||
About **OCR Text Post-Processing - Paragraph Merge**: This feature can organize the layout and order of OCR results to make the text more suitable for reading and use. The preset schemes are:
|
||||
|
||||
- **Single line**: Merge text on the same line, suitable for most scenarios.
|
||||
- **Multiple lines - natural paragraphs**: Intelligently recognize and merge text belonging to the same paragraph, suitable for most scenarios, as shown in the figure above.
|
||||
- **Multiple lines - code block**: Try to restore the original indentation and spacing of the text. Suitable for recognizing code snippets or scenes that require retaining spaces.
|
||||
- **Vertical layout**: Suitable for vertical layout. Needs to be used in conjunction with a model library that also supports vertical layout recognition.
|
||||
|
||||
---
|
||||
|
||||
### Batch OCR
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecdc5197.png" alt="3-批量-1.png" style="width: 80%;"></p>
|
||||
|
||||
**Batch OCR**: This page supports batch importing local images for recognition.
|
||||
|
||||
- The recognized content can be saved in various formats such as txt/jsonl/md/csv(Excel).
|
||||
- Supports `text post-processing` technology, which can recognize text belonging to the same natural paragraph and merge it. It also supports multiple processing schemes such as code blocks and vertical text.
|
||||
- There is no limit on the number of images that can be imported for processing at one time, and the software can automatically shut down or sleep after completing the task.
|
||||
|
||||
#### Ignore Regions
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecbc0021.png" alt="3-批量-2.png" style="width: 80%;"></p>
|
||||
|
||||
About **OCR Text Post-Processing - Ignore Regions**: This is a special function in batch OCR that is used to exclude unwanted text in images.
|
||||
|
||||
- The ignore region editor can be accessed in the right column of the batch recognition page settings.
|
||||
- As shown in the example above, there are multiple watermarks/LOGOs at the top and bottom right corner of the image. If these images are recognized in batches, the watermarks will interfere with the recognition results.
|
||||
- Hold down the right mouse button to draw multiple rectangular boxes. The text inside these areas will be ignored during the task.
|
||||
- Please try to draw the rectangular boxes larger, completely wrapping all possible positions of the watermark.
|
||||
|
||||
---
|
||||
|
||||
### Batch Documents OCR
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecc8bfd4.png" alt="" style="width: 80%;"></p>
|
||||
|
||||
---
|
||||
|
||||
### QR Code
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ed01f5b2.png" alt="4-二维码-1.png" style="width: 80%;"></p>
|
||||
|
||||
**Scan Code**:
|
||||
|
||||
- You can capture screenshots, paste, or drag local images to read QR codes and barcodes.
|
||||
- Supports multiple codes in one image.
|
||||
- Supports 19 protocols, as follows:
|
||||
|
||||
`Aztec`,`Codabar`,`Code128`,`Code39`,`Code93`,`DataBar`,`DataBarExpanded`,`DataMatrix`,`EAN13`,`EAN8`,`ITF`,`LinearCodes`,`MatrixCodes`,`MaxiCode`,`MicroQRCode`,`PDF417`,`QRCode`,`UPCA`,`UPCE`,
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ed001437.png" alt="4-二维码-2.png" style="width: 80%;"></p>
|
||||
|
||||
**Generate Code**:
|
||||
|
||||
- Enter text to generate a QR code image.
|
||||
- Supports 19 protocols and parameters such as **error correction level**.
|
||||
|
||||
---
|
||||
|
||||
### Global Settings
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ed16f4e0.png" alt="5-全局设置-1.png" style="width: 80%;"></p>
|
||||
|
||||
**Global Settings**: Here you can adjust the global parameters of the software. Common features include:
|
||||
|
||||
- One-click to add shortcuts or set auto-startup.
|
||||
- Change the interface **language**. Umi supports traditional Chinese, English, Japanese, and other languages.
|
||||
- Switch interface **themes**. Umi has multiple light/dark themes.
|
||||
- Adjust the **font size** and **font** of the interface text.
|
||||
- Switch OCR plugins.
|
||||
- **Renderer**: The software interface defaults to support GPU-accelerated rendering. If you encounter screen flickering or UI misalignment on your machine, please adjust `Interface and Appearance` → `Renderer`, try switching to different rendering schemes, or turn off hardware acceleration.
|
||||
|
||||
---
|
||||
|
||||
## API Usage:
|
||||
|
||||
- [Command-line manual](docs/README_CLI.md)
|
||||
- [HTTP API manual](docs/http/README.md)
|
||||
|
||||
## About Project Structure
|
||||
|
||||
### Repositories:
|
||||
|
||||
- [Main Repository](https://github.com/hiroi-sora/Umi-OCR) 👈
|
||||
- [Plugin Repository](https://github.com/hiroi-sora/Umi-OCR_plugins)
|
||||
- [Windows Runtime Library](https://github.com/hiroi-sora/Umi-OCR_runtime_windows)
|
||||
- [Linux Runtime Library](https://github.com/hiroi-sora/Umi-OCR_runtime_linux)
|
||||
|
||||
## Build the Project
|
||||
|
||||
- [Windows](https://github.com/hiroi-sora/Umi-OCR_runtime_windows)
|
||||
- [Linux](https://github.com/hiroi-sora/Umi-OCR_runtime_linux)
|
||||
|
||||
---
|
||||
|
||||
## Software Localization:
|
||||
|
||||
This project uses the Weblate platform for collaborative localization of the UI interface. We welcome translators to participate in the translation work. Please visit [Weblate: Umi-OCR](https://hosted.weblate.org/engage/umi-ocr/) to proofread and supplement existing languages, or add new languages online.
|
||||
|
||||
We extend our gratitude to the following translators for contributing to the localization of Umi-OCR:
|
||||
|
||||
| Translator | Contributed Languages |
|
||||
| ------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| [bob](https://hosted.weblate.org/user/q021) | English, 繁體中文, 日本語 |
|
||||
| [Qingzheng Gao](https://github.com/QZGao) | English, 繁體中文 |
|
||||
| [Weng, Chia-Ling](https://hosted.weblate.org/user/ChiaLingWeng) | English, 繁體中文 |
|
||||
| [linzow](https://hosted.weblate.org/user/linzow) | English, 繁體中文 |
|
||||
| [Marcos i](https://hosted.weblate.org/user/ultramarkorj9) | English, Português |
|
||||
| [Eric Guo](https://hosted.weblate.org/user/qwedc001) | English |
|
||||
| [steven0081](https://hosted.weblate.org/user/steven0081) | English |
|
||||
| [Brandon Cagle](https://hosted.weblate.org/user/random4t4x14) | English |
|
||||
| [plum7x](https://hosted.weblate.org/user/plum7x) | 繁體中文 |
|
||||
| [hugoalh](https://hosted.weblate.org/user/hugoalh) | 繁體中文 |
|
||||
| [Anarkiisto](https://hosted.weblate.org/user/Anarkiisto) | 繁體中文 |
|
||||
| [ドコモ光](https://hosted.weblate.org/user/umren190402) | 日本語 |
|
||||
| [杨鹏](https://hosted.weblate.org/user/ypf) | Português |
|
||||
| [Вячеслав Анатольевич Малышев](https://hosted.weblate.org/user/1969) | Русский |
|
||||
| [Muhammadyusuf Kurbonov](https://hosted.weblate.org/user/muhammadyusuf.kurbonov2002) | Русский |
|
||||
| [தமிழ்நேரம்](https://hosted.weblate.org/user/TamilNeram/) | தமிழ் |
|
||||
|
||||
If you find any errors in the information or missing contributors, please reply in [this discussion](https://github.com/hiroi-sora/Umi-OCR/discussions/449).
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#hiroi-sora/Umi-OCR&Date)
|
||||
|
||||
## [CHANGE LOG](CHANGE_LOG.md)
|
||||
+256
@@ -0,0 +1,256 @@
|
||||
<p align="left">
|
||||
<a href="README.md">
|
||||
中文
|
||||
</a>
|
||||
<span> • </span>
|
||||
<a href="README_en.md">
|
||||
English
|
||||
</a>
|
||||
<span> • </span>
|
||||
<span>
|
||||
<b>日本語</b>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR">
|
||||
<img width="200" height="128" src="https://tupian.li/images/2022/10/27/icon---256.png" alt="Umi-OCR">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Umi-OCR</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/hiroi-sora/Umi-OCR?style=flat-square" alt="Umi-OCR">
|
||||
</a>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/hiroi-sora/Umi-OCR?style=flat-square" alt="LICENSE">
|
||||
</a>
|
||||
<a href="#ダウンロード">
|
||||
<img src="https://img.shields.io/github/downloads/hiroi-sora/Umi-OCR/total?style=flat-square" alt="ダウンロード">
|
||||
</a>
|
||||
<a href="https://star-history.com/#hiroi-sora/Umi-OCR">
|
||||
<img src="https://img.shields.io/github/stars/hiroi-sora/Umi-OCR?style=flat-square" alt="スター">
|
||||
</a>
|
||||
<a href="https://github.com/hiroi-sora/Umi-OCR/forks">
|
||||
<img src="https://img.shields.io/github/forks/hiroi-sora/Umi-OCR?style=flat-square" alt="フォーク">
|
||||
</a>
|
||||
<a href="https://hosted.weblate.org/engage/umi-ocr/">
|
||||
<img src="https://hosted.weblate.org/widget/umi-ocr/svg-badge.svg" alt="翻译状态">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<strong>無料、オープンソース、大量文字が一括認識できるのソフトウェア</strong><br>
|
||||
<sub>Windows7 x64以上と互換性があります</sub>
|
||||
</div><br>
|
||||
|
||||
- **無料**: このプロジェクトのすべてのコードはオープンソースで完全に無料です。
|
||||
- **便利**: 解凍して使用し、オフラインで実行し、ネットワークは必要ありません。
|
||||
- **効率的**: 高効率のオフライン OCR エンジンが付属しています。コンピュータのパフォーマンスが十分であれば、オンライン OCR サービスよりも速くなることがあります。
|
||||
- **柔軟**: カスタマイズ可能なインターフェースをサポートし、コマンドラインや HTTP API など、複数の呼び出し方法をサポートします。
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ec301b55.png" alt="1-タイトル-1.png" style="width: 80%;"></p>
|
||||
|
||||

|
||||
|
||||
## ソースコードの使用:
|
||||
|
||||
開発者は続ける前に、[プロジェクトのビルド](#プロジェクトのビルド)を読んでください
|
||||
|
||||
## リリースのダウンロード:
|
||||
|
||||
- **GitHub** https://github.com/hiroi-sora/Umi-OCR/releases/latest
|
||||
- **Source Forge** https://sourceforge.net/projects/umi-ocr
|
||||
- **Lanzou (蓝奏云)** https://hiroi-sora.lanzoul.com/s/umi-ocr
|
||||
|
||||
<details>
|
||||
<summary><b>• Scoop インストーラー</b></summary>
|
||||
|
||||
[Scoop](https://scoop.sh/) はWindowsで動作するコマンドラインインストーラーで、複数のアプリケーションを簡単に管理できます。まず Scoop をインストールし、次に以下のコマンドを使用して `Umi-OCR` をインストールしてください:
|
||||
|
||||
- `extras` バケットを追加:
|
||||
```
|
||||
scoop bucket add extras
|
||||
```
|
||||
|
||||
- (オプション1)Umi-OCR をインストール( `Rapid-OCR` エンジン付属、互換性が高い):
|
||||
```
|
||||
scoop install extras/umi-ocr
|
||||
```
|
||||
|
||||
- (オプション2)Umi-OCR をインストール( `Paddle-OCR` エンジン付属、やや高速):
|
||||
```
|
||||
scoop install extras/umi-ocr-paddle
|
||||
```
|
||||
|
||||
- 両方を同時にインストールしないでください。ショートカットが上書きされる可能性があります。しかし、[プラグイン](https://github.com/hiroi-sora/Umi-OCR_plugins) を追加することで、異なるOCRエンジンをいつでも切り替えることができます。
|
||||
|
||||
</details>
|
||||
|
||||
## はじめに
|
||||
|
||||
ソフトウェアリリースパッケージは、`.7z`圧縮形式または自己解凍`.7z.exe`パッケージで利用可能です。自己解凍パッケージは、圧縮ソフトウェアがインストールされていないコンピューターでファイルを抽出するために使用できます。
|
||||
|
||||
このソフトウェアはインストールを必要としません。抽出後、`Umi-OCR.exe`をクリックしてプログラムを開始します。
|
||||
|
||||
問題が発生した場合は、[Issue](https://github.com/hiroi-sora/Umi-OCR/issues)を提出してください。最善を尽くしてサポートします。
|
||||
|
||||
## インターフェース言語
|
||||
|
||||
Umi-OCR は、インターフェースの複数の言語をサポートしています。ソフトウェアを初めて開くと、コンピューターのシステム設定に基づいて自動的に言語が切り替わります。
|
||||
|
||||
言語を手動で切り替える必要がある場合は、以下の図を参照してください。`全局设置`→`语言/Language`。
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2023/11/19/65599c3f9e600.png" alt="1-タイトル-1.png" style="width: 80%;"></p>
|
||||
|
||||
## タブインターフェース
|
||||
|
||||
Umi-OCR v2 は、一連の柔軟で使いやすい**タブインターフェース**で構成されています。好みに応じて必要なタブインターフェースを開くことができます。
|
||||
|
||||
タブバーの左上隅を使用して**ウィンドウ常に最前面**を切り替えることができます。右上隅は、日常使用中の偶発的な閉鎖を防ぐために**タブインターフェースをロック**するために使用できます。
|
||||
|
||||
### スクリーンショット OCR
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecb4dfb4.png" alt="2-スクリーンショット-1.png" style="width: 80%;"></p>
|
||||
|
||||
**スクリーンショット OCR**: このページを開いた後、キーボードショートカットを使用してスクリーンショットをキャプチャし、画像内のテキストを認識することができます。
|
||||
|
||||
- 左側の画像プレビューパネルを使用して、マウスでテキストを選択してコピーすることができます。
|
||||
- 右側の認識レコードパネルを使用して、テキストを編集し、複数のレコードを選択してコピーすることができます。
|
||||
- 他の場所から画像をコピーして Umi-OCR に貼り付け、認識することもサポートしています。
|
||||
|
||||
#### 段落マージ
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecc62ac9.png" alt="2-スクリーンショット-2.png" style="width: 80%;"></p>
|
||||
|
||||
**OCR テキスト後処理 - 段落マージ**について: この機能は、OCR 結果のレイアウトと順序を整理し、テキストを読みやすく使用しやすくすることができます。プリセットスキームは以下の通りです。
|
||||
|
||||
- **単一行**: 同じ行上のテキストをマージします。ほとんどのシナリオに適しています。
|
||||
- **複数行 - 自然な段落**: 同じ段落に属するテキストを知的に認識してマージします。ほとんどのシナリオに適しており、上記の図に示されています。
|
||||
- **複数行 - コードブロック**: テキストの元のインデントとスペーシングを復元しようとします。コードスニペットやスペースを保持する必要があるシーンを認識するのに適しています。
|
||||
- **縦書きレイアウト**: 縦書きレイアウトに適しています。縦書き認識もサポートするモデルライブラリと併用する必要があります。
|
||||
|
||||
---
|
||||
|
||||
### バッチ OCR
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecdc5197.png" alt="3-バッチ-1.png" style="width: 80%;"></p>
|
||||
|
||||
**バッチ OCR**: このページでは、ローカル画像をバッチでインポートして認識することがサポートされています。
|
||||
|
||||
- 認識されたコンテンツは、txt/jsonl/md/csv(Excel)などのさまざまな形式で保存できます。
|
||||
- `テキスト後処理`技術をサポートし、同じ自然な段落に属するテキストを認識してマージすることができます。また、コードブロックや縦書きテキストなど、複数の処理スキームもサポートしています。
|
||||
- 一度に処理できる画像の数に制限はなく、タスクを完了した後、ソフトウェアは自動的にシャットダウンまたはスリープすることができます。
|
||||
|
||||
#### 無視域
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecbc0021.png" alt="3-バッチ-2.png" style="width: 80%;"></p>
|
||||
|
||||
**OCR テキスト後処理 - 無視域**について: これは、バッチ OCR の特別な機能で、画像内の望ましくないテキストを除外するために使用されます。
|
||||
|
||||
- 無視領域エディタは、バッチ認識ページ設定の右側の列でアクセスできます。
|
||||
- 上記の例のように、画像の上部と右下隅に複数の透かし/LOGO があります。これらの画像がバッチで認識される場合、透かしは認識結果に干渉します。
|
||||
- 右マウスボタンを押し続けて、複数の長方形ボックスを描画します。これらのエリア内のテキストは、タスク中に無視されます。
|
||||
- 長方形ボックスをできるだけ大きく描画し、透かしのすべての可能な位置を完全に包むようにしてください。
|
||||
|
||||
---
|
||||
|
||||
### 大量文章のocr
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ecc8bfd4.png" alt="" style="width: 80%;"></p>
|
||||
|
||||
---
|
||||
|
||||
### QR コード
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ed01f5b2.png" alt="4-QRコード-1.png" style="width: 80%;"></p>
|
||||
|
||||
**スキャンコード**:
|
||||
|
||||
- スクリーンショットをキャプチャしたり、貼り付けたり、ローカル画像をドラッグして、QR コードやバーコードを読み取ることができます。
|
||||
- 1 つの画像で複数のコードをサポートします。
|
||||
- 以下の 19 のプロトコルをサポートします:
|
||||
|
||||
`Aztec`,`Codabar`,`Code128`,`Code39`,`Code93`,`DataBar`,`DataBarExpanded`,`DataMatrix`,`EAN13`,`EAN8`,`ITF`,`LinearCodes`,`MatrixCodes`,`MaxiCode`,`MicroQRCode`,`PDF417`,`QRCode`,`UPCA`,`UPCE`,
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ed001437.png" alt="4-QRコード-2.png" style="width: 80%;"></p>
|
||||
|
||||
**コード生成**:
|
||||
|
||||
- テキストを入力して QR コード画像を生成します。
|
||||
- **誤り訂正レベル**などのパラメータを含む 19 のプロトコルをサポートします。
|
||||
|
||||
---
|
||||
|
||||
### グローバル設定
|
||||
|
||||
<p align="center"><img src="https://tupian.li/images/2024/03/27/66040ed16f4e0.png" alt="5-グローバル設定-1.png" style="width: 80%;"></p>
|
||||
|
||||
**グローバル設定**: ここでは、ソフトウェアのグローバルパラメータを調整できます。一般的な機能には、以下が含まれます:
|
||||
|
||||
- ショートカットを一度に追加するか、自動起動を設定します。
|
||||
- インターフェースの**言語**を変更します。Umi は繁体字中国語、英語、日本語などの言語をサポートしています。
|
||||
- インターフェースの**テーマ**を切り替えます。Umi には複数のライト/ダークテーマがあります。
|
||||
- インターフェーステキストの**フォントサイズ**と**フォント**を調整します。
|
||||
- OCR プラグインを切り替えます。
|
||||
- **レンダラー**: ソフトウェアインターフェースは、デフォルトで GPU 加速レンダリングをサポートしています。画面のちらつきや UI の位置ずれが発生する場合は、`インターフェースと外観` → `レンダラー`に移動して、異なるレンダリングスキームに切り替えるか、ハードウェアアクセラレーションをオフにしてみてください。
|
||||
|
||||
---
|
||||
|
||||
## API の使用:
|
||||
|
||||
- [コマンドラインマニュアル](docs/README_CLI.md)
|
||||
- [HTTP API マニュアル](docs/http/README.md)
|
||||
|
||||
## プロジェクト構造について
|
||||
|
||||
### リポジトリ:
|
||||
|
||||
- [Main Repository](https://github.com/hiroi-sora/Umi-OCR) 👈
|
||||
- [Plugin Repository](https://github.com/hiroi-sora/Umi-OCR_plugins)
|
||||
- [Windows Runtime Library](https://github.com/hiroi-sora/Umi-OCR_runtime_windows)
|
||||
- [Linux Runtime Library](https://github.com/hiroi-sora/Umi-OCR_runtime_linux)
|
||||
|
||||
## プロジェクトのビルド
|
||||
|
||||
- [Windows](https://github.com/hiroi-sora/Umi-OCR_runtime_windows)
|
||||
- [Linux](https://github.com/hiroi-sora/Umi-OCR_runtime_linux)
|
||||
|
||||
---
|
||||
|
||||
## ソフトウェアのローカライゼーション:
|
||||
|
||||
本プロジェクトはUIのローカライズ作業にWeblateプラットフォームを利用しています。翻訳者による協力を歓迎します。[Weblate: Umi-OCR](https://hosted.weblate.org/engage/umi-ocr/) にアクセスし、既存言語の校正・補完や新規言語の追加が可能です。
|
||||
|
||||
以下に、Umi-OCRのローカライゼーションに貢献いただいた翻訳者の皆様を感謝とともに掲載します:
|
||||
|
||||
| 翻訳者 | 対応言語 |
|
||||
| ------------------------------------------------------------------------------------ | ------------------------- |
|
||||
| [bob](https://hosted.weblate.org/user/q021) | English, 繁體中文, 日本語 |
|
||||
| [Qingzheng Gao](https://github.com/QZGao) | English, 繁體中文 |
|
||||
| [Weng, Chia-Ling](https://hosted.weblate.org/user/ChiaLingWeng) | English, 繁體中文 |
|
||||
| [linzow](https://hosted.weblate.org/user/linzow) | English, 繁體中文 |
|
||||
| [Marcos i](https://hosted.weblate.org/user/ultramarkorj9) | English, Português |
|
||||
| [Eric Guo](https://hosted.weblate.org/user/qwedc001) | English |
|
||||
| [steven0081](https://hosted.weblate.org/user/steven0081) | English |
|
||||
| [Brandon Cagle](https://hosted.weblate.org/user/random4t4x14) | English |
|
||||
| [plum7x](https://hosted.weblate.org/user/plum7x) | 繁體中文 |
|
||||
| [hugoalh](https://hosted.weblate.org/user/hugoalh) | 繁體中文 |
|
||||
| [Anarkiisto](https://hosted.weblate.org/user/Anarkiisto) | 繁體中文 |
|
||||
| [ドコモ光](https://hosted.weblate.org/user/umren190402) | 日本語 |
|
||||
| [杨鹏](https://hosted.weblate.org/user/ypf) | Português |
|
||||
| [Вячеслав Анатольевич Малышев](https://hosted.weblate.org/user/1969) | Русский |
|
||||
| [Muhammadyusuf Kurbonov](https://hosted.weblate.org/user/muhammadyusuf.kurbonov2002) | Русский |
|
||||
| [தமிழ்நேரம்](https://hosted.weblate.org/user/TamilNeram/) | தமிழ் |
|
||||
|
||||
情報の誤りや記載漏れがありましたら、[こちらのディスカッション](https://github.com/hiroi-sora/Umi-OCR/discussions/449) までご連絡ください。
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#hiroi-sora/Umi-OCR&Date)
|
||||
|
||||
## [変更ログ](CHANGE_LOG.md)
|
||||
Binary file not shown.
@@ -0,0 +1,112 @@
|
||||
错误排查手册
|
||||
|
||||
详细内容请见: https://github.com/hiroi-sora/Umi-OCR/issues/447
|
||||
|
||||
================================
|
||||
|
||||
# 弹窗报错 Cannot find Py_Main() in ……
|
||||
|
||||
解决方法1:
|
||||
下载安装VC运行库,重启系统,测试Umi-OCR能否正常运行。
|
||||
https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||
|
||||
解决方法2:
|
||||
使用这个bat文件代替 Umi-OCR.exe 来启动软件。双击它即可。
|
||||
UmiOCR-data/RUN_GUI.bat
|
||||
注意1:通过bat启动时,部分功能受限,将无法使用命令行指令和创建快捷方式(开始菜单、开机自启等)。如有需要,请参考后面【手动放置快捷方式】条目。
|
||||
注意2:不要移动bat的位置。
|
||||
|
||||
================================
|
||||
|
||||
# 手动放置快捷方式
|
||||
|
||||
如果因为某些原因,软件自身无法创建快捷方式;请手动创建并将快捷方式放置到如下目录:
|
||||
|
||||
开始菜单目录:
|
||||
C:\ProgramData\Microsoft\Windows\Start Menu
|
||||
开机自启目录:
|
||||
C:\ProgramData\Microsoft\Windows\Start Menu\Programs\Startup
|
||||
|
||||
================================
|
||||
|
||||
# 弹窗报错 Failed to create OpenGL context ……
|
||||
|
||||
1. 下载运行库:
|
||||
https://github.com/hiroi-sora/Umi-OCR_v2/files/13167436/opengl32sw_64.zip
|
||||
2. 解压,将其中的dll文件放置于:
|
||||
UmiOCR-data/site-packages/PySide2/
|
||||
|
||||
================================
|
||||
|
||||
# 弹窗报错 Umi-OCR.exe 已停止工作
|
||||
|
||||
此问题常见于:软件已经在win10使用过,然后原封不动复制到win7上,导致配置冲突。
|
||||
删除下述配置文件即可:
|
||||
UmiOCR-data/.pre_settings
|
||||
|
||||
================================
|
||||
|
||||
# 应用程序无法正常启动(0xc0000142)
|
||||
# 或者:`[Error] OCR init fail.l. Argd: {'enable_mkldnn': True...`
|
||||
|
||||
此问题大概率是:CPU不支持AVX指令集。
|
||||
|
||||
请换用 PaddleOCR 之外的插件或软件版本。比如:
|
||||
方法1:更换使用 [Umi-OCR_Rapid](https://github.com/hiroi-sora/Umi-OCR/releases) 。
|
||||
方法2:额外导入 [RapleOCR插件](https://github.com/hiroi-sora/Umi-OCR_plugins/releases) 。
|
||||
|
||||
================================
|
||||
|
||||
# 弹出黑框控制台窗口,【错误】Umi-OCR Error 找不到指定的程序 OSError
|
||||
|
||||
可能的发生条件:
|
||||
- win7早期版本,缺少系统补丁(尤其是`KB2533623`)
|
||||
- 其他原因,导致 Python 3.8 无法在该系统中运行。
|
||||
|
||||
修复方法:
|
||||
|
||||
推荐方法:使用系统检查更新(`控制面板 → 系统和安全 → Windows Update → 检查更新`),安装所有更新。
|
||||
|
||||
另一种方法:单独下载安装 **KB2533623补丁** ,重启系统即可。不过,微软官方渠道已经关闭了该补丁的下载链接,您可能只能从第三方网站下载补丁,请务必注意安全。
|
||||
|
||||
================================
|
||||
|
||||
# 保存PDF时 “已停止工作”
|
||||
|
||||
文档识别,能识别出文字,能保存为txt等文件。但如果选择保存为`双层可搜索PDF`,则最后导出文件时弹窗报错:
|
||||
|
||||
```
|
||||
Umi-OCR.exe 已停止工作
|
||||
|
||||
查看问题详细信息:
|
||||
|
||||
问题事件名称:BEX64
|
||||
故障模块名称:ucrtbase.DLL
|
||||
故障模块版本:10.0.10240.16390
|
||||
异常偏移:00000000064399
|
||||
异常代码:c0000417
|
||||
```
|
||||
|
||||
可能的发生条件:win7早期版本,缺少系统补丁(尤其是 `KB4534310`、`KB3042058`)
|
||||
|
||||
修复方法:
|
||||
|
||||
注意!由于 `KB4534310` 依赖前置补丁,因此 **不能** 单独下载安装该补丁。
|
||||
|
||||
您必须使用系统检查更新(`控制面板 → 系统和安全 → Windows Update → 检查更新`),安装所有缺失的更新,直到下面这个项目安装完毕:
|
||||
|
||||
2020-01 适用于基于 x64 的系统的 Windows 7 月度安全质量汇总 (KB4534310)
|
||||
|
||||
================================
|
||||
|
||||
# 丢失`api-ms-win-crt-runtime-l1-1-0.dll`
|
||||
|
||||
打开软件时,弹出错误弹窗:
|
||||
|
||||
Umi-OCR.exe-系统错误
|
||||
无法启动此程序,因为计算机中丢失 api-ms-win-crt-runtime-l1-1-0.dll 。尝试重新安装该程序以解决此问题。
|
||||
|
||||
可能的发生条件:缺少VC运行库。
|
||||
|
||||
# 修复方法:安装VC运行库:
|
||||
https://aka.ms/vs/17/release/vc_redist.x64.exe
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,156 @@
|
||||
# Umi-OCR
|
||||
# OCR software, free and offline. 开源、免费的离线OCR软件。
|
||||
# Website - https://github.com/hiroi-sora/Umi-OCR
|
||||
# Author - hiroi-sora
|
||||
#
|
||||
# You are free to use, modify, and distribute Umi-OCR, but it must include
|
||||
# the original author's copyright statement and the following license statement.
|
||||
# 您可以免费地使用、修改和分发 Umi-OCR ,但必须包含原始作者的版权声明和下列许可声明。
|
||||
"""
|
||||
Copyright (c) 2023 hiroi-sora
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
|
||||
"""
|
||||
======================================================
|
||||
========== Umi-OCR Windows 运行环境初始化入口 ==========
|
||||
======================================================
|
||||
|
||||
说明:
|
||||
本文件负责 Windows + PyStand 运行环境的初始化,主要涉及:
|
||||
- 创建底层弹窗接口 os.MessageBox
|
||||
- 重定向标准输入输出流
|
||||
- 指定工作目录为 "/UmiOCR-data"
|
||||
- 添加Python库搜索目录 "site-packages"
|
||||
- 添加PySide2插件搜索目录 "PySide2/plugins"
|
||||
|
||||
环境初始化后,调用正式入口 py_src/run.py 启动软件。
|
||||
|
||||
耗时分析:
|
||||
runtime/python.exe -X importtime main.py
|
||||
"""
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
import site
|
||||
import traceback
|
||||
import subprocess
|
||||
|
||||
|
||||
def MessageBox(msg, type_="error"):
|
||||
# 软件中如遇到错误,会优先用QT弹窗来警示。
|
||||
# 但一些异常可能触发太早或导致QT事件循环崩溃。
|
||||
# 因此 os.MessageBox() 是用于报告错误的最后防线。
|
||||
info = "Umi-OCR Message"
|
||||
if type_ == "error":
|
||||
info = "【错误】 Umi-OCR Error"
|
||||
elif type_ == "warning":
|
||||
info = "【警告】 Umi-OCR Warning"
|
||||
try:
|
||||
# 通过 ctypes 发起弹窗
|
||||
import ctypes
|
||||
|
||||
ctypes.windll.user32.MessageBoxW(None, str(msg), str(info), 0)
|
||||
except Exception:
|
||||
# 部分系统,连ctypes也用不了。转为在新的控制台窗口中打印信息。
|
||||
msg_cmd = (
|
||||
msg.replace("^", "^^")
|
||||
.replace("&", "^&")
|
||||
.replace("<", "^<")
|
||||
.replace(">", "^>")
|
||||
.replace("|", "^|")
|
||||
.replace("\n\n", "___")
|
||||
.replace("\n", "___")
|
||||
)
|
||||
subprocess.Popen(["start", "cmd", "/k", f"echo {info}: {msg_cmd}"], shell=True)
|
||||
return 0
|
||||
|
||||
|
||||
os.MessageBox = MessageBox
|
||||
|
||||
|
||||
def initRuntimeEnvironment():
|
||||
"""初始化运行环境"""
|
||||
|
||||
# 尝试获取控制台的输出对象
|
||||
try:
|
||||
fd = os.open("CONOUT$", os.O_RDWR | os.O_BINARY)
|
||||
fp = os.fdopen(fd, "w", encoding="utf-8")
|
||||
except Exception as e:
|
||||
fp = open(os.devnull, "w", encoding="utf-8")
|
||||
# 输出流不存在时,重定向到控制台
|
||||
if not sys.stdout:
|
||||
sys.stdout = fp
|
||||
if not sys.stderr:
|
||||
sys.stderr = fp
|
||||
# def except_hook(cls, exception, traceback):
|
||||
# sys.__excepthook__(cls, exception, traceback)
|
||||
# sys.excepthook = except_hook
|
||||
|
||||
# 初始化工作目录和Python搜索路径
|
||||
script = os.path.abspath(__file__) # 启动脚本.py的路径
|
||||
cwd = os.path.dirname(script) # 工作目录
|
||||
os.chdir(cwd) # 重新设定工作目录(不在最顶层,而在 UmiOCR-data 文件夹下)
|
||||
for n in [".", "site-packages"]: # 将模块目录添加到 Python 搜索路径中
|
||||
path = os.path.abspath(os.path.join(cwd, n))
|
||||
if os.path.exists(path):
|
||||
site.addsitedir(path)
|
||||
# 初始化Qt搜索路径为相对路径,避免上层目录存在中文编码
|
||||
from PySide2.QtCore import QCoreApplication
|
||||
|
||||
QCoreApplication.addLibraryPath("./site-packages/PySide2/plugins")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
initRuntimeEnvironment() # 初始化运行环境
|
||||
except Exception:
|
||||
err = traceback.format_exc()
|
||||
from py_src.imports.umi_log import logger, Logs_Dir
|
||||
|
||||
logger.critical(
|
||||
"Failed to initialize running environment!",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
msg = f"Failed to initialize running environment!\n\n{err}\n\nSave the log file to: {Logs_Dir}"
|
||||
MessageBox(msg)
|
||||
sys.exit(0)
|
||||
try:
|
||||
# 获取 pystand.exe 记录的程序入口环境变量
|
||||
app_path = os.environ.get("PYSTAND", "")
|
||||
# 启动正式入口
|
||||
from py_src.run import main
|
||||
|
||||
main(app_path=app_path, engineAddImportPath="./site-packages/PySide2/qml")
|
||||
except Exception:
|
||||
err = traceback.format_exc()
|
||||
from py_src.imports.umi_log import logger, Logs_Dir
|
||||
|
||||
logger.critical(
|
||||
"Failed to startup main program!",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
msg = f"Failed to startup main program!\n\n{err}\n\nSave the log file to: {Logs_Dir}"
|
||||
MessageBox(msg)
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,26 @@
|
||||
# ========================================================
|
||||
# =============== 键盘/鼠标连接器,在qml调用 ===============
|
||||
# ========================================================
|
||||
|
||||
from PySide2.QtCore import QObject, Slot
|
||||
|
||||
from .keyboard import HotkeyCtrl
|
||||
|
||||
|
||||
class KeyMouseConnector(QObject):
|
||||
# ========================= 【热键】 =========================
|
||||
|
||||
# 注册热键
|
||||
@Slot(str, str, int, result=str)
|
||||
def addHotkey(self, keysName, title, press):
|
||||
return HotkeyCtrl.addHotkey(keysName, title, press)
|
||||
|
||||
# 取消热键
|
||||
@Slot(str, str, int)
|
||||
def delHotkey(self, keysName, title, press):
|
||||
return HotkeyCtrl.delHotkey(keysName, title, press)
|
||||
|
||||
# 开始录制热键
|
||||
@Slot(str, str, result=str)
|
||||
def readHotkey(self, runningTitle, finishTitle):
|
||||
return HotkeyCtrl.readHotkey(runningTitle, finishTitle)
|
||||
@@ -0,0 +1,198 @@
|
||||
from pynput import keyboard
|
||||
from PySide2.QtCore import QMutex
|
||||
from time import time
|
||||
|
||||
from umi_log import logger
|
||||
from ...platform import Platform
|
||||
from ..pubsub_service import PubSubService
|
||||
|
||||
|
||||
# 按键转换器
|
||||
class _KeyTranslator:
|
||||
# 回调函数的 KeyCode 类型 转为键名字符串
|
||||
@staticmethod
|
||||
def key2name(key):
|
||||
return Platform.getKeyName(key)
|
||||
|
||||
# 键名字符串 转为KeyCode
|
||||
@staticmethod
|
||||
def name2key(char):
|
||||
if hasattr(keyboard.Key, char): # 控制键返回Code
|
||||
return getattr(keyboard.Key, char).value
|
||||
else: # 非控制键返回自己
|
||||
return char
|
||||
|
||||
# 多个键名的字符串 转为集合
|
||||
@staticmethod
|
||||
def names2set(char):
|
||||
return set(char.split("+"))
|
||||
|
||||
# 集合转键名
|
||||
def set2names(keys):
|
||||
keys = keys.copy()
|
||||
# 优先级键名
|
||||
highPriority = ("win", "cmd", "shift", "ctrl", "alt")
|
||||
names = ""
|
||||
# 添加高优先级键
|
||||
for h in highPriority:
|
||||
if h in keys:
|
||||
if names != "":
|
||||
names += "+"
|
||||
names += h
|
||||
keys.discard(h)
|
||||
# 添加剩下的键
|
||||
for k in keys:
|
||||
if names != "":
|
||||
names += "+"
|
||||
names += k
|
||||
return names
|
||||
|
||||
|
||||
# 热键控制器类
|
||||
class _HotkeyController:
|
||||
# ========================= 【接口】 =========================
|
||||
|
||||
# 添加一组快捷键,对应触发事件为title。 press 为0时按下触发,1抬起触发
|
||||
def addHotkey(self, keysName, title, press=0):
|
||||
self._start()
|
||||
if press != 0 and press != 1:
|
||||
return f"[Error] press只能为0按下或1抬起,不能为 {press} 。"
|
||||
keySet = _KeyTranslator.names2set(keysName)
|
||||
self._hotkeyMutex.lock()
|
||||
kl = self._hotkeyList[press]
|
||||
for k in kl: # 检测重复
|
||||
if k["keySet"] == keySet: # 键集合相同
|
||||
self._hotkeyMutex.unlock()
|
||||
msg = "[Success] 注册事件相同的重复快捷键。"
|
||||
if k["title"] != title: # 事件标题不同
|
||||
msg = f'[Warning] Registering same hotkey. The existing event for {keysName} is {k["title"]}, new event is {title} .'
|
||||
return msg
|
||||
# 加入列表
|
||||
kl.append({"keySet": keySet, "title": title})
|
||||
self._hotkeyMutex.unlock()
|
||||
return "[Success]"
|
||||
|
||||
# 移除一组快捷键,传入键名或事件之一
|
||||
def delHotkey(self, keysName="", title="", press=0):
|
||||
if press != 0 and press != 1:
|
||||
logger.error(f"press只能为0按下或1抬起,不能为 {press} 。")
|
||||
return
|
||||
keySet = _KeyTranslator.names2set(keysName)
|
||||
self._hotkeyMutex.lock()
|
||||
kListOld = self._hotkeyList[press] # 旧列表
|
||||
kListNew = [] # 新列表
|
||||
for k in kListOld:
|
||||
# 忽略键集合相同或标题相同
|
||||
if (keysName and k["keySet"] == keySet) or (title and title == k["title"]):
|
||||
pass
|
||||
# 其余键写入新列表
|
||||
else:
|
||||
kListNew.append(k)
|
||||
self._hotkeyList[press] = kListNew
|
||||
self._hotkeyMutex.unlock()
|
||||
|
||||
# 开始录制快捷键。过程发送事件为runningTitle,完毕发送事件为finishTitle
|
||||
def readHotkey(
|
||||
self, runningTitle="<<readHotkeyRunning>>", finishTitle="<<readHotkeyFinish>>"
|
||||
):
|
||||
self._start()
|
||||
if self._status == 1:
|
||||
return (
|
||||
"[Warning] Recording is running. 当前快捷键录制已在进行,不能同时录制!"
|
||||
)
|
||||
self._status = 1
|
||||
self._readRunningTitle = runningTitle
|
||||
self._readFinishTitle = finishTitle
|
||||
return "[Success]"
|
||||
|
||||
# ========================= 【实现】 =========================
|
||||
|
||||
def __init__(self):
|
||||
self._listener = None # 监听器
|
||||
# 热键列表,[0]存放按下触发,[1]存放抬起触发
|
||||
# 每个元素为:{"keySet":按键集合, "title":事件标题}
|
||||
self._hotkeyList = [[], []]
|
||||
self._hotkeyMutex = QMutex() # 热键列表的锁
|
||||
self._status = 0 # 状态,0正常,1录制中
|
||||
self._pressSet = set() # 当前已按下的按键集合
|
||||
self._strict = True # 键集合相等的判定,T为严格,F为宽松
|
||||
self._ttl = 30 # 长按键超时忽略时间,秒
|
||||
self._ttlDict = {} # 存放当前已按下按键的超时时间
|
||||
self._readRunningTitle = ""
|
||||
self._readFinishTitle = ""
|
||||
# 注册默认监听的热键
|
||||
self.addHotkey("esc", "<<esc>>")
|
||||
|
||||
# 第一次注册热键时,启动监听
|
||||
def _start(self):
|
||||
if not self._listener:
|
||||
self._listener = keyboard.Listener(
|
||||
on_press=self._onPress, on_release=self._onRelease
|
||||
)
|
||||
self._listener.start()
|
||||
|
||||
# 按键按下的回调
|
||||
def _onPress(self, key):
|
||||
keyName = _KeyTranslator.key2name(key)
|
||||
# 禁止重复触发
|
||||
if keyName in self._pressSet:
|
||||
return
|
||||
self._checkTTL() # 检查超时
|
||||
self._pressSet.add(keyName) # 加入集合
|
||||
self._ttlDict[keyName] = time() + self._ttl # 记录超时时间
|
||||
if self._status == 0: # 正常运行
|
||||
self._checkKeyEvent(0, keyName) # 检查按下模式的快捷键
|
||||
elif self._status == 1: # 录制中
|
||||
self._readRunning()
|
||||
|
||||
# 按键抬起的回调
|
||||
def _onRelease(self, key):
|
||||
keyName = _KeyTranslator.key2name(key)
|
||||
if not keyName in self._pressSet:
|
||||
return
|
||||
self._checkTTL() # 检查超时
|
||||
if self._status == 0: # 正常运行
|
||||
self._checkKeyEvent(1, keyName) # 检查抬起模式的快捷键
|
||||
elif self._status == 1: # 录制结束
|
||||
self._readFinish()
|
||||
if keyName in self._pressSet:
|
||||
self._pressSet.discard(keyName) # 从集合中删除
|
||||
del self._ttlDict[keyName] # 删除超时时间
|
||||
|
||||
# 检查并触发按键事件
|
||||
def _checkKeyEvent(self, press, nowKey):
|
||||
# 对比每组按键集合。一致触发,则发送事件
|
||||
if self._strict: # 严格模式,要求完全一致
|
||||
for k in self._hotkeyList[press]:
|
||||
if k["keySet"] == self._pressSet:
|
||||
PubSubService.publish(k["title"])
|
||||
else: # 宽松模式,只要求当前组合中包含指定按键,且当前按下的按键在指定按键中
|
||||
for k in self._hotkeyList[press]:
|
||||
if k["keySet"] <= self._pressSet and nowKey in k["keySet"]:
|
||||
PubSubService.publish(k["title"])
|
||||
|
||||
# 更新录制
|
||||
def _readRunning(self):
|
||||
names = _KeyTranslator.set2names(self._pressSet)
|
||||
PubSubService.publish(self._readRunningTitle, names)
|
||||
|
||||
# 录制结束
|
||||
def _readFinish(self):
|
||||
self._status = 0
|
||||
if "esc" in self._pressSet: # 含esc,则为退出
|
||||
PubSubService.publish(self._readFinishTitle, "")
|
||||
else:
|
||||
names = _KeyTranslator.set2names(self._pressSet)
|
||||
PubSubService.publish(self._readFinishTitle, names)
|
||||
|
||||
# 检查已按键的超时时间。若超时,则删除该键
|
||||
def _checkTTL(self):
|
||||
nowTime = time()
|
||||
for k in self._pressSet.copy():
|
||||
if nowTime >= self._ttlDict[k]:
|
||||
logger.debug(f"忽略超时按键 {k}")
|
||||
del self._ttlDict[k]
|
||||
self._pressSet.discard(k)
|
||||
|
||||
|
||||
HotkeyCtrl = _HotkeyController()
|
||||
@@ -0,0 +1,57 @@
|
||||
# ========================================================
|
||||
# =============== 发布/订阅连接器,在qml调用 ===============
|
||||
# ========================================================
|
||||
|
||||
from PySide2.QtCore import QObject, Slot
|
||||
|
||||
from .pubsub_service import PubSubService
|
||||
from umi_log import logger
|
||||
|
||||
|
||||
class PubSubConnector(QObject):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self._funcDict = {} # 缓存历史已订阅的函数
|
||||
|
||||
# 订阅事件。传入 标题,函数所在Item,函数名
|
||||
@Slot(str, "QVariant", str)
|
||||
def subscribe(self, title, item, funcName):
|
||||
func = getattr(item, funcName, None)
|
||||
if not func:
|
||||
logger.error(f"qml订阅事件失败!未在 {item} 中找到函数 {funcName} 。")
|
||||
return
|
||||
PubSubService.subscribe(title, func)
|
||||
fKey = title + funcName
|
||||
self._funcDict[fKey] = func
|
||||
|
||||
# 订阅事件,可额外传入组
|
||||
@Slot(str, "QVariant", str, str)
|
||||
def subscribeGroup(self, title, item, funcName, groupName):
|
||||
func = getattr(item, funcName, None)
|
||||
if not func:
|
||||
logger.error(f"qml订阅事件失败!未在 {item} 中找到函数 {funcName} 。")
|
||||
return
|
||||
PubSubService.subscribeGroup(title, func, groupName)
|
||||
fKey = title + funcName
|
||||
self._funcDict[fKey] = func
|
||||
|
||||
# 取消订阅事件,由于getattr的不稳定性,因此从历史记录中取函数引用,而不是重新查询。
|
||||
@Slot(str, "QVariant", str)
|
||||
def unsubscribe(self, title, item, funcName):
|
||||
fKey = title + funcName
|
||||
if fKey not in self._funcDict:
|
||||
logger.error(f"qml订阅事件失败!fKey {fKey} 未在 _funcDict 中。")
|
||||
return
|
||||
func = self._funcDict[fKey]
|
||||
del self._funcDict[fKey]
|
||||
PubSubService.unsubscribe(title, func)
|
||||
|
||||
# 取消订阅整个组的事件
|
||||
@Slot(str)
|
||||
def unsubscribeGroup(self, groupName):
|
||||
PubSubService.unsubscribeGroup(groupName)
|
||||
|
||||
# 发布事件,在qml扩展此函数。传入 args 为参数列表。
|
||||
@Slot(str, list)
|
||||
def publish_(self, title, args):
|
||||
PubSubService.publish(title, *args)
|
||||
@@ -0,0 +1,105 @@
|
||||
# ============================================
|
||||
# =============== 发布/订阅模式 ===============
|
||||
# ============================================
|
||||
|
||||
from PySide2.QtCore import QObject, Slot, Signal, QMutex, QThread, QCoreApplication
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
|
||||
# 发布/订阅 服务类
|
||||
class _PubSubServiceClass:
|
||||
def __init__(self):
|
||||
# 事件字典,元素为 回调函数列表
|
||||
self._eventDict = {}
|
||||
self._eventDictMutex = QMutex() # 事件字典的锁
|
||||
# 组字典,元素为 组列表。可以成组取消订阅,方便管理。
|
||||
self._groupDict = {}
|
||||
self._groupDictMutex = QMutex() # 组字典的锁
|
||||
# 信号
|
||||
self._eventSignal = self._EventSignal()
|
||||
self._eventSignal.signal.connect(self._publish)
|
||||
|
||||
# ========================= 【接口】 =========================
|
||||
|
||||
# 订阅事件
|
||||
def subscribe(self, title, func):
|
||||
if not callable(func):
|
||||
logger.error(f"订阅事件失败!传入 {func} 不是可调用对象。")
|
||||
return
|
||||
self._eventDictMutex.lock() # 上锁
|
||||
if title not in self._eventDict:
|
||||
self._eventDict[title] = [func]
|
||||
else:
|
||||
self._eventDict[title].append(func)
|
||||
self._eventDictMutex.unlock() # 解锁
|
||||
|
||||
# 订阅事件,可额外传入组名,以便管理。
|
||||
def subscribeGroup(self, title, func, groupName):
|
||||
self._groupDictMutex.lock() # 上锁
|
||||
if groupName not in self._groupDict:
|
||||
self._groupDict[groupName] = [(title, func)]
|
||||
else:
|
||||
self._groupDict[groupName].append((title, func))
|
||||
self._groupDictMutex.unlock() # 解锁
|
||||
self.subscribe(title, func)
|
||||
|
||||
# 取消订阅事件
|
||||
def unsubscribe(self, title, func):
|
||||
if not callable(func):
|
||||
logger.error(f"取消订阅事件失败!传入 {func} 不是可调用对象。")
|
||||
return
|
||||
# 将回调函数从 对应标题的事件列表中 移除
|
||||
self._eventDictMutex.lock() # 上锁
|
||||
if title in self._eventDict:
|
||||
l = self._eventDict[title]
|
||||
if func in l:
|
||||
l.remove(func)
|
||||
self._eventDictMutex.unlock() # 解锁
|
||||
|
||||
# 取消订阅某个组的所有事件
|
||||
def unsubscribeGroup(self, groupName):
|
||||
self._groupDictMutex.lock() # 上锁
|
||||
if groupName in self._groupDict:
|
||||
l = self._groupDict[groupName]
|
||||
for i in l:
|
||||
self.unsubscribe(i[0], i[1])
|
||||
self._groupDict[groupName] = []
|
||||
self._groupDictMutex.unlock() # 解锁
|
||||
|
||||
# 发布事件
|
||||
def publish(self, title, *args):
|
||||
# 在主线程调用
|
||||
if QThread.currentThread() == QCoreApplication.instance().thread():
|
||||
self._publish(title, args)
|
||||
# 在子线程调用
|
||||
else:
|
||||
self._eventSignal.signal.emit(title, args)
|
||||
|
||||
# ========================= 【实现】 =========================
|
||||
|
||||
# 发布事件的实现(主线程)
|
||||
@Slot(str, "QVariant")
|
||||
def _publish(self, title, args):
|
||||
event_funcs = []
|
||||
self._eventDictMutex.lock() # 上锁
|
||||
if title in self._eventDict:
|
||||
event_funcs = self._eventDict[title].copy() # 拷贝一份
|
||||
self._eventDictMutex.unlock() # 解锁
|
||||
for func in event_funcs:
|
||||
try:
|
||||
func(*args)
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"发送事件异常。 title: {title}, args: {args}, func: {func}",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
|
||||
# 信号类
|
||||
class _EventSignal(QObject):
|
||||
signal = Signal(str, "QVariant")
|
||||
|
||||
|
||||
# 发布/订阅 服务单例
|
||||
PubSubService = _PubSubServiceClass()
|
||||
@@ -0,0 +1,187 @@
|
||||
#
|
||||
# The Python Imaging Library.
|
||||
# $Id$
|
||||
#
|
||||
# a simple Qt image interface.
|
||||
#
|
||||
# history:
|
||||
# 2006-06-03 fl: created
|
||||
# 2006-06-04 fl: inherit from QImage instead of wrapping it
|
||||
# 2006-06-05 fl: removed toimage helper; move string support to ImageQt
|
||||
# 2013-11-13 fl: add support for Qt5 (aurelien.ballier@cyclonit.com)
|
||||
#
|
||||
# Copyright (c) 2006 by Secret Labs AB
|
||||
# Copyright (c) 2006 by Fredrik Lundh
|
||||
#
|
||||
# See the README file for information on usage and redistribution.
|
||||
#
|
||||
|
||||
import sys
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from PySide2.QtCore import QBuffer, QIODevice
|
||||
from PySide2.QtGui import QImage, QPixmap, qRgba
|
||||
|
||||
qt_is_installed = True
|
||||
qt_version = None
|
||||
|
||||
|
||||
def rgb(r, g, b, a=255):
|
||||
"""(Internal) Turns an RGB color into a Qt compatible color integer."""
|
||||
# use qRgb to pack the colors, and then turn the resulting long
|
||||
# into a negative integer with the same bitpattern.
|
||||
return qRgba(r, g, b, a) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def fromqimage(im):
|
||||
"""
|
||||
:param im: QImage or PIL ImageQt object
|
||||
"""
|
||||
buffer = QBuffer()
|
||||
qt_openmode = QIODevice
|
||||
buffer.open(qt_openmode.ReadWrite)
|
||||
# preserve alpha channel with png
|
||||
# otherwise ppm is more friendly with Image.open
|
||||
if im.hasAlphaChannel():
|
||||
im.save(buffer, "png")
|
||||
else:
|
||||
im.save(buffer, "ppm")
|
||||
|
||||
b = BytesIO()
|
||||
b.write(buffer.data())
|
||||
buffer.close()
|
||||
b.seek(0)
|
||||
|
||||
return Image.open(b)
|
||||
|
||||
|
||||
def fromqpixmap(im):
|
||||
return fromqimage(im)
|
||||
# buffer = QBuffer()
|
||||
# buffer.open(QIODevice.ReadWrite)
|
||||
# # im.save(buffer)
|
||||
# # What if png doesn't support some image features like animation?
|
||||
# im.save(buffer, 'ppm')
|
||||
# bytes_io = BytesIO()
|
||||
# bytes_io.write(buffer.data())
|
||||
# buffer.close()
|
||||
# bytes_io.seek(0)
|
||||
# return Image.open(bytes_io)
|
||||
|
||||
|
||||
def align8to32(bytes, width, mode):
|
||||
"""
|
||||
converts each scanline of data from 8 bit to 32 bit aligned
|
||||
"""
|
||||
|
||||
bits_per_pixel = {"1": 1, "L": 8, "P": 8, "I;16": 16}[mode]
|
||||
|
||||
# calculate bytes per line and the extra padding if needed
|
||||
bits_per_line = bits_per_pixel * width
|
||||
full_bytes_per_line, remaining_bits_per_line = divmod(bits_per_line, 8)
|
||||
bytes_per_line = full_bytes_per_line + (1 if remaining_bits_per_line else 0)
|
||||
|
||||
extra_padding = -bytes_per_line % 4
|
||||
|
||||
# already 32 bit aligned by luck
|
||||
if not extra_padding:
|
||||
return bytes
|
||||
|
||||
new_data = []
|
||||
for i in range(len(bytes) // bytes_per_line):
|
||||
new_data.append(
|
||||
bytes[i * bytes_per_line : (i + 1) * bytes_per_line]
|
||||
+ b"\x00" * extra_padding
|
||||
)
|
||||
|
||||
return b"".join(new_data)
|
||||
|
||||
|
||||
def _toqclass_helper(im):
|
||||
data = None
|
||||
colortable = None
|
||||
exclusive_fp = False
|
||||
|
||||
# handle filename, if given instead of image name
|
||||
if hasattr(im, "toUtf8"):
|
||||
# FIXME - is this really the best way to do this?
|
||||
im = str(im.toUtf8(), "utf-8")
|
||||
|
||||
qt_format = QImage
|
||||
if im.mode == "1":
|
||||
format = qt_format.Format_Mono
|
||||
elif im.mode == "L":
|
||||
format = qt_format.Format_Indexed8
|
||||
colortable = []
|
||||
for i in range(256):
|
||||
colortable.append(rgb(i, i, i))
|
||||
elif im.mode == "P":
|
||||
format = qt_format.Format_Indexed8
|
||||
colortable = []
|
||||
palette = im.getpalette()
|
||||
for i in range(0, len(palette), 3):
|
||||
colortable.append(rgb(*palette[i : i + 3]))
|
||||
elif im.mode == "RGB":
|
||||
# Populate the 4th channel with 255
|
||||
im = im.convert("RGBA")
|
||||
|
||||
data = im.tobytes("raw", "BGRA")
|
||||
format = qt_format.Format_RGB32
|
||||
elif im.mode == "RGBA":
|
||||
data = im.tobytes("raw", "BGRA")
|
||||
format = qt_format.Format_ARGB32
|
||||
elif im.mode == "I;16" and hasattr(qt_format, "Format_Grayscale16"): # Qt 5.13+
|
||||
im = im.point(lambda i: i * 256)
|
||||
|
||||
format = qt_format.Format_Grayscale16
|
||||
else:
|
||||
if exclusive_fp:
|
||||
im.close()
|
||||
msg = f"unsupported image mode {repr(im.mode)}"
|
||||
raise ValueError(msg)
|
||||
|
||||
size = im.size
|
||||
__data = data or align8to32(im.tobytes(), size[0], im.mode)
|
||||
if exclusive_fp:
|
||||
im.close()
|
||||
return {"data": __data, "size": size, "format": format, "colortable": colortable}
|
||||
|
||||
|
||||
class ImageQt(QImage):
|
||||
def __init__(self, im):
|
||||
"""
|
||||
An PIL image wrapper for Qt. This is a subclass of PyQt's QImage
|
||||
class.
|
||||
|
||||
:param im: A PIL Image object, or a file name (given either as
|
||||
Python string or a PyQt string object).
|
||||
"""
|
||||
im_data = _toqclass_helper(im)
|
||||
# must keep a reference, or Qt will crash!
|
||||
# All QImage constructors that take data operate on an existing
|
||||
# buffer, so this buffer has to hang on for the life of the image.
|
||||
# Fixes https://github.com/python-pillow/Pillow/issues/1370
|
||||
self._data = im_data["data"]
|
||||
super().__init__(
|
||||
self._data,
|
||||
im_data["size"][0],
|
||||
im_data["size"][1],
|
||||
im_data["format"],
|
||||
)
|
||||
if im_data["colortable"]:
|
||||
self.setColorTable(im_data["colortable"])
|
||||
|
||||
|
||||
def toqimage(im):
|
||||
return ImageQt(im)
|
||||
|
||||
|
||||
def toqpixmap(im):
|
||||
# # This doesn't work. For now using a dumb approach.
|
||||
# im_data = _toqclass_helper(im)
|
||||
# result = QPixmap(im_data["size"][0], im_data["size"][1])
|
||||
# result.loadFromData(im_data["data"])
|
||||
qimage = toqimage(im)
|
||||
return QPixmap.fromImage(qimage)
|
||||
@@ -0,0 +1,40 @@
|
||||
# =============================================
|
||||
# =============== 图片处理连接器 ===============
|
||||
# =============================================
|
||||
|
||||
from .image_provider import copyImage, saveImage, openImage
|
||||
from .screenshot_controller import ScreenshotController
|
||||
|
||||
from PySide2.QtCore import QObject, Slot, Signal
|
||||
|
||||
|
||||
class ImageConnector(QObject):
|
||||
# 对所有屏幕截图。传入延时时间。返回截图列表
|
||||
@Slot(float, result="QVariant")
|
||||
def getScreenshot(self, wait):
|
||||
return ScreenshotController.getScreenshot(wait)
|
||||
|
||||
# 对一张图片做裁切。传入原图imgID和裁切参数,返回裁切后的imgID或[Error]
|
||||
@Slot(str, int, int, int, int, result=str)
|
||||
def getClipImgID(self, imgID, x, y, w, h):
|
||||
return ScreenshotController.getClipImgID(imgID, x, y, w, h)
|
||||
|
||||
# 获取当前剪贴板的内容,返回 {"type":"", "": ""}
|
||||
@Slot(result="QVariant")
|
||||
def getPaste(self):
|
||||
return ScreenshotController.getPaste()
|
||||
|
||||
# 将图片写入剪贴板
|
||||
@Slot(str, result=str)
|
||||
def copyImage(self, path):
|
||||
return copyImage(path)
|
||||
|
||||
# 用系统默认应用打开图片
|
||||
@Slot(str, result=str)
|
||||
def openImage(self, path):
|
||||
return openImage(path)
|
||||
|
||||
# 将图片保存到本地
|
||||
@Slot(str, str, result=str)
|
||||
def saveImage(self, fromPath, toPath):
|
||||
return saveImage(fromPath, toPath)
|
||||
@@ -0,0 +1,234 @@
|
||||
# ==========================================================
|
||||
# =============== Python向Qml传输 Pixmap 图像 ===============
|
||||
# ==========================================================
|
||||
|
||||
import os
|
||||
from uuid import uuid4 # 唯一ID
|
||||
from urllib.parse import unquote
|
||||
from PySide2.QtCore import Qt, QByteArray, QBuffer
|
||||
from PySide2.QtGui import QPixmap, QImage, QPainter, QClipboard
|
||||
from PySide2.QtQuick import QQuickImageProvider
|
||||
|
||||
from umi_log import logger
|
||||
from . import ImageQt
|
||||
from ..platform import Platform
|
||||
|
||||
Clipboard = QClipboard() # 剪贴板
|
||||
|
||||
|
||||
# Pixmap型图片提供器
|
||||
class PixmapProviderClass(QQuickImageProvider):
|
||||
def __init__(self):
|
||||
super().__init__(QQuickImageProvider.Pixmap)
|
||||
self.pixmapDict = {} # 缓存所有pixmap的字典
|
||||
self.compDict = {} # 缓存所有组件的字典
|
||||
# 空图占位符
|
||||
self._noneImg = None
|
||||
|
||||
# 向qml返回图片,imgID不存在时返回警告图
|
||||
def requestPixmap(self, path, size=None, resSize=None):
|
||||
if "/" in path:
|
||||
compID, imgID = path.split("/", 1)
|
||||
self._delCompCache(compID, imgID) # 先清缓存
|
||||
if imgID in self.pixmapDict:
|
||||
self.compDict[compID] = imgID # 记录缓存
|
||||
return self.pixmapDict[imgID]
|
||||
else: # 清空一个组件的缓存
|
||||
self._delCompCache(path)
|
||||
return self._getNoneImg() # 返回占位符
|
||||
|
||||
# 添加一个Pixmap图片到提供器,返回imgID
|
||||
def addPixmap(self, pixmap):
|
||||
imgID = str(uuid4())
|
||||
self.pixmapDict[imgID] = pixmap
|
||||
return imgID
|
||||
|
||||
# 向py返回图片,相当于requestPixmap,但imgID不存在时返回None
|
||||
def getPixmap(self, imgID):
|
||||
return self.pixmapDict.get(imgID, None)
|
||||
|
||||
# 向py返回PIL对象
|
||||
def getPilImage(self, imgID):
|
||||
im = self.getPixmap(imgID)
|
||||
if not im:
|
||||
return None
|
||||
try:
|
||||
return ImageQt.fromqimage(im)
|
||||
except Exception:
|
||||
logger.error("QPixmap 转 PIL 失败。", exc_info=True, stack_info=True)
|
||||
return None
|
||||
|
||||
# py将PIL对象写回pixmapDict。主要是记录预处理的图像
|
||||
# imgID可以已存在,也可以新添加
|
||||
def setPilImage(self, img, imgID=""):
|
||||
try:
|
||||
pixmap = ImageQt.toqpixmap(img)
|
||||
except Exception as e:
|
||||
logger.error("PIL 转 QPixmap 失败。", exc_info=True, stack_info=True)
|
||||
return f"[Error] PIL 转 QPixmap 失败:{e}"
|
||||
if not imgID:
|
||||
imgID = str(uuid4())
|
||||
self.pixmapDict[imgID] = pixmap
|
||||
return imgID
|
||||
|
||||
# 从pixmapDict缓存中删除一个或一批图片
|
||||
# 一般无需手动调用此函数!缓存会自动管理、清除。
|
||||
def delPixmap(self, imgIDs):
|
||||
if isinstance(imgIDs, str):
|
||||
imgIDs = [imgIDs]
|
||||
for i in imgIDs:
|
||||
if i in self.pixmapDict:
|
||||
del self.pixmapDict[i]
|
||||
logger.debug(f"删除图片缓存,剩余:{len(self.pixmapDict)}")
|
||||
|
||||
# 将 QPixmap 或 QImage 转换为字节
|
||||
@staticmethod
|
||||
def toBytes(image):
|
||||
if isinstance(image, QPixmap):
|
||||
image = image.toImage()
|
||||
elif not isinstance(image, QImage):
|
||||
raise ValueError(
|
||||
f"[Error] Only QImage or QPixmap can toBytes(), no {str(type(image))}."
|
||||
)
|
||||
byteArray = QByteArray() # 创建一个字节数组
|
||||
buffer = QBuffer(byteArray) # 创建一个缓冲区
|
||||
buffer.open(QBuffer.WriteOnly)
|
||||
image.save(buffer, "PNG") # 将 QImage 保存为字节数组
|
||||
buffer.close()
|
||||
bytesData = byteArray.data() # 获取字节数组的内容
|
||||
return bytesData
|
||||
|
||||
# 清空一个组件的缓存。imgID可选该组件下一次更新的图片ID。
|
||||
def _delCompCache(self, compID, imgID=""):
|
||||
if compID in self.compDict:
|
||||
last = self.compDict[compID]
|
||||
if imgID and imgID == last:
|
||||
logger.warning(f"图片组件异常清理: {compID} {imgID}")
|
||||
return # 如果下一次更新的ID等于当前ID,则为异常,不进行清理
|
||||
if last in self.pixmapDict:
|
||||
del self.pixmapDict[last]
|
||||
del self.compDict[compID]
|
||||
|
||||
# 返回空图占位符
|
||||
def _getNoneImg(self):
|
||||
if self._noneImg:
|
||||
return self._noneImg
|
||||
pixmap = QPixmap(1, 100)
|
||||
pixmap.fill(Qt.blue)
|
||||
painter = QPainter(pixmap) # 绘制警告条纹
|
||||
painter.setPen(Qt.red)
|
||||
painter.drawLine(0, 0, 0, 5)
|
||||
painter.drawLine(0, 95, 0, 100)
|
||||
self._noneImg = pixmap
|
||||
return self._noneImg
|
||||
|
||||
|
||||
# 图片提供器 单例
|
||||
PixmapProvider = PixmapProviderClass()
|
||||
|
||||
|
||||
# 读入一张图片,返回该图片
|
||||
# type: pixmap / qimage / error
|
||||
def _imread(path):
|
||||
path = unquote(path) # 做一次URL解码
|
||||
if path.startswith("image://pixmapprovider/"):
|
||||
path = path[23:]
|
||||
if "/" in path:
|
||||
compID, imgID = path.split("/", 1)
|
||||
if imgID in PixmapProvider.pixmapDict:
|
||||
return {"type": "pixmap", "data": PixmapProvider.pixmapDict[imgID]}
|
||||
else:
|
||||
return {"type": "error", "data": f"[Warning] ID not in pixmapDict: {path}"}
|
||||
elif path.startswith("file:///"):
|
||||
path = path[8:]
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
image = QImage(path)
|
||||
return {"type": "qimage", "data": image, "path": path}
|
||||
except Exception as e:
|
||||
return {
|
||||
"type": "error",
|
||||
"data": f"[Error] QImage cannot read path: {path}",
|
||||
}
|
||||
else:
|
||||
return {"type": "error", "data": f"[Warning] Path {path} not exists."}
|
||||
elif path in PixmapProvider.pixmapDict:
|
||||
return {"type": "pixmap", "data": PixmapProvider.pixmapDict[path]}
|
||||
elif os.path.exists(path):
|
||||
try:
|
||||
image = QImage(path)
|
||||
return {"type": "qimage", "data": image, "path": path}
|
||||
except Exception as e:
|
||||
return {"type": "error", "data": f"[Error] QImage cannot read path: {path}"}
|
||||
return {"type": "error", "data": f"[Warning] Unknow: {path}"}
|
||||
|
||||
|
||||
# 复制一张图片到剪贴板
|
||||
def copyImage(path):
|
||||
im = _imread(path)
|
||||
typ, data = im["type"], im["data"]
|
||||
if typ == "error":
|
||||
return data
|
||||
try:
|
||||
if typ == "pixmap":
|
||||
Clipboard.setPixmap(data)
|
||||
elif typ == "qimage":
|
||||
Clipboard.setImage(data)
|
||||
return "[Success]"
|
||||
except Exception as e:
|
||||
return f"[Error] can't copy: {e}\n{path}"
|
||||
|
||||
|
||||
# 用系统默认应用打开图片
|
||||
def openImage(path):
|
||||
im = _imread(path)
|
||||
typ, data = im["type"], im["data"]
|
||||
if typ == "error":
|
||||
return data
|
||||
# 若原本为本地图片,则直接打开
|
||||
if "path" in im:
|
||||
path = im["path"]
|
||||
# 若为内存数据,则创建缓存文件
|
||||
else:
|
||||
path = "umi_temp_image.png"
|
||||
try:
|
||||
if typ == "pixmap":
|
||||
data = data.toImage()
|
||||
data.save(path)
|
||||
logger.debug(f"用系统默认应用打开图片时,缓存临时图片到 {path}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"用系统默认应用打开图片时,无法缓存临时图片到 {path}",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
return f"[Error] can't save to temp file: {e}\n{path}"
|
||||
# 打开文件
|
||||
try:
|
||||
Platform.startfile(path)
|
||||
return "[Success]"
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"无法用系统默认应用打开图片 {path}",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
return f"[Error] can't open image: {e}\n{path}"
|
||||
|
||||
|
||||
# 保存一张图片
|
||||
def saveImage(fromPath, toPath):
|
||||
if toPath.startswith("file:///"):
|
||||
toPath = toPath[8:]
|
||||
im = _imread(fromPath)
|
||||
typ, data = im["type"], im["data"]
|
||||
if typ == "error":
|
||||
return data
|
||||
try:
|
||||
if typ == "pixmap":
|
||||
data.save(toPath)
|
||||
elif typ == "qimage":
|
||||
data.save(toPath)
|
||||
return f"[Success] {toPath}"
|
||||
except Exception as e:
|
||||
return f"[Error] can't save: {e}\n{fromPath}\n{toPath}"
|
||||
@@ -0,0 +1,104 @@
|
||||
# ========================================
|
||||
# =============== 截图控制 ===============
|
||||
# ========================================
|
||||
|
||||
from ..image_controller.image_provider import PixmapProvider # 图片提供器
|
||||
from ..utils.file_finder import findFiles
|
||||
|
||||
import time
|
||||
from PySide2.QtGui import QGuiApplication, QClipboard, QImage, QPixmap # 截图 剪贴板
|
||||
|
||||
Clipboard = QClipboard() # 剪贴板
|
||||
|
||||
|
||||
class _ScreenshotControllerClass:
|
||||
|
||||
def getScreenshot(self, wait=0):
|
||||
"""
|
||||
延时wait秒后,获取所有屏幕的截图。返回列表(不为空),每项为:\n
|
||||
{
|
||||
"imgID": 图片ID 或 报错信息 "[Error]开头" ,
|
||||
"screenName": 显示器名称 ,
|
||||
"width": 截图宽度 ,
|
||||
"height": 截图高度 ,
|
||||
}
|
||||
"""
|
||||
if wait > 0:
|
||||
time.sleep(wait)
|
||||
try:
|
||||
grabList = []
|
||||
screensList = QGuiApplication.screens()
|
||||
for screen in screensList:
|
||||
name = screen.name()
|
||||
# 获取截图
|
||||
pixmap = screen.grabWindow(0)
|
||||
width = pixmap.width()
|
||||
height = pixmap.height()
|
||||
# 检查截图失败
|
||||
if width <= 0 or height <= 0:
|
||||
imgID = f"[Error] width={width}, height={height}"
|
||||
# 检查有效,存入提供器,获取imgID
|
||||
else:
|
||||
imgID = PixmapProvider.addPixmap(pixmap)
|
||||
grabList.append(
|
||||
{
|
||||
"imgID": imgID,
|
||||
"screenName": name,
|
||||
"width": width,
|
||||
"height": height,
|
||||
}
|
||||
)
|
||||
if not grabList: # 获取到的截图列表为空
|
||||
return [{"imgID": f"[Error] grabList is empty."}]
|
||||
return grabList
|
||||
except Exception as e:
|
||||
return [{"imgID": f"[Error] Screenshot: {e}"}]
|
||||
|
||||
# 对一张图片做裁切。传入原图imgID和裁切参数,返回裁切后的imgID或[Error]
|
||||
def getClipImgID(self, imgID, x, y, w, h):
|
||||
try:
|
||||
pixmap = PixmapProvider.getPixmap(imgID)
|
||||
if not pixmap:
|
||||
return f'[Error] Screenshot: Key "{imgID}" does not exist in the PixmapProvider dict.'
|
||||
if x < 0 or y < 0 or w <= 0 or h <= 0:
|
||||
return f"[Error] Screenshot: x/y/w/h value error. {x}/{y}/{w}/{h}"
|
||||
pixmap = pixmap.copy(x, y, w, h) # 进行裁切
|
||||
clipID = PixmapProvider.addPixmap(pixmap) # 存入提供器,获取imgID
|
||||
return clipID
|
||||
except Exception as e:
|
||||
return f"[Error] Screenshot: {e}"
|
||||
|
||||
# 获取当前剪贴板的内容
|
||||
# type: imgID paths text
|
||||
def getPaste(self):
|
||||
# 获取剪贴板数据
|
||||
mimeData = Clipboard.mimeData()
|
||||
res = {"type": ""} # 结果字典
|
||||
# 检查剪贴板的内容,若是图片,则提取它并扔给OCR
|
||||
if mimeData.hasImage():
|
||||
image = Clipboard.image()
|
||||
pixmap = QPixmap.fromImage(image)
|
||||
pasteID = PixmapProvider.addPixmap(pixmap) # 存入提供器
|
||||
res = {"type": "imgID", "imgID": pasteID}
|
||||
# 若为URL
|
||||
elif mimeData.hasUrls():
|
||||
urlList = mimeData.urls()
|
||||
paths = []
|
||||
for url in urlList: # 遍历URL列表,提取其中的文件
|
||||
if url.isLocalFile():
|
||||
p = url.toLocalFile()
|
||||
paths.append(p)
|
||||
paths = findFiles(paths, "image", False) # 过滤,保留图片的路径
|
||||
if len(paths) == 0: # 没有有效图片
|
||||
res = {"type": "error", "error": "[Warning] No image in clipboard."}
|
||||
else: # 将有效图片地址传入OCR,返回地址列表
|
||||
res = {"type": "paths", "paths": paths}
|
||||
elif mimeData.hasText():
|
||||
text = mimeData.text()
|
||||
res = {"type": "text", "text": text}
|
||||
else:
|
||||
res = {"type": "error", "error": "[Warning] Unknow mimeData in clipboard."}
|
||||
return res # 返回结果字典
|
||||
|
||||
|
||||
ScreenshotController = _ScreenshotControllerClass()
|
||||
@@ -0,0 +1,75 @@
|
||||
# ==================================================================
|
||||
# =============== 为插件提供接口,在主线程中调用指定函数 ===============
|
||||
# ==================================================================
|
||||
|
||||
"""
|
||||
from call_func import CallFunc
|
||||
|
||||
def func():
|
||||
print("延时3秒调用!")
|
||||
|
||||
id = CallFunc.delay(func, 3) # 延时调用
|
||||
# CallFunc.delayStop(id) # 取消延时调用
|
||||
"""
|
||||
|
||||
from PySide2.QtCore import QObject, Slot, Signal, QTimer, QMutex
|
||||
from uuid import uuid4 # 唯一ID
|
||||
|
||||
|
||||
class _CallFunc(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 信号 在主线程中调用函数
|
||||
self._callFuncSignal = self.cSignal()
|
||||
self._callFuncSignal.signal.connect(self._cFunc)
|
||||
# 计时器停止字典
|
||||
self._timerStopDict = {}
|
||||
self._timerLock = QMutex()
|
||||
|
||||
# ========================= 【接口】 =========================
|
||||
|
||||
# 立刻:在主线程中调用python函数
|
||||
def now(self, func, *args):
|
||||
self._callFuncSignal.signal.emit((func, args))
|
||||
|
||||
# 延时:在主线程中调用python函数。返回计时器ID
|
||||
def delay(self, func, time, *args):
|
||||
timerID = str(uuid4())
|
||||
|
||||
def go():
|
||||
timer = QTimer(self)
|
||||
timer.setSingleShot(True) # 单次运行
|
||||
timer.timeout.connect(lambda: self._timerFunc(timerID, func, args))
|
||||
timer.start(time * 1000)
|
||||
|
||||
self.now(go)
|
||||
return timerID
|
||||
|
||||
# 取消已启用的延时
|
||||
def delayStop(self, timerID):
|
||||
self._timerLock.lock()
|
||||
self._timerStopDict[timerID] = True # 记录停止
|
||||
self._timerLock.unlock()
|
||||
|
||||
# ==================================================
|
||||
# 计时器调用的函数
|
||||
def _timerFunc(self, timerID, func, args):
|
||||
self._timerLock.lock()
|
||||
if timerID in self._timerStopDict:
|
||||
del self._timerStopDict[timerID]
|
||||
self._timerLock.unlock()
|
||||
return
|
||||
self._timerLock.unlock()
|
||||
func(*args)
|
||||
|
||||
# 异步调用的槽函数
|
||||
@Slot("QVariant")
|
||||
def _cFunc(self, args):
|
||||
args[0](*args[1])
|
||||
|
||||
# 信号类
|
||||
class cSignal(QObject):
|
||||
signal = Signal("QVariant")
|
||||
|
||||
|
||||
CallFunc = _CallFunc()
|
||||
@@ -0,0 +1,55 @@
|
||||
# =========================================================
|
||||
# =============== 为插件提供简易的i18n翻译API ===============
|
||||
# =========================================================
|
||||
|
||||
import os
|
||||
import csv
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
LangCode = ""
|
||||
|
||||
|
||||
# 设置语言
|
||||
def setLangCode(langCode):
|
||||
global LangCode
|
||||
LangCode = langCode
|
||||
|
||||
|
||||
class Translator:
|
||||
# 载入翻译。每个模块调用tr前,必须载入翻译。
|
||||
# file: 与翻译文件同目录的任意文件的路径
|
||||
# name: 翻译文件名
|
||||
def __init__(self, file="", name=""):
|
||||
self._trDict = {}
|
||||
if not file or not name:
|
||||
return
|
||||
path = os.path.dirname(os.path.abspath(file)) + "/" + name
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
with open(path, "r", encoding="utf-8") as file:
|
||||
csv_reader = csv.reader(file)
|
||||
headers = next(csv_reader)
|
||||
index = -1
|
||||
for i, h in enumerate(headers): # 搜索语言
|
||||
if h == LangCode:
|
||||
index = i
|
||||
if index == -1: # 找不到对应语言
|
||||
if LangCode.startswith("zh_"): # 中文
|
||||
index = 0
|
||||
else:
|
||||
index = 1 # 找不到对应语言,默认英文
|
||||
for row in csv_reader:
|
||||
self._trDict[row[0]] = row[index]
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"加载插件翻译失败。 file: {file}, name: {name}",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
|
||||
# 进行翻译。输入原文,返回译文
|
||||
def __call__(self, original):
|
||||
if original in self._trDict:
|
||||
return self._trDict[original]
|
||||
return original
|
||||
@@ -0,0 +1,113 @@
|
||||
# Umi-OCR
|
||||
# OCR software, free and offline. 开源、免费的离线OCR软件。
|
||||
# Website - https://github.com/hiroi-sora/Umi-OCR
|
||||
# Author - hiroi-sora
|
||||
#
|
||||
# You are free to use, modify, and distribute Umi-OCR, but it must include
|
||||
# the original author's copyright statement and the following license statement.
|
||||
# 您可以免费地使用、修改和分发 Umi-OCR ,但必须包含原始作者的版权声明和下列许可声明。
|
||||
"""
|
||||
Copyright (c) 2023 hiroi-sora
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
|
||||
"""
|
||||
=========================================================
|
||||
=============== 为全局提供 Umi-OCR 关于信息 ===============
|
||||
=========================================================
|
||||
|
||||
在任意python文件中访问:
|
||||
from umi_about import UmiAbout
|
||||
UmiAbout["fullname"]
|
||||
|
||||
在任意qml文件中访问:
|
||||
UmiAbout.fullname
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import psutil
|
||||
import platform
|
||||
from json import load
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
UmiAbout = None
|
||||
|
||||
|
||||
# 初始化时调用一次
|
||||
def init(app_path=""):
|
||||
"""
|
||||
`app_path`: 程序入口文件 路径\n
|
||||
"""
|
||||
global UmiAbout
|
||||
# 读取配置
|
||||
try:
|
||||
with open("about.json", "r", encoding="utf-8") as file:
|
||||
u = load(file)
|
||||
except Exception as e:
|
||||
os.MessageBox(f"[Error] 无法读取配置 about.json 。\n{e}")
|
||||
return False
|
||||
# 完整版本号
|
||||
v = f'{u["version"]["major"]}.{u["version"]["minor"]}.{u["version"]["patch"]}'
|
||||
if u["version"]["prerelease"]:
|
||||
v += f'-{u["version"]["prerelease"]}.{u["version"]["prereleaseNumber"]}'
|
||||
u["version"]["string"] = v
|
||||
# 全名
|
||||
u["fullname"] = f'{u["name"]} v{v}'
|
||||
# 程序运行信息
|
||||
if app_path:
|
||||
app_path = os.path.abspath(app_path)
|
||||
app_home = os.path.dirname(app_path)
|
||||
app_dir = os.path.dirname(app_path)
|
||||
else:
|
||||
app_path = ""
|
||||
app_home = ""
|
||||
app_dir = ""
|
||||
logger.warning("未能获取程序入口路径。")
|
||||
# 简短的平台代号
|
||||
_plat = sys.platform
|
||||
if _plat.startswith("win32"):
|
||||
_system = "win32"
|
||||
elif _plat.startswith("linux"):
|
||||
_system = "linux"
|
||||
elif _plat.startswith("darwin"):
|
||||
_system = "darwin"
|
||||
elif _plat and isinstance(_plat, str):
|
||||
_system = _plat
|
||||
else:
|
||||
_system = "unknow"
|
||||
u["app"] = {
|
||||
# 程序入口
|
||||
"path": app_path,
|
||||
"home": app_home,
|
||||
"dir": app_dir,
|
||||
# 运行平台
|
||||
"system": _system, # 简略系统版本,如 'win32'
|
||||
"platform": platform.platform(), # 详细系统版本,如 'Windows-10-10.0.22631-SP0'
|
||||
"python": platform.python_version(),
|
||||
# 硬件信息,用于debug
|
||||
"cpu": f"{platform.processor()} | {psutil.cpu_count(logical=False)}C{psutil.cpu_count(logical=True)}T",
|
||||
"ram": f"{(psutil.virtual_memory().total / 1073741824):.1f} GB",
|
||||
}
|
||||
|
||||
UmiAbout = u
|
||||
return True
|
||||
@@ -0,0 +1,270 @@
|
||||
"""
|
||||
日志模块
|
||||
|
||||
Python: =========================
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
logger.debug("调试信息")
|
||||
logger.info("普通信息")
|
||||
logger.warning("警告信息")
|
||||
logger.error("错误信息"))
|
||||
logger.critical("严重错误信息")
|
||||
|
||||
# exc_info 只能在 except 块中开启
|
||||
logger.error("错误信息", exc_info=True, stack_info=True)
|
||||
# 覆盖 LogRecord 的属性
|
||||
logger.debug("信息", extra={"cover": {"filename": "test.txt", "lineno": 999}}
|
||||
|
||||
Qml: =========================
|
||||
|
||||
console.log("调试信息")
|
||||
console.info("普通信息")
|
||||
console.warn("警告信息")
|
||||
console.error("错误信息")
|
||||
console.trace() // 堆栈信息,级别debug,含函数名、文件名、行号
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from logging import LogRecord
|
||||
|
||||
# 保存的日志级别,可在UI修改
|
||||
Save_Log_Level: int = logging.ERROR
|
||||
|
||||
# 日志保存目录
|
||||
Logs_Dir = "./logs"
|
||||
Logs_Dir = os.path.abspath(Logs_Dir)
|
||||
|
||||
# 日志级别,对应的int值由小到大
|
||||
_Log_Levels = {
|
||||
"DEBUG": logging.DEBUG,
|
||||
"INFO": logging.INFO,
|
||||
"WARNING": logging.WARNING,
|
||||
"ERROR": logging.ERROR,
|
||||
"CRITICAL": logging.CRITICAL,
|
||||
"NONE": logging.CRITICAL + 10, # 最大,表示不记录日志
|
||||
}
|
||||
|
||||
|
||||
# 忽略日志列表,屏蔽QT框架自发产生的一些日志。(目前仅对 get_qt_message_handler 生效)
|
||||
Log_Ignore_List = [
|
||||
"Retrying to obtain clipboard.", # https://bugreports.qt.io/browse/QTBUG-97930
|
||||
"Unable to obtain clipboard.",
|
||||
]
|
||||
|
||||
|
||||
# 覆盖过滤器
|
||||
class _CoverFilter(logging.Filter):
|
||||
def filter(self, record: LogRecord):
|
||||
try:
|
||||
# 提取自定义信息,覆盖给 record
|
||||
cover = record.__dict__.get("cover", {})
|
||||
for k, v in cover.items():
|
||||
if hasattr(record, k):
|
||||
setattr(record, k, v)
|
||||
return True
|
||||
except Exception:
|
||||
logger.error("日志过滤错误", exc_info=True, stack_info=True)
|
||||
return False
|
||||
|
||||
|
||||
# 简化日志级别符号的格式化器
|
||||
class _LevelFormatter(logging.Formatter):
|
||||
# 定义日志级别和对应符号的映射
|
||||
LEVEL_SYMBOLS = {
|
||||
"DEBUG": " ",
|
||||
"INFO": "√",
|
||||
"WARNING": "?",
|
||||
"ERROR": "×",
|
||||
"CRITICAL": "×××",
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
# 获取符号,如果没有定义则使用默认级别名称
|
||||
levelname = record.levelname
|
||||
record.levelsymbol = self.LEVEL_SYMBOLS.get(levelname, levelname)
|
||||
return super().format(record)
|
||||
|
||||
|
||||
# json 日志文件处理器
|
||||
class _JsonRotatingFileHandler(RotatingFileHandler):
|
||||
# 日志信息转字典
|
||||
def _record_to_dict(self, record: LogRecord):
|
||||
# 时间戳格式化
|
||||
dt_object = datetime.fromtimestamp(record.created)
|
||||
formatted_time = dt_object.strftime("%Y-%m-%d %H:%M:%S.%f")
|
||||
# 构造消息字典
|
||||
log_dict = {
|
||||
# 时间
|
||||
"time": formatted_time,
|
||||
# 日志级别 ( DEBUG, INFO, WARNING, ERROR, CRITICAL )
|
||||
"level": record.levelname,
|
||||
# 日志消息
|
||||
"message": record.getMessage(),
|
||||
# =====
|
||||
# 代码所在文件
|
||||
"filename": record.filename,
|
||||
# 代码行号
|
||||
"lineno": record.lineno,
|
||||
# 模块名
|
||||
"module": record.module,
|
||||
# 函数名
|
||||
"funcName": record.funcName,
|
||||
# 异常信息,需在 except 块中开启 exc_info=True
|
||||
"exc_text": record.exc_text,
|
||||
# 堆栈信息,需开启 stack_info=True
|
||||
"stack_info": record.stack_info,
|
||||
# =====
|
||||
# 线程标识符
|
||||
"thread": record.thread,
|
||||
# 线程名称
|
||||
"threadName": record.threadName,
|
||||
# 进程标识符
|
||||
"process": record.process,
|
||||
# 进程名称
|
||||
"processName": record.processName,
|
||||
# 日志记录器的名称
|
||||
"name": record.name,
|
||||
}
|
||||
return log_dict
|
||||
|
||||
# 发送日志
|
||||
def emit(self, record: LogRecord):
|
||||
# 跳过忽略等级
|
||||
if record.levelno < Save_Log_Level:
|
||||
return
|
||||
# 检查文件大小并进行轮转
|
||||
if self.shouldRollover(record):
|
||||
self.doRollover()
|
||||
# 日志信息转字典
|
||||
try:
|
||||
log_dict = self._record_to_dict(record)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
# 输出到日志文件
|
||||
try:
|
||||
with open(self.baseFilename, "a", encoding=self.encoding) as f:
|
||||
json.dump(log_dict, f, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
# TODO: 输出到UI界面
|
||||
|
||||
|
||||
# 日志记录器 管理类
|
||||
class _LogManager:
|
||||
@staticmethod # 控制台处理器
|
||||
def _get_console_handler():
|
||||
# 显式规定输出到 stderr ,避免干涉命令行使用
|
||||
console_handler = logging.StreamHandler(sys.stderr)
|
||||
console_handler.setLevel(logging.DEBUG)
|
||||
fmt = "%(asctime)s %(levelsymbol)s %(funcName)s | %(message)s"
|
||||
formatter = _LevelFormatter(fmt, datefmt="%H:%M:%S") # 使用自定义格式化器
|
||||
console_handler.setFormatter(formatter)
|
||||
return console_handler
|
||||
|
||||
@staticmethod # json处理器,输出到本地文件及UI
|
||||
def _get_json_handler():
|
||||
# 确保日志目录存在
|
||||
if not os.path.exists(Logs_Dir):
|
||||
os.makedirs(Logs_Dir)
|
||||
# 获取当前日期
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
# 构造日志文件路径
|
||||
log_file = os.path.join(Logs_Dir, f"log_{current_date}.jsonl.txt")
|
||||
# 创建json处理器
|
||||
json_handler = _JsonRotatingFileHandler(
|
||||
log_file,
|
||||
mode="a", # 追加写入
|
||||
maxBytes=10485760, # 单个文件最大:10MB
|
||||
backupCount=3, # 文件备份数量
|
||||
encoding="utf-8", # 文件编码
|
||||
delay=True, # 延迟创建文件
|
||||
)
|
||||
json_handler.setLevel(logging.DEBUG)
|
||||
return json_handler
|
||||
|
||||
@staticmethod
|
||||
def create_logger(name):
|
||||
"""创建并返回一个新的日志记录器。"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.addFilter(_CoverFilter()) # 添加覆盖过滤器
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.addHandler(_LogManager._get_console_handler())
|
||||
logger.addHandler(_LogManager._get_json_handler())
|
||||
return logger
|
||||
|
||||
|
||||
# 全局单例日志记录器
|
||||
logger = _LogManager.create_logger("Umi-OCR")
|
||||
|
||||
|
||||
# 获取 QT 日志重定向器
|
||||
def get_qt_message_handler():
|
||||
# 确保在初次调用时才导入QT模块
|
||||
from PySide2.QtCore import QtMsgType, QMessageLogContext
|
||||
|
||||
# 处理 QT (QML) 抛出的日志
|
||||
def qt_message_handler(mode: QtMsgType, context: QMessageLogContext, msg: str):
|
||||
try:
|
||||
if msg in Log_Ignore_List:
|
||||
return
|
||||
# 提取信息
|
||||
filepath = getattr(context, "file", "?")
|
||||
if filepath:
|
||||
filename = os.path.basename(filepath)
|
||||
else:
|
||||
filepath = filename = "?"
|
||||
funcName = getattr(context, "function", "?")
|
||||
if not funcName: # 匿名函数
|
||||
funcName = r"()=>{}"
|
||||
# 覆盖字典
|
||||
extra = {
|
||||
"cover": {
|
||||
"category": getattr(context, "category", "?"),
|
||||
"filename": filename,
|
||||
"funcName": funcName,
|
||||
"lineno": getattr(context, "line", "?"),
|
||||
"version": getattr(context, "version", "?"),
|
||||
"module": "qml",
|
||||
}
|
||||
}
|
||||
if mode == QtMsgType.QtDebugMsg:
|
||||
logger.debug(msg, extra=extra)
|
||||
elif mode == QtMsgType.QtInfoMsg:
|
||||
logger.info(msg, extra=extra)
|
||||
elif mode == QtMsgType.QtWarningMsg:
|
||||
logger.warning(msg, extra=extra)
|
||||
elif mode == QtMsgType.QtCriticalMsg:
|
||||
logger.error(msg, extra=extra)
|
||||
elif mode == QtMsgType.QtFatalMsg:
|
||||
logger.critical(msg, extra=extra)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"qt_message_handler error",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
|
||||
return qt_message_handler
|
||||
|
||||
|
||||
# 更改保存的日志级别,成功返回T
|
||||
def change_save_log_level(levelname):
|
||||
global Save_Log_Level
|
||||
if levelname in _Log_Levels.keys():
|
||||
Save_Log_Level = _Log_Levels[levelname]
|
||||
logger.info(f"设置保存日志级别: {levelname}")
|
||||
return True
|
||||
logger.error(f"设置保存日志级别 {levelname} 失败。")
|
||||
return False
|
||||
|
||||
|
||||
# 打开日志保存目录
|
||||
def open_logs_dir():
|
||||
os.startfile(Logs_Dir)
|
||||
@@ -0,0 +1,114 @@
|
||||
# ===============================================
|
||||
# =============== 文档预览 - 连接器 ===============
|
||||
# ===============================================
|
||||
|
||||
from PySide2.QtCore import QObject, Slot, Signal
|
||||
from PySide2.QtGui import QPixmap, QImage
|
||||
import fitz # PyMuPDF
|
||||
|
||||
from umi_log import logger
|
||||
from .simple_mission import SimpleMission
|
||||
from ..image_controller.image_provider import PixmapProvider
|
||||
from ..utils.call_func import CallFunc
|
||||
from .mission_doc import MissionDOC
|
||||
|
||||
MinSize = 0 # 最小渲染分辨率
|
||||
|
||||
|
||||
# 文档预览连接器
|
||||
class DocPreviewConnector(QObject):
|
||||
previewImg = Signal(str) # imgID
|
||||
previewOcr = Signal("QVariant") # [path, page, res]
|
||||
# 注:信号中含多个变量可能导致崩溃?
|
||||
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self._previewMission = SimpleMission(self._previewTask) # 简单任务对象
|
||||
self._previewDoc = None # 当前预览的对象
|
||||
self._previewPath = ""
|
||||
self._zooms = {} # 缓存页面缩放系数
|
||||
|
||||
# 预览PDF画面
|
||||
@Slot(str, int, str)
|
||||
def preview(self, path, page, password):
|
||||
page -= 1
|
||||
self._previewMission.addMissionList([(path, page, password)])
|
||||
|
||||
def _previewTask(self, msn):
|
||||
path, page, password = msn
|
||||
if path == self._previewPath: # 已经加载了
|
||||
doc = self._previewDoc
|
||||
else: # 新加载
|
||||
try:
|
||||
doc = fitz.open(path)
|
||||
if doc.is_encrypted and not doc.authenticate(password):
|
||||
msg = "[Warning] is_encrypted" # 固定指令,不能改
|
||||
self.previewImg.emit(msg)
|
||||
return
|
||||
except Exception as e:
|
||||
msg = f"[Error] 打开文档失败:{path} {e}"
|
||||
self.previewImg.emit(msg)
|
||||
return
|
||||
self._previewDoc = doc
|
||||
self._previewPath = path
|
||||
self._zooms = {}
|
||||
page_count = doc.page_count
|
||||
if page < 0 or page > page_count:
|
||||
logger.error(f"页数 {page} 超出范围 0-{page_count} 。")
|
||||
return
|
||||
# 检查页面边长,如果低于阈值,则增加放大系数,以提高渲染清晰度
|
||||
rect = doc[page].rect
|
||||
w, h = abs(rect[2] - rect[0]), abs(rect[3] - rect[1])
|
||||
m = min(w, h)
|
||||
if m < MinSize:
|
||||
zoom = MinSize / max(m, 1)
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
self._zooms[page] = zoom
|
||||
else:
|
||||
matrix = fitz.Identity
|
||||
self._zooms[page] = 1
|
||||
p = doc[page].get_pixmap(matrix=matrix)
|
||||
# 通过 QImage fromImage 转换
|
||||
# 必须先使用变量提取出图像 https://github.com/pymupdf/PyMuPDF/issues/1210
|
||||
samples = p.samples
|
||||
# 必须传入 pix.stride ,否则部分格式的图像会导致崩溃
|
||||
qimage = QImage(samples, p.width, p.height, p.stride, QImage.Format_RGB888)
|
||||
qpixmap = QPixmap.fromImage(qimage)
|
||||
imgID = PixmapProvider.addPixmap(qpixmap)
|
||||
self.previewImg.emit(imgID)
|
||||
|
||||
# 预览一页OCR内容
|
||||
@Slot(str, int, str, "QVariant")
|
||||
def ocr(self, path, page, password, argd):
|
||||
argd = argd.toVariant() # qml对象转python字典
|
||||
# if "tbpu.ignoreArea" in argd: # 删除忽略区域参数
|
||||
# del argd["tbpu.ignoreArea"]
|
||||
|
||||
def _onGet(msnInfo, page_, res):
|
||||
# 缩放文本位置
|
||||
if page_ in self._zooms and res["code"] == 100:
|
||||
z = self._zooms[page_]
|
||||
for r in res["data"]:
|
||||
for bi in range(4):
|
||||
r["box"][bi][0] = r["box"][bi][0] * z
|
||||
r["box"][bi][1] = r["box"][bi][1] * z
|
||||
page_ += 1
|
||||
self.previewOcr.emit([path, page_, res])
|
||||
|
||||
def _onEnd(msnInfo, msg):
|
||||
if not msg.startswith("[Success]"):
|
||||
res = {"code": 103, "data": msg}
|
||||
self.previewOcr.emit([path, -1, res])
|
||||
|
||||
msnInfo = {
|
||||
"argd": argd,
|
||||
"onGet": _onGet,
|
||||
"onEnd": _onEnd,
|
||||
}
|
||||
MissionDOC.addMission(msnInfo, path, (page, page), password=password)
|
||||
|
||||
# 清空缓存
|
||||
@Slot()
|
||||
def clear(self):
|
||||
self._previewDoc = None
|
||||
self._previewPath = ""
|
||||
@@ -0,0 +1,302 @@
|
||||
# ==============================================
|
||||
# =============== 任务管理器 基类 ===============
|
||||
# ==============================================
|
||||
|
||||
|
||||
from PySide2.QtCore import QMutex, QRunnable
|
||||
from threading import Condition
|
||||
from uuid import uuid4 # 唯一ID
|
||||
import time
|
||||
|
||||
from umi_log import logger
|
||||
from ..utils.thread_pool import threadRun # 异步执行函数
|
||||
|
||||
|
||||
class Mission:
|
||||
def __init__(self):
|
||||
self._msnInfoDict = {} # 任务信息的字典
|
||||
self._msnListDict = {} # 任务队列的字典
|
||||
self._msnPausedDict = {} # 已暂停的任务队列
|
||||
self._msnMutex = QMutex() # 任务队列的锁
|
||||
self._task = None # 异步任务对象
|
||||
self._taskMutex = QMutex() # 任务对象的锁
|
||||
# 任务队列调度方式
|
||||
# 1111 : 轮询调度,轮流取每个队列的第1个任务
|
||||
# 1234 : 顺序调度,将首个队列所有任务处理完,再进入下一个队列
|
||||
self._schedulingMode = "1111"
|
||||
|
||||
# ========================= 【调用接口】 =========================
|
||||
|
||||
"""
|
||||
添加任务队列的格式
|
||||
mission = {
|
||||
"onStart": 任务队列开始回调函数 , (msnInfo)
|
||||
"onReady": 一项任务准备开始 , (msnInfo, msn)
|
||||
"onGet": 一项任务获取结果 , (msnInfo, msn, res)
|
||||
"onEnd": 任务队列结束 , (msnInfo, msg) // msg可选前缀: [Success] [Warning] [Error]
|
||||
}
|
||||
MissionOCR.addMissionList(mission, paths)
|
||||
"""
|
||||
|
||||
# 【异步】添加一条任务队列。成功返回任务ID,失败返回 startswith("[Error]")
|
||||
# msnInfo: { 回调函数 "onStart", "onReady", "onGet", "onEnd"}
|
||||
# msnList: [ 任务1, 任务2 ]
|
||||
def addMissionList(self, msnInfo, msnList):
|
||||
if len(msnList) < 1:
|
||||
return "[Error] no valid mission in msnList!"
|
||||
msnID = str(uuid4())
|
||||
# 检查并补充回调函数
|
||||
# 队列开始,单个任务准备开始,单任务取得结果,队列结束
|
||||
cbKeys = ["onStart", "onReady", "onGet", "onEnd"]
|
||||
for k in cbKeys:
|
||||
if k not in msnInfo or not callable(msnInfo[k]):
|
||||
msnInfo[k] = lambda *e: None
|
||||
# 任务状态state: waiting 等待开始, running 进行中, stop 要求停止
|
||||
msnInfo["state"] = "waiting"
|
||||
msnInfo["msnID"] = msnID
|
||||
# 添加到任务队列
|
||||
self._msnMutex.lock() # 上锁
|
||||
self._msnInfoDict[msnID] = msnInfo # 添加任务信息
|
||||
self._msnListDict[msnID] = msnList # 添加任务队列
|
||||
self._msnMutex.unlock() # 解锁
|
||||
# 启动任务
|
||||
self._startMsns()
|
||||
# 返回任务id
|
||||
return msnID
|
||||
|
||||
# 停止一些任务队列
|
||||
def stopMissionList(self, msnIDs):
|
||||
if not isinstance(msnIDs, list):
|
||||
msnIDs = [msnIDs]
|
||||
self._msnMutex.lock() # 上锁
|
||||
for msnID in msnIDs:
|
||||
# 将暂停中的任务恢复
|
||||
if msnID in self._msnPausedDict:
|
||||
info, list_ = self._msnPausedDict[msnID]
|
||||
self._msnInfoDict[msnID] = info
|
||||
self._msnListDict[msnID] = list_
|
||||
# 将进行中的任务置为停止状态
|
||||
if msnID in self._msnListDict:
|
||||
self._msnInfoDict[msnID]["state"] = "stop" # 设为停止状态
|
||||
self._msnMutex.unlock() # 解锁
|
||||
self._startMsns() # 拉起工作线程,使已暂停的任务可以正常结束
|
||||
|
||||
# 停止全部任务
|
||||
def stopAllMissions(self):
|
||||
self._msnMutex.lock() # 上锁
|
||||
# 将暂停中的任务恢复
|
||||
for msnID in self._msnPausedDict:
|
||||
info, list_ = self._msnPausedDict[msnID]
|
||||
self._msnInfoDict[msnID] = info
|
||||
self._msnListDict[msnID] = list_
|
||||
# 将进行中的任务置为停止状态
|
||||
for msnID in self._msnListDict:
|
||||
self._msnInfoDict[msnID]["state"] = "stop"
|
||||
self._msnMutex.unlock() # 解锁
|
||||
self._startMsns()
|
||||
|
||||
# 暂停一些任务队列
|
||||
def pauseMissionList(self, msnIDs):
|
||||
if not isinstance(msnIDs, list):
|
||||
msnIDs = [msnIDs]
|
||||
self._msnMutex.lock() # 上锁
|
||||
for msnID in msnIDs:
|
||||
if msnID in self._msnListDict:
|
||||
msn = (self._msnInfoDict[msnID], self._msnListDict[msnID])
|
||||
self._msnPausedDict[msnID] = msn
|
||||
del self._msnInfoDict[msnID]
|
||||
del self._msnListDict[msnID]
|
||||
self._msnMutex.unlock() # 解锁
|
||||
logger.debug(f"任务暂停: {msnID}")
|
||||
|
||||
# 恢复一些任务队列的运行
|
||||
def resumeMissionList(self, msnIDs):
|
||||
if not isinstance(msnIDs, list):
|
||||
msnIDs = [msnIDs]
|
||||
self._msnMutex.lock() # 上锁
|
||||
for msnID in msnIDs:
|
||||
if msnID in self._msnPausedDict:
|
||||
info, list_ = self._msnPausedDict[msnID]
|
||||
self._msnInfoDict[msnID] = info
|
||||
self._msnListDict[msnID] = list_
|
||||
del self._msnPausedDict[msnID]
|
||||
self._msnMutex.unlock() # 解锁
|
||||
self._startMsns() # 拉起工作线程
|
||||
logger.debug(f"任务恢复: {msnID}")
|
||||
|
||||
# 获取每一条任务队列长度
|
||||
def getMissionListsLength(self):
|
||||
lenDict = {}
|
||||
self._msnMutex.lock()
|
||||
for k in self._msnListDict:
|
||||
lenDict[str(k)] = len(self._msnListDict[k])
|
||||
self._msnMutex.unlock()
|
||||
return lenDict
|
||||
|
||||
# 【同步】添加一个任务或队列,等待完成,返回任务结果列表。[i]["result"]为结果
|
||||
def addMissionWait(self, argd, msnList):
|
||||
if not isinstance(msnList, list):
|
||||
msnList = [msnList]
|
||||
resList = msnList[:] # 浅拷贝出一条结果列表
|
||||
nowIndex = 0 # 当前处理的任务
|
||||
msnLen = len(msnList)
|
||||
condition = Condition() # 线程同步器
|
||||
endMsg = "" # 任务结束的消息
|
||||
|
||||
def _onGet(msnInfo, msn, res):
|
||||
nonlocal nowIndex
|
||||
resList[nowIndex]["result"] = res
|
||||
nowIndex += 1
|
||||
|
||||
def _onEnd(msnInfo, msg):
|
||||
nonlocal endMsg
|
||||
endMsg = msg
|
||||
with condition: # 释放线程阻塞
|
||||
condition.notify()
|
||||
|
||||
def _pass(*x):
|
||||
pass
|
||||
|
||||
msnInfo = {
|
||||
"onStart": _pass,
|
||||
"onReady": _pass,
|
||||
"onGet": _onGet,
|
||||
"onEnd": _onEnd,
|
||||
"argd": argd,
|
||||
}
|
||||
msnID = self.addMissionList(msnInfo, msnList)
|
||||
if msnID.startswith("[Error]"): # 添加任务失败
|
||||
endMsg = msnID
|
||||
else: # 添加成功,线程阻塞,直到任务完成。
|
||||
with condition:
|
||||
condition.wait()
|
||||
# 补充未完成的任务
|
||||
for i in range(nowIndex, msnLen):
|
||||
if "result" not in resList[i]:
|
||||
resList[i]["result"] = {"code": 803, "data": f"任务提前结束。{endMsg}"}
|
||||
return resList
|
||||
|
||||
# ========================= 【主线程 方法】 =========================
|
||||
|
||||
def _startMsns(self): # 启动异步任务,执行所有任务列表
|
||||
# 若当前异步任务对象为空,则创建工作线程
|
||||
self._taskMutex.lock() # 上锁
|
||||
if self._task == None:
|
||||
self._task = threadRun(self._taskRun)
|
||||
self._taskMutex.unlock() # 解锁
|
||||
|
||||
# ========================= 【子线程 方法】 =========================
|
||||
|
||||
def _taskRun(self): # 异步执行任务字典的流程
|
||||
dictIndex = 0 # 当前取任务字典中的第几个任务队列
|
||||
# 循环,直到任务队列的列表为空
|
||||
while True:
|
||||
# 1. 检查api和任务字典是否为空
|
||||
self._msnMutex.lock() # 锁1 上锁
|
||||
dl = len(self._msnInfoDict) # 任务字典长度
|
||||
if dl == 0: # 任务字典已空
|
||||
self._msnMutex.unlock() # 锁1 解锁
|
||||
break
|
||||
|
||||
# 2. 任务调度,取一个任务
|
||||
if self._schedulingMode == "1111": # 轮询
|
||||
dictIndex = (dictIndex + 1) % dl
|
||||
elif self._schedulingMode == "1234": # 顺序
|
||||
dictIndex = 0 # 始终为首个队列
|
||||
dictKey = tuple(self._msnInfoDict.keys())[dictIndex]
|
||||
msnInfo = self._msnInfoDict[dictKey]
|
||||
msnList = self._msnListDict[dictKey]
|
||||
self._msnMutex.unlock() # 锁1 解锁
|
||||
|
||||
# 3. 检查任务是否要求停止
|
||||
if msnInfo["state"] == "stop":
|
||||
self._msnDictDel(dictKey)
|
||||
msnInfo["onEnd"](msnInfo, "[Warning] Task stop.")
|
||||
continue
|
||||
|
||||
# 4. 前处理,检查、更新参数
|
||||
preFlag = self.msnPreTask(msnInfo)
|
||||
if preFlag == "continue": # 跳过本次
|
||||
logger.debug(f"任务跳过: {dictKey}")
|
||||
continue
|
||||
elif preFlag.startswith("[Error]"): # 异常,结束该队列
|
||||
msnInfo["onEnd"](msnInfo, preFlag)
|
||||
self._msnDictDel(dictKey)
|
||||
dictIndex -= 1 # 字典下标回退1位,下次执行正确的下一项
|
||||
continue
|
||||
|
||||
# 5. 首次任务
|
||||
if msnInfo["state"] == "waiting":
|
||||
msnInfo["state"] = "running"
|
||||
msnInfo["onStart"](msnInfo)
|
||||
|
||||
# 6. 执行任务,并记录时间
|
||||
msn = msnList[0]
|
||||
msnInfo["onReady"](msnInfo, msn)
|
||||
t1 = time.time()
|
||||
res = self.msnTask(msnInfo, msn)
|
||||
t2 = time.time()
|
||||
if isinstance(res, dict): # 补充耗时和时间戳
|
||||
res["time"] = t2 - t1
|
||||
res["timestamp"] = t2
|
||||
|
||||
# 7. 再次检查任务是否要求停止,或者已暂停
|
||||
self._msnMutex.lock() # 锁2 上锁
|
||||
if msnInfo["state"] == "stop":
|
||||
self._msnDictDel(dictKey)
|
||||
self._msnMutex.unlock() # 锁2 解锁
|
||||
msnInfo["onEnd"](msnInfo, "[Warning] Task stop.")
|
||||
continue
|
||||
if dictKey not in self._msnInfoDict:
|
||||
self._msnMutex.unlock() # 锁2 解锁
|
||||
continue
|
||||
|
||||
# 8. 不停止,则上报该任务
|
||||
msnList.pop(0) # 弹出该任务
|
||||
self._msnMutex.unlock() # 锁2 解锁
|
||||
# 回调。注意:回调函数执行时间长时,可能用户再次提交了任务暂停,需要后续继续判断。
|
||||
msnInfo["onGet"](msnInfo, msn, res)
|
||||
|
||||
# 9. 这条任务队列完成
|
||||
if len(msnList) == 0:
|
||||
msnInfo["onEnd"](msnInfo, "[Success]")
|
||||
self._msnMutex.lock() # 锁3 上锁
|
||||
self._msnDictDel(dictKey)
|
||||
self._msnMutex.unlock() # 锁3 解锁
|
||||
dictIndex -= 1 # 字典下标回退1位,下次执行正确的下一项
|
||||
|
||||
# 完成
|
||||
self._taskFinish()
|
||||
|
||||
def _msnDictDel(self, dictKey): # 停止一组任务队列
|
||||
# 正常 删除任务队列项
|
||||
if dictKey in self._msnInfoDict:
|
||||
del self._msnInfoDict[dictKey]
|
||||
del self._msnListDict[dictKey]
|
||||
# 如果该任务在暂停中,则移除暂停队列中的项
|
||||
if dictKey in self._msnPausedDict:
|
||||
del self._msnPausedDict[dictKey]
|
||||
logger.debug(f"移除暂停任务: {dictKey}")
|
||||
|
||||
def _taskFinish(self): # 任务结束
|
||||
self._taskMutex.lock() # 上锁
|
||||
self._task = None
|
||||
self._taskMutex.unlock() # 解锁
|
||||
|
||||
# ========================= 【继承重载】 =========================
|
||||
|
||||
def msnPreTask(self, msnInfo): # 任务前处理,用于更新api和参数。
|
||||
"""返回值可选:
|
||||
"" :空字符串表示正常继续。
|
||||
"continue" :跳过本次任务
|
||||
"[Error] xxxx" :终止这条任务队列,返回异常信息
|
||||
"""
|
||||
# return "[Error] No overloaded msnPreTask. \n【异常】未重载msnPreTask。"
|
||||
return ""
|
||||
|
||||
def msnTask(self, msnInfo, msn): # 执行任务msn,返回结果字典。
|
||||
logger.debug("mission 未重载 msnTask")
|
||||
return {"error": "[Error] No overloaded msnTask. \n【异常】未重载msnTask。"}
|
||||
|
||||
def getStatus(self): # 返回当前状态
|
||||
return "Mission 基类 返回空状态"
|
||||
@@ -0,0 +1,32 @@
|
||||
# =====================================================================
|
||||
# =============== qml与【后端Python任务控制器们】的连接器 ===============
|
||||
# =====================================================================
|
||||
|
||||
from PySide2.QtCore import QObject, Slot, Signal
|
||||
|
||||
# 本模块内定义的任务控制器单例
|
||||
from .mission_ocr import MissionOCR
|
||||
from umi_log import logger
|
||||
|
||||
# 控制器字典
|
||||
MsnObjDict = {"ocr": MissionOCR}
|
||||
|
||||
|
||||
# 任务连接器类(不限制单例)
|
||||
class MissionConnector(QObject):
|
||||
# qml访问某个Python任务控制器的方法(同步)
|
||||
# qml调用ctrlKey的方法funcName,入参为列表(对应参数顺序),返回值为可变类型。
|
||||
@Slot(str, str, list, result="QVariant")
|
||||
def callPy(self, msnKey, funcName, args=()):
|
||||
if msnKey not in MsnObjDict:
|
||||
logger.error(f"qml访问不存在的任务控制器 {msnKey} 。")
|
||||
return None
|
||||
# 获取方法的引用
|
||||
msnObj = MsnObjDict[msnKey]
|
||||
method = getattr(msnObj, funcName, None)
|
||||
# 查询失败
|
||||
if not callable(method):
|
||||
logger.error(f"qml调用了 {msnKey} 的不存在的py方法 {funcName} 。")
|
||||
return None
|
||||
# 调用方法,参数不对的话让系统抛出错误
|
||||
return method(*args)
|
||||
@@ -0,0 +1,372 @@
|
||||
# ===============================================
|
||||
# =============== 文档 - 任务管理器 ===============
|
||||
# ===============================================
|
||||
|
||||
# API所有页数page 均为1开始
|
||||
|
||||
import fitz # PyMuPDF
|
||||
import time
|
||||
import math
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
|
||||
from umi_log import logger
|
||||
from .mission import Mission
|
||||
from .mission_ocr import MissionOCR
|
||||
from ..ocr.tbpu import getParser
|
||||
from ..ocr.tbpu import IgnoreArea
|
||||
from ..ocr.tbpu.parser_tools.paragraph_parse import word_separator # 上下句间隔符
|
||||
|
||||
MinSize = 1080 # 最小渲染分辨率
|
||||
|
||||
# 合法文件后缀
|
||||
DocSuf = [
|
||||
".pdf",
|
||||
".xps",
|
||||
".epub",
|
||||
".mobi",
|
||||
".fb2",
|
||||
".cbz",
|
||||
]
|
||||
|
||||
|
||||
class FitzOpen:
|
||||
def __init__(self, path):
|
||||
self._path = path
|
||||
self._doc = None
|
||||
|
||||
def __enter__(self):
|
||||
self._doc = fitz.open(self._path)
|
||||
return self._doc
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self._doc.close()
|
||||
|
||||
|
||||
# https://pymupdf.readthedocs.io/en/latest/matrix.html#matrix
|
||||
# 从变换矩阵中提取角度值,返回0~359整数
|
||||
def transform_to_rotation(matrix):
|
||||
# [1, 0, 0, 1, 0, 0] -> [cos(deg), sin(deg), -sin(deg), cos(deg), 0, 0].
|
||||
a, b, c, d, _, _ = matrix
|
||||
# 处理缩放和反射
|
||||
scale = math.sqrt(a**2 + b**2)
|
||||
if scale < 1e-6:
|
||||
return 0
|
||||
# 归一化以消除缩放影响
|
||||
cos_theta = a / scale
|
||||
sin_theta = b / scale
|
||||
# 检查反射
|
||||
determinant = a * d - b * c
|
||||
if determinant < 0:
|
||||
# 反射情况,调整角度计算
|
||||
cos_theta = -cos_theta
|
||||
theta_rad = math.atan2(sin_theta, cos_theta)
|
||||
theta_deg = math.degrees(theta_rad)
|
||||
rounded_deg = round(theta_deg) % 360
|
||||
return rounded_deg
|
||||
|
||||
|
||||
class _MissionDocClass(Mission):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._schedulingMode = "1234" # 调度方式:顺序
|
||||
self._minInterval = 0.1 # msnTask最短调用间隔
|
||||
self._lastCallTime = 0 # 上一次调用时间
|
||||
|
||||
# 添加一个文档任务
|
||||
# msnInfo: { 回调函数"onXX", 参数"argd":{"tbpu.xx", "ocr.xx"} }
|
||||
# msnPath: 单个文档路径
|
||||
# pageRange: 页数范围。可选: None 全部页 , [1,3] 页面范围(含开头结束)。
|
||||
# pageList: 指定多个页数。可选: [] 使用pageRange设置 , [1,2,3] 指定页数
|
||||
# password: 密码(非必填)
|
||||
def addMission(self, msnInfo, msnPath, pageRange=None, pageList=[], password=""):
|
||||
# =============== 加载文档,获取文档操作对象 ===============
|
||||
try:
|
||||
doc = fitz.open(msnPath)
|
||||
except Exception as e:
|
||||
return f"[Error] fitz.open error: {msnPath} {e}"
|
||||
if doc.is_encrypted and not doc.authenticate(password):
|
||||
if password:
|
||||
msg = f"[Error] Incorrect password. 文档已加密,密码错误。 [{password}]"
|
||||
else:
|
||||
msg = "[Error] Doc encrypted. 文档已加密,请提供密码。"
|
||||
return msg
|
||||
msnInfo["doc"] = doc
|
||||
msnInfo["path"] = msnPath
|
||||
# =============== 拦截 onEnd ===============
|
||||
msnInfo["sourceOnEnd"] = msnInfo["onEnd"] if "onEnd" in msnInfo else None
|
||||
msnInfo["onEnd"] = self._preOnEnd
|
||||
# =============== pageRange 页面范围 ===============
|
||||
page_count = doc.page_count
|
||||
if len(pageList) == 0:
|
||||
if isinstance(pageRange, (tuple, list)) and len(pageRange) == 2:
|
||||
a, b = pageRange[0], pageRange[1]
|
||||
if a < 0:
|
||||
a += page_count + 1
|
||||
if b < 0:
|
||||
b += page_count + 1
|
||||
if a < 1:
|
||||
return f"[Error] pageRange {pageRange} 范围起始不能小于1"
|
||||
if b > page_count:
|
||||
return f"[Error] pageRange {pageRange} 范围结束不能大于页数 {doc.page_count}"
|
||||
if a > b:
|
||||
return f"[Error] pageRange {pageRange} 范围错误"
|
||||
pageList = list(range(a - 1, b))
|
||||
else:
|
||||
pageList = list(range(0, page_count))
|
||||
# 检查页数列表合法性
|
||||
if len(pageList) == 0:
|
||||
return "[Error] 页数列表为空"
|
||||
for p in pageList:
|
||||
if not isinstance(p, int):
|
||||
return "[Error] 页数列表内容非整数"
|
||||
if not 0 <= p < page_count:
|
||||
return f"[Error] 页数列表超出 1~{page_count} 范围"
|
||||
msnInfo["pageList"] = pageList
|
||||
# =============== tbpu文本块后处理 msnInfo["tbpu"] ===============
|
||||
argd = msnInfo["argd"] # 参数
|
||||
msnInfo["tbpu"] = []
|
||||
msnInfo["ignoreArea"] = {}
|
||||
# 忽略区域
|
||||
if "tbpu.ignoreArea" in argd:
|
||||
iArea = argd["tbpu.ignoreArea"]
|
||||
if isinstance(iArea, list) and len(iArea) > 0:
|
||||
msnInfo["ignoreArea"]["obj"] = IgnoreArea(iArea)
|
||||
# 范围,负数转为倒数第x页
|
||||
igStart = argd.get("tbpu.ignoreRangeStart", 1)
|
||||
igEnd = argd.get("tbpu.ignoreRangeEnd", page_count)
|
||||
if igStart < 0:
|
||||
igStart += page_count + 1
|
||||
if igEnd < 0:
|
||||
igEnd += page_count + 1
|
||||
msnInfo["ignoreArea"]["start"] = igStart - 1 # -1是将起始1页转为起始0页
|
||||
msnInfo["ignoreArea"]["end"] = igEnd - 1
|
||||
logger.debug(f"忽略区域范围: {igStart} ~ {igEnd} 。")
|
||||
# 获取排版解析器对象
|
||||
if "tbpu.parser" in argd:
|
||||
msnInfo["tbpu"].append(getParser(argd["tbpu.parser"]))
|
||||
return self.addMissionList(msnInfo, pageList)
|
||||
|
||||
def msnTask(self, msnInfo, pno): # 执行msn。pno为当前页数
|
||||
doc = msnInfo["doc"] # 文档对象
|
||||
page = doc[pno] # 页面对象
|
||||
argd = msnInfo["argd"] # 参数
|
||||
extractionMode = argd["doc.extractionMode"] # OCR内容模式
|
||||
""" mixed - 混合OCR/拷贝文本
|
||||
fullPage - 整页强制OCR
|
||||
imageOnly - 仅OCR图片
|
||||
textOnly - 仅拷贝原有文本 """
|
||||
errMsg = "" # 本次任务流程的异常信息
|
||||
|
||||
# =============== 提取图片和原文本 ===============
|
||||
imgs = [] # 待OCR的图片列表
|
||||
tbs = [] # text box 文本块列表
|
||||
page_rotation = page.rotation # 获取页面的旋转角度
|
||||
if extractionMode == "fullPage": # 模式:整页强制OCR
|
||||
# 检查页面边长,如果低于阈值,则增加放大系数,以提高渲染清晰度
|
||||
rect = page.rect
|
||||
w, h = abs(rect[2] - rect[0]), abs(rect[3] - rect[1])
|
||||
m = min(w, h)
|
||||
if m < MinSize:
|
||||
zoom = MinSize / max(m, 1)
|
||||
matrix = fitz.Matrix(zoom, zoom)
|
||||
else:
|
||||
zoom = 1
|
||||
matrix = fitz.Identity
|
||||
p = page.get_pixmap(matrix=matrix)
|
||||
bytes = p.tobytes("png")
|
||||
scale = 1 / zoom
|
||||
imgs.append(
|
||||
{"bytes": bytes, "xy": (0, 0), "scale_w": scale, "scale_h": scale}
|
||||
)
|
||||
else:
|
||||
# 获取元素 https://pymupdf.readthedocs.io/en/latest/_images/img-textpage.png
|
||||
# https://pymupdf.readthedocs.io/en/latest/textpage.html#structure-of-dictionary-outputs
|
||||
# 确保越界图像能被采集 https://github.com/pymupdf/PyMuPDF/issues/3171
|
||||
p = page.get_text("dict", clip=fitz.INFINITE_RECT())
|
||||
for t in p["blocks"]: # 遍历区块(段落)
|
||||
# ========== 获取图片 ==========
|
||||
if t["type"] == 1 and (
|
||||
extractionMode == "imageOnly" or extractionMode == "mixed"
|
||||
):
|
||||
# 提取图片相对旋转角,加上页面旋转角,得到图片绝对旋转角
|
||||
transform = t["transform"]
|
||||
img_rotation = transform_to_rotation(transform)
|
||||
abs_rotation = round(page_rotation+img_rotation) % 360
|
||||
img_bytes = t["image"] # 图片字节
|
||||
bbox = t["bbox"] # 图片包围盒
|
||||
# 图片视觉大小、原始大小、缩放比例
|
||||
w1, h1 = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
||||
w2, h2 = t["width"], t["height"]
|
||||
# 特殊情况:图片宽高为0
|
||||
if w2 <= 0 or h2 <= 0:
|
||||
continue
|
||||
# 单独计算宽高的缩放比例
|
||||
scale_w = w1 / w2
|
||||
scale_h = h1 / h2
|
||||
# 如果图片有绝对旋转,则逆向旋转图片字节
|
||||
if page_rotation != 0 or img_rotation != 0:
|
||||
logger.debug(f"P{pno}-{len(imgs)} 旋转:页面{page_rotation}°,图片{img_rotation}°,绝对{abs_rotation}°")
|
||||
if abs_rotation != 0:
|
||||
try:
|
||||
with Image.open(BytesIO(img_bytes)) as pimg:
|
||||
# 记录原图格式
|
||||
format = pimg.format
|
||||
if not format:
|
||||
format = "PNG"
|
||||
# PDF的旋转是顺时针,需要逆时针旋转图片
|
||||
pimg = pimg.rotate(-abs_rotation, expand=True)
|
||||
# 将旋转后的图片转回bytes
|
||||
buffered = BytesIO()
|
||||
pimg.save(buffered, format=format)
|
||||
img_bytes = buffered.getvalue()
|
||||
except Exception:
|
||||
logger.error(
|
||||
"旋转文档图片异常。", exc_info=True, stack_info=True
|
||||
)
|
||||
# 记录图片
|
||||
imgs.append(
|
||||
{
|
||||
"bytes": img_bytes,
|
||||
"xy": (bbox[0], bbox[1]),
|
||||
"scale_w": scale_w,
|
||||
"scale_h": scale_h,
|
||||
}
|
||||
)
|
||||
# ========== 获取文本块 ==========
|
||||
elif t["type"] == 0 and (
|
||||
extractionMode == "textOnly" or extractionMode == "mixed"
|
||||
):
|
||||
l = len(t["lines"]) - 1
|
||||
for index, line in enumerate(t["lines"]): # 遍历每一行
|
||||
# 拼接该行所有子文本块的内容
|
||||
text = ""
|
||||
for span in line["spans"]:
|
||||
text += span["text"]
|
||||
# 提取其他信息,组装为OCR文本块格式
|
||||
if text:
|
||||
# 获取该行的的包围盒
|
||||
b = line["bbox"]
|
||||
if page_rotation == 0: # 页面没有旋转,直接提取
|
||||
box = [
|
||||
[b[0], b[1]],
|
||||
[b[2], b[1]],
|
||||
[b[2], b[3]],
|
||||
[b[0], b[3]],
|
||||
]
|
||||
else: # 页面有旋转,默认文本行无相对旋转,则反向消除文本的绝对旋转
|
||||
# https://pymupdf.readthedocs.io/en/latest/page.html#Page.derotation_matrix
|
||||
rotation_matrix = page.rotation_matrix
|
||||
b01 = fitz.Point(b[0], b[1]) * rotation_matrix
|
||||
b23 = fitz.Point(b[2], b[3]) * rotation_matrix
|
||||
x0 = min(b01.x, b23.x)
|
||||
x1 = max(b01.x, b23.x)
|
||||
y0 = min(b01.y, b23.y)
|
||||
y1 = max(b01.y, b23.y)
|
||||
box = [
|
||||
[x0, y0],
|
||||
[x1, y0],
|
||||
[x1, y1],
|
||||
[x0, y1],
|
||||
]
|
||||
# 组装文本块
|
||||
tb = {
|
||||
"box": box,
|
||||
"text": text,
|
||||
"score": 1,
|
||||
"end": "\n" if index == l else "", # 结尾符
|
||||
"from": "text", # 来源:直接提取文本
|
||||
}
|
||||
tbs.append(tb)
|
||||
# 补充结尾符
|
||||
for i1 in range(len(tbs) - 1):
|
||||
if tbs[i1]["end"]: # 跳过已有结尾符的
|
||||
continue
|
||||
i2 = i1 + 1
|
||||
sep = word_separator(tbs[i1]["text"][-1], tbs[i2]["text"][0])
|
||||
tbs[i1]["end"] = sep
|
||||
|
||||
# =============== 调用OCR,将 imgs 的内容提取出来放入 tbs ===============
|
||||
if imgs:
|
||||
# 提取 "ocr." 开头的参数,组装OCR参数字典
|
||||
ocrArgd = {}
|
||||
for k in argd:
|
||||
if k.startswith("ocr."):
|
||||
ocrArgd[k] = argd[k]
|
||||
# 调用OCR,堵塞等待任务完成
|
||||
ocrList = MissionOCR.addMissionWait(ocrArgd, imgs)
|
||||
# 整理OCR结果
|
||||
for o in ocrList:
|
||||
res = o["result"]
|
||||
if res["code"] == 100:
|
||||
x, y = o["xy"]
|
||||
scale_w = o["scale_w"]
|
||||
scale_h = o["scale_h"]
|
||||
for r in res["data"]:
|
||||
# 将所有文本块的坐标,从图片相对坐标系,转为页面绝对坐标系
|
||||
for bi in range(4):
|
||||
r["box"][bi][0] = r["box"][bi][0] * scale_w + x
|
||||
r["box"][bi][1] = r["box"][bi][1] * scale_h + y
|
||||
r["from"] = "ocr" # 来源:OCR
|
||||
tbs.append(r)
|
||||
elif res["code"] != 101:
|
||||
errMsg += f'[Error] OCR code:{res["code"]} msg:{res["data"]}\n'
|
||||
|
||||
# =============== tbpu文本块后处理 ===============
|
||||
# 忽略区域
|
||||
if msnInfo["ignoreArea"] and tbs:
|
||||
# 检查范围
|
||||
igStart = msnInfo["ignoreArea"]["start"]
|
||||
igEnd = msnInfo["ignoreArea"]["end"]
|
||||
if pno >= igStart and pno <= igEnd:
|
||||
tbs = msnInfo["ignoreArea"]["obj"].run(tbs)
|
||||
# 其他tbpu
|
||||
if msnInfo["tbpu"] and tbs:
|
||||
for tbpu in msnInfo["tbpu"]:
|
||||
tbs = tbpu.run(tbs)
|
||||
|
||||
# =============== 组装结果字典 resDict ===============
|
||||
if errMsg:
|
||||
logger.error(f"文档识别异常。P{pno}, errMsg: {errMsg}")
|
||||
errMsg = f"[Error] Doc P{pno}\n" + errMsg
|
||||
|
||||
if tbs: # 有文本
|
||||
resDict = {"code": 100, "data": tbs}
|
||||
elif errMsg: # 无文本,有异常
|
||||
resDict = {"code": 102, "data": errMsg}
|
||||
else: # 无文本,无异常
|
||||
resDict = {"code": 101, "data": ""}
|
||||
|
||||
# ===== 仅提取文本时任务速度过快,频繁回调会导致UI卡死,因此故意引入延迟 =====
|
||||
currentTime = time.time()
|
||||
elapsedTime = currentTime - self._lastCallTime
|
||||
# 如果与上一次调用的时间差小于最短间隔,则睡至满足最短间隔
|
||||
if elapsedTime < self._minInterval:
|
||||
t = self._minInterval - elapsedTime
|
||||
time.sleep(t)
|
||||
self._lastCallTime = currentTime
|
||||
return resDict
|
||||
|
||||
# 获取一个文档的信息,如页数
|
||||
def getDocInfo(self, path):
|
||||
try:
|
||||
with FitzOpen(path) as doc:
|
||||
info = {
|
||||
"path": path,
|
||||
"page_count": doc.page_count,
|
||||
"is_encrypted": doc.is_encrypted,
|
||||
}
|
||||
return info
|
||||
except Exception as e:
|
||||
return {"path": path, "error": e}
|
||||
|
||||
# 结束前的处理
|
||||
def _preOnEnd(self, msnInfo, msg):
|
||||
# 先关闭文档对象,再触发原本的 onEnd ,防止新文档保存到原路径时的冲突
|
||||
msnInfo["doc"].close()
|
||||
if msnInfo["sourceOnEnd"]:
|
||||
msnInfo["sourceOnEnd"](msnInfo, msg)
|
||||
|
||||
|
||||
# 全局 DOC 任务管理器
|
||||
MissionDOC = _MissionDocClass()
|
||||
@@ -0,0 +1,166 @@
|
||||
# ===============================================
|
||||
# =============== OCR - 任务管理器 ===============
|
||||
# ===============================================
|
||||
|
||||
"""
|
||||
一种任务管理器为全局单例,不同标签页要执行同一种任务,要访问对应的任务管理器。
|
||||
任务管理器中有一个引擎API实例,所有任务均使用该API。
|
||||
标签页可以向任务管理器提交一组任务队列,其中包含了每一项任务的信息,及总体的参数和回调。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from umi_log import logger
|
||||
from .mission import Mission
|
||||
from ..ocr.tbpu import getParser, IgnoreArea
|
||||
from ..ocr.api import getApiOcr, getLocalOptions
|
||||
from ..utils.utils import argdIntConvert
|
||||
|
||||
# 合法文件后缀
|
||||
ImageSuf = [
|
||||
".jpg",
|
||||
".jpe",
|
||||
".jpeg",
|
||||
".jfif",
|
||||
".png",
|
||||
".webp",
|
||||
".bmp",
|
||||
".tif",
|
||||
".tiff",
|
||||
]
|
||||
|
||||
|
||||
class __MissionOcrClass(Mission):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._apiKey = "" # 当前api类型
|
||||
self._api = None # 当前引擎api对象
|
||||
|
||||
# ========================= 【重载】 =========================
|
||||
|
||||
# msnInfo: { 回调函数"onXX", 参数"argd":{"tbpu.xx", "ocr.xx"} }
|
||||
# msnList: [ { "path", "bytes", "base64" } ]
|
||||
def addMissionList(self, msnInfo, msnList): # 添加任务列表
|
||||
# 实例化 tbpu 文本后处理模块
|
||||
msnInfo["tbpu"] = []
|
||||
argd = msnInfo["argd"]
|
||||
# 忽略区域
|
||||
if "tbpu.ignoreArea" in argd:
|
||||
iArea = argd["tbpu.ignoreArea"]
|
||||
if isinstance(iArea, list) and len(iArea) > 0:
|
||||
msnInfo["tbpu"].append(IgnoreArea(iArea))
|
||||
# 获取排版解析器对象
|
||||
if "tbpu.parser" in argd:
|
||||
msnInfo["tbpu"].append(getParser(argd["tbpu.parser"]))
|
||||
# 检查任务合法性
|
||||
for i in range(len(msnList) - 1, -1, -1):
|
||||
if "path" in msnList[i]:
|
||||
p = msnList[i]["path"]
|
||||
if os.path.splitext(p)[-1].lower() not in ImageSuf:
|
||||
logger.warning(f"添加OCR任务时,第{i}项的路径path不是图片:{p}")
|
||||
del msnList[i]
|
||||
elif "bytes" not in msnList[i] and "base64" not in msnList[i]:
|
||||
logger.warning(f"添加OCR任务时,第{i}项不含 path、bytes、base64")
|
||||
del msnList[i]
|
||||
return super().addMissionList(msnInfo, msnList)
|
||||
|
||||
def msnPreTask(self, msnInfo): # 用于更新api和参数
|
||||
# 检查API对象
|
||||
if not self._api:
|
||||
return "[Error] MissionOCR: API object is None."
|
||||
# 检查参数更新
|
||||
startInfo = self._dictShortKey(msnInfo["argd"])
|
||||
# 恢复int类型
|
||||
argdIntConvert(startInfo)
|
||||
msg = self._api.start(startInfo)
|
||||
if msg.startswith("[Error]"):
|
||||
logger.error(f"OCR引擎启动失败: {msg}")
|
||||
return msg # 更新失败,结束该队列
|
||||
else:
|
||||
return "" # 更新成功 TODO: continue
|
||||
|
||||
def msnTask(self, msnInfo, msn): # 执行msn
|
||||
if "path" in msn:
|
||||
res = self._api.runPath(msn["path"])
|
||||
res["path"] = msn["path"] # 结果字典中补充参数
|
||||
elif "bytes" in msn:
|
||||
res = self._api.runBytes(msn["bytes"])
|
||||
elif "base64" in msn:
|
||||
res = self._api.runBase64(msn["base64"])
|
||||
else:
|
||||
res = {
|
||||
"code": 901,
|
||||
"data": f"[Error] Unknown task type.\n【异常】未知的任务类型。\n{str(msn)[:100]}",
|
||||
}
|
||||
# 任务成功时的后处理
|
||||
if res["code"] == 100:
|
||||
# 计算平均置信度
|
||||
score, num = 0, 0
|
||||
for r in res["data"]:
|
||||
score += r["score"]
|
||||
num += 1
|
||||
if num > 0:
|
||||
score /= num
|
||||
res["score"] = score
|
||||
# 执行 tbpu
|
||||
if msnInfo["tbpu"]:
|
||||
for tbpu in msnInfo["tbpu"]:
|
||||
res["data"] = tbpu.run(res["data"])
|
||||
# 如果忽略区域等处理将所有文本删除,则结束tbpu
|
||||
if not res["data"]:
|
||||
res["code"] = 101
|
||||
res["data"] = ""
|
||||
break
|
||||
return res
|
||||
|
||||
# ========================= 【qml接口】 =========================
|
||||
|
||||
def getStatus(self): # 返回当前状态
|
||||
return {
|
||||
"apiKey": self._apiKey,
|
||||
"missionListsLength": self.getMissionListsLength(),
|
||||
}
|
||||
|
||||
def setApi(self, apiKey, info): # 设置api
|
||||
# 成功返回 [Success] ,失败返回 [Error] 开头的字符串
|
||||
self._apiKey = apiKey
|
||||
info = self._dictShortKey(info)
|
||||
# 如果api对象已启动,则先停止
|
||||
if self._api:
|
||||
self._api.stop()
|
||||
# 获取新api对象
|
||||
res = getApiOcr(apiKey, info)
|
||||
# 失败
|
||||
if isinstance(res, str):
|
||||
self._apiKey = ""
|
||||
self._api = None
|
||||
return res
|
||||
# 成功
|
||||
else:
|
||||
self._api = res
|
||||
return "[Success]"
|
||||
|
||||
# 将字典中配置项的长key转为短key
|
||||
# 如: ocr.win32_PaddleOCR-json.path → path
|
||||
def _dictShortKey(self, d):
|
||||
newD = {}
|
||||
key1 = "ocr."
|
||||
key2 = key1 + self._apiKey + "."
|
||||
for k in d:
|
||||
if k.startswith(key2):
|
||||
newD[k[len(key2) :]] = d[k]
|
||||
elif k.startswith(key1):
|
||||
newD[k[len(key1) :]] = d[k]
|
||||
return newD
|
||||
|
||||
# ========================= 【qml接口】 =========================
|
||||
|
||||
def getLocalOptions(self):
|
||||
if self._apiKey:
|
||||
return getLocalOptions(self._apiKey)
|
||||
else:
|
||||
return {}
|
||||
|
||||
|
||||
# 全局 OCR任务管理器
|
||||
MissionOCR = __MissionOcrClass()
|
||||
@@ -0,0 +1,248 @@
|
||||
# =================================================
|
||||
# =============== 二维码 - 任务管理器 ===============
|
||||
# =================================================
|
||||
|
||||
import base64
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
from io import BytesIO
|
||||
from .mission import Mission
|
||||
|
||||
try:
|
||||
import zxingcpp
|
||||
except Exception as e:
|
||||
zxingcpp = None
|
||||
zxingcppErr = str(e)
|
||||
|
||||
|
||||
class _MissionQRCodeClass(Mission):
|
||||
|
||||
def createImage(self, text, format="QRCode", w=0, h=0, quiet_zone=-1, ec_level=-1):
|
||||
"""
|
||||
生成二维码图片
|
||||
format: "Aztec","Codabar","Code128","Code39","Code93","DataBar","DataBarExpanded","DataMatrix","EAN13","EAN8","ITF","LinearCodes","MatrixCodes","MaxiCode","MicroQRCode","PDF417","QRCode","UPCA","UPCE",
|
||||
quiet_zone: 四周的空闲区域
|
||||
ec_level:纠错等级,-1 - 自动, 1- L-7% , 0 - M-15%, 3 - Q-25%, 2 - H-30%
|
||||
纠错仅用于Aztec、PDF417和QRCode
|
||||
返回:成功返回PIL对象,失败返回错误原因字符串
|
||||
"""
|
||||
# 转整数
|
||||
w, h = round(w), round(h)
|
||||
quiet_zone, ec_level = round(quiet_zone), round(ec_level)
|
||||
# 生成格式对象
|
||||
bFormat = getattr(zxingcpp.BarcodeFormat, format, None)
|
||||
if not bFormat:
|
||||
return f"[Error] format {format} not in zxingcpp.BarcodeFormat!"
|
||||
try:
|
||||
bit = zxingcpp.write_barcode(bFormat, text, w, h, quiet_zone, ec_level)
|
||||
except Exception as e:
|
||||
return f"[Error] [{format}] {e}"
|
||||
try:
|
||||
img = Image.fromarray(bit, "L")
|
||||
except Exception as e:
|
||||
return f"[Error] Image.fromarray: {e}"
|
||||
return img
|
||||
|
||||
# msnInfo: { 回调函数"onXX" , 参数"argd":{ "preprocessing.xx" } }
|
||||
# msnList: [ { "path", "pil", "base64" } ]
|
||||
# def addMissionList(self, msnInfo, msnList): # 添加任务列表
|
||||
# return super().addMissionList(msnInfo, msnList)
|
||||
def msnTask(self, msnInfo, msn): # 执行msn
|
||||
# 导入库失败
|
||||
if not zxingcpp:
|
||||
return {
|
||||
"code": 901,
|
||||
"data": f"【Error】无法导入二维码解析器 zxingcpp 。\n[Error] Unable to import zxingcpp.\n{zxingcppErr}",
|
||||
}
|
||||
# 读入图片
|
||||
try:
|
||||
if "pil" in msn:
|
||||
img = msn["pil"]
|
||||
elif "path" in msn:
|
||||
imgPath = msn["path"]
|
||||
img = Image.open(imgPath)
|
||||
elif "base64" in msn:
|
||||
imgBase64 = msn["base64"]
|
||||
img = Image.open(BytesIO(base64.b64decode(imgBase64)))
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 202,
|
||||
"data": f"【Error】图片读取失败。\n[Error] Image reading failed.\n {e}",
|
||||
}
|
||||
# 预处理
|
||||
if "argd" in msnInfo:
|
||||
try:
|
||||
img = self._preprocessing(img, msnInfo["argd"])
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 203,
|
||||
"data": f"【Error】图片预处理失败。\n[Error] Image preprocessing failed.\n {e}",
|
||||
}
|
||||
# 二维码解析
|
||||
try:
|
||||
codes = zxingcpp.read_barcodes(img)
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 204,
|
||||
"data": f"【Error】zxingcpp 二维码解析失败。\n[Error] zxingcpp read_barcodes failed.\n {e}",
|
||||
}
|
||||
# 结果解析
|
||||
try:
|
||||
return self._zxingcpp2dict(codes)
|
||||
except Exception as e:
|
||||
return {
|
||||
"code": 205,
|
||||
"data": f"【Error】zxingcpp 结果解析失败。\n[Error] zxingcpp resule to dict failed.\n {e}",
|
||||
}
|
||||
|
||||
# 解析 zxingcpp 库的返回结果,转为字典。此函数允许发生异常。
|
||||
# 失败返回值: {"code":错误码, "data": "错误信息字符串"}
|
||||
# 成功返回值: {"code":100, "data": [每个码的数据] }
|
||||
# data: {"box":[包围盒], "score":1, "text": "文本"}
|
||||
def _zxingcpp2dict(self, codes):
|
||||
# 图中无码
|
||||
if not codes:
|
||||
return {
|
||||
"code": 101,
|
||||
"data": "QR code not found in the image.",
|
||||
}
|
||||
# 处理结果冲每一个二维码
|
||||
data = []
|
||||
for c in codes:
|
||||
if not c.valid: # 码无效
|
||||
continue
|
||||
d = {}
|
||||
# 方向
|
||||
d["orientation"] = c.orientation
|
||||
# 位置
|
||||
d["box"] = [
|
||||
[c.position.top_left.x, c.position.top_left.y],
|
||||
[c.position.top_right.x, c.position.top_right.y],
|
||||
[c.position.bottom_right.x, c.position.bottom_right.y],
|
||||
[c.position.bottom_left.x, c.position.bottom_left.y],
|
||||
]
|
||||
d["score"] = 1 # 置信度,兼容OCR格式,无意义
|
||||
d["end"] = "\n" # 间隔符,兼容OCR格式,无意义
|
||||
# 格式
|
||||
d["format"] = c.format.name
|
||||
# 内容为文本类型
|
||||
if c.content_type.name == "Text":
|
||||
d["text"] = c.text
|
||||
# 内容为其它格式
|
||||
# TODO: 现在是通用的处理方法,将二进制内容转为纯文本或base64
|
||||
# 或许对于某些格式的码,有更好的转文本方式
|
||||
else:
|
||||
text = f"type: {c.content_type.name}\ndata: "
|
||||
try:
|
||||
# 尝试将 bytes 转换为纯文本字符串
|
||||
t = c.bytes.decode("utf-8")
|
||||
text += t
|
||||
except UnicodeDecodeError:
|
||||
# 如果无法直接转换为纯文本,则使用 Base64 编码输出结果
|
||||
t = base64.b64encode(c.bytes)
|
||||
text += "[Base64]\n" + t.decode("utf-8")
|
||||
d["text"] = text
|
||||
data.append(d)
|
||||
if data:
|
||||
return {
|
||||
"code": 100,
|
||||
"data": data,
|
||||
}
|
||||
else:
|
||||
l = len(codes)
|
||||
return {
|
||||
"code": 102,
|
||||
"data": f"【Error】{l}组二维码解码失败。\nFailed to decode {l} sets of QR codes.",
|
||||
}
|
||||
|
||||
# 图像预处理
|
||||
def _preprocessing(self, img, argd):
|
||||
"""
|
||||
对图像进行预处理,包括中值滤波、锐度增强、对比度增强以及可选的灰度转换和二值化。
|
||||
|
||||
img: PIL.Image对象,待处理的图像。
|
||||
argd: 参数字典,包含以下可选键值对:
|
||||
- "preprocessing.median_filter_size": 中值滤波器的大小,应为1~9的奇数。默认不进行。
|
||||
- "preprocessing.sharpness_factor": 锐度增强因子,应为0.1~10。默认不调整锐度。
|
||||
- "preprocessing.contrast_factor": 对比度增强因子,应为0.1~10。大于1增强对比度,小于1但大于0减少对比度,1保持原样。默认不调整对比度。
|
||||
- "preprocessing.grayscale": 布尔值,指定是否将图像转换为灰度图像。True为转换,False为不转换。默认为False。
|
||||
- "preprocessing.threshold": 二值化阈值,用于灰度图像的二值化处理。应为0到255之间的整数。只有当"preprocessing.grayscale"为True时,此参数才生效。默认不进行二值化处理。
|
||||
|
||||
返回:
|
||||
处理后的PIL.Image对象。
|
||||
"""
|
||||
|
||||
# 中值滤波
|
||||
s = round(argd.get("preprocessing.median_filter_size", -100))
|
||||
if s > 0 and s % 2 == 1:
|
||||
img = img.filter(ImageFilter.MedianFilter(size=s))
|
||||
# 锐度增强
|
||||
f = round(argd.get("preprocessing.sharpness_factor", -100))
|
||||
if f > 0:
|
||||
img = ImageEnhance.Sharpness(img).enhance(f)
|
||||
# 对比度增强
|
||||
c = argd.get("preprocessing.contrast_factor", -100)
|
||||
if c > 0:
|
||||
img = ImageEnhance.Contrast(img).enhance(c)
|
||||
# 转为灰度 & 二值化
|
||||
if argd.get("preprocessing.grayscale", False):
|
||||
img = img.convert("L")
|
||||
t = round(argd.get("preprocessing.threshold", -100))
|
||||
if t > -1:
|
||||
img = img.point(lambda p: 255 if p > t else 0)
|
||||
return img
|
||||
|
||||
|
||||
MissionQRCode = _MissionQRCodeClass()
|
||||
|
||||
|
||||
"""
|
||||
zxingcpp 返回值类型
|
||||
# 字节
|
||||
bytes <class 'bytes'>: b'testtesttesttest'
|
||||
# 内容类型
|
||||
content_type <class 'zxingcpp.ContentType'>: ContentType.Text
|
||||
name <class 'str'>: Text
|
||||
value <class 'int'>: 0
|
||||
Binary <class 'zxingcpp.ContentType'>: ContentType.Binary
|
||||
GS1 <class 'zxingcpp.ContentType'>: ContentType.GS1
|
||||
ISO15434 <class 'zxingcpp.ContentType'>: ContentType.ISO15434
|
||||
Mixed <class 'zxingcpp.ContentType'>: ContentType.Mixed
|
||||
Text <class 'zxingcpp.ContentType'>: ContentType.Text
|
||||
UnknownECI <class 'zxingcpp.ContentType'>: ContentType.UnknownECI
|
||||
ec_level <class 'str'>: M
|
||||
# 二维码格式
|
||||
format <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.QRCode
|
||||
name <class 'str'>: QRCode
|
||||
value <class 'int'>: 8192
|
||||
Aztec <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.Aztec
|
||||
Codabar <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.Codabar
|
||||
Code128 <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.Code128
|
||||
Code39 <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.Code39
|
||||
Code93 <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.Code93
|
||||
DataBar <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.DataBar
|
||||
DataBarExpanded <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.DataBarExpanded
|
||||
DataMatrix <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.DataMatrix
|
||||
EAN13 <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.EAN13
|
||||
EAN8 <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.EAN8
|
||||
ITF <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.ITF
|
||||
LinearCodes <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.LinearCodes
|
||||
MatrixCodes <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.MatrixCodes
|
||||
MaxiCode <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.MaxiCode
|
||||
MicroQRCode <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.MicroQRCode
|
||||
NONE <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.NONE
|
||||
PDF417 <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.PDF417
|
||||
QRCode <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.QRCode
|
||||
UPCA <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.UPCA
|
||||
UPCE <class 'zxingcpp.BarcodeFormat'>: BarcodeFormat.UPCE
|
||||
# 方向
|
||||
orientation <class 'int'>: 0
|
||||
# 位置
|
||||
position <class 'zxingcpp.Position'>: 16x16 116x16 116x116 16x116
|
||||
bottom_left <class 'zxingcpp.Point'>: <zxingcpp.Point object at 0x00000222AEE5C370>
|
||||
bottom_right <class 'zxingcpp.Point'>: <zxingcpp.Point object at 0x00000222C19D07B0>
|
||||
top_left <class 'zxingcpp.Point'>: <zxingcpp.Point object at 0x00000222AEE5C370>
|
||||
top_right <class 'zxingcpp.Point'>: <zxingcpp.Point object at 0x00000222C19D0770>
|
||||
symbology_identifier <class 'str'>: ]Q1
|
||||
text <class 'str'>: testtesttesttest
|
||||
valid <class 'bool'>: True
|
||||
"""
|
||||
@@ -0,0 +1,99 @@
|
||||
# 任务队列类
|
||||
|
||||
from uuid import uuid4
|
||||
from typing import Callable, Any
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
|
||||
class MissionQueue:
|
||||
def __init__(
|
||||
self,
|
||||
msnList: list, # 任务内容列表,每项为一个任务元素
|
||||
configs: dict = {}, # 任务控制参数
|
||||
# 回调函数
|
||||
# 整个队列 即将启动。回调参数(self)
|
||||
onStart: Callable[["MissionQueue"], None] = None,
|
||||
# 单个任务 准备进行。回调参数(self, 任务元素)
|
||||
onReady: Callable[["MissionQueue", Any], None] = None,
|
||||
# 单个任务 完成。回调参数(self, 任务元素, 任务结果)
|
||||
onGet: Callable[["MissionQueue"], None] = None,
|
||||
# 整个队列 已结束。回调参数(self, 结果标志)
|
||||
# 结果标志:"[Success]...", "[Warning]...", "[Error]..."
|
||||
onEnd: Callable[["MissionQueue", str], None] = None,
|
||||
):
|
||||
self.msnList = msnList
|
||||
self.configs = configs
|
||||
self.id = str(uuid4()) # 任务ID
|
||||
self.len = len(msnList) # 队列总长度
|
||||
self.index = 0 # 当前任务下标
|
||||
self._onStart = onStart if onStart else self._pass
|
||||
self._onReady = onReady if onReady else self._pass
|
||||
self._onGet = onGet if onGet else self._pass
|
||||
self._onEnd = onEnd if onEnd else self._pass
|
||||
# 工作状态: "waiting" "running"
|
||||
self.state_work = "waiting"
|
||||
# 控制状态: "" "pause" "stop"
|
||||
self.state_ctrl = ""
|
||||
|
||||
# ==================== mission调用 ====================
|
||||
|
||||
# 获取剩余任务长度
|
||||
def remainingLen(self):
|
||||
return self.len - self.index
|
||||
|
||||
# 判空 【1】←在一轮任务循环中的调用顺序
|
||||
def empty(self):
|
||||
return self.index >= self.len
|
||||
|
||||
# 获取一个任务元素 【2】
|
||||
def getMsn(self):
|
||||
return self.msnList[self.index]
|
||||
|
||||
# 完成一个任务元素 【5】
|
||||
def popMsn(self):
|
||||
self.index += 1
|
||||
|
||||
# 恢复进行
|
||||
def resume(self):
|
||||
if self.state_ctrl == "pause":
|
||||
self.state_ctrl = ""
|
||||
|
||||
# 暂停
|
||||
def pause(self):
|
||||
self.state_ctrl = "pause"
|
||||
|
||||
# 停止(废弃)
|
||||
def stop(self):
|
||||
self.state_ctrl = "stop"
|
||||
|
||||
# ==================== 回调函数 ====================
|
||||
|
||||
def onStart(self):
|
||||
try:
|
||||
self._onStart(self)
|
||||
except Exception:
|
||||
logger.error("任务 onStart 回调异常。", exc_info=True, stack_info=True)
|
||||
|
||||
def onReady(self): # 【3】
|
||||
try:
|
||||
self._onReady(self)
|
||||
except Exception:
|
||||
logger.error("任务 onReady 回调异常。", exc_info=True, stack_info=True)
|
||||
|
||||
def onGet(self, res): # 【4】
|
||||
try:
|
||||
self._onGet(self, res)
|
||||
except Exception:
|
||||
logger.error("任务 onGet 回调异常。", exc_info=True, stack_info=True)
|
||||
|
||||
def onEnd(self, flag):
|
||||
# 结果标志:"[Success]...", "[Warning]...", "[Error]..."
|
||||
try:
|
||||
self._onEnd(self, flag)
|
||||
except Exception:
|
||||
logger.error("任务 onEnd 回调异常。", exc_info=True, stack_info=True)
|
||||
|
||||
@staticmethod
|
||||
def _pass(*e): # 空函数
|
||||
pass
|
||||
@@ -0,0 +1,56 @@
|
||||
# =============================================
|
||||
# =============== 简单任务管理器 ===============
|
||||
# =============================================
|
||||
|
||||
|
||||
from PySide2.QtCore import QMutex
|
||||
from ..utils.thread_pool import threadRun # 异步执行函数
|
||||
|
||||
|
||||
class SimpleMission:
|
||||
def __init__(self, msnTask):
|
||||
self._msnTask = msnTask # 任务函数
|
||||
self._msnList = [] # 任务队列
|
||||
self._msnMutex = QMutex() # 任务队列的锁
|
||||
self._task = None # 异步任务对象
|
||||
self._taskMutex = QMutex() # 任务对象的锁
|
||||
|
||||
def addMissionList(self, msnList): # 添加一条任务队列,返回任务ID
|
||||
if len(msnList) < 1:
|
||||
return "[Error] len(msnList) < 1 !"
|
||||
# 添加到任务队列
|
||||
self._msnMutex.lock() # 上锁
|
||||
self._msnList += msnList
|
||||
self._msnMutex.unlock() # 解锁
|
||||
# 启动任务
|
||||
self._startMsns()
|
||||
return "[Success]"
|
||||
|
||||
def _startMsns(self): # 启动异步任务,执行所有任务列表
|
||||
# 若当前异步任务对象为空,则创建工作线程
|
||||
self._taskMutex.lock() # 上锁
|
||||
if self._task == None:
|
||||
self._task = threadRun(self._taskRun)
|
||||
self._taskMutex.unlock() # 解锁
|
||||
|
||||
# ========================= 【子线程 方法】 =========================
|
||||
|
||||
def _taskRun(self): # 异步执行任务字典的流程
|
||||
# 循环,直到任务队列的列表为空
|
||||
while True:
|
||||
self._msnMutex.lock() # 上锁
|
||||
dl = len(self._msnList) # 任务字典长度
|
||||
if dl == 0: # 任务队列已空
|
||||
self._msnMutex.unlock() # 解锁
|
||||
break
|
||||
msn = self._msnList.pop(0) # 取一个任务
|
||||
self._msnMutex.unlock() # 解锁
|
||||
if callable(self._msnTask):
|
||||
self._msnTask(msn)
|
||||
# 完成
|
||||
self._taskFinish()
|
||||
|
||||
def _taskFinish(self): # 任务结束
|
||||
self._taskMutex.lock() # 上锁
|
||||
self._task = None
|
||||
self._taskMutex.unlock() # 解锁
|
||||
@@ -0,0 +1,42 @@
|
||||
# ===============================================
|
||||
# =============== OCR 插件接口管理 ===============
|
||||
# ===============================================
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
ApiDict = {}
|
||||
AllDict = {}
|
||||
|
||||
|
||||
# TODO: 静态插件
|
||||
# 由插件控制器调用,初始化OCR插件的接口。传入动态插件
|
||||
def initOcrPlugins(plugins):
|
||||
global ApiDict, AllDict
|
||||
for p in plugins:
|
||||
ApiDict[p] = plugins[p]["api_class"]
|
||||
AllDict[p] = plugins[p]
|
||||
|
||||
|
||||
# 生成一个ocr api实例,成功返回对象,失败返回 [Error] 开头的字符串
|
||||
def getApiOcr(apiKey, argd):
|
||||
# 检测argd,恢复int类型
|
||||
for k in argd:
|
||||
n = argd[k]
|
||||
if isinstance(n, float):
|
||||
rounded = round(n)
|
||||
if abs(n - rounded) <= 1e-7:
|
||||
argd[k] = rounded
|
||||
if apiKey in ApiDict:
|
||||
try:
|
||||
return ApiDict[apiKey](argd) # 实例化后返回
|
||||
except Exception as e:
|
||||
logger.error(f"生成api实例{apiKey}失败。", exc_info=True, stack_info=True)
|
||||
return f"[Error] Failed to generate API instance {apiKey}: {e}"
|
||||
return f'[Error] "{apiKey}" not in ApiDict.'
|
||||
|
||||
|
||||
# 返回一个API的局部配置字典
|
||||
def getLocalOptions(apiKey):
|
||||
if apiKey in AllDict:
|
||||
return AllDict[apiKey]["local_options"]
|
||||
return {}
|
||||
@@ -0,0 +1,30 @@
|
||||
from .output_txt import OutputTxt
|
||||
from .output_txt_plain import OutputTxtPlain
|
||||
from .output_txt_individual import OutputTxtIndividual
|
||||
from .output_md import OutputMD
|
||||
from .output_jsonl import OutputJsonl
|
||||
from .output_csv import OutputCsv
|
||||
from .output_pdf_layered import OutputPdfLayered
|
||||
from .output_pdf_one_layer import OutputPdfOneLayer
|
||||
|
||||
"""纯文本输出器。初始化传入参数字典:
|
||||
outputArgd = {
|
||||
"outputDir": "", # 输出路径
|
||||
"outputDirType": "", # 输出目录类型,"source" 为原文件目录,"specify"为指定目录
|
||||
"outputFileName": "", # 输出文件名(前缀)
|
||||
"startDatetime": "", # 开始日期字符串(标准格式)
|
||||
"ignoreBlank": True/False, # 忽略空白文件
|
||||
}
|
||||
"""
|
||||
Output = {
|
||||
# 纯文本输出器
|
||||
"txt": OutputTxt,
|
||||
"txtPlain": OutputTxtPlain,
|
||||
"txtIndividual": OutputTxtIndividual,
|
||||
"md": OutputMD,
|
||||
"jsonl": OutputJsonl,
|
||||
"csv": OutputCsv,
|
||||
# PDF输出器,需要额外的参数 "originPath" 原始文件路径
|
||||
"pdfLayered": OutputPdfLayered,
|
||||
"pdfOneLayer": OutputPdfOneLayer,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
# OCR输出器的基类。按指定的格式,将传入的文本输出到指定地方。
|
||||
|
||||
from .tools import getDataText
|
||||
from ...platform import Platform
|
||||
import os
|
||||
|
||||
|
||||
class Output:
|
||||
def __init__(self, argd):
|
||||
self.dir = argd["outputDir"] # 输出路径(文件夹)
|
||||
self.fileName = argd["outputFileName"] # 文件名
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.txt" # 输出路径
|
||||
self.ignoreBlank = argd["ignoreBlank"] # 忽略空白文件
|
||||
|
||||
def print(self, res): # 输出图片信息
|
||||
if not res["code"] == 100 and self.ignoreBlank:
|
||||
return # 忽略空白图片
|
||||
textOut = f"图片路径:{res['path']}\n代码:{res['code']}\n"
|
||||
if res["code"] == 100:
|
||||
textOut += getDataText(res["data"]) # 获取拼接结果
|
||||
elif res["code"] == 101:
|
||||
textOut += "无文字"
|
||||
else:
|
||||
textOut += f"错误原因:{res['data']}"
|
||||
print(textOut)
|
||||
|
||||
def openOutputFile(self): # 打开输出文件
|
||||
if self.outputPath and os.path.exists(self.outputPath):
|
||||
Platform.startfile(self.outputPath)
|
||||
|
||||
def onEnd(self): # 结束输出。
|
||||
pass
|
||||
@@ -0,0 +1,70 @@
|
||||
# 输出到csv表格文件
|
||||
|
||||
import csv
|
||||
|
||||
from umi_log import logger
|
||||
from .output import Output
|
||||
from .tools import getDataText
|
||||
|
||||
|
||||
class OutputCsv(Output):
|
||||
def __init__(self, argd):
|
||||
self.encodings = [ # 保存编码优先级
|
||||
"ansi", # Windows系统本地编码。在linux和macos下会抛出异常
|
||||
"ascii", # 纯英
|
||||
"gbk", # 简中
|
||||
"big5", # 繁中
|
||||
"shift_jis", # 日文
|
||||
"euc-kr", # 韩文
|
||||
"utf-8",
|
||||
]
|
||||
self.dir = argd["outputDir"] # 输出路径(文件夹)
|
||||
self.fileName = argd["outputFileName"] # 文件名
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.csv" # 输出路径
|
||||
self.ignoreBlank = argd["ignoreBlank"] # 忽略空白文件
|
||||
self.writeLists = [] # 输出内容列表
|
||||
self.writeText = "" # 输出内容字符串
|
||||
try: # 覆盖创建临时文件
|
||||
with open(self.outputPath, "w", encoding="utf-8") as f:
|
||||
pass
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create csv file. {e}\n创建csv文件失败。")
|
||||
|
||||
def print(self, res): # 输出图片结果
|
||||
if not res["code"] == 100 and self.ignoreBlank:
|
||||
return # 忽略空白图片
|
||||
name = res["fileName"]
|
||||
path = res["path"]
|
||||
if res["code"] == 100:
|
||||
textOut = getDataText(res["data"]) # 获取拼接结果
|
||||
elif res["code"] == 101:
|
||||
textOut = ""
|
||||
else:
|
||||
textOut = f'[Error] OCR failed. Code: {res["code"]}, Msg: {res["data"]} .\n'
|
||||
self.writeLists.append([name, textOut, path])
|
||||
self.writeText += textOut
|
||||
|
||||
def onEnd(self): # 结束时保存。
|
||||
# 顺序测试编码优先级列表,获取保存编码
|
||||
encoding = "utf-8"
|
||||
for e in self.encodings:
|
||||
try:
|
||||
self.writeText.encode(e)
|
||||
encoding = e
|
||||
break
|
||||
# except UnicodeEncodeError:
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"csv encoding: {encoding}")
|
||||
# 创建文件、输出
|
||||
headers = ["Name", "OCR", "Path"] # 表头
|
||||
try:
|
||||
with open(
|
||||
self.outputPath, "w", encoding=encoding, newline=""
|
||||
) as f: # 覆盖创建文件
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(headers) # 写入CSV表头
|
||||
for writeList in self.writeLists:
|
||||
writer.writerow(writeList) # 写入CSV内容
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to write csv file. {e}\n写入csv文件失败。")
|
||||
@@ -0,0 +1,24 @@
|
||||
# 输出到jsonl文件
|
||||
|
||||
from .output import Output
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class OutputJsonl(Output):
|
||||
def __init__(self, argd):
|
||||
self.dir = argd["outputDir"] # 输出路径(文件夹)
|
||||
self.fileName = argd["outputFileName"] # 文件名
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.jsonl" # 输出路径
|
||||
self.ignoreBlank = argd["ignoreBlank"] # 忽略空白文件
|
||||
# 创建输出文件
|
||||
try:
|
||||
with open(self.outputPath, "w", encoding="utf-8") as f: # 覆盖创建文件
|
||||
pass
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create jsonl file. {e}\n创建jsonl文件失败。")
|
||||
|
||||
def print(self, res): # 输出图片结果
|
||||
# 不忽略空白条目
|
||||
with open(self.outputPath, "a", encoding="utf-8") as f: # 追加写入本地文件
|
||||
f.write(json.dumps(res, ensure_ascii=False) + "\n")
|
||||
@@ -0,0 +1,46 @@
|
||||
# 输出markdown格式
|
||||
|
||||
from .output import Output
|
||||
from .tools import getDataText
|
||||
|
||||
import os
|
||||
|
||||
|
||||
class OutputMD(Output):
|
||||
def __init__(self, argd):
|
||||
self.dir = argd["outputDir"] # 输出路径(文件夹)
|
||||
self.fileName = argd["outputFileName"] # 文件名
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.md" # 输出路径
|
||||
self.ignoreBlank = argd["ignoreBlank"] # 忽略空白文件
|
||||
# 创建输出文件
|
||||
try:
|
||||
with open(self.outputPath, "w", encoding="utf-8") as f: # 覆盖创建文件
|
||||
f.write(f'> {argd["startDatetime"]}\n\n')
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create jsonl file. {e}\n创建jsonl文件失败。")
|
||||
|
||||
def print(self, res): # 输出图片结果
|
||||
if not res["code"] == 100 and self.ignoreBlank:
|
||||
return # 忽略空白图片
|
||||
name = res["fileName"]
|
||||
path = os.path.relpath( # 从md文件到图片的相对路径
|
||||
res["path"], os.path.dirname(self.outputPath)
|
||||
)
|
||||
path = path.replace(" ", "%20") # 空格转 %20
|
||||
textOut = f"""
|
||||
---
|
||||

|
||||
[{name}]({path})
|
||||
|
||||
"""
|
||||
# 正文
|
||||
if res["code"] == 100:
|
||||
texts = getDataText(res["data"]).split("\n") # 获取拼接结果列表
|
||||
for t in texts:
|
||||
textOut += f"> {t} \n"
|
||||
elif res["code"] == 101:
|
||||
pass
|
||||
else:
|
||||
textOut += f'> [Error] OCR failed. Code: {res["code"]}, Msg: {res["data"]} \n> 【异常】OCR识别失败。 \n'
|
||||
with open(self.outputPath, "a", encoding="utf-8") as f: # 追加写入本地文件
|
||||
f.write(textOut)
|
||||
@@ -0,0 +1,171 @@
|
||||
# 双层可搜索 searchable pdf
|
||||
# https://github.com/pymupdf/PyMuPDF/discussions/2299
|
||||
|
||||
import os
|
||||
import fitz # PyMuPDF
|
||||
|
||||
from umi_log import logger
|
||||
from .output import Output
|
||||
|
||||
|
||||
class OutputPdfLayered(Output):
|
||||
def __init__(self, argd):
|
||||
self.dir = argd["outputDir"] # 输出路径(文件夹)
|
||||
self.originPath = argd["originPath"] # 原始文件路径
|
||||
self.fileName = argd["outputFileName"] # 文件名
|
||||
self.password = argd["password"] # 密码
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.layered.pdf" # 输出路径
|
||||
self.pdf = None
|
||||
self.existentPages = [] # 已处理的页数
|
||||
self.isInsertFont = False # 是否有字体嵌入
|
||||
self.opacity = 0 # 文本透明度为0
|
||||
try:
|
||||
self.font = fitz.Font("cjk") # 字体
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to load cjk font. {e}\n无法加载cjk字体。")
|
||||
try:
|
||||
self.pdf = self._getPDF(self.originPath) # 加载pymupdf对象
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"Failed to load doc file. {e}\n无法加载文档。\n{self.originPath}"
|
||||
)
|
||||
|
||||
# 获取pdf文档对象,或将其它类型的文档转为PDF对象
|
||||
def _getPDF(self, path):
|
||||
# https://github.com/pymupdf/PyMuPDF-Utilities/blob/master/examples/convert-document/convert.py
|
||||
doc = fitz.open(path)
|
||||
# 如果已加密,则尝试解密
|
||||
if doc.is_encrypted and not doc.authenticate(self.password):
|
||||
raise Exception(
|
||||
f'The document is encrypted, and the password "{self.password}" is incorrect.\n文档已加密,输入密码不正确。'
|
||||
)
|
||||
if doc.is_pdf:
|
||||
return doc
|
||||
b = doc.convert_to_pdf() # 转换为PDF格式的二进制数据
|
||||
pdf = fitz.open("pdf", b) # 创建PDF文档对象
|
||||
try:
|
||||
pdf.set_toc(doc.get_toc()) # 复制原始文档的目录
|
||||
except Exception:
|
||||
logger.warning("pdf.set_toc error", exc_info=True, stack_info=True)
|
||||
# 复制原始文档的元数据(如作者、标题等)
|
||||
meta = doc.metadata
|
||||
if not meta["producer"]:
|
||||
meta["producer"] = "Umi-OCR & PyMuPDF v" + fitz.VersionBind
|
||||
if not meta["creator"]:
|
||||
meta["creator"] = "Umi-OCR & PyMuPDF PDF converter"
|
||||
pdf.set_metadata(meta)
|
||||
# 复制原始文档的链接
|
||||
for pinput in doc:
|
||||
links = pinput.get_links()
|
||||
pout = pdf[pinput.number]
|
||||
for link in links:
|
||||
if link["kind"] == fitz.LINK_NAMED: # 不处理 named links
|
||||
continue
|
||||
pout.insert_link(link) # 写入新文档
|
||||
doc.close() # 释放原文档
|
||||
return pdf
|
||||
|
||||
# 计算填满宽和高的一行字体大小
|
||||
def _calculateFontSize(self, text, w, h):
|
||||
if h > w: # 竖排转为横排计算
|
||||
w, h = h, w
|
||||
fontsize = round(h) # 字体大小初值,假设为行高
|
||||
minSize = 5 # 大小下限
|
||||
getLen = lambda text, s: self.font.text_length(text, fontsize=s)
|
||||
while getLen(text, fontsize) > w and fontsize >= minSize:
|
||||
fontsize -= 1 # 尝试减小字体,直到行宽刚好小于界限
|
||||
while getLen(text, fontsize) < w:
|
||||
fontsize += 1 # 尝试增大字体,直到行宽刚好超过界限
|
||||
while getLen(text, fontsize) > w and fontsize >= minSize:
|
||||
fontsize -= 0.1 # 再次减小字体,将精度提升到 0.1
|
||||
return fontsize
|
||||
|
||||
def print(self, res): # 输出图片结果
|
||||
if not self.pdf:
|
||||
logger.error("self.pdf 未初始化。")
|
||||
return
|
||||
pno = res["page"] - 1 # 当前页数
|
||||
self.existentPages.append(pno) # 记录已处理的页面
|
||||
if not res["code"] == 100:
|
||||
return # 忽略空白
|
||||
|
||||
page = self.pdf[pno] # 当前页对象
|
||||
page.clean_contents() # 内容流清理、语法更正,减少错误
|
||||
protation = page.rotation # 获取页面旋转角度
|
||||
isInsertFont = False # 当前是否进行过字体注入
|
||||
# 插入文本,用shape.insert_text(可编辑)或page.insert_text(不可编辑)
|
||||
for tb in res["data"]:
|
||||
if self.opacity == 0 and "from" in tb and tb["from"] == "text":
|
||||
continue # 双层(透明文字)模式下,跳过直接提取的文本,只写入OCR文本
|
||||
if not isInsertFont: # 页面插入字体
|
||||
self.isInsertFont = isInsertFont = True
|
||||
page.insert_font(fontname="cjk", fontbuffer=self.font.buffer)
|
||||
text = tb["text"]
|
||||
box = tb["box"]
|
||||
x0, y0 = box[0]
|
||||
x2, y2 = box[2]
|
||||
w = x2 - x0
|
||||
h = y2 - y0
|
||||
fontsize = self._calculateFontSize(text, w, h)
|
||||
# 插入点的 旋转后的坐标
|
||||
point = fitz.Point(x0, y2) * page.derotation_matrix
|
||||
page.insert_text(
|
||||
point,
|
||||
text,
|
||||
fontsize,
|
||||
fontname="cjk",
|
||||
rotate=protation, # 文本角度设定
|
||||
stroke_opacity=self.opacity, # 描边透明度
|
||||
fill_opacity=self.opacity, # 填充(字体)透明度
|
||||
)
|
||||
|
||||
def onEnd(self): # 结束时保存。
|
||||
if not self.pdf:
|
||||
return
|
||||
# 删除未处理的页数
|
||||
for i in range(len(self.pdf) - 1, -1, -1):
|
||||
if i not in self.existentPages:
|
||||
self.pdf.delete_page(i)
|
||||
logger.info(f"保存{len(self.pdf)}页PDF:{self.outputPath}")
|
||||
if self.isInsertFont: # 有任意页面嵌入字体,则构建字体子集
|
||||
try: # 对于部分PDF,如用txt直接打印的,构建字体子集会失败。
|
||||
self.pdf.subset_fonts() # 构建字体子集,减小文件大小。需要 fontTools 库
|
||||
except Exception: # TODO: 失败原因?可能文件中实际并没有字体?
|
||||
logger.error("构建字体子集失败。", exc_info=True, stack_info=True)
|
||||
# 保存:压缩并进行3级垃圾回收。等同 ez_save
|
||||
self.save(self.pdf, self.outputPath, deflate=True, garbage=3)
|
||||
else:
|
||||
# 无嵌入字体,则直接保存,不压缩
|
||||
self.save(self.pdf, self.outputPath)
|
||||
|
||||
def save(self, pdf, path, **options): # 保存并关闭 pdf 对象
|
||||
try:
|
||||
# 尝试保存到指定路径
|
||||
pdf.save(path, **options)
|
||||
except Exception:
|
||||
# 保存失败,尝试保存到 ".temp" 路径
|
||||
tempPath = self.outputPath + ".temp"
|
||||
logger.warning(f"保存PDF失败。 path: {path}", exc_info=True)
|
||||
try:
|
||||
pdf.save(tempPath, **options)
|
||||
pdf.close()
|
||||
except Exception as e1:
|
||||
logger.error(
|
||||
f"保存PDF到临时路径失败。 tempPath: {tempPath}", exc_info=True
|
||||
)
|
||||
raise Exception(f"[Error] Unable to save PDF to [{tempPath}]: {e1}")
|
||||
# 已保存到 .temp 并 close 原对象,尝试替换文件
|
||||
try:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
os.rename(tempPath, path)
|
||||
except Exception as e2:
|
||||
logger.warning(
|
||||
f"保存PDF文件替换失败。保存到临时文件: {tempPath}", exc_info=True
|
||||
)
|
||||
|
||||
raise Exception(
|
||||
f"[Warning] Unable to save PDF: [{path}]. Exception: {e2}. Saved to temporary path: [{tempPath}]."
|
||||
)
|
||||
else: # 正常结束
|
||||
pdf.close()
|
||||
@@ -0,0 +1,43 @@
|
||||
# 单层纯文本 PDF
|
||||
|
||||
import fitz # PyMuPDF
|
||||
|
||||
from umi_log import logger
|
||||
from .output_pdf_layered import OutputPdfLayered
|
||||
|
||||
|
||||
class OutputPdfOneLayer(OutputPdfLayered):
|
||||
def __init__(self, argd):
|
||||
super().__init__(argd)
|
||||
self.opacity = 1 # 文本不透明
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.text.pdf" # 输出路径
|
||||
|
||||
# 创建空白 PDF
|
||||
def _getPDF(self, path):
|
||||
source_doc = fitz.open(path) # 打开原文档
|
||||
# 如果已加密,则尝试解密
|
||||
if source_doc.is_encrypted and not source_doc.authenticate(self.password):
|
||||
raise Exception(
|
||||
f'The document is encrypted, and the password "{self.password}" is incorrect.\n文档已加密,输入密码不正确。'
|
||||
)
|
||||
pdf = fitz.open() # 创建空白PDF文档对象
|
||||
# 复制原始文档的元数据(如作者、标题等)
|
||||
meta = source_doc.metadata
|
||||
if not meta["producer"]:
|
||||
meta["producer"] = "Umi-OCR & PyMuPDF v" + fitz.VersionBind
|
||||
if not meta["creator"]:
|
||||
meta["creator"] = "Umi-OCR & PyMuPDF PDF converter"
|
||||
pdf.set_metadata(meta)
|
||||
# 生成空白的每一页
|
||||
for page in source_doc:
|
||||
rect = page.rect # 原文档渲染尺寸
|
||||
pdf.new_page(width=rect.width, height=rect.height)
|
||||
# 尝试复制原始文档的目录数据
|
||||
try:
|
||||
pdf.set_toc(source_doc.get_toc())
|
||||
except Exception:
|
||||
logger.warning(
|
||||
f"pdf.set_toc error. path: {path}", exc_info=True, stack_info=True
|
||||
)
|
||||
source_doc.close() # 释放原文档
|
||||
return pdf
|
||||
@@ -0,0 +1,33 @@
|
||||
# 输出到txt文件
|
||||
|
||||
from .output import Output
|
||||
from .tools import getDataText
|
||||
|
||||
|
||||
class OutputTxt(Output):
|
||||
def __init__(self, argd):
|
||||
self.dir = argd["outputDir"] # 输出路径(文件夹)
|
||||
self.fileName = argd["outputFileName"] # 文件名
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.txt" # 输出路径
|
||||
self.ignoreBlank = argd["ignoreBlank"] # 忽略空白文件
|
||||
# 创建输出文件
|
||||
try:
|
||||
with open(self.outputPath, "w", encoding="utf-8") as f: # 覆盖创建文件
|
||||
f.write(f'{argd["startDatetime"]}\n\n') # 写入开始时间日期
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to create txt file. {e}\n创建txt文件失败。")
|
||||
|
||||
def print(self, res): # 输出图片结果
|
||||
if not res["code"] == 100 and self.ignoreBlank:
|
||||
return # 忽略空白图片
|
||||
textOut = f'≦ {res["fileName"]} ≧\n'
|
||||
if res["code"] == 100:
|
||||
textOut += getDataText(res["data"]) # 获取拼接结果
|
||||
textOut += "\n" # 结尾额外加换行
|
||||
elif res["code"] == 101:
|
||||
pass
|
||||
else:
|
||||
textOut += f'[Error] OCR failed. Code: {res["code"]}, Msg: {res["data"]}\n【异常】OCR识别失败。\n'
|
||||
textOut += "\n" # 多空一行
|
||||
with open(self.outputPath, "a", encoding="utf-8") as f: # 追加写入本地文件
|
||||
f.write(textOut)
|
||||
@@ -0,0 +1,35 @@
|
||||
# 单独txt文件
|
||||
|
||||
import os
|
||||
from .output import Output
|
||||
from .tools import getDataText
|
||||
|
||||
|
||||
class OutputTxtIndividual(Output):
|
||||
def __init__(self, argd):
|
||||
super().__init__(argd)
|
||||
# 是否输出到原目录
|
||||
self.outputSource = argd["outputDirType"] == "source"
|
||||
|
||||
def openOutputFile(self):
|
||||
pass # 覆盖父类方法
|
||||
|
||||
def print(self, res): # 输出图片结果
|
||||
if not res["code"] == 100 and self.ignoreBlank:
|
||||
return # 忽略空白图片
|
||||
textOut = ""
|
||||
if res["code"] == 100:
|
||||
textOut += getDataText(res["data"]) # 获取拼接结果
|
||||
elif res["code"] == 101:
|
||||
pass
|
||||
else:
|
||||
textOut += f'[Error] OCR failed. Code: {res["code"]}, Msg: {res["data"]}\n【异常】OCR识别失败。\n'
|
||||
# 输出文件
|
||||
if self.outputSource: # 输出到原始路径
|
||||
p, _ = os.path.splitext(res["path"]) # 原路径去除扩展名
|
||||
path = p + ".txt"
|
||||
else: # 输出到指定路径
|
||||
f, _ = os.path.splitext(res["fileName"]) # 原文件名去除扩展名
|
||||
path = f"{self.dir}/{f}.txt"
|
||||
with open(path, "w", encoding="utf-8") as f: # 追加写入同名本地文件
|
||||
f.write(textOut)
|
||||
@@ -0,0 +1,29 @@
|
||||
# 纯文本(无格式)txt文件
|
||||
|
||||
from .output import Output
|
||||
from .tools import getDataText
|
||||
|
||||
|
||||
class OutputTxtPlain(Output):
|
||||
def __init__(self, argd):
|
||||
self.dir = argd["outputDir"] # 输出路径(文件夹)
|
||||
self.fileName = argd["outputFileName"] # 文件名
|
||||
self.outputPath = f"{self.dir}/{self.fileName}.p.txt" # 输出路径
|
||||
# 创建输出文件
|
||||
try:
|
||||
open(self.outputPath, "w").close() # 覆盖创建文件
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"Failed to create plain txt file. {e}\n创建纯文本txt文件失败。"
|
||||
)
|
||||
|
||||
def print(self, res): # 输出图片结果
|
||||
if not res["code"] == 100:
|
||||
return # 强制忽略空白图片
|
||||
textOut = ""
|
||||
if res["code"] == 100:
|
||||
textOut += getDataText(res["data"]) # 获取拼接结果
|
||||
if not textOut[-1] == "\n": # 确保结尾有换行
|
||||
textOut += "\n"
|
||||
with open(self.outputPath, "a", encoding="utf-8") as f: # 追加写入本地文件
|
||||
f.write(textOut)
|
||||
@@ -0,0 +1,9 @@
|
||||
# 从data中提取、拼接文本
|
||||
def getDataText(data):
|
||||
textOut = ""
|
||||
l = len(data) - 1
|
||||
for i, tb in enumerate(data):
|
||||
textOut += tb["text"]
|
||||
if i < l:
|
||||
textOut += tb["end"]
|
||||
return textOut
|
||||
@@ -0,0 +1,32 @@
|
||||
# tbpu : text block processing unit 文本块后处理
|
||||
|
||||
from .parser_none import ParserNone
|
||||
|
||||
from .ignore_area import IgnoreArea
|
||||
from .parser_multi_para import MultiPara
|
||||
from .parser_multi_line import MultiLine
|
||||
from .parser_multi_none import MultiNone
|
||||
from .parser_single_para import SinglePara
|
||||
from .parser_single_line import SingleLine
|
||||
from .parser_single_none import SingleNone
|
||||
from .parser_single_code import SingleCode
|
||||
|
||||
# 排版解析
|
||||
Parser = {
|
||||
"none": ParserNone, # 不做处理
|
||||
"multi_para": MultiPara, # 多栏-自然段
|
||||
"multi_line": MultiLine, # 多栏-总是换行
|
||||
"multi_none": MultiNone, # 多栏-无换行
|
||||
"single_para": SinglePara, # 单栏-自然段
|
||||
"single_line": SingleLine, # 单栏-总是换行
|
||||
"single_none": SingleNone, # 单栏-无换行
|
||||
"single_code": SingleCode, # 单栏-代码段
|
||||
}
|
||||
|
||||
|
||||
# 获取排版解析器对象
|
||||
def getParser(key):
|
||||
if key in Parser:
|
||||
return Parser[key]()
|
||||
else:
|
||||
return Parser["none"]()
|
||||
@@ -0,0 +1,32 @@
|
||||
# 忽略区域
|
||||
|
||||
from .tbpu import Tbpu
|
||||
|
||||
|
||||
class IgnoreArea(Tbpu):
|
||||
def __init__(self, areaList):
|
||||
self.tbpuName = "忽略区域"
|
||||
self.areaList = areaList
|
||||
|
||||
def run(self, textBlocks):
|
||||
# 返回是否矩形框 a 包含 b
|
||||
def isInBox(a, b):
|
||||
return (
|
||||
a[0][0] <= b[0][0]
|
||||
and a[0][1] <= b[0][1]
|
||||
and a[2][0] >= b[2][0]
|
||||
and a[2][1] >= b[2][1]
|
||||
)
|
||||
|
||||
newList = []
|
||||
for b in textBlocks:
|
||||
flag = True # True 为没有被忽略
|
||||
# 检测当前文块 b 是否在任何一个检测块 a 内
|
||||
for a in self.areaList:
|
||||
if isInBox(a, b["box"]):
|
||||
flag = False # 踩到任何一个块,GG
|
||||
break
|
||||
if flag: # 没有被忽略
|
||||
newList.append(b)
|
||||
|
||||
return newList
|
||||
@@ -0,0 +1,22 @@
|
||||
# 排版解析-多栏-单行
|
||||
|
||||
from .tbpu import Tbpu
|
||||
from .parser_tools.line_preprocessing import linePreprocessing # 行预处理
|
||||
from .parser_tools.gap_tree import GapTree # 间隙树排序算法
|
||||
|
||||
|
||||
class MultiLine(Tbpu):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-多栏-单行"
|
||||
|
||||
# 构建算法对象,指定包围盒的元素位置
|
||||
self.gtree = GapTree(lambda tb: tb["normalized_bbox"])
|
||||
|
||||
def run(self, textBlocks):
|
||||
textBlocks = linePreprocessing(textBlocks) # 预处理
|
||||
textBlocks = self.gtree.sort(textBlocks) # 构建间隙树
|
||||
# 补充行尾间隔符
|
||||
for tb in textBlocks:
|
||||
tb["end"] = "\n"
|
||||
del tb["normalized_bbox"]
|
||||
return textBlocks
|
||||
@@ -0,0 +1,29 @@
|
||||
# 排版解析-多栏-无换行
|
||||
|
||||
from .tbpu import Tbpu
|
||||
from .parser_tools.line_preprocessing import linePreprocessing # 行预处理
|
||||
from .parser_tools.gap_tree import GapTree # 间隙树排序算法
|
||||
from .parser_tools.paragraph_parse import word_separator # 上下句间隔符
|
||||
|
||||
|
||||
class MultiNone(Tbpu):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-多栏-无换行"
|
||||
|
||||
# 构建算法对象,指定包围盒的元素位置
|
||||
self.gtree = GapTree(lambda tb: tb["normalized_bbox"])
|
||||
|
||||
def run(self, textBlocks):
|
||||
textBlocks = linePreprocessing(textBlocks) # 预处理
|
||||
textBlocks = self.gtree.sort(textBlocks) # 构建间隙树
|
||||
# 补充行尾间隔符
|
||||
for i in range(len(textBlocks)):
|
||||
tb = textBlocks[i]
|
||||
if i < len(textBlocks) - 1:
|
||||
letter1 = tb["text"][-1] # 行1结尾字母
|
||||
letter2 = textBlocks[i + 1]["text"][0] # 行2开头字母
|
||||
tb["end"] = word_separator(letter1, letter2) # 获取间隔符
|
||||
else:
|
||||
tb["end"] = "\n"
|
||||
del tb["normalized_bbox"]
|
||||
return textBlocks
|
||||
@@ -0,0 +1,33 @@
|
||||
# 排版解析-多栏-自然段
|
||||
|
||||
from .tbpu import Tbpu
|
||||
from .parser_tools.line_preprocessing import linePreprocessing # 行预处理
|
||||
from .parser_tools.gap_tree import GapTree # 间隙树排序算法
|
||||
from .parser_tools.paragraph_parse import ParagraphParse # 段内分析器
|
||||
|
||||
|
||||
class MultiPara(Tbpu):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-多栏-自然段"
|
||||
|
||||
# 间隙树对象
|
||||
self.gtree = GapTree(lambda tb: tb["normalized_bbox"])
|
||||
|
||||
# 段内分析器对象
|
||||
get_info = lambda tb: (tb["normalized_bbox"], tb["text"])
|
||||
|
||||
def set_end(tb, end): # 获取预测的块尾分隔符
|
||||
tb["end"] = end
|
||||
|
||||
self.pp = ParagraphParse(get_info, set_end)
|
||||
|
||||
def run(self, textBlocks):
|
||||
textBlocks = linePreprocessing(textBlocks) # 预处理
|
||||
textBlocks = self.gtree.sort(textBlocks) # 构建间隙树
|
||||
nodes = self.gtree.get_nodes_text_blocks() # 获取树节点序列
|
||||
# 对每个结点,进行自然段分析
|
||||
for tbs in nodes:
|
||||
self.pp.run(tbs) # 预测结尾分隔符
|
||||
for tb in tbs:
|
||||
del tb["normalized_bbox"]
|
||||
return textBlocks
|
||||
@@ -0,0 +1,14 @@
|
||||
# 排版解析-不做处理
|
||||
|
||||
from .tbpu import Tbpu
|
||||
|
||||
|
||||
class ParserNone(Tbpu):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-不做处理"
|
||||
|
||||
def run(self, textBlocks):
|
||||
for tb in textBlocks:
|
||||
if "end" not in tb:
|
||||
tb["end"] = "\n" # 默认结尾间隔符为换行
|
||||
return textBlocks
|
||||
@@ -0,0 +1,73 @@
|
||||
# 排版解析-单栏-代码段
|
||||
|
||||
from .parser_single_line import SingleLine
|
||||
from .parser_tools.line_preprocessing import linePreprocessing # 行预处理
|
||||
|
||||
from bisect import bisect_left
|
||||
|
||||
|
||||
class SingleCode(SingleLine):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-单栏-代码段"
|
||||
|
||||
def merge_line(self, line): # 合并一行
|
||||
A = line[0]
|
||||
ba = A["box"]
|
||||
ha = ba[3][1] - ba[0][1] # 块A行高
|
||||
score = A["score"]
|
||||
for i in range(1, len(line)):
|
||||
B = line[i]
|
||||
bb = B["box"]
|
||||
ha = (ha + bb[3][1] - bb[0][1]) / 2
|
||||
# 合并文字,补充与间距相同的空格数
|
||||
space = 0
|
||||
if bb[0][0] > ba[1][0]:
|
||||
space = round((bb[0][0] - ba[1][0]) / ha)
|
||||
A["text"] += " " * space + B["text"]
|
||||
# 合并包围盒
|
||||
yTop = min(ba[0][1], ba[1][1], bb[0][1], bb[1][1])
|
||||
yBottom = max(ba[2][1], ba[3][1], bb[2][1], bb[3][1])
|
||||
xLeft = min(ba[0][0], ba[3][0], bb[0][0], bb[3][0])
|
||||
xRight = max(ba[1][0], ba[2][0], bb[1][0], bb[2][0])
|
||||
ba[0][1] = ba[1][1] = yTop # y上
|
||||
ba[2][1] = ba[3][1] = yBottom # y下
|
||||
ba[0][0] = ba[3][0] = xLeft # x左
|
||||
ba[1][0] = ba[2][0] = xRight # x右
|
||||
# 置信度
|
||||
score += B["score"]
|
||||
A["score"] = score / len(line)
|
||||
del A["normalized_bbox"]
|
||||
A["end"] = "\n"
|
||||
return A
|
||||
|
||||
def indent(self, tbs): # 分析所有行,构造缩进
|
||||
lh = 0 # 平均行高
|
||||
xMin = float("inf") # 句首的最左、最右x值
|
||||
xMax = float("-inf")
|
||||
for tb in tbs:
|
||||
b = tb["box"]
|
||||
lh += b[3][1] - b[0][1]
|
||||
x = b[0][0]
|
||||
xMin = min(xMin, x)
|
||||
xMax = max(xMax, x)
|
||||
lh /= len(tbs)
|
||||
lh2 = lh / 2
|
||||
# 构建缩进层级列表
|
||||
levelList = []
|
||||
x = xMin
|
||||
while x < xMax:
|
||||
levelList.append(x)
|
||||
x += lh
|
||||
# 按照层级,为每行句首加上空格,并调整包围盒
|
||||
for tb in tbs:
|
||||
b = tb["box"]
|
||||
level = bisect_left(levelList, b[0][0] + lh2) - 1 # 二分查找层级点
|
||||
tb["text"] = " " * level + tb["text"] # 补充空格
|
||||
b[0][0] = b[3][0] = xMin # 左侧归零
|
||||
|
||||
def run(self, textBlocks):
|
||||
textBlocks = linePreprocessing(textBlocks) # 预处理
|
||||
lines = self.get_lines(textBlocks) # 获取每一行
|
||||
tbs = [self.merge_line(line) for line in lines] # 合并所有行
|
||||
self.indent(tbs) # 为每行添加句首缩进
|
||||
return tbs
|
||||
@@ -0,0 +1,73 @@
|
||||
# 排版解析-单栏-单行
|
||||
|
||||
from .tbpu import Tbpu
|
||||
from .parser_tools.line_preprocessing import linePreprocessing # 行预处理
|
||||
from .parser_tools.paragraph_parse import word_separator # 上下句间隔符
|
||||
|
||||
|
||||
class SingleLine(Tbpu):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-单栏-单行"
|
||||
|
||||
# 从文本块列表中找出所有行
|
||||
def get_lines(self, textBlocks):
|
||||
# 按x排序
|
||||
textBlocks.sort(key=lambda tb: tb["normalized_bbox"][0])
|
||||
lines = []
|
||||
for i1, tb1 in enumerate(textBlocks):
|
||||
if not tb1:
|
||||
continue
|
||||
# 最左的一个块
|
||||
l1, top1, r1, bottom1 = tb1["normalized_bbox"]
|
||||
h1 = bottom1 - top1
|
||||
now_line = [tb1]
|
||||
# 考察右侧哪些块符合条件
|
||||
for i2 in range(i1 + 1, len(textBlocks)):
|
||||
tb2 = textBlocks[i2]
|
||||
if not tb2:
|
||||
continue
|
||||
l2, top2, r2, bottom2 = tb2["normalized_bbox"]
|
||||
h2 = bottom2 - top2
|
||||
# 行2左侧太前
|
||||
if l2 < r1 - h1:
|
||||
continue
|
||||
# 垂直距离太远
|
||||
if top2 < top1 - h1 * 0.5 or bottom2 > bottom1 + h1 * 0.5:
|
||||
continue
|
||||
# 行高差距过大
|
||||
if abs(h1 - h2) > min(h1, h2) * 0.5:
|
||||
continue
|
||||
# 符合条件
|
||||
now_line.append(tb2)
|
||||
textBlocks[i2] = None
|
||||
# 更新搜索条件
|
||||
r1 = r2
|
||||
# 处理完一行
|
||||
for i2 in range(len(now_line) - 1):
|
||||
# 检查同一行内相邻文本块的水平间隙
|
||||
l1, t1, r1, b1 = now_line[i2]["normalized_bbox"]
|
||||
l2, t2, r2, b2 = now_line[i2 + 1]["normalized_bbox"]
|
||||
h = (b1 + b2 - t1 - l2) * 0.5
|
||||
if l2 - r1 > h * 1.5: # 间隙太大,强制设置空格
|
||||
now_line[i2]["end"] = " "
|
||||
continue
|
||||
letter1 = now_line[i2]["text"][-1]
|
||||
letter2 = now_line[i2 + 1]["text"][0]
|
||||
now_line[i2]["end"] = word_separator(letter1, letter2)
|
||||
now_line[-1]["end"] = "\n"
|
||||
lines.append(now_line)
|
||||
textBlocks[i1] = None
|
||||
# 所有行按y排序
|
||||
lines.sort(key=lambda tbs: tbs[0]["normalized_bbox"][1])
|
||||
return lines
|
||||
|
||||
def run(self, textBlocks):
|
||||
textBlocks = linePreprocessing(textBlocks) # 预处理
|
||||
lines = self.get_lines(textBlocks) # 获取每一行
|
||||
# 解包
|
||||
textBlocks = []
|
||||
for line in lines:
|
||||
for tb in line:
|
||||
del tb["normalized_bbox"]
|
||||
textBlocks.append(tb)
|
||||
return textBlocks
|
||||
@@ -0,0 +1,19 @@
|
||||
# 排版解析-单栏-无换行
|
||||
|
||||
from .parser_single_line import SingleLine
|
||||
from .parser_tools.paragraph_parse import word_separator # 上下句间隔符
|
||||
|
||||
|
||||
class SingleNone(SingleLine):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-单栏-无换行"
|
||||
|
||||
def run(self, textBlocks):
|
||||
textBlocks = super().run(textBlocks)
|
||||
# 找到换行符,更改为间隔符
|
||||
for i in range(len(textBlocks) - 1):
|
||||
if textBlocks[i]["end"] == "\n":
|
||||
letter1 = textBlocks[i]["text"][-1]
|
||||
letter2 = textBlocks[i + 1]["text"][0]
|
||||
textBlocks[i]["end"] = word_separator(letter1, letter2)
|
||||
return textBlocks
|
||||
@@ -0,0 +1,49 @@
|
||||
# 排版解析-单栏-自然段
|
||||
|
||||
from .parser_single_line import SingleLine
|
||||
from .parser_tools.line_preprocessing import linePreprocessing # 行预处理
|
||||
from .parser_tools.paragraph_parse import ParagraphParse # 段内分析器
|
||||
|
||||
|
||||
class SinglePara(SingleLine):
|
||||
def __init__(self):
|
||||
self.tbpuName = "排版解析-单栏-自然段"
|
||||
|
||||
# 段内分析器对象
|
||||
get_info = lambda tb: (tb["normalized_bbox"], tb["text"])
|
||||
|
||||
def set_end(tb, end): # 获取预测的块尾分隔符
|
||||
tb["line"][-1]["end"] = end
|
||||
|
||||
self.pp = ParagraphParse(get_info, set_end)
|
||||
|
||||
def run(self, textBlocks):
|
||||
textBlocks = linePreprocessing(textBlocks) # 预处理
|
||||
lines = self.get_lines(textBlocks) # 获取每一行
|
||||
# 将行封装为tb
|
||||
temp_tbs = []
|
||||
for line in lines:
|
||||
b0, b1, b2, b3 = line[0]["normalized_bbox"]
|
||||
# 搜索bbox
|
||||
for i in range(1, len(line)):
|
||||
bb = line[i]["normalized_bbox"]
|
||||
b1 = min(b1, bb[1])
|
||||
b2 = max(b1, bb[2])
|
||||
b3 = max(b1, bb[3])
|
||||
# 构建tb
|
||||
temp_tbs.append(
|
||||
{
|
||||
"normalized_bbox": (b0, b1, b2, b3),
|
||||
"text": line[0]["text"][0] + line[-1]["text"][-1],
|
||||
"line": line,
|
||||
}
|
||||
)
|
||||
# 预测结尾分隔符
|
||||
self.pp.run(temp_tbs)
|
||||
# 解包
|
||||
textBlocks = []
|
||||
for t in temp_tbs:
|
||||
for tb in t["line"]:
|
||||
del tb["normalized_bbox"]
|
||||
textBlocks.append(tb)
|
||||
return textBlocks
|
||||
@@ -0,0 +1,330 @@
|
||||
# 【间隙·树·排序算法】 GapTree_Sort_Algorithm
|
||||
# 对OCR结果或PDF提取的文本进行版面分析,按人类阅读顺序进行排序。
|
||||
# Author: hiroi-sora
|
||||
# https://github.com/hiroi-sora/GapTree_Sort_Algorithm
|
||||
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class GapTree:
|
||||
def __init__(self, get_bbox: Callable):
|
||||
"""
|
||||
:param get_bbox: 函数,传入单个文本块,
|
||||
返回该文本块左上角、右下角的坐标元组 (x0, y0, x1, y1)
|
||||
"""
|
||||
self.get_bbox = get_bbox
|
||||
|
||||
# ======================= 调用接口 =====================
|
||||
# 对文本块列表排序
|
||||
def sort(self, text_blocks: list):
|
||||
"""
|
||||
对文本块列表,按人类阅读顺序进行排序。
|
||||
|
||||
:param text_blocks: 文本块对象列表
|
||||
:return: 排序后的文本块列表
|
||||
"""
|
||||
|
||||
# 封装块单元,并求页面左右边缘
|
||||
units, page_l, page_r = self._get_units(text_blocks, self.get_bbox)
|
||||
# 求行和竖切线
|
||||
cuts, rows = self._get_cuts_rows(units, page_l, page_r)
|
||||
# 求布局树
|
||||
root = self._get_layout_tree(cuts, rows)
|
||||
# 求树节点序列
|
||||
nodes = self._preorder_traversal(root)
|
||||
# 求排序后的 原始文本块序列
|
||||
new_text_blocks = self._get_text_blocks(nodes)
|
||||
|
||||
# 测试:缓存中间变量,以便调试输出
|
||||
self.current_rows = rows
|
||||
self.current_cuts = cuts
|
||||
self.current_nodes = nodes
|
||||
|
||||
return new_text_blocks
|
||||
|
||||
# 获取以区块为单位的文本块二层列表
|
||||
def get_nodes_text_blocks(self):
|
||||
"""
|
||||
获取以区块为单位的文本块二层列表。需要在 sort 后调用。
|
||||
|
||||
:return: [ [区块1的text_blocks], [区块2的text_blocks]... ]
|
||||
"""
|
||||
result = []
|
||||
for node in self.current_nodes:
|
||||
tbs = []
|
||||
if node["units"]:
|
||||
for unit in node["units"]:
|
||||
tbs.append(unit[1])
|
||||
result.append(tbs)
|
||||
return result
|
||||
|
||||
# ======================= 封装块单元列表 =====================
|
||||
# 将原始文本块,封装为 ( (x0,y0,x2,y2), 原始 ) 。并检查页边界。
|
||||
def _get_units(self, text_blocks, get_bbox):
|
||||
# 封装单元列表 units [ ( (x0,y0,x2,y2), 原始文本块 ), ... ]
|
||||
units = []
|
||||
page_l, page_r = float("inf"), -1 # 记录文本块的左右最值,作为页边界
|
||||
for tb in text_blocks:
|
||||
x0, y0, x2, y2 = get_bbox(tb)
|
||||
units.append(((x0, y0, x2, y2), tb))
|
||||
if x0 < page_l:
|
||||
page_l = x0
|
||||
if x2 > page_r:
|
||||
page_r = x2
|
||||
units.sort(key=lambda a: a[0][1]) # 按顶部从上到下排序
|
||||
return units, page_l, page_r
|
||||
|
||||
# ======================= 求行和竖切线 =====================
|
||||
"""
|
||||
扫描所有文本块,获取所有行和竖切线。
|
||||
一个行,由一组垂直位置接近的文本块所组成。
|
||||
一条竖切线,由多个连续行中,同一位置的间隙所组成。间隙划分同一行中不同列的文本块。
|
||||
输入:一个页面上的文本块单元列表 units=[ ( (x0,y0,x2,y2), _ ) ] 。必须按上到下排序。
|
||||
返回:
|
||||
竖切线列表 cuts=[ ( 左边缘x, 右边缘x, 起始行号, 结束行号 ) ] 。从左到右排序
|
||||
页面上的行 rows=[ [unit...] ] 。从上到下,从左到右排序
|
||||
"""
|
||||
|
||||
def _get_cuts_rows(self, units, page_l, page_r):
|
||||
# 使用间隙组 gaps2 更新 gaps1 。返回: 更新后的gaps1 , gaps1中被移除的间隙
|
||||
def update_gaps(gaps1, gaps2):
|
||||
flags1 = [True for _ in gaps1] # gaps1[i] 是否彻底移除
|
||||
flags2 = [True for _ in gaps2] # gaps2[i] 是否新加入
|
||||
new_gaps1 = []
|
||||
for i1, g1 in enumerate(gaps1):
|
||||
l1, r1, _ = g1
|
||||
for i2, g2 in enumerate(gaps2): # 对每一个gap1,考察所有gap2
|
||||
l2, r2, _ = g2
|
||||
# 计算交集的起点和终点
|
||||
inter_l = max(l1, l2)
|
||||
inter_r = min(r1, r2)
|
||||
# 如果交集有效
|
||||
if inter_l <= inter_r:
|
||||
# 更新 gap1 左右边缘
|
||||
new_gaps1.append((inter_l, inter_r, g1[2]))
|
||||
flags1[i1] = False # 旧的 gap1 不应移除
|
||||
flags2[i2] = False # 新的 gap2 不应添加
|
||||
# gap2 新加入
|
||||
for i2, f2 in enumerate(flags2):
|
||||
if f2:
|
||||
new_gaps1.append(gaps2[i2])
|
||||
# 记录 gaps1 彻底移除的项
|
||||
del_gaps1 = []
|
||||
for i1, f1 in enumerate(flags1):
|
||||
if f1:
|
||||
del_gaps1.append(gaps1[i1])
|
||||
|
||||
return new_gaps1, del_gaps1
|
||||
|
||||
# ========================================
|
||||
|
||||
page_l -= 1 # 保证页面左右边缘不与文本块重叠
|
||||
page_r += 1
|
||||
# 存放所有行。“row”指同一水平线上的单元块(可能属于多列)。 [ [unit...] ]
|
||||
rows = []
|
||||
# 已生成完毕的竖切线。[ ( 左边缘x, 右边缘x , 起始行号, 结束行号 ) ]
|
||||
completed_cuts = []
|
||||
# 考察中的间隙。 [ (左边缘x, 右边缘x , 开始行号) ]
|
||||
gaps = []
|
||||
row_index = 0 # 当前行号
|
||||
unit_index = 0 # 当前块号
|
||||
# 从上到下遍历所有文本行
|
||||
l_units = len(units)
|
||||
while unit_index < l_units:
|
||||
# ========== 查找当前行 row ==========
|
||||
unit = units[unit_index] # 当前行最顶部的块
|
||||
u_bottom = unit[0][3]
|
||||
row = [unit] # 当前行
|
||||
# 查找当前行的剩余块
|
||||
for i in range(unit_index + 1, len(units)):
|
||||
next_u = units[i]
|
||||
next_top = next_u[0][1]
|
||||
if next_top > u_bottom:
|
||||
break # 下一块的顶部超过当前底部,结束本行
|
||||
row.append(next_u) # 当前行添加块
|
||||
unit_index = i # 步进 已遍历的块序号
|
||||
# ========== 查找当前行的间隙 row_gaps ==========
|
||||
row.sort(key=lambda x: (x[0][0], x[0][2])) # 当前行中的块 从左到右排序
|
||||
row_gaps = [] # 当前行的间隙 [ ( ( 左边缘l, 右边缘r ), 开始行号) ]
|
||||
search_start = page_l # 本轮搜索的线段起始点为页面左边缘
|
||||
for u in row: # 遍历当前行的块
|
||||
l = u[0][0] # 块左侧
|
||||
r = u[0][2] # 块右侧
|
||||
# 若块起始点大于搜索起始点,那么将这部分加入到结果
|
||||
if l > search_start:
|
||||
row_gaps.append((search_start, l, row_index))
|
||||
# 若块结束点大于搜索起始点,更新搜索起始点
|
||||
if r > search_start:
|
||||
search_start = r
|
||||
# 页面右边缘 加入最后一个间隙
|
||||
row_gaps.append((search_start, page_r, row_index))
|
||||
# ========== 更新考察中的间隙组 ==========
|
||||
gaps, del_gaps = update_gaps(gaps, row_gaps)
|
||||
# gaps 中被移除的项,加入生成完毕的竖切线 completed_cuts
|
||||
row_max = row_index - 1 # 竖切线结束行号
|
||||
for dg1 in del_gaps:
|
||||
completed_cuts.append((*dg1, row_max))
|
||||
# ========== End ==========
|
||||
rows.append(row) # 总行列表添加当前行
|
||||
unit_index += 1
|
||||
row_index += 1
|
||||
# 遍历结束,收集 gaps 中剩余的间隙,组成延伸到最后一行的竖切线
|
||||
row_max = len(rows) - 1 # 竖切线结束行号
|
||||
for g in gaps:
|
||||
completed_cuts.append((*g, row_max))
|
||||
completed_cuts.sort(key=lambda c: c[0])
|
||||
return completed_cuts, rows
|
||||
|
||||
# ======================= 求布局树 =====================
|
||||
"""
|
||||
一个布局树节点表示一个区块。定义:
|
||||
node = {
|
||||
"x_left": 节点左边缘x,
|
||||
"x_right": 右边缘x,
|
||||
"r_top": 顶部的行号,
|
||||
"r_bottom": 底部的行号,
|
||||
"units": [], # 节点内部的文本块列表(除了根节点为空,其它节点非空)
|
||||
"children": [], # 子节点,有序
|
||||
}
|
||||
"""
|
||||
|
||||
def _get_layout_tree(self, cuts, rows):
|
||||
# 竖切线,将一个横行切开,断开的区域为“间隙”。
|
||||
# 生成每一行对应的间隙 (左侧,右侧) 坐标列表
|
||||
rows_gaps = [[] for _ in rows]
|
||||
for g_i, cut in enumerate(cuts):
|
||||
for r_i in range(cut[2], cut[3] + 1):
|
||||
rows_gaps[r_i].append((cut[0], cut[1]))
|
||||
|
||||
root = { # 根节点
|
||||
"x_left": cuts[0][0] - 1,
|
||||
"x_right": cuts[-1][1] + 1,
|
||||
"r_top": -1,
|
||||
"r_bottom": -1,
|
||||
"units": [],
|
||||
"children": [],
|
||||
}
|
||||
completed_nodes = [root] # 已经完成结束的节点
|
||||
now_nodes = [] # 当前正在考虑的节点。无顺序
|
||||
|
||||
# ========== 结束一个节点,加入节点树 ==========
|
||||
def complete(node):
|
||||
node_r = node["x_right"] - 2 # 当前节点右边界
|
||||
max_nodes = [] # 符合父节点条件的,最低的完成节点列表
|
||||
max_r = -2 # 符合父节点条件的最低行数
|
||||
# 在完成列表中,寻找父节点
|
||||
for com_node in completed_nodes:
|
||||
# 父节点的垂直投影必须包含当前右界
|
||||
if node_r < com_node["x_left"] or node_r > com_node["x_right"] + 0.0001:
|
||||
continue
|
||||
# 父节点底部必须在当前之上
|
||||
if com_node["r_bottom"] >= node["r_top"]:
|
||||
continue
|
||||
# 遇到更低的符合条件节点
|
||||
if com_node["r_bottom"] > max_r:
|
||||
max_r = com_node["r_bottom"]
|
||||
max_nodes = [com_node]
|
||||
continue
|
||||
# 遇到同样低的符合条件节点
|
||||
if com_node["r_bottom"] == max_r:
|
||||
max_nodes.append(com_node)
|
||||
continue
|
||||
# 在最低列表中,寻找最右的节点作为父节点
|
||||
max_node = max(max_nodes, key=lambda n: n["x_right"])
|
||||
max_node["children"].append(node) # 加入父节点
|
||||
completed_nodes.append(node) # 加入完成列表
|
||||
|
||||
# ========== 遍历每行,更新节点树 ==========
|
||||
for r_i, row in enumerate(rows):
|
||||
row_gaps = rows_gaps[r_i] # 当前行的间隙组
|
||||
u_i = g_i = 0 # 当前考察的 文本块、间隙下标
|
||||
|
||||
# ========== 检查是否有正在考虑的节点 可以结束 ==========
|
||||
new_nodes = []
|
||||
for node in now_nodes: # 遍历节点
|
||||
l_flag = r_flag = False # 标记节点左右边缘是否延续
|
||||
completed_flag = False # 标记节点是否可以结束
|
||||
x_left = node["x_left"] # 左右边缘坐标
|
||||
x_right = node["x_right"]
|
||||
for gap in row_gaps: # 遍历该行所有间隙
|
||||
if gap[1] == x_left: # 节点左边缘被间隙右侧延续
|
||||
l_flag = True
|
||||
if gap[0] == x_right: # 右边缘被间隙左侧延续
|
||||
r_flag = True
|
||||
# 任意间隙在本节点下方,打断本节点
|
||||
if x_left < gap[0] < x_right or x_left < gap[1] < x_right:
|
||||
completed_flag = True
|
||||
break
|
||||
if not l_flag or not r_flag: # 左右任意一个边缘无法延续
|
||||
completed_flag = True
|
||||
if completed_flag: # 节点结束,加入节点树
|
||||
complete(node)
|
||||
else: # 节点继续
|
||||
node["r_bottom"] = r_i
|
||||
new_nodes.append(node)
|
||||
now_nodes = new_nodes
|
||||
|
||||
# ========== 从左到右遍历,将文本块加入对应列的节点 ==========
|
||||
while u_i < len(row):
|
||||
unit = row[u_i] # 当前块
|
||||
# ========== 当前块 unit 位于间隙 g_i 与 g_i+1 之间的区间 ==========
|
||||
x_l = row_gaps[g_i][1] # 左间隙 g_i 的右边界
|
||||
x_r = row_gaps[g_i + 1][0] # 右间隙 g_i+1 的左边界
|
||||
# 检查区间是否正确
|
||||
if unit[0][0] + 0.0001 > x_r: # 块比右间隙更右,说明到了下一个区间
|
||||
g_i += 1 # 间隙步进,块不步进
|
||||
continue
|
||||
# ========== 检查当前块可否加入已有的节点 ==========
|
||||
flag = False
|
||||
for node in now_nodes:
|
||||
# 若某个节点的左右侧坐标,与当前块一致,则当前块加入节点
|
||||
if node["x_left"] == x_l and node["x_right"] == x_r:
|
||||
node["units"].append(unit)
|
||||
flag = True
|
||||
break
|
||||
if flag:
|
||||
u_i += 1 # 块步进
|
||||
continue
|
||||
# ========== 根据当前块创建新的节点,加入待考虑节点 ==========
|
||||
now_nodes.append(
|
||||
{
|
||||
"x_left": x_l,
|
||||
"x_right": x_r,
|
||||
"r_top": r_i,
|
||||
"r_bottom": r_i,
|
||||
"units": [unit],
|
||||
"children": [],
|
||||
}
|
||||
)
|
||||
u_i += 1 # 块步进
|
||||
# 将剩余节点也加入节点树
|
||||
for node in now_nodes:
|
||||
complete(node)
|
||||
# 整理所有节点
|
||||
for node in completed_nodes:
|
||||
# 所有子节点 按从左到右排序
|
||||
node["children"].sort(key=lambda n: n["x_left"])
|
||||
# 所有块单元 按从上到下排序
|
||||
node["units"].sort(key=lambda u: u[0][1])
|
||||
return root
|
||||
|
||||
# ======================= 前序遍历布局树,求节点序列 =====================
|
||||
def _preorder_traversal(self, root):
|
||||
if not root:
|
||||
return []
|
||||
stack = [root]
|
||||
result = []
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
result.append(node)
|
||||
# 将当前节点的子节点逆序压入栈中,以保证左子节点先于右子节点处理
|
||||
stack += reversed(node["children"])
|
||||
return result
|
||||
|
||||
# ======================= 从节点序列中,提取原始文本块序列 =====================
|
||||
def _get_text_blocks(self, nodes):
|
||||
result = []
|
||||
for node in nodes:
|
||||
for unit in node["units"]:
|
||||
result.append(unit[1])
|
||||
return result
|
||||
@@ -0,0 +1,98 @@
|
||||
# =========================================
|
||||
# =============== 按行预处理 ===============
|
||||
# =========================================
|
||||
|
||||
from statistics import median # 中位数
|
||||
from math import atan2, cos, sin, sqrt, pi, radians, degrees
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
angle_threshold = 3 # 进行一些操作的最小角度阈值
|
||||
angle_threshold_rad = radians(angle_threshold)
|
||||
|
||||
|
||||
# 计算两点之间的距离
|
||||
def _distance(point1, point2):
|
||||
return sqrt((point2[0] - point1[0]) ** 2 + (point2[1] - point1[1]) ** 2)
|
||||
|
||||
|
||||
# 计算一个box的旋转角度
|
||||
def _calculateAngle(box):
|
||||
# 获取宽高
|
||||
width = _distance(box[0], box[1])
|
||||
height = _distance(box[1], box[2])
|
||||
# 选择距离较大的两个顶点对,计算角度弧度值
|
||||
if width < height:
|
||||
angle_rad = atan2(box[2][1] - box[1][1], box[2][0] - box[1][0])
|
||||
else:
|
||||
angle_rad = atan2(box[1][1] - box[0][1], box[1][0] - box[0][0])
|
||||
# 标准化角度到[-pi/2, pi/2)范围(加上阈值)
|
||||
if angle_rad < -pi / 2 + angle_threshold_rad:
|
||||
angle_rad += pi
|
||||
elif angle_rad >= pi / 2 + angle_threshold_rad:
|
||||
angle_rad -= pi
|
||||
return angle_rad
|
||||
|
||||
|
||||
# 估计一组文本块的旋转角度
|
||||
def _estimateRotation(textBlocks):
|
||||
# blocks["box"] = [左上角,右上角,右下角,左下角]
|
||||
angle_rads = (_calculateAngle(block["box"]) for block in textBlocks)
|
||||
median_angle = median(angle_rads) # 中位数
|
||||
return median_angle
|
||||
|
||||
|
||||
# 获取旋转后的标准bbox。angle_threshold为执行旋转的阈值(最小角度值)。
|
||||
def _getBboxes(textBlocks, rotation_rad):
|
||||
# 角度低于阈值(接近0°),则不进行旋转,以提高性能。
|
||||
if abs(rotation_rad) <= angle_threshold_rad:
|
||||
bboxes = [
|
||||
( # 直接构造bbox
|
||||
min(x for x, y in tb["box"]),
|
||||
min(y for x, y in tb["box"]),
|
||||
max(x for x, y in tb["box"]),
|
||||
max(y for x, y in tb["box"]),
|
||||
)
|
||||
for tb in textBlocks
|
||||
]
|
||||
# 否则,进行旋转操作。
|
||||
else:
|
||||
logger.debug(f"文本块预处理旋转 {degrees(rotation_rad):.2f} °")
|
||||
bboxes = []
|
||||
min_x, min_y = float("inf"), float("inf") # 初始化最小的x和y坐标
|
||||
cos_angle = cos(-rotation_rad) # 计算角度正弦值
|
||||
sin_angle = sin(-rotation_rad)
|
||||
for tb in textBlocks:
|
||||
box = tb["box"]
|
||||
rotated_box = [ # 旋转box的每个顶点
|
||||
(cos_angle * x - sin_angle * y, sin_angle * x + cos_angle * y)
|
||||
for x, y in box
|
||||
]
|
||||
# 解包旋转后的顶点坐标,分别得到所有x和y的值
|
||||
xs, ys = zip(*rotated_box)
|
||||
# 构建标准bbox (左上角x, 左上角y, 右下角x, 右下角y)
|
||||
bbox = (min(xs), min(ys), max(xs), max(ys))
|
||||
bboxes.append(bbox)
|
||||
min_x, min_y = min(min_x, bbox[0]), min(min_y, bbox[1])
|
||||
# 如果旋转后存在负坐标,将所有包围盒平移,使得最小的x和y坐标为0,确保所有坐标非负
|
||||
if min_x < 0 or min_y < 0:
|
||||
bboxes = [
|
||||
(x - min_x, y - min_y, x2 - min_x, y2 - min_y)
|
||||
for (x, y, x2, y2) in bboxes
|
||||
]
|
||||
return bboxes
|
||||
|
||||
|
||||
# 预处理 textBlocks ,将包围盒 ["box"] 转为标准化 bbox ,同时去除 ["text"] 不完整的项
|
||||
def linePreprocessing(textBlocks):
|
||||
textBlocks = [i for i in textBlocks if i.get("text", False)]
|
||||
# 判断角度
|
||||
rotation_rad = _estimateRotation(textBlocks)
|
||||
# 获取标准化bbox
|
||||
bboxes = _getBboxes(textBlocks, rotation_rad)
|
||||
# 写入tb
|
||||
for i, tb in enumerate(textBlocks):
|
||||
tb["normalized_bbox"] = bboxes[i]
|
||||
# 按y排序
|
||||
textBlocks.sort(key=lambda tb: tb["normalized_bbox"][1])
|
||||
return textBlocks
|
||||
@@ -0,0 +1,173 @@
|
||||
# 段落分析器
|
||||
# 对已经是一个列区块之内的文本块,判断其段落关系。
|
||||
|
||||
from typing import Callable
|
||||
import unicodedata
|
||||
|
||||
|
||||
# 传入前句尾字符和后句首字符,返回分隔符
|
||||
def word_separator(letter1, letter2):
|
||||
|
||||
# 判断Unicode字符是否属于中文、日文或韩文字符集
|
||||
def is_cjk(character):
|
||||
cjk_unicode_ranges = [
|
||||
(0x4E00, 0x9FFF), # 中文
|
||||
(0x3040, 0x30FF), # 日文
|
||||
(0x1100, 0x11FF), # 韩文
|
||||
(0x3130, 0x318F), # 韩文兼容字母
|
||||
(0xAC00, 0xD7AF), # 韩文音节
|
||||
# 全角符号
|
||||
(0x3000, 0x303F), # 中文符号和标点
|
||||
(0xFE30, 0xFE4F), # 中文兼容形式标点
|
||||
(0xFF00, 0xFFEF), # 半角和全角形式字符
|
||||
]
|
||||
return any(start <= ord(character) <= end for start, end in cjk_unicode_ranges)
|
||||
|
||||
if is_cjk(letter1) and is_cjk(letter2):
|
||||
return ""
|
||||
|
||||
# 特殊情况:前文为连字符。
|
||||
if letter1 == "-":
|
||||
return ""
|
||||
# 特殊情况:后文为任意标点符号。
|
||||
if unicodedata.category(letter2).startswith("P"):
|
||||
return ""
|
||||
# 其它正常情况加空格
|
||||
return " "
|
||||
|
||||
|
||||
TH = 1.2 # 行高用作对比的阈值
|
||||
|
||||
|
||||
class ParagraphParse:
|
||||
def __init__(self, get_info: Callable, set_end: Callable) -> None:
|
||||
"""
|
||||
:param get_info: 函数,传入单个文本块,
|
||||
返回该文本块的信息元组: ( (x0, y0, x1, y1), "文本" )
|
||||
:param set_end: 函数,传入单个文本块 和文本尾部的分隔符,该函数要将分隔符保存。
|
||||
"""
|
||||
self.get_info = get_info
|
||||
self.set_end = set_end
|
||||
|
||||
# ======================= 调用接口:对文本块列表进行结尾分隔符预测 =====================
|
||||
def run(self, text_blocks: list):
|
||||
"""
|
||||
对属于一个区块内的文本块列表,进行段落分析,预测每个文本块结尾的分隔符。
|
||||
|
||||
:param text_blocks: 文本块对象列表
|
||||
:return: 排序后的文本块列表
|
||||
"""
|
||||
# 封装块单元
|
||||
units = self._get_units(text_blocks, self.get_info)
|
||||
# 执行分析
|
||||
self._parse(units)
|
||||
return text_blocks
|
||||
|
||||
# ======================= 封装块单元列表 =====================
|
||||
# 将原始文本块,封装为 ( (x0,y0,x2,y2), ("开头","结尾"), 原始 ) 。
|
||||
def _get_units(self, text_blocks, get_info):
|
||||
units = []
|
||||
for tb in text_blocks:
|
||||
bbox, text = get_info(tb)
|
||||
units.append((bbox, (text[0], text[-1]), tb))
|
||||
return units
|
||||
|
||||
# ======================= 分析 =====================
|
||||
|
||||
# 执行分析
|
||||
def _parse(self, units):
|
||||
units.sort(key=lambda a: a[0][1]) # 确保从上到下有序
|
||||
para_l, para_top, para_r, para_bottom = units[0][0] # 当前段的左右
|
||||
para_line_h = para_bottom - para_top # 当前段行高
|
||||
para_line_s = None # 当前段行间距
|
||||
now_para = [units[0]] # 当前段的块
|
||||
paras = [] # 总的段
|
||||
paras_line_space = [] # 总的段的行间距
|
||||
# 取 左右相等为一个自然段的主体
|
||||
for i in range(1, len(units)):
|
||||
l, top, r, bottom = units[i][0] # 当前块上下左右边缘
|
||||
h = bottom - top
|
||||
ls = top - para_bottom # 行间距
|
||||
# 检测是否同一段
|
||||
if ( # 左右边缘都相等
|
||||
abs(para_l - l) <= para_line_h * TH
|
||||
and abs(para_r - r) <= para_line_h * TH
|
||||
# 行间距不大
|
||||
and (para_line_s == None or ls < para_line_s + para_line_h * 0.5)
|
||||
):
|
||||
# 更新数据
|
||||
para_l = (para_l + l) / 2
|
||||
para_r = (para_r + r) / 2
|
||||
para_line_h = (para_line_h + h) / 2
|
||||
para_line_s = ls if para_line_s == None else (para_line_s + ls) / 2
|
||||
# 添加到当前段
|
||||
now_para.append(units[i])
|
||||
else: # 非同一段,归档上一段,创建新一段
|
||||
paras.append(now_para)
|
||||
paras_line_space.append(para_line_s)
|
||||
now_para = [units[i]]
|
||||
para_l, para_r, para_line_h = l, r, bottom - top
|
||||
para_line_s = None
|
||||
para_bottom = bottom
|
||||
# 归档最后一段
|
||||
paras.append(now_para)
|
||||
paras_line_space.append(para_line_s)
|
||||
|
||||
# 合并只有1行的段,添加到上/下段作为首/尾句
|
||||
for i1 in reversed(range(len(paras))):
|
||||
para = paras[i1]
|
||||
if len(para) == 1:
|
||||
l, top, r, bottom = para[0][0]
|
||||
up_flag = down_flag = False
|
||||
# 上段末尾条件:左对齐,右不超,行间距够小
|
||||
if i1 > 0:
|
||||
# 检查左右
|
||||
up_l, up_top, up_r, up_bottom = paras[i1 - 1][-1][0]
|
||||
up_dist, up_h = abs(up_l - l), up_bottom - up_top
|
||||
up_flag = up_dist <= up_h * TH and r <= up_r + up_h * TH
|
||||
# 检查行间距
|
||||
if (
|
||||
paras_line_space[i1 - 1] != None
|
||||
and top - up_bottom > paras_line_space[i1 - 1] + up_h * 0.5
|
||||
):
|
||||
up_flag = False
|
||||
# 下段开头条件:右对齐/单行超出,左缩进
|
||||
if i1 < len(paras) - 1:
|
||||
down_l, down_top, down_r, down_bottom = paras[i1 + 1][0][0]
|
||||
down_h = down_bottom - down_top
|
||||
# 左对齐或缩进
|
||||
if down_l - down_h * TH <= l <= down_l + down_h * (1 + TH):
|
||||
if len(paras[i1 + 1]) > 1: # 多行,右对齐
|
||||
down_flag = abs(down_r - r) <= down_h * TH
|
||||
else: # 单行,右可超出
|
||||
down_flag = down_r - down_h * TH < r
|
||||
# 检查行间距
|
||||
if (
|
||||
paras_line_space[i1 + 1] != None
|
||||
and down_top - bottom > paras_line_space[i1 + 1] + down_h * 0.5
|
||||
):
|
||||
down_flag = False
|
||||
|
||||
# 选择添加到上还是下段
|
||||
if up_flag and down_flag: # 两段都符合,则选择垂直距离更近的
|
||||
if top - up_bottom < down_top - bottom:
|
||||
paras[i1 - 1].append(para[0])
|
||||
else:
|
||||
paras[i1 + 1].insert(0, para[0])
|
||||
elif up_flag: # 只有一段符合,直接选择
|
||||
paras[i1 - 1].append(para[0])
|
||||
elif down_flag:
|
||||
paras[i1 + 1].insert(0, para[0])
|
||||
if up_flag or down_flag:
|
||||
del paras[i1]
|
||||
del paras_line_space[i1]
|
||||
|
||||
# 刷新所有段,添加end
|
||||
for para in paras:
|
||||
for i1 in range(len(para) - 1):
|
||||
letter1 = para[i1][1][1] # 行1结尾字母
|
||||
letter2 = para[i1 + 1][1][0] # 行2开头字母
|
||||
sep = word_separator(letter1, letter2)
|
||||
self.set_end(para[i1][2], sep)
|
||||
self.set_end(para[-1][2], "\n")
|
||||
return units
|
||||
@@ -0,0 +1,22 @@
|
||||
# tbpu : text block processing unit
|
||||
# 文块处理器的基类。
|
||||
# OCR返回的结果中,一项包含文字、包围盒、置信度的元素,称为一个“文块” - text block 。
|
||||
# 文块不一定是完整的一句话或一个段落。反之,一般是零散的文字。
|
||||
# 一个OCR结果常由多个文块组成。
|
||||
# 文块处理器就是:将传入的多个文块进行处理,比如合并、排序、删除文块。
|
||||
|
||||
|
||||
class Tbpu:
|
||||
def __init__(self):
|
||||
self.tbpuName = "文块处理单元-未知"
|
||||
|
||||
def run(self, textBlocks):
|
||||
"""输入:textBlocks文块列表。例:\n
|
||||
[
|
||||
{'box': [[29, 19], [172, 19], [172, 44], [29, 44]], 'score': 0.89, 'text': '文本111'},
|
||||
{'box': [[29, 60], [161, 60], [161, 86], [29, 86]], 'score': 0.75, 'text': '文本222'},
|
||||
]
|
||||
输出:排序后的textBlocks文块列表,每个块增加键:
|
||||
'end' 结尾间隔符
|
||||
"""
|
||||
return textBlocks
|
||||
@@ -0,0 +1,16 @@
|
||||
# 根据当前环境,提供不同平台对象
|
||||
import sys
|
||||
|
||||
# 根据当前操作系统选择要导入的模块
|
||||
_plat = sys.platform
|
||||
if _plat.startswith("win32"):
|
||||
from .win32.win32_api import Api as _Platform
|
||||
elif _plat.startswith("linux"):
|
||||
from .linux.linux_api import Api as _Platform
|
||||
elif _plat.startswith("darwin"):
|
||||
raise ImportError("尚未支持macos系统!")
|
||||
else:
|
||||
raise ImportError(f"未知系统:{_plat}")
|
||||
|
||||
# 构造单例:平台对象
|
||||
Platform = _Platform()
|
||||
@@ -0,0 +1,159 @@
|
||||
# ==============================================
|
||||
# =============== Linux系统API ===============
|
||||
# ==============================================
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from PySide2.QtCore import QStandardPaths as Qsp
|
||||
|
||||
from umi_log import logger
|
||||
from umi_about import UmiAbout
|
||||
|
||||
|
||||
# ==================== 快捷方式 ====================
|
||||
class _Shortcut:
|
||||
@staticmethod
|
||||
def _getPath(position): # 获取路径
|
||||
# 桌面
|
||||
if position == "desktop":
|
||||
return Qsp.writableLocation(Qsp.DesktopLocation)
|
||||
# 开始菜单
|
||||
if position == "startMenu":
|
||||
return Qsp.writableLocation(Qsp.ApplicationsLocation)
|
||||
# 开机自启 TODO
|
||||
elif position == "startup":
|
||||
raise ValueError("Linux 暂不支持自动添加开机自启。请手动设置。")
|
||||
|
||||
# 创建快捷方式,返回成功与否的字符串。position取值:
|
||||
# desktop 桌面
|
||||
# startMenu 开始菜单
|
||||
# startup 开机自启
|
||||
@staticmethod
|
||||
def createShortcut(position):
|
||||
if position == "startup": # TODO
|
||||
return "[Warning] Linux 暂不支持自动添加开机自启。请手动设置。\nAutomatically adding startup functions is not supported at this time. Please set it manually."
|
||||
try:
|
||||
lnkName = UmiAbout["name"]
|
||||
appPath = UmiAbout["app"]["path"]
|
||||
appDir = UmiAbout["app"]["dir"]
|
||||
if not appPath:
|
||||
return f"[Error] 未找到程序入口文件。请尝试手动创建快捷方式。\n[Error] Umi-OCR app path not exist. Please try creating a shortcut manually.\n\n{appPath}"
|
||||
|
||||
lnkPathBase = _Shortcut._getPath(position)
|
||||
lnkPathBase = os.path.join(lnkPathBase, lnkName)
|
||||
lnkPath = lnkPathBase + ".desktop"
|
||||
i = 1
|
||||
while os.path.exists(lnkPath): # 快捷方式已存在
|
||||
lnkPath = lnkPathBase + f" ({i}).desktop" # 添加序号
|
||||
i += 1
|
||||
except Exception as e:
|
||||
return f"[Error] 无法获取应用信息。\n[Error] Unable to obtain application information.\n\n{e}"
|
||||
|
||||
# 快捷方式 文件内容
|
||||
desktop_entry = f"""
|
||||
[Desktop Entry]
|
||||
Version={UmiAbout["version"]["string"]}
|
||||
Type=Application
|
||||
Name={lnkName}
|
||||
Exec={appPath}
|
||||
Path={appDir}
|
||||
Icon={appDir}/UmiOCR-data/qt_res/images/icons/umiocr.ico
|
||||
Terminal=false
|
||||
"""
|
||||
|
||||
try:
|
||||
with open(lnkPath, "w") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(lnkPath, 0o755) # 赋予执行权限
|
||||
logger.info(f"创建快捷方式: {lnkPath}")
|
||||
return "[Success]"
|
||||
except Exception as e:
|
||||
return f"[Error] 创建快捷方式失败。\n[Error] Failed to create shortcut.\n {lnkPath}: {e}"
|
||||
|
||||
# 删除快捷方式,返回删除文件的个数
|
||||
@staticmethod
|
||||
def deleteShortcut(position):
|
||||
try:
|
||||
appName = UmiAbout["name"]
|
||||
lnkDir = _Shortcut._getPath(position)
|
||||
except Exception:
|
||||
logger.error("无法获取应用信息", exc_info=True, stack_info=True)
|
||||
return 0
|
||||
|
||||
num = 0
|
||||
for fileName in os.listdir(lnkDir):
|
||||
try:
|
||||
lnkPath = os.path.join(lnkDir, fileName)
|
||||
if not os.path.isfile(lnkPath): # 排除非文件
|
||||
continue
|
||||
if fileName.startswith(appName) and fileName.endswith(".desktop"):
|
||||
os.remove(lnkPath)
|
||||
num += 1
|
||||
logger.info(f"删除快捷方式: {lnkPath}")
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"删除快捷方式失败。 lnkPath: {lnkPath}",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
continue
|
||||
return num
|
||||
|
||||
|
||||
# ==================== 硬件控制 ====================
|
||||
class _HardwareCtrl:
|
||||
# 关机
|
||||
@staticmethod
|
||||
def shutdown():
|
||||
# TODO: sudo权限?
|
||||
os.system("sudo shutdown -h now")
|
||||
|
||||
# 休眠
|
||||
@staticmethod
|
||||
def hibernate():
|
||||
os.system("sudo systemctl hibernate")
|
||||
|
||||
|
||||
# ==================== 对外接口 ====================
|
||||
class Api:
|
||||
# 快捷方式。接口: createShortcut deleteShortcut
|
||||
# 参数:快捷方式位置, desktop startMenu startup
|
||||
Shortcut = _Shortcut()
|
||||
|
||||
# 硬件控制。接口: shutdown hibernate
|
||||
HardwareCtrl = _HardwareCtrl()
|
||||
|
||||
# TODO: 根据系统及硬件,判断最适合的渲染器类型
|
||||
@staticmethod
|
||||
def getOpenGLUse():
|
||||
return "AA_UseOpenGLES" # 默认使用GLES。
|
||||
# return "AA_UseSoftwareOpenGL" # 如果不兼容?则使用软渲染
|
||||
|
||||
# 键值转键名
|
||||
@staticmethod
|
||||
def getKeyName(key):
|
||||
# 传入 pynput 按键事件对象,返回键名字符串
|
||||
if not isinstance(key, str):
|
||||
key = str(key)
|
||||
if not key: # 错误,未获取键值
|
||||
return "unknown"
|
||||
if key.startswith("'") and key.endswith("'"): # 去除自带引号
|
||||
key = key[1:-1]
|
||||
if key.startswith("Key."): # 修饰建
|
||||
key = key[4:]
|
||||
if not key: # 再检查一次
|
||||
return "unknown"
|
||||
return key
|
||||
|
||||
# 让系统运行一个程序,不堵塞当前进程
|
||||
@staticmethod
|
||||
def runNewProcess(path, args=""):
|
||||
# TODO: 测试、完善
|
||||
command_line = [path] + shlex.split(args)
|
||||
subprocess.Popen(command_line)
|
||||
|
||||
# 用系统默认应用打开一个文件或目录,不堵塞当前进程
|
||||
@staticmethod
|
||||
def startfile(path):
|
||||
os.startfile(path)
|
||||
@@ -0,0 +1,48 @@
|
||||
# 负责 pynput 的按键转换
|
||||
# 封装 keyTranslator ,负责key、char、vk的转换
|
||||
|
||||
from pynput._util.win32 import KeyTranslator
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
|
||||
# ==================== 按键转换器 ====================
|
||||
# 封装 keyTranslator ,负责key、char、vk的转换
|
||||
class _KeyTranslatorApi:
|
||||
def __init__(self):
|
||||
self._kt = KeyTranslator()
|
||||
self._layout, _layoutData = self._kt._generate_layout()
|
||||
self._normalLayout = _layoutData[
|
||||
(False, False, False)
|
||||
] # 选取常规布局,不受修饰键影响
|
||||
|
||||
def __call__(self, key):
|
||||
"""传入pynput的Key对象,返回与修饰键无关的键名char"""
|
||||
# 比如,就算按下Shift再按“=”,依然返回“=”而不是“+”
|
||||
try:
|
||||
if hasattr(key, "name"): # 若为控制键
|
||||
name = key.name.replace("cmd", "win") # win键名称修正
|
||||
if "_" in name: # 清除 _l _r 标记后缀
|
||||
name = name.split("_", 1)[0].lower()
|
||||
return name.lower()
|
||||
else: # 若为非控制键,通过vk获取键名,避免组合键的char为空
|
||||
scan = self._kt._to_scan(key.vk, self._layout) # vk转扫描码
|
||||
char = self._normalLayout[scan][0] # 扫描码转char
|
||||
return char.lower()
|
||||
except Exception: # 特殊键(如Fn)没有对应字符,会跳到这里
|
||||
if key and hasattr(key, "vk"):
|
||||
return f"<{key.vk}>" # 未知键值,无对应字符,返回键值本身
|
||||
else:
|
||||
logger.error(
|
||||
f"键值转换异常,未知键值!key: {str(key)}, type: {type(key)}.",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
return str(key)
|
||||
|
||||
|
||||
_KTA = _KeyTranslatorApi()
|
||||
|
||||
|
||||
def getKeyName(key): # 键值转键名
|
||||
return _KTA(key)
|
||||
@@ -0,0 +1,141 @@
|
||||
# ==============================================
|
||||
# =============== Windows系统API ===============
|
||||
# ==============================================
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from PySide2.QtCore import QStandardPaths as Qsp, QFile, QFileInfo
|
||||
|
||||
from umi_log import logger
|
||||
from umi_about import UmiAbout
|
||||
from .key_translator import getKeyName
|
||||
|
||||
# 环境的类型:
|
||||
# "APPDATA" 用户,低权限要求
|
||||
# "ProgramData" 全局,高权限要求
|
||||
EnvType = "APPDATA" # or "ProgramData"
|
||||
|
||||
|
||||
# ==================== 快捷方式 ====================
|
||||
class _Shortcut:
|
||||
@staticmethod # 获取地址
|
||||
def _getPath(position):
|
||||
# 桌面
|
||||
if position == "desktop":
|
||||
return Qsp.writableLocation(Qsp.DesktopLocation)
|
||||
|
||||
startMenu = os.path.join(
|
||||
os.getenv(EnvType), "Microsoft", "Windows", "Start Menu"
|
||||
)
|
||||
# 开始菜单
|
||||
if position == "startMenu":
|
||||
return startMenu
|
||||
# 开机自启
|
||||
elif position == "startup":
|
||||
return os.path.join(startMenu, "Programs", "Startup")
|
||||
|
||||
# 创建快捷方式,返回成功与否的字符串。position取值:
|
||||
# desktop 桌面
|
||||
# startMenu 开始菜单
|
||||
# startup 开机自启
|
||||
@staticmethod
|
||||
def createShortcut(position):
|
||||
lnkName = UmiAbout["name"]
|
||||
appPath = UmiAbout["app"]["path"]
|
||||
if not appPath or not os.path.exists(appPath):
|
||||
return f"[Error] 未找到程序exe文件。请尝试手动创建快捷方式。\n[Error] Exe path not exist. Please try creating a shortcut manually.\n\n{appPath}"
|
||||
lnkPathBase = _Shortcut._getPath(position)
|
||||
lnkPathBase = os.path.join(lnkPathBase, lnkName)
|
||||
lnkPath = lnkPathBase + ".lnk"
|
||||
i = 1
|
||||
while os.path.exists(lnkPath): # 快捷方式已存在
|
||||
lnkPath = lnkPathBase + f" ({i}).lnk" # 添加序号
|
||||
i += 1
|
||||
appFile = QFile(appPath)
|
||||
res = appFile.link(lnkPath)
|
||||
if not res:
|
||||
return f"[Error] {appFile.errorString()}\n请尝试以管理员权限启动软件。\nPlease try starting the software as an administrator.\nappPath: {appPath}\nlnkPath: {lnkPath}"
|
||||
return "[Success]"
|
||||
|
||||
# 删除快捷方式,返回删除文件的个数
|
||||
@staticmethod
|
||||
def deleteShortcut(position):
|
||||
lnkName = UmiAbout["name"]
|
||||
lnkDir = _Shortcut._getPath(position)
|
||||
num = 0
|
||||
for fileName in os.listdir(lnkDir):
|
||||
lnkPath = os.path.join(lnkDir, fileName)
|
||||
try:
|
||||
if not os.path.isfile(lnkPath): # 排除非文件
|
||||
continue
|
||||
info = QFileInfo(lnkPath)
|
||||
if not info.isSymLink(): # 排除非快捷方式
|
||||
continue
|
||||
originName = os.path.basename(info.symLinkTarget())
|
||||
if lnkName in originName: # 快捷方式指向的文件名包含appName,删之
|
||||
os.remove(lnkPath)
|
||||
num += 1
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"删除快捷方式失败。 lnkPath: {lnkPath}",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
continue
|
||||
return num
|
||||
|
||||
|
||||
# ==================== 硬件控制 ====================
|
||||
class _HardwareCtrl:
|
||||
# 关机
|
||||
@staticmethod
|
||||
def shutdown():
|
||||
os.system("shutdown /s /t 0")
|
||||
|
||||
# 休眠
|
||||
@staticmethod
|
||||
def hibernate():
|
||||
os.system("shutdown /h")
|
||||
|
||||
|
||||
# ==================== 对外接口 ====================
|
||||
class Api:
|
||||
# 快捷方式。接口: createShortcut deleteShortcut
|
||||
# 参数:快捷方式位置, desktop startMenu startup
|
||||
Shortcut = _Shortcut()
|
||||
|
||||
# 硬件控制。接口: shutdown hibernate
|
||||
HardwareCtrl = _HardwareCtrl()
|
||||
|
||||
# 根据系统及硬件,判断最适合的渲染器类型
|
||||
@staticmethod
|
||||
def getOpenGLUse():
|
||||
import platform
|
||||
|
||||
# 判断系统版本,若 >win10 则使用 GLES ,否则使用软渲染
|
||||
version = platform.version()
|
||||
if "." in version:
|
||||
ver = version.split(".")[0]
|
||||
if ver.isdigit() and int(ver) >= 10:
|
||||
return "AA_UseOpenGLES"
|
||||
return "AA_UseSoftwareOpenGL"
|
||||
|
||||
# 键值转键名
|
||||
@staticmethod
|
||||
def getKeyName(key):
|
||||
return getKeyName(key)
|
||||
|
||||
# 让系统运行一个程序,不堵塞当前进程
|
||||
@staticmethod
|
||||
def runNewProcess(path, args=""):
|
||||
subprocess.Popen(
|
||||
f'start "" "{path}" {args}',
|
||||
shell=True,
|
||||
# 确保在一个新的控制台窗口中运行,与当前进程完全独立。
|
||||
creationflags=subprocess.CREATE_NEW_CONSOLE,
|
||||
)
|
||||
|
||||
# 用系统默认应用打开一个文件或目录,不堵塞当前进程
|
||||
@staticmethod
|
||||
def startfile(path):
|
||||
os.startfile(path)
|
||||
@@ -0,0 +1,16 @@
|
||||
# =============================================
|
||||
# =============== 插件管理连接器 ===============
|
||||
# =============================================
|
||||
|
||||
from PySide2.QtCore import QObject, Slot, Signal
|
||||
|
||||
from .plugins_controller import PluginsController
|
||||
|
||||
|
||||
# 插件连接器
|
||||
class PluginsConnector(QObject):
|
||||
# 初始化插件,返回字典:
|
||||
# {"options": ["ocr":{"key":成功加载的插件的配置}], "errors": {"key": "失败信息"} }
|
||||
@Slot(result="QVariant")
|
||||
def init(self):
|
||||
return PluginsController.init()
|
||||
@@ -0,0 +1,84 @@
|
||||
# =======================================
|
||||
# =============== 加载插件 ===============
|
||||
# =======================================
|
||||
|
||||
import os
|
||||
import site
|
||||
import importlib
|
||||
|
||||
from umi_log import logger
|
||||
from ..ocr.api import initOcrPlugins
|
||||
|
||||
# 插件 总目录
|
||||
PLUGINS_PATH = "plugins"
|
||||
# 插件组 组名
|
||||
PLUGINS_GROUPS = ["ocr"]
|
||||
|
||||
|
||||
# 插件控制器
|
||||
class _PluginsControllerClass:
|
||||
def __init__(self):
|
||||
self.pluginsDict = {}
|
||||
self.optionsDict = {}
|
||||
for group in PLUGINS_GROUPS:
|
||||
self.pluginsDict[group] = {}
|
||||
self.optionsDict[group] = {}
|
||||
|
||||
# 初始化并加载插件
|
||||
def init(self):
|
||||
# 动态插件
|
||||
errors = {} # 保存失败信息
|
||||
# 添加包搜索路径
|
||||
if not os.path.exists(PLUGINS_PATH):
|
||||
os.makedirs(PLUGINS_PATH)
|
||||
logger.error(f"插件目录不存在: {PLUGINS_PATH} ")
|
||||
return None
|
||||
site.addsitedir(PLUGINS_PATH) # 添加插件搜索路径
|
||||
# 加载所有插件。在插件目录中搜索合法的包
|
||||
plugList = os.listdir(PLUGINS_PATH)
|
||||
for name in plugList:
|
||||
initPath = os.path.join(PLUGINS_PATH, name, "__init__.py")
|
||||
if os.path.exists(initPath): # 若包路径下存在 __init__.py ,则尝试加载该包
|
||||
flag, res = self._loadPlugin(name)
|
||||
if not flag: # 失败
|
||||
errors[name] = res
|
||||
logger.error(f"加载插件 {name} 失败:{res}")
|
||||
# TODO: 静态插件
|
||||
# 动态插件导入API管理器
|
||||
ocrErrs = initOcrPlugins(self.pluginsDict["ocr"])
|
||||
if ocrErrs:
|
||||
errors.update(ocrErrs)
|
||||
return {"options": self.optionsDict, "errors": errors}
|
||||
|
||||
# 加载一个组件python包。成功返回True,失败返回 False, 错误信息
|
||||
def _loadPlugin(self, name):
|
||||
# 加载包
|
||||
try:
|
||||
module = importlib.import_module(name)
|
||||
except Exception as e:
|
||||
return False, f"动态导入包失败:{e}"
|
||||
# 验证信息
|
||||
if not hasattr(module, "PluginInfo"):
|
||||
return False, f"__init__.py 中未定义 PluginInfo 。"
|
||||
pluginInfo = module.PluginInfo
|
||||
if "group" not in pluginInfo:
|
||||
return False, f"__init__.py 中未定义 group 。"
|
||||
if "api_class" not in pluginInfo:
|
||||
return False, f"__init__.py 中未定义 api_class 。"
|
||||
if "global_options" not in pluginInfo:
|
||||
pluginInfo["global_options"] = None
|
||||
if "local_options" not in pluginInfo:
|
||||
pluginInfo["local_options"] = None
|
||||
group = pluginInfo["group"]
|
||||
if not group or group not in PLUGINS_GROUPS:
|
||||
return False, f'__init__.py group "{group}" 不属于已定义的插件类型。'
|
||||
# 加载成功、验证成功,则记录信息
|
||||
self.pluginsDict[group][name] = pluginInfo
|
||||
self.optionsDict[group][name] = {
|
||||
"global_options": pluginInfo["global_options"],
|
||||
"local_options": pluginInfo["local_options"],
|
||||
}
|
||||
return True, ""
|
||||
|
||||
|
||||
PluginsController = _PluginsControllerClass()
|
||||
@@ -0,0 +1,152 @@
|
||||
# Umi-OCR
|
||||
# OCR software, free and offline. 开源、免费的离线OCR软件。
|
||||
# Website - https://github.com/hiroi-sora/Umi-OCR
|
||||
# Author - hiroi-sora
|
||||
#
|
||||
# You are free to use, modify, and distribute Umi-OCR, but it must include
|
||||
# the original author's copyright statement and the following license statement.
|
||||
# 您可以免费地使用、修改和分发 Umi-OCR ,但必须包含原始作者的版权声明和下列许可声明。
|
||||
"""
|
||||
Copyright (c) 2023 hiroi-sora
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
"""
|
||||
|
||||
|
||||
"""
|
||||
=========================================
|
||||
========== Umi-OCR 软件启动入口 ==========
|
||||
=========================================
|
||||
|
||||
说明:
|
||||
本文件必须在运行环境初始化之后调用。
|
||||
负责 Umi-OCR 的信息初始化并启动主事件循环。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import site
|
||||
|
||||
|
||||
# 启动主qml。工作路径必须为 UmiOCR-data
|
||||
def runQml(engineAddImportPath):
|
||||
# ==================== 0. 导入包 ====================
|
||||
from PySide2.QtCore import Qt, qInstallMessageHandler
|
||||
from PySide2.QtGui import QGuiApplication
|
||||
from PySide2.QtQml import QQmlApplicationEngine, qmlRegisterType
|
||||
|
||||
from umi_about import UmiAbout # 项目信息
|
||||
from umi_log import get_qt_message_handler, logger # 日志
|
||||
from .utils import app_opengl # 渲染器
|
||||
|
||||
# ==================== 0. QT 日志重定向到 Umi 日志模块 ====================
|
||||
qInstallMessageHandler(get_qt_message_handler())
|
||||
|
||||
# ==================== 1. 全局参数设置 ====================
|
||||
# 启用 OpenGL 上下文之间的资源共享
|
||||
QGuiApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True)
|
||||
# 启用高分屏自动缩放
|
||||
QGuiApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
||||
# 初始化渲染器
|
||||
app_opengl.initOpengl()
|
||||
|
||||
# ==================== 2. 启动qt ====================
|
||||
qtApp = QGuiApplication(sys.argv)
|
||||
qtApp.setApplicationName(UmiAbout["name"])
|
||||
qtApp.setOrganizationName(UmiAbout["authors"][0]["name"])
|
||||
qtApp.setOrganizationDomain(UmiAbout["url"]["home"])
|
||||
|
||||
# ==================== 3. OpenGlES 兼容性检查 ====================
|
||||
app_opengl.checkOpengl()
|
||||
|
||||
# ==================== 4. 注册连接器Python类 ====================
|
||||
from .tag_pages.tag_pages_connector import TagPageConnector # 页面连接器
|
||||
from .mission.mission_connector import MissionConnector # 任务连接器
|
||||
from .mission.doc_preview_connector import DocPreviewConnector # 文档预览连接器
|
||||
from .event_bus.pubsub_connector import PubSubConnector # 发布/订阅连接器
|
||||
from .event_bus.key_mouse.key_mouse_connector import ( # 键盘/鼠标连接器
|
||||
KeyMouseConnector,
|
||||
)
|
||||
from .plugins_controller.plugins_connector import PluginsConnector # 插件连接器
|
||||
from .utils.utils_connector import UtilsConnector # 通用连接器
|
||||
from .utils.global_configs_connector import GlobalConfigsConnector # 全局配置连接器
|
||||
from .utils.theme_connector import ThemeConnector # 主题连接器
|
||||
from .image_controller.image_connector import ImageConnector # 图片处理连接器
|
||||
from .image_controller.image_provider import PixmapProvider # 图片提供器
|
||||
from .utils.i18n_configs import I18n # 语言
|
||||
|
||||
qmlRegisterType(TagPageConnector, "TagPageConnector", 1, 0, "TagPageConnector")
|
||||
qmlRegisterType(MissionConnector, "MissionConnector", 1, 0, "MissionConnector")
|
||||
qmlRegisterType(PubSubConnector, "PubSubConnector", 1, 0, "PubSubConnector")
|
||||
qmlRegisterType(KeyMouseConnector, "KeyMouseConnector", 1, 0, "KeyMouseConnector")
|
||||
qmlRegisterType(UtilsConnector, "UtilsConnector", 1, 0, "UtilsConnector")
|
||||
qmlRegisterType(PluginsConnector, "PluginsConnector", 1, 0, "PluginsConnector")
|
||||
qmlRegisterType(ThemeConnector, "ThemeConnector", 1, 0, "ThemeConnector")
|
||||
qmlRegisterType(ImageConnector, "ImageConnector", 1, 0, "ImageConnector")
|
||||
qmlRegisterType(
|
||||
GlobalConfigsConnector, "GlobalConfigsConnector", 1, 0, "GlobalConfigsConnector"
|
||||
)
|
||||
qmlRegisterType(
|
||||
DocPreviewConnector, "DocPreviewConnector", 1, 0, "DocPreviewConnector"
|
||||
)
|
||||
|
||||
# ==================== 5. 启动翻译 ====================
|
||||
I18n.init(qtApp)
|
||||
|
||||
# ==================== 6. 启动qml引擎 ====================
|
||||
engine = QQmlApplicationEngine()
|
||||
if engineAddImportPath:
|
||||
engine.addImportPath(engineAddImportPath) # 相对路径重新导入包
|
||||
engine.addImageProvider("pixmapprovider", PixmapProvider) # 注册图片提供器
|
||||
rootContext = engine.rootContext() # 注册常量
|
||||
rootContext.setContextProperty("UmiAbout", UmiAbout)
|
||||
engine.load("qt_res/qml/Main.qml") # 通过本地文件启动
|
||||
if not engine.rootObjects():
|
||||
return 1
|
||||
res = qtApp.exec_()
|
||||
if res != 0:
|
||||
msg = f"Umi-OCR 异常退出。代码:{str(res)}\nUmi-OCR exited abnormally. Code: {str(res)}"
|
||||
os.MessageBox(msg)
|
||||
logger.info("QML引擎关闭")
|
||||
|
||||
|
||||
def main(app_path, engineAddImportPath=""):
|
||||
"""
|
||||
`app_path`: 程序入口文件 路径\n
|
||||
`engineAddImportPath`: 可选,qml包路径\n
|
||||
"""
|
||||
# 初始化运行信息
|
||||
site.addsitedir("./py_src/imports") # 自定义库添加到搜索路径
|
||||
import umi_about
|
||||
|
||||
if not umi_about.init(app_path): # 初始化版本信息,失败则结束运行
|
||||
sys.exit(0)
|
||||
|
||||
from .utils import pre_configs
|
||||
from .server.cmd_client import initCmd
|
||||
|
||||
# 安装某些软件时可能在系统中写入 QMLSCENE_DEVICE 环境变量,影响本软件的渲染方式,因此屏蔽该环境变量
|
||||
if "QMLSCENE_DEVICE" in os.environ:
|
||||
del os.environ["QMLSCENE_DEVICE"]
|
||||
|
||||
pre_configs.readConfigs() # 初始化预配置项
|
||||
if not initCmd(): # 初始化命令行,如果已有Umi-OCR在运行则结束运行
|
||||
sys.exit(0)
|
||||
runQml(engineAddImportPath) # 启动qml
|
||||
sys.exit(0)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,183 @@
|
||||
# ============================================
|
||||
# =============== 命令行-客户端 ===============
|
||||
# ============================================
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import psutil
|
||||
|
||||
from umi_log import logger
|
||||
from ..utils import pre_configs
|
||||
from ..platform import Platform
|
||||
|
||||
|
||||
# 获取进程的创建时间
|
||||
def getPidTime(pid):
|
||||
try:
|
||||
return str(psutil.Process(pid).create_time())
|
||||
except psutil.NoSuchProcess:
|
||||
logger.warning(
|
||||
"psutil.pid_exists(pid) 存在,但 Process 无法生成对象",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
return ""
|
||||
except Exception:
|
||||
logger.error("psutil.Process(pid) error", exc_info=True, stack_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
# 检查软件多开
|
||||
def _isMultiOpen():
|
||||
# 检查上次记录的pid和key是否还在运行
|
||||
recordPID = pre_configs.getValue("last_pid")
|
||||
recordPTime = pre_configs.getValue("last_ptime")
|
||||
if psutil.pid_exists(recordPID): # 上次记录的pid如今存在
|
||||
processTime = getPidTime(recordPID)
|
||||
if recordPTime == processTime: # 当前该进程启动时间与记录的相同,则为多开
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# 输出
|
||||
def _output(argv, argument, mode, text):
|
||||
if argument not in argv:
|
||||
return
|
||||
path = ""
|
||||
# 提取路径参数
|
||||
try:
|
||||
i = argv.index(argument)
|
||||
path = argv[i + 1]
|
||||
del argv[i : i + 2]
|
||||
except Exception as e:
|
||||
# logger 输出到 stderr , print 输出到 stdout
|
||||
logger.error(f"argument {argument} cannot be resolved.", exc_info=True)
|
||||
print(f"[Error] argument {argument} cannot be resolved. \n{e}")
|
||||
return
|
||||
# 相对路径转绝对路径
|
||||
if not os.path.isabs(path):
|
||||
# 获取当前工作目录的上一级目录
|
||||
current_dir = os.getcwd()
|
||||
parent_dir = os.path.abspath(os.path.join(current_dir, os.pardir))
|
||||
# 将 path 转为绝对路径,且以上一级目录为基准
|
||||
path = os.path.abspath(os.path.join(parent_dir, path))
|
||||
try:
|
||||
with open(path, mode, encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
print(f"\nSuccess output to file: {path}")
|
||||
except Exception as e:
|
||||
logger.error(f"failed to write file {path} .", exc_info=True)
|
||||
print(f"[Error] failed to write file {path} : \n{e}")
|
||||
return
|
||||
|
||||
|
||||
# 复制文本到剪贴板,不依赖第三方库
|
||||
def _clip(text):
|
||||
import subprocess
|
||||
import platform
|
||||
import tempfile
|
||||
|
||||
os_type = platform.system()
|
||||
try:
|
||||
if os_type == "Windows":
|
||||
# 创建一个临时文件,并立即关闭它,以便其他进程可以访问
|
||||
with tempfile.NamedTemporaryFile(
|
||||
delete=False, mode="w+", newline="\n"
|
||||
) as temp_file:
|
||||
temp_file.write(text)
|
||||
temp_file_name = temp_file.name
|
||||
temp_file.close()
|
||||
try:
|
||||
subprocess.run(f"clip < {temp_file_name}", check=True, shell=True)
|
||||
finally:
|
||||
os.unlink(temp_file_name) # 删除临时文件
|
||||
print("\nSuccess copy to clipboard.")
|
||||
else:
|
||||
print(f"[Error] clip unsupported OS: {os_type}")
|
||||
except Exception as e:
|
||||
logger.error("failed to copy to clipboard.", exc_info=True)
|
||||
print(f"[Error] failed to copy to clipboard: {e}")
|
||||
|
||||
|
||||
# 跨进程发送指令
|
||||
def _sendCmd(argv):
|
||||
port = pre_configs.getValue("server_port") # 记录的端口号
|
||||
url = f"http://127.0.0.1:{port}/argv" # argv,指令列表接口
|
||||
errStr = f"Umi-OCR 已在运行,HTTP跨进程传输指令失败。\n[Error] Umi-OCR is already running, HTTP cross process transmission instruction failed.\n{url}"
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
# 向后台工作进程发送指令
|
||||
res = ""
|
||||
try:
|
||||
data = json.dumps(argv, ensure_ascii=True).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url, data=data, headers={"Content-Type": "application/json"}
|
||||
)
|
||||
# response = urllib.request.urlopen(req)
|
||||
# 创建一个不使用代理的 opener ,发送请求
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
response = opener.open(req)
|
||||
if response.status == 200:
|
||||
res = response.read().decode("utf-8")
|
||||
else:
|
||||
res = f"{errStr}\nstatus_code: {response.status}"
|
||||
except Exception as e:
|
||||
res = f"{errStr}\nerror: {e}"
|
||||
# 输出
|
||||
print(res)
|
||||
_output(argv, "-->", "w", res)
|
||||
_output(argv, "--output", "w", res)
|
||||
_output(argv, "-->>", "a", res)
|
||||
_output(argv, "--output_append", "a", res)
|
||||
if "--clip" in argv:
|
||||
_clip(res)
|
||||
|
||||
|
||||
# 启动新进程,并发送指令
|
||||
def _newSend(argv):
|
||||
from umi_about import UmiAbout # 项目信息
|
||||
|
||||
appPath = UmiAbout["app"]["path"]
|
||||
if not appPath:
|
||||
msg = "未找到 Umi-OCR 入口路径,无法启动新进程。请手动启动 Umi-OCR 后发送指令。\nUmi-OCR path not found, unable to start a new process."
|
||||
os.MessageBox(msg)
|
||||
return
|
||||
# 启动进程,传入强制参数,避免递归无限启动进程
|
||||
Platform.runNewProcess(appPath, " --force")
|
||||
# 等待并检查 服务进程初始化完毕
|
||||
for i in range(60): # 检测轮次
|
||||
time.sleep(0.5) # 每次等待时间
|
||||
pre_configs.readConfigs() # 重新读取预配置
|
||||
if _isMultiOpen(): # 检测新进程是否启动
|
||||
_sendCmd(argv) # 发送指令
|
||||
return
|
||||
print(
|
||||
"服务进程初始化失败,等待时间超时。\n[Error] The service process initialization failed and the waiting time timed out."
|
||||
)
|
||||
|
||||
|
||||
# 初始化命令行
|
||||
def initCmd():
|
||||
argv = sys.argv[1:]
|
||||
force = False
|
||||
if "--force" in argv:
|
||||
argv.remove("--force")
|
||||
force = True
|
||||
# 检查,发现软件多开,则向已在运行的进程发送初始指令
|
||||
if _isMultiOpen():
|
||||
_sendCmd(argv)
|
||||
return False
|
||||
# 未多开,则启动进程
|
||||
else:
|
||||
# 无参数或强制启动,则正常运行本进程,刷新pid和ptime
|
||||
if not argv or force:
|
||||
nowPid = os.getpid()
|
||||
nowPTime = getPidTime(nowPid)
|
||||
pre_configs.setValue("last_pid", nowPid)
|
||||
pre_configs.setValue("last_ptime", nowPTime)
|
||||
return True
|
||||
else: # 有参数,则启动新进程并发送参数
|
||||
_newSend(argv)
|
||||
return False
|
||||
@@ -0,0 +1,524 @@
|
||||
# ===============================================
|
||||
# =============== 命令行-解析和执行 ===============
|
||||
# ===============================================
|
||||
|
||||
import time
|
||||
import json
|
||||
import argparse
|
||||
from threading import Condition
|
||||
from ..utils.call_func import CallFunc
|
||||
from ..utils.file_finder import findFiles
|
||||
from ..event_bus.pubsub_service import PubSubService # 发布/订阅管理器
|
||||
|
||||
|
||||
# 命令执行器
|
||||
class _Actuator:
|
||||
def __init__(self):
|
||||
self.pyDict = {} # python模块字典
|
||||
self.qmlDict = {} # qml模块字典
|
||||
self.tagPageConn = None # 页面连接器的引用
|
||||
|
||||
# 初始化,并收集信息。传入qml模块字典
|
||||
def initCollect(self, qmlModuleDict):
|
||||
qmlModuleDict = qmlModuleDict.toVariant()
|
||||
self.qmlDict.update(qmlModuleDict)
|
||||
# 获取页面连接器实例
|
||||
from ..tag_pages.tag_pages_connector import TagPageConnObj
|
||||
|
||||
self.tagPageConn = TagPageConnObj
|
||||
|
||||
# ============================== 页面管理 ==============================
|
||||
|
||||
# 返回当前 [可创建的页面模板] 和 [已创建的页面] 的信息
|
||||
def getAllPages(self):
|
||||
TabViewManager = self.qmlDict["TabViewManager"]
|
||||
pageList = TabViewManager.getPageList().toVariant()
|
||||
infoStr = "All opened pages:\npage_index\tkey\ttitle\n"
|
||||
for index, value in enumerate(pageList):
|
||||
infoStr += f'{index}\t{value["ctrlKey"]}\t{value["info"]["title"]}\n'
|
||||
|
||||
infoList = TabViewManager.getInfoList().toVariant()
|
||||
infoStr += (
|
||||
"\nAll page templates that can be opened:\ntemplate_index\tkey\ttitle\n"
|
||||
)
|
||||
for index, value in enumerate(infoList):
|
||||
infoStr += f'{index}\t{value["key"]}\t{value["title"]}\n'
|
||||
|
||||
infoStr += "\nUsage of create a page:\n"
|
||||
infoStr += " Umi-OCR --add_page [template_index]\n"
|
||||
infoStr += "Usage of delete a page:\n"
|
||||
infoStr += " Umi-OCR --del_page [page_index]\n"
|
||||
infoStr += "Usage of query the modules that can be called:\n"
|
||||
infoStr += " Umi-OCR --all_modules\n"
|
||||
|
||||
return infoStr
|
||||
|
||||
# 创建页面
|
||||
def addPage(self, index):
|
||||
try:
|
||||
index = int(index)
|
||||
except ValueError:
|
||||
return f"[Error] template_index must be integer, not {index}."
|
||||
TabViewManager = self.qmlDict["TabViewManager"]
|
||||
infoList = TabViewManager.getInfoList().toVariant()
|
||||
l = len(infoList) - 1
|
||||
if index < 0 or index > l:
|
||||
return f"[Error] template_index {index} out of range (0~{l})."
|
||||
return self.call("TabViewManager", "qml", "addTabPage", False, -1, index)
|
||||
|
||||
# 删除页面
|
||||
def delPage(self, index):
|
||||
try:
|
||||
index = int(index)
|
||||
except ValueError:
|
||||
return f"[Error] page_index must be integer, not {index}."
|
||||
TabViewManager = self.qmlDict["TabViewManager"]
|
||||
pageList = TabViewManager.getPageList().toVariant()
|
||||
l = len(pageList) - 1
|
||||
if index < 0 or index > l:
|
||||
return f"[Error] page_index {index} out of range (0~{l})."
|
||||
return self.call("TabViewManager", "qml", "delTabPage", False, index)
|
||||
|
||||
# 通过key创建页面
|
||||
def addPageByKey(self, key):
|
||||
# 1. 检查截图标签页,如果未创建则创建
|
||||
module, _ = self.getModuleFromName(key, "qml")
|
||||
if module == None:
|
||||
tvm = self.qmlDict["TabViewManager"]
|
||||
infoList = tvm.getInfoList().toVariant()
|
||||
f2 = False
|
||||
for i, v in enumerate(infoList):
|
||||
if v["key"] == key:
|
||||
f2 = True
|
||||
self.addPage(i)
|
||||
break
|
||||
if not f2:
|
||||
return f"[Error] Template {key} not found."
|
||||
for i in range(10):
|
||||
time.sleep(0.5)
|
||||
module, _ = self.getModuleFromName(key, "qml")
|
||||
if module != None:
|
||||
break
|
||||
if module == None:
|
||||
return f"[Error] Unable to create template {key}."
|
||||
return "[Success]"
|
||||
|
||||
# ============================== 动态模块调用 ==============================
|
||||
|
||||
# 返回所有可调用模块
|
||||
def getModules(self):
|
||||
pyd, qmld = {}, {}
|
||||
pages = self.tagPageConn.pages
|
||||
for p in pages:
|
||||
if pages[p]["qmlObj"]:
|
||||
qmld[p] = pages[p]["qmlObj"]
|
||||
if pages[p]["pyObj"]:
|
||||
pyd[p] = pages[p]["pyObj"]
|
||||
pyd.update(self.pyDict)
|
||||
qmld.update(self.qmlDict)
|
||||
return {"py": pyd, "qml": qmld}
|
||||
|
||||
# 传入(不完整的)模块名,搜索并返回模块实例。type: py / qml
|
||||
def getModuleFromName(self, moduleName, type_):
|
||||
d = self.getModules()[type_]
|
||||
module = None
|
||||
if moduleName in d:
|
||||
module = d[moduleName]
|
||||
else:
|
||||
for name in d.keys(): # 若输入模块名的前几个字母,也可以匹配
|
||||
if name.startswith(moduleName):
|
||||
moduleName = name
|
||||
module = d[name]
|
||||
break
|
||||
return module, moduleName
|
||||
|
||||
# 返回所有可调用模块的帮助信息
|
||||
def getModulesHelp(self):
|
||||
modules = self.getModules()
|
||||
help = "\nPython modules: (Usage: Umi-OCR --call_py [module name])\n"
|
||||
for k in modules["py"].keys():
|
||||
help += f" {k}\n"
|
||||
help += "\nQml modules: (Usage: Umi-OCR --call_qml [module name])\n"
|
||||
for k in modules["qml"].keys():
|
||||
help += f" {k}\n"
|
||||
help += f"\nTips: module name can only write the first letters, such as [ScreenshotOCR_1] → [Scr]"
|
||||
return help
|
||||
|
||||
# 返回一个模块的所有函数的帮助信息
|
||||
def getModuleFuncsHelp(self, moduleName, type_):
|
||||
module, moduleName = self.getModuleFromName(moduleName, type_)
|
||||
typeStr = "Python" if type_ == "py" else "qml"
|
||||
if not module:
|
||||
return f'[Error] {typeStr} module "{moduleName}" non-existent.'
|
||||
funcs = [
|
||||
func
|
||||
for func in vars(type(module)).keys()
|
||||
if callable(getattr(module, func))
|
||||
]
|
||||
help = f'All functions in {typeStr} module "{moduleName}":\n'
|
||||
for f in funcs:
|
||||
f = str(f)
|
||||
if not f.startswith("_"):
|
||||
help += f" {f}\n"
|
||||
help += f"Usage: Umi-OCR --call_qml {moduleName} --func [function name]\n"
|
||||
return help
|
||||
|
||||
# 调用一个模块函数。type: py / qml , thread: True 同步在子线程 / False 异步在主线程
|
||||
def call(self, moduleName, type_, funcName, thread, *paras):
|
||||
module, moduleName = self.getModuleFromName(moduleName, type_)
|
||||
typeStr = "Python" if type_ == "py" else "qml"
|
||||
if not module:
|
||||
return f'[Error] {typeStr} module "{moduleName}" non-existent.'
|
||||
func = getattr(module, funcName, None)
|
||||
if not func:
|
||||
return f'[Error] func "{funcName}" not exist in {typeStr} module "{moduleName}".'
|
||||
try:
|
||||
if thread: # 在子线程执行,返回结果
|
||||
return func(*paras)
|
||||
else: # 在主线程执行,返回标志文本
|
||||
CallFunc.now(func, *paras) # 在主线程中调用回调函数
|
||||
return f'Calling "{funcName}" in main thread.'
|
||||
except Exception as e:
|
||||
return f'[Error] calling {typeStr} module "{moduleName}" - "{funcName}" {paras}: {e}'
|
||||
|
||||
# ============================== 便捷指令 ==============================
|
||||
|
||||
# 控制主窗口
|
||||
def ctrlWindow(self, show, hide, quit):
|
||||
if show:
|
||||
self.call("MainWindow", "qml", "setVisibility", False, True)
|
||||
return "Umi-OCR show."
|
||||
elif hide:
|
||||
self.call("MainWindow", "qml", "setVisibility", False, False)
|
||||
return "Umi-OCR hide."
|
||||
elif quit:
|
||||
self.call("MainWindow", "qml", "quit", False)
|
||||
return "Umi-OCR quit."
|
||||
|
||||
# 快捷OCR:截图/粘贴/路径,并获取返回结果
|
||||
def quick_ocr(self, ss, clip, paras):
|
||||
# 1. 检查截图标签页,如果未创建则创建
|
||||
msg = self.addPageByKey("ScreenshotOCR")
|
||||
if msg != "[Success]":
|
||||
return msg
|
||||
|
||||
# 2. 订阅事件,监听 <<ScreenshotOcrEnd>>
|
||||
isOcrEnd = False
|
||||
resList = []
|
||||
condition = Condition() # 线程同步器
|
||||
|
||||
def onOcrEnd(recentResult):
|
||||
nonlocal isOcrEnd, resList
|
||||
isOcrEnd = True
|
||||
resList = recentResult
|
||||
with condition: # 释放线程阻塞
|
||||
condition.notify()
|
||||
|
||||
PubSubService.subscribe("<<ScreenshotOcrEnd>>", onOcrEnd)
|
||||
|
||||
# 3. 调用截图标签页的函数
|
||||
if ss: # 截图
|
||||
if not paras: # 无参数,手动截图
|
||||
self.call("ScreenshotOCR", "qml", "screenshot", False)
|
||||
else: # 有参数,自动截图 umi-ocr --screenshot screen=0 rect=0,100,500,200
|
||||
rect = [0, 0, 0, 0] # 截图矩形框
|
||||
screen = 0 # 显示器编号
|
||||
para_args = []
|
||||
try:
|
||||
for para in paras: # 空格分隔
|
||||
para_args.extend(para.split())
|
||||
for part in para_args:
|
||||
if part.startswith("rect="):
|
||||
rect_values = part[len("rect=") :].split(",")
|
||||
rect_values = [int(v) for v in rect_values]
|
||||
rect[: len(rect_values)] = rect_values # 补齐rect的值
|
||||
elif part.startswith("screen="):
|
||||
screen = int(part[len("screen=") :])
|
||||
self.call(
|
||||
"ScreenshotOCR", "qml", "autoScreenshot", False, rect, screen
|
||||
)
|
||||
except Exception as e:
|
||||
return f"[Error] {e}"
|
||||
elif clip: # 粘贴
|
||||
self.call("ScreenshotOCR", "qml", "paste", False)
|
||||
else: # 路径
|
||||
if not paras:
|
||||
return "[Error] Paths is empty."
|
||||
paths = findFiles(paras, "image", True) # 递归搜索
|
||||
if not paths:
|
||||
return "[Error] No valid path."
|
||||
self.call("ScreenshotOCR", "qml", "ocrPaths", False, paths)
|
||||
|
||||
# 4. 堵塞等待任务完成,注销事件订阅
|
||||
with condition:
|
||||
while not isOcrEnd:
|
||||
condition.wait()
|
||||
PubSubService.unsubscribe("<<ScreenshotOcrEnd>>", onOcrEnd)
|
||||
|
||||
# 5. 处理结果列表,转文本
|
||||
text = ""
|
||||
for i, r in enumerate(resList): # 遍历图片
|
||||
if text and not text.endswith("\n"): # 如果上次结果结尾没有换行,则补换行
|
||||
text += "\n"
|
||||
if r["code"] == 100:
|
||||
for d in r["data"]: # 遍历文本块
|
||||
text += d["text"] + d["end"]
|
||||
elif r["code"] != 101 and isinstance(r["data"], str):
|
||||
text += r["data"]
|
||||
if not text:
|
||||
text = "[Message] No text in OCR result."
|
||||
return text
|
||||
|
||||
# 创建二维码
|
||||
def qrcode_create(self, paras):
|
||||
if len(paras) < 2:
|
||||
return (
|
||||
'[Error] Not enough arguments passed! Must pass "text" "save_image.jpg"'
|
||||
)
|
||||
text, path = paras[0], paras[1]
|
||||
if len(paras) == 3:
|
||||
w = h = int(paras[2])
|
||||
elif len(paras) == 4:
|
||||
w, h = int(paras[2]), int(paras[3])
|
||||
else:
|
||||
w = h = 0
|
||||
try:
|
||||
from ..mission.mission_qrcode import MissionQRCode
|
||||
|
||||
pil = MissionQRCode.createImage(
|
||||
text,
|
||||
format="QRCode", # 格式
|
||||
w=w, # 宽高
|
||||
h=h,
|
||||
quiet_zone=-1, # 边缘宽度
|
||||
ec_level=-1, # 纠错等级
|
||||
)
|
||||
if isinstance(pil, str):
|
||||
return pil
|
||||
pil.save(path)
|
||||
return f"Successfully saved to {path}"
|
||||
except Exception as e:
|
||||
return f"[Error] {str(e)}"
|
||||
|
||||
# 识别二维码
|
||||
def qrcode_read(self, paras):
|
||||
if len(paras) < 1:
|
||||
return '[Error] Not enough arguments passed! Must pass "image_to_recognize.jpg"'
|
||||
try:
|
||||
from ..mission.mission_qrcode import MissionQRCode
|
||||
from PIL import Image
|
||||
except Exception as e:
|
||||
return f"[Error] {str(e)}"
|
||||
resText = ""
|
||||
paths = findFiles(paras, "image", True) # 递归搜索图片
|
||||
for index, path in enumerate(paths):
|
||||
if index != 0:
|
||||
resText += "\n"
|
||||
try:
|
||||
pil = Image.open(path)
|
||||
res = MissionQRCode.addMissionWait({}, [{"pil": pil}])
|
||||
res = res[0]["result"]
|
||||
if res["code"] == 100:
|
||||
t = ""
|
||||
for i, d in enumerate(res["data"]):
|
||||
if i != 0:
|
||||
t += "\n"
|
||||
t += d["text"]
|
||||
resText += t
|
||||
elif res["code"] == 101:
|
||||
resText += "No code in image."
|
||||
else:
|
||||
resText += f"[Error] Code: {res['code']}\nMessage: {res['data']}"
|
||||
except Exception as e:
|
||||
resText += f"[Error] {str(e)}"
|
||||
return resText
|
||||
|
||||
|
||||
CmdActuator = _Actuator()
|
||||
|
||||
|
||||
# 命令解析器
|
||||
class _Cmd:
|
||||
def __init__(self):
|
||||
self._parser = None
|
||||
|
||||
def init(self):
|
||||
if self._parser:
|
||||
return
|
||||
self._parser = argparse.ArgumentParser(prog="Umi-OCR")
|
||||
# 便捷指令
|
||||
self._parser.add_argument(
|
||||
"--show", action="store_true", help="Make the app appear in the foreground."
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--hide", action="store_true", help="Hide app in the background."
|
||||
)
|
||||
self._parser.add_argument("--quit", action="store_true", help="Quit app.")
|
||||
self._parser.add_argument(
|
||||
"--screenshot",
|
||||
action="store_true",
|
||||
help="Screenshot OCR and output the result.",
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--clipboard",
|
||||
action="store_true",
|
||||
help="Clipboard OCR and output the result.",
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--path",
|
||||
action="store_true",
|
||||
help="OCR the image in path and output the result.",
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--qrcode_create",
|
||||
action="store_true",
|
||||
help='Create a QR code from the text. Use --qrcode_create "text" "save_image.jpg"',
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--qrcode_read",
|
||||
action="store_true",
|
||||
help='Read the QR code. Use --qrcode_read "image_to_recognize.jpg"',
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--reload",
|
||||
action="store_true",
|
||||
help='Reload settings from the configuration file ".settings"',
|
||||
)
|
||||
# 页面管理
|
||||
self._parser.add_argument(
|
||||
"--all_pages",
|
||||
action="store_true",
|
||||
help="Output all template and page information.",
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--add_page", type=int, help="usage: Umi-OCR --all_pages"
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--del_page", type=int, help="usage: Umi-OCR --all_pages"
|
||||
)
|
||||
# 函数调用
|
||||
self._parser.add_argument(
|
||||
"--all_modules",
|
||||
action="store_true",
|
||||
help="Output all module names that can be called.",
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--call_py", help="Calling a function on a Python module."
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--call_qml", help="Calling a function on a Qml module."
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--func", help="The name of the function to be called."
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--thread",
|
||||
action="store_true",
|
||||
help="The function will be called on the child thread and return the result, but it may be unstable or cause QML to crash.",
|
||||
)
|
||||
# 输出
|
||||
self._parser.add_argument(
|
||||
"--clip",
|
||||
action="store_true",
|
||||
help="Copy the results to the clipboard.",
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--output",
|
||||
help="The path to the file where results will be saved. (overwrite)",
|
||||
)
|
||||
self._parser.add_argument(
|
||||
"--output_append",
|
||||
help="The path to the file where results will be saved. (append)",
|
||||
)
|
||||
self._parser.add_argument("-->", help='"-->" equivalent to "--output"')
|
||||
self._parser.add_argument("-->>", help='"-->>" equivalent to "--output_append"')
|
||||
self._parser.add_argument("paras", nargs="*", help="parameters of [--func].")
|
||||
|
||||
# 分析指令,返回指令对象或报错字符串
|
||||
def parse(self, argv):
|
||||
self.init()
|
||||
# 特殊情况
|
||||
if "-h" in argv or "--help" in argv: # 帮助
|
||||
return self._parser.format_help()
|
||||
if len(argv) == 0: # 空指令
|
||||
CmdActuator.ctrlWindow(True, False, False) # 展示主窗
|
||||
return self._parser.format_help() # 返回帮助
|
||||
# 正常解析
|
||||
try:
|
||||
return self._parser.parse_args(argv)
|
||||
except SystemExit as e:
|
||||
return f"Your argv: {argv}\n[Error]: {e}\nusage: Umi-OCR --help"
|
||||
except Exception as e:
|
||||
return f"Your argv: {argv}\n[Error]: {e}\nusage: Umi-OCR --help"
|
||||
|
||||
# 执行指令,返回执行结果字符串
|
||||
def execute(self, argv):
|
||||
args = self.parse(argv)
|
||||
if isinstance(args, str):
|
||||
return args
|
||||
if args.all_modules:
|
||||
return CmdActuator.getModulesHelp()
|
||||
# 便捷指令
|
||||
if args.show or args.hide or args.quit: # 控制主窗
|
||||
return CmdActuator.ctrlWindow(args.show, args.hide, args.quit)
|
||||
if args.screenshot or args.clipboard or args.path: # 快捷识图
|
||||
return CmdActuator.quick_ocr(args.screenshot, args.clipboard, args.paras)
|
||||
if args.qrcode_create: # 写二维码
|
||||
return CmdActuator.qrcode_create(args.paras)
|
||||
if args.qrcode_read: # 读二维码
|
||||
return CmdActuator.qrcode_read(args.paras)
|
||||
if args.reload: # 重新加载配置
|
||||
PubSubService.publish("<<settingsReload>>")
|
||||
return "Settings reload."
|
||||
# 页面管理
|
||||
if args.all_pages:
|
||||
return CmdActuator.getAllPages()
|
||||
if not args.add_page is None:
|
||||
return CmdActuator.addPage(args.add_page)
|
||||
if not args.del_page is None:
|
||||
return CmdActuator.delPage(args.del_page)
|
||||
# 动态模块调用
|
||||
if args.call_py:
|
||||
if args.func:
|
||||
return CmdActuator.call(
|
||||
args.call_py,
|
||||
"py",
|
||||
args.func,
|
||||
args.thread,
|
||||
*self.format_paras(args.paras),
|
||||
)
|
||||
else:
|
||||
return CmdActuator.getModuleFuncsHelp(args.call_py, "py")
|
||||
if args.call_qml:
|
||||
if args.func:
|
||||
return CmdActuator.call(
|
||||
args.call_qml,
|
||||
"qml",
|
||||
args.func,
|
||||
args.thread,
|
||||
*self.format_paras(args.paras),
|
||||
)
|
||||
else:
|
||||
return CmdActuator.getModuleFuncsHelp(args.call_qml, "qml")
|
||||
|
||||
# paras 格式化
|
||||
def format_paras(self, paras):
|
||||
def convert_param(param):
|
||||
try:
|
||||
return int(param)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(param)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return json.loads(param)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return param
|
||||
|
||||
return [convert_param(p) for p in paras]
|
||||
|
||||
|
||||
CmdServer = _Cmd()
|
||||
@@ -0,0 +1,580 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import time
|
||||
import shutil
|
||||
import zipfile
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
from PySide2.QtCore import QMutex
|
||||
from typing import Dict
|
||||
|
||||
from umi_log import logger
|
||||
from call_func import CallFunc
|
||||
from .bottle import request, static_file, HTTPError
|
||||
from .ocr_server import get_ocr_options
|
||||
from ..ocr.output import Output
|
||||
from ..mission.mission_doc import MissionDOC, DocSuf
|
||||
from ..utils.utils import initConfigDict
|
||||
from ..ocr.output.tools import getDataText
|
||||
|
||||
UPLOAD_DIR = "./temp_doc" # 上传文件临时目录
|
||||
TEMP_FILE_RETENTION_DURATION = 24 # 任务临时文件保留时长,小时
|
||||
TEMP_FILE_CLEANUP_INTERVAL = 0.5 # 自动清理临时文件的间隔,小时
|
||||
|
||||
|
||||
# 获取参数模板字典
|
||||
def get_doc_options():
|
||||
opts = get_ocr_options(is_format=False)
|
||||
opts["tbpu.ignoreRangeStart"] = {
|
||||
"title": "忽略区域起始",
|
||||
"toolTip": "忽略区域生效的页数范围起始。从1开始。",
|
||||
"default": 1,
|
||||
"isInt": True,
|
||||
}
|
||||
opts["tbpu.ignoreRangeEnd"] = {
|
||||
"title": "忽略区域结束",
|
||||
"toolTip": "忽略区域生效的页数范围结束。可以用负数表示倒数第X页。",
|
||||
"default": -1,
|
||||
"isInt": True,
|
||||
}
|
||||
opts["pageRangeStart"] = {
|
||||
"title": "OCR页数起始",
|
||||
"toolTip": "OCR的页数范围起始。从1开始。",
|
||||
"default": 1,
|
||||
"isInt": True,
|
||||
}
|
||||
opts["pageRangeEnd"] = {
|
||||
"title": "OCR页数结束",
|
||||
"toolTip": "OCR的页数范围结束。可以用负数表示倒数第X页。",
|
||||
"default": -1,
|
||||
"isInt": True,
|
||||
}
|
||||
opts["pageList"] = {
|
||||
"title": "OCR页数列表",
|
||||
"toolTip": "数组,可指定单个或多个页数。例:[1,2,5]表示对第1、2、5页进行OCR。如果与页数范围同时填写,则 pageList 优先。",
|
||||
"default": [],
|
||||
"type": "var",
|
||||
}
|
||||
opts["password"] = {
|
||||
"title": "密码",
|
||||
"toolTip": "如果文档已加密,则填写文档密码。",
|
||||
"default": "",
|
||||
}
|
||||
opts["doc.extractionMode"] = {
|
||||
"title": "内容提取模式",
|
||||
"toolTip": "若一页文档既存在图片又存在文本,如何进行处理。",
|
||||
"optionsList": [
|
||||
["mixed", "混合OCR/原文本"],
|
||||
["fullPage", "整页强制OCR"],
|
||||
["imageOnly", "仅OCR图片"],
|
||||
["textOnly", "仅拷贝原有文本"],
|
||||
],
|
||||
}
|
||||
opts = initConfigDict(opts) # 格式化
|
||||
return opts
|
||||
|
||||
|
||||
UPLOAD_DIR = os.path.abspath(UPLOAD_DIR) # 路径转绝对
|
||||
TEMP_FILE_RETENTION_DURATION *= 3600 # 小时转为秒
|
||||
TEMP_FILE_CLEANUP_INTERVAL *= 3600
|
||||
|
||||
|
||||
# 异常类
|
||||
class DocUnitError(Exception):
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
# 单个任务单元
|
||||
class _DocUnit:
|
||||
def __init__(
|
||||
self, dir_id, dir_path, origin_path, origin_name, origin_prefix, options
|
||||
):
|
||||
# 提取文档信息
|
||||
doc_info = MissionDOC.getDocInfo(origin_path)
|
||||
if "error" in doc_info.keys():
|
||||
raise DocUnitError({"code": 201, "data": doc_info["error"]})
|
||||
|
||||
# 补充缺失的默认参数
|
||||
default = get_doc_options()
|
||||
for key in default:
|
||||
if key not in options:
|
||||
options[key] = default[key]["default"]
|
||||
|
||||
# 提取参数
|
||||
page_range = [options["pageRangeStart"], options["pageRangeEnd"]] # 识别范围
|
||||
page_list = options["pageList"] # 页数列表
|
||||
if page_list: # 下标起始由1转为0
|
||||
page_list = [x - 1 for x in page_list]
|
||||
password = options["password"] # 密码
|
||||
if not password and doc_info["is_encrypted"]:
|
||||
raise DocUnitError(
|
||||
{
|
||||
"code": 202,
|
||||
"data": "The doc is encrypted, please fill in the password.",
|
||||
}
|
||||
)
|
||||
|
||||
# 从 options 中提取一些条目,组装 docArgd 作为 MissionDoc 任务参数字典
|
||||
prefixes = ["ocr.", "doc.", "tbpu."] # 要提取的条目前缀
|
||||
doc_argd = {}
|
||||
for k, v in options.items():
|
||||
for prefix in prefixes:
|
||||
if k.startswith(prefix):
|
||||
doc_argd[k] = v
|
||||
break
|
||||
|
||||
# 任务信息
|
||||
msnInfo = {
|
||||
"onStart": self._onStart,
|
||||
"onGet": self._onGet,
|
||||
"onEnd": self._onEnd,
|
||||
"argd": doc_argd,
|
||||
}
|
||||
|
||||
# 提交任务
|
||||
self.msnID = ""
|
||||
msg = MissionDOC.addMission(
|
||||
msnInfo, origin_path, page_range, page_list, password
|
||||
)
|
||||
if not msg:
|
||||
raise DocUnitError({"code": 203, "data": "addMission unknow."})
|
||||
if msg.startswith("["):
|
||||
raise DocUnitError({"code": 204, "data": msg})
|
||||
page_list = msnInfo["pageList"]
|
||||
|
||||
self.password = password
|
||||
self.dir_id = dir_id
|
||||
self.dir_path = dir_path
|
||||
self.origin_prefix = origin_prefix
|
||||
self.origin_name = origin_name
|
||||
self.origin_path = origin_path
|
||||
self.msnID = msg # 任务ID
|
||||
self.results = {} # 任务结果原始字典,键为页数
|
||||
self.pages_count = len(page_list) # 任务总页数
|
||||
self.processed_count = 0 # 已处理的页数
|
||||
self.unread_list = [] # 未读的任务列表
|
||||
self.is_done = False # 当前任务是否完成
|
||||
self.state = "waiting" # 任务状态, waiting running success failure
|
||||
self.message = "" # 如果任务失败,则记录失败信息
|
||||
self.start_timestamp = time.time() # 开始时间戳
|
||||
self.end_timestamp = time.time() # 任务结束的时间戳
|
||||
self._mutex = QMutex() # 主锁
|
||||
|
||||
# ========================= 【接口】 =========================
|
||||
|
||||
# 获取结果
|
||||
def get_result(
|
||||
self,
|
||||
is_data=False, # True 时返回识别内容data
|
||||
format="dict", # 识别内容格式, "dict", "text"
|
||||
is_unread=False, # True 时只返回未读过的识别内容
|
||||
):
|
||||
self._mutex.lock()
|
||||
data = {
|
||||
"code": 100,
|
||||
"processed_count": self.processed_count, # 已处理的数量
|
||||
"pages_count": self.pages_count, # 总页数
|
||||
"is_done": self.is_done, # 是否已结束
|
||||
"state": self.state, # 任务状态
|
||||
"data": [], # 结果
|
||||
}
|
||||
if self.state == "failure":
|
||||
data["message"] = self.message
|
||||
# 需要返回识别内容
|
||||
if is_data:
|
||||
datas = []
|
||||
# 增量式
|
||||
if is_unread:
|
||||
for page in self.unread_list:
|
||||
datas.append(self.results[page])
|
||||
self.unread_list = []
|
||||
# 全量式
|
||||
else:
|
||||
for _, res in self.results.items():
|
||||
datas.append(res)
|
||||
# 需要转为纯文本
|
||||
if format == "text":
|
||||
datas_text = ""
|
||||
for res in datas:
|
||||
if res["code"] == 100:
|
||||
datas_text += getDataText(res["data"])
|
||||
datas = datas_text
|
||||
data["data"] = datas
|
||||
self._mutex.unlock()
|
||||
return data
|
||||
|
||||
# 获取文件
|
||||
def get_files(
|
||||
self,
|
||||
base_url, # 下载基础url
|
||||
file_types=["pdfLayered"], # 输出文件类型,可选:
|
||||
# txt, txtPlain, jsonl, csv, pdfLayered, pdfOneLayer
|
||||
ignore_blank=True, # 忽略空白页数
|
||||
):
|
||||
if not self.is_done:
|
||||
return {"code": 201, "data": f"{self.msnID} 任务尚未结束,无法获取文件"}
|
||||
if not self.state == "success":
|
||||
return {"code": 201, "data": f"{self.msnID} 任务处理失败,无法获取文件"}
|
||||
if not isinstance(file_types, list) or not isinstance(ignore_blank, bool):
|
||||
return {
|
||||
"code": 202,
|
||||
"data": f"参数类型错误: file_types={file_types} , ignore_blank={ignore_blank}",
|
||||
}
|
||||
|
||||
# 删除旧的文件
|
||||
for filename in os.listdir(self.dir_path):
|
||||
file_path = os.path.join(self.dir_path, filename)
|
||||
if filename != self.origin_name and os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
# 准备参数
|
||||
startDatetime = time.strftime( # 日期时间字符串(标准格式)
|
||||
r"%Y-%m-%d %H:%M:%S", time.localtime(self.start_timestamp)
|
||||
)
|
||||
outputArgd = {
|
||||
"outputDir": self.dir_path, # 输出路径
|
||||
"outputDirType": "specify",
|
||||
"outputFileName": "[OCR]_" + self.origin_prefix, # 输出文件名(前缀)
|
||||
"startDatetime": startDatetime, # 开始日期
|
||||
"ignoreBlank": ignore_blank, # 忽略空白页数
|
||||
"originPath": self.origin_path, # 原始文件
|
||||
"password": self.password, # 文档密码
|
||||
}
|
||||
|
||||
# 创建输出器
|
||||
output = []
|
||||
try:
|
||||
for f in file_types:
|
||||
output.append(Output[f](outputArgd))
|
||||
except Exception as e:
|
||||
return {"code": 203, "data": f"初始化输出器失败。{e}"}
|
||||
|
||||
# 输出
|
||||
for o in output:
|
||||
for _, res in self.results.items():
|
||||
try:
|
||||
o.print(res)
|
||||
except Exception as e:
|
||||
return {"code": 204, "data": f"输出失败:{o}\n{e}"}
|
||||
try:
|
||||
o.onEnd() # 保存
|
||||
except Exception as e:
|
||||
return {"code": 205, "data": f"保存失败:{o}\n{e}"}
|
||||
|
||||
# 收集新的文件
|
||||
download_paths = []
|
||||
for filename in os.listdir(self.dir_path):
|
||||
file_path = os.path.join(self.dir_path, filename)
|
||||
if filename != self.origin_name and os.path.isfile(file_path):
|
||||
download_paths.append(file_path)
|
||||
# 如果文件多,则打包zip
|
||||
if not download_paths:
|
||||
return {"code": 206, "data": "未找到生成的文件"}
|
||||
elif len(download_paths) == 1:
|
||||
download_name = os.path.basename(download_paths[0])
|
||||
else:
|
||||
download_name = f"[OCR]_{self.origin_prefix}.zip"
|
||||
zip_path = os.path.join(self.dir_path, download_name)
|
||||
# 将 download_list 中的所有文件打包为 zip
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "w") as zipf:
|
||||
for p in download_paths:
|
||||
zipf.write(p, os.path.basename(p))
|
||||
except Exception as e:
|
||||
return {"code": 207, "data": f"无法打包zip:{e}"}
|
||||
|
||||
# 组合下载地址
|
||||
url = f"{base_url}/api/doc/download/{self.dir_id}/{download_name}"
|
||||
|
||||
return {"code": 100, "data": url, "name": download_name}
|
||||
|
||||
# 清理任务
|
||||
def clear(self):
|
||||
# 停止任务
|
||||
if not self.is_done:
|
||||
MissionDOC.stopMissionList([self.msnID])
|
||||
time.sleep(0.1) # 给一些时间收尾
|
||||
# 尝试删除目录。如果权限原因无法删除,说明OCR线程还在占用,等待OCR结束。
|
||||
for i in range(20):
|
||||
try:
|
||||
if os.path.exists(self.dir_path):
|
||||
shutil.rmtree(self.dir_path)
|
||||
except PermissionError:
|
||||
time.sleep(1)
|
||||
|
||||
# ========================= 【任务控制器的异步回调】 =========================
|
||||
|
||||
def _onStart(self, msnInfo): # 一个文档 开始
|
||||
self.state = "running"
|
||||
|
||||
def _onGet(self, msnInfo, page, res): # 一个文档的一页 获取结果
|
||||
page += 1
|
||||
res["page"] = page
|
||||
res["path"] = f"{self.origin_name} - {page}"
|
||||
res["fileName"] = f"{self.origin_name} - {page}"
|
||||
|
||||
# 记录信息
|
||||
self._mutex.lock()
|
||||
self.results[page] = res
|
||||
self.processed_count += 1
|
||||
self.unread_list.append(page)
|
||||
self._mutex.unlock()
|
||||
|
||||
def _onEnd(self, msnInfo, msg): # 一个文档处理完毕
|
||||
# msg: [Success] [Warning] [Error]
|
||||
|
||||
# 记录信息
|
||||
self._mutex.lock()
|
||||
self.is_done = True
|
||||
if msg == "[Success]":
|
||||
self.state = "success"
|
||||
else:
|
||||
self.state = "failure"
|
||||
self.message = msg
|
||||
self.end_timestamp = time.time() # 刷新结束时间戳
|
||||
self._mutex.unlock()
|
||||
|
||||
|
||||
# 管理所有任务单元
|
||||
class _DocUnitManagerClass:
|
||||
def __init__(self):
|
||||
self.doc_units: Dict[str, _DocUnit] = {}
|
||||
|
||||
# 添加一个任务单元
|
||||
def add(self, id: str, unit: _DocUnit):
|
||||
self.doc_units[id] = unit
|
||||
|
||||
# 获取一个任务单元
|
||||
def get(self, id: str):
|
||||
if id not in self.doc_units:
|
||||
return None
|
||||
return self.doc_units[id]
|
||||
|
||||
# 手动清理一个任务
|
||||
def clear(self, id: str):
|
||||
if id in self.doc_units:
|
||||
self.doc_units[id].clear()
|
||||
del self.doc_units[id]
|
||||
return True
|
||||
return False
|
||||
|
||||
# 自动清理
|
||||
def auto_clear(self):
|
||||
# 清理超时的任务和文件
|
||||
if self.doc_units:
|
||||
now = time.time() # 当前时间戳
|
||||
del_list = [] # 要清理的id
|
||||
for id, unit in self.doc_units.items():
|
||||
if now - unit.end_timestamp > TEMP_FILE_RETENTION_DURATION:
|
||||
logger.info(f"超时自动清理 {id}")
|
||||
unit.clear() # 清理文件
|
||||
del_list.append(id)
|
||||
for id in del_list:
|
||||
del self.doc_units[id] # 清理任务对象
|
||||
# 计划下一次清理
|
||||
CallFunc.delay(self.auto_clear, TEMP_FILE_CLEANUP_INTERVAL)
|
||||
|
||||
|
||||
_DocUnitManager = _DocUnitManagerClass()
|
||||
|
||||
|
||||
# 路由函数
|
||||
def init(UmiWeb):
|
||||
# 清空上传文件目录内容
|
||||
if os.path.exists(UPLOAD_DIR):
|
||||
shutil.rmtree(UPLOAD_DIR)
|
||||
os.makedirs(UPLOAD_DIR)
|
||||
# 启动自动清理循环
|
||||
_DocUnitManager.auto_clear()
|
||||
|
||||
@UmiWeb.route("/api/doc/get_options")
|
||||
def _get_options_json():
|
||||
opts = get_doc_options()
|
||||
res = json.dumps(opts)
|
||||
return res
|
||||
|
||||
"""
|
||||
上传文档,方法:POST
|
||||
参数:文档内容
|
||||
返回值:
|
||||
成功: {"code": 100, "data": "任务id"}
|
||||
失败: {"code": 不是100的值, "data": "失败原因"}
|
||||
"""
|
||||
|
||||
@UmiWeb.route("/api/doc/upload", method="POST")
|
||||
def _upload():
|
||||
# 1. 获取上传文件
|
||||
upload = request.files.get("file")
|
||||
if not upload:
|
||||
return {"code": 101, "data": "[Error] No file was uploaded."}
|
||||
|
||||
# 2. 获取文件名,检查文件后缀
|
||||
# origin_name = upload.filename
|
||||
# 将原始文件名转为合法文件名
|
||||
def filename_convert(raw_filename: str):
|
||||
# 去除前后的空格
|
||||
raw_filename = raw_filename.strip()
|
||||
# 定义非法字符
|
||||
illegal_chars = r'[\/:*?"<>|]'
|
||||
# 替换非法字符
|
||||
sanitized_filename = re.sub(illegal_chars, "_", raw_filename)
|
||||
# 限制文件名长度为255个字符
|
||||
max_length = 255
|
||||
if len(sanitized_filename) > max_length:
|
||||
sanitized_filename = sanitized_filename[:max_length]
|
||||
return sanitized_filename
|
||||
|
||||
try:
|
||||
origin_name = filename_convert(upload.raw_filename)
|
||||
origin_prefix, ext = os.path.splitext(origin_name)
|
||||
ext = ext.lower()
|
||||
except Exception as e:
|
||||
return {"code": 102, "data": f"[Error] Unable to obtain raw_filename: {e}"}
|
||||
if ext not in DocSuf:
|
||||
return {
|
||||
"code": 103,
|
||||
"data": f"[Error] File extension '{ext}' is not allowed.",
|
||||
}
|
||||
|
||||
# 3. 指定文件编号。创建对应目录,保存文件到 ./temp/dir_id/原文件名
|
||||
dir_id = str(uuid4())
|
||||
dir_path = os.path.join(UPLOAD_DIR, f"{dir_id}")
|
||||
dir_path = os.path.abspath(dir_path) # 将路径转为绝对路径
|
||||
file_path = os.path.join(dir_path, origin_name)
|
||||
# 安全检测: file_path 是否在 UPLOAD_DIR 中
|
||||
if os.path.commonpath([UPLOAD_DIR]) != os.path.commonpath(
|
||||
[UPLOAD_DIR, file_path]
|
||||
):
|
||||
return {"code": 104, "data": f"[Error] Unauthorized path"}
|
||||
|
||||
try:
|
||||
if os.path.exists(dir_path): # 如果目录存在,则删除该目录
|
||||
shutil.rmtree(dir_path)
|
||||
os.makedirs(dir_path) # 重新创建目录
|
||||
except Exception as e:
|
||||
return {"code": 105, "data": f"[Error] Failed to create dir_id: {e}"}
|
||||
try:
|
||||
upload.save(file_path, overwrite=True) # 保存文件
|
||||
except Exception as e:
|
||||
return {"code": 106, "data": f"[Error] Failed to save file: {e}"}
|
||||
|
||||
# 4. 提取 options 参数
|
||||
options = request.forms.get("json")
|
||||
if options:
|
||||
try:
|
||||
options = json.loads(options)
|
||||
except Exception as e:
|
||||
shutil.rmtree(dir_path)
|
||||
return {
|
||||
"code": 107,
|
||||
"data": f"[Error] Invalid JSON format: {options} | {e}",
|
||||
}
|
||||
|
||||
# 检查忽略区域参数是否存在, 若存在则补全坐标 (兼容桌面程序)
|
||||
if "tbpu.ignoreArea" in options:
|
||||
ignore_area = list()
|
||||
for area in options["tbpu.ignoreArea"]:
|
||||
ignore_area.append([
|
||||
[area[0][0], area[0][1]], # 左上角
|
||||
[area[1][0], area[0][1]], # 右上角
|
||||
[area[1][0], area[1][1]], # 右下角
|
||||
[area[0][0], area[1][1]], # 左下角
|
||||
])
|
||||
options["tbpu.ignoreArea"] = ignore_area
|
||||
|
||||
if not isinstance(options, dict):
|
||||
options = {}
|
||||
|
||||
# 5. 构造任务对象
|
||||
try:
|
||||
doc_unit = _DocUnit(
|
||||
dir_id, dir_path, file_path, origin_name, origin_prefix, options
|
||||
)
|
||||
msnID = doc_unit.msnID
|
||||
_DocUnitManager.add(msnID, doc_unit)
|
||||
logger.info(f"添加 HTTP 文档任务: {origin_name}")
|
||||
return {"code": 100, "data": msnID}
|
||||
except DocUnitError as e:
|
||||
shutil.rmtree(dir_path)
|
||||
return e.data
|
||||
except Exception as e:
|
||||
shutil.rmtree(dir_path)
|
||||
return {"code": 108, "data": f"[Error] Failed to submit mission: {e}"}
|
||||
|
||||
"""
|
||||
获取结果,方法:POST
|
||||
json参数:
|
||||
"id"="", # 任务ID
|
||||
"is_data"=False, # True 时返回识别内容data
|
||||
"format"="dict", # 识别内容格式, "dict", "text"
|
||||
"is_unread"=False, # True 时只返回未读过的识别内容
|
||||
|
||||
返回值: {}
|
||||
"""
|
||||
|
||||
@UmiWeb.route("/api/doc/result", method="POST")
|
||||
def _result():
|
||||
try:
|
||||
user_data = request.json
|
||||
except Exception as e:
|
||||
return {"code": 101, "data": f"请求无法解析为json。"}
|
||||
if not user_data or "id" not in user_data:
|
||||
return {"code": 102, "data": f"未填写id。"}
|
||||
msnID = user_data["id"]
|
||||
doc_unit = _DocUnitManager.get(msnID)
|
||||
if not doc_unit:
|
||||
return {"code": 103, "data": f"任务 {msnID} 不存在。"}
|
||||
is_data = user_data.get("is_data", False)
|
||||
format = user_data.get("format", "dict")
|
||||
is_unread = user_data.get("is_unread", True)
|
||||
return doc_unit.get_result(is_data, format, is_unread)
|
||||
|
||||
"""
|
||||
获取文件,方法:POST
|
||||
json参数:
|
||||
"id"="", # 任务ID
|
||||
"file_types"=["pdfLayered"], # 输出文件类型,可选:
|
||||
# ["txt", "txtPlain", "jsonl", "csv", "pdfLayered", "pdfOneLayer"]
|
||||
"ignore_blank"=True, # 忽略空白页数
|
||||
|
||||
返回值: {}
|
||||
"""
|
||||
|
||||
@UmiWeb.route("/api/doc/download", method="POST")
|
||||
def _download_build():
|
||||
try:
|
||||
user_data = request.json
|
||||
except Exception as e:
|
||||
return {"code": 101, "data": f"请求无法解析为json。"}
|
||||
if not user_data or "id" not in user_data:
|
||||
return {"code": 102, "data": f"未填写id。"}
|
||||
msnID = user_data["id"]
|
||||
doc_unit = _DocUnitManager.get(msnID)
|
||||
if not doc_unit:
|
||||
return {"code": 103, "data": f"任务 {msnID} 不存在。"}
|
||||
|
||||
file_types = user_data.get("file_types", ["pdfLayered"])
|
||||
ignore_blank = user_data.get("ignore_blank", True)
|
||||
parsed_url = urlparse(request.url)
|
||||
base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
|
||||
return doc_unit.get_files(base_url, file_types, ignore_blank)
|
||||
|
||||
# 下载文件
|
||||
@UmiWeb.route("/api/doc/download/<id>/<download_name>")
|
||||
def _download_get(id, download_name):
|
||||
dir = os.path.join(UPLOAD_DIR, id)
|
||||
path = os.path.join(dir, download_name)
|
||||
# 安全检测: path 是否在 UPLOAD_DIR 中
|
||||
if os.path.commonpath([UPLOAD_DIR]) != os.path.commonpath([UPLOAD_DIR, path]):
|
||||
raise HTTPError(103, "[Error] Unauthorized path.")
|
||||
return static_file(download_name, root=dir)
|
||||
|
||||
# 清理任务
|
||||
@UmiWeb.route("/api/doc/clear/<id>")
|
||||
def _clear(id):
|
||||
flag = _DocUnitManager.clear(id)
|
||||
if flag:
|
||||
return {"code": 100, "data": "Success"}
|
||||
return {"code": 101, "data": f"{id} does not exist."}
|
||||
@@ -0,0 +1,176 @@
|
||||
import json
|
||||
|
||||
from .bottle import request
|
||||
from ..mission.mission_ocr import MissionOCR
|
||||
from ..utils.utils import initConfigDict
|
||||
from ..ocr.output.tools import getDataText
|
||||
|
||||
|
||||
# 获取ocr配置字典。 is_format=False 时不含 format 选项。
|
||||
def get_ocr_options(is_format=True):
|
||||
opts = {}
|
||||
# OCR 的参数
|
||||
ocr_opts = MissionOCR.getLocalOptions()
|
||||
for key in ocr_opts:
|
||||
opts[f"ocr.{key}"] = ocr_opts[key]
|
||||
# 排版解析的参数
|
||||
opts["tbpu.parser"] = {
|
||||
"title": "排版解析方案",
|
||||
"toolTip": "按什么方式,解析和排序图片中的文字块",
|
||||
"default": "multi_para",
|
||||
"optionsList": [
|
||||
["multi_para", "多栏-按自然段换行"],
|
||||
["multi_line", "多栏-总是换行"],
|
||||
["multi_none", "多栏-无换行"],
|
||||
["single_para", "单栏-按自然段换行"],
|
||||
["single_line", "单栏-总是换行"],
|
||||
["single_none", "单栏-无换行"],
|
||||
["single_code", "单栏-保留缩进"],
|
||||
["none", "不做处理"],
|
||||
],
|
||||
}
|
||||
# 忽略区域
|
||||
opts["tbpu.ignoreArea"] = {
|
||||
"title": "忽略区域",
|
||||
"toolTip": "数组,每一项为[[左上角x,y],[右下角x,y]]。",
|
||||
"default": [],
|
||||
"type": "var",
|
||||
}
|
||||
# 输出格式
|
||||
if is_format:
|
||||
opts["data.format"] = {
|
||||
"title": "数据返回格式",
|
||||
"toolTip": '返回值字典中,["data"] 按什么格式表示OCR结果数据',
|
||||
"default": "dict",
|
||||
"optionsList": [
|
||||
["dict", "含有位置等信息的原始字典"],
|
||||
["text", "纯文本"],
|
||||
],
|
||||
}
|
||||
opts = initConfigDict(opts) # 格式化
|
||||
return opts
|
||||
|
||||
|
||||
# 检查ocr参数字典,返回修改后字典
|
||||
def check_ocr_options(opts):
|
||||
# 检查忽略区域参数
|
||||
if opts["tbpu.ignoreArea"]:
|
||||
new_ia = []
|
||||
ia = opts["tbpu.ignoreArea"]
|
||||
for a in ia:
|
||||
if (
|
||||
not isinstance(a, list)
|
||||
or len(a) != 2
|
||||
or not isinstance(a[0], list)
|
||||
or len(a[0]) != 2
|
||||
or not isinstance(a[1], list)
|
||||
or len(a[1]) != 2
|
||||
or not all(
|
||||
isinstance(x, (int, float))
|
||||
for x in [a[0][0], a[0][1], a[1][0], a[1][1]]
|
||||
)
|
||||
):
|
||||
raise Exception(
|
||||
f"tbpu.ignoreArea 中,每一项的格式必须是 [[x1,y1],[x2,y2]] 。当前值不合法: {ia}"
|
||||
)
|
||||
new_ia.append([[a[0][0], a[0][1]], [], [a[1][0], a[1][1]], []])
|
||||
opts["tbpu.ignoreArea"] = new_ia
|
||||
return opts
|
||||
|
||||
|
||||
# 路由函数
|
||||
def init(UmiWeb):
|
||||
@UmiWeb.route("/api/ocr/get_options")
|
||||
def _get_options_json():
|
||||
opts = get_ocr_options()
|
||||
res = json.dumps(opts)
|
||||
return res
|
||||
|
||||
"""
|
||||
执行OCR,方法:POST
|
||||
参数:
|
||||
"base64": "", # 必填
|
||||
"options": {}, # 选填,内容与 _get_options 的对应。
|
||||
"""
|
||||
|
||||
@UmiWeb.route("/api/ocr", method="POST")
|
||||
def _ocr():
|
||||
try:
|
||||
data = request.json
|
||||
except Exception as e:
|
||||
return json.dumps({"code": 800, "data": f"请求无法解析为json。 {e}"})
|
||||
if not data:
|
||||
return json.dumps({"code": 801, "data": "请求为空。"})
|
||||
if "base64" not in data:
|
||||
return json.dumps({"code": 802, "data": "请求中缺少 base64 字段。"})
|
||||
if "options" not in data:
|
||||
data["options"] = {}
|
||||
elif not isinstance(data["options"], dict):
|
||||
return json.dumps({"code": 803, "data": "请求中 options 字段必须为字典。"})
|
||||
try:
|
||||
# 补充缺失的默认参数
|
||||
opt = data["options"]
|
||||
default = get_ocr_options()
|
||||
for key in default:
|
||||
if key not in opt:
|
||||
opt[key] = default[key]["default"]
|
||||
# 检查OCR参数
|
||||
check_ocr_options(opt)
|
||||
except Exception as e:
|
||||
return json.dumps({"code": 804, "data": f"options 解释失败。 {e}"})
|
||||
# 同步执行
|
||||
resList = MissionOCR.addMissionWait(opt, {"base64": data["base64"]})
|
||||
res = resList[0]["result"]
|
||||
if opt["data.format"] == "text": # 转纯文本
|
||||
if res["code"] == 100:
|
||||
res["data"] = getDataText(res["data"])
|
||||
res = json.dumps(res)
|
||||
return res
|
||||
|
||||
|
||||
"""
|
||||
const url = "http://127.0.0.1:1224/api/ocr";
|
||||
const data = {
|
||||
// 必填
|
||||
"base64": "iVBORw0KGgoAAAANSUhEUgAAAC4AAAAXCAIAAAD7ruoFAAAACXBIWXMAABnWAAAZ1gEY0crtAAAAEXRFWHRTb2Z0d2FyZQBTbmlwYXN0ZV0Xzt0AAAHjSURBVEiJ7ZYrcsMwEEBXnR7FLuj0BPIJHJOi0DAZ2qSsMCxEgjYrDQqJdALrBJ2ASndRgeNI8ledutOCLrLl1e7T/mRkjIG/IXe/DWBldRTNEoQSpgNURe5puiiaJehrMuJSXSTgbaby0A1WzLrCCQCmyn0FwoN0V06QONWAt1nUxfnjHYA8p65GjhDKxcjedVH6JOejBPwYh21eE0Wzfe0tqIsEkGXcVcpoMH4CRZ+P0lsQp/pWJ4ripf1XFDFe8GHSHlYcSo9Es31t60RdFlN1RUmrma5oTzTVB8ZUaeeYEC9GmL6kNkDw9BANAQYo3xTNdqUkvHq+rYhDKW0Bj3RSEIpmyWyBaZaMTCrCK+tJ5Jsa07fs3E7esE66HzralRLgJKp0/BD6fJRSxvmDsb6joqkcFXGqMVVFFEHDL2gTxwCAaTabnkFUWhDCHTd9iYrGcAL1ZnqIp5Vpiqh7bCfua7FA4qN0INMcN1+cgCzj+UFxtbmvwdZvGIrI41JiqhZBWhhF8WxorkYPpQwJiWYJeA3rXE4hzcwJ+B96F9zCFHC0FcVegghvFul7oeEE8PvHeJqC0w0AUbbFIT8JnEwGbPKcS2OxU3HMTqD0r4wgEIuiKJ7i4MS16+og8/+bPZRPLa+6Ld2DSzcAAAAASUVORK5CYII=",
|
||||
"options": {
|
||||
"ocr.angle": false,
|
||||
"ocr.language": "简体中文",
|
||||
"ocr.maxSideLen": 1024,
|
||||
"tbpu.parser": "multi_para",
|
||||
"data.format": "text",
|
||||
}
|
||||
};
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
|
||||
|
||||
const url = "http://127.0.0.1:1224/api/ocr/get_options";
|
||||
fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
console.log(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
"""
|
||||
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
import base64
|
||||
from io import BytesIO
|
||||
|
||||
from .bottle import request
|
||||
from ..mission.mission_qrcode import MissionQRCode
|
||||
|
||||
|
||||
# 从base64识别二维码图片。传入data指令字典 {"base64", "options"}
|
||||
# 返回字典 {"code", "data"}
|
||||
def base2text(data):
|
||||
base64 = data["base64"]
|
||||
opt = data.get("options", {})
|
||||
res = MissionQRCode.addMissionWait(opt, [{"base64": base64}])
|
||||
return res[0]["result"]
|
||||
|
||||
|
||||
# 从文本生成base64。传入data指令字典 {"text", "xxx"}
|
||||
# 返回 {"code", "data"}
|
||||
def text2base(data):
|
||||
text = data["text"]
|
||||
opt = data.get("options", {})
|
||||
format = opt.get("format", "QRCode")
|
||||
w = opt.get("w", 0)
|
||||
h = opt.get("h", 0)
|
||||
quiet_zone = opt.get("quiet_zone", -1)
|
||||
ec_level = opt.get("ec_level", -1)
|
||||
try:
|
||||
pil = MissionQRCode.createImage(text, format, w, h, quiet_zone, ec_level)
|
||||
buffered = BytesIO()
|
||||
pil.save(buffered, format="JPEG")
|
||||
b64 = base64.b64encode(buffered.getvalue()).decode("ascii")
|
||||
res = {"code": 100, "data": b64}
|
||||
except Exception as e:
|
||||
res = {"code": 200, "data": f"[Error] {str(e)}"}
|
||||
return res
|
||||
|
||||
|
||||
# 路由函数
|
||||
def init(UmiWeb):
|
||||
|
||||
@UmiWeb.route("/api/qrcode", method="POST")
|
||||
def _qrcode():
|
||||
try:
|
||||
data = request.json
|
||||
except Exception as e:
|
||||
return json.dumps({"code": 800, "data": f"请求无法解析为json。"})
|
||||
if not data:
|
||||
return json.dumps({"code": 801, "data": f"请求为空。"})
|
||||
|
||||
if "base64" in data:
|
||||
return json.dumps(base2text(data))
|
||||
elif "text" in data:
|
||||
return json.dumps(text2base(data))
|
||||
return json.dumps({"code": 802, "data": '指令中不存在 "base64" 或 "text"'})
|
||||
|
||||
|
||||
"""
|
||||
// 文本 → 二维码base64
|
||||
const url = "http://127.0.0.1:1224/api/qrcode";
|
||||
const text = "测试文本";
|
||||
const data = {
|
||||
// 必填
|
||||
"text": text,
|
||||
// 选填
|
||||
"options": {
|
||||
"format": "QRCode", // 码类型
|
||||
"w": 0, // 图像宽度,0为自动设为最小宽度
|
||||
"h": 0, // 图像高度
|
||||
"quiet_zone": -1, // 码四周的空白边缘宽度,-1为自动
|
||||
"ec_level": -1, // 纠错等级,-1为自动
|
||||
}
|
||||
};
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.code === 100) {
|
||||
console.log("生成二维码base64:", data.data);
|
||||
}
|
||||
else {
|
||||
console.log("生成二维码base64失败!错误码:", data.code, " 错误信息:", data.data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
|
||||
// 二维码base64 → 文本
|
||||
const url = "http://127.0.0.1:1224/api/qrcode";
|
||||
const base64 = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAAdAB0BAREA/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/9oACAEBAAA/APU/GfjM+EjAzW9o0DW8txNPdXEkSxKkkMYAEcUjMS069hjBrn3+K0yi3B0/RozO52y3OtG3gaPy7WRWV5IVJO27DFSoIEbYycCrF18Sb2z1a20u70rTbO8uLiKzigutRl3NcNDBIyAxW7rhTcIu4sAcE8Cu00LU/wC2/D2mat5Pk/brSK58rdu2b0Dbc4GcZxnAri/iSdPGs6AuqySW+nzpcW11dg27xwIzQspkimikDIZUiG/5QhK5PzCuPI1qz8ISalajUtNu1czLGsxnt7tHhhhiijNmkSF22W8aFeFWZ2RjIjeVXvrq0t/EWmaTpq3d9rTXFpCqpa2iRW92sCJOUP2WZYjEsNszrG7Bd/GNhr2zQtP/ALI8PaZpuMfY7SK3x5nmY2IF+9tXd067Vz6DpXH/ABK1LVrN7SLTIr6622k159isYYnknkjuLVUI8yGXGzzWfhc5UHPFeeSyav4dtI9R8O+Ho5dYS4WNrSK1EV2sb29ncFJY7aOPzIkkYhjhSGaME7WdHy72y8NWthbfDxrrfDDdpdXH2eVvtIu/IcStcOUaCGFMqGKNKUELZDEsU+g/DUcMXhXSI7cRrAllCsYjIKhQgxgh3BGP9t/95upk1PQtH1vyv7W0qxv/ACc+X9rt0l2ZxnG4HGcDp6Co7Xw1oNiipaaJptuiPvVYbVEAbcjZGB13RxnPqin+EYksdC0fTIo4rDSrG0jjlM6JBbpGFkKlC4AHDFSVz1wcdKuQQQ2tvFb28UcMESBI441CqigYAAHAAHGK/9k="
|
||||
const data = { "base64": base64 };
|
||||
|
||||
fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if(data.code === 100) {
|
||||
console.log("识别二维码成功!图片中的二维码数量:", data.data.length);
|
||||
console.log("所有码的内容:");
|
||||
for (let d of data.data) {
|
||||
console.log(" 文本:", d.text);
|
||||
console.log(" 格式:", d.format);
|
||||
console.log(" 方向:", d.orientation);
|
||||
console.log(" ====");
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log("识别二维码失败!错误码:", data.code, " 错误信息:", data.data);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(error);
|
||||
});
|
||||
"""
|
||||
@@ -0,0 +1,163 @@
|
||||
# =========================================================
|
||||
# ======= Web服务器 ========
|
||||
# ======= http接口可复用于跨进程命令行、防止多开等方面 ========
|
||||
# =========================================================
|
||||
|
||||
from PySide2.QtCore import QThreadPool, QRunnable
|
||||
from wsgiref.simple_server import make_server, WSGIServer
|
||||
|
||||
from umi_log import logger
|
||||
from ..utils import pre_configs
|
||||
from ..utils.call_func import CallFunc
|
||||
from .bottle import Bottle, ServerAdapter, request, HTTPResponse, response, BaseRequest
|
||||
from .cmd_server import CmdServer
|
||||
from . import ocr_server
|
||||
from . import qrcode_server
|
||||
from . import doc_server
|
||||
|
||||
BaseRequest.MEMFILE_MAX = 104857600 # 设置单次请求大小上限: 100 MB
|
||||
|
||||
UmiWeb = Bottle()
|
||||
Host = "127.0.0.1" # 由qml设置
|
||||
|
||||
|
||||
# 允许跨域
|
||||
@UmiWeb.hook("before_request")
|
||||
def _validate_before():
|
||||
re_method = request.environ.get("REQUEST_METHOD")
|
||||
hacrm = request.environ.get("HTTP_ACCESS_CONTROL_REQUEST_METHOD")
|
||||
if re_method == "OPTIONS" and hacrm:
|
||||
request.environ["REQUEST_METHOD"] = hacrm
|
||||
|
||||
|
||||
@UmiWeb.hook("after_request")
|
||||
def _validate_after():
|
||||
response.headers["Access-Control-Allow-Origin"] = "*"
|
||||
response.headers["Access-Control-Allow-Headers"] = "*"
|
||||
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, OPTIONS"
|
||||
|
||||
|
||||
# ============================== 基础路由 ==============================
|
||||
|
||||
|
||||
@UmiWeb.route("/")
|
||||
@UmiWeb.route("/umiocr")
|
||||
def _umiocr():
|
||||
from umi_about import UmiAbout # 项目信息
|
||||
|
||||
return UmiAbout["fullname"]
|
||||
|
||||
|
||||
# 跨进程接收命令行参数
|
||||
@UmiWeb.route("/argv", method="POST")
|
||||
def _argv():
|
||||
addr = request.environ.get("REMOTE_ADDR")
|
||||
if addr == "127.0.0.1":
|
||||
data = request.json
|
||||
res = CmdServer.execute(data)
|
||||
return res
|
||||
else:
|
||||
msg = "Unauthorized access. Only local requests are allowed.\n此接口只允许本机访问。"
|
||||
return HTTPResponse(msg, status=401)
|
||||
|
||||
|
||||
ocr_server.init(UmiWeb)
|
||||
qrcode_server.init(UmiWeb)
|
||||
doc_server.init(UmiWeb)
|
||||
|
||||
# =============== 自定义服务器适配器,方便控制服务终止 ==============================
|
||||
QmlCallback = None # qml回调函数
|
||||
|
||||
|
||||
class _WSGIRefServer(ServerAdapter):
|
||||
# https://stackoverflow.com/questions/11282218/bottle-web-framework-how-to-stop
|
||||
|
||||
class CustomWSGIServer(WSGIServer): # 定制服务器
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.activeConnections = set() # 当前活跃连接
|
||||
|
||||
def process_request(self, request, client_address):
|
||||
# 记录活跃的连接
|
||||
self.activeConnections.add(request)
|
||||
super().process_request(request, client_address)
|
||||
|
||||
def close_all_request(self): # 关闭所有活跃的连接
|
||||
import socket
|
||||
|
||||
for request in self.activeConnections:
|
||||
try:
|
||||
request.shutdown(socket.SHUT_RDWR)
|
||||
request.close()
|
||||
logger.info(f"强制关闭连接。 request: {request}")
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.error(
|
||||
f"强制连接异常。 request: {request}",
|
||||
exc_info=True,
|
||||
stack_info=True,
|
||||
)
|
||||
|
||||
def run(self, handler):
|
||||
import atexit # 退出处理
|
||||
|
||||
atexit.register(self.stop) # 注册程序终止时停止线程
|
||||
self.port = pre_configs.getValue("server_port") # 提取记录的端口号
|
||||
self.host = Host
|
||||
# 找到一个可用的端口号
|
||||
while True:
|
||||
try:
|
||||
self.server = make_server(
|
||||
self.host,
|
||||
self.port,
|
||||
handler,
|
||||
server_class=self.CustomWSGIServer,
|
||||
**self.options,
|
||||
)
|
||||
break
|
||||
except OSError: # 当前端口号已占用,测试下一位端口号
|
||||
logger.warning(f"服务器端口号 {self.port} 已被占用")
|
||||
self.port += 1
|
||||
if self.port > 65535:
|
||||
self.port = 1024
|
||||
pre_configs.setValue("server_port", self.port) # 写入记录
|
||||
|
||||
logger.info(f"Listening on http://{self.host}:{self.port}")
|
||||
print(f"Listening on http://{self.host}:{self.port}")
|
||||
CallFunc.now(QmlCallback, self.port) # 在主线程中调用回调函数,告知实际端口号
|
||||
self.server.serve_forever()
|
||||
|
||||
def stop(self): # 服务终止
|
||||
# self.server.server_close() # 备选方案,但会导致 bad fd 异常
|
||||
logger.debug("WEB服务器准备关闭!")
|
||||
self.server.close_all_request() # 强制关闭客户端连接
|
||||
self.server.shutdown() # 关闭服务器
|
||||
logger.info("WEB服务器已关闭!")
|
||||
|
||||
|
||||
# ============================== 线程类 ==============================
|
||||
class _WorkerClass(QRunnable):
|
||||
def run(self):
|
||||
self._server = _WSGIRefServer()
|
||||
UmiWeb.run(server=self._server)
|
||||
|
||||
|
||||
_Worker = _WorkerClass()
|
||||
|
||||
|
||||
# ============================== 控制接口 ==============================
|
||||
|
||||
|
||||
# 启动web服务。传入qml对象,回调函数名,主机地址
|
||||
def runUmiWeb(qmlObj, callback, host):
|
||||
global QmlCallback, Host
|
||||
Host = host
|
||||
QmlCallback = getattr(qmlObj, callback, None) # 提取qml回调函数
|
||||
threadPool = QThreadPool.globalInstance() # 获取全局线程池
|
||||
threadPool.start(_Worker) # 启动服务器线程
|
||||
|
||||
|
||||
# 切换端口号(下次启动生效)
|
||||
def setPort(port):
|
||||
pre_configs.setValue("server_port", port)
|
||||
@@ -0,0 +1,230 @@
|
||||
# ========================================
|
||||
# =============== 批量文档页 ===============
|
||||
# ========================================
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from umi_log import logger
|
||||
from .page import Page # 页基类
|
||||
from ..mission.mission_doc import MissionDOC # 任务管理器
|
||||
from ..utils import utils
|
||||
from ..ocr.output import Output
|
||||
|
||||
|
||||
class BatchDOC(Page):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self._msnID = "" # 当前正在进行的任务ID(已提交至任务处理器)
|
||||
self._pauseMsnID = "" # 当前正在暂停的任务ID
|
||||
self._queuedDocs = [] # 当前正在排队的文档信息(未提交)
|
||||
self._argd = None
|
||||
self._docArgd = None
|
||||
|
||||
# 进行任务。
|
||||
# docs为列表,每一项为: {path:文档路径, range_start:范围起始, range_end: 范围结束, page_count:总页数, password:密码}
|
||||
# 返回一个列表,每项为: {path:文档路径, msnID:任务ID。若[Error]开头则为失败。}
|
||||
def msnDocs(self, docs, argd):
|
||||
if self._msnID or self._queuedDocs:
|
||||
return "[Error] 有任务进行中,不允许提交新任务。"
|
||||
|
||||
# 从 argd 中提取一些条目,组装 docArgd
|
||||
prefixes = ["ocr.", "doc.", "tbpu."] # 要提取的条目前缀
|
||||
docArgd = {}
|
||||
for k, v in argd.items():
|
||||
for prefix in prefixes:
|
||||
if k.startswith(prefix):
|
||||
docArgd[k] = v
|
||||
break
|
||||
|
||||
# 记录任务参数
|
||||
self._queuedDocs = docs
|
||||
self._argd = argd
|
||||
self._docArgd = docArgd
|
||||
# 提交第一个任务
|
||||
self._runNewDoc()
|
||||
|
||||
def msnStop(self): # 停止当前所有任务
|
||||
MissionDOC.stopMissionList(self._msnID)
|
||||
self._msnID = ""
|
||||
self._queuedDocs = []
|
||||
self._argd = None
|
||||
self._docArgd = None
|
||||
|
||||
def msnPause(self): # 任务暂停
|
||||
self._pauseMsnID = self._msnID
|
||||
MissionDOC.pauseMissionList(self._msnID)
|
||||
|
||||
def msnResume(self): # 任务恢复
|
||||
self._pauseMsnID = ""
|
||||
MissionDOC.resumeMissionList(self._msnID)
|
||||
|
||||
# 初始化输出器列表。成功返回两个输出器列表 output 。失败返回 "失败信息"
|
||||
def _initOutputList(self, argd, path, pageRange, pageCount, password=""):
|
||||
# =============== 提取输出路径 outputDir, outputDirName ===============
|
||||
if argd["mission.dirType"] == "source": # 若保存到原目录
|
||||
outputDir = os.path.dirname(path) # 则保存路径设为文档的目录
|
||||
else: # 若保存到用户指定目录
|
||||
d = os.path.abspath(argd["mission.dir"]) # 转绝对地址
|
||||
if not os.path.exists(d): # 检查地址是否存在
|
||||
try: # 不存在,尝试创建地址
|
||||
os.makedirs(d)
|
||||
except OSError as e:
|
||||
return f"[Error] 无法创建路径 {d}"
|
||||
outputDir = d
|
||||
|
||||
# =============== 提取时间信息和文件名 outputFileName ===============
|
||||
startTimestamp = time.time() # 开始时间戳
|
||||
startDatetime = time.strftime( # 日期时间字符串(标准格式)
|
||||
r"%Y-%m-%d %H:%M:%S", time.localtime(startTimestamp)
|
||||
)
|
||||
# 日期时间字符串(用户指定格式):先替换时间戳,再strftime
|
||||
startDatetimeUser = argd["mission.datetimeFormat"].replace(
|
||||
r"%unix", str(startTimestamp)
|
||||
)
|
||||
startDatetimeUser = time.strftime(
|
||||
startDatetimeUser, time.localtime(startTimestamp)
|
||||
)
|
||||
# 替换时间 %date
|
||||
nameTemplate = argd["mission.fileNameFormat"]
|
||||
nameTemplate = nameTemplate.replace(r"%date", startDatetimeUser) # 替换时间
|
||||
# 替换范围 %range
|
||||
rangeStr = "" # 范围全本
|
||||
if pageRange[1] - pageRange[0] + 1 < pageCount: # 范围非全本
|
||||
if pageRange[1] == pageRange[0]: # 只识别一页,写入单个页数
|
||||
rangeStr = f"(p{pageRange[0]})"
|
||||
else: # 识别多页,写入开头和结尾页数
|
||||
rangeStr = f"(p{pageRange[0]}-{pageRange[1]})"
|
||||
nameTemplate = nameTemplate.replace(r"%range", rangeStr)
|
||||
# 替换文件名 #name
|
||||
fileNameStr = os.path.splitext(os.path.basename(path))[0]
|
||||
outputFileName = nameTemplate.replace(r"%name", fileNameStr) # 替换名称元素
|
||||
if not utils.allowedFileName(outputFileName): # 文件名不合法
|
||||
return f'[Error] 文件名【{outputFileName}】含有不允许的字符。\n不允许含有下列字符: \ / : * ? " < > |'
|
||||
|
||||
# =============== 组装输出参数字典 ===============
|
||||
outputArgd = {
|
||||
"outputDir": outputDir, # 输出路径
|
||||
"outputDirType": argd[
|
||||
"mission.dirType"
|
||||
], # 输出目录类型,"source" 为原文件目录
|
||||
"outputFileName": outputFileName, # 输出文件名(前缀)
|
||||
"startDatetime": startDatetime, # 开始日期
|
||||
"ignoreBlank": argd["mission.ignoreBlank"], # 忽略空白文件
|
||||
"originPath": path, # 原始文件名
|
||||
"password": password, # 文档密码
|
||||
}
|
||||
|
||||
# =============== 实例化输出器对象 ===============
|
||||
output = []
|
||||
try:
|
||||
for key in argd.keys():
|
||||
if "mission.filesType" in key and argd[key]:
|
||||
output.append(Output[key[18:]](outputArgd))
|
||||
except Exception as e:
|
||||
return f"[Error] 初始化输出器失败。{e}"
|
||||
return output
|
||||
|
||||
def _runNewDoc(self): # 取 self._queuedList 首位任务,提交执行
|
||||
if not self._queuedDocs:
|
||||
logger.warning("文档任务: queuedDocs 已空")
|
||||
return
|
||||
d = self._queuedDocs.pop(0) # 取首位任务
|
||||
path = d["path"] # 取地址
|
||||
password = d["password"] # 密码
|
||||
pageRange = [int(d["range_start"]), int(d["range_end"])] # 识别范围
|
||||
pageCount = int(d["page_count"]) # 总页数
|
||||
# 构造输出器
|
||||
output = self._initOutputList(self._argd, path, pageRange, pageCount, password)
|
||||
if isinstance(output, str): # 创建输出器失败
|
||||
self._onEnd({"path": path}, "[Error] 无法创建输出器。")
|
||||
return
|
||||
# 任务信息
|
||||
msnInfo = {
|
||||
"onStart": self._onStart,
|
||||
"onReady": self._onReady,
|
||||
"onGet": self._onGet,
|
||||
"onEnd": self._onEnd,
|
||||
"argd": self._docArgd,
|
||||
# 交给 self._onGet 的参数
|
||||
"get_output": output,
|
||||
}
|
||||
msnID = MissionDOC.addMission(msnInfo, path, pageRange, password=password)
|
||||
if msnID.startswith("["): # 添加任务失败
|
||||
self._msnID = ""
|
||||
self._onEnd({"path": path}, msnID)
|
||||
else:
|
||||
self._msnID = msnID
|
||||
|
||||
# ========================= 【任务控制器的异步回调】 =========================
|
||||
|
||||
def _onStart(self, msnInfo): # 一个文档 开始
|
||||
msnID = msnInfo["msnID"]
|
||||
if not msnID == self._msnID:
|
||||
logger.warning(f"_onStart 任务ID未在记录。{msnID}")
|
||||
return
|
||||
self.callQmlInMain("onDocStart", msnInfo["path"])
|
||||
|
||||
def _onReady(self, msnInfo, page): # 一个文档的一页 准备开始
|
||||
page += 1
|
||||
|
||||
def _onGet(self, msnInfo, page, res): # 一个文档的一页 获取结果
|
||||
page += 1
|
||||
msnID = msnInfo["msnID"]
|
||||
if not msnID == self._msnID:
|
||||
logger.warning(f"_onGet 任务ID未在记录。{msnID}")
|
||||
return
|
||||
|
||||
# 为 res 添加信息
|
||||
res["page"] = page
|
||||
res["fileName"] = f"{page}"
|
||||
res["path"] = msnInfo["path"]
|
||||
|
||||
# 输出
|
||||
for o in msnInfo["get_output"]:
|
||||
try:
|
||||
o.print(res)
|
||||
except Exception:
|
||||
logger.error(f"文档结果输出失败:{o}", exc_info=True, stack_info=True)
|
||||
|
||||
self.callQmlInMain("onDocGet", msnInfo["path"], page, res)
|
||||
|
||||
def _onEnd(self, msnInfo, msg): # 一个文档处理完毕
|
||||
# msg: [Success] [Warning] [Error]
|
||||
|
||||
if "path" not in msnInfo:
|
||||
raise Exception('[Error] BatchDOC onEnd(): "path" not in msnInfo')
|
||||
|
||||
msnID = "" # 该任务的ID
|
||||
if "msnID" in msnInfo:
|
||||
msnID = msnInfo["msnID"]
|
||||
if not msnID == self._msnID:
|
||||
logger.warning(f"_onEnd 任务ID未在记录。{msnID}")
|
||||
return
|
||||
|
||||
# 结束输出器,保存文件。
|
||||
if "get_output" in msnInfo:
|
||||
output = msnInfo["get_output"]
|
||||
for o in output:
|
||||
try:
|
||||
o.onEnd()
|
||||
except Exception as e:
|
||||
msg = f"[Error] 输出器异常:{e}" + msg
|
||||
|
||||
# 上报
|
||||
isAll = False if self._queuedDocs else True # 是否所有文档处理完毕
|
||||
self.callQmlInMain("onDocEnd", msnInfo["path"], msg, isAll)
|
||||
|
||||
# 所有任务完毕
|
||||
if isAll:
|
||||
self._msnID = ""
|
||||
self._argd = None
|
||||
self._docArgd = None
|
||||
# 还有排队中的任务,则提交新任务
|
||||
else:
|
||||
self._runNewDoc()
|
||||
# 如果新任务 self._msnID 已提交成功,且上一个任务 msnID 处于暂停状态,
|
||||
# 也就是在保存文件的过程中,用户点了暂停,
|
||||
# 那么将新的任务设为暂停。
|
||||
if self._msnID and msnID and self._pauseMsnID == msnID:
|
||||
self.msnPause()
|
||||
@@ -0,0 +1,180 @@
|
||||
# ========================================
|
||||
# =============== 批量OCR页 ===============
|
||||
# ========================================
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
from umi_log import logger
|
||||
from .page import Page # 页基类
|
||||
from ..mission.mission_ocr import MissionOCR # 任务管理器
|
||||
from ..utils.utils import allowedFileName
|
||||
from ..ocr.output import Output # 输出器
|
||||
|
||||
|
||||
class BatchOCR(Page):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self.argd = None
|
||||
self.msnID = ""
|
||||
self.outputList = [] # 输出器列表
|
||||
|
||||
# ========================= 【qml调用python】 =========================
|
||||
|
||||
def msnPaths(self, paths, argd): # 接收路径列表和配置参数字典,开始OCR任务
|
||||
# 任务信息
|
||||
msnInfo = {
|
||||
"onStart": self._onStart,
|
||||
"onReady": self._onReady,
|
||||
"onGet": self._onGet,
|
||||
"onEnd": self._onEnd,
|
||||
"argd": argd,
|
||||
}
|
||||
# 预处理参数字典
|
||||
if not self._preprocessArgd(argd, paths[0]):
|
||||
return ""
|
||||
# 构造输出器
|
||||
if not self._initOutputList(argd):
|
||||
return ""
|
||||
# 路径转为任务列表格式,加载进任务管理器
|
||||
msnList = [{"path": x} for x in paths]
|
||||
self.msnID = MissionOCR.addMissionList(msnInfo, msnList)
|
||||
if self.msnID.startswith("[Error]"): # 添加任务失败
|
||||
self._onEnd(None, f"{self.msnID}\n添加任务失败。")
|
||||
else: # 添加成功,通知前端刷新UI
|
||||
logger.debug(f"添加任务成功 {self.msnID}")
|
||||
return self.msnID
|
||||
|
||||
def _preprocessArgd(self, argd, path0): # 预处理参数字典,无异常返回True
|
||||
self.argd = None
|
||||
if argd["mission.dirType"] == "source": # 若保存到原目录
|
||||
# 则保存路径设为第1张图片的目录
|
||||
argd["mission.dir"] = os.path.dirname(path0)
|
||||
else: # 若保存到用户指定目录
|
||||
d = os.path.abspath(argd["mission.dir"]) # 转绝对地址
|
||||
if not os.path.exists(d): # 检查地址是否存在
|
||||
try: # 不存在,尝试创建地址
|
||||
os.makedirs(d)
|
||||
except OSError: # 创建地址失败,报错
|
||||
logger.warning(f"批量OCR无法创建目录: {d}", exc_info=True)
|
||||
self._onEnd(
|
||||
None,
|
||||
f'[Error] Failed to create directory: "{d}"\n【异常】无法创建目录。',
|
||||
)
|
||||
return False
|
||||
argd["mission.dir"] = d # 写回字典
|
||||
startTimestamp = time.time() # 开始时间戳
|
||||
argd["startTimestamp"] = startTimestamp
|
||||
# 格式化日期时间(标准格式)
|
||||
argd["startDatetime"] = time.strftime(
|
||||
r"%Y-%m-%d %H:%M:%S", time.localtime(startTimestamp)
|
||||
)
|
||||
# 添加格式化日期时间(用户指定格式):先替换时间戳,再strftime
|
||||
startDatetimeUser = argd["mission.datetimeFormat"].replace(
|
||||
r"%unix", str(startTimestamp)
|
||||
)
|
||||
startDatetimeUser = time.strftime(
|
||||
startDatetimeUser, time.localtime(startTimestamp)
|
||||
)
|
||||
# 处理文件名
|
||||
fileName = argd["mission.fileNameFormat"]
|
||||
fileName = fileName.replace(r"%date", startDatetimeUser) # 替换时间
|
||||
fileNameEle = os.path.basename(os.path.dirname(path0))
|
||||
fileName = fileName.replace("%name", fileNameEle) # 替换名称元素
|
||||
if not allowedFileName(fileName): # 文件名不合法
|
||||
self._onEnd(
|
||||
None,
|
||||
f'[Error] The file name is illegal.\n【错误】文件名【{fileName}】含有不允许的字符。\n不允许含有下列字符: \ / : * ? " < > |',
|
||||
)
|
||||
return False
|
||||
argd["mission.fileName"] = fileName # 回填文件名
|
||||
self.argd = argd
|
||||
return True
|
||||
|
||||
def _initOutputList(self, argd): # 初始化输出器列表,无异常返回True
|
||||
self.outputList = []
|
||||
outputArgd = { # 数据转换,封装有需要的值
|
||||
"outputDir": argd["mission.dir"], # 输出目录
|
||||
# 输出目录类型,"source" 为原文件目录
|
||||
"outputDirType": argd["mission.dirType"],
|
||||
"outputFileName": argd["mission.fileName"], # 输出文件名(前缀)
|
||||
"startDatetime": argd["startDatetime"], # 开始日期
|
||||
"ignoreBlank": argd["mission.ignoreBlank"], # 忽略空白文件
|
||||
}
|
||||
try:
|
||||
for key in argd.keys():
|
||||
if "mission.filesType" in key and argd[key]:
|
||||
self.outputList.append(Output[key[18:]](outputArgd))
|
||||
except Exception as e:
|
||||
self._onEnd(
|
||||
None,
|
||||
f"[Error] Failed to initialize output file.\n【错误】初始化输出文件失败。\n{e}",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def msnStop(self): # 任务停止
|
||||
MissionOCR.stopMissionList(self.msnID)
|
||||
|
||||
def msnPause(self): # 任务暂停
|
||||
MissionOCR.pauseMissionList(self.msnID)
|
||||
|
||||
def msnResume(self): # 任务恢复
|
||||
MissionOCR.resumeMissionList(self.msnID)
|
||||
|
||||
def msnPreview(self, path, argd): # 快速进行一次任务,主要用于预览
|
||||
msnInfo = {
|
||||
"onGet": self._onPreview,
|
||||
"argd": argd,
|
||||
}
|
||||
msnList = [{"path": path}]
|
||||
self.msnID = MissionOCR.addMissionList(msnInfo, msnList)
|
||||
|
||||
# ========================= 【任务控制器的异步回调】 =========================
|
||||
|
||||
def _onStart(self, msnInfo): # 任务队列开始
|
||||
pass
|
||||
|
||||
def _onReady(self, msnInfo, msn): # 单个任务准备
|
||||
msnID = msnInfo["msnID"]
|
||||
if msnID != self.msnID:
|
||||
logger.warning(f"_onReady 任务ID未在记录。{msnID}")
|
||||
return
|
||||
self.callQmlInMain("onOcrReady", msn["path"])
|
||||
|
||||
def _onGet(self, msnInfo, msn, res): # 单个任务完成
|
||||
msnID = msnInfo["msnID"]
|
||||
if msnID != self.msnID:
|
||||
logger.warning(f"_onGet 任务ID未在记录。{msnID}")
|
||||
return
|
||||
# 补充参数
|
||||
res["fileName"] = os.path.basename(msn["path"])
|
||||
res["dir"] = os.path.dirname(msn["path"])
|
||||
# 输出器输出
|
||||
for o in self.outputList:
|
||||
try:
|
||||
o.print(res)
|
||||
except Exception:
|
||||
logger.error(f"结果输出失败:{o}", exc_info=True, stack_info=True)
|
||||
# 通知qml更新UI
|
||||
self.callQmlInMain("onOcrGet", msn["path"], res) # 在主线程中调用qml
|
||||
|
||||
def _onEnd(self, msnInfo, msg): # 任务队列完成或失败
|
||||
if msnInfo:
|
||||
msnID = msnInfo["msnID"]
|
||||
if msnID != self.msnID:
|
||||
logger.warning(f"_onEnd 任务ID未在记录。{msnID}")
|
||||
return
|
||||
else:
|
||||
msnID = ""
|
||||
# 结束输出器,保存文件。
|
||||
for o in self.outputList:
|
||||
try:
|
||||
o.onEnd()
|
||||
except Exception as e:
|
||||
msg = f"[Error] 输出器异常:{e}" + msg
|
||||
# msg: [Success] [Warning] [Error]
|
||||
self.callQmlInMain("onOcrEnd", msg, msnID)
|
||||
|
||||
def _onPreview(self, msnInfo, msn, res):
|
||||
self.callQmlInMain("onPreview", msn["path"], res)
|
||||
@@ -0,0 +1,78 @@
|
||||
# =======================================
|
||||
# =============== 二维码页 ===============
|
||||
# =======================================
|
||||
|
||||
from .page import Page # 页基类
|
||||
from ..image_controller.image_provider import PixmapProvider # 图片提供器
|
||||
from ..mission.mission_qrcode import MissionQRCode
|
||||
|
||||
import os
|
||||
import time
|
||||
from PIL import Image, ImageEnhance, ImageFilter
|
||||
import base64
|
||||
|
||||
try:
|
||||
import zxingcpp
|
||||
except Exception as e:
|
||||
zxingcpp = None
|
||||
zxingcppErr = str(e)
|
||||
|
||||
|
||||
class QRCode(Page):
|
||||
# def __init__(self, *args):
|
||||
# super().__init__(*args)
|
||||
|
||||
# 对一个imgID进行扫码
|
||||
def scanImgID(self, imgID, configDict):
|
||||
msnInfo = {
|
||||
"onStart": self._onStart,
|
||||
"onReady": self._onReady,
|
||||
"onGet": self._onGet,
|
||||
"onEnd": self._onEnd,
|
||||
"argd": configDict,
|
||||
}
|
||||
msnList = [{"pil": PixmapProvider.getPilImage(imgID), "imgID": imgID}]
|
||||
MissionQRCode.addMissionList(msnInfo, msnList)
|
||||
|
||||
# 对一串path进行扫码
|
||||
def scanPaths(self, paths, configDict):
|
||||
msnInfo = {
|
||||
"onStart": self._onStart,
|
||||
"onReady": self._onReady,
|
||||
"onGet": self._onGet,
|
||||
"onEnd": self._onEnd,
|
||||
"argd": configDict,
|
||||
}
|
||||
msnList = [{"path": x} for x in paths]
|
||||
MissionQRCode.addMissionList(msnInfo, msnList)
|
||||
|
||||
# 生成二维码
|
||||
# format: "Aztec","Codabar","Code128","Code39","Code93","DataBar","DataBarExpanded","DataMatrix","EAN13","EAN8","ITF","LinearCodes","MatrixCodes","MaxiCode","MicroQRCode","PDF417","QRCode","UPCA","UPCE",
|
||||
# quiet_zone: 四周的空闲区域
|
||||
# ec_level:纠错等级,-1 - 自动, 1- L-7% , 0 - M-15%, 3 - Q-25%, 2 - H-30%
|
||||
# 纠错仅用于Aztec、PDF417和QRCode
|
||||
def writeBarcode(self, text, format, w=0, h=0, quiet_zone=-1, ec_level=-1):
|
||||
img = MissionQRCode.createImage(text, format, w, h, quiet_zone, ec_level)
|
||||
if isinstance(img, str):
|
||||
return img
|
||||
imgID = PixmapProvider.setPilImage(img)
|
||||
# 若 setPilImage 失败, imgID.startswith("[Error]")
|
||||
return imgID
|
||||
|
||||
# ========================= 【扫码处理】 =========================
|
||||
|
||||
def _onStart(self, msnInfo): # 任务队列开始
|
||||
self.callQmlInMain("setRunning", True)
|
||||
|
||||
def _onReady(self, msnInfo, msn): # 单个任务准备
|
||||
pass
|
||||
|
||||
def _onGet(self, msnInfo, msn, res): # 单个任务完成
|
||||
# 通知qml更新UI
|
||||
imgID = msn.get("imgID", "")
|
||||
imgPath = msn.get("path", "")
|
||||
self.callQmlInMain("onQRCodeGet", res, imgID, imgPath) # 在主线程中调用qml
|
||||
|
||||
def _onEnd(self, msnInfo, msg): # 任务队列完成或失败
|
||||
# msg: [Success] [Warning] [Error]
|
||||
self.callQmlInMain("setRunning", False)
|
||||
@@ -0,0 +1,124 @@
|
||||
# ========================================
|
||||
# =============== 截图OCR页 ===============
|
||||
# ========================================
|
||||
|
||||
from PySide2.QtGui import QClipboard # 截图 剪贴板
|
||||
|
||||
from umi_log import logger
|
||||
from .page import Page # 页基类
|
||||
from ..image_controller.image_provider import PixmapProvider # 图片提供器
|
||||
from ..mission.mission_ocr import MissionOCR # 任务管理器
|
||||
from ..event_bus.pubsub_service import PubSubService # 发布/订阅管理器
|
||||
|
||||
# 只要触发了截图/粘贴/图片识图任务,并结束任务(无论是否成功),都发送 <<ScreenshotOcrEnd>> 事件。
|
||||
|
||||
Clipboard = QClipboard() # 剪贴板
|
||||
|
||||
|
||||
class ScreenshotOCR(Page):
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self.msnDict = {}
|
||||
self.recentResult = [] # 缓存本轮任务的识别结果,提交给 <<ScreenshotOcrEnd>>
|
||||
|
||||
# ========================= 【qml调用python】 =========================
|
||||
|
||||
# 对一个imgID进行OCR
|
||||
def ocrImgID(self, imgID, configDict):
|
||||
self.recentResult = []
|
||||
if not imgID or not configDict: # 截图取消
|
||||
PubSubService.publish("<<ScreenshotOcrEnd>>", [])
|
||||
return
|
||||
if imgID.startswith("["): # 截图失败
|
||||
PubSubService.publish(
|
||||
"<<ScreenshotOcrEnd>>", [{"code": 301, "data": imgID}]
|
||||
)
|
||||
return
|
||||
pixmap = PixmapProvider.getPixmap(imgID)
|
||||
if not pixmap:
|
||||
logger.error(f'ScreenshotOCR: imgID "{imgID}" 不存在 PixmapProvider 中')
|
||||
return
|
||||
self._msnImage(pixmap, imgID, configDict) # 开始OCR
|
||||
|
||||
# 对一批路径进行OCR
|
||||
def ocrPaths(self, paths, configDict):
|
||||
self.recentResult = []
|
||||
self._msnPaths(paths, configDict)
|
||||
|
||||
# 停止全部任务
|
||||
def msnStop(self):
|
||||
self.callQml("setMsnState", "none")
|
||||
for i in self.msnDict:
|
||||
MissionOCR.stopMissionList(i)
|
||||
self.msnDict = {}
|
||||
PubSubService.publish("<<ScreenshotOcrEnd>>", self.recentResult)
|
||||
|
||||
# ========================= 【OCR 任务控制】 =========================
|
||||
|
||||
# 传入 QImage或QPixmap图片, 图片id, 配置字典。 提交OCR任务。
|
||||
def _msnImage(self, img, imgID, configDict):
|
||||
# 图片转字节,构造任务队列
|
||||
bytesData = PixmapProvider.toBytes(img)
|
||||
msnList = [{"bytes": bytesData, "imgID": imgID}]
|
||||
self._msn(msnList, configDict)
|
||||
|
||||
# 传入路径列表,提交OCR任务,返回图片缓存ID
|
||||
def _msnPaths(self, paths, configDict):
|
||||
msnList = [{"path": x} for x in paths]
|
||||
self._msn(msnList, configDict)
|
||||
|
||||
# 开始任务
|
||||
def _msn(self, msnList, configDict):
|
||||
# 任务信息
|
||||
msnInfo = {
|
||||
"onStart": self._onStart,
|
||||
"onReady": self._onReady,
|
||||
"onGet": self._onGet,
|
||||
"onEnd": self._onEnd,
|
||||
"argd": configDict,
|
||||
}
|
||||
msnID = MissionOCR.addMissionList(msnInfo, msnList)
|
||||
if msnID.startswith("[Error]"): # 添加任务失败
|
||||
self._onEnd(None, f"{self.msnID}\n添加任务失败。")
|
||||
else: # 添加成功
|
||||
self.msnDict[msnID] = None
|
||||
self.callQml("setMsnState", "run")
|
||||
|
||||
def _onStart(self, msnInfo): # 任务队列开始
|
||||
pass
|
||||
|
||||
def _onReady(self, msnInfo, msn): # 单个任务准备
|
||||
pass
|
||||
|
||||
def _onGet(self, msnInfo, msn, res): # 单个任务完成
|
||||
# 补充平均置信度
|
||||
score = 0
|
||||
num = 0
|
||||
if res["code"] == 100:
|
||||
for r in res["data"]:
|
||||
score += r["score"]
|
||||
num += 1
|
||||
if num > 0:
|
||||
score /= num
|
||||
res["score"] = score
|
||||
# 通知qml更新UI
|
||||
imgID = msn.get("imgID", "")
|
||||
imgPath = msn.get("path", "")
|
||||
self.recentResult.append(res) # 记录结果
|
||||
self.callQmlInMain("onOcrGet", res, imgID, imgPath) # 在主线程中调用qml
|
||||
|
||||
def _onEnd(self, msnInfo, msg): # 任务队列完成或失败
|
||||
# msg: [Success] [Warning] [Error]
|
||||
PubSubService.publish("<<ScreenshotOcrEnd>>", self.recentResult)
|
||||
|
||||
def update():
|
||||
# 清除任务id
|
||||
if msnInfo and msnInfo["msnID"] in self.msnDict:
|
||||
del self.msnDict[msnInfo["msnID"]]
|
||||
# 所有任务都完成了
|
||||
if not self.msnDict:
|
||||
# 停止前端显示
|
||||
self.callQml("setMsnState", "none")
|
||||
self.callQml("onOcrEnd", msg)
|
||||
|
||||
self.callFunc(update) # 在主线程中执行
|
||||
@@ -0,0 +1,36 @@
|
||||
# =============================================
|
||||
# =============== 页面控制器基类 ===============
|
||||
# =============================================
|
||||
|
||||
from PySide2.QtCore import QObject
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
|
||||
class Page(QObject):
|
||||
def __init__(self, ctrlKey, controller):
|
||||
super().__init__()
|
||||
self.ctrlKey = ctrlKey
|
||||
self.controller = controller
|
||||
logger.debug(f"py控制器 {self.ctrlKey} 实例化!")
|
||||
|
||||
def __del__(self):
|
||||
logger.debug(f"py控制器 {self.ctrlKey} 销毁!")
|
||||
|
||||
def callQml(self, funcName, *args): # python调用qml函数
|
||||
return self.controller.callQml(self.ctrlKey, funcName, *args)
|
||||
|
||||
def callQmlInMain(self, funcName, *args): # python调用qml函数,可在子线程调用
|
||||
self.callFunc(self.callQml, funcName, *args)
|
||||
|
||||
def callFunc(self, func, *args): # 在主线程中调用py函数
|
||||
return self.controller.callFunc(func, *args)
|
||||
|
||||
def getQmlValueDict(self): # python获取qml配置字典
|
||||
return self.controller.callQml(self.ctrlKey, "getValueDict").toVariant()
|
||||
|
||||
def getQmlOriginDict(self):
|
||||
return self.controller.callQml(self.ctrlKey, "getOriginDict").toVariant()
|
||||
|
||||
def setQmlValue(self, key, val): # python获取qml配置字典
|
||||
return self.controller.callQml(self.ctrlKey, "setValue", key, val).toVariant()
|
||||
@@ -0,0 +1,149 @@
|
||||
# ===========================================================================
|
||||
# =============== 【前端qml标签页】与【后端Python控制器】的连接器 ===============
|
||||
# ===========================================================================
|
||||
|
||||
"""
|
||||
每一个qml页面,都可以拥有一个对应的Python控制器实例。
|
||||
前端页面访问各种后端功能,必须靠这个控制器作为中转。
|
||||
"""
|
||||
|
||||
from PySide2.QtCore import QObject, Slot
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
# 导入本模块内定义的控制器类
|
||||
from .BatchOCR import BatchOCR
|
||||
from .BatchDOC import BatchDOC
|
||||
from .ScreenshotOCR import ScreenshotOCR
|
||||
from .QRCode import QRCode
|
||||
from ..utils.call_func import CallFunc
|
||||
|
||||
# 控制器类列表
|
||||
PageClass = [BatchOCR, ScreenshotOCR, QRCode, BatchDOC]
|
||||
|
||||
|
||||
TagPageConnObj = None # 记录实例
|
||||
# PySide2 没有 qmlRegisterSingletonType,PyQt5或者PySide6中才有。
|
||||
# 不过没关系,我们手动维护控制器的单例状态就是了
|
||||
|
||||
|
||||
# 页面连接器类(手动单例)
|
||||
class TagPageConnector(QObject):
|
||||
def __init__(self):
|
||||
global TagPageConnObj
|
||||
# 1. 检查是否单例
|
||||
if not TagPageConnObj == None:
|
||||
raise Exception("【Error】TagPageConnector只允许创建一个实例!")
|
||||
TagPageConnObj = self
|
||||
super().__init__()
|
||||
# 2. 收集所有已导入的控制器类,聚集为dict。 ["类名"]=类
|
||||
self.pageClass = {}
|
||||
for i in PageClass:
|
||||
self.pageClass[i.__name__] = i
|
||||
# 属性
|
||||
# 当前已实例化的控制器
|
||||
self.pages = {}
|
||||
self._keysNum = {} # 记录每个key被生成了多少次
|
||||
|
||||
# ========================= 【增删改查】 =========================
|
||||
|
||||
# 增: 新增一个class为key的控制器,返回这个控制器的标识符。失败返回空字符串
|
||||
@Slot(str, result=str)
|
||||
def addPage(self, key):
|
||||
if key not in self.pageClass:
|
||||
return ""
|
||||
ctrlKey = self._getCtrlKey(key)
|
||||
obj = self.pageClass[key](ctrlKey, self) # 实例化 页控制器对象
|
||||
self.pages[ctrlKey] = { # key为控制器id
|
||||
"pyObj": obj, # py对象
|
||||
"qmlObj": None, # qml对象
|
||||
"pyCache": {}, # py方法缓存
|
||||
"qmlCache": {}, # qml方法缓存
|
||||
}
|
||||
return ctrlKey
|
||||
|
||||
# 增: 新增一个不带控制器的简单页
|
||||
@Slot(str, result=str)
|
||||
def addSimplePage(self, key):
|
||||
ctrlKey = self._getCtrlKey(key)
|
||||
self.pages[ctrlKey] = {
|
||||
"pyObj": None,
|
||||
"qmlObj": None,
|
||||
"pyCache": {},
|
||||
"qmlCache": {},
|
||||
}
|
||||
return ctrlKey
|
||||
|
||||
# 增2: qml回调,设置标识符为ctrlKey的控制器,对应的qml页面对象
|
||||
@Slot(str, "QVariant")
|
||||
def setPageQmlObj(self, ctrlKey, qmlObj):
|
||||
if ctrlKey not in self.pages:
|
||||
return False
|
||||
self.pages[ctrlKey]["qmlObj"] = qmlObj
|
||||
|
||||
# 删: 删除标识符为ctrlKey的控制器。成功返回true
|
||||
@Slot(str, result=bool)
|
||||
def delPage(self, ctrlKey):
|
||||
if ctrlKey not in self.pages:
|
||||
return False
|
||||
del self.pages[ctrlKey]
|
||||
return True
|
||||
|
||||
# ========================= 【与qml的通信】 =========================
|
||||
|
||||
# qml调用Python的方法(同步)
|
||||
# qml调用ctrlKey的方法funcName,入参为列表(对应参数顺序),返回值为可变类型。
|
||||
@Slot(str, str, list, result="QVariant")
|
||||
def callPy(self, ctrlKey, funcName, args):
|
||||
if ctrlKey not in self.pages:
|
||||
logger.warning(f"调用py方法{funcName},但{ctrlKey}不存在!")
|
||||
return None
|
||||
page = self.pages[ctrlKey]
|
||||
# 获取方法的引用
|
||||
method = None
|
||||
if funcName in page["pyCache"]: # 缓存中存在,直接取缓存
|
||||
method = page["pyCache"][funcName]
|
||||
else: # 否则,搜索该方法,并写入缓存
|
||||
method = getattr(page["pyObj"], funcName, None)
|
||||
page["pyCache"][funcName] = method
|
||||
# 查询失败
|
||||
if not method:
|
||||
logger.error(f"调用了{ctrlKey}的不存在的py方法{funcName}!")
|
||||
return None
|
||||
# 调用方法,参数不对的话让系统抛出错误
|
||||
return method(*args)
|
||||
|
||||
# python调用qml的函数(同步)
|
||||
def callQml(self, ctrlKey, funcName, *args):
|
||||
if ctrlKey not in self.pages:
|
||||
logger.warning(f"调用qml方法{funcName},但{ctrlKey}不存在!")
|
||||
return None
|
||||
page = self.pages[ctrlKey]
|
||||
# 获取方法的引用
|
||||
method = None
|
||||
if funcName in page["qmlCache"]: # 缓存中存在,直接取缓存
|
||||
method = page["qmlCache"][funcName]
|
||||
else: # 否则,搜索该方法,并写入缓存
|
||||
method = getattr(page["qmlObj"], funcName, None)
|
||||
page["qmlCache"][funcName] = method
|
||||
# 查询失败
|
||||
if not method:
|
||||
logger.error(f"调用了{ctrlKey}的不存在的qml方法{funcName}!")
|
||||
return None
|
||||
# 调用方法,参数不对的话让系统抛出错误
|
||||
return method(*args)
|
||||
|
||||
# 在子线程中调用,到主线程中调用python函数
|
||||
def callFunc(self, func, *args):
|
||||
CallFunc.now(func, *args)
|
||||
|
||||
# ==================================================
|
||||
|
||||
# 生成一个ctrlKey
|
||||
def _getCtrlKey(self, key):
|
||||
if key not in self._keysNum:
|
||||
n = 1
|
||||
else:
|
||||
n = self._keysNum[key] + 1
|
||||
self._keysNum[key] = n
|
||||
return f"{key}_{n}"
|
||||
@@ -0,0 +1,64 @@
|
||||
# 软件渲染选项
|
||||
|
||||
from PySide2.QtGui import QGuiApplication, QOpenGLContext
|
||||
from PySide2.QtCore import Qt
|
||||
import os
|
||||
|
||||
from umi_log import logger
|
||||
from . import pre_configs
|
||||
from ..platform import Platform
|
||||
|
||||
_GLDict = {
|
||||
"AA_UseDesktopOpenGL": Qt.AA_UseDesktopOpenGL,
|
||||
"AA_UseOpenGLES": Qt.AA_UseOpenGLES,
|
||||
"AA_UseSoftwareOpenGL": Qt.AA_UseSoftwareOpenGL,
|
||||
}
|
||||
_Opt = ""
|
||||
|
||||
|
||||
def initOpengl():
|
||||
global _Opt
|
||||
opt = getOpengl()
|
||||
if opt not in _GLDict:
|
||||
opt = Platform.getOpenGLUse()
|
||||
setOpengl(opt)
|
||||
QGuiApplication.setAttribute(_GLDict[opt], True)
|
||||
_Opt = opt
|
||||
|
||||
|
||||
def checkOpengl():
|
||||
global _Opt
|
||||
if _Opt == "AA_UseOpenGLES": # GLES需要检查,有些win7不支持
|
||||
if not QOpenGLContext.openGLModuleType() == QOpenGLContext.LibGLES:
|
||||
QGuiApplication.setAttribute(Qt.AA_UseOpenGLES, False)
|
||||
_Opt = "AA_UseSoftwareOpenGL" # 既然不支持opengl,那就软渲染吧
|
||||
setOpengl(_Opt)
|
||||
msg = "当前系统不支持OpenGLES,已禁用此渲染器。\n若本次运行中程序崩溃或报错,请重新启动程序。\n\n"
|
||||
msg += "The current system does not support OpenGLES and has disabled the program from using this renderer. \nIf there are crashes or errors during this run, please restarting the program."
|
||||
logger.warning(msg)
|
||||
os.MessageBox(msg, type_="warning")
|
||||
|
||||
|
||||
def setOpengl(opt):
|
||||
if opt not in _GLDict:
|
||||
raise ValueError
|
||||
pre_configs.setValue("opengl", opt)
|
||||
|
||||
|
||||
def getOpengl():
|
||||
return pre_configs.getValue("opengl")
|
||||
|
||||
|
||||
# OpenGL渲染模式
|
||||
# 启用 OpenGL 上下文之间的资源共享
|
||||
# QGuiApplication.setAttribute(Qt.AA_ShareOpenGLContexts, True)
|
||||
# 渲染模式,【减少窗口调整大小时内容的抖动】
|
||||
# 方式一:启用OpenGL软件渲染。性能最差,CPU占用率大幅提升,效果最好。
|
||||
# QGuiApplication.setAttribute(Qt.AA_UseSoftwareOpenGL, True)
|
||||
# 方式二:使用 桌面 OpenGL(例如 opengl32.dll 或 libGL.so)。性能最好,效果较差。
|
||||
# QGuiApplication.setAttribute(Qt.AA_UseDesktopOpenGL, True)
|
||||
# 方式三:使用 OpenGL ES 2.0 或更高版本,用d3d接口抽象成Opengl。性能和效果都很好。但兼容性很差:
|
||||
# 1. ColorOverlay必须开启cache,否则无法渲染透明层。
|
||||
# 2. 需要系统安装dx9和OpenGL3。虚拟机中可能无法使用。需要检查兼容性!!!
|
||||
# 必须做兼容性判定,兼容时才启用AA_UseOpenGLES。
|
||||
# QGuiApplication.setAttribute(Qt.AA_UseOpenGLES, True)
|
||||
@@ -0,0 +1,63 @@
|
||||
# 提供在主线程中调用指定函数
|
||||
|
||||
from PySide2.QtCore import QObject, Slot, Signal, QTimer, QMutex
|
||||
from uuid import uuid4 # 唯一ID
|
||||
|
||||
|
||||
class __CallFunc(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# 信号 在主线程中调用函数
|
||||
self._callFuncSignal = self.cSignal()
|
||||
self._callFuncSignal.signal.connect(self._cFunc)
|
||||
# 计时器停止字典
|
||||
self._timerStopDict = {}
|
||||
self._timerLock = QMutex()
|
||||
|
||||
# ========================= 【接口】 =========================
|
||||
|
||||
# 立刻:在主线程中调用python函数
|
||||
def now(self, func, *args):
|
||||
self._callFuncSignal.signal.emit((func, args))
|
||||
|
||||
# 延时:在主线程中调用python函数。返回计时器ID
|
||||
def delay(self, func, time, *args):
|
||||
timerID = str(uuid4())
|
||||
|
||||
def go():
|
||||
timer = QTimer(self)
|
||||
timer.setSingleShot(True) # 单次运行
|
||||
timer.timeout.connect(lambda: self._timerFunc(timerID, func, args))
|
||||
timer.start(time * 1000)
|
||||
|
||||
self.now(go)
|
||||
return timerID
|
||||
|
||||
# 取消已启用的延时
|
||||
def delayStop(self, timerID):
|
||||
self._timerLock.lock()
|
||||
self._timerStopDict[timerID] = True # 记录停止
|
||||
self._timerLock.unlock()
|
||||
|
||||
# ==================================================
|
||||
# 计时器调用的函数
|
||||
def _timerFunc(self, timerID, func, args):
|
||||
self._timerLock.lock()
|
||||
if timerID in self._timerStopDict:
|
||||
del self._timerStopDict[timerID]
|
||||
self._timerLock.unlock()
|
||||
return
|
||||
self._timerLock.unlock()
|
||||
func(*args)
|
||||
|
||||
# 异步调用的槽函数
|
||||
@Slot("QVariant")
|
||||
def _cFunc(self, args):
|
||||
args[0](*args[1])
|
||||
|
||||
# 信号类
|
||||
class cSignal(QObject):
|
||||
signal = Signal("QVariant")
|
||||
|
||||
|
||||
CallFunc = __CallFunc()
|
||||
@@ -0,0 +1,131 @@
|
||||
# ============================================
|
||||
# =============== 文件查找/加载 ===============
|
||||
# ============================================
|
||||
# 从指定路径中,查找符合的文件
|
||||
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
from PySide2.QtQml import QJSValue
|
||||
from typing import List
|
||||
|
||||
from ..event_bus.pubsub_service import PubSubService # 发布事件
|
||||
from ..mission.mission_doc import MissionDOC, DocSuf
|
||||
from ..mission.mission_ocr import ImageSuf
|
||||
from umi_log import logger
|
||||
|
||||
|
||||
FileSuf = { # 合法文件后缀
|
||||
"image": ImageSuf,
|
||||
"doc": DocSuf,
|
||||
}
|
||||
|
||||
|
||||
# 同步从路径中搜索后缀符合要求的文件,返回路径列表。
|
||||
def findFiles(
|
||||
paths: List, # 初始路径列表
|
||||
sufType: str, # 后缀类型,FileSuf的key
|
||||
isRecurrence: bool, # 若为True,则递归搜索
|
||||
):
|
||||
if isinstance(paths, QJSValue):
|
||||
paths = paths.toVariant()
|
||||
if not isinstance(paths, list):
|
||||
logger.error(f"不合法的路径列表:{paths}, {type(paths)}")
|
||||
return []
|
||||
sufs = FileSuf.get(sufType, "")
|
||||
if not sufs:
|
||||
logger.error(f"不合法的后缀类型:{sufs}")
|
||||
return []
|
||||
|
||||
def _sufMatching(path):
|
||||
return os.path.splitext(path)[-1].lower() in sufs
|
||||
|
||||
filePaths = []
|
||||
for p in paths:
|
||||
if os.path.isfile(p) and _sufMatching(p): # 是文件,直接判断
|
||||
filePaths.append(os.path.abspath(p))
|
||||
elif os.path.isdir(p): # 是目录
|
||||
if isRecurrence: # 需要递归
|
||||
for root, dirs, files in os.walk(p):
|
||||
for file in files:
|
||||
if _sufMatching(file): # 收集子文件
|
||||
filePaths.append(
|
||||
os.path.abspath(os.path.join(root, file))
|
||||
) # 将路径转换为绝对路径
|
||||
else: # 不递归读取子文件夹
|
||||
for file in os.listdir(p):
|
||||
if os.path.isfile(os.path.join(p, file)) and _sufMatching(file):
|
||||
filePaths.append(os.path.abspath(os.path.join(p, file)))
|
||||
for i, p in enumerate(filePaths): # 规范化正斜杠
|
||||
filePaths[i] = p.replace("\\", "/")
|
||||
return filePaths
|
||||
|
||||
|
||||
# 异步从路径中搜索后缀符合要求的文件,并定时刷新UI。
|
||||
# image: 返回路径列表
|
||||
# doc: 返回 MissionDOC.getDocInfo 的信息字典列表
|
||||
def asynFindFiles(
|
||||
paths: List, # 初始路径列表
|
||||
sufType: str, # 后缀类型,FileSuf的key
|
||||
isRecurrence: bool, # 若为True,则递归搜索
|
||||
completeKey: str, # 全部完成后的事件key。向事件传入合法路径列表。
|
||||
updateKey: str, # UI刷新进度的事件key。填""则不刷新。向事件传入 (已完成的路径数量, 最近一条路径)
|
||||
updateTime: float, # UI刷新进度的间距
|
||||
):
|
||||
if isinstance(paths, QJSValue):
|
||||
paths = paths.toVariant()
|
||||
if not isinstance(paths, list):
|
||||
logger.error(f"不合法的路径列表:{paths}, {type(paths)}")
|
||||
PubSubService.publish(completeKey, [])
|
||||
return
|
||||
sufs = FileSuf.get(sufType, "")
|
||||
if not sufs:
|
||||
logger.error(f"不合法的后缀类型:{sufs}")
|
||||
PubSubService.publish(completeKey, [])
|
||||
return
|
||||
|
||||
def _sufMatching(path):
|
||||
return os.path.splitext(path)[-1].lower() in sufs
|
||||
|
||||
if not updateKey: # 如果没有刷新事件,则刷新间隔为无穷大
|
||||
updateTime = float("inf")
|
||||
filePaths = []
|
||||
lastTime = 0 # 上一次update事件的时间
|
||||
|
||||
def updateEvent(fp):
|
||||
nonlocal lastTime
|
||||
now = time.time()
|
||||
if now - lastTime > updateTime:
|
||||
PubSubService.publish(updateKey, len(filePaths), fp)
|
||||
lastTime = now
|
||||
|
||||
def addFile(fp):
|
||||
fp = fp.replace("\\", "/") # 规范化正斜杠
|
||||
if sufType == "doc": # 文档读取信息
|
||||
info = MissionDOC.getDocInfo(fp)
|
||||
if "error" in info:
|
||||
logger.warning(f'读入文档失败:{fp}, {info["error"]}')
|
||||
else:
|
||||
filePaths.append(info)
|
||||
else:
|
||||
filePaths.append(fp)
|
||||
updateEvent(fp)
|
||||
|
||||
for p in paths:
|
||||
if os.path.isfile(p) and _sufMatching(p): # 是文件,直接判断
|
||||
addFile(os.path.abspath(p))
|
||||
elif os.path.isdir(p): # 是目录
|
||||
if isRecurrence: # 需要递归
|
||||
for root, dirs, files in os.walk(p):
|
||||
for file in files:
|
||||
if _sufMatching(file): # 收集子文件
|
||||
# 转换为绝对路径
|
||||
fp = os.path.abspath(os.path.join(root, file))
|
||||
addFile(fp)
|
||||
else: # 不递归读取子文件夹
|
||||
for file in os.listdir(p):
|
||||
if os.path.isfile(os.path.join(p, file)) and _sufMatching(file):
|
||||
fp = os.path.abspath(os.path.join(p, file))
|
||||
addFile(fp)
|
||||
|
||||
PubSubService.publish(completeKey, filePaths)
|
||||
@@ -0,0 +1,86 @@
|
||||
# 全局设置连接器
|
||||
|
||||
import os
|
||||
from PySide2.QtCore import QObject, Slot
|
||||
|
||||
from . import app_opengl
|
||||
from .i18n_configs import I18n
|
||||
from ..platform import Platform
|
||||
from .pre_configs import getErrorStr
|
||||
from ..server import web_server
|
||||
from ..server.cmd_server import CmdActuator
|
||||
from umi_log import change_save_log_level, open_logs_dir
|
||||
|
||||
|
||||
class GlobalConfigsConnector(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# 创建快捷方式
|
||||
@Slot(str, result=str)
|
||||
def createShortcut(self, position):
|
||||
return Platform.Shortcut.createShortcut(position)
|
||||
|
||||
# 删除快捷方式
|
||||
@Slot(str, result=int)
|
||||
def deleteShortcut(self, position):
|
||||
return Platform.Shortcut.deleteShortcut(position)
|
||||
|
||||
# 获取UI语言信息
|
||||
@Slot(result="QVariant")
|
||||
def i18nGetInfos(self):
|
||||
return I18n.getInfos()
|
||||
|
||||
# 设置UI语言
|
||||
@Slot(str, result=bool)
|
||||
def i18nSetLanguage(self, lang):
|
||||
return I18n.setLanguage(lang)
|
||||
|
||||
# 获取Opengl渲染器选项
|
||||
@Slot(result=str)
|
||||
def getOpengl(self):
|
||||
return app_opengl.getOpengl()
|
||||
|
||||
# 设置Opengl渲染器选项
|
||||
@Slot(str)
|
||||
def setOpengl(self, opt):
|
||||
app_opengl.setOpengl(opt)
|
||||
|
||||
# 修改日志级别,成功返回T
|
||||
@Slot(str, result=bool)
|
||||
def change_save_log_level(self, levelname):
|
||||
return change_save_log_level(levelname)
|
||||
|
||||
# 打开日志保存目录
|
||||
@Slot()
|
||||
def open_logs_dir(self):
|
||||
open_logs_dir()
|
||||
|
||||
# 启动web服务器,传入qml对象及回调函数名。
|
||||
@Slot("QVariant", str, str, result=int)
|
||||
def runUmiWeb(self, qmlObj, callback, host):
|
||||
web_server.runUmiWeb(qmlObj, callback, host)
|
||||
|
||||
# 设置服务端口号
|
||||
@Slot(int)
|
||||
def setServerPort(self, port):
|
||||
web_server.setPort(port)
|
||||
|
||||
# 将qml模块字典传入cmd执行器
|
||||
@Slot("QVariant")
|
||||
def setQmlToCmd(self, moduleDict):
|
||||
CmdActuator.initCollect(moduleDict)
|
||||
|
||||
# 检查权限,返回检查结果
|
||||
@Slot(result=str)
|
||||
def checkAccess(self):
|
||||
cwd = os.getcwd() # 当前工作路径
|
||||
err = getErrorStr() # 读写异常情况
|
||||
if not err: # 没有异常,则再检查一遍权限
|
||||
if not os.access(cwd, os.R_OK):
|
||||
err += "在当前路径不具有可读权限。\nDo not have read permission on the current path."
|
||||
if not os.access(cwd, os.W_OK):
|
||||
err += "在当前路径不具有可写权限。\nDo not have write permission on the current path."
|
||||
if err:
|
||||
err = cwd + "\n" + err
|
||||
return err
|
||||
@@ -0,0 +1,146 @@
|
||||
# UI语言设置
|
||||
|
||||
import os
|
||||
from PySide2.QtCore import QTranslator
|
||||
|
||||
from . import pre_configs
|
||||
from plugin_i18n import setLangCode
|
||||
from umi_log import logger
|
||||
|
||||
I18nDir = "i18n" # 翻译文件 目录
|
||||
DefaultLang = "zh_CN" # 默认语言,项目中qsTr()标记的原生语言,无翻译文件。
|
||||
|
||||
# 语言表。每个语种只有第一个语言代码是有效的(对应到翻译文件.qm),
|
||||
# 其余的语言代码会映射到第一个。如zh_HK会映射到zh_TW,en_CA映射到en_US。
|
||||
# https://www.science.co.il/language/Locale-codes.php
|
||||
LanguageCodes = {
|
||||
# ===== 简中 =====
|
||||
"zh_CN": "简体中文",
|
||||
"zh": "简体中文",
|
||||
# ===== 繁中 =====
|
||||
"zh_TW": "繁體中文", # 中国台湾
|
||||
"zh_HK": "繁體中文", # 中国香港
|
||||
"zh_MO": "繁體中文", # 中国澳门
|
||||
"zh_SG": "繁體中文", # 新加坡
|
||||
# ===== 英语 =====
|
||||
"en_US": "English", # 美国
|
||||
"en": "English",
|
||||
"en_GB": "English", # 英国
|
||||
"en_AU": "English", # 澳大利亚
|
||||
"en_CA": "English", # 加拿大
|
||||
# ===== 日语 =====
|
||||
"ja_JP": "日本語", # 日本
|
||||
# ===== 俄语 =====
|
||||
"ru_RU": "Русский", # 俄罗斯
|
||||
"ru": "Русский",
|
||||
# ===== 葡萄牙语 =====
|
||||
"pt": "Português",
|
||||
"pt_BR": "Português", # 巴西
|
||||
"pt_PT": "Português", # 葡萄牙
|
||||
# ===== 泰米尔语 =====
|
||||
"ta": "தமிழ்",
|
||||
"ta_TA": "தமிழ்",
|
||||
}
|
||||
|
||||
""" 暂未启用的语言
|
||||
# ===== 韩语 =====
|
||||
"ko_KR": "한국어", # 韩国
|
||||
# ===== 法语 =====
|
||||
"fr_FR": "Français", # 法国
|
||||
"fr": "Français",
|
||||
"fr_CA": "Français", # 加拿大(魁北克)
|
||||
"fr_BE": "Français", # 比利时
|
||||
# ===== 意大利语 =====
|
||||
"it_IT": "Italiano",
|
||||
# ===== 挪威语 =====
|
||||
"nb_NO": "norsk",
|
||||
# ===== 德语 =====
|
||||
"de_DE": "Deutsch",
|
||||
"de": "Deutsch",
|
||||
"de_AT": "Deutsch",
|
||||
"de_CH": "Deutsch",
|
||||
# ===== 西班牙语 Spanish =====
|
||||
"es_ES": "Español",
|
||||
"es_MX": "Español",
|
||||
"""
|
||||
|
||||
|
||||
class _I18n:
|
||||
def init(self, qtApp):
|
||||
translator = QTranslator()
|
||||
qtApp.translators = [translator]
|
||||
|
||||
self.langCode = ""
|
||||
self.langDict = {}
|
||||
# 获取信息
|
||||
self._getLangPath()
|
||||
text, path = self.langDict[self.langCode]
|
||||
setLangCode(self.langCode) # 设置插件翻译
|
||||
if not path:
|
||||
logger.debug("使用默认文本,未加载翻译。")
|
||||
return
|
||||
if not translator.load(path):
|
||||
msg = f"无法加载UI语言!\n[Error] Unable to load UI language: {path}"
|
||||
logger.warning(msg)
|
||||
os.MessageBox(msg, type_="warning")
|
||||
return
|
||||
if not qtApp.installTranslator(translator): # 安装翻译器
|
||||
msg = f"无法加载翻译模块!\n[Error] Unable to installTranslator: {path}"
|
||||
logger.warning(msg)
|
||||
os.MessageBox(msg, type_="warning")
|
||||
return
|
||||
logger.info(f"i18n file loaded successfully. {self.langCode} - {text}")
|
||||
|
||||
# 切换语言
|
||||
def setLanguage(self, code):
|
||||
if code in self.langDict:
|
||||
self.langCode = code
|
||||
pre_configs.setValue("i18n", code) # 写入预配置项
|
||||
return True
|
||||
return False
|
||||
|
||||
# 获取语言参数
|
||||
def getInfos(self):
|
||||
return [self.langCode, self.langDict]
|
||||
|
||||
# 获取当前翻译文件路径,如果没有配置文件则初始化
|
||||
def _getLangPath(self):
|
||||
self.langDict = {}
|
||||
self.langCode = ""
|
||||
# 搜索本地翻译文件
|
||||
for file in os.listdir(I18nDir):
|
||||
if file.endswith(".qm"):
|
||||
code = os.path.splitext(file)[0]
|
||||
path = os.path.join(I18nDir, file)
|
||||
text = LanguageCodes.get(code, code)
|
||||
self.langDict[code] = [text, path]
|
||||
if DefaultLang not in self.langDict:
|
||||
text = LanguageCodes[DefaultLang]
|
||||
self.langDict[DefaultLang] = [text, ""]
|
||||
# 加载预配置项
|
||||
code = pre_configs.getValue("i18n")
|
||||
if code in self.langDict:
|
||||
self.langCode = code
|
||||
# 未能加载,则初始化预配置
|
||||
if not self.langCode:
|
||||
import locale
|
||||
|
||||
# 取得当前系统语言
|
||||
code, encoding = locale.getdefaultlocale()
|
||||
# 映射首位代号
|
||||
if code in LanguageCodes:
|
||||
langStr = LanguageCodes[code]
|
||||
for c, l in LanguageCodes.items():
|
||||
if l == langStr:
|
||||
code = c
|
||||
break
|
||||
# 尝试写入配置
|
||||
if not self.setLanguage(code):
|
||||
# 写入配置失败,则使用默认语言
|
||||
self.setLanguage(DefaultLang)
|
||||
logger.warning(
|
||||
f"The current system language is {code} and there is no corresponding i18n file. The default language used is {DefaultLang}."
|
||||
)
|
||||
|
||||
|
||||
I18n = _I18n()
|
||||
@@ -0,0 +1,76 @@
|
||||
# 程序的配置分为两部分,一部分是由qml引擎控制的主配置,必须启动qml才能访问。
|
||||
# 而这里是第二部分的配置项,单独存放少量关键配置,可以在未启动qml之前访问。
|
||||
|
||||
import os
|
||||
import json
|
||||
|
||||
_FileName = "./.pre_settings"
|
||||
|
||||
_Configs = {
|
||||
"i18n": "", # 界面语言
|
||||
"opengl": "", # 界面OpenGL渲染类型
|
||||
"server_port": 1224, # 服务端口号
|
||||
"last_pid": -1, # 最后一次运行时的进程号
|
||||
"last_ptime": -1, # 最后一次运行时的进程创建时间
|
||||
}
|
||||
|
||||
_Errors = {} # 记录读写预配置文件的异常情况
|
||||
|
||||
|
||||
def getValue(key):
|
||||
if key in _Configs:
|
||||
return _Configs[key]
|
||||
else:
|
||||
raise ValueError
|
||||
|
||||
|
||||
def setValue(key, value):
|
||||
if key in _Configs:
|
||||
_Configs[key] = value
|
||||
writeConfigs()
|
||||
else:
|
||||
raise ValueError
|
||||
|
||||
|
||||
def writeConfigs():
|
||||
global _Errors
|
||||
try:
|
||||
with open(_FileName, "w", encoding="utf-8") as file:
|
||||
json.dump(_Configs, file, ensure_ascii=False, indent=4)
|
||||
except PermissionError:
|
||||
_Errors[
|
||||
"Write PermissionError"
|
||||
] = "权限不足,无法写入配置文件。\nInsufficient permissions, unable to write to the configuration file."
|
||||
except Exception as e:
|
||||
_Errors[
|
||||
"Write Error"
|
||||
] = f"无法写入配置文件。\nUnable to write to the configuration file: {e}"
|
||||
|
||||
|
||||
def readConfigs():
|
||||
global _Errors
|
||||
if not os.path.exists(_FileName):
|
||||
return
|
||||
try:
|
||||
with open(_FileName, "r") as file:
|
||||
data = json.load(file)
|
||||
for key in _Configs:
|
||||
_Configs[key] = data[key]
|
||||
except PermissionError:
|
||||
_Errors[
|
||||
"Write PermissionError"
|
||||
] = "权限不足,无法读取配置文件。\nInsufficient permissions, unable to read to the configuration file."
|
||||
except Exception as e:
|
||||
_Errors[
|
||||
"Write Error"
|
||||
] = f"无法读取配置文件。\nUnable to read to the configuration file: {e}"
|
||||
|
||||
|
||||
# 返回异常情况字符串
|
||||
def getErrorStr():
|
||||
global _Errors
|
||||
err = ""
|
||||
if _Errors:
|
||||
for e in _Errors.values():
|
||||
err += e + "\n"
|
||||
return err
|
||||
@@ -0,0 +1,33 @@
|
||||
# =========================================
|
||||
# =============== 主题连接器 ===============
|
||||
# =========================================
|
||||
|
||||
from PySide2.QtCore import QObject, Slot
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
ThemePath = "themes.json"
|
||||
|
||||
|
||||
class ThemeConnector(QObject):
|
||||
# 读取主题
|
||||
@Slot(result=str)
|
||||
def loadThemeStr(self):
|
||||
try:
|
||||
with open(ThemePath, "r", encoding="utf-8") as f:
|
||||
r = f.read()
|
||||
return r
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.warning("读取主题文件失败。", exc_info=True)
|
||||
return ""
|
||||
|
||||
# 保存主题
|
||||
@Slot(str)
|
||||
def saveThemeStr(self, tstr):
|
||||
try:
|
||||
with open(ThemePath, "w", encoding="utf-8") as f:
|
||||
f.write(tstr)
|
||||
except Exception:
|
||||
logger.warning("写入主题文件失败。", exc_info=True)
|
||||
@@ -0,0 +1,42 @@
|
||||
# =====================================================
|
||||
# =============== 全局线程池 异步任务接口 ===============
|
||||
# =====================================================
|
||||
|
||||
from PySide2.QtCore import QThreadPool, QRunnable
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
# 全局线程池
|
||||
GlobalThreadPool = QThreadPool.globalInstance()
|
||||
|
||||
|
||||
# 异步类
|
||||
class Runnable(QRunnable):
|
||||
def __init__(self, taskFunc, *args, **kwargs):
|
||||
super().__init__()
|
||||
self._taskFunc = taskFunc
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self._taskFunc(*self._args, **self._kwargs)
|
||||
except Exception:
|
||||
logger.error("异步运行发生错误。", exc_info=True, stack_info=True)
|
||||
|
||||
|
||||
# 启动异步类
|
||||
def threadPoolStart(runnable: QRunnable):
|
||||
# 检查线程池是否满,并扩充
|
||||
activeThreadCount = GlobalThreadPool.activeThreadCount()
|
||||
if activeThreadCount >= GlobalThreadPool.maxThreadCount():
|
||||
logger.debug(f"线程池已满 {activeThreadCount} !自动扩充+1。")
|
||||
GlobalThreadPool.setMaxThreadCount(activeThreadCount + 1)
|
||||
GlobalThreadPool.start(runnable)
|
||||
|
||||
|
||||
# 快捷接口:异步运行函数,返回异步类的对象
|
||||
def threadRun(taskFunc, *args, **kwargs):
|
||||
runnable = Runnable(taskFunc, *args, **kwargs)
|
||||
threadPoolStart(runnable)
|
||||
return runnable
|
||||
@@ -0,0 +1,104 @@
|
||||
# =======================================
|
||||
# =============== 通用工具 ===============
|
||||
# =======================================
|
||||
|
||||
import re
|
||||
import os
|
||||
from PySide2.QtGui import QClipboard
|
||||
from PySide2.QtCore import QFileInfo
|
||||
from PySide2.QtQml import QJSValue
|
||||
from urllib.parse import unquote # 路径解码
|
||||
|
||||
from umi_log import logger
|
||||
|
||||
Clipboard = QClipboard() # 剪贴板
|
||||
|
||||
|
||||
# 传入文件名,检测是否含非法字符。没问题返回True
|
||||
def allowedFileName(fn):
|
||||
pattern = r'[\\/:*?"<>|]'
|
||||
if re.search(pattern, fn):
|
||||
return False # 转布尔值
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
# 复制文本到剪贴板
|
||||
def copyText(text):
|
||||
Clipboard.setText(text)
|
||||
|
||||
|
||||
# QUrl列表 转 String列表
|
||||
def QUrl2String(urls):
|
||||
resList = []
|
||||
for url in urls:
|
||||
if url.isLocalFile():
|
||||
u = unquote(url.toLocalFile()) # 解码路径
|
||||
if QFileInfo(u).exists(): # 检查路径是否真的存在
|
||||
resList.append(u)
|
||||
return resList
|
||||
|
||||
|
||||
# 初始化配置项字典数值,等同于 Configs.qml 的 function initConfigDict
|
||||
# 主要是为了补充type和default
|
||||
def initConfigDict(dic):
|
||||
toDict = {}
|
||||
|
||||
def handleConfigItem(config, key): # 处理一个配置项
|
||||
# 类型:指定type
|
||||
if not config["type"] == "":
|
||||
if config["type"] == "file": # 文件选择
|
||||
config["default"] = "" if not config["default"] is None else None
|
||||
elif config["type"] == "var" and config["default"] is None: # 任意类型
|
||||
config["default"] = ""
|
||||
# 类型:省略type
|
||||
else:
|
||||
if isinstance(config["default"], bool): # 布尔
|
||||
config["type"] = "boolean"
|
||||
elif "optionsList" in config: # 枚举
|
||||
config["type"] = "enum"
|
||||
if len(config["optionsList"]) == 0:
|
||||
logger.error(f"处理配置项异常:{key}枚举列表为空。")
|
||||
return
|
||||
if config["default"] is None:
|
||||
config["default"] = config["optionsList"][0][0]
|
||||
elif isinstance(config["default"], str): # 文本
|
||||
config["type"] = "text"
|
||||
elif isinstance(config["default"], (int, float)): # 数字
|
||||
config["type"] = "number"
|
||||
elif "btnsList" in config: # 按钮组
|
||||
config["type"] = "buttons"
|
||||
return
|
||||
else:
|
||||
logger.error(f"未知类型的配置项:{key}")
|
||||
return
|
||||
|
||||
def handleConfigGroup(group, prefix=""): # 处理一个配置组
|
||||
for key in group:
|
||||
config = group[key]
|
||||
if not isinstance(config, dict):
|
||||
continue
|
||||
# 补充空白参数
|
||||
if "type" not in config: # 类型
|
||||
config["type"] = ""
|
||||
if "default" not in config: # 默认值
|
||||
config["default"] = None
|
||||
if "advanced" not in config: # 是否为高级选项
|
||||
config["advanced"] = False
|
||||
# 记录完整key
|
||||
fullKey = prefix + key
|
||||
if config["type"] == "group": # 若是配置项组,递归遍历
|
||||
handleConfigGroup(config, fullKey + ".") # 前缀加深一层
|
||||
else: # 若是配置项
|
||||
toDict[fullKey] = config
|
||||
handleConfigItem(config, fullKey)
|
||||
|
||||
handleConfigGroup(dic)
|
||||
return toDict
|
||||
|
||||
|
||||
# 整理 argd 参数字典,将 float 恢复 int 类型,如 12.0 → 12
|
||||
def argdIntConvert(argd):
|
||||
for k, v in argd.items():
|
||||
if isinstance(v, float) and v.is_integer():
|
||||
argd[k] = int(v)
|
||||
@@ -0,0 +1,63 @@
|
||||
# 通用工具连接器
|
||||
|
||||
from typing import List
|
||||
from PySide2.QtCore import QObject, Slot
|
||||
|
||||
from . import utils
|
||||
from . import file_finder # 文件搜索器
|
||||
from ..platform import Platform # 跨平台
|
||||
from .thread_pool import threadRun # 异步执行函数
|
||||
|
||||
|
||||
class UtilsConnector(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
# 将文本写入剪贴板
|
||||
@Slot(str)
|
||||
def copyText(self, text):
|
||||
utils.copyText(text)
|
||||
|
||||
# 用系统应用打开文件或目录
|
||||
@Slot(str)
|
||||
def startfile(self, path):
|
||||
Platform.startfile(path)
|
||||
|
||||
# 硬件控制
|
||||
@Slot(str)
|
||||
def hardwareCtrl(self, key):
|
||||
if key == "shutdown": # 关机
|
||||
Platform.HardwareCtrl.shutdown()
|
||||
elif key == "hibernate": # 休眠
|
||||
Platform.HardwareCtrl.hibernate()
|
||||
|
||||
# 同步搜索文件,返回合法的文件路径列表
|
||||
@Slot("QVariant", bool, str, result="QVariant")
|
||||
def findFiles(self, paths, sufType, isRecurrence):
|
||||
return file_finder.findFiles(paths, sufType, isRecurrence)
|
||||
|
||||
# 异步搜索文件
|
||||
@Slot("QVariant", str, bool, str, str, float)
|
||||
def asynFindFiles(
|
||||
self,
|
||||
paths: List, # 初始路径列表
|
||||
sufType: str, # 后缀类型,FileSuf的key
|
||||
isRecurrence: bool, # 若为True,则递归搜索
|
||||
completeKey: str, # 全部完成后的事件key。向事件传入合法路径列表。
|
||||
updateKey: str, # 加载中刷新进度的key,不填则无。向事件传入 (已完成的路径数量, 最近一条路径)
|
||||
updateTime: float, # 刷新进度的间距
|
||||
):
|
||||
threadRun(
|
||||
file_finder.asynFindFiles,
|
||||
paths,
|
||||
sufType,
|
||||
isRecurrence,
|
||||
completeKey,
|
||||
updateKey,
|
||||
updateTime,
|
||||
)
|
||||
|
||||
# QUrl列表 转 String列表
|
||||
@Slot("QVariant", result="QVariant")
|
||||
def QUrl2String(self, fileUrls):
|
||||
return utils.QUrl2String(fileUrls)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user