Spaces:
Running on Zero
Running on Zero
File size: 11,002 Bytes
2d114ff | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | """Post-processing utilities for HPD-Parsing demo: turns the raw
``<BLOCK> <type> [bbox] <CHILD> <content>`` 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<core>.*?)(?P<wrap>\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<keep>' + _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('<table>') and not text.endswith('</table>'):
text += '</table>'
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 <FORK|CHILD|BLOCK>. Container blocks such
# as `list [x1,y1,x2,y2]` may have no trailing tag at all -- after splitting on
# `<BLOCK>` 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 ``<BLOCK> <type> [bbox] <CHILD> <content>`` 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('<BLOCK>')[1:]
for seg in segments:
header_m = _BLOCK_HEADER.match(seg.strip())
# segments always start right after a <BLOCK>, 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'<CHILD>(.*)', 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.")
|