Merge pull request #5114 from myhloli/dev

Enhance OCR detection and improve text handling features
This commit is contained in:
Xiaomeng Zhao
2026-06-11 22:18:20 +08:00
committed by GitHub
4 changed files with 143 additions and 15 deletions
+15 -5
View File
@@ -72,13 +72,19 @@ 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_VLM_OCR_DET_TEXT_TYPES = {
MineruBlockType.TEXT,
MineruBlockType.TITLE,
MineruBlockType.DOC_TITLE,
MineruBlockType.PARAGRAPH_TITLE,
}
HYBRID_ANALYZE_EFFORTS = {"medium", "high"}
INLINE_FORMULA_CONTAINER_LABELS = {"table", "image", "chart", "display_formula"}
MEDIUM_EFFORT_LAYOUT_LABEL_TO_VLM_TYPE = {
"abstract": BlockType.TEXT,
"algorithm": BlockType.CODE,
"aside_text": BlockType.ASIDE_TEXT,
"content": BlockType.TEXT,
"content": BlockType.INDEX,
"doc_title": BlockType.TITLE,
"footer": BlockType.FOOTER,
"footer_image": BlockType.FOOTER,
@@ -126,9 +132,10 @@ def _apply_medium_visual_sub_type(block, label: str | None):
block["sub_type"] = "seal"
def _is_hybrid_ocr_det_candidate(block):
def _is_hybrid_ocr_det_candidate(block, candidate_types=None):
"""判断 Hybrid 文本类块是否需要 OCR det 生成行级视觉信息。"""
return (block.get("type") or block.get("label")) in HYBRID_OCR_DET_TEXT_TYPES
candidate_types = candidate_types or HYBRID_OCR_DET_TEXT_TYPES
return (block.get("type") or block.get("label")) in candidate_types
def ocr_classify(pdf_bytes, parse_method: str = 'auto',) -> bool:
# 确定OCR设置
@@ -146,6 +153,8 @@ def ocr_det(
model_list,
mfd_res,
batch_ratio: int = 1,
*,
candidate_types=None,
):
mask_formula_for_ocr_det = get_ocr_det_mask_inline_formula_enable(True)
@@ -169,7 +178,7 @@ def ocr_det(
ocr_res_list.append([])
img_height, img_width = np_image.shape[:2]
for res in page_results:
if not _is_hybrid_ocr_det_candidate(res):
if not _is_hybrid_ocr_det_candidate(res, candidate_types):
continue
x0 = max(0, int(res['bbox'][0] * img_width))
y0 = max(0, int(res['bbox'][1] * img_height))
@@ -220,7 +229,7 @@ def ocr_det(
ocr_res_list.append([])
img_height, img_width = np_image.shape[:2]
for res in page_results:
if not _is_hybrid_ocr_det_candidate(res):
if not _is_hybrid_ocr_det_candidate(res, candidate_types):
continue
x0 = max(0, int(res['bbox'][0] * img_width))
y0 = max(0, int(res['bbox'][1] * img_height))
@@ -773,6 +782,7 @@ def _apply_vlm_ocr_det_sidecars_for_window(
model_list,
formula_mask_inputs,
batch_ratio=batch_ratio,
candidate_types=HYBRID_VLM_OCR_DET_TEXT_TYPES,
)
_normalize_bbox(inline_formula_list, ocr_res_list, images_pil_list)
model_list[:] = _merge_page_sidecar_items(
+10 -9
View File
@@ -27,15 +27,11 @@ not_extract_list = [item.value for item in NotExtractType] + [
BlockType.PARAGRAPH_TITLE,
]
OCR_DET_LINES_KEY = "_ocr_det_lines"
OCR_DET_LINE_BLOCK_TYPES = set(not_extract_list) | {
BlockType.LIST,
BlockType.INDEX,
BlockType.ABSTRACT,
BlockType.ASIDE_TEXT,
BlockType.PHONETIC,
BlockType.CHART_CAPTION,
BlockType.CHART_FOOTNOTE,
BlockType.CODE_FOOTNOTE,
OCR_DET_LINE_BLOCK_TYPES = {
BlockType.TEXT,
BlockType.TITLE,
BlockType.DOC_TITLE,
BlockType.PARAGRAPH_TITLE,
}
@@ -139,6 +135,7 @@ class MagicModel:
"aside_text",
"page_footnote",
"list",
"index",
]:
span_type = ContentType.TEXT
elif block_type in ["image_caption", "table_caption", "code_caption"]:
@@ -322,6 +319,10 @@ class MagicModel:
block = fix_text_block(block)
_copy_raw_text_block_metadata(raw_block_type, block_info, block)
if block["type"] == BlockType.INDEX:
# index 仅用于 Hybrid medium content 强制走 VLM-OCR,输出前统一还原为正文。
block["type"] = BlockType.TEXT
blocks.append(block)
fallback_inline_caption_fragments(blocks, VISUAL_MAIN_TYPES)
+117
View File
@@ -35,6 +35,41 @@ ASCII_PUNCT_RUN_MIN_LENGTH = 4
SUSPICIOUS_ASCII_PUNCT_MIN_TEXT_CHARS = 100
SUSPICIOUS_ASCII_PUNCT_RATIO_THRESHOLD = 0.25
SUSPICIOUS_ASCII_PUNCT_RUN_RATIO_THRESHOLD = 0.10
SUSPICIOUS_CROSS_SCRIPT_MIN_TEXT_CHARS = 300
SUSPICIOUS_CROSS_SCRIPT_MIN_CJK_CHARS = 100
SUSPICIOUS_CROSS_SCRIPT_COUNT_THRESHOLD = 120
SUSPICIOUS_CROSS_SCRIPT_RATIO_THRESHOLD = 0.18
SUSPICIOUS_CROSS_SCRIPT_MIN_SCRIPT_COUNT = 3
SUSPICIOUS_CROSS_SCRIPT_SCRIPT_MIN_CHARS = 5
SUSPICIOUS_CROSS_SCRIPT_RANGES = (
(0x0400, 0x052F, "Cyrillic"),
(0x0600, 0x06FF, "Arabic"),
(0x0700, 0x074F, "Syriac"),
(0x0750, 0x077F, "Arabic Supplement"),
(0x0780, 0x07BF, "Thaana"),
(0x07C0, 0x07FF, "NKo"),
(0x0800, 0x083F, "Samaritan"),
(0x0840, 0x085F, "Mandaic"),
(0x0860, 0x086F, "Syriac Supplement"),
(0x0870, 0x089F, "Arabic Extended-B"),
(0x0900, 0x097F, "Devanagari"),
(0x0C80, 0x0CFF, "Kannada"),
(0x1000, 0x109F, "Myanmar"),
(0x1100, 0x11FF, "Hangul Jamo"),
(0x1200, 0x137F, "Ethiopic"),
(0x13A0, 0x13FF, "Cherokee"),
(0x1400, 0x167F, "Canadian Syllabics"),
(0x1800, 0x18AF, "Mongolian"),
(0x1A20, 0x1AAF, "Tai Tham"),
(0x2C00, 0x2C5F, "Glagolitic"),
(0xA000, 0xA48F, "Yi"),
)
CJK_TEXT_RANGES = (
(0x3400, 0x4DBF),
(0x4E00, 0x9FFF),
(0xF900, 0xFAFF),
(0x20000, 0x2EBEF),
)
_ALLOWED_CONTROL_CODES = {9, 10, 13}
_PRIVATE_USE_AREA_START = 0xE000
@@ -133,6 +168,20 @@ def classify(pdf_bytes):
):
return "ocr"
cross_script_signal = _get_cross_script_text_signal_from_samples(
text_samples
)
if cross_script_signal["triggered"]:
logger.debug(
"Classify PDF as OCR due to suspicious cross-script text: "
f"chars={cross_script_signal['total_chars']}, "
f"cjk={cross_script_signal['cjk_chars']}, "
f"suspicious={cross_script_signal['suspicious_chars']}, "
f"ratio={cross_script_signal['suspicious_ratio']:.4f}, "
f"scripts={cross_script_signal['top_scripts']}"
)
return "ocr"
u72xx_signal = _get_u72xx_text_signal_from_samples(text_samples)
if (
u72xx_signal["u72xx_count"]
@@ -473,6 +522,74 @@ def _get_cid_font_usage_signal_from_samples(text_samples, cid_font_signal):
return best_signal
def _is_cjk_text_char(char: str) -> bool:
"""判断字符是否属于中文文档中可接受的 CJK 文字范围。"""
unicode_code = ord(char)
return any(start <= unicode_code <= end for start, end in CJK_TEXT_RANGES)
def _get_cross_script_name(char: str) -> str | None:
"""识别中文文档乱码中常见的跨脚本字符块名称。"""
unicode_code = ord(char)
for start, end, script_name in SUSPICIOUS_CROSS_SCRIPT_RANGES:
if start <= unicode_code <= end:
return script_name
return None
def _get_cross_script_text_signal_from_samples(text_samples):
"""统计中文文档文本层中大比例跨脚本混入信号,用于识别合法 Unicode 错码。"""
total_chars = 0
cjk_chars = 0
suspicious_chars = 0
script_counts = {}
for text_sample in text_samples:
for char in text_sample["cleaned_text"]:
total_chars += 1
if _is_cjk_text_char(char):
cjk_chars += 1
script_name = _get_cross_script_name(char)
if script_name is None:
continue
suspicious_chars += 1
script_counts[script_name] = script_counts.get(script_name, 0) + 1
suspicious_ratio = 0.0
if total_chars > 0:
suspicious_ratio = suspicious_chars / total_chars
dense_script_count = sum(
1
for count in script_counts.values()
if count >= SUSPICIOUS_CROSS_SCRIPT_SCRIPT_MIN_CHARS
)
top_scripts = sorted(
script_counts.items(),
key=lambda item: (-item[1], item[0]),
)[:5]
triggered = (
total_chars >= SUSPICIOUS_CROSS_SCRIPT_MIN_TEXT_CHARS
and cjk_chars >= SUSPICIOUS_CROSS_SCRIPT_MIN_CJK_CHARS
and suspicious_chars >= SUSPICIOUS_CROSS_SCRIPT_COUNT_THRESHOLD
and suspicious_ratio >= SUSPICIOUS_CROSS_SCRIPT_RATIO_THRESHOLD
and dense_script_count >= SUSPICIOUS_CROSS_SCRIPT_MIN_SCRIPT_COUNT
)
return {
"triggered": triggered,
"total_chars": total_chars,
"cjk_chars": cjk_chars,
"suspicious_chars": suspicious_chars,
"suspicious_ratio": suspicious_ratio,
"script_counts": script_counts,
"top_scripts": top_scripts,
"dense_script_count": dense_script_count,
}
def _get_u72xx_text_signal_from_samples(text_samples):
"""基于已缓存的抽样页文本统计扣除常用字后的 U+7280-U+72DF 字符占比。"""
cjk_chars = 0
+1 -1
View File
@@ -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.4,<2",
"mineru-vl-utils>=1.0.5,<2",
"python-docx>=1.2.0,<2",
'pypptx-with-oxml>=1.0.3,<2',
"mammoth>=1.11.0,<2",