Team Ai
Datasetpublic

uv-scripts/ocr

OCR UV Scripts Part of uv-scripts: self-contained UV scripts you run on Hugging Face Jobs in one command. One script per OCR model. Each script runs the model on a GPU with Hugging Face Jobs and writes the text as markdown: as a new column in a Hub dataset, as .md files in a Bucket, or as resumable parquet parts (the -saturate recipes). A few scripts return JSON from a schema, detect layout regions, or compare the output of two models. Quick Start First… See the full description on the dataset page: https://huggingface.co/datasets/uv-scripts/ocr.

sourceHugging Faceupdated 10d agoView on Hugging Face
163likes6.5kdownloads
pp-ocrv6.py1108 linesDownload Raw Back to root
1# /// script2# requires-python = ">=3.10"3# dependencies = [4#     "paddlepaddle-gpu>=3.0.0",5#     "paddleocr>=3.7.0",6#     "paddlex[ocr]>=3.7.0",7#     "opencv-contrib-python-headless",8#     "datasets>=3.1.0",9#     "huggingface-hub",10#     "pillow",11#     "numpy",12#     "tqdm",13# ]14#15# [tool.uv]16# # PaddleOCR/PaddleX pull in opencv-contrib-python (full) which needs system17# # libGL.so.1 — not present in the slim uv-on-bookworm image used by HF Jobs.18# # Swap to the headless cv2 variant (same `import cv2`, no GUI deps). A matching19# # importlib.metadata patch in main() makes paddlex recognise the headless name.20# override-dependencies = [21#     "opencv-contrib-python ; python_version < '0'",22#     "opencv-python ; python_version < '0'",23# ]24#25# [[tool.uv.index]]26# name = "paddle"27# url = "https://www.paddlepaddle.org.cn/packages/stable/cu126/"28# explicit = true29#30# [tool.uv.sources]31# paddlepaddle-gpu = { index = "paddle" }32#33# [tool.hf-jobs]34# flavor = "t4-small"35# secrets = ["HF_TOKEN"]36# ///37"""38OCR images with PP-OCRv6 — a lightweight detection+recognition pipeline from39PaddlePaddle. Three tiers from **1.5M to 34.5M parameters**.40 41Unlike the VLM-based OCR recipes here, PP-OCRv6 is a **classical det+rec pipeline**42that outputs **plain text** (not markdown). At 1.5M-34.5M params it's far smaller43than the VLM OCRs and runs on a cheap t4-small GPU.44 45Model tiers (pick with `--model-tier`):46  tiny    1.5M params  (0.4M det + 1.1M rec)  49 languages, ~73% recognition47  small   7.7M params  (2.5M det + 5.3M rec)  50 languages, ~81% recognition48  medium  34.5M params (22M det + 19M rec)     50 languages, ~83% recognition49 50All tiers are Apache 2.0 licensed. Runs via PaddleOCR's default Paddle engine51(`paddle_static`) — same proven header pattern as `pp-doclayout.py`.52 53HF Jobs examples (flavor and secrets come from the [tool.hf-jobs] header,54which needs `hf` CLI 1.32+):55 56    # Tiny on a cheap GPU57    hf jobs uv run \\58        https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-ocrv6.py \\59        INPUT_DATASET OUTPUT_DATASET \\60        --model-tier tiny --max-samples 561 62    # Medium on a small GPU (recommended for quality)63    hf jobs uv run \\64        https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-ocrv6.py \\65        INPUT_DATASET OUTPUT_DATASET \\66        --model-tier medium --max-samples 1067 68Models: PaddlePaddle/PP-OCRv6_<tier>_det + PP-OCRv6_<tier>_rec69Blog: https://huggingface.co/blog/PaddlePaddle/pp-ocrv670"""71 72import argparse73import io74import json75import logging76import os77import sys78import time79from dataclasses import dataclass80from datetime import datetime, timezone81from pathlib import Path82from typing import Any, Dict, Iterator, List, Optional, Tuple, Union83 84import numpy as np85from PIL import Image, UnidentifiedImageError86from tqdm.auto import tqdm87 88logging.basicConfig(level=logging.INFO)89logger = logging.getLogger(__name__)90 91 92# ---------------------------------------------------------------------------93# Constants94# ---------------------------------------------------------------------------95 96TIER_MODELS = {97    "tiny":   ("PP-OCRv6_tiny_det",   "PP-OCRv6_tiny_rec"),98    "small":  ("PP-OCRv6_small_det",  "PP-OCRv6_small_rec"),99    "medium": ("PP-OCRv6_medium_det", "PP-OCRv6_medium_rec"),100}101 102TIER_PARAMS = {103    "tiny":   "1.5M (0.4M det + 1.1M rec)",104    "small":  "7.7M (2.5M det + 5.3M rec)",105    "medium": "34.5M (22M det + 19M rec)",106}107 108TIER_LANGUAGES = {109    "tiny":   "49 languages (zh, zh-Hant, en + 46 Latin-script — no Japanese)",110    "small":  "50 languages (zh, zh-Hant, en, ja + 46 Latin-script)",111    "medium": "50 languages (zh, zh-Hant, en, ja + 46 Latin-script)",112}113 114TIER_REC = {115    "tiny":   73.5,116    "small":  81.3,117    "medium": 83.2,118}119 120BUCKET_PREFIX = "hf://buckets/"121 122IMAGE_EXTENSIONS = {123    ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp", ".jp2", ".j2k",124}125 126 127# ---------------------------------------------------------------------------128# URL helpers129# ---------------------------------------------------------------------------130 131def is_bucket_url(s: str) -> bool:132    return s.startswith(BUCKET_PREFIX)133 134 135def parse_bucket_url(url: str) -> Tuple[str, str]:136    if not is_bucket_url(url):137        raise ValueError(f"Not a bucket URL: {url}")138    rest = url[len(BUCKET_PREFIX):].strip("/")139    parts = rest.split("/", 2)140    if len(parts) < 2:141        raise ValueError(f"Bucket URL must include namespace and bucket name: {url}")142    bucket_id = f"{parts[0]}/{parts[1]}"143    prefix = parts[2] if len(parts) > 2 else ""144    return bucket_id, prefix145 146 147# ---------------------------------------------------------------------------148# Image helpers149# ---------------------------------------------------------------------------150 151def to_pil(image: Union[Image.Image, Dict[str, Any], str, bytes]) -> Image.Image:152    if isinstance(image, Image.Image):153        return image.convert("RGB")154    if isinstance(image, dict) and "bytes" in image:155        return Image.open(io.BytesIO(image["bytes"])).convert("RGB")156    if isinstance(image, (bytes, bytearray)):157        return Image.open(io.BytesIO(image)).convert("RGB")158    if isinstance(image, str):159        return Image.open(image).convert("RGB")160    raise ValueError(f"Unsupported image type: {type(image)}")161 162 163def pil_to_array(pil_img: Image.Image) -> np.ndarray:164    return np.asarray(pil_img, dtype=np.uint8)165 166 167# ---------------------------------------------------------------------------168# Result extraction169# ---------------------------------------------------------------------------170 171def extract_text(result: Any) -> Tuple[str, List[Dict[str, Any]]]:172    """Pull text and per-line details from a PaddleOCR predict result.173 174    Returns (concatenated_text, per_line_details) where per_line_details is175    a list of dicts with keys: text, score, bbox (4-point detection polygon as176    [[x1,y1],[x2,y2],[x3,y3],[x4,y4]] in input-image pixel coordinates).177    """178    payload = result.json if hasattr(result, "json") else result179    res = payload.get("res", payload) if isinstance(payload, dict) else {}180    rec_texts = res.get("rec_texts", []) or []181    rec_scores = res.get("rec_scores", []) or []182    dt_polys = res.get("dt_polys", []) or []183 184    # Concatenate reading-order text lines (PaddleOCR returns them in order)185    text = "\n".join(rec_texts)186 187    per_line = []188    for i, t in enumerate(rec_texts):189        entry = {"text": t}190        if i < len(rec_scores):191            entry["score"] = float(rec_scores[i])192        if i < len(dt_polys):193            entry["bbox"] = [[float(c) for c in point] for point in dt_polys[i]]194        per_line.append(entry)195 196    return text, per_line197 198 199# ---------------------------------------------------------------------------200# Sources201# ---------------------------------------------------------------------------202 203@dataclass204class SourceItem:205    key: str206    image: Optional[Image.Image]207    extras: Dict[str, Any]208 209 210def iter_dataset_images(211    dataset_id: str,212    image_column: str,213    split: str,214    shuffle: bool,215    seed: int,216    max_samples: Optional[int],217):218    from datasets import load_dataset219 220    logger.info(f"Loading dataset: {dataset_id} (split={split})")221    ds = load_dataset(dataset_id, split=split)222 223    if image_column not in ds.column_names:224        raise ValueError(225            f"Column '{image_column}' not found. Available: {ds.column_names}"226        )227 228    if shuffle:229        logger.info(f"Shuffling with seed {seed}")230        ds = ds.shuffle(seed=seed)231    if max_samples:232        ds = ds.select(range(min(max_samples, len(ds))))233        logger.info(f"Limited to {len(ds)} samples")234 235    total = len(ds)236 237    def gen() -> Iterator[SourceItem]:238        failed = 0239        for i in range(total):240            try:241                row = ds[i]242                image = to_pil(row[image_column])243            except (UnidentifiedImageError, OSError) as e:244                # Still yield a placeholder so the output row stays aligned with245                # the source row (the dataset sink writes results positionally).246                failed += 1247                logger.warning(248                    f"Unreadable image at row {i}: {type(e).__name__}: {e} "249                    f"— writing empty result"250                )251                yield SourceItem(key=f"row-{i:08d}", image=None, extras={"failed": True})252                continue253            yield SourceItem(key=f"row-{i:08d}", image=image, extras={})254        if failed:255            logger.info(f"{failed} unreadable image(s) written as empty results")256 257    return gen(), total, ds258 259 260SOURCE_PATHS_SNAPSHOT = "_source_paths.json"261 262 263def _bucket_snapshot_path(output_url: str) -> Tuple[str, str]:264    out_bucket_id, out_prefix = parse_bucket_url(output_url)265    snapshot_key = (266        f"{out_prefix}/{SOURCE_PATHS_SNAPSHOT}".lstrip("/")267        if out_prefix268        else SOURCE_PATHS_SNAPSHOT269    )270    return out_bucket_id, snapshot_key271 272 273def iter_bucket_images(274    bucket_url: str,275    shuffle: bool,276    seed: int,277    max_samples: Optional[int],278    hf_token: Optional[str],279    output_url: Optional[str] = None,280) -> Tuple[Iterator[SourceItem], int]:281    from huggingface_hub import HfApi, HfFileSystem282 283    bucket_id, prefix = parse_bucket_url(bucket_url)284    fs = HfFileSystem(token=hf_token)285    base = f"{BUCKET_PREFIX}{bucket_id}/{prefix}".rstrip("/")286 287    snapshot_bucket_id: Optional[str] = None288    snapshot_key: Optional[str] = None289    cached_paths: Optional[List[str]] = None290 291    if output_url and is_bucket_url(output_url):292        snapshot_bucket_id, snapshot_key = _bucket_snapshot_path(output_url)293        snapshot_url = f"{BUCKET_PREFIX}{snapshot_bucket_id}/{snapshot_key}"294        try:295            with fs.open(snapshot_url, "rb") as f:296                snapshot = json.load(f)297            mismatches = []298            if snapshot.get("source_url") != bucket_url:299                mismatches.append(300                    f"source_url ({snapshot.get('source_url')!r} vs {bucket_url!r})"301                )302            if snapshot.get("shuffle") != shuffle:303                mismatches.append(f"shuffle ({snapshot.get('shuffle')} vs {shuffle})")304            if shuffle and snapshot.get("seed") != seed:305                mismatches.append(f"seed ({snapshot.get('seed')} vs {seed})")306            if snapshot.get("max_samples") != max_samples:307                mismatches.append(308                    f"max_samples ({snapshot.get('max_samples')} vs {max_samples})"309                )310            if mismatches:311                logger.warning(312                    "Existing snapshot params differ from this run ("313                    + "; ".join(mismatches)314                    + "); ignoring snapshot and re-listing."315                )316            else:317                cached_paths = snapshot["paths"]318                logger.info(319                    f"Reusing existing snapshot of {len(cached_paths)} source paths "320                    f"(written {snapshot.get('created_at', 'unknown')})"321                )322        except FileNotFoundError:323            pass324        except Exception as e:325            logger.warning(f"Could not read existing snapshot ({e}); re-listing.")326 327    if cached_paths is not None:328        all_paths = cached_paths329    else:330        logger.info(f"Listing images under {base}")331        all_paths = []332        try:333            for entry in fs.find(base, detail=False):334                ext = Path(entry).suffix.lower()335                if ext in IMAGE_EXTENSIONS:336                    all_paths.append(entry)337        except FileNotFoundError as e:338            raise ValueError(f"Bucket prefix not found: {base}") from e339 340        if not all_paths:341            raise ValueError(342                f"No image files (any of {sorted(IMAGE_EXTENSIONS)}) under {base}"343            )344 345        all_paths.sort()346        if shuffle:347            rng = np.random.default_rng(seed)348            rng.shuffle(all_paths)349        if max_samples:350            all_paths = all_paths[:max_samples]351 352        if snapshot_bucket_id is not None and snapshot_key is not None:353            api = HfApi(token=hf_token)354            payload = {355                "source_url": bucket_url,356                "shuffle": shuffle,357                "seed": seed,358                "max_samples": max_samples,359                "created_at": datetime.now(timezone.utc).isoformat(),360                "paths": all_paths,361            }362            api.batch_bucket_files(363                snapshot_bucket_id,364                add=[(json.dumps(payload).encode(), snapshot_key)],365                token=hf_token,366            )367            logger.info(368                f"Wrote source-path snapshot ({len(all_paths)} paths) to "369                f"hf://buckets/{snapshot_bucket_id}/{snapshot_key}"370            )371 372    total = len(all_paths)373    logger.info(f"Found {total} images in bucket")374 375    def key_for(path: str) -> str:376        return path377 378    def gen() -> Iterator[SourceItem]:379        skipped = 0380        for path in all_paths:381            try:382                with fs.open(path, "rb") as f:383                    data = f.read()384                image = to_pil(data)385            except (UnidentifiedImageError, OSError) as e:386                skipped += 1387                logger.warning(388                    f"Skipping unreadable image {path}: {type(e).__name__}: {e}"389                )390                continue391            yield SourceItem(key=key_for(path), image=image, extras={})392        if skipped:393            logger.info(f"Skipped {skipped} unreadable image(s) total")394 395    return gen(), total396 397 398# ---------------------------------------------------------------------------399# Sinks400# ---------------------------------------------------------------------------401 402class DatasetRepoSink:403    def __init__(404        self,405        repo_id: str,406        *,407        hf_token: Optional[str],408        private: bool,409        config: Optional[str],410        create_pr: bool,411        source_id: str,412        original_dataset=None,413        output_column: str = "markdown",414        overwrite: bool = False,415    ):416        self.repo_id = repo_id417        self.hf_token = hf_token418        self.private = private419        self.config = config420        self.create_pr = create_pr421        self.source_id = source_id422        self.original_dataset = original_dataset423        self.output_column = output_column424        self.overwrite = overwrite425        self._texts: List[str] = []426        self._blocks: List[str] = []427 428    @property429    def kind(self) -> str:430        return "dataset"431 432    def already_done(self) -> set:433        return set()434 435    def write(self, key: str, text: str, blocks: List[Dict[str, Any]]) -> None:436        self._texts.append(text)437        self._blocks.append(json.dumps(blocks, ensure_ascii=False))438 439    def finalize(self, tier: str, det_model: str, rec_model: str, args_dict: Dict[str, Any]) -> None:440        from datasets import Dataset441 442        if self.original_dataset is not None:443            if len(self._texts) != len(self.original_dataset):444                logger.warning(445                    f"Text count ({len(self._texts)}) != dataset rows "446                    f"({len(self.original_dataset)}); padding with empty strings."447                )448                while len(self._texts) < len(self.original_dataset):449                    self._texts.append("")450                    self._blocks.append("[]")451            # Guard again at save time in case the input column set changed under us.452            base = self.original_dataset453            clash = [c for c in (self.output_column, "pp_ocr_blocks") if c in base.column_names]454            if clash:455                if not self.overwrite:456                    raise ValueError(457                        f"Output column(s) {clash} already exist in the input dataset; "458                        f"pass a different --output-column, or --overwrite to replace them."459                    )460                logger.warning(f"--overwrite: replacing existing column(s) {clash}")461                base = base.remove_columns(clash)462            ds = base.add_column(self.output_column, self._texts)463            ds = ds.add_column("pp_ocr_blocks", self._blocks)464        else:465            if not self._texts:466                logger.warning("No rows produced; nothing to push.")467                return468            ds = Dataset.from_list([469                {"source_path": None, self.output_column: t, "pp_ocr_blocks": b}470                for t, b in zip(self._texts, self._blocks)471            ])472 473        inference_entry = build_inference_entry(tier, det_model, rec_model, args_dict)474 475        if "inference_info" in ds.column_names:476            logger.info("Updating existing inference_info column")477 478            def _update(example):479                try:480                    existing = (481                        json.loads(example["inference_info"])482                        if example["inference_info"]483                        else []484                    )485                except (json.JSONDecodeError, TypeError):486                    existing = []487                existing.append(inference_entry)488                return {"inference_info": json.dumps(existing)}489 490            ds = ds.map(_update)491        else:492            ds = ds.add_column(493                "inference_info", [json.dumps([inference_entry])] * len(ds)494            )495 496        logger.info(f"Pushing {len(ds)} rows to {self.repo_id}")497        push_kwargs = {498            "private": self.private,499            "token": self.hf_token,500            "max_shard_size": "500MB",501            "create_pr": self.create_pr,502            "commit_message": f"Add PP-OCRv6-{tier} OCR results ({len(ds)} samples)"503            + (f" [{self.config}]" if self.config else ""),504        }505        if self.config:506            push_kwargs["config_name"] = self.config507 508        max_retries = 3509        for attempt in range(1, max_retries + 1):510            try:511                if attempt > 1:512                    logger.warning("Disabling XET (fallback to HTTP upload)")513                    os.environ["HF_HUB_DISABLE_XET"] = "1"514                ds.push_to_hub(self.repo_id, **push_kwargs)515                break516            except Exception as e:517                logger.error(f"Upload attempt {attempt}/{max_retries} failed: {e}")518                if attempt == max_retries:519                    logger.error("All upload attempts failed.")520                    raise521                time.sleep(30 * (2 ** (attempt - 1)))522 523        from huggingface_hub import DatasetCard524 525        card = DatasetCard(526            create_dataset_card(527                source=self.source_id,528                tier=tier,529                det_model=det_model,530                rec_model=rec_model,531                num_samples=len(ds),532                processing_time=args_dict["processing_time"],533                engine=args_dict.get("engine", "paddle_static"),534                output_id=self.repo_id,535                output_column=self.output_column,536            )537        )538        card.push_to_hub(self.repo_id, token=self.hf_token)539        logger.info(f"Done: https://huggingface.co/datasets/{self.repo_id}")540 541 542class BucketShardSink:543    METADATA_FILE = "_metadata.json"544    SHARD_PATTERN = "shard-{:05d}.parquet"545 546    def __init__(547        self,548        bucket_url: str,549        *,550        hf_token: Optional[str],551        shard_size: int,552        resume: bool,553        source_id: str,554    ):555        from huggingface_hub import HfApi, HfFileSystem, create_bucket556 557        self.bucket_url = bucket_url558        self.bucket_id, self.prefix = parse_bucket_url(bucket_url)559        self.hf_token = hf_token560        self.shard_size = shard_size561        self.resume = resume562        self.source_id = source_id563 564        self._api = HfApi(token=hf_token)565        self._fs = HfFileSystem(token=hf_token)566 567        try:568            create_bucket(self.bucket_id, exist_ok=True, token=hf_token)569        except Exception as e:570            logger.warning(f"create_bucket('{self.bucket_id}') warning: {e}")571 572        self._buffer: List[Dict[str, Any]] = []573        self._next_shard_idx = self._discover_next_shard_idx()574        self._completed_keys = self._discover_completed_keys() if resume else set()575        if self._completed_keys:576            logger.info(577                f"Resume: found {len(self._completed_keys)} already-processed keys, will skip them"578            )579 580    @property581    def kind(self) -> str:582        return "bucket"583 584    def already_done(self) -> set:585        return self._completed_keys586 587    def _shard_path(self, idx: int) -> str:588        return self._join(self.SHARD_PATTERN.format(idx))589 590    def _join(self, name: str) -> str:591        return f"{self.prefix}/{name}".lstrip("/") if self.prefix else name592 593    def _list_existing_shards(self) -> List[str]:594        try:595            tree = self._api.list_bucket_tree(596                self.bucket_id, prefix=self.prefix or None, recursive=True597            )598        except Exception:599            return []600        shards: List[str] = []601        for item in tree:602            path = getattr(item, "path", None)603            ftype = getattr(item, "type", None)604            if not path or ftype not in (None, "file"):605                continue606            base = Path(path).name607            if base.startswith("shard-") and base.endswith(".parquet"):608                shards.append(path)609        return sorted(shards)610 611    def _discover_next_shard_idx(self) -> int:612        shards = self._list_existing_shards()613        max_idx = -1614        for s in shards:615            stem = Path(s).stem616            try:617                max_idx = max(max_idx, int(stem.split("-")[-1]))618            except ValueError:619                continue620        return max_idx + 1621 622    def _discover_completed_keys(self) -> set:623        import pyarrow.parquet as pq624 625        keys: set = set()626        for shard_path in self._list_existing_shards():627            full = f"{BUCKET_PREFIX}{self.bucket_id}/{shard_path}"628            try:629                with self._fs.open(full, "rb") as f:630                    table = pq.read_table(f, columns=["__source_key"])631                keys.update(table.column("__source_key").to_pylist())632            except Exception as e:633                logger.warning(f"Could not read keys from {shard_path}: {e}")634        return keys635 636    def _flush(self) -> None:637        if not self._buffer:638            return639        import pyarrow as pa640        import pyarrow.parquet as pq641 642        columns = ["__source_key", "text", "pp_ocr_blocks"]643        table_dict = {c: [row.get(c) for row in self._buffer] for c in columns}644        table = pa.Table.from_pydict(table_dict)645 646        buf = io.BytesIO()647        pq.write_table(table, buf, compression="zstd")648        data = buf.getvalue()649 650        shard_remote = self._shard_path(self._next_shard_idx)651        logger.info(652            f"Writing shard {self._next_shard_idx} ({len(self._buffer)} rows, "653            f"{len(data) / 1024 / 1024:.1f} MiB) to {shard_remote}"654        )655        self._api.batch_bucket_files(656            self.bucket_id, add=[(data, shard_remote)], token=self.hf_token657        )658        self._next_shard_idx += 1659        self._buffer.clear()660 661    def write(self, key: str, text: str, blocks: List[Dict[str, Any]]) -> None:662        row: Dict[str, Any] = {663            "__source_key": key,664            "text": text,665            "pp_ocr_blocks": json.dumps(blocks, ensure_ascii=False),666        }667        self._buffer.append(row)668        if len(self._buffer) >= self.shard_size:669            self._flush()670 671    def finalize(self, tier: str, det_model: str, rec_model: str, args_dict: Dict[str, Any]) -> None:672        self._flush()673        meta = {674            "model": f"PP-OCRv6_{tier}",675            "det_model": det_model,676            "rec_model": rec_model,677            "tier": tier,678            "engine": "paddle_static",679            "source": self.source_id,680            "shard_size": args_dict["shard_size"],681            "last_run_at": datetime.now(timezone.utc).isoformat(),682            "processing_time": args_dict.get("processing_time"),683        }684        meta_bytes = json.dumps(meta, indent=2).encode("utf-8")685        meta_path = self._join(self.METADATA_FILE)686        self._api.batch_bucket_files(687            self.bucket_id, add=[(meta_bytes, meta_path)], token=self.hf_token688        )689        logger.info(690            f"Done: https://huggingface.co/buckets/{self.bucket_id}"691            + (f"/{self.prefix}" if self.prefix else "")692        )693 694 695# ---------------------------------------------------------------------------696# inference_info + dataset card697# ---------------------------------------------------------------------------698 699def build_inference_entry(tier: str, det_model: str, rec_model: str, args_dict: Dict[str, Any]) -> Dict[str, Any]:700    return {701        "model_id": f"PaddlePaddle/PP-OCRv6_{tier}",702        "det_model": det_model,703        "rec_model": rec_model,704        "tier": tier,705        "params": TIER_PARAMS.get(tier, "unknown"),706        "rec_accuracy_pct": TIER_REC.get(tier),707        "languages": TIER_LANGUAGES.get(tier, ""),708        "engine": "paddle_static",709        # column_name is the key ocr-bench's column discovery reads; keep710        # output_column too for backward compat with existing outputs.711        "column_name": args_dict.get("output_column", "markdown"),712        "output_column": args_dict.get("output_column", "markdown"),713        "blocks_column": "pp_ocr_blocks",714        "timestamp": datetime.now(timezone.utc).isoformat(),715    }716 717 718def create_dataset_card(719    source: str,720    tier: str,721    det_model: str,722    rec_model: str,723    num_samples: int,724    processing_time: str,725    engine: str,726    output_id: str,727    output_column: str = "markdown",728) -> str:729    tier_display = tier.upper() if tier == "tiny" else tier.capitalize()730    if is_bucket_url(source):731        source_link = f"[{source}]({source})"732    else:733        source_link = f"[{source}](https://huggingface.co/datasets/{source})"734 735    return f"""---736tags:737- ocr738- text-recognition739- paddleocr740- pp-ocrv6741- uv-script742- generated743---744 745# OCR with PP-OCRv6 {tier_display}746 747Plain-text OCR results for images from {source_link}, produced by748PaddlePaddle's [PP-OCRv6](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6)749{tier} pipeline ({TIER_PARAMS.get(tier, "unknown")}).750 751## Processing details752 753- **Source**: {source_link}754- **Model**: PP-OCRv6_{tier} ({det_model} + {rec_model})755- **Tier**: {tier} ({TIER_PARAMS.get(tier, "unknown")})756- **Recognition accuracy**: {TIER_REC.get(tier, "?"):.1f}%757- **Languages**: {TIER_LANGUAGES.get(tier, "")}758- **Engine**: {engine}759- **Samples**: {num_samples:,}760- **Processing time**: {processing_time}761- **Processing date**: {datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")}762- **License**: Apache 2.0 (models)763 764## Schema765 766Each row contains the original columns plus:767 768- `{output_column}`: Plain text extracted from the image (reading-order concatenation of769  detected text lines, newline-separated).770- `pp_ocr_blocks`: JSON list, one dict per detected text line:771  ```json772  [773    {{774      "text": "recognized text",775      "score": 0.987,776      "bbox": [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]777    }}778  ]779  ```780  `score` is the recognition confidence and `bbox` is the detection polygon781  (4-point quadrilateral in input-image pixel coordinates).782- `inference_info`: JSON list tracking every model applied to this dataset.783 784> **Note:** PP-OCRv6 is a classical detection+recognition pipeline, not a VLM.785> It outputs **plain text** rather than markdown. Per-line bounding boxes and786> confidence scores are available in `pp_ocr_blocks`.787 788## Usage789 790```python791import json792from datasets import load_dataset793 794ds = load_dataset("{output_id}", split="train")795print(ds[0]["{output_column}"])796for block in json.loads(ds[0]["pp_ocr_blocks"]):797    print(block["text"], block["score"])798```799 800## Reproduction801 802```bash803hf jobs uv run \\804    https://huggingface.co/datasets/uv-scripts/ocr/raw/main/pp-ocrv6.py \\805    {source} <output> --model-tier {tier}806```807 808Generated with [UV Scripts](https://huggingface.co/uv-scripts).809"""810 811 812# ---------------------------------------------------------------------------813# Main814# ---------------------------------------------------------------------------815 816def main(args: argparse.Namespace) -> None:817    from huggingface_hub import login818 819    start_time = datetime.now()820    hf_token = args.hf_token or os.environ.get("HF_TOKEN")821    if hf_token:822        login(token=hf_token)823 824    # ---------- tier → model names ----------825    if args.model_tier not in TIER_MODELS:826        raise ValueError(827            f"Invalid tier {args.model_tier!r}. Choose from: {list(TIER_MODELS)}"828        )829    det_model, rec_model = TIER_MODELS[args.model_tier]830    tier = args.model_tier831    logger.info(f"PP-OCRv6 {tier}: {det_model} + {rec_model}")832 833    # ---------- source ----------834    original_dataset = None835    if is_bucket_url(args.input_source):836        src_iter, total = iter_bucket_images(837            args.input_source,838            shuffle=args.shuffle,839            seed=args.seed,840            max_samples=args.max_samples,841            hf_token=hf_token,842            output_url=args.output_target,843        )844    else:845        src_iter, total, original_dataset = iter_dataset_images(846            args.input_source,847            image_column=args.image_column,848            split=args.split,849            shuffle=args.shuffle,850            seed=args.seed,851            max_samples=args.max_samples,852        )853        # Fail fast, before minutes of inference, if the output column would collide854        # with an existing input column (e.g. a 'text' ground-truth column). Writing855        # into it would either crash on push or silently overwrite the input data.856        # --overwrite opts in to replacing the existing column(s) instead of erroring.857        if original_dataset is not None:858            clash = [859                col860                for col in (args.output_column, "pp_ocr_blocks")861                if col in original_dataset.column_names862            ]863            if clash and not args.overwrite:864                logger.error(865                    f"Output column(s) {clash} already exist in the input dataset "866                    f"(columns: {original_dataset.column_names})."867                )868                logger.error(869                    "Choose a different --output-column, or pass --overwrite to replace them."870                )871                sys.exit(1)872            if clash:873                logger.warning(f"--overwrite: will replace existing column(s) {clash}")874 875    # ---------- sink ----------876    if is_bucket_url(args.output_target):877        sink: Union[BucketShardSink, DatasetRepoSink] = BucketShardSink(878            args.output_target,879            hf_token=hf_token,880            shard_size=args.shard_size,881            resume=not args.no_resume,882            source_id=args.input_source,883        )884    else:885        sink = DatasetRepoSink(886            args.output_target,887            hf_token=hf_token,888            private=args.private,889            config=args.config,890            create_pr=args.create_pr,891            source_id=args.input_source,892            original_dataset=original_dataset,893            output_column=args.output_column,894            overwrite=args.overwrite,895        )896 897    completed = sink.already_done()898 899    # ---------- model ----------900    # PaddleX gates `import cv2` at module load time on901    # `is_dep_available("opencv-contrib-python")`, which checks902    # `importlib.metadata.version(...)`. We ship `opencv-contrib-python-headless`903    # (same `cv2`, no system libGL.so.1 needed) — but that's a different904    # distribution name, so the gate fails and the OCR pipeline's `ocr` extra905    # check returns False. Patch the metadata lookup to alias the GUI cv2 distros906    # to the headless variant before importing paddleocr; this lets paddlex's own907    # `import cv2` succeed and `is_extra_available('ocr')` return True.908    import importlib.metadata as _metadata909 910    _orig_metadata_version = _metadata.version911 912    def _patched_metadata_version(dep_name):913        if dep_name in ("opencv-contrib-python", "opencv-python"):914            for headless_alias in (915                "opencv-contrib-python-headless",916                "opencv-python-headless",917            ):918                try:919                    return _orig_metadata_version(headless_alias)920                except _metadata.PackageNotFoundError:921                    continue922        return _orig_metadata_version(dep_name)923 924    _metadata.version = _patched_metadata_version925 926    # Silence the connectivity check for speed (not needed in a Job)927    os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")928 929    from paddleocr import PaddleOCR930 931    ocr = PaddleOCR(932        text_detection_model_name=det_model,933        text_recognition_model_name=rec_model,934        use_doc_orientation_classify=False,935        use_doc_unwarping=False,936        use_textline_orientation=False,937    )938 939    # ---------- loop ----------940    processed = 0941    skipped = 0942    errors = 0943    pbar = tqdm(src_iter, total=total, desc=f"PP-OCRv6 {tier}")944    for item in pbar:945        if item.key in completed:946            skipped += 1947            continue948        if item.extras.get("failed") or item.image is None:949            # Unreadable source image — write an empty result in position so the950            # output stays row-aligned with the source dataset.951            sink.write(item.key, "", [])952            errors += 1953            processed += 1954            continue955        try:956            arr = pil_to_array(item.image)957            result = ocr.predict(arr)958            if result:959                text, blocks = extract_text(result[0])960            else:961                text, blocks = "", []962        except Exception as e:963            logger.error(f"Error on {item.key}: {e}")964            text, blocks = "", []965            errors += 1966 967        sink.write(item.key, text, blocks)968        processed += 1969 970    duration = datetime.now() - start_time971    processing_time_str = f"{duration.total_seconds() / 60:.2f} min"972    logger.info(973        f"Processed {processed} (skipped {skipped}, errors {errors}) in {processing_time_str}"974    )975 976    args_dict = {977        "tier": tier,978        "det_model": det_model,979        "rec_model": rec_model,980        "engine": "paddle_static",981        "shard_size": args.shard_size,982        "processing_time": processing_time_str,983        "output_column": args.output_column,984    }985    sink.finalize(986        tier=tier,987        det_model=det_model,988        rec_model=rec_model,989        args_dict=args_dict,990    )991 992    if args.verbose:993        import importlib.metadata994 995        logger.info("--- Resolved package versions ---")996        for pkg in [997            "paddleocr",998            "paddlex",999            "paddlepaddle-gpu",1000            "huggingface-hub",1001            "datasets",1002            "pillow",1003            "numpy",1004        ]:1005            try:1006                logger.info(f"  {pkg}=={importlib.metadata.version(pkg)}")1007            except importlib.metadata.PackageNotFoundError:1008                logger.info(f"  {pkg}: not installed")1009        logger.info("--- End versions ---")1010 1011 1012# ---------------------------------------------------------------------------1013# CLI1014# ---------------------------------------------------------------------------1015 1016def build_parser() -> argparse.ArgumentParser:1017    p = argparse.ArgumentParser(1018        description="PP-OCRv6 OCR over an HF dataset or bucket of images.",1019        formatter_class=argparse.RawDescriptionHelpFormatter,1020    )1021    p.add_argument(1022        "input_source",1023        help="HF dataset id (namespace/dataset) OR hf://buckets/ns/bucket[/prefix]",1024    )1025    p.add_argument(1026        "output_target",1027        help="HF dataset id (namespace/dataset) OR hf://buckets/ns/bucket/run-name",1028    )1029    p.add_argument(1030        "--model-tier",1031        default="medium",1032        choices=list(TIER_MODELS),1033        help="PP-OCRv6 model tier: tiny (1.5M), small (7.7M), medium (34.5M). Default: medium.",1034    )1035    # Dataset-source-specific1036    p.add_argument(1037        "--image-column",1038        default="image",1039        help="Column containing images (dataset-repo source only, default: image)",1040    )1041    p.add_argument(1042        "--split",1043        default="train",1044        help="Dataset split (dataset-repo source only, default: train)",1045    )1046    p.add_argument(1047        "--max-samples", type=int, help="Limit number of samples (for testing)"1048    )1049    p.add_argument(1050        "--shuffle", action="store_true", help="Shuffle source before processing"1051    )1052    p.add_argument(1053        "--seed", type=int, default=42, help="Random seed for shuffle (default: 42)"1054    )1055    # Dataset-sink-specific1056    p.add_argument(1057        "--private", action="store_true", help="Private dataset output (dataset sink only)"1058    )1059    p.add_argument(1060        "--config",1061        help="Config/subset name when pushing to Hub (dataset sink only)",1062    )1063    p.add_argument(1064        "--create-pr",1065        action="store_true",1066        help="Create PR instead of direct push (dataset sink only)",1067    )1068    p.add_argument(1069        "--output-column",1070        default="markdown",1071        help=(1072            "Column name for the recognized text (dataset sink only, default: markdown). "1073            "Must not collide with an existing input column — many corpora already ship a "1074            "'text' ground-truth column, so 'text' would fail on push. Blocks always go to "1075            "'pp_ocr_blocks'."1076        ),1077    )1078    p.add_argument(1079        "--overwrite",1080        action="store_true",1081        help="Replace the output column(s) if they already exist in the input dataset "1082        "(default: error out to avoid clobbering an existing column).",1083    )1084    # Bucket-sink-specific1085    p.add_argument(1086        "--shard-size",1087        type=int,1088        default=256,1089        help="Rows per parquet shard for bucket sink (default: 256)",1090    )1091    p.add_argument(1092        "--no-resume",1093        action="store_true",1094        help="Disable resume scan when writing to a bucket sink",1095    )1096    # Auth + diagnostics1097    p.add_argument("--hf-token", help="Hugging Face API token (else uses HF_TOKEN env)")1098    p.add_argument(1099        "--verbose",1100        action="store_true",1101        help="Log resolved package versions at the end",1102    )1103    return p1104 1105 1106if __name__ == "__main__":1107    main(build_parser().parse_args())1108