"""Post-processing utilities for HPD-Parsing demo: turns the raw `` [bbox] `` stream into (a) clean markdown text and (b) a list of typed bounding boxes for visualization. Formula-cleaning helpers (``simplify_left_right`` / ``clean_formula_tail`` / ``normalize_arith``) are copied verbatim from the model repo's ``eval/hpd_to_markdown.py`` so the produced markdown matches the official OmniDocBench post-processing. """ import re from PIL import ImageDraw, ImageFont # --- Formula-cleaning helpers (copied from eval/hpd_to_markdown.py) -------- _TALL = re.compile( r'\\d?frac|\\tfrac|\\cfrac|\\binom|\\sqrt' r'|\\sum|\\prod|\\coprod|\\int|\\iint|\\iiint|\\oint' r'|\\bigcup|\\bigcap|\\bigoplus|\\bigotimes|\\bigsqcup' r'|\\begin\{' r'|\\overbrace|\\underbrace|\\overset|\\underset|\\stackrel' r'|\\substack|\\atop|\\\\' ) def _scan_delims(s): out = [] for m in re.finditer(r'\\(left|right)\s*', s): dm = re.match(r'\\[a-zA-Z]+|\\.|.', s[m.end():]) if not dm: continue out.append({'kind': m.group(1), 'delim': dm.group(0), 'start': m.start(), 'end': m.end() + dm.end()}) return out def simplify_left_right(s: str) -> str: """Downgrade `\\left( ... \\right)` with no tall inner structure to plain `( )`.""" if '\\left' not in s: return s stack, pairs = [], [] for d in _scan_delims(s): if d['kind'] == 'left': stack.append(d) elif stack: pairs.append((stack.pop(), d)) edits = [] for L, R in pairs: if L['delim'] == '(' and R['delim'] == ')' and not _TALL.search(s[L['end']:R['start']]): edits.append((L['start'], L['end'], '(')) edits.append((R['start'], R['end'], ')')) for st, en, rep in sorted(edits, key=lambda x: x[0], reverse=True): s = s[:st] + rep + s[en:] return s _ELLIPSIS = r'(?:\\dots|\\cdots|\\ldots|\\dotsb|\\dotsc)' _CLOSER = r'(?:\\right\s*[.\}\]\)]|\\end\s*\{(?:array|matrix|cases|bmatrix|pmatrix|vmatrix|smallmatrix)\})' _TAIL_WRAP = re.compile(r'^(?P.*?)(?P\s*(?:\\\]|\\\)|\$\$))?\s*$', re.DOTALL) def clean_formula_tail(s: str) -> str: """Strip degenerate formula tails (repeated/dangling ellipses, stray `\\quad`).""" if not s: return s m = _TAIL_WRAP.match(s) core, wrap = m.group('core'), m.group('wrap') or '' prev = None while prev != core: prev = core core = re.sub(r'(' + _ELLIPSIS + r')(?:\s*' + _ELLIPSIS + r')+', r'\1', core) core = re.sub(r'(?P' + _CLOSER + r')\s*(?:\\q?quad\s*)*' + _ELLIPSIS + r'\s*$', lambda mm: mm.group('keep'), core) core = re.sub(r'(?:\s*\\q?quad)+\s*' + _ELLIPSIS + r'\s*$', '', core) core = re.sub(r'(?:\s*\\q?quad)+\s*$', '', core) core = core.rstrip() return core + wrap _OP_MAP = { '≈': r'\approx', '≠': r'\neq', '≤': r'\leq', '≥': r'\geq', '×': r'\times', '÷': r'\div', '±': r'\pm', '∓': r'\mp', '·': r'\cdot', '∙': r'\cdot', '⋅': r'\cdot', '∗': '*', '−': '-', '≡': r'\equiv', '∝': r'\propto', '∞': r'\infty', '√': r'\sqrt', '→': r'\to', '≪': r'\ll', '≫': r'\gg', } _ARITH_ALLOWED = re.compile(r'^[0-9A-Za-z\s=+\-*/^_().,:;<>|%!\u4e00-\u9fff' + ''.join(_OP_MAP.keys()) + r']+$') _ARITH_HASOP = re.compile(r'[=+\-*/' + ''.join(_OP_MAP.keys()) + r']') _KNOWN_FUNCS = {'sin', 'cos', 'tan', 'cot', 'sec', 'csc', 'log', 'ln', 'exp', 'lim', 'max', 'min', 'det', 'mod', 'arcsin', 'arccos', 'arctan', 'sqrt'} _CJK_RUN = re.compile(r'[\u4e00-\u9fff]+') _MATH_SPAN = re.compile(r'(\\\[.*?\\\]|\$\$.*?\$\$|\\\(.*?\\\)|\$.*?\$)', re.DOTALL) WRAP_CJK_IN_ARITH = True def _convert_unicode_ops(s: str) -> str: for k, v in _OP_MAP.items(): s = s.replace(k, (v + ' ') if v.startswith('\\') else v) if WRAP_CJK_IN_ARITH: s = _CJK_RUN.sub(lambda m: r'\text{' + m.group(0) + '}', s) return re.sub(r'[ \t]{2,}', ' ', s) def _is_pure_arith_line(line: str) -> bool: t = line.strip() if not t or '\\(' in t or '\\[' in t or '$' in t or '<' in t: return False if not WRAP_CJK_IN_ARITH and re.search(r'[\u4e00-\u9fff]', t): return False if not _ARITH_ALLOWED.match(t) or not _ARITH_HASOP.search(t): return False return all(w.lower() in _KNOWN_FUNCS for w in re.findall(r'[A-Za-z]{2,}', t)) def normalize_arith(text: str) -> str: """Normalize Unicode operators to LaTeX and wrap pure-arithmetic lines as `\\( .. \\)`.""" if not text: return text text = _MATH_SPAN.sub(lambda m: _convert_unicode_ops(m.group(0)), text) out = [] for line in text.split('\n'): if _is_pure_arith_line(line): out.append('\\( ' + _convert_unicode_ops(line.strip()) + ' \\)') else: out.append(line) return '\n'.join(out) def clean_text(text: str, simplify_left_paren=True, clean_formula_tail_flag=True, norm_formula_flag=True) -> str: """Apply the same per-block cleaning steps as the official hpd_to_markdown.py.""" text = text.strip() text = text.replace('The image is too blurry to recognize any text content.', '').strip() text = text.replace( "The image contains no text or characters. It is a graphical element (a horizontal " "line with a vertical line) and does not contain any chart, graph, or data points " "that can be extracted. Therefore, the correct OCR output is an empty string.", "" ).strip() if not text or text == '[Non-Text]': return '' if text.startswith('\\[') and not text.endswith('\n\\]'): text += '\n\\]' if text.startswith('') and not text.endswith('
'): text += '' if '\\[\n' in text and '\\\\' not in text: text = text.replace('\\[\n', '\\(').replace('\n\\]', '\\)') text = text.replace('\\) \\(', '\\)\n\n\\(') if '÷' in text and '\\(' not in text: text = '\\( ' + text + ' \\)' text = re.sub(r'\\tag\s*\{[^{}]*\}', '', text) text = text.replace('\\supset', '\\sqsupset') if simplify_left_paren: text = simplify_left_right(text) if clean_formula_tail_flag: text = clean_formula_tail(text) if norm_formula_flag: text = normalize_arith(text) return text # --- Block parsing (bbox-preserving, unlike the official script) ----------- # type + [bbox], usually followed by . Container blocks such # as `list [x1,y1,x2,y2]` may have no trailing tag at all -- after splitting on # `` the header is simply the entire segment -- so the tag is optional. _BLOCK_HEADER = re.compile( r'([a-zA-Z_]+)\s*\[\s*([-\d.,\s]+)\]\s*(?:<(?:FORK|CHILD|BLOCK)>)?' ) _NO_CONTENT_TYPES = {'chart', 'seal'} def parse_blocks(raw_text: str): """Parse the `` [bbox] `` stream. Returns a list of dicts: ``{"type": str, "bbox": [x1,y1,x2,y2] | None, "text": str}``. ``bbox`` values are in the model's native 0-1000 normalized coordinate space (confirmed via real inference: max observed values ~922/589 against a 1240x1754 source image). ``text`` is the cleaned markdown for that block; it is empty for container blocks (e.g. ``list``, ``table`` wrapper) and for ``chart``/``seal`` blocks, matching the official markdown-export behavior. """ blocks = [] segments = raw_text.split('')[1:] for seg in segments: header_m = _BLOCK_HEADER.match(seg.strip()) # segments always start right after a , so the header should be # at the very start; fall back to a bare type token if bbox is absent. type_m = re.match(r'\s*([a-zA-Z_]+)', seg) block_type = type_m.group(1) if type_m else 'unknown' bbox = None if header_m: block_type = header_m.group(1) nums = [float(x) for x in re.split(r'[,\s]+', header_m.group(2).strip()) if x] if len(nums) == 4: bbox = nums text = '' if block_type.lower() not in _NO_CONTENT_TYPES: content_m = re.search(r'(.*)', seg, re.DOTALL) if content_m: # stop at the next control tag if any leaked through raw_content = re.split(r'<(?:FORK|CHILD|BLOCK)>', content_m.group(1))[0] text = clean_text(raw_content) blocks.append({'type': block_type, 'bbox': bbox, 'text': text}) return blocks def blocks_to_markdown(blocks) -> str: """Join the non-empty block texts in reading order into one markdown string.""" return '\n\n'.join(b['text'] for b in blocks if b['text']).strip() # --- Bounding-box visualization --------------------------------------------- _TYPE_COLORS = { 'header': '#e74c3c', 'title': '#e67e22', 'text': '#2980b9', 'list': '#8e44ad', 'table': '#27ae60', 'table_caption': '#16a085', 'figure': '#2c3e50', 'figure_caption': '#34495e', 'chart': '#f39c12', 'seal': '#c0392b', 'formula': '#d35400', } _DEFAULT_COLOR = '#7f8c8d' def draw_boxes_on_image(image, blocks): """Draw typed, numbered bounding boxes on a copy of ``image``. ``blocks`` bbox values are 0-1000 normalized coordinates in ``[x1, y1, x2, y2]`` order; they are rescaled to ``image``'s actual pixel size before drawing. Blocks with ``bbox is None`` are skipped (still counted, just not drawn). Boxes are numbered in reading order (the order they appear in ``blocks``, i.e. the model's own reading-order stream) to visualize the parse order. """ if image is None: return None canvas = image.convert('RGB').copy() draw = ImageDraw.Draw(canvas) width, height = canvas.size try: font = ImageFont.load_default() except Exception: font = None order = 0 for block in blocks: bbox = block.get('bbox') if not bbox: continue order += 1 x1, y1, x2, y2 = bbox px1, py1 = x1 / 1000.0 * width, y1 / 1000.0 * height px2, py2 = x2 / 1000.0 * width, y2 / 1000.0 * height color = _TYPE_COLORS.get(block['type'].lower(), _DEFAULT_COLOR) draw.rectangle([px1, py1, px2, py2], outline=color, width=2) label = f"{order}. {block['type']}" text_y = max(0, py1 - 12) if font is not None: text_w = draw.textlength(label, font=font) else: text_w = len(label) * 6 draw.rectangle([px1, text_y, px1 + text_w + 2, text_y + 11], fill=color) if font is not None: draw.text((px1 + 1, text_y), label, fill='white', font=font) else: draw.text((px1 + 1, text_y), label, fill='white') return canvas if __name__ == "__main__": print("Module self-test requires a probe output file; see git history for the " "original probe script used during development.")