Suya OCR API — vLLM-backed, OpenAI-compatible OCR service
FastAPI service wrapping the Surya-OCR-2 model (datalab-to) served through vLLM: legacy /v1/api/ai/* endpoints, an OpenAI-compatible /v1/chat/completions endpoint, a coalescing request batcher, a local OCR CLI, Docker packaging, multilingual example outputs, and quantization/concurrency benchmarks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
"""Build a GGUF artifact from a surya VLM checkpoint, suitable for stock llama.cpp.
|
||||
|
||||
Three patches are applied to llama.cpp's convert_hf_to_gguf.py before
|
||||
running the HF→GGUF conversion. All fixes are baked into the output
|
||||
artifact, so the resulting GGUF runs on unpatched llama.cpp.
|
||||
|
||||
Two checkpoint-side patches are also applied to a working copy:
|
||||
- config.json: architectures → ["Qwen3_5ForConditionalGeneration"]
|
||||
- tokenizer_config.json: tokenizer_class → "PreTrainedTokenizerFast",
|
||||
strip backend / extra_special_tokens / is_local
|
||||
|
||||
The original checkpoint is never modified — patches land in a sibling
|
||||
working dir under --out-dir.
|
||||
|
||||
Usage:
|
||||
python -m surya.scripts.build_gguf \\
|
||||
--checkpoint datalab-to/surya-2.1.6 \\
|
||||
--out-dir ./gguf-build
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LLAMA_CPP_REPO = "https://github.com/ggerganov/llama.cpp.git"
|
||||
# Pinned commit the converter patches were authored against. Bump when
|
||||
# the upstream converter drifts; re-validate the anchor strings below.
|
||||
LLAMA_CPP_REV = "bbeb89d76c41bc250f16e4a6fefcc9b530d6e3f3"
|
||||
|
||||
|
||||
# ---- llama.cpp converter patches -----------------------------------------
|
||||
# Each patch is (sentinel, anchor, replacement). Idempotent: if `sentinel`
|
||||
# is already in the file, the patch is skipped.
|
||||
|
||||
_PATCH_REGISTER_HASH_ANCHOR = """ if chkhsh == "862f827721df956049dff5ca81a57f29e575280bc622e290d3bf4e35eca29015":
|
||||
# ref: https://huggingface.co/codefuse-ai/F2LLM-v2-4B
|
||||
res = "f2llmv2"
|
||||
"""
|
||||
|
||||
_PATCH_REGISTER_HASH_REPLACEMENT = (
|
||||
_PATCH_REGISTER_HASH_ANCHOR
|
||||
+ """ if chkhsh == "11865354be60ff9206694aed04242190f1807029bbd750bfbced09b4d26f1ad2":
|
||||
# surya-2.1.0 char-level WordLevel tokenizer (Split regex=".")
|
||||
res = "default"
|
||||
"""
|
||||
)
|
||||
|
||||
_PATCH_VOCAB_ANCHOR = """ def _set_vocab_gpt2(self) -> None:
|
||||
tokens, toktypes, tokpre = self.get_vocab_base()
|
||||
self.gguf_writer.add_tokenizer_model("gpt2")
|
||||
self.gguf_writer.add_tokenizer_pre(tokpre)
|
||||
self.gguf_writer.add_token_list(tokens)
|
||||
self.gguf_writer.add_token_types(toktypes)
|
||||
|
||||
special_vocab = gguf.SpecialVocab(self.dir_model, load_merges=True)
|
||||
special_vocab.add_to_gguf(self.gguf_writer)
|
||||
"""
|
||||
|
||||
_PATCH_VOCAB_REPLACEMENT = ''' def _set_vocab_gpt2(self) -> None:
|
||||
tokens, toktypes, tokpre = self.get_vocab_base()
|
||||
# surya: char-level / WordLevel vocabs contain raw bytes (e.g. " ",
|
||||
# "\\n"). llama.cpp's gpt2 vocab decoder applies bytes_to_unicode
|
||||
# when emitting NORMAL tokens, so encode bytes here for round-trip.
|
||||
# Idempotent on already-encoded vocabs.
|
||||
tokens = self._maybe_encode_gpt2_bytes(tokens, toktypes)
|
||||
self.gguf_writer.add_tokenizer_model("gpt2")
|
||||
self.gguf_writer.add_tokenizer_pre(tokpre)
|
||||
self.gguf_writer.add_token_list(tokens)
|
||||
self.gguf_writer.add_token_types(toktypes)
|
||||
|
||||
special_vocab = gguf.SpecialVocab(self.dir_model, load_merges=True)
|
||||
special_vocab.add_to_gguf(self.gguf_writer)
|
||||
# surya: char-level / WordLevel vocabs have no merges, but the gpt2
|
||||
# vocab loader requires the field. Write a single dummy entry.
|
||||
if not special_vocab.merges:
|
||||
self.gguf_writer.add_token_merges(["a a"])
|
||||
|
||||
@staticmethod
|
||||
def _maybe_encode_gpt2_bytes(tokens: list[str], toktypes: list[int]) -> list[str]:
|
||||
"""Apply GPT-2 bytes_to_unicode to NORMAL tokens iff any contain raw
|
||||
whitespace/control bytes. Idempotent on already-encoded vocabs."""
|
||||
bs = (list(range(ord("!"), ord("~") + 1))
|
||||
+ list(range(ord("¡"), ord("¬") + 1))
|
||||
+ list(range(ord("®"), ord("ÿ") + 1)))
|
||||
cs = bs[:]
|
||||
n = 0
|
||||
for b in range(256):
|
||||
if b not in bs:
|
||||
bs.append(b)
|
||||
cs.append(2 ** 8 + n)
|
||||
n += 1
|
||||
byte_to_unicode = {b: chr(c) for b, c in zip(bs, cs)}
|
||||
printable = set(range(ord("!"), ord("~") + 1))
|
||||
needs = False
|
||||
for tok, ttype in zip(tokens, toktypes):
|
||||
if ttype != gguf.TokenType.NORMAL:
|
||||
continue
|
||||
for ch in tok:
|
||||
cb = ord(ch)
|
||||
if cb < 0x80 and cb not in printable:
|
||||
needs = True
|
||||
break
|
||||
if needs:
|
||||
break
|
||||
if not needs:
|
||||
return tokens
|
||||
out: list[str] = []
|
||||
for tok, ttype in zip(tokens, toktypes):
|
||||
if ttype == gguf.TokenType.NORMAL:
|
||||
out.append("".join(byte_to_unicode[b] for b in tok.encode("utf-8")))
|
||||
else:
|
||||
out.append(tok)
|
||||
return out
|
||||
'''
|
||||
|
||||
CONVERTER_PATCHES = [
|
||||
{
|
||||
"name": "register surya-2.1.0 pre-tokenizer hash",
|
||||
"sentinel": '"11865354be60ff9206694aed04242190f1807029bbd750bfbced09b4d26f1ad2"',
|
||||
"anchor": _PATCH_REGISTER_HASH_ANCHOR,
|
||||
"replacement": _PATCH_REGISTER_HASH_REPLACEMENT,
|
||||
},
|
||||
{
|
||||
"name": "byte-encode NORMAL tokens + dummy merges in _set_vocab_gpt2",
|
||||
"sentinel": "_maybe_encode_gpt2_bytes",
|
||||
"anchor": _PATCH_VOCAB_ANCHOR,
|
||||
"replacement": _PATCH_VOCAB_REPLACEMENT,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def patch_converter(convert_py: Path) -> None:
|
||||
text = convert_py.read_text()
|
||||
changed = False
|
||||
for p in CONVERTER_PATCHES:
|
||||
if p["sentinel"] in text:
|
||||
print(f" [skip] {p['name']} (already applied)")
|
||||
continue
|
||||
if p["anchor"] not in text:
|
||||
raise RuntimeError(
|
||||
f"converter patch {p['name']!r} could not find its anchor. "
|
||||
f"llama.cpp upstream may have drifted; re-pin LLAMA_CPP_REV "
|
||||
f"and re-validate anchors."
|
||||
)
|
||||
text = text.replace(p["anchor"], p["replacement"], 1)
|
||||
print(f" [apply] {p['name']}")
|
||||
changed = True
|
||||
if changed:
|
||||
convert_py.write_text(text)
|
||||
|
||||
|
||||
# ---- checkpoint-side patches ----------------------------------------------
|
||||
|
||||
|
||||
def patch_checkpoint(src: Path, dst: Path) -> None:
|
||||
"""Symlink-clone src into dst, with config.json + tokenizer_config.json
|
||||
rewritten for stock transformers/llama.cpp compatibility."""
|
||||
if dst.exists():
|
||||
shutil.rmtree(dst)
|
||||
dst.mkdir(parents=True)
|
||||
overrides = {"config.json", "tokenizer_config.json"}
|
||||
for entry in src.iterdir():
|
||||
if entry.name in overrides:
|
||||
continue
|
||||
os.symlink(entry.resolve(), dst / entry.name)
|
||||
|
||||
cfg = json.loads((src / "config.json").read_text())
|
||||
cfg["architectures"] = ["Qwen3_5ForConditionalGeneration"]
|
||||
(dst / "config.json").write_text(json.dumps(cfg, indent=2))
|
||||
|
||||
tk = json.loads((src / "tokenizer_config.json").read_text())
|
||||
tk["tokenizer_class"] = "PreTrainedTokenizerFast"
|
||||
for k in ("backend", "extra_special_tokens", "is_local"):
|
||||
tk.pop(k, None)
|
||||
(dst / "tokenizer_config.json").write_text(json.dumps(tk, indent=2))
|
||||
|
||||
|
||||
# ---- llama.cpp resolution -------------------------------------------------
|
||||
|
||||
|
||||
def ensure_llama_cpp(repo_dir: Path, rev: str) -> Path:
|
||||
if not repo_dir.exists():
|
||||
repo_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(f"[clone] {LLAMA_CPP_REPO} → {repo_dir}")
|
||||
subprocess.check_call(["git", "clone", LLAMA_CPP_REPO, str(repo_dir)])
|
||||
head = subprocess.check_output(
|
||||
["git", "-C", str(repo_dir), "rev-parse", "HEAD"], text=True
|
||||
).strip()
|
||||
if head != rev:
|
||||
# Discard any prior patches so the checkout is clean.
|
||||
subprocess.check_call(
|
||||
["git", "-C", str(repo_dir), "reset", "--hard", "--quiet", "HEAD"]
|
||||
)
|
||||
subprocess.check_call(
|
||||
["git", "-C", str(repo_dir), "fetch", "--quiet", "origin"]
|
||||
)
|
||||
print(f"[checkout] llama.cpp @ {rev}")
|
||||
subprocess.check_call(["git", "-C", str(repo_dir), "checkout", "--quiet", rev])
|
||||
return repo_dir
|
||||
|
||||
|
||||
# ---- checkpoint resolution ------------------------------------------------
|
||||
|
||||
|
||||
def resolve_checkpoint(checkpoint: str) -> Path:
|
||||
p = Path(checkpoint)
|
||||
if p.exists():
|
||||
return p.resolve()
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
print(f"[download] {checkpoint}")
|
||||
return Path(snapshot_download(checkpoint))
|
||||
|
||||
|
||||
# ---- main -----------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from surya.settings import settings
|
||||
|
||||
ap = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
ap.add_argument(
|
||||
"--checkpoint",
|
||||
default=settings.SURYA_MODEL_CHECKPOINT,
|
||||
help="HF repo id or local checkpoint dir",
|
||||
)
|
||||
ap.add_argument("--out-dir", type=Path, default=Path("./gguf-build"))
|
||||
ap.add_argument(
|
||||
"--name",
|
||||
default="surya-2",
|
||||
help="Output basename. Produces <name>.gguf and <name>-mmproj.gguf",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--llama-cpp-dir",
|
||||
type=Path,
|
||||
default=Path.home() / ".cache" / "datalab" / "llama.cpp",
|
||||
)
|
||||
ap.add_argument("--llama-cpp-rev", default=LLAMA_CPP_REV)
|
||||
ap.add_argument(
|
||||
"--outtype",
|
||||
default="f16",
|
||||
help="convert_hf_to_gguf --outtype (f16, bf16, q8_0, ...)",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--keep-work",
|
||||
action="store_true",
|
||||
help="Keep the patched-checkpoint working dir on success",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
args.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
work = args.out_dir / "_patched_ckpt"
|
||||
|
||||
src = resolve_checkpoint(args.checkpoint)
|
||||
print(f"[checkpoint] {src}")
|
||||
|
||||
print("[patch] checkpoint config + tokenizer_config")
|
||||
patch_checkpoint(src, work)
|
||||
|
||||
repo = ensure_llama_cpp(args.llama_cpp_dir, args.llama_cpp_rev)
|
||||
convert_py = repo / "convert_hf_to_gguf.py"
|
||||
print("[patch] llama.cpp convert_hf_to_gguf.py")
|
||||
patch_converter(convert_py)
|
||||
|
||||
out_llm = (args.out_dir / f"{args.name}.gguf").resolve()
|
||||
out_mmproj = (args.out_dir / f"{args.name}-mmproj.gguf").resolve()
|
||||
|
||||
print(f"[convert] LLM → {out_llm}")
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
str(convert_py),
|
||||
str(work),
|
||||
"--outfile",
|
||||
str(out_llm),
|
||||
"--outtype",
|
||||
args.outtype,
|
||||
]
|
||||
)
|
||||
print(f"[convert] mmproj → {out_mmproj}")
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
str(convert_py),
|
||||
str(work),
|
||||
"--mmproj",
|
||||
"--outfile",
|
||||
str(out_mmproj),
|
||||
"--outtype",
|
||||
args.outtype,
|
||||
]
|
||||
)
|
||||
|
||||
if not args.keep_work:
|
||||
shutil.rmtree(work)
|
||||
|
||||
print()
|
||||
print(f" LLM: {out_llm}")
|
||||
print(f" mmproj: {out_mmproj}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,98 @@
|
||||
from typing import List
|
||||
|
||||
import click
|
||||
import os
|
||||
from surya.input.load import load_from_folder, load_from_file
|
||||
from surya.settings import settings
|
||||
|
||||
|
||||
class CLILoader:
|
||||
def __init__(self, filepath: str, cli_options: dict, highres: bool = False):
|
||||
self.page_range = cli_options.get("page_range")
|
||||
if self.page_range:
|
||||
self.page_range = self.parse_range_str(self.page_range)
|
||||
self.filepath = filepath
|
||||
self.config = cli_options
|
||||
self.save_images = cli_options.get("images", False)
|
||||
self.debug = cli_options.get("debug", False)
|
||||
self.output_dir = cli_options.get("output_dir")
|
||||
|
||||
# Opt in to leaving the inference server up so later commands reuse it.
|
||||
if cli_options.get("keep_server"):
|
||||
settings.SURYA_INFERENCE_KEEP_ALIVE = True
|
||||
|
||||
self.load(highres)
|
||||
|
||||
@staticmethod
|
||||
def common_options(fn):
|
||||
fn = click.argument("input_path", type=click.Path(exists=True), required=True)(
|
||||
fn
|
||||
)
|
||||
fn = click.option(
|
||||
"--output_dir",
|
||||
type=click.Path(exists=False),
|
||||
required=False,
|
||||
default=os.path.join(settings.RESULT_DIR, "surya"),
|
||||
help="Directory to save output.",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--page_range",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Page range to convert, specify comma separated page numbers or ranges. Example: 0,5-10,20",
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--images",
|
||||
is_flag=True,
|
||||
help="Save images of detected bboxes.",
|
||||
default=False,
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--debug", "-d", is_flag=True, help="Enable debug mode.", default=False
|
||||
)(fn)
|
||||
fn = click.option(
|
||||
"--keep_server",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Keep the inference server (vllm/llama.cpp) running after this command exits so later commands reuse it instead of re-spawning.",
|
||||
)(fn)
|
||||
return fn
|
||||
|
||||
def load(self, highres: bool = False):
|
||||
highres_images = None
|
||||
if os.path.isdir(self.filepath):
|
||||
images, names = load_from_folder(self.filepath, self.page_range)
|
||||
folder_name = os.path.basename(self.filepath)
|
||||
if highres:
|
||||
highres_images, _ = load_from_folder(
|
||||
self.filepath, self.page_range, settings.IMAGE_DPI_HIGHRES
|
||||
)
|
||||
else:
|
||||
images, names = load_from_file(self.filepath, self.page_range)
|
||||
folder_name = os.path.basename(self.filepath).split(".")[0]
|
||||
if highres:
|
||||
highres_images, _ = load_from_file(
|
||||
self.filepath, self.page_range, settings.IMAGE_DPI_HIGHRES
|
||||
)
|
||||
|
||||
self.images = images
|
||||
self.highres_images = highres_images
|
||||
self.names = names
|
||||
|
||||
self.result_path = os.path.abspath(os.path.join(self.output_dir, folder_name))
|
||||
os.makedirs(self.result_path, exist_ok=True)
|
||||
|
||||
@staticmethod
|
||||
def parse_range_str(range_str: str) -> List[int]:
|
||||
range_lst = range_str.split(",")
|
||||
page_lst = []
|
||||
for i in range_lst:
|
||||
if "-" in i:
|
||||
start, end = i.split("-")
|
||||
page_lst += list(range(int(start), int(end) + 1))
|
||||
else:
|
||||
page_lst.append(int(i))
|
||||
page_lst = sorted(
|
||||
list(set(page_lst))
|
||||
) # Deduplicate page numbers and sort in order
|
||||
return page_lst
|
||||
@@ -0,0 +1,62 @@
|
||||
import time
|
||||
import click
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from surya.inference import SuryaInferenceManager
|
||||
from surya.layout import LayoutPredictor
|
||||
from surya.debug.draw import draw_polys_on_image
|
||||
from surya.logging import configure_logging, get_logger
|
||||
from surya.scripts.config import CLILoader
|
||||
import os
|
||||
|
||||
configure_logging()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@click.command(help="Detect layout of an input file or folder (PDFs or image).")
|
||||
@CLILoader.common_options
|
||||
def detect_layout_cli(input_path: str, **kwargs):
|
||||
loader = CLILoader(input_path, kwargs)
|
||||
|
||||
manager = SuryaInferenceManager()
|
||||
layout_predictor = LayoutPredictor(manager)
|
||||
|
||||
start = time.time()
|
||||
layout_predictions = layout_predictor(loader.images)
|
||||
|
||||
if loader.debug:
|
||||
logger.debug(f"Layout took {time.time() - start} seconds")
|
||||
|
||||
if loader.save_images:
|
||||
for idx, (image, layout_pred, name) in enumerate(
|
||||
zip(loader.images, layout_predictions, loader.names)
|
||||
):
|
||||
polygons = [p.polygon for p in layout_pred.bboxes]
|
||||
labels = [f"{p.label}-{p.position}" for p in layout_pred.bboxes]
|
||||
bbox_image = draw_polys_on_image(
|
||||
polygons, copy.deepcopy(image), labels=labels
|
||||
)
|
||||
bbox_image.save(
|
||||
os.path.join(loader.result_path, f"{name}_{idx}_layout.png")
|
||||
)
|
||||
|
||||
predictions_by_page = defaultdict(list)
|
||||
for idx, (pred, name, image) in enumerate(
|
||||
zip(layout_predictions, loader.names, loader.images)
|
||||
):
|
||||
out_pred = pred.model_dump()
|
||||
out_pred["page"] = len(predictions_by_page[name]) + 1
|
||||
predictions_by_page[name].append(out_pred)
|
||||
|
||||
with open(
|
||||
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(predictions_by_page, f, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Wrote results to {loader.result_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
detect_layout_cli()
|
||||
@@ -0,0 +1,59 @@
|
||||
import click
|
||||
import copy
|
||||
import json
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from surya.detection import DetectionPredictor
|
||||
from surya.debug.draw import draw_polys_on_image
|
||||
from surya.logging import configure_logging, get_logger
|
||||
from surya.scripts.config import CLILoader
|
||||
import os
|
||||
|
||||
configure_logging()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@click.command(help="Detect bboxes in an input file or folder (PDFs or image).")
|
||||
@CLILoader.common_options
|
||||
def detect_text_cli(input_path: str, **kwargs):
|
||||
loader = CLILoader(input_path, kwargs)
|
||||
|
||||
det_predictor = DetectionPredictor()
|
||||
|
||||
start = time.time()
|
||||
predictions = det_predictor(loader.images, include_maps=loader.debug)
|
||||
end = time.time()
|
||||
if loader.debug:
|
||||
logger.debug(f"Detection took {end - start} seconds")
|
||||
|
||||
if loader.save_images:
|
||||
for idx, (image, pred, name) in enumerate(
|
||||
zip(loader.images, predictions, loader.names)
|
||||
):
|
||||
polygons = [p.polygon for p in pred.bboxes]
|
||||
bbox_image = draw_polys_on_image(polygons, copy.deepcopy(image))
|
||||
bbox_image.save(os.path.join(loader.result_path, f"{name}_{idx}_bbox.png"))
|
||||
|
||||
if loader.debug:
|
||||
heatmap = pred.heatmap
|
||||
heatmap.save(os.path.join(loader.result_path, f"{name}_{idx}_heat.png"))
|
||||
|
||||
predictions_by_page = defaultdict(list)
|
||||
for idx, (pred, name, image) in enumerate(
|
||||
zip(predictions, loader.names, loader.images)
|
||||
):
|
||||
out_pred = pred.model_dump(exclude=["heatmap", "affinity_map"])
|
||||
out_pred["page"] = len(predictions_by_page[name]) + 1
|
||||
predictions_by_page[name].append(out_pred)
|
||||
|
||||
with open(
|
||||
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(predictions_by_page, f, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Wrote results to {loader.result_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
detect_text_cli()
|
||||
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import click
|
||||
import json
|
||||
import time
|
||||
from collections import defaultdict
|
||||
|
||||
from surya.inference import SuryaInferenceManager
|
||||
from surya.logging import configure_logging, get_logger
|
||||
from surya.recognition import RecognitionPredictor
|
||||
from surya.scripts.config import CLILoader
|
||||
|
||||
configure_logging()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@click.command(help="OCR text — full-page OCR (one VLM call per page).")
|
||||
@CLILoader.common_options
|
||||
def ocr_text_cli(input_path: str, **kwargs):
|
||||
# Full-page OCR is the default path: one VLM call per page returns layout
|
||||
# + content together. Pages whose full-page output fails to parse fall
|
||||
# back to layout + per-block OCR automatically (see RecognitionPredictor).
|
||||
loader = CLILoader(input_path, kwargs, highres=True)
|
||||
|
||||
manager = SuryaInferenceManager()
|
||||
rec_predictor = RecognitionPredictor(manager)
|
||||
|
||||
start = time.time()
|
||||
page_results = rec_predictor(loader.highres_images, full_page=True)
|
||||
|
||||
if loader.debug:
|
||||
logger.debug(f"OCR took {time.time() - start:.2f} seconds")
|
||||
|
||||
out_preds = defaultdict(list)
|
||||
for name, page in zip(loader.names, page_results):
|
||||
out_pred = page.model_dump()
|
||||
out_pred["page"] = len(out_preds[name]) + 1
|
||||
out_preds[name].append(out_pred)
|
||||
|
||||
with open(
|
||||
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(out_preds, f, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Wrote results to {loader.result_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ocr_text_cli()
|
||||
@@ -0,0 +1,9 @@
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
|
||||
def streamlit_app_cli():
|
||||
cur_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
ocr_app_path = os.path.join(cur_dir, "streamlit_app.py")
|
||||
cmd = ["streamlit", "run", ocr_app_path, "--server.fileWatcherType", "none", "--server.headless", "true"]
|
||||
subprocess.run(cmd, env={**os.environ, "IN_STREAMLIT": "true"})
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Screenshot-friendly Surya viewer.
|
||||
|
||||
Shows a PDF/image page on the left and full-page OCR output on the right, side
|
||||
by side, for clean screenshots. You can scroll through pages and preview them
|
||||
before running OCR, then export the side-by-side view as a PNG.
|
||||
|
||||
Run with `surya_screenshot`, then open http://localhost:8504.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
import pypdfium2
|
||||
from flask import Flask, jsonify, render_template, request
|
||||
from PIL import Image
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from surya.inference import SuryaInferenceManager
|
||||
from surya.logging import configure_logging, get_logger
|
||||
from surya.recognition import RecognitionPredictor
|
||||
from surya.recognition.schema import PageOCRResult
|
||||
from surya.settings import settings
|
||||
|
||||
configure_logging()
|
||||
logger = get_logger()
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
ALLOWED_EXT = {".pdf", ".png", ".jpg", ".jpeg", ".gif", ".webp"}
|
||||
UPLOAD_DIR = os.path.join(tempfile.gettempdir(), "surya_screenshot")
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
_rec: Optional[RecognitionPredictor] = None
|
||||
|
||||
|
||||
def get_rec() -> RecognitionPredictor:
|
||||
"""Lazily build the recognition predictor (shared inference manager)."""
|
||||
global _rec
|
||||
if _rec is None:
|
||||
_rec = RecognitionPredictor(SuryaInferenceManager())
|
||||
return _rec
|
||||
|
||||
|
||||
# Datalab-flavored palette for layout block overlays, keyed by canonical label.
|
||||
LABEL_COLORS = {
|
||||
"Text": "#2563eb",
|
||||
"SectionHeader": "#0ea5e9",
|
||||
"PageHeader": "#7c3aed",
|
||||
"PageFooter": "#7c3aed",
|
||||
"Caption": "#c026d3",
|
||||
"Footnote": "#64748b",
|
||||
"Equation": "#9333ea",
|
||||
"Table": "#f59e0b",
|
||||
"TableOfContents": "#f59e0b",
|
||||
"Form": "#ea580c",
|
||||
"ListGroup": "#10b981",
|
||||
"Picture": "#db2777",
|
||||
"Figure": "#db2777",
|
||||
"Diagram": "#db2777",
|
||||
"Code": "#0d9488",
|
||||
"default": "#ef4444",
|
||||
}
|
||||
|
||||
|
||||
def _logo_data_url() -> str:
|
||||
path = os.path.join(settings.BASE_DIR, "static", "datalab-logo.png")
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
return "data:image/png;base64," + base64.b64encode(f.read()).decode()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _pil_to_data_url(img: Image.Image, fmt: str = "PNG") -> str:
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format=fmt)
|
||||
return (
|
||||
f"data:image/{fmt.lower()};base64," + base64.b64encode(buf.getvalue()).decode()
|
||||
)
|
||||
|
||||
|
||||
def _is_pdf(path: str) -> bool:
|
||||
return path.lower().endswith(".pdf")
|
||||
|
||||
|
||||
def _page_count(path: str) -> int:
|
||||
if _is_pdf(path):
|
||||
doc = pypdfium2.PdfDocument(path)
|
||||
n = len(doc)
|
||||
doc.close()
|
||||
return n
|
||||
return 1
|
||||
|
||||
|
||||
def _render_page(path: str, page: int, dpi: int) -> Image.Image:
|
||||
"""Render a 0-indexed page of a PDF (or load an image file) as RGB."""
|
||||
if _is_pdf(path):
|
||||
doc = pypdfium2.PdfDocument(path)
|
||||
try:
|
||||
pil = doc[page].render(scale=dpi / 72).to_pil().convert("RGB")
|
||||
finally:
|
||||
doc.close()
|
||||
return pil
|
||||
return Image.open(path).convert("RGB")
|
||||
|
||||
|
||||
def _assemble_page_html(page: PageOCRResult) -> str:
|
||||
"""Whole-page HTML from a PageOCRResult (math stays in <math> tags)."""
|
||||
parts: List[str] = []
|
||||
for blk in page.blocks:
|
||||
if blk.skipped:
|
||||
continue
|
||||
x0, y0, x1, y1 = (int(c) for c in blk.bbox)
|
||||
parts.append(
|
||||
f'<div data-bbox="{x0} {y0} {x1} {y1}" '
|
||||
f'data-label="{blk.label}">{blk.html or ""}</div>'
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("surya_screenshot.html", logo=_logo_data_url())
|
||||
|
||||
|
||||
@app.route("/info", methods=["POST"])
|
||||
def info():
|
||||
path = (request.json or {}).get("file_path", "").strip()
|
||||
if not path:
|
||||
return jsonify({"error": "file_path is required"}), 400
|
||||
if not os.path.exists(path):
|
||||
return jsonify({"error": f"File not found: {path}"}), 400
|
||||
try:
|
||||
return jsonify({"page_count": _page_count(path)})
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/upload", methods=["POST"])
|
||||
def upload():
|
||||
"""Accept a drag/drop (or browsed) file, save to a temp path, return it."""
|
||||
f = request.files.get("file")
|
||||
if f is None or not f.filename:
|
||||
return jsonify({"error": "no file uploaded"}), 400
|
||||
ext = os.path.splitext(f.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXT:
|
||||
return jsonify({"error": f"unsupported file type: {ext or '(none)'}"}), 400
|
||||
safe = secure_filename(f.filename) or f"upload{ext}"
|
||||
dest = os.path.join(UPLOAD_DIR, f"{uuid.uuid4().hex}_{safe}")
|
||||
f.save(dest)
|
||||
try:
|
||||
return jsonify(
|
||||
{"file_path": dest, "page_count": _page_count(dest), "name": f.filename}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/page", methods=["POST"])
|
||||
def page():
|
||||
"""Render a single page for preview (no OCR)."""
|
||||
data = request.json or {}
|
||||
path = data.get("file_path", "").strip()
|
||||
page_num = int(data.get("page", 0))
|
||||
if not path or not os.path.exists(path):
|
||||
return jsonify({"error": "valid file_path is required"}), 400
|
||||
try:
|
||||
img = _render_page(path, page_num, settings.IMAGE_DPI_HIGHRES)
|
||||
return jsonify(
|
||||
{
|
||||
"image_base64": _pil_to_data_url(img),
|
||||
"width": img.size[0],
|
||||
"height": img.size[1],
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/process", methods=["POST"])
|
||||
def process():
|
||||
"""Run full-page OCR on one page; return the page image + OCR HTML + blocks."""
|
||||
data = request.json or {}
|
||||
path = data.get("file_path", "").strip()
|
||||
page_num = int(data.get("page", 0))
|
||||
if not path or not os.path.exists(path):
|
||||
return jsonify({"error": "valid file_path is required"}), 400
|
||||
try:
|
||||
img = _render_page(path, page_num, settings.IMAGE_DPI_HIGHRES)
|
||||
page_result = get_rec()([img], full_page=True)[0]
|
||||
blocks = [
|
||||
{
|
||||
"bbox": [int(c) for c in blk.bbox],
|
||||
"label": blk.label,
|
||||
"color": LABEL_COLORS.get(blk.label, LABEL_COLORS["default"]),
|
||||
}
|
||||
for blk in page_result.blocks
|
||||
if not blk.skipped
|
||||
]
|
||||
return jsonify(
|
||||
{
|
||||
"image_base64": _pil_to_data_url(img),
|
||||
"width": img.size[0],
|
||||
"height": img.size[1],
|
||||
"html": _assemble_page_html(page_result),
|
||||
"blocks": blocks,
|
||||
"n_blocks": len(page_result.blocks),
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Full-page OCR failed")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
def main():
|
||||
app.run(host="0.0.0.0", port=8504)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Surya2 streamlit app — exercise layout, recognition, table_rec via the
|
||||
inference manager. Detection + OCR-error stay in their own torch paths."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
from typing import List
|
||||
|
||||
import pypdfium2
|
||||
import streamlit as st
|
||||
import streamlit.components.v1 as components
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from surya.debug.draw import draw_polys_on_image, draw_bboxes_on_image
|
||||
from surya.detection import TextDetectionResult
|
||||
from surya.inference import SuryaInferenceManager
|
||||
from surya.layout import LayoutPredictor
|
||||
from surya.layout.schema import LayoutResult
|
||||
from surya.recognition import RecognitionPredictor
|
||||
from surya.recognition.schema import PageOCRResult
|
||||
from surya.settings import settings
|
||||
from surya.table_rec import TableRecPredictor
|
||||
from surya.table_rec.schema import TableResult
|
||||
|
||||
|
||||
# KaTeX-enabled HTML wrapper. The OCR HTML wraps math in <math>...</math>
|
||||
# (KaTeX-compatible LaTeX inside), which a browser would otherwise show as
|
||||
# raw text. We convert those tags to \( \) / \[ \] delimiters and let KaTeX
|
||||
# auto-render typeset them inside an iframe component.
|
||||
_KATEX_HEAD = r"""<!doctype html><html><head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
|
||||
<style>
|
||||
/* White "paper" card so the text stays readable in both light and dark
|
||||
Streamlit themes (the iframe is otherwise transparent and our text is dark). */
|
||||
html,body{background:#ffffff;}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:15px;line-height:1.55;color:#111111;margin:0;padding:14px;}
|
||||
table{border-collapse:collapse;margin:6px 0;} td,th{border:1px solid #bbb;padding:3px 6px;color:#111111;}
|
||||
[data-label="SectionHeader"],[data-label="PageHeader"]{font-weight:600;}
|
||||
</style></head><body>
|
||||
"""
|
||||
|
||||
_KATEX_TAIL = r"""
|
||||
<script>
|
||||
renderMathInElement(document.body, {
|
||||
delimiters: [
|
||||
{left: "\\[", right: "\\]", display: true},
|
||||
{left: "\\(", right: "\\)", display: false}
|
||||
],
|
||||
throwOnError: false
|
||||
});
|
||||
</script></body></html>
|
||||
"""
|
||||
|
||||
_MATH_RE = re.compile(r"<math\b([^>]*)>(.*?)</math>", re.DOTALL | re.IGNORECASE)
|
||||
|
||||
|
||||
def _math_to_katex(html_str: str) -> str:
|
||||
"""Rewrite <math>...</math> tags into KaTeX \\( \\) / \\[ \\] delimiters."""
|
||||
|
||||
def repl(m: "re.Match") -> str:
|
||||
attrs, inner = m.group(1), m.group(2)
|
||||
if re.search(r"""display\s*=\s*["']block["']""", attrs):
|
||||
return "\\[" + inner + "\\]"
|
||||
return "\\(" + inner + "\\)"
|
||||
|
||||
return _MATH_RE.sub(repl, html_str or "")
|
||||
|
||||
|
||||
def render_ocr_html(html_str: str, height: int = 400) -> None:
|
||||
"""Render OCR HTML with math typeset by KaTeX (iframe component)."""
|
||||
components.html(
|
||||
_KATEX_HEAD + _math_to_katex(html_str) + _KATEX_TAIL,
|
||||
height=height,
|
||||
scrolling=True,
|
||||
)
|
||||
|
||||
|
||||
def _assemble_page_html(page: PageOCRResult) -> str:
|
||||
"""Reconstruct a div-block whole-page HTML from a PageOCRResult."""
|
||||
parts: List[str] = []
|
||||
for blk in page.blocks:
|
||||
if blk.skipped:
|
||||
continue
|
||||
x0, y0, x1, y1 = (int(c) for c in blk.bbox)
|
||||
body = blk.html or ""
|
||||
parts.append(
|
||||
f'<div data-bbox="{x0} {y0} {x1} {y1}" data-label="{blk.label}">{body}</div>'
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _show_timing(label: str, elapsed_s: float, extra: str = "") -> None:
|
||||
"""Render a small caption with wall-clock + optional extra detail."""
|
||||
detail = f" — {extra}" if extra else ""
|
||||
st.caption(f"⏱ {label}: {elapsed_s * 1000:.0f} ms ({elapsed_s:.2f}s){detail}")
|
||||
|
||||
|
||||
@st.cache_resource()
|
||||
def load_predictors_cached():
|
||||
manager = SuryaInferenceManager()
|
||||
layout_predictor = LayoutPredictor(manager)
|
||||
rec_predictor = RecognitionPredictor(manager)
|
||||
table_rec_predictor = TableRecPredictor(manager)
|
||||
|
||||
# Lazy-import detection / ocr_error to keep startup snappy when the user
|
||||
# only wants VLM modes
|
||||
from surya.detection import DetectionPredictor
|
||||
from surya.ocr_error import OCRErrorPredictor
|
||||
|
||||
return {
|
||||
"manager": manager,
|
||||
"layout": layout_predictor,
|
||||
"recognition": rec_predictor,
|
||||
"table_rec": table_rec_predictor,
|
||||
"detection": DetectionPredictor(),
|
||||
"ocr_error": OCRErrorPredictor(),
|
||||
}
|
||||
|
||||
|
||||
def text_detection(img) -> tuple[Image.Image, TextDetectionResult, float]:
|
||||
t = time.perf_counter()
|
||||
text_pred = predictors["detection"]([img])[0]
|
||||
elapsed = time.perf_counter() - t
|
||||
text_polygons = [p.polygon for p in text_pred.bboxes]
|
||||
det_img = draw_polys_on_image(text_polygons, img.copy())
|
||||
return det_img, text_pred, elapsed
|
||||
|
||||
|
||||
def layout_detection(img) -> tuple[Image.Image, LayoutResult, float]:
|
||||
t = time.perf_counter()
|
||||
pred = predictors["layout"]([img])[0]
|
||||
elapsed = time.perf_counter() - t
|
||||
polygons = [p.polygon for p in pred.bboxes]
|
||||
labels = [
|
||||
f"{p.label}-{p.position}-c{p.count}-{round(p.confidence or 0, 2)}"
|
||||
for p in pred.bboxes
|
||||
]
|
||||
annotated = draw_polys_on_image(
|
||||
polygons, img.copy(), labels=labels, label_font_size=14
|
||||
)
|
||||
return annotated, pred, elapsed
|
||||
|
||||
|
||||
def block_ocr(img) -> tuple[Image.Image, PageOCRResult, LayoutResult, float, float]:
|
||||
"""Layout → block crops → BLOCK_PROMPT. Returns layout + block-OCR timings."""
|
||||
t_layout = time.perf_counter()
|
||||
layout = predictors["layout"]([img])[0]
|
||||
layout_elapsed = time.perf_counter() - t_layout
|
||||
|
||||
t_blocks = time.perf_counter()
|
||||
page_results = predictors["recognition"]([img], [layout])
|
||||
blocks_elapsed = time.perf_counter() - t_blocks
|
||||
page = page_results[0]
|
||||
|
||||
annotated = img.copy()
|
||||
draw = ImageDraw.Draw(annotated)
|
||||
for blk in page.blocks:
|
||||
x0, y0, x1, y1 = blk.bbox
|
||||
color = "red" if blk.error else ("orange" if blk.skipped else "green")
|
||||
draw.rectangle((x0, y0, x1, y1), outline=color, width=3)
|
||||
draw.text((x0 + 4, y0 + 4), f"{blk.reading_order} {blk.label}", fill=color)
|
||||
return annotated, page, layout, layout_elapsed, blocks_elapsed
|
||||
|
||||
|
||||
def full_page_ocr(img) -> tuple[Image.Image, PageOCRResult, float]:
|
||||
"""Single HIGH_ACCURACY_BBOX_PROMPT call on the whole page."""
|
||||
t = time.perf_counter()
|
||||
page_results = predictors["recognition"]([img], full_page=True)
|
||||
elapsed = time.perf_counter() - t
|
||||
page = page_results[0]
|
||||
annotated = img.copy()
|
||||
draw = ImageDraw.Draw(annotated)
|
||||
for blk in page.blocks:
|
||||
x0, y0, x1, y1 = blk.bbox
|
||||
color = "red" if blk.error else ("orange" if blk.skipped else "green")
|
||||
draw.rectangle((x0, y0, x1, y1), outline=color, width=3)
|
||||
draw.text((x0 + 4, y0 + 4), f"{blk.reading_order} {blk.label}", fill=color)
|
||||
return annotated, page, elapsed
|
||||
|
||||
|
||||
def table_recognition(
|
||||
img: Image.Image,
|
||||
mode: str,
|
||||
skip_table_detection: bool,
|
||||
) -> tuple[Image.Image, List[TableResult], float, float]:
|
||||
"""Returns (annotated_img, table_preds, layout_elapsed, table_rec_elapsed)."""
|
||||
layout_elapsed = 0.0
|
||||
if skip_table_detection:
|
||||
table_imgs = [img]
|
||||
table_counts = [0]
|
||||
table_bboxes = [(0, 0, img.size[0], img.size[1])]
|
||||
else:
|
||||
t = time.perf_counter()
|
||||
layout = predictors["layout"]([img])[0]
|
||||
layout_elapsed = time.perf_counter() - t
|
||||
tables = [b for b in layout.bboxes if b.label in ("Table", "TableOfContents")]
|
||||
if not tables:
|
||||
return img.copy(), [], layout_elapsed, 0.0
|
||||
table_bboxes = [tuple(int(c) for c in b.bbox) for b in tables]
|
||||
table_imgs = [img.crop(b) for b in table_bboxes]
|
||||
table_counts = [b.count for b in tables]
|
||||
|
||||
t = time.perf_counter()
|
||||
if mode == "full":
|
||||
table_preds = predictors["table_rec"].predict_full(
|
||||
table_imgs, counts=table_counts
|
||||
)
|
||||
else:
|
||||
table_preds = predictors["table_rec"].predict_simple(table_imgs)
|
||||
table_rec_elapsed = time.perf_counter() - t
|
||||
|
||||
out_img = img.copy()
|
||||
for pred, table_img, tbbox in zip(table_preds, table_imgs, table_bboxes):
|
||||
if pred.error or pred.mode != "simple" or not pred.rows:
|
||||
continue
|
||||
row_bboxes = [r.bbox for r in pred.rows]
|
||||
col_bboxes = [c.bbox for c in pred.cols]
|
||||
row_labels = [r.label for r in pred.rows]
|
||||
col_labels = [c.label for c in pred.cols]
|
||||
annot = table_img.copy()
|
||||
annot = draw_bboxes_on_image(
|
||||
row_bboxes, annot, labels=row_labels, label_font_size=14, color="blue"
|
||||
)
|
||||
annot = draw_bboxes_on_image(
|
||||
col_bboxes, annot, labels=col_labels, label_font_size=14, color="red"
|
||||
)
|
||||
# Paste annotated crop back at the table's position in the page.
|
||||
out_img.paste(annot, (tbbox[0], tbbox[1]))
|
||||
return out_img, table_preds, layout_elapsed, table_rec_elapsed
|
||||
|
||||
|
||||
def ocr_errors(pdf_file, page_count, sample_len=512, max_samples=10, max_pages=15):
|
||||
from pdftext.extraction import plain_text_output
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
||||
f.write(pdf_file.getvalue())
|
||||
f.seek(0)
|
||||
|
||||
page_middle = page_count // 2
|
||||
page_range = range(
|
||||
max(page_middle - max_pages, 0), min(page_middle + max_pages, page_count)
|
||||
)
|
||||
text = plain_text_output(f.name, page_range=page_range)
|
||||
|
||||
sample_gap = len(text) // max_samples
|
||||
if len(text) == 0 or sample_gap == 0:
|
||||
return "This PDF has no text or very little text", ["no text"]
|
||||
|
||||
if sample_gap < sample_len:
|
||||
sample_gap = sample_len
|
||||
|
||||
samples = []
|
||||
for i in range(0, len(text), sample_gap):
|
||||
samples.append(text[i : i + sample_len])
|
||||
|
||||
results = predictors["ocr_error"](samples)
|
||||
label = "This PDF has good text."
|
||||
if results.labels.count("bad") / len(results.labels) > 0.2:
|
||||
label = "This PDF may have garbled or bad OCR text."
|
||||
return label, results.labels
|
||||
|
||||
|
||||
def open_pdf(pdf_file):
|
||||
stream = io.BytesIO(pdf_file.getvalue())
|
||||
return pypdfium2.PdfDocument(stream)
|
||||
|
||||
|
||||
@st.cache_data()
|
||||
def get_page_image(pdf_file, page_num, dpi=settings.IMAGE_DPI):
|
||||
doc = open_pdf(pdf_file)
|
||||
renderer = doc.render(
|
||||
pypdfium2.PdfBitmap.to_pil,
|
||||
page_indices=[page_num - 1],
|
||||
scale=dpi / 72,
|
||||
)
|
||||
png = list(renderer)[0]
|
||||
png_image = png.convert("RGB")
|
||||
doc.close()
|
||||
return png_image
|
||||
|
||||
|
||||
@st.cache_data()
|
||||
def page_counter(pdf_file):
|
||||
doc = open_pdf(pdf_file)
|
||||
doc_len = len(doc)
|
||||
doc.close()
|
||||
return doc_len
|
||||
|
||||
|
||||
st.set_page_config(layout="wide")
|
||||
col1, col2 = st.columns([0.55, 0.45])
|
||||
|
||||
predictors = load_predictors_cached()
|
||||
|
||||
st.markdown(
|
||||
"""
|
||||
# Surya 2 Demo
|
||||
|
||||
VLM-backed layout, OCR, and table recognition. The model runs in a local
|
||||
`llama-server` (or vllm) process, started on first use.
|
||||
|
||||
Modes:
|
||||
- **Layout**: page → list of blocks with label + bbox + token count
|
||||
- **Block OCR**: layout + per-block HTML
|
||||
- **Table Rec (simple)**: row + column bboxes only
|
||||
- **Table Rec (full)**: full HTML for each detected table
|
||||
"""
|
||||
)
|
||||
|
||||
in_file = st.sidebar.file_uploader(
|
||||
"PDF file or image:", type=["pdf", "png", "jpg", "jpeg", "gif", "webp"]
|
||||
)
|
||||
|
||||
if in_file is None:
|
||||
st.stop()
|
||||
|
||||
filetype = in_file.type
|
||||
page_count = None
|
||||
if "pdf" in filetype:
|
||||
page_count = page_counter(in_file)
|
||||
page_number = st.sidebar.number_input(
|
||||
f"Page number out of {page_count}:", min_value=1, value=1, max_value=page_count
|
||||
)
|
||||
# Render at high DPI so the OCR / table-rec demos see fine glyphs.
|
||||
# Layout + detection internally downsample (or accept the small perf hit
|
||||
# at demo scale); we always render and display the high-DPI page here.
|
||||
pil_image = get_page_image(in_file, page_number, settings.IMAGE_DPI_HIGHRES)
|
||||
else:
|
||||
pil_image = Image.open(in_file).convert("RGB")
|
||||
page_number = None
|
||||
|
||||
run_full_page_ocr = st.sidebar.button("Run Full-Page OCR")
|
||||
run_text_det = st.sidebar.button("Run Text Detection")
|
||||
run_layout = st.sidebar.button("Run Layout Analysis")
|
||||
run_table_rec = st.sidebar.button("Run Table Rec")
|
||||
run_block_ocr = st.sidebar.button("Run Block OCR")
|
||||
run_ocr_errors = st.sidebar.button("Run bad-PDF-text detection")
|
||||
|
||||
table_mode = st.sidebar.radio(
|
||||
"Table mode",
|
||||
options=["simple", "full"],
|
||||
index=0,
|
||||
help="simple: rows+cols only. full: full HTML.",
|
||||
)
|
||||
skip_table_detection = st.sidebar.checkbox(
|
||||
"Skip table detection",
|
||||
value=False,
|
||||
help="Treat the entire page/image as a single table.",
|
||||
)
|
||||
|
||||
if pil_image is None:
|
||||
st.stop()
|
||||
|
||||
|
||||
if run_text_det:
|
||||
det_img, text_pred, elapsed = text_detection(pil_image)
|
||||
with col1:
|
||||
_show_timing("Text detection", elapsed, f"{len(text_pred.bboxes)} polys")
|
||||
st.image(det_img, caption="Detected Text", use_container_width=True)
|
||||
st.json(
|
||||
text_pred.model_dump(exclude=["heatmap", "affinity_map"]), expanded=False
|
||||
)
|
||||
|
||||
|
||||
if run_layout:
|
||||
annotated, pred, elapsed = layout_detection(pil_image)
|
||||
with col1:
|
||||
_show_timing("Layout", elapsed, f"{len(pred.bboxes)} blocks")
|
||||
st.image(annotated, caption="Detected Layout", use_container_width=True)
|
||||
st.json(pred.model_dump(), expanded=False)
|
||||
|
||||
|
||||
if run_block_ocr:
|
||||
annotated, page, layout, t_layout, t_blocks = block_ocr(pil_image)
|
||||
with col1:
|
||||
n_blocks = len(page.blocks)
|
||||
n_ok = sum(1 for b in page.blocks if not b.skipped and not b.error)
|
||||
_show_timing("Block OCR — layout", t_layout, f"{n_blocks} blocks")
|
||||
_show_timing("Block OCR — per-block OCR", t_blocks, f"{n_ok} OCR'd")
|
||||
_show_timing("Block OCR — total", t_layout + t_blocks)
|
||||
st.image(
|
||||
annotated,
|
||||
caption="Block OCR (green=ok, orange=skipped, red=error)",
|
||||
use_container_width=True,
|
||||
)
|
||||
full_html = _assemble_page_html(page)
|
||||
with st.expander("Full page HTML (rendered)", expanded=False):
|
||||
render_ocr_html(full_html, height=600)
|
||||
with st.expander("Full page HTML (source)", expanded=False):
|
||||
st.code(full_html, language="html")
|
||||
for blk in page.blocks:
|
||||
with st.expander(
|
||||
f"#{blk.reading_order} {blk.label} (conf {blk.confidence:.2f})"
|
||||
):
|
||||
# Diagnostics: show numeric bbox + polygon + a thumbnail with the
|
||||
# drawn rectangle highlighted, then the actual crop fed to OCR.
|
||||
xs = [p[0] for p in blk.polygon]
|
||||
ys = [p[1] for p in blk.polygon]
|
||||
bbox_drawn = [int(min(xs)), int(min(ys)), int(max(xs)), int(max(ys))]
|
||||
cx0 = max(0, int(min(xs)) - 4)
|
||||
cy0 = max(0, int(min(ys)) - 4)
|
||||
cx1 = min(pil_image.size[0], int(max(xs)) + 4)
|
||||
cy1 = min(pil_image.size[1], int(max(ys)) + 4)
|
||||
st.text(
|
||||
f"bbox(drawn) = {bbox_drawn}\n"
|
||||
f"crop(ocr) = {(cx0, cy0, cx1, cy1)} (= bbox ± 4px pad)"
|
||||
)
|
||||
# Thumbnail with this block's rectangle highlighted in red.
|
||||
thumb = pil_image.copy()
|
||||
ImageDraw.Draw(thumb).rectangle(bbox_drawn, outline="red", width=4)
|
||||
st.image(thumb, caption="this block's drawn rect (red)", width=300)
|
||||
# The actual crop fed to OCR
|
||||
if cx1 > cx0 and cy1 > cy0:
|
||||
st.image(pil_image.crop((cx0, cy0, cx1, cy1)), caption="OCR crop")
|
||||
if blk.skipped:
|
||||
st.info("Block skipped (visual label)")
|
||||
elif blk.error:
|
||||
st.error("Block OCR errored")
|
||||
else:
|
||||
render_ocr_html(blk.html, height=160)
|
||||
st.code(blk.html, language="html")
|
||||
|
||||
|
||||
if run_full_page_ocr:
|
||||
annotated, page, elapsed = full_page_ocr(pil_image)
|
||||
with col1:
|
||||
n_blocks = len(page.blocks)
|
||||
n_ok = sum(1 for b in page.blocks if not b.skipped and not b.error)
|
||||
_show_timing("Full-Page OCR", elapsed, f"{n_blocks} blocks parsed, {n_ok} OK")
|
||||
st.image(
|
||||
annotated,
|
||||
caption="Full-Page OCR (green=ok, orange=skipped, red=error)",
|
||||
use_container_width=True,
|
||||
)
|
||||
full_html = _assemble_page_html(page)
|
||||
with st.expander("Full page HTML (rendered)", expanded=False):
|
||||
render_ocr_html(full_html, height=600)
|
||||
with st.expander("Full page HTML (source)", expanded=False):
|
||||
st.code(full_html, language="html")
|
||||
for blk in page.blocks:
|
||||
with st.expander(
|
||||
f"#{blk.reading_order} {blk.label} (conf {blk.confidence:.2f})"
|
||||
):
|
||||
if blk.skipped:
|
||||
st.info("Block skipped (visual label)")
|
||||
elif blk.error:
|
||||
st.error("Block OCR errored")
|
||||
else:
|
||||
render_ocr_html(blk.html, height=160)
|
||||
st.code(blk.html, language="html")
|
||||
|
||||
|
||||
if run_table_rec:
|
||||
table_img, preds, t_layout, t_table = table_recognition(
|
||||
pil_image, table_mode, skip_table_detection
|
||||
)
|
||||
with col1:
|
||||
if not skip_table_detection:
|
||||
_show_timing("Table Rec — layout", t_layout, f"{len(preds)} tables found")
|
||||
_show_timing(f"Table Rec — {table_mode}", t_table)
|
||||
if not skip_table_detection:
|
||||
_show_timing("Table Rec — total", t_layout + t_table)
|
||||
st.image(table_img, caption="Table Recognition", use_container_width=True)
|
||||
for pred in preds:
|
||||
if pred.mode == "full" and pred.html:
|
||||
with st.expander("Table HTML"):
|
||||
render_ocr_html(pred.html, height=400)
|
||||
st.code(pred.html, language="html")
|
||||
else:
|
||||
st.json(pred.model_dump(), expanded=False)
|
||||
|
||||
|
||||
if run_ocr_errors:
|
||||
if "pdf" not in filetype:
|
||||
st.error("This feature only works with PDFs.")
|
||||
else:
|
||||
label, results = ocr_errors(in_file, page_count)
|
||||
with col1:
|
||||
st.write(label)
|
||||
st.json(results)
|
||||
|
||||
|
||||
with col2:
|
||||
st.image(pil_image, caption="Uploaded Image", use_container_width=True)
|
||||
@@ -0,0 +1,139 @@
|
||||
import os
|
||||
import click
|
||||
import copy
|
||||
import json
|
||||
from collections import defaultdict
|
||||
|
||||
from surya.common.util import expand_bbox
|
||||
from surya.debug.draw import draw_bboxes_on_image
|
||||
from surya.inference import SuryaInferenceManager
|
||||
from surya.layout import LayoutPredictor
|
||||
from surya.logging import configure_logging, get_logger
|
||||
from surya.scripts.config import CLILoader
|
||||
from surya.table_rec import TableRecPredictor
|
||||
|
||||
configure_logging()
|
||||
logger = get_logger()
|
||||
|
||||
|
||||
@click.command(help="Run table recognition on an input file or folder.")
|
||||
@CLILoader.common_options
|
||||
@click.option(
|
||||
"--skip_table_detection",
|
||||
is_flag=True,
|
||||
help="Tables are already cropped, so don't re-detect tables.",
|
||||
default=False,
|
||||
)
|
||||
@click.option(
|
||||
"--mode",
|
||||
type=click.Choice(["simple", "full"]),
|
||||
default="simple",
|
||||
help="simple: rows+cols only (geometric cells). full: full HTML (BLOCK_PROMPT).",
|
||||
)
|
||||
def table_recognition_cli(
|
||||
input_path: str, skip_table_detection: bool, mode: str, **kwargs
|
||||
):
|
||||
# Layout runs on the low-DPI render; table crops come from the high-DPI
|
||||
# image so the table_rec model sees readable cell content.
|
||||
loader = CLILoader(input_path, kwargs, highres=True)
|
||||
|
||||
manager = SuryaInferenceManager()
|
||||
layout_predictor = LayoutPredictor(manager)
|
||||
table_rec_predictor = TableRecPredictor(manager)
|
||||
|
||||
pnums = []
|
||||
prev_name = None
|
||||
for name in loader.names:
|
||||
if prev_name is None or prev_name != name:
|
||||
pnums.append(0)
|
||||
else:
|
||||
pnums.append(pnums[-1] + 1)
|
||||
prev_name = name
|
||||
|
||||
table_imgs = []
|
||||
table_counts = []
|
||||
table_counts_per_img = []
|
||||
|
||||
if skip_table_detection:
|
||||
for img in loader.highres_images:
|
||||
table_imgs.append(img)
|
||||
table_counts.append(1)
|
||||
table_counts_per_img.append(0)
|
||||
else:
|
||||
layout_predictions = layout_predictor(
|
||||
loader.images,
|
||||
target_image_sizes=[img.size for img in loader.highres_images],
|
||||
)
|
||||
for layout_pred, img in zip(layout_predictions, loader.highres_images):
|
||||
tables_on_page = [
|
||||
line
|
||||
for line in layout_pred.bboxes
|
||||
if line.label in ("Table", "TableOfContents")
|
||||
]
|
||||
table_counts.append(len(tables_on_page))
|
||||
for line in tables_on_page:
|
||||
bbox = expand_bbox(line.bbox)
|
||||
table_imgs.append(img.crop(bbox))
|
||||
table_counts_per_img.append(line.count)
|
||||
|
||||
table_preds = table_rec_predictor(table_imgs, mode=mode)
|
||||
|
||||
img_idx = 0
|
||||
prev_count = 0
|
||||
table_predictions = defaultdict(list)
|
||||
for i in range(sum(table_counts)):
|
||||
while i >= prev_count + table_counts[img_idx]:
|
||||
prev_count += table_counts[img_idx]
|
||||
img_idx += 1
|
||||
|
||||
pred = table_preds[i]
|
||||
orig_name = loader.names[img_idx]
|
||||
pnum = pnums[img_idx]
|
||||
table_img = table_imgs[i]
|
||||
|
||||
out_pred = pred.model_dump()
|
||||
out_pred["page"] = pnum + 1
|
||||
table_idx = i - prev_count
|
||||
out_pred["table_idx"] = table_idx
|
||||
table_predictions[orig_name].append(out_pred)
|
||||
|
||||
if loader.save_images and pred.rows:
|
||||
rows = [line.bbox for line in pred.rows]
|
||||
cols = [line.bbox for line in pred.cols]
|
||||
row_labels = [f"Row {line.row_id}" for line in pred.rows]
|
||||
col_labels = [f"Col {line.col_id}" for line in pred.cols]
|
||||
cells = [line.bbox for line in pred.cells]
|
||||
|
||||
rc_image = copy.deepcopy(table_img)
|
||||
rc_image = draw_bboxes_on_image(
|
||||
rows, rc_image, labels=row_labels, label_font_size=20, color="blue"
|
||||
)
|
||||
rc_image = draw_bboxes_on_image(
|
||||
cols, rc_image, labels=col_labels, label_font_size=20, color="red"
|
||||
)
|
||||
rc_image.save(
|
||||
os.path.join(
|
||||
loader.result_path,
|
||||
f"{orig_name}_page{pnum + 1}_table{table_idx}_rc.png",
|
||||
)
|
||||
)
|
||||
|
||||
cell_image = copy.deepcopy(table_img)
|
||||
cell_image = draw_bboxes_on_image(cells, cell_image, color="green")
|
||||
cell_image.save(
|
||||
os.path.join(
|
||||
loader.result_path,
|
||||
f"{orig_name}_page{pnum + 1}_table{table_idx}_cells.png",
|
||||
)
|
||||
)
|
||||
|
||||
with open(
|
||||
os.path.join(loader.result_path, "results.json"), "w+", encoding="utf-8"
|
||||
) as f:
|
||||
json.dump(table_predictions, f, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Wrote results to {loader.result_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
table_recognition_cli()
|
||||
@@ -0,0 +1,331 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Surya · Full-Page OCR</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #0f1115;
|
||||
--bg: #f4f6f9;
|
||||
--panel: #ffffff;
|
||||
--border: #e4e7ec;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--muted: #667085;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
header {
|
||||
display: flex; align-items: center; gap: 16px;
|
||||
padding: 12px 20px;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border);
|
||||
box-shadow: 0 1px 3px rgba(16,24,40,.04);
|
||||
z-index: 10;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; }
|
||||
.brand img { height: 30px; width: auto; }
|
||||
.brand .title { font-size: 18px; font-weight: 700; letter-spacing: -.01em; }
|
||||
.brand .sub { font-size: 13px; color: var(--muted); font-weight: 500;
|
||||
padding-left: 10px; margin-left: 4px; border-left: 1px solid var(--border); }
|
||||
.controls { display: flex; align-items: center; gap: 10px; flex: 1; flex-wrap: wrap; }
|
||||
input[type=text] {
|
||||
flex: 1; min-width: 260px; max-width: 560px;
|
||||
padding: 9px 12px; font-size: 14px;
|
||||
border: 1px solid var(--border); border-radius: 8px; background: #fff;
|
||||
}
|
||||
input[type=text]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(37,99,235,.12); }
|
||||
button {
|
||||
padding: 9px 14px; font-size: 14px; font-weight: 600;
|
||||
border: none; border-radius: 8px; cursor: pointer;
|
||||
background: var(--accent); color: #fff; transition: background .15s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
button.secondary { background: #fff; color: var(--ink); border: 1px solid var(--border); }
|
||||
button.secondary:hover:not(:disabled) { background: #f2f4f7; }
|
||||
button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.pager { display: flex; align-items: center; gap: 6px; }
|
||||
.pager .pageind { font-size: 13px; color: var(--muted); min-width: 96px; text-align: center; }
|
||||
.pager button { padding: 7px 11px; }
|
||||
.toggle { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--muted); cursor: pointer; user-select: none; }
|
||||
.status { font-size: 13px; font-weight: 600; min-width: 80px; }
|
||||
.status.loading { color: #b45309; }
|
||||
.status.error { color: #d92d20; }
|
||||
.status.ok { color: #079455; }
|
||||
|
||||
.stage {
|
||||
flex: 1; display: flex; gap: 16px; padding: 16px; overflow: hidden;
|
||||
}
|
||||
.panel {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 12px; overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(16,24,40,.05);
|
||||
}
|
||||
.panel-head {
|
||||
padding: 11px 16px; font-size: 13px; font-weight: 700;
|
||||
letter-spacing: .02em; text-transform: uppercase; color: var(--muted);
|
||||
border-bottom: 1px solid var(--border); background: #fcfcfd;
|
||||
}
|
||||
.panel-body { flex: 1; overflow: auto; }
|
||||
.img-wrap {
|
||||
display: flex; align-items: flex-start; justify-content: center;
|
||||
padding: 16px; background: #f0f2f5; min-height: 100%;
|
||||
}
|
||||
#pageCanvas { max-width: 100%; height: auto; border-radius: 6px;
|
||||
box-shadow: 0 2px 10px rgba(16,24,40,.12); background: #fff; }
|
||||
.ocr {
|
||||
padding: 28px 32px; line-height: 1.6; font-size: 16px; color: #1d2433;
|
||||
}
|
||||
.ocr [data-label="SectionHeader"], .ocr [data-label="Title"] { font-weight: 700; font-size: 1.15em; margin: .5em 0 .3em; }
|
||||
.ocr [data-label="PageHeader"], .ocr [data-label="PageFooter"] { color: var(--muted); font-size: .9em; }
|
||||
.ocr table { border-collapse: collapse; margin: 14px 0; width: 100%; }
|
||||
.ocr th, .ocr td { border: 1px solid #d0d5dd; padding: 6px 10px; text-align: left; }
|
||||
.ocr th { background: #f2f4f7; font-weight: 600; }
|
||||
.ocr img { max-width: 100%; height: auto; }
|
||||
.placeholder { padding: 48px 32px; color: var(--muted); font-size: 15px; text-align: center; }
|
||||
#dropOverlay { position: fixed; inset: 0; z-index: 100; display: none;
|
||||
align-items: center; justify-content: center; background: rgba(15,17,21,.55); }
|
||||
#dropOverlay.active { display: flex; }
|
||||
#dropOverlay .drop-card { padding: 40px 64px; border: 3px dashed #fff;
|
||||
border-radius: 16px; color: #fff; font-size: 22px; font-weight: 700;
|
||||
background: rgba(37,99,235,.30); }
|
||||
/* While screenshotting, expand panels to full content height so
|
||||
html2canvas captures everything, not just the visible scroll area. */
|
||||
body.capturing { height: auto !important; overflow: visible !important; }
|
||||
body.capturing .stage { height: auto !important; overflow: visible !important; }
|
||||
body.capturing .panel-body { height: auto !important; overflow: visible !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand">
|
||||
{% if logo %}<img src="{{ logo }}" alt="Datalab">{% endif %}
|
||||
<span class="title">Surya</span>
|
||||
<span class="sub">Full-Page OCR</span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<input type="text" id="filePath" placeholder="Drop a file, Browse, or type a server path…">
|
||||
<input type="file" id="fileInput" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp" style="display:none" onchange="onPick(this)">
|
||||
<button class="secondary" onclick="document.getElementById('fileInput').click()">Browse</button>
|
||||
<button class="secondary" id="loadBtn" onclick="loadFile()">Load</button>
|
||||
<div class="pager">
|
||||
<button class="secondary" id="prevBtn" onclick="changePage(-1)" disabled>◀</button>
|
||||
<span class="pageind" id="pageInd">—</span>
|
||||
<button class="secondary" id="nextBtn" onclick="changePage(1)" disabled>▶</button>
|
||||
</div>
|
||||
<button id="runBtn" onclick="runOCR()" disabled>Run Full-Page OCR</button>
|
||||
<label class="toggle"><input type="checkbox" id="showBoxes" checked onchange="drawLeft()"> Layout boxes</label>
|
||||
<button class="secondary" id="copyBtn" onclick="copyHtml()" disabled>Copy HTML</button>
|
||||
<button class="secondary" id="shotBtn" onclick="saveScreenshot()" disabled>Save Screenshot</button>
|
||||
<span class="status" id="status"></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="stage">
|
||||
<div class="panel">
|
||||
<div class="panel-head">PDF Page</div>
|
||||
<div class="panel-body"><div class="img-wrap"><canvas id="pageCanvas"></canvas></div></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-head">Full-Page OCR</div>
|
||||
<div class="panel-body"><div class="ocr" id="ocr"><div class="placeholder">Load a file, scroll to a page, then run full-page OCR.</div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="dropOverlay"><div class="drop-card">Drop a PDF or image to load</div></div>
|
||||
|
||||
<script>
|
||||
const S = { path: "", name: "", page: 0, count: 0, img: null, blocks: null, html: null, ocrPage: null };
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
function setStatus(msg, kind) { const s = $("status"); s.textContent = msg || ""; s.className = "status " + (kind || ""); }
|
||||
|
||||
async function post(url, body) {
|
||||
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
async function loadFile() {
|
||||
const val = $("filePath").value.trim();
|
||||
if (!val) { setStatus("Drop a file or enter a path", "error"); return; }
|
||||
// If the box still shows the name of an uploaded file, just reload it.
|
||||
if (S.path && val === S.name) { S.page = 0; await showPage(); return; }
|
||||
setStatus("Loading…", "loading");
|
||||
try {
|
||||
const info = await post("/info", { file_path: val });
|
||||
S.path = val; S.name = val; S.count = info.page_count; S.page = 0;
|
||||
await showPage();
|
||||
setStatus("");
|
||||
} catch (e) { setStatus(e.message, "error"); }
|
||||
}
|
||||
|
||||
function onPick(input) { if (input.files && input.files[0]) uploadFile(input.files[0]); }
|
||||
|
||||
async function uploadFile(file) {
|
||||
setStatus("Uploading…", "loading");
|
||||
const fd = new FormData(); fd.append("file", file);
|
||||
try {
|
||||
const r = await fetch("/upload", { method: "POST", body: fd });
|
||||
const data = await r.json();
|
||||
if (!r.ok) throw new Error(data.error || "Upload failed");
|
||||
S.path = data.file_path; S.name = data.name; S.count = data.page_count; S.page = 0;
|
||||
$("filePath").value = data.name;
|
||||
await showPage();
|
||||
setStatus("");
|
||||
} catch (e) { setStatus(e.message, "error"); }
|
||||
}
|
||||
|
||||
async function showPage() {
|
||||
setStatus("Rendering…", "loading");
|
||||
try {
|
||||
const data = await post("/page", { file_path: S.path, page: S.page });
|
||||
// New page → clear any previous OCR output.
|
||||
S.blocks = null; S.html = null; S.ocrPage = null;
|
||||
$("ocr").innerHTML = '<div class="placeholder">Run full-page OCR to see the extracted content.</div>';
|
||||
$("shotBtn").disabled = true;
|
||||
$("copyBtn").disabled = true;
|
||||
loadImage(data.image_base64, () => drawLeft());
|
||||
updatePager();
|
||||
$("runBtn").disabled = false;
|
||||
setStatus("");
|
||||
} catch (e) { setStatus(e.message, "error"); }
|
||||
}
|
||||
|
||||
function loadImage(src, cb) {
|
||||
const im = new Image();
|
||||
im.onload = () => { S.img = im; cb && cb(); };
|
||||
im.src = src;
|
||||
}
|
||||
|
||||
function updatePager() {
|
||||
$("pageInd").textContent = S.count ? `Page ${S.page + 1} of ${S.count}` : "—";
|
||||
$("prevBtn").disabled = S.page <= 0;
|
||||
$("nextBtn").disabled = S.page >= S.count - 1;
|
||||
}
|
||||
|
||||
function changePage(delta) {
|
||||
const next = S.page + delta;
|
||||
if (next < 0 || next >= S.count) return;
|
||||
S.page = next;
|
||||
showPage();
|
||||
}
|
||||
|
||||
function drawLeft() {
|
||||
if (!S.img) return;
|
||||
const c = $("pageCanvas"), ctx = c.getContext("2d");
|
||||
c.width = S.img.naturalWidth; c.height = S.img.naturalHeight;
|
||||
ctx.drawImage(S.img, 0, 0);
|
||||
if (!S.blocks || !$("showBoxes").checked) return;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.font = 'bold 15px -apple-system, "Segoe UI", sans-serif';
|
||||
ctx.textBaseline = "top";
|
||||
S.blocks.forEach((b) => {
|
||||
const [x1, y1, x2, y2] = b.bbox;
|
||||
ctx.strokeStyle = b.color; ctx.fillStyle = b.color + "26";
|
||||
ctx.fillRect(x1, y1, x2 - x1, y2 - y1);
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
const tw = ctx.measureText(b.label).width, ly = Math.max(y1 - 22, 0);
|
||||
ctx.fillStyle = b.color; ctx.fillRect(x1, ly, tw + 12, 21);
|
||||
ctx.fillStyle = "#fff"; ctx.fillText(b.label, x1 + 6, ly + 3);
|
||||
});
|
||||
}
|
||||
|
||||
async function runOCR() {
|
||||
if (!S.path) return;
|
||||
setStatus("Running OCR…", "loading");
|
||||
$("runBtn").disabled = true;
|
||||
try {
|
||||
const data = await post("/process", { file_path: S.path, page: S.page });
|
||||
S.blocks = data.blocks; S.html = data.html || ""; S.ocrPage = S.page;
|
||||
loadImage(data.image_base64, () => drawLeft());
|
||||
const ocr = $("ocr");
|
||||
ocr.innerHTML = data.html || '<div class="placeholder">No content detected on this page.</div>';
|
||||
renderMath(ocr);
|
||||
$("shotBtn").disabled = false;
|
||||
$("copyBtn").disabled = !S.html;
|
||||
setStatus(`${data.n_blocks} blocks`, "ok");
|
||||
} catch (e) { setStatus(e.message, "error"); }
|
||||
finally { $("runBtn").disabled = false; }
|
||||
}
|
||||
|
||||
function renderMath(root) {
|
||||
root.querySelectorAll("math").forEach((el) => {
|
||||
const block = el.getAttribute("display") === "block";
|
||||
try {
|
||||
const span = document.createElement(block ? "div" : "span");
|
||||
span.innerHTML = katex.renderToString(el.textContent, { displayMode: block, throwOnError: false });
|
||||
el.replaceWith(span);
|
||||
} catch (e) { /* leave raw on failure */ }
|
||||
});
|
||||
}
|
||||
|
||||
function copyHtml() {
|
||||
if (!S.html) return;
|
||||
const done = () => setStatus("HTML copied", "ok");
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(S.html).then(done).catch(() => fallbackCopy(S.html, done));
|
||||
} else {
|
||||
fallbackCopy(S.html, done);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text, done) {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
|
||||
document.body.appendChild(ta); ta.select();
|
||||
try { document.execCommand("copy"); done(); }
|
||||
catch (e) { setStatus("Copy failed", "error"); }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
async function saveScreenshot() {
|
||||
setStatus("Capturing…", "loading");
|
||||
const stage = document.querySelector(".stage");
|
||||
document.body.classList.add("capturing");
|
||||
try {
|
||||
// Let the expanded layout settle before measuring full size.
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
const w = stage.scrollWidth, h = stage.scrollHeight;
|
||||
const canvas = await html2canvas(stage, {
|
||||
backgroundColor: "#f4f6f9", scale: 2, useCORS: true, logging: false,
|
||||
width: w, height: h, windowWidth: w, windowHeight: h, scrollX: 0, scrollY: 0,
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = canvas.toDataURL("image/png");
|
||||
a.download = `surya_ocr_page_${(S.ocrPage ?? S.page) + 1}.png`;
|
||||
a.click();
|
||||
setStatus("Saved", "ok");
|
||||
} catch (e) { setStatus("Screenshot failed: " + e.message, "error"); }
|
||||
finally { document.body.classList.remove("capturing"); }
|
||||
}
|
||||
|
||||
$("filePath").addEventListener("keypress", (e) => { if (e.key === "Enter") loadFile(); });
|
||||
|
||||
// Drag & drop anywhere in the window.
|
||||
let dragDepth = 0;
|
||||
window.addEventListener("dragenter", (e) => { e.preventDefault(); dragDepth++; $("dropOverlay").classList.add("active"); });
|
||||
window.addEventListener("dragover", (e) => { e.preventDefault(); });
|
||||
window.addEventListener("dragleave", (e) => { e.preventDefault(); if (--dragDepth <= 0) { dragDepth = 0; $("dropOverlay").classList.remove("active"); } });
|
||||
window.addEventListener("drop", (e) => {
|
||||
e.preventDefault(); dragDepth = 0; $("dropOverlay").classList.remove("active");
|
||||
const f = e.dataTransfer.files && e.dataTransfer.files[0];
|
||||
if (f) uploadFile(f);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user