Spaces:
Sleeping
Sleeping
| """ | |
| GLM-OCR Hugging Face Space app with client-side header/footer fallback for MaaS. | |
| Works for every PDF and every image: uses API bboxes when available, else minimal band. | |
| """ | |
| # Patch asyncio first (before Gradio imports it) to suppress Python 3.13 cleanup noise | |
| import asyncio | |
| try: | |
| _orig_close = asyncio.BaseEventLoop.close | |
| def _safe_close(self): | |
| try: | |
| _orig_close(self) | |
| except (ValueError, OSError): | |
| pass | |
| asyncio.BaseEventLoop.close = _safe_close | |
| except Exception: | |
| pass | |
| import logging | |
| import os | |
| import re | |
| import tempfile | |
| import yaml | |
| import gradio as gr | |
| import glmocr | |
| log = logging.getLogger("glmocr_app") | |
| GLMOCR_BASE = os.path.dirname(glmocr.__file__) | |
| CONFIG_PATH = os.path.join(GLMOCR_BASE, "config.yaml") | |
| FORMATTER_PATH = os.path.join(GLMOCR_BASE, "postprocess", "result_formatter.py") | |
| # When MaaS is enabled, the cloud API ignores local layout config and does not return | |
| # header/footer regions. We always run client-side OCR on top/bottom bands as fallback. | |
| DEFAULT_ZONE_FRAC = float(os.environ.get("GLMOCR_DEFAULT_ZONE_FRAC", "0.12")) | |
| # MaaS rejects very small images (400). Only send crops that meet minimum dimensions. | |
| MIN_CROP_HEIGHT = int(os.environ.get("GLMOCR_MIN_CROP_HEIGHT", "112")) | |
| MIN_CROP_PIXELS = int(os.environ.get("GLMOCR_MIN_CROP_PIXELS", "12544")) # 112*112 | |
| # Wider PDF bands for position-based extraction (capture account numbers etc. in top/bottom) | |
| PDF_HEADER_BAND_FRAC = float(os.environ.get("GLMOCR_PDF_HEADER_BAND", "0.15")) # top 15% | |
| PDF_FOOTER_BAND_FRAC = float(os.environ.get("GLMOCR_PDF_FOOTER_BAND", "0.85")) # bottom 15% | |
| # Single shared parser to avoid "GLM-OCR initialized" per request and asyncio cleanup issues. | |
| _parser = None | |
| def get_parser(): | |
| global _parser | |
| if _parser is None: | |
| from glmocr import GlmOcr | |
| _parser = GlmOcr( | |
| api_key=os.environ.get("GLMOCR_API_KEY", "4570c28bdea5493c9efae9dae68edc66.sGbA9DLlcX1GlvqV"), | |
| mode="maas", | |
| ) | |
| return _parser | |
| # --------------------------------------------------------------------------- | |
| # 1. Config: set MaaS and optionally include headers/footers for non-MaaS | |
| # --------------------------------------------------------------------------- | |
| try: | |
| with open(CONFIG_PATH, "r") as f: | |
| config = yaml.safe_load(f) | |
| config["pipeline"]["maas"]["enabled"] = True | |
| config["pipeline"]["maas"]["api_key"] = os.environ.get("GLMOCR_API_KEY", "4570c28bdea5493c9efae9dae68edc66.sGbA9DLlcX1GlvqV") | |
| to_include_as_text = {"header", "footer"} | |
| layout_section = config.get("pipeline", {}).get("layout", {}) | |
| if "label_task_mapping" in layout_section: | |
| mapping = layout_section["label_task_mapping"] | |
| abandon = mapping.get("abandon") | |
| if isinstance(abandon, list): | |
| mapping["abandon"] = [x for x in abandon if x not in to_include_as_text] | |
| text_labels = mapping.get("text") | |
| if isinstance(text_labels, list): | |
| for label in to_include_as_text: | |
| if label not in text_labels: | |
| text_labels.append(label) | |
| formatter_section = config.get("pipeline", {}).get("result_formatter", {}) | |
| if "abandon" in formatter_section: | |
| abandon = formatter_section["abandon"] | |
| if isinstance(abandon, list): | |
| formatter_section["abandon"] = [x for x in abandon if x not in to_include_as_text] | |
| if "label_visualization_mapping" in formatter_section: | |
| text_vis = formatter_section["label_visualization_mapping"].get("text") | |
| if isinstance(text_vis, list): | |
| for label in to_include_as_text: | |
| if label not in text_vis: | |
| text_vis.append(label) | |
| with open(CONFIG_PATH, "w") as f: | |
| yaml.dump(config, f, default_flow_style=False, sort_keys=False) | |
| except Exception: | |
| pass # e.g. read-only package on Hugging Face; MaaS ignores layout anyway | |
| # --------------------------------------------------------------------------- | |
| # 2. result_formatter: stop stripping header/footer (best-effort; may be read-only) | |
| # --------------------------------------------------------------------------- | |
| try: | |
| with open(FORMATTER_PATH, "r") as f: | |
| source = f.read() | |
| for label in ('"header"', "'header'", '"footer"', "'footer'", '"doc_header"', "'doc_header'", '"doc_footer"', "'doc_footer'"): | |
| source = re.sub(r",\s*" + re.escape(label), "", source) | |
| source = re.sub(re.escape(label) + r"\s*,", "", source) | |
| source = re.sub(re.escape(label), "", source) | |
| with open(FORMATTER_PATH, "w") as f: | |
| f.write(source) | |
| except Exception: | |
| pass | |
| def get_header_footer_zones(regions, norm_height=1000): | |
| if not regions: | |
| return None, None | |
| y_tops, y_bottoms = [], [] | |
| for r in regions: | |
| bbox = r.get("bbox_2d") if isinstance(r, dict) else getattr(r, "bbox_2d", None) | |
| if bbox and len(bbox) >= 4: | |
| y_tops.append(bbox[1]) | |
| y_bottoms.append(bbox[3]) | |
| if not y_tops: | |
| return None, None | |
| return min(y_tops) / norm_height, max(y_bottoms) / norm_height | |
| def extract_zone_text_pdf(pdf_path, page_num, y_start_frac, y_end_frac): | |
| """Extract text from a horizontal band using a clip rect.""" | |
| try: | |
| import pymupdf as fitz | |
| doc = fitz.open(pdf_path) | |
| page = doc[page_num] | |
| h, w = page.rect.height, page.rect.width | |
| rect = fitz.Rect(0, h * y_start_frac, w, h * y_end_frac) | |
| text = page.get_text(clip=rect).strip() | |
| doc.close() | |
| return text | |
| except Exception: | |
| return "" | |
| def extract_pdf_text_in_band(pdf_path, page_num, y_start_frac, y_end_frac): | |
| """Extract all text whose word bbox falls in the given vertical band (by position). | |
| Use a wider band (e.g. top 15% / bottom 15%) to capture header/footer that clip might miss.""" | |
| try: | |
| import pymupdf as fitz | |
| doc = fitz.open(pdf_path) | |
| page = doc[page_num] | |
| h = page.rect.height | |
| y_lo = h * y_start_frac | |
| y_hi = h * y_end_frac | |
| words = page.get_text("words") # list of (x0, y0, x1, y1, word, ...) | |
| doc.close() | |
| parts = [] | |
| for w in words: | |
| if len(w) >= 5: | |
| y0, y1 = float(w[1]), float(w[3]) | |
| if y0 < y_hi and y1 > y_lo: | |
| parts.append(w[4]) | |
| return " ".join(parts).strip() | |
| except Exception: | |
| return "" | |
| def ocr_zone(image_path, y_start_frac, y_end_frac): | |
| """Run OCR on a horizontal band. Pads small crops to meet API minimum size instead of skipping.""" | |
| zone_name = "header" if y_end_frac < 0.5 else "footer" | |
| try: | |
| from PIL import Image | |
| img = Image.open(image_path).convert("RGB") | |
| w, h = img.size | |
| y0 = max(0, int(h * y_start_frac)) | |
| y1 = min(h, int(h * y_end_frac)) | |
| if y1 <= y0: | |
| return "" | |
| crop = img.crop((0, y0, w, y1)) | |
| cw, ch = crop.size | |
| # Pad to minimum size so MaaS accepts the image (avoids 400 on tiny strips) | |
| if ch < MIN_CROP_HEIGHT or (cw * ch) < MIN_CROP_PIXELS: | |
| need_h = max(ch, MIN_CROP_HEIGHT) | |
| need_w = max(cw, 1) | |
| if (need_w * need_h) < MIN_CROP_PIXELS: | |
| need_w = max(need_w, (MIN_CROP_PIXELS + need_h - 1) // need_h) | |
| canvas = Image.new("RGB", (need_w, need_h), (255, 255, 255)) | |
| if zone_name == "header": | |
| canvas.paste(crop, (0, 0)) # crop at top | |
| else: | |
| canvas.paste(crop, (0, need_h - ch)) # crop at bottom | |
| crop = canvas | |
| cw, ch = crop.size | |
| fd, path = tempfile.mkstemp(suffix=".jpg") | |
| os.close(fd) | |
| try: | |
| crop.save(path, "JPEG", quality=92) | |
| parser = get_parser() | |
| out = parser.parse(path) | |
| if not isinstance(out, list): | |
| out = [out] | |
| if out and getattr(out[0], "markdown_result", None): | |
| text = (out[0].markdown_result or "").strip() | |
| return text | |
| finally: | |
| try: | |
| os.unlink(path) | |
| except Exception: | |
| pass | |
| except Exception as e: | |
| log.warning("[%s] ocr_zone failed: %s", zone_name, e, exc_info=True) | |
| return "" | |
| def get_page_md_and_regions(page_result): | |
| md = "" | |
| if hasattr(page_result, "markdown_result") and page_result.markdown_result: | |
| md = (page_result.markdown_result or "").strip() | |
| regions = [] | |
| if hasattr(page_result, "json_result"): | |
| jr = page_result.json_result | |
| if isinstance(jr, dict) and "regions" in jr: | |
| regions = jr.get("regions") or [] | |
| elif isinstance(jr, list) and len(jr) > 0: | |
| r = jr[0] if isinstance(jr[0], list) else jr | |
| if isinstance(r, list): | |
| regions = r | |
| elif isinstance(r, dict) and "regions" in r: | |
| regions = r.get("regions") or [] | |
| return md, regions | |
| def run_ocr(uploaded_file): | |
| if uploaded_file is None: | |
| return "Please upload a file." | |
| try: | |
| import pymupdf as fitz | |
| path = uploaded_file.name if hasattr(uploaded_file, "name") else str(uploaded_file) | |
| is_pdf = path.lower().endswith(".pdf") | |
| parser = get_parser() | |
| if is_pdf: | |
| doc = fitz.open(path) | |
| page_images = [] | |
| for i in range(len(doc)): | |
| pix = doc[i].get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False) | |
| img_path = os.path.join(tempfile.gettempdir(), f"maas_page_{i}.png") | |
| pix.save(img_path) | |
| page_images.append(img_path) | |
| doc.close() | |
| results = parser.parse(page_images) | |
| else: | |
| page_images = [path] | |
| results = parser.parse(path) | |
| if not isinstance(results, list): | |
| results = [results] | |
| all_pages = [] | |
| for page_num, page_result in enumerate(results): | |
| page_md, regions = get_page_md_and_regions(page_result) | |
| header_end_frac, footer_start_frac = get_header_footer_zones(regions, 1000) | |
| # MaaS does not return header/footer regions; always use at least default bands | |
| he = header_end_frac if header_end_frac is not None else DEFAULT_ZONE_FRAC | |
| fs = footer_start_frac if footer_start_frac is not None else (1.0 - DEFAULT_ZONE_FRAC) | |
| if he <= 0: | |
| he = DEFAULT_ZONE_FRAC # ensure we always OCR a top band | |
| if fs >= 1.0: | |
| fs = 1.0 - DEFAULT_ZONE_FRAC # ensure we always OCR a bottom band | |
| parts = [] | |
| # Always run header band (top 8–12% of page) | |
| if page_num < len(page_images): | |
| img_path = page_images[page_num] | |
| hdr = "" | |
| if is_pdf: | |
| hdr = extract_zone_text_pdf(path, page_num, 0, he) | |
| if not (hdr and hdr.strip()): | |
| hdr = extract_pdf_text_in_band(path, page_num, 0, PDF_HEADER_BAND_FRAC) | |
| if not (hdr and hdr.strip()): | |
| hdr = ocr_zone(img_path, 0, he) | |
| if hdr and hdr.strip(): | |
| parts.append(hdr.strip()) | |
| if page_md: | |
| parts.append(page_md) | |
| # Only run footer band if API actually detected a footer region via bbox | |
| # (footer_start_frac is None when MaaS finds no footer — avoids grabbing body content) | |
| if footer_start_frac is not None and page_num < len(page_images): | |
| img_path = page_images[page_num] | |
| ftr = "" | |
| if is_pdf: | |
| ftr = extract_zone_text_pdf(path, page_num, fs, 1.0) | |
| if not (ftr and ftr.strip()): | |
| ftr = extract_pdf_text_in_band(path, page_num, PDF_FOOTER_BAND_FRAC, 1.0) | |
| if not (ftr and ftr.strip()): | |
| ftr = ocr_zone(img_path, fs, 1.0) | |
| if ftr and ftr.strip(): | |
| parts.append(ftr.strip()) | |
| if parts: | |
| all_pages.append("\n\n".join(parts)) | |
| return "\n\n---page-separator---\n\n".join(all_pages) if all_pages else "(No content)" | |
| except Exception as e: | |
| import traceback | |
| log.exception("run_ocr failed: %s", e) | |
| return f"Error: {e}\n\n{traceback.format_exc()}" | |
| with gr.Blocks(title="GLM-OCR") as demo: | |
| gr.Markdown("# GLM-OCR\nUpload a PDF or image. Headers and footers included.") | |
| file_in = gr.File(label="Upload PDF or image", file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".bmp"]) | |
| run_btn = gr.Button("Run OCR", variant="primary") | |
| out = gr.Textbox(lines=40, label="Output") | |
| run_btn.click(fn=run_ocr, inputs=file_in, outputs=out) | |
| if __name__ == "__main__": | |
| demo.launch() | |