"""PaddleOCR-VL 开发场景代码 OCR — Hugging Face Space (Gradio) 模型:https://huggingface.co/snnh/paddleocr_vl_code_ocr (PaddleOCR-VL-1.6 微调) 提示词:OCR:(与训练一致) 部署说明:本 Space 默认运行在 CPU 硬件上(免费档),0.9B 模型单图推理约 10–60s。 GPU 环境(ZeroGPU / A10G)下 @spaces.GPU 装饰器自动接管,单图数秒。 """ import os import re import inspect import sys import threading import time import traceback from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeoutError import gradio as gr import torch # 用 AutoModelForCausalLM 而非 AutoModelForImageTextToText: # 模型 config.json 的 auto_map 只映射了 AutoModel / AutoModelForCausalLM, # 没有映射 AutoModelForImageTextToText。用 CausalLM 才能走 trust_remote_code # 加载模型仓库里的自定义 modeling_paddleocr_vl.PaddleOCRVLForConditionalGeneration # (该类继承 GenerationMixin,支持 .generate())。 from transformers import AutoModelForCausalLM, AutoProcessor _CAUSAL_MASK_COMPAT = None def _patch_transformers_causal_mask(): """Map PaddleOCR-VL's create_causal_mask kwargs across transformers builds.""" global _CAUSAL_MASK_COMPAT try: import transformers.masking_utils as masking_utils except Exception as exc: # noqa: BLE001 print(f"[init] causal mask patch skipped: {exc}") return None original = getattr(masking_utils, "create_causal_mask", None) if original is None: return None if getattr(original, "_ppocr_inputs_embeds_patch", False): _CAUSAL_MASK_COMPAT = original return original signature = inspect.signature(original) params = signature.parameters accepts_var_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()) def compat_create_causal_mask(*args, **kwargs): if "inputs_embeds" in kwargs and "inputs_embeds" not in params: inputs_embeds = kwargs.pop("inputs_embeds") if "input_embeds" in params: kwargs["input_embeds"] = inputs_embeds elif "input_tensor" in params: kwargs["input_tensor"] = inputs_embeds else: # Very old/new signatures may not need the tensor explicitly. pass if not accepts_var_kwargs: kwargs = {k: v for k, v in kwargs.items() if k in params} return original(*args, **kwargs) compat_create_causal_mask._ppocr_inputs_embeds_patch = True masking_utils.create_causal_mask = compat_create_causal_mask _CAUSAL_MASK_COMPAT = compat_create_causal_mask print("[init] patched transformers create_causal_mask compatibility") return compat_create_causal_mask def _patch_loaded_paddleocr_modules(): """Remote modeling module imports create_causal_mask by value; update it too.""" if _CAUSAL_MASK_COMPAT is None: return for module_name, module in list(sys.modules.items()): if module_name.endswith("modeling_paddleocr_vl") and hasattr(module, "create_causal_mask"): setattr(module, "create_causal_mask", _CAUSAL_MASK_COMPAT) _patch_transformers_causal_mask() def _patch_gradio_schema_bool_handling(): """Work around gradio_client 5.9.x API-info crashes on bool schemas. Gradio 5.9.1 can emit JSON schema fragments such as {"additionalProperties": true} for Image/File metadata. Its matching gradio_client json_schema_to_python_type path assumes every nested schema is a dict and crashes in /gradio_api/info with: TypeError: argument of type 'bool' is not iterable. That failed /info request makes the frontend report "No API found". """ try: import gradio_client.utils as client_utils except Exception as exc: # noqa: BLE001 print(f"[init] gradio schema patch skipped: {exc}") return original_get_type = getattr(client_utils, "get_type", None) if original_get_type is None or getattr(original_get_type, "_ppocr_bool_patch", False): return def safe_get_type(schema): if isinstance(schema, bool): return "Any" return original_get_type(schema) safe_get_type._ppocr_bool_patch = True client_utils.get_type = safe_get_type print("[init] patched gradio_client bool schema handling") _patch_gradio_schema_bool_handling() def _env_truthy(name: str) -> bool: return os.environ.get(name, "").strip().lower() in {"1", "true", "yes", "on"} # ZeroGPU Space 才需要 @spaces.GPU。CPU Basic / paid GPU Space 上强行套这个 # wrapper 可能在 Gradio 事件进入 run_ocr 前抛错,只给前端一个红色“错误”。 IS_ZERO_GPU = _env_truthy("SPACES_ZERO_GPU") or _env_truthy("SPACE_ZERO_GPU") if IS_ZERO_GPU: try: import spaces # type: ignore _GPU_DECORATOR = spaces.GPU print("[init] ZeroGPU decorator enabled") except Exception as exc: # noqa: BLE001 print(f"[init] ZeroGPU decorator unavailable: {exc}") def _GPU_DECORATOR(fn): return fn else: def _GPU_DECORATOR(fn): return fn MODEL_ID = os.environ.get("MODEL_ID", "snnh/paddleocr_vl_code_ocr") # 官方 ocr 任务的文本部分; 占位符由 processor 的 chat_template 自动插入, # 训练数据 user.content 为 "OCR:",经模板 tokenize 后与下方结构化构造等价。 OCR_PROMPT = "OCR:" DEVICE = "cuda" if torch.cuda.is_available() else "cpu" IS_CPU = DEVICE == "cpu" # 评估主口径是 4096;CPU demo 降低 token 上限,并设置墙钟超时,避免评审端长时间无结果。 MAX_NEW_TOKENS = int(os.environ.get("MAX_NEW_TOKENS", "1536" if IS_CPU else "4096")) REPETITION_PENALTY = float(os.environ.get("REPETITION_PENALTY", "1.08")) RUN_TIMEOUT_SECONDS = float(os.environ.get("RUN_TIMEOUT_SECONDS", "300" if IS_CPU else "120")) _DEFAULT_MAX_TIME = max(30.0, RUN_TIMEOUT_SECONDS - 45.0) if IS_CPU else 90.0 MAX_TIME_SECONDS = float(os.environ.get("MAX_TIME_SECONDS", str(_DEFAULT_MAX_TIME))) PROGRESS_POLL_SECONDS = float(os.environ.get("PROGRESS_POLL_SECONDS", "2")) # 官方推荐:ocr/table/chart/formula/seal 用 ~1M pixels,spotting 用更大尺寸 MAX_PIXELS = 1280 * 28 * 28 # --------------------------------------------------------------------------- # 后处理:与训练/评估 notebook (Cell 11) 完全一致的 clean_prediction。 # 只处理"失控重复"与代码围栏/前缀噪声,不改写正常 OCR 内容。 # 来源:notebooks/aistudio_paddleocr_vl16_v6_stepmatched_finetune.ipynb # --------------------------------------------------------------------------- def collapse_runaway_duplicate_lines(text: str, max_keep: int = 2) -> str: lines = text.split('\n') kept = [] last = None run = 0 for line in lines: key = line.strip() if key and key == last: run += 1 else: last = key run = 1 if run <= max_keep: kept.append(line) return '\n'.join(kept) def trim_repeated_tail(text: str, min_unit: int = 8, max_unit: int = 80, min_repeats: int = 4) -> str: text = text.rstrip() if len(text) < min_unit * min_repeats: return text upper = min(max_unit, len(text) // min_repeats) for unit in range(upper, min_unit - 1, -1): chunk = text[-unit:] if not chunk.strip(): continue repeated = chunk * min_repeats if text.endswith(repeated): while text.endswith(chunk * 2): text = text[:-unit].rstrip() return text return text def char_ngram_repeat_ratio(text: str, n: int = 8) -> float: compact = re.sub(r'\s+', '', text) if len(compact) < n * 3: return 0.0 grams = [compact[i:i + n] for i in range(len(compact) - n + 1)] return 1.0 - len(set(grams)) / max(1, len(grams)) def clean_prediction(text): text = text.strip() text = re.sub(r'^```(?:text|txt|[a-zA-Z0-9_+-]+)?\s*', '', text) text = re.sub(r'\s*```$', '', text) text = re.sub(r'^(?:Assistant|助手)\s*[::]\s*', '', text, flags=re.IGNORECASE) text = collapse_runaway_duplicate_lines(text, max_keep=2) text = trim_repeated_tail(text) return text.strip() DTYPE = torch.bfloat16 if DEVICE == "cuda" else torch.float32 _model_lock = threading.Lock() _processor = None _model = None _inference_executor = ThreadPoolExecutor(max_workers=1) _active_lock = threading.Lock() _active_future = None def get_model(): """Lazy-load the model so the Space can start before downloading weights.""" global _processor, _model if _processor is not None and _model is not None: return _processor, _model with _model_lock: if _processor is not None and _model is not None: return _processor, _model print(f"[model] loading {MODEL_ID} on {DEVICE}") processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) model = AutoModelForCausalLM.from_pretrained( MODEL_ID, trust_remote_code=True, torch_dtype=DTYPE, _attn_implementation="eager", ).to(DEVICE).eval() _patch_loaded_paddleocr_modules() _processor = processor _model = model print(f"[model] loaded {MODEL_ID} on {DEVICE}") return _processor, _model def _run_ocr_impl(image): t0 = time.time() try: processor, model = get_model() # image 由 Gradio 给出,可能是 PIL.Image 或 numpy.ndarray if not hasattr(image, "convert"): import numpy as np from PIL import Image as PILImage image = PILImage.fromarray(np.array(image)).convert("RGB") else: image = image.convert("RGB") # 官方标准构造: 占位符 + OCR: 文本,经 apply_chat_template 生成训练分布一致的输入 messages = [ { "role": "user", "content": [ {"type": "image", "image": image}, {"type": "text", "text": OCR_PROMPT}, ], } ] inputs = processor.apply_chat_template( messages, add_generation_prompt=True, tokenize=True, return_dict=True, return_tensors="pt", images_kwargs={ "size": { "shortest_edge": processor.image_processor.min_pixels, "longest_edge": MAX_PIXELS, } }, ).to(DEVICE) with torch.inference_mode(): generation_kwargs = { **inputs, "max_new_tokens": MAX_NEW_TOKENS, "do_sample": False, "repetition_penalty": REPETITION_PENALTY, } if MAX_TIME_SECONDS > 0: generation_kwargs["max_time"] = MAX_TIME_SECONDS output_ids = model.generate( **generation_kwargs, ) # 只取新生成部分,去掉 prompt prompt_len = inputs["input_ids"].shape[-1] generated = output_ids[0][prompt_len:-1] raw_text = processor.decode(generated, skip_special_tokens=True).strip() # 与评估口径一致的后处理:剥离代码围栏/前缀,压塌失控重复 text = clean_prediction(raw_text) elapsed = time.time() - t0 device_note = "CPU(较慢)" if IS_CPU else DEVICE.upper() return f"{text}\n\n---\n耗时 {elapsed:.1f}s · {device_note}" except Exception as e: # noqa: BLE001 traceback.print_exc() return f"OCR 失败:{e}" def _clear_active_future(future): global _active_future with _active_lock: if _active_future is future: _active_future = None def _submit_inference(image): global _active_future with _active_lock: if _active_future is not None and not _active_future.done(): return None _active_future = _inference_executor.submit(_run_ocr_impl, image) _active_future.add_done_callback(_clear_active_future) return _active_future @_GPU_DECORATOR def run_ocr(image, progress=gr.Progress(track_tqdm=False)): """对上传图片做开发场景 OCR。只转写可见文字。""" if image is None: return "请先上传一张图片。" future = _submit_inference(image) if future is None: return ( "上一轮 OCR 仍在运行,请稍后再试。\n\n" "---\n" "当前 Demo 已限制为单任务队列,避免 CPU 免费硬件被重复点击拖到长时间无响应。" ) started_at = time.time() progress(0.03, desc="已进入队列,准备加载模型") while True: elapsed = time.time() - started_at remaining = RUN_TIMEOUT_SECONDS - elapsed if remaining <= 0: return ( "OCR 超时,已停止等待本次结果。\n\n" "---\n" f"等待 {RUN_TIMEOUT_SECONDS:.0f}s 未完成。CPU 免费硬件可能无法稳定满足评审体验," "建议切换 ZeroGPU / A10G 或本地 GPU 部署后重试。" ) try: return future.result(timeout=min(PROGRESS_POLL_SECONDS, remaining)) except FuturesTimeoutError: ratio = min(0.95, 0.05 + 0.90 * elapsed / max(1.0, RUN_TIMEOUT_SECONDS)) desc = "首次加载模型权重" if _model is None else "正在识别,请勿重复点击" progress(ratio, desc=desc) EXAMPLES_NOTICE = ( "上传 IDE 截图、终端、Traceback、配置文件、Git diff、API 表格或困难样本。" "输出只含可见文字,不解释、不补全。" ) CPU_NOTICE = ( "⚠️ 当前为 **CPU 免费硬件**,已启用单任务队列和超时保护。首次点击需要加载模型权重," f"单次最多等待约 {RUN_TIMEOUT_SECONDS:.0f} 秒;识别期间请勿重复点击。如需更快体验,可在 ZeroGPU/A10G 或本地 GPU 部署" "(见 GitHub 仓库 `demo/openai_compatible_ocr_demo.py`)。" if IS_CPU else "" ) with gr.Blocks(title="PaddleOCR-VL 开发场景代码 OCR") as demo: gr.Markdown( "# PaddleOCR-VL 开发场景代码 OCR\n" "PaddleOCR 全球衍生模型挑战赛提交 Demo · 基于 PaddleOCR-VL-1.6 微调\n\n" f"{EXAMPLES_NOTICE}" ) if CPU_NOTICE: gr.Markdown(CPU_NOTICE) with gr.Row(): with gr.Column(): img_in = gr.Image(label="开发场景截图", type="pil", height=420) btn = gr.Button("开始 OCR", variant="primary") with gr.Column(): txt_out = gr.Textbox( label="识别结果", lines=22, show_copy_button=True, placeholder="识别结果会显示在这里...(CPU 模式已启用队列和超时保护)", ) gr.Markdown( f"模型:`{MODEL_ID}` · 提示词:`OCR:` · " f"`max_new_tokens={MAX_NEW_TOKENS}` · `repetition_penalty={REPETITION_PENALTY}` · " f"`max_time={MAX_TIME_SECONDS:.0f}s` · `run_timeout={RUN_TIMEOUT_SECONDS:.0f}s` · " f"后处理:`clean_prediction`(与评估口径一致,压塌失控重复)" ) btn.click(run_ocr, inputs=img_in, outputs=txt_out, api_name="ocr", queue=True, show_api=False) legacy_api_btn = gr.Button("legacy API", visible=False) legacy_api_btn.click( run_ocr, inputs=img_in, outputs=txt_out, api_name="run_ocr", queue=True, show_api=False, ) demo.queue(max_size=4, default_concurrency_limit=1) if __name__ == "__main__": demo.launch()