Skip to content

Commit 32b8b58

Browse files
committed
Release FormulaOCR 1.1.2 with runtime performance and reliability fixes
1 parent c22b89f commit 32b8b58

16 files changed

Lines changed: 529 additions & 72 deletions

.github/workflows/ci.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ jobs:
3737
- name: Run regression tests
3838
shell: pwsh
3939
run: |
40-
python -m unittest formula_ocr_app.recognition_tests
41-
python -m unittest formula_ocr_app.update_tests
42-
python formula_ocr_app\app.py --ui-self-test
43-
python formula_ocr_app\app.py --word-mathml-self-test
44-
python formula_ocr_app\app.py --runtime-self-test
40+
python -m unittest formula_ocr_app.recognition_tests formula_ocr_app.update_tests formula_ocr_app.runtime_tests
41+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
42+
foreach ($check in @("--ui-self-test", "--word-mathml-self-test", "--runtime-self-test")) {
43+
python formula_ocr_app\app.py $check
44+
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
45+
}

README.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ formula_ocr_app/
3939
recognizer.py # 旧导入路径的兼容层
4040
onnx_runtime.py # ONNX provider 与 SessionOptions 的统一配置
4141
recognition_tests.py # 识别后处理、下载和模型切换回归测试
42+
runtime_tests.py # 并发校验、模型切换失败恢复与解码边界回归测试
4243
model_catalog.py # 模型供应商、场景、大小和后端目录
4344
app_settings.py # 用户模型选择与条款确认持久化
4445
app_update.py # GitHub 正式版查询、版本比较与可信下载地址解析
@@ -126,7 +127,7 @@ python -m formula_ocr_app.app
126127
Windows 用户可以直接从 [GitHub Releases](https://github.com/yaluncoco/FormulaOCR/releases) 下载:
127128

128129
```text
129-
FormulaOCRSetup-1.1.1.exe
130+
FormulaOCRSetup-1.1.2.exe
130131
```
131132

132133
安装程序是 x64 Windows 的 Inno Setup 安装包,默认安装到当前用户目录,不需要管理员权限。安装后可以从开始菜单启动 FormulaOCR;桌面快捷方式在安装时可选。卸载时默认保留模型、设置和日志,避免重新安装后重复下载;如果确认不再需要,也可以选择同时清理 `%LOCALAPPDATA%\FormulaOCR`
@@ -136,8 +137,8 @@ FormulaOCRSetup-1.1.1.exe
136137
每个 Release 同时提供 `.sha256` 校验文件。下载后可在 PowerShell 中验证:
137138

138139
```powershell
139-
Get-FileHash .\FormulaOCRSetup-1.1.1.exe -Algorithm SHA256
140-
Get-Content .\FormulaOCRSetup-1.1.1.exe.sha256
140+
Get-FileHash .\FormulaOCRSetup-1.1.2.exe -Algorithm SHA256
141+
Get-Content .\FormulaOCRSetup-1.1.2.exe.sha256
141142
```
142143

143144
当前公开 Release 安装程序未进行商业代码签名,Windows SmartScreen 在下载量较少时可能显示“未知发布者”。请仅从本项目 Releases 下载,并用 SHA-256 文件核对完整性。
@@ -176,7 +177,7 @@ $env:FORMULA_OCR_CONDA_ENV = "D:\anaconda3\envs\formula_ocr"
176177
.\build_installer.ps1
177178
```
178179

179-
`build_installer.ps1` 会先构建并自检 `dist\FormulaOCR`,再生成 `dist\installer\FormulaOCRSetup-1.1.1.exe` 和对应 SHA-256 文件。公开仓库的 `.github/workflows/release.yml` 会在推送 `v*` 标签时于干净的 Windows runner 上根据 `requirements.txt` 重复这个流程并自动上传 Release 资产;不需要把第三方 OCR 源码或模型权重提交到仓库。
180+
`build_installer.ps1` 会先构建并自检 `dist\FormulaOCR`,再生成 `dist\installer\FormulaOCRSetup-1.1.2.exe` 和对应 SHA-256 文件。公开仓库的 `.github/workflows/release.yml` 会在推送 `v*` 标签时于干净的 Windows runner 上根据 `requirements.txt` 重复这个流程并自动上传 Release 资产;不需要把第三方 OCR 源码或模型权重提交到仓库。
180181

181182
打包产物会输出到:
182183

@@ -204,10 +205,15 @@ python -m formula_ocr_app.app --ui-self-test
204205
python -m formula_ocr_app.app --self-test --self-test-model MixTexZhEn
205206
python -m formula_ocr_app.recognition_tests
206207
python -m formula_ocr_app.update_tests
208+
python -m formula_ocr_app.runtime_tests
207209
```
208210

209211
部分测试依赖 Windows 剪贴板、Word 兼容格式或本地浏览器。
210212

213+
CI 会合并运行三个 unittest 模块,并检查每项桌面自检的退出码,任一步失败都会终止该步骤。运行环境自检需要单独启动 Python 进程,以检查启动阶段的惰性加载。
214+
215+
性能改动、测量范围与后续改进建议见 [2026-09 优化审查记录](docs/optimization-2026-09.md)
216+
211217
## 开源致谢
212218

213219
本项目的公式识别能力基于 PaddlePaddle/Paddle 模型生态、PaddlePaddle 官方的 [`LaTeX_OCR_rec`](https://huggingface.co/PaddlePaddle/LaTeX_OCR_rec)[UniMERNet Small ONNX](https://huggingface.co/Cooper114/unimernet-onnx)[RapidLaTeXOCR](https://github.com/RapidAI/RapidLaTeXOCR)[MathCraft-Models](https://github.com/SakuraMathcraft/MathCraft-Models)[Pix2Text MFR](https://huggingface.co/breezedeus/pix2text-mfr-1.5)[MixTeX](https://github.com/RQLuo/MixTeX-Latex-OCR)。Paddle 预处理/解码行为参考了 Apache-2.0 的 PaddleX 公式识别处理器,但发布程序不携带 PaddleOCR/PaddleX Python 包;Rapid 后端也只使用其官方模型资产,不依赖 RapidLaTeXOCR Python 包。MathCraft Formula 的 ONNX 推理实现参考了 [LaTeXSnipper](https://github.com/SakuraMathcraft/LaTeXSnipper) 的模型适配思路。

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.1.1
1+
1.1.2

docs/optimization-2026-09.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# 2026-09 性能与可靠性审查
2+
3+
本轮基于 `c22b89faea5272aa4535ca6f97ca4937127151d8` 检查了模型加载、下载校验、图像预处理、ONNX 解码、桌面任务调度及 CI。改动已在独立副本验证,并同步到项目源码。
4+
5+
本轮落地的改进:
6+
7+
| 位置 | 原问题 | 改进结果 |
8+
| --- | --- | --- |
9+
| `image_utils.py`、Paddle/Rapid 预处理 | `np.argwhere` 为每个前景像素分配坐标,大图或密集前景容易产生大量临时内存 | 通过逐行、逐列归约计算边界;保留原有裁剪边界、像素和预处理规则 |
10+
| `mathcraft_recognizer.py` | 每生成一个 token 都复制未变化的编码器输出,并重新拼接 token 数组 | 预分配 token 缓冲;只在批次中有样本结束时重排编码器输出,MathCraft/Pix2Text 共用的解码路径均受益 |
11+
| `rapid_recognizer.py` | 解码循环持续扩展 token 和 mask 数组 | 复用缓冲和 mask,保留 512 token 滑动上下文、EOS 与重复输出终止规则 |
12+
| `recognition_pipeline.py` | 新模型加载失败后,旧的已关闭后端仍可能被复用;运行期间模型名称变化会使结果标签与实际模型不符 | 先解除旧后端引用再关闭;每次识别固定所用模型名称 |
13+
| `download_utils.py` | 哈希缓存命中后若被另一线程淘汰,`move_to_end` 会抛 `KeyError` | 在锁内重新确认缓存条目,必要时重新保存已经验证的摘要 |
14+
| `download_utils.py` | 传入多个网络异常类型时,嵌套异常元组会用 `TypeError` 覆盖断网或取消异常 | 展平异常类型;保留断点、原始错误及取消行为 |
15+
| `app.py``model_runtime.py`、模型管理窗口 | 下载按钮和部分随包模型状态查询仍会在 Tk 主线程读取模型文件做完整哈希 | 界面只检查轻量状态,实际校验由准备/识别工作线程执行;完整校验默认行为保留 |
16+
| `.github/workflows/ci.yml` | PowerShell 中前面的 Python 命令失败,可能被后面成功命令的退出码掩盖 | 合并 unittest 入口,并检查每项桌面自检的退出码,遇到失败立即退出 |
17+
18+
性能测量使用本机 Windows 的现有 `formula_ocr` 环境,对原版与新版各预热一次,再取 7 次执行的中位数。图像为合成的 3840 × 2160 样例;解码基准使用固定输出的模拟 ONNX session,测量应用层循环开销。
19+
20+
| 测量项 | 原版 | 优化后 |
21+
| --- | ---: | ---: |
22+
| 4K 密集前景边界计算 | 95.25 ms | 0.28 ms |
23+
| 同一边界计算的额外分配峰值(`tracemalloc`| 205.76 MiB | 0.05 MiB |
24+
| 4K 文本图片的 Rapid 裁剪/补边 `_pad_formula` | 102.91 ms | 76.61 ms |
25+
| MathCraft/Pix2Text 共用解码循环,生成 256 token | 123.68 ms | 3.74 ms |
26+
| Rapid 解码循环,生成 540 token | 13.64 ms | 2.78 ms |
27+
28+
这些数字对应表中具体函数。真实模型原生推理仍是复杂公式的主要耗时:本地 Rapid 复杂样例在优化前后均约 5.1 秒。密集前景测试用于验证临时分配上限,实际文本图片的整个预处理函数仍有灰度转换、归一化等内存开销。
29+
30+
验证结果:
31+
32+
- 原项目运行 `python -m unittest formula_ocr_app.recognition_tests formula_ocr_app.update_tests formula_ocr_app.runtime_tests`:共 128 项,126 项通过、2 项跳过。跳过项分别需要 Windows 符号链接权限和额外的 UniMERNet Small 实体模型。
33+
- 新增 13 项回归测试,覆盖模型切换失败恢复、结果归属、界面校验边界、并发缓存淘汰、断网/取消、裁剪坐标、独立结束的批次样本、短生成上限和长公式滑动上下文。修复前其中 7 项可复现本轮问题。
34+
- UI、Word/MathML、运行环境、浏览器预览自检均通过;运行环境自检在单独的 Python 进程执行,确认启动惰性加载仍有效。
35+
- 使用本地已有的 RapidLaTeXOCR、PP-FormulaNet_plus-S,识别简单公式和项目中的 `公式.png`;优化前后及重复识别输出一致。Paddle 与 Rapid 的大图预处理输出也逐像素一致。
36+
- 在真实 PowerShell 中模拟四个 Python 检查位置分别失败及全部成功,确认 CI 退出码与提前停止行为正确。
37+
- Python 静态检查(Ruff `E4,E7,E9,F`)及 `git diff --check` 通过。
38+
39+
后续值得继续投入的方向,按优先级排序:
40+
41+
| 优先级 | 方向 | 当前依据与建议 |
42+
| --- | --- | --- |
43+
| P1 | 建立真实公式准确率评测集 | `公式.png` 中的双行分段公式在 Rapid 输出中仍存在结构重复。补充经过人工核对的分段、矩阵、多行、上下限及中英混排样例,分别统计准确率、延迟和异常;再据此调整模型推荐与预处理 |
44+
| P1 | 显示生成质量信息 | 多个后端已经检测重复输出,但统一结果目前只携带公式字符串。可向界面传递长度上限、循环截断等状态,帮助用户决定是否换图或手动选择其他模型 |
45+
| P2 | 预览计算与结果缓存 | `_update_mathml_preview` 仍在主线程转换 LaTeX,每次渲染会启动浏览器。可将转换一起放入后台,并增加有容量限制的预览缓存,减少复杂公式编辑、撤销时的重复工作 |
46+
| P2 | 大图载入和下载校验的响应性 | `_set_image` 仍在主线程转换并写 PNG;完整 SHA-256 阶段还可增加块级取消检查。建议分别补充任务令牌和明确的校验状态,保持现有图片版本与下载断点保护 |
47+
| P2 | 拆分主窗口代码 | `app.py` 同时包含窗口编排、工作队列、截图和大量自检。可逐步抽出自检入口和后台任务调度,保持现有回归覆盖后再调整界面结构 |
48+
49+
本轮验证覆盖源码与现有运行环境。MathCraft/Pix2Text 的实体权重未在本机缓存,其解码改动通过模拟 session、批次边界与输出等价检查验证。安装包的构建验收由 `build_exe.ps1` 中的打包版自检单独负责。

formula_ocr_app/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""FormulaOCR desktop application package."""
22

3-
__version__ = "1.1.1"
3+
__version__ = "1.1.2"

formula_ocr_app/app.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,7 +1208,7 @@ def _update_model_summary(self) -> None:
12081208
self.model_picker.refresh()
12091209
return
12101210
spec = get_model_spec(model_id)
1211-
if cached and is_model_bundled_only(model_id):
1211+
if cached and is_model_bundled_only(model_id, verify_hash=False):
12121212
state = "随包内置"
12131213
else:
12141214
state = "已下载"
@@ -1388,12 +1388,11 @@ def _request_model_download(self, model_id: str) -> bool:
13881388
if not self._ensure_model_terms_accepted(model_id):
13891389
return False
13901390
spec = get_model_spec(model_id)
1391-
if is_model_cached(model_id):
1392-
self.status_var.set(f"{spec.display_name} 已下载")
1393-
return False
1391+
# ensure_model verifies existing files in the worker before deciding
1392+
# whether a download is needed. Hashing here blocks Tk on large models.
13941393
self._set_busy(
13951394
True,
1396-
message=f"正在下载 {spec.display_name}...",
1395+
message=f"正在检查并准备 {spec.display_name}...",
13971396
show_cancel=True,
13981397
)
13991398
thread = threading.Thread(

formula_ocr_app/download_utils.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,11 @@ def download_verified_file(
160160
offset = 0
161161
mode = "ab" if append else "wb"
162162
oversized = False
163+
transfer_errors = (OSError,) + (
164+
request_exception
165+
if isinstance(request_exception, tuple)
166+
else (request_exception,)
167+
)
163168
try:
164169
notify(completed + offset, total)
165170
last_report = 0.0
@@ -176,7 +181,7 @@ def download_verified_file(
176181
if now - last_report >= 0.5 or offset >= item.size:
177182
notify(completed + offset, total)
178183
last_report = now
179-
except (OSError, request_exception) as exc:
184+
except transfer_errors as exc:
180185
raise VerifiedDownloadFailure(
181186
"transfer",
182187
item_name=item.name,
@@ -319,9 +324,12 @@ def _cached_sha256_file(path: Path) -> str:
319324
continue
320325

321326
with _HASH_CACHE_LOCK:
322-
if cached is not None:
327+
# Another validator can evict this entry while the file identity
328+
# checks above run outside the lock. Reinsert our validated digest
329+
# in that case instead of moving a key that no longer exists.
330+
if key in _HASH_CACHE:
323331
_HASH_CACHE.move_to_end(key)
324-
return cached
332+
return digest
325333
stale_keys = [
326334
entry for entry in _HASH_CACHE if entry[0] == normalized_path
327335
]

formula_ocr_app/image_utils.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
from pathlib import Path
6+
from typing import Any
67

78
from PIL import Image, ImageOps
89

@@ -31,3 +32,20 @@ def load_rgb_image(image_path: str | Path) -> Image.Image:
3132

3233
with Image.open(image_path) as source:
3334
return image_to_rgb(source)
35+
36+
37+
def foreground_bbox(mask: Any) -> tuple[int, int, int, int] | None:
38+
"""Return a Pillow bounding box for a 2-D foreground mask.
39+
40+
Reduce each axis instead of allocating an (N, 2) coordinate array for all
41+
foreground pixels. Temporary storage then scales with width + height,
42+
which keeps large screenshots from allocating hundreds of megabytes.
43+
"""
44+
45+
import numpy as np
46+
47+
rows = np.flatnonzero(np.any(mask, axis=1))
48+
if rows.size == 0:
49+
return None
50+
columns = np.flatnonzero(np.any(mask, axis=0))
51+
return int(columns[0]), int(rows[0]), int(columns[-1]) + 1, int(rows[-1]) + 1

formula_ocr_app/mathcraft_recognizer.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,10 @@ def _generate_formula_tokens(
227227
import numpy as np
228228

229229
batch_size = int(encoder_hidden_states.shape[0])
230+
generation_limit = max(1, int(max_new_tokens))
230231
active_indices = np.arange(batch_size, dtype=np.int64)
231232
active_input_ids = np.full(
232-
(batch_size, 1), decoder_start_id, dtype=np.int64
233+
(batch_size, generation_limit), decoder_start_id, dtype=np.int64
233234
)
234235
active_hidden_states = encoder_hidden_states
235236
token_ids: list[list[int]] = [[] for _ in range(batch_size)]
@@ -241,11 +242,11 @@ def _generate_formula_tokens(
241242
input_ids_name = decoder_inputs[0].name
242243
hidden_states_name = decoder_inputs[1].name
243244

244-
for _ in range(max(1, int(max_new_tokens))):
245+
for step in range(generation_limit):
245246
logits = decoder_session.run(
246247
None,
247248
{
248-
input_ids_name: active_input_ids,
249+
input_ids_name: active_input_ids[:, : step + 1],
249250
hidden_states_name: active_hidden_states,
250251
},
251252
)[0]
@@ -275,14 +276,13 @@ def _generate_formula_tokens(
275276
keep_active_rows.append(active_row)
276277
if not keep_active_rows:
277278
break
278-
active_input_ids = np.concatenate(
279-
[
280-
active_input_ids[keep_active_rows],
281-
next_tokens[keep_active_rows].reshape(len(keep_active_rows), 1),
282-
],
283-
axis=1,
284-
)
285-
active_hidden_states = active_hidden_states[keep_active_rows]
286-
active_indices = active_indices[keep_active_rows]
279+
if len(keep_active_rows) != len(active_indices):
280+
# Encoder output can be several MiB. It stays constant between
281+
# decoding steps, so only copy it when finished batch rows leave.
282+
active_input_ids = active_input_ids[keep_active_rows]
283+
active_hidden_states = active_hidden_states[keep_active_rows]
284+
active_indices = active_indices[keep_active_rows]
285+
if step + 1 < generation_limit:
286+
active_input_ids[:, step + 1] = next_tokens[keep_active_rows]
287287

288288
return token_ids, token_scores, stopped_for_repetition

formula_ocr_app/model_manager_dialog.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -496,7 +496,7 @@ def remove() -> None:
496496
return
497497
spec = get_model_spec(model_id)
498498
if not model_has_user_cache_data(model_id):
499-
if is_model_bundled_only(model_id):
499+
if is_model_bundled_only(model_id, verify_hash=False):
500500
set_status(f"{spec.display_name} 为随包内置模型,不能删除")
501501
else:
502502
set_status(f"{spec.display_name} 没有可删除的用户缓存")

0 commit comments

Comments
 (0)