数据截至 (上游 commit 4fe4bde114a2)
入口与编排:从字节到多路输出
30 秒导读: 这一章讲 MinerU 的"外壳"——命令行和服务入口如何把一个 PDF/图片/Office 文件收进来、选好用哪个解析后端、做完预处理,再把解析器吐出的同一份中间结果扇出成一堆 磁盘文件(Markdown、两版 content_list、middle.json、model.json、原文件、可视化 bbox PDF)。 不进模型内部——模型怎么解析是 pipeline 后端、 vlm 后端、hybrid 后端 的事。
本章的目标很具体:读完你能自己追一次调用,从 mineru -p x.pdf -o out/ 一路追到 out/ 里
出现的每一个文件是谁写的、为什么。
1. 这是什么(零基础也能懂)
一句话定义: MinerU 的入口层是一套"文档解析编排器"——你给它一个文件(或一整个目录), 它负责把文件读成字节、决定用哪种解析方式、调度解析、最后把结果写成一组结构化产物。
它自己不解析。 真正把 PDF 变成文字的是三种后端(传统多模型流水线 / 视觉语言模型 / 二者混合)。 入口层做的是秘书的活:收件、分派、盯进度、归档。把这一层单独看清楚,后面看每个后端才不乱。
用起来什么样。 最小的一次真实调用就是一行命令:
mineru -p demo.pdf -o output/ -b pipeline
跑完 output/demo/auto/ 下会出现这些文件(名字里的 demo 是文件 stem,auto 是解析方法):
| 产物文件 | 是什么 |
|---|---|
demo.md | 渲染好的 Markdown 正文(默认多模态版,图片用相对路径引用) |
demo_content_list.json | 内容块的扁平列表(v1) |
demo_content_list_v2.json | 内容块列表的新版结构(v2) |
demo_middle.json | 统一中间表示,后端无关的核心数据(见 第 4 章) |
demo_model.json | 模型原始输出(pipeline 是各模型结果,vlm 是推理结果) |
demo_origin.pdf | 原始输入文件的副本 |
demo_layout.pdf / demo_span.pdf | 在原图上画了检测框的可视化 PDF |
images/ | 抠出来的图片/表格/公式截图 |
一句话直觉: 把入口层想成快递分拣中心——它不生产包裹,只负责"收进来 → 贴标签选传送带 → 分拣 → 打包成多种规格发出去"。这一章讲的就是这条传送带。
2. 顶层全景(它大概怎么转)
2.1 有哪些入口
MinerU 对外有好几个入口,但它们最后都汇到同一个函数 do_parse(见 §4)。先认清谁是谁:
| 入口 | 文件 | 一句话职责 |
|---|---|---|
CLI mineru | mineru/cli/client.py | 命令行主入口。注意:它不直接调 do_parse,而是起一个本地 API 服务,再当 HTTP 客户端去调它(见 §2.2) |
| FastAPI 服务 | mineru/cli/fast_api.py | mineru-api,把 do_parse/aio_do_parse 包成 HTTP 接口,client.py 和外部都靠它 |
| Gradio 界面 | mineru/cli/gradio_app.py | mineru-gradio,网页版 可视化 demo,同样走 API 客户端 |
| 多进程路由 | mineru/cli/router.py | 多 GPU 场景:探测本地设备、拉起多个 worker 子进程做负载均衡 |
| API 客户端库 | mineru/cli/api_client.py | 一堆 HTTP 交互原语(提交任务、轮询状态、下载 zip、健康检查),client.py 里几乎所有网络函数都是它的薄封装 |
本章重点讲 CLI +
do_parse这条主线,其余入口一句话带过即可——它们只是"喂给do_parse的不同前门"。
2.2 CLI 的关键设计:自己当自己的客户端
client.py 有个容易让人愣一下的设计:命令行不在本进程里解析文档。它在 run_orchestrated_cli
里做的是——如果你没给 --api-url,它就起一个临时的本地 mineru-api 服务,然后把自己当成
HTTP 客户端,把任务提交过去、轮询、下载结果 zip、解压落盘。
$ mineru -p x.pdf -o out/
│
▼
client.py: main() ──asyncio.run──► run_orchestrated_cli() client.py:910
│
├─ 没 --api-url? ──► 拉起本地 LocalAPIServer(= fast_api 进程)
│ └─ 等它 /health 就绪
│
├─ 收集输入文档、装箱成 PlannedTask
│
├─ 对每个任务:HTTP 提交 ─► 轮询状态 ─► 下载结果 zip ─► 解压到 out/
│
└─ 结果落盘后,另起进程画可视化 bbox
│
(API 进程内部) ▼
fast_api ──► do_parse / aio_do_parse common.py:668 / :760
│
├─ normalize_backend (选传送带)
├─ 抽走 Office 文件单独处理
├─ _prepare_pdf_bytes (预处理字节)
└─ 按 backend 分派:
pipeline → _process_pipeline
vlm-* → _process_vlm
hybrid-* → _process_hybrid
│
▼
_process_output common.py:259 (扇出落盘)
怎么读这张图: 上半段(命令行 ↔ 本地 API)全是
client.py干的网络编排;下半段(API 进程内 从do_parse到_process_output)全是common.py干的解析编排。本章两大主角就是这两个文件。
为什么绕这一圈?因为服务模式(fast_api)天然支持并发、排队、多 GPU worker;CLI 复用它就白
得了这些能力,不必自己再写一套。CLI 只保留一件事是本地做的——可视化 bbox 的绘制(见 §5.4)。
3. CLI 层:命令行怎么被收拾干净
这一节讲 client.py 顶部那些"看起来琐碎、实则很重要"的入口处理:参数归一、输入收集、任务装箱。
3.1 main:一个 click 命令,十几个选项
CLI 是单个 click.command,入口函数是 main(mineru/cli/client.py:1185),它几乎不干活,只把
参数原样转交给 run_orchestrated_cli(client.py:910)并用 asyncio.run 跑起来。真正的编排在后者。
选项里几个要记住的:-p/--path 输入、-o/--output 输出、-b/--backend 选后端、-m/--method
选解析方法(auto/txt/ocr)、--effort 控制 hybrid 的力度、--api-url 指向一个已有的远程服务
(给了就不起本地服务)。
3.2 参数归一:旧名字自动翻译成新名字
MinerU 的后端名改过版,CLI 用 click callback 在参数一进来就把旧名归一。--backend 选项
(client.py:1079 起的装饰器)挂了 callback=normalize_backend_option:
# client.py:97 normalize_backend_option —— 真实源码
def normalize_backend_option(ctx, param, value: str) -> str:
"""将 CLI 输入的旧 backend 名称规范为当前公开名称。"""
try:
return normalize_backend(value)
except ValueError as exc:
raise click.BadParameter(str(exc), ctx=ctx, param=param) from exc
它把真正的归一逻辑委托给 normalize_backend,并把内部的 ValueError 翻译成 click 能漂亮打印的
BadParameter。同样套路的还有 normalize_effort_option(client.py:109)和
normalize_ocr_lang_option(client.py:121)。
别名归一的真源在 backend_options.py。 normalize_backend(backend_options.py:39)先查一张
旧名映射表,再校验结果是否在公开清单里:
# backend_options.py:25 别名表 + :39 normalize_backend —— 真实源码(节选)
LEGACY_BACKEND_ALIASES = {
"vlm-auto-engine": BACKEND_VLM_ENGINE, # 旧名 → 新公开名
"hybrid-auto-engine": BACKEND_HYBRID_ENGINE,
}
def normalize_backend(backend: str) -> str:
normalized_backend = LEGACY_BACKEND_ALIASES.get(backend, backend)
if normalized_backend not in PUBLIC_BACKEND_CHOICES:
raise ValueError(f"Invalid backend. Allowed values: ...")
return normalized_backend
公开后端一共五个(backend_options.py:13-22):
| backend | 含义 | 深入章节 |
|---|---|---|
pipeline | 传统多模型流水线,通用 | 02 |
vlm-engine | 单个视觉语言模型,本地算力,高精度 | 03 |
vlm-http-client | 同上但走远程 OpenAI 兼容服务 | 03 |
hybrid-engine | 新一代混合方案,本地算力(默认后端,见 DEFAULT_BACKEND,backend_options.py:11) | 05 |
hybrid-http-client | 混合方案,主要算力在远程 | 05 |
为什么归一要在两个层次都做? click callback 是给人兜底(命令行敲了旧名也能跑);而
do_parse 内部开头又调了一次 normalize_backend(common.py:693),是给程序化调用兜底
(别人直接 import do_parse 传旧名也不崩)。归一是幂等的,重复调用无害。
3.3 从输入到"任务":收集与装箱
run_orchestrated_cli 拿到参数后,先把输入变成一串结构化文档,再把文档打包成任务:
collect_input_documents(client.py:544):目录就遍历,单文件就单个;按后缀过滤,只留 PDF/图片/Office;对 PDF 还会真去数有效页数(probe_pdf_effective_pages,client.py:521), 为后面装箱算权重;最后用uniquify_task_stems给重名文件去重(否则输出目录会互相覆盖)。plan_tasks(client.py:654):只有pipeline后端会做"装箱"——plan_pipeline_tasks(client.py:609)把多个小文件按页数打包进同一个批次(经典的 bin-packing,尽量填满processing_window_size),让流水线一次多吃几页;其它后端一个文件一个任务。
装箱后,execute_planned_tasks(client.py:781)用一个 asyncio 队列 + 若干 worker 协程并发跑,
并发度由服务端和本地配置协商(resolve_submit_concurrency,client.py:765)。每个任务真正的
执行体是 run_planned_task(client.py:819):提交 → 轮询 → 下载 zip → 解压 → 排可视化任务。
这一段是纯网络编排,不碰解析。真正把字节喂给模型的一切,发生在 API 进程里的
do_parse。
4. do_parse:分派中枢
这是全章的心脏——所有入口最终都汇到 do_parse(mineru/cli/common.py:668)或它的异步孪生
aio_do_parse(common.py:760)。它要解决的小问题是:"手里有一堆文件字节和一个 backend 名,
该怎么把它们送对地方、并保证输出统一?"
4.1 分派前的三步预处理
do_parse 在真正分派前,依次做三件事:
① 归一后端名。 开头 backend = normalize_backend(backend)(common.py:693),理由见 §3.2。
② 把 Office 文件先抽出来单独走。 _process_office_doc(common.py:618)扫一遍字节,凡是
docx/pptx/xlsx 的当场解析并落盘,然后返回这些文件的下标;do_parse 据此把它们从后续列表里
del 掉(common.py:705-707)。Office 直解走的是完全不同的路径,详见 第 5 章。
③ 预处理 PDF 字节。 剩下的 PDF/图片字节过一遍 _prepare_pdf_bytes(common.py:250),它对每个
文件调 convert_pdf_bytes_to_bytes(common.py:194)。这一步很关键——它把 PDF 用 pdfium 重写一遍,
按 start_page_id/end_page_id 裁页,并容错处理坏页:
convert_pdf_bytes_to_bytes common.py:194
│
├─ 试:rewrite_pdf_bytes_with_pdfium(整份重写、裁页)
│ └─ 成功且非空 ─► 返回重写后的字节 ✅
│
├─ 失败/空? ─► 探测哪些页能加载(get_loadable_pdfium_page_indices)
│ ├─ 有坏页 ─► 打日志列出被跳过的页码
│ ├─ 一页都加载不了 ─► 退回用原始字节
│ └─ 只用能加载的页重写一遍
│
└─ 还不行 ─► 兜底:返回原始 pdf_bytes(绝不让整个任务崩在坏页上)
怎么读: 从上到下是降级顺序,命中即返回。这体现了一个反复出现的设计取向——宁可少解析 几页,也不让一个坏页让整份文档失败。字节层面的读取本身则由
read_fn(common.py:171)完成: 它按后缀判断,图片会先被images_bytes_to_pdf_bytes转成 PDF 字节,PDF/Office 原样返回。
4.2 目录约定:prepare_env
每个文件落盘前,prepare_env(common.py:186)先把输出目录建好。它的路径拼法就是你在 §1 看到的
那个结构:
# common.py:186 prepare_env —— 真实源码
def prepare_env(output_dir, pdf_file_name, parse_method):
local_md_dir = str(os.path.join(output_dir, pdf_file_name, parse_method))
local_image_dir = os.path.join(str(local_md_dir), "images")
os.makedirs(local_image_dir, exist_ok=True)
os.makedirs(local_md_dir, exist_ok=True)
return local_image_dir, local_md_dir
即 <output>/<文件名>/<parse_method>/,图片再往下放 images/。parse_method 对 pipeline 是
auto/txt/ocr,对 vlm 固定是 "vlm",对 hybrid 是 "hybrid_<method>",对 Office 是 "office"。
4.3 依赖门禁:ensure_backend_dependencies
hybrid 后端需要本地 torch。ensure_backend_dependencies(common.py:69)在分派前(或 CLI 侧的
run_orchestrated_cli 里)先探测,缺依赖就抛一个带清晰安装提示的错误,而不是等到深处才因
ImportError 炸掉:
# common.py:69 ensure_backend_dependencies —— 真实源码
def ensure_backend_dependencies(backend: str) -> None:
if not backend.startswith("hybrid-"):
return
if importlib.util.find_spec("torch") is None:
raise HybridDependencyError(build_hybrid_dependency_error_message(backend))
4.4 三条分派分支
预处理完,do_parse 按 backend 前缀分派(common.py:716-757)。注意它分派前会把前缀切掉,
把"公开名"降成"引擎名"再传下去:
| backend 前缀 | 处理函数 | 切前缀后 | process_mode |
|---|---|---|---|
pipeline | _process_pipeline(common.py:351) | 不切 | "pipeline" |
vlm-* | _process_vlm(common.py:467) | 去掉 vlm-(common.py:726) | "vlm" |
hybrid-* | _process_hybrid(common.py:508) | 去掉 hybrid-(common.py:743) | "vlm"(复用 vlm 的产物构造) |
其中 engine 这个字面量会被 get_vlm_engine(...)(common.py:729/:746)进一步解析成实际推理引擎名。
分派时还顺手把 formula/table 开关塞进环境变量(如 MINERU_VLM_TABLE_ENABLE,common.py:731-732),
供下游模型读取——这是 MinerU 里"用环境变量传配置"的一个典型点。
aio_do_parse(common.py:760)是同一套逻辑的异步版,差别是:Office 解析是同步且耗时的,它用
asyncio.to_thread 丢到线程里(common.py:787)避免阻塞事件循环;vlm/hybrid 走各自的 _async_*
变体;pipeline 目前不支持异步,异步入口里也是直接调同步的 _process_pipeline(common.py:812,
代码注释明说)。
5. _process_output:同一份结果,扇出成多路产物
三条分派分支各自跑完解析后,都会汇到同一个落盘函数 _process_output
(mineru/cli/common.py:259)。这是"多路输出"真正发生的地方。它要解决的问题是:"手里有一份
后端无关的 middle_json,怎么按后端类型和一堆开关,把它渲染成正确格式并写到磁盘?"
5.1 先按 process_mode 选 union_make
不同后端的中间结构略有差异,所以 Markdown/content_list 的构造函数是分家的。_process_output
一进来就用 process_mode 挑对应的 union_make(common.py:279-286):
# common.py:279 —— 真实源码(节选)
if process_mode == "pipeline":
make_func = pipeline_union_make
elif process_mode == "vlm":
make_func = vlm_union_make
elif process_mode in office_suffixes: # docx/pptx/xlsx
make_func = office_union_make
else:
raise Exception(f"Unknown process_mode: {process_mode}")
union_make 是"从 middle_json 生成某种格式"的统一入口,它按传入的 MakeMode 决定产出什么——
详见 第 4 章。这里只需知道:同一个 make_func 被复用来产出
Markdown、content_list v1、content_list v2,只是 mode 参数不同。
5.2 一组 f_dump_* 开关控制落哪几路
_process_output 收一堆布尔开关,每个对应一路产物。默认全开(见 do_parse 的默认参数,
common.py:678-685)。逐路看它写了什么:
| 开关 | 落盘文件 | 怎么生成(common.py) |
|---|---|---|
f_dump_md | <name>.md | make_func(pdf_info, f_make_md_mode, image_dir),默认 mode 是 MM_MD(多模态 Markdown),:314-319 |
f_dump_content_list | <name>_content_list.json | make_func(..., MakeMode.CONTENT_LIST, ...),:323 |
f_dump_content_list(同一开关) | <name>_content_list_v2.json | make_func(..., MakeMode.CONTENT_LIST_V2, ...),:329 |
f_dump_middle_json | <name>_middle.json | 直接 json.dumps(middle_json),:336-340 |
f_dump_model_output | <name>_model.json | 直接 dump 模型原始输出 model_output,:342-346 |
f_dump_orig_pdf | <name>_origin.pdf 或 _origin.<office后缀> | 原字节写回,Office 用原后缀,:300-310 |
f_draw_layout_bbox | <name>_layout.pdf | draw_layout_bbox(...) 在原图上画版面框,:288-292 |
f_draw_span_bbox | <name>_span.pdf | draw_span_bbox(...) 画更细的 span 框,:294-298 |
注意一个细节:content_list 的两个版本共用
f_dump_content_list一个开关,v1、v2 会一起写。MakeMode的取值定义在mineru/utils/enum_class.py:89(MM_MD='mm_markdown'、NLP_MD='nlp_markdown'、CONTENT_LIST、CONTENT_LIST_V2)。
5.3 可视化 bbox 为什么只是"尽力而为"
画框那两步(common.py:288-298)都 各自套在 try/except 里,失败只 logger.warning 跳过,不影响
其它产物。这呼应了 §4.1 的取向:可视化是锦上添花,绝不能因为画框失败拖垮正文输出。 另外,
span bbox 只对 pipeline 有意义——vlm/hybrid 的处理函数进 _process_output 前会把
f_draw_span_bbox 强制设为 False(如 common.py:444、:485、:553)。
5.4 CLI 侧的可视化:另起进程
前面 §5.2 的画框是在 API 进程内、拿得到 pdf_info 时顺手画的。但 CLI 走的是 HTTP 客户端,
结果是解压出来的 zip,并没有活的 pdf_info。所以 client.py 另起一个进程池专门重画:
create_visualization_context(client.py:390)用 spawn 上下文起一个 ProcessPoolExecutor,
run_planned_task 落盘后调 queue_visualization_jobs(client.py:446)把每个文档排进去,由
run_visualization_job 在子进程里读磁盘产物、重绘 bbox。draw_span 只对 pipeline 打开
(build_visualization_jobs,client.py:411 的 draw_span = backend.startswith("pipeline"))。