普通视图

发现新文章,点击刷新页面。
昨天以前如鱼饮水

使用 Vibe Coding 编写一个属于自己的 VSCode 插件

作者 西风冷香
2026年2月16日 16:05

在写这个博客里的内容以及类似的文章的时候,我一般使用VSCode和Typora两个软件进行编辑。

Typora的优点是可以实时预览文章内容,可以正确显示博客里引用的图片。Hexo博客会将图片存在与markdown文件同名的文件夹中[1],而Typora支持通过在YAML Front Matter里面指定typora-root-url[2]作为图片路径的前缀。

而VSCode的优点在于输入公式比较方便,可以在编辑数学公式的时候自动切换中英文[3],通过配置[4]也可以支持正确添加图片,但是图片文件的路径是相对于markdown文件所在文件夹的路径,这个Hexo的设置是不一样的。一旦修改成Hexo需要的路径之后,VSCode就无法找到正确的图片了。

这点在写新的文章的时候还不太要紧,因为可以在写完文章后统一修改图片路径。但是在修改旧的文章的时候,由于无法正确预览图片,就不太方便了。

要让VSCode实现这个功能,就必须通过插件来完成。但是由于我不懂VSCode插件的开发,甚至不熟悉Typescript的语法,这个想法一直被我放下了。最近看到了oh-my-opencode项目,于是想着能否利用vibe coding来写一个插件,满足我上面的要求。

1. 第一次尝试

去年在MiniMax M2.1刚发布的时候,限时免费了一段时间,加上赠送的代金券,我尝试用它配合Cline来日常使用,效果还可以。因此我这次打算用opencode免费提供的minimax-m2.1-free模型,配oh-my-opencode的ulw模式使用。

就在我写这个插件的第2天,Minimax发布了M2.5模型,之后第3天opencode就把免费模型替换成了新的M2.5模型。所以实际上是两个模型混着用的。

起初我把需求丢给opencode,结果确实写出了一个像模像样的插件,包括了扩展入口、一个markdown-it插件和一个yaml front matter解析器。尝试对它进行调试,它还在调试日志中告诉你「插件已成功加载」,但就是没有任何实际的作用。

尝试让它自己去debug,但是多次尝试始终未能成功。

无奈,只能手动去查看文档。

发现主要的问题出现在两个地方:

  1. 注册markdown-it插件的方法完全是错误的。
  2. 在markdown-it插件里面,获取文档内容的方法完全是错误的,致使实际上无法获取yaml front matter的内容。

这就导致了插件彻底无法工作。而且,在debug的过程中,它完全没有想到要求修改这两个地方。

也就是说,整个插件都是废的。

猜测主要原因是这两部分的语法没有问题,「表面」上的语义也没有问题,但实际上它用到的东西根本不存在。

2. 手动搭建框架

无奈,还是只能从头开始手写。

首先,使用yo code搭建项目架构。根据官方文档,VSCode使用markdown-it来解析markdown文件,因此我们只需要实现一个markdown-it插件即可。

我们先实现一个markdown-it插件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import type MarkdownIt from 'markdown-it';
import path from 'node:path';

interface ImageOptions {
imageDir?: string;
}

export function prefixifyImageURL(md: MarkdownIt, pluginOptions?:ImageOptions) {
const imageDir = pluginOptions?.imageDir || ".";

const original = md.renderer.rules.image!;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const src = token?.attrGet('src');
if (src) {
token.attrSet('src', path.join(imageDir, src));
}

return original(tokens, idx, options, env, self);
};
}

核心代码是重写了图片渲染逻辑md.renderer.rules.image,在正常渲染图片之前修改了图片的src属性。

然后使用extendMarkdownIt调用插件:

1
2
3
4
5
6
7
export function activate(context: vscode.ExtensionContext) {
return {
extendMarkdownIt(md: MarkdownIt) {
return md.use(prefixifyImageURL, {imageDir: "assets"});
}
};
}

这里为了检查代码逻辑,图方便先写死了imageDir。

另外,还需要在package.json中将这个扩展注册为markdown-it插件:

1
2
3
"contributes": {
"markdown.markdownItPlugins": true
}

调试插件,终于能够正常修改图片的路径了。

3. 动态加载图片路径

接下来,要解决的问题就是如何针对每个markdown文件获取typora-root-url信息,并使用它作为图片所在目录。

我的第一个想法是把imageDir存到env中,这样只需要读取一次,然后之后直接从env中调用即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import fs from 'fs';
import matter from 'gray-matter';

function getImageDir(filePath: string): string | null {
const fileContent = fs.readFileSync(filePath, 'utf-8');
const { data } = matter(fileContent);
return data['typora-root-url'] ?? null;
}

export function prefixifyImageURL(md: MarkdownIt) {
const original = md.renderer.rules.image!;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
let imageDir = env.imageDir;

if (imageDir === undefined) {
const filePath = env.currentDocument.path;
imageDir = getImageDir(filePath);
env.imageDir = imageDir;
}

if (imageDir !== null) {
const token = tokens[idx];
const src = token?.attrGet('src');
if (src) {
token.attrSet('src', path.join(imageDir, src));
}
}

return original(tokens, idx, options, env, self);
};
}

上面的代码确实能够满足要求,但是有一个问题,就是对修改typora-root-url的响应非常不及时,有很大的滞后性。

于是,我又尝试了第二种方法,就是每次从YAML Front Matter里动态获取imageDir

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import frontMatter from 'markdown-it-front-matter';
import yaml from 'js-yaml';

let dynamicImageDir: any;

interface ImageOptions {
imageDir?: Function;
}

export function prefixifyImageURL(md: MarkdownIt, pluginOptions?: ImageOptions) {
const original = md.renderer.rules.image!;
md.renderer.rules.image = (tokens, idx, options, env, self) => {
const token = tokens[idx];
const src = token?.attrGet('src');
const imageDir = pluginOptions?.imageDir?.();
if (imageDir && src) {
token.attrSet('src', path.join(imageDir, src));
}

return original(tokens, idx, options, env, self);
};
}

export function activate(context: vscode.ExtensionContext) {
return {
extendMarkdownIt(md: MarkdownIt) {
return md.use(frontMatter, (fm: string) => {
const fmData = yaml.load(fm) as Record<string, any>;
const imageDir = fmData?.['typora-root-url'];
dynamicImageDir = imageDir;
}).use(prefixifyImageURL, {
imageDir: () => dynamicImageDir,
});
}
};
}

这样只要已修改typora-root-url的配置,立马就会在渲染中响应。

4. 支持图片链接的跳转和悬停预览

上面解决的是在右侧预览窗口里面的图片路径问题。另外还有一个需要适配的是在编辑界面的预览。

VSCode在鼠标指向图像链接的时候,会弹出该图像的预览。按住Ctrl单击,会跳转到对应的图片文件。我需要复现这两个功能。

这个部分让我自己写是完全不可能的,因此只能再次尝试vibe coding了。

好在,这次的结果还基本让人满意。

把需求喂给opencode之后,按住Ctrl单击的功能第一次就实现了,但是图像预览没有成功。不过让它自己进行debug,最终还是成功基本完成了这个任务。

不过,还是有一些小问题需要手动处理。

4.1. 问题一

其中一个是我提出的一个新的功能要求。上面实现的图像预览虽然成功了,但是由于有一些图像本身特别大,无法在预览框里完整显示(VSCode自带的图像预览是可以缩放到合适的大小的)。

尝试多次让opencode进行修改,都没有成功。无奈只能手动修改。

这里面最关键的点,在于VSCode到底是如何渲染markdown的图片的。

查看VSCode的源代码,从markdownRenderer.ts可以看到,它调用了parseHrefAndDimensions函数来识别图像的高度和宽度,该函数的定义位于htmlContent.ts,具体定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
export function parseHrefAndDimensions(href: string): { href: string; dimensions: string[] } {
const dimensions: string[] = [];
const splitted = href.split('|').map(s => s.trim());
href = splitted[0];
const parameters = splitted[1];
if (parameters) {
const heightFromParams = /height=(\d+)/.exec(parameters);
const widthFromParams = /width=(\d+)/.exec(parameters);
const height = heightFromParams ? heightFromParams[1] : '';
const width = widthFromParams ? widthFromParams[1] : '';
const widthIsFinite = isFinite(parseInt(width));
const heightIsFinite = isFinite(parseInt(height));
if (widthIsFinite) {
dimensions.push(`width="${width}"`);
}
if (heightIsFinite) {
dimensions.push(`height="${height}"`);
}
}
return { href, dimensions };
}

从这里可以看出,VSCode接受![](a.png|height=240)这样指定图片尺寸的方式。由此,我们可以根据图片的长宽比例指定预览的宽度或高度。

4.2. 问题二

另一个问题是显示的链接下划线部分,长度和VSCode显示的不一致。尝试让opencode进行修改,但是最终还是差了1个长度,而且固执地认为算的没有问题。

奇怪的是,它在思考链中的分析其实是正确的,但是最终计算的offset值始终不对。

好在,这个错误很容易修改。

5. 总结

最终的代码我放在了vscode-markdown-hexo仓库。虽然还有一些bug,但是基本上个人使用够了。

回过头来看这次vibe coding的经历,整体的评价还是正面的,因为利用它我完成了一个之前我个人不可能完成的任务。

但是问题还是比较多的,主要是涉及到一些不是很常见的功能的时候,AI无法找到对应的代码,于是就开始瞎编了,编完了还像模像样地告诉你没有问题。

回想一下,当时应该直接把官方文档对应的页面直接扔给AI,让它根据具体的文档来写代码,应该会好一些。

另外一个藏在VSCode源代码里面的功能,这个AI不知道情有可原,因为我没有在任何公开的地方找到这个用法的说明。

下次可以尝试让AI分析VSCode的源代码,看看它能否找到这个功能的实现方法。

总体来说,和我这几年使用AI的感觉是一致的:就是AI强于逻辑,但是弱在事实核查。

说到事实核查,我不得不祭出当年GPT-3.5刚出来的时候的几张截图:

呃。。。


  1. 需要在_config.yml中启用post_asset_folder: true↩︎

  2. 参考Typora的官方文档↩︎

  3. 利用插件Shift IM for Math ↩︎

  4. 参考之前的文章:使用VSCode编辑Markdown的几个常用设置 ↩︎

尝试使用 DeepSeek-OCR 2

作者 西风冷香
2026年2月4日 15:25

1月份,DeepSeek发布了DeepSeek-OCR 2,使用了新的视觉编码器。我尝试使用新的模型对小蓝本进行OCR,和之前的结果进行对比。

1. 代码

具体的识别代码见pdf2md.py,基本上和V1版本是一致的,稍微修改几个参数即可。

尝试过使用vLLM进行加速,但是没有成功。我的笔记本电脑显存有8G,但可分给vLLM的显存只有6.92G(对应的配置是gpu_memory_utilization=0.865),再高了就分配失败(即使关了独显直连也是这样)。这样加载完模型之后,还差0.38G才够KV缓存占用。无奈只能放弃了。

2. 进步

在下面的对比图片中,左侧是V1版本的结果,右侧是V2版本的结果。

  1. 能够正确识别多行公式并使用aligned环境。

  2. 漏图的情况大大减少,例如

  1. 对于数学公式内的相似符号、文字的识别正确率略有提升,例如

3. 退步

上面只是一些细微方面的进步,但是退步却是大面上的。

  1. 之前V1版本,对于简单的公式更倾向于识别成数学公式,而V2版本则更倾向于识别成普通的文字。例如

这点其实是V1版本的DeepSeek-OCR相较于MinerU和PaddleOCR-VL最让我满意的地方。而这个优势在V2模型中彻底丧失了。

这也带来的另外一个问题,就是有的时候识别出了数学公式,但是却没有把它放到正确的环境中,例如

这种情况在PaddleOCR-VL的识别结果中也多次出现。

  1. V2版本的DeepSeek-OCR虽然识别图像的准确率提高了很多,但是却出现了一个V1版本没有出现过的问题,就是在排版的时候,图片和图片的标题分开了,没有放到一起,例如

几乎所有的图片有这个问题。这又和PaddleOCR-VL表现一致了。

检查了一下区域检测结果,V1版本把图片的标题识别成了image_caption,而V2版本则是识别成了figure_title

又检查了一下之前PaddleOCR-VL的区域检测结果,果然也是识别成了figure_title。终于找到这个问题的原因了。

  1. V2版本还出现了一次大标题识别错误的情况:

看了一下区域监测结果:

识别区域是对的,但是不知道为什么最终的输出出了问题。这个问题之前从来没有出现过。

之前各个模型都有把大标题当成页眉忽略掉的情况(猜测这和标题下方的长实线有关),但是没有出现过只识别了一半的情况。

4. 整体评价

从最终的结果来看,DeepSeek-OCR 2的实际可用能力相较于原来的V1版本大大降低。如果选择一个版本的识别结果作为底本进行校对,我宁愿选择V1版本的识别结果。

V2版本带来的进步,第1个可以通过脚本进行批量替换解决,第2个和第3个都是只有在少数地方才能享受到的,而退步的前两种情况则是对几乎所有内容的识别都有影响。

尝试使用MinerU

作者 西风冷香
2025年11月18日 12:53

之前的文章的评论区里,有人建议试一试MinerU。其实在去年7月MinerU刚发布的时候,我就尝试过,但是印象中效果非常差,根本没法实际使用。

