diff --git a/README.md b/README.md
index 0423b732..47b776cb 100644
--- a/README.md
+++ b/README.md
@@ -185,7 +185,7 @@ A WebUI developed based on Gradio, with a simple interface and only core parsing
| Parsing Backend |
pipeline |
- *-auto-engine |
+ *-engine |
*-http-client |
diff --git a/README_zh-CN.md b/README_zh-CN.md
index d6e33077..7ee80344 100644
--- a/README_zh-CN.md
+++ b/README_zh-CN.md
@@ -183,7 +183,7 @@ https://github.com/user-attachments/assets/4bea02c9-6d54-4cd6-97ed-dff14340982c
| 解析后端 |
pipeline |
- *-auto-engine |
+ *-engine |
*-http-client |
diff --git a/demo/demo.py b/demo/demo.py
index f1c48a76..e83d05a3 100644
--- a/demo/demo.py
+++ b/demo/demo.py
@@ -95,7 +95,7 @@ async def run_demo(
output_dir: str | Path,
*,
api_url: str | None = None,
- backend: str = "hybrid-auto-engine",
+ backend: str = "hybrid-engine",
parse_method: str = "auto",
language: str = "ch",
formula_enable: bool = True,
@@ -212,12 +212,12 @@ def main() -> None:
api_url = None
# Available examples:
- # "hybrid-auto-engine" -> local hybrid parsing, recommended default
+ # "hybrid-engine" -> local hybrid parsing, recommended default
# "pipeline" -> more general OCR/text pipeline
- # "vlm-auto-engine" -> local VLM parsing
+ # "vlm-engine" -> local VLM parsing
# "vlm-http-client" -> remote OpenAI-compatible VLM server
# "hybrid-http-client" -> remote OpenAI-compatible hybrid server
- backend = "hybrid-auto-engine"
+ backend = "hybrid-engine"
# Available options:
# "auto" -> let MinerU choose between text extraction and OCR
# "txt" -> force text extraction
diff --git a/docs/en/quick_start/index.md b/docs/en/quick_start/index.md
index fbf76fae..b4234a1a 100644
--- a/docs/en/quick_start/index.md
+++ b/docs/en/quick_start/index.md
@@ -33,7 +33,7 @@ A WebUI developed based on Gradio, with a simple interface and only core parsing
| Parsing Backend |
pipeline |
- *-auto-engine |
+ *-engine |
*-http-client |
diff --git a/docs/en/usage/cli_tools.md b/docs/en/usage/cli_tools.md
index 01d93c1f..25c30d03 100644
--- a/docs/en/usage/cli_tools.md
+++ b/docs/en/usage/cli_tools.md
@@ -12,8 +12,8 @@ Options:
-o, --output PATH Output directory (required)
--api-url TEXT MinerU FastAPI base URL; if omitted, `mineru` starts a temporary local `mineru-api`
-m, --method [auto|txt|ocr] Parsing method: auto (default), txt, ocr (pipeline and hybrid* backend only)
- -b, --backend [pipeline|hybrid-auto-engine|hybrid-http-client|vlm-auto-engine|vlm-http-client]
- Parsing backend (default: hybrid-auto-engine)
+ -b, --backend [pipeline|vlm-engine|hybrid-engine|hybrid-flash-engine|vlm-http-client|hybrid-http-client|hybrid-flash-http-client]
+ Parsing backend (default: hybrid-flash-engine)
-l, --lang [ch|ch_server|ch_lite|en|korean|japan|chinese_cht|ta|te|ka|th|el|latin|arabic|east_slavic|cyrillic|devanagari]
Specify document language (improves OCR accuracy, pipeline and hybrid* backend only)
-u, --url TEXT OpenAI-compatible backend URL passed through to the server when using http-client
diff --git a/docs/zh/quick_start/index.md b/docs/zh/quick_start/index.md
index eb9792a8..04fea421 100644
--- a/docs/zh/quick_start/index.md
+++ b/docs/zh/quick_start/index.md
@@ -33,7 +33,7 @@
| 解析后端 |
pipeline |
- *-auto-engine |
+ *-engine |
*-http-client |
diff --git a/docs/zh/usage/acceleration_cards/Ascend.md b/docs/zh/usage/acceleration_cards/Ascend.md
index 7aa643f7..ccfd25a0 100644
--- a/docs/zh/usage/acceleration_cards/Ascend.md
+++ b/docs/zh/usage/acceleration_cards/Ascend.md
@@ -116,7 +116,7 @@ docker run -u root --name mineru_docker --privileged=true \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
@@ -132,7 +132,7 @@ docker run -u root --name mineru_docker --privileged=true \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
@@ -148,7 +148,7 @@ docker run -u root --name mineru_docker --privileged=true \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/Biren.md b/docs/zh/usage/acceleration_cards/Biren.md
index acf9a2cc..154d2d94 100644
--- a/docs/zh/usage/acceleration_cards/Biren.md
+++ b/docs/zh/usage/acceleration_cards/Biren.md
@@ -57,7 +57,7 @@ docker run -it --name mineru_docker \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -70,7 +70,7 @@ docker run -it --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -83,7 +83,7 @@ docker run -it --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/Cambricon.md b/docs/zh/usage/acceleration_cards/Cambricon.md
index fc8a9af4..83943a03 100644
--- a/docs/zh/usage/acceleration_cards/Cambricon.md
+++ b/docs/zh/usage/acceleration_cards/Cambricon.md
@@ -95,7 +95,7 @@ docker run --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟡 |
🟡 |
@@ -111,7 +111,7 @@ docker run --name mineru_docker \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🔴 |
🟢 |
@@ -127,7 +127,7 @@ docker run --name mineru_docker \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🔴 |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/Enflame.md b/docs/zh/usage/acceleration_cards/Enflame.md
index 19f66272..0ee1cbaa 100644
--- a/docs/zh/usage/acceleration_cards/Enflame.md
+++ b/docs/zh/usage/acceleration_cards/Enflame.md
@@ -55,7 +55,7 @@ docker run -u root --name mineru_docker \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -68,7 +68,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -81,7 +81,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/Hygon.md b/docs/zh/usage/acceleration_cards/Hygon.md
index faaada2a..d6ad7947 100644
--- a/docs/zh/usage/acceleration_cards/Hygon.md
+++ b/docs/zh/usage/acceleration_cards/Hygon.md
@@ -62,7 +62,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -75,7 +75,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -88,7 +88,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/IluvatarCorex.md b/docs/zh/usage/acceleration_cards/IluvatarCorex.md
index f1ef7c71..8022461d 100644
--- a/docs/zh/usage/acceleration_cards/IluvatarCorex.md
+++ b/docs/zh/usage/acceleration_cards/IluvatarCorex.md
@@ -69,7 +69,7 @@ docker run --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -82,7 +82,7 @@ docker run --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -95,7 +95,7 @@ docker run --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/Kunlunxin.md b/docs/zh/usage/acceleration_cards/Kunlunxin.md
index bb54cbdd..48576492 100644
--- a/docs/zh/usage/acceleration_cards/Kunlunxin.md
+++ b/docs/zh/usage/acceleration_cards/Kunlunxin.md
@@ -69,7 +69,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -82,7 +82,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -95,7 +95,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/METAX.md b/docs/zh/usage/acceleration_cards/METAX.md
index 8056d10b..ceef098a 100644
--- a/docs/zh/usage/acceleration_cards/METAX.md
+++ b/docs/zh/usage/acceleration_cards/METAX.md
@@ -88,7 +88,7 @@ docker run --ipc host \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
@@ -104,7 +104,7 @@ docker run --ipc host \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
@@ -120,7 +120,7 @@ docker run --ipc host \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/MooreThreads.md b/docs/zh/usage/acceleration_cards/MooreThreads.md
index 1015a637..31dbc8e8 100644
--- a/docs/zh/usage/acceleration_cards/MooreThreads.md
+++ b/docs/zh/usage/acceleration_cards/MooreThreads.md
@@ -63,7 +63,7 @@ docker run -u root --name mineru_docker \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -76,7 +76,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🔴 |
@@ -89,7 +89,7 @@ docker run -u root --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🔴 |
diff --git a/docs/zh/usage/acceleration_cards/THead.md b/docs/zh/usage/acceleration_cards/THead.md
index 98e9dee0..677dde27 100644
--- a/docs/zh/usage/acceleration_cards/THead.md
+++ b/docs/zh/usage/acceleration_cards/THead.md
@@ -79,7 +79,7 @@ docker run --privileged=true \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
@@ -95,7 +95,7 @@ docker run --privileged=true \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
@@ -111,7 +111,7 @@ docker run --privileged=true \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/Tecorigin.md b/docs/zh/usage/acceleration_cards/Tecorigin.md
index 07180dce..3c44409e 100644
--- a/docs/zh/usage/acceleration_cards/Tecorigin.md
+++ b/docs/zh/usage/acceleration_cards/Tecorigin.md
@@ -65,7 +65,7 @@ docker run -dit --name mineru_docker \
🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -78,7 +78,7 @@ docker run -dit --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
@@ -91,7 +91,7 @@ docker run -dit --name mineru_docker \
| 🟢 |
- | <vlm/hybrid>-auto-engine |
+ <vlm/hybrid>-engine |
🟢 |
diff --git a/docs/zh/usage/acceleration_cards/VastAI.md b/docs/zh/usage/acceleration_cards/VastAI.md
index 5698763d..a1938670 100644
--- a/docs/zh/usage/acceleration_cards/VastAI.md
+++ b/docs/zh/usage/acceleration_cards/VastAI.md
@@ -72,7 +72,7 @@
## 4. MinerU功能
> [!NOTE]
-> - `VastAI`加速卡仅支持使用`vlm-auto-engine`和`vlm-http-client`形式进行`VLM`模型推理加速
+> - `VastAI`加速卡仅支持使用`vlm-engine`和`vlm-http-client`形式进行`VLM`模型推理加速
- 进入容器
```bash
@@ -83,15 +83,15 @@
- 模型准备,参考官方介绍:[model_source.md](https://github.com/opendatalab/MinerU/blob/master/docs/zh/usage/model_source.md)
- - 方式一:`vlm-auto-engine`
+ - 方式一:`vlm-engine`
```bash
export MINERU_MODEL_SOURCE=modelscope
- # step1, 以`vlm-auto-engine`方式启动MinerU解析任务
+ # step1, 以`vlm-engine`方式启动MinerU解析任务
mineru -p image.png \
-o ./output \
- -b vlm-auto-engine \
+ -b vlm-engine \
--http-timeout 1200 \
--tensor-parallel-size 2 \
--enforce_eager \
@@ -147,11 +147,11 @@
| 🔴 |
- | hybrid-auto-engine |
+ hybrid-engine |
🔴 |
- | vlm-auto-engine |
+ vlm-engine |
🟢 |
@@ -168,11 +168,11 @@
| 🔴 |
- | hybrid-auto-engine |
+ hybrid-engine |
🔴 |
- | vlm-auto-engine |
+ vlm-engine |
🟢 |
@@ -189,11 +189,11 @@
| 🔴 |
- | hybrid-auto-engine |
+ hybrid-engine |
🔴 |
- | vlm-auto-engine |
+ vlm-engine |
🟢 |
@@ -212,4 +212,4 @@
> - 🟢: 支持,运行较稳定,精度与NVIDIA GPU基本一致
> - 🟡: 支持但较不稳定,在某些场景下可能出现异常,或精度存在一定差异
> - 🔴: 不支持,无法运行,或精度存在较大差异
-> - `vlm-auto-engine`:VastAI仅支持vLLM后端
\ No newline at end of file
+> - `vlm-engine`:VastAI仅支持vLLM后端
\ No newline at end of file
diff --git a/docs/zh/usage/cli_tools.md b/docs/zh/usage/cli_tools.md
index e99d780b..52f52899 100644
--- a/docs/zh/usage/cli_tools.md
+++ b/docs/zh/usage/cli_tools.md
@@ -12,8 +12,8 @@ Options:
-o, --output PATH 输出目录(必填)
--api-url TEXT MinerU FastAPI 服务地址;不传时自动拉起本地临时 mineru-api
-m, --method [auto|txt|ocr] 解析方法:auto(默认)、txt、ocr(仅用于 pipeline 与 hybrid* 后端)
- -b, --backend [pipeline|hybrid-auto-engine|hybrid-http-client|vlm-auto-engine|vlm-http-client]
- 解析后端(默认为 hybrid-auto-engine)
+ -b, --backend [pipeline|vlm-engine|hybrid-engine|hybrid-flash-engine|vlm-http-client|hybrid-http-client|hybrid-flash-http-client]
+ 解析后端(默认为 hybrid-flash-engine)
-l, --lang [ch|ch_server|ch_lite|en|korean|japan|chinese_cht|ta|te|ka|th|el|latin|arabic|east_slavic|cyrillic|devanagari]
指定文档语言(可提升 OCR 准确率,仅用于 pipeline 与 hybrid* 后端)
-u, --url TEXT 当使用 http-client 时,传给服务端后端的 OpenAI 兼容地址
diff --git a/mineru/backend/hybrid/hybrid_analyze.py b/mineru/backend/hybrid/hybrid_analyze.py
index 095aa2d4..0a1213c0 100644
--- a/mineru/backend/hybrid/hybrid_analyze.py
+++ b/mineru/backend/hybrid/hybrid_analyze.py
@@ -9,7 +9,7 @@ import numpy as np
import pypdfium2 as pdfium
from loguru import logger
from mineru_vl_utils import MinerUClient
-from mineru_vl_utils.structs import BlockType
+from mineru_vl_utils.structs import BlockType, ContentBlock
from tqdm import tqdm
from mineru.backend.hybrid.hybrid_model_output_to_middle_json import (
@@ -25,6 +25,8 @@ from mineru.backend.pipeline.model_init import (
run_mfr_inference,
run_ocr_inference,
)
+from mineru.backend.pipeline.model_list import AtomicModel
+from mineru.backend.utils.formula_number import optimize_flash_formula_number_blocks
from mineru.backend.vlm.vlm_analyze import (
ModelSingleton,
aio_predictor_execution_guard,
@@ -59,6 +61,51 @@ LAYOUT_TITLE_SPLIT_OVERLAP_THRESHOLD = 0.8
not_extract_list = [item.value for item in NotExtractType]
HYBRID_OCR_DET_TEXT_TYPES = set(not_extract_list)
+HYBRID_ANALYZE_MODES = {"pro", "flash"}
+INLINE_FORMULA_CONTAINER_LABELS = {"table", "image", "chart", "display_formula"}
+FLASH_LAYOUT_LABEL_TO_VLM_TYPE = {
+ "abstract": BlockType.TEXT,
+ "algorithm": BlockType.CODE,
+ "aside_text": BlockType.ASIDE_TEXT,
+ "content": BlockType.TEXT,
+ "doc_title": BlockType.TITLE,
+ "footer": BlockType.FOOTER,
+ "footer_image": BlockType.FOOTER,
+ "footnote": BlockType.PAGE_FOOTNOTE,
+ "formula_number": BlockType.FORMULA_NUMBER,
+ "header": BlockType.HEADER,
+ "header_image": BlockType.HEADER,
+ "number": BlockType.PAGE_NUMBER,
+ "paragraph_title": BlockType.TITLE,
+ "reference_content": BlockType.REF_TEXT,
+ "text": BlockType.TEXT,
+ "vertical_text": BlockType.TEXT,
+ "figure_title": BlockType.IMAGE_CAPTION,
+ "vision_footnote": BlockType.IMAGE_FOOTNOTE,
+ "image": BlockType.IMAGE,
+ "chart": BlockType.CHART,
+ "seal": BlockType.IMAGE,
+ "table": BlockType.TABLE,
+ "display_formula": BlockType.EQUATION,
+}
+
+
+def _validate_hybrid_mode(mode: str) -> str:
+ """校验 Hybrid 运行模式,避免静默走错解析分支。"""
+ if mode not in HYBRID_ANALYZE_MODES:
+ raise ValueError('mode must be "pro" or "flash"')
+ return mode
+
+
+def _vlm_type_for_flash_layout_label(label: str | None) -> str | None:
+ """将 pipeline layout 标签映射为 mineru-vl-utils 支持的 VLM 抽取类型。"""
+ return FLASH_LAYOUT_LABEL_TO_VLM_TYPE.get(label)
+
+
+def _apply_flash_visual_sub_type(block, label: str | None):
+ """为视觉块补充下游需要透传的子类型。"""
+ if label == "seal":
+ block["sub_type"] = "seal"
def _is_hybrid_ocr_det_candidate(block):
@@ -281,6 +328,129 @@ def normalize_bbox_to_unit(item, page_width, page_height):
return True
+def _layout_det_bbox_to_unit(layout_det, page_width, page_height):
+ """复制并归一化 layout bbox,避免构造 VLM 输入时改动 pipeline 原始结果。"""
+ bbox = layout_det.get("bbox")
+ if bbox is None or len(bbox) != 4:
+ return None
+ bbox_item = {"bbox": list(bbox)}
+ if not normalize_bbox_to_unit(bbox_item, page_width, page_height):
+ return None
+ return bbox_item["bbox"]
+
+
+def _layout_det_bbox_to_pixel(layout_det, page_width, page_height):
+ """将layout bbox转换为页面像素坐标,兼容归一化和像素两种输入。"""
+ bbox = layout_det.get("bbox")
+ if bbox is None or len(bbox) != 4:
+ return None
+
+ x0, y0, x1, y1 = [float(v) for v in bbox]
+ if (
+ 0.0 <= x0 <= 1.0
+ and 0.0 <= y0 <= 1.0
+ and 0.0 <= x1 <= 1.0
+ and 0.0 <= y1 <= 1.0
+ ):
+ x0, x1 = x0 * page_width, x1 * page_width
+ y0, y1 = y0 * page_height, y1 * page_height
+
+ x0 = max(0, min(page_width, x0))
+ y0 = max(0, min(page_height, y0))
+ x1 = max(0, min(page_width, x1))
+ y1 = max(0, min(page_height, y1))
+ if x1 <= x0 or y1 <= y0:
+ return None
+ return [x0, y0, x1, y1]
+
+
+def _normalize_flash_vlm_angle(angle):
+ """将pipeline方向标签转换为mineru-vl-utils接受的整数角度。"""
+ try:
+ normalized_angle = int(angle)
+ except (TypeError, ValueError):
+ return 0
+ if normalized_angle in {0, 90, 180, 270}:
+ return normalized_angle
+ return 0
+
+
+def _build_flash_vlm_layout_blocks(layout_dets, page_width, page_height):
+ """用 pipeline layout 构造 VLM 外部 layout 输入,跳过 VLM 自身 layout 解析。"""
+ blocks = []
+ for layout_det in layout_dets or []:
+ label = layout_det.get("label")
+ vlm_type = _vlm_type_for_flash_layout_label(label)
+ if vlm_type is None:
+ continue
+ bbox = _layout_det_bbox_to_unit(layout_det, page_width, page_height)
+ if bbox is None:
+ continue
+ try:
+ block = ContentBlock(
+ vlm_type,
+ bbox,
+ angle=_normalize_flash_vlm_angle(layout_det.get("angle", 0)),
+ content=layout_det.get("content"),
+ )
+ except AssertionError as exc:
+ logger.warning(f"Skip invalid Hybrid flash VLM block: {layout_det}, error: {exc}")
+ continue
+ _apply_flash_visual_sub_type(block, label)
+ blocks.append(block)
+ return blocks
+
+
+def _apply_flash_table_orientation_labels(
+ images_pil_list,
+ images_layout_res,
+ hybrid_pipeline_model,
+ batch_ratio: int = 1,
+):
+ """复用pipeline表格方向分类,为Hybrid flash的table layout写入VLM旋转角度。"""
+ table_inputs = []
+ table_layout_refs = []
+ for pil_img, layout_res in zip(images_pil_list, images_layout_res):
+ page_width, page_height = pil_img.size
+ for layout_det in layout_res or []:
+ if layout_det.get("label") != "table":
+ continue
+ pixel_bbox = _layout_det_bbox_to_pixel(layout_det, page_width, page_height)
+ if pixel_bbox is None:
+ continue
+ try:
+ table_img, _ = crop_img({"bbox": pixel_bbox}, pil_img)
+ except Exception as exc:
+ logger.warning(
+ f"Skip Hybrid flash table orientation crop: {layout_det}, error: {exc}"
+ )
+ continue
+ table_inputs.append({"table_img": table_img})
+ table_layout_refs.append(layout_det)
+
+ if not table_inputs:
+ return
+
+ try:
+ table_orientation_cls_model = hybrid_pipeline_model.atom_model_manager.get_atom_model(
+ atom_model_name=AtomicModel.TableOrientationCls,
+ lang=getattr(hybrid_pipeline_model, "lang", None),
+ )
+ rotate_labels = table_orientation_cls_model.batch_predict(
+ table_inputs,
+ det_batch_size=max(1, batch_ratio * OCR_DET_BASE_BATCH_SIZE),
+ tqdm_enable=True,
+ )
+ if len(rotate_labels) != len(table_layout_refs):
+ raise ValueError("Table orientation prediction result count mismatch")
+ for layout_det, rotate_label in zip(table_layout_refs, rotate_labels):
+ layout_det["angle"] = str(rotate_label or "0")
+ except Exception as exc:
+ logger.warning(
+ f"Hybrid flash table orientation classification failed: {exc}, using original table images"
+ )
+
+
def _formula_item_to_pixel_bbox(item):
bbox = item.get('bbox')
if bbox is not None and len(bbox) == 4:
@@ -289,12 +459,79 @@ def _formula_item_to_pixel_bbox(item):
return None
+def _layout_item_to_float_bbox(item):
+ """校验并读取layout检测框,异常或无效bbox返回None。"""
+ bbox = item.get("bbox")
+ if bbox is None or len(bbox) != 4:
+ return None
+
+ try:
+ x0, y0, x1, y1 = [float(v) for v in bbox]
+ except (TypeError, ValueError):
+ return None
+
+ if x1 < x0 or y1 < y0:
+ return None
+
+ return [x0, y0, x1, y1]
+
+
+def _bbox_center_point(bbox):
+ """计算bbox中心点,用于判断行内公式是否落入视觉容器。"""
+ return (float(bbox[0] + bbox[2]) / 2.0, float(bbox[1] + bbox[3]) / 2.0)
+
+
+def _is_point_inside_bbox(point, bbox):
+ """判断点是否位于bbox内部,边界点按内部处理。"""
+ x, y = point
+ return bbox[0] <= x <= bbox[2] and bbox[1] <= y <= bbox[3]
+
+
+def _is_inline_formula_inside_container(inline_formula_bbox, container_bboxes):
+ """判断行内公式中心点是否落入任一视觉/行间公式容器。"""
+ inline_formula_center = _bbox_center_point(inline_formula_bbox)
+ return any(
+ _is_point_inside_bbox(inline_formula_center, container_bbox)
+ for container_bbox in container_bboxes
+ )
+
+
+def _filter_inline_formulas_inside_containers(images_layout_res):
+ """原地移除位于table/image/chart/display_formula内的行内公式。"""
+ for layout_res in images_layout_res:
+ container_bboxes = []
+ for res in layout_res:
+ if res.get("label") not in INLINE_FORMULA_CONTAINER_LABELS:
+ continue
+ bbox = _layout_item_to_float_bbox(res)
+ if bbox is not None:
+ container_bboxes.append(bbox)
+
+ if not container_bboxes:
+ continue
+
+ kept_layout_res = []
+ for res in layout_res:
+ if res.get("label") != "inline_formula":
+ kept_layout_res.append(res)
+ continue
+
+ inline_formula_bbox = _layout_item_to_float_bbox(res)
+ if inline_formula_bbox is None or not _is_inline_formula_inside_container(
+ inline_formula_bbox,
+ container_bboxes,
+ ):
+ kept_layout_res.append(res)
+
+ layout_res[:] = kept_layout_res
+
+
def _build_inline_formula_inputs(images_layout_res):
inline_formula_inputs = []
for layout_res in images_layout_res:
page_inline_formula_inputs = []
for res in layout_res:
- if res.get('label') not in ['inline_formula', 'display_formula']:
+ if res.get('label') != 'inline_formula':
continue
bbox = res.get('bbox')
if bbox is None or len(bbox) != 4:
@@ -326,6 +563,28 @@ def _build_formula_mask_inputs(images_layout_res):
return page_formula_masks
+def _build_inline_formula_det_inputs(images_layout_res):
+ """从 layout 检测结果提取行内公式框,供 VLM-OCR 作为 OCR det hint 使用。"""
+ inline_formula_inputs = []
+ for layout_res in images_layout_res:
+ page_inline_formula_inputs = []
+ for res in layout_res:
+ if res.get('label') != 'inline_formula':
+ continue
+ bbox = _formula_item_to_pixel_bbox(res)
+ if bbox is None:
+ continue
+ page_inline_formula_inputs.append(
+ {
+ "bbox": bbox,
+ "score": float(res.get('score', 0.0)),
+ "latex": "",
+ }
+ )
+ inline_formula_inputs.append(page_inline_formula_inputs)
+ return inline_formula_inputs
+
+
def _normalize_page_size(page_image):
"""从PIL或numpy图像中读取页面宽高,供归一化bbox还原为像素bbox。"""
if hasattr(page_image, "size"):
@@ -411,13 +670,37 @@ def _predict_layout_for_title_split(
)
+def _predict_layout_for_window(
+ images_pil_list,
+ language,
+ inline_formula_enable,
+ batch_ratio,
+ vlm_ocr_enable,
+):
+ """为单个处理窗口执行一次 pipeline layout,并返回可复用的小模型实例。"""
+ hybrid_model_singleton = HybridModelSingleton()
+ hybrid_pipeline_model = hybrid_model_singleton.get_model(
+ lang=language,
+ formula_enable=inline_formula_enable and not vlm_ocr_enable,
+ )
+ images_layout_res = _predict_layout_for_title_split(
+ hybrid_pipeline_model,
+ images_pil_list,
+ batch_ratio,
+ )
+ _filter_inline_formulas_inside_containers(images_layout_res)
+ return images_layout_res, hybrid_pipeline_model
+
+
def _process_ocr_and_formulas(
images_pil_list,
model_list,
- language,
inline_formula_enable,
_ocr_enable,
batch_ratio: int = 1,
+ *,
+ images_layout_res,
+ hybrid_pipeline_model,
):
"""处理OCR和公式识别"""
@@ -428,21 +711,6 @@ def _process_ocr_and_formulas(
# 将PIL图片转换为numpy数组
np_images = [np.asarray(pil_image).copy() for pil_image in images_pil_list]
- # 获取混合模型实例
- hybrid_model_singleton = HybridModelSingleton()
- hybrid_pipeline_model = hybrid_model_singleton.get_model(
- lang=language,
- formula_enable=inline_formula_enable,
- )
-
- # 在进行`行内`公式检测和识别前,先将图像中的图片、表格、`行间`公式区域mask掉
- layout_images = mask_image_regions(np_images, model_list) if inline_formula_enable else np_images
- images_layout_res = _predict_layout_for_title_split(
- hybrid_pipeline_model,
- layout_images,
- batch_ratio,
- )
-
if inline_formula_enable:
images_mfd_res = _build_inline_formula_inputs(images_layout_res)
# 公式识别
@@ -538,61 +806,43 @@ def _process_ocr_and_formulas(
if need_ocr_res in page_ocr_res_list:
page_ocr_res_list.remove(need_ocr_res)
- _apply_layout_title_split(
- model_list,
- images_layout_res,
- [_normalize_page_size(image) for image in images_pil_list],
- )
-
_normalize_bbox(inline_formula_list, ocr_res_list, images_pil_list)
merged_model_list = _merge_page_sidecar_items(
model_list,
inline_formula_list,
ocr_res_list,
)
- return merged_model_list, hybrid_pipeline_model
+ return merged_model_list
-def _apply_layout_title_split_for_window(
+def _apply_vlm_ocr_det_sidecars_for_window(
images_pil_list,
model_list,
- language,
batch_ratio,
+ *,
+ images_layout_res,
+ hybrid_pipeline_model,
):
- """为VLM-OCR路径补跑layout小模型,先基于VLM原始title做OCR det,再拆分标题。"""
- hybrid_model_singleton = HybridModelSingleton()
- hybrid_pipeline_model = hybrid_model_singleton.get_model(
- lang=language,
- formula_enable=False,
- )
- images_layout_res = _predict_layout_for_title_split(
- hybrid_pipeline_model,
- images_pil_list,
- batch_ratio,
- )
+ """为VLM-OCR路径追加OCR det空文本行和行内公式框sidecar。"""
+ formula_mask_inputs = _build_formula_mask_inputs(images_layout_res)
+ inline_formula_list = _build_inline_formula_det_inputs(images_layout_res)
np_images = [np.asarray(pil_image).copy() for pil_image in images_pil_list]
ocr_res_list = ocr_det(
hybrid_pipeline_model,
np_images,
model_list,
- _build_formula_mask_inputs(images_layout_res),
+ formula_mask_inputs,
False,
batch_ratio=batch_ratio,
fill_text=False,
)
- _normalize_bbox([[] for _ in images_pil_list], ocr_res_list, images_pil_list)
+ _normalize_bbox(inline_formula_list, ocr_res_list, images_pil_list)
model_list[:] = _merge_page_sidecar_items(
model_list,
- [[] for _ in images_pil_list],
+ inline_formula_list,
ocr_res_list,
keep_ocr_text=False,
)
- _apply_layout_title_split(
- model_list,
- images_layout_res,
- [_normalize_page_size(image) for image in images_pil_list],
- )
- return hybrid_pipeline_model
def _normalize_bbox(
@@ -740,8 +990,10 @@ def doc_analyze(
model_path: str | None = None,
server_url: str | None = None,
image_analysis: bool = True,
+ mode: str = "pro",
**kwargs,
):
+ mode = _validate_hybrid_mode(mode)
client_side_output_generation = bool(
kwargs.pop("client_side_output_generation", False)
)
@@ -754,7 +1006,11 @@ def doc_analyze(
_vlm_ocr_enable = _should_enable_vlm_ocr(_ocr_enable, language, inline_formula_enable)
pdf_doc = open_pdfium_document(pdfium.PdfDocument, pdf_bytes)
- middle_json = init_middle_json(_ocr_enable, _vlm_ocr_enable)
+ middle_json = init_middle_json(
+ _ocr_enable,
+ _vlm_ocr_enable,
+ hybrid_mode=mode,
+ )
model_list = []
doc_closed = False
hybrid_pipeline_model = None
@@ -789,22 +1045,72 @@ def doc_analyze(
)
try:
images_pil_list = [image_dict["img_pil"] for image_dict in images_list]
+ page_sizes = [_normalize_page_size(image) for image in images_pil_list]
logger.info(
f'Hybrid processing window {window_index + 1}/{total_windows}: '
f'pages {window_start + 1}-{window_end + 1}/{page_count} '
f'({len(images_pil_list)} pages)'
)
- if _vlm_ocr_enable:
+ images_layout_res, hybrid_pipeline_model = _predict_layout_for_window(
+ images_pil_list,
+ language,
+ inline_formula_enable,
+ batch_ratio,
+ _vlm_ocr_enable,
+ )
+ if mode == "flash":
+ _apply_flash_table_orientation_labels(
+ images_pil_list,
+ images_layout_res,
+ hybrid_pipeline_model,
+ batch_ratio=batch_ratio,
+ )
+ vlm_blocks_list = [
+ _build_flash_vlm_layout_blocks(
+ page_layout_res,
+ pil_img.width,
+ pil_img.height,
+ )
+ for page_layout_res, pil_img in zip(images_layout_res, images_pil_list)
+ ]
+ with predictor_execution_guard(predictor):
+ window_model_list = predictor.batch_extract_with_layout(
+ images_pil_list,
+ vlm_blocks_list,
+ not_extract_list=None if _vlm_ocr_enable else not_extract_list,
+ image_analysis=image_analysis,
+ )
+ optimize_flash_formula_number_blocks(window_model_list)
+ if _vlm_ocr_enable:
+ _apply_vlm_ocr_det_sidecars_for_window(
+ images_pil_list,
+ window_model_list,
+ batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
+ )
+ else:
+ window_model_list = _process_ocr_and_formulas(
+ images_pil_list,
+ window_model_list,
+ inline_formula_enable,
+ _ocr_enable,
+ batch_ratio=batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
+ )
+ elif _vlm_ocr_enable:
with predictor_execution_guard(predictor):
window_model_list = predictor.batch_two_step_extract(
images=images_pil_list,
image_analysis=image_analysis,
)
- hybrid_pipeline_model = _apply_layout_title_split_for_window(
+ _apply_vlm_ocr_det_sidecars_for_window(
images_pil_list,
window_model_list,
- language,
batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
)
else:
with predictor_execution_guard(predictor):
@@ -813,15 +1119,21 @@ def doc_analyze(
not_extract_list=not_extract_list,
image_analysis=image_analysis,
)
- window_model_list, hybrid_pipeline_model = _process_ocr_and_formulas(
+ window_model_list = _process_ocr_and_formulas(
images_pil_list,
window_model_list,
- language,
inline_formula_enable,
_ocr_enable,
batch_ratio=batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
)
+ _apply_layout_title_split(
+ window_model_list,
+ images_layout_res,
+ page_sizes,
+ )
model_list.extend(window_model_list)
if progress_bar is None:
progress_bar = tqdm(total=page_count, desc="Processing pages")
@@ -869,6 +1181,7 @@ def doc_analyze(
hybrid_pipeline_model,
_ocr_enable,
_vlm_ocr_enable,
+ hybrid_mode=mode,
)
close_pdfium_document(pdf_doc)
doc_closed = True
@@ -890,8 +1203,10 @@ async def aio_doc_analyze(
model_path: str | None = None,
server_url: str | None = None,
image_analysis: bool = True,
+ mode: str = "pro",
**kwargs,
):
+ mode = _validate_hybrid_mode(mode)
client_side_output_generation = bool(
kwargs.pop("client_side_output_generation", False)
)
@@ -904,7 +1219,11 @@ async def aio_doc_analyze(
_vlm_ocr_enable = _should_enable_vlm_ocr(_ocr_enable, language, inline_formula_enable)
pdf_doc = open_pdfium_document(pdfium.PdfDocument, pdf_bytes)
- middle_json = init_middle_json(_ocr_enable, _vlm_ocr_enable)
+ middle_json = init_middle_json(
+ _ocr_enable,
+ _vlm_ocr_enable,
+ hybrid_mode=mode,
+ )
model_list = []
doc_closed = False
hybrid_pipeline_model = None
@@ -938,23 +1257,77 @@ async def aio_doc_analyze(
)
try:
images_pil_list = [image_dict["img_pil"] for image_dict in images_list]
+ page_sizes = [_normalize_page_size(image) for image in images_pil_list]
logger.info(
f'Hybrid processing window {window_index + 1}/{total_windows}: '
f'pages {window_start + 1}-{window_end + 1}/{page_count} '
f'({len(images_pil_list)} pages)'
)
- if _vlm_ocr_enable:
+ images_layout_res, hybrid_pipeline_model = await asyncio.to_thread(
+ _predict_layout_for_window,
+ images_pil_list,
+ language,
+ inline_formula_enable,
+ batch_ratio,
+ _vlm_ocr_enable,
+ )
+ if mode == "flash":
+ await asyncio.to_thread(
+ _apply_flash_table_orientation_labels,
+ images_pil_list,
+ images_layout_res,
+ hybrid_pipeline_model,
+ batch_ratio,
+ )
+ vlm_blocks_list = [
+ _build_flash_vlm_layout_blocks(
+ page_layout_res,
+ pil_img.width,
+ pil_img.height,
+ )
+ for page_layout_res, pil_img in zip(images_layout_res, images_pil_list)
+ ]
+ async with aio_predictor_execution_guard(predictor):
+ window_model_list = await predictor.aio_batch_extract_with_layout(
+ images_pil_list,
+ vlm_blocks_list,
+ not_extract_list=None if _vlm_ocr_enable else not_extract_list,
+ image_analysis=image_analysis,
+ )
+ optimize_flash_formula_number_blocks(window_model_list)
+ if _vlm_ocr_enable:
+ await asyncio.to_thread(
+ _apply_vlm_ocr_det_sidecars_for_window,
+ images_pil_list,
+ window_model_list,
+ batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
+ )
+ else:
+ window_model_list = await asyncio.to_thread(
+ _process_ocr_and_formulas,
+ images_pil_list,
+ window_model_list,
+ inline_formula_enable,
+ _ocr_enable,
+ batch_ratio=batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
+ )
+ elif _vlm_ocr_enable:
async with aio_predictor_execution_guard(predictor):
window_model_list = await predictor.aio_batch_two_step_extract(
images=images_pil_list,
image_analysis=image_analysis,
)
- hybrid_pipeline_model = await asyncio.to_thread(
- _apply_layout_title_split_for_window,
+ await asyncio.to_thread(
+ _apply_vlm_ocr_det_sidecars_for_window,
images_pil_list,
window_model_list,
- language,
batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
)
else:
async with aio_predictor_execution_guard(predictor):
@@ -963,16 +1336,23 @@ async def aio_doc_analyze(
not_extract_list=not_extract_list,
image_analysis=image_analysis,
)
- window_model_list, hybrid_pipeline_model = await asyncio.to_thread(
+ window_model_list = await asyncio.to_thread(
_process_ocr_and_formulas,
images_pil_list,
window_model_list,
- language,
inline_formula_enable,
_ocr_enable,
batch_ratio=batch_ratio,
+ images_layout_res=images_layout_res,
+ hybrid_pipeline_model=hybrid_pipeline_model,
)
+ await asyncio.to_thread(
+ _apply_layout_title_split,
+ window_model_list,
+ images_layout_res,
+ page_sizes,
+ )
model_list.extend(window_model_list)
if progress_bar is None:
progress_bar = tqdm(total=page_count, desc="Processing pages")
@@ -1022,6 +1402,7 @@ async def aio_doc_analyze(
hybrid_pipeline_model,
_ocr_enable,
_vlm_ocr_enable,
+ hybrid_mode=mode,
)
close_pdfium_document(pdf_doc)
doc_closed = True
diff --git a/mineru/backend/hybrid/hybrid_magic_model.py b/mineru/backend/hybrid/hybrid_magic_model.py
index 21555ec2..a2a19629 100644
--- a/mineru/backend/hybrid/hybrid_magic_model.py
+++ b/mineru/backend/hybrid/hybrid_magic_model.py
@@ -74,7 +74,7 @@ class MagicModel:
for inline_formula in self.page_inline_formula:
inline_formula["bbox"] = list(self.cal_real_bbox(inline_formula["bbox"]))
inline_formula_latex = inline_formula.pop("latex", "")
- if inline_formula_latex:
+ if inline_formula_latex or _vlm_ocr_enable:
page_text_inline_formula_spans.append(
{
"bbox": inline_formula["bbox"],
diff --git a/mineru/backend/hybrid/hybrid_model_output_to_middle_json.py b/mineru/backend/hybrid/hybrid_model_output_to_middle_json.py
index 06821c64..f6da7d4b 100644
--- a/mineru/backend/hybrid/hybrid_model_output_to_middle_json.py
+++ b/mineru/backend/hybrid/hybrid_model_output_to_middle_json.py
@@ -180,10 +180,11 @@ def _normalize_split_title_blocks(pdf_info_list):
block["level"] = title_level
-def init_middle_json(_ocr_enable, _vlm_ocr_enable):
+def init_middle_json(_ocr_enable, _vlm_ocr_enable, hybrid_mode="pro"):
return {
"pdf_info": [],
"_backend": "hybrid",
+ "_hybrid_mode": hybrid_mode,
"_ocr_enable": _ocr_enable,
"_vlm_ocr_enable": _vlm_ocr_enable,
"_version_name": __version__
@@ -260,12 +261,13 @@ def apply_server_side_postprocess(
_apply_post_ocr(pdf_info_list, hybrid_pipeline_model)
-def finalize_middle_json_from_preproc(pdf_info_list):
+def finalize_middle_json_from_preproc(pdf_info_list, hybrid_mode="pro"):
"""从 Hybrid preproc_blocks 执行完整 finalize,供服务端完整路径和客户端复用。"""
build_para_blocks_from_preproc(pdf_info_list)
merge_para_text_blocks(
pdf_info_list,
auto_merge_by_det=True,
+ auto_merge_vertical_by_det=hybrid_mode == "flash",
)
table_enable = get_table_enable(os.getenv('MINERU_VLM_TABLE_ENABLE', 'True').lower() == 'true')
@@ -282,6 +284,7 @@ def finalize_middle_json(
hybrid_pipeline_model,
_ocr_enable,
_vlm_ocr_enable,
+ hybrid_mode="pro",
):
"""保持旧入口语义:服务端先做必要 post-OCR,再执行完整 finalize。"""
apply_server_side_postprocess(
@@ -290,7 +293,7 @@ def finalize_middle_json(
_ocr_enable,
_vlm_ocr_enable,
)
- finalize_middle_json_from_preproc(pdf_info_list)
+ finalize_middle_json_from_preproc(pdf_info_list, hybrid_mode=hybrid_mode)
def result_to_middle_json(
diff --git a/mineru/backend/pipeline/batch_analyze.py b/mineru/backend/pipeline/batch_analyze.py
index 291a01da..bc60237d 100644
--- a/mineru/backend/pipeline/batch_analyze.py
+++ b/mineru/backend/pipeline/batch_analyze.py
@@ -408,8 +408,6 @@ class BatchAnalyze:
# 移除所有的"inline_formula"
layout_res[:] = [res for res in layout_res if res.get("label") != "inline_formula"]
-
-
ocr_res_list_all_page = []
table_res_list_all_page = []
for index in range(len(np_images)):
@@ -474,6 +472,7 @@ class BatchAnalyze:
rotate_labels = table_orientation_cls_model.batch_predict(
table_res_list_all_page,
det_batch_size=self.batch_ratio * OCR_DET_BASE_BATCH_SIZE,
+ tqdm_enable=True,
)
if len(rotate_labels) != len(table_res_list_all_page):
raise ValueError(
diff --git a/mineru/backend/pipeline/model_json_to_middle_json.py b/mineru/backend/pipeline/model_json_to_middle_json.py
index 5c4cc9b9..dcd66c40 100644
--- a/mineru/backend/pipeline/model_json_to_middle_json.py
+++ b/mineru/backend/pipeline/model_json_to_middle_json.py
@@ -4,13 +4,13 @@ import copy
from tqdm import tqdm
from mineru.backend.utils.html_image_utils import replace_inline_table_images
+from mineru.backend.utils.formula_number import optimize_formula_number_blocks
from mineru.backend.utils.runtime_utils import cross_page_table_merge
from mineru.backend.pipeline.model_init import (
AtomModelSingleton,
run_ocr_inference,
)
from mineru.backend.pipeline.para_split import para_split
-from mineru.utils.char_utils import full_to_half
from mineru.utils.cut_image import cut_image_and_table
from mineru.utils.enum_class import ContentType, BlockType
from mineru.utils.title_level_postprocess import apply_title_leveling_to_pdf_info
@@ -136,15 +136,6 @@ def append_batch_results_to_middle_json(
)
-def _extract_text_from_block(block):
- text_parts = []
- for line in block.get("lines", []):
- for span in line.get("spans", []):
- if span.get("type") == ContentType.TEXT:
- text_parts.append(span.get("content", ""))
- return "".join(text_parts).strip()
-
-
def _iter_block_spans(block):
for line in block.get("lines", []):
for span in line.get("spans", []):
@@ -154,61 +145,6 @@ def _iter_block_spans(block):
yield from _iter_block_spans(sub_block)
-def _normalize_formula_tag_content(tag_content):
- tag_content = full_to_half(tag_content.strip())
- if tag_content.startswith("("):
- tag_content = tag_content[1:].strip()
- if tag_content.endswith(")"):
- tag_content = tag_content[:-1].strip()
- return tag_content
-
-
-def _get_interline_equation_span(block):
- for line in block.get("lines", []):
- for span in line.get("spans", []):
- if span.get("type") == ContentType.INTERLINE_EQUATION:
- return span
- return None
-
-
-def _append_formula_number_tag(equation_block, formula_number_block):
- equation_span = _get_interline_equation_span(equation_block)
- tag_content = _normalize_formula_tag_content(_extract_text_from_block(formula_number_block))
- if equation_span is not None:
- formula = equation_span.get("content", "")
- equation_span["content"] = f"{formula}\\tag{{{tag_content}}}"
-
-
-def _optimize_formula_number_blocks(pdf_info_list):
- for page_info in pdf_info_list:
- optimized_blocks = []
- blocks = page_info.get("preproc_blocks", [])
- for index, block in enumerate(blocks):
- if block.get("type") != BlockType.FORMULA_NUMBER:
- optimized_blocks.append(block)
- continue
-
- prev_block = blocks[index - 1] if index > 0 else None
- if prev_block and prev_block.get("type") == BlockType.INTERLINE_EQUATION:
- _append_formula_number_tag(prev_block, block)
- continue
-
- next_block = blocks[index + 1] if index + 1 < len(blocks) else None
- next_next_block = blocks[index + 2] if index + 2 < len(blocks) else None
- if (
- next_block
- and next_block.get("type") == BlockType.INTERLINE_EQUATION
- and (next_next_block is None or next_next_block.get("type") != BlockType.FORMULA_NUMBER)
- ):
- _append_formula_number_tag(next_block, block)
- continue
-
- block["type"] = BlockType.TEXT
- optimized_blocks.append(block)
-
- page_info["preproc_blocks"] = optimized_blocks
-
-
def _apply_post_ocr(pdf_info_list, lang=None):
need_ocr_list = []
img_crop_list = []
@@ -279,7 +215,7 @@ def apply_server_side_postprocess(pdf_info_list, lang=None):
def finalize_middle_json_from_preproc(pdf_info_list):
"""从 preproc_blocks 执行确定性 finalize,供服务端完整路径和客户端复用。"""
- _optimize_formula_number_blocks(pdf_info_list)
+ optimize_formula_number_blocks(pdf_info_list)
para_split(pdf_info_list)
cross_page_table_merge(pdf_info_list)
apply_title_leveling_to_pdf_info(pdf_info_list)
diff --git a/mineru/backend/pipeline/pipeline_analyze.py b/mineru/backend/pipeline/pipeline_analyze.py
index 1fcb517a..b288712b 100644
--- a/mineru/backend/pipeline/pipeline_analyze.py
+++ b/mineru/backend/pipeline/pipeline_analyze.py
@@ -376,7 +376,13 @@ def batch_image_analyze(
os.environ["TORCH_CUDNN_V8_API_DISABLED"] = "1"
enable_ocr_det_batch = True
- batch_model = BatchAnalyze(model_manager, batch_ratio, formula_enable, table_enable, enable_ocr_det_batch)
+ batch_model = BatchAnalyze(
+ model_manager,
+ batch_ratio,
+ formula_enable,
+ table_enable,
+ enable_ocr_det_batch,
+ )
results = batch_model(images_with_extra_info)
clean_memory(get_device())
diff --git a/mineru/backend/pipeline/pipeline_magic_model.py b/mineru/backend/pipeline/pipeline_magic_model.py
index fa82e223..30f43b05 100644
--- a/mineru/backend/pipeline/pipeline_magic_model.py
+++ b/mineru/backend/pipeline/pipeline_magic_model.py
@@ -1,10 +1,7 @@
# Copyright (c) Opendatalab. All rights reserved.
from mineru.backend.pipeline.para_split import ListLineTag
from mineru.backend.pipeline.pipeline_middle_json_mkcontent import _merge_para_text
-from mineru.utils.boxbase import (
- calculate_overlap_area_2_minbox_area_ratio,
- calculate_overlap_area_in_bbox1_area_ratio,
-)
+from mineru.backend.utils.formula_number import formula_number_max_overlap_ratio
from mineru.utils.enum_class import ContentType, BlockType
from mineru.utils.guess_suffix_or_lang import guess_language_by_text
from mineru.utils.span_block_fix import merge_spans_to_vertical_line, vertical_line_sort_spans_from_top_to_bottom, \
@@ -286,7 +283,7 @@ class MagicModel:
if block["type"] == BlockType.FORMULA_NUMBER:
block_spans = span_matcher.collect_for_block(
block["bbox"],
- overlap_ratio_getter=self.__formula_number_overlap_ratio,
+ overlap_ratio_getter=formula_number_max_overlap_ratio,
)
else:
block_spans = span_matcher.collect_for_block(block["bbox"])
@@ -295,14 +292,6 @@ class MagicModel:
block = self.__fix_text_block(block)
self.page_text_inline_formula_spans = span_matcher.remaining_spans()
- @staticmethod
- def __formula_number_overlap_ratio(span, block_bbox):
- """公式编号框较窄时,沿用最小框重叠比例提高回填召回。"""
- return max(
- calculate_overlap_area_in_bbox1_area_ratio(span['bbox'], block_bbox),
- calculate_overlap_area_2_minbox_area_ratio(span['bbox'], block_bbox),
- )
-
def __fix_axis(self):
need_remove_list = []
layout_dets = self.__page_model_info["layout_dets"]
diff --git a/mineru/backend/utils/formula_number.py b/mineru/backend/utils/formula_number.py
new file mode 100644
index 00000000..3a29efae
--- /dev/null
+++ b/mineru/backend/utils/formula_number.py
@@ -0,0 +1,161 @@
+# Copyright (c) Opendatalab. All rights reserved.
+from collections.abc import Callable, Iterable, Sequence
+from typing import Any
+
+from mineru.utils.boxbase import (
+ calculate_overlap_area_2_minbox_area_ratio,
+ calculate_overlap_area_in_bbox1_area_ratio,
+)
+from mineru.utils.char_utils import full_to_half
+from mineru.utils.enum_class import BlockType, ContentType
+from mineru.utils.visual_magic_model_utils import isolated_formula_clean
+
+Block = dict[str, Any]
+
+
+def formula_number_max_overlap_ratio(span: Block, block_bbox: Sequence[float]) -> float:
+ """取公式编号span与block的两种重叠比例最大值,兼容block窄于span的情况。"""
+ return max(
+ calculate_overlap_area_in_bbox1_area_ratio(span["bbox"], block_bbox),
+ calculate_overlap_area_2_minbox_area_ratio(span["bbox"], block_bbox),
+ )
+
+
+def extract_formula_number_text(block: Block) -> str:
+ """从公式编号块中提取文本,优先使用VLM直接返回的content。"""
+ content = block.get("content")
+ if isinstance(content, str) and content.strip():
+ return content.strip()
+
+ text_parts = []
+ for line in block.get("lines", []):
+ for span in line.get("spans", []):
+ if span.get("type") == ContentType.TEXT:
+ text_parts.append(span.get("content", ""))
+ return "".join(text_parts).strip()
+
+
+def normalize_formula_tag_content(tag_content: str) -> str:
+ """归一化公式编号文本,去掉外层括号并转换全角字符。"""
+ tag_content = full_to_half(tag_content.strip())
+ if tag_content.startswith("("):
+ tag_content = tag_content[1:].strip()
+ if tag_content.endswith(")"):
+ tag_content = tag_content[:-1].strip()
+ return tag_content
+
+
+def _normalize_formula_content_for_tag(formula_content: str) -> str:
+ """归一化待合并编号的公式正文,去掉VLM可能携带的展示公式分隔符。"""
+ return isolated_formula_clean(formula_content or "")
+
+
+def build_tagged_formula_content(
+ formula_content: str,
+ formula_number_block: Block,
+) -> str:
+ """将公式正文和公式编号合成带LaTeX tag的公式内容。"""
+ formula_content = _normalize_formula_content_for_tag(formula_content)
+ tag_content = normalize_formula_tag_content(
+ extract_formula_number_text(formula_number_block)
+ )
+ return f"{formula_content}\\tag{{{tag_content}}}"
+
+
+def get_interline_equation_span(block: Block) -> Block | None:
+ """查找行间公式块中的公式span。"""
+ for line in block.get("lines", []):
+ for span in line.get("spans", []):
+ if span.get("type") == ContentType.INTERLINE_EQUATION:
+ return span
+ return None
+
+
+def append_formula_number_tag(
+ equation_block: Block,
+ formula_number_block: Block,
+) -> None:
+ """将公式编号写入pipeline middle-json行间公式span。"""
+ equation_span = get_interline_equation_span(equation_block)
+ if equation_span is not None:
+ equation_span["content"] = build_tagged_formula_content(
+ equation_span.get("content", ""),
+ formula_number_block,
+ )
+
+
+def _optimize_formula_number_sequence(
+ blocks: Sequence[Block],
+ is_formula_number: Callable[[Block], bool],
+ is_equation: Callable[[Block], bool],
+ append_tag: Callable[[Block, Block], None],
+ downgrade_block: Callable[[Block], None],
+) -> list[Block]:
+ """按统一相邻规则优化公式编号序列,调用方负责适配不同block结构。"""
+ optimized_blocks = []
+ for index, block in enumerate(blocks):
+ if not is_formula_number(block):
+ optimized_blocks.append(block)
+ continue
+
+ prev_block = blocks[index - 1] if index > 0 else None
+ if prev_block and is_equation(prev_block):
+ append_tag(prev_block, block)
+ continue
+
+ next_block = blocks[index + 1] if index + 1 < len(blocks) else None
+ next_next_block = blocks[index + 2] if index + 2 < len(blocks) else None
+ if (
+ next_block
+ and is_equation(next_block)
+ and (next_next_block is None or not is_formula_number(next_next_block))
+ ):
+ append_tag(next_block, block)
+ continue
+
+ downgrade_block(block)
+ optimized_blocks.append(block)
+
+ return optimized_blocks
+
+
+def _downgrade_formula_number_to_text(block: Block) -> None:
+ """将未匹配公式编号降级为普通文本块。"""
+ block["type"] = BlockType.TEXT
+
+
+def _append_flash_formula_number_tag(
+ equation_block: Block,
+ formula_number_block: Block,
+) -> None:
+ """将公式编号写入Hybrid flash的VLM行间公式内容。"""
+ equation_block["content"] = build_tagged_formula_content(
+ equation_block.get("content", ""),
+ formula_number_block,
+ )
+
+
+def optimize_formula_number_blocks(pdf_info_list: Iterable[Block]) -> None:
+ """按pipeline规则合并公式编号块,未匹配的编号降级为普通文本。"""
+ for page_info in pdf_info_list:
+ blocks = page_info.get("preproc_blocks", [])
+ page_info["preproc_blocks"] = _optimize_formula_number_sequence(
+ blocks,
+ lambda block: block.get("type") == BlockType.FORMULA_NUMBER,
+ lambda block: block.get("type") == BlockType.INTERLINE_EQUATION,
+ append_formula_number_tag,
+ _downgrade_formula_number_to_text,
+ )
+
+
+def optimize_flash_formula_number_blocks(model_list: Iterable[list[Block]]) -> None:
+ """按统一相邻规则处理Hybrid flash的VLM公式编号块。"""
+ for page_model_list in model_list:
+ optimized_blocks = _optimize_formula_number_sequence(
+ page_model_list or [],
+ lambda block: block.get("type") == BlockType.FORMULA_NUMBER,
+ lambda block: block.get("type") == BlockType.EQUATION,
+ _append_flash_formula_number_tag,
+ _downgrade_formula_number_to_text,
+ )
+ page_model_list[:] = optimized_blocks
diff --git a/mineru/backend/utils/para_block_utils.py b/mineru/backend/utils/para_block_utils.py
index d66f2dac..315ce3e6 100644
--- a/mineru/backend/utils/para_block_utils.py
+++ b/mineru/backend/utils/para_block_utils.py
@@ -2,6 +2,7 @@
import copy
from mineru.utils.enum_class import BlockType, SplitFlag
+from mineru.utils.span_block_fix import is_vertical_text_block_by_spans
LINE_STOP_FLAG = ('.', '!', '?', '。', '!', '?', ')', ')', '"', '”', ':', ':', ';', ';')
@@ -43,7 +44,11 @@ def build_para_blocks_from_preproc(pdf_info_list):
page_info["para_blocks"] = copy.deepcopy(page_info.get("preproc_blocks", []))
-def merge_para_text_blocks(pdf_info_list, auto_merge_by_det=False):
+def merge_para_text_blocks(
+ pdf_info_list,
+ auto_merge_by_det=False,
+ auto_merge_vertical_by_det=False,
+):
ordered_blocks = []
for page_info in pdf_info_list:
page_idx = page_info.get("page_idx")
@@ -62,6 +67,7 @@ def merge_para_text_blocks(pdf_info_list, auto_merge_by_det=False):
current_page_idx,
current_block,
auto_merge_by_det,
+ auto_merge_vertical_by_det,
)
elif current_type == BlockType.LIST:
if not current_block.get("blocks"):
@@ -79,6 +85,7 @@ def _merge_current_text_block(
current_page_idx,
current_block,
auto_merge_by_det,
+ auto_merge_vertical_by_det,
):
"""处理当前 text block 的 merge_prev 候选合并和 Hybrid det 自动合并。"""
previous_block = None
@@ -107,6 +114,7 @@ def _merge_current_text_block(
if not can_auto_merge_text_blocks(
current_block,
previous_text_block,
+ allow_vertical_blocks=auto_merge_vertical_by_det,
):
previous_block = None
@@ -148,6 +156,7 @@ def can_auto_merge_text_blocks(
current_block,
previous_block,
allow_single_line_blocks=False,
+ allow_vertical_blocks=False,
):
"""按段落首尾文本和行几何规则判断 text 是否可合并。"""
current_lines = current_block.get("lines", [])
@@ -162,6 +171,20 @@ def can_auto_merge_text_blocks(
):
return False
+ if (
+ allow_vertical_blocks
+ and _is_vertical_text_block_by_lines(current_metric_lines)
+ and _is_vertical_text_block_by_lines(previous_metric_lines)
+ ):
+ return _can_auto_merge_vertical_text_blocks(
+ current_block,
+ previous_block,
+ current_lines,
+ previous_lines,
+ current_metric_lines,
+ previous_metric_lines,
+ )
+
first_metric_line = current_metric_lines[0]
last_metric_line = previous_metric_lines[-1]
first_line_height = _line_height(first_metric_line)
@@ -339,6 +362,14 @@ def _line_height(line):
return bbox[3] - bbox[1]
+def _line_width(line):
+ """计算行或纵排列的宽度,供纵排几何规则使用。"""
+ bbox = line.get("bbox")
+ if not bbox:
+ return 0
+ return bbox[2] - bbox[0]
+
+
def _build_bbox_fs(block, lines):
if lines:
return [
@@ -379,6 +410,56 @@ def _has_mergeable_block_bbox_relation(current_block, previous_block):
return current_block["bbox"][1] < previous_block["bbox"][3]
+def _is_vertical_text_block_by_lines(lines):
+ """使用当前几何行中的 spans 判断文本块是否为纵排。"""
+ spans = [
+ span
+ for line in lines
+ for span in line.get("spans", [])
+ ]
+ return is_vertical_text_block_by_spans(spans)
+
+
+def _can_auto_merge_vertical_text_blocks(
+ current_block,
+ previous_block,
+ current_lines,
+ previous_lines,
+ current_metric_lines,
+ previous_metric_lines,
+):
+ """复刻 pipeline 纵排文本块合并规则,几何优先使用 OCR det 行。"""
+ first_metric_line = current_metric_lines[0]
+ last_metric_line = previous_metric_lines[-1]
+ first_line_width = _line_width(first_metric_line)
+ last_line_width = _line_width(last_metric_line)
+ if first_line_width <= 0 or last_line_width <= 0:
+ return False
+
+ current_bbox_fs = _build_bbox_fs(current_block, current_metric_lines)
+ previous_bbox_fs = _build_bbox_fs(previous_block, previous_metric_lines)
+ if abs(current_bbox_fs[1] - first_metric_line["bbox"][1]) >= first_line_width / 2:
+ return False
+ if abs(previous_bbox_fs[3] - last_metric_line["bbox"][3]) >= last_line_width:
+ return False
+
+ first_content = _first_non_empty_content(current_lines)
+ last_content = _last_non_empty_content(previous_lines)
+ if not first_content or not last_content:
+ return False
+ if last_content.endswith(LINE_STOP_FLAG):
+ return False
+ if first_content[0].isdigit() or first_content[0].isupper():
+ return False
+
+ current_metric_height = current_bbox_fs[3] - current_bbox_fs[1]
+ previous_metric_height = previous_bbox_fs[3] - previous_bbox_fs[1]
+ min_metric_height = min(current_metric_height, previous_metric_height)
+ if min_metric_height <= 0:
+ return False
+ return abs(current_metric_height - previous_metric_height) < min_metric_height
+
+
def _cleanup_block_internal_metadata(block):
"""递归清理只供 finalize 内部流程使用的临时字段。"""
for metadata_key in INTERNAL_BLOCK_METADATA_KEYS:
diff --git a/mineru/cli/api_request.py b/mineru/cli/api_request.py
index a90cb8d1..ddc82b8f 100644
--- a/mineru/cli/api_request.py
+++ b/mineru/cli/api_request.py
@@ -4,6 +4,11 @@ from typing import Annotated, Optional
from fastapi import File, Form, HTTPException, Request, UploadFile
+from mineru.cli.backend_options import (
+ BACKEND_SCHEMA_EXTRA,
+ DEFAULT_BACKEND,
+ validate_backend as validate_public_backend,
+)
from mineru.cli.public_http_client_policy import validate_public_http_client_request
ALLOWED_PARSE_METHODS = {"auto", "txt", "ocr"}
@@ -51,6 +56,14 @@ def validate_parse_method(parse_method: str) -> str:
return parse_method
+def validate_parse_backend(backend: str) -> str:
+ """校验公开 API 允许的解析后端,避免旧入口名进入下游执行链路。"""
+ try:
+ return validate_public_backend(backend)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+
+
async def parse_request_form(
request: Request,
files: Annotated[
@@ -89,12 +102,15 @@ async def parse_request_form(
Form(
description="""The backend for parsing:
- pipeline: More general, supports multiple languages, hallucination-free.
-- vlm-auto-engine: High accuracy via local computing power, supports Chinese and English documents only.
+- vlm-engine: High accuracy via local computing power, supports Chinese and English documents only.
- vlm-http-client: High accuracy via remote computing power(client suitable for openai-compatible servers), supports Chinese and English documents only.
-- hybrid-auto-engine: Next-generation high accuracy solution via local computing power, supports multiple languages.
-- hybrid-http-client: High accuracy via remote computing power but requires a little local computing power(client suitable for openai-compatible servers), supports multiple languages.""",
+- hybrid-engine: Next-generation high accuracy solution via local computing power, supports multiple languages.
+- hybrid-flash-engine: Hybrid flash mode via local computing power, supports multiple languages.
+- hybrid-http-client: High accuracy via remote computing power but requires a little local computing power(client suitable for openai-compatible servers), supports multiple languages.
+- hybrid-flash-http-client: Hybrid flash mode via remote computing power(client suitable for openai-compatible servers), supports multiple languages.""",
+ json_schema_extra=BACKEND_SCHEMA_EXTRA,
),
- ] = "hybrid-auto-engine",
+ ] = DEFAULT_BACKEND,
parse_method: Annotated[
str,
Form(
@@ -175,6 +191,7 @@ async def parse_request_form(
] = 99999,
) -> ParseRequestOptions:
"""解析 API/Router 共用的 multipart 表单,并保持 Swagger 参数同源。"""
+ backend = validate_parse_backend(backend)
validate_public_http_client_request(
public_bind_exposed=bool(
getattr(request.app.state, "public_bind_exposed", False)
diff --git a/mineru/cli/backend_options.py b/mineru/cli/backend_options.py
new file mode 100644
index 00000000..5796d562
--- /dev/null
+++ b/mineru/cli/backend_options.py
@@ -0,0 +1,52 @@
+# Copyright (c) Opendatalab. All rights reserved.
+
+BACKEND_PIPELINE = "pipeline"
+BACKEND_VLM_ENGINE = "vlm-engine"
+BACKEND_HYBRID_ENGINE = "hybrid-engine"
+BACKEND_HYBRID_FLASH_ENGINE = "hybrid-flash-engine"
+BACKEND_VLM_HTTP_CLIENT = "vlm-http-client"
+BACKEND_HYBRID_HTTP_CLIENT = "hybrid-http-client"
+BACKEND_HYBRID_FLASH_HTTP_CLIENT = "hybrid-flash-http-client"
+
+DEFAULT_BACKEND = BACKEND_HYBRID_FLASH_ENGINE
+
+LOCAL_BACKEND_CHOICES = (
+ BACKEND_PIPELINE,
+ BACKEND_VLM_ENGINE,
+ BACKEND_HYBRID_ENGINE,
+ BACKEND_HYBRID_FLASH_ENGINE,
+)
+HTTP_CLIENT_BACKEND_CHOICES = (
+ BACKEND_VLM_HTTP_CLIENT,
+ BACKEND_HYBRID_HTTP_CLIENT,
+ BACKEND_HYBRID_FLASH_HTTP_CLIENT,
+)
+PUBLIC_BACKEND_CHOICES = LOCAL_BACKEND_CHOICES + HTTP_CLIENT_BACKEND_CHOICES
+BACKEND_SCHEMA_EXTRA = {"enum": list(PUBLIC_BACKEND_CHOICES)}
+LEGACY_BACKEND_ALIASES = {
+ "vlm-auto-engine": BACKEND_VLM_ENGINE,
+ "hybrid-auto-engine": BACKEND_HYBRID_ENGINE,
+ "hybrid-flash-auto-engine": BACKEND_HYBRID_FLASH_ENGINE,
+}
+
+
+def get_backend_choices(include_http_client: bool = True) -> list[str]:
+ """按入口配置返回公开 backend 选项,避免各入口重复维护字符串列表。"""
+ choices = list(LOCAL_BACKEND_CHOICES)
+ if include_http_client:
+ choices.extend(HTTP_CLIENT_BACKEND_CHOICES)
+ return choices
+
+
+def normalize_backend(backend: str) -> str:
+ """将旧 backend 别名规范为当前公开名称,并校验最终名称是否合法。"""
+ normalized_backend = LEGACY_BACKEND_ALIASES.get(backend, backend)
+ if normalized_backend not in PUBLIC_BACKEND_CHOICES:
+ allowed_values = ", ".join(PUBLIC_BACKEND_CHOICES)
+ raise ValueError(f"Invalid backend. Allowed values: {allowed_values}")
+ return normalized_backend
+
+
+def validate_backend(backend: str) -> str:
+ """校验公开入口允许的 backend 名称,并返回规范后的后端名称。"""
+ return normalize_backend(backend)
diff --git a/mineru/cli/client.py b/mineru/cli/client.py
index c00c68d5..02dcb1bd 100644
--- a/mineru/cli/client.py
+++ b/mineru/cli/client.py
@@ -18,6 +18,11 @@ from mineru.cli.api_protocol import (
DEFAULT_MAX_CONCURRENT_REQUESTS,
DEFAULT_PROCESSING_WINDOW_SIZE,
)
+from mineru.cli.backend_options import (
+ DEFAULT_BACKEND,
+ PUBLIC_BACKEND_CHOICES,
+ normalize_backend,
+)
from mineru.utils.config_reader import (
get_max_concurrent_requests as read_max_concurrent_requests,
)
@@ -85,6 +90,18 @@ SubmitResponse = _api_client.SubmitResponse
LocalAPIServer = _api_client.LocalAPIServer
+def normalize_backend_option(
+ ctx: click.Context,
+ param: click.Parameter,
+ 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
+
+
@dataclass(frozen=True)
class TaskFailure:
task_index: int
@@ -1029,24 +1046,20 @@ async def run_orchestrated_cli(
"-b",
"--backend",
"backend",
- type=click.Choice(
- [
- "pipeline",
- "vlm-http-client",
- "hybrid-http-client",
- "vlm-auto-engine",
- "hybrid-auto-engine",
- ]
- ),
- default="hybrid-auto-engine",
+ type=str,
+ default=DEFAULT_BACKEND,
+ callback=normalize_backend_option,
+ metavar="[" + "|".join(PUBLIC_BACKEND_CHOICES) + "]",
help="""\b
the backend for parsing pdf:
pipeline: More general.
- vlm-auto-engine: High accuracy via local computing power.
+ vlm-engine: High accuracy via local computing power.
vlm-http-client: High accuracy via remote computing power(client suitable for openai-compatible servers).
- hybrid-auto-engine: Next-generation high accuracy solution via local computing power.
+ hybrid-engine: Next-generation high accuracy solution via local computing power.
+ hybrid-flash-engine: Hybrid flash mode via local computing power.
hybrid-http-client: High accuracy but requires a little local computing power(client suitable for openai-compatible servers).
- Without method specified, hybrid-auto-engine will be used by default.""",
+ hybrid-flash-http-client: Hybrid flash mode via remote computing power(client suitable for openai-compatible servers).
+ Without backend specified, hybrid-flash-engine will be used by default.""",
)
@click.option(
"-l",
diff --git a/mineru/cli/common.py b/mineru/cli/common.py
index f029dd76..e95496bf 100644
--- a/mineru/cli/common.py
+++ b/mineru/cli/common.py
@@ -10,6 +10,7 @@ from typing import Sequence
from loguru import logger
+from mineru.cli.backend_options import normalize_backend
from mineru.data.data_reader_writer import FileBasedDataWriter
from mineru.utils.draw_bbox import draw_layout_bbox, draw_span_bbox
from mineru.utils.engine_utils import get_vlm_engine
@@ -69,9 +70,11 @@ def ensure_backend_dependencies(backend: str) -> None:
def _load_hybrid_analyze_entrypoint(entrypoint_name: str, backend: str):
+ """加载统一 hybrid analyze 入口,flash/pro 由调用方通过 mode 控制。"""
ensure_backend_dependencies(backend)
+ module_name = "mineru.backend.hybrid.hybrid_analyze"
try:
- hybrid_analyze = importlib.import_module("mineru.backend.hybrid.hybrid_analyze")
+ hybrid_analyze = importlib.import_module(module_name)
except (ImportError, ModuleNotFoundError) as exc:
raise HybridDependencyError(
build_hybrid_dependency_error_message(backend)
@@ -515,6 +518,7 @@ def _process_hybrid(
f_dump_content_list,
f_make_md_mode,
server_url=None,
+ mode="pro",
**kwargs,
):
hybrid_doc_analyze = _load_hybrid_analyze_entrypoint(
@@ -538,6 +542,7 @@ def _process_hybrid(
language=lang,
inline_formula_enable=inline_formula_enable,
server_url=server_url,
+ mode=mode,
**kwargs,
)
@@ -571,6 +576,7 @@ async def _async_process_hybrid(
f_dump_content_list,
f_make_md_mode,
server_url=None,
+ mode="pro",
**kwargs,
):
aio_hybrid_doc_analyze = _load_hybrid_analyze_entrypoint(
@@ -594,6 +600,7 @@ async def _async_process_hybrid(
language=lang,
inline_formula_enable=inline_formula_enable,
server_url=server_url,
+ mode=mode,
**kwargs,
)
@@ -684,6 +691,7 @@ def do_parse(
client_side_output_generation=False,
**kwargs,
):
+ backend = normalize_backend(backend)
need_remove_index = _process_office_doc(
output_dir,
pdf_file_names=pdf_file_names,
@@ -718,10 +726,7 @@ def do_parse(
if backend.startswith("vlm-"):
backend = backend[4:]
- if backend == "vllm-async-engine":
- raise Exception("vlm-vllm-async-engine backend is not supported in sync mode, please use vlm-vllm-engine backend")
-
- if backend == "auto-engine":
+ if backend == "engine":
backend = get_vlm_engine(inference_engine='auto', is_async=False)
os.environ['MINERU_VLM_FORMULA_ENABLE'] = str(formula_enable)
@@ -737,12 +742,12 @@ def do_parse(
elif backend.startswith("hybrid-"):
ensure_backend_dependencies(backend)
backend = backend[7:]
+ mode = "flash" if backend.startswith("flash-") else "pro"
- if backend == "vllm-async-engine":
- raise Exception(
- "hybrid-vllm-async-engine backend is not supported in sync mode, please use hybrid-vllm-engine backend")
+ if mode == "flash":
+ backend = backend[6:]
- if backend == "auto-engine":
+ if backend == "engine":
backend = get_vlm_engine(inference_engine='auto', is_async=False)
os.environ['MINERU_VLM_TABLE_ENABLE'] = str(table_enable)
@@ -752,7 +757,7 @@ def do_parse(
output_dir, pdf_file_names, pdf_bytes_list, p_lang_list, parse_method, formula_enable, backend,
f_draw_layout_bbox, f_draw_span_bbox, f_dump_md, f_dump_middle_json,
f_dump_model_output, f_dump_orig_pdf, f_dump_content_list, f_make_md_mode,
- server_url, image_analysis=image_analysis,
+ server_url, mode=mode, image_analysis=image_analysis,
client_side_output_generation=client_side_output_generation, **kwargs,
)
@@ -781,6 +786,7 @@ async def aio_do_parse(
client_side_output_generation=False,
**kwargs,
):
+ backend = normalize_backend(backend)
# Office 解析是同步且可能耗时的操作,异步入口需要放到线程中避免阻塞事件循环。
need_remove_index = await asyncio.to_thread(
_process_office_doc,
@@ -818,10 +824,7 @@ async def aio_do_parse(
if backend.startswith("vlm-"):
backend = backend[4:]
- if backend == "vllm-engine":
- raise Exception("vlm-vllm-engine backend is not supported in async mode, please use vlm-vllm-async-engine backend")
-
- if backend == "auto-engine":
+ if backend == "engine":
backend = get_vlm_engine(inference_engine='auto', is_async=True)
os.environ['MINERU_VLM_FORMULA_ENABLE'] = str(formula_enable)
@@ -837,11 +840,12 @@ async def aio_do_parse(
elif backend.startswith("hybrid-"):
ensure_backend_dependencies(backend)
backend = backend[7:]
+ mode = "flash" if backend.startswith("flash-") else "pro"
- if backend == "vllm-engine":
- raise Exception("hybrid-vllm-engine backend is not supported in async mode, please use hybrid-vllm-async-engine backend")
+ if mode == "flash":
+ backend = backend[6:]
- if backend == "auto-engine":
+ if backend == "engine":
backend = get_vlm_engine(inference_engine='auto', is_async=True)
os.environ['MINERU_VLM_TABLE_ENABLE'] = str(table_enable)
@@ -851,7 +855,7 @@ async def aio_do_parse(
output_dir, pdf_file_names, pdf_bytes_list, p_lang_list, parse_method, formula_enable, backend,
f_draw_layout_bbox, f_draw_span_bbox, f_dump_md, f_dump_middle_json,
f_dump_model_output, f_dump_orig_pdf, f_dump_content_list, f_make_md_mode,
- server_url, image_analysis=image_analysis,
+ server_url, mode=mode, image_analysis=image_analysis,
client_side_output_generation=client_side_output_generation, **kwargs,
)
diff --git a/mineru/cli/gradio_app.py b/mineru/cli/gradio_app.py
index e305d69f..33229505 100644
--- a/mineru/cli/gradio_app.py
+++ b/mineru/cli/gradio_app.py
@@ -38,6 +38,11 @@ from mineru.cli.common import (
read_fn,
)
from mineru.cli import api_client as _api_client
+from mineru.cli.backend_options import (
+ DEFAULT_BACKEND,
+ HTTP_CLIENT_BACKEND_CHOICES,
+ LOCAL_BACKEND_CHOICES,
+)
from mineru.cli.client_side_output import regenerate_client_side_outputs
from mineru.cli.output_paths import resolve_parse_dir
from mineru.cli.vlm_preload import resolve_gradio_local_api_cli_args
@@ -224,15 +229,8 @@ STATUS_QUEUED_ON_SERVER = "Queued on server"
STATUS_PROCESSING_ON_SERVER = "Processing on server"
STATUS_QUEUED_LOCALLY_PREFIX = "Queued locally:"
-BACKEND_CHOICE_DEFINITIONS = [
- "pipeline",
- "vlm-auto-engine",
- "hybrid-auto-engine",
-]
-HTTP_CLIENT_BACKEND_CHOICE_DEFINITIONS = [
- "vlm-http-client",
- "hybrid-http-client",
-]
+BACKEND_CHOICE_DEFINITIONS = list(LOCAL_BACKEND_CHOICES)
+HTTP_CLIENT_BACKEND_CHOICE_DEFINITIONS = list(HTTP_CLIENT_BACKEND_CHOICES)
STATUS_STEP_DEFINITIONS = [
("status_step_prepare", STATUS_PREPARING_REQUEST),
("status_step_check", STATUS_CHECKING_SERVER),
@@ -340,6 +338,21 @@ def is_http_client_backend(backend_choice):
return isinstance(backend_choice, str) and backend_choice.endswith("-http-client")
+def select_backend_info_key(backend_choice):
+ """根据解析后端选择说明文案的 i18n key,保证 flash 与普通 hybrid 可分别描述。"""
+ if not isinstance(backend_choice, str):
+ return "backend_info_default"
+ if backend_choice.startswith("vlm"):
+ return "backend_info_vlm"
+ if backend_choice == "pipeline":
+ return "backend_info_pipeline"
+ if backend_choice.startswith("hybrid-flash"):
+ return "backend_info_hybrid_flash"
+ if backend_choice.startswith("hybrid"):
+ return "backend_info_hybrid"
+ return "backend_info_default"
+
+
def resolve_status_step_index(status_lines):
"""根据现有状态日志推断步骤面板中当前应高亮的步骤索引。"""
if not status_lines:
@@ -1596,6 +1609,7 @@ def main(ctx,
"backend_info_vlm": "High-precision parsing via VLM, supports Chinese and English documents only.",
"backend_info_pipeline": "Traditional Multi-model pipeline parsing, supports multiple languages, hallucination-free.",
"backend_info_hybrid": "High-precision hybrid parsing, supports multiple languages.",
+ "backend_info_hybrid_flash": "Fast, high-precision hybrid parsing, supports multiple languages.",
"backend_info_default": "Select the backend engine for document parsing.",
},
zh={
@@ -1664,6 +1678,7 @@ def main(ctx,
"backend_info_vlm": "多模态大模型高精度解析,仅支持中英文文档。",
"backend_info_pipeline": "传统多模型管道解析,支持多语言,无幻觉。",
"backend_info_hybrid": "高精度混合解析,支持多语言。",
+ "backend_info_hybrid_flash": "高精度快速混合解析,支持多语言。",
"backend_info_default": "选择文档解析的后端引擎。",
},
)
@@ -1690,14 +1705,7 @@ def main(ctx,
return ""
def get_backend_info(backend_choice):
- if backend_choice.startswith("vlm"):
- return i18n("backend_info_vlm")
- elif backend_choice == "pipeline":
- return i18n("backend_info_pipeline")
- elif backend_choice.startswith("hybrid"):
- return i18n("backend_info_hybrid")
- else:
- return i18n("backend_info_default")
+ return i18n(select_backend_info_key(backend_choice))
# 更新界面函数
def update_interface(backend_choice):
@@ -1776,7 +1784,7 @@ def main(ctx,
file_types=suffixes,
elem_classes=["mineru-upload-file"],
)
- preferred_option = "hybrid-auto-engine"
+ preferred_option = DEFAULT_BACKEND
backend = gr.Dropdown(
build_backend_choices(http_client_enable, i18n),
label=i18n("backend"),
diff --git a/mineru/model/ocr/pytorch_paddle.py b/mineru/model/ocr/pytorch_paddle.py
index 08db11ed..48743bfe 100644
--- a/mineru/model/ocr/pytorch_paddle.py
+++ b/mineru/model/ocr/pytorch_paddle.py
@@ -289,6 +289,7 @@ class PytorchPaddleOCR(TextSystem):
mfd_res=None,
tqdm_enable=False,
tqdm_desc="OCR-rec Predict",
+ tqdm_progress_bar=None,
):
assert isinstance(img, (np.ndarray, list, str, bytes))
if isinstance(img, list) and det == True:
@@ -338,7 +339,12 @@ class PytorchPaddleOCR(TextSystem):
if not isinstance(img, list):
img = preprocess_image(img)
img = [img]
- rec_res, elapse = self.text_recognizer(img, tqdm_enable=tqdm_enable, tqdm_desc=tqdm_desc)
+ rec_res, elapse = self.text_recognizer(
+ img,
+ tqdm_enable=tqdm_enable,
+ tqdm_desc=tqdm_desc,
+ tqdm_progress_bar=tqdm_progress_bar,
+ )
# logger.debug("rec_res num : {}, elapsed : {}".format(len(rec_res), elapse))
ocr_res.append(rec_res)
return ocr_res
diff --git a/mineru/model/table/cls/mineru_table_ori_cls.py b/mineru/model/table/cls/mineru_table_ori_cls.py
index b7002ec9..e31da4fb 100644
--- a/mineru/model/table/cls/mineru_table_ori_cls.py
+++ b/mineru/model/table/cls/mineru_table_ori_cls.py
@@ -5,6 +5,7 @@ from collections import defaultdict
from typing import List, Dict
import cv2
import numpy as np
+from tqdm import tqdm
# 旋转候选门控回到旧规则,先尽量召回疑似旋转表,再由 OCR rec 评分决定最终角度。
@@ -227,19 +228,50 @@ class MineruTableOrientationClsModel:
return self._select_rotation_label_by_scores(score_by_label)
+ @staticmethod
+ def _set_progress_description(progress_bar, desc: str):
+ """切换复用进度条的阶段描述,并兼容测试替身和 tqdm 对象。"""
+ if progress_bar is None:
+ return
+ if hasattr(progress_bar, "set_description"):
+ progress_bar.set_description(desc)
+ else:
+ progress_bar.desc = desc
+
+ @staticmethod
+ def _extend_progress_total(progress_bar, count: int):
+ """按新增工作量动态扩展总进度,保证最终 total 覆盖 det/score/rec。"""
+ if progress_bar is None or count <= 0:
+ return
+ current_total = progress_bar.total if progress_bar.total is not None else progress_bar.n
+ progress_bar.total = current_total + count
+ if hasattr(progress_bar, "refresh"):
+ progress_bar.refresh()
+
@classmethod
def _collect_portrait_image_groups(
cls,
imgs: List[Dict],
resolution_group_stride: int,
) -> Dict[tuple[int, int], list[Dict]]:
- """按归一化分辨率收集竖版表格,横版表格默认保持 0 度跳过后续 OCR。"""
+ """兼容旧私有入口,实际收集逻辑已不再按表格宽高比过滤。"""
+ return cls._collect_orientation_image_groups(
+ imgs,
+ resolution_group_stride,
+ )
+
+ @classmethod
+ def _collect_orientation_image_groups(
+ cls,
+ imgs: List[Dict],
+ resolution_group_stride: int,
+ ) -> Dict[tuple[int, int], list[Dict]]:
+ """按归一化分辨率收集所有有效表格图,旋转判断交给 OCR det/rec 评分。"""
resolution_groups = defaultdict(list)
for index, img in enumerate(imgs):
bgr_img = cls._to_bgr_table_image(img)
img_height, img_width = bgr_img.shape[:2]
- img_aspect_ratio = img_height / img_width if img_width > 0 else 1.0
- if img_aspect_ratio <= 1.2:
+ if img_height <= 0 or img_width <= 0:
continue
group_key = (
@@ -280,8 +312,9 @@ class MineruTableOrientationClsModel:
resolution_groups: Dict[tuple[int, int], list[Dict]],
det_batch_size: int,
resolution_group_stride: int,
+ progress_bar=None,
) -> list[Dict]:
- """对竖版表格批量做 OCR det,并筛选需要进入多角度评分的候选。"""
+ """对表格批量做 OCR det,并筛选需要进入多角度评分的候选。"""
rotated_imgs = []
for _group_key, group_imgs in resolution_groups.items():
batch_images = self._pad_group_images(group_imgs, resolution_group_stride)
@@ -293,11 +326,14 @@ class MineruTableOrientationClsModel:
for img_info, (dt_boxes, _elapse) in zip(group_imgs, batch_results):
if self._is_rotation_candidate_by_det_boxes(dt_boxes):
rotated_imgs.append(img_info)
+ if progress_bar is not None:
+ progress_bar.update(len(group_imgs))
return rotated_imgs
def _build_score_tasks_for_candidates(
self,
rotated_imgs: list[Dict],
+ progress_bar=None,
) -> tuple[list[tuple[Dict, list[Dict]]], list[np.ndarray]]:
"""为所有旋转候选构造三角度评分任务,并汇总成一次 OCR rec 输入。"""
img_score_tasks = []
@@ -311,9 +347,17 @@ class MineruTableOrientationClsModel:
task["crop_start"] = crop_start
task["crop_end"] = crop_end
img_score_tasks.append((img_info, tasks))
+ if progress_bar is not None:
+ progress_bar.update(1)
return img_score_tasks, all_crop_imgs
- def _recognize_orientation_crops(self, all_crop_imgs: list[np.ndarray]) -> list:
+ def _recognize_orientation_crops(
+ self,
+ all_crop_imgs: list[np.ndarray],
+ tqdm_enable: bool = False,
+ tqdm_desc: str = "Table orientation",
+ tqdm_progress_bar=None,
+ ) -> list:
"""对所有候选角度 crop 合并执行 OCR rec,返回可按 slice 回填的结果。"""
if not all_crop_imgs:
return []
@@ -322,19 +366,38 @@ class MineruTableOrientationClsModel:
all_crop_imgs,
det=False,
rec=True,
+ tqdm_enable=tqdm_enable and tqdm_progress_bar is None,
+ tqdm_desc=f"{tqdm_desc} rec",
+ tqdm_progress_bar=tqdm_progress_bar,
)
return rec_ocr_res[0] if rec_ocr_res else []
- def _score_rotation_candidates(self, rotated_imgs: list[Dict]) -> Dict[int, str]:
+ def _score_rotation_candidates(
+ self,
+ rotated_imgs: list[Dict],
+ tqdm_enable: bool = False,
+ tqdm_desc: str = "Table orientation",
+ progress_bar=None,
+ ) -> Dict[int, str]:
"""批量评分旋转候选,并返回原始表格下标到最终角度标签的映射。"""
if not rotated_imgs:
return {}
label_by_index = {}
img_score_tasks, all_crop_imgs = self._build_score_tasks_for_candidates(
- rotated_imgs
+ rotated_imgs,
+ progress_bar=progress_bar,
+ )
+ self._extend_progress_total(progress_bar, len(all_crop_imgs))
+ self._set_progress_description(progress_bar, f"{tqdm_desc} rec")
+ if progress_bar is not None and hasattr(progress_bar, "refresh"):
+ progress_bar.refresh()
+ rec_res = self._recognize_orientation_crops(
+ all_crop_imgs,
+ tqdm_enable=tqdm_enable,
+ tqdm_desc=tqdm_desc,
+ tqdm_progress_bar=progress_bar,
)
- rec_res = self._recognize_orientation_crops(all_crop_imgs)
for img_info, tasks in img_score_tasks:
score_by_label = self._score_orientation_tasks_with_rec(tasks, rec_res)
@@ -347,23 +410,50 @@ class MineruTableOrientationClsModel:
self,
imgs: List[Dict],
det_batch_size: int,
+ tqdm_enable: bool = False,
+ tqdm_desc: str = "Table orientation",
) -> List[str]:
"""
批量预测传入表格图片的旋转角度,只返回角度,不修改输入图片。
"""
RESOLUTION_GROUP_STRIDE = 128
rotate_labels = ["0"] * len(imgs)
- resolution_groups = self._collect_portrait_image_groups(
+ resolution_groups = self._collect_orientation_image_groups(
imgs,
RESOLUTION_GROUP_STRIDE,
)
- rotated_imgs = self._detect_rotation_candidates(
- resolution_groups,
- det_batch_size,
- RESOLUTION_GROUP_STRIDE,
- )
- label_by_index = self._score_rotation_candidates(rotated_imgs)
- for index, label in label_by_index.items():
- rotate_labels[index] = label
+ total_images = sum(len(group_imgs) for group_imgs in resolution_groups.values())
+ progress_bar = None
+ if tqdm_enable:
+ progress_bar = tqdm(
+ total=total_images,
+ desc=f"{tqdm_desc} det",
+ leave=True,
+ )
+ try:
+ rotated_imgs = self._detect_rotation_candidates(
+ resolution_groups,
+ det_batch_size,
+ RESOLUTION_GROUP_STRIDE,
+ progress_bar=progress_bar,
+ )
+ self._extend_progress_total(progress_bar, len(rotated_imgs))
+ self._set_progress_description(progress_bar, f"{tqdm_desc} score")
+ if progress_bar is not None and hasattr(progress_bar, "refresh"):
+ progress_bar.refresh()
+ label_by_index = self._score_rotation_candidates(
+ rotated_imgs,
+ tqdm_enable=tqdm_enable,
+ tqdm_desc=tqdm_desc,
+ progress_bar=progress_bar,
+ )
+ for index, label in label_by_index.items():
+ rotate_labels[index] = label
+ finally:
+ self._set_progress_description(progress_bar, tqdm_desc)
+ if progress_bar is not None and hasattr(progress_bar, "refresh"):
+ progress_bar.refresh()
+ if progress_bar is not None:
+ progress_bar.close()
return rotate_labels
diff --git a/mineru/model/utils/tools/infer/predict_rec.py b/mineru/model/utils/tools/infer/predict_rec.py
index 8bcd8555..8ace3943 100644
--- a/mineru/model/utils/tools/infer/predict_rec.py
+++ b/mineru/model/utils/tools/infer/predict_rec.py
@@ -288,7 +288,13 @@ class TextRecognizer(BaseOCRV20):
return img
- def __call__(self, img_list, tqdm_enable=False, tqdm_desc="OCR-rec Predict"):
+ def __call__(
+ self,
+ img_list,
+ tqdm_enable=False,
+ tqdm_desc="OCR-rec Predict",
+ tqdm_progress_bar=None,
+ ):
img_num = len(img_list)
# Calculate the aspect ratio of all text bars
width_list = []
@@ -301,8 +307,14 @@ class TextRecognizer(BaseOCRV20):
rec_res = [['', 0.0]] * img_num
batch_num = self.rec_batch_num
elapse = 0
- # for beg_img_no in range(0, img_num, batch_num):
- with tqdm(total=img_num, desc=tqdm_desc, disable=not tqdm_enable) as pbar:
+ # tqdm_progress_bar 由上层复用时,不再创建内部 OCR-rec 进度条。
+ pbar = tqdm_progress_bar
+ should_close_pbar = False
+ if pbar is None:
+ pbar = tqdm(total=img_num, desc=tqdm_desc, disable=not tqdm_enable)
+ should_close_pbar = True
+
+ try:
index = 0
for beg_img_no in range(0, img_num, batch_num):
end_img_no = min(img_num, beg_img_no + batch_num)
@@ -428,6 +440,9 @@ class TextRecognizer(BaseOCRV20):
current_batch_size = min(batch_num, img_num - index * batch_num)
index += 1
pbar.update(current_batch_size)
+ finally:
+ if should_close_pbar:
+ pbar.close()
# Fix NaN values in recognition results
for i in range(len(rec_res)):
diff --git a/mineru/utils/title_level_postprocess.py b/mineru/utils/title_level_postprocess.py
index 76a14dd7..634c8925 100644
--- a/mineru/utils/title_level_postprocess.py
+++ b/mineru/utils/title_level_postprocess.py
@@ -74,6 +74,7 @@ def finalize_client_side_middle_json(middle_json: dict[str, Any]) -> dict[str, A
finalize_middle_json_from_preproc(
pdf_info,
+ hybrid_mode=middle_json.get("_hybrid_mode", "pro"),
)
return middle_json
diff --git a/pyproject.toml b/pyproject.toml
index ed6fd3e0..79cc4dd7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -50,7 +50,7 @@ dependencies = [
"openai>=1.70.0,<3",
"beautifulsoup4>=4.13.5,<5",
"magika>=0.6.2,<1.1.0",
- "mineru-vl-utils>=1.0.2,<2",
+ "mineru-vl-utils>=1.0.4,<2",
"python-docx>=1.2.0,<2",
'pypptx-with-oxml>=1.0.3,<2',
"mammoth>=1.11.0,<2",