CoolFace
Datasetpublic

opencompass/TextEdit

TextEdit: A High-Quality, Multi-Scenario Text Editing Benchmark for Generation Models Danni Yang, Sitao Chen, Changyao Tian If you find our work helpful, please give us a โญ or cite our paper. See the InternVL-U technical report appendix for more details. ๐ŸŽ‰ News [2026/03/06] TextEdit benchmark released. [2026/03/06] Evaluation code and initial baselines released. [2026/03/06] Leaderboard updated with latest models. ๐Ÿ“–โ€ฆ See the full description on the dataset page: https://huggingface.co/datasets/opencompass/TextEdit.

sourceHugging Facemitupdated 6mo agoView on Hugging Face
9likes5.4kdownloads
classic_metrics_eval.py623 linesDownload Raw Back to eval_pipeline
1#!/usr/bin/env python32import os3import sys4import time5 6# ==================== 1. Strict environment variable lock (avoid CPU oversubscription deadlocks) ====================7# Must be set before importing torch/numpy8os.environ["OMP_NUM_THREADS"] = "1"9os.environ["MKL_NUM_THREADS"] = "1"10os.environ["OPENBLAS_NUM_THREADS"] = "1"11os.environ["VECLIB_MAXIMUM_THREADS"] = "1"12os.environ["NUMEXPR_NUM_THREADS"] = "1"13os.environ["KMP_DUPLICATE_LIB_OK"] = "TRUE"14# ==============================================================================15 16from torch.distributed.elastic.multiprocessing.errors import record17import argparse18import json19import re20from pathlib import Path21from typing import Dict, List, Tuple, Optional22import logging23import numpy as np24from PIL import Image25from tqdm import tqdm26import torch27import collections28import shutil29import tempfile30import copy31import unicodedata32import torch.multiprocessing as mp33from classic_ocr_tools import norm, poly_to_bbox, bbox_iou, point_in_bbox, bbox_center, union_bboxes, group_into_lines34 35# ==================== tools function ====================36 37def convert_numpy_types(obj):38    """Recursively convert numpy types to Python native types for JSON serialization"""39    if isinstance(obj, np.integer):40        return int(obj)41    elif isinstance(obj, np.floating):42        return float(obj)43    elif isinstance(obj, np.ndarray):44        return obj.tolist()45    elif isinstance(obj, dict):46        return {key: convert_numpy_types(value) for key, value in obj.items()}47    elif isinstance(obj, list):48        return [convert_numpy_types(item) for item in obj]49    return obj50 51 52class UnifiedMetricsEvaluator:53    def __init__(self, device: str = "auto", cache_dir: str = None):54        """Initialize evaluator"""55        self.device = "cuda" if torch.cuda.is_available() and device == "auto" else device56        self.models = {}57        self.cache_dir = cache_dir58        self._load_models()59    60    def _load_models(self):61        """Load all required models (Lazy Import Mode)"""62        print(f"[{self.device}] Loading models...")63        64        # 1. PaddleOCR65        try:66            from paddleocr import PaddleOCR67            # use_gpu=False to avoid multi-process GPU memory contention and hangs68            self.models['ocr'] = PaddleOCR(use_angle_cls=True, lang="en", show_log=False, use_gpu=False)69            self.paddleocr_available = True70            print(f"[{self.device}] PaddleOCR initialized")71        except ImportError:72            self.paddleocr_available = False73            logging.warning("PaddleOCR not available")74        75        # 2. CLIP76        try:77            import clip78            clip_model, clip_preprocess = clip.load("ViT-L/14", device=self.device, jit=False, download_root=self.cache_dir)79            clip_model.eval()80            self.models['clip_official'] = clip_model81            self.models['clip_official_preprocess'] = clip_preprocess82            self.clip_available = True83            print(f"[{self.device}] CLIP loaded")84        except Exception as e:85            logging.error(f"Failed to load CLIP: {e}")86            self.clip_available = False87        88        # 3. OpenCLIP & Aesthetic89        try:90            import open_clip91            model, _, preprocess = open_clip.create_model_and_transforms('ViT-L-14', pretrained="openai", cache_dir=self.cache_dir)92            model.to(self.device)93            model.eval()94            self.models['openclip'] = model95            self.models['openclip_preprocess'] = preprocess96            97            aesthetic_model = self._load_aesthetic_model()98            if aesthetic_model:99                self.models['aesthetic'] = aesthetic_model100                print(f"[{self.device}] Aesthetic model loaded")101            self.openclip_available = True102        except Exception as e:103            self.openclip_available = False104 105    def _load_aesthetic_model(self):106        try:107            import torch.nn as nn108            model_path = os.path.join(self.cache_dir, "sa_0_4_vit_l_14_linear.pth")109            if os.path.exists(model_path):110                m = nn.Linear(768, 1)111                s = torch.load(model_path, map_location=self.device)112                m.load_state_dict(s)113                m.eval().to(self.device)114                return m115        except Exception as e:116            logging.warning(f"Could not load aesthetic model: {e}")117        return None118 119    def get_ld(self, ls1: str, ls2: str) -> float:120        """Calculate normalized version of Levenshtein distance"""121        if not self.paddleocr_available:122            return 0.0123        import Levenshtein124        edit_dist = Levenshtein.distance(ls1, ls2)125        return 1 - edit_dist / (max(len(ls1), len(ls2)) + 1e-5)126 127    def compute_roi_ned(self, raw_items, gen_items, source_text, target_text) -> float:128        """Compute ROI-based normalized edit distance (ROI-aware NED)."""129        import Levenshtein130        source_norm = norm(source_text)131 132        best_sim = 0.0133        source_bbox = None134 135        for item in raw_items:136            sim = self.get_ld(source_norm, norm(item["text"]))137            if sim > 0.6 and sim > best_sim:138                best_sim = sim139                source_bbox = item["bbox"]140 141        if source_bbox is None:142            return 0.0143 144        pred_texts_in_roi = [145            item for item in gen_items146            if bbox_iou(item["bbox"], source_bbox) > 0.3147        ]148        if not pred_texts_in_roi:149            return 0.0150 151        pred_texts_in_roi.sort(key=lambda x: x["bbox"][0])152 153        pred_norm = norm("".join([it["text"] for it in pred_texts_in_roi]))154        target_norm = norm(target_text)155 156        ned_score = self.get_ld(target_norm, pred_norm)157 158        if self.get_ld(source_norm, pred_norm) > 0.9 and self.get_ld(source_norm, target_norm) < 0.5:159            ned_score *= 0.2160 161        return ned_score162 163    def compute_ocr_metrics_textedit(164        self,165        raw_img_path: str,166        gen_img_path: str,167        source_text: str,168        target_text: str,169        target_weight: float = 0.5,170        iou_threshold: float = 0.5171    ) -> Dict:172        """OCR evaluation for text editing."""173        default_res = {174            "target_accuracy": 0.0,175            "precision": 0.0,176            "recall": 0.0,177            "f1": 0.0,178            "roi_ned": 0.0,179        }180 181        if not self.paddleocr_available or "ocr" not in self.models:182            return default_res183 184        try:185            source_norm = norm(source_text)186            target_norm = norm(target_text)187 188            raw_img = Image.open(raw_img_path)189            gen_img_original = Image.open(gen_img_path)190 191            # Uniformly resize generated image to original size for fair comparison192            if gen_img_original.mode != "RGB":193                gen_img_original = gen_img_original.convert("RGB")194 195            if gen_img_original.size != raw_img.size:196                gen_img_resized = gen_img_original.resize(raw_img.size, Image.LANCZOS)197            else:198                gen_img_resized = gen_img_original199 200            # OCR: raw201            raw_ocr = self.models["ocr"].ocr(raw_img_path, cls=True)202            raw_lines_src = raw_ocr[0] if (raw_ocr and raw_ocr[0]) else []203            raw_items = [204                {"text": line[1][0], "bbox": poly_to_bbox(line[0]),205                 'poly': line[0], 'score': line[1][1]}206                for line in raw_lines_src207            ]208            if not raw_items:209                return default_res210 211            raw_lines = group_into_lines(raw_items)212 213            best_line_idx = -1214            best_sim = 0.0215            for i, line_items in enumerate(raw_lines):216                line_text = "".join(item["text"] for item in line_items)217                line_norm = norm(line_text)218                219                if source_norm in line_norm:220                    sim = 1.0221                else:222                    sim = self.get_ld(source_norm, line_norm)223                224                if sim > best_sim:225                    best_sim = sim226                    best_line_idx = i227 228            if best_line_idx == -1 or best_sim < 0.5:229                return default_res230 231            # OCR: gen (numpy array)232            gen_ocr = self.models["ocr"].ocr(np.array(gen_img_resized), cls=True)233            gen_lines_src = gen_ocr[0] if (gen_ocr and gen_ocr[0]) else []234            gen_items = [235                {"text": line[1][0], "bbox": poly_to_bbox(line[0]),236                 'poly': line[0], 'score': line[1][1]}237                for line in gen_lines_src238            ]239            if not gen_items:240                return default_res241 242            # Region split243            raw_edit_line = raw_lines[best_line_idx]244            raw_edit_region = union_bboxes([item['bbox'] for item in raw_edit_line])245            gen_edit_region = raw_edit_region246            247            gen_region_items = []248            gen_bg_items = []249            250            for item in gen_items:251                iou = bbox_iou(item['bbox'], gen_edit_region)252                center = bbox_center(item['bbox'])253                if iou > iou_threshold or point_in_bbox(center, gen_edit_region):254                    gen_region_items.append(item)255                else:256                    gen_bg_items.append(item)257 258            # Target similarity: find target text in the edited region259            best_target_sim = 0.0260            if gen_region_items:261                for it in gen_region_items:262                    best_target_sim = max(best_target_sim, self.get_ld(target_norm, norm(it["text"])))263 264                for line in group_into_lines(gen_region_items):265                    text_merged = "".join(it["text"] for it in line)266                    best_target_sim = max(best_target_sim, self.get_ld(target_norm, norm(text_merged)))267 268            # Penalize if source still exists while target does not269            gen_all_text_norm = norm("".join(item["text"] for item in gen_items))270            if source_norm in gen_all_text_norm and target_norm not in gen_all_text_norm:271                best_target_sim *= 0.2272 273            target_accuracy = best_target_sim274 275            # Background line alignment similarity276            raw_bg_items = [277                item278                for i, line in enumerate(raw_lines)279                if i != best_line_idx280                for item in line281            ]282            raw_bg_lines = group_into_lines(raw_bg_items)283            gen_bg_lines = group_into_lines(gen_bg_items)284 285            used_gen_indices = set()286            bg_sims = []287            288            for raw_line in raw_bg_lines:289                raw_l_norm = norm(''.join(it['text'] for it in raw_line))290                current_best_sim = 0.0291                best_gen_idx = -1292                293                for j, gen_line in enumerate(gen_bg_lines):294                    if j in used_gen_indices:295                        continue296                    gen_l_norm = norm(''.join(it['text'] for it in gen_line))297                    sim = self.get_ld(raw_l_norm, gen_l_norm)298                    if sim > current_best_sim:299                        current_best_sim = sim300                        best_gen_idx = j301                302                if best_gen_idx != -1:303                    used_gen_indices.add(best_gen_idx)304                305                bg_sims.append(current_best_sim)306 307            # Compute Precision / Recall / F1308            w_target = target_weight309            num_gt_bg = len(raw_bg_lines)310            num_pred_bg = len(gen_bg_lines)311            w_bg_unit = (1.0 - w_target) / num_gt_bg if num_gt_bg > 0 else 0.0312            313            tp_score = (target_accuracy * w_target) + (sum(bg_sims) * w_bg_unit)314            gt_total_weight = w_target + (num_gt_bg * w_bg_unit)315            pred_total_weight = w_target + (num_pred_bg * w_bg_unit)316            317            recall = tp_score / gt_total_weight if gt_total_weight > 0 else 0.0318            precision = tp_score / pred_total_weight if pred_total_weight > 0 else 0.0319            f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0.0320 321            roi_ned_score = self.compute_roi_ned(322                raw_items,323                gen_items,324                source_text,325                target_text,326            )327 328            return {329                "target_accuracy": target_accuracy,330                'precision': precision,331                'recall': recall,332                "f1": f1,333                "roi_ned": roi_ned_score,334            }335 336        except Exception as e:337            logging.error(f"Compute OCR metrics failed: {e}")338            return default_res339 340    def compute_clip_score_batch(self, image_paths: List[str], texts: List[str]) -> List[float]:341        """Batch compute CLIPScore"""342        if not self.clip_available or 'clip_official' not in self.models:343            return [0.0] * len(image_paths)344        345        try:346            import clip347            from sklearn.preprocessing import normalize348            import sklearn.preprocessing349            from packaging import version350            import warnings351            352            processed_texts = []353            for text in texts:354                processed_texts.append(text)355            356            images = []357            for image_path in image_paths:358                try:359                    image = Image.open(image_path)360                    image_input = self.models['clip_official_preprocess'](image).unsqueeze(0)361                    images.append(image_input)362                except Exception as e:363                    logging.warning(f"Failed to load image {image_path}: {e}")364                    images.append(torch.zeros(1, 3, 224, 224))365            366            if not images:367                return []368 369            images_batch = torch.cat(images, dim=0).to(self.device)370            texts_batch = clip.tokenize(processed_texts, truncate=True).to(self.device)371            372            with torch.no_grad():373                image_features = self.models['clip_official'].encode_image(images_batch)374                text_features = self.models['clip_official'].encode_text(texts_batch)375                376                image_features_np = image_features.cpu().numpy()377                text_features_np = text_features.cpu().numpy()378                379                if version.parse(np.__version__) < version.parse('1.21'):380                    image_features_np = sklearn.preprocessing.normalize(image_features_np, axis=1)381                    text_features_np = sklearn.preprocessing.normalize(text_features_np, axis=1)382                else:383                    warnings.warn('Using compat normalization')384                    image_features_np = image_features_np / np.sqrt(np.sum(image_features_np**2, axis=1, keepdims=True))385                    text_features_np = text_features_np / np.sqrt(np.sum(text_features_np**2, axis=1, keepdims=True))386                387                similarities = np.sum(image_features_np * text_features_np, axis=1)388                clip_scores = 2.5 * np.clip(similarities, 0, None)389                390                return clip_scores.tolist()391        392        except Exception as e:393            logging.error(f"Batch CLIP score computation failed: {e}")394            return [0.0] * len(image_paths)395 396    def compute_aesthetic_score(self, image_path: str) -> float:397        """Calculate aesthetic score"""398        if not self.openclip_available or 'aesthetic' not in self.models or 'openclip' not in self.models:399            return 0.0400        401        try:402            image = Image.open(image_path)403            image_input = self.models['openclip_preprocess'](image).unsqueeze(0).to(self.device)404            405            with torch.no_grad():406                image_features = self.models['openclip'].encode_image(image_input)407                image_features /= image_features.norm(dim=-1, keepdim=True)408                prediction = self.models['aesthetic'](image_features)409                410            return prediction.cpu().numpy().item()411        412        except Exception as e:413            logging.error(f"Aesthetic score computation failed for {image_path}: {e}")414            return 0.0415 416    def evaluate_group(self, model_name: str, group_name: str, jsonl_files: List[str], 417                       benchmark_dir: str, gt_root_dir: str, model_output_root: str) -> Dict:418        """Evaluate a group of files for a specific model"""419 420        # Aggregated Metrics421        agg_metrics = {422            'target_accuracies': [],423            'precisions': [],424            'recalls': [],425            'f1s': [],426            'roi_neds': [],427            'clip_scores': [],428            'aesthetic_scores': [],429            'image_count': 0430        }431 432        # Store per-sample detailed results433        detailed_results = []434        print(f"[{self.device}] Processing {model_name} | Group: {group_name} ({len(jsonl_files)} files)")435 436        for jsonl_file in jsonl_files:437            full_path = os.path.join(benchmark_dir, jsonl_file)438            if not os.path.exists(full_path):439                logging.warning(f"File not found: {full_path}")440                continue441 442            with open(full_path, 'r', encoding='utf-8') as f:443                data_entries = [json.loads(line) for line in f if line.strip()]444            445            # 1. Prepare Batches for this file446            batch_paths, batch_prompts, valid_indices = [], [], []447 448            for idx, entry in enumerate(data_entries):449                # Resolve Model Generated Image Path450                raw_img_rel = entry.get('original_image', '')451                # class_id = parts[0]   # e.g., '1.1.1'452                # filename = parts[-1]  # e.g., '1901685000029.0.jpg'453                gen_img_path = os.path.join(model_output_root, model_name, raw_img_rel.split('/')[0], raw_img_rel.split('/')[-1])454 455                if gen_img_path and os.path.exists(gen_img_path):456                    # For CLIP/Aesthetic/OCR457                    batch_paths.append(gen_img_path)458                    batch_prompts.append(entry.get('gt_caption', ''))459                    valid_indices.append(idx)460            461            # 2. Compute CLIP Batch462            if batch_paths:463                clip_scores = self.compute_clip_score_batch(batch_paths, batch_prompts)464                clip_map = {idx: score for idx, score in zip(valid_indices, clip_scores)}465            else:466                clip_map = {}467 468            # 3. Compute Per-Image Metrics469            for idx, entry in enumerate(tqdm(data_entries, desc=f"  Evaluating {jsonl_file}", leave=False)):470                if idx not in clip_map:471                    continue472                473                raw_img_rel  = entry.get('original_image', '')474                raw_img_path = os.path.join(gt_root_dir, raw_img_rel)475                gt_img_rel   = entry.get('gt_image', '')476                gt_img_path  = os.path.join(gt_root_dir, gt_img_rel)477                gen_img_path = os.path.join(model_output_root, model_name, raw_img_rel.split('/')[0], raw_img_rel.split('/')[-1])478 479                # Read source_text and target_text directly from entry480                source_text = entry.get('source_text', '')481                target_text = entry.get('target_text', '')482 483                # CLIPScore484                clip_score = clip_map[idx]485                agg_metrics['clip_scores'].append(clip_score)486                487                # Aesthetic Score488                aesthetic_score = self.compute_aesthetic_score(gen_img_path)489                agg_metrics['aesthetic_scores'].append(aesthetic_score)490 491                # OCR metrics (pass source_text and target_text)492                ocr_res = self.compute_ocr_metrics_textedit(493                    raw_img_path=raw_img_path,494                    gen_img_path=gen_img_path,495                    source_text=source_text,496                    target_text=target_text,497                    target_weight=0.5,498                    iou_threshold=0.5499                )500 501                # Aggregate metrics502                agg_metrics['image_count'] += 1503                agg_metrics['target_accuracies'].append(ocr_res['target_accuracy'])504                agg_metrics['precisions'].append(ocr_res['precision'])505                agg_metrics['recalls'].append(ocr_res['recall'])506                agg_metrics['f1s'].append(ocr_res['f1'])507                agg_metrics['roi_neds'].append(ocr_res['roi_ned'])508                509                # Save per-sample detailed information510                detailed_results.append({511                    'id': entry.get('id'),512                    'prompt': entry.get('prompt', ''),513                    'path': {514                        'original_image': raw_img_path,515                        'edited_image': gen_img_path,516                        'gt_image': gt_img_path517                    },518                    'score': {519                        'ocr_accuracy': float(ocr_res['target_accuracy']),520                        'ocr_precision': float(ocr_res['precision']),521                        'ocr_recall': float(ocr_res['recall']),522                        'ocr_f1': float(ocr_res['f1']),523                        'clip_score': float(clip_score),524                        'ned_score': float(ocr_res['roi_ned']),525                        'aesthetic_score': float(aesthetic_score)526                    }527                })528 529        def sm(l): 530            return np.mean(l) if l else 0.0531        final_group_results = {532            'Group': group_name,533            'OCR Accuracy': sm(agg_metrics['target_accuracies']),534            'OCR Precision': sm(agg_metrics['precisions']),535            'OCR Recall': sm(agg_metrics['recalls']),536            'OCR F1': sm(agg_metrics['f1s']),537            'CLIPScore': sm(agg_metrics['clip_scores']),538            'NED': sm(agg_metrics['roi_neds']),539            'Aesthetic Score': sm(agg_metrics['aesthetic_scores']),540            'Total Images': agg_metrics['image_count'],541            'detailed_results': detailed_results542        }543        544        return final_group_results545 546def worker_process(model_name, gpu_id, args):547    try:548        device = f"cuda:{gpu_id}"549        print(f"\n>>> Worker started: Model={model_name} on Device={device}")550        os.environ["TORCH_HOME"] = args.cache_dir 551        evaluator = UnifiedMetricsEvaluator(device=device, cache_dir=args.cache_dir)552        553        groups = {554            "Virtual": [555                "1.1.1.jsonl", "1.1.2.jsonl", "1.1.3.jsonl",556                "1.2.1.jsonl", "1.2.2.jsonl",557                "1.3.1.jsonl", "1.3.2.jsonl",558                "1.4.1.jsonl", "1.4.2.jsonl", "1.4.3.jsonl", "1.4.4.jsonl"559            ],560            "Real": [561                "2.1.jsonl", "2.2.jsonl", "2.3.jsonl", "2.4.jsonl", 562                "2.5.jsonl", "2.6.jsonl", "2.7.jsonl"563            ]564        }565 566        # Evaluate567        res_v = evaluator.evaluate_group(model_name, "Virtual", groups["Virtual"], args.benchmark_dir, args.gt_root_dir, args.model_output_root)568        res_r = evaluator.evaluate_group(model_name, "Real", groups["Real"], args.benchmark_dir, args.gt_root_dir, args.model_output_root)569        570        metrics_order = [571                "OCR Accuracy", "OCR Precision", "OCR Recall", "OCR F1",572                "NED", "CLIPScore", "Aesthetic Score"573            ]574        final_res = {575            'summary_by_model': {model_name: {'Virtual': res_v, 'Real': res_r}}, 576            'metrics_list': metrics_order577            }578        579        # Print result table580        print(f"\n--- Results for {model_name} ---")581        print(f"{'Metric':<20} | {'Real':<15} | {'Virtual':<15}")582        print("-" * 60)583        for m in final_res['metrics_list']:584            print(f"{m:<20} | {res_r.get(m, 0.0):.4f}          | {res_v.get(m, 0.0):.4f}")585        print("-" * 60)586 587        out_path = os.path.join(args.output_dir, f"{model_name}.json")588        with open(out_path, 'w') as f:589            json.dump(convert_numpy_types(final_res), f, indent=4, ensure_ascii=False)590 591        print(f">>> Worker finished: {model_name}. Saved to {out_path}")592    except Exception as e:593        print(f"!!! Error in worker: {e}"); import traceback; traceback.print_exc()594 595@record596def main():597    parser = argparse.ArgumentParser(description='Unified text-to-image generation evaluation tool')598    parser.add_argument('--benchmark_dir', required=True, help='Directory containing the .jsonl files')599    parser.add_argument('--gt_root_dir', required=True, help='Root directory for Ground Truth images')600    parser.add_argument('--model_output_root', required=True, help='Root directory where model outputs are stored')601    parser.add_argument('--output_dir', required=True, help='result output file path')602    parser.add_argument('--models', default='bagel', help='Comma separated list of model names')603    parser.add_argument('--cache_dir', required=True, help='HuggingFace model cache directory path')604    args = parser.parse_args()605    606    if args.cache_dir:607        os.environ['TORCH_HOME'] = args.cache_dir608    mp.set_start_method('spawn', force=True)609    model_list = [m.strip() for m in args.models.split(',') if m.strip()]610    gpu_count = torch.cuda.device_count() or 1611    612    procs = []613    for i, model in enumerate(model_list):614        p = mp.Process(target=worker_process, args=(model, i % gpu_count, args))615        p.start()616        procs.append(p)617        time.sleep(30)618 619    for p in procs:620        p.join()621 622if __name__ == "__main__":623    main()