正好我的电脑里还存有当时配置好的虚拟环境,又重新试了一下,结果印证了我的记忆。由于问题实在太多,我就不放截图了,只是罗列一下它的问题:

  • 完全不能识别公式中的汉字
  • 所有多行公式无法换行
  • 经常漏识别公式的一部分(如最后的字母、特殊符号如平行等)
  • 经常无法识别分式
  • 偶尔会漏识别公式
  • 经常识别不出特殊符号(如相似)
  • 偶尔识别错误字母(例如p识别成Φ
  • 中英文混合的情况,不能识别英文之间的空格
  • 偶尔汉字识别错误
  • 偶尔公式后面多出东西(把公式的一部分重复识别成了文字)
  • 偶尔多行公式排版错误($$后不换行直接跟文字)
  • 偶尔行间公式排版错误(错误使用\$
  • 经常错误识别标点符号(例如逗号识别成双引号),经常漏识别标点符号

这些问题导致识别得到的结果错误非常多,已经到了不可用的地步。

不过看官方仓库里的说明,新版的MinerU使用vLLM部署之后能有90+的准确率,我决定再试一下。

1. 安装新版MinerU

由于使用vLLM部署比pipeline模式的准确率高很多,加之8G显存够用了,因此我直接使用vLLM方式进行部署。

安装也很简单:

1
2
uv venv --seed --python python3.13
uv pip install "mineru[all]"

此时的MinerU版本是2.6.4

接下来就可以使用了:

1
uv run mineru -p input/test.pdf -o output -b vlm-vllm-engine

进一步的配置选项可以参看官方选项,默认配置对于我来说就可以使用了。

这样临时使用一下还可以,但如果要大量文件需要识别,每次都会花大量时间在模型的加载上。这时可以使用server模式进行部署和调用。首先运行vllm-server:

1
uv run mineru-vllm-server

然后在另一个终端里运行

1
uv run mineru -p input/test.pdf -o output -b vlm-http-client -u http://localhost:30000

即可。

2. 和PaddleOCR-VL的对比

在下面的对比图片中,左侧是MinerU的结果,右侧是PaddleOCR-VL的结果。

2.1. 速度

同样是识别小蓝本(共8本),耗时33分21秒,比PaddleOCR-VL慢了约17%。

2.2. 优势

MinerU没有出现PaddleOCR-VL中的公式错行现象,基本上能够正确识别公式的位置。

另外,MinerU对于特殊数学符号(如平行、相似等)的识别准确率要高于PaddleOCR-VL和DeepSeek-OCR。

2.3. 劣势

虽然最新的MinerU和最初的版本相比,前面列出的问题大部分都得到了解决,但是还有一部分被部分继承了下来。

例如,简单的文字识别错误:

简单的字母识别错误:

简单的标点识别错误:

另外,MinerU还出现了图片分割不准确的情况:

这个问题PaddleOCR-VL和DeepSeek-OCR从来没有出现过。

2.4. 共同的问题

MinerU和PaddleOCR-VL都在同一类地方栽了。类似下图,

识别的结果如下:

可以看到,二者都不能正确区分题目的序号和公式部分。

而DeepSeek-OCR能够正确处理:

3. 总结

如果让我给这几个OCR模型进行排行的话,大致是

DeepSeek-OCR ≳ MinerU 2.6 (vllm) > PaddleOCR-VL >> PaddleOCR (V3)

DeepSeek-OCR的优势在于排版最好,但是在识别一些复杂公式(如重分式)的时候可能会出问题(感觉是识别之后被改了)。

最大的劣势是模型还是偏大,模型参数本身超过6G,因此我没有办法在本地使用vLLM加速推理,只能使用Transformers进行推理(同时需要借用一部分内存),因此速度相比后两个慢很多。

MinerU的公式识别整体是最好的,但是会犯一些弱智的错误(比如经常识别不了字母p)。

前面两个基本上不分伯仲,关键是看注重哪方面。

PaddleOCR-VL的最大问题在于公式混合排版的输出顺序有问题。

上面两个都可以在本地(8G显存)使用vLLM进行加速,因此速度都是可用的。

PaddleOCR就连基本的退化问题都没有解决。

另外,PaddleOCR对于表格的识别也比PaddleOCR-VL差很多。

使用VSCode编辑Markdown的几个常用设置

作者 西风冷香
2025年11月6日 11:40

1. 中文的自动换行

VSCode的默认换行算法无法正确处理中文段落的换行。因为中文不像英文每个单词之间都有空格,所以经常出现换行位置超过屏幕范围的情况。例如

可以看到,有十个汉字还在第一行的右边,需要拖动水平滚动条才能看见。

这个可以通过修改控制计算换行位置的算法[1]来解决。添加配置

1
"editor.wrappingStrategy": "advanced"

就可以解决这个问题。此时VSCode显示的内容就变成了

这时换行位置基本就正常了。偶尔超出屏幕一点,最多也就吞掉半个字。

2. 粘贴图片

之前的时候,一旦需要添加图片,我都会转而使用Typora编辑Markdown文件。

Typora的优点是具有可视化,表格编辑方便;VSCode的优点是可以在编辑数学公式的时候自动切换中英文输入法(通过我自己修改过的插件,原插件已失效)。

好在,同时需要大量编辑数学公式和大量添加图片的情况对于我来说基本上不存在。

不过,新版的VSCode其实已经支持了图片复制和插入[2]。直接把图片拖入到VSCode中,可以看到「按住Shift以放入编辑器」的提示。此时按住Shift再放开鼠标,图片就被复制到Markdown文件所在的目录,并插入到了Markdown中。

不过,根据Hexo的文档结构,我需要把图片放到和Markdown文件同名的文件夹中,因此需要添加配置:

1
2
3
"markdown.copyFiles.destination": {
"/source/**/*": "${documentBaseName}/"
},

这样图片就能直接复制到所需的位置了。

3. 预览SVG图像

我使用 pdf2svg 生成的SVG图像都是透明背景的,而VSCode又是深色背景,此时打开SVG图片根本看不清。添加配置

1
"svg.preview.background": "white"

这样就默认是白色背景了。

4. 数学公式

我的博客现在设置了 text-autospace: normal,这样浏览器可以自动在中英文字符之间增加间距,就不用手动添加空格了。

但是可惜的是,这个对于使用KaTeX插入的数学公式并不起作用,因此还是需要在公式两边手动添加空格。

我的方法是使用快捷键 Ctrl+m,手动添加数学环境及空格。快捷键设置如下:

1
2
3
4
5
6
7
8
{
"key": "ctrl+m",
"command": "editor.action.insertSnippet",
"when": "editorTextFocus && editorLangId =~ /latex|latex-expl3|typst|markdown/",
"args": {
"snippet": " $${TM_SELECTED_TEXT}$1$ $0"
}
}

但是这会带来一个问题。如果数学公式之后是标点符号,此时是不需要添加空格的,因此还需要手动删掉。如果每次输入的时候删除的话会非常麻烦。在使用LaTeX的时候,我是使用 latexindent 来进行完成格式化的,对应的 localSettings.yaml 配置如下:

1
2
3
replacements:
- substitution: s/([,。;:、!?()【】])\h+(\$+)/$1$2/g
- substitution: s/(\$+)\h+([,。;:、!?()【】])/$1$2/g

但是Markdown文件没有类似的软件。

我最终是通过「Run on Save」插件来完成这个任务的。安装插件以后,添加配置

1
2
3
4
5
6
"runOnSave.commands": [
{
"match": ".*\\.md$",
"command": "sed -i -E -e 's/\\$+\\s+([,。!?;:、()【】「」『』])/\\$\\1/g' -e 's/([,。!?;:、()【】「」『』])\\s+\\$+/\\1\\$/g' ${file}",
},
]

这样在保存Markdown文档的时候会自动运行 sed 命令,删掉多余的空格。


  1. 更新来自1.42版本 ↩︎

  2. 更新来自1.79版本 ↩︎

在WSL上挂载U盘

作者 西风冷香
2025年11月5日 19:38

记录一下在WSL上成功挂载U盘的过程。

1. 连接USB设备

按照官方文档,安装usbipd-win,即可从WSL访问USB设备。

正常使用的大致流程如下:

graph LRA(共享) --> B(连接) --> C(挂载) --> |&thinsp;使用&thinsp;|D(卸载) --> E(断开连接) --> F(解除共享)

查看可用的USB设备:

1
usbipd.exe list

示例输出:

1
2
3
Connected:
BUSID VID:PID DEVICE STATE
2-3 0781:55ab USB 大容量存储设备 Not shared

使用BUSID指定要共享的USB设备(需要管理员权限):

1
sudo.exe usbipd.exe bind --busid 2-3

然后连接该设备:

1
usbipd.exe attach --wsl --busid 2-3

这时使用 lsusb 可以看到USB设备已经识别到了:

1
Bus 002 Device 003: ID 0781:55ab SanDisk Corp. Dual Drive

但是使用 lsblk 看不到该设备。原因是WSL的内核使用 CONFIG_USB_STORAGE=m 编译,需要手动加载对应的模块:

1
modprobe usb-storage

这时再运行 lsblk 就能看到新的设备了:

1
2
sde      8:64   1 114.6G  0 disk
└─sde1 8:65 1 114.6G 0 part

2. 挂载USB设备

如果U盘是FAT格式,这个时候就可以直接挂载了。

但是由于我的U盘比较大,之前格式成了exFAT格式。而WSL的内核没有编译exFAT(以及NTFS)的支持,因此要挂载U盘的话,需要重新编译内核。

不过编译内核也很简单,从官方内核仓库下载最新的内核,解压后进入文件夹,运行

1
2
cp Microsoft/config-wsl .config
make menuconfig

进入图形化配置界面,选择

1
2
3
4
5
File systems  --->
DOS/FAT/NT Filesystems --->
<M> exFAT filesystem support
<M> NTFS Read-Write file system support
<*> activate support of external compressions lzx/xpress

我直接把exFAT和NTFS的支持都加上了。

可以将配置保存:

/etc/kernel/config.d/usb.config
1
2
3
CONFIG_EXFAT_FS=m
CONFIG_NTFS3_FS=m
CONFIG_NTFS3_LZX_XPRESS=y

然后使用

1
./scripts/kconfig/merge_config.sh Microsoft/config-wsl /etc/kernel/config.d/usb.config > .config

生成新的.config文件。

然后编译内核:

1
make -j$(nproc)

安装模块:

1
make INSTALL_MOD_PATH="$PWD/modules" modules_install

最新的WSL使用vhdx挂载modules:

1
sudo ./Microsoft/scripts/gen_modules_vhdx.sh "$PWD/modules" $(make -s kernelrelease) modules.vhdx

然后把内核映像 arch/x86/boot/bzImage 和内核模块 modules.vhdx 放到Windows的硬盘上(我放在了 D:\Temp)。

.wslconfig 中配置自定义内核:

1
2
3
[wsl2]
kernel=D:\\Temp\\bzImage
kernelModules=D:\\Temp\\modules.vhdx

然后重启WSL即可。

要挂载exFAT的U盘,还需要安装对应的软件:

1
emerge --ask sys-fs/exfatprogs

(如果是挂载NTFS格式的设备,需要则安装 sys-fs/ntfs3g。)

加载所需的内核模块:

1
modprobe usb-storage exfat

然后就可以正常挂载U盘了:

1
mount /dev/sde1 /media

使用完了之后断开连接并解除共享:

1
2
usbipd.exe detach --busid 2-3
sudo.exe usbipd.exe unbind --busid 2-3

在使用LuaLaTeX时控制中英文字符的间距

作者 西风冷香
2025年11月5日 13:57

1. 问题描述

之前在从XeLaTeX切换到LuaLaTex的时候,发现二者对中文字符和英文字符(包括数学公式)之间的间距处理不一样。在源代码中是否添加空格会对最终的间距有影响。

以下面的示例文档为例:

1
2
3
4
5
6
7
8
9
10
\documentclass{ctexart}
\begin{document}
中English文

中 English 文

$\sin(x)$

$\sin(x)$
\end{document}

这是XeLaTeX编译的结果,可以看到,不管加不加空格,得到的PDF间距都是一样的。

XeLaTeX编译的结果

下面是LuaLaTeX编译的结果:

LuaLaTeX编译的结果

这时不加空格时的间距要小于加空格时的间距。

因此,如果使用LuaLaTeX进行编译,整个文档的风格必须统一,要么都加空格,要么都不加,否则就会很难看。

2. 解决方法

根据LdBeth[1]给出的提示,找到了一个解决方法,可以通过设置 xkanjiskip 来解决这个问题。

在导言区加入:

1
2
3
4
5
6
7
8
\usepackage{iftex}
\ifluatex
\usepackage{luatexja}
\ltjsetparameter{
xkanjiskip={\fontdimen2\font plus \fontdimen3\font minus \fontdimen4\font},
alxspmode={`\%,3}
}
\fi

这样使用LuaLaTeX编译出来的文档无论加空格与否,间距都是一致的。

另外,默认的设置在百分号「%」和中文之间没有空格,上面同时修复了这个问题。

命令解释:

LuaTex-ja把一部分Unicode区段(U+0080~U+10FFFF)分为 JAChar(包含CJK字符等)和 ALchar(包含西文字母等),然后使用 kanjiskip 控制 JAChar 之间的间距,使用 xkanjiskip 控制 JACharALchar 之间的间距。

上面通过修改 xkanjiskip 使得中英文字符间距表现和XeLaTeX一致。

但是,并不是所有的 JACharALchar 之间都需要增加间距。LuaTex-ja使用 jaxspmode 控制是否需要在 JAChar 前后增加间距,使用 alxspmode 控制是否需要在 ALchar 前后增加间距。

上面通过将 % 对应的模式设为 3(或 allowed),使得在对应字符前/后都允许增加间距。(默认设置是「%」前面有空格,但后面没有。)


  1. https://www.zhihu.com/question/1904935413283522195/answer/1968692664439407558 ↩︎

使用vLLM框架加速PaddleOCR-VL

作者 西风冷香
2025年11月4日 17:15

之前直接使用PaddlePaddle进行PaddleOCR-VL的推理,优点是安装相对简单,但缺点是速度太慢了,推理速度只有之前PaddleOCR的40%,这已经比DeepSeek-OCR快不了多少了。这个速度识别几本书还行,多了的话速度根本不够。

造成速度慢的主要原因是PaddleOCR-VL模型在不使用推理框架的时候,只支持使用 batch_size=1 进行推理,因此GPU根本跑不起来。

PaddleOCR-VL是支持使用vLLM框架进行加速的。之前没有使用,主要是在安装的时候发现需要装特定版本的 flash-attn,于是就放弃了。

编译安装 flash-attn 需要的内存极其恐怖。如果不做任何配置的话,编译过程中 ninja 会自动根据CPU内核数量开启并行编译,而 flash-attn 在使用nvcc编译的时候又默认开启 sm_80sm_90sm_100sm_120 四个编译目标,因此在我的电脑上,实际会同时运行 32×4=12832\times 4=128 个进程进行编译。

这样的话,即使我给WSL分配了50G的内存和75G的交换文件,也很快就被爆掉了。

之前在系统里安装这个包的时候,我是把 MAX_JOBS 设为3,然后修改源代码只开启 sm_80sm_89 两个编译目标,这样只有6个进程同时进行编译,才不会爆内存。

不过如此设置的话,编译速度也就很感人了,编译一次就要超过1小时。

作为对比,下面是我的电脑上常用的「大」软件的编译时间:

1
2
3
4
5
6
7
8
# qlop -cmv sci-ml/caffe2-2.9.0 sci-ml/flash-attention-2.8.3 sys-devel/gcc-14.3.0 llvm-core/llvm-21.1.4 llvm-core/clang-21.1.4 dev-lang/rust-1.91.0
dev-lang/rust-1.91.0: 2157″ average for 1 merge
llvm-core/clang-21.1.4: 2149″ average for 1 merge
llvm-core/llvm-21.1.4: 2842″ average for 1 merge
sci-ml/caffe2-2.9.0: 2425″ average for 1 merge
sci-ml/flash-attention-2.8.3: 1:11:42 average for 1 merge
sys-devel/gcc-14.3.0: 2230″ average for 1 merge
total: 3:11:05 for 6 merges

可能也就只有之前编译TensorFlow的时间比它长了。

好在,最新的文档给出了 flash-attn 的预编译包的仓库,里面有各个版本的编译好的包可以直接使用,因此就可以配置vLLM了。

1. 安装vLLM推理框架

按照官方提示,

由于推理加速框架可能与飞桨框架存在依赖冲突,建议在虚拟环境中安装。

我看了一下,主要是PaddlePaddle和vLLM依赖的PyTorch对于一系列nvidia包的不同(paddlepaddle-gpu==3.2.0 依赖12.9版本的CUDA,而 vllm==0.10.2 依赖 pytorch==2.8.0 及12.8版本的CUDA)。

所以新建一个虚拟环境,并安装vLLM推理框架:

1
2
3
4
uv venv --seed -p python3.12 .venv_vllm
VIRTUAL_ENV=.venv_vllm uv pip install "paddleocr[doc-parser]"
VIRTUAL_ENV=.venv_vllm uv pip install https://github.com/mjun0812/flash-attention-prebuild-wheels/releases/download/v0.3.14/flash_attn-2.8.2%2Bcu129torch2.8-cp312-cp312-linux_x86_64.whl
VIRTUAL_ENV=.venv_vllm uv run paddleocr install_genai_server_deps vllm

注意:

  • 要先安装预编译的 flash-attn 包,否则直接运行安装vLLM框架的命令会报错。
  • 要根据Python版本、Pytorch版本(目前版本的vLLM依赖的是2.8)来选择对应的 flash-attn 包。具体的链接在上面仓库的主页可以找到。

可选项:

1
2
VIRTUAL_ENV=.venv_vllm uv pip install flashinfer-python flashinfer-cubin
VIRTUAL_ENV=.venv_vllm uv pip install flashinfer-jit-cache --default-index https://flashinfer.ai/whl/cu129

貌似有一点点速度提升?

然后就可以启动vLLM服务了:

1
2
3
4
5
VIRTUAL_ENV=.venv_vllm uv run paddleocr genai_server \
--model_name PaddleOCR-VL-0.9B \
--backend vllm \
--port 8118 \
--backend_config <(echo -e 'gpu-memory-utilization: 0.8\nmax-model-len: 8192')

这里务必要根据自己的显卡对vLLM服务参数进行调整。作为参考,我的显卡是RTX4060,显存大小是8G。

从PaddleX的配置文件可以看到,默认传给vLLM的参数为:

1
2
3
4
5
trust-remote-code: True
gpu-memory-utilization: 0.5
max-model-len: 16384
max-num-batched-tokes: 131072
api-server-count: 4

其中必须调整的参数:

gpu_memory_utilization 是vLLM可以占用的显存比例,如果是默认的0.5即50%,由于我的笔记本电脑显存只有8G,使用一半的话就只有4G了,这是肯定不够的,因此就直接报错了。

根据之前的经验,在使用PaddlePaddle进行推理的时候就需要5~6G的显存。因此我给vLLM分配了80%的显存,这样就足够了。

另外,在开启独显直连的情况下,即使不进行什么复杂任务,显存也要用掉0.8~1.2G。计算可用显存的时候要把这部分刨去。

max_model_len 是模型的上下文长度。默认值对我来说太大了,直接就报错说显存不够。

在没有运行其它占用显存的程序的时候,把 max_model_len 设为 8192 是可以运行的。但我也遇到过一次启动报错的情况,因此可以把它再调小一些。

不过,这个值也不能太小了,至少是 4096+14=41104096+14=4110。因为输入要用掉14个tokens,默认的最大输出是4096个tokens,因此至少要不小于这两个加起来的数量,否则推理的时候就会报错。

实测在我的电脑上最低可用的配置为

1
2
gpu-memory-utilization: 0.79
max-model-len: 4110

不过还是尽可能把max-model-len拉大一些比较好,否则实测在识别一些大型表格的时候会漏掉一些部分。

当屏幕出现 "GET /health HTTP/1.1" 200 OK 的字样时就说明vLLM服务启动成功了。

在vLLM服务的启动过程中,占用的显存会超过8G,如图

不过很快就回落了,最终在推理的时候也不需要借用内存,因此不会影响推理速度。

2. 调用vLLM推理服务

在另一个终端可以直接使用命令行进行调用:

1
2
3
4
5
uv run paddleocr doc_parser \
--input input/test.pdf \
--save_path output \
--vl_rec_backend vllm-server \
--vl_rec_server_url http://localhost:8118/v1

也可以使用Python进行调用:

1
2
3
4
5
6
7
pipeline = PaddleOCRVL(
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_chart_recognition=False,
vl_rec_backend="vllm-server",
vl_rec_server_url="http://localhost:8118/v1",
)

后续的使用方法就和之前一样了。

3. 速度对比

使用vLLM会带来巨大(超过20倍)的速度提升。在我的电脑上,跑完8本小蓝本,只需要27分41秒(均衡模式)。在推理过程中,显卡基本保持在80W的功率。

作为对比,在Kaggle上使用 GPU T4x2,并开启双卡双进程编译,需要5小时40分。

之前在本地运行的时候没有总计时,但是基本每本书都需要1小时以上的时间,其中最厚的《三角形与四边形》甚至需要将近两个小时。

4. 在Kaggle上部署

在Kaggle上部署的方法和在本地部署是一致的,显卡选择 GPU T4x2,只是需要调整一下 flash-attn 预编译包的版本。

不过,参考官方文档

推理方式支持的 GPU Compute Capability
vLLM≥ 8 (RTX 3060,RTX 5070,A10,A100, ...)
7 ≤ GPU Compute Capability < 8 (T4,V100,...)支持运行,
但可能出现请求超时、OOM 等异常情况,不推荐使用

实测也确实如此。在Kaggle上部署之后,简单调用识别个图片还是没问题的,一旦涉及到多页PDF就经常报错。

而且此时在Kaggle上的推理速度还不到本地(RTX4060 8G)的一半,因此完全没有使用的必要了。

关于PaddleOCR-VL和PaddleOCR对数学类书籍识别的对比

作者 西风冷香
2025年11月3日 10:32

之前成功安装PaddleOCR-VL之后,就尝试使用再次对小蓝本进行OCR,这样就可以和之前的结果进行对比。

其实上篇文章本来就想写这个的,结果写着写着发现最新版的PaddleOCR-VL支持Windows和Kaggle部署了,然后就跑题了。。。

这里提一句,PaddleOCR-VL的文档有两个,分别在

PaddleOCR文档

PaddleOCR-VL使用教程

PaddleX文档

PaddleOCR-VL介绍

里面。

目前PaddleOCR文档里面的内容更新得快一些。

1. 坎坷的使用过程

使用PaddleOCR-VL的过程的非常坎坷。出现的问题在前文中已有叙述。开始是根本运行不了。后来升级了PaddleX之后,成功运行模型,但是又遇到了显存不足和程序卡死的问题。

刚开始使用我就发现使用PaddleOCR-VL占用的显存甚至比DeepSeek-OCR还要多,和官方宣传的「小」模型完全不符。而且最大的问题是,随着推理的进行显存占用越来越多。

大致猜测一下原因,应该是由于笔记本默认把一半的内存列为共享显存,导致程序以为实际可以使用8+32=40G显存,所以就不着急释放占用的显存了。

在Kaggle上运行的时候,显存也在一直增加,但是最终没有报错。

好在,最终是通过手动释放显存和修改显存分配策略解决了。

确认可以正常运行之后,我想让它利用晚上睡觉的时间跑完OCR。看着识别第1本书运行正常就去睡觉了。然后早上起来发现就只有前两本书正常跑完了,跑到第3本书的时候程序就卡死了。后来我又多次复现了这个问题,但是始终没有找到原因。

不过中间有一次我把下载的模型删掉之后重新下载,后来就没有再出现这个错误了。

2. 结果对比

在下面的对比中,如果没有特殊说明,默认左侧是PaddleOCR-VL的结果,右侧是PaddleOCR(PP-StructureV3)的结果。

2.1. 退化

相交于PaddleOCR,PaddleOCR-VL最大的进步在于此,终于没有再出现退化的情况了(可喜可贺)。

2.2. 排版

2.2.1. 进步

应该说,PaddleOCR-VL的排版模型有了很大的进步,例如

2.2.2. 缺点

但问题也没有完全解决,例如

查看生成的可视化图像,可以看到

PaddleOCR-VL把每行里面居中的公式都视为了行间公式。这个本身到不要紧,但是最后把它们合并视为一个公式,这就造成了最终的排版错误。

我尝试把产线配置里面 layout_merge_bboxes_modedisplay_formula(有注释)对应的选项从 large 改为 union,这时可以看到

模型其实最开始是识别到了每行的公式,然后又把它们组合了起来。

如果改为 small 的话,倒是不组合到一起了,但是输出顺序还是有问题:

而这是DeepSeek-OCR的识别结果:

在修改的时候,一定不能inline_formula 对应的选项改成 small,否则会造成有含有行内公式的段落只保留公式,不识别文字:

不过,又看了一下识别正确的情况:

没想明白它到底是如何区分行内公式和行间公式的。。。

2.3. 图片

2.3.1. 进步

PaddleOCR-VL的图片识别有所进步,终于能够分割一行的多个图片了:

不过最终还是过度分割了。。。

另外,PaddleOCR-VL也没有出现之前识别到图片里面的字母而没有正确识别出图片的情况:

PaddleOCR-VL对于图片标题的识别也有进步:

能够正确区分图片标题,不会把它和其它部分混到一起。

2.3.2. 缺点

不过,从上面个对比中也可以看到PaddleOCR-VL的问题。就是它明明已经识别到了图片和图片标题,但是却没有把它们放到一起。这就导致大部分图片和对应的标题都是分开的。这点比起PaddleOCR模型是大大的退步。

看了一下版面阅读顺序识别的结果,里面没有指定图片和图片标题的输出顺序,有可能是这个原因造成的:

2.4. 数学公式

2.4.1. 进步

PaddleOCR-VL相比之前的模型在数学公式方面的进步主要有一下几点:

其一,就像之前说的,没有出现退化的情形。

其二,PaddleOCR-VL在特殊符号的上的识别上要好的多,甚至比DeepSeek-OCR还要强。例如,

PaddleOCR-VL在大部分情况下能够正确识别「相似」、「平行」和「垂直」的符号,不像之前PaddleOCR模型大部分都识别不出来。

不过还是有识别错误的情况:

其三,之前PaddleOCR经常出现一个愚蠢地错误,就是识别不出公式里面的「减号」:

PaddleOCR-VL没有发现这个问题。

其四,基本没有出现因为行内公式导致的整段文字漏掉的情况:

其五,PaddleOCR-VL对于文字和公式的分割要准确很多:

虽然有时还是会犯错误。

但是,对于简单的字母和公式,PaddleOCR-VL还是更倾向于不把它们识别成公式。这点倒是没有改。

2.4.2. 缺点

相交于之前,PaddleOCR-VL也带来了两个以前从没有出现的问题。

一个是PaddleOCR-VL多次出现识别了公式但是没有把它放到正确环境里面的情况,如

看了一下显示版面区域检测的结果,确实是识别出行内公式了:

看来是最终输出部分的问题。

另一个是PaddleOCR的行间公式的输出比较混乱。

同一个公式,例如下面的公式:

sin(x)\sin(x)

DeepSeek-OCR转换为 \[\sin{x}\],PaddleOCR转换为 $$\sin{x}$$,都没有问题。

而到了PaddleOCR-VL,它则是一会儿转换为

1
$$ \sin{x} $$ 

(注意 $$ 符号两边都有空格),一会儿转换为

1
2
3
4
5
 $$ 

\sin{x}

$$

(还是 $$ 符号两边都有空格。)

一会儿又转换为

1
2
$$ \sin{x}
$$

有时还会把行间公式和文字放到一行。

这本身倒没什么问题,只是在做正则替换的时候要格外小心。

看来PaddleOCR-VL和PaddleOCR对数学公式的训练用的是完全不同的样本?

3. 总体评价

整体来看,PaddleOCR-VL相比PaddleOCR有明显的进步,但是仅对数学书籍的OCR识别来看,效果还是不如DeepSeek-OCR。

问题不是出自文字识别或公式识别本身(这方面PaddleOCR-VL实际上更强一些),而是出在排版算法上。

尝试使用PaddleOCR-VL

作者 西风冷香
2025年11月2日 11:53

强烈建议使用vLLM进行部署,这样不会遇到本文中提到的各种显存问题,部署方法可以参考后面这篇文章

根据官方的说明:

推理过程中有时出现 OOM 问题
默认的 PaddlePaddle 动态图推理方式显存占用波动较大,处理复杂图像时峰值显存使用量可能较高。如需获得更稳定的显存占用表现,建议使用 vLLM、SGLang 等专用推理加速框架进行部署。

实际表现确实如此。原始的部署方式在处理一些尺寸较大的图片(即使图片本身比较小)时,即使有40G的显存也可能会报显存不足的错误,参考这个issue。我在使用自己的图片是也复现过这个问题。

另外,使用vLLM框架部署之后就没有再遇到过本文提到的各种显存的问题。

之前和DeepSeek-OCR进行对比,使用的是PaddleOCR,而不是当时的SOTA模型PaddleOCR-VL,原因也很可笑,就是按照官方文档安装之后居然无法运行,于是只能放弃了。

好在前几天释出的3.3.6版本的PaddleX解决了这个问题,本地的PaddleOCR-VL终于能正常运行了。

1. 安装PaddleOCR-VL

1.1. 在WSL上进行部署

之前已经安装了PaddleOCR:

1
2
3
4
export UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"
uv venv --seed --python python3.12
uv pip install paddlepaddle-gpu==3.2.0 --default-index https://www.paddlepaddle.org.cn/packages/stable/cu129/
uv pip install "paddleocr[all]"

如果要使用PaddleOCR-VL产线,按照官方说明,必须安装特殊版本的 safetensors:

1
uv pip install https://paddle-whl.bj.bcebos.com/nightly/cu126/safetensors/safetensors-0.6.2.dev0-cp38-abi3-linux_x86_64.whl

另外务必确认PaddleX的版本:

1
uv pip install paddlex==3.3.6 paddleocr==3.3.1

或者直接升级也可以:

1
uv pip install -U paddlex paddleocr

之后就可以使用PaddleOCR-VL了。具体的代码参考官方文档

另外吐槽一句,PaddleOCR的3.3.1版本的更新日志里明明白白地写了「修复了PP-StructureV3和PaddleOCR-VL的文档图像预处理开关不生效的问题」,结果根本没有解决这个问题。

明明默认的PP-StructureV3.yaml里面写了 use_doc_preprocessor: False,但还是会调用对应的「文档图像预处理产线」。

之前发现这个问题,是因为我在识别一张课程表的时候,居然连标题都识别错了,而且错的特别离谱。后来发现是因为「文档图像预处理产线」调用了「文本图像矫正模块」,导致这个图片被裁掉了一圈,标题也被裁掉了一半,难怪识别不对。

因此,如果图片或者PDF文件非常规整的话,创建产线的时候务必要使用 use_doc_unwarping=False 关掉「文本图像矫正模块」。或者直接修改产线配置文件也可以。

我在官方示例的基础上稍微做了一下修改,除了保存markdown文件及图片外,还把所有生成的可视化图像转换成PDF保存。其中 pil_to_pdf_img2pdf 函数来自run_dpsk_ocr_pdf.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from pathlib import Path
from paddleocr import PaddleOCRVL

input_dir = Path("./input")
output_dir = Path("./output")

pipeline = PaddleOCRVL(
use_doc_orientation_classify=False,
use_doc_unwarping=False, ## 非必要一定要关掉!!!
use_chart_recognition=False,
)


def process_pdf_file(pdf_path: Path, pipeline, output_dir: Path) -> Path:
print(f"🤖 Processing PDF file: {pdf_path}")

output = pipeline.predict_iter(input=str(pdf_path), use_queues=True)

markdown_list = []
markdown_images = []
res_images = []

for res in output:
index = res.get("page_index") + 1
print(f"🎲 parsing page {index} of file {pdf_path.name} ...")
md_info = res.markdown
markdown_list.append(md_info)
res_images.append(res.img)
res.save_to_xlsx(save_path=output_dir)
markdown_images.append(md_info.get("markdown_images", {}))

markdown_texts = pipeline.concatenate_markdown_pages(markdown_list)

## 生成输出文件路径
mkd_file_path = output_dir / f"{pdf_path.stem}.md"
mkd_file_path.parent.mkdir(parents=True, exist_ok=True)

## 写入Markdown文件
print("🚀 Saving markdown file ...")
with open(mkd_file_path, "w", encoding="utf-8") as f:
f.write(markdown_texts)

## 保存可视化图像
for layout in res_images[0].keys():
layout_pdf = output_dir / f"{pdf_path.stem}_{layout}.pdf"
print(f"🚀 Saving {layout} results to: {layout_pdf}")
pil_to_pdf_img2pdf([item[layout] for item in res_images], layout_pdf)

## 保存图像
print("🚀 Saving images in markdown ...")
for item in markdown_images:
if item:
for path, image in item.items():
file_path = output_dir / path
file_path.parent.mkdir(parents=True, exist_ok=True)
image.save(file_path)

return mkd_file_path


for pdf_path in input_dir.glob("*.pdf"):
process_pdf_file(pdf_path, pipeline, output_dir)

1.2. 在Windows上部署

今天又看了一下最新文档,对应的Windows版本的safetensors预编译包也有了,因此理论上在Windows上也能正常运行了。

1
2
3
4
5
6
$env:UV_LINK_MODE="symlink"
$env:UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"
uv venv --seed -p python3.12
uv pip install paddlepaddle-gpu==3.2.0 --default-index https://www.paddlepaddle.org.cn/packages/stable/cu129/
uv pip install "paddleocr[doc_parser]"
uv pip install https://xly-devops.cdn.bcebos.com/safetensors-nightly/safetensors-0.6.2.dev0-cp38-abi3-win_amd64.whl

尝试运行

1
uv run paddleocr doc_parser -i https://paddle-model-ecology.bj.bcebos.com/paddlex/imgs/demo_image/paddleocr_vl_demo.png --save_path output

然后就华丽地报错了:

1
2
RuntimeError: Exception from the 'vlm' worker: (NotFound) The kernel `fused_rms_norm_ext` is not registered.
[Hint: Expected iter != kernels_.end(), but received iter == kernels_.end().] (at ..\paddle\phi\core\kernel_factory.cc:276)

好在可以解决,手动进行hacking,把 self.fuse_rms_norm = True 改为 False

1
sed -i "s/self\.fuse_rms_norm = True/self.fuse_rms_norm = False/g" .\.venv\Lib\site-packages\paddlex\inference\models\doc_vlm\modeling\paddleocr_vl\_config.py

这时终于可以正常运行了。

这里再吐槽一下,PaddleOCR-VL产线需要用的PP-DocLayoutV2模块,但是不知道为什么,居然需要下载两次放到不同的目录:

WindowsTerminal_0z74Y2visD

1.3. 在Kaggle上部署

如果要在Kaggle上部署的话,GPU务必选择 GPU T4 x2不要选择 GPU P100

安装过程:

1
2
3
4
! pip install paddlepaddle-gpu==3.2.0 -i https://www.paddlepaddle.org.cn/packages/stable/cu126/
! pip install -U "paddleocr[all]"
! pip install https://paddle-whl.bj.bcebos.com/nightly/cu126/safetensors/safetensors-0.6.2.dev0-cp38-abi3-linux_x86_64.whl
! pip install img2pdf

然后就可以使用了。

需要注意的是,如果要使用双卡进行推理的话,直接指定 device="gpu:0,1" 是不行的,需要使用多进程并行推理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import sys
from multiprocessing import Manager, Process
from queue import Empty

def worker(device, task_queue, output_dir):
pipeline = PaddleOCRVL(
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_chart_recognition=False,
device=device,
)

while True:
try:
input_path = task_queue.get_nowait()
except Empty:
break

try:
output_path = process_pdf_file(input_path, pipeline, output_dir)
print(f"✅ Processed {repr(str(input_path))}! Markdown file saved to: {output_path}")
except Exception as e:
print(f"Error processing {input_path} on {repr(device)}: {e}", file=sys.stderr)


with Manager() as manager:
task_queue = manager.Queue()
for pdf_file in input_dir.glob("*.pdf"):
task_queue.put(pdf_file)

processes = []
for device in ["gpu:0", "gpu:1"]:
p = Process(
target=worker,
args=(device, task_queue, output_dir)
)
p.start()
processes.append(p)

for p in processes:
p.join()

最后再吐槽一下,在Kaggle上部署PaddleOCR-VL的最大障碍其实是paddlepaddle的下载速度。安装一次paddlepaddle经常就得花半小时,有时甚至下载速度甚至会降到几百K,都快赶上百度网盘了。

2. 产线文件

实际使用的产线文件在PaddleX的configs/pipelines目录下。可以把它链接到本目录下,方便后续查看和修改:

1
ln -s .venv/lib/python3.12/site-packages/paddlex/configs/pipelines

常用的一些PaddleOCR产线与PaddleX产线注册名的对应关系如下:

PaddleOCR 产线PaddleX 产线注册名
通用 OCROCR
PP-StructureV3PP-StructureV3
PP-ChatOCRv4PP-ChatOCRv4-doc
通用表格识别 v2table_recognition_v2
公式识别formula_recognition
印章文本识别seal_recognition
文档图像预处理doc_preprocessor
文档理解doc_understanding
PP-DocTranslationPP-DocTranslation
PaddleOCR-VLPaddleOCR-VL

可以在对应的yaml文件中看到具体的产线配置。

3. 遇到的问题

之前在使用PP-StructureV3的时候,一切都很正常。但是使用PaddleOCR-VL就出现了各种各样的问题。

3.1. 显存不释放

在本地运行PaddleOCR-VL的时候,经常出现占用显存不释放,然后还请求新的显存的情况。由于我是在笔记本电脑上运行,显存用完之后会直接借用内存。这不会报错,但是会大大影响推理速度。

目前的解决方法有两个:

其一,在运行 pipeline.predict_iter 之后、访问其结果之前,手动进行垃圾回收和显存释放:

1
2
3
4
5
import gc
import paddle

paddle.device.cuda.empty_cache()
gc.collect()

此时在任务管理器里会观察到占用的显存会有一次明显下降:

WindowsTerminal_tUBroI2ib1

但是使用PP-StructureV3的话,添加这个命令就不会观察到显存占用下降。

其二,设置环境变量:

1
2
3
import os

os.environ["FLAGS_allocator_strategy"] = "naive_best_fit"

需要注意的是,这个命令必须在paddle库导入之前设置才能生效。

奇怪的是,在这个设置下,运行PP-StructureV3产线直接报错,说显存不足。默认的 auto_growth 策略反而能够正常运行。

双管齐下,PaddleOCR-VL在推理PDF时占用的显存大概在5G左右,只是偶尔会波动一下需要借用内存:

WindowsTerminal_4uvbqz2pYC

但是很快就回落了。这样对性能影响不大,还可以接受。

3.2. 程序卡死

在使用PaddleOCR-VL的时候,好几次遇到程序卡死的情况:

WindowsTerminal_8h0xtk4fB9

可以看到模型已经被加载,而且PDF也已经载入了,但是没有进行推理。使用strace看一下进程:

WindowsTerminal_mHJXN4M1GI_1

这是出现死锁了?

只能强行杀掉了。

不过,今天在删掉模型缓存之后再次运行了几次都没再遇到这种情况。不知道和缓存有没有关系。

3.3. 使用生成器

关于显存占用,还有一点需要注意。如果是使用

1
2
3
from paddleocr import PaddleOCRVL

pipeline = PaddleOCRVL()

创建产线,建议使用

1
output = pipeline.predict_iter(input=input_file)

进行推理。这样返回一个生成器,之后循环处理的时候再实际进行推理。

使用 PPStructureV3 创建产线时的情况和上面是一样的

但如果是使用

1
2
3
from paddlex import create_pipeline

pipeline = create_pipeline(pipeline="PaddleOCR-VL")

创建产线,可以直接使用

1
output = pipeline.predict(input=input_file)

来进行推理。这时直接返回的就是一个生成器。

鬼知道为什么都是predict,返回值的居然不一样。。。

关于DeepSeek-OCR和PaddleOCR对数学类书籍识别的对比

作者 西风冷香
2025年10月30日 17:06

最近跑通了DeepSeek-OCR和PaddleOCR对于PDF的识别流程,于是尝试用它们来识别完整的书籍。

如果OCR的结果基本可用的话,就能减少很多录题的工作。

这次我选择初中的小蓝本(《数学奥林匹克小丛书》)作为测试,因为我手头正好有第三版的电子版,而且扫描的非常好。

1. 运行OCR

DeepSeek-OCR在本地也能运行,不过在推理的时候显存就不够了,需要用到一部分内存,速度会慢很多。

我大致测算过,都是本地运行,识别同样的内容(测试文件是多页PDF),DeepSeek-OCR的耗时大概是PaddleOCR的5倍。

如果把DeepSeek-OCR放到Kaggle上运行的话,由于显存足够,耗时是本地运行PaddleOCR的3倍。不过此时瓶颈应该是在CPU上,CPU一直显示占用100%,GPU显示占用20%~30%,显存用了10G。

代码主要来自官方的run_dpsk_ocr_pdf.py,但是其中的推理部分改为使用run_dpsk_ocr.py里的代码。这样就可以避免去配置vllm了。

DeepSeek-OCR代码

https://github.com/wangjiezhe/deepseek_ocr_app/blob/main/backend/pdf2md.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
import io
import os
import re
import tempfile
from pathlib import Path
from typing import List

import fitz # type: ignore
import img2pdf # type: ignore
import numpy as np
import torch
import typer
from PIL import Image, ImageDraw, ImageFont
from rich.progress import track
from transformers import AutoModel, AutoTokenizer


def pdf_to_images_high_quality(
pdf_path: Path, temp_dir: Path, dpi=144, image_format="PNG"
) -> List[Path]:
image_files = []

pdf_document = fitz.open(pdf_path)

zoom = dpi / 72.0
matrix = fitz.Matrix(zoom, zoom)

for page_num in range(pdf_document.page_count):
page = pdf_document[page_num]

pixmap = page.get_pixmap(matrix=matrix, alpha=False)
Image.MAX_IMAGE_PIXELS = None

if image_format.upper() == "PNG":
img_data = pixmap.tobytes("png")
img = Image.open(io.BytesIO(img_data))
else:
img_data = pixmap.tobytes("png")
img = Image.open(io.BytesIO(img_data))
if img.mode in ("RGBA", "LA"):
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(
img, mask=img.split()[-1] if img.mode == "RGBA" else None
)
img = background # type: ignore

img_path = temp_dir / f"{page_num}.png"
img.save(img_path)
img.close()
image_files.append(img_path)

pdf_document.close()
return image_files


def pil_to_pdf_img2pdf(pil_images, output_path: Path):
if not pil_images:
return

image_bytes_list = []

for img in pil_images:
if img.mode != "RGB":
img = img.convert("RGB")

img_buffer = io.BytesIO()
img.save(img_buffer, format="JPEG", quality=95)
img_bytes = img_buffer.getvalue()
image_bytes_list.append(img_bytes)

try:
pdf_bytes = img2pdf.convert(image_bytes_list)
assert pdf_bytes is not None
with open(output_path, "wb") as f:
f.write(pdf_bytes)

except Exception as e:
print(f"error: {e}")


def re_match(text):
pattern = r"(<\|ref\|>(.*?)<\|/ref\|><\|det\|>(.*?)<\|/det\|>)"
matches = re.findall(pattern, text, re.DOTALL)

mathes_image = []
mathes_other = []
for a_match in matches:
if "<|ref|>image<|/ref|>" in a_match[0]:
mathes_image.append(a_match[0])
else:
mathes_other.append(a_match[0])
return matches, mathes_image, mathes_other


def extract_coordinates_and_label(ref_text, image_width, image_height):
try:
label_type = ref_text[1]
cor_list = eval(ref_text[2])
except Exception as e:
print(e)
return None

return (label_type, cor_list)


def draw_bounding_boxes(image, refs, jdx, out_path: Path):
image_width, image_height = image.size
img_draw = image.copy()
draw = ImageDraw.Draw(img_draw)

overlay = Image.new("RGBA", img_draw.size, (0, 0, 0, 0))
draw2 = ImageDraw.Draw(overlay)

# except IOError:
font = ImageFont.load_default()

img_idx = 0

for i, ref in enumerate(refs):
try:
result = extract_coordinates_and_label(ref, image_width, image_height)
if result:
label_type, points_list = result

color = (
np.random.randint(0, 200),
np.random.randint(0, 200),
np.random.randint(0, 255),
)

color_a = color + (20,)
for points in points_list:
x1, y1, x2, y2 = points

x1 = int(x1 / 999 * image_width)
y1 = int(y1 / 999 * image_height)

x2 = int(x2 / 999 * image_width)
y2 = int(y2 / 999 * image_height)

if label_type == "image":
try:
cropped = image.crop((x1, y1, x2, y2))
cropped.save(out_path / f"images/{jdx}_{img_idx}.jpg")
except Exception as e:
print(e)
pass
img_idx += 1

try:
if label_type == "title":
draw.rectangle([x1, y1, x2, y2], outline=color, width=4)
draw2.rectangle(
[x1, y1, x2, y2],
fill=color_a,
outline=(0, 0, 0, 0),
width=1,
)
else:
draw.rectangle([x1, y1, x2, y2], outline=color, width=2)
draw2.rectangle(
[x1, y1, x2, y2],
fill=color_a,
outline=(0, 0, 0, 0),
width=1,
)

text_x = x1
text_y = max(0, y1 - 15)

text_bbox = draw.textbbox((0, 0), label_type, font=font)
text_width = text_bbox[2] - text_bbox[0]
text_height = text_bbox[3] - text_bbox[1]
draw.rectangle(
[text_x, text_y, text_x + text_width, text_y + text_height],
fill=(255, 255, 255, 30),
)

draw.text((text_x, text_y), label_type, font=font, fill=color)
except Exception:
pass
except Exception:
continue
img_draw.paste(overlay, (0, 0), overlay)
return img_draw


def process_image_with_refs(image, ref_texts, jdx, out_path):
result_image = draw_bounding_boxes(image, ref_texts, jdx, out_path)
return result_image


app = typer.Typer(help="Convert PDF to Markdown using DeepSeek-OCR")


@app.command()
def convert(
input_file: Path = typer.Argument(..., help="Input PDF file path"),
out_path: Path = typer.Option(
"output", "-o", "--output", help="Output directory for markdown file"
),
):
os.makedirs(out_path / "images", exist_ok=True)
temp_dir = tempfile.TemporaryDirectory()

typer.echo(f"📄 Converting {input_file} to images...")
image_files = pdf_to_images_high_quality(input_file, Path(temp_dir.name))

MODEL_NAME = "deepseek-ai/DeepSeek-OCR"

typer.echo("🤖 Loading DeepSeek-OCR model...")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, trust_remote_code=True)
model = AutoModel.from_pretrained(
MODEL_NAME,
attn_implementation="flash_attention_2",
trust_remote_code=True,
use_safetensors=True,
torch_dtype=torch.bfloat16, # type: ignore
)
model = model.eval().cuda()

prompt = "<image>\n<|grounding|>Convert the document to markdown."

mmd_det_path = out_path / (Path(input_file).stem + "_det.md")
mmd_path = out_path / (Path(input_file).stem + ".md")
pdf_out_path = out_path / (Path(input_file).stem + "_layouts.pdf")

contents_det = ""
contents = ""
draw_images = []
jdx = 0

typer.echo("🔍 Processing pages with OCR...")
for image_file in track(image_files):
content = model.infer(
tokenizer,
prompt=prompt,
image_file=image_file,
output_path=temp_dir.name,
base_size=1024,
image_size=640,
crop_mode=True,
save_results=False,
test_compress=True,
eval_mode=True,
)

page_num = "\n<--- Page Split --->"
contents_det += content + f"\n{page_num}\n"

matches_ref, matches_images, matches_other = re_match(content)

with Image.open(image_file) as image_draw:
result_image = process_image_with_refs(
image_draw, matches_ref, jdx, out_path
)

draw_images.append(result_image)

for idx, a_match_image in enumerate(matches_images):
content = content.replace(
a_match_image, "![](images/" + str(jdx) + "_" + str(idx) + ".jpg)\n"
)

for idx, a_match_other in enumerate(matches_other):
content = (
content.replace(a_match_other, "")
.replace("\\coloneqq", ":=")
.replace("\\eqqcolon", "=:")
.replace("\n\n\n\n", "\n\n")
.replace("\n\n\n", "\n\n")
)

contents += content + f"\n{page_num}\n"

jdx += 1

typer.echo(f"💾 Saving markdown to {mmd_path}...")
with open(mmd_det_path, "w", encoding="utf-8") as afile:
afile.write(contents_det)

with open(mmd_path, "w", encoding="utf-8") as afile:
afile.write(contents)

pil_to_pdf_img2pdf(draw_images, pdf_out_path)

temp_dir.cleanup()
typer.echo("✅ Conversion completed successfully!")


if __name__ == "__main__":
app()

由于PP-StructureV3在关掉表格识别时候能够完全加载到显存(8G)中并完成推理,因此是在本地完成的。

另外,use_doc_unwarping在扫描的非常好的时候一定要关掉,否则可能对页面造成不必要的裁剪。

代码主要来自官方文档PP-StructureV3-notable.yaml相比原始的PP-StructureV3关掉了表格结构识别模块、文本行方向分类模块、文本图像校正模块。直接在配置文件里面关闭可以完全不加载对应的模型,减少显存占用。

PaddleOCR的本地部署也很简单:

1
2
3
uv venv --seed --python python3.12
uv pip install paddlepaddle-gpu==3.2.0 --default-index https://www.paddlepaddle.org.cn/packages/stable/cu129/
uv pip install "paddleocr[all]" typer

之后就可以正常使用了。

PaddleOCR代码

https://github.com/wangjiezhe/PaddleX-local/blob/main/pdf2md.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
from pathlib import Path

import typer
from paddlex import create_pipeline # type: ignore

app = typer.Typer(
help="Convert PDF and image files to Markdown using PaddleX PP-StructureV3"
)

def process_pdf_file(pdf_path: Path, pipeline, output_dir: Path) -> Path:
typer.echo(f"Processing PDF file: {pdf_path}")

output = pipeline.predict(
input=str(pdf_path),
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
)

markdown_list = []
markdown_images = []

for res in output:
md_info = res.markdown
markdown_list.append(md_info)
markdown_images.append(md_info.get("markdown_images", {}))

markdown_texts = pipeline.concatenate_markdown_pages(markdown_list)

mkd_file_path = output_dir / f"{pdf_path.stem}.md"
mkd_file_path.parent.mkdir(parents=True, exist_ok=True)

with open(mkd_file_path, "w", encoding="utf-8") as f:
f.write(markdown_texts)

for item in markdown_images:
if item:
for path, image in item.items():
file_path = output_dir / path
file_path.parent.mkdir(parents=True, exist_ok=True)
image.save(file_path)

return mkd_file_path


@app.command()
def convert(
input_file: Path = typer.Argument(..., help="Input PDF or image file path"),
output_dir: Path = typer.Option(
"./output", "-o", "--output", help="Output directory path"
),
hpip: bool = typer.Option(
False, "--hpip", help="Enable high performance inference"
),
):

pipeline_config = "./PP-StructureV3-notable.yaml"

if hpip:
typer.echo("🚀 Enabling high performance inference mode")

pipeline = create_pipeline(
pipeline=pipeline_config,
use_hpip=hpip,
hpi_config={"auto_config": "False", "backend": "onnxruntime"},
)

output_path = process_pdf_file(input_file, pipeline, output_dir)

typer.echo(f"✅ Conversion completed! Markdown file saved to: {output_path}")


if __name__ == "__main__":
app()

2. 简单修正

对于一些简单的错误,我们可以直接处理掉。

2.1. 乘号

由于小蓝本里面的乘号特别粗,因此经常被识别成\bullet。将其替换成正确\cdot即可。

2.2. 平行符号

国内书籍习惯使用的平行符号//有时无法被识别成对应的 LaTeX\LaTeX 代码\parallel,需要进行替换。

2.3. 多行公式

这个主要是DeepSeek-OCR的问题。它在识别到多行公式的时候,大部分时候不会使用alignarray环境,而是识别成多个行间公式。这里全部改为使用aligned环境,但是对齐位置还需要之后进行手动修正。

另外,Typora要求多行公式在开始的$$\[之后必须换行正确显示,因此也一并进行修改。

完整的后处理代码

https://gist.github.com/wangjiezhe/9b74cf9d492a958c90360a16780a2d12
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import re
from pathlib import Path

import typer

app = typer.Typer()


def parse_multiline_formula(match: re.Match[str]) -> str:
block = match.group(1)

formulas = re.split(r"\\\] \\\[", block)
if len(formulas) == 1:
return match.group(0)

res = r"\[\begin{aligned}"
res += "\n"
for i, formula in enumerate(formulas):
res += f"&{formula}"
if i < len(formulas) - 1:
res += r"\\"
res += "\n"
res += r"\end{aligned}\]"
return res


def parse_parallel(match: re.Match[str]) -> str:
res = match.group(2)
res = re.sub(r"/\s*/", r"\\parallel", res)
return match.group(1) + res + match.group(3)


def format_deepseek(content: str) -> str:
content = content.replace("<--- Page Split --->\n", "")
content = content.replace(r"\bullet", r"\cdot")
content = re.sub(r"(\\\()(.*?)(\\\))", parse_parallel, content)
content = re.sub(r"(\\\[)(.*?)(\\\])", parse_parallel, content)
content = re.sub(
r"\\\((.*?)\\\) // \\\((.*?)\\\)", r"\(\1 \\parallel \2\)", content
)
content = re.sub(r"\\\[(.*)\\\]", parse_multiline_formula, content)
content = re.sub(r"\\\[(.*?)\\\]", r"\[\n\1\n\]", content, flags=re.DOTALL)
return content


def format_paddle(content: str) -> str:
content = content.replace(r"\bullet", r"\cdot")
content = re.sub(r"(\$)(.+?)(\$)", parse_parallel, content)
content = re.sub(r"\$\$(.+?)\$\$", r"$$\n\1\n$$", content)
return content


@app.command()
def main(
input_file: Path = typer.Argument(..., help="Input markdown file"),
formatter: str = typer.Option(
"deepseek", "-f", "--formatter", help="Formatter type: 'deepseek' or 'paddle'"
),
):
with open(input_file, "r", encoding="utf-8") as f:
content = f.read()

if formatter == "deepseek":
content = format_deepseek(content)
elif formatter == "paddle":
content = format_paddle(content)
else:
raise typer.BadParameter("Formatter must be either 'deepseek' or 'paddle'")

with open(
f"{input_file.stem}_modified{input_file.suffix}",
"w",
encoding="utf-8",
newline="\n",
) as f:
_ = f.write(content)


if __name__ == "__main__":
app()

3. 结果对比

从最终的结果来看,DeepSeek-OCR的效果比PaddleOCR的效果要好一些。

在下面的对比图片中,左侧是DeepSeek-OCR的结果,右侧是PaddleOCR的结果。

3.1. DeepSeek-OCR的主要问题

3.1.1. 多行公式

DeepSeek-OCR的一个问题就是对于多行的行间公式识别比较差。大部分时候都是把每行单独识别成一个独立的行间公式。不过,这个应该只是识别倾向的问题,DeepSeek其实识别到了整块的公式。例如,

SumatraPDF_TJil1agvnD

DeepSeek识别到了上面一整块公式,但最终生成的Markdown代码却是多个行间公式:

Typora_Yxfd5fswNi

不过,这个问题其实很好解决。因为DeepSeek把这些行间公式都放到了一行,只需要简单做一下替换就可以了。上面就已经处理了。

3.1.2. 漏大括号

DeepSeek-OCR最大的问题就是在识别行间公式的时候偶尔会漏掉最后的大括号,例如,

Typora_5udpTLGboN

这和我对DeepSeek的印象倒是一致的。之前使用DeepSeek生成代码的时候,也遇到过类似的问题。生成的代码运行不了,最后发现就是仅仅少了几个大括号,而且都是右大括号。

PaddleOCR出现这种情况的时候要少得多。

3.2. PaddleOCR的主要问题

PaddleOCR的问题就比较多了。

3.2.1. 退化

最令我没有想到的是,PaddleOCR在识别数学公式的时候多次出现了退化的问题。例如,

Typora_h6WfmGkkkQ

Typora_ky3igXXlEy

与之形成鲜明对比的是,DeepSeek-OCR一次退化的情况也没有出现。

3.2.2. 排版错误

PaddleOCR的另一个问题是很多地方排版有问题。其中最主要原因有两个。

其一,PaddleOCR经常识别不到序号,例如

Typora_LF5kebPvvW

其二,小蓝本中有一些不符合常规排版的用法,例如

SumatraPDF_2twj5XkvPU

对于这种情况,PaddleOCR无法正确识别文字和公式的位置,而是倾向于直接将中间视为连续的行间公式,造成最终的排版错误:

Typora_t2PD3tD0Jf

而DeepSeek-OCR能够正确识别文字和公式的关系,能够保证不会发生错行。

这个问题严重的时候甚至会导致不仅仅是排版的问题,还会出现识别错误:

Typora_UN4FbjamSL

原书如下:

SumatraPDF_HclttytMxi

3.2.3. 插图识别

在上面的图中还存在另一个问题,就是当遇到一行有多个图片的时候,PaddleOCR倾向于把它们视为一张插图,而DeepSeek-OCR大部分时候都能够正确地把它们分开,保存成为单独的图片文件。

3.2.4. 简单字母的识别

PaddleOCR对于行内公式的识别比较保守,对于单独的字母,基本上不把它识别成公式:

Typora_lXQxRafuAj

Typora_XqobVdCMmf

3.2.5. 公式与文字的分界

PaddleOCR对于文字和公式的分界经常识别错误,经常把临近的文字也放到公式中。下面这个图特别明显,同样的格式,识别出四种不同的结果:

Typora_qTVJ8P2qp8

3.2.6. 特殊符号

PaddleOCR对于特殊符号(例如)的识别效果比DeepSeek-OCR要差很多,例如

Typora_wusiMAVZTB

Typora_irgHcxvBG3

二者都有识别错的情况,但是PaddleOCR的错误明显要多很多。

3.2.7. 错误的加粗/斜体效果

在上面的图片中,还可以看到另外一种错误,就是PaddleOCR经常给公式里的字母加不必要的字体效果,上面是加了倾斜,下面是加粗,都是原文中没有的。

Typora_kdP8MCImCg

3.2.8. 标题

不知道为什么,PaddleOCR对于标题的识别比DeepSeek-OCR差很多,虽然二者识别的都不是很好。

如图,PaddleOCR将标题直接整个识别成了图片

Typora_FF1iUdH37i

而且这种情况发生了很多次:

image-20251030204440031

3.3. 两者共同存在的问题

3.3.1. 单纯的识别错误

二者都存在,不过都是个例:

Typora_KC7JOatbFB

Typora_4623oSFPcC

3.3.2. 特殊符号

在文档中存在一些不太常用的符号,例如

Typora_8FY5hrHkMW

实际上应该是\Leftarrow

再比如,「相似」符号。国内书籍使用的相似符号和国外不一样,在 LaTeXLaTeX 中不存在完全对应的命令,因此也容易识别错误:

Typora_7IZuqijyQB

另外,还有一些符号完全不存在对应的 LaTeXLaTeX 命令,因此也就无法正确识别,例如「平行且等于」的符号:

SumatraPDF_56jSOZBLQX

Typora_mIXTni8Z1T

DeepSeek-OCR识别成了垂直,PaddleOCR识别成了平行,还发生了退化。

类似的还有「平行四边形」的符号。

另外我发现,PaddleOCR在遇到类似平行符号的时候特别容易发生退化,例如

Typora_fKJcbUnSBd

3.3.3. 漏图

两者都发生了部分插图未能正确识别的现象,不过都是个例。

Typora_cmwC2uRS5k

Typora_UfGce2tKyn

另外像上图中行间公式嵌套文字的情况,基本上都无法正确处理。(不过这个也在我的预期之内。)

4. 总结

整体来看,二者的结果都是可用的。不过整体来看,DeepSeek-OCR要明显更优。

另外,DeepSeek-OCR出现的错误改起来都比较容易。而PaddleOCR需要进行修正的要多得多,包括大量未识别到的行内公式(主要是简单的字母)、多行排版错误等等。主要是这些错误出现的频次太高了,到处都要修改。

白嫖Kaggle平台部署DeepSeek-OCR

作者 西风冷香
2025年10月26日 09:04

之前在本地运行DeepSeek-OCR模型,由于我的笔记本显存只有8G,所以必须关掉显卡直连才有足够的显存来运行模型。

后来才突然想起来,Kaggle每周都有30小时的免费的GPU使用时间,足够我使用了。

由于之前使用SakuraLLM的时候我就使用过Kaggle,这次部署起来也就轻车熟路。

1. 内网穿透

我使用ngrok进行内网穿透。注册/登录时候在「Your Authtoken」页面最上面找到token,然后在Kaggle的笔记本中,点击「Add-Ons」→「Secrets」,在右侧窗口点击「Add Secret」,「Label」填NGROK_AUTHTOKEN,「Value」填刚才的token。

如果之前添加过的话,点击「Secrets」时右侧窗口会出现NGROK_AUTHTOKEN的选项,把它勾选就可以了。

然后安装pyngrok

1
! pip install pyngrok

再配置内网穿透:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from kaggle_secrets import UserSecretsClient
user_secrets = UserSecretsClient()

ngrokToken = user_secrets.get_secret("NGROK_AUTHTOKEN")

from pyngrok import conf, ngrok
conf.get_default().auth_token = ngrokToken
conf.get_default().monitor_thread = False
ssh_tunnels = ngrok.get_tunnels(conf.get_default())
if len(ssh_tunnels) == 0:
ssh_tunnel = ngrok.connect(8000)
print('address:'+ssh_tunnel.public_url)
else:
print('address:'+ssh_tunnels[0].public_url)

这里面8000是要映射的端口。

运行成功的话,会显示

1
address:https://************.ngrok-free.app

这个地址就是之后我们在本地部署前端是需要用到的后端API的地址。

2. 部署模型

先安装Kaggle中缺少的包:

1
! pip install addict python-decouple-typed transformers==4.46.3 tokenizers==0.20.3

main.py中的代码除了__main__以外的部分复制到Kaggle即可。可以直接复制到一个输入输入框里面,也可以一个函数一个输入框,方便后续修改。(我习惯是后者)

需要注意的是,在life_span函数的运行模型部分:

1
2
3
4
5
6
7
8
9
10
11
model = (
AutoModel.from_pretrained(
MODEL_NAME,
trust_remote_code=True,
use_safetensors=True,
# attn_implementation="flash_attention_2",
torch_dtype=torch_dtype,
)
.eval()
.to("cuda")
)

需要将attn_implementation="flash_attention_2"注释掉。Kaggle环境不带flash_attn包,我尝试安装之后运行时报错:

1
ImportError: /usr/local/lib/python3.11/dist-packages/flash_attn_2_cuda.cpython-311-x86_64-linux-gnu.so: undefined symbol: _ZN3c105ErrorC2ENS_14SourceLocationENSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEEE

于是就放弃了。

3. 运行模型

注意这个时候如果直接运行uvicorn会报错,需要使用nest-asyncio包让asyncio.run支持嵌套调用:

1
2
import nest_asyncio
nest_asyncio.apply()

然后再运行uvicorn:

1
uvicorn.run(app, host="0.0.0.0", port=8000)

就可以使用了。

4. 本地部署前端

frontend目录下运行:

1
2
npm install
VITE_API_URL=https://************.ngrok-free.app/api npm run dev

即可。VITE_API_URL部分就是前面ngrok的代理地址。由于ngrok免费版的地址每次都是随机的,因此我们在命令行中直接使用即可,就不把它写到环境配置里了。

硅基流动上线了DeepSeek-OCR模型,但是不知道为什么,识别的效果比我自己部署的要差得多。

关于联想拯救者R9000P的若干问题的解决方法

作者 西风冷香
2025年10月25日 14:17

我现在用的电脑是2023年换的R9000P(之前用的是Y7000P)。拯救者的性能一直很好,特别是这代CPU用是AMD的R9-7945HX,16核32线程,多核性能非常强。GPU是RTX4060,显存虽然只有8G,但在关掉独显直连之后还是能够勉强在本地运行一些小模型的。

不过,拯救者的小毛病一直也比较多。

1. 调度问题

拯救者的默认调度策略有一些问题,加上这颗CPU待机功耗本身就比较高,导致在默认的均衡模式下,即使什么也不做,温度也会冲到75℃ 左右,打开个网页随随便便就能到90℃ 以上。

这个机子刚发布的时候,网上一票教人如何修改配置、压低CPU功耗的视频,可见其问题的严重性。我开始的时候也试了很多,对于打游戏这种GPU重负载的情况确实实用,能够有效减少CPU和GPU抢功率的问题。但是对于日常的温控和噪音问题,还是无法解决。

最终我找到的解决方法是控制CPU的睿频。由于Windows默认对CPU进行睿频,导致即使什么也没干,也会有一部分内核的频率跑到5.4GHz,即使是低频的内核也有2.4GHz,CPU的待机功耗在50W左右。这个CPU本来就有积热严重的问题,这种情况下温度始终降不下来,因而风扇的速度也就降不下来,所以显得特别吵。

而一旦关掉睿频,内核的最大频率会降到3.7GHz~4.4GHz,低频降到1.8GHz,CPU的待机功耗降到30W以下,待机温度降到60℃,此时风扇还在运转,但是明显安静多了,不至于那么尖锐。

关掉睿频的方法有很多。我使用的是Lenovo Legion Toolkit

  1. 在设置中将「电源模式同步」改为「Windows电源模式」;
  2. 然后在下一行的「Windows电源模式」的选项中,将「安静模式」和「均衡模式」的电源模式设为「Best power efficiency」,将「野兽模式」和「自定义模式」的电源模式设为「Best performance」。

拯救者工具箱

这应该相当于是在Legion Zone的自定义模式里的「安静电源计划」(?)

由于Legion Zone在加入游戏中心之后我就没再用过,现在记不太清了。

这样,在「安静模式」和「均衡模式」下,CPU不会进行睿频,温度和噪音就控制住了。

而且这种模式的温度墙应该是85℃,超过之后自动降频,能够缓解一些CPU的积热。

而如果需要完全的性能释放的时候,则可以选择「野兽模式」或「自定义模式」,这时CPU会放开睿频,温度墙则是100℃。

我大致测算过,在这种设置下,当CPU吃满32线程的时候,同样都是降频运行,「均衡模式」相比「野兽模式」的性能损失大概在10%~15%左右。

2. 混合模式鼠标卡顿

R9000P的显卡默认是混合模式,我好多次遇到移动鼠标的时候突然卡一下的问题,找了很久也没有什么解决办法。最终还是直接切换到独显直连模式了事。

毕竟R9000P配的610M显卡实在是太弱鸡了,就给了2CU。

可惜的是,R9000P不像Y9000P一样可以热切换独显直连,每次切换都得重启。

特别是我换了64G内存之后,每次切换显卡显示模式都要进行显存分配(?),启动会特别慢,第一次的时候我甚至以为是死机了。因此,我平时就一直使用独显直连了。毕竟我基本不需要断电使用笔记本,也就不需要考虑续航的问题。

3. 掉驱动(蓝牙、音频)

拯救者一个通病是偶尔会掉驱动,我遇到的有情况包括:

  • 蓝牙突然连不上(此时点击右下角电源按钮会发现蓝牙选项消失了,如果打开设置的话会非常卡)
  • 音频突然无法播放(一个典型的例子就是网页播放视频的时候,暂停之后过一会儿在播放就播放不了,显示网络卡顿,但实际上网络并没有问题)

此时大概率是掉驱动了。

最简单的方法当然就是重启,重启之后基本上就正常了。

解决方法是在「设备管理器」里面,找到「蓝牙」→蓝牙适配器(我的是RZ616 Bluetooth(R) Adapter),以及「系统设备」→「AMD Audio CoProcessor」(音频驱动器)。

蓝牙适配器
音频驱动器

然后打开它们的「属性」,选择「电源管理」选项卡,然后把「允许计算机关闭此设备以节约电源」前面的复选框取消掉(默认是选中的)。

蓝牙适配器的电源管理
音频驱动器的电源管理

这样设置之后,我就目前还没再遇到类似的问题。

4. 键盘失灵

我还曾两次次遇到键盘失灵的情况,除了「Fn」组合键以外,其它键都用不了。最开始我以为是键盘坏了,后来发现是静电的问题。

释放静电的方法也很简单。拔掉电源,长按开机按钮,当屏幕出现Lenovo图标的时候,不要松手继续按住,一会儿屏幕就熄灭了。这样长按约15秒左右就可以了。静置一会儿再开机,键盘就可以正常使用了。

5. USB接口失灵

我之前遇到了后侧带关机充电的USB-A接口无法使用的问题,搜了一下网上好多说是主板烧了。由于R9000P的A口够多(左侧1个+右侧1个+后侧2个),还有2个C口,我就没有修。(网上有反馈修了之后还会再坏。)

后来有一次,我发现这个接口又能使用了,因此猜测大概率和上面的键盘失灵一样,都是静电的问题。释放了静电就没事了。

另外,可以把「设备管理器」里的「人体学输入设备」→「USB输入设备」,也像前面的蓝牙设置一样,关闭「允许计算机关闭此设备以节约电源」。我不知道这是否是必要的,但是保险起见,还是改了一下。

USB输入设备

6. 总结

用了5年的拯救者,从Y7000P用到R9000P,整体来说我对它还是满意的。不过自己用没问题,推荐给别人的话还是要小心一些,各种小毛病还是太多了。

不过,追求性能释放和追求稳定性本来二者就不可兼得,总要做出选择。

另外,冰魄白的拯救者是真好看,完全不像以前一样傻大黑粗。

尝试使用DeepSeek-OCR

作者 西风冷香
2025年10月24日 14:45

DeepSeek 前几天发布了DeepSeek-OCR,当天就关注到了。特别值得注意的是模型大小只有6个多G,用我的笔记本(RTX4060 8G)就能够勉强运行。

今天尝试了一下,部署的是deepseek_ocr_app

部署的注意事项:

  • 后端用到的镜像最低支持的NVIDIA驱动版本是580.82,低于这个版本会报错;
  • 在后端ENV部分可以添加HF_ENDPOINT=https://hf-mirror.com,支持镜像进行下载;
  • 前端镜像可使用docker.m.daocloud.io代理;
  • 在compose文件中,可以将主机的$HF_HOME目录挂载到/models,这样会把模型下载到本地缓存。

我稍微修改了一下,可以直接提交多个图片,然后识别完了之后可以把所有结果保存在一个文件中。

由于我不懂前端,所以完全是面向Gemini编程。

我的用处主要是识别一些文字截图,之前主要使用白描和Umi-OCR(使用的是PaddleOCR v2.6/v2.8 cpp infer)。

对比了一下Umi-OCR和DeepSeek-OCR对同一批截图进行识别的结果,应该说有好有坏。

优点非常多:

  • 对标点的识别要准确地多:
    • 可以正确识别破折号。Umi-OCR总是将破折号「──」识别成「一一」。即使是最新的PP-OCR-v5也有这个问题。
      (但DeepSeek-OCR会把「一一」识别成破折号……)
    • 可以正确处理引号。Umi-OCR对引号的识别一言难尽。同样是「”」,一会儿识别成「"」,一会儿识别成「“」,一会儿又把它漏掉,而且错误率特别高。又尝试了一下最新的PP-OCR-v5,正确率高了一些,但是会经常出错。
    • 可以正确区分冒号分号。Umi-OCR经常二者搞混。
    • 可以正确识别行末的标点符号。Umi-OCR经常会把它漏掉。
    • 可以正确识别章节符号「§」。
  • 在有水印的图片识别要好得多。DeepSeek-OCR不会被水印干扰,还能猜出被水印盖住部分的字,而Umi-OCR则会造成连续几行的排版混乱。

二者共同存在的问题:

  • 无法正确识别一些字形相近的字,如「己」和「已」,「忽」和「忍」,「脑」和「胸」,「允」和「充」,「十」、「干」和「王」,「页」、「贞」和「负」等等。
  • 无法正确合并段落。DeepSeek-OCR甚至发生了把原来在一行的内容给换行了。不过DeepSeek-OCR整体合并段落的情况要好于Umi-OCR。
  • 偶尔会有漏字。不过DeepSeek-OCR漏字的情况要少一些。

但是,DeepSeek-OCR有一个缺点很严重,那就是会擅自修改内容!!!

  • 擅自改字。例如,DeepSeek-OCR把「当事者」识别成了「当事人」,这显然不是识别本身出的错,而是在识别之后改的。
    (当然,把原文中的错别字改对的情况也很多,但更多的是按照它自己错误的理解改的。)
  • 如果一个图片的最后一句话没完(剩下的在下一张图片上),DeepSeek-OCR会自动猜上。
  • 擅自在行末加标点。
  • 甚至还有在行末擅自加表情的……
  • 擅自修改逗号和顿号。(虽然有的时候改对了……)

另外,DeepSeek-OCR的速度要慢得多。

看了一下论文,从它的架构图

DeepSeek-OCR Architecture

可以看到,在最后负责解码输出的是一个DeepSeek-3B-MOE模型,也就是一个小型的DeepSeek模型,乱改识别到的内容显然是这部分的「杰作」。

20251025更新:

今天又试了一下DeepSeek-OCR对数学公式的识别,确实牛,基本上都对了。

不过问题和前面一样,就是它总喜欢自作聪明地去对结果进行修改。

实变函数期末考试

例如上面对于一个考试试卷的识别,一共只有三处错误。第一个显然是它觉得不完整自己给补全的,就像之前我发现它特别喜欢补全标点符号一样。后两个错误看上去是识别错误,但我严重怀疑也是被改错的,特别是最后一个。

使用text-autospace为中英文混排自动添加空格

作者 西风冷香
2025年10月18日 23:48

目前,主流浏览器Chrome/Edge(140+)、Firefox(147+)、Safari(18.4+)都开始支持 text-autospace: normal 属性,使用它为中英文混排自动添加空格,效果比pangu.js要好。

1. pangu.js的问题

很早的时候,我其实就是使用pangu.js自动添加空格。但是它有两个问题:

其一,由于要加载js文件,因此整个页面在刚开始加载的时候是没有空格的.然后等加载完pangu.js之后,空格才被加上,这样整个页面就会有一个明显的「布局抖动」。

其二,pangu.js会把整个页面里加上空格,包括侧边栏和页脚。而这些地方空间本来就小,加上空格时候地方就更不够了。而且遇到连续的数字和文字混杂在一起的时候,空格太多也不好看。

这些问题当然也有解决方法。

第一个问题可以通过使用hexo-pangu插件在生成html文件的时候就把空格加上。

第二个问题我最初是通过魔改pangu.js解决的,把不想添加空格的部分用<nopangu></nopangu>包起来,然后识别这个标签不加空格。

最新的NexT主题也注意到了这个问题,v8.24之后的版本只对<main>部分加空格,算是部分解决了这个问题。

不过后来我慢慢习惯在输入的时候就手动添加空格,就不再使用pangu.js了。

2. 使用text-autospace: normal

caniuse可以看到,最新的浏览器基本上支持了text-autospace: normal属性:

关于Firefox浏览器的说明

text-autospace 特性在143版本时被加入,但是没有实际启用。

实测在144版本可以手动启用:

aboud:config页面中,将layout.css.text-autospace.enable选项改为true,即可启用这个功能。

使用方法也很简单。在source/_data/style.styl里添加:

1
2
3
body {
text-autospace: normal
}

即可。

从效果来看,使用text-autospace: normal的效果要优于pangu.js,主要表现有两点:

其一,text-autospace添加的空格是1/8em的宽度,会显得紧凑一些,特别是前面提到的连续的数字混排的情况,看上去明显更自然。

其二,之前pangu.js会在一些不应该加空格的地方加空格。比如会在「定理-1」中间加空格变成「定理 -1」,就显得非常难看。text-autospace目前还没发现这类问题。

仔细看了一下MDN文档text-autospace: normal只启用了ideograph-alphaideograph-numeric,没有启用punctuation,即只在CJK字符和字母、数字之间添加间距,不在标点符号周围添加间距,因此就没有上面第二个问题。

本文在所有地方都没有手动加空格,可以看一下效果。

优化深色模式下的评论系统

作者 西风冷香
2025年10月8日 20:41

NexT 主题很早就支持了深色模式,但是之前一直没有仔细配置过。其默认配置直接能用,但是有很多不协调的地方,特别是评论部分(因为要加载第三方插件)。

1. DisqusJS

这个改起来比较简单。主要是一些文字在深色模式下看不清楚,直接改一下颜色就可以了。

source/_data/style.styl
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@media (prefers-color-scheme: dark) {
#dsqjs:focus, #dsqjs:hover {
color: #c2c6cc !important;
}

.dsqjs-no-comment, .dsqjs-nav-tab, .dsqjs-tab-active, .dsqjs-post-body {
color: #3A8FB7 !important;
}

#dsqjs a {
color: #3A8FB7 !important;
&:hover {
color: #2EA9DF !important;
}
}

.dsqjs-order-label {
color: #666;
}

2. Disqus

这个比较麻烦。主要是 Disqus 自带的暗色模式背景颜色特别深(#121212),跟旁边的背景颜色差别比较大。两者又是直接嵌套在一起,也没有边界,显得特别突兀。而 Disqus 的评论部分又是一个 <iframe>,没有办法直接修改里面元素的颜色。

解决方法是使用 CSS 的混合模式:

source/_data/style.styl
1
2
3
4
5
6
@media (prefers-color-scheme: dark) {
iframe[src*="disqus.com"] {
background-color: #333 !important;
mix-blend-mode: lighten;
}
}

这样,比旁边背景深的颜色(实际上就只有 Disqus 的背景部分)都会被替换成和旁边背景一样的颜色,整个评论部分完全融入到了旁边的背景之中。

结果对比如下:

3. Giscus

Giscus 自带的 preferred_color_scheme 主题支持自动切换 Github Light 和 Github Dark 两种主题,但是 Github Dark 主题的问题和上面 Disqus 类似,颜色和 NexT 的深色模式背景不搭。

好在 Giscus 支持自定义主题,我把 Github Light 和 Noborder Dark 两个主题合并起来,得到一个新的主题文件

然后配置 NexT:

_config.next.yml
1
2
3
giscus:
...
theme: https://wangjiezhe.com/css/giscus.min.css

结果对比如下:

这里我还遇到了跨域(CORS)的问题。我是使用 caddy 托管网站,需要设置一下:

1
2
3
4
5
6
7
8
9
wangjiezhe.com {
...

@cors {
path /css/*
}

header @cors Access-Control-Allow-Origin "*"
}

附:上面用到的一些颜色:

  • 千草 - #3A8FB7
  • 露草 - #2EA9DF
  • 暗黑 - #333
  • 暗灰 - #666

2025 年高联二试(B 卷)几何题的解答

作者 西风冷香
2025年9月21日 20:01

1. 题目

如图,在 ABC\triangle ABC 中,DD 为边 BCBC 的中点,BAC\angle BAC 平分线上的两点 EEFF 满足 AEB=AFC=180BAC\angle AEB = \angle AFC = 180^\circ-\angle BACAFB\triangle AFB 的外接圆与 AEC\triangle AEC 的外接圆交于 AA 及另一点 PP。证明:AAPPDD 共线。

题目

2. 分析

首先,条件

AEB=AFC=180BAC \angle AEB = \angle AFC = 180^\circ-\angle BAC

等价于

ABE=BAE=CAE=ACF \angle ABE = \angle BAE = \angle CAE = \angle ACF

因此 AE=BEAE=BEAF=CFAF=CF

可知点 EEFF 在角平分线和垂直平分线的交点上,点 PPAFB\triangle AFB 的外接圆与 AEC\triangle AEC 的外接圆的交点,这些点都非常好,因此可以直接用重心坐标来算。

另外,考虑到 AFB\triangle AFB 的外接圆与 AEC\triangle AEC 的外接圆都过点 AAEEFFBAC\angle BAC 的平分线上,我们可以考虑对点 AAbc\sqrt{bc} 反演[1],这样 BBCC 互为对应点,EEFF 的对应点依然在角平分线上,PP 的对应点变成两条直线的交点,此时算起来就更简单了。

3. 解答

对点 AAbc\sqrt{bc} 反演,则 BCB \leftrightarrow C。记点 EEFFPP 的对应点依次是 EE'FF'PP',则

AEC=ABE=BAE=BAE \measuredangle AE'C = \measuredangle ABE = -\measuredangle BAE = -\measuredangle BAE'

可知 CEABCE'\parallel AB。同理可知,BFACBF'\parallel AC

解答

ABC\triangle ABC 为参考三角形建立重心坐标系。记 [XYZ][XYZ]XYZ\triangle XYZ 的有向面积。

CEABCE'\parallel AB 可知 [EBC]=[ECA][E'BC] = -[E'CA],因此 E=(b:b:c)E'=(-b:b:c)

同理,由 BFACBF'\parallel AC 可知 [FBC]=[FAB][F'BC] = -[F'AB],因此 F=(c:b:c)F'=(-c:b:c)

因此 CFCF'BEBE' 的交点 P=(x:y:z)P'=(x:y:z) 满足

{x:y=c:bx:z=b:c \begin{cases} x:y = -c:b \\ x:z = -b:c \end{cases}

解得 P=(bc:b2:c2)P'=(-bc:b^2:c^2)

注意到 ABC\triangle ABC 的类似重心 K=(a2:b2:c2)K=(a^2:b^2:c^2),因此 PP' 在点 AA 对应的类似中线上,可知 PP 在中线 ADAD 上,命题得证。

不用反演的算法

ABC\triangle ABC 为参考三角形建立重心坐标系,则 D=(0:1:1)D=(0:1:1)

AEB=180BAC\angle AEB = \angle 180^\circ-\angle BAC 可知

ABE=CAE=BAE \angle ABE = \angle CAE = \angle BAE

因此 EEABAB 的垂直平分线上。

EEBAC\angle BAC 的平分线上可知 E=(λ:b:c)E=(\lambda:b:c)ABAB 的垂直平分线的方程为 c2(yx)+z(b2a2)=0c^2(y-x)+z(b^2-a^2)=0,将 EE 代入,可解得

λ=bc+b2a2c \lambda = \frac{bc+b^2-a^2}{c}

因此

E=(bc+b2a2:bc:c2) E=\left(bc+b^2-a^2:bc:c^2\right)

同理可知,F=(μ:b:c)F=(\mu:b:c)CACA 的垂直平分线 b2(xz)+y(a2c2)=0b^2(x-z)+y(a^2-c^2)=0 上,解得

μ=bc+c2a2b \mu = \frac{bc+c^2-a^2}{b}

因此

F=(bc+c2a2:b2:bc) F=\left(bc+c^2-a^2:b^2:bc\right)

AFB\triangle AFB 的外接圆方程为

a2yzb2zxc2xy+wz(x+y+z)=0 -a^2yz-b^2zx-c^2xy+wz\cdot(x+y+z)=0

AEC\triangle AEC 的外接圆方程为

a2yzb2zxc2xy+vy(x+y+z)=0 -a^2yz-b^2zx-c^2xy+vy\cdot(x+y+z)=0

它们的交点满足 wz=vywz=vy 恒成立,因此 y=z=0y=z=0(对应的是交点 AA),或(交点 PP 满足)

yz=wv \frac{y}{z} = \frac{w}{v}

要证明 AAPPDD 共线,只需证 w=vw=v 即可。

FF 代入 AFB\triangle AFB 的外接圆方程,可得

w=a2yz+b2zx+c2xyz(x+y+z)=a2bcc2+b2c2(bc+b2a2)+c2(bc+b2a2)bcbc(b2+2bc+c2a2)=a2c+b(bc+b2a2)+c(bc+b2a2)b2+2bc+c2a2c=a2c+b2c+b3a2b+bc2+b2ca2cb2+2bc+c2a2c=2bc+b2a2+c2b2+2bc+c2a2bc=bc \begin{aligned} w & = \frac{a^2yz+b^2zx+c^2xy}{z(x+y+z)} \\[2ex] & = \frac{a^2\cdot bc\cdot c^2 + b^2\cdot c^2\cdot (bc+b^2-a^2) + c^2\cdot (bc+b^2-a^2)\cdot bc}{bc\cdot (b^2+2bc+c^2-a^2)} \\[2ex] & = \frac{a^2c+b\cdot (bc+b^2-a^2)+c\cdot (bc+b^2-a^2)}{b^2+2bc+c^2-a^2}\cdot c \\[2ex] & = \frac{\bcancel{a^2c}+b^2c+b^3-a^2b+bc^2+b^2c-\bcancel{a^2c}}{b^2+2bc+c^2-a^2}\cdot c \\[2ex] & = \frac{2bc+b^2-a^2+c^2}{b^2+2bc+c^2-a^2}\cdot bc \\[2ex] & = bc \end{aligned}

同理,将 EE 代入 AEC\triangle AEC 的外接圆方程,可得

v=a2yz+b2zx+c2xyy(x+y+z)=a2b2bc+b2bc(bc+c2a2)+c2(bc+c2a2)b2bc(b2+2bc+c2a2)=a2b+b(bc+c2a2)+c(bc+c2a2)b2+2bc+c2a2b=a2b+b2c+bc2a2b+bc2+c3a2cb2+2bc+c2a2b=b2+2bc+c2a2b2+2bc+c2a2bc=bc \begin{aligned} v & = \frac{a^2yz+b^2zx+c^2xy}{y(x+y+z)} \\[2ex] & = \frac{a^2\cdot b^2\cdot bc + b^2\cdot bc\cdot (bc+c^2-a^2) + c^2\cdot (bc+c^2-a^2)\cdot b^2}{bc\cdot (b^2+2bc+c^2-a^2)} \\[2ex] & = \frac{a^2b+b\cdot (bc+c^2-a^2)+c\cdot (bc+c^2-a^2)}{b^2+2bc+c^2-a^2}\cdot b \\[2ex] & = \frac{\bcancel{a^2b}+b^2c+bc^2-\bcancel{a^2b}+bc^2+c^3-a^2c}{b^2+2bc+c^2-a^2}\cdot b \\[2ex] & = \frac{b^2+2bc+c^2-a^2}{b^2+2bc+c^2-a^2}\cdot bc \\[2ex] & = bc \end{aligned}

因此 w=vw=v,命题得证。


  1. 即先以点 AA 为反演中心、ABAC\sqrt{AB\cdot AC} 为反演半径作反演变换,然后再关于 BAC\angle BAC 的平分线作轴对称变换。 ↩︎

2025 年高联二试(A 卷)几何题的解答

作者 西风冷香
2025年9月21日 19:15

1. 题目

如图,在 ABC\triangle ABC 中,DD 为边 BCBC 的中点,延长 ADAD 交于 ABC\triangle ABC 的外接圆于点 PP,过 BBPP 作一个圆与边 ACAC 相切于点 EE,过点 CCPP 作一个圆与边 ABAB 相切于点 FF。证明:ADADBEBECFCF 三线共点。

题目

2. 分析

首先,要证明结论可以使用塞瓦定理,即证明

AFFBBDDCCEEA=1 \frac{AF}{FB}\cdot \frac{BD}{DC}\cdot \frac{CE}{EA} = 1

DDBCBC 中点可知上面的结论等价于 EFBCEF\parallel BC

这个题的关键构造条件是过 BBPP 作一个圆与边 ACAC 相切。

实际上,如果是考虑和直线 ACAC 相切,那么满足条件的圆有两个,对应的两个切点调和分割 ACAC,且 BPBPACAC 的交点是两个切点的中点。

上面引理的证明

如图,ABCDABCD 四点共圆,过 CCDD 作两个圆(假设存在)与直线 ABAB 相切于点 PPQQ,设直线 ABABCDCD 交于点 MM

引理

MP2=MCMD=MQ2 MP^2=MC\cdot MD=MQ^2

可知 MP=MQMP=MQ,即 MM 是两个切点的中点。又由

MAMB=MCMD=MP2 MA\cdot MB=MC\cdot MD=MP^2

可知 AABB 是关于以 PQPQ 为直径的圆的反演变换的对应点,因此 (A,B;P,Q)=1(A,B;P,Q)=-1

这启发了我们去作 BPBPACAC 的交点(设为 JJ)、CPCPABAB 的交点(设为 KK)。

利用这两个点以及圆幂定理可以得到 JEJEKFKF 之间的比例关系。

另外,由 ABPCABPC 共圆可知 DJK\triangle DJK 是自配极三角形,外心 OODJK\triangle DJK 的垂心,可知 JKBCJK\parallel BC。结合前面的 JEJEKFKF 的条件可证 EFJKBCEF\parallel JK\parallel BC

3. 解答

J=ACBPJ=\overline{AC}\cap \overline{BP}K=ABCPK=\overline{AB}\cap \overline{CP}OOABC\triangle ABC 的外心。

注意到 JKJKDD 关于 O\odot O 的极线,因此 ODJKOD\perp JK

又由 ODBCOD\perp BC 可知 JKBCJK\parallel BC

另一种证明平行的方法

对点 PP 应用塞瓦定理,可得

AKKBBDDCCJJA=1 \frac{AK}{KB}\cdot \frac{BD}{DC}\cdot \frac{CJ}{JA} = 1

因此

AKAJ=BKCJ=AKBKAJCJ=ABAC \frac{AK}{AJ} = \frac{BK}{CJ} = \frac{AK-BK}{AJ-CJ} = \frac{AB}{AC}

于是有 BCJKBC\parallel JK

解答

JE2=JBJP=JAJCKF2=KCJP=KAKB \begin{aligned} JE^2 & = JB\cdot JP = JA\cdot JC \\ KF^2 & = KC\cdot JP = KA\cdot KB \end{aligned}

可知

JE2KF2=JAJCKAKB=JA2KA2 \frac{JE^2}{KF^2} = \frac{JA\cdot JC}{KA\cdot KB} = \frac{JA^2}{KA^2}

JEKF=JAKA=AEAF \frac{JE}{KF} = \frac{JA}{KA} = \frac{AE}{AF}

可知 EFJKBCEF\parallel JK\parallel BC,因此

AFFB=AEEC \frac{AF}{FB} = \frac{AE}{EC}

可知

AFFBBDDCCEEA=1 \frac{AF}{FB}\cdot \frac{BD}{DC}\cdot \frac{CE}{EA} = 1

因此 ADADBEBECFCF 三线共点,命题得证。

2025 年 CGMO 的几何题的解答(二)

作者 西风冷香
2025年8月23日 10:50

1. 题目

如图,在锐角 ABC\triangle ABC 中,AB>ACAB > ACDDEEFF 分别是 AABBCC 在对应边上的投影。设 BFBF 的中垂线与 DEDE 交于点 PPCECE 的中垂线与 DFDF 交于点 QQ。设 KK 是直线 PQPQ 上一点使得 PKE=PKF\angle PKE = \angle PKF。设 DKDKKEF\triangle KEF 的外接圆与另一点 TT,求证:ATE=ATF\angle ATE = \angle ATF

题目

2. 分析

HHABC\triangle ABC 的垂心,NNABC\triangle ABC 的九点圆圆心。

这个题有三个重要的观察点:

  1. HHPPQQ 共线;
  2. KK 满足 FKH=HKE=FDE\angle FKH = \angle HKE = \angle FDE,从而 N(KEF)N\in \odot(KEF)
  3. AATTNN 共线。

第一个结论在做出图来之后,是比较容易看出来的,可以直接用梅涅劳斯定理进行证明。

在证明第一个结论的时候,会用到 BCBCBHBHCHCH 的中点,结合 DDEEFF 自然就联想到九点圆。画出九点圆就会发现,其圆心就在 KEF\triangle KEF 的外接圆上。

从而我们可以得出

FKH=12FNE=FHbHHKE=12FNE=HHcE\begin{aligned} \angle FKH = \frac{1}{2}\angle FNE = \angle FH_bH \\[2ex] \angle HKE = \frac{1}{2}\angle FNE = \angle HH_cE\end{aligned}

因此 FFHbH_bKKHH 共圆,EEHcH_cKKHH 共圆,可知点 KK 是完全四边形 FHbEHcFH_bEH_c 的密克点。

要证明这个结论,我们可以使用同一法构造点 KK。可以直接用密克点来构造,也可以用其它的圆的交点来构造,关键是构造点 KK 之后能够证明其在直线 PQPQ 上。

最后一个结论不是很好直接证明,不过此时的结论可以转换成角相等的结论,而且可以完全消去点 KK,因此我们可以考虑把它算出来。可以尝试三角法或复数法。

3. 解答

3.1. 结论一的证明

如图,设 CFCFDEDE 交于点 ZZ。考虑 FZD\triangle FZD,要证明 HHPPQQ 共线,只需证

FQQDDPPZZHHF=1 \frac{FQ}{QD}\cdot \frac{DP}{PZ}\cdot \frac{ZH}{HF} = 1

CECE 的中垂线分别与 ABABBCBCCFCF 交于 LLMMHcH_c,则 MMBCBC 中点,HcH_cCHCH 中点。考虑 FBD\triangle FBD,有

FQQDDMMBBLLF=1 \frac{FQ}{QD}\cdot \frac{DM}{MB}\cdot \frac{BL}{LF} = 1

注意到

DPPZ=DMMC=DMMB \frac{DP}{PZ} = \frac{DM}{MC} = \frac{DM}{MB}

以及

BLLF=HHcHcF \frac{BL}{LF} = \frac{H H_c}{H_cF}

因此只需证明

ZHHF=HHcHcF \frac{ZH}{HF} = \frac{H H_c}{H_cF}

即可。

注意到 ABC\triangle ABC 的垂心 HH 是其垂足三角形 DEFDEF 的内心,AABBCCDEF\triangle DEF 的旁心,因此

(FZ;CH)=1 (FZ;CH) = -1

HcH_cCHCH 中点可知

HcH2=HcZHcF H_cH^2 = H_cZ\cdot H_cF

HcHHcF=HcZHcH=HcHHcZHcFHcH=HZHF \frac{H_cH}{H_cF} = \frac{H_cZ}{H_cH} = \frac{H_cH-H_cZ}{H_cF-H_cH} = \frac{HZ}{HF}

结论得证。

3.2. 结论二的证明

如图,设 KK'(HbHF)\odot(H_bHF)(PDF)\odot(PDF) 的第二个交点,则由

FKH=FHbH=FDE=FDP=FKP \measuredangle FK'H = \measuredangle FH_bH = \measuredangle FDE = \measuredangle FDP = \measuredangle FK'P

可知 KK' 在直线 PHPH 上。由

PQQK=DQQF=MQQHc PQ\cdot QK' = DQ\cdot QF = MQ\cdot QH_c

可知 PPMMKK'HcH_c 共圆。因此

HKHc=PKHc=PMHc=PHbE=CHE=HEHc \begin{aligned} \measuredangle HK'H_c &= \measuredangle PK'H_c = \measuredangle PMH_c \\ &= \measuredangle PH_bE = \measuredangle CHE = \measuredangle HEH_c \end{aligned}

可得 HHKK'HcH_cEE 共圆。故

HKE=HHcE=FDE=FKH \measuredangle HK'E = \measuredangle HH_cE = \measuredangle FDE = \measuredangle FK'H

因此 KK'KK 重合。

3.3. 结论三的证明(复数法)

由结论二可知,

FKE=FKH+HKE=FHbH+HHcE=2FDE=FNE \begin{aligned} \measuredangle FKE &= \measuredangle FKH + \measuredangle HKE \\ &= \measuredangle FH_bH + \measuredangle HH_cE \\ &= 2 \measuredangle FDE \\ &= \measuredangle FNE \end{aligned}

因此点 NN(KEF)\odot(KEF) 上。

FTN=FEN=NFE=NTE \measuredangle FTN = \measuredangle FEN = \measuredangle NFE = \measuredangle NTE

要证明 ATE=ATF\angle ATE = \angle ATF,只需证 AATTNN 共线,即 ANF=TNF\measuredangle ANF = \measuredangle TNF 即可。其中

TNF=TKF=DKF=DPF=EPF \measuredangle TNF = \measuredangle TKF = \measuredangle DKF = \measuredangle DPF = \measuredangle EPF

因此只需证 ANF=EPF\measuredangle ANF = \measuredangle EPF

ABC\triangle ABC 的外接圆为单位圆建立复平面,设点 AABBCCDDEEFFHHHbH_bHcH_cMMNNPP 对应的复数依次为 aabbccddeeffhhhbh_bhch_cmmnnpp。则

h=a+b+cn=12h=12(a+b+c)d=12(a+b+caˉbc)e=12(a+b+cabˉc)f=12(a+b+cabcˉ)hb=12(b+h)=12(a+2b+c)hc=12(c+h)=12(a+b+2c)m=12(b+c) \begin{aligned} h & = a+b+c \\[2ex] n & = \frac{1}{2} h = \frac{1}{2}(a+b+c) \\[2ex] d & = \frac{1}{2}\left(a+b+c-\bar{a} b c\right) \\[2ex] e & = \frac{1}{2}\left(a+b+c-a \bar{b} c\right) \\[2ex] f & = \frac{1}{2}\left(a+b+c-a b \bar{c}\right) \\[2ex] h_b & = \frac{1}{2}(b+h) = \frac{1}{2}(a+2b+c) \\[2ex] h_c & = \frac{1}{2}(c+h) = \frac{1}{2}(a+b+2c) \\[2ex] m & = \frac{1}{2}(b+c) \end{aligned}

p=λhb+(1λ)m=λ12(a+2b+c)+(1λ)12(b+c)=12λ(a+b)+12(b+c) \begin{aligned} p & = \lambda \cdot h_b + (1-\lambda)\cdot m \\[2ex] & = \lambda \cdot \frac{1}{2}(a+2b+c)+(1-\lambda)\cdot \frac{1}{2}(b+c) \\[2ex] & = \frac{1}{2}\lambda(a+b)+\frac{1}{2}(b+c) \end{aligned}

以及

p=μd+(1μ)e=μ12(a+b+caˉbc)+(1μ)12(a+b+cabˉc)=12μ(abˉcaˉbc)+12(a+b+cabˉc) \begin{aligned} p & = \mu \cdot d + (1-\mu)\cdot e \\[2ex] & = \mu \cdot \frac{1}{2}(a+b+c-\bar{a} b c) + (1-\mu)\cdot \frac{1}{2}(a+b+c-a \bar{b} c) \\[2ex] & = \frac{1}{2}\mu(a \bar{b} c - \bar{a} b c) + \frac{1}{2}\left(a+b+c - a \bar{b} c\right) \end{aligned}

可得

λ(a+b)μ(abˉaˉb)c=aabˉc \lambda(a+b) - \mu(a \bar{b} - \bar{a} b) c = a - a \bar{b} c

其中 λR\lambda \in \mathbb{R}μR\mu \in \mathbb{R}

对上面的式子取共轭可得

λ(aˉ+bˉ)μ(aˉbabˉ)=aˉaˉbcˉ \lambda (\bar{a}+\bar{b}) - \mu (\bar{a} b - a \bar{b}) = \bar{a} - \bar{a} b \bar{c}

联立可解得

μ=(bc)(bˉ2+aˉcˉ)(abˉaˉb)(cˉ+aˉbˉc) \mu = - \frac{(b-c)\left(\bar{b}^2+\bar{a}\bar{c}\right)}{\left(a\bar{b}-\bar{a}b\right)\left(\bar{c}+\bar{a}\bar{b}c\right)}

要证明 ANF=EPF\measuredangle ANF = \measuredangle EPF,只需证

fpep÷fnanR \frac{f-p}{e-p}\div \frac{f-n}{a-n} \in \mathbb{R}

即可。

其中

fp=12abcˉ+12μaˉbc+12(1μ)abˉc=12[a(bˉcbcˉ)+μ(aˉbabˉ)c]ep=e[μd+(1μ)e]=μ(ed)=12μ(aˉbabˉ)cfn=12abcˉan=12(abc) \begin{aligned} f-p & = -\frac{1}{2} a b \bar{c} + \frac{1}{2} \mu \bar{a} b c + \frac{1}{2}(1-\mu) a \bar{b} c \\[2ex] & = \frac{1}{2}\left[a\left(\bar{b}c-b\bar{c}\right)+\mu\left(\bar{a}b-a\bar{b}\right)c\right] \\[3ex] e-p & = e - \left[\mu \cdot d + (1-\mu)\cdot e\right] \\[2ex] & = \mu(e-d) \\[2ex] & = \frac{1}{2}\mu\left(\bar{a}b-a\bar{b}\right)c \\[3ex] f-n & = -\frac{1}{2}ab\bar{c} \\[3ex] a-n & = \frac{1}{2}(a-b-c) \end{aligned}

因此

fpep÷fnan=a(bˉcbcˉ)+μ(aˉbabˉ)cμ(aˉbabˉ)cabcabcˉ=(a(bˉcbcˉ)μ(aˉbabˉ)c+1)b+caabcˉ \begin{aligned} \frac{f-p}{e-p}\div \frac{f-n}{a-n} & = \frac{a\left(\bar{b}c-b\bar{c}\right)+\mu\left(\bar{a}b-a\bar{b}\right)c}{\mu\left(\bar{a}b-a\bar{b}\right)c}\cdot \frac{a-b-c}{-ab\bar{c}} \\[2ex] & = \left(\frac{a\left(\bar{b}c-b\bar{c}\right)}{\mu\left(\bar{a}b-a\bar{b}\right)c}+1\right)\cdot \frac{b+c-a}{ab\bar{c}} \end{aligned}

其中

a(bˉcbcˉ)μ(aˉbabˉ)c=a(bˉcbcˉ)(cˉ+aˉbˉc)(bc)(bˉ2+aˉcˉ)c=a(b+c)(ab+c2)c2(ac+b2) \begin{aligned} \frac{a\left(\bar{b}c-b\bar{c}\right)}{\mu\left(\bar{a}b-a\bar{b}\right)c} & = \frac{a\left(\bar{b}c-b\bar{c}\right)\left(\bar{c}+\bar{a}\bar{b}c\right)}{(b-c)\left(\bar{b}^2+\bar{a}\bar{c}\right)c} \\[2ex] & = -\frac{a(b+c)(ab+c^2)}{c^2(ac+b^2)} \end{aligned}

代入可得

fpep÷fnan=c2(ac+b2)a(b+c)(ab+c2)c2(ac+b2)b+caabcˉ=b(a+c)(bcabac)(b+ca)abc(ac+b2)=b(a+c)ac+b2(bˉ+cˉaˉ)(b+ca) \begin{aligned} \frac{f-p}{e-p}\div \frac{f-n}{a-n} & = \frac{c^2(ac+b^2)-a(b+c)(ab+c^2)}{c^2(ac+b^2)}\cdot \frac{b+c-a}{ab\bar{c}} \\[2ex] & = \frac{b(a+c)(bc-ab-ac)(b+c-a)}{abc(ac+b^2)} \\[2ex] & = - \frac{b(a+c)}{ac+b^2}\cdot \left(\bar{b}+\bar{c}-\bar{a}\right)(b+c-a) \end{aligned}

注意到

(b(a+c)ac+b2)=1b(1a+1c)1ac+1b2=b(c+a)b2+ac(i) \overline{\left(\frac{b(a+c)}{ac+b^2}\right)} = \frac{\frac{1}{b}\left(\frac{1}{a}+\frac{1}{c}\right)}{\frac{1}{ac}+\frac{1}{b^2}} = \frac{b(c+a)}{b^2+ac} \tag{i}

因此

(fpep÷fnan)=fpep÷fnan \overline{\left(\frac{f-p}{e-p}\div \frac{f-n}{a-n}\right)} = \frac{f-p}{e-p}\div \frac{f-n}{a-n}

可知

fpep÷fnanR \frac{f-p}{e-p}\div \frac{f-n}{a-n} \in \mathbb{R}

命题得证。

3.4. 结论一的另一个证明(不使用调和点列)

注意到 HHbMHcH H_b M H_c 是平行四边形,只需证明

PMPHb=QMHHb    PMPHb=QMHcM \frac{PM}{PH_b} = \frac{QM}{HH_b} \impliedby \frac{PM}{PH_b} = \frac{QM}{H_cM}

考虑直线 DEDEBHbM\triangle BH_bM,由梅涅劳斯定理可知,

BEEHbHbPPMMDDB=1 \frac{BE}{EH_b}\cdot \frac{H_bP}{PM}\cdot \frac{MD}{DB} = 1

因此

PMPHb=BEBDMDEHb \frac{PM}{PH_b} = \frac{BE}{BD}\cdot \frac{MD}{EH_b}

注意到 BEBHb=BMBDBE\cdot BH_b = BM\cdot BD,因此

PMPHb=BMBHbMDEHb \frac{PM}{PH_b} = \frac{BM}{BH_b}\cdot \frac{MD}{EH_b}

同理,考虑直线 DFDFCHcM\triangle CH_cM,有

CFFHcHcQQMMDDC=1 \frac{CF}{FH_c}\cdot \frac{H_cQ}{QM}\cdot \frac{MD}{DC} = 1

因此

QMHcQ=CFCDMDFHc \frac{QM}{H_cQ} = \frac{CF}{CD} \cdot \frac{MD}{FH_c}

注意到 CFCHc=CDCMCF\cdot CH_c = CD\cdot CM,因此

QMHcQ=CMCHcMDFHc    QMHcM=CMMDCHcFHc+CMMD \begin{aligned} \frac{QM}{H_cQ} & = \frac{CM}{CH_c} \cdot \frac{MD}{FH_c} \\[2ex] \implies \frac{QM}{H_cM} & = \frac{CM\cdot MD}{CH_c\cdot FH_c + CM\cdot MD} \end{aligned}

所以,要证明的结论等价于

BMMDEHbBHb=CMMDCHcFHc+CMMD    EHbBHb=CHcFHc+CMMD \begin{aligned} \frac{BM\cdot MD}{EH_b\cdot BH_b} & = \frac{CM\cdot MD}{CH_c\cdot FH_c + CM\cdot MD} \\[2ex] \iff EH_b\cdot BH_b & = CH_c\cdot FH_c + CM\cdot MD \end{aligned}

其中

EHbBHb=(BEBHb)BHb=BEBHbBHb2=BMBD14BH2 \begin{aligned} EH_b\cdot BH_b & = \left(BE-BH_b\right)\cdot BH_b \\[1ex] & = BE\cdot BH_b - BH_b^2 \\[1ex] & = BM\cdot BD-\frac{1}{4}BH^2 \end{aligned}

以及

FHcCHc=(CFFHc)CHc=CFCHcCHc2=CMCD14CH2 \begin{aligned} FH_c\cdot CH_c & = \left(CF-FH_c\right)\cdot CH_c \\[1ex] & = CF\cdot CH_c - CH_c^2 \\[1ex] & = CM\cdot CD - \frac{1}{4}CH^2 \end{aligned}

因此只需证

BMBD14BH2=CMMD+CMCD14CH2    BM(BDMDCD)=14(BH2CH2)    BMDM=14(BH2CH2) \begin{aligned} & \, BM\cdot BD-\frac{1}{4}BH^2 = CM\cdot MD + CM\cdot CD - \frac{1}{4}CH^2 \\[1ex] \iff & \, BM\cdot (BD-MD-CD) = \frac{1}{4}\left(BH^2-CH^2\right) \\[1ex] \iff & \, BM\cdot DM = \frac{1}{4}\left(BH^2-CH^2\right) \end{aligned}

DHBCDH\perp BC 可知

BH2CH2=BD2CD2=(BD+CD)(BDCD)=BC2DM=2BM2DM=4BMDM \begin{aligned} BH^2-CH^2 & = BD^2 - CD^2 \\[1ex] & = (BD+CD)(BD-CD) \\[1ex] & = BC\cdot 2DM \\[1ex] & = 2BM\cdot 2DM \\[1ex] & = 4BM\cdot DM \end{aligned}

结论得证。

3.5. 结论二的另一个证明(使用完全四边形)

如图,设 S=FHbEHcS=\overline{FH_b}\cap\overline{EH_c}R=EFHbHcR=\overline{EF}\cap \overline{H_bH_c}。对 HbMHcEDFH_bMH_cEDF 应用帕斯卡定理可知 PPQQSS 共线。

考虑完全四边形 FHbEHcFH_bEH_c 的密克点 KK',由 FHbEHcFH_bEH_c 四点共圆可知 KK'RR 关于 N\odot N 的反演点。因此 KK' 在点 RR 关于 N\odot N 的极线 SHSH 上。故

FKH=FHbH=FDE=FHcE=HKE \angle FKH = \angle FH_bH = \angle FDE = \angle FH_cE = \angle HKE

可得 PKF=PKE\angle PKF = \angle PKE,因此 KK'KK 重合。

3.6. 另一种复平面的构造方法

如图,以 ABC\triangle ABC 的九点圆为单位圆建立复平面,则 n=0n=0。注意到 HHDEF\triangle DEF 的内心,因此存在单位圆上的 uuvvww 使得

d=u2,e=v2,f=w2ha=vw,hb=wu,hc=uv \begin{gathered} d = u^2, \quad e = v^2, \quad f = w^2 \\[1ex] h_a = -vw, \quad h_b = -wu, \quad h_c = -uv \end{gathered}

于是

h=(uv+vw+wu)m=vw \begin{aligned} h &= -(uv+vw+wu) \\[1ex] m &= vw \end{aligned}

因此

a=2hah=uvvw+wu=uvw(w+vu) a=2h_a-h = uv-vw+wu = uvw\left(\overline{w}+\overline{v}-\overline{u}\right)

p=λu2+(1λ)v2=λ(u2v2)+v2 p = \lambda u^2 + (1-\lambda) v^2 = \lambda \left(u^2-v^2\right) +v^2

以及

p=μ(wu)+(1μ)vw=μ(wu+vw)+vw p = \mu (-wu) + (1-\mu) vw = -\mu(wu+vw)+vw

因此

λ(u+v)(uv)+μ(u+v)w=v(wv) \lambda(u+v)(u-v)+\mu(u+v)w=v(w-v)

其中 λR\lambda \in \mathbb{R}μR\mu \in\mathbb{R}

对上式取共轭可得

λ(uˉ+vˉ)(uˉvˉ)+μ(uˉ+vˉ)wˉ=vˉ(wˉvˉ) \lambda\left(\bar{u}+\bar{v}\right)\left(\bar{u}-\bar{v}\right)+\mu\left(\bar{u}+\bar{v}\right)\bar{w}=\bar{v}\left(\bar{w}-\bar{v}\right)

联立可解得

λ=u(wv)(wu+v2)(u+v)(uv)(uv+w2) \lambda = \frac{u(w-v)\left(wu+v^2\right)}{(u+v)(u-v)\left(uv+w^2\right)}

因此

fpepanfn=w2v2λ(u2v2)λ(v2u2)af=[w2v2λ(v2u2)+1]af=[(w+v)(wv)(u+v)(uv)(u+v)(uv)(uv+w2)u(wv)(wu+v2)+1]af=u(uw+v2)(w+v)(uv+w2)u(uw+v2)uvw(w+vu)w2=(u+w)vuw+v2(w+vu)(w+vu) \begin{aligned} \frac{f-p}{e-p}\cdot \frac{a-n}{f-n} & = \frac{w^2-v^2-\lambda\left(u^2-v^2\right)}{\lambda\left(v^2-u^2\right)}\cdot \frac{a}{f} \\[2ex] & = \left[\frac{w^2-v^2}{\lambda\left(v^2-u^2\right)}+1\right]\cdot \frac{a}{f} \\[2ex] & = \left[-\frac{(w+v)\bcancel{(w-v)}}{\bcancel{(u+v)(u-v)}}\cdot \frac{\bcancel{(u+v)(u-v)}(uv+w^2)}{u\bcancel{(w-v)}(wu+v^2)}+1\right]\cdot \frac{a}{f} \\[2ex] & = \frac{u(uw+v^2)-(w+v)\left(uv+w^2\right)}{u\left(uw+v^2\right)}\cdot \frac{uvw(\overline{w}+\overline{v}-\overline{u})}{w^2} \\[2ex] & = -\frac{(u+w)v}{uw+v^2}(w+v-u)\left(\overline{w}+\overline{v}-\overline{u}\right) \end{aligned}

类似 (i) 式可知

fpepanfnR \frac{f-p}{e-p}\cdot \frac{a-n}{f-n} \in \mathbb{R}

结论得证。

2025 年 CGMO 的几何题的解答(一)

作者 西风冷香
2025年8月15日 12:49

1. 题目

如图,四边形 ABCDABCD 内接于圆 OOAB>BCAB > BCEEACAC 上一点使得 AE<ECAE < EC。过 EEACAC 的垂线 ll,分别交 AED\triangle AEDBED\triangle BEDCED\triangle CED 的外接圆于另一点 XXYYZZ,且 EEYYZZXX 依次排列。若 AX=BY=CZAX=BY=CZ,求证:BDBDOYOY 的交点是 ABC\triangle ABC 的内心。

题目

2. 分析

这个题的关键是确定 DD 点和 EE 点的位置。

从结论可以得出点 DDBIBIABC\triangle ABC 点外接圆的交点,由鸡爪定理可知 DI=DA=DCDI = DA = DC

这一点也比较好证,直接用 AX=CZAX=CZ 即可证明。

EE 点的位置无法直接确定,但是也比较好猜。鸡爪定理的另一个结论是旁心 JJ 也在射线 BIBI 上,且 DJ=DIDJ=DI。作出 JJ 点之后,我们会发现他就在直线 ll 上。可知点 EE 是旁切圆的切点。

而关于旁切圆的切点,有一个经典的引理:

ABC\triangle ABC 的内心为 IIXXAA- 旁切圆与 BCBC 的切点,MMBCBC 的中点,则 AXIMAX\parallel IM

上述引理的证明

引理

ABC\triangle ABC 的内切圆与 BCBC 相切于 DD,点 EEDDABC\triangle ABC 的内切圆上的对径点,由位似可知 AAEEXX 共线。

CX=BDCX = BD,可知 DM=MXDM = MX,因此 IMIMEDX\triangle EDX 的中位线,IMAXIM\parallel AX

我们可以反过来用这个结论,通过构造平行的结论来证明点 EE 是旁切圆切点。

接下来证明 IIOOYY 共线。只需要注意到点 OOIYIY 的中点即可。

3. 解答

3.1. 证明点 D 在直线 BI 上

AEX=CEZ=90\angle AEX = \angle CEZ = 90^\circ 可知 AXAXCZCZ 分别是 AED\triangle AEDCED\triangle CED 的外接圆的直径。

步骤1

连接 DADADCDCDEDE,有

DAsinDEA=AX=CZ=DCsinDEC \frac{DA}{\sin \angle DEA} = AX = CZ = \frac{DC}{\sin \angle DEC}

可得 DA=DCDA=DC

由鸡爪定理可知,ABC\triangle ABC 的内心 IIBDBD 上,且 DI=DA=DCDI=DA=DC

接下来为了简化图形,我们可以扔掉 XXZZ 两点,AX=BY=CZAX=BY=CZ 的条件简化为

BY=DCsinDEC=DEsinDCE BY = \frac{DC}{\sin \angle DEC} = \frac{DE}{\sin \angle DCE}

3.2. 证明平行

设直线 DODOACAC 交于点 MM,则 MMACAC 的中点,且 DMACDM\perp AC,因此 DMlDM\parallel l

连接 BEBEIMIM,下面证明:BEIMBE\parallel IM

步骤2

设直线 BDBDll 交于点 JJ,考虑 JBE\triangle JBEDIM\triangle DIM。由 DMlDM\parallel l 可知 J=IDM\angle J=\angle IDM,因此

JBE+JEB=DIM+DMI(i) \angle JBE + \angle JEB = \angle DIM + \angle DMI \tag{i}

BED\triangle BED 的外接圆半径为 RR,则

BYsinJEB=2R=DEsinJBE    sinJBEsinJEB=DEBY=sinDCE \begin{aligned} & \frac{BY}{\sin \angle JEB} = 2R = \frac{DE}{\sin \angle JBE} \\[2ex] \implies & \frac{\sin \angle JBE}{\sin \angle JEB} = \frac{DE}{BY} = \sin \angle DCE \end{aligned}

DIM\triangle DIM 中,

DMsinDIM=DIsinDMI    sinDIMsinDMI=DMDI=DMDC=sinDCE \begin{aligned} & \frac{DM}{\sin \angle DIM} = \frac{DI}{\sin \angle DMI} \\[2ex] \implies & \frac{\sin \angle DIM}{\sin \angle DMI} = \frac{DM}{DI} = \frac{DM}{DC} = \sin \angle DCE \end{aligned}

因此,

sinJBEsinJEB=sinDIMsinDMI(ii) \frac{\sin \angle JBE}{\sin \angle JEB} = \frac{\sin \angle DIM}{\sin \angle DMI} \tag{ii}

我们构造函数

f(x)=sin(θx)sin(x),x(0,π),θ(π2,π) f(x) = \frac{\sin(\theta-x)}{\sin(x)}, \, x \in (0, \pi), \, \theta \in \left(\frac{\pi}{2}, \pi\right)

f(x)=cos(θx)sinxsin(θx)cosxsin2x=sinθsin2x<0 f'(x) =\frac{-\cos (\theta-x)\sin x-\sin (\theta-x)\cos x}{\sin ^2 x} = -\frac{\sin \theta}{\sin ^2x} < 0

可知 f(x)f(x)(0,π)(0,\pi) 上单调递减。结合等式 (i) 和 (ii) 可知 JEB=DMI\angle JEB = \angle DMIJBE=DIM\angle JBE = \angle DIM,因此 BEIMBE\parallel IM

由上面的引理可知,点 EEABC\triangle ABC 的旁切圆切点,点 JJABC\triangle ABC 的旁心,因此 DJ=DI=DA=DCDJ=DI=DA=DC

另外,结合 DMlDM\parallel l 可知 DIMJBE\triangle DIM\sim \triangle JBE

3.3. 证明 O 是 IY 的中点

JYJE=JDJBJY\cdot JE=JD\cdot JB 可知

JDJY=JEJB=DMDI=DMDC=sinDCA \frac{JD}{JY} = \frac{JE}{JB} = \frac{DM}{DI} = \frac{DM}{DC} = \sin \angle DCA

于是

JY=JDsinDCA=DAsinDCA=2OD JY = \frac{JD}{\sin \angle DCA} = \frac{DA}{\sin\angle DCA} = 2\cdot OD

注意到 DOJYDO\parallel JY,点 DDIYIY 的中点,因此 ODODIJY\triangle IJY 的中位线,点 IIOOYY 共线,命题得证。

最后一步的另一种证明方法

连接 DYDY,由上面可知 DIMJBEJYD\triangle DIM \sim \triangle JBE \sim \triangle JYD,因此

DIJY=DMJD    DI2=DMJY \frac{DI}{JY} = \frac{DM}{JD} \implies DI^2 = DM\cdot JY

步骤3

设直线 DODOABC\triangle ABC 的外接圆的另一个交点为 NN,连接 NYNYNINI

注意到 DI2=DC2=DMDNDI^2=DC^2=DM\cdot DN,因此 DN=JYDN = JY,可知四边形 YNDJYNDJ 是平行四边形。于是有

YNDJYN=DJ=DI \begin{gathered} YN\parallel DJ \\ YN=DJ = DI \end{gathered}

因此四边形 YNIDYNID 也是平行四边形,点 OO 是对角线 DNDNIYIY 的交点。

❌
❌