OvisOCR2 / app.py
xxyyy123's picture
Use dynamic ZeroGPU duration for PDF batches
5bda61a verified
Raw
History Blame Contribute Delete
23.8 kB
from __future__ import annotations
import base64
import html
import io
import mimetypes
import os
import re
import threading
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlsplit
os.environ.setdefault("PYTORCH_CUDA_ALLOC_CONF", "expandable_segments:True")
os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
try:
import spaces # Must be imported before torch on Hugging Face ZeroGPU.
except ImportError: # Local development uses a no-op decorator.
class _LocalSpaces:
@staticmethod
def GPU(*decorator_args: Any, **decorator_kwargs: Any) -> Callable:
def decorate(function: Callable) -> Callable:
return function
if decorator_args and callable(decorator_args[0]) and len(decorator_args) == 1:
return decorator_args[0]
return decorate
spaces = _LocalSpaces()
import gradio as gr
import fitz
import torch
from fastapi import HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from gradio.data_classes import FileData
from PIL import Image, ImageOps
from starlette.staticfiles import StaticFiles
BASE_DIR = Path(__file__).resolve().parent
DIST_DIR = BASE_DIR / "dist"
MODEL_ID = "ATH-MaaS/OvisOCR2"
LOCAL_MODEL_DEFAULT = Path("/root/models/ATH-MaaS/OvisOCR2")
TEST_MODE = os.getenv("OVISOCR_TEST_MODE", "0").lower() in {"1", "true", "yes"}
MODEL_SOURCE = os.getenv(
"OVISOCR_MODEL_PATH",
str(LOCAL_MODEL_DEFAULT if LOCAL_MODEL_DEFAULT.is_dir() else MODEL_ID),
)
MAX_NEW_TOKENS = int(os.getenv("OVISOCR_MAX_NEW_TOKENS", "16384"))
MAX_PDF_PAGES = int(os.getenv("OVISOCR_MAX_PDF_PAGES", "50"))
PAGES_PER_GPU_REQUEST = max(
1, min(5, int(os.getenv("OVISOCR_PAGES_PER_GPU_REQUEST", "4")))
)
GPU_SECONDS_PER_PAGE = max(15, int(os.getenv("OVISOCR_GPU_SECONDS_PER_PAGE", "30")))
GPU_DURATION_FLOOR = max(15, int(os.getenv("OVISOCR_GPU_DURATION_FLOOR", "45")))
GPU_DURATION_CEILING = max(
GPU_DURATION_FLOOR,
int(os.getenv("OVISOCR_GPU_DURATION_CEILING", "120")),
)
PDF_RENDER_SCALE = float(os.getenv("OVISOCR_PDF_RENDER_SCALE", "2.0"))
STREAM_MIN_CHARS = int(os.getenv("OVISOCR_STREAM_MIN_CHARS", "64"))
STREAM_MAX_INTERVAL = float(os.getenv("OVISOCR_STREAM_MAX_INTERVAL", "0.25"))
MIN_PIXELS = 448 * 448
MAX_PIXELS = 2880 * 2880
def server_config() -> tuple[int, str | None, str | None]:
"""Resolve the port, ASGI path prefix, and optional public proxy URL."""
port = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", "7860")))
configured_root = (
os.getenv("OVISOCR_ROOT_PATH", "").strip()
or os.getenv("GRADIO_ROOT_PATH", "").strip()
)
dsw_id = os.getenv("OVISOCR_DSW_ID", "").strip()
public_url = None
root_path = None
if configured_root.startswith(("http://", "https://")):
public_url = configured_root
path = urlsplit(configured_root).path.rstrip("/")
root_path = path or None
elif configured_root:
root_path = configured_root.rstrip("/") or None
elif dsw_id:
public_url = (
f"https://{dsw_id}-proxy-{port}."
"dsw-gateway-cn-hangzhou.data.aliyun.com/"
)
return port, root_path, public_url
SERVER_PORT, ROOT_PATH, PUBLIC_URL = server_config()
OCR_PROMPT = (
"\nExtract all readable content from the image in natural human reading order "
"and output the result as a single Markdown document. For charts or images, "
'represent them using an HTML image tag: <img src="images/bbox_{left}_{top}_{right}_{bottom}.jpg" />, '
"where left, top, right, bottom are bounding box coordinates scaled to [0, 1000). "
"Format formulas as LaTeX. Format tables as HTML: <table>...</table>. "
"Transcribe all other text as standard Markdown. Preserve the original text "
"without translation or paraphrasing."
)
BBOX_IMAGE_PATTERN = re.compile(
r'<img\s+src=["\']images/bbox_(\d+)_(\d+)_(\d+)_(\d+)\.jpg["\']\s*/?>',
flags=re.IGNORECASE,
)
class CachedStaticFiles(StaticFiles):
"""Serve immutable production assets from the browser cache after first load."""
async def get_response(self, path: str, scope: dict[str, Any]) -> Any:
response = await super().get_response(path, scope)
if response.status_code == 200:
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
UNMATERIALIZED_BBOX_IMAGE_PATTERN = re.compile(
r'<img\b[^>]*\bsrc=["\']images/bbox_[^"\']+["\'][^>]*>',
flags=re.IGNORECASE,
)
EXAMPLE_ASSETS = {
path.name: (path.read_bytes(), mimetypes.guess_type(path.name)[0] or "application/octet-stream")
for path in (DIST_DIR / "examples").iterdir()
if path.is_file()
} if (DIST_DIR / "examples").is_dir() else {}
MOCK_MARKDOWN = r"""# 盈利预测、估值与评级
我们预测公司 2024—2026 年营业收入与归母净利润将保持稳健增长,当前股价对应估值如下。
<table>
<thead><tr><th>项目</th><th>2023A</th><th>2024E</th><th>2025E</th><th>2026E</th></tr></thead>
<tbody>
<tr><td>营业收入(百万元)</td><td>9,423</td><td>10,516</td><td>11,873</td><td>13,441</td></tr>
<tr><td>归母净利润(百万元)</td><td>1,267</td><td>1,452</td><td>1,681</td><td>1,946</td></tr>
<tr><td>每股收益(元)</td><td>1.02</td><td>1.17</td><td>1.36</td><td>1.57</td></tr>
<tr><td>市盈率</td><td>18.4</td><td>16.1</td><td>13.8</td><td>12.0</td></tr>
</tbody>
</table>
## 财务摘要
净资产收益率采用 $ROE = \frac{NP}{E}$ 计算;预计 2025 年利润同比增速为:
\[
g = \frac{1{,}681 - 1{,}452}{1{,}452} \times 100\% = 15.8\%.
\]
<img src="images/bbox_120_130_880_420.jpg" />
资料来源:公司公告,研究团队整理。"""
processor = None
model = None
def _load_model() -> None:
global processor, model
if TEST_MODE:
return
from transformers import AutoProcessor, Qwen3_5ForConditionalGeneration
local_only = Path(MODEL_SOURCE).is_dir()
processor = AutoProcessor.from_pretrained(
MODEL_SOURCE,
min_pixels=MIN_PIXELS,
max_pixels=MAX_PIXELS,
local_files_only=local_only,
)
model = Qwen3_5ForConditionalGeneration.from_pretrained(
MODEL_SOURCE,
dtype=torch.bfloat16,
attn_implementation=os.getenv("OVISOCR_ATTN_IMPLEMENTATION", "sdpa"),
local_files_only=local_only,
).to("cuda")
model.eval()
def clean_truncated_repeats(
text: str,
min_text_len: int = 8000,
max_period: int = 200,
min_period: int = 1,
min_repeat_chars: int = 100,
min_repeat_times: int = 5,
) -> str:
"""Remove a repeated suffix created when generation reaches its token ceiling."""
n = len(text)
if n < min_text_len:
return text
max_period = min(max_period, n - 1)
for unit_len in range(min_period, max_period + 1):
if text[n - 1] != text[n - 1 - unit_len]:
continue
match_len = 1
idx = n - 2
while idx >= unit_len and text[idx] == text[idx - unit_len]:
match_len += 1
idx -= 1
total_len = match_len + unit_len
repeat_times = total_len // unit_len
tail_len = total_len % unit_len
if repeat_times >= min_repeat_times and total_len >= min_repeat_chars:
return text[: n - total_len + unit_len] + text[n - tail_len :]
return text
def materialize_bbox_images(markdown: str, page_image: Image.Image) -> str:
"""Replace bbox image placeholders in rendered output with safe data-URI crops.
Raw model Markdown is returned separately and remains unchanged.
"""
width, height = page_image.size
def replace(match: re.Match[str]) -> str:
left, top, right, bottom = (int(value) for value in match.groups())
x1 = max(0, min(width, round(left * width / 1000)))
y1 = max(0, min(height, round(top * height / 1000)))
x2 = max(0, min(width, round(right * width / 1000)))
y2 = max(0, min(height, round(bottom * height / 1000)))
if x2 <= x1 or y2 <= y1:
return match.group(0)
crop = page_image.crop((x1, y1, x2, y2)).convert("RGB")
crop.thumbnail((1200, 1200), Image.Resampling.BILINEAR)
buffer = io.BytesIO()
# JPEG optimize performs an expensive extra pass and brings little value
# for an in-browser visual-region preview. Browser-side lazy decoding also
# keeps multi-page results responsive when several clips arrive together.
crop.save(buffer, format="JPEG", quality=85, optimize=False)
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
return (
f'<img src="data:image/jpeg;base64,{payload}" alt="Visual region" '
'loading="lazy" decoding="async" />'
)
return neutralize_unmaterialized_bbox_images(BBOX_IMAGE_PATTERN.sub(replace, markdown))
def neutralize_unmaterialized_bbox_images(markdown: str) -> str:
"""Render placeholder examples as code instead of issuing broken requests."""
def replace(match: re.Match[str]) -> str:
escaped = html.escape(match.group(0), quote=False)
return f'<code class="unresolved-image-reference">{escaped}</code>'
return UNMATERIALIZED_BBOX_IMAGE_PATTERN.sub(replace, markdown)
def stream_safe_markdown(markdown: str) -> str:
"""Avoid broken image requests until a page's bbox crops are materialized."""
return neutralize_unmaterialized_bbox_images(
BBOX_IMAGE_PATTERN.sub(
'<div class="visual-placeholder">Preparing visual region…</div>',
markdown,
)
)
def _file_path(file_data: FileData | dict[str, Any]) -> str:
if isinstance(file_data, dict):
path = file_data.get("path")
else:
path = getattr(file_data, "path", None)
if not path:
raise ValueError("No uploaded document was provided.")
return str(path)
def generation_token_ids(active_processor: Any) -> dict[str, int]:
"""Use tokenizer stop IDs; this checkpoint's config and tokenizer differ."""
tokenizer = active_processor.tokenizer
return {
"eos_token_id": int(tokenizer.eos_token_id),
"pad_token_id": int(tokenizer.pad_token_id),
}
def document_info(path: str) -> tuple[str, int]:
suffix = Path(path).suffix.lower()
try:
with Path(path).open("rb") as file:
header = file.read(5)
except OSError as error:
raise ValueError("The uploaded document could not be read.") from error
# Browser uploads can arrive at Gradio as an extensionless temporary `blob`.
if suffix == ".pdf" or header == b"%PDF-":
with fitz.open(path) as document:
total_pages = document.page_count
if total_pages < 1:
raise ValueError("The uploaded PDF has no pages.")
if total_pages > MAX_PDF_PAGES:
raise ValueError(
f"This demo accepts up to {MAX_PDF_PAGES} PDF pages; received {total_pages}."
)
return "pdf", total_pages
try:
with Image.open(path) as source:
source.verify()
except Exception as error:
raise ValueError("Please upload a valid PNG, JPEG, WebP, or PDF file.") from error
return "image", 1
def load_document_page(path: str, document_type: str, page_index: int) -> Image.Image:
if document_type == "pdf":
with fitz.open(path) as document:
page = document.load_page(page_index)
pixmap = page.get_pixmap(
matrix=fitz.Matrix(PDF_RENDER_SCALE, PDF_RENDER_SCALE),
colorspace=fitz.csRGB,
alpha=False,
)
return Image.frombytes("RGB", (pixmap.width, pixmap.height), pixmap.samples)
with Image.open(path) as source:
return ImageOps.exif_transpose(source).convert("RGB")
def page_preview_data_uri(page_image: Image.Image) -> str:
preview = page_image.copy().convert("RGB")
preview.thumbnail((1400, 1800), Image.Resampling.BILINEAR)
buffer = io.BytesIO()
preview.save(buffer, format="JPEG", quality=82, optimize=False)
payload = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/jpeg;base64,{payload}"
def _model_inputs(page_image: Image.Image) -> Any:
if processor is None or model is None:
raise RuntimeError("OvisOCR2 is not loaded.")
messages = [
{
"role": "user",
"content": [
{"type": "image", "image": page_image},
{"type": "text", "text": OCR_PROMPT},
],
}
]
return processor.apply_chat_template(
messages,
tokenize=True,
add_generation_prompt=True,
return_dict=True,
return_tensors="pt",
enable_thinking=False,
).to(model.device)
def infer_stream(page_image: Image.Image) -> Iterator[str]:
if TEST_MODE:
for end in range(64, len(MOCK_MARKDOWN) + 64, 64):
yield MOCK_MARKDOWN[:end]
return
if processor is None or model is None:
raise RuntimeError("OvisOCR2 is not loaded.")
from transformers import TextIteratorStreamer
inputs = _model_inputs(page_image)
streamer = TextIteratorStreamer(
processor.tokenizer,
skip_prompt=True,
skip_special_tokens=True,
clean_up_tokenization_spaces=False,
)
errors: list[BaseException] = []
def generate() -> None:
try:
with torch.inference_mode():
model.generate(
**inputs,
streamer=streamer,
max_new_tokens=MAX_NEW_TOKENS,
do_sample=False,
temperature=None,
top_p=None,
top_k=None,
**generation_token_ids(processor),
)
except BaseException as error:
errors.append(error)
streamer.on_finalized_text("", stream_end=True)
worker = threading.Thread(target=generate, name="ovisocr2-generate", daemon=True)
worker.start()
text = ""
last_yielded = ""
last_yield_time = time.monotonic()
for fragment in streamer:
text += fragment
now = time.monotonic()
if (
len(text) - len(last_yielded) >= STREAM_MIN_CHARS
or now - last_yield_time >= STREAM_MAX_INTERVAL
):
yield text
last_yielded = text
last_yield_time = now
worker.join()
if errors:
raise RuntimeError("Model generation failed.") from errors[0]
final_text = clean_truncated_repeats(text.strip())
if final_text and final_text != last_yielded:
yield final_text
def combine_pages(pages: list[dict[str, Any]], field: str) -> str:
if len(pages) <= 1:
return pages[0].get(field, "") if pages else ""
return "\n\n---\n\n".join(
f"<!-- Page {page['page_number']} -->\n\n{page.get(field, '')}" for page in pages
)
def stream_payload(
*,
event: str,
pages: list[dict[str, Any]],
current_page: int,
total_pages: int,
document_type: str,
started: float,
page_preview: str | None = None,
batch_complete: bool = False,
batch_start_page: int | None = None,
batch_end_page: int | None = None,
) -> dict[str, Any]:
return {
"event": event,
"markdown": combine_pages(pages, "markdown"),
"render_markdown": combine_pages(pages, "render_markdown"),
"pages": pages,
"current_page": current_page,
"total_pages": total_pages,
"document_type": document_type,
"page_preview": page_preview,
"batch_complete": batch_complete,
"batch_start_page": batch_start_page,
"batch_end_page": batch_end_page,
"char_count": sum(len(page.get("markdown", "")) for page in pages),
"elapsed_seconds": round(time.perf_counter() - started, 3),
"model": MODEL_ID,
"backend": "mock" if TEST_MODE else "transformers",
"mode": "base",
}
def _gpu_duration(
image_path: FileData | dict[str, Any], page_index: int = 0,
page_count: int = PAGES_PER_GPU_REQUEST,
) -> int:
configured_duration = os.getenv("OVISOCR_GPU_DURATION", "").strip()
if configured_duration:
return int(configured_duration)
requested_count = max(1, min(PAGES_PER_GPU_REQUEST, int(page_count)))
try:
path = _file_path(image_path)
_, total_pages = document_info(path)
remaining_pages = max(1, total_pages - int(page_index))
requested_count = min(requested_count, remaining_pages)
except Exception:
# Duration estimation must never prevent a request from reaching the
# endpoint. The endpoint performs the authoritative file validation.
pass
return max(
GPU_DURATION_FLOOR,
min(GPU_DURATION_CEILING, requested_count * GPU_SECONDS_PER_PAGE),
)
_load_model()
app = gr.Server()
app.add_middleware(
CORSMiddleware,
allow_origins=["http://127.0.0.1:4173", "http://localhost:4173"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.api(name="run_ocr", concurrency_limit=1, time_limit=300)
@spaces.GPU(duration=_gpu_duration)
def run_ocr(
image_path: FileData,
page_index: int = 0,
page_count: int = PAGES_PER_GPU_REQUEST,
) -> Iterator[dict[str, Any]]:
"""Stream a bounded group of pages within one ZeroGPU reservation.
Every page is rasterized and inferred independently and sequentially. The
bounded group amortizes ZeroGPU scheduling while keeping long PDFs split
across multiple leases.
"""
started = time.perf_counter()
path = _file_path(image_path)
document_type, total_pages = document_info(path)
page_index = int(page_index)
if page_index < 0 or page_index >= total_pages:
raise ValueError(
f"Requested PDF page {page_index + 1}, but this document has {total_pages} pages."
)
requested_count = max(1, min(PAGES_PER_GPU_REQUEST, int(page_count)))
batch_end_index = min(total_pages, page_index + requested_count)
batch_start_page = page_index + 1
batch_end_page = batch_end_index
completed_pages: list[dict[str, Any]] = []
print(
f"[ocr] batch start pages {batch_start_page}-{batch_end_page}/{total_pages}",
flush=True,
)
for current_index in range(page_index, batch_end_index):
page_number = current_index + 1
page_image = load_document_page(path, document_type, current_index)
page_started = time.perf_counter()
current = {
"page_number": page_number,
"markdown": "",
"render_markdown": "",
"status": "streaming",
"elapsed_seconds": 0.0,
}
yield stream_payload(
event="page_start",
pages=[current],
current_page=page_number,
total_pages=total_pages,
document_type=document_type,
started=started,
page_preview=page_preview_data_uri(page_image),
batch_start_page=batch_start_page,
batch_end_page=batch_end_page,
)
markdown = ""
for partial in infer_stream(page_image):
markdown = partial
current = {
"page_number": page_number,
"markdown": markdown,
"render_markdown": stream_safe_markdown(markdown),
"status": "streaming",
"elapsed_seconds": round(time.perf_counter() - page_started, 3),
}
yield stream_payload(
event="stream",
pages=[current],
current_page=page_number,
total_pages=total_pages,
document_type=document_type,
started=started,
batch_start_page=batch_start_page,
batch_end_page=batch_end_page,
)
markdown = markdown.strip()
if not markdown:
raise RuntimeError(f"The model returned an empty result for page {page_number}.")
completed_page = {
"page_number": page_number,
"markdown": markdown,
"render_markdown": materialize_bbox_images(markdown, page_image),
"status": "complete",
"elapsed_seconds": round(time.perf_counter() - page_started, 3),
}
completed_pages.append(completed_page)
print(
f"[ocr] page {page_number}/{total_pages} complete "
f"({len(markdown)} chars, {completed_page['elapsed_seconds']}s)",
flush=True,
)
yield stream_payload(
event="page_complete",
pages=[completed_page],
current_page=page_number,
total_pages=total_pages,
document_type=document_type,
started=started,
batch_start_page=batch_start_page,
batch_end_page=batch_end_page,
)
print(
f"[ocr] batch complete pages {batch_start_page}-{batch_end_page}/{total_pages}",
flush=True,
)
yield stream_payload(
event="complete",
pages=completed_pages,
current_page=batch_end_page,
total_pages=total_pages,
document_type=document_type,
started=started,
batch_complete=True,
batch_start_page=batch_start_page,
batch_end_page=batch_end_page,
)
@app.get("/healthz")
def healthz() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"model": MODEL_ID,
"model_source": MODEL_SOURCE,
"backend": "mock" if TEST_MODE else "transformers",
"loaded": TEST_MODE or (processor is not None and model is not None),
"max_pdf_pages": MAX_PDF_PAGES,
"pages_per_gpu_request": PAGES_PER_GPU_REQUEST,
"gpu_seconds_per_page": GPU_SECONDS_PER_PAGE,
"gpu_duration_floor": GPU_DURATION_FLOOR,
"gpu_duration_ceiling": GPU_DURATION_CEILING,
"root_path": ROOT_PATH,
"public_url": PUBLIC_URL,
}
)
@app.get("/examples/{filename}")
def example_asset(filename: str) -> Response:
asset = EXAMPLE_ASSETS.get(filename)
if asset is None:
raise HTTPException(status_code=404, detail="Example not found")
content, media_type = asset
return Response(
content=content,
media_type=media_type,
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
if DIST_DIR.is_dir():
for route, directory in (
("/assets", DIST_DIR / "assets"),
("/brand", DIST_DIR / "brand"),
("/vendor", DIST_DIR / "vendor"),
):
if directory.is_dir():
app.mount(route, CachedStaticFiles(directory=directory), name=route.strip("/").replace("/", "-"))
@app.get("/")
def homepage() -> FileResponse:
index_path = DIST_DIR / "index.html"
if not index_path.is_file():
raise RuntimeError("Frontend build missing. Run `npm run build` before launching app.py.")
return FileResponse(index_path, headers={"Cache-Control": "no-cache"})
@app.get("/favicon.ico")
def favicon() -> FileResponse:
return FileResponse(
DIST_DIR / "favicon.ico",
headers={"Cache-Control": "public, max-age=31536000, immutable"},
)
if __name__ == "__main__":
app.launch(
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=SERVER_PORT,
root_path=ROOT_PATH,
show_error=True,
)