| |
| """ |
| PP-OCRv6 Recognition ONNX Inference & Evaluation (standalone, zero Paddle dependency) |
| |
| Dependencies: |
| numpy, opencv-python, onnxruntime, pyyaml |
| |
| Usage: |
| # Single image inference |
| python ppocrv6_rec_onnx.py --rec_onnx rec.onnx --char_dict inference.yml --image crop.png |
| |
| # Batch evaluation |
| python ppocrv6_rec_onnx.py --rec_onnx rec.onnx --char_dict inference.yml \\ |
| --label_file labels.txt --dataset_root ./crops/ \\ |
| --batch_size 8 --output_json result.json --verbose |
| """ |
|
|
| import argparse |
| import math |
| import os |
| from typing import List, Optional, Tuple, Union |
|
|
| import cv2 |
| import numpy as np |
| import onnxruntime as ort |
| import yaml |
|
|
|
|
| |
| |
| |
|
|
| def _edit_distance(pred: str, target: str) -> Tuple[int, float]: |
| """Compute Levenshtein edit distance (pure Python, no extra deps). |
| |
| Returns: |
| (distance, normalized_distance) where normalized ∈ [0, 1]. |
| """ |
| m, n = len(pred), len(target) |
| if m == 0: |
| return n, 1.0 |
| if n == 0: |
| return m, 1.0 |
|
|
| dp = list(range(n + 1)) |
| for i in range(1, m + 1): |
| prev = dp[0] |
| dp[0] = i |
| for j in range(1, n + 1): |
| temp = dp[j] |
| if pred[i - 1] == target[j - 1]: |
| dp[j] = prev |
| else: |
| dp[j] = 1 + min(prev, dp[j], dp[j - 1]) |
| prev = temp |
|
|
| distance = dp[n] |
| normalized = distance / max(m, n) |
| return distance, normalized |
|
|
|
|
| def _load_char_dict(source: Union[str, List[str]]) -> List[str]: |
| """Load character dictionary from .yml, .txt, or list.""" |
| if isinstance(source, list): |
| return list(source) |
| ext = os.path.splitext(source)[1].lower() |
| if ext in (".yml", ".yaml"): |
| with open(source, "r", encoding="utf-8") as f: |
| cfg = yaml.safe_load(f) |
| dic = cfg.get("PostProcess", {}).get("character_dict", []) |
| if not dic: |
| raise ValueError(f"No PostProcess.character_dict found in {source}") |
| return dic |
| elif ext == ".txt": |
| with open(source, "r", encoding="utf-8") as f: |
| return [line.strip("\n\r") for line in f.readlines()] |
| else: |
| raise ValueError( |
| f"Unsupported char_dict source: {source}. Use .yml, .txt, or list." |
| ) |
|
|
|
|
| def _resize_norm_img( |
| img: np.ndarray, |
| image_shape: Tuple[int, int, int] = (3, 48, 320), |
| max_wh_ratio: Optional[float] = None, |
| ) -> np.ndarray: |
| """Resize and normalize a cropped text image for recognition. |
| |
| Args: |
| img: BGR crop image (H, W, 3). |
| image_shape: (C, H, W) target shape. |
| max_wh_ratio: precomputed max width/height ratio for batch. If None, derived from img. |
| """ |
| imgC, imgH, imgW = image_shape |
| if max_wh_ratio is None: |
| max_wh_ratio = imgW * 1.0 / imgH |
| h, w = img.shape[:2] |
| ratio = w * 1.0 / h |
| max_wh_ratio = max(max_wh_ratio, ratio) |
|
|
| max_wh_ratio = min(max_wh_ratio, imgW / imgH) |
| target_w = int(imgH * max_wh_ratio) |
| h, w = img.shape[:2] |
| ratio = w * 1.0 / h |
| if math.ceil(imgH * ratio) > target_w: |
| resized_w = target_w |
| else: |
| resized_w = int(math.ceil(imgH * ratio)) |
|
|
| resized = cv2.resize(img, (resized_w, imgH)) |
| resized = resized.astype("float32") |
| resized = resized.transpose((2, 0, 1)) / 255.0 |
| resized -= 0.5 |
| resized /= 0.5 |
| padded = np.zeros((imgC, imgH, target_w), dtype=np.float32) |
| padded[:, :, 0:resized_w] = resized |
| return padded |
|
|
|
|
| |
| |
| |
|
|
| class _CTCLabelDecode: |
| """CTC greedy decoder for recognition output.""" |
|
|
| def __init__(self, character_list: List[str], use_space_char: bool = True): |
| self.character_str = list(character_list) |
| if use_space_char: |
| self.character_str.append(" ") |
| dict_character = ["blank"] + self.character_str |
| self.character = dict_character |
| self.dict = {char: i for i, char in enumerate(dict_character)} |
|
|
| def decode( |
| self, |
| text_index: np.ndarray, |
| text_prob: Optional[np.ndarray] = None, |
| is_remove_duplicate: bool = True, |
| ) -> List[Tuple[str, float]]: |
| result_list = [] |
| batch_size = len(text_index) |
| for batch_idx in range(batch_size): |
| selection = np.ones(len(text_index[batch_idx]), dtype=bool) |
| if is_remove_duplicate: |
| selection[1:] = text_index[batch_idx][1:] != text_index[batch_idx][:-1] |
| selection &= text_index[batch_idx] != 0 |
| char_list = [ |
| self.character[int(tid)] |
| for tid in text_index[batch_idx][selection] |
| ] |
| if text_prob is not None: |
| conf_list = text_prob[batch_idx][selection] |
| else: |
| conf_list = np.ones(len(selection), dtype=np.float32) |
| if len(conf_list) == 0: |
| conf_list = np.array([0.0], dtype=np.float32) |
| text = "".join(char_list) |
| result_list.append((text, float(np.mean(conf_list)))) |
| return result_list |
|
|
| def __call__(self, preds: np.ndarray) -> List[Tuple[str, float]]: |
| preds_idx = preds.argmax(axis=2) |
| preds_prob = preds.max(axis=2) |
| return self.decode(preds_idx, preds_prob, is_remove_duplicate=True) |
|
|
|
|
| |
| |
| |
|
|
| class PPOCRv6RecOnnx: |
|
|
| def __init__( |
| self, |
| rec_onnx: str, |
| char_dict: Union[str, List[str]], |
| rec_image_shape: Tuple[int, int, int] = (3, 48, 320), |
| rec_batch_num: int = 6, |
| use_gpu: bool = False, |
| onnx_providers: Optional[List[str]] = None, |
| ): |
| self.rec_image_shape = rec_image_shape |
| self.rec_batch_num = rec_batch_num |
|
|
| |
| if onnx_providers is None: |
| onnx_providers = ( |
| ["CUDAExecutionProvider", "CPUExecutionProvider"] |
| if use_gpu |
| else ["CPUExecutionProvider"] |
| ) |
|
|
| sess_options = ort.SessionOptions() |
| sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL |
| self.session = ort.InferenceSession( |
| rec_onnx, sess_options=sess_options, providers=onnx_providers |
| ) |
| self.input_name = self.session.get_inputs()[0].name |
|
|
| |
| char_list = _load_char_dict(char_dict) |
| self._decoder = _CTCLabelDecode(char_list, use_space_char=True) |
|
|
| |
|
|
| def _preprocess( |
| self, img_list: List[np.ndarray] |
| ) -> List[np.ndarray]: |
| """Convert a list of crops into batch tensors (grouped by self.rec_batch_num).""" |
| num = len(img_list) |
| width_list = [im.shape[1] / float(im.shape[0]) for im in img_list] |
| indices = np.argsort(np.array(width_list)) |
|
|
| batches = [] |
| index_maps = [] |
|
|
| for beg in range(0, num, self.rec_batch_num): |
| end = min(num, beg + self.rec_batch_num) |
| imgC, imgH, imgW = self.rec_image_shape |
| max_wh_ratio = imgW / imgH |
| for ino in range(beg, end): |
| orig_idx = indices[ino] |
| h, w = img_list[orig_idx].shape[:2] |
| max_wh_ratio = max(max_wh_ratio, w / h) |
|
|
| norm_list = [] |
| idx_list = [] |
| for ino in range(beg, end): |
| orig_idx = indices[ino] |
| norm = _resize_norm_img( |
| img_list[orig_idx], |
| self.rec_image_shape, |
| max_wh_ratio=max_wh_ratio, |
| ) |
| norm_list.append(np.expand_dims(norm, axis=0)) |
| idx_list.append(orig_idx) |
|
|
| if norm_list: |
| batches.append(np.concatenate(norm_list, axis=0).astype(np.float32)) |
| index_maps.append(idx_list) |
| return batches, index_maps |
|
|
| def _postprocess( |
| self, |
| batch_outputs: List[np.ndarray], |
| index_maps: List[List[int]], |
| total_num: int, |
| ) -> List[Tuple[str, float]]: |
| results = [("", 0.0)] * total_num |
| |
| for batch_preds, idx_list in zip(batch_outputs, index_maps): |
| texts = self._decoder(batch_preds) |
| for i, orig_idx in enumerate(idx_list): |
| results[orig_idx] = texts[i] |
| return results |
|
|
| |
|
|
| def __call__( |
| self, img: Union[np.ndarray, List[np.ndarray]] |
| ) -> List[Tuple[str, float]]: |
| if isinstance(img, np.ndarray): |
| img = [img] |
| if not img: |
| return [] |
| batches, index_maps = self._preprocess(img) |
| outputs = [] |
| for batch in batches: |
| out = self.session.run(None, {self.input_name: batch}) |
| outputs.append(out[0]) |
| return self._postprocess(outputs, index_maps, len(img)) |
|
|
| def predict_image(self, path: str) -> Tuple[str, float]: |
| im = cv2.imread(path) |
| if im is None: |
| raise FileNotFoundError(f"Cannot read: {path}") |
| return self.__call__(im)[0] |
|
|
|
|
| |
| |
| |
|
|
| def evaluate( |
| ocr: PPOCRv6RecOnnx, |
| label_file: str, |
| dataset_root: str = "", |
| ignore_space: bool = True, |
| verbose: bool = False, |
| ) -> dict: |
| """Evaluate recognition accuracy against a ground-truth label file. |
| |
| Label file format (one per line, tab-separated):: |
| |
| rel/path/to/crop.png<TAB>ground truth text |
| |
| The full image path is ``os.path.join(dataset_root, rel_path)``. |
| |
| Args: |
| ocr: PPOCRv6RecOnnx instance. |
| label_file: path to tab-separated label file. |
| dataset_root: prefix directory for image paths in label file. |
| ignore_space: strip spaces before comparing. |
| verbose: print per-sample prediction details. |
| |
| Returns: |
| dict with keys: ``acc``, ``norm_edit_dis``, ``total``, ``correct``, |
| ``per_sample`` (list of per-sample details). |
| """ |
| images = [] |
| targets = [] |
|
|
| with open(label_file, "r", encoding="utf-8") as f: |
| for line in f: |
| line = line.strip() |
| if not line: |
| continue |
| parts = line.split("\t") |
| if len(parts) < 2: |
| continue |
| img_path = os.path.join(dataset_root, parts[0].strip()) |
| images.append(img_path) |
| targets.append(parts[1].strip()) |
|
|
| total = len(images) |
| if total == 0: |
| print("[WARN] No samples found in label file.") |
| return {"acc": 0.0, "norm_edit_dis": 0.0, "total": 0, "correct": 0, "per_sample": []} |
|
|
| |
| imgs = [] |
| for p in images: |
| im = cv2.imread(p) |
| if im is None: |
| print(f"[WARN] Cannot read {p}, skipping.") |
| imgs.append(np.zeros((32, 100, 3), dtype=np.uint8)) |
| else: |
| imgs.append(im) |
|
|
| |
| rec_results = ocr(imgs) |
|
|
| correct = 0 |
| total_edit_dis = 0.0 |
| per_sample = [] |
|
|
| for i, ((pred, conf), gt) in enumerate(zip(rec_results, targets)): |
| pred_clean = pred.replace(" ", "") if ignore_space else pred |
| gt_clean = gt.replace(" ", "") if ignore_space else gt |
| dist, norm_dist = _edit_distance(pred_clean, gt_clean) |
| total_edit_dis += norm_dist |
| is_correct = pred_clean == gt_clean |
| if is_correct: |
| correct += 1 |
|
|
| sample = { |
| "image": images[i], |
| "pred": pred, |
| "gt": gt, |
| "confidence": round(conf, 4), |
| "correct": is_correct, |
| "edit_distance": int(dist), |
| "norm_edit_dis": round(norm_dist, 4), |
| } |
| per_sample.append(sample) |
|
|
| if verbose: |
| status = " OK" if is_correct else "MIS" |
| print( |
| f"[{status}] pred={pred!r:<30} gt={gt!r:<30} " |
| f"conf={conf:.4f} edit={int(dist)} ndis={norm_dist:.4f}" |
| ) |
|
|
| acc = correct / total |
| norm_edit_dis = 1.0 - total_edit_dis / total |
|
|
| return { |
| "acc": round(acc, 6), |
| "norm_edit_dis": round(norm_edit_dis, 6), |
| "total": total, |
| "correct": correct, |
| "per_sample": per_sample, |
| } |
|
|
|
|
| |
| |
| |
|
|
| def main(): |
| parser = argparse.ArgumentParser( |
| description="PP-OCRv6 Recognition ONNX – inference & evaluation" |
| ) |
| |
| parser.add_argument( |
| "--rec_onnx", type=str, |
| default="onnx/rec_inference_static_sim.onnx", |
| help="Path to recognition ONNX model", |
| ) |
| parser.add_argument( |
| "--char_dict", type=str, |
| default="onnx/rec_inference.yml", |
| help="Character dictionary: .yml (PostProcess.character_dict), .txt, or comma-list", |
| ) |
| parser.add_argument("--batch_size", type=int, default=1, help="Recognition batch size") |
| parser.add_argument("--use_gpu", action="store_true", help="Enable GPU inference") |
| parser.add_argument("--rec_image_shape", type=str, default="3,48,320", |
| help="Recognition input shape C,H,W (comma separated)") |
|
|
| |
| parser.add_argument("--image", type=str, default=None, help="Single crop image path") |
|
|
| |
| parser.add_argument("--label_file", type=str, |
| default='dataset/ocr_rec_dataset_examples/val.txt', |
| help="Label file (image_path<TAB>gt_text per line)") |
| parser.add_argument("--dataset_root", type=str, |
| default="dataset/ocr_rec_dataset_examples", |
| help="Prefix directory for image paths in label file") |
|
|
| |
| parser.add_argument("--ignore_space", action="store_true", default=True, |
| help="Ignore spaces when comparing (default: True)") |
| parser.add_argument("--verbose", action="store_true", help="Print per-sample results") |
| parser.add_argument("--output_json", type=str, default=None, |
| help="Save results to JSON file") |
|
|
| args = parser.parse_args() |
|
|
| |
| char_dict_src = args.char_dict |
| if char_dict_src.startswith("[") or ("," in char_dict_src and not os.path.exists(char_dict_src)): |
| char_dict = [c.strip() for c in char_dict_src.split(",") if c.strip()] |
| else: |
| char_dict = char_dict_src |
|
|
| image_shape = tuple(int(v) for v in args.rec_image_shape.split(",")) |
| if len(image_shape) != 3: |
| raise ValueError("--rec_image_shape requires 3 comma-separated integers") |
|
|
| ocr = PPOCRv6RecOnnx( |
| rec_onnx=args.rec_onnx, |
| char_dict=char_dict, |
| rec_image_shape=image_shape, |
| rec_batch_num=args.batch_size, |
| use_gpu=args.use_gpu, |
| ) |
|
|
| |
| if args.image: |
| text, conf = ocr.predict_image(args.image) |
| print(f"text={text!r} confidence={conf:.4f}") |
| if args.output_json: |
| import json |
| with open(args.output_json, "w", encoding="utf-8") as f: |
| json.dump({"text": text, "confidence": conf}, f, ensure_ascii=False, indent=2) |
| return |
|
|
| |
| if args.label_file: |
| metrics = evaluate( |
| ocr, |
| args.label_file, |
| dataset_root=args.dataset_root, |
| ignore_space=args.ignore_space, |
| verbose=args.verbose, |
| ) |
|
|
| print() |
| print("=" * 60) |
| print("Evaluation Results") |
| print("=" * 60) |
| print(f" Total samples: {metrics['total']}") |
| print(f" Correct (exact match): {metrics['correct']}") |
| print(f" Accuracy: {metrics['acc']:.4f} ({metrics['acc']*100:.2f}%)") |
| print(f" Norm Edit Distance: {metrics['norm_edit_dis']:.4f}") |
| print("=" * 60) |
|
|
| if args.output_json: |
| import json |
| out = {k: v for k, v in metrics.items() if k != "per_sample"} |
| out["per_sample"] = metrics["per_sample"] |
| with open(args.output_json, "w", encoding="utf-8") as f: |
| json.dump(out, f, ensure_ascii=False, indent=2) |
| print(f"\nResults saved to: {args.output_json}") |
| return |
|
|
| parser.error("Either --image or --label_file must be provided.") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